diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 6b582691..4a682019 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -22,6 +22,7 @@ import { HardDrive, Wrench, BookUser, + KeyRound, type LucideIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -40,6 +41,7 @@ import { KeywordSettings } from '@/components/settings/keyword-settings'; import { AccountSecuritySettings } from '@/components/settings/account-security-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings'; +import { SmimeSettings } from '@/components/settings/smime-settings'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { useIsDesktop } from '@/hooks/use-media-query'; @@ -48,7 +50,7 @@ import { ResizeHandle } from '@/components/layout/resize-handle'; import { useConfig } from '@/hooks/use-config'; import { cn } from '@/lib/utils'; -type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced'; +type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'advanced'; type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; interface TabDef { @@ -64,6 +66,7 @@ const tabIcons: Record = { account: User, security: Shield, identities: UserPen, + encryption: KeyRound, vacation: PalmtreeIcon, calendar: Calendar, contacts: BookUser, @@ -131,6 +134,7 @@ export default function SettingsPage() { { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' }, ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []), { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' }, + { id: 'encryption', label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'account' }, ...(supportsVacation ? [{ id: 'vacation' as Tab, label: t('tabs.vacation'), icon: tabIcons.vacation, group: 'account' as TabGroup }] : []), ...(supportsSieve ? [{ id: 'filters' as Tab, label: t('tabs.filters'), icon: tabIcons.filters, group: 'organization' as TabGroup }] : []), { id: 'templates', label: t('tabs.templates'), icon: tabIcons.templates, group: 'organization' }, @@ -168,6 +172,7 @@ export default function SettingsPage() { {activeTab === 'account' && } {activeTab === 'security' && } {activeTab === 'identities' && } + {activeTab === 'encryption' && } {activeTab === 'vacation' && } {activeTab === 'calendar' && <>
} {activeTab === 'contacts' && } diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 78920635..9d69d210 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -1,12 +1,16 @@ "use client"; +import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; -import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle } from "lucide-react"; +import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; +import { useSmimeStore } from "@/stores/smime-store"; +import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; +import type { CertificateInfo } from "@/lib/smime/types"; import { toast } from "@/stores/toast-store"; interface ContactDetailProps { @@ -56,6 +60,50 @@ function formatDate(dateInput: string | Record): string { export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); + const smimeStore = useSmimeStore(); + const [parsedCerts, setParsedCerts] = useState>(new Map()); + + const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; + + useEffect(() => { + if (!contact) return; + let cancelled = false; + const parseCerts = async () => { + const results = new Map(); + for (let i = 0; i < cryptoKeys.length; i++) { + const key = cryptoKeys[i]; + if (typeof key.uri !== 'string') continue; + try { + let derBytes: ArrayBuffer | string | null = null; + if (key.uri.startsWith('data:')) { + // data URI — extract base64 content + const commaIdx = key.uri.indexOf(','); + if (commaIdx === -1) continue; + const b64 = key.uri.substring(commaIdx + 1); + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j); + derBytes = bytes.buffer; + } else if (key.uri.startsWith('-----BEGIN')) { + // PEM-encoded certificate inline + derBytes = key.uri; + } + if (!derBytes) continue; + const cert = parseCertificatePemOrDer(derBytes); + const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes; + const info = await extractCertificateInfo(cert, der); + if (!cancelled) results.set(i, info); + } catch { /* skip unparseable keys */ } + } + if (!cancelled) setParsedCerts(results); + }; + if (cryptoKeys.length > 0) { + parseCerts(); + } else { + setParsedCerts(new Map()); + } + return () => { cancelled = true; }; + }, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps if (!contact) { return ( @@ -79,7 +127,31 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : []; const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : []; const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : []; - const cryptoKeys = contact.cryptoKeys ? Object.values(contact.cryptoKeys) : []; + + const handleImportContactCert = async (keyIndex: number) => { + const key = cryptoKeys[keyIndex]; + if (!key?.uri || typeof key.uri !== 'string') return; + try { + let derBytes: ArrayBuffer | string; + if (key.uri.startsWith('data:')) { + const commaIdx = key.uri.indexOf(','); + if (commaIdx === -1) return; + const b64 = key.uri.substring(commaIdx + 1); + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j); + derBytes = bytes.buffer; + } else if (key.uri.startsWith('-----BEGIN')) { + derBytes = key.uri; + } else { + return; + } + await smimeStore.importPublicCert(derBytes, 'contact', contact.id); + toast.success(t("detail.cert_imported")); + } catch (err) { + toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed")); + } + }; const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : []; const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : []; const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : []; @@ -331,17 +403,63 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } {cryptoKeys.length > 0 && (
- {cryptoKeys.map((key, i) => ( -
- {typeof key.uri === 'string' && key.uri.startsWith("http") ? ( - - {key.uri} - - ) : ( - {typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')} - )} -
- ))} + {cryptoKeys.map((key, i) => { + const certInfo = parsedCerts.get(i); + const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false; + const alreadyImported = certInfo?.emailAddresses?.[0] + ? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0]) + : false; + + return ( +
+ {certInfo ? ( + <> +
+ {isExpired ? ( + + ) : ( + + )} + {certInfo.subject} +
+
+

{t("detail.cert_issuer")}: {certInfo.issuer}

+

+ {t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()} + {isExpired && ({t("detail.cert_expired")})} +

+

{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...

+ {certInfo.algorithm &&

{t("detail.cert_algorithm")}: {certInfo.algorithm}

} +
+ {!alreadyImported && ( + + )} + {alreadyImported && ( +

{t("detail.cert_already_imported")}

+ )} + + ) : ( +
+ {typeof key.uri === 'string' && key.uri.startsWith("http") ? ( + + {key.uri} + + ) : ( + {typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')} + )} +
+ )} +
+ ); + })}
)} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index da02a189..8185cde3 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,13 +5,19 @@ 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 } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react"; import { cn, formatFileSize } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { sanitizeEmailHtml } from "@/lib/email-sanitization"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; +import { useSmimeStore } from "@/stores/smime-store"; +import { useEmailStore } from "@/stores/email-store"; +import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder"; +import type { MimeAttachment } from "@/lib/smime/mime-builder"; +import { smimeSign } from "@/lib/smime/smime-sign"; +import { smimeEncrypt } from "@/lib/smime/smime-encrypt"; import { useContactStore } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; @@ -169,6 +175,11 @@ export function EmailComposer({ const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false); + const [smimeSign_, setSmimeSign] = useState(false); + const [smimeEncrypt_, setSmimeEncrypt] = useState(false); + const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); + const [smimePassphraseInput, setSmimePassphraseInput] = useState(''); + const [smimePassphraseError, setSmimePassphraseError] = useState(''); const saveTemplateModalRef = useFocusTrap({ isActive: showSaveAsTemplate, @@ -187,6 +198,33 @@ export function EmailComposer({ const primaryIdentity = identities[0] ?? null; const getAutocomplete = useContactStore((s) => s.getAutocomplete); const addTemplate = useTemplateStore((s) => s.addTemplate); + const sendRawEmail = useEmailStore((s) => s.sendRawEmail); + const smimeStore = useSmimeStore(); + + // Determine S/MIME availability for the selected identity + const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id; + const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined; + const canSmimeSign = !!smimeKeyRecord; + const canSmimeEncrypt = (() => { + if (!smimeKeyRecord) return false; + const toAddrs = to.split(',').map(e => e.trim()).filter(Boolean); + const ccAddrs = cc.split(',').map(e => e.trim()).filter(Boolean); + const bccAddrs = bcc.split(',').map(e => e.trim()).filter(Boolean); + const allRecipients = [...toAddrs, ...ccAddrs, ...bccAddrs]; + if (allRecipients.length === 0) return false; + const { missing } = smimeStore.getRecipientCerts(allRecipients); + return missing.length === 0; + })(); + + // Initialize S/MIME defaults from store when identity changes + useEffect(() => { + if (currentSmimeIdentityId) { + setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign); + } + setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt); + // Only run when identity changes, not on every recipient edit + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentSmimeIdentityId]); // Keep a ref to current state for the unmount save const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }); @@ -642,18 +680,116 @@ export function EmailComposer({ } try { - await onSend?.({ - to: toAddresses, - cc: ccAddresses, - bcc: bccAddresses, - subject, - body: finalBody, - htmlBody: finalHtmlBody, - draftId: finalDraftId || undefined, - fromEmail, - fromName: currentIdentity?.name || undefined, - identityId: currentIdentity?.id, - }); + // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail + if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { + // 1. Resolve S/MIME key + if (smimeSign_ && !smimeKeyRecord) { + throw new Error('No S/MIME key bound to this identity'); + } + + // 2. Ensure key is unlocked for signing + if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) { + const passphrase = await new Promise((resolve, reject) => { + setSmimePassphrasePrompt({ keyId: smimeKeyRecord.id, resolve, reject }); + }); + try { + await smimeStore.unlockKey(smimeKeyRecord.id, passphrase); + } finally { + setSmimePassphrasePrompt(null); + setSmimePassphraseInput(''); + setSmimePassphraseError(''); + } + } + + // 3. Resolve attachments as ArrayBuffers + const mimeAttachments: MimeAttachment[] = []; + for (const att of attachments) { + if (att.error || att.uploading) continue; + let content: ArrayBuffer; + if (att.file.size > 0) { + content = await att.file.arrayBuffer(); + } else if (att.blobId && client) { + content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type); + } else { + continue; + } + mimeAttachments.push({ + filename: att.file.name, + contentType: att.file.type || 'application/octet-stream', + content, + }); + } + + // 4. Build canonical MIME + const mimeBytes = buildMimeMessage({ + from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, + to: toAddresses.map(e => ({ email: e })), + cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, + bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined, + subject, + textBody: finalBody, + htmlBody: finalHtmlBody, + attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, + }); + + let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' }); + + const smimeHeaders = { + from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, + to: toAddresses.map(e => ({ email: e })), + cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined, + subject, + }; + + // 5. Sign if enabled + if (smimeSign_ && smimeKeyRecord) { + const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id); + if (!privateKey) throw new Error('S/MIME key is not unlocked'); + const cmsBlob = await smimeSign( + mimeBytes, + privateKey, + smimeKeyRecord.certificate, + smimeKeyRecord.certificateChain || [], + ); + const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer()); + payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' }); + } + + // 6. Encrypt if enabled + if (smimeEncrypt_ && smimeKeyRecord) { + const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses]; + const { found, missing } = smimeStore.getRecipientCerts(allRecipients); + if (missing.length > 0) { + throw new Error(`Missing certificates for: ${missing.join(', ')}`); + } + const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer); + const payloadBytes = new Uint8Array(await payload.arrayBuffer()); + const cmsBlob = await smimeEncrypt( + payloadBytes, + recipientCertsDer, + smimeKeyRecord.certificate, + ); + const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer()); + payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' }); + } + + // 7. Send via raw email path + await sendRawEmail(client, payload, currentIdentity.id); + } else { + // Standard JMAP send path + await onSend?.({ + to: toAddresses, + cc: ccAddresses, + bcc: bccAddresses, + subject, + body: finalBody, + htmlBody: finalHtmlBody, + draftId: finalDraftId || undefined, + fromEmail, + fromName: currentIdentity?.name || undefined, + identityId: currentIdentity?.id, + }); + } setTo(""); setCc(""); @@ -1059,6 +1195,32 @@ export function EmailComposer({ > + + {/* S/MIME toggles */} + {canSmimeSign && ( + <> +
+ + + + )}
{/* Right side - Discard + Send (desktop) */} @@ -1117,6 +1279,60 @@ export function EmailComposer({ )} + {/* S/MIME passphrase prompt */} + {smimePassphrasePrompt && ( +
+
e.stopPropagation()} + className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200" + > +
+

{t('smime_unlock_title')}

+

{t('smime_unlock_message')}

+ { + setSmimePassphraseInput(e.target.value); + setSmimePassphraseError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && smimePassphraseInput) { + smimePassphrasePrompt.resolve(smimePassphraseInput); + } + }} + placeholder={t('smime_passphrase_placeholder')} + className="mt-3 w-full px-3 py-2 border border-border rounded-md text-sm bg-background text-foreground outline-none focus:ring-2 focus:ring-primary" + /> + {smimePassphraseError && ( +

{smimePassphraseError}

+ )} +
+
+ + +
+
+
+ )} + {showCloseDialog && (
{ + const headers = new Map(); + const lines = headerText.split(/\r?\n/); + let currentKey: string | null = null; + + for (const line of lines) { + if (!line) continue; + if (/^[ \t]/.test(line) && currentKey) { + headers.set(currentKey, `${headers.get(currentKey) || ''} ${line.trim()}`.trim()); + continue; + } + + const separatorIndex = line.indexOf(':'); + if (separatorIndex <= 0) continue; + + currentKey = line.slice(0, separatorIndex).trim().toLowerCase(); + headers.set(currentKey, line.slice(separatorIndex + 1).trim()); + } + + return headers; +} + +function getMimeBoundary(contentType: string): string | null { + const match = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/i); + return match?.[1] || match?.[2] || null; +} + +function decodeQuotedPrintableUtf8(input: string): string { + const normalized = input.replace(/=(\r?\n)/g, ''); + const bytes: number[] = []; + + for (let index = 0; index < normalized.length; index++) { + if (normalized[index] === '=' && /^[0-9A-Fa-f]{2}$/.test(normalized.slice(index + 1, index + 3))) { + bytes.push(parseInt(normalized.slice(index + 1, index + 3), 16)); + index += 2; + continue; + } + bytes.push(normalized.charCodeAt(index) & 0xff); + } + + return new TextDecoder().decode(new Uint8Array(bytes)); +} + +function decodeBase64Utf8(input: string): string { + const cleaned = input.replace(/\s/g, ''); + if (!cleaned) return ''; + try { + const binary = atob(cleaned); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return new TextDecoder().decode(bytes); + } catch { + return input; + } +} + +function decodeBase64Bytes(input: string): Uint8Array | null { + const cleaned = input.replace(/\s/g, ''); + if (!cleaned) return null; + + try { + const binary = atob(cleaned); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; + } catch { + return null; + } +} + +function splitMimeHeadersAndBody(rawText: string): { headerText: string; bodyText: string } { + const separatorMatch = rawText.match(/\r?\n\r?\n/); + const separatorIndex = separatorMatch?.index ?? -1; + const separator = separatorMatch?.[0] ?? ''; + + if (separatorIndex < 0) { + return { headerText: '', bodyText: rawText }; + } + + return { + headerText: rawText.slice(0, separatorIndex), + bodyText: rawText.slice(separatorIndex + separator.length), + }; +} + +function getAttachmentContentBytes(attachment: { + content?: ArrayBuffer | Uint8Array | string; + encoding?: 'base64' | 'utf8'; +}): Uint8Array | null { + const { content, encoding } = attachment; + + if (content instanceof Uint8Array) { + return content; + } + + if (content instanceof ArrayBuffer) { + return new Uint8Array(content); + } + + if (typeof content === 'string') { + if (encoding === 'base64') { + return decodeBase64Bytes(content); + } + return new TextEncoder().encode(content); + } + + return null; +} + +function extractNestedSignedDataCandidate( + parsed: { attachments?: Array; headers?: Array<{ key: string; value: string }> }, + rawBytes: Uint8Array, +): { source: string; bytes: ArrayBuffer } | null { + const topLevelContentType = (parsed.headers?.find(h => h.key === 'content-type')?.value || '').toLowerCase(); + if (topLevelContentType.includes('application/pkcs7-mime') && topLevelContentType.includes('signed-data')) { + const rawText = new TextDecoder().decode(rawBytes); + const { bodyText } = splitMimeHeadersAndBody(rawText); + const topLevelTransferEncoding = ( + parsed.headers?.find(h => h.key === 'content-transfer-encoding')?.value || '' + ).toLowerCase(); + + if (topLevelTransferEncoding.includes('base64')) { + const decoded = decodeBase64Bytes(bodyText); + if (decoded) { + return { + source: 'top-level-content-type-body', + bytes: decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength) as ArrayBuffer, + }; + } + } + + const bodyBytes = new TextEncoder().encode(bodyText); + return { + source: 'top-level-content-type-body-text', + bytes: bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer, + }; + } + + const rawText = new TextDecoder().decode(rawBytes); + const messageContent = splitMimeHeadersAndBody(rawText).bodyText; + const { headerText, bodyText } = splitMimeHeadersAndBody(messageContent); + const bodyHeaders = parseMimeHeaders(headerText); + const bodyContentType = (bodyHeaders.get('content-type') || '').toLowerCase(); + const bodyTransferEncoding = (bodyHeaders.get('content-transfer-encoding') || '').toLowerCase(); + + if (bodyContentType.includes('application/pkcs7-mime') && bodyContentType.includes('signed-data')) { + if (bodyTransferEncoding.includes('base64')) { + const decoded = decodeBase64Bytes(bodyText); + if (decoded) { + return { + source: 'message-body-signed-data', + bytes: decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength) as ArrayBuffer, + }; + } + } + + const bodyBytes = new TextEncoder().encode(bodyText); + return { + source: 'message-body-signed-data-text', + bytes: bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer, + }; + } + + const nestedAttachment = parsed.attachments?.find(attachment => { + const mimeType = ((attachment as { mimeType?: string }).mimeType || '').toLowerCase(); + const filename = ((attachment as { filename?: string | null }).filename || '').toLowerCase(); + return mimeType.includes('application/pkcs7-mime') || filename.endsWith('.p7m'); + }) as { + filename?: string | null; + mimeType?: string; + encoding?: 'base64' | 'utf8'; + content?: ArrayBuffer | Uint8Array | string; + } | undefined; + + if (!nestedAttachment) { + return null; + } + + const attachmentBytes = getAttachmentContentBytes(nestedAttachment); + if (!attachmentBytes) { + return null; + } + + return { + source: nestedAttachment.mimeType || nestedAttachment.filename || 'attachment-signed-data', + bytes: attachmentBytes.buffer.slice( + attachmentBytes.byteOffset, + attachmentBytes.byteOffset + attachmentBytes.byteLength, + ) as ArrayBuffer, + }; +} + +function extractMimePartContent(rawText: string, depth = 0): { html: string | null; text: string | null } { + if (depth > 6) { + const trimmed = rawText.trim(); + return { html: null, text: trimmed || null }; + } + + const separatorMatch = rawText.match(/\r?\n\r?\n/); + const separatorIndex = separatorMatch?.index ?? -1; + const separator = separatorMatch?.[0] ?? ''; + + const headerText = separatorIndex >= 0 ? rawText.slice(0, separatorIndex) : ''; + const bodyText = separatorIndex >= 0 ? rawText.slice(separatorIndex + separator.length) : rawText; + const headers = parseMimeHeaders(headerText); + const contentType = (headers.get('content-type') || '').toLowerCase(); + const transferEncoding = (headers.get('content-transfer-encoding') || '').toLowerCase(); + + if (contentType.includes('multipart/')) { + const boundary = getMimeBoundary(contentType); + if (boundary) { + const boundaryMarker = `--${boundary}`; + const sections = bodyText.split(boundaryMarker); + let bestHtml: string | null = null; + let bestText: string | null = null; + + for (const section of sections) { + const trimmedSection = section.trim(); + if (!trimmedSection || trimmedSection === '--') continue; + const normalizedSection = trimmedSection.endsWith('--') + ? trimmedSection.slice(0, -2).trim() + : trimmedSection; + const extracted = extractMimePartContent(normalizedSection, depth + 1); + if (extracted.html && !bestHtml) { + bestHtml = extracted.html; + } + if (extracted.text && !bestText) { + bestText = extracted.text; + } + if (bestHtml && bestText) break; + } + + return { html: bestHtml, text: bestText }; + } + } + + if (contentType.includes('message/rfc822')) { + return extractMimePartContent(bodyText, depth + 1); + } + + let decodedBody = bodyText; + if (transferEncoding.includes('quoted-printable')) { + decodedBody = decodeQuotedPrintableUtf8(bodyText); + } else if (transferEncoding.includes('base64')) { + decodedBody = decodeBase64Utf8(bodyText); + } + + const trimmedBody = decodedBody.trim(); + if (!trimmedBody) { + return { html: null, text: null }; + } + + if (contentType.includes('text/html')) { + return { html: decodedBody, text: null }; + } + + if (contentType.includes('text/plain')) { + return { html: null, text: decodedBody }; + } + + if (/^\s* }, + rawBytes: Uint8Array, +): { html: string | null; text: string | null; fallbackUsed: boolean } { + const parsedHtml = parsed.html?.trim() ? parsed.html : null; + const parsedText = parsed.text?.trim() ? parsed.text : null; + + if (parsedHtml || parsedText) { + return { html: parsedHtml, text: parsedText, fallbackUsed: false }; + } + + const rawText = new TextDecoder().decode(rawBytes); + const fallback = extractMimePartContent(rawText); + if (fallback.html || fallback.text) { + return { html: fallback.html, text: fallback.text, fallbackUsed: true }; + } + + const trimmed = rawText.trim(); + return { + html: null, + text: trimmed || null, + fallbackUsed: !!trimmed, + }; +} + +interface EffectiveAttachment { + id: string; + name: string | null; + type: string; + size: number; + blobId?: string; + cid?: string; + decryptedAttachment?: PostalMimeAttachment; +} + +function getPostalMimeAttachmentSize(attachment: PostalMimeAttachment): number { + const bytes = getAttachmentContentBytes(attachment); + return bytes?.byteLength ?? 0; +} + // Helper to render clickable recipient elements with popovers function renderClickableRecipients( recipients: Array<{ name?: string; email: string }>, @@ -491,6 +810,7 @@ export function EmailViewer({ const addTrustedSender = useSettingsStore((state) => state.addTrustedSender); const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted); const emailKeywords = useSettingsStore((state) => state.emailKeywords); + const debugMode = useSettingsStore((state) => state.debugMode); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); @@ -528,6 +848,22 @@ export function EmailViewer({ const [overflowCount, setOverflowCount] = useState(0); const currentColor = getCurrentColor(email?.keywords); + // S/MIME state + const [smimeStatus, setSmimeStatus] = useState(null); + const [smimeDecryptedHtml, setSmimeDecryptedHtml] = useState(null); + const [smimeDecryptedText, setSmimeDecryptedText] = useState(null); + const [smimeDecryptedAttachments, setSmimeDecryptedAttachments] = useState([]); + const [smimeUnlockDialogOpen, setSmimeUnlockDialogOpen] = useState(false); + const [smimeUnlockTargetId, setSmimeUnlockTargetId] = useState(null); + const [smimeUnlockError, setSmimeUnlockError] = useState(null); + const smimeStore = useSmimeStore(); + + // Ensure S/MIME key records are loaded from IndexedDB + useEffect(() => { + smimeStore.load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Build mailbox tree for move-to dropdown const moveTargetIds = useMemo(() => new Set( mailboxes @@ -677,10 +1013,618 @@ export function EmailViewer({ setIsQuickReplyFocused(false); setShowSourceModal(false); setEmailViewDarkOverride(null); + setSmimeStatus(null); + setSmimeDecryptedHtml(null); + setSmimeDecryptedText(null); + setSmimeDecryptedAttachments([]); + setSmimeUnlockDialogOpen(false); + setSmimeUnlockTargetId(null); + setSmimeUnlockError(null); }, [email?.id, externalContentPolicy]); + const prepareSmimeUnlock = useCallback((keyRecordId: string) => { + setSmimeUnlockTargetId(keyRecordId); + setSmimeUnlockError(null); + }, []); + + const openSmimeUnlockDialog = useCallback(() => { + if (!smimeUnlockTargetId) { + return; + } + + setSmimeUnlockDialogOpen(true); + }, [smimeUnlockTargetId]); + + const handleSmimeUnlockSubmit = useCallback(async (passphrase: string) => { + if (!smimeUnlockTargetId) { + return; + } + + try { + await smimeStore.unlockKey(smimeUnlockTargetId, passphrase); + setSmimeUnlockDialogOpen(false); + setSmimeUnlockTargetId(null); + setSmimeUnlockError(null); + } catch (error) { + setSmimeUnlockError(error instanceof Error ? error.message : 'Unlock failed'); + } + }, [smimeStore, smimeUnlockTargetId]); + + // S/MIME detection and processing + useEffect(() => { + if (!email || !client) return; + + const smimeDebug = (...args: unknown[]) => { + if (debugMode) { + console.debug(...args); + } + }; + + const smimeWarn = (...args: unknown[]) => { + if (debugMode) { + console.warn(...args); + } + }; + + const smimeError = (...args: unknown[]) => { + if (debugMode) { + console.error(...args); + } + }; + + const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type']; + const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType; + const detection = detectSmime( + contentType, + email.bodyStructure as Parameters[1], + email.attachments as Parameters[2], + ); + + smimeDebug('[S/MIME] detection:', { contentType, bodyStructure: email.bodyStructure, attachments: email.attachments, detection }); + + if (!detection.type) return; + + // Unsupported type (e.g., detached signature) + if (!detection.supported) { + setSmimeStatus({ + isSigned: detection.type === 'detached-sig', + isEncrypted: false, + unsupportedReason: 'Detached S/MIME signatures are not yet supported', + }); + return; + } + + if (!detection.blobId) return; + + let cancelled = false; + + async function processSmime() { + try { + const toHex = (bytes: Uint8Array, count: number) => + Array.from(bytes.slice(0, count)).map(b => b.toString(16).padStart(2, '0')).join(' '); + + const toAsciiPreview = (bytes: Uint8Array, count: number) => { + try { + return new TextDecoder().decode(bytes.slice(0, count)); + } catch { + return ''; + } + }; + + const toExactArrayBuffer = (view: Uint8Array): ArrayBuffer => + view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; + + const cmsCandidates: Array<{ source: string; raw: ArrayBuffer }> = []; + + const findPartById = ( + part: Parameters[1], + targetPartId: string, + ): Parameters[1] | undefined => { + if (!part) return undefined; + if (part.partId === targetPartId) return part; + if (part.subParts) { + for (const sub of part.subParts) { + const found = findPartById(sub as Parameters[1], targetPartId); + if (found) return found; + } + } + return undefined; + }; + + const detectedPart = detection.partId + ? findPartById(email!.bodyStructure as Parameters[1], detection.partId) + : undefined; + const detectedPartName = detectedPart?.name || 'smime.p7m'; + const detectedPartType = detectedPart?.type || 'application/pkcs7-mime'; + + const detectedPartSize = (detectedPart as { size?: number } | undefined)?.size; + if (detectedPartSize === 0) { + smimeWarn('[S/MIME] detected part has size=0; trying multiple blob fetch variants', { + partId: detection.partId, + blobId: detection.blobId, + name: detectedPartName, + type: detectedPartType, + }); + } + + // Primary source: Blob/download endpoint + try { + const blobBytes = await client!.fetchBlobArrayBuffer(detection.blobId!); + if (blobBytes.byteLength > 0) { + cmsCandidates.push({ source: 'blob-default', raw: blobBytes }); + } + smimeWarn('[S/MIME] blob-default fetch result:', { + byteLength: blobBytes.byteLength, + }); + } catch (error) { + smimeWarn('[S/MIME] blob fetch failed:', error); + // Fallback sources below may still work + } + + // Variant source: same blob with explicit part name/type in URL template + try { + const typedBlobBytes = await client!.fetchBlobArrayBuffer( + detection.blobId!, + detectedPartName, + detectedPartType, + ); + if (typedBlobBytes.byteLength > 0) { + cmsCandidates.push({ source: 'blob-typed', raw: typedBlobBytes }); + } + smimeWarn('[S/MIME] blob-typed fetch result:', { + byteLength: typedBlobBytes.byteLength, + name: detectedPartName, + type: detectedPartType, + }); + } catch (error) { + smimeWarn('[S/MIME] typed blob fetch failed:', error); + } + + // Fallback source: bodyValues entry for the detected S/MIME part + const bodyValue = detection.partId ? email!.bodyValues?.[detection.partId]?.value : undefined; + const bodyValueMeta = detection.partId ? email!.bodyValues?.[detection.partId] : undefined; + smimeWarn('[S/MIME] bodyValues candidate:', { + partId: detection.partId, + exists: !!bodyValueMeta, + valueLength: bodyValue?.length ?? 0, + isTruncated: bodyValueMeta?.isTruncated ?? false, + isEncodingProblem: bodyValueMeta?.isEncodingProblem ?? false, + }); + if (bodyValue) { + const bodyValueBytes = new TextEncoder().encode(bodyValue); + cmsCandidates.push({ source: 'bodyValues', raw: toExactArrayBuffer(bodyValueBytes) }); + } + + // Fallback source: fetch full RFC822 blob and extract CMS bytes from message body + // Some servers return empty bytes for part blobId=0 while Email.blobId still has full content. + if (email!.blobId) { + try { + const fullMessageBytes = await client!.fetchBlobArrayBuffer( + email!.blobId, + 'message.eml', + 'message/rfc822', + ); + if (fullMessageBytes.byteLength > 0) { + cmsCandidates.push({ source: 'email-blob', raw: fullMessageBytes }); + } + smimeWarn('[S/MIME] email-blob fetch result:', { + blobId: email!.blobId, + byteLength: fullMessageBytes.byteLength, + }); + } catch (error) { + smimeWarn('[S/MIME] email-blob fetch failed:', error); + } + } else { + smimeWarn('[S/MIME] email-blob unavailable: Email.blobId not present'); + } + + if (cmsCandidates.length === 0) { + throw new Error('No usable CMS bytes found (blob-default/blob-typed/bodyValues/email-blob all empty)'); + } + + const expandedCandidates: Array<{ source: string; raw: ArrayBuffer }> = []; + + for (const candidate of cmsCandidates) { + expandedCandidates.push(candidate); + + if (candidate.source === 'email-blob') { + // Candidate 1: raw message body (strip RFC822 headers) + try { + const fullText = new TextDecoder().decode(candidate.raw); + const headerEnd = fullText.search(/\r?\n\r?\n/); + if (headerEnd >= 0) { + const headerSep = fullText.slice(headerEnd).match(/^\r?\n\r?\n/)?.[0] ?? '\r\n\r\n'; + const bodyText = fullText.slice(headerEnd + headerSep.length); + if (bodyText.trim().length > 0) { + const bodyBytes = new TextEncoder().encode(bodyText); + expandedCandidates.push({ + source: 'email-blob-body', + raw: toExactArrayBuffer(bodyBytes), + }); + } + } + } catch { + // ignore extraction failures + } + + // Candidate 2: parse MIME and extract pkcs7 attachment content + try { + const { default: PostalMime } = await import('postal-mime'); + const parser = new PostalMime(); + const parsedFull = await parser.parse(candidate.raw); + const smimeAttachment = parsedFull.attachments?.find(att => { + const mimeType = ((att as { mimeType?: string }).mimeType || '').toLowerCase(); + const filename = ((att as { filename?: string }).filename || '').toLowerCase(); + return mimeType.includes('application/pkcs7-mime') || filename.endsWith('.p7m'); + }); + + if (smimeAttachment) { + const content = (smimeAttachment as { content?: unknown }).content; + if (content instanceof Uint8Array) { + expandedCandidates.push({ + source: 'email-blob-attachment', + raw: toExactArrayBuffer(content), + }); + } else if (content instanceof ArrayBuffer) { + expandedCandidates.push({ + source: 'email-blob-attachment', + raw: content, + }); + } else if (typeof content === 'string') { + const contentBytes = new TextEncoder().encode(content); + expandedCandidates.push({ + source: 'email-blob-attachment', + raw: toExactArrayBuffer(contentBytes), + }); + } + } + } catch (error) { + smimeWarn('[S/MIME] email-blob MIME parse/extract failed:', error); + } + } + } + + const normalizedCandidates = expandedCandidates.map(candidate => ({ + source: candidate.source, + raw: candidate.raw, + normalized: normalizeCmsBytes(candidate.raw), + })); + + const candidateSummaries = normalizedCandidates.map((candidate, index) => { + const rawBytes = new Uint8Array(candidate.raw); + const normalizedBytes = new Uint8Array(candidate.normalized); + return { + index, + source: candidate.source, + rawLength: candidate.raw.byteLength, + normalizedLength: candidate.normalized.byteLength, + rawFirstBytesHex: toHex(rawBytes, 24), + normalizedFirstBytesHex: toHex(normalizedBytes, 24), + rawAsciiPreview: toAsciiPreview(rawBytes, 180), + }; + }); + + smimeWarn('[S/MIME] CMS candidates:', { + detection, + candidateCount: candidateSummaries.length, + candidates: candidateSummaries, + }); + + if (debugMode && typeof window !== 'undefined') { + const debugPayload = { + emailId: email!.id, + detection, + generatedAt: new Date().toISOString(), + candidates: candidateSummaries, + }; + + const exportCandidate = (index = 0, normalized = true) => { + const candidate = normalizedCandidates[index]; + if (!candidate) { + throw new Error(`Invalid candidate index: ${index}`); + } + const bytes = normalized ? candidate.normalized : candidate.raw; + const mode = normalized ? 'normalized' : 'raw'; + const filename = `smime-${email!.id}-${candidate.source}-${index}-${mode}.p7m`; + const blob = new Blob([bytes], { type: 'application/pkcs7-mime' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + return { filename, byteLength: bytes.byteLength, source: candidate.source, mode }; + }; + + (window as unknown as { + __smimeDebugLast?: unknown; + __smimeDebugExport?: (index?: number, normalized?: boolean) => unknown; + }).__smimeDebugLast = debugPayload; + (window as unknown as { + __smimeDebugLast?: unknown; + __smimeDebugExport?: (index?: number, normalized?: boolean) => unknown; + }).__smimeDebugExport = exportCandidate; + + smimeWarn('[S/MIME] debug helpers ready: window.__smimeDebugLast, window.__smimeDebugExport(index, normalized=true)'); + } + + const isCmsParseError = (error: unknown) => { + if (!(error instanceof Error)) return false; + return ( + error.message.includes('Invalid ASN.1 data') || + error.message.includes('Unexpected CMS content type') || + error.message.includes('Object\'s schema was not verified against input data for ContentInfo') + ); + }; + + const fromEmail = email!.from?.[0]?.email; + + if (detection.type === 'enveloped-data') { + // Encrypted message + const { keyRecords, unlockedDecryptionKeys } = smimeStore; + smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) }); + try { + let result: Awaited> | null = null; + let lastError: unknown = null; + + for (const candidate of normalizedCandidates) { + try { + result = await smimeDecrypt({ + cmsBytes: candidate.normalized, + keyRecords, + unlockedKeys: unlockedDecryptionKeys, + }); + smimeDebug('[S/MIME] decrypt success with candidate:', { + source: candidate.source, + byteLength: candidate.normalized.byteLength, + }); + break; + } catch (error) { + lastError = error; + smimeWarn('[S/MIME] decrypt candidate failed:', { + source: candidate.source, + error: error instanceof Error ? error.message : String(error), + }); + // SmimeKeyLockedError should bubble up immediately so the UI can prompt for passphrase + if (error instanceof SmimeKeyLockedError) { + throw error; + } + // For other errors (CMS parse, decrypt failure), try the next candidate + } + } + + if (!result) { + throw lastError instanceof Error ? lastError : new Error('Decryption failed'); + } + + if (cancelled) return; + + // Parse inner MIME + const { default: PostalMime } = await import('postal-mime'); + const parser = new PostalMime(); + const parsed = await parser.parse(result.mimeBytes); + if (cancelled) return; + const parsedContent = getRenderableSmimeContent(parsed, result.mimeBytes); + smimeDebug('[S/MIME] decrypted MIME parsed:', { + subject: parsed.subject, + htmlLength: parsed.html?.length ?? 0, + textLength: parsed.text?.length ?? 0, + attachmentCount: parsed.attachments?.length ?? 0, + fallbackUsed: parsedContent.fallbackUsed, + renderHtmlLength: parsedContent.html?.length ?? 0, + renderTextLength: parsedContent.text?.length ?? 0, + }); + + // Check if inner content is also signed + const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes); + if (nestedSignedData) { + // Nested sign-then-encrypt — verify inner signature + const innerBytes = normalizeCmsBytes(nestedSignedData.bytes); + smimeDebug('[S/MIME] nested signed-data candidate:', { + source: nestedSignedData.source, + byteLength: innerBytes.byteLength, + }); + try { + const verifyResult = await smimeVerify(innerBytes, fromEmail); + if (cancelled) return; + // Parse the verified inner content + const innerParsed = await new PostalMime().parse(verifyResult.mimeBytes); + if (cancelled) return; + const innerParsedContent = getRenderableSmimeContent(innerParsed, verifyResult.mimeBytes); + smimeDebug('[S/MIME] verified inner MIME parsed:', { + subject: innerParsed.subject, + htmlLength: innerParsed.html?.length ?? 0, + textLength: innerParsed.text?.length ?? 0, + attachmentCount: innerParsed.attachments?.length ?? 0, + fallbackUsed: innerParsedContent.fallbackUsed, + renderHtmlLength: innerParsedContent.html?.length ?? 0, + renderTextLength: innerParsedContent.text?.length ?? 0, + }); + setSmimeDecryptedHtml(innerParsedContent.html); + setSmimeDecryptedText(innerParsedContent.text); + setSmimeDecryptedAttachments(innerParsed.attachments ?? []); + setSmimeStatus({ + ...verifyResult.status, + isEncrypted: true, + decryptionSuccess: true, + }); + // Auto-import signer cert if enabled + if (smimeStore.autoImportSignerCerts && verifyResult.status.signatureValid && verifyResult.status.signerCert) { + const existing = smimeStore.getPublicCertForEmail(verifyResult.status.signerCert.email); + if (!existing) { + try { + await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email'); + } catch { /* ignore import errors */ } + } + } + } catch (error) { + smimeError('[S/MIME] nested signature verify failed:', { + source: nestedSignedData.source, + error: error instanceof Error ? error.message : String(error), + }); + // Verification failed but decryption worked + setSmimeDecryptedHtml(parsedContent.html); + setSmimeDecryptedText(parsedContent.text); + setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); + setSmimeStatus({ + isSigned: false, + isEncrypted: true, + decryptionSuccess: true, + }); + } + } else { + setSmimeDecryptedHtml(parsedContent.html); + setSmimeDecryptedText(parsedContent.text); + setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); + setSmimeStatus({ + isSigned: false, + isEncrypted: true, + decryptionSuccess: true, + }); + } + } catch (err) { + if (cancelled) return; + smimeError('[S/MIME] decrypt error:', err); + if (err instanceof SmimeKeyLockedError) { + prepareSmimeUnlock(err.keyRecordId); + setSmimeStatus({ + isSigned: false, + isEncrypted: true, + decryptionError: 'locked', + }); + } else { + setSmimeStatus({ + isSigned: false, + isEncrypted: true, + decryptionError: err instanceof Error ? err.message : 'Decryption failed', + }); + } + } + } else if (detection.type === 'signed-data') { + // Signed message + try { + let result: Awaited> | null = null; + let lastError: unknown = null; + + for (const candidate of normalizedCandidates) { + try { + result = await smimeVerify(candidate.normalized, fromEmail); + smimeDebug('[S/MIME] verify success with candidate:', { + source: candidate.source, + byteLength: candidate.normalized.byteLength, + }); + break; + } catch (error) { + lastError = error; + smimeWarn('[S/MIME] verify candidate failed:', { + source: candidate.source, + error: error instanceof Error ? error.message : String(error), + }); + if (!isCmsParseError(error)) { + throw error; + } + } + } + + if (!result) { + throw lastError instanceof Error ? lastError : new Error('Verification failed'); + } + + if (cancelled) return; + + // Parse inner MIME + const { default: PostalMime } = await import('postal-mime'); + const parser = new PostalMime(); + const parsed = await parser.parse(result.mimeBytes); + if (cancelled) return; + const parsedContent = getRenderableSmimeContent(parsed, result.mimeBytes); + smimeDebug('[S/MIME] verified MIME parsed:', { + subject: parsed.subject, + htmlLength: parsed.html?.length ?? 0, + textLength: parsed.text?.length ?? 0, + attachmentCount: parsed.attachments?.length ?? 0, + fallbackUsed: parsedContent.fallbackUsed, + renderHtmlLength: parsedContent.html?.length ?? 0, + renderTextLength: parsedContent.text?.length ?? 0, + }); + + setSmimeDecryptedHtml(parsedContent.html); + setSmimeDecryptedText(parsedContent.text); + setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); + setSmimeStatus(result.status); + // Auto-import signer cert if enabled + if (smimeStore.autoImportSignerCerts && result.status.signatureValid && result.status.signerCert) { + const existing = smimeStore.getPublicCertForEmail(result.status.signerCert.email); + if (!existing) { + try { + await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email'); + } catch { /* ignore import errors */ } + } + } + } catch (err) { + if (cancelled) return; + setSmimeStatus({ + isSigned: true, + isEncrypted: false, + signatureValid: false, + signatureError: err instanceof Error ? err.message : 'Verification failed', + }); + } + } + } catch (err) { + if (cancelled) return; + smimeError('[S/MIME] processing failed before decrypt/verify:', err); + // Failed to fetch CMS blob + setSmimeStatus({ + isSigned: false, + isEncrypted: detection.type === 'enveloped-data', + decryptionError: err instanceof Error ? err.message : 'Failed to fetch encrypted content', + }); + } + } + + processSmime(); + return () => { cancelled = true; }; + }, [ + email, + client, + debugMode, + prepareSmimeUnlock, + smimeStore.autoImportSignerCerts, + smimeStore.keyRecords, + smimeStore.unlockedDecryptionKeys, + ]); + // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { + let cancelled = false; + const objectUrls: string[] = []; + + const decryptedCidAttachments = smimeDecryptedAttachments.filter(att => att.contentId); + if (decryptedCidAttachments.length > 0) { + const urls: Record = {}; + + decryptedCidAttachments.forEach((att, index) => { + const bytes = getAttachmentContentBytes(att); + if (!bytes) return; + const cidValue = att.contentId!.replace(/^<|>$/g, ''); + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const blob = new Blob([buffer], { type: att.mimeType || 'application/octet-stream' }); + const objectUrl = URL.createObjectURL(blob); + urls[cidValue] = objectUrl; + objectUrls.push(objectUrl); + }); + + setCidBlobUrls(urls); + + return () => { + cancelled = true; + objectUrls.forEach(url => URL.revokeObjectURL(url)); + }; + } + if (!client || !email?.attachments) { setCidBlobUrls({}); return; @@ -692,9 +1636,6 @@ export function EmailViewer({ return; } - let cancelled = false; - const objectUrls: string[] = []; - async function fetchCidBlobs() { const urls: Record = {}; await Promise.all(cidAttachments.map(async (att) => { @@ -722,7 +1663,29 @@ export function EmailViewer({ cancelled = true; objectUrls.forEach(url => URL.revokeObjectURL(url)); }; - }, [client, email?.id]); + }, [client, email?.id, smimeDecryptedAttachments]); + + const effectiveAttachments = useMemo(() => { + if (smimeDecryptedAttachments.length > 0) { + return smimeDecryptedAttachments.map((attachment, index) => ({ + id: `smime-${index}-${attachment.filename || attachment.mimeType}`, + name: attachment.filename, + type: attachment.mimeType || 'application/octet-stream', + size: getPostalMimeAttachmentSize(attachment), + cid: attachment.contentId, + decryptedAttachment: attachment, + })); + } + + return (email?.attachments ?? []).map((attachment, index) => ({ + id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, + name: attachment.name || null, + type: attachment.type || 'application/octet-stream', + size: attachment.size, + blobId: attachment.blobId, + cid: attachment.cid, + })); + }, [email?.attachments, smimeDecryptedAttachments]); // Generate email source for viewing const generateEmailSource = (email: Email): string => { @@ -1027,21 +1990,81 @@ export function EmailViewer({ }; }, [email, allowExternalContent, hasBlockedContent, externalContentPolicy, isSenderTrusted, cidBlobUrls]); + // Override email content with S/MIME decrypted content when available + const effectiveEmailContent = useMemo(() => { + if (smimeDecryptedHtml) { + const htmlWithCidUrls = smimeDecryptedHtml.replace( + /\bcid:([^"'\s)]+)/gi, + (_match, cidRef) => { + return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + } + ); + const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_SANITIZE_CONFIG); + return { html: cleanHtml, isHtml: true }; + } + if (smimeDecryptedText) { + const htmlFromText = smimeDecryptedText + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
') + .replace(/(https?:\/\/[^\s<]+)/g, '$1'); + return { html: htmlFromText, isHtml: false }; + } + return emailContent; + }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText]); + + const handleEffectiveAttachmentOpen = useCallback((attachment: EffectiveAttachment) => { + const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); + const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; + + if (attachment.blobId && onDownloadAttachment) { + onDownloadAttachment(attachment.blobId, attachment.name || 'download', attachment.type); + return; + } + + if (!attachment.decryptedAttachment) { + return; + } + + const bytes = getAttachmentContentBytes(attachment.decryptedAttachment); + if (!bytes || bytes.byteLength === 0) { + return; + } + + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + const blob = new Blob([buffer], { type: attachment.type || 'application/octet-stream' }); + const objectUrl = URL.createObjectURL(blob); + + if (opensPreview) { + window.open(objectUrl, '_blank', 'noopener,noreferrer'); + } else { + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = attachment.name || 'download'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } + + setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000); + }, [mailAttachmentAction, onDownloadAttachment]); + // Iframe for rendering HTML emails true-to-life const iframeRef = useRef(null); // Detect if the email HTML has native dark mode support const emailHasNativeDarkMode = useMemo(() => { - if (!emailContent.isHtml) return false; - return /prefers-color-scheme\s*:\s*dark/i.test(emailContent.html); - }, [emailContent.html, emailContent.isHtml]); + if (!effectiveEmailContent.isHtml) return false; + return /prefers-color-scheme\s*:\s*dark/i.test(effectiveEmailContent.html); + }, [effectiveEmailContent.html, effectiveEmailContent.isHtml]); const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode); const [emailViewDarkOverride, setEmailViewDarkOverride] = useState(null); const isDark = emailAlwaysLightMode ? false : (emailViewDarkOverride !== null ? emailViewDarkOverride : resolvedTheme === 'dark'); const emailIframeSrcDoc = useMemo(() => { - if (!emailContent.isHtml) return ''; + if (!effectiveEmailContent.isHtml) return ''; // If email has native dark mode, let it handle its own theming // Otherwise, use CSS filter inversion for dark mode (preserves layout) @@ -1068,8 +2091,8 @@ export function EmailViewer({ table { max-width: 100%; } pre { white-space: pre-wrap; word-wrap: break-word; } ${darkModeCSS} -${emailContent.html}`; - }, [emailContent.html, emailContent.isHtml, isDark, emailHasNativeDarkMode]); +${effectiveEmailContent.html}`; + }, [effectiveEmailContent.html, effectiveEmailContent.isHtml, isDark, emailHasNativeDarkMode]); const handleIframeLoad = useCallback(() => { const iframe = iframeRef.current; @@ -1128,7 +2151,7 @@ export function EmailViewer({ ${date ? `
${t('date')}: ${DOMPurify.sanitize(date)}
` : ''}
-
${emailContent.html}
+
${effectiveEmailContent.html}
`); printWindow.document.close(); printWindow.focus(); @@ -2348,7 +3371,7 @@ export function EmailViewer({ {formatFileSize(email.size)} )} - {emailContent.isHtml && ( + {effectiveEmailContent.isHtml && (