From ec0f355c13a2507347494bfe7d1a1d420f31ad46 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 1 May 2026 01:48:39 +0200 Subject: [PATCH] feat: allow custom sub-addressing delimiter character #239 --- components/settings/composing-settings.tsx | 51 ++++++++++++++++++---- lib/__tests__/sub-addressing.test.ts | 49 +++++++++++++++++++++ lib/sub-addressing.ts | 13 +++++- locales/cs/common.json | 4 +- 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/ko/common.json | 4 +- locales/lv/common.json | 4 +- locales/nl/common.json | 4 +- locales/pl/common.json | 4 +- locales/pt/common.json | 4 +- locales/ru/common.json | 4 +- locales/tr/common.json | 4 +- locales/uk/common.json | 4 +- locales/zh/common.json | 4 +- stores/settings-store.ts | 9 ++-- 20 files changed, 154 insertions(+), 32 deletions(-) diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 1f8dbc13..1da3cd14 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -8,9 +8,13 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s import { Mail, X } from 'lucide-react'; import { SUPPORTED_SUB_ADDRESS_DELIMITERS, - type SubAddressDelimiter, + isSupportedSubAddressDelimiter, + isValidSubAddressDelimiter, } from '@/lib/sub-addressing'; +const CUSTOM_DELIMITER_SENTINEL = '__custom__'; +const DEFAULT_CUSTOM_DELIMITER = '~'; + export function ComposingSettings() { const t = useTranslations('settings.email_behavior'); const { appName } = useConfig(); @@ -49,14 +53,43 @@ export function ComposingSettings() { label={t('sub_address_delimiter.label')} description={t('sub_address_delimiter.description', { delimiter: subAddressDelimiter })} > - { + if (value === CUSTOM_DELIMITER_SENTINEL) { + if (isSupportedSubAddressDelimiter(subAddressDelimiter)) { + updateSetting('subAddressDelimiter', DEFAULT_CUSTOM_DELIMITER); + } + } else { + updateSetting('subAddressDelimiter', value); + } + }} + options={[ + ...SUPPORTED_SUB_ADDRESS_DELIMITERS.map((delim) => ({ + value: delim, + label: t('sub_address_delimiter.option', { delimiter: delim }), + })), + { value: CUSTOM_DELIMITER_SENTINEL, label: t('sub_address_delimiter.custom') }, + ]} + /> + {!isSupportedSubAddressDelimiter(subAddressDelimiter) && ( + { + const next = e.target.value.slice(0, 1); + if (next && isValidSubAddressDelimiter(next)) { + updateSetting('subAddressDelimiter', next); + } + }} + aria-label={t('sub_address_delimiter.custom_input_label')} + placeholder={DEFAULT_CUSTOM_DELIMITER} + className="w-16 px-2 py-1 text-sm font-mono text-center bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-ring" + /> + )} + diff --git a/lib/__tests__/sub-addressing.test.ts b/lib/__tests__/sub-addressing.test.ts index 00bb9400..c78f1e08 100644 --- a/lib/__tests__/sub-addressing.test.ts +++ b/lib/__tests__/sub-addressing.test.ts @@ -7,6 +7,7 @@ import { isValidTag, getTagValidationError, isSupportedSubAddressDelimiter, + isValidSubAddressDelimiter, SUPPORTED_SUB_ADDRESS_DELIMITERS, DEFAULT_SUB_ADDRESS_DELIMITER, MAX_TAG_LENGTH, @@ -427,4 +428,52 @@ describe('custom delimiter', () => { expect(isSupportedSubAddressDelimiter(DEFAULT_SUB_ADDRESS_DELIMITER)).toBe(true); }); }); + + describe('isValidSubAddressDelimiter', () => { + it('accepts every preset delimiter', () => { + for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) { + expect(isValidSubAddressDelimiter(delim)).toBe(true); + } + }); + + it('accepts atext special characters as custom delimiters', () => { + const customs = ['~', '!', '#', '$', '%', '&', "'", '*', '/', '?', '^', '_', '`', '{', '|', '}']; + for (const c of customs) { + expect(isValidSubAddressDelimiter(c)).toBe(true); + } + }); + + it('rejects alphanumeric characters', () => { + expect(isValidSubAddressDelimiter('a')).toBe(false); + expect(isValidSubAddressDelimiter('Z')).toBe(false); + expect(isValidSubAddressDelimiter('0')).toBe(false); + }); + + it('rejects "@", whitespace, and quotes', () => { + expect(isValidSubAddressDelimiter('@')).toBe(false); + expect(isValidSubAddressDelimiter(' ')).toBe(false); + expect(isValidSubAddressDelimiter('\t')).toBe(false); + expect(isValidSubAddressDelimiter('"')).toBe(false); + }); + + it('rejects multi-character strings', () => { + expect(isValidSubAddressDelimiter('++')).toBe(false); + expect(isValidSubAddressDelimiter('abc')).toBe(false); + }); + + it('rejects empty / non-string inputs', () => { + expect(isValidSubAddressDelimiter('')).toBe(false); + expect(isValidSubAddressDelimiter(null)).toBe(false); + expect(isValidSubAddressDelimiter(undefined)).toBe(false); + expect(isValidSubAddressDelimiter(1)).toBe(false); + }); + + it('round-trips through parse/generate with a custom "~" delimiter', () => { + const generated = generateSubAddress('user@example.com', 'shopping', '~'); + expect(generated).toBe('user~shopping@example.com'); + const parsed = parseSubAddress(generated, '~'); + expect(parsed.baseUser).toBe('user'); + expect(parsed.tag).toBe('shopping'); + }); + }); }); diff --git a/lib/sub-addressing.ts b/lib/sub-addressing.ts index e4530858..ea04d4e0 100644 --- a/lib/sub-addressing.ts +++ b/lib/sub-addressing.ts @@ -12,12 +12,21 @@ const TAG_REGEX = /^[a-zA-Z0-9-]{1,30}$/; export const DEFAULT_SUB_ADDRESS_DELIMITER = '+'; export const SUPPORTED_SUB_ADDRESS_DELIMITERS = ['+', '-', '.', '='] as const; -export type SubAddressDelimiter = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number]; +export type SubAddressDelimiterPreset = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number]; -export function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiter { +export function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiterPreset { return (SUPPORTED_SUB_ADDRESS_DELIMITERS as readonly string[]).includes(value); } +// RFC 5321 atext "special" characters, minus alphanumerics and "@". A custom +// delimiter must be exactly one of these — they're safe to embed in a local +// part and unambiguously separate the user from the tag. +const VALID_DELIMITER_REGEX = /^[!#$%&'*+\-./=?^_`{|}~]$/; + +export function isValidSubAddressDelimiter(value: unknown): value is string { + return typeof value === 'string' && VALID_DELIMITER_REGEX.test(value); +} + export type TagValidationErrorCode = | 'EMPTY' | 'TOO_LONG' diff --git a/locales/cs/common.json b/locales/cs/common.json index 39cace66..a638136f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -914,7 +914,9 @@ "sub_address_delimiter": { "label": "Oddělovač sub-adresy", "description": "Znak oddělující uživatelské jméno od sub-adresy. Zvolte oddělovač používaný vaším poštovním serverem (např. uzivatel{delimiter}stitek@domena.cz).", - "option": "{delimiter} (uzivatel{delimiter}stitek@domena.cz)" + "option": "{delimiter} (uzivatel{delimiter}stitek@domena.cz)", + "custom": "Vlastní…", + "custom_input_label": "Vlastní znak oddělovače" }, "attachment_click_action": { "label": "Akce po kliknutí na přílohu", diff --git a/locales/de/common.json b/locales/de/common.json index 84f1c896..897ec395 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -914,7 +914,9 @@ "sub_address_delimiter": { "label": "Sub-Adress-Trennzeichen", "description": "Zeichen, das Ihren Benutzernamen vom Sub-Adress-Tag trennt. Verwenden Sie das von Ihrem Mailserver verwendete Trennzeichen (z. B. benutzer{delimiter}tag@domain.de).", - "option": "{delimiter} (benutzer{delimiter}tag@domain.de)" + "option": "{delimiter} (benutzer{delimiter}tag@domain.de)", + "custom": "Benutzerdefiniert…", + "custom_input_label": "Benutzerdefiniertes Trennzeichen" }, "attachment_click_action": { "label": "Aktion beim Klick auf Anhänge", diff --git a/locales/en/common.json b/locales/en/common.json index 255c2a77..39cb5af0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -914,7 +914,9 @@ "sub_address_delimiter": { "label": "Sub-Address Delimiter", "description": "Character separating your username from a sub-address tag. Match the delimiter your mail server uses (e.g. user{delimiter}tag@domain.com).", - "option": "{delimiter} (user{delimiter}tag@domain.com)" + "option": "{delimiter} (user{delimiter}tag@domain.com)", + "custom": "Custom…", + "custom_input_label": "Custom delimiter character" }, "attachment_click_action": { "label": "Attachment Click Action", diff --git a/locales/es/common.json b/locales/es/common.json index 8b5ab5ee..7208d4c6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -909,7 +909,9 @@ "sub_address_delimiter": { "label": "Delimitador de sub-dirección", "description": "Carácter que separa tu nombre de usuario de la etiqueta de sub-dirección. Usa el delimitador que utilice tu servidor de correo (por ejemplo, usuario{delimiter}etiqueta@dominio.com).", - "option": "{delimiter} (usuario{delimiter}etiqueta@dominio.com)" + "option": "{delimiter} (usuario{delimiter}etiqueta@dominio.com)", + "custom": "Personalizado…", + "custom_input_label": "Carácter delimitador personalizado" }, "show_preview": { "label": "Mostrar Vista Previa", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3cb79da7..8578b50f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -909,7 +909,9 @@ "sub_address_delimiter": { "label": "Délimiteur de sous-adresse", "description": "Caractère séparant votre nom d'utilisateur de l'étiquette de sous-adresse. Utilisez le délimiteur configuré sur votre serveur de messagerie (par ex. utilisateur{delimiter}tag@domaine.com).", - "option": "{delimiter} (utilisateur{delimiter}tag@domaine.com)" + "option": "{delimiter} (utilisateur{delimiter}tag@domaine.com)", + "custom": "Personnalisé…", + "custom_input_label": "Caractère de délimiteur personnalisé" }, "show_preview": { "label": "Afficher l'aperçu", diff --git a/locales/it/common.json b/locales/it/common.json index 8bb58cc1..ee0c273d 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -909,7 +909,9 @@ "sub_address_delimiter": { "label": "Delimitatore sub-indirizzo", "description": "Carattere che separa il tuo nome utente dall'etichetta del sub-indirizzo. Usa il delimitatore configurato sul tuo server di posta (es. utente{delimiter}tag@dominio.com).", - "option": "{delimiter} (utente{delimiter}tag@dominio.com)" + "option": "{delimiter} (utente{delimiter}tag@dominio.com)", + "custom": "Personalizzato…", + "custom_input_label": "Carattere delimitatore personalizzato" }, "show_preview": { "label": "Mostra anteprima testo", diff --git a/locales/ja/common.json b/locales/ja/common.json index bafcde8e..5bcd2395 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -909,7 +909,9 @@ "sub_address_delimiter": { "label": "サブアドレス区切り文字", "description": "ユーザー名とサブアドレスタグを区切る文字です。お使いのメールサーバーが使用する区切り文字に合わせてください(例: user{delimiter}tag@domain.com)。", - "option": "{delimiter} (user{delimiter}tag@domain.com)" + "option": "{delimiter} (user{delimiter}tag@domain.com)", + "custom": "カスタム…", + "custom_input_label": "カスタム区切り文字" }, "show_preview": { "label": "プレビューテキストを表示", diff --git a/locales/ko/common.json b/locales/ko/common.json index a0e39d38..7e3cff7a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -914,7 +914,9 @@ "sub_address_delimiter": { "label": "서브 주소 구분자", "description": "사용자 이름과 서브 주소 태그를 나누는 문자예요. 메일 서버가 사용하는 구분자에 맞춰 주세요 (예: user{delimiter}tag@domain.com).", - "option": "{delimiter} (user{delimiter}tag@domain.com)" + "option": "{delimiter} (user{delimiter}tag@domain.com)", + "custom": "사용자 지정…", + "custom_input_label": "사용자 지정 구분 문자" }, "attachment_click_action": { "label": "첨부파일 클릭 동작", diff --git a/locales/lv/common.json b/locales/lv/common.json index 1520839a..d3b5c770 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -909,7 +909,9 @@ "sub_address_delimiter": { "label": "Apakšadreses atdalītājs", "description": "Zīme, kas atdala lietotājvārdu no apakšadreses tagu. Izvēlieties atdalītāju, ko lieto jūsu pasta serveris (piem. lietotajs{delimiter}tags@domens.lv).", - "option": "{delimiter} (lietotajs{delimiter}tags@domens.lv)" + "option": "{delimiter} (lietotajs{delimiter}tags@domens.lv)", + "custom": "Pielāgots…", + "custom_input_label": "Pielāgota atdalītāja zīme" }, "show_preview": { "label": "Rādīt priekšskatījuma tekstu", diff --git a/locales/nl/common.json b/locales/nl/common.json index d6420a3c..8b012985 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -909,7 +909,9 @@ "sub_address_delimiter": { "label": "Sub-adres scheidingsteken", "description": "Teken dat je gebruikersnaam scheidt van het sub-adres-label. Gebruik het scheidingsteken dat je mailserver gebruikt (bv. gebruiker{delimiter}tag@domein.nl).", - "option": "{delimiter} (gebruiker{delimiter}tag@domein.nl)" + "option": "{delimiter} (gebruiker{delimiter}tag@domein.nl)", + "custom": "Aangepast…", + "custom_input_label": "Aangepast scheidingsteken" }, "show_preview": { "label": "Voorbeeldtekst tonen", diff --git a/locales/pl/common.json b/locales/pl/common.json index d66d0508..fb57eabc 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -914,7 +914,9 @@ "sub_address_delimiter": { "label": "Separator sub-adresu", "description": "Znak oddzielający Twoją nazwę użytkownika od tagu sub-adresu. Użyj separatora zgodnego z Twoim serwerem pocztowym (np. user{delimiter}tag@domain.com).", - "option": "{delimiter} (user{delimiter}tag@domain.com)" + "option": "{delimiter} (user{delimiter}tag@domain.com)", + "custom": "Niestandardowy…", + "custom_input_label": "Niestandardowy znak separatora" }, "attachment_click_action": { "label": "Akcja po kliknięciu załącznika", diff --git a/locales/pt/common.json b/locales/pt/common.json index d0ed8c0f..05d72752 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -909,7 +909,9 @@ "sub_address_delimiter": { "label": "Delimitador de sub-endereço", "description": "Caractere que separa seu nome de usuário da tag de sub-endereço. Use o delimitador configurado no seu servidor de e-mail (ex.: usuario{delimiter}tag@dominio.com).", - "option": "{delimiter} (usuario{delimiter}tag@dominio.com)" + "option": "{delimiter} (usuario{delimiter}tag@dominio.com)", + "custom": "Personalizado…", + "custom_input_label": "Caractere delimitador personalizado" }, "show_preview": { "label": "Mostrar Texto de Visualização", diff --git a/locales/ru/common.json b/locales/ru/common.json index e6cac299..37be56ae 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -909,7 +909,9 @@ "sub_address_delimiter": { "label": "Разделитель суб-адресов", "description": "Символ, отделяющий имя пользователя от тега суб-адреса. Используйте разделитель, настроенный на вашем почтовом сервере (например, user{delimiter}tag@domain.com).", - "option": "{delimiter} (user{delimiter}tag@domain.com)" + "option": "{delimiter} (user{delimiter}tag@domain.com)", + "custom": "Свой…", + "custom_input_label": "Свой символ-разделитель" }, "show_preview": { "label": "Показывать текст предпросмотра", diff --git a/locales/tr/common.json b/locales/tr/common.json index 8d6dd4f7..045c5945 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -914,7 +914,9 @@ "sub_address_delimiter": { "label": "Alt Adres Ayırıcı", "description": "Kullanıcı adını alt adres etiketinden ayıran karakter. Posta sunucunuzun kullandığı ayırıcıyı seçin (ör. kullanici{delimiter}etiket@domain.com).", - "option": "{delimiter} (kullanici{delimiter}etiket@domain.com)" + "option": "{delimiter} (kullanici{delimiter}etiket@domain.com)", + "custom": "Özel…", + "custom_input_label": "Özel ayırıcı karakter" }, "attachment_click_action": { "label": "Ek Tıklama İşlemi", diff --git a/locales/uk/common.json b/locales/uk/common.json index 61fc82f0..3702630a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -914,7 +914,9 @@ "sub_address_delimiter": { "label": "Розділювач під-адреси", "description": "Символ, який відокремлює ім'я користувача від мітки під-адреси. Використовуйте розділювач, налаштований на вашому поштовому сервері (напр. user{delimiter}tag@domain.com).", - "option": "{delimiter} (user{delimiter}tag@domain.com)" + "option": "{delimiter} (user{delimiter}tag@domain.com)", + "custom": "Власний…", + "custom_input_label": "Власний символ-розділювач" }, "attachment_click_action": { "label": "Вкладення Натисніть Дія", diff --git a/locales/zh/common.json b/locales/zh/common.json index a73466e2..b6d00b09 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -914,7 +914,9 @@ "sub_address_delimiter": { "label": "子地址分隔符", "description": "用于分隔用户名和子地址标签的字符。请选择与您的邮件服务器一致的分隔符(例如 user{delimiter}tag@domain.com)。", - "option": "{delimiter} (user{delimiter}tag@domain.com)" + "option": "{delimiter} (user{delimiter}tag@domain.com)", + "custom": "自定义…", + "custom_input_label": "自定义分隔字符" }, "attachment_click_action": { "label": "附件单击操作", diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 479961e0..3c1bf5aa 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -6,8 +6,7 @@ import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { apiFetch } from '@/lib/browser-navigation'; import { DEFAULT_SUB_ADDRESS_DELIMITER, - isSupportedSubAddressDelimiter, - type SubAddressDelimiter, + isValidSubAddressDelimiter, } from '@/lib/sub-addressing'; // Use console directly to avoid circular dependency with lib/debug.ts @@ -143,7 +142,7 @@ interface SettingsState { defaultReplyMode: ReplyMode; autoSelectReplyIdentity: boolean; plainTextMode: boolean; // Send plain text only (no rich text editor) - subAddressDelimiter: SubAddressDelimiter; // Character separating user from tag (e.g. "user+tag@") + subAddressDelimiter: string; // Character separating user from tag (e.g. "user+tag@") // Privacy & Security sessionTimeout: number; // minutes (0 = never) @@ -292,7 +291,7 @@ const DEFAULT_SETTINGS = { defaultReplyMode: 'reply' as ReplyMode, autoSelectReplyIdentity: false, plainTextMode: false, - subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER as SubAddressDelimiter, + subAddressDelimiter: DEFAULT_SUB_ADDRESS_DELIMITER, // Privacy & Security sessionTimeout: 0, // Never @@ -516,7 +515,7 @@ export const useSettingsStore = create()( // Apply settings Object.keys(settings).forEach((key) => { if (key in DEFAULT_SETTINGS) { - if (key === 'subAddressDelimiter' && !isSupportedSubAddressDelimiter(settings[key])) { + if (key === 'subAddressDelimiter' && !isValidSubAddressDelimiter(settings[key])) { return; } set({ [key]: settings[key] });