From b0640c9ecc37bb9fdb10186d018426f47f121c79 Mon Sep 17 00:00:00 2001 From: Augustin Marcin Date: Sat, 9 May 2026 19:27:36 -0700 Subject: [PATCH] feat(compose): From override + catch-all auto-reply (fixes #246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Override toggle in the composer's From row. When enabled, name and address become free-text inputs. Mail is still submitted through the selected identity, but the outgoing message's From: header — and the SMTP envelope MAIL FROM when different — is set from the override. The existing "Auto-select Reply Address" setting is extended: if the incoming message was addressed to an alias on a domain that matches one of your identities but isn't itself an identity (classic domain catch- all), it now auto-enables Override and pre-fills the alias. Quick reply honors the same resolution. The setting is relabeled to reflect the broader behavior. JMAP: client.sendEmail gains an optional envelopeMailFrom; when set, the EmailSubmission includes an explicit envelope with that mailFrom and the to/cc/bcc as rcptTo so header-From and envelope can diverge (JMAP §7.3). S/MIME: override is incompatible with sign/encrypt and is refused with a clear error — signing a different visible From from the identity's certificate Subject would produce messages clients reject. Tests: resolveReplyFrom covers exact match, sub-address stripping, catch-all detection, identity preference, and foreign-domain null. --- app/[locale]/page.tsx | 34 +++++-- components/email/email-composer.tsx | 129 +++++++++++++++++++++------ lib/__tests__/reply-identity.test.ts | 37 +++++++- lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 22 ++++- lib/reply-identity.ts | 95 ++++++++++++++++++++ locales/en/common.json | 13 ++- stores/email-store.ts | 6 +- 8 files changed, 297 insertions(+), 40 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 4786426b..aeee0eac 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -53,6 +53,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; import { appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; +import { resolveReplyFrom } from "@/lib/reply-identity"; import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -875,6 +876,7 @@ export default function Home() { fromEmail?: string; fromName?: string; identityId?: string; + envelopeMailFrom?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; @@ -885,7 +887,7 @@ export default function Home() { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; - 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); + 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.envelopeMailFrom); setShowComposer(false); // Mark the original email with $answered or $forwarded keyword @@ -1641,9 +1643,28 @@ export default function Home() { } const primaryIdentity = identities[0]; + const autoSelectReplyIdentity = useSettingsStore.getState().autoSelectReplyIdentity; - // Append signature from the primary identity - const finalBody = appendPlainTextSignature(body, primaryIdentity); + // Decide the sending identity and (for domain-catch-all) an optional + // header From override that matches the address the message was sent to. + // When the setting is off, fall through to primary-identity behavior. + const resolved = autoSelectReplyIdentity + ? resolveReplyFrom(identities, { + to: selectedEmail.to, + cc: selectedEmail.cc, + bcc: selectedEmail.bcc, + }) + : null; + const sendingIdentity = resolved + ? (identities.find((i) => i.id === resolved.identityId) || primaryIdentity) + : primaryIdentity; + const headerFromEmail = resolved?.overrideEmail || sendingIdentity?.email; + const headerFromName = resolved?.overrideName || sendingIdentity?.name || undefined; + const envelopeMailFrom = resolved?.overrideEmail ? sendingIdentity?.email : undefined; + + // Append signature from the sending identity (fall back to primary + // when the reply-from lives on the same identity but a different alias). + const finalBody = appendPlainTextSignature(body, sendingIdentity); const originalEmailId = selectedEmail.id; @@ -1661,14 +1682,15 @@ export default function Home() { finalBody, undefined, undefined, - primaryIdentity?.id, - primaryIdentity?.email, + sendingIdentity?.id, + headerFromEmail, undefined, - primaryIdentity?.name || undefined, + headerFromName, undefined, undefined, threading?.inReplyTo, threading?.references, + envelopeMailFrom, ); // Mark the original email as answered diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index be57fd10..076cfd1d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -32,7 +32,7 @@ import { TemplatePicker } from "@/components/templates/template-picker"; import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; -import { findReplyIdentityId } from "@/lib/reply-identity"; +import { resolveReplyFrom } from "@/lib/reply-identity"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { RichTextEditor } from "@/components/email/rich-text-editor"; @@ -55,6 +55,10 @@ export interface ComposerDraftData { mode: 'compose' | 'reply' | 'replyAll' | 'forward'; replyTo?: EmailComposerProps['replyTo']; draftId: string | null; + /** When set, overrides the header From: — sent through the selected identity's envelope. */ + fromOverrideEmail?: string; + fromOverrideName?: string; + fromOverrideEnabled?: boolean; } interface EmailComposerProps { @@ -69,6 +73,7 @@ interface EmailComposerProps { fromEmail?: string; fromName?: string; identityId?: string; + envelopeMailFrom?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; @@ -245,6 +250,9 @@ export function EmailComposer({ const [shakeField, setShakeField] = useState(null); const [selectedIdentityId, setSelectedIdentityId] = useState(initialData?.selectedIdentityId ?? null); const [subAddressTag, setSubAddressTag] = useState(initialData?.subAddressTag ?? ''); + const [fromOverrideEnabled, setFromOverrideEnabled] = useState(initialData?.fromOverrideEnabled ?? false); + const [fromOverrideEmail, setFromOverrideEmail] = useState(initialData?.fromOverrideEmail ?? ''); + const [fromOverrideName, setFromOverrideName] = useState(initialData?.fromOverrideName ?? ''); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false); @@ -292,14 +300,19 @@ export function EmailComposer({ if (selectedIdentityId || initialData?.selectedIdentityId) return; if (mode !== 'reply' && mode !== 'replyAll') return; - const matchedIdentityId = findReplyIdentityId(identities, { + const resolved = resolveReplyFrom(identities, { to: replyTo?.to, cc: replyTo?.cc, bcc: replyTo?.bcc, }); - if (matchedIdentityId) { - setSelectedIdentityId(matchedIdentityId); + if (resolved) { + setSelectedIdentityId(resolved.identityId); + if (resolved.overrideEmail && !fromOverrideEnabled) { + setFromOverrideEnabled(true); + setFromOverrideEmail(resolved.overrideEmail); + if (resolved.overrideName) setFromOverrideName(resolved.overrideName); + } return; } @@ -318,6 +331,7 @@ export function EmailComposer({ } }, [ autoSelectReplyIdentity, + fromOverrideEnabled, identities, initialData?.selectedIdentityId, mode, @@ -367,8 +381,8 @@ export function EmailComposer({ }, [currentSmimeIdentityId]); // Keep a ref to current state for the unmount save - const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }); - stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId }; + const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName }); + stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId, fromOverrideEnabled, fromOverrideEmail, fromOverrideName }; // Track initial values for dirty detection (captured once on first render) const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length }); @@ -761,11 +775,17 @@ export function EmailComposer({ // Get the selected identity or primary identity // Generate sub-addressed email if tag is set - const fromEmail = currentIdentity?.email + const identityFromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) : currentIdentity.email : undefined; + const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim()) + ? fromOverrideEmail.trim() + : identityFromEmail; + const fromName = (fromOverrideEnabled && fromOverrideEmail.trim()) + ? (fromOverrideName.trim() || undefined) + : (currentIdentity?.name || undefined); try { const savedDraftId = await client.createDraft( @@ -778,7 +798,7 @@ export function EmailComposer({ fromEmail, draftId || undefined, uploadedAttachments, - currentIdentity?.name || undefined, + fromName, plainTextMode ? undefined : body ); @@ -951,11 +971,21 @@ export function EmailComposer({ } } - const fromEmail = currentIdentity?.email + const identityFromEmail = currentIdentity?.email ? subAddressTag ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) : currentIdentity.email : undefined; + // When the user has typed a From override, that becomes the header From + // (and MIME-builder From in the S/MIME path). The identity still drives + // the SMTP envelope MAIL FROM — set explicitly so it doesn't mistakenly + // default to the override address. + const overrideActive = fromOverrideEnabled && fromOverrideEmail.trim().length > 0; + const fromEmail = overrideActive ? fromOverrideEmail.trim() : identityFromEmail; + const fromName = overrideActive + ? (fromOverrideName.trim() || undefined) + : (currentIdentity?.name || undefined); + const envelopeMailFrom = overrideActive ? identityFromEmail : undefined; // Body is already HTML from the rich text editor (or plain text in plain text mode). // Build HTML signature block (used only in rich text mode) @@ -1012,6 +1042,12 @@ export function EmailComposer({ if (smimeSign_ && !smimeKeyRecord) { throw new Error('No S/MIME key bound to this identity'); } + // S/MIME binds to the identity's key; sending from an override address + // would produce a signature whose Subject differs from the visible + // From, which most clients reject or flag. Refuse up front. + if (overrideActive) { + throw new Error('Cannot use From override with S/MIME — disable one to send.'); + } // 2. Ensure key is unlocked for signing if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) { @@ -1156,8 +1192,9 @@ export function EmailComposer({ htmlBody: outgoing.htmlBody || undefined, draftId: finalDraftId || undefined, fromEmail, - fromName: currentIdentity?.name || undefined, + fromName, identityId: outgoing.identityId || currentIdentity?.id, + envelopeMailFrom, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, inReplyTo: threadingHeaders?.inReplyTo, references: threadingHeaders?.references, @@ -1185,7 +1222,7 @@ export function EmailComposer({ setSubAddressTag(""); setValidationErrors({}); // Clear ref so unmount effect doesn't re-save - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; } catch (err) { debug.error('Failed to send email:', err); toast.error(t('send_failed')); @@ -1196,7 +1233,7 @@ export function EmailComposer({ if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; onClose?.(); }; @@ -1206,7 +1243,7 @@ export function EmailComposer({ clearTimeout(saveTimeoutRef.current); } await saveDraft(); - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; onClose?.(); }; @@ -1218,7 +1255,7 @@ export function EmailComposer({ if (draftId && onDiscardDraft) { onDiscardDraft(draftId); } - stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null }; + stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null, fromOverrideEnabled: false, fromOverrideEmail: '', fromOverrideName: '' }; onClose?.(); }; @@ -1302,7 +1339,25 @@ export function EmailComposer({
{t('from')}:
- {identities.length > 1 ? ( + {fromOverrideEnabled ? ( +
+ setFromOverrideName(e.target.value)} + placeholder={t('from_override.name_placeholder')} + className="h-7 text-sm w-32 md:w-40 shrink-0" + aria-label={t('from_override.name_label')} + /> + setFromOverrideEmail(e.target.value)} + placeholder={t('from_override.email_placeholder')} + type="email" + className="h-7 text-sm flex-1 min-w-0 font-mono" + aria-label={t('from_override.email_label')} + /> +
+ ) : identities.length > 1 ? (