Promote to main: M5 (Mail lifecycle + incremental sync) #235

Merged
KofTwentyTwo merged 5 commits from dev into main 2026-07-30 03:00:46 +00:00
5 Commits
Author SHA1 Message Date
james.maesandJames Maes c8446a5800 docs: mark M5 complete, set M6 as next (#234)
* docs: mark M5 complete (feature surface done), set M6 as next

* docs: correct HANDOFF service count to eight

---------

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 21:51:43 -05:00
james.maesandJames Maes 4b73d11754 feat(mail): select an explicit account for SendMessage (#232)
SendMessage previously always sent from the first configured account
with no way to choose, so it silently used the wrong identity the
moment a second account existed. Add an optional account_id field to
SendMessageRequest; when set, resolve that specific account and fail
with a new AccountNotFound error if it does not exist, rather than
falling back to a different account. Omitting account_id preserves
the prior first-account behavior. Thread the selector through
ComposeRequest, resolve_sending_account, the CLI's mail send --account
flag, and regenerate the nuncio.v1 descriptor golden.

Refs #196, #170

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 21:45:16 -05:00
james.maesandJames Maes d7dce525e2 feat(sync): incremental IMAP sync with progress + per-item FETCH timeout (#231)
* feat(store): add per-folder sync-state checkpoint table + CRUD

Adds folder_sync_state(account_id, folder_id, state) with
get/save methods and a round-trip test, giving inbound sync a place
to persist a per-mailbox resumption token between runs.

Refs #195 #184

* feat(mail): honor since_state for incremental IMAP fetch + bound FETCH stalls

ImapEngine now derives a real UIDNEXT-based checkpoint after selecting a
folder and narrows the UID FETCH to {uidnext}:* when a checkpoint is
supplied, instead of always re-fetching 1:* and returning a state
fabricated from emails.len(). An unparseable checkpoint falls back to a
full fetch but is surfaced distinctly. Each per-item FETCH read is bounded
by a named timeout, surfacing MailError::FetchStalled instead of hanging.
MockMailBackend captures the since_state arg and simulates an incremental
(empty) second fetch. Session-level duplex tests assert the narrowed FETCH
range and the bounded stall.

Refs #195 #184 #183

* feat(sync): persist and thread the folder checkpoint, publish sync progress

fetch_and_persist now reads the folder's stored checkpoint, passes it to
the backend for a narrowed fetch, and persists the returned checkpoint only
after that folder's messages land -- so the second sync of an account/folder
is genuinely incremental. Adds CoreEvent::SyncProgress (per folder) as an
internal signal; map_core_event drops it from the gRPC stream since it has
no wire-contract case (proto unchanged).

Refs #195 #184

* fix(mail): track UIDVALIDITY in sync checkpoint to prevent mail loss

A checkpoint stored only UIDNEXT, so a mailbox renumber (UIDVALIDITY
change, UIDs restart low) would drive {old_uidnext}:* against the new low
UIDs -- per RFC 3501 n:* returns only the single highest message and
silently skips the rest, permanently dropping mail. The checkpoint now
encodes {uidvalidity}:{uidnext}; the incremental narrowing applies only
when the stored UIDVALIDITY still matches the mailbox's current one. A
mismatch (renumber), a missing current UIDVALIDITY, or a legacy
bare-number/unparseable checkpoint falls back to a safe full 1:* re-fetch
with a debug-visible warn, then rewrites the checkpoint fresh.

Refs #195 #184

---------

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 21:33:55 -05:00
james.maesandJames Maes 0b8307a268 M5: Account lifecycle over gRPC (edit/delete/test) + persist TLS mode (#230)
* feat(store): persist per-account IMAP/SMTP TLS modes; add account CRUD

Adds a backfill-safe imap_tls_mode/smtp_tls_mode column migration,
binds+reads both modes in save_account/list_accounts (previously
validated then silently discarded and hardcoded back to ImplicitTls),
and adds get_account/delete_account. Regression test proves the modes
survive a save/list/get round-trip.

Refs #194 #182

* feat(mail): branch IMAP/SMTP transport on TlsMode; add connection probes

Threads TlsMode through ImapEngine/ImapDualSocketManager and
SmtpTransportEngine so implicit TLS, STARTTLS, and plain are genuine
transport paths (a unified MailStream plus a real IMAP STARTTLS
negotiation), not validate-and-discard. Adds bounded connect timeout
and per-engine probe_connection backing TestAccountConnection, with
mock/loopback-only setup tests for each mode.

Refs #194 #182

* feat(accounts): edit/delete/test account lifecycle over gRPC

Implements Accounts UpdateAccount/RemoveAccount/TestAccountConnection in
the daemon (update mirrors add_account's keyring rollback discipline;
test probes real IMAP+SMTP via an injectable tester, honoring persisted
TLS modes), wires the configured TLS modes into build_mail_backend/
build_smtp_sender, and replaces the fabricated CLI account
show/edit/delete/test handlers with real thin gRPC clients. Adds an
offline full-daemon lifecycle E2E and unauth-rejection coverage for all
three new RPCs.

Refs #194 #182

* fix(accounts): restore prior credential on update rollback

A password rotation that fails to persist previously deleted the
just-written secret, destroying the working credential the key already
held and orphaning the still-present account row. Snapshot the prior
secret first and restore it on save failure (falling back to delete
only when none existed). Regression tests force save_account to fail
and assert the original secret survives.

Refs #194 #182

* docs(accounts): scope TestAccountConnection SMTP claim to reachability

The SMTP leg only dials and negotiates TLS/STARTTLS; it performs no AUTH
(lettre exposes no clean auth-only probe on AsyncSmtpTransport), while
IMAP genuinely logs in. Clarify the proto doc for smtp_ok/smtp_error and
the CLI output wording so nothing implies SMTP authentication was
verified. Doc-only proto change; golden unchanged.

Refs #194 #182

---------

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 20:57:55 -05:00
james.maesandJames Maes 43b95e7614 fix(accounts): roll back orphaned keyring secret on add_account failure (#229)
If set_secret succeeds but the subsequent save_account fails, the
credential was left in the keyring with no account row referencing it.
add_account now deletes the just-written secret before returning,
appending any rollback-delete failure to the original error message
without masking it.

Refs #197, #169

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 20:12:17 -05:00