feat(compose): From override + catch-all auto-reply (fixes #246)

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.
This commit is contained in:
Augustin Marcin
2026-05-11 12:13:47 +02:00
committed by Linus Rath
parent 2d7e24b513
commit b0640c9ecc
8 changed files with 297 additions and 40 deletions
+28 -6
View File
@@ -53,6 +53,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { isFilePreviewable } from "@/lib/file-preview"; import { isFilePreviewable } from "@/lib/file-preview";
import { appendPlainTextSignature } from "@/lib/signature-utils"; import { appendPlainTextSignature } from "@/lib/signature-utils";
import { computeReplyThreadingHeaders } from "@/lib/email-threading"; 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 { 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 { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -875,6 +876,7 @@ export default function Home() {
fromEmail?: string; fromEmail?: string;
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
envelopeMailFrom?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
@@ -885,7 +887,7 @@ export default function Home() {
const effectiveMode = pendingDraft?.mode ?? composerMode; const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = selectedEmail?.id; 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); setShowComposer(false);
// Mark the original email with $answered or $forwarded keyword // Mark the original email with $answered or $forwarded keyword
@@ -1641,9 +1643,28 @@ export default function Home() {
} }
const primaryIdentity = identities[0]; const primaryIdentity = identities[0];
const autoSelectReplyIdentity = useSettingsStore.getState().autoSelectReplyIdentity;
// Append signature from the primary identity // Decide the sending identity and (for domain-catch-all) an optional
const finalBody = appendPlainTextSignature(body, primaryIdentity); // 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; const originalEmailId = selectedEmail.id;
@@ -1661,14 +1682,15 @@ export default function Home() {
finalBody, finalBody,
undefined, undefined,
undefined, undefined,
primaryIdentity?.id, sendingIdentity?.id,
primaryIdentity?.email, headerFromEmail,
undefined, undefined,
primaryIdentity?.name || undefined, headerFromName,
undefined, undefined,
undefined, undefined,
threading?.inReplyTo, threading?.inReplyTo,
threading?.references, threading?.references,
envelopeMailFrom,
); );
// Mark the original email as answered // Mark the original email as answered
+104 -25
View File
@@ -32,7 +32,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form"; import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types"; import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils"; 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 { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { RichTextEditor } from "@/components/email/rich-text-editor"; import { RichTextEditor } from "@/components/email/rich-text-editor";
@@ -55,6 +55,10 @@ export interface ComposerDraftData {
mode: 'compose' | 'reply' | 'replyAll' | 'forward'; mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo']; replyTo?: EmailComposerProps['replyTo'];
draftId: string | null; draftId: string | null;
/** When set, overrides the header From: — sent through the selected identity's envelope. */
fromOverrideEmail?: string;
fromOverrideName?: string;
fromOverrideEnabled?: boolean;
} }
interface EmailComposerProps { interface EmailComposerProps {
@@ -69,6 +73,7 @@ interface EmailComposerProps {
fromEmail?: string; fromEmail?: string;
fromName?: string; fromName?: string;
identityId?: string; identityId?: string;
envelopeMailFrom?: string;
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>;
inReplyTo?: string[]; inReplyTo?: string[];
references?: string[]; references?: string[];
@@ -245,6 +250,9 @@ export function EmailComposer({
const [shakeField, setShakeField] = useState<string | null>(null); const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null); const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? ''); const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
const [fromOverrideEnabled, setFromOverrideEnabled] = useState<boolean>(initialData?.fromOverrideEnabled ?? false);
const [fromOverrideEmail, setFromOverrideEmail] = useState<string>(initialData?.fromOverrideEmail ?? '');
const [fromOverrideName, setFromOverrideName] = useState<string>(initialData?.fromOverrideName ?? '');
const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false);
@@ -292,14 +300,19 @@ export function EmailComposer({
if (selectedIdentityId || initialData?.selectedIdentityId) return; if (selectedIdentityId || initialData?.selectedIdentityId) return;
if (mode !== 'reply' && mode !== 'replyAll') return; if (mode !== 'reply' && mode !== 'replyAll') return;
const matchedIdentityId = findReplyIdentityId(identities, { const resolved = resolveReplyFrom(identities, {
to: replyTo?.to, to: replyTo?.to,
cc: replyTo?.cc, cc: replyTo?.cc,
bcc: replyTo?.bcc, bcc: replyTo?.bcc,
}); });
if (matchedIdentityId) { if (resolved) {
setSelectedIdentityId(matchedIdentityId); setSelectedIdentityId(resolved.identityId);
if (resolved.overrideEmail && !fromOverrideEnabled) {
setFromOverrideEnabled(true);
setFromOverrideEmail(resolved.overrideEmail);
if (resolved.overrideName) setFromOverrideName(resolved.overrideName);
}
return; return;
} }
@@ -318,6 +331,7 @@ export function EmailComposer({
} }
}, [ }, [
autoSelectReplyIdentity, autoSelectReplyIdentity,
fromOverrideEnabled,
identities, identities,
initialData?.selectedIdentityId, initialData?.selectedIdentityId,
mode, mode,
@@ -367,8 +381,8 @@ export function EmailComposer({
}, [currentSmimeIdentityId]); }, [currentSmimeIdentityId]);
// Keep a ref to current state for the unmount save // Keep a ref to current state for the unmount save
const stateRef = useRef({ 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 }; 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) // Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: attachments.length }); 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 // Get the selected identity or primary identity
// Generate sub-addressed email if tag is set // Generate sub-addressed email if tag is set
const fromEmail = currentIdentity?.email const identityFromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email : currentIdentity.email
: undefined; : undefined;
const fromEmail = (fromOverrideEnabled && fromOverrideEmail.trim())
? fromOverrideEmail.trim()
: identityFromEmail;
const fromName = (fromOverrideEnabled && fromOverrideEmail.trim())
? (fromOverrideName.trim() || undefined)
: (currentIdentity?.name || undefined);
try { try {
const savedDraftId = await client.createDraft( const savedDraftId = await client.createDraft(
@@ -778,7 +798,7 @@ export function EmailComposer({
fromEmail, fromEmail,
draftId || undefined, draftId || undefined,
uploadedAttachments, uploadedAttachments,
currentIdentity?.name || undefined, fromName,
plainTextMode ? undefined : body plainTextMode ? undefined : body
); );
@@ -951,11 +971,21 @@ export function EmailComposer({
} }
} }
const fromEmail = currentIdentity?.email const identityFromEmail = currentIdentity?.email
? subAddressTag ? subAddressTag
? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter) ? generateSubAddress(currentIdentity.email, subAddressTag, subAddressDelimiter)
: currentIdentity.email : currentIdentity.email
: undefined; : 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). // 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) // Build HTML signature block (used only in rich text mode)
@@ -1012,6 +1042,12 @@ export function EmailComposer({
if (smimeSign_ && !smimeKeyRecord) { if (smimeSign_ && !smimeKeyRecord) {
throw new Error('No S/MIME key bound to this identity'); 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 // 2. Ensure key is unlocked for signing
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) { if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
@@ -1156,8 +1192,9 @@ export function EmailComposer({
htmlBody: outgoing.htmlBody || undefined, htmlBody: outgoing.htmlBody || undefined,
draftId: finalDraftId || undefined, draftId: finalDraftId || undefined,
fromEmail, fromEmail,
fromName: currentIdentity?.name || undefined, fromName,
identityId: outgoing.identityId || currentIdentity?.id, identityId: outgoing.identityId || currentIdentity?.id,
envelopeMailFrom,
attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined,
inReplyTo: threadingHeaders?.inReplyTo, inReplyTo: threadingHeaders?.inReplyTo,
references: threadingHeaders?.references, references: threadingHeaders?.references,
@@ -1185,7 +1222,7 @@ export function EmailComposer({
setSubAddressTag(""); setSubAddressTag("");
setValidationErrors({}); setValidationErrors({});
// Clear ref so unmount effect doesn't re-save // 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) { } catch (err) {
debug.error('Failed to send email:', err); debug.error('Failed to send email:', err);
toast.error(t('send_failed')); toast.error(t('send_failed'));
@@ -1196,7 +1233,7 @@ export function EmailComposer({
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(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?.(); onClose?.();
}; };
@@ -1206,7 +1243,7 @@ export function EmailComposer({
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
await saveDraft(); 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?.(); onClose?.();
}; };
@@ -1218,7 +1255,7 @@ export function EmailComposer({
if (draftId && onDiscardDraft) { if (draftId && onDiscardDraft) {
onDiscardDraft(draftId); 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?.(); onClose?.();
}; };
@@ -1302,7 +1339,25 @@ export function EmailComposer({
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50"> <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
<div className="flex-1 flex items-center gap-1 min-w-0"> <div className="flex-1 flex items-center gap-1 min-w-0">
{identities.length > 1 ? ( {fromOverrideEnabled ? (
<div className="flex-1 flex items-center gap-1 min-w-0">
<Input
value={fromOverrideName}
onChange={(e) => 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')}
/>
<Input
value={fromOverrideEmail}
onChange={(e) => 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')}
/>
</div>
) : identities.length > 1 ? (
<select <select
value={selectedIdentityId || primaryIdentity?.id || ''} value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)} onChange={(e) => setSelectedIdentityId(e.target.value)}
@@ -1334,16 +1389,18 @@ export function EmailComposer({
)} )}
</span> </span>
)} )}
<SubAddressHelper {!fromOverrideEnabled && (
baseEmail={ <SubAddressHelper
(selectedIdentityId baseEmail={
? identities.find(id => id.id === selectedIdentityId)?.email (selectedIdentityId
: primaryIdentity?.email) || '' ? identities.find(id => id.id === selectedIdentityId)?.email
} : primaryIdentity?.email) || ''
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)} }
onSelectTag={setSubAddressTag} recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
/> onSelectTag={setSubAddressTag}
{subAddressTag && ( />
)}
{!fromOverrideEnabled && subAddressTag && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -1355,6 +1412,28 @@ export function EmailComposer({
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</Button> </Button>
)} )}
<Button
type="button"
variant={fromOverrideEnabled ? 'outline' : 'ghost'}
size="sm"
onClick={() => {
if (fromOverrideEnabled) {
setFromOverrideEnabled(false);
} else {
setFromOverrideEnabled(true);
if (!fromOverrideEmail && currentIdentity?.email) {
setFromOverrideEmail(currentIdentity.email);
}
if (!fromOverrideName && currentIdentity?.name) {
setFromOverrideName(currentIdentity.name);
}
}
}}
className="h-6 px-2 text-xs shrink-0"
title={t('from_override.toggle_tooltip')}
>
{fromOverrideEnabled ? t('from_override.toggle_on') : t('from_override.toggle_off')}
</Button>
</div> </div>
</div> </div>
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { findReplyIdentityId } from '../reply-identity'; import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import type { Identity } from '../jmap/types'; import type { Identity } from '../jmap/types';
const identities: Identity[] = [ const identities: Identity[] = [
@@ -49,4 +49,39 @@ describe('findReplyIdentityId', () => {
expect(selected).toBeNull(); expect(selected).toBeNull();
}); });
});
describe('resolveReplyFrom', () => {
it('returns the matching identity with no override when exact match', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
.toEqual({ identityId: 'secondary' });
});
it('strips +tag before matching identities', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry+news@primary.com' }] }))
.toEqual({ identityId: 'primary' });
});
it('surfaces catch-all override when recipient is on an identity domain but not an identity', () => {
const result = resolveReplyFrom(identities, {
to: [{ email: 'stripe@primary.com', name: 'Stripe' }],
});
expect(result).toEqual({
identityId: 'primary',
overrideEmail: 'stripe@primary.com',
overrideName: 'Stripe',
});
});
it('prefers identity match over catch-all override when both appear', () => {
const result = resolveReplyFrom(identities, {
to: [{ email: 'harry@primary.com' }, { email: 'stripe@primary.com' }],
});
expect(result).toEqual({ identityId: 'primary' });
});
it('returns null when recipients are on foreign domains', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] }))
.toBeNull();
});
}); });
+1
View File
@@ -146,6 +146,7 @@ export interface IJMAPClient {
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[], inReplyTo?: string[],
references?: string[], references?: string[],
envelopeMailFrom?: string,
): Promise<void>; ): Promise<void>;
sendImipReply(opts: { sendImipReply(opts: {
+19 -3
View File
@@ -2089,7 +2089,8 @@ export class JMAPClient implements IJMAPClient {
htmlBody?: string, htmlBody?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
inReplyTo?: string[], inReplyTo?: string[],
references?: string[] references?: string[],
envelopeMailFrom?: string
): Promise<void> { ): Promise<void> {
const emailId = `send-${Date.now()}`; const emailId = `send-${Date.now()}`;
const mailboxes = await this.getMailboxes(); const mailboxes = await this.getMailboxes();
@@ -2185,6 +2186,21 @@ export class JMAPClient implements IJMAPClient {
}, },
}; };
// When an explicit envelope MAIL FROM is provided (header From ≠ envelope,
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
// is omitted the server derives mailFrom from the Identity.
const submissionCreate = (submissionId: string): Record<string, unknown> => {
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
if (envelopeMailFrom) {
create.envelope = {
mailFrom: { email: envelopeMailFrom },
rcptTo: [...to, ...(cc || []), ...(bcc || [])].map((email) => ({ email })),
};
}
return { [submissionId]: create };
};
if (draftId) { if (draftId) {
// Destroy the old draft and create a new email with the final body // Destroy the old draft and create a new email with the final body
methodCalls.push(["Email/set", { methodCalls.push(["Email/set", {
@@ -2197,7 +2213,7 @@ export class JMAPClient implements IJMAPClient {
}, "1"]); }, "1"]);
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, create: submissionCreate("1"),
onSuccessUpdateEmail, onSuccessUpdateEmail,
}, "2"]); }, "2"]);
} else { } else {
@@ -2207,7 +2223,7 @@ export class JMAPClient implements IJMAPClient {
}, "0"]); }, "0"]);
methodCalls.push(["EmailSubmission/set", { methodCalls.push(["EmailSubmission/set", {
accountId: this.accountId, accountId: this.accountId,
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, create: submissionCreate("1"),
onSuccessUpdateEmail, onSuccessUpdateEmail,
}, "1"]); }, "1"]);
} }
+95
View File
@@ -2,6 +2,7 @@ import type { Identity } from '@/lib/jmap/types';
interface ReplyRecipient { interface ReplyRecipient {
email?: string | null; email?: string | null;
name?: string | null;
} }
interface ReplyRecipients { interface ReplyRecipients {
@@ -29,6 +30,11 @@ function normalizeBaseEmailAddress(email: string): string {
return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`; return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
} }
function domainOf(email: string): string {
const at = email.indexOf('@');
return at > 0 ? email.slice(at + 1).toLowerCase() : '';
}
export function findReplyIdentityId( export function findReplyIdentityId(
identities: Identity[], identities: Identity[],
recipients?: ReplyRecipients, recipients?: ReplyRecipients,
@@ -59,4 +65,93 @@ export function findReplyIdentityId(
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email))); const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
return baseIdentity?.id ?? null; return baseIdentity?.id ?? null;
}
export interface ReplyFromResolution {
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
identityId: string;
/**
* Override for the outgoing `From:` header. Populated when the incoming
* message was delivered to an address on a domain the user owns (by
* identity) but that isn't itself a configured identity — typical
* domain-catch-all deployments. When set, the composer should put this
* address (and `overrideName`) in the message's From header while sending
* through the chosen identity.
*/
overrideEmail?: string;
overrideName?: string;
}
/**
* Pick the identity + optional header-From override for replying to a message.
*
* Decision order:
* 1. If a recipient address exactly matches an identity, reply as that
* identity with no override.
* 2. Else if a recipient matches an identity after stripping `+tag`
* sub-addressing, reply as that identity with no override.
* 3. Else if a recipient address is on a domain that one of the identities
* uses, treat that recipient as a catch-all alias: return the matching
* identity + the recipient as a header-From override.
* 4. Else return `null` (caller falls back to primary identity).
*/
export function resolveReplyFrom(
identities: Identity[],
recipients?: ReplyRecipients,
): ReplyFromResolution | null {
if (identities.length === 0 || !recipients) {
return null;
}
const received: { email: string; name: string | undefined }[] = [
...(recipients.to || []),
...(recipients.cc || []),
...(recipients.bcc || []),
].flatMap((r) => {
const email = r.email?.trim();
if (!email) return [];
return [{ email, name: r.name?.trim() || undefined }];
});
if (received.length === 0) {
return null;
}
const identityEmails = new Set(identities.map((i) => normalizeEmailAddress(i.email)));
const identityBaseEmails = new Set(identities.map((i) => normalizeBaseEmailAddress(i.email)));
const exactIdentity = identities.find((i) =>
received.some((r) => normalizeEmailAddress(r.email) === normalizeEmailAddress(i.email)),
);
if (exactIdentity) {
return { identityId: exactIdentity.id };
}
const baseIdentity = identities.find((i) =>
received.some((r) => normalizeBaseEmailAddress(r.email) === normalizeBaseEmailAddress(i.email)),
);
if (baseIdentity) {
return { identityId: baseIdentity.id };
}
const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean));
const catchAll = received.find((r) => {
const email = normalizeEmailAddress(r.email);
if (identityEmails.has(email) || identityBaseEmails.has(normalizeBaseEmailAddress(email))) {
return false;
}
return ownedDomains.has(domainOf(email));
});
if (catchAll) {
const anchor = identities.find((i) => domainOf(i.email) === domainOf(catchAll.email)) || identities[0];
return {
identityId: anchor.id,
overrideEmail: catchAll.email,
overrideName: catchAll.name,
};
}
return null;
} }
+11 -2
View File
@@ -531,6 +531,15 @@
"to": "To: {recipients}" "to": "To: {recipients}"
}, },
"remove_sub_address": "Remove sub-address", "remove_sub_address": "Remove sub-address",
"from_override": {
"toggle_off": "Override",
"toggle_on": "Cancel override",
"toggle_tooltip": "Edit the From name and address freely. Mail is still sent through your identity — only the visible From header changes.",
"name_label": "From name",
"name_placeholder": "Name",
"email_label": "From email address",
"email_placeholder": "alias@example.com"
},
"use_template": "Template", "use_template": "Template",
"save_as_template": "Save as Template", "save_as_template": "Save as Template",
"validation": { "validation": {
@@ -960,8 +969,8 @@
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards" "description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
}, },
"auto_select_reply_identity": { "auto_select_reply_identity": {
"label": "Auto-select Reply Address", "label": "Reply From Received Address",
"description": "When replying, automatically switch the From address to the identity that originally received the message" "description": "When replying, send from the address the message was originally sent to. Matches identities first; for domain catch-all deliveries, rewrites the From header to the alias while sending through your primary identity."
}, },
"sub_address_delimiter": { "sub_address_delimiter": {
"label": "Sub-Address Delimiter", "label": "Sub-Address Delimiter",
+3 -3
View File
@@ -75,7 +75,7 @@ interface EmailStore {
loadMoreEmails: (client: IJMAPClient) => Promise<void>; loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>; 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[]) => 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[], envelopeMailFrom?: string) => Promise<void>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
@@ -534,10 +534,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} }
}, },
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references) => { sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references); await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom);
// Refresh handled by UI layer for immediate feedback // Refresh handled by UI layer for immediate feedback
set({ isLoading: false }); set({ isLoading: false });
} catch (error) { } catch (error) {