Files
ihasmail/web/src/lib/__tests__/mdn.test.ts
T
jcoffey-dev b0944b2f42 Send the read receipt the sender asked for
JMAP has an extension for this -- RFC 9007's MDN/send -- and Stalwart does
not implement it, so ihasmail assembles the RFC 8098 multipart/report itself
and sends it the long way round: raw MIME uploaded as a blob, imported,
submitted. That is also why the receipt lands in Sent, which is where it
honestly belongs.

The plumbing is the easy half. A receipt tells whoever asked that the address
is live and when the message was read, to an address the sender chose, so the
refusals are the feature: nothing marked Auto-Submitted (RFC 3834, or two
servers answer each other forever), nothing carrying Precedence bulk/list/junk
or a List-Id, nothing already acknowledged, nothing that never arrived. A
receipt aimed anywhere other than the sender is offered, but says so first.
There is no "always send" setting, only ask or never.

Sending is recorded with RFC 3503's $mdnsent keyword on the original rather
than remembered locally, so a second look -- or another client entirely --
knows not to ask again. Non-ASCII parts go base64 rather than 8bit, so
nothing rests on 8BITMIME surviving every hop.

Verified against the mock end to end: the blob uploads, the receipt imports
and submits, and the original reads back marked. Not yet exercised against
the live server.
2026-08-24 22:53:06 -07:00

189 lines
7.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { buildMdn, encodeHeaderWord, formatAddressHeader, mdnDecision, rfc5322Date, MDN_SENT_KEYWORD } from "@/lib/mdn";
import type { Email, EmailAddress } from "@/jmap/types";
/**
* A read receipt tells whoever asked that an address is live and was read, at
* a time of their choosing, to an address of their choosing. The refusals are
* the feature; the MIME is the easy part.
*/
function email(over: Partial<Email> = {}): Email {
return {
id: "e1",
subject: "Quarterly numbers",
from: [{ name: "Ann", email: "[email protected]" }],
keywords: {},
messageId: ["<[email protected]>"],
sentAt: "2026-08-20T09:00:00Z",
"header:Disposition-Notification-To:asAddresses": [{ name: null, email: "[email protected]" }],
...over,
} as unknown as Email;
}
describe("when a receipt is offered", () => {
it("offers one when a person asked for it", () => {
const d = mdnDecision(email());
expect(d.offer).toBe(true);
expect(d.to?.email).toBe("[email protected]");
expect(d.redirected).toBe(false);
});
it("says nothing when none was requested", () => {
const d = mdnDecision(email({ "header:Disposition-Notification-To:asAddresses": null }));
expect(d.offer).toBe(false);
expect(d.refusal).toBe("not-requested");
});
it("refuses twice for the same message", () => {
const d = mdnDecision(email({ keywords: { [MDN_SENT_KEYWORD]: true } }));
expect(d.offer).toBe(false);
expect(d.refusal).toBe("already-sent");
});
});
describe("what it refuses to acknowledge", () => {
it("refuses automatic mail, so two servers cannot answer each other forever", () => {
// RFC 3834: only "no" means a person sent it.
for (const v of ["auto-generated", "auto-replied", "auto-notified", "AUTO-GENERATED"]) {
expect(mdnDecision(email({ "header:Auto-Submitted:asText": v })).refusal).toBe("auto-submitted");
}
});
it("still answers mail that explicitly says a person sent it", () => {
expect(mdnDecision(email({ "header:Auto-Submitted:asText": "no" })).offer).toBe(true);
});
it("refuses bulk and list mail, where a receipt only confirms the address", () => {
for (const p of ["bulk", "list", "junk", " Bulk "]) {
expect(mdnDecision(email({ "header:Precedence:asText": p })).refusal).toBe("bulk");
}
expect(mdnDecision(email({ "header:List-Id:asText": "<dev.example.com>" })).refusal).toBe("bulk");
});
it("refuses a draft, which was never received", () => {
expect(mdnDecision(email({ keywords: { $draft: true } })).refusal).toBe("draft-or-sent");
});
it("flags a receipt aimed somewhere other than the sender", () => {
const d = mdnDecision(email({ "header:Disposition-Notification-To:asAddresses": [{ name: null, email: "[email protected]" }] }));
expect(d.offer).toBe(true);
expect(d.redirected).toBe(true);
});
it("does not mistake a differently-cased sender for a redirect", () => {
const d = mdnDecision(email({ "header:Disposition-Notification-To:asAddresses": [{ name: null, email: "[email protected]" }] }));
expect(d.redirected).toBe(false);
});
});
const OPTS = {
from: { name: "John Ellis", email: "[email protected]" } as EmailAddress,
to: { name: null, email: "[email protected]" } as EmailAddress,
finalRecipient: "[email protected]",
reportingUa: "mail.example.org; ihasmail 2.0",
now: new Date("2026-08-25T10:30:00Z"),
boundary: "==bnd==",
messageId: "<[email protected]>",
};
describe("the report itself", () => {
const mime = buildMdn({ email: email(), ...OPTS });
it("is a multipart/report of the kind RFC 8098 defines", () => {
expect(mime).toContain("Content-Type: multipart/report; report-type=disposition-notification;");
expect(mime).toContain('boundary="==bnd=="');
expect(mime).toContain("Content-Type: message/disposition-notification");
});
it("marks itself auto-replied, so it does not draw a reply of its own", () => {
expect(mime).toContain("Auto-Submitted: auto-replied");
});
it("reports a manual disposition, because a person chose to send it", () => {
expect(mime).toContain("Disposition: manual-action/MDN-sent-manually; displayed");
});
it("names the recipient and the message being acknowledged", () => {
expect(mime).toContain("Final-Recipient: rfc822;[email protected]");
expect(mime).toContain("Original-Message-ID: <[email protected]>");
expect(mime).toContain("In-Reply-To: <[email protected]>");
});
it("uses CRLF line endings throughout, as a message on the wire must", () => {
expect(mime.includes("\r\n")).toBe(true);
expect(mime.replace(/\r\n/g, "")).not.toContain("\n");
});
it("closes the multipart properly", () => {
expect(mime.trimEnd().endsWith("--==bnd==--")).toBe(true);
});
it("copes with a message that carries no Message-ID", () => {
const bare = buildMdn({ email: email({ messageId: null }), ...OPTS });
expect(bare).not.toContain("Original-Message-ID");
expect(bare).not.toContain("In-Reply-To");
expect(bare).toContain("Disposition: manual-action/MDN-sent-manually; displayed");
});
});
describe("header encoding", () => {
it("leaves plain ASCII alone", () => {
expect(encodeHeaderWord("Read: hello")).toBe("Read: hello");
});
it("encodes non-ASCII rather than putting raw bytes in a header", () => {
const encoded = encodeHeaderWord("Grüße");
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
expect(encoded).not.toContain("ü");
});
it("carries a non-ASCII subject through the built report", () => {
const mime = buildMdn({ email: email({ subject: "Grüße" }), ...OPTS });
const subject = mime.split("\r\n").find((l) => l.startsWith("Subject:"))!;
expect(subject).toMatch(/^Subject: =\?UTF-8\?B\?/);
});
it("quotes a display name that would otherwise break the address", () => {
expect(formatAddressHeader({ name: "Ellis, John", email: "[email protected]" })).toBe('"Ellis, John" <[email protected]>');
expect(formatAddressHeader({ name: null, email: "[email protected]" })).toBe("[email protected]");
expect(formatAddressHeader({ name: "John", email: "[email protected]" })).toBe("John <[email protected]>");
});
});
describe("rfc5322Date", () => {
it("is the format a message header wants, not toUTCString's", () => {
expect(rfc5322Date(new Date("2026-08-25T10:30:00Z"))).toBe("Tue, 25 Aug 2026 10:30:00 +0000");
});
});
describe("transfer encoding", () => {
it("sends an ASCII body as 7bit, untouched", () => {
const mime = buildMdn({ email: email(), ...OPTS });
expect(mime).toContain("Content-Transfer-Encoding: 7bit");
expect(mime).not.toContain("Content-Transfer-Encoding: base64");
expect(mime).toContain("Subject: Quarterly numbers");
});
it("base64s a body that is not ASCII, rather than trusting 8BITMIME end to end", () => {
const mime = buildMdn({ email: email({ subject: "Grüße" }), ...OPTS });
expect(mime).toContain("Content-Transfer-Encoding: base64");
// No raw non-ASCII may survive anywhere in the message.
// eslint-disable-next-line no-control-regex
expect(/^[\x00-\x7F]*$/.test(mime)).toBe(true);
});
it("round-trips the encoded body back to what it said", () => {
const mime = buildMdn({ email: email({ subject: "Grüße" }), ...OPTS });
const part = mime.split("--==bnd==")[1]!;
const b64 = part.split("\r\n\r\n")[1]!.replace(/\r\n/g, "");
const text = new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));
expect(text).toContain("Grüße");
expect(text).toContain("has been displayed");
});
it("keeps the machine-readable part readable, since it is ASCII by construction", () => {
const mime = buildMdn({ email: email(), ...OPTS });
expect(mime).toContain("Disposition: manual-action/MDN-sent-manually; displayed");
});
});