Files
mailflare/docs/api.md
T
c1441cedd5 feat(jmap): Email/import for MIME-composing clients, and the header query filter (#46)
* fix(jmap): answer the header filter in Email/query instead of ignoring it

conditionToSql() had no `header` branch, so a condition it did not
recognise was dropped and the query matched every message in the
mailbox. A client that de-duplicates drafts by Message-ID before
importing therefore either adopted an unrelated draft or refused to
send because "multiple remote drafts" came back.

Handle `header` per RFC 8621 §4.4.1 from the columns Mailflare already
stores — Message-ID, In-Reply-To and References — and make every other
header name an `unsupportedFilter` error, which is what RFC 8620 §5.5
requires of a filter the server cannot apply. Message-IDs compare
without angle brackets, since inbound rows store them and outbound rows
do not, and header names are matched case-insensitively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(jmap): Email/import so MIME-composing clients can send

Clients that build MIME locally — Flectar Mail and anything else on the
jmap-client crate — send by uploading the raw message, importing it into
Drafts and submitting it. Email/import answered `unknownMethod`, so
reading worked but every send failed.

Implement it for the Drafts mailbox (RFC 8621 §4.8). The blob must be an
upload this account owns, the target exactly one Drafts mailbox, and the
parsed From has to pass the same getAuthorizedSenderAddress() check a
draft created through Email/set does; anything else comes back in
notCreated as blobNotFound, invalidProperties, invalidEmail, forbidden
or tooLarge. Importing into arbitrary mailboxes is deliberately left
out: delivered mail belongs to the inbound pipeline, which owns
threading, spam scoring and inbound state.

The parsed headers fill the same columns Email/set create writes —
Email/set create and Email/import now share one insert and one target
rule — plus providerMessageId, which is what lets the client find its
own draft again with a Message-ID filter. The uploaded bytes are kept
verbatim under `drafts/<messageId>.eml`, so downloading the message's
blob returns the client's own MIME instead of the rebuilt minimal
message, and the jmap-uploads object is released once claimed.

Email/copy and Email/parse stay unsupported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(jmap): cover Email/import validation and the header filter

tests/jmap-email-import.test.mjs is a node:test file over the pure
pieces — the mailbox rule, keyword and receivedAt parsing, and the SQL
Email/query builds for header conditions, including the unsupportedFilter
throw and the regression that a Message-ID filter narrows the mailbox
instead of matching every draft. The modules are TypeScript behind the
`@/*` alias, so they are bundled with the esbuild the repo already uses
for the self-hosted server rather than loaded directly; nothing here
needs a Workers binding.

scripts/jmap-import-check.mjs is the end-to-end pass against
`npm run dev`: upload a multipart message with an attachment, import it,
read it back, download the blob byte-for-byte, query it by Message-ID,
submit it, and check every rejection path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(jmap): document Email/import and the header query filter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(jmap): clean up imported draft objects

---------

Co-authored-by: egamer24 <25360766+egamer24@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Hieu Nguyen (Jack) <nc.hieu@live.com>
2026-09-18 15:16:52 +07:00

8.2 KiB

API and integrations

Mailflare exposes APIs for domain management and sending email. Authentication and mailbox permissions still apply to these routes.

Domain management

Adding or removing a domain from Mailflare also updates Cloudflare Email Routing and sending resources.

Mailflare route Purpose
GET /api/domains List connected domains
POST /api/domains Connect a domain and configure Cloudflare
GET /api/domains/[id] Get a connected domain
DELETE /api/domains/[id] Remove a domain and clean up its Cloudflare resources
GET /api/domains/[id]/dns View its routing and sending DNS status

The hostname must be the apex of a zone available to the configured Cloudflare credentials, or a subdomain of that zone. Creating a mailbox also creates the Cloudflare Email Routing rule that delivers its address to the mailflare Worker.

Sending email

Send email through POST /api/v1/send. to, cc and bcc accept either a comma-separated header string or an array of addresses; each entry may carry a display name ("Maya Chen" <maya@example.net>). A message can reach up to 50 recipients across the three fields. Attachments are optional and use Base64-encoded content:

{
  "from": "support@example.com",
  "to": ["user@example.net", "\"Maya Chen\" <maya@example.net>"],
  "cc": "ops@example.com",
  "bcc": ["audit@example.com"],
  "subject": "Report",
  "text": "Attached.",
  "attachments": [
    {
      "filename": "report.pdf",
      "type": "application/pdf",
      "contentBase64": "<base64 data>"
    }
  ]
}

To send a reply that threads correctly in the recipient's client, pass the parent's Message-ID as inReplyTo and its chain as references (a header string or an array). Mailflare writes the In-Reply-To and References headers, files the sent copy in the same conversation, and stores threadId, inReplyTo and references on every message.

{
  "from": "support@example.com",
  "to": "user@example.net",
  "subject": "Re: Report",
  "text": "Thanks, received.",
  "inReplyTo": "<CAF1abc@mail.example.net>",
  "references": ["<CAF0root@mail.example.net>", "<CAF1abc@mail.example.net>"]
}

GET /api/messages/{id}/thread (session auth) returns every stored message in the same conversation, oldest first, excluding drafts and trash. GET /api/messages?group=thread collapses a list to one row per conversation (its newest message matching the filter) and adds threadCount, threadUnread and threadMessageIds, the ids that row stands for within the current filter, so bulk actions can act on the whole conversation.

Messages composed in Mailflare are sent as HTML with a plain-text alternative derived from it. Quoted or forwarded content is wrapped in <div class="mailflare-quote" data-mailflare-quote="1"> so the reader can fold it. POST /api/drafts accepts forwardOfMessageId, which copies that message's attachments onto the new draft; DELETE /api/drafts/{id}/attachments/{attachmentId} removes one, and POST /api/send with draftId sends the draft's stored files along with any uploaded in the request.

The dashboard composer accepts up to 10 attachments, with a 10 MB limit per file and a 20 MB combined limit. Attachment metadata is stored in D1 and file content is stored in R2. Downloads require access to the mailbox containing the message.

JMAP

Mailflare serves JMAP (RFC 8620 core and RFC 8621 mail, plus submission) so external mail apps can read and send mail. Discovery is at /.well-known/jmap, which redirects to /jmap/session. Authenticate with an API key that has the jmap scope, either as Authorization: Bearer <key> or as the password of HTTP Basic auth (the username is ignored). Settings > Account > Email apps mints such a key.

The account id is the user id. Each Mailflare mailbox appears as a top-level JMAP Mailbox with system children (inbox, drafts, sent, archive, junk, trash) and one child per user folder; a message belongs to exactly one of them. Supported methods: Mailbox/get|query|set (folders only), Thread/get, Email/get|query|set|import, SearchSnippet/get, Identity/get, EmailSubmission/set, and Core/echo. Email/copy and Email/parse are not implemented. */changes return cannotCalculateChanges, so clients re-query on a state change; /jmap/eventsource pushes state changes by polling. Email/set creates drafts, updates $seen and $flagged, moves between mailboxes, and destroys (to Trash first, then permanently). EmailSubmission/set sends a draft and reports it destroyed, since the sent copy is a new message. Blob download and upload follow the Session's downloadUrl and uploadUrl.

Email/import takes a message/rfc822 blob that was uploaded first and stores it as a draft, which is how clients that compose MIME themselves send: upload, import, then EmailSubmission/set. Each entry takes blobId, mailboxIds, and optionally keywords and receivedAt. The target must be exactly one Drafts mailbox — importing into Inbox or a folder is rejected with invalidProperties on mailboxIds, because delivered mail belongs to the inbound pipeline that does threading and spam scoring. $seen and $flagged are stored, $draft is implied, and other keywords are ignored. receivedAt sets the message date, falling back to the Date header and then to now. The uploaded bytes are kept verbatim, so downloading the new message's blobId returns exactly what was uploaded rather than a reconstruction, and the Message-ID header is stored so the client can find its own draft again. Per-message failures come back in notCreated as blobNotFound, invalidEmail, invalidProperties, forbidden (the From address is not one the key may send from) or tooLarge.

Email/query supports the header filter: ["Message-ID", "<id@example.com>"] matches messages with that value, and ["Message-ID"] matches any message that has the header. Message-ID, In-Reply-To and References are answered from stored columns; Message-IDs compare with or without angle brackets, and header names are case-insensitive. Any other header name returns an unsupportedFilter error rather than silently matching everything.

Password reset and two-factor authentication

POST /api/auth/password-reset/request with { email } always answers 200 { ok: true }; when the account exists and has a recovery email, a single-use link valid for 30 minutes is mailed there. POST /api/auth/password-reset/confirm with { token, password } sets the password and signs the account out everywhere.

When two-factor authentication is on, POST /api/auth/login returns { ok: true, mfaRequired: true, challengeToken } instead of a session. POST /api/auth/mfa/verify with { challengeToken, code } completes the sign-in; code is a 6-digit TOTP or one of the recovery codes. Challenges expire after 5 minutes. Enrolment, recovery codes and turning it off are under /api/settings/mfa/* (session auth) and always re-check the password.

Searching

GET /api/messages?q=... (session) and GET /api/v1/messages?q=... (API key, read scope) accept the same query grammar, backed by an FTS5 index over subject, sender, recipients and body:

Syntax Meaning
invoice prefix match anywhere (inv finds "invoice")
"private window" exact phrase
-word exclude
from:maya, to:sam, subject:report restrict a term to one field (to: covers Cc)
has:attachment at least one non-inline attachment
is:unread, is:read, is:starred flags
after:2026-09-01, before:2026-09-30 date bounds (UTC, before exclusive)

Terms combine with AND. Admins can check or rebuild the index with GET / POST /api/admin/search-index; triggers keep it current, so a rebuild is only needed after restoring a backup made before the index existed.

Real-time updates

Mailflare uses a Durable Object WebSocket hub to notify connected users after an inbound message is stored. Mailbox owners, the domain administrator, and delegated users receive events for mailboxes they can access.

The REALTIME binding and its migration are declared in wrangler.jsonc. When a WebSocket is temporarily unavailable, the app retries the connection and uses a slower refresh until it recovers.