From a4ab96d0253788ac8c9fa78e61cff9d41aac7289 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 2 Sep 2026 12:10:51 +0100 Subject: [PATCH] feat(dovecot): incubator owner wheregroup@ + info@ delete access (#251) --- README.md | 74 +++++++++++++++++++++++++++++++++++------- config/user-patches.sh | 48 +++++++++++++++++++++++---- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index cfc9e01..0af97e1 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,20 @@ Docker Compose mail stack for `mail.produktor.io` on arc-01, based on ## Accounts Source of truth is file-based: `config/postfix-accounts.cf` (SHA512-CRYPT -hashes). Current mailboxes: +hashes). The file is gitignored (secrets) — it lives on the host only. Current +mailboxes: -- `info@produktor.io` +- `info@produktor.io` — human reader (Roundcube login for the whole list) - `andriy.oblivantsev@produktor.io` - `ano@produktor.io` - `postmaster@produktor.io` - `postman@produktor.io` +- `wheregroup@produktor.io` — incubator mailbox (issue #251): nobody logs in; + legacy `.eml` corpus is imported via `doveadm import` by the ETL connector. Passwords live in `.env` (`INFO_PASSWORD`, `ANDRIY_PASSWORD`; `ano@` uses -`GATOR_MAIL_PASS` in the gator repo `.env`). Do not commit `.env`. +`GATOR_MAIL_PASS` in the gator repo `.env`; `wheregroup@` uses +`WHEGROUP_PASSWORD`, random — no interactive login). Do not commit `.env`. ## Web UI (Roundcube) @@ -35,8 +39,9 @@ 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/` and can read them — one login covers the whole -account list. +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): @@ -61,12 +66,56 @@ 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. + +```bash +# 1. random password for the new source mailbox (stored in .env only) +PW=$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 32) +printf 'WHEGROUP_PASSWORD=%s\n' "$PW" >> .env # never commit .env + +# 2. add the account — password is read from stdin, never from argv/ps +printf '%s\n%s\n' "$PW" "$PW" | + docker exec -i mailserver setup email add wheregroup@produktor.io + +# 3. changedetector applies within ~5 s; verify +docker exec mailserver doveadm user wheregroup@produktor.io + +# 4. give the fresh mailbox an INBOX (a brand-new owner has none — without it +# `doveadm mailbox list -u ` is empty and user-patches.sh cannot +# record any share), then apply the shared/ACL policy +docker exec mailserver doveadm mailbox create -u wheregroup@produktor.io 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 ` and continues). + ## Shared mailboxes (единый вход info@) -`info@produktor.io` can read all mailboxes (`ano@`, `andriy.oblivantsev@`, -`postmaster@`) as read-only shared folders — one login in Roundcube covers the -whole account list. Delivery is unchanged (no aliases, no redirects); other -accounts keep their own passwords and full rights. +`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** (`wheregroup@produktor.io`, later `gmail_lenovo@`, + `tb-*`/`pst-*`): 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): @@ -74,14 +123,17 @@ How it works (Dovecot 2.3 ACL + shared namespace): 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 read-only (`lr`) ACLs from each shared - owner's mailboxes to `user=info@produktor.io` via `doveadm acl set` — the +- `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 diff --git a/config/user-patches.sh b/config/user-patches.sh index 6203619..ea32184 100755 --- a/config/user-patches.sh +++ b/config/user-patches.sh @@ -1,6 +1,13 @@ #!/bin/bash -# Dovecot shared mailboxes (issue #79): info@produktor.io gets read-only (lr) -# access to the mailboxes of ano@, andriy.oblivantsev@, postmaster@produktor.io. +# Dovecot shared mailboxes. info@produktor.io gets access to the mailboxes of +# two owner classes (issue #79, issue #251 / epic #250): +# - production owners (ano@, andriy.oblivantsev@, postmaster@): read-only +# (`lookup read`) — one login in Roundcube covers the whole account list; +# - incubator owners (wheregroup@produktor.io; future sources like +# gmail_lenovo@, tb-*/pst-*): read + delete (`lookup read delete expunge +# write-deleted`) — the mailbox owner never logs in, mail is imported via +# doveadm; deleting a message in Roundcube = filter/exclusion from the +# corpus (autosync, epic B). # DMS runs this only on the FIRST start of each container instance (plain # `docker compose restart` skips the setup step by design — /CONTAINER_START # marker), so it must stay idempotent. ACLs, the shared dict and subscriptions @@ -13,21 +20,50 @@ mkdir -p "${SHARED_DB_DIR}" chown docker:docker "${SHARED_DB_DIR}" chmod 0770 "${SHARED_DB_DIR}" -# 2. Grant info@ read-only rights on every current mailbox of the shared owners. +# 2. Grant info@ rights on every current mailbox of the shared owners. # doveadm acl set is the only way Dovecot records the share in acl_shared_dict # (manual dovecot-acl files do NOT populate the dictionary — Dovecot docs). # NOTE: this Dovecot build accepts full right NAMES ("lookup read"), single -# letters ("lr") are rejected with "Invalid right". +# letters ("lr") are rejected with "Invalid right". The incubator set below +# was verified on live (issue #251): `write-deleted` is enough for the +# \Deleted flag that Roundcube sets on Delete — the extra `write` right is +# NOT required; `expunge` is also what Dovecot MOVE needs on the source side +# when Roundcube moves a deleted message to Trash. READER='info@produktor.io' -for owner in ano@produktor.io andriy.oblivantsev@produktor.io postmaster@produktor.io; do +PRODUCTION_OWNERS='ano@produktor.io andriy.oblivantsev@produktor.io postmaster@produktor.io' +INCUBATOR_OWNERS='wheregroup@produktor.io' + +grant_share() { # $1=owner, remaining=right names + local owner=$1 + shift + # Skip owners not (yet) in postfix-accounts.cf — e.g. right after a fresh + # clone, before `setup email add` was run for the incubator source. + if ! doveadm mailbox list -u "${owner}" >/dev/null 2>&1; then + echo "user-patches: skip ${owner}: account does not exist yet (run 'setup email add ${owner}')" + return 0 + fi # The shared mailbox "shared/" maps to the owner's INBOX (Dovecot # shared-storage semantics) — subscribe it explicitly so Roundcube's # subscribed folder list shows it. doveadm mailbox subscribe -u "${READER}" "shared/${owner}" + # A brand-new mailbox owner has no INBOX yet and `doveadm mailbox list` + # above would be empty, so no share would be recorded. Ensure INBOX exists + # first (issue #251); "Mailbox already exists" is fine. + doveadm mailbox create -u "${owner}" INBOX >/dev/null 2>&1 || true for mb in $(doveadm mailbox list -u "${owner}"); do - doveadm acl set -u "${owner}" "${mb}" "user=${READER}" lookup read + doveadm acl set -u "${owner}" "${mb}" "user=${READER}" "$@" if [ "${mb}" != "INBOX" ]; then doveadm mailbox subscribe -u "${READER}" "shared/${owner}/${mb}" fi done +} + +# Production owners stay read-only for info@ (regression guard for #79). +for owner in ${PRODUCTION_OWNERS}; do + grant_share "${owner}" lookup read +done + +# Incubator owners: info@ can read AND delete (filter semantics, epic #250). +for owner in ${INCUBATOR_OWNERS}; do + grant_share "${owner}" lookup read delete expunge write-deleted done