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:
@@ -133,7 +133,7 @@ interface EmailStore {
|
||||
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||
fetchQuota: (client: IJMAPClient) => Promise<void>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise<SendEmailResult>;
|
||||
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean }) => Promise<SendEmailResult>;
|
||||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||
@@ -841,10 +841,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom) => {
|
||||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom);
|
||||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options);
|
||||
// Refresh handled by UI layer for immediate feedback
|
||||
set({
|
||||
isLoading: false,
|
||||
|
||||
@@ -31,6 +31,8 @@ export type ListDensity = Density;
|
||||
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
|
||||
export type ReplyMode = 'reply' | 'replyAll';
|
||||
export type SignaturePosition = 'above_quote' | 'below_quote';
|
||||
/** How to handle an incoming Disposition-Notification-To (read-receipt) request. */
|
||||
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
|
||||
export type DateFormat = 'smart' | 'relative' | 'full';
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
@@ -157,6 +159,8 @@ interface SettingsState {
|
||||
sendDelaySeconds: SendDelaySeconds;
|
||||
signaturePosition: SignaturePosition; // Position of the signature relative to quoted text in replies/forwards
|
||||
signatureSeparatorEnabled: boolean; // Prefix the signature with the RFC 3676 "-- " delimiter
|
||||
requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer
|
||||
readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
@@ -332,6 +336,8 @@ const DEFAULT_SETTINGS = {
|
||||
sendDelaySeconds: 0 as SendDelaySeconds,
|
||||
signaturePosition: 'below_quote' as SignaturePosition,
|
||||
signatureSeparatorEnabled: true,
|
||||
requestReadReceiptDefault: false,
|
||||
readReceiptResponse: 'ask' as ReadReceiptResponse,
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: 0, // Never
|
||||
@@ -526,6 +532,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
sendDelaySeconds: state.sendDelaySeconds,
|
||||
signaturePosition: state.signaturePosition,
|
||||
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
||||
requestReadReceiptDefault: state.requestReadReceiptDefault,
|
||||
readReceiptResponse: state.readReceiptResponse,
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
||||
emailNotificationSound: state.emailNotificationSound,
|
||||
|
||||
Reference in New Issue
Block a user