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:
@@ -241,6 +241,8 @@ export interface Email {
|
||||
"header:X-Priority:asText"?: string | null;
|
||||
"header:Importance:asText"?: string | null;
|
||||
"header:Auto-Submitted:asText"?: string | null;
|
||||
/** Bulk/list mail marks itself here; read receipts for it only confirm the address. */
|
||||
"header:Precedence:asText"?: string | null;
|
||||
"header:Return-Path:asText"?: string | null;
|
||||
"header:Authentication-Results:asText"?: string | null;
|
||||
"header:Received:asText:all"?: string[] | null;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Read receipts (Message Disposition Notifications, RFC 8098).
|
||||
*
|
||||
* JMAP has an extension for this -- RFC 9007's `MDN/send` -- and Stalwart does
|
||||
* not implement it: `urn:ietf:params:jmap:mdn` is absent from its capability
|
||||
* list. So ihasmail builds the report itself and sends it like any other
|
||||
* message: raw MIME, uploaded as a blob, imported, submitted.
|
||||
*
|
||||
* The plumbing is the easy half. A read receipt tells a stranger that a
|
||||
* specific address is live, was read, and when -- which is precisely what
|
||||
* a spammer wants to learn, and the sender chooses the address it goes to.
|
||||
* The rules in `mdnDecision` below are what keep that from being automatic.
|
||||
*/
|
||||
import type { Email, EmailAddress } from "@/jmap/types";
|
||||
import { formatAddress, sameAddress } from "./address";
|
||||
|
||||
/** RFC 3503: set on the original once a receipt has been sent for it. */
|
||||
export const MDN_SENT_KEYWORD = "$mdnsent";
|
||||
|
||||
export type MdnRefusal =
|
||||
| "not-requested"
|
||||
| "already-sent"
|
||||
| "auto-submitted"
|
||||
| "bulk"
|
||||
| "draft-or-sent";
|
||||
|
||||
export interface MdnDecision {
|
||||
/** Whether to offer the receipt at all. */
|
||||
offer: boolean;
|
||||
/** Why it is not being offered. */
|
||||
refusal?: MdnRefusal;
|
||||
/** Where the sender asked the receipt to go. */
|
||||
to?: EmailAddress;
|
||||
/**
|
||||
* Set when the receipt would go somewhere other than who the mail came from.
|
||||
* Legitimate but abused: it is how a sender routes the confirmation to an
|
||||
* address that never appeared in the message, so the user is told.
|
||||
*/
|
||||
redirected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to offer to send a receipt for this message, and what to warn about.
|
||||
*
|
||||
* Refusals are deliberate rather than conservative-by-accident:
|
||||
* - RFC 3834 forbids replying to anything marked `Auto-Submitted` other than
|
||||
* `no`, which is what stops two servers answering each other forever.
|
||||
* - Bulk and list mail asks for receipts to confirm addresses, not to be
|
||||
* polite. `Precedence: bulk/list/junk` and a `List-Id` both say so.
|
||||
* - A message that never arrived -- our own draft or sent copy -- has no
|
||||
* disposition to report.
|
||||
*/
|
||||
export function mdnDecision(email: Email): MdnDecision {
|
||||
const to = email["header:Disposition-Notification-To:asAddresses"]?.[0];
|
||||
if (!to?.email) return { offer: false, refusal: "not-requested" };
|
||||
if (email.keywords?.[MDN_SENT_KEYWORD]) return { offer: false, refusal: "already-sent", to };
|
||||
if (email.keywords?.$draft) return { offer: false, refusal: "draft-or-sent", to };
|
||||
|
||||
const auto = (email["header:Auto-Submitted:asText"] ?? "").trim().toLowerCase();
|
||||
// "auto-submitted: no" is the only value that means a person sent it.
|
||||
if (auto && !auto.startsWith("no")) return { offer: false, refusal: "auto-submitted", to };
|
||||
|
||||
const precedence = (email["header:Precedence:asText"] ?? "").trim().toLowerCase();
|
||||
if (["bulk", "list", "junk"].includes(precedence)) return { offer: false, refusal: "bulk", to };
|
||||
if (email["header:List-Id:asText"]) return { offer: false, refusal: "bulk", to };
|
||||
|
||||
const from = email.from?.[0];
|
||||
const redirected = !from || !sameAddress(from.email, to.email);
|
||||
return { offer: true, to, redirected };
|
||||
}
|
||||
|
||||
/** Why we are not offering, in words for the message header. */
|
||||
export function refusalText(refusal: MdnRefusal): string {
|
||||
switch (refusal) {
|
||||
case "already-sent":
|
||||
return "A read receipt was already sent for this message.";
|
||||
case "auto-submitted":
|
||||
return "This message was sent automatically, so no read receipt is offered.";
|
||||
case "bulk":
|
||||
return "This is bulk or list mail; read receipts for it only confirm the address is live.";
|
||||
case "draft-or-sent":
|
||||
return "This message has not been received, so there is nothing to report.";
|
||||
default:
|
||||
return "The sender did not request a read receipt.";
|
||||
}
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** RFC 5322 date-time, which is not what toUTCString produces. */
|
||||
export function rfc5322Date(d: Date): string {
|
||||
return `${DAYS[d.getUTCDay()]}, ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a header value that may carry non-ASCII, as RFC 2047 base64.
|
||||
* Everything here is UTF-8, so the alternative is a mangled subject line.
|
||||
*/
|
||||
export function encodeHeaderWord(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^[\x20-\x7E]*$/.test(value)) return value;
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return `=?UTF-8?B?${btoa(bin)}?=`;
|
||||
}
|
||||
|
||||
/** Fold a header so no line runs past the 998-octet limit RFC 5322 sets. */
|
||||
function headerLine(name: string, value: string): string {
|
||||
return `${name}: ${value}`;
|
||||
}
|
||||
|
||||
export interface MdnOptions {
|
||||
/** The message being acknowledged. */
|
||||
email: Email;
|
||||
/** The identity acknowledging it. */
|
||||
from: EmailAddress;
|
||||
/** Where the receipt goes, from `Disposition-Notification-To`. */
|
||||
to: EmailAddress;
|
||||
/** Which of our addresses the original was delivered to. */
|
||||
finalRecipient: string;
|
||||
/** Names the software in the report, as RFC 8098 asks. */
|
||||
reportingUa: string;
|
||||
now: Date;
|
||||
/** Distinguishes one report from another. */
|
||||
boundary: string;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full MDN as raw MIME: a `multipart/report` carrying a sentence for the
|
||||
* person and a `message/disposition-notification` for their mail client.
|
||||
*
|
||||
* `Auto-Submitted: auto-replied` is not decoration -- it is what stops the
|
||||
* receipt itself drawing a reply, and what other implementations look for.
|
||||
*/
|
||||
export function buildMdn(opts: MdnOptions): string {
|
||||
const { email, from, to, finalRecipient, reportingUa, now, boundary, messageId } = opts;
|
||||
const subject = email.subject ?? "";
|
||||
const originalId = email.messageId?.[0] ?? null;
|
||||
const sentOn = email.sentAt ?? email.receivedAt ?? null;
|
||||
|
||||
const human = [
|
||||
`Your message to ${formatAddress(from)} has been displayed.`,
|
||||
"",
|
||||
`Subject: ${subject}`,
|
||||
...(sentOn ? [`Sent: ${rfc5322Date(new Date(sentOn))}`] : []),
|
||||
"",
|
||||
"This is a receipt for the message you requested one for. It says only that",
|
||||
"the message was displayed on the recipient's computer. There is no",
|
||||
"guarantee that it has been read or understood.",
|
||||
].join("\r\n");
|
||||
|
||||
const report = [
|
||||
`Reporting-UA: ${reportingUa}`,
|
||||
`Final-Recipient: rfc822;${finalRecipient}`,
|
||||
...(originalId ? [`Original-Message-ID: ${originalId}`] : []),
|
||||
// manual-action/MDN-sent-manually: a person chose to send this, which is
|
||||
// the only mode ihasmail offers.
|
||||
"Disposition: manual-action/MDN-sent-manually; displayed",
|
||||
].join("\r\n");
|
||||
|
||||
const headers = [
|
||||
headerLine("Date", rfc5322Date(now)),
|
||||
headerLine("From", formatAddressHeader(from)),
|
||||
headerLine("To", formatAddressHeader(to)),
|
||||
headerLine("Subject", encodeHeaderWord(`Read: ${subject}`)),
|
||||
headerLine("Message-ID", messageId),
|
||||
...(originalId ? [headerLine("In-Reply-To", originalId), headerLine("References", originalId)] : []),
|
||||
headerLine("Auto-Submitted", "auto-replied"),
|
||||
headerLine("MIME-Version", "1.0"),
|
||||
headerLine("Content-Type", `multipart/report; report-type=disposition-notification;\r\n\tboundary="${boundary}"`),
|
||||
].join("\r\n");
|
||||
|
||||
return [
|
||||
headers,
|
||||
"",
|
||||
"This is a message in MIME format; parts of it are for your mail program.",
|
||||
"",
|
||||
`--${boundary}`,
|
||||
...encodedPart("text/plain; charset=utf-8", human),
|
||||
"",
|
||||
`--${boundary}`,
|
||||
// The machine-readable part is ASCII by construction: addresses and
|
||||
// fixed keywords only.
|
||||
...encodedPart("message/disposition-notification", report),
|
||||
"",
|
||||
`--${boundary}--`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* A body part, encoded so it survives a hop through a mail server that never
|
||||
* agreed to carry 8-bit content. Pure ASCII goes as-is; anything else is
|
||||
* base64, which is always safe and costs nothing here.
|
||||
*/
|
||||
function encodedPart(contentType: string, body: string): string[] {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/^[\x00-\x7F]*$/.test(body)) {
|
||||
return [`Content-Type: ${contentType}`, "Content-Transfer-Encoding: 7bit", "", body];
|
||||
}
|
||||
const bytes = new TextEncoder().encode(body);
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
const b64 = btoa(bin).replace(/(.{76})/g, "$1\r\n");
|
||||
return [`Content-Type: ${contentType}`, "Content-Transfer-Encoding: base64", "", b64];
|
||||
}
|
||||
|
||||
/** `Name <addr>` with the display name encoded and quoted when it has to be. */
|
||||
export function formatAddressHeader(a: EmailAddress): string {
|
||||
if (!a.name) return a.email;
|
||||
const encoded = encodeHeaderWord(a.name);
|
||||
const needsQuotes = encoded === a.name && /[(),.:;<>@[\]\\"]/.test(a.name);
|
||||
return `${needsQuotes ? `"${a.name.replace(/(["\\])/g, "\\$1")}"` : encoded} <${a.email}>`;
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export const FULL_PROPS = [
|
||||
"header:X-Priority:asText",
|
||||
"header:Importance:asText",
|
||||
"header:Auto-Submitted:asText",
|
||||
"header:Precedence:asText",
|
||||
"header:Authentication-Results:asText",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { client, setErrorMessage } from "@/jmap/client";
|
||||
import type { Email, EmailAddress, Id, SetResponse } from "@/jmap/types";
|
||||
import { sameAddress } from "@/lib/address";
|
||||
import { buildMdn, mdnDecision, MDN_SENT_KEYWORD } from "@/lib/mdn";
|
||||
import { uid } from "@/lib/format";
|
||||
import { useMail } from "./mail";
|
||||
|
||||
/**
|
||||
* Send the read receipt the sender asked for.
|
||||
*
|
||||
* Stalwart has no `MDN/send` (RFC 9007 is not among its capabilities), so the
|
||||
* report is built as raw MIME and posted the long way round: upload it as a
|
||||
* blob, import it so it has an id, then submit it like any other message.
|
||||
*
|
||||
* Never call this without the user having chosen it for this message --
|
||||
* `mdnDecision` says whether it may even be offered.
|
||||
*/
|
||||
export async function sendReadReceipt(email: Email): Promise<void> {
|
||||
const mail = useMail.getState();
|
||||
const accountId = mail.accountId;
|
||||
if (!accountId) throw new Error("Not signed in");
|
||||
|
||||
const decision = mdnDecision(email);
|
||||
if (!decision.offer || !decision.to) throw new Error("No read receipt is due for this message");
|
||||
|
||||
// Answer as whichever identity the message was addressed to, so the receipt
|
||||
// comes from the address the sender wrote to rather than a default that may
|
||||
// be a different persona entirely.
|
||||
const addressed = [...(email.to ?? []), ...(email.cc ?? []), ...(email.bcc ?? [])];
|
||||
const identity =
|
||||
mail.identities.find((i) => addressed.some((a) => sameAddress(a.email, i.email))) ?? mail.identities[0];
|
||||
if (!identity) throw new Error("No sending identity available");
|
||||
|
||||
const from: EmailAddress = { name: identity.name || null, email: identity.email };
|
||||
const host = window.location.hostname || "localhost";
|
||||
const mime = buildMdn({
|
||||
email,
|
||||
from,
|
||||
to: decision.to,
|
||||
finalRecipient: identity.email,
|
||||
reportingUa: `${host}; ihasmail 2.0`,
|
||||
now: new Date(),
|
||||
boundary: `==ihasmail-${uid("b")}==`,
|
||||
messageId: `<${uid("mdn")}.${Date.now()}@${host}>`,
|
||||
});
|
||||
|
||||
const blob = new Blob([mime], { type: "message/rfc822" });
|
||||
const uploaded = await client.upload(accountId, blob, { type: "message/rfc822" });
|
||||
|
||||
// It has to live somewhere to be submitted; Sent is where it honestly belongs.
|
||||
const sentId = mail.roleId("sent") ?? mail.roleId("archive") ?? mail.roleId("inbox");
|
||||
if (!sentId) throw new Error("No folder to file the receipt in");
|
||||
const mdnId = await mail.importEml(uploaded.blobId, sentId, { $seen: true });
|
||||
if (!mdnId) throw new Error("The server would not accept the receipt");
|
||||
|
||||
const res = await client.chain(
|
||||
[
|
||||
[
|
||||
"EmailSubmission/set",
|
||||
{
|
||||
accountId,
|
||||
create: {
|
||||
s: {
|
||||
identityId: identity.id,
|
||||
emailId: mdnId,
|
||||
envelope: { mailFrom: { email: identity.email }, rcptTo: [{ email: decision.to.email }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
"s",
|
||||
],
|
||||
// RFC 3503's keyword, set on the original rather than remembered locally,
|
||||
// so a second look -- or another client entirely -- knows not to ask again.
|
||||
["Email/set", { accountId, update: { [email.id]: { [`keywords/${MDN_SENT_KEYWORD}`]: true } } }, "k"],
|
||||
],
|
||||
{ allowErrors: true },
|
||||
);
|
||||
|
||||
const sub = res.get("s")?.[0] as unknown as SetResponse & { __error?: { type: string; description?: string } };
|
||||
if (sub.__error) throw new Error(setErrorMessage(sub.__error));
|
||||
if (sub.notCreated?.s) {
|
||||
// Do not leave an unsent receipt sitting in Sent looking like it went.
|
||||
void client.call("Email/set", { accountId, destroy: [mdnId] });
|
||||
throw new Error(setErrorMessage(sub.notCreated.s));
|
||||
}
|
||||
|
||||
markSent(email.id);
|
||||
void mail.loadMailboxes();
|
||||
}
|
||||
|
||||
/** Reflect the keyword locally so the banner goes at once. */
|
||||
function markSent(emailId: Id): void {
|
||||
useMail.setState((s) => {
|
||||
const cur = s.emails[emailId];
|
||||
if (!cur) return {};
|
||||
return { emails: { ...s.emails, [emailId]: { ...cur, keywords: { ...cur.keywords, [MDN_SENT_KEYWORD]: true } } } };
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type Density = "comfortable" | "cozy" | "compact";
|
||||
export type ReadingPane = "right" | "bottom" | "off";
|
||||
export type ImagePolicy = "ask" | "always" | "contacts";
|
||||
export type ComposeFormat = "html" | "text";
|
||||
export type ReadReceiptPolicy = "ask" | "never";
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
@@ -35,6 +36,13 @@ export interface Settings {
|
||||
signatureAboveQuote: boolean;
|
||||
includeQuote: boolean;
|
||||
requestReadReceipt: boolean;
|
||||
/**
|
||||
* What to do when a sender asks for a read receipt. There is deliberately no
|
||||
* "always": an automatic receipt confirms to whoever asked that the address
|
||||
* is live and when it was read, which is exactly what a sender who should
|
||||
* not have that is fishing for. RFC 8098 asks that a person decide each one.
|
||||
*/
|
||||
readReceiptPolicy: ReadReceiptPolicy;
|
||||
confirmDelete: boolean;
|
||||
desktopNotifications: boolean;
|
||||
notificationSound: boolean;
|
||||
@@ -89,6 +97,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
signatureAboveQuote: true,
|
||||
includeQuote: true,
|
||||
requestReadReceipt: false,
|
||||
readReceiptPolicy: "ask",
|
||||
confirmDelete: false,
|
||||
desktopNotifications: false,
|
||||
notificationSound: false,
|
||||
|
||||
@@ -490,6 +490,8 @@ img { max-width: 100%; }
|
||||
.remote-banner button { color: inherit; font-weight: 700; text-decoration: underline; }
|
||||
.scheduled-banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; padding: 8px 12px; background: var(--accent-soft); color: var(--accent-soft-fg); border-radius: var(--radius-sm); font-size: .9em; }
|
||||
.scheduled-banner button { color: inherit; font-weight: 700; text-decoration: underline; }
|
||||
.receipt-banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; padding: 8px 12px; background: var(--warn-soft); color: var(--warn); border-radius: var(--radius-sm); font-size: .9em; }
|
||||
.receipt-banner button { color: inherit; font-weight: 700; text-decoration: underline; }
|
||||
.quote-toggle { display: inline-flex; align-items: center; gap: 4px; margin: 8px 0; padding: 2px 10px; border-radius: 999px; background: var(--bg-sunken); color: var(--fg-muted); font-size: 12px; border: 1px solid var(--border); }
|
||||
.quote-toggle:hover { background: var(--bg-active); }
|
||||
.attachments { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 16px 16px; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Clock, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Download, ExternalLink, Forward, MoreVertical, Printer, Reply, ReplyAll, Star, Trash2, Code, FileText, Image as ImageIcon, File, Eye, Calendar, UserPlus, ShieldAlert, Mail, Ban, Clock, CheckCheck, Paperclip, FileArchive, FileSpreadsheet, Film, Music, Filter } from "lucide-react";
|
||||
import { FilterFromMessageDialog } from "./FilterFromMessage";
|
||||
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
|
||||
import { useMail } from "@/store/mail";
|
||||
@@ -22,6 +22,8 @@ import { AddressList, useAddressMenu } from "./AddressMenu";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useScheduled } from "@/store/scheduled";
|
||||
import { formatScheduleTime } from "@/lib/schedule";
|
||||
import { mdnDecision, refusalText } from "@/lib/mdn";
|
||||
import { sendReadReceipt } from "@/store/mdn";
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -50,6 +52,8 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
const remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts);
|
||||
const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true);
|
||||
const scheduled = useScheduled((s) => s.pending[e.id]);
|
||||
const receipt = useMemo(() => mdnDecision(e), [e]);
|
||||
const [receiptDone, setReceiptDone] = useState<"sending" | "dismissed" | null>(null);
|
||||
const cancelScheduled = useScheduled((s) => s.cancel);
|
||||
|
||||
const htmlPart = e.htmlBody?.[0];
|
||||
@@ -201,9 +205,36 @@ export const MessageView = memo(function MessageView({ email: e, expanded, onTog
|
||||
{e.messageId?.[0] && <><dt>Message-ID</dt><dd className="mono small">{e.messageId[0]}</dd></>}
|
||||
{e["header:List-Id:asText"] && <><dt>List</dt><dd>{e["header:List-Id:asText"]}</dd></>}
|
||||
<dt>Size</dt><dd>{formatSize(e.size)}</dd>
|
||||
{receiptRequested && <><dt>Receipt</dt><dd>The sender requested a read receipt (not sent automatically).</dd></>}
|
||||
{receiptRequested && <><dt>Receipt</dt><dd>{receipt.offer ? `Requested, to ${receipt.to!.email}. Never sent automatically.` : refusalText(receipt.refusal!)}</dd></>}
|
||||
</dl>
|
||||
)}
|
||||
{receipt.offer && settings.readReceiptPolicy !== "never" && receiptDone !== "dismissed" && (
|
||||
<div className="receipt-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<CheckCheck size={16} />
|
||||
<span className="grow">
|
||||
The sender asked for a read receipt.
|
||||
{receipt.redirected && (
|
||||
<> It would go to <strong>{receipt.to!.email}</strong>, which is not where the message came from.</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
disabled={receiptDone === "sending"}
|
||||
onClick={async () => {
|
||||
setReceiptDone("sending");
|
||||
try {
|
||||
await sendReadReceipt(e);
|
||||
toast.success("Read receipt sent");
|
||||
} catch (err) {
|
||||
setReceiptDone(null);
|
||||
toast.error(`Could not send the receipt: ${(err as Error).message}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{receiptDone === "sending" ? "Sending…" : "Send receipt"}
|
||||
</button>
|
||||
<button onClick={() => setReceiptDone("dismissed")}>Not this time</button>
|
||||
</div>
|
||||
)}
|
||||
{scheduled && (
|
||||
<div className="scheduled-banner" style={{ margin: "0 16px 8px" }}>
|
||||
<Clock size={16} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useSettings, type ReadReceiptPolicy } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
import { browserTimeZone, listTimeZones } from "@/lib/dates";
|
||||
import { toast } from "@/ui/toast";
|
||||
@@ -113,6 +113,18 @@ export function GeneralSettings() {
|
||||
<Switch checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label="Place signature above quoted text" />
|
||||
<Switch checked={s.attachmentReminder} onChange={(v) => update({ attachmentReminder: v })} label="Attachment reminder" hint="Warn when the message mentions an attachment but none is attached." />
|
||||
<Switch checked={s.requestReadReceipt} onChange={(v) => update({ requestReadReceipt: v })} label="Always request read receipts" />
|
||||
<div className="field">
|
||||
<label>When someone requests a read receipt</label>
|
||||
<select className="select" value={s.readReceiptPolicy} onChange={(e) => update({ readReceiptPolicy: e.target.value as ReadReceiptPolicy })}>
|
||||
<option value="ask">Ask me on each message</option>
|
||||
<option value="never">Never send one</option>
|
||||
</select>
|
||||
<p className="hint">
|
||||
A receipt tells whoever asked that this address is live and when the message was read, and the sender
|
||||
chooses where it goes — so there is no automatic option. Bulk mail, mailing lists and anything marked
|
||||
auto-submitted are never offered one at all.
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label="Spell check while typing" />
|
||||
|
||||
<h2>Locale</h2>
|
||||
|
||||
Reference in New Issue
Block a user