diff --git a/web/src/store/__tests__/compose-email.test.ts b/web/src/store/__tests__/compose-email.test.ts new file mode 100644 index 0000000..1424bcd --- /dev/null +++ b/web/src/store/__tests__/compose-email.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { buildEmailObject, type Draft } from "@/store/compose"; +import { useMail } from "@/store/mail"; + +/** + * JMAP servers may reject `null` for a header property — Stalwart parses these + * as address lists and fails the whole create, which took down every send. + * Empty header fields must be omitted, not nulled. + */ +function draft(over: Partial = {}): Draft { + return { + key: "k", draftId: null, identityId: "i1", + to: [{ name: null, email: "ann@example.com" }], + cc: [], bcc: [], replyTo: [], + subject: "Hello", html: "", text: "Hi there", format: "text", + attachments: [], inReplyTo: null, references: null, + relatedEmailId: null, relatedKeyword: null, + requestReceipt: false, priority: "normal", + showCc: false, showBcc: false, showReplyTo: false, + minimized: false, maximized: false, dirty: false, savedAt: null, + saving: false, sending: false, error: null, signatureHtml: "", replyMode: null, + ...over, + }; +} + +beforeEach(() => { + useMail.setState({ + accountId: "a1", + identities: [{ id: "i1", name: "John", email: "john@example.org", replyTo: null }] as never, + mailboxes: { mb1: { id: "mb1", role: "sent", name: "Sent" }, mb2: { id: "mb2", role: "drafts", name: "Drafts" } } as never, + }); +}); + +describe("buildEmailObject", () => { + it("omits empty header properties rather than sending null", async () => { + const obj = await buildEmailObject(draft(), { forSend: true }); + expect(obj).not.toHaveProperty("cc"); + expect(obj).not.toHaveProperty("bcc"); + expect(obj).not.toHaveProperty("replyTo"); + expect(obj).not.toHaveProperty("inReplyTo"); + expect(obj).not.toHaveProperty("references"); + expect(obj.to).toEqual([{ name: null, email: "ann@example.com" }]); + }); + + it("includes header properties that have a value", async () => { + const obj = await buildEmailObject( + draft({ + cc: [{ name: null, email: "c@x.io" }], + bcc: [{ name: null, email: "d@x.io" }], + replyTo: [{ name: null, email: "r@x.io" }], + inReplyTo: [""], + references: [""], + }), + { forSend: true }, + ); + expect(obj.cc).toHaveLength(1); + expect(obj.bcc).toHaveLength(1); + expect(obj.replyTo).toHaveLength(1); + expect(obj.inReplyTo).toEqual([""]); + expect(obj.references).toEqual([""]); + }); + + it("never emits a null value for any property", async () => { + for (const forSend of [true, false]) { + const obj = await buildEmailObject(draft({ subject: "" }), { forSend }); + for (const [k, v] of Object.entries(obj)) { + expect(v, `${k} is null`).not.toBeNull(); + } + } + }); + + it("files a sent message in Sent and a draft in Drafts", async () => { + expect((await buildEmailObject(draft(), { forSend: true })).mailboxIds).toEqual({ mb1: true }); + expect((await buildEmailObject(draft(), { forSend: false })).mailboxIds).toEqual({ mb2: true }); + }); +}); diff --git a/web/src/store/compose.ts b/web/src/store/compose.ts index 4e18300..1702573 100644 --- a/web/src/store/compose.ts +++ b/web/src/store/compose.ts @@ -481,7 +481,7 @@ function scheduleAutosave(key: string, get: () => ComposeState) { } /** Build the JMAP Email creation object from a draft. */ -async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise> { +export async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise> { const mail = useMail.getState(); const accountId = mail.accountId!; const ident = mail.identities.find((i) => i.id === d.identityId) ?? mail.identities[0]; @@ -561,18 +561,23 @@ async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise = { from: [from], - to: d.to.length ? d.to : null, - cc: d.cc.length ? d.cc : null, - bcc: d.bcc.length ? d.bcc : null, - replyTo: d.replyTo.length ? d.replyTo : ident.replyTo?.length ? ident.replyTo : null, subject: d.subject, sentAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), - inReplyTo: d.inReplyTo, - references: d.references, bodyStructure, bodyValues, "header:User-Agent:asText": "ihasmail/2.0", }; + // Header properties are omitted when empty, never sent as null: JMAP servers + // are entitled to reject null for a header field (Stalwart parses these as + // address lists and fails the whole create), and on a create there is no + // previous value that would need clearing. + const replyTo = d.replyTo.length ? d.replyTo : (ident.replyTo ?? []); + if (d.to.length) obj.to = d.to; + if (d.cc.length) obj.cc = d.cc; + if (d.bcc.length) obj.bcc = d.bcc; + if (replyTo.length) obj.replyTo = replyTo; + if (d.inReplyTo?.length) obj.inReplyTo = d.inReplyTo; + if (d.references?.length) obj.references = d.references; if (d.priority === "high") { obj["header:X-Priority:asText"] = "1 (Highest)"; obj["header:Importance:asText"] = "High";