fix(mail): rotate-creds wrong subcommand — silently broke for 35h

`mail.droplet.rotate_creds` and `seed_mailboxes` called
`maddy creds set --password X user@host`, but maddy has no `set`
subcommand — the actual one is `password`. Effect: the ssh exec
returned non-zero (Error: No help topic for 'set'); fall-through to
`creds create` errored on the existing account; local credentials.json
got the new password but the droplet kept the OLD one. The IMAP
poller then looped forever on Invalid credentials.

Discovered when investigating why mail-poller had been unhealthy for
35 hours straight (zero CMS emails ingested into Zotero).

- Use `creds password` for existing accounts; fall through to
  `creds create` + `imap-acct create` only when password fails.
- Save under both short cache key (back-compat) and full address.
- Same fix applied to seed_mailboxes' in-sync path.

Manual recovery already done out-of-band:
- maddy creds password applied directly on droplet
- credentials.json updated locally; mail-poller restarted
- 45 items now tagged source:email in bib.sqlite

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kert
2026-04-23 19:50:56 -04:00
parent 16d657a8e0
commit 643094522f

View File

@@ -421,12 +421,25 @@ def _addr_to_key(addr: str) -> str:
def rotate_creds(user_or_addr: str) -> str: def rotate_creds(user_or_addr: str) -> str:
"""Mint new password and push to droplet. ``user_or_addr`` may be """Mint new password and push to droplet. ``user_or_addr`` may be
``user`` (assumed at apex) or full ``user@host``.""" ``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}" addr = user_or_addr if "@" in user_or_addr else f"{user_or_addr}@{DOMAIN}"
meta = discover_droplet(_do_client()) meta = discover_droplet(_do_client())
if not meta: if not meta:
raise RuntimeError("no mail droplet exists") raise RuntimeError("no mail droplet exists")
new_pw = _gen_password() new_pw = _gen_password()
# Try password change first (account exists). If the account
# doesn't exist yet, create it + the matching IMAP account.
r = _ssh( r = _ssh(
meta["public_ip"], meta["public_ip"],
"docker", "docker",
@@ -434,7 +447,7 @@ def rotate_creds(user_or_addr: str) -> str:
"mail", "mail",
"maddy", "maddy",
"creds", "creds",
"set", "password",
"--password", "--password",
new_pw, new_pw,
addr, addr,
@@ -464,7 +477,10 @@ def rotate_creds(user_or_addr: str) -> str:
addr, addr,
) )
creds = _load_json(CREDS_JSON) 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_to_key(addr)] = new_pw
creds[addr] = new_pw
_save_json(CREDS_JSON, creds) _save_json(CREDS_JSON, creds)
CREDS_JSON.chmod(0o600) CREDS_JSON.chmod(0o600)
ok(f"rotated {addr}") ok(f"rotated {addr}")
@@ -488,6 +504,8 @@ def seed_mailboxes(addrs: tuple[str, ...] = DEFAULT_MAILBOXES) -> None:
key = _addr_to_key(addr) key = _addr_to_key(addr)
pw = creds.get(key) pw = creds.get(key)
if pw: if pw:
# `creds password` updates an existing account; falls
# through to create+imap-acct for first-time provision.
r = _ssh( r = _ssh(
ip, ip,
"docker", "docker",
@@ -495,7 +513,7 @@ def seed_mailboxes(addrs: tuple[str, ...] = DEFAULT_MAILBOXES) -> None:
"mail", "mail",
"maddy", "maddy",
"creds", "creds",
"set", "password",
"--password", "--password",
pw, pw,
addr, addr,