diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index a7c57c61..d9ab5b77 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1130,6 +1130,7 @@ export default function Home() { inReplyTo?: string[]; references?: string[]; delayedUntil?: string; + requestReadReceipt?: boolean; }) => { if (!client) return; @@ -1137,7 +1138,7 @@ export default function Home() { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; - const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom); + const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt }); setShowComposer(false); if (result.scheduled) { await refreshScheduledMetadata(client); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index bf80385c..acdf43fa 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -90,6 +90,7 @@ interface EmailComposerProps { inReplyTo?: string[]; references?: string[]; delayedUntil?: string; + requestReadReceipt?: boolean; }) => void | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; @@ -206,6 +207,7 @@ export function EmailComposer({ const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); + const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault); const activeIdentities = useIdentityStore((s) => s.identities); // Pro shell: surface identities from every connected account, grouped // for the From dropdown's s. Outside Pro this collapses to @@ -384,6 +386,7 @@ export function EmailComposer({ const [body, setBody] = useState(initialData?.body ?? getInitialBody()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc()); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); + const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault); const [draftId, setDraftId] = useState(initialData?.draftId ?? null); // Mirror of draftId for synchronous reads inside chained saves; React's // setDraftId is async, so a queued saveDraft would otherwise see the old @@ -1656,6 +1659,7 @@ export function EmailComposer({ attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, inReplyTo: threadingHeaders?.inReplyTo, references: threadingHeaders?.references, + requestReadReceipt, delayedUntil: effectiveDelayedUntil, }); @@ -2217,7 +2221,7 @@ export function EmailComposer({ )} {/* Bottom toolbar */} -
+
{/* Left side actions */}
)} + + {/* Read-receipt request toggle */} +
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0f5b25e6..08b6d603 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -83,6 +83,8 @@ import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; +import { ReadReceiptBanner } from "./read-receipt-banner"; +import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useTour } from "@/components/tour/tour-provider"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; @@ -911,6 +913,7 @@ export function EmailViewer({ const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); + const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const dragOutActive = useMemo(() => isDragOutSupported(), []); @@ -2193,6 +2196,9 @@ export function EmailViewer({ // Hide inline cid-referenced images when the user has opted to keep them // out of the attachment list (default on): these are embedded in the body. .filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) + // Hide machine-readable report parts (MDN read-receipts, DSN bounce + // reports). These are required MIME parts, not real user attachments. + .filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status') .map((attachment, index) => ({ id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, name: attachment.name || null, @@ -3252,6 +3258,103 @@ export function EmailViewer({ ? calendarInvitationParsingEnabled && !!findCalendarAttachment(email) : false; + // ── Read receipt (MDN, RFC 8098) ────────────────────────────── + // Detect a Disposition-Notification-To request on the open message. The + // header is parsed into email.headers by the client; look it up + // case-insensitively and extract the bare address. + const readReceiptRequestedBy = useMemo(() => { + const headers = email?.headers as Record | undefined; + if (!headers) return null; + let raw: string | undefined; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'disposition-notification-to') { + const v = headers[key]; + raw = Array.isArray(v) ? v[0] : v; + break; + } + } + if (!raw) return null; + const m = raw.match(/<([^>]+)>/); + const addr = (m ? m[1] : raw).trim(); + return addr || null; + }, [email?.headers]); + + // The identity whose address received the original message — the MDN is sent + // "from" that address. Falls back to the primary identity. + const receiptIdentity = useMemo(() => { + if (!identities?.length) return null; + const recipients = [...(email?.to || []), ...(email?.cc || [])] + .map(r => r.email?.toLowerCase()) + .filter(Boolean); + return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0]; + }, [identities, email?.to, email?.cc]); + + const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true; + const [mdnHandledLocally, setMdnHandledLocally] = useState(false); + useEffect(() => { setMdnHandledLocally(false); }, [email?.id]); + + // Only offer the receipt for mail you're actually reading in a "received" + // location. Suppress your own copies (sent/drafts), discarded mail (trash), + // and spam (junk) - never confirm your address to spammers. Inbox, Archive + // and user folders all qualify. + const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || ''); + + const shouldOfferReadReceipt = + !!readReceiptRequestedBy && + !mdnAlreadyHandled && + !mdnHandledLocally && + readReceiptResponse !== 'never' && + inReceiptEligibleFolder && + !isDraft && + !!receiptIdentity; + + const sendReadReceiptNow = useCallback(async (automatic: boolean) => { + if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return; + const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id); + try { + await client.sendReadReceipt({ + to: readReceiptRequestedBy, + fromEmail: receiptIdentity.email, + fromName: receiptIdentity.name, + identityId: rawId ?? receiptIdentity.id, + originalMessageId: email.messageId, + originalSubject: email.subject, + originalRecipient: receiptIdentity.email, + automatic, + subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }), + humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }), + }); + await client.setKeyword(email.id, '$mdnsent'); + } catch (err) { + // Surface the failure instead of silently resetting the banner so we can + // see which step (upload / import / submission) failed. + console.error('Read-receipt (MDN) send failed:', err); + toast.error(t('read_receipt.send_failed'), { + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } + }, [client, email, readReceiptRequestedBy, receiptIdentity, t]); + + const ignoreReadReceipt = useCallback(async () => { + setMdnHandledLocally(true); + if (client && email) { + // $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the + // request is suppressed everywhere - not just locally. + try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ } + } + }, [client, email]); + + // "always" mode: auto-send the MDN once when the message is opened. + const autoMdnRef = useRef(null); + useEffect(() => { + if (readReceiptResponse !== 'always') return; + if (!shouldOfferReadReceipt || !email) return; + if (autoMdnRef.current === email.id) return; + autoMdnRef.current = email.id; + sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; }); + }, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]); + // Show loading skeleton while email is being fetched if (isLoading && !email) { return ( @@ -4946,9 +5049,10 @@ export function EmailViewer({ error={smimeUnlockError} /> - {/* Unified Notification Banner - External Content + Calendar Invitation */} + {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || - hasCalendarInvitation) && ( + hasCalendarInvitation || + (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
@@ -5003,6 +5107,17 @@ export function EmailViewer({ + {/* Read-receipt (MDN) request banner — only in "ask" mode */} + {readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && ( +
+ sendReadReceiptNow(false)} + onIgnore={ignoreReadReceipt} + /> +
+ )} + {/* Calendar Invitation Banner */} {hasCalendarInvitation && (
diff --git a/components/email/read-receipt-banner.tsx b/components/email/read-receipt-banner.tsx new file mode 100644 index 00000000..933befc9 --- /dev/null +++ b/components/email/read-receipt-banner.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState } from 'react'; +import { MailCheck, Loader2, CheckCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +interface ReadReceiptBannerProps { + /** Address that requested the receipt (Disposition-Notification-To). */ + requestedBy: string; + /** Sends the MDN. Should resolve when the receipt has been submitted. */ + onSend: () => Promise; + /** Suppresses the request without sending (sets $MDNSent server-side). */ + onIgnore: () => void; +} + +export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) { + const t = useTranslations('email_viewer.read_receipt'); + const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle'); + + if (state === 'sent') { + return ( +
+ + {t('sent')} +
+ ); + } + + return ( +
+ + {t('prompt')} + {requestedBy} +
+ + +
+
+ ); +} diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index c7787b6d..7646e488 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -28,6 +28,8 @@ export function ComposingSettings() { subAddressDelimiter, signaturePosition, signatureSeparatorEnabled, + requestReadReceiptDefault, + readReceiptResponse, updateSetting, } = useSettingsStore(); const { client } = useAuthStore(); @@ -78,6 +80,25 @@ export function ComposingSettings() { /> + + updateSetting('requestReadReceiptDefault', checked)} + /> + + + +