feat: choose exact-address or same-domain matching for reply from received address #1000

Ported from the webmail (565d5c10). With "Reply from received address"
on, a new "Received address matching" choice limits the catch-all From
rewrite: "Exact address only" keeps replies to configured identities,
"Any address on my domains" (the default, and the old behaviour) also
sends from other addresses on an identity's domain. Deployments whose
extra addresses are distribution lists rather than aliases want exact.

The setting is `replyIdentityMatch`, the webmail's key, so it round-trips
through settings export and import. The toggle's description now says
that identities always match, since that no longer depends on it.
This commit is contained in:
Linus Rath
2026-09-24 19:10:45 +02:00
parent a0c2bd7f4a
commit 2d907ffab8
7 changed files with 69 additions and 10 deletions
+7 -1
View File
@@ -428,11 +428,17 @@
"description_mobile": "What happens when you tap an attachment."
},
"auto_select_reply_identity": {
"description_mobile": "When replying, send from the address the message was originally sent to."
"description_mobile": "Replies always come from the identity a message was sent to. With this on, a reply to another address on your domains (a catch-all alias) is sent from that address too."
},
"plain_text_mode": {
"description_mobile": "Compose and read in plain text only."
},
"reply_identity_match": {
"description": "Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.",
"domain": "Any address on my domains",
"exact": "Exact address only",
"label": "Received Address Matching"
},
"send_delay": {
"description_mobile": "Hold outgoing mail for a few seconds so you can cancel it. Requires server support."
},
+18 -2
View File
@@ -5,7 +5,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SignaturePosition } from '../../stores/settings-store';
import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
@@ -13,6 +13,7 @@ export function ComposingSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const replyIdentityMatch = useSettingsStore((s) => s.replyIdentityMatch);
const t = useLocaleStore((s) => s.t);
const setAutoSelectReplyIdentity = useSettingsStore((s) => s.setAutoSelectReplyIdentity);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
@@ -69,6 +70,11 @@ export function ComposingSettings() {
{ label: t('settings.email_behavior.signature_position.below_quote', 'After quoted text'), value: 'below_quote' },
];
const REPLY_IDENTITY_MATCHES: { label: string; value: ReplyIdentityMatch }[] = [
{ label: t('settings.email_behavior.reply_identity_match.exact', 'Exact address only'), value: 'exact' },
{ label: t('settings.email_behavior.reply_identity_match.domain', 'Any address on my domains'), value: 'domain' },
];
const segmented = <T extends string | number>(
options: { label: string; value: T }[],
current: T,
@@ -99,11 +105,21 @@ export function ComposingSettings() {
>
<SettingItem
label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")}
description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "When replying, send from the address the message was originally sent to.")}
description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "Replies always come from the identity a message was sent to. With this on, a reply to another address on your domains (a catch-all alias) is sent from that address too.")}
>
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem>
{autoSelectReplyIdentity && (
<View style={styles.subBlock}>
<Text style={styles.subLabel}>{t('settings.email_behavior.reply_identity_match.label', 'Received Address Matching')}</Text>
<Text style={styles.subDescription}>
{t('settings.email_behavior.reply_identity_match.description', 'Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.')}
</Text>
{segmented(REPLY_IDENTITY_MATCHES, replyIdentityMatch, (v) => updateSetting('replyIdentityMatch', v))}
</View>
)}
<SettingItem
label={t('settings.email_behavior.plain_text_mode.label', "Plain Text Only")}
description={t('settings.email_behavior.plain_text_mode.description', "Disable the rich text editor and send all emails as plain text only, including replies and forwards")}
+6
View File
@@ -98,6 +98,11 @@ describe('resolveReplyFrom', () => {
it('returns null for foreign domains', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'x@other.com' }] })).toBeNull();
});
it('skips the catch-all in exact match mode but still matches identities', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'sales@example.com' }] }, 'exact')).toBeNull();
expect(resolveReplyFrom(identities, { to: [{ email: 'info+x@example.com' }] }, 'exact')).toEqual({ identityId: 'info' });
});
});
describe('resolveReplyIdentity', () => {
@@ -116,6 +121,7 @@ describe('resolveReplyIdentity', () => {
expect(resolveReplyIdentity(identities, original, { ownEmails, catchAll: true })).toEqual({
identityId: 'main', overrideEmail: 'sales@example.com', overrideName: 'Sales',
});
expect(resolveReplyIdentity(identities, original, { ownEmails, catchAll: true, matchMode: 'exact' })).toBeNull();
});
it('replies to our own message from the identity that sent it', () => {
+23 -6
View File
@@ -167,6 +167,16 @@ export function findDraftIdentityId(
return base?.id ?? null;
}
/**
* How far `resolveReplyFrom` goes when matching received addresses:
* `exact` stops at the user's configured identities (steps 1-2 below),
* `domain` also takes the same-domain catch-all step (3), which rewrites
* `From:` to an address the user has not configured. Deployments where the
* extra addresses on a domain are distribution lists rather than aliases
* want `exact` (webmail #1000).
*/
export type ReplyIdentityMatchMode = 'exact' | 'domain';
export interface ReplyFromResolution {
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
identityId: string;
@@ -192,12 +202,14 @@ export interface ReplyFromResolution {
* 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.
* identity + the recipient as a header-From override. Skipped in
* `exact` match mode.
* 4. Else return `null` (caller falls back to primary identity).
*/
export function resolveReplyFrom(
identities: Identity[],
recipients?: ReplyRecipients,
matchMode: ReplyIdentityMatchMode = 'domain',
): ReplyFromResolution | null {
if (identities.length === 0 || !recipients) {
return null;
@@ -234,6 +246,10 @@ export function resolveReplyFrom(
return { identityId: baseIdentity.id };
}
if (matchMode !== 'domain') {
return null;
}
const ownedDomains = new Set(identities.map((i) => domainOf(i.email)).filter(Boolean));
const catchAll = received.find((r) => {
@@ -264,15 +280,16 @@ export function resolveReplyFrom(
* 2. The own identity the original was delivered to (`findReplyIdentityId`).
* Unconditional: it only ever picks a configured address.
* 3. With `catchAll`, the same-domain catch-all From override of
* `resolveReplyFrom`. Callers pass it only for an opted-in reply, never
* a forward: a reply continues a thread whose participants know the
* addressing, a forward shows the rewritten From to someone new.
* `resolveReplyFrom`, unless `matchMode` is `exact`. Callers pass it
* only for an opted-in reply, never a forward: a reply continues a
* thread whose participants know the addressing, a forward shows the
* rewritten From to someone new.
* Null when nothing matches (the caller keeps its default identity).
*/
export function resolveReplyIdentity(
identities: Identity[],
original: ReplyRecipients & { from?: ReplyRecipient | null },
opts: { ownEmails: string[]; catchAll: boolean },
opts: { ownEmails: string[]; catchAll: boolean; matchMode?: ReplyIdentityMatchMode },
): ReplyFromResolution | null {
if (identities.length === 0) {
return null;
@@ -292,5 +309,5 @@ export function resolveReplyIdentity(
return { identityId: ownId };
}
return opts.catchAll ? resolveReplyFrom(identities, recipients) : null;
return opts.catchAll ? resolveReplyFrom(identities, recipients, opts.matchMode) : null;
}
+3 -1
View File
@@ -438,6 +438,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const customDraftRef = React.useRef<Date>(new Date());
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const replyIdentityMatch = useSettingsStore((s) => s.replyIdentityMatch);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
@@ -860,6 +861,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const resolved = resolveReplyIdentity(identities, replyTo, {
ownEmails,
catchAll: autoSelectReplyIdentity && mode !== 'forward',
matchMode: replyIdentityMatch,
});
if (resolved) {
setSelectedIdentityId(resolved.identityId);
@@ -871,7 +873,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}
setSelectedIdentityId(defaultIdentity.id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identities, autoSelectReplyIdentity, replyTo, draft, selectedIdentityId, preferredIdentityIds]);
}, [identities, autoSelectReplyIdentity, replyIdentityMatch, replyTo, draft, selectedIdentityId, preferredIdentityIds]);
const primaryIdentity = React.useMemo(() => {
if (identities.length === 0) return null;
@@ -21,6 +21,7 @@ describe('settings-store', () => {
const s = useSettingsStore.getState();
expect(s.includeGroupInUnified).toBe(true);
expect(s.autoSelectReplyIdentity).toBe(false);
expect(s.replyIdentityMatch).toBe('domain');
expect(s.showBirthdayCalendar).toBe(false);
expect(s.attachmentReminderKeywords).toContain('anhang');
expect(s.attachmentReminderKeywords).toContain('添付');
@@ -52,6 +53,11 @@ describe('settings-store', () => {
expect(out.bottomQuickActions).toEqual(['delete', 'reply', 'replyAll']);
});
it('accepts only the known reply identity match modes', () => {
expect(mergeWithDefaults({ replyIdentityMatch: 'exact' }).replyIdentityMatch).toBe('exact');
expect(mergeWithDefaults({ replyIdentityMatch: 'loose' as never }).replyIdentityMatch).toBe('domain');
});
it('fills missing debug categories from the default', () => {
const out = mergeWithDefaults({ debugCategories: { push: false } as never });
expect(out.debugCategories.push).toBe(false);
+6
View File
@@ -26,6 +26,9 @@ export type SwipeAction =
| 'move';
export type SwipeMode = 'instant' | 'reveal';
export type SignaturePosition = 'above_quote' | 'below_quote';
// With autoSelectReplyIdentity on: 'exact' = configured identities only,
// 'domain' = also same-domain catch-all addresses (rewrites From), #1000.
export type ReplyIdentityMatch = 'exact' | 'domain';
// Actions that can be placed in the email reader's bottom quick-action bar.
// The first three are the reply family (the default bar); any reply-family
// action the user removes from the bar is relocated to the top toolbar so it
@@ -154,6 +157,7 @@ interface PersistedSettings {
// Composing
autoSelectReplyIdentity: boolean;
replyIdentityMatch: ReplyIdentityMatch;
attachmentReminderEnabled: boolean;
attachmentReminderKeywords: string[];
plainTextMode: boolean;
@@ -306,6 +310,7 @@ const DEFAULT_PERSISTED: PersistedSettings = {
activeThemeId: null,
autoSelectReplyIdentity: false,
replyIdentityMatch: 'domain',
attachmentReminderEnabled: true,
// Same multilingual list as the webmail so a synced/imported settings blob
// does not flip the reminder behaviour between clients.
@@ -530,6 +535,7 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
// Same set the webmail accepts (stores/settings-store.ts importSettings).
sendDelaySeconds: oneOf([0, 10, 30, 60]),
signaturePosition: oneOf(['above_quote', 'below_quote']),
replyIdentityMatch: oneOf(['exact', 'domain']),
autoSaveDraftInterval: intBetween(1000, 3600000),
// RFC 5321 atext specials minus alphanumerics and "@" (lib/sub-addressing).
subAddressDelimiter: (v) => typeof v === 'string' && /^[!#$%&'*+\-./=?^_`{|}~]$/.test(v),