NAS Email Archive Setup Guide
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):
- ✅
dockergroupACL granted; tree owned1029:65536(section 1.5)- ✅
mail-composeproject created in Container Manager;mailtoolsrunning as1029: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-xoauth2compiled into themailtoolsimage; 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-defaultvmail(UID 1000) +group_add: 65536, passwd-file auth (usershawn), drop-in config. flatcurve FTS is active — the official image enablesfts_flatcurveby default (index dirs appear inside each mailbox); server-side search works, no extra config needed.- ✅ Phase E done: DSM Task Scheduler nightly
-apull (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,
.pemkey in 1Password) of thebackup_ok.shgate parses DSM’s backup log and writes the marker prune reads (section 9).- ✅ Phase G done (2026-07-29): batched
prune.pymoved 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:
- Pull —
mbsync, 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) withExpunge None. Cloud is never modified by this step. - Backup — Hyper Backup of the
mailvolume to an off-box target. Writes a success marker that gates step 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, notPull: in isync,Sync Pullexpands toNew + ReNew + Delete + Flags. ThatDeletemeans once the prune removes a message from Gmail, the next sync would flag the local archived copy\Deleted— silently defeating the archive. Restricting toPullNew PullFlagsis what actually decouples the three layers. (See the isync manual.)
1. NAS prep
- SSH — already enabled on Babar (port 22,
ssh babar). Nothing to do. - 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. - Container Manager — already installed (Babar runs Gitea, the *arr stack, nostr-relay, etc. under it).
- Storage — three tiers, matching Babar’s convention (compose-as-code in
projects/<svc>-compose/, app config/state indocker/<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.
Container access to the share — the Synology ACL gotcha. Synology shares are
root:root, mode0000, governed by a Synology ACL, not POSIX. A container running as an arbitrary UID (e.g. Dovecot’s default1000) is not in that ACL and gets permission-denied even on a0777-looking folder. The working pattern on Babar (the same one the media stack uses) is to run mail containers as the dedicateddockerlimitedidentity1029:65536(dockergroup) and grant that group on the share:- Grant
dockergroupon themailshare (UI, keeps DSM’s permission DB in sync): Control Panel → Shared Folder →mail→ Edit → Permissions → Local groups →dockergroup→ Read/Write. This mirrors thedatashare’s ACL entrygroup:dockergroup:allow:rwxpdDaARWc--:fd--. - Own the archive tree as
1029:65536:sudo chown -R 1029:65536 /volume1/mail/{gmail,hotmail}. (Thedocker/mailconfig tier is a plain folder, not an ACL-governed share, so a normalsudo chown -R 1029:65536 /volume1/docker/mail && sudo chmod 700 /volume1/docker/mail/stateis all it needs — nodockergroupgrant required there.) - Verify from inside the container:
docker exec mailtools sh -c 'id; touch /mail/gmail/.w && echo OK && rm /mail/gmail/.w'→ expectuid=1029 gid=65536andOK.
Both mail containers therefore run as
user: "1029:65536"(section 4). Credentials created from inside the running container land owned1029:65536automatically — no host-side chown needed per file. Note the ACL gotcha applies only to themailshare; thedocker/mailconfig tier is a plain folder with ordinary POSIX ownership.- Grant
2. Gmail credential
- Enable 2-Step Verification on
shawn.oster@gmail.comif not already on. - 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. - Load it from inside the running container so it lands owned
1029:65536at0600without a host chown, and never touches shell history:Type the 16 chars with spaces removed → Ctrl-D. (mbsync strips the trailing newline.)docker exec -it mailtools sh -c 'umask 077; cat > /config/state/gmail_app_pw'
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 registrations → New registration):
- Name:
NAS mbsync - Supported account types: Accounts in any organizational directory (multitenant) and personal Microsoft accounts — required for a
@hotmail.comaccount. - 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.Allunder 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.0commonendpoint 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.comusually 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
libsasl2and delegatesXOAUTH2to 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--libdirand installs to/usr/lib/sasl2, but Debian’slibsasl2only searches the multiarch dir/usr/lib/x86_64-linux-gnu/sasl2/— hence thecp; (2) the finalls ... libxoauth2.so*is a build guard — if the copy didn’t land,set -efails the build instead of shipping a broken image. Gmail (app-passwordAuthMechs 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-compose → Use 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
mailtoolsafter a Dockerfile change (e.g. the SASL plugin): DSM 7.2+ Container Manager bundles Compose v2, sosudo docker compose build mailtoolsworks from the project dir — butbuildalone does not replace the running container. Follow withsudo 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 XOAUTH2fails withavailable: EXTERNALwithout it. Authorize the token (section 3) before the firsthotmail-pull. The initial pull throttles like Gmail (Connection reset by peermid-run);SyncStatemakes it resumable, so loop it:docker exec mailtools sh -c 'until mbsync -c /config/mbsync/mbsyncrc hotmail-pull; do sleep 15; done'. If you tightenPatternsafter a pull already created a noise folder, alsorm -rfthat 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 Maildecides where the Maildir actually lands. The prune script’sMAILDIRconstant 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 overrideuser:to 1029. Forcing 1029 breaks Dovecot’s privilege-separation state dirs (anvil, login processes expect the image’s ownvmail/dovecot/dovenullusers). Instead, keep vmail and addgroup_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 withSubFolders Verbatim(filesystem layout, e.g. a literal[Gmail]/All Maildirectory). Dovecot must use the matchingfslayout or it won’t see the folders.- passwd-file auth. A tiny static
passdb/userdboff ausersfile — no system accounts, no LDAP. Theusersfile must be readable by vmail (own it1000:1000,chmod 640), or auth fails silently. - Two namespaces, one login. The single
shawnlogin exposes both archives: an inbox namespace (empty prefix) →/mail/gmail/Maildir, and a hotmail namespace (prefixHotmail/) →/mail/hotmail/Maildir. In Dovecot 2.4 each namespace sets its ownmail_driver = maildir+mail_path(the old 2.3location = maildir:…is gone) — verified against the 2.4 namespaces doc. The globalmail_pathbecomes an unused default once namespaces are explicit. - FTS (flatcurve) is active by default — no config needed. The official
dovecot/dovecot:2.4.4image shipsfts_flatcurveenabled, so server-side full-text search works out of the box. Confirmed byfts-flatcurve/index directories appearing inside each mailbox on disk (e.g.[Gmail]/All Mail/fts-flatcurve/). The index is a fraction of mailbox size;/volume1has ~1.2 TB free so it’s a non-issue here, but on a tight volumedf -h /volume1is 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.bakfirst,sudo docker restart mail-dovecot, thensudo docker logs --tail 30 mail-dovecot— a cleanstarting up for imapwith noFatal:means good; otherwise restore the.bakand restart. The image is minimal (nogrep), 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):
--limit 200dry-run → confirmskipped(not-local): 0(matching logic sound).--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.)- Full dry-run (no
--limit) → real total +skipped(not-local)count (got 12,964 would-move, 5 skipped). --commit --limit 5live micro-test → verify Gmail’sCOPY→Trash actually moves (All Mail candidate count must drop by exactly 5). It did — confirmingCOPYalone is the correct delete op; noSTORE \Deleted/ All-Mail EXPUNGE needed (Gmail treats Trash as a move, so those would act on stale UIDs).--commitfull run.
The 5 skipped are Gmail messages with no
Message-IDheader — 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 unaffected —Sync 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):
| Task | Schedule | Command |
|---|---|---|
Mail pull (mail - nightly pull) | Daily 03:00 | -a pull, see below |
Hyper Backup (mail-offsite-b2) | Daily 04:40 | Backblaze B2 (section 9) |
Backup gate (mail-backup-gate) | Daily 05:30 | bash /volume1/docker/mail/scripts/backup_ok.sh >> …/backup_ok.log 2>&1 (owner root) |
| Prune (optional) | Manual, or weekly for a rolling window | docker 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, regionus-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
.pemkey is stored in 1Password. Without it the backup is unrecoverable (the password alone won’t do a full restore). - Data source:
mailshare only (notdocker/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=1is hardcoded = the only Hyper Backup task on this box. If you add another Hyper Backup task, re-check whichtask_idis 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 -acompletes 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
\Deletedwhen mail leaves Gmail (PullNew PullFlagsfix — 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_okfresh -
prune.pydry-run reportedskipped(not-local): 0on the 200-sample; full dry-run 12,964 would-move / 5 skipped;--commit --limit 5confirmed GmailCOPY→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.