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 { 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
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
|
||||
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 [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({
|
||||
<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>
|
||||
<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
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
@@ -1334,16 +1389,18 @@ export function EmailComposer({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
{subAddressTag && (
|
||||
{!fromOverrideEnabled && (
|
||||
<SubAddressHelper
|
||||
baseEmail={
|
||||
(selectedIdentityId
|
||||
? identities.find(id => id.id === selectedIdentityId)?.email
|
||||
: primaryIdentity?.email) || ''
|
||||
}
|
||||
recipientEmails={to.split(',').map(e => e.trim()).filter(Boolean)}
|
||||
onSelectTag={setSubAddressTag}
|
||||
/>
|
||||
)}
|
||||
{!fromOverrideEnabled && subAddressTag && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1355,6 +1412,28 @@ export function EmailComposer({
|
||||
<X className="w-3 h-3" />
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findReplyIdentityId } from '../reply-identity';
|
||||
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
|
||||
import type { Identity } from '../jmap/types';
|
||||
|
||||
const identities: Identity[] = [
|
||||
@@ -49,4 +49,39 @@ describe('findReplyIdentityId', () => {
|
||||
|
||||
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 }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[],
|
||||
envelopeMailFrom?: string,
|
||||
): Promise<void>;
|
||||
|
||||
sendImipReply(opts: {
|
||||
|
||||
+19
-3
@@ -2089,7 +2089,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
htmlBody?: string,
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||||
inReplyTo?: string[],
|
||||
references?: string[]
|
||||
references?: string[],
|
||||
envelopeMailFrom?: string
|
||||
): Promise<void> {
|
||||
const emailId = `send-${Date.now()}`;
|
||||
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) {
|
||||
// Destroy the old draft and create a new email with the final body
|
||||
methodCalls.push(["Email/set", {
|
||||
@@ -2197,7 +2213,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}, "1"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||||
create: submissionCreate("1"),
|
||||
onSuccessUpdateEmail,
|
||||
}, "2"]);
|
||||
} else {
|
||||
@@ -2207,7 +2223,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}, "0"]);
|
||||
methodCalls.push(["EmailSubmission/set", {
|
||||
accountId: this.accountId,
|
||||
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||||
create: submissionCreate("1"),
|
||||
onSuccessUpdateEmail,
|
||||
}, "1"]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
interface ReplyRecipient {
|
||||
email?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
interface ReplyRecipients {
|
||||
@@ -29,6 +30,11 @@ function normalizeBaseEmailAddress(email: string): string {
|
||||
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(
|
||||
identities: Identity[],
|
||||
recipients?: ReplyRecipients,
|
||||
@@ -59,4 +65,93 @@ export function findReplyIdentityId(
|
||||
const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
|
||||
|
||||
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}"
|
||||
},
|
||||
"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",
|
||||
"save_as_template": "Save as Template",
|
||||
"validation": {
|
||||
@@ -960,8 +969,8 @@
|
||||
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
|
||||
},
|
||||
"auto_select_reply_identity": {
|
||||
"label": "Auto-select Reply Address",
|
||||
"description": "When replying, automatically switch the From address to the identity that originally received the message"
|
||||
"label": "Reply From Received Address",
|
||||
"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": {
|
||||
"label": "Sub-Address Delimiter",
|
||||
|
||||
@@ -75,7 +75,7 @@ interface EmailStore {
|
||||
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
|
||||
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
|
||||
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>;
|
||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: 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 });
|
||||
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
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user