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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user