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:
2026-08-24 22:53:06 -07:00
parent be75a02181
commit 3310149fcc
10 changed files with 570 additions and 4 deletions
+33 -2
View File
@@ -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} />