Adds a JMAP (RFC 8620 core, RFC 8621 mail, submission) endpoint so mail
apps such as Mailtemi, Twake Mail and aerc can read and send Mailflare
mail. Discovery at /.well-known/jmap, session at /jmap/session, API at
/jmap/api, blobs at /jmap/download and /jmap/upload, push at
/jmap/eventsource.
- Auth: an API key with the new `jmap` scope, as a Bearer token or as the
password of HTTP Basic auth. `authenticateApiKeyValue` is split out of
the Next-bound `src/lib/api/auth.ts` into a framework-free module and
refreshes `lastUsedAt` at most once a minute, since protocol clients make
many small calls.
- Model: the account is the user. Each Mailflare mailbox is a top-level
JMAP Mailbox with six system children keyed by status and one child per
user folder; ids encode `mailboxId`, `mailboxId~role` or
`mailboxId~f~folderId`. A message is in exactly one of them.
- Methods: Mailbox/get|query|set (folders only), Thread/get,
Email/get|query|set, SearchSnippet/get, Identity/get,
EmailSubmission/set, Core/echo. Every */changes answers
cannotCalculateChanges (messages have no change log), so clients
re-query when the state string moves; eventsource emits StateChange by
polling the state digest.
- Email/query maps FilterCondition and AND/OR/NOT trees onto message
columns, supports collapseThreads, anchors, position and calculateTotal.
Email/get builds objects from stored rows (no MIME parsing), with
bodyValues, bodyStructure, attachments as blob ids, and header:X forms
answered from stored headers.
- Email/set updates $seen/$flagged, moves via mailboxIds or
mailboxIds/<id> patches, creates drafts (with uploaded attachments), and
destroys to Trash first and permanently from Trash.
EmailSubmission/set sends a draft through the existing send path and
reports it destroyed via the implicit Email/set response, since the sent
copy is a new row. Result references (#ids with resultOf/name/path,
including the * form) and #creationIds work within a request.
- Settings > Account > Email apps mints a jmap-scoped key and shows the
server details once. docs/api.md and CLAUDE.md describe the mapping.
Verified with a 47-check script on local D1: discovery redirect, 401/403
paths, Basic and Bearer auth, capabilities, unknown method/capability
errors, mailbox tree and counts, folder create/rename/destroy and system
mailbox protection, query with collapseThreads and result references,
attachment/text/operator filters, bodies with truncation, blob download
of an attachment and of a raw message, Thread/get, snippets, keyword and
mailbox patches, ifInState, upload, Identity/get, a draft created and
submitted in one request with #d1, the sent copy carrying cc and the
attachment, two-step destroy, and eventsource. tsc (11) and eslint (105)
unchanged from main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The app collected a recovery email at first run and never read it, and had
no second factor. A lost password meant editing D1 by hand.
Password reset
- `POST /api/auth/password-reset/request` mails a single-use link (hashed
token, 30 minutes) to the account's `reset_email`. It answers 200 for any
well-formed input so the form cannot probe for accounts, and is behind the
login rate limiter and Turnstile.
- `POST /api/auth/password-reset/confirm` sets the password, burns the
token, revokes every session and writes an audit entry.
- Reset mail goes through `sendSystemEmail` straight to the send binding
from the first admin mailbox on a sending-enabled domain: no Sent copy,
no contact upsert, no webhooks, auto-generated headers.
- New /forgot-password and /reset-password pages; "Forgot password?" on the
login form.
Two-factor authentication
- RFC 6238 TOTP over Web Crypto (`src/lib/auth/totp.ts`, no dependency;
verified against the RFC 4226/6238 test vectors), ±1 step tolerance,
constant-time compare. `qrcode` renders the otpauth URL as inline SVG.
- Enrolment stores a secret but only trusts it once the user proves a code
from it; that also issues 8 single-use recovery codes and signs out every
other session. Turning it off needs the password and a current code.
- Login with MFA on returns `{ mfaRequired, challengeToken }` instead of a
session (`login_challenges`, 5 minutes); `/api/auth/mfa/verify` accepts a
TOTP or recovery code and issues the session, so the SPA still receives
its bearer token on the final step.
- Settings > Account > Security gains a card with enrol / new codes / off
dialogs; `/api/auth/me` exposes `mfaEnabled`.
Related
- Changing a password now signs out every other session.
- Admins can set a password on a managed account (PATCH /api/accounts/[id]
gains `password`; every session of that user is revoked). The route
already hashed passwords but no caller ever passed one.
- Migration 0031: three columns on users, `password_reset_tokens`,
`mfa_recovery_codes`, `login_challenges`; bootstrap SQL and backup list
updated.
Verified on local D1: unknown and known accounts get the same reset answer;
a bad token, a used token and a reused token are rejected; redemption
revokes an open session and the old password stops working; enrolment
rejects a wrong password and a wrong code; confirming with a real code
returns 8 codes and revokes the other session but not the current one;
login then requires MFA, a wrong code is rejected, a right code and a
recovery code each sign in, the challenge is consumed, a recovery code
cannot be reused, and disabling restores password-only login. The QR
enrolment, MFA step and reset pages were exercised in the browser.
tsc (11) and eslint (105) unchanged from main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Search covered four columns with LIKE and never looked at message bodies.
This adds an FTS5 index over subject, sender, recipients and both bodies,
kept in sync by triggers, and a query grammar that both the search box and
the public API understand.
- Migration 0030 creates `messages_fts` as an external-content table (bodies
are read from `messages`, not stored twice) with insert/update/delete
triggers, and rebuilds it once so existing mail is indexed.
- `src/lib/search/query-utils.ts` parses `from:`, `to:` (covers Cc),
`subject:`, `has:attachment`, `is:unread|read|starred`, `after:`/`before:`,
quoted phrases and `-negation`, and builds a MATCH expression with every
user value quoted so input cannot alter the query. Plain terms match by
prefix. The legacy `title:` and `:unread` forms still work.
- `buildSearchConditions` turns that into a `rowid IN (SELECT rowid FROM
messages_fts WHERE messages_fts MATCH ?)` predicate plus column filters,
used by `/api/messages` and now `/api/v1/messages?q=`.
- The search box is debounced (250 ms) so a typed word costs one request.
- `GET`/`POST /api/admin/search-index` reports index vs table counts and
rebuilds on demand.
- The bootstrap schema splitter now keeps trigger bodies whole, and the
backup coverage check skips the FTS shadow tables (derived data that
repopulates on restore). While there, `mailbox_aliases` was missing from
BACKUP_TABLES, which made every backup fail on main; it is added.
Verified on local D1: all operators return the expected rows against the
seed data, hostile input (`x" OR "y`, `term)(`) is matched literally,
a draft edit and delete update the index immediately, rebuild is a no-op
on a consistent index, and a backup completes with the index present.
tsc (11) and eslint (105) unchanged from main.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Completes the three follow-ups from the threading PR.
Conversation view
- `GET /api/messages?group=thread` returns one row per conversation (its
newest message matching the filter) via a latest-per-thread subquery,
with `threadCount`, `threadUnread`, and `threadMessageIds` (the ids the
row stands for within the current filter).
- Lists default to conversation view, remembered per browser, with a header
toggle. Row and bulk actions, drags, and selection expand to the whole
conversation; a row is bold while any message in it is unread. Drafts are
never grouped.
Forwarding
- Forward builds a "---------- Forwarded message ---------" block from the
original and creates a draft with `forwardOfMessageId`; the server copies
the original's attachments onto the draft (new R2 objects, since keys are
unique per row). The composer shows them as removable chips.
- `/api/send` accepts `draftId` and sends the draft's stored files with
whatever was uploaded in the request. Deleting a draft now removes its R2
objects too, and `DELETE /api/drafts/[id]/attachments/[attachmentId]`
drops one file.
Rich-text composer
- contentEditable editor with bold/italic/underline/strikethrough, lists,
quote, link (⌘K) and clear formatting; pasted content is flattened to text.
- The body is one HTML string; the text/plain part is derived from it, with
bullets, numbering and "> " quote prefixes so text-only clients and the
reader's collapse heuristics still work.
- Reply and forward quotes are HTML wrapped in a marked block that both the
composer (•••) and the reader ("Quoted text") fold, so rich replies keep
their formatting instead of falling back to text. Signature handling and
draft round-trips work on the HTML.
- Shared `.email-body` styles restore list markers and blockquotes that
Tailwind preflight strips from rendered mail.
Verified on a fresh local D1: grouped inbox collapses 4 messages to 1 row
with the right ids; a forward from an imported message carried its PDF onto
the draft, survived a draft reload, sent with the file, and joined the
original's thread; deleting the draft dropped its R2 object. tsc (11) and
eslint (96) unchanged from baseline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
Mailflare stored one recipient per message and never used the threading
headers: send accepted a single `to`, replies carried no In-Reply-To, and
`threadId` was always the message's own Message-ID, so nothing ever grouped.
Threading
- `resolveThreadId` (src/lib/email/threading.ts) looks up the parent named
by In-Reply-To/References among stored messages in the same mailbox and
reuses its thread; otherwise the message's own Message-ID seeds a new one.
Applied to inbound mail and to .eml/.mbox/IMAP import.
- Outbound replies write In-Reply-To and References and file the sent copy
under the parent's thread; a fresh send is keyed by the Message-ID
Cloudflare returns, which is what any reply will reference.
- New `GET /api/messages/[id]/thread` returns the conversation (drafts and
trash excluded). The reader shows earlier and later messages as collapsed
cards that expand in place; list rows show a "(n)" count.
Recipients
- `to`, `cc`, `bcc` accept a header string or an array (composer, /api/send,
/api/v1/send). Full To and Cc lines are stored on inbound messages so
reply-all can address everyone; Bcc is stored on sent mail only.
- Composer gains a chip input (comma, Enter, paste, blur commit; Backspace
edits the last chip; invalid entries highlighted) with Cc/Bcc toggles.
- Reply-all appears only when it would reach someone a plain reply would
not, and drops the mailbox's own addresses.
- Reader header shows every To/Cc/Bcc recipient; Sent rows read "Name, +2".
Schema: migration 0025 adds cc_addr, bcc_addr, in_reply_to,
references_header and two indexes; bootstrap SQL and MIGRATION_NAMES
updated. mbox export now writes Cc/In-Reply-To/References.
Verified against a fresh local D1 with the seeded demo data: a reply sent
through /api/send, an .eml reply imported with In-Reply-To pointing at the
Cloudflare-assigned Message-ID, and a reply-all from the composer all landed
in one 4-message thread. tsc (11) and eslint (96) unchanged from baseline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h
Adds README sections covering the three-phase domain rule evaluation order, the
available match fields and operators, the keep-a-copy forwarding behaviour, the
webhook retry/backoff model, the signing headers, and the new API surface, plus
screenshots of both pages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGeWDVSkXtVEmDAN2AtB9h