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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user