Promote to main: M3 (JMAP) + M4 (Filters made live) #228

Merged
KofTwentyTwo merged 6 commits from dev into main 2026-07-30 01:09:32 +00:00
6 Commits
Author SHA1 Message Date
james.maesandJames Maes ed71f51b22 docs: mark M1-M4 complete, set M5 as next milestone (#227)
* docs: mark M1-M4 complete, set M5 as next milestone

Phase 3 feature completeness (Calendar, Contacts, JMAP, live Filters)
is done and merged to dev; update ROADMAP.md, the rendered roadmap
page, and HANDOFF.md to reflect it and point at M5 next.

* docs: correct service count to eight (Calendar+Contacts added)

---------

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 20:02:59 -05:00
james.maesandJames Maes 2e9c43a32c feat(filter): add streaming Filters.Triage RPC for retroactive rescan (#226)
Adds a server-streaming Triage RPC that walks the whole message store
in bounded keyset chunks, evaluates the live FilterEngine against each
message via the shared apply_filter_actions helper, and streams real
cumulative progress -- so the active rule set can be applied to mail
synced before those rules existed. Wires the CLI's first streaming-RPC
consumer (filter triage) and regenerates the proto contract golden.

Refs #193

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 19:54:30 -05:00
james.maesandJames Maes 6cb8ae5753 feat(filter): move edit/export/import/logs onto Filters gRPC service (#224)
Adds UpdateRule/ExportRules/ImportRules/GetExecutionLogs to the
authenticated Filters gRPC service and rewires the CLI's
filter edit/export/import/logs subcommands onto it, replacing their
throwaway ephemeral local-db reads with the daemon's real, persistent
store and live FilterEngine. ImportRules now reports per-line parse
failures instead of silently dropping them.

Regenerates the nuncio-proto contract-stability golden for the
additive proto changes.

Refs #192

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 19:37:19 -05:00
james.maesandJames Maes 2186670ca8 fix(filter): correct account-field evaluation and reject header conditions (#223)
The account condition field lumped in with folder and compared against
email.folder_id, so WHERE account = '...' would false-positive on any
message sharing a folder with the target account. Split the arm so it
reads email.account_id instead.

header[...] conditions silently evaluated to false forever because Email
has no parsed-headers field to match against. Reject header conditions
at validation time with an honest FieldTypeMismatch instead of accepting
a rule that can never fire; the runtime fallback is now documented as an
unreachable defensive branch.

Refs #191

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 19:19:37 -05:00
james.maesandJames Maes b43d7bbb5f M4: Wire FilterEngine into live sync so matched rules actually fire (#222)
* feat(filters): wire FilterEngine into live inbound sync

Every synced message is now evaluated against the daemon's live
FilterEngine after it is persisted. MARK READ/UNREAD apply immediately
via set_message_read; MOVE/COPY/FLAG/UNFLAG/DELETE/FORWARD/CALL WEBHOOK
enqueue a real PendingRemoteMutation through the existing outbox for
the background worker to drain. Every match appends a
FilterExecutionLog entry, so a rule match now produces a genuine,
persisted, observable side effect instead of nothing.

The evaluate-and-route logic lives in a standalone apply_filter_actions
helper (not inlined into fetch_and_persist) so other sync paths can
reuse the exact same routing instead of re-deriving it.

* fix(filters): fire filter actions exactly once per new message

Sync is not incremental yet, so every re-sync re-fetches and
re-persists messages the backend still reports. Applying filter
actions unconditionally on every persisted message meant a rule kept
re-firing on every sync cycle for a message that arrived once,
enqueueing a fresh outbox mutation and execution log entry each time.

Guard fetch_and_persist so it only routes a message through
apply_filter_actions when the message did not already exist in the
store before this sync pass (DatabaseError::is_not_found, checked
before save_email's INSERT OR REPLACE erases the distinction). Also
makes the immediate-action match in apply_filter_actions exhaustive
over the remote action variants instead of a wildcard, so a future
immediate-style action can't silently fall through to remote routing.

---------

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 19:11:37 -05:00
james.maesandJames Maes 12c8724644 feat(mail): wire a real JMAP client into the mail sync path (#221)
Replace JmapEngine's canned session/mailbox/message data with real
RFC 8620/8621 HTTP session discovery and Mailbox/get + Email/query +
Email/get JSON-RPC calls over reqwest, gated behind with_credentials
so uncredentialed callers keep the deterministic no-network fallback.
Fix the camelCase wire bug on JmapEmail (receivedAt/isUnread/
bodySnippet were silently defaulting) and add a regression test.
Wire nunciod::sync::build_mail_backend to construct the JMAP engine
with the account's real host/address/keyring password, mirroring the
IMAP arm, and rewrite the sync.rs tests that previously relied on
JmapEngine's canned data to run against wiremock servers instead.

Co-authored-by: James Maes <jmaes@dmdbrands.com>
2026-07-29 18:40:19 -05:00