Runbook — Email provisioning (Migadu, P3)¶
Severity : P3 (operator-driven config ; no live trading impact)
Owner : @dococeven
Story : email infra setup
Linked code : scripts/migadu_provision.py · documentation/email/cvntrade-mailboxes.yaml
This runbook covers the day-1 setup + day-2 ops for the @cvntrade.eu mail surface, hosted at Migadu (Suisse, SaaS). The infra is declarative-as-code : the YAML in documentation/email/ is source-of-truth, the Python script applies it idempotently.
Why Migadu : pricing per-domain (not per-user), CHF jurisdiction, IMAP/SMTP standards, REST admin API, no GAFAM dependency. Limitations : no SSO, no webhooks, no ActiveSync. If/when those are needed → migration to Bluemind (FR) or Infomaniak Business (CH) — see §8.
§1 — Initial signup¶
One-shot, ~5 min :
- Go to migadu.com, click Pricing, choose Standard plan (5 €/mois, 30 GB shared, sufficient for ≤ 20 mailboxes initially)
- Create account → confirm email
- Add domain : Migadu admin → Domains → Add →
cvntrade.eu - Migadu generates a verify TXT record like
hosted-email-verify=<token>— see §2 below - Generate API token : Migadu admin → API Keys → Generate → copy the token (shown ONCE)
- Save token locally :
cat >> .env <<EOF
# Migadu — mailbox provisioning (cvntrade.eu)
MIGADU_ADMIN_EMAIL=admin@cvntrade.eu
MIGADU_TOKEN=<paste-token-here>
EOF
(.env is gitignored ; never commit the token)
§2 — DNS records at Scaleway¶
Migadu provides 7 DNS records to add at the registrar/DNS host. CVNTrade DNS lives at Scaleway : Console Scaleway → Domains and DNS → cvntrade.eu → DNS Zone → Add records.
For all apex records (MX / SPF / DMARC / verify TXT), leave the Name field empty (Scaleway resolves empty Name to the zone root cvntrade.eu).
| # | Type | Name | Value | TTL |
|---|---|---|---|---|
| 1 | TXT | (empty) | hosted-email-verify=<token> (one-shot, removable post-verification) |
3600 |
| 2 | MX | (empty) | aspmx1.migadu.com. (priority 10) |
3600 |
| 3 | MX | (empty) | aspmx2.migadu.com. (priority 20) |
3600 |
| 4 | TXT | (empty) | v=spf1 include:spf.migadu.com -all |
3600 |
| 5 | CNAME | key1._domainkey |
key1.<your-domain-key>._domainkey.migadu.com. (Migadu generates) |
3600 |
| 6 | CNAME | key2._domainkey |
key2.<your-domain-key>._domainkey.migadu.com. |
3600 |
| 7 | CNAME | key3._domainkey |
key3.<your-domain-key>._domainkey.migadu.com. |
3600 |
| 8 | TXT | _dmarc |
v=DMARC1; p=quarantine; rua=mailto:admin@cvntrade.eu; ruf=mailto:admin@cvntrade.eu; fo=1 |
3600 |
Verify propagation :
dig +short MX cvntrade.eu # → aspmx1.migadu.com / aspmx2.migadu.com
dig +short TXT cvntrade.eu | grep spf # → "v=spf1 include:spf.migadu.com -all"
dig +short CNAME key1._domainkey.cvntrade.eu # → key1.xxxxx._domainkey.migadu.com.
dig +short TXT _dmarc.cvntrade.eu # → "v=DMARC1; p=quarantine; ..."
Migadu admin auto-detects the records (~1-5 min after propagation). All checks turn green = ready.
§3 — Provision the YAML-declared mailboxes¶
# Install deps if not done
source .venv_airflow/bin/activate
pip install -q pyyaml requests
# Preview what would change (no API writes)
python scripts/migadu_provision.py diff
# Apply (idempotent)
make email-sync
# OR equivalently
python scripts/migadu_provision.py sync
The sync prints bootstrap passwords for newly-created mailboxes ONLY ONCE — copy them to a secure channel (1Password, password manager) immediately. The operator rotates each via webmail on first login (§5).
Capture pattern : passwords are written to stderr (not stdout) so the regular sync output stays parseable. To capture both for review while running locally :
make email-sync 2> /tmp/email-bootstrap-$(date +%s).log
# Then read the file, transfer the passwords to your password manager,
# and shred the file :
shred -u /tmp/email-bootstrap-*.log
Residual CI risk : if make email-sync is ever invoked from a CI pipeline (it should NOT — provisioning is operator-only per §3), CI runners typically capture both stdout AND stderr into the build log. The "stderr instead of logger.info" routing here protects against the most common case (logging frameworks pulling stdout) but does NOT make CI capture safe. Treat any CI run of email-sync as a credential leak incident → rotate all bootstrap passwords immediately + revoke the API token.
Adding a new mailbox¶
- Edit
documentation/email/cvntrade-mailboxes.yaml— add a new entry undermailboxes: - Run
make email-sync - New bootstrap password printed — share via secure channel
- Commit the YAML change with a small PR (audit trail per CLAUDE.md workflow)
Adding an alias to an existing mailbox¶
- Edit the YAML — add a string to the
aliases: []list under the target mailbox - Run
make email-sync(existing mailbox = skip ; new alias = create) - Commit + PR
Scope : create-only, no drift detection¶
make email-sync is create-only : if a mailbox or alias already exists in Migadu, the script logs already exists — skip and moves on. It does NOT verify that the existing mailbox's name / spam_action / storage_quota / forward_to fields match the YAML. Modifying any of those fields requires a manual edit via the Migadu admin UI (admin.migadu.com → Mailboxes → click → edit).
This is intentional for v1 (create-only safety, no destructive deltas) but it does mean the YAML is source-of-truth for mailbox/alias EXISTENCE only — not for their per-field configuration once they exist. If field-level drift detection becomes a need (e.g., quota changes pushed to OP for audit), it lands in a follow-up PR with explicit --reconcile semantics + a dry-run gate.
§4 — Status check¶
python scripts/migadu_provision.py status
# Or JSON output for tooling :
python scripts/migadu_provision.py status --json
Prints which YAML-declared mailboxes/aliases are present in Migadu vs missing. Non-destructive.
§5 — Rotating a password¶
After bootstrap, every mailbox owner rotates the password via webmail :
- Login at admin.migadu.com with
<local_part>@cvntrade.eu+ bootstrap password - Settings → Change password → set a strong new password (manage in password manager)
- Update any IMAP / SMTP clients (Thunderbird, Apple Mail, mobile)
Operator-side reset (forgotten password) :
- Migadu admin → Mailboxes → click the mailbox → Reset password → new bootstrap printed
- Share via secure channel ; user rotates again per above
- Do NOT use the script for password changes — keep it create-only for the sync invariant.
§6 — Decommissioning a mailbox¶
The provisioning script is create-only — it never deletes. To decommission :
- First, edit the YAML to remove the mailbox (commit + PR with
[decom]tag in the title) - Then, manually delete via Migadu admin UI : Mailboxes → click → Delete
- Migadu retains the mailbox content for ~30 days post-delete (recovery window per their TOS) ; full purge requires a support request
- Document the decommission date + reason in the PR description
Why not auto-delete : production-mail content is durable — accidental delete from a YAML diff would be irreversible within the 30-day window. Manual confirmation required.
§7 — Backup / export¶
Migadu does NOT auto-backup mailbox content (only their infra-level redundancy). Operator-driven backups :
# Per-mailbox IMAP snapshot via imapsync (open source, available on brew/apt)
imapsync --host1 imap.migadu.com --user1 admin@cvntrade.eu --password1 "$ADMIN_PASSWORD" \
--host2 imap.example.local --user2 backup@local --password2 "$BACKUP_PASSWORD"
Recommended cadence : monthly snapshots for admin@ + trade@ (high-value), quarterly for dev@ (ephemeral notifications).
For audit-grade retention (legal hold), consider Migadu's "Black hole" feature OR a dedicated backup service like MXVault (CHF, EU).
§8 — Migration path (if Migadu doesn't scale)¶
If the team grows past ~20 mailboxes OR needs SSO / webhooks / ActiveSync, migrate to Bluemind (🇫🇷 Toulouse) or Infomaniak kSuite Business Manager (🇨🇭 Genève). Migration steps :
- Provision the target provider in parallel (different MX, no overlap)
- Use
imapsyncto copy each mailbox content from Migadu to target - Lower Migadu MX TTL to 300s ~24h before cutover
- Cutover : update MX records at Scaleway → target provider (5-15 min propagation)
- Keep Migadu running for ~7 days as fallback (catch in-flight mails) → final shutdown
Because the YAML in documentation/email/ is provider-agnostic, only the script's adapter changes — the declared mailbox set stays intact. See scripts/migadu_provision.py MigaduClient class for the integration shape to mirror in a BluemindClient / InfomaniakClient.
§9 — Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
dig MX cvntrade.eu returns empty |
DNS records not added OR Scaleway propagation delay (up to 24h, usually <15 min) | Wait + re-check ; if still empty after 30 min, verify records in Scaleway console |
| Migadu admin shows "Verification failed" on a record | Record value typo OR TTL too high (cached negative) | Re-check the value char-by-char ; Scaleway may take 5-10 min to refresh |
Mail to <box>@cvntrade.eu bounces with "no MX" |
MX records missing | Add records 2 + 3 from §2 table |
| Mail bounces with "SPF fail" | SPF record missing OR includes wrong host | Add record 4 from §2 ; ensure no other v=spf1 TXT present (only ONE allowed) |
make email-sync raises MigaduError 401 |
API token expired or wrong | Regenerate token at Migadu admin → API Keys → update .env |
make email-sync raises MigaduError 422 on POST mailbox |
Invalid local_part (chars not in [a-z0-9._-]) OR password too weak |
Check YAML local_part, generated password is 32 chars URL-safe (always strong) — likely local_part issue |
| Sync prints "skipped" for a mailbox you expect to be created | Already exists in Migadu (operator must have created via UI) | Confirm via python scripts/migadu_provision.py status ; if duplicate, decom via §6 |
§10 — Security notes¶
- API token is read/write — anyone with it can create/delete mailboxes + reset passwords. Treat as a production secret. Rotate quarterly (Migadu admin → API Keys → Revoke + Regenerate).
- Bootstrap passwords are printed to stderr once (not stdout, not the project logger) — they bypass the configured
logger.infoso log aggregators don't capture them. The operator copies + shares via secure channel + rotates immediately. See §3 "Capture pattern" for the local capture command + §3 "Residual CI risk" for the credential-leak playbook if the sync is ever invoked from CI by mistake. - Forwards to external addresses (e.g.,
forward_to: ceven@gmail.comin the YAML) leak metadata to the external provider. Only use for personal-pager scenarios where the trade-off is explicit. - DKIM rotation : Migadu rotates DKIM keys annually ; the CNAME records auto-resolve to the current key. No DNS update needed.
- SPF strict mode (
-all) : rejects spoofed senders. If you legitimately send@cvntrade.eufrom another service (transactional email, newsletter), addinclude:<service-spf>to the SPF record.