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 { 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