feat: allow custom sub-addressing delimiter character #239

This commit is contained in:
Linus Rath
2026-05-01 01:48:39 +02:00
parent c555973b6b
commit ec0f355c13
20 changed files with 154 additions and 32 deletions
+42 -9
View File
@@ -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 })}
>
<Select
value={subAddressDelimiter}
onChange={(value) => updateSetting('subAddressDelimiter', value as SubAddressDelimiter)}
options={SUPPORTED_SUB_ADDRESS_DELIMITERS.map((delim) => ({
value: delim,
label: t('sub_address_delimiter.option', { delimiter: delim }),
}))}
/>
<div className="flex flex-col items-end gap-2">
<Select
value={isSupportedSubAddressDelimiter(subAddressDelimiter) ? subAddressDelimiter : CUSTOM_DELIMITER_SENTINEL}
onChange={(value) => {
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) && (
<input
type="text"
maxLength={1}
value={subAddressDelimiter}
onChange={(e) => {
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"
/>
)}
</div>
</SettingItem>
<SettingItem label={t('attachment_reminder.label')} description={t('attachment_reminder.description')}>
+49
View File
@@ -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');
});
});
});
+11 -2
View File
@@ -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'
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "プレビューテキストを表示",
+3 -1
View File
@@ -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": "첨부파일 클릭 동작",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Показывать текст предпросмотра",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Вкладення Натисніть Дія",
+3 -1
View File
@@ -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": "附件单击操作",
+4 -5
View File
@@ -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<SettingsState>()(
// 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] });