// 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: ". */ 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 = ``; 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"); }