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:
+111
-1
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user