Feature: read receipts (MDN, RFC 8098)

Bulwark had no read-receipt support (JMAP/Stalwart have no native MDN).
End-to-end, client-side, in three parts:

- Request (compose): a toolbar toggle (MailCheck, green when on) sets
  Disposition-Notification-To on the outgoing message via the JMAP
  "header:<name>:asText" create property. Threaded composer -> page ->
  email-store -> client.sendEmail. Default from requestReadReceiptDefault.

- Detect (viewer): reads Disposition-Notification-To case-insensitively from
  the parsed headers and shows a banner (green Send / red Ignore) in the
  unified notification bar. Hidden in Sent/Drafts/Trash/Junk and once handled.
  message/disposition-notification + message/delivery-status report parts are
  filtered out of the attachment list.

- Respond (MDN): lib/mdn.ts builds an RFC 8098 multipart/report (text/plain +
  message/disposition-notification, UTF-8/base64, localized subject + body).
  client.sendReadReceipt uploads the blob, imports it into Sent via
  Email/import, then submits with an explicit envelope. Both Send and Ignore
  set the $MDNSent keyword (RFC 3503) so no client re-prompts. Behaviour
  configurable: ask / always / never.

New: lib/mdn.ts, read-receipt-banner.tsx. Settings (requestReadReceiptDefault,
readReceiptResponse) + UI. All 17 locales.
This commit is contained in:
dealerweb
2026-05-30 15:58:39 +02:00
committed by Linus Rath
parent 2ba0003e16
commit bebb394f54
28 changed files with 901 additions and 9 deletions
+8
View File
@@ -530,6 +530,14 @@ export class DemoJMAPClient implements IJMAPClient {
return { blobId, size: file.size, type: file.type };
}
async importEmail(): Promise<string | null> {
return generateDemoId('email');
}
async sendReadReceipt(): Promise<void> {
// Demo mode: no real network send.
}
getBlobDownloadUrl(blobId: string): string {
return `data:application/octet-stream;demo-blob=${blobId}`;
}
+22
View File
@@ -150,8 +150,30 @@ export interface IJMAPClient {
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string,
options?: { requestReadReceipt?: boolean },
): Promise<SendEmailResult>;
importEmail(
blobId: string,
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string,
): Promise<string | null>;
sendReadReceipt(params: {
to: string;
fromEmail: string;
fromName?: string;
identityId: string;
originalMessageId?: string | string[];
originalSubject?: string;
originalRecipient?: string;
automatic?: boolean;
accountId?: string;
subject?: string;
humanText?: string;
}): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>;
+111 -1
View File
@@ -2149,7 +2149,8 @@ export class JMAPClient implements IJMAPClient {
inReplyTo?: string[],
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string
envelopeMailFrom?: string,
options?: { requestReadReceipt?: boolean }
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const emailId = `send-${Date.now()}`;
@@ -2219,6 +2220,13 @@ export class JMAPClient implements IJMAPClient {
mailboxIds: { [draftsMailbox.id]: true },
};
if (options?.requestReadReceipt) {
// RFC 8098: ask the recipient's client to return a Message Disposition
// Notification to our address. JMAP lets us set the raw header on create
// via the "header:<Name>:asText" property form.
emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username;
}
if (htmlBody) {
// Send as multipart/alternative with both text and HTML
emailCreate.bodyValues = {
@@ -3007,6 +3015,108 @@ export class JMAPClient implements IJMAPClient {
throw new Error('Invalid upload response: blobId not found');
}
/**
* Import a raw RFC822 message (referenced by a previously-uploaded blob) into
* one or more mailboxes. Returns the new email id. Used for sending MDNs,
* where the exact MIME bytes must be preserved (Email/set can't express a
* multipart/report report-type parameter reliably).
*/
async importEmail(
blobId: string,
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string
): Promise<string | null> {
const targetAccountId = accountId || this.accountId;
const creationId = `imp-${Date.now()}`;
const response = await this.request([
["Email/import", {
accountId: targetAccountId,
emails: {
[creationId]: { blobId, mailboxIds, keywords: keywords || { "$seen": true } },
},
}, "0"],
]);
const res = response.methodResponses?.[0];
if (res?.[0] !== "Email/import") {
console.error('Email/import: unexpected response', res);
return null;
}
const payload = res[1] as {
created?: Record<string, { id: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
};
const created = payload?.created?.[creationId];
if (!created) {
const reason = payload?.notCreated?.[creationId];
console.error('Email/import failed:', reason || payload);
throw new Error(`Email/import: ${reason?.description || reason?.type || 'unknown error'}`);
}
return created.id;
}
/**
* Send an RFC 8098 Message Disposition Notification (read receipt) in reply
* to a message that carried a Disposition-Notification-To header. Builds the
* multipart/report, uploads it as a blob, imports it into Sent, then submits
* it with an explicit envelope (MAIL FROM = our identity, RCPT TO = the
* requesting address).
*/
async sendReadReceipt(params: {
to: string;
fromEmail: string;
fromName?: string;
identityId: string;
originalMessageId?: string | string[];
originalSubject?: string;
originalRecipient?: string;
automatic?: boolean;
accountId?: string;
subject?: string;
humanText?: string;
}): Promise<void> {
const targetAccountId = params.accountId || this.accountId;
const { buildMdnMessage } = await import("@/lib/mdn");
const raw = buildMdnMessage(params);
const file = new File([raw], "receipt.eml", { type: "message/rfc822" });
const { blobId } = await this.uploadBlob(file);
const mailboxes = await this.getMailboxes();
const targetMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes[0];
if (!targetMailbox) throw new Error('No mailbox available for MDN import');
const emailId = await this.importEmail(
blobId,
{ [targetMailbox.id]: true },
{ "$seen": true },
targetAccountId
);
if (!emailId) throw new Error('MDN import failed');
const subId = `mdnsub-${Date.now()}`;
const response = await this.request([
["EmailSubmission/set", {
accountId: targetAccountId,
create: {
[subId]: {
emailId,
identityId: params.identityId,
envelope: {
mailFrom: { email: params.fromEmail },
rcptTo: [{ email: params.to }],
},
},
},
}, "0"],
]);
const subRes = response.methodResponses?.[0];
const notCreated = (subRes?.[1] as { notCreated?: Record<string, { type?: string; description?: string }> })?.notCreated?.[subId];
if (notCreated) {
throw new Error(`MDN submission failed: ${notCreated.description || notCreated.type || 'unknown'}`);
}
}
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
if (!this.downloadUrl) {
throw new Error('Download URL not available. Please reconnect.');
+154
View File
@@ -0,0 +1,154 @@
// Builds an RFC 8098 Message Disposition Notification (MDN) as a raw RFC 5322
// message string. JMAP/Stalwart has no native MDN support, so the client
// constructs the multipart/report itself and sends it via
// blob-upload -> Email/import -> EmailSubmission/set (see client.sendReadReceipt).
//
// The message has two parts:
// 1. text/plain — human-readable explanation (English, ASCII; rarely shown)
// 2. message/disposition-notification — the machine-readable fields
// The optional third part (original message/headers) is omitted; RFC 8098 §3.1
// permits a two-part report.
export interface MdnOptions {
/** Address that requested the receipt (Disposition-Notification-To) — the MDN recipient. */
to: string;
/** Our identity address (sender of the MDN). */
fromEmail: string;
/** Optional display name for the From header. */
fromName?: string;
/** Original Message-ID. JMAP may hand this back as a string[]
* (header:Message-ID:asMessageIds), so accept both. */
originalMessageId?: string | string[];
/** Original Subject (used to build the MDN subject). */
originalSubject?: string;
/**
* The address the original message was delivered to (our address/alias).
* Used for Final-Recipient/Original-Recipient. Falls back to fromEmail.
*/
originalRecipient?: string;
/** true => automatic-action (setting "always"); false => manual-action (user clicked send). */
automatic?: boolean;
/** Reporting-UA value, e.g. "mail.dornig.de; Bulwark Webmail". */
reportingUa?: string;
/** Localized full Subject line. Defaults to "Read: <originalSubject>". */
subject?: string;
/** Localized human-readable explanation (first report part). Defaults to English. */
humanText?: string;
}
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 in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
function rfc5322Date(d: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
}
/** UTF-8 string -> base64, without the deprecated unescape(). */
function utf8ToBase64(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
function base64Body(text: string): string {
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
}
/** RFC 2047 encoded-word for header values that contain non-ASCII characters. */
function encodeHeaderWord(value: string): string {
// eslint-disable-next-line no-control-regex
if (!/[^\x00-\x7F]/.test(value)) return value;
return `=?UTF-8?B?${utf8ToBase64(value)}?=`;
}
function ensureAngles(messageId: string | string[] | undefined): string {
// JMAP often returns Message-ID as a string[] (header:...:asMessageIds), so
// normalize string | string[] | undefined down to a single bracketed id.
const raw = Array.isArray(messageId) ? messageId[0] : messageId;
if (typeof raw !== "string") return "";
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
}
function randomToken(): string {
const rnd = Math.random().toString(36).slice(2);
return `${Date.now().toString(36)}.${rnd}`;
}
/**
* Build the raw RFC 5322 MDN message. Lines are CRLF-terminated as required
* by the MIME standard so the bytes import/transmit verbatim.
*/
export function buildMdnMessage(opts: MdnOptions): string {
const finalRecipient = opts.originalRecipient || opts.fromEmail;
const domain = (opts.fromEmail.split("@")[1] || "localhost").trim();
const messageId = `<mdn.${randomToken()}@${domain}>`;
const boundary = `----=_MDN_${randomToken()}`;
const origMsgId = ensureAngles(opts.originalMessageId); // normalized "<...>" or ""
const fromHeader = opts.fromName
? `${encodeHeaderWord(opts.fromName)} <${opts.fromEmail}>`
: opts.fromEmail;
const subject = encodeHeaderWord(
opts.subject ?? `Read: ${opts.originalSubject || ""}`.trim()
);
const disposition = opts.automatic
? "automatic-action/MDN-sent-automatically; displayed"
: "manual-action/MDN-sent-manually; displayed";
const reportingUa = opts.reportingUa || `${domain}; Bulwark Webmail`;
// Human-readable part. Caller passes a localized humanText; fall back to
// English. Encoded as UTF-8/base64 below so any language survives.
const humanText = opts.humanText ?? [
`This is a return receipt for the message you sent to ${finalRecipient}.`,
``,
`Note: This receipt only acknowledges that the message was displayed on the`,
`recipient's computer. There is no guarantee that the recipient has read or`,
`understood the message contents.`,
].join("\r\n");
// Machine-readable disposition-notification part (pure ASCII tokens).
const mdnFields = [
`Reporting-UA: ${reportingUa}`,
`Final-Recipient: rfc822;${finalRecipient}`,
...(opts.originalRecipient ? [`Original-Recipient: rfc822;${opts.originalRecipient}`] : []),
...(origMsgId ? [`Original-Message-ID: ${origMsgId}`] : []),
`Disposition: ${disposition}`,
].join("\r\n");
return [
`Date: ${rfc5322Date()}`,
`From: ${fromHeader}`,
`To: ${opts.to}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
...(origMsgId ? [`In-Reply-To: ${origMsgId}`] : []),
`MIME-Version: 1.0`,
`Content-Type: multipart/report; report-type=disposition-notification;`,
`\tboundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
`Content-Transfer-Encoding: base64`,
``,
base64Body(humanText),
``,
`--${boundary}`,
`Content-Type: message/disposition-notification`,
`Content-Transfer-Encoding: 7bit`,
``,
mdnFields,
``,
`--${boundary}--`,
``,
].join("\r\n");
}