- compose: ENABLE_UPDATE_CHECK=0 (image intentionally pinned to DMS v15.1.0). - postscreen: permit Google SMTP outbound ranges (Gmail retries from rotating IPs, '450 PASS NEW' never completes) — keeps LinkedIn/Gmail mail out of the tarpit. - README: DNS/ops notes; user-patches: whitelist copy path.
10 KiB
mail-server
Docker Compose mail stack for mail.produktor.io on arc-01, based on
docker-mailserver (DMS).
| Service | Container | Ports |
|---|---|---|
| Mail server (DMS) | mailserver |
25 (SMTP), 465 (SMTPS), 587 (Submission STARTTLS), 143 (IMAP STARTTLS), 993 (IMAPS) |
| Webmail (Roundcube) | webmail |
127.0.0.1:19944 / 172.17.0.1:19944 (HTTP, behind NPM) |
| Account admin | — (removed) | — |
Accounts
Source of truth is file-based: config/postfix-accounts.cf (SHA512-CRYPT
hashes). The file is gitignored (secrets) — it lives on the host only. Current
mailboxes:
info@produktor.io— human reader (Roundcube login for the whole list)andriy.oblivantsev@produktor.ioano@produktor.iopostmaster@produktor.iopostman@produktor.ioandriy.oblivantsev@wheregroup.com— incubator mailbox of the гдеgroup period (issue #252): the owner's historical address, not an abstract source box. Nobody logs in; legacy.emlcorpus is imported via doveadm by the ETL connector (bin/mail/incubator.go, 2dph), routed toSent/INBOX/INBOX/Unmatchedby the recipient headers.— deleted: superseded A1 pilot box of the old abstract-"source" model (issue #251); its 1000 messages were migrated towheregroup@produktor.ioandriy.oblivantsev@wheregroup.comand the account was removed (issue #252).
Passwords live in .env (INFO_PASSWORD, ANDRIY_PASSWORD; ano@ uses
GATOR_MAIL_PASS in the gator repo .env; andriy.oblivantsev@wheregroup.com
uses ANDRIY_WG_PASSWORD, random — no interactive login). Do not commit
.env.
Web UI (Roundcube)
Webmail runs as the webmail service (official roundcube/roundcubemail
image) and is reachable at https://mail.produktor.io (alias
https://webmail.produktor.io) via Nginx Proxy Manager (proxy host 66 →
172.17.0.1:19944, Let's Encrypt).
Login: any mailbox address from the table above + its real password. The UI
shows one mailbox per login; the account list is the postfix-accounts.cf
file (see Accounts).
Since the shared-mailbox setup (below) info@ additionally sees every other
mailbox under Shared/ — one login covers the whole account list. Rights
differ per owner class: read-only for production mailboxes, read + delete for
incubator mailboxes (see Shared mailboxes).
Connection details used by the webmail (IMAP/SMTP):
- IMAP:
mail.produktor.io:143STARTTLS (or:993SSL) - SMTP submission:
mail.produktor.io:587STARTTLS, AUTH required
Host note: the webmail must connect to the DMS container via the FQDN
mail.produktor.io (Docker embedded DNS resolves it to the mailserver
container inside the compose network). Connecting to the bare container alias
mailserver fails TLS peer-name verification, because the DMS certificate is
issued for mail.produktor.io.
Manage
docker compose up -d # start mailserver + webmail
docker compose logs -f webmail # webmail logs
docker exec webmail sh # shell into webmail
The webmail stores its sqlite database (addressbook, settings) in
data/roundcube/db/. ROUNDCUBEMAIL_DES_KEY (session encryption) must be set
in .env — compose fails without it.
Manage: adding a mailbox without recreating the container
A new account is applied live without docker compose up -d — DMS's
changedetector (check-for-changes.sh, polls every 2 s) picks up the edited
config/postfix-accounts.cf and regenerates /etc/postfix/vmailbox,
/etc/dovecot/userdb and /etc/postfix/vhost, then reloads Postfix and
Dovecot. No mail is lost, no container recreation.
# 1. random password for the new historical-address mailbox (stored in .env only)
PW=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32)
printf 'ANDRIY_WG_PASSWORD=%s\n' "$PW" >> .env # never commit .env
# 2. add the account — password is read from stdin, never from argv/ps.
# DMS accepts any virtual user: the domain need not be serviced by
# mail.produktor.io (verified live with wheregroup.com, issue #252) — the
# account only serves IMAP/doveadm, no inbound delivery.
printf '%s\n%s\n' "$PW" "$PW" |
docker exec -i mailserver setup email add andriy.oblivantsev@wheregroup.com
# 3. changedetector applies within ~5 s; verify
docker exec mailserver doveadm user andriy.oblivantsev@wheregroup.com
# 4. give the fresh mailbox an INBOX (a brand-new owner has none — without it
# `doveadm mailbox list -u <owner>` is empty and user-patches.sh cannot
# record any share), then apply the shared/ACL policy
docker exec mailserver doveadm mailbox create -u andriy.oblivantsev@wheregroup.com INBOX
docker exec mailserver /bin/bash /tmp/docker-mailserver/user-patches.sh
On a fresh deployment (empty postfix-accounts.cf), add the account line
before the first docker compose up -d: user-patches.sh runs on the
container's first start and would otherwise skip owners that do not exist yet
(it logs skip <owner> and continues).
Shared mailboxes (единый вход info@)
info@produktor.io sees every other mailbox under Shared/ — one login in
Roundcube covers the whole account list. Delivery is unchanged (no aliases, no
redirects); other accounts keep their own passwords. Two owner classes, two
right sets for info@:
- production owners (
ano@,andriy.oblivantsev@,postmaster@): read-only —lookup read(issue #79); - incubator owners (one account per historical address of the owner:
andriy.oblivantsev@wheregroup.comfor the гдеgroup period; latereslider@gmail.com,...@viscreation.de, ...): read and delete —lookup read delete expunge write-deleted(issue #251). The owner never logs in; mail arrives viadoveadm import. Deleting a message in Roundcube = filter/exclusion from the corpus (auto-sync, epic B), so the delete button must work inShared/. Verified on live:write-deletedis sufficient for the\Deletedflag Roundcube sets (no extrawriteright needed),expungeis also what Dovecot MOVE needs on the source side when Roundcube moves a deleted message to the reader's Trash.
How it works (Dovecot 2.3 ACL + shared namespace):
config/dovecot.cf(→/etc/dovecot/local.conf) enables theaclplugin, adds a shared namespaceshared/%%u/(list=children, read index per reader viaINDEXPVT), and pointsacl_shared_dictto/var/lib/dovecot/db/shared-mailboxes.db(persistent viamail-state).config/user-patches.shre-applies the ACLs from each shared owner's mailboxes touser=info@produktor.ioviadoveadm acl set— the only way Dovecot records the share in the shared dictionary — and pre-subscribes the shared folders forinfo@. DMS runs it on the first start of each container instance (plaindocker compose restartskips the setup step by design); ACLs, the shared dict and subscriptions persist inmail-state/maildirs, so nothing is lost on restarts. Idempotent — safe to run manually:docker exec mailserver /bin/bash /tmp/docker-mailserver/user-patches.sh. The script skips owners that do not exist yet and creates a missing owner INBOX itself (the share maps to the owner's INBOX and is only recorded if the INBOX exists).
Upgrade behavior (image :latest): the config survives container recreation
because both files live in the mounted config/. On image upgrade the
entrypoint re-applies dovecot.cf and runs user-patches.sh again on the new
container's first start, so ACLs and subscriptions are recreated. The only
state kept outside the repo is shared-mailboxes.db (inside
data/mail-state/); if it is lost, the next (re)creation rebuilds it via
doveadm acl set.
Limitation (Dovecot semantics): new mailboxes created by an owner after the
last start do not inherit the share (no ACL inheritance); they appear for
info@ after the next container start.
Reverse proxy (NPM)
mail.produktor.io is a proxy host in Nginx Proxy Manager (provider container,
see the gitea repo): forward http://172.17.0.1:19944, Let's Encrypt cert
(SAN: mail.produktor.io, webmail.produktor.io), SSL forced, HTTP/2.
TLS
DMS uses a Let's Encrypt certificate for mail.produktor.io mounted from
tls/letsencrypt/mail.produktor.io/ (SSL_TYPE=letsencrypt).
DNS (live zone, arc-01)
Outbound IP is dynamic (Orange residential). SPF follows the Dynu hostname
instead of a fixed ip4: — ddclient on arc-01 keeps
produktor.mywire.org pointed at the current address
(produktor/duckdns/dyndns/config/ddclient.conf).
produktor.io — Dynadot
| Type | Host | Value |
|---|---|---|
| CNAME | mail |
produktor.mywire.org |
| MX | @ |
10 mail.produktor.io |
| TXT | @ |
v=spf1 a:produktor.mywire.org ~all |
| TXT | _dmarc |
v=DMARC1; p=quarantine; adkim=r; aspf=r; pct=100 |
| TXT | mail._domainkey |
v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8P5kdq57uAD9r9XSxvDViVbvOaQfVEIHwS99G5PYFHcoLdhm6sAHaE94pw27BBVweed+TjevhoEaD77RV+uwsE9E+zHepnoLYcCql7vLtRy7QLrSKzNJonCin6g+kzw/2swZ+022w1W27kZgLc3LwUFaTerRI8xDOtbEUmcWGsMPW52JaKVmU3UhFMDVLpH/t1OrbZeCEReM8iK5Cc1jPno9nf3F7ang9x9o0Gyw1CP6takDQiS4X6UK23vjymaauO9PrQQpkAydhkHODq3Sxm3rgSnYjWgPl7BrVr9ujN+K12OObzquj0/Zol1Da1d0IPdzEOAa4SkpLt5FUOgQ7wIDAQAB |
DKIM private key: config/opendkim/keys/produktor.io/mail.private (gitignored
on live host). Re-publish the TXT from mail.txt after key rotation:
docker exec mailserver cat /etc/opendkim/keys/produktor.io/mail.txt.
ACME DNS-01 for mail.produktor.io uses scripts/dynadot-dns.sh (Dynadot API).
produktor.mywire.org — Dynu (dynamic A)
| Type | Host | Value |
|---|---|---|
| A | @ |
current WAN IP (ddclient → Dynu API, ~5 min) |
As of last check: 90.169.228.16.
Verify
dig @1.1.1.1 +short A mail.produktor.io
dig @1.1.1.1 +short MX produktor.io
dig @1.1.1.1 +short TXT produktor.io
dig @1.1.1.1 +short TXT _dmarc.produktor.io
dig @1.1.1.1 +short TXT mail._domainkey.produktor.io
dig @1.1.1.1 +short A produktor.mywire.org
External deliverability smoke test: scripts/mail-outlook-test.sh.
PTR is not under our control (Orange pool) — expected mismatch; see
~/.config/opencode/skill/mails/SKILL.md.