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),