* 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>
301 lines
15 KiB
JavaScript
301 lines
15 KiB
JavaScript
/**
|
|
* End-to-end check of JMAP Email/import and the `header` query filter against a
|
|
* running dev server, in the shape a MIME-composing client sends: upload raw
|
|
* message/rfc822, import it into Drafts, find it again by Message-ID, submit it.
|
|
*
|
|
* npm run dev && npm run db:seed # in another shell
|
|
* node scripts/jmap-import-check.mjs
|
|
*
|
|
* Pass a key with the `jmap` scope in JMAP_KEY, or let the script sign in with
|
|
* the seeded demo account and mint one. BASE_URL overrides http://localhost:3000.
|
|
*/
|
|
|
|
const BASE = (process.env.BASE_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
|
const DEMO = { email: "admin@example.com", password: "demo-password-change-me" };
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function check(name, ok, detail) {
|
|
if (ok) {
|
|
passed += 1;
|
|
console.log(` ok ${name}`);
|
|
} else {
|
|
failed += 1;
|
|
console.log(` FAIL ${name}${detail === undefined ? "" : ` — ${typeof detail === "string" ? detail : JSON.stringify(detail)}`}`);
|
|
}
|
|
}
|
|
|
|
function section(title) {
|
|
console.log(`\n${title}`);
|
|
}
|
|
|
|
async function mintKey() {
|
|
if (process.env.JMAP_KEY) return process.env.JMAP_KEY;
|
|
const login = await fetch(`${BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(DEMO),
|
|
});
|
|
if (!login.ok) throw new Error(`login failed (${login.status}); run npm run db:seed first`);
|
|
const cookie = (login.headers.getSetCookie?.() ?? []).map((value) => value.split(";")[0]).join("; ");
|
|
const created = await fetch(`${BASE}/api/api-keys`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Cookie: cookie },
|
|
body: JSON.stringify({ name: `import-check ${Date.now()}`, scopes: ["jmap"] }),
|
|
});
|
|
if (!created.ok) throw new Error(`could not mint an API key (${created.status})`);
|
|
return (await created.json()).key;
|
|
}
|
|
|
|
/** One JMAP request; returns the method responses. */
|
|
async function jmap(key, methodCalls, using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"]) {
|
|
const response = await fetch(`${BASE}/jmap/api`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
|
|
body: JSON.stringify({ using, methodCalls }),
|
|
});
|
|
const body = await response.json();
|
|
if (!response.ok) throw new Error(`JMAP ${response.status}: ${JSON.stringify(body)}`);
|
|
return body.methodResponses;
|
|
}
|
|
|
|
async function callOne(key, name, args) {
|
|
const [[, result]] = await jmap(key, [[name, args, "c0"]]);
|
|
return result;
|
|
}
|
|
|
|
/** A multipart/mixed message with one text part and one attachment. */
|
|
function buildMime({ from, to, subject, messageId, text, attachmentName, attachmentBody }) {
|
|
const boundary = "mailflare-import-check-boundary";
|
|
return [
|
|
`From: ${from}`,
|
|
`To: ${to}`,
|
|
`Subject: ${subject}`,
|
|
`Message-ID: ${messageId}`,
|
|
"Date: Tue, 16 Sep 2026 09:00:00 +0000",
|
|
"MIME-Version: 1.0",
|
|
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
|
"",
|
|
`--${boundary}`,
|
|
"Content-Type: text/plain; charset=utf-8",
|
|
"Content-Transfer-Encoding: 7bit",
|
|
"",
|
|
text,
|
|
`--${boundary}`,
|
|
`Content-Type: text/plain; charset=utf-8; name="${attachmentName}"`,
|
|
`Content-Disposition: attachment; filename="${attachmentName}"`,
|
|
"Content-Transfer-Encoding: 7bit",
|
|
"",
|
|
attachmentBody,
|
|
`--${boundary}--`,
|
|
"",
|
|
].join("\r\n");
|
|
}
|
|
|
|
async function upload(key, accountId, bytes) {
|
|
const response = await fetch(`${BASE}/jmap/upload/${encodeURIComponent(accountId)}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "message/rfc822", Authorization: `Bearer ${key}` },
|
|
body: bytes,
|
|
});
|
|
if (!response.ok) throw new Error(`upload failed (${response.status})`);
|
|
return response.json();
|
|
}
|
|
|
|
async function main() {
|
|
const key = await mintKey();
|
|
|
|
section("Session");
|
|
const session = await (await fetch(`${BASE}/jmap/session`, { headers: { Authorization: `Bearer ${key}` } })).json();
|
|
const accountId = session.primaryAccounts["urn:ietf:params:jmap:mail"];
|
|
check("session advertises a mail account", !!accountId, session.primaryAccounts);
|
|
check("session advertises the submission capability", !!session.capabilities["urn:ietf:params:jmap:submission"]);
|
|
|
|
const mailboxes = await callOne(key, "Mailbox/get", { accountId, ids: null });
|
|
const drafts = mailboxes.list.find((box) => box.role === "drafts");
|
|
const inbox = mailboxes.list.find((box) => box.role === "inbox");
|
|
check("a Drafts mailbox exists", !!drafts, mailboxes.list.map((box) => box.role));
|
|
check("an Inbox mailbox exists", !!inbox);
|
|
|
|
const identities = await callOne(key, "Identity/get", { accountId, ids: null });
|
|
const identity = identities.list[0];
|
|
check("at least one Identity to send from", !!identity, identities.list);
|
|
|
|
section("Upload");
|
|
const messageId = `<import-check-${Date.now()}@example.com>`;
|
|
const otherMessageId = `<import-check-absent-${Date.now()}@example.com>`;
|
|
const raw = buildMime({
|
|
from: identity.email,
|
|
to: "recipient@example.net",
|
|
subject: "Import check",
|
|
messageId,
|
|
text: "Composed locally and imported over JMAP.\r\n",
|
|
attachmentName: "note.txt",
|
|
attachmentBody: "attachment body\r\n",
|
|
});
|
|
const rawBytes = new TextEncoder().encode(raw);
|
|
const uploaded = await upload(key, accountId, rawBytes);
|
|
check("upload returns a blobId", typeof uploaded.blobId === "string", uploaded);
|
|
check("upload reports the exact size", uploaded.size === rawBytes.byteLength, { got: uploaded.size, want: rawBytes.byteLength });
|
|
|
|
section("Email/import");
|
|
const stale = await callOne(key, "Email/import", {
|
|
accountId,
|
|
ifInState: "not-the-current-state",
|
|
emails: { s1: { blobId: uploaded.blobId, mailboxIds: { [drafts.id]: true }, keywords: { $draft: true, $seen: true } } },
|
|
});
|
|
check("a stale ifInState is a stateMismatch", stale.type === "stateMismatch", stale);
|
|
|
|
const imported = await callOne(key, "Email/import", {
|
|
accountId,
|
|
emails: {
|
|
d1: {
|
|
blobId: uploaded.blobId,
|
|
mailboxIds: { [drafts.id]: true },
|
|
keywords: { $draft: true, $seen: true },
|
|
receivedAt: "2026-09-16T09:00:00Z",
|
|
},
|
|
},
|
|
});
|
|
const created = imported.created?.d1;
|
|
check("the message is created", !!created, imported);
|
|
check("created carries an id", typeof created?.id === "string", created);
|
|
check("created carries a blobId", typeof created?.blobId === "string", created);
|
|
check("created carries a threadId", typeof created?.threadId === "string", created);
|
|
check("created carries a size", typeof created?.size === "number" && created.size > 0, created);
|
|
check("the state moves", imported.oldState !== imported.newState, { old: imported.oldState, new: imported.newState });
|
|
check("nothing is reported as notCreated", Object.keys(imported.notCreated ?? {}).length === 0, imported.notCreated);
|
|
|
|
section("Email/get on the imported draft");
|
|
const fetched = await callOne(key, "Email/get", {
|
|
accountId,
|
|
ids: [created.id],
|
|
properties: ["subject", "from", "to", "keywords", "mailboxIds", "attachments", "messageId", "threadId", "blobId", "size", "receivedAt", "header:Message-ID"],
|
|
});
|
|
const email = fetched.list[0];
|
|
check("Email/get finds it", !!email, fetched);
|
|
check("subject round-trips", email?.subject === "Import check", email?.subject);
|
|
check("from round-trips", email?.from?.[0]?.email === identity.email, email?.from);
|
|
check("to round-trips", email?.to?.[0]?.email === "recipient@example.net", email?.to);
|
|
check("the attachment is stored", email?.attachments?.length === 1, email?.attachments);
|
|
check("the attachment keeps its filename", email?.attachments?.[0]?.name === "note.txt", email?.attachments?.[0]);
|
|
check("$draft is set", email?.keywords?.$draft === true, email?.keywords);
|
|
check("$seen is set", email?.keywords?.$seen === true, email?.keywords);
|
|
check("it lands in Drafts", email?.mailboxIds?.[drafts.id] === true, email?.mailboxIds);
|
|
check("the Message-ID is stored", email?.messageId?.[0] === messageId.replace(/^<|>$/g, ""), email?.messageId);
|
|
check("header:Message-ID answers it", typeof email?.["header:Message-ID"] === "string" && email["header:Message-ID"].includes(messageId), email?.["header:Message-ID"]);
|
|
check("receivedAt honours the argument", email?.receivedAt?.startsWith("2026-09-16T09:00:00"), email?.receivedAt);
|
|
check("the blobId matches the import response", email?.blobId === created.blobId, { get: email?.blobId, import: created.blobId });
|
|
check("the threadId matches the import response", email?.threadId === created.threadId, { get: email?.threadId, import: created.threadId });
|
|
check("the size matches the import response", email?.size === created.size, { get: email?.size, import: created.size });
|
|
|
|
section("Blob download");
|
|
const download = await fetch(`${BASE}/jmap/download/${encodeURIComponent(accountId)}/${encodeURIComponent(email.blobId)}/message.eml`, {
|
|
headers: { Authorization: `Bearer ${key}` },
|
|
});
|
|
const downloaded = new Uint8Array(await download.arrayBuffer());
|
|
check("the blob downloads", download.ok, download.status);
|
|
check("the download is served as message/rfc822", (download.headers.get("content-type") ?? "").startsWith("message/rfc822"), download.headers.get("content-type"));
|
|
check("the raw bytes are byte-identical to the upload", downloaded.length === rawBytes.length && downloaded.every((byte, index) => byte === rawBytes[index]), { got: downloaded.length, want: rawBytes.length });
|
|
|
|
section("Email/query with a header filter");
|
|
const byMessageId = await callOne(key, "Email/query", { accountId, filter: { inMailbox: drafts.id, header: ["Message-ID", messageId] } });
|
|
check("the Message-ID filter returns exactly the imported draft", byMessageId.ids?.length === 1 && byMessageId.ids[0] === created.id, byMessageId.ids);
|
|
|
|
const withoutBrackets = await callOne(key, "Email/query", { accountId, filter: { inMailbox: drafts.id, header: ["Message-ID", messageId.replace(/^<|>$/g, "")] } });
|
|
check("the same id without angle brackets matches too", withoutBrackets.ids?.length === 1 && withoutBrackets.ids[0] === created.id, withoutBrackets.ids);
|
|
|
|
const lowerCaseName = await callOne(key, "Email/query", { accountId, filter: { inMailbox: drafts.id, header: ["message-id", messageId] } });
|
|
check("the header name is matched case-insensitively", lowerCaseName.ids?.length === 1 && lowerCaseName.ids[0] === created.id, lowerCaseName.ids);
|
|
|
|
const otherId = await callOne(key, "Email/query", { accountId, filter: { inMailbox: drafts.id, header: ["Message-ID", otherMessageId] } });
|
|
check("a different Message-ID matches nothing", otherId.ids?.length === 0, otherId.ids);
|
|
|
|
const present = await callOne(key, "Email/query", { accountId, filter: { inMailbox: drafts.id, header: ["Message-ID"] } });
|
|
check("the one-element form matches drafts that carry the header", present.ids?.includes(created.id), present.ids);
|
|
|
|
const unfiltered = await callOne(key, "Email/query", { accountId, filter: { inMailbox: drafts.id } });
|
|
check("the filter actually narrows the mailbox", unfiltered.ids?.length >= byMessageId.ids?.length, { all: unfiltered.ids?.length, filtered: byMessageId.ids?.length });
|
|
|
|
const unknownHeader = await callOne(key, "Email/query", { accountId, filter: { inMailbox: drafts.id, header: ["X-Unknown"] } });
|
|
check("an unsupported header is an unsupportedFilter error", unknownHeader.type === "unsupportedFilter", unknownHeader);
|
|
|
|
const unknownInTree = await callOne(key, "Email/query", {
|
|
accountId,
|
|
filter: { operator: "AND", conditions: [{ inMailbox: drafts.id }, { header: ["X-Mailer", "anything"] }] },
|
|
});
|
|
check("an unsupported header inside an operator tree errors too", unknownInTree.type === "unsupportedFilter", unknownInTree);
|
|
|
|
section("Rejections");
|
|
const wrongMailbox = await callOne(key, "Email/import", {
|
|
accountId,
|
|
emails: { bad: { blobId: uploaded.blobId, mailboxIds: { [inbox.id]: true } } },
|
|
});
|
|
check("importing into Inbox is invalidProperties", wrongMailbox.notCreated?.bad?.type === "invalidProperties", wrongMailbox.notCreated);
|
|
check("the rejection names mailboxIds", wrongMailbox.notCreated?.bad?.properties?.[0] === "mailboxIds", wrongMailbox.notCreated?.bad);
|
|
|
|
const twoMailboxes = await callOne(key, "Email/import", {
|
|
accountId,
|
|
emails: { bad: { blobId: uploaded.blobId, mailboxIds: { [drafts.id]: true, [inbox.id]: true } } },
|
|
});
|
|
check("importing into two mailboxes is invalidProperties", twoMailboxes.notCreated?.bad?.type === "invalidProperties", twoMailboxes.notCreated);
|
|
|
|
const missingBlob = await callOne(key, "Email/import", {
|
|
accountId,
|
|
emails: { bad: { blobId: "up~does-not-exist", mailboxIds: { [drafts.id]: true } } },
|
|
});
|
|
check("an unknown blobId is blobNotFound", missingBlob.notCreated?.bad?.type === "blobNotFound", missingBlob.notCreated);
|
|
|
|
const nonUploadBlob = await callOne(key, "Email/import", {
|
|
accountId,
|
|
emails: { bad: { blobId: `msg~${created.id}`, mailboxIds: { [drafts.id]: true } } },
|
|
});
|
|
check("a non-upload blobId is blobNotFound", nonUploadBlob.notCreated?.bad?.type === "blobNotFound", nonUploadBlob.notCreated);
|
|
|
|
check("the upload is released once claimed", missingBlob.notCreated?.bad?.type === "blobNotFound" && (await callOne(key, "Email/import", { accountId, emails: { again: { blobId: uploaded.blobId, mailboxIds: { [drafts.id]: true } } } })).notCreated?.again?.type === "blobNotFound", "re-importing the claimed upload should fail");
|
|
|
|
section("Import and submit in one request");
|
|
const secondMessageId = `<import-check-submit-${Date.now()}@example.com>`;
|
|
const secondRaw = new TextEncoder().encode(buildMime({
|
|
from: identity.email,
|
|
to: "recipient@example.net",
|
|
subject: "Import and submit",
|
|
messageId: secondMessageId,
|
|
text: "Sent straight after import.\r\n",
|
|
attachmentName: "second.txt",
|
|
attachmentBody: "second attachment\r\n",
|
|
}));
|
|
const secondUpload = await upload(key, accountId, secondRaw);
|
|
const responses = await jmap(key, [
|
|
["Email/import", { accountId, emails: { d2: { blobId: secondUpload.blobId, mailboxIds: { [drafts.id]: true }, keywords: { $draft: true, $seen: true } } } }, "i1"],
|
|
["EmailSubmission/set", { accountId, create: { s1: { emailId: "#d2", identityId: identity.id } } }, "i2"],
|
|
]);
|
|
const importResponse = responses.find(([name, , id]) => name === "Email/import" && id === "i1")?.[1];
|
|
const submission = responses.find(([name, , id]) => name === "EmailSubmission/set" && id === "i2")?.[1];
|
|
const implicitSet = responses.find(([name, , id]) => name === "Email/set" && id === "i2")?.[1];
|
|
const importedId = importResponse?.created?.d2?.id;
|
|
check("the second import succeeds", !!importedId, importResponse);
|
|
check("#creationId resolves in the same request", !!submission?.created?.s1, submission);
|
|
check("the submission is final", submission?.created?.s1?.undoStatus === "final", submission?.created?.s1);
|
|
check("the implicit Email/set reports the draft destroyed", implicitSet?.destroyed?.includes(importedId), implicitSet);
|
|
|
|
const gone = await callOne(key, "Email/get", { accountId, ids: [importedId] });
|
|
check("the draft is gone after sending", gone.notFound?.includes(importedId), gone);
|
|
|
|
const sent = await callOne(key, "Email/query", { accountId, filter: { inMailbox: `${drafts.id.split("~")[0]}~sent` }, limit: 5 });
|
|
check("a sent copy exists", (sent.ids?.length ?? 0) > 0, sent.ids);
|
|
|
|
section("Cleanup");
|
|
const destroyed = await callOne(key, "Email/set", { accountId, destroy: [created.id] });
|
|
check("the first imported draft can be destroyed", destroyed.destroyed?.includes(created.id), destroyed);
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
process.exit(failed === 0 ? 0 : 1);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`\nAborted: ${error.message}`);
|
|
process.exit(1);
|
|
});
|