eSlider e1208be24e chore(mail): pinned-version update check off, Gmail postscreen whitelist, docs
- 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.
2026-09-16 14:59:45 +01:00

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.io
  • ano@produktor.io
  • postmaster@produktor.io
  • postman@produktor.io
  • andriy.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 .eml corpus is imported via doveadm by the ETL connector (bin/mail/incubator.go, 2dph), routed to Sent/INBOX/INBOX/Unmatched by the recipient headers.
  • wheregroup@produktor.io — deleted: superseded A1 pilot box of the old abstract-"source" model (issue #251); its 1000 messages were migrated to andriy.oblivantsev@wheregroup.com and 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:143 STARTTLS (or :993 SSL)
  • SMTP submission: mail.produktor.io:587 STARTTLS, 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.com for the гдеgroup period; later eslider@gmail.com, ...@viscreation.de, ...): read and delete — lookup read delete expunge write-deleted (issue #251). The owner never logs in; mail arrives via doveadm import. Deleting a message in Roundcube = filter/exclusion from the corpus (auto-sync, epic B), so the delete button must work in Shared/. Verified on live: write-deleted is sufficient for the \Deleted flag Roundcube sets (no extra write right needed), expunge is 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 the acl plugin, adds a shared namespace shared/%%u/ (list=children, read index per reader via INDEXPVT), and points acl_shared_dict to /var/lib/dovecot/db/shared-mailboxes.db (persistent via mail-state).
  • config/user-patches.sh re-applies the ACLs from each shared owner's mailboxes to user=info@produktor.io via doveadm acl set — the only way Dovecot records the share in the shared dictionary — and pre-subscribes the shared folders for info@. DMS runs it on the first start of each container instance (plain docker compose restart skips the setup step by design); ACLs, the shared dict and subscriptions persist in mail-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.

S
Description
docker-mailserver stack for produktor.io (compose + config + scripts)
Readme
130 KiB
Languages
Shell 100%