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
+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'