fix(mail): IMAP-acct invariant, test isolation, mail-poller healthcheck
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

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.
This commit is contained in:
kert
2026-05-21 08:58:18 -04:00
parent 3edde5aab9
commit c0493a0b8f
4 changed files with 115 additions and 29 deletions

View File

@@ -547,8 +547,21 @@ services:
command: >
sh -c 'while true; do
uv run --no-sync stack bib ingest-mail || true;
touch /tmp/heartbeat;
sleep $${MAIL_POLL_INTERVAL};
done'
# Override the inherited HEALTHCHECK from the api image (which
# probes http://localhost:8000/health — wrong for this loop-only
# container). Mark healthy when the poll loop has completed an
# iteration within the last 2 * MAIL_POLL_INTERVAL seconds.
healthcheck:
test:
- "CMD-SHELL"
- "test -f /tmp/heartbeat && test $$(( $$(date +%s) - $$(stat -c %Y /tmp/heartbeat) )) -lt $$(( $${MAIL_POLL_INTERVAL:-600} * 2 ))"
interval: 60s
timeout: 5s
retries: 3
start_period: 120s
labels:
- "promtail=true"
security_opt:

View File

@@ -421,6 +421,28 @@ def _addr_to_key(addr: str) -> str:
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``.
@@ -438,12 +460,13 @@ def rotate_creds(user_or_addr: str) -> str:
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 + the matching IMAP account.
# doesn't exist yet, create it.
r = _ssh(
meta["public_ip"],
ip,
"docker",
"exec",
"mail",
@@ -457,7 +480,7 @@ def rotate_creds(user_or_addr: str) -> str:
)
if r.returncode != 0:
_ssh(
meta["public_ip"],
ip,
"docker",
"exec",
"mail",
@@ -468,16 +491,10 @@ def rotate_creds(user_or_addr: str) -> str:
new_pw,
addr,
)
_ssh(
meta["public_ip"],
"docker",
"exec",
"mail",
"maddy",
"imap-acct",
"create",
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.
@@ -490,24 +507,26 @@ def rotate_creds(user_or_addr: str) -> str:
def seed_mailboxes(addrs: tuple[str, ...] = DEFAULT_MAILBOXES) -> None:
"""Ensure each address exists on droplet with cache-matching password.
"""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.
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:
# `creds password` updates an existing account; falls
# through to create+imap-acct for first-time provision.
r = _ssh(
ip,
"docker",
@@ -522,6 +541,7 @@ def seed_mailboxes(addrs: tuple[str, ...] = DEFAULT_MAILBOXES) -> None:
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(
@@ -536,10 +556,11 @@ def seed_mailboxes(addrs: tuple[str, ...] = DEFAULT_MAILBOXES) -> None:
pw,
addr,
)
_ssh(ip, "docker", "exec", "mail", "maddy", "imap-acct", "create", 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:

23
tests/mail/conftest.py Normal file
View File

@@ -0,0 +1,23 @@
"""Isolate every test in tests/mail/ from real mail state on disk.
Without this, a test that exercises real code paths (e.g. `down()` with only
the DigitalOcean client mocked) will run `unlink(missing_ok=True)` on the
production `CREDS_JSON` and `DROPLET_JSON`. The pre-commit pytest hook
silently destroyed live mail state this way in May 2026.
"""
from __future__ import annotations
import pytest
@pytest.fixture(autouse=True)
def _isolate_mail_state(tmp_path, monkeypatch):
state_dir = tmp_path / "mail"
state_dir.mkdir()
git_dir = tmp_path / "git"
git_dir.mkdir()
monkeypatch.setattr("mail.droplet.STATE_DIR", state_dir)
monkeypatch.setattr("mail.droplet.CREDS_JSON", state_dir / "credentials.json")
monkeypatch.setattr("mail.droplet.DROPLET_JSON", state_dir / "droplet.json")
monkeypatch.setattr("mail.droplet.GIT_MAILER_ENV", git_dir / "mailer.env")

View File

@@ -240,10 +240,31 @@ class TestSeedMailboxes:
def test_cached_sync(self, mc_save, mc_ssh, mc_load, mc_discover, mc_client):
mc_discover.return_value = {"public_ip": "1.2.3.4"}
mc_load.return_value = {"postmaster": "pw1"}
mc_ssh.return_value = MagicMock(returncode=0)
# stdout="" so _maddy_imap_accts returns empty set; addr is then
# ensured (one extra ssh call for imap-acct create).
mc_ssh.return_value = MagicMock(returncode=0, stdout="")
seed_mailboxes(addrs=(f"postmaster@{DOMAIN}",))
assert mc_ssh.call_count == 1
# imap-acct list + creds password + imap-acct create
assert mc_ssh.call_count == 3
@patch("mail.droplet._do_client")
@patch("mail.droplet.discover_droplet")
@patch("mail.droplet._load_json")
@patch("mail.droplet._ssh")
@patch("mail.droplet._save_json")
def test_cached_sync_imap_present(
self, mc_save, mc_ssh, mc_load, mc_discover, mc_client
):
"""When IMAP acct already exists, no create-call is issued."""
mc_discover.return_value = {"public_ip": "1.2.3.4"}
mc_load.return_value = {"postmaster": "pw1"}
addr = f"postmaster@{DOMAIN}"
mc_ssh.return_value = MagicMock(returncode=0, stdout=f"{addr}\n")
seed_mailboxes(addrs=(addr,))
# imap-acct list + creds password — no create needed
assert mc_ssh.call_count == 2
@patch("mail.droplet._do_client")
@patch("mail.droplet.discover_droplet")
@@ -255,17 +276,20 @@ class TestSeedMailboxes:
):
mc_discover.return_value = {"public_ip": "1.2.3.4"}
mc_load.return_value = {"postmaster": "pw1"}
mc_ssh.return_value = MagicMock(returncode=1)
mc_ssh.return_value = MagicMock(returncode=1, stdout="")
seed_mailboxes(addrs=(f"postmaster@{DOMAIN}",))
assert mc_ssh.call_count == 3
# imap-acct list + creds password (fail) + creds create + imap-acct create
assert mc_ssh.call_count == 4
@patch("mail.droplet._do_client")
@patch("mail.droplet.discover_droplet")
@patch("mail.droplet._load_json", return_value={})
@patch("mail.droplet._ssh")
@patch("mail.droplet.rotate_creds")
def test_no_cache_rotates(self, mc_rotate, mc_load, mc_discover, mc_client):
def test_no_cache_rotates(self, mc_rotate, mc_ssh, mc_load, mc_discover, mc_client):
mc_discover.return_value = {"public_ip": "1.2.3.4"}
mc_ssh.return_value = MagicMock(returncode=0, stdout="")
seed_mailboxes(addrs=(f"postmaster@{DOMAIN}",))
mc_rotate.assert_called_once()
@@ -347,10 +371,12 @@ class TestRotateCredsBody:
self, mc_chmod, mc_save, mc_load, mc_ssh, mc_discover, mc_client
):
mc_discover.return_value = {"public_ip": "1.2.3.4"}
mc_ssh.return_value = MagicMock(returncode=0)
# creds password ok + imap-acct list (empty stdout → set()) +
# imap-acct create.
mc_ssh.return_value = MagicMock(returncode=0, stdout="")
pw = rotate_creds("git")
assert len(pw) == 24
assert mc_ssh.call_count == 1
assert mc_ssh.call_count == 3
@patch("mail.droplet._do_client")
@patch("mail.droplet.discover_droplet")
@@ -362,14 +388,17 @@ class TestRotateCredsBody:
self, mc_chmod, mc_save, mc_load, mc_ssh, mc_discover, mc_client
):
mc_discover.return_value = {"public_ip": "1.2.3.4"}
# creds password (fail) + creds create + imap-acct list (empty)
# + imap-acct create.
mc_ssh.side_effect = [
MagicMock(returncode=1),
MagicMock(returncode=0),
MagicMock(returncode=0),
MagicMock(returncode=1, stdout=""),
MagicMock(returncode=0, stdout=""),
MagicMock(returncode=0, stdout=""),
MagicMock(returncode=0, stdout=""),
]
pw = rotate_creds("user@sub.example.com")
assert len(pw) == 24
assert mc_ssh.call_count == 3
assert mc_ssh.call_count == 4
class TestProvision: