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