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:
committed by
Linus Rath
parent
2d7e24b513
commit
b0640c9ecc
+28
-6
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user