From eadec49b4f0dd2d8203f56337e1051d2f8effd4f Mon Sep 17 00:00:00 2001 From: John Ellis Date: Sun, 23 Aug 2026 13:59:33 -0700 Subject: [PATCH] Fix sending: never send null for an empty header property Every message ihasmail sent set cc, bcc and replyTo to null when unused, and inReplyTo/references likewise on a new message. Stalwart parses those properties with try_into_address_list, which returns None for null, and the create is rejected outright: if let Some(addresses) = value.try_into_address_list() { ... } else { response.invalid_property_create(id, header); continue 'create; } So every send failed with "Invalid property or value.", new messages and replies alike, regardless of attachments or signature. The mock server accepts anything, which is why this only showed up against a real server. Empty header properties are now omitted. On a create there is no previous value to clear, so null was never needed - only the properties actually being set belong in the object. buildEmailObject is exported so the shape can be tested directly, with a regression test that no property is ever null. --- web/src/store/__tests__/compose-email.test.ts | 76 +++++++++++++++++++ web/src/store/compose.ts | 19 +++-- 2 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 web/src/store/__tests__/compose-email.test.ts 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";