From 2d907ffab83519c3a6ecaa22d8f616c03636d35d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:45:24 +0200 Subject: [PATCH] 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. --- locales/rn/en.json | 8 ++++- src/components/settings/ComposingSettings.tsx | 20 +++++++++++-- src/lib/__tests__/reply-identity.test.ts | 6 ++++ src/lib/reply-identity.ts | 29 +++++++++++++++---- src/screens/ComposeScreen.tsx | 4 ++- src/stores/__tests__/settings-store.test.ts | 6 ++++ src/stores/settings-store.ts | 6 ++++ 7 files changed, 69 insertions(+), 10 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index be72b543..71e46efe 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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." }, diff --git a/src/components/settings/ComposingSettings.tsx b/src/components/settings/ComposingSettings.tsx index 03e31a2b..540ffd98 100644 --- a/src/components/settings/ComposingSettings.tsx +++ b/src/components/settings/ComposingSettings.tsx @@ -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 = ( options: { label: string; value: T }[], current: T, @@ -99,11 +105,21 @@ export function ComposingSettings() { > + {autoSelectReplyIdentity && ( + + {t('settings.email_behavior.reply_identity_match.label', 'Received Address Matching')} + + {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.')} + + {segmented(REPLY_IDENTITY_MATCHES, replyIdentityMatch, (v) => updateSetting('replyIdentityMatch', v))} + + )} + { 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', () => { diff --git a/src/lib/reply-identity.ts b/src/lib/reply-identity.ts index 0e25dbea..a38deb10 100644 --- a/src/lib/reply-identity.ts +++ b/src/lib/reply-identity.ts @@ -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; } diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 20b26d7f..660ef546 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -438,6 +438,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const customDraftRef = React.useRef(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; diff --git a/src/stores/__tests__/settings-store.test.ts b/src/stores/__tests__/settings-store.test.ts index 35b77b19..a9cb4780 100644 --- a/src/stores/__tests__/settings-store.test.ts +++ b/src/stores/__tests__/settings-store.test.ts @@ -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); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index 163d1d4f..ea464bb8 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -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 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),