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,