From ddb636ed73d1a73bfc16e7841b130dcfe124aea7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Fri, 27 Mar 2026 23:58:42 +0100
Subject: [PATCH] feat: add auto-select reply identity feature with settings
and localization
---
app/[locale]/page.tsx | 1 +
app/admin/policy/page.tsx | 1 +
components/email/email-composer.tsx | 28 +++++++++
components/settings/email-settings.tsx | 8 +++
lib/__tests__/reply-identity.test.ts | 52 ++++++++++++++++
lib/reply-identity.ts | 62 +++++++++++++++++++
locales/de/common.json | 4 ++
locales/en/common.json | 4 ++
locales/es/common.json | 4 ++
locales/fr/common.json | 4 ++
locales/it/common.json | 4 ++
locales/ja/common.json | 4 ++
locales/nl/common.json | 4 ++
locales/pt/common.json | 4 ++
locales/ru/common.json | 4 ++
.../settings-store-attachments.test.ts | 10 +++
stores/settings-store.ts | 3 +
17 files changed, 201 insertions(+)
create mode 100644 lib/__tests__/reply-identity.test.ts
create mode 100644 lib/reply-identity.ts
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index 4997abef..9cb62eb9 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -1539,6 +1539,7 @@ export default function Home() {
from: selectedEmail.from,
to: selectedEmail.to,
cc: selectedEmail.cc,
+ bcc: selectedEmail.bcc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
htmlBody: selectedEmail.bodyValues?.[selectedEmail.htmlBody?.[0]?.partId || '']?.value || undefined,
diff --git a/app/admin/policy/page.tsx b/app/admin/policy/page.tsx
index ff2be065..d8490ad3 100644
--- a/app/admin/policy/page.tsx
+++ b/app/admin/policy/page.tsx
@@ -32,6 +32,7 @@ const RESTRICTABLE_SETTINGS = [
{ key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'] },
{ key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean' },
{ key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'] },
+ { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean' },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean' },
{ key: 'sessionTimeout', label: 'Session Timeout', category: 'Privacy', type: 'number' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean' },
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 5a452745..a32d2845 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -29,6 +29,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
+import { findReplyIdentityId } from "@/lib/reply-identity";
import { RichTextEditor } from "@/components/email/rich-text-editor";
/** Strip HTML tags and decode entities to get a plain-text version */
@@ -78,6 +79,7 @@ interface EmailComposerProps {
from?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
+ bcc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
htmlBody?: string;
@@ -100,6 +102,7 @@ export function EmailComposer({
const tCommon = useTranslations('common');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextMode = useSettingsStore((state) => state.plainTextMode);
+ const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
// Initialize with reply/forward data if provided
const getInitialTo = () => {
@@ -226,6 +229,31 @@ export function EmailComposer({
const currentIdentity = selectedIdentityId
? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity
: primaryIdentity;
+ useEffect(() => {
+ if (!autoSelectReplyIdentity) return;
+ if (selectedIdentityId || initialData?.selectedIdentityId) return;
+ if (mode !== 'reply' && mode !== 'replyAll') return;
+
+ const matchedIdentityId = findReplyIdentityId(identities, {
+ to: replyTo?.to,
+ cc: replyTo?.cc,
+ bcc: replyTo?.bcc,
+ });
+
+ if (matchedIdentityId) {
+ setSelectedIdentityId(matchedIdentityId);
+ }
+ }, [
+ autoSelectReplyIdentity,
+ identities,
+ initialData?.selectedIdentityId,
+ mode,
+ replyTo?.bcc,
+ replyTo?.cc,
+ replyTo?.to,
+ selectedIdentityId,
+ ]);
+
const composerSignatureHtml = currentIdentity?.htmlSignature
? `
${sanitizeEmailHtml(currentIdentity.htmlSignature)}
`
: currentIdentity?.textSignature
diff --git a/components/settings/email-settings.tsx b/components/settings/email-settings.tsx
index 8f13f8a4..3e4e30ea 100644
--- a/components/settings/email-settings.tsx
+++ b/components/settings/email-settings.tsx
@@ -40,6 +40,7 @@ export function EmailSettings() {
permanentlyDeleteJunk,
showPreview,
disableThreading,
+ autoSelectReplyIdentity,
plainTextMode,
emailsPerPage,
externalContentPolicy,
@@ -230,6 +231,13 @@ export function EmailSettings() {
/>
+
+ updateSetting('autoSelectReplyIdentity', checked)}
+ />
+
+
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
diff --git a/lib/__tests__/reply-identity.test.ts b/lib/__tests__/reply-identity.test.ts
new file mode 100644
index 00000000..532571de
--- /dev/null
+++ b/lib/__tests__/reply-identity.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import { findReplyIdentityId } from '../reply-identity';
+import type { Identity } from '../jmap/types';
+
+const identities: Identity[] = [
+ {
+ id: 'primary',
+ name: 'Harry Primary',
+ email: 'harry@primary.com',
+ mayDelete: false,
+ },
+ {
+ id: 'secondary',
+ name: 'Harry Secondary',
+ email: 'harry@secondary.com',
+ mayDelete: false,
+ },
+];
+
+describe('findReplyIdentityId', () => {
+ it('matches the identity that received the original message', () => {
+ const selected = findReplyIdentityId(identities, {
+ to: [{ email: 'harry@secondary.com' }],
+ });
+
+ expect(selected).toBe('secondary');
+ });
+
+ it('matches case-insensitively across recipients', () => {
+ const selected = findReplyIdentityId(identities, {
+ cc: [{ email: 'HARRY@PRIMARY.COM' }],
+ });
+
+ expect(selected).toBe('primary');
+ });
+
+ it('falls back to sub-address matching when needed', () => {
+ const selected = findReplyIdentityId(identities, {
+ to: [{ email: 'harry+news@secondary.com' }],
+ });
+
+ expect(selected).toBe('secondary');
+ });
+
+ it('returns null when no reply recipient matches an identity', () => {
+ const selected = findReplyIdentityId(identities, {
+ to: [{ email: 'other@example.com' }],
+ });
+
+ expect(selected).toBeNull();
+ });
+});
\ No newline at end of file
diff --git a/lib/reply-identity.ts b/lib/reply-identity.ts
new file mode 100644
index 00000000..1cfd334f
--- /dev/null
+++ b/lib/reply-identity.ts
@@ -0,0 +1,62 @@
+import type { Identity } from '@/lib/jmap/types';
+
+interface ReplyRecipient {
+ email?: string | null;
+}
+
+interface ReplyRecipients {
+ to?: ReplyRecipient[];
+ cc?: ReplyRecipient[];
+ bcc?: ReplyRecipient[];
+}
+
+function normalizeEmailAddress(email: string): string {
+ return email.trim().toLowerCase();
+}
+
+function normalizeBaseEmailAddress(email: string): string {
+ const normalized = normalizeEmailAddress(email);
+ const atIndex = normalized.indexOf('@');
+
+ if (atIndex <= 0) {
+ return normalized;
+ }
+
+ const localPart = normalized.slice(0, atIndex);
+ const domain = normalized.slice(atIndex + 1);
+ const plusIndex = localPart.indexOf('+');
+
+ return `${plusIndex >= 0 ? localPart.slice(0, plusIndex) : localPart}@${domain}`;
+}
+
+export function findReplyIdentityId(
+ identities: Identity[],
+ recipients?: ReplyRecipients,
+): string | null {
+ if (identities.length === 0 || !recipients) {
+ return null;
+ }
+
+ const receivedAddresses = [
+ ...(recipients.to || []),
+ ...(recipients.cc || []),
+ ...(recipients.bcc || []),
+ ]
+ .map((recipient) => recipient.email?.trim())
+ .filter((email): email is string => Boolean(email));
+
+ if (receivedAddresses.length === 0) {
+ return null;
+ }
+
+ const exactMatches = new Set(receivedAddresses.map(normalizeEmailAddress));
+ const exactIdentity = identities.find((identity) => exactMatches.has(normalizeEmailAddress(identity.email)));
+ if (exactIdentity) {
+ return exactIdentity.id;
+ }
+
+ const baseMatches = new Set(receivedAddresses.map(normalizeBaseEmailAddress));
+ const baseIdentity = identities.find((identity) => baseMatches.has(normalizeBaseEmailAddress(identity.email)));
+
+ return baseIdentity?.id ?? null;
+}
\ No newline at end of file
diff --git a/locales/de/common.json b/locales/de/common.json
index 17357647..7885f372 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -811,6 +811,10 @@
"label": "Nur Klartext",
"description": "Rich-Text-Editor deaktivieren und alle E-Mails nur als Klartext senden, einschließlich Antworten und Weiterleitungen"
},
+ "auto_select_reply_identity": {
+ "label": "Antwortadresse automatisch wählen",
+ "description": "Beim Antworten die Absenderadresse automatisch auf die Identität umstellen, die die ursprüngliche Nachricht erhalten hat"
+ },
"attachment_click_action": {
"label": "Aktion beim Klick auf Anhänge",
"description": "Festlegen, ob ein Dateianhang beim Anklicken in der Vorschau geöffnet oder sofort heruntergeladen wird",
diff --git a/locales/en/common.json b/locales/en/common.json
index c2feb1f6..e31f833a 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -811,6 +811,10 @@
"label": "Plain Text Only",
"description": "Disable the rich text editor and send all emails as plain text only, including replies and forwards"
},
+ "auto_select_reply_identity": {
+ "label": "Auto-select Reply Address",
+ "description": "When replying, automatically switch the From address to the identity that originally received the message"
+ },
"attachment_click_action": {
"label": "Attachment Click Action",
"description": "Choose whether clicking a file attachment previews it or downloads it immediately",
diff --git a/locales/es/common.json b/locales/es/common.json
index 9ad532e0..7cda162b 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -807,6 +807,10 @@
"label": "Solo texto sin formato",
"description": "Desactivar el editor de texto enriquecido y enviar todos los correos solo como texto sin formato, incluyendo respuestas y reenvíos"
},
+ "auto_select_reply_identity": {
+ "label": "Seleccionar dirección de respuesta automáticamente",
+ "description": "Al responder, cambia automáticamente la dirección del remitente a la identidad que recibió el mensaje original"
+ },
"show_preview": {
"label": "Mostrar Vista Previa",
"description": "Mostrar vista previa del correo en la lista"
diff --git a/locales/fr/common.json b/locales/fr/common.json
index ebb8fb7b..5bd8b248 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -807,6 +807,10 @@
"label": "Texte brut uniquement",
"description": "Désactiver l'éditeur de texte enrichi et envoyer tous les e-mails en texte brut uniquement, y compris les réponses et les transferts"
},
+ "auto_select_reply_identity": {
+ "label": "Sélection automatique de l'adresse de réponse",
+ "description": "Lors d'une réponse, bascule automatiquement l'adresse d'expédition vers l'identité qui a reçu le message d'origine"
+ },
"show_preview": {
"label": "Afficher l'aperçu",
"description": "Afficher l'aperçu de l'email dans la liste"
diff --git a/locales/it/common.json b/locales/it/common.json
index a730d4e3..780477b1 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -807,6 +807,10 @@
"label": "Solo testo normale",
"description": "Disabilita l'editor di testo formattato e invia tutte le email solo come testo normale, incluse risposte e inoltri"
},
+ "auto_select_reply_identity": {
+ "label": "Seleziona automaticamente l'indirizzo di risposta",
+ "description": "Quando rispondi, passa automaticamente l'indirizzo mittente all'identità che ha ricevuto il messaggio originale"
+ },
"show_preview": {
"label": "Mostra anteprima testo",
"description": "Visualizza l'anteprima del messaggio nell'elenco"
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 59d679cb..f2cfde91 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -807,6 +807,10 @@
"label": "プレーンテキストのみ",
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
},
+ "auto_select_reply_identity": {
+ "label": "返信元アドレスを自動選択",
+ "description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
+ },
"show_preview": {
"label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示"
diff --git a/locales/nl/common.json b/locales/nl/common.json
index d24c8279..cc9b43fd 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -807,6 +807,10 @@
"label": "Alleen platte tekst",
"description": "Schakel de rich text-editor uit en verzend alle e-mails alleen als platte tekst, inclusief antwoorden en doorgestuurde berichten"
},
+ "auto_select_reply_identity": {
+ "label": "Antwoordadres automatisch selecteren",
+ "description": "Schakel bij het beantwoorden automatisch het Van-adres om naar de identiteit die het oorspronkelijke bericht ontving"
+ },
"show_preview": {
"label": "Voorbeeldtekst tonen",
"description": "E-mailvoorbeeld weergeven in de lijst"
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 16ef3c07..16e126de 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -807,6 +807,10 @@
"label": "Apenas texto simples",
"description": "Desativar o editor de texto formatado e enviar todos os e-mails apenas como texto simples, incluindo respostas e encaminhamentos"
},
+ "auto_select_reply_identity": {
+ "label": "Selecionar automaticamente o endereço de resposta",
+ "description": "Ao responder, muda automaticamente o endereço do remetente para a identidade que recebeu a mensagem original"
+ },
"show_preview": {
"label": "Mostrar Texto de Visualização",
"description": "Exibir visualização do e-mail na lista"
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 651a43f8..f24ba5f3 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -807,6 +807,10 @@
"label": "Только простой текст",
"description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки"
},
+ "auto_select_reply_identity": {
+ "label": "Автоматически выбирать адрес для ответа",
+ "description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
+ },
"show_preview": {
"label": "Показывать текст предпросмотра",
"description": "Отображать предпросмотр письма в списке"
diff --git a/stores/__tests__/settings-store-attachments.test.ts b/stores/__tests__/settings-store-attachments.test.ts
index 18e05d4a..5bff57a9 100644
--- a/stores/__tests__/settings-store-attachments.test.ts
+++ b/stores/__tests__/settings-store-attachments.test.ts
@@ -29,4 +29,14 @@ describe('settings-store attachment action', () => {
expect(exported.calendarInvitationParsingEnabled).toBe(false);
});
+
+ it('includes reply identity auto-selection in exported settings', () => {
+ useSettingsStore.getState().updateSetting('autoSelectReplyIdentity', true);
+
+ const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
+ autoSelectReplyIdentity?: boolean;
+ };
+
+ expect(exported.autoSelectReplyIdentity).toBe(true);
+ });
});
\ No newline at end of file
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index 95bb0087..c32f9a2c 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -119,6 +119,7 @@ interface SettingsState {
autoSaveDraftInterval: number; // milliseconds
sendConfirmation: boolean;
defaultReplyMode: ReplyMode;
+ autoSelectReplyIdentity: boolean;
plainTextMode: boolean; // Send plain text only (no rich text editor)
// Privacy & Security
@@ -238,6 +239,7 @@ const DEFAULT_SETTINGS = {
autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode,
+ autoSelectReplyIdentity: false,
plainTextMode: false,
// Privacy & Security
@@ -346,6 +348,7 @@ export const useSettingsStore = create()(
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
+ autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,