Files
stack/src/mail/droplet.py
kert c0493a0b8f
Some checks failed
Deploy / docs (push) Has been skipped
Infra CI / notebooks (push) Failing after 16s
Infra CI / api (push) Successful in 32s
Deploy / report (push) Successful in 14s
CI / lint (push) Successful in 28s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / zotero (push) Successful in 14s
Infra CI / docs (push) Successful in 15s
Infra CI / mc (push) Successful in 12s
CI / test (push) Successful in 12m27s
Harden / build-scan-report (push) Failing after 42s
Renovate / renovate (push) Successful in 16s
Package Supply Chain / pkg-supply-chain (push) Failing after 49s
fix(mail): IMAP-acct invariant, test isolation, mail-poller healthcheck
Three pre-existing bugs surfaced by the postmaster@mail.fhirworx.io
DMARC setup; all fixed together because they interact.

1. `seed_mailboxes` and `rotate_creds` only created the IMAP mailbox
   when the SMTP-creds `password` path failed (i.e. first-time
   provision). If creds existed but imap-acct didn't — observed for
   postmaster@mail.fhirworx.io after a partial earlier run — auth
   would succeed and delivered mail would have nowhere to land,
   silently dropping e.g. DMARC aggregate reports.

   Both functions now list `maddy imap-acct` once per droplet visit
   and ensure each address has its IMAP store, independently of the
   SMTP store. Added `_maddy_imap_accts` + `_ensure_imap_acct`
   helpers.

2. `tests/mail/test_droplet_exercise.py::TestDown::test_noop_when_none`
   called real `down(confirm=True)` while mocking only `_do_client`.
   `down()` then ran `(DROPLET_JSON, CREDS_JSON).unlink(missing_ok=
   True)` on the live state files. The pre-commit pytest hook
   destroyed production credentials.json + droplet.json this way.

   Added `tests/mail/conftest.py` with an autouse fixture that
   monkeypatches STATE_DIR / CREDS_JSON / DROPLET_JSON / GIT_MAILER_ENV
   to a tmp_path for every test in `tests/mail/`. Belt-and-suspenders:
   future tests in this dir can't reach the real filesystem even if
   they forget to patch.

3. `mail-poller` inherited a `HEALTHCHECK` from the `api` image
   probing `http://localhost:8000/health`, but the container runs
   only a polling loop with no HTTP server, so it stayed
   permanently `unhealthy`.

   Compose now overrides the inherited healthcheck. The poll loop
   touches `/tmp/heartbeat` after each iteration; the healthcheck
   verifies it's been touched within `2 * MAIL_POLL_INTERVAL`
   seconds. `start_period: 120s` covers the first cold poll.

Existing seed/rotate tests updated to expect the extra ssh calls
introduced by the imap-acct check; added one new test covering the
"imap-acct already present" path.
2026-05-21 08:58:18 -04:00

641 lines
21 KiB
Python

"""DigitalOcean droplet lifecycle for the Maddy mail server.
All commands are touchless / stateless / idempotent:
* **Touchless** — droplet name = FQDN so DO auto-sets the PTR record;
DNS through CF API; SSH uses account-registered keys; no panel.
* **Stateless** — DO is the source of truth for droplet metadata
(queried by tag ``stack-mail`` on every command). Resend is the
source of truth for domain verification + DKIM. Cloudflare is the
source of truth for DNS. ``.state/mail/`` is a cache only.
* **Idempotent** — ``up`` adopts an existing droplet, never duplicates.
DNS records are upserted (no error on existing). The smarthost block
is fenced and replaced in place. Mailbox passwords use ``maddy creds
password`` first, then fall through to ``create`` + ``imap-acct create``
for first-time provision.
"""
from __future__ import annotations
import json
import secrets
import string
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from mail._helpers import (
DOMAIN,
DROPLET_SIZE,
HOSTNAME,
IMAGE,
ROOT,
STATE_DIR,
TAG,
env,
ok,
step,
warn,
)
DROPLET_JSON = STATE_DIR / "droplet.json"
CREDS_JSON = STATE_DIR / "credentials.json"
GIT_MAILER_ENV = ROOT / ".state" / "git" / "mailer.env"
# ── Small utilities ─────────────────────────────────────────────
def _save_json(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2) + "\n")
tmp.rename(path)
def _load_json(path: Path) -> dict:
return json.loads(path.read_text()) if path.exists() else {}
def _gen_password(n: int = 24) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(n))
def _do_client():
import logging
import pydo
logging.getLogger("azure").setLevel(logging.WARNING)
token = env("DIGITAL_OCEAN_PAT")
if not token:
raise RuntimeError("DIGITAL_OCEAN_PAT not set in stack/.env")
return pydo.Client(token=token)
def _get_ssh_key_ids(client) -> list[int]:
return [k["id"] for k in client.ssh_keys.list().get("ssh_keys", [])]
def discover_droplet(client) -> dict | None:
"""Find the mail droplet by tag. Authoritative source of truth."""
for d in client.droplets.list(tag_name=TAG).get("droplets") or []:
public_ip = next(
(
n["ip_address"]
for n in d.get("networks", {}).get("v4", [])
if n["type"] == "public"
),
None,
)
return {
"id": d["id"],
"name": d["name"],
"region": (
d["region"]["slug"]
if isinstance(d.get("region"), dict)
else d.get("region")
),
"hostname": d["name"],
"public_ip": public_ip,
"status": d.get("status"),
"created": d.get("created_at"),
}
return None
def _wait_for_active(client, droplet_id: int, *, timeout: int = 300) -> str:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
d = client.droplets.get(droplet_id)["droplet"]
if d["status"] == "active":
for n in d["networks"]["v4"]:
if n["type"] == "public":
return n["ip_address"]
time.sleep(5)
raise RuntimeError(f"droplet {droplet_id} never went active")
def _ssh_args() -> list[str]:
"""Use the user's default SSH identity. ``STACK_SSH_KEY`` overrides."""
override = env("STACK_SSH_KEY")
return ["-i", override] if override else []
def _ssh(
public_ip: str, *cmd: str, check: bool = True, input_: str | None = None
) -> subprocess.CompletedProcess:
args = [
"ssh",
*_ssh_args(),
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"ConnectTimeout=15",
f"root@{public_ip}",
*cmd,
]
return subprocess.run(
args, check=check, capture_output=True, text=True, input=input_
)
# ── Cloud-init builder ──────────────────────────────────────────
def _cloud_init() -> str:
"""Build the user_data script. Mailbox passwords are NOT included —
cloud-init only stands up Maddy + cert; mailboxes get seeded later
over SSH via `seed_mailboxes`. This keeps secrets off the DO
metadata service, which is queryable from inside the droplet by
any process with HTTP access."""
script = (ROOT / "infra" / "droplets" / "mail-setup.sh").read_text()
tpl = (ROOT / "infra" / "mail" / "maddy.conf.tpl").read_text()
script = script.replace("__MADDY_CONF_TPL_PLACEHOLDER__", tpl)
cf_token = env("CF_API_TOKEN") or env("CLOUDFLARE_API_TOKEN")
if not cf_token:
raise RuntimeError(
"CF_API_TOKEN not set — required for DNS-01 cert "
"issuance inside cloud-init."
)
# certbot-dns-cloudflare credentials.ini expects the var named
# CLOUDFLARE_API_TOKEN regardless of which side our env had it.
return f"""#!/bin/bash
set -euo pipefail
export HOSTNAME={HOSTNAME!r}
export PRIMARY_DOMAIN={DOMAIN!r}
export CLOUDFLARE_API_TOKEN={cf_token!r}
{script}
"""
# ── Lifecycle commands ─────────────────────────────────────────
def up(region: str = "nyc3") -> dict:
"""Adopt-or-create the mail droplet. Idempotent."""
step("Mail droplet up")
client = _do_client()
existing = discover_droplet(client)
if existing:
ok(f"adopting droplet {existing['id']} at {existing['public_ip']}")
_save_json(DROPLET_JSON, existing)
return existing
ssh_key_ids = _get_ssh_key_ids(client)
if not ssh_key_ids:
raise RuntimeError(
"No SSH keys on the DO account — register one at "
"https://cloud.digitalocean.com/account/security so this and "
"future machines can manage the droplet."
)
step(f"Creating droplet {HOSTNAME} in {region}")
resp = client.droplets.create(
body={
"name": HOSTNAME, # name=FQDN → DO auto-PTR
"region": region,
"size": DROPLET_SIZE,
"image": IMAGE,
"user_data": _cloud_init(),
"tags": [TAG],
"ssh_keys": ssh_key_ids,
}
)
droplet_id = resp["droplet"]["id"]
public_ip = _wait_for_active(client, droplet_id)
info = {
"id": droplet_id,
"name": HOSTNAME,
"region": region,
"hostname": HOSTNAME,
"public_ip": public_ip,
"created": datetime.now(timezone.utc).isoformat(),
}
STATE_DIR.mkdir(parents=True, exist_ok=True)
_save_json(DROPLET_JSON, info)
ok(f"droplet live at {public_ip}")
return info
def down(*, confirm: bool = False) -> None:
"""Destroy the droplet. Idempotent no-op if none exists."""
client = _do_client()
existing = discover_droplet(client)
if not existing:
ok("no mail droplet exists — nothing to do")
for f in (DROPLET_JSON, CREDS_JSON):
f.unlink(missing_ok=True)
return
if not confirm:
raise RuntimeError(f"refusing to destroy {existing['name']} without --yes")
try:
client.droplets.destroy(existing["id"])
ok(f"destroyed droplet {existing['id']}")
except Exception as e: # noqa: BLE001
warn(f"destroy failed (maybe already gone): {e}")
for f in (DROPLET_JSON, CREDS_JSON):
f.unlink(missing_ok=True)
def status() -> dict | None:
"""Return droplet state from DO + PTR check."""
import socket
client = _do_client()
meta = discover_droplet(client)
if not meta:
return None
_save_json(DROPLET_JSON, meta)
try:
meta["ptr"] = socket.gethostbyaddr(meta["public_ip"])[0]
meta["ptr_match"] = meta["ptr"] == meta["hostname"]
except (socket.herror, socket.gaierror):
meta["ptr"] = None
meta["ptr_match"] = False
return meta
# ── DNS + smarthost orchestration ──────────────────────────────
def apply_dns(claim_apex: bool = False) -> None:
"""Upsert mail DNS in Cloudflare. Idempotent.
Apex MX/SPF/DMARC require ``claim_apex=True`` — default skips
them so we don't trample an existing provider (Proton, Fastmail,
Google Workspace) at the bare domain.
"""
import httpx
from mail.cloudflare import ensure_mail_dns
meta = discover_droplet(_do_client())
if not meta:
raise RuntimeError("no mail droplet exists")
with httpx.Client(timeout=30) as client:
ensure_mail_dns(
client,
hostname=meta["hostname"],
public_ip=meta["public_ip"],
domain=DOMAIN,
claim_apex=claim_apex,
)
def export_dkim() -> None:
"""Read maddy's generated DKIM key and publish to Cloudflare. Idempotent."""
import httpx
from mail.cloudflare import ensure_dkim_record
meta = discover_droplet(_do_client())
if not meta:
raise RuntimeError("no mail droplet exists")
r = _ssh(
meta["public_ip"],
f"cat /srv/mail/data/dkim_keys/{DOMAIN}_default.dns",
check=False,
)
if r.returncode != 0 or not r.stdout.strip():
raise RuntimeError(
f"could not read DKIM key: {r.stderr.strip()}\n"
"Cloud-init likely still running; wait a minute and retry."
)
with httpx.Client(timeout=30) as client:
ensure_dkim_record(client, domain=DOMAIN, rdata=r.stdout.strip())
def attach_smarthost() -> None:
"""Register at Postmark, push DNS, install smarthost block on droplet.
Postmark (not Resend) because Resend's free tier caps at 1 domain
and corwins.media already used it. Postmark allows multiple
domains free, transactional-only, has an account+server token
model that mints us a per-Server SMTP credential.
"""
import httpx
from mail.postmark import (
ensure_postmark_domain,
ensure_postmark_server,
publish_postmark_dns,
verify_postmark_domain,
)
meta = discover_droplet(_do_client())
if not meta:
raise RuntimeError("no mail droplet exists — `stack mail provision` first")
ip = meta["public_ip"]
step("Provisioning Postmark Server + Domain")
server_token = ensure_postmark_server("fhirworx")
domain_info = ensure_postmark_domain(DOMAIN)
with httpx.Client(timeout=30) as client:
publish_postmark_dns(domain_info, client)
# Trigger verification — Postmark won't auto-verify after Domain creation.
verify_postmark_domain(domain_info["ID"])
# Postmark SMTP: 25 / 587 (STARTTLS) / 2525 (also STARTTLS, alt
# port for hosts that block 587). DO blocks 25/465/587 silently;
# 2525 is the universally-open one so we use it. Maddy's `tcp://`
# scheme means connect plaintext + negotiate STARTTLS — exactly
# what Postmark's 2525 speaks.
block = (
"# SMARTHOST-BEGIN managed by `stack mail` — do not edit by hand.\n"
"target.smtp outbound_smarthost {\n"
" targets tcp://smtp.postmarkapp.com:2525\n"
f' auth plain "{server_token}" "{server_token}"\n'
"}\n"
"# SMARTHOST-END\n"
)
step("Installing smarthost block on droplet")
# Maddy errors out if `outbound_smarthost` is defined twice, so the
# patch always strips:
# 1. The fenced SMARTHOST-BEGIN/END region (prior install)
# 2. Any unfenced `target.smtp outbound_smarthost { … }` (the
# stub from the base template, written by cloud-init)
# Then prepends the fresh fenced block. Idempotent + state-agnostic.
py_script = (
"import re, pathlib\n"
"BLOCK = " + repr(block) + "\n"
"CONF = pathlib.Path('/srv/mail/data/maddy.conf')\n"
"txt = CONF.read_text()\n"
"txt = re.sub(r'# SMARTHOST-BEGIN.*?# SMARTHOST-END\\n', '', txt, flags=re.DOTALL)\n"
"txt = re.sub(r'target\\.smtp outbound_smarthost \\{[^{}]*\\}\\n*', '', txt)\n"
"txt = BLOCK + txt\n"
"CONF.write_text(txt)\n"
)
# Pipe via stdin — ssh's argv-quoting otherwise mangles multi-line Python.
subprocess.run(
[
"ssh",
*_ssh_args(),
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
f"root@{ip}",
"python3 -",
],
input=py_script,
text=True,
check=True,
capture_output=True,
)
_ssh(ip, "docker", "restart", "mail")
ok("smarthost attached — outbound via smtp.postmarkapp.com:2525")
# ── Mailbox + Gitea wiring ─────────────────────────────────────
# Mailboxes the orchestrator always seeds. Full addresses so each
# can sit on whichever domain makes sense:
# apex (fhirworx.io) for outbound branding (Gitea From=)
# subdomain (mail.fhirworx.io) for inbound when apex MX is owned
# by another provider (e.g. Proton at the apex)
DEFAULT_MAILBOXES = (
f"postmaster@{DOMAIN}",
f"postmaster@{HOSTNAME}", # DMARC rua/ruf destination; apex MX is Proton
f"git@{DOMAIN}", # Gitea's outbound SMTP submission AS this user
f"cmsupdates@{HOSTNAME}", # CMS list signups — MX must reach our Maddy
)
def _addr_to_key(addr: str) -> str:
"""Cache-key for an address. ``user@apex`` → ``user`` (back-compat for
the older single-domain credentials.json layout); subdomain
addresses get the full address as the key so they're unambiguous."""
if addr.endswith(f"@{DOMAIN}"):
return addr.split("@", 1)[0]
return addr
def _maddy_imap_accts(ip: str) -> set[str]:
"""Set of existing IMAP account addresses on the droplet."""
r = _ssh(ip, "docker", "exec", "mail", "maddy", "imap-acct", "list")
return {line.strip() for line in r.stdout.splitlines() if line.strip()}
def _ensure_imap_acct(ip: str, addr: str, existing: set[str]) -> None:
"""Create the IMAP mailbox for ``addr`` if it doesn't already exist.
Maddy keeps SMTP credentials (creds) and IMAP storage (imap-acct)
in separate stores. Having one without the other means auth
succeeds but delivered mail has nowhere to land — which silently
discards e.g. DMARC aggregate reports. Always pair them.
``existing`` is mutated so callers can list once and reuse the set.
"""
if addr in existing:
return
_ssh(ip, "docker", "exec", "mail", "maddy", "imap-acct", "create", addr)
existing.add(addr)
def rotate_creds(user_or_addr: str) -> str:
"""Mint new password and push to droplet. ``user_or_addr`` may be
``user`` (assumed at apex) or full ``user@host``.
maddy subcommands:
creds password → change password on existing account
creds create → create new account (also needs imap-acct create)
Earlier versions of this function called ``creds set``, which doesn't
exist in maddy — it silently failed and fell through to ``creds
create`` which then errored on the existing account. Net result:
local credentials.json got the new password but the droplet kept
the old one. The poller then loops forever on Invalid credentials.
"""
addr = user_or_addr if "@" in user_or_addr else f"{user_or_addr}@{DOMAIN}"
meta = discover_droplet(_do_client())
if not meta:
raise RuntimeError("no mail droplet exists")
ip = meta["public_ip"]
new_pw = _gen_password()
# Try password change first (account exists). If the account
# doesn't exist yet, create it.
r = _ssh(
ip,
"docker",
"exec",
"mail",
"maddy",
"creds",
"password",
"--password",
new_pw,
addr,
check=False,
)
if r.returncode != 0:
_ssh(
ip,
"docker",
"exec",
"mail",
"maddy",
"creds",
"create",
"--password",
new_pw,
addr,
)
# IMAP mailbox may be missing even when SMTP creds exist (saw this
# in production with postmaster@mail.fhirworx.io after a partial
# earlier provision). Ensure it independently.
_ensure_imap_acct(ip, addr, _maddy_imap_accts(ip))
creds = _load_json(CREDS_JSON)
# Save under both forms so lookup works whether the caller passes
# the bare key or the full address.
creds[_addr_to_key(addr)] = new_pw
creds[addr] = new_pw
_save_json(CREDS_JSON, creds)
CREDS_JSON.chmod(0o600)
ok(f"rotated {addr}")
return new_pw
def seed_mailboxes(addrs: tuple[str, ...] = DEFAULT_MAILBOXES) -> None:
"""Ensure each address exists on droplet with cache-matching password
AND has a matching IMAP mailbox.
Idempotent: when an address has a cached password, push it to the
droplet (no-op if matches, replace if differs, create if missing).
When no cached password exists, mint and push a fresh one. Either
way, the IMAP mailbox is verified to exist (independently of the
SMTP creds store — they can drift).
"""
meta = discover_droplet(_do_client())
if not meta:
raise RuntimeError("no mail droplet exists")
ip = meta["public_ip"]
creds = _load_json(CREDS_JSON)
imap_accts = _maddy_imap_accts(ip)
for addr in addrs:
key = _addr_to_key(addr)
pw = creds.get(key)
if pw:
r = _ssh(
ip,
"docker",
"exec",
"mail",
"maddy",
"creds",
"password",
"--password",
pw,
addr,
check=False,
)
if r.returncode == 0:
_ensure_imap_acct(ip, addr, imap_accts)
ok(f"mailbox {addr}: cached password in sync with droplet")
continue
_ssh(
ip,
"docker",
"exec",
"mail",
"maddy",
"creds",
"create",
"--password",
pw,
addr,
)
_ensure_imap_acct(ip, addr, imap_accts)
ok(f"mailbox {addr}: created on droplet from cached password")
else:
rotate_creds(addr)
imap_accts.add(addr)
def write_git_mailer_env() -> bool:
"""Write the Gitea SMTP env file. Returns True if content changed.
Loaded by the ``git`` service in compose.yml as an optional
``env_file``. Once changed, recreate the container with
``docker compose up -d git`` to apply (a plain restart doesn't
re-read compose).
"""
if not CREDS_JSON.exists() or not DROPLET_JSON.exists():
warn("git mailer: mail droplet not provisioned — skipping")
return False
creds = _load_json(CREDS_JSON)
droplet = _load_json(DROPLET_JSON)
if "git" not in creds:
warn("git mailer: no `git` mailbox creds — `stack mail rotate-creds git`")
return False
hostname = droplet.get("hostname") or HOSTNAME
desired = (
"GITEA__mailer__ENABLED=true\n"
"GITEA__mailer__PROTOCOL=smtps\n"
f"GITEA__mailer__SMTP_ADDR={hostname}\n"
"GITEA__mailer__SMTP_PORT=465\n"
f"GITEA__mailer__USER=git@{DOMAIN}\n"
f"GITEA__mailer__PASSWD={creds['git']}\n"
f"GITEA__mailer__FROM=git@{DOMAIN}\n"
)
GIT_MAILER_ENV.parent.mkdir(parents=True, exist_ok=True)
if GIT_MAILER_ENV.exists() and GIT_MAILER_ENV.read_text() == desired:
return False
tmp = GIT_MAILER_ENV.with_suffix(".tmp")
tmp.write_text(desired)
tmp.rename(GIT_MAILER_ENV)
return True
# ── End-to-end orchestrator ────────────────────────────────────
def provision(region: str = "nyc3", wait_seconds: int = 360) -> None:
"""End-to-end idempotent: up → wait → DNS → DKIM → seed → smarthost → git env."""
up(region=region)
meta = _load_json(DROPLET_JSON)
public_ip = meta["public_ip"]
step("Waiting for Maddy + DKIM keypair (cloud-init)")
deadline = time.monotonic() + wait_seconds
dkim_path = f"/srv/mail/data/dkim_keys/{DOMAIN}_default.dns"
while time.monotonic() < deadline:
r = _ssh(public_ip, f"test -f {dkim_path} && cat {dkim_path}", check=False)
if r.returncode == 0 and r.stdout.strip():
ok("DKIM key present on droplet")
break
time.sleep(15) # pragma: no cover — timing-dependent wait loop
else:
warn("DKIM key never appeared in time — `stack mail dkim-export` later")
apply_dns()
try:
export_dkim()
except Exception as e: # noqa: BLE001
warn(f"DKIM step skipped: {e}")
# Seed mailboxes BEFORE smarthost: the smarthost block reads
# `git`'s creds when it writes the env file later, and an early
# `seed_mailboxes` run also surfaces auth errors before we paint
# over the maddy config with the smarthost block.
seed_mailboxes()
attach_smarthost()
if write_git_mailer_env():
ok("git mailer env written → recreate gitea: `docker compose up -d git`")
else:
ok("git mailer env unchanged or skipped")
ok(f"mail droplet provisioned at {HOSTNAME} ({public_ip})")