Merge pull request #21 from LINUXexpert-org/read-receipts

Send the read receipt the sender asked for
This commit is contained in:
LINUXexpert.org
2026-08-24 23:01:03 -07:00
committed by GitHub
10 changed files with 569 additions and 4 deletions
+2 -1
View File
@@ -49,6 +49,7 @@ seconds of downtime with nothing lost.
- Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's) - Safe HTML rendering: DOMPurify sanitisation inside a Shadow DOM, **remote images blocked by default** with a per-sender allow-list and an optional **privacy image proxy** (like Gmail's)
- Messages sit on a light card by default, untouched as the sender designed them. *Appearance Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone - Messages sit on a light card by default, untouched as the sender designed them. *Appearance Apply the theme to messages too* lets them follow the app's light/dark theme instead — plain-text mail always does, and with the option on so does HTML mail that brings no colours of its own; mail that styles itself is still left alone
- Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer - Attachments: previews for images/PDF/text, download all, inline `cid:` images, `.eml` export, *Show original*, header viewer
- **Read receipts**: when a sender asks for one, the message offers to send it — a real RFC 8098 `multipart/report`, never automatically. Bulk mail, mailing lists and anything marked `Auto-Submitted` are not offered one at all, and a receipt aimed somewhere other than the sender says so before you send it. Sending is recorded with RFC 3503's `$mdnsent` keyword, so a second look — or another client — knows not to ask again
- Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click - Invitations: `.ics` parts render as an invite card with **Yes/Maybe/No** RSVP (via `CalendarEvent/parse` + iTIP); `.vcf` parts offer *Add to contacts*; `List-Unsubscribe` one-click
- **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address - **Right-click anyone named in a message** — sender, To, Cc, Bcc, Reply-To — to add them to the address book (the contact editor opens prefilled, with the display name split into first/last), edit them if they are already known, write to them, or copy the address
- Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel - Search with Gmail operators (`from:`, `to:`, `subject:`, `has:attachment`, `is:unread`, `is:starred`, `in:`, `label:`, `before:`, `after:`, `larger:`, `smaller:` …) plus an advanced-search panel
@@ -200,6 +201,7 @@ advertised the capability in the wrong place too, which is why nothing caught
it. Fixed, and the mock now advertises it where the real server does — but it. Fixed, and the mock now advertises it where the real server does — but
the registry path is **awaiting live re-verification**. the registry path is **awaiting live re-verification**.
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`); **not yet exercised against the live server**.
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as pre-0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the pre-0.16 code path. It now looks in all three places. Two related soft spots went with it: a transport error while probing the registry no longer downgrades a server to the legacy REST path (which would have posted the current password to an endpoint that is not there), and a locale request that is merely refused no longer discards a generation the capability had already settled. - **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as pre-0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the pre-0.16 code path. It now looks in all three places. Two related soft spots went with it: a transport error while probing the registry no longer downgrades a server to the legacy REST path (which would have posted the current password to an endpoint that is not there), and a locale request that is merely refused no longer discards a generation the capability had already settled.
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline. - **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
- **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete are also confirmed; the older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24). - **Files on Stalwart before 0.16** — three things differ there, none of which the server reports as an error. (Confirmed live on 0.15.5 before the upgrade. The live instance now runs 0.16.19, where folder creation, upload, rename, move and delete are also confirmed; the older path is kept for anyone still on 0.15.x and covered by `npm run dev:mock:legacy`.) `FileNode/query` masks its results to non-containers, so it returns files and **never folders**; `nodeType` does not exist, and sending it fails the create outright (a directory is instead a node with no file properties at all); and rights are only `mayRead`/`mayWrite`/`mayShare`, so the finer-grained `mayDelete`/`mayRename` the UI gates on are absent. ihasmail detects the older server by the absence of `urn:stalwart:jmap` — looked for in `primaryAccounts` and `accountCapabilities` as well as the session capabilities, since that is where 0.16 actually advertises it — lists the tree through `FileNode/get` instead of query, shapes creates accordingly, and widens the old rights. Upload, folder creation, listing, rename, move and delete are all confirmed live on 0.15.5 (2026-08-24).
@@ -212,7 +214,6 @@ the registry path is **awaiting live re-verification**.
## Roadmap / not yet ## Roadmap / not yet
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away) - Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Read-receipt (MDN) sending, S/MIME / OpenPGP
- Translations (strings are English-only for now) - Translations (strings are English-only for now)
## License ## License
+2
View File
@@ -241,6 +241,8 @@ export interface Email {
"header:X-Priority:asText"?: string | null; "header:X-Priority:asText"?: string | null;
"header:Importance:asText"?: string | null; "header:Importance:asText"?: string | null;
"header:Auto-Submitted: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:Return-Path:asText"?: string | null;
"header:Authentication-Results:asText"?: string | null; "header:Authentication-Results:asText"?: string | null;
"header:Received:asText:all"?: string[] | null; "header:Received:asText:all"?: string[] | null;
+188
View File
@@ -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");
});
});
+221
View File
@@ -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}>`;
}
+1
View File
@@ -58,6 +58,7 @@ export const FULL_PROPS = [
"header:X-Priority:asText", "header:X-Priority:asText",
"header:Importance:asText", "header:Importance:asText",
"header:Auto-Submitted:asText", "header:Auto-Submitted:asText",
"header:Precedence:asText",
"header:Authentication-Results:asText", "header:Authentication-Results:asText",
]; ];
+98
View File
@@ -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 } } } };
});
}
+9
View File
@@ -8,6 +8,7 @@ export type Density = "comfortable" | "cozy" | "compact";
export type ReadingPane = "right" | "bottom" | "off"; export type ReadingPane = "right" | "bottom" | "off";
export type ImagePolicy = "ask" | "always" | "contacts"; export type ImagePolicy = "ask" | "always" | "contacts";
export type ComposeFormat = "html" | "text"; export type ComposeFormat = "html" | "text";
export type ReadReceiptPolicy = "ask" | "never";
export interface Template { export interface Template {
id: string; id: string;
@@ -35,6 +36,13 @@ export interface Settings {
signatureAboveQuote: boolean; signatureAboveQuote: boolean;
includeQuote: boolean; includeQuote: boolean;
requestReadReceipt: 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; confirmDelete: boolean;
desktopNotifications: boolean; desktopNotifications: boolean;
notificationSound: boolean; notificationSound: boolean;
@@ -89,6 +97,7 @@ export const DEFAULT_SETTINGS: Settings = {
signatureAboveQuote: true, signatureAboveQuote: true,
includeQuote: true, includeQuote: true,
requestReadReceipt: false, requestReadReceipt: false,
readReceiptPolicy: "ask",
confirmDelete: false, confirmDelete: false,
desktopNotifications: false, desktopNotifications: false,
notificationSound: false, notificationSound: false,
+2
View File
@@ -490,6 +490,8 @@ img { max-width: 100%; }
.remote-banner button { color: inherit; font-weight: 700; text-decoration: underline; } .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 { 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; } .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 { 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); } .quote-toggle:hover { background: var(--bg-active); }
.attachments { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 16px 16px; } .attachments { display: flex; flex-wrap: wrap; gap: 10px; padding: 4px 16px 16px; }
+33 -2
View File
@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { FilterFromMessageDialog } from "./FilterFromMessage";
import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types"; import type { Email, EmailAddress, EmailBodyPart, Id } from "@/jmap/types";
import { useMail } from "@/store/mail"; import { useMail } from "@/store/mail";
@@ -22,6 +22,8 @@ import { AddressList, useAddressMenu } from "./AddressMenu";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useScheduled } from "@/store/scheduled"; import { useScheduled } from "@/store/scheduled";
import { formatScheduleTime } from "@/lib/schedule"; import { formatScheduleTime } from "@/lib/schedule";
import { mdnDecision, refusalText } from "@/lib/mdn";
import { sendReadReceipt } from "@/store/mdn";
interface Props { interface Props {
email: Email; 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 remoteAllowed = allowRemote || settings.imagePolicy === "always" || senderTrusted || (settings.imagePolicy === "contacts" && inContacts);
const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true); const imageProxy = useSession((s) => s.session?.ihasmail?.imageProxy ?? true);
const scheduled = useScheduled((s) => s.pending[e.id]); 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 cancelScheduled = useScheduled((s) => s.cancel);
const htmlPart = e.htmlBody?.[0]; 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.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></>} {e["header:List-Id:asText"] && <><dt>List</dt><dd>{e["header:List-Id:asText"]}</dd></>}
<dt>Size</dt><dd>{formatSize(e.size)}</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> </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 && ( {scheduled && (
<div className="scheduled-banner" style={{ margin: "0 16px 8px" }}> <div className="scheduled-banner" style={{ margin: "0 16px 8px" }}>
<Clock size={16} /> <Clock size={16} />
+13 -1
View File
@@ -1,4 +1,4 @@
import { useSettings } from "@/store/settings"; import { useSettings, type ReadReceiptPolicy } from "@/store/settings";
import { Switch } from "@/ui/misc"; import { Switch } from "@/ui/misc";
import { browserTimeZone, listTimeZones } from "@/lib/dates"; import { browserTimeZone, listTimeZones } from "@/lib/dates";
import { toast } from "@/ui/toast"; 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.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.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" /> <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" /> <Switch checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label="Spell check while typing" />
<h2>Locale</h2> <h2>Locale</h2>