Skip to content

feat: JMAP identity sync (host-configurable) with a Fastmail provider profile - #279

Open
salmonumbrella wants to merge 1 commit into
maathimself:mainfrom
salmonumbrella:feat/jmap-identity-sync
Open

feat: JMAP identity sync (host-configurable) with a Fastmail provider profile#279
salmonumbrella wants to merge 1 commit into
maathimself:mainfrom
salmonumbrella:feat/jmap-identity-sync

Conversation

@salmonumbrella

@salmonumbrella salmonumbrella commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Re-cut of #256 along the direction laid out there: host-configurable, capabilities optional, providers as profiles. A per-account, read-only JMAP identity sync answers one question — which addresses may this account send as — and stores the result in a private sendable_addresses table that is never rendered as a list anywhere (no settings pollution, no From-picker enumeration — the objections to the previous cut). On reply, when the delivered-to address is in that set, the From is set transiently for that one draft; at send time the server authorizes the explicit From against the same set. So "delivered to X" alone never grants "send as X": a mailing-list delivery resolves to nothing and replies from the primary, as today.

The JMAP client requires only urn:ietf:params:jmap:core + urn:ietf:params:jmap:submission (RFC 8620/8621 — Stalwart-compatible), takes its session URL from account config, and ignores capabilities it doesn't know (Masked Email included — split into a follow-up so this layer stays vendor-neutral). Fastmail is one provider profile: a settings preset (IMAP/SMTP hosts + JMAP session URL prefill) and an imapManager runtime profile. The scope is deliberately tiny — identity listing only, no JMAP mail transport — so it can fold into a future general JMAP layer.

Off unless a user configures a token; existing accounts and the existing send request shape are unchanged.

Changes

  • Migration 0036 (IF NOT EXISTS-guarded): sendable_addresses (private authorization set) + JMAP session URL / encrypted token / sync-status columns on email_accounts
  • jmapClient.js: session load + Identity/get with response-shape validation; the session-advertised apiUrl is pinned same-origin to the session URL so the Bearer token can never travel to another host
  • The user-configured session URL goes through the same SSRF guards as every other user-configured host: validateHost + safeFetch (per-hop IP validation), HTTPS required unless the host is genuinely private and the admin's connection policy allows private hosts — same rule as the CardDAV server URL
  • identitySync.js: read-only reconcile (add/update/remove by provider id), including *@domain catch-all identities; runs on save and via a rate-limited manual refresh endpoint; no background scheduler
  • send.js: optional fromEmail, mutually exclusive with aliasId (400 if both); authorized against the sendable set (exact, else wildcard cover) → 422 SENDER_UNAVAILABLE on a miss; the aliasId path is byte-for-byte untouched
  • GET /messages/:id/reply-sender: resolves the transient reply From from the delivered-to capture (one-shot re-sync when stale); returns a single sender or null — never a list
  • Compose: one pinned transient From option for that draft only; a 422 on send shows a toast and falls back to the account default
  • Settings: JMAP session URL + API token fields (token write-only, encrypted at rest like auth_pass, surfaced only as a "configured" flag; clearing it wipes every synced row — in the same save as any other edited fields), plus last-sync/error status and a refresh button
  • Fastmail provider profile: settings preset + imapManager runtime profile (fastmail.com / messagingengine.com)
  • i18n: all 7 locales

Testing

  • Backend: 726/726 (vitest), lint clean (--max-warnings 0) — session/capability validation incl. a Stalwart-style custom host, apiUrl origin pinning, SSRF host-policy cases (private host blocked by default, plaintext-HTTP-to-public blocked even with private hosts allowed), reconcile add/update/remove/wipe, sender authorization (exact / wildcard / miss / header-injection), token clear + field update in one save, token never present in any response or error
  • Frontend: 1381/1381 (node --test), lint clean, production build clean
  • No new dependencies

Runtime-tested against a live Fastmail account on a real mailbox: token setup and validation, identity sync, delivered-to reply-From selection, and sending. Stacked on #278 (its diff is included here so the branch stands alone); I'll rebase this down to just its own changes once #278 lands.


Contributor License Agreement

By submitting this pull request I confirm that:

  • I have read and agree to the Contributor License Agreement.
  • My contribution is my own original work (or I have identified any
    third-party material and confirmed it is compatible with the CLA).
  • I have the right to submit this contribution under the terms of the CLA.

Adds a read-only JMAP identity sync (Identity/get only, RFC 8620/8621) that
answers one question per account: which addresses is it allowed to send as.
Results live in a private sendable_addresses table that is never rendered as
a list anywhere in the UI. On reply, if the delivered-to address is in the
sendable set, the From is set transiently for that one draft only -- nothing
is auto-created or persisted. At send time the server re-authorizes any
explicit From against the same set.

This is the inverse of the previously-closed Fastmail-specific approach
(maathimself#256): the JMAP session URL is a per-account setting, not a hardcoded host;
only the standard core + submission capabilities are required, everything
else (Masked Email included) is ignored rather than demanded; and Fastmail is
one provider profile among others, prefilling IMAP/SMTP/JMAP settings the way
the existing google/apple/purelymail profiles already do. The session URL
goes through the same SSRF guards as every other user-configured host
(validateHost + safeFetch, HTTPS unless the admin policy admits a genuinely
private host).

Existing behavior is unchanged: the send request keeps its current
accountId/aliasId shape, with an optional fromEmail that is mutually
exclusive with aliasId and requires server-side authorization (422
SENDER_UNAVAILABLE on a miss). The token is encrypted at rest like auth_pass,
never returned to the client, and clearing it wipes every synced address.
Migration 0036 is IF NOT EXISTS-guarded, numbered after 0035.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant