feat: add auto-select reply identity feature with settings and localization

This commit is contained in:
Linus Rath
2026-03-27 23:58:42 +01:00
parent 92ff5fe449
commit ddb636ed73
17 changed files with 201 additions and 0 deletions
+1
View File
@@ -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,
+1
View File
@@ -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' },
+28
View File
@@ -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
? `<div>${sanitizeEmailHtml(currentIdentity.htmlSignature)}</div>`
: currentIdentity?.textSignature
+8
View File
@@ -40,6 +40,7 @@ export function EmailSettings() {
permanentlyDeleteJunk,
showPreview,
disableThreading,
autoSelectReplyIdentity,
plainTextMode,
emailsPerPage,
externalContentPolicy,
@@ -230,6 +231,13 @@ export function EmailSettings() {
/>
</SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
{/* Quick Hover Actions */}
{isFeatureEnabled('hoverActionsConfigEnabled') && (
<div className="py-3 border-b border-border space-y-3">
+52
View File
@@ -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();
});
});
+62
View File
@@ -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;
}
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -807,6 +807,10 @@
"label": "プレーンテキストのみ",
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
},
"auto_select_reply_identity": {
"label": "返信元アドレスを自動選択",
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
},
"show_preview": {
"label": "プレビューテキストを表示",
"description": "リストにメールのプレビューを表示"
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -807,6 +807,10 @@
"label": "Только простой текст",
"description": "Отключить редактор форматированного текста и отправлять все письма только в виде простого текста, включая ответы и пересылки"
},
"auto_select_reply_identity": {
"label": "Автоматически выбирать адрес для ответа",
"description": "При ответе автоматически переключать адрес отправителя на ту учетную запись, которая получила исходное сообщение"
},
"show_preview": {
"label": "Показывать текст предпросмотра",
"description": "Отображать предпросмотр письма в списке"
@@ -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);
});
});
+3
View File
@@ -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<SettingsState>()(
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
autoSelectReplyIdentity: state.autoSelectReplyIdentity,
plainTextMode: state.plainTextMode,
sessionTimeout: state.sessionTimeout,
emailNotificationsEnabled: state.emailNotificationsEnabled,