Target: Babar — Synology DS224+, DSM 7.3.2-86009 Update 3, Celeron J4125, 18GB RAM, 192.168.50.230 — via Container Manager. Goal: Mirror Gmail + Hotmail to a local Maildir, served over IMAPS by a local Dovecot 2.4 instance (with flatcurve FTS active by default in the image), with a decoupled prune step that deletes old mail from the cloud only after it’s safely archived and backed up. Status: fully built and operational — Gmail pruned to a 1-year cloud window (2026-07-29).

Environment note: Real values for this machine are filled in below (from ~/dev/notebook/knowledge/home-network.md, synology.md, projects/babar/README.md, profile.md). Still verify drift at build time for the two things that actually change over months: Azure app-registration screens and Docker image tags.

Deployment status (2026-07-29):

  • mail share created (Btrfs checksum + compression on); dockergroup ACL granted; tree owned 1029:65536 (section 1.5)
  • mail-compose project created in Container Manager; mailtools running as 1029:65536
  • ✅ Gmail app-password loaded; auth verified; initial All Mail pull complete — ~15,884 messages / ~1.4 GB (/config/state/gmail-pull.log)
  • ✅ Restructured to three tiers: secrets/config moved to docker/mail (/config), share holds Maildirs only
  • Phase C done: Hotmail via Azure device-code OAuth2 (monocularjack@hotmail.com); cyrus-sasl-xoauth2 compiled into the mailtools image; initial pull complete — 2,561 messages across 22 folders. Token auto-refreshes; nightly now runs -a (both accounts).
  • Phase D done: Dovecot 2.4.4 serving both archives over IMAPS on port 10993 via two namespaces (Gmail = inbox, Hotmail = Hotmail/ prefix); Thunderbird authenticates over TLS and browses both. Runs as the image-default vmail (UID 1000) + group_add: 65536, passwd-file auth (user shawn), drop-in config. flatcurve FTS is active — the official image enables fts_flatcurve by default (index dirs appear inside each mailbox); server-side search works, no extra config needed.
  • Phase E done: DSM Task Scheduler nightly -a pull (03:00) + Hyper Backup (04:40) + backup gate (05:30), staggered so each feeds the next.
  • Phase F done: off-site Backblaze B2 Hyper Backup (S3 connector, client-side encrypted, .pem key in 1Password) of the mail share; verified complete. backup_ok.sh gate parses DSM’s backup log and writes the marker prune reads (section 9).
  • Phase G done (2026-07-29): batched prune.py moved 12,959 Gmail messages older than 1 year to Trash (5 headerless messages safely skipped); local archive (16,005 msgs) untouched. Gmail now holds ~1 year in the cloud; the rest is LAN-only in the archive.

0. Architecture recap

Three genuinely decoupled layers, three separate scheduled jobs:

  1. Pullmbsync, pull-only and non-destructive to the archive: Sync PullNew PullFlags (new mail + flag changes only — never propagates a remote deletion onto the local copy) with Expunge None. Cloud is never modified by this step.
  2. Backup — Hyper Backup of the mail volume to an off-box target. Writes a success marker that gates step 3.
  3. Prune — a separate script that only ever touches messages already verified present locally and covered by a fresh backup. Moves them to Gmail Trash (30-day grace), never hard-expunges.

Search is layered on top via Dovecot, so the whole archive is just an IMAP mailbox you point Thunderbird at.

Why the pull is PullNew PullFlags, not Pull: in isync, Sync Pull expands to New + ReNew + Delete + Flags. That Delete means once the prune removes a message from Gmail, the next sync would flag the local archived copy \Deleted — silently defeating the archive. Restricting to PullNew PullFlags is what actually decouples the three layers. (See the isync manual.)


1. NAS prep

  1. SSH — already enabled on Babar (port 22, ssh babar). Nothing to do.
  2. Shared folder — Control Panel → Shared Folder → Create mail. Enable data checksumming (Btrfs) — catches silent corruption over years of storage, and makes the folder a clean Hyper Backup unit.
  3. Container Manager — already installed (Babar runs Gitea, the *arr stack, nostr-relay, etc. under it).
  4. Storage — three tiers, matching Babar’s convention (compose-as-code in projects/<svc>-compose/, app config/state in docker/<svc>/, bulk data in a share — exactly like the *arr stack: arrs-compose + docker/sonarr + /volume1/data):
  /volume1/mail/                        # BULK DATA — Btrfs checksum on; the Hyper Backup unit; MAIL ONLY
├── gmail/Maildir/
└── hotmail/Maildir/

/volume1/docker/mail/                 # app CONFIG + SECRETS — NOT in the mail backup; mounted as /config
├── mbsync/mbsyncrc
├── dovecot/{99-archive.conf, users, certs/}   # drop-in conf + passwd-file + self-signed cert (see section 6)
├── scripts/{prune.py, backup_ok.sh, mutt_oauth2.py}
└── state/                            # gmail_app_pw, hotmail_oauth2, last_backup_ok, *.log (chmod 700)

/volume1/docker/projects/mail-compose/   # COMPOSE-as-code only
├── docker-compose.yml
└── mailtools/Dockerfile
  

/volume1/mail mounts into the containers as /mail, /volume1/docker/mail as /config (see section 4). Keep secrets and regenerables (credentials, OAuth token, FTS index, logs) in /volume1/docker/mail — never in the /volume1/mail share — so the off-box backup (section 9) carries mail only. mbsync’s SyncState * lives inside each Maildir (in the share), so it’s backed up with the mail it describes, which is correct.

  1. Container access to the share — the Synology ACL gotcha. Synology shares are root:root, mode 0000, governed by a Synology ACL, not POSIX. A container running as an arbitrary UID (e.g. Dovecot’s default 1000) is not in that ACL and gets permission-denied even on a 0777-looking folder. The working pattern on Babar (the same one the media stack uses) is to run mail containers as the dedicated dockerlimited identity 1029:65536 (dockergroup) and grant that group on the share:

    • Grant dockergroup on the mail share (UI, keeps DSM’s permission DB in sync): Control Panel → Shared Folder → mail → Edit → Permissions → Local groupsdockergroupRead/Write. This mirrors the data share’s ACL entry group:dockergroup:allow:rwxpdDaARWc--:fd--.
    • Own the archive tree as 1029:65536: sudo chown -R 1029:65536 /volume1/mail/{gmail,hotmail}. (The docker/mail config tier is a plain folder, not an ACL-governed share, so a normal sudo chown -R 1029:65536 /volume1/docker/mail && sudo chmod 700 /volume1/docker/mail/state is all it needs — no dockergroup grant required there.)
    • Verify from inside the container: docker exec mailtools sh -c 'id; touch /mail/gmail/.w && echo OK && rm /mail/gmail/.w' → expect uid=1029 gid=65536 and OK.

    Both mail containers therefore run as user: "1029:65536" (section 4). Credentials created from inside the running container land owned 1029:65536 automatically — no host-side chown needed per file. Note the ACL gotcha applies only to the mail share; the docker/mail config tier is a plain folder with ordinary POSIX ownership.


2. Gmail credential

  1. Enable 2-Step Verification on shawn.oster@gmail.com if not already on.
  2. Generate an app password (not your account password — Google blocks that over IMAP): myaccount.google.com/apppasswords → name it “NAS mbsync.” Google shows 16 chars in 4 groups.
  3. Load it from inside the running container so it lands owned 1029:65536 at 0600 without a host chown, and never touches shell history:
      docker exec -it mailtools sh -c 'umask 077; cat > /config/state/gmail_app_pw'
      
    Type the 16 chars with spaces removed → Ctrl-D. (mbsync strips the trailing newline.)

3. Hotmail credential (OAuth2 device-code — as built)

Basic auth / app passwords are dead for Outlook.com IMAP — it must be OAuth2/XOAUTH2. Register a free Azure/Entra app and let mutt_oauth2.py handle the token dance and auto-refresh. We use the device-code flow with a public client (no client secret) — it’s the only flow that works cleanly headless, because the container and your browser are on different machines (the localhostauthcode listener flow can’t work across that gap).

Azure/Entra registration (portal.azure.com → App registrationsNew registration):

  • Name: NAS mbsync
  • Supported account types: Accounts in any organizational directory (multitenant) and personal Microsoft accounts — required for a @hotmail.com account.
  • Redirect URI: leave blank (device-code doesn’t use one).
  • After registering, copy the Application (client) ID from Overview.
  • Authentication → Advanced settings → Allow public client flows → Yes → Save. ← this is what enables device-code. Without it, the token request fails with AADSTS7000218 (“request body must contain client_assertion or client_secret”).
  • API permissions: ideally add delegated IMAP.AccessAsUser.All under APIs my organization uses → Office 365 Exchange Online. But on a brand-new personal-account tenant that API often isn’t provisioned and won’t appear — that’s fine: skip it. The v2.0 common endpoint does dynamic consent, so the IMAP scope in the token request is approved on the device-login consent screen anyway. No client secret, no admin consent.

Azure’s portal screens shift periodically — verify against current Microsoft docs at build time rather than trusting these labels verbatim. Registering an app requires a phone/address/payment on the MS account (an Xbox account like monocularjack@hotmail.com usually already has this).

Stage the token helper (pure-stdlib, runs on the container’s python3):

  sudo mkdir -p /volume1/docker/mail/scripts
sudo curl -fsSL -o /volume1/docker/mail/scripts/mutt_oauth2.py \
  https://raw.githubusercontent.com/neomutt/neomutt/main/contrib/oauth2/mutt_oauth2.py
sudo chown 1029:65536 /volume1/docker/mail/scripts/mutt_oauth2.py
sudo chmod 755 /volume1/docker/mail/scripts/mutt_oauth2.py
  

Authorize once (device-code). Run it inside the container so the token lands owned 1029:65536. The token is stored plaintext via cat passthrough pipes — no GPG in the container, same posture as the plaintext Gmail app-password (/config/state is chmod 700):

  sudo docker exec -it mailtools python3 /config/scripts/mutt_oauth2.py \
  /config/state/hotmail_oauth2 \
  --authorize --provider microsoft --authflow devicecode \
  --client-id <APP_ID> \
  --email monocularjack@hotmail.com \
  --decryption-pipe cat --encryption-pipe cat --verbose
  

It prompts for a client secret — press Enter to leave it blank (public client). Then it prints a microsoft.com/devicelogin URL + a code: open it on any device, sign in as monocularjack@hotmail.com, enter the code, approve the IMAP + offline-access consent. On success it writes /config/state/hotmail_oauth2 (contains the refresh token) and prints an access token. Then sudo chmod 600 that file. mbsync consumes it via PassCmd (section 5); the script auto-refreshes and writes the rotated refresh token back on every run.


4. Compose project: mail-compose

One compose project defines both containers — an idle mailtools (invoked on a schedule via docker exec) and a long-running dovecot. This matches how every other Babar service is defined (/volume1/docker/projects/<svc>-compose/docker-compose.yml).

mailtools/Dockerfile — holds mbsync + Python + the oauth2 helper, plus the cyrus-sasl-xoauth2 SASL plugin compiled from source:

  FROM debian:trixie-slim

# isync + python for mbsync/oauth. Debian has no cyrus-sasl-xoauth2 package, so
# compile the plugin from source — without it mbsync only offers EXTERNAL and
# cannot do XOAUTH2 against Microsoft (outlook.office365.com). The plugin's build
# installs to /usr/lib/sasl2, but Debian's libsasl2 searches the multiarch dir,
# so we copy it into /usr/lib/x86_64-linux-gnu/sasl2/. Build deps purged in-layer.
RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
      isync python3 ca-certificates curl \
      libsasl2-2 libsasl2-modules \
      build-essential autoconf automake libtool pkg-config libsasl2-dev git; \
    git clone --depth 1 https://github.com/moriyoshi/cyrus-sasl-xoauth2.git /tmp/xoauth2; \
    cd /tmp/xoauth2; \
    ./autogen.sh; \
    ./configure --prefix=/usr; \
    make; \
    make install; \
    install -d /usr/lib/x86_64-linux-gnu/sasl2; \
    cp -av /usr/lib/sasl2/libxoauth2.so* /usr/lib/x86_64-linux-gnu/sasl2/; \
    ls -l /usr/lib/x86_64-linux-gnu/sasl2/libxoauth2.so*; \
    apt-get purge -y build-essential autoconf automake libtool pkg-config libsasl2-dev git; \
    apt-get autoremove -y; \
    rm -rf /tmp/xoauth2 /var/lib/apt/lists/*

WORKDIR /mail
ENTRYPOINT ["sleep", "infinity"]
  

⚠ Why this is not optional (cost a full debugging round). isync 1.5.1 links libsasl2 and delegates XOAUTH2 to Cyrus SASL — it does not have a usable built-in XOAUTH2 despite 1.5’s changelog. With no plugin, the Hotmail pull fails immediately: selected: XOAUTH2 / available: EXTERNAL. Two install-path gotchas the Dockerfile above already handles: (1) the plugin’s build ignores --libdir and installs to /usr/lib/sasl2, but Debian’s libsasl2 only searches the multiarch dir /usr/lib/x86_64-linux-gnu/sasl2/ — hence the cp; (2) the final ls ... libxoauth2.so* is a build guard — if the copy didn’t land, set -e fails the build instead of shipping a broken image. Gmail (app-password AuthMechs LOGIN) doesn’t need any of this — only the Microsoft XOAUTH2 path does. Verify after building: docker exec mailtools ls /usr/lib/x86_64-linux-gnu/sasl2/ | grep xoauth2.

docker-compose.yml:

  services:
  mailtools:
    build: ./mailtools
    container_name: mailtools
    user: "1029:65536"               # dockerlimited:dockergroup — matches the share ACL (see section 1.5)
    volumes:
      - /volume1/mail:/mail              # bulk data (Maildirs) — the backup unit
      - /volume1/docker/mail:/config     # config + secrets — NOT backed up with the mail
    restart: unless-stopped          # idle; driven by `docker exec` on a schedule

  dovecot:
    image: dovecot/dovecot:2.4.4     # current CE release; flatcurve FTS is in core (no plugin package)
    container_name: mail-dovecot
    # AS-BUILT: runs as the image-default vmail (UID 1000), NOT 1029. Overriding user to 1029:65536
    # cascaded into Dovecot's privilege-separation state dirs (anvil/login expect the image's own
    # vmail/dovecot/dovenull identities) and would not start. The working pattern is: keep vmail, and
    # add the Synology dockergroup as a supplementary group so vmail can READ the Maildirs mbsync wrote.
    group_add:
      - "65536"                      # dockergroup — grants vmail read access to the mail share (ACL)
    volumes:
      - /volume1/mail:/mail
      # Drop-in override, NOT a full dovecot.conf replacement — the base image's dovecot.conf does
      # `!include_try conf.d/*.conf`, so a single 99-* file layers cleanly on top (section 6).
      - /volume1/docker/mail/dovecot/99-archive.conf:/etc/dovecot/conf.d/99-archive.conf:ro
      - /volume1/docker/mail/dovecot/users:/etc/dovecot/users:ro    # passwd-file (passdb/userdb)
      - /volume1/docker/mail/dovecot/certs:/certs:ro
    networks:
      - synobridge                   # match the rest of Babar's stack (Sonarr, etc.); LAN-reachable
    ports:
      - "10993:10993"                # IMAPS on a NON-privileged port — vmail (non-root) can't bind <1024
    # WHY 10993 + published port on synobridge: vmail is unprivileged, so 993 (privileged) is out; we
    # bind 10993 inside the container and publish it. synobridge is LAN-only (not internet-exposed, never
    # router-forwarded) and cannot ride the DSM HTTP reverse proxy anyway (that's HTTP/WebSocket only).
    restart: unless-stopped
    profiles: ["search"]             # held back until Phase D; `mailtools` starts without it

networks:
  synobridge:
    external: true                   # pre-existing bridge shared by the rest of Babar's stack
  

Note the mbsync PassCmd for Hotmail references /config/state/... and /config/scripts/... — those resolve to the mounts above.

Deploy via Container Manager (Babar convention): the plain docker CLI needs root on Synology, and every other service here is a UI-managed project. Create the project in Container Manager → Project → Create → Set Path to /volume1/docker/projects/mail-composeUse existing docker-compose.yml. It builds mailtools and starts it; the dovecot service is held back by its profiles: ["search"] gate until Phase D. All later mbsync/prune invocations use sudo docker exec mailtools …. (TLSType replaces the older SSLType; the newer isync in Debian 13 warns on the latter.)

Rebuilding mailtools after a Dockerfile change (e.g. the SASL plugin): DSM 7.2+ Container Manager bundles Compose v2, so sudo docker compose build mailtools works from the project dir — but build alone does not replace the running container. Follow with sudo docker compose up -d --force-recreate mailtools, or you’ll keep exec-ing into the old image and wonder why the change isn’t there. (Cosmetic caveat: a CLI rebuild leaves Container Manager’s Project page showing the project as out-of-sync; harmless. To stay fully CM-native instead: Project → mail-compose → Stop → Build → Start.)


5. mbsync config

/volume1/docker/mail/mbsync/mbsyncrc (mounted at /config/mbsync/mbsyncrc; point mbsync at it with -c):

  # --- Gmail: archive-safe, All Mail only ---
IMAPAccount gmail
Host imap.gmail.com
User shawn.oster@gmail.com
PassCmd "cat /config/state/gmail_app_pw"
TLSType IMAPS
AuthMechs LOGIN

IMAPStore gmail-remote
Account gmail

MaildirStore gmail-local
Path /mail/gmail/Maildir/
Inbox /mail/gmail/Maildir/INBOX
SubFolders Verbatim

Channel gmail-pull
Far :gmail-remote:
Near :gmail-local:
Patterns "[Gmail]/All Mail"       # All Mail only — avoids per-label duplication (each labelled msg would otherwise land 2-4x)
Create Near
Sync PullNew PullFlags            # never propagate a remote deletion onto the archive
Expunge None
SyncState *

# --- Hotmail: OAuth2 device-code (monocularjack@hotmail.com) ---
IMAPAccount hotmail
Host outlook.office365.com
User monocularjack@hotmail.com
# NOTE: no `-t` — that's --test (runs login tests). Default output is the plain
# access token, which is what mbsync's XOAUTH2 needs. The cat pipes = plaintext token.
PassCmd "python3 /config/scripts/mutt_oauth2.py /config/state/hotmail_oauth2 --decryption-pipe cat --encryption-pipe cat"
AuthMechs XOAUTH2
TLSType IMAPS

IMAPStore hotmail-remote
Account hotmail

MaildirStore hotmail-local
Path /mail/hotmail/Maildir/
Inbox /mail/hotmail/Maildir/INBOX
SubFolders Verbatim

Channel hotmail-pull
Far :hotmail-remote:
Near :hotmail-local:
# Outlook folders are REAL folders (no All-Mail union like Gmail), so pull them all
# EXCEPT the noise. Real personal-account names are short: Deleted / Junk / Notes /
# Outbox / "Sync Issues" (NOT "Deleted Items"/"Junk Email" — confirm against your account).
Patterns * !"Deleted" !"Deleted Items" !"Junk" !"Junk Email" !"Notes" !"Outbox" !"Drafts" !"Sync Issues"
Create Near
Sync PullNew PullFlags
Expunge None
SyncState *
  

Hotmail requires the SASL plugin (section 4 Dockerfile) — AuthMechs XOAUTH2 fails with available: EXTERNAL without it. Authorize the token (section 3) before the first hotmail-pull. The initial pull throttles like Gmail (Connection reset by peer mid-run); SyncState makes it resumable, so loop it: docker exec mailtools sh -c 'until mbsync -c /config/mbsync/mbsyncrc hotmail-pull; do sleep 15; done'. If you tighten Patterns after a pull already created a noise folder, also rm -rf that folder’s local Maildir — changing the pattern stops future syncs but doesn’t delete what’s already local.

Test one channel at a time before scheduling anything:

  docker exec mailtools mbsync -c /config/mbsync/mbsyncrc gmail-pull   # watch for auth errors
  

The first Gmail run pulls ~20 years of All Mail and is CPU/IO-heavy on the Celeron J4125 (it “just spins” on big filesystem work — see synology.md). Run it off-hours; incrementals afterward are light.

After the first sync, confirm the on-disk path. SubFolders Verbatim + a folder literally named [Gmail]/All Mail decides where the Maildir actually lands. The prune script’s MAILDIR constant and Dovecot’s config must both point at that same real path — reconcile all three before enabling prune.


6. Dovecot 2.4 (search layer) — as built

Uses the official dovecot/dovecot:2.4.4 image. Rather than replacing the image’s dovecot.conf, we layer a single drop-in (conf.d/99-archive.conf) — the base config ends with !include_try conf.d/*.conf, so this is the least-surprising way to add just what the archive needs.

Key as-built decisions (each cost a debugging round; documented so the next person skips them):

  • Runs as vmail (UID 1000), the image default — do NOT override user: to 1029. Forcing 1029 breaks Dovecot’s privilege-separation state dirs (anvil, login processes expect the image’s own vmail/dovecot/dovenull users). Instead, keep vmail and add group_add: ["65536"] (dockergroup) so vmail can read the Maildirs — see the compose service in section 4.
  • Port 10993, not 993. vmail is unprivileged and can’t bind a port <1024. Bind 10993 inside the container, publish it, point clients there.
  • mailbox_list_layout = fs. mbsync writes with SubFolders Verbatim (filesystem layout, e.g. a literal [Gmail]/All Mail directory). Dovecot must use the matching fs layout or it won’t see the folders.
  • passwd-file auth. A tiny static passdb/userdb off a users file — no system accounts, no LDAP. The users file must be readable by vmail (own it 1000:1000, chmod 640), or auth fails silently.
  • Two namespaces, one login. The single shawn login exposes both archives: an inbox namespace (empty prefix) → /mail/gmail/Maildir, and a hotmail namespace (prefix Hotmail/) → /mail/hotmail/Maildir. In Dovecot 2.4 each namespace sets its own mail_driver = maildir + mail_path (the old 2.3 location = maildir:… is gone) — verified against the 2.4 namespaces doc. The global mail_path becomes an unused default once namespaces are explicit.
  • FTS (flatcurve) is active by default — no config needed. The official dovecot/dovecot:2.4.4 image ships fts_flatcurve enabled, so server-side full-text search works out of the box. Confirmed by fts-flatcurve/ index directories appearing inside each mailbox on disk (e.g. [Gmail]/All Mail/fts-flatcurve/). The index is a fraction of mailbox size; /volume1 has ~1.2 TB free so it’s a non-issue here, but on a tight volume df -h /volume1 is worth a glance.

/volume1/docker/mail/dovecot/99-archive.conf (as deployed):

  protocols = imap

mailbox_list_layout = fs
mail_home = /mail/.dovecot/%{user}

namespace inbox {
  separator = /
  prefix =
  mail_driver = maildir
  mail_path = /mail/gmail/Maildir
  inbox = yes
}

namespace hotmail {
  separator = /
  prefix = Hotmail/
  mail_driver = maildir
  mail_path = /mail/hotmail/Maildir
}

passdb passwd-file {
  passwd_file_path = /etc/dovecot/users
}
userdb static {
  fields {
    home = /mail/.dovecot/%{user}
  }
}

ssl = yes
ssl_server_cert_file = /certs/dovecot.pem
ssl_server_key_file = /certs/dovecot.key

service imap-login {
  inet_listener imap { port = 0 }          # plaintext IMAP off
  inet_listener imaps {
    port = 10993
    ssl = yes
  }
}
service doveadm { inet_listener http { port = 0 } }
service stats  { inet_listener http { port = 0 } }
  

Editing this drop-in later is risky — a bad config crash-loops Dovecot and takes both archives offline. Always cp 99-archive.conf 99-archive.conf.bak first, sudo docker restart mail-dovecot, then sudo docker logs --tail 30 mail-dovecot — a clean starting up for imap with no Fatal: means good; otherwise restore the .bak and restart. The image is minimal (no grep), so run diagnostics host-side: sudo docker exec mail-dovecot doveconf -a 2>/dev/null | grep -E "mail_path|prefix =" should list both Maildir paths.

/volume1/docker/mail/dovecot/users — one line, user:{SCHEME}hash (generate with doveadm pw -s SHA512-CRYPT):

  shawn:{SHA512-CRYPT}$6$...redacted...
  

Then chown 1000:1000 + chmod 640 that file (vmail must read it). Self-signed cert at /certs/dovecot.{pem,key} (CN can be anything, e.g. babar-mail) — LAN-only, so accept the trust prompt once in the client; no DSM Let’s Encrypt cert needed.

IMAPS is LAN-only via synobridge + a published 10993:10993 port (section 4) — reachable at Babar’s LAN IP like every other service, never router-forwarded, and it cannot ride the DSM nginx reverse proxy (that’s HTTP/WebSocket only; IMAP is neither).

Client setup (Thunderbird): IMAP server 192.168.50.230, port 10993, SSL/TLS, username shawn (matches the users file), the password you hashed. One gotcha: mbsync doesn’t set IMAP subscriptions, so Thunderbird hides the archive folders by default — uncheck “Show only subscribed folders” (right-click the account → Subscribe, or Server Settings → Advanced) to see [Gmail]/All Mail and the whole Hotmail/ tree (INBOX, Archive, Sent, and your custom folders). After changing Dovecot namespaces, re-open Subscribe / refresh the account for the new tree to appear.

Remote/phone access is a separate VPN project (WireGuard-on-ASUS vs Tailscale — see notebook/projects/aya-gateway); do not expose IMAPS to the internet as a substitute.


7. Prune script (the delayed-delete step)

scripts/prune.py (deployed at /volume1/docker/mail/scripts/prune.py, run as docker exec mailtools python3 /config/scripts/prune.py …) — dry-run by default, hard-gated on a fresh backup marker, batched and resumable. Ran 2026-07-29: moved 12,959 Gmail messages older than 1 year to Trash, 5 headerless messages safely skipped.

  #!/usr/bin/env python3
"""Prune Gmail [Gmail]/All Mail messages present in the local Maildir and covered by a
verified backup. Dry-run unless --commit. Moves matched messages to [Gmail]/Trash (Gmail
treats COPY->Trash as a MOVE: 30-day grace, then auto-purged). NEVER touches a message
whose Message-ID isn't in the local archive. Batched + resumable (per-batch expunge)."""
import argparse, email, imaplib, os, re, sys, time

MAILDIR   = "/mail/gmail/Maildir/[Gmail]/All Mail"   # VERIFIED on-disk path (cur/ + new/)
BACKUP_OK = "/config/state/last_backup_ok"           # epoch ts written by backup_ok.sh
PW_FILE   = "/config/state/gmail_app_pw"
USER      = "shawn.oster@gmail.com"
BACKUP_MAX_AGE = 7 * 86400
HEADER_BYTES   = 32768       # read only the header region to find Message-ID (truncation -> safe skip)
BATCH_SIZE     = 500
UID_RE = re.compile(rb"UID (\d+)")

def local_message_id(path):
    with open(path, "rb") as fh:
        head = fh.read(HEADER_BYTES)
    return email.message_from_bytes(head).get("Message-ID")

def load_local_message_ids():
    ids = set()
    for sub in ("cur", "new"):
        d = os.path.join(MAILDIR, sub)
        if not os.path.isdir(d):
            continue
        for fn in os.listdir(d):
            mid = local_message_id(os.path.join(d, fn))
            if mid:
                ids.add(mid.strip())
    return ids

def backup_gate():
    if not os.path.exists(BACKUP_OK):
        sys.exit("ABORT: no verified backup marker.")
    ts = float(open(BACKUP_OK).read().strip())
    if time.time() - ts > BACKUP_MAX_AGE:
        sys.exit(f"ABORT: backup marker stale ({(time.time()-ts)/86400:.1f}d).")

def connect(folder, readonly):
    M = imaplib.IMAP4_SSL("imap.gmail.com")
    M.login(USER, open(PW_FILE).read().strip())
    M.select(f'"{folder}"', readonly=readonly)
    return M

def fetch_mids(M, uid_batch):
    typ, data = M.uid("FETCH", ",".join(uid_batch),
                      "(UID BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
    out = {}
    for item in data:
        if not isinstance(item, tuple) or len(item) < 2:
            continue
        m = UID_RE.search(item[0])
        if not m:
            continue
        mid = email.message_from_bytes(item[1]).get("Message-ID")
        out[m.group(1).decode()] = mid.strip() if mid else None
    return out

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--commit", action="store_true")
    ap.add_argument("--folder", default="[Gmail]/All Mail")
    ap.add_argument("--min-age-days", type=int, default=365)
    ap.add_argument("--limit", type=int, default=0)
    ap.add_argument("--count-only", action="store_true")
    args = ap.parse_args()

    def cutoff_str():
        return time.strftime("%d-%b-%Y", time.gmtime(time.time() - args.min_age_days*86400))

    if args.count_only:
        M = connect(args.folder, readonly=True)
        typ, data = M.uid("SEARCH", None, f"(BEFORE {cutoff_str()})")
        print(f"candidates older than {args.min_age_days}d ({cutoff_str()}): {len(data[0].split())}")
        M.logout(); return

    backup_gate()
    local = load_local_message_ids()
    print(f"local archived Message-IDs: {len(local)}", flush=True)

    M = connect(args.folder, readonly=not args.commit)
    typ, data = M.uid("SEARCH", None, f"(BEFORE {cutoff_str()})")
    uids = [u.decode() for u in data[0].split()]
    if args.limit:
        uids = uids[:args.limit]
    print(f"candidates older than {args.min_age_days}d ({cutoff_str()}): {len(uids)}"
          + (f" (limited to {args.limit})" if args.limit else ""), flush=True)

    moved = skipped = 0
    nbatches = (len(uids) + BATCH_SIZE - 1) // BATCH_SIZE
    for bi in range(nbatches):
        batch = uids[bi*BATCH_SIZE:(bi+1)*BATCH_SIZE]
        attempt = 0
        while True:
            try:
                mids = fetch_mids(M, batch)
                to_move = [u for u in batch if mids.get(u) and mids[u] in local]
                if args.commit and to_move:
                    M.uid("COPY", ",".join(to_move), '"[Gmail]/Trash"')  # Gmail: move to Trash
                    M.expunge()
                break
            except (imaplib.IMAP4.abort, imaplib.IMAP4.error, OSError) as e:
                attempt += 1
                if attempt > 3:
                    print(f"\nABORT at batch {bi+1}/{nbatches}: {e}\n"
                          f"Progress saved (moved={moved}). Re-run to resume.", file=sys.stderr)
                    sys.exit(1)
                print(f"  [batch {bi+1} error: {e}; reconnecting {attempt}/3]", flush=True)
                time.sleep(5 * attempt)
                try: M.logout()
                except Exception: pass
                M = connect(args.folder, readonly=not args.commit)
        skipped += len(batch) - len(to_move)
        moved += len(to_move)
        print(f"  batch {bi+1}/{nbatches}: {'moved' if args.commit else 'would move'} "
              f"{len(to_move)} (total {moved}, skipped {skipped})", flush=True)
        time.sleep(1)
    print(f"{'MOVED' if args.commit else 'WOULD MOVE'}: {moved}  skipped(not-local): {skipped}", flush=True)
    try: M.logout()
    except Exception: pass

if __name__ == "__main__":
    main()
  

Safety properties: hard-aborts without a fresh backup marker; matches by Message-ID against the local Maildir; never touches a message absent locally; dry-run opens the mailbox read-only (readonly=not args.commit); Trash-move (not hard delete) keeps Gmail’s 30-day grace; per-batch expunge makes a throttled run resumable (re-run does a fresh SEARCH and continues). Reads only the first 32 KB of each local file for the Message-ID — a truncated header can only cause a safe skip, never a wrongful delete.

Staged rollout that was actually used (do it this way):

  1. --limit 200 dry-run → confirm skipped(not-local): 0 (matching logic sound).
  2. --count-only --min-age-days {365,730,1095,1825,3650} → survey how many messages each cutoff prunes, to pick the age window. (Here: mail is heavily old — ~13k >1yr, ~10k >10yr — so the cutoff mostly decides how recent stays in Gmail for phone/web, not how much is deleted. Chose 1 year.)
  3. Full dry-run (no --limit) → real total + skipped(not-local) count (got 12,964 would-move, 5 skipped).
  4. --commit --limit 5 live micro-test → verify Gmail’s COPY→Trash actually moves (All Mail candidate count must drop by exactly 5). It did — confirming COPY alone is the correct delete op; no STORE \Deleted / All-Mail EXPUNGE needed (Gmail treats Trash as a move, so those would act on stale UIDs).
  5. --commit full run.

The 5 skipped are Gmail messages with no Message-ID header — unmatchable on both sides, so they’re left in the cloud forever. Harmless. All-Mail-only also means Drafts/Spam/Trash were never archived — don’t later assume drafts were captured. Post-prune the local archive is unaffectedSync PullNew PullFlags (section 5, correction C2) never deletes local copies when they vanish from Gmail, which is the entire point.


8. Wire up DSM Task Scheduler

Control Panel → Task Scheduler → Create → Scheduled Task → User-defined script. Owner: root (the task runs docker exec, which needs the docker socket; the work still runs as 1029:65536 inside the container — no sudo inside a scheduler task, it’s already root).

The as-built staggered pipeline (each step feeds the next):

TaskScheduleCommand
Mail pull (mail - nightly pull)Daily 03:00-a pull, see below
Hyper Backup (mail-offsite-b2)Daily 04:40Backblaze B2 (section 9)
Backup gate (mail-backup-gate)Daily 05:30bash /volume1/docker/mail/scripts/backup_ok.sh >> …/backup_ok.log 2>&1 (owner root)
Prune (optional)Manual, or weekly for a rolling windowdocker exec mailtools python3 /config/scripts/prune.py --commit

The prune was run once manually (2026-07-29) to establish the 1-year window. To keep a rolling 1-year window (mail auto-pruned as it crosses 365 days), schedule it weekly — but leave it disabled until every box in section 10 is checked, and only after the backup gate is proven.

The pull task, with timestamped append-logging so nightly runs leave a trail. Now that Hotmail is live it runs -a (all channels — Gmail + Hotmail):

  docker exec mailtools sh -c 'mbsync -c /config/mbsync/mbsyncrc -a >> /config/state/gmail-pull.log 2>&1; echo "[$(date)] nightly EXIT $?" >> /config/state/gmail-pull.log'
  

History: scope this to gmail-pull (not -a) until Hotmail’s token is authorized — otherwise the Hotmail channel fails-auth every night and spams the log. Switch to -a once section 3 is done (it is). Note: while an initial multi-day pull is still running, nightly runs hit mbsync’s channel lock and exit 1 (a safe no-op, not a failure) — leave “email on abnormal termination” off until initial pulls complete. This gives automatic cap-resume: any daily-throttle stop is picked back up by the next nightly run via SyncState. The nightly -a also refreshes the Hotmail OAuth token non-interactively (the mutt_oauth2.py PassCmd rotates and re-saves the refresh token each run), so the token stays alive as long as the box syncs at least every ~90 days.

Leave the prune task disabled until every box in section 10 is checked.


9. Backup (hard prerequisite for prune) — as built

Backblaze B2, off-site. Hyper Backup of the mail share to B2 via the S3-Compatible Storage connector (this DSM has no native Backblaze option). Gotchas hit:

  • Endpoint s3.us-west-004.backblazeb2.com, region us-west-004, Signature v4.
  • The B2 application key must NOT be bucket-restricted — Hyper Backup lists all buckets to populate its dropdown, and a bucket-scoped key can’t ListBuckets → “Insufficient privileges.” Create an all-buckets Read/Write key.
  • Client-side encryption ON — the .pem key is stored in 1Password. Without it the backup is unrecoverable (the password alone won’t do a full restore).
  • Data source: mail share only (not docker/mail — secrets stay out of the backup). Smart Recycle rotation, daily 04:40, weekly integrity check.

RAID is not a backup. B2 is off-site (survives box loss/theft/fire) — the right posture since the whole point is to delete the cloud copy. A SanDisk Extreme USB local copy is planned as a second (3-2-1) layer; not required for the gate.

The gate — scripts/backup_ok.sh (runs as root on the host via Task Scheduler, 05:30). DSM 7.3 has no synobackup.log; backup success is in /var/log/systemd/synobackupd.service.log as launch job [{…"action":1…"task_id":1…}] followed by job [N] exit(0). The script finds the most recent backup job for task_id=1 (the only Hyper Backup task), confirms exit(0) within 26 h, and writes the epoch to /volume1/docker/mail/state/last_backup_ok (chown 1029:65536, chmod 640) so the container’s prune.py can read it. A stale/failed backup leaves the marker untouched → prune.py aborts on its own 7-day check. The scheduled task emails only on abnormal termination, so a failed backup that closes the gate pings you automatically.

  #!/bin/bash
set -uo pipefail
LOG="/var/log/systemd/synobackupd.service.log"
MARKER="/volume1/docker/mail/state/last_backup_ok"
TASK_ID=1; MAX_AGE_HOURS=26; OWNER="1029:65536"
fail() { echo "backup_ok: GATE CLOSED — $*" >&2; exit 1; }
[ -r "$LOG" ] || fail "cannot read $LOG"
jobid=$(grep -aE '"action":1[,}].*"task_id":'"$TASK_ID"'[,}]' "$LOG" \
        | grep -a 'launch job \[{' | sed -E 's/.*"job_id":([0-9]+).*/\1/' | tail -n1)
[ -n "$jobid" ] || fail "no backup launch for task_id=$TASK_ID"
exitline=$(grep -aE "job \[$jobid\] exit\(0\)" "$LOG" | tail -n1)
[ -n "$exitline" ] || fail "job $jobid has no exit(0)"
ts=$(echo "$exitline" | awk '{print $1}')
epoch=$(date -d "$ts" +%s 2>/dev/null) \
  || epoch=$(python3 -c 'import sys,datetime;print(int(datetime.datetime.fromisoformat(sys.argv[1]).timestamp()))' "$ts" 2>/dev/null) \
  || fail "could not parse timestamp: $ts"
age_h=$(( ($(date +%s) - epoch) / 3600 ))
[ "$age_h" -le "$MAX_AGE_HOURS" ] || fail "latest success is ${age_h}h old (> ${MAX_AGE_HOURS}h)"
umask 077; echo "$epoch" > "$MARKER"; chown "$OWNER" "$MARKER"; chmod 640 "$MARKER"
echo "backup_ok: GATE OPEN — task $TASK_ID job $jobid exit(0) at $ts (${age_h}h old); marker=$epoch"
  

task_id=1 is hardcoded = the only Hyper Backup task on this box. If you add another Hyper Backup task, re-check which task_id is the mail one (the log JSON only carries the id, not the name).


10. Validation checklist (all met before the 2026-07-29 prune)

  • mbsync -a completes cleanly across nightly runs
  • Local count matches server (16,005 local Gmail msgs, not a multiple — All-Mail-only fix confirmed)
  • Local copies keep no \Deleted when mail leaves Gmail (PullNew PullFlags fix — proven by the prune itself: 12,959 left Gmail, all 16,005 still local)
  • Dovecot serves both archives; old mail opens in Thunderbird at 192.168.50.230:10993 (flatcurve FTS active)
  • Full Hyper Backup cycle completed (B2) and last_backup_ok fresh
  • prune.py dry-run reported skipped(not-local): 0 on the 200-sample; full dry-run 12,964 would-move / 5 skipped; --commit --limit 5 confirmed Gmail COPY→Trash is a move
  • Spot-checked old threads open from the NAS

Outcome: committed prune moved 12,959 messages older than 1 year to Gmail Trash (30-day grace), 5 headerless safely skipped. Gmail now holds ~1 year in the cloud; the full ~16k archive is preserved locally, searchable, and backed up off-site.

Remaining / optional follow-ups: SanDisk local backup (2nd copy, 3-2-1); a B2 test-restore to prove the off-site copy + .pem key actually restore (do within the 30-day Trash window); optionally schedule the prune weekly for a rolling 1-year window; Hotmail has no prune logic yet (Gmail-only); remote access is the separate VPN project.