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.
This commit is contained in:
@@ -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> = {}): Draft {
|
||||
return {
|
||||
key: "k", draftId: null, identityId: "i1",
|
||||
to: [{ name: null, email: "[email protected]" }],
|
||||
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: "[email protected]", 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: "[email protected]" }]);
|
||||
});
|
||||
|
||||
it("includes header properties that have a value", async () => {
|
||||
const obj = await buildEmailObject(
|
||||
draft({
|
||||
cc: [{ name: null, email: "[email protected]" }],
|
||||
bcc: [{ name: null, email: "[email protected]" }],
|
||||
replyTo: [{ name: null, email: "[email protected]" }],
|
||||
inReplyTo: ["<a@b>"],
|
||||
references: ["<a@b>"],
|
||||
}),
|
||||
{ forSend: true },
|
||||
);
|
||||
expect(obj.cc).toHaveLength(1);
|
||||
expect(obj.bcc).toHaveLength(1);
|
||||
expect(obj.replyTo).toHaveLength(1);
|
||||
expect(obj.inReplyTo).toEqual(["<a@b>"]);
|
||||
expect(obj.references).toEqual(["<a@b>"]);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>> {
|
||||
export async function buildEmailObject(d: Draft, opts: { forSend: boolean }): Promise<Record<string, unknown>> {
|
||||
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<R
|
||||
|
||||
const obj: Record<string, unknown> = {
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user