Skip to content

Commit 6b7bf01

Browse files
ralyodioclaude
andcommitted
feat(mail): plaintext loopback IMAP so the gateway bypasses Mailu's front
Mailu's front (nginx mail proxy) pre-authenticates against Mailu's user DB before proxying to Dovecot, which rejects the Dovecot master-user login <addr>*gateway. The gateway must reach Dovecot directly. The imap container has no TLS cert (only the front does), so the bypass is plaintext over loopback — the master password never leaves the host. - mailbox: IMAPConfig.Plaintext dials with DialInsecure (loopback only). - main.go: mailClientFor sets Plaintext from AGENTBBS_MAIL_IMAP_PLAINTEXT. - override.example: add the unbound resolver (admin needs DNSSEC), webmail image fix (2024.06 uses mailu/webmail), and publish Dovecot 143 on 127.0.0.1:14143. - docs/mail.md: document the front-bypass, the dovecot.conf master passdb (Mailu includes that exact filename), and the 644 master-users perms (640 = temp_fail). Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent ed0ccba commit 6b7bf01

4 files changed

Lines changed: 73 additions & 9 deletions

File tree

cmd/agentbbs/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1355,6 +1355,11 @@ func (a *app) mailClientFor(su store.User) (*mailbox.Client, error) {
13551355
SMTPAddr: env("AGENTBBS_MAIL_SMTP_ADDR", "127.0.0.1:25"),
13561356
Username: login,
13571357
Password: os.Getenv("AGENTBBS_MAIL_MASTER_PASS"),
1358+
// Mailu's front nginx pre-authenticates against its user DB before
1359+
// proxying, which rejects the "<addr>*master" master login. The gateway
1360+
// therefore talks to Dovecot directly over loopback (plaintext, on-host)
1361+
// when AGENTBBS_MAIL_IMAP_PLAINTEXT=1. See docs/mail.md.
1362+
Plaintext: os.Getenv("AGENTBBS_MAIL_IMAP_PLAINTEXT") == "1",
13581363
// SMTPUser/SMTPPass left empty: submit via the trusted local relay.
13591364
}
13601365
tr, err := mailbox.NewIMAPTransport(cfg)
Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,36 @@
11
# docker-compose.override.yml — copy to docker-compose.override.yml (gitignored).
2-
#
3-
# The base docker-compose.yml does not declare a network, so Docker would assign
4-
# the project's default bridge an arbitrary subnet. Mailu trusts SUBNET (from
5-
# mailu.env) as its internal network for service-to-service auth and relaying, so
6-
# the real network subnet MUST equal SUBNET or internal auth/relay breaks. This
7-
# override pins the default network to the same subnet you set as SUBNET in
8-
# mailu.env (default 192.168.203.0/24). Compose loads this file automatically.
2+
# Compose loads this file automatically. It carries three fixes the trimmed base
3+
# compose needs; see docs/mail.md for the full rationale.
94
networks:
105
default:
116
driver: bridge
127
ipam:
138
config:
9+
# Mailu trusts SUBNET (mailu.env) as its internal network for
10+
# service-to-service auth/relay; the real network MUST match it.
1411
- subnet: 192.168.203.0/24
12+
services:
13+
# Mailu requires a DNSSEC-validating resolver or admin won't start.
14+
resolver:
15+
image: ghcr.io/mailu/unbound:2024.06
16+
env_file: mailu.env
17+
restart: always
18+
networks:
19+
default:
20+
ipv4_address: 192.168.203.254
21+
front: { dns: [192.168.203.254], depends_on: [resolver] }
22+
admin: { dns: [192.168.203.254], depends_on: [resolver] }
23+
imap:
24+
dns: [192.168.203.254]
25+
depends_on: [resolver]
26+
# Publish Dovecot directly on loopback so the agentbbs gateway can use the
27+
# master-user login (the front's nginx auth proxy rejects "<addr>*master").
28+
# Plaintext is fine: the connection never leaves the host.
29+
ports: ["127.0.0.1:14143:143"]
30+
smtp: { dns: [192.168.203.254], depends_on: [resolver] }
31+
antispam: { dns: [192.168.203.254], depends_on: [resolver] }
32+
# In Mailu 2024.06 the webmail image is "webmail" (not "roundcube:2024.06").
33+
webmail:
34+
image: ghcr.io/mailu/webmail:2024.06
35+
dns: [192.168.203.254]
36+
depends_on: [resolver]

docs/mail.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ Set these on the agentbbs service (setup.sh §9e upserts the non-secret ones):
9090
|---|---|
9191
| `AGENTBBS_MAIL_ADDR_DOMAIN` | `bbs.profullstack.com` |
9292
| `AGENTBBS_MAIL_DOMAIN` | `mail.profullstack.com` |
93-
| `AGENTBBS_MAIL_IMAP_ADDR` | `mail.profullstack.com:993` |
93+
| `AGENTBBS_MAIL_IMAP_ADDR` | `127.0.0.1:14143` (Dovecot direct, loopback) |
94+
| `AGENTBBS_MAIL_IMAP_PLAINTEXT` | `1` (the loopback path is plaintext) |
9495
| `AGENTBBS_MAIL_SMTP_ADDR` | `127.0.0.1:25` |
9596
| `AGENTBBS_MAIL_ADMIN_URL` | `http://127.0.0.1:8080` |
9697
| `AGENTBBS_MAIL_API_TOKEN` | the Mailu `API_TOKEN` (secret) |
@@ -102,6 +103,33 @@ Without `AGENTBBS_MAIL_API_TOKEN` auto-provisioning is skipped (the address is
102103
still shown); without `AGENTBBS_MAIL_MASTER_PASS` the gateway can't open
103104
mailboxes.
104105

106+
### Why the gateway talks to Dovecot directly (plaintext loopback)
107+
108+
Mailu's **front** (nginx mail proxy) pre-authenticates every IMAP/SMTP login
109+
against Mailu's user DB before proxying to Dovecot — and it rejects the Dovecot
110+
master-user login form `<addr>*gateway`. So the gateway must reach **Dovecot
111+
directly**, bypassing the front. The `imap` container has no TLS cert (only the
112+
front does), so the bypass is plaintext over loopback — safe because the
113+
connection (and the master password) never leave the host. Wiring:
114+
115+
- Publish Dovecot's IMAP on loopback (docker-compose.override.yml):
116+
`imap.ports: ["127.0.0.1:14143:143"]`.
117+
- The Dovecot master user is defined in `data/overrides/dovecot/dovecot.conf`
118+
(Mailu includes exactly that filename — *not* `*.conf`):
119+
120+
```
121+
auth_master_user_separator = *
122+
passdb { driver = passwd-file; master = yes; args = /overrides/master-users }
123+
```
124+
125+
with `data/overrides/dovecot/master-users` holding `gateway:{SHA512-CRYPT}$6$…`
126+
(the hash of `AGENTBBS_MAIL_MASTER_PASS`). The file must be **world-readable
127+
(644)** — Dovecot reads it as a non-root user, and 640 root:root yields a
128+
`temp_fail`. Do **not** add `result_success = continue` (that would also
129+
require the target user's own password); the target mailbox comes from userdb.
130+
- Point the gateway at it: `AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143` +
131+
`AGENTBBS_MAIL_IMAP_PLAINTEXT=1`.
132+
105133
## Sending mail from the BBS (verify codes + notifications)
106134

107135
The join@ verification code and signup notifications use `internal/mail` (the

internal/mailbox/imap.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ type IMAPConfig struct {
2424
// SMTPUser/SMTPPass default to Username/Password when empty.
2525
SMTPUser string
2626
SMTPPass string
27+
// Plaintext dials IMAP without TLS. Used only for a co-located backend over
28+
// loopback (the Mailu gateway hitting Dovecot directly on 127.0.0.1, bypassing
29+
// the front's auth proxy so master-user login works) — the password never
30+
// leaves the host. Never enable it for a remote server.
31+
Plaintext bool
2732
}
2833

2934
// imapTransport is a Transport backed by a single authenticated IMAP connection
@@ -38,7 +43,11 @@ type imapTransport struct {
3843

3944
// NewIMAPTransport dials the IMAP server, logs in, and returns a Transport.
4045
func NewIMAPTransport(cfg IMAPConfig) (Transport, error) {
41-
c, err := imapclient.DialTLS(cfg.IMAPAddr, nil)
46+
dial := imapclient.DialTLS
47+
if cfg.Plaintext {
48+
dial = imapclient.DialInsecure
49+
}
50+
c, err := dial(cfg.IMAPAddr, nil)
4251
if err != nil {
4352
return nil, fmt.Errorf("imap dial %s: %w", cfg.IMAPAddr, err)
4453
}

0 commit comments

Comments
 (0)