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