feat: add auto-select reply identity feature with settings and localization
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -807,6 +807,10 @@
|
||||
"label": "プレーンテキストのみ",
|
||||
"description": "リッチテキストエディターを無効にし、返信や転送を含むすべてのメールをプレーンテキストのみで送信します"
|
||||
},
|
||||
"auto_select_reply_identity": {
|
||||
"label": "返信元アドレスを自動選択",
|
||||
"description": "返信時に、元のメッセージを受信したIDへ差出人アドレスを自動的に切り替えます"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "プレビューテキストを表示",
|
||||
"description": "リストにメールのプレビューを表示"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user