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.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user