feat: configurable sub-addressing delimiter #239
This commit is contained in:
@@ -6,6 +6,9 @@ import {
|
||||
suggestTagsForDomain,
|
||||
isValidTag,
|
||||
getTagValidationError,
|
||||
isSupportedSubAddressDelimiter,
|
||||
SUPPORTED_SUB_ADDRESS_DELIMITERS,
|
||||
DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
MAX_TAG_LENGTH,
|
||||
} from '../sub-addressing';
|
||||
|
||||
@@ -354,3 +357,74 @@ describe('getTagValidationError', () => {
|
||||
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom delimiter', () => {
|
||||
describe('parseSubAddress with non-default delimiter', () => {
|
||||
it('should parse with "-" delimiter', () => {
|
||||
const result = parseSubAddress('user-shopping@example.com', '-');
|
||||
expect(result.baseUser).toBe('user');
|
||||
expect(result.tag).toBe('shopping');
|
||||
});
|
||||
|
||||
it('should parse with "." delimiter', () => {
|
||||
const result = parseSubAddress('user.shopping@example.com', '.');
|
||||
expect(result.baseUser).toBe('user');
|
||||
expect(result.tag).toBe('shopping');
|
||||
});
|
||||
|
||||
it('should parse with "=" delimiter', () => {
|
||||
const result = parseSubAddress('user=shopping@example.com', '=');
|
||||
expect(result.baseUser).toBe('user');
|
||||
expect(result.tag).toBe('shopping');
|
||||
});
|
||||
|
||||
it('should ignore "+" when "-" is configured as the delimiter', () => {
|
||||
const result = parseSubAddress('user+shopping@example.com', '-');
|
||||
expect(result.baseUser).toBe('user+shopping');
|
||||
expect(result.tag).toBeNull();
|
||||
});
|
||||
|
||||
it('should split on first occurrence when delimiter appears multiple times', () => {
|
||||
const result = parseSubAddress('alice-shop-orders@example.com', '-');
|
||||
expect(result.baseUser).toBe('alice');
|
||||
expect(result.tag).toBe('shop-orders');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSubAddress with non-default delimiter', () => {
|
||||
it('should generate using "-" delimiter', () => {
|
||||
expect(generateSubAddress('user@example.com', 'shopping', '-')).toBe('user-shopping@example.com');
|
||||
});
|
||||
|
||||
it('should generate using "." delimiter', () => {
|
||||
expect(generateSubAddress('user@example.com', 'shopping', '.')).toBe('user.shopping@example.com');
|
||||
});
|
||||
|
||||
it('should replace existing tag using the configured delimiter', () => {
|
||||
expect(generateSubAddress('user-old@example.com', 'new', '-')).toBe('user-new@example.com');
|
||||
});
|
||||
|
||||
it('should not strip a "+" sign in the local part when delimiter is "-"', () => {
|
||||
// "+" is not the delimiter so it should remain part of the base user
|
||||
expect(generateSubAddress('user+plus@example.com', 'tag', '-')).toBe('user+plus-tag@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedSubAddressDelimiter', () => {
|
||||
it('accepts every supported delimiter', () => {
|
||||
for (const delim of SUPPORTED_SUB_ADDRESS_DELIMITERS) {
|
||||
expect(isSupportedSubAddressDelimiter(delim)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsupported characters', () => {
|
||||
expect(isSupportedSubAddressDelimiter('_')).toBe(false);
|
||||
expect(isSupportedSubAddressDelimiter('++')).toBe(false);
|
||||
expect(isSupportedSubAddressDelimiter('')).toBe(false);
|
||||
});
|
||||
|
||||
it('default delimiter is supported', () => {
|
||||
expect(isSupportedSubAddressDelimiter(DEFAULT_SUB_ADDRESS_DELIMITER)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+36
-14
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* Sub-addressing utilities for user+tag@domain.com format
|
||||
* Sub-addressing utilities for user{delimiter}tag@domain.com format
|
||||
* Works server-side automatically - no JMAP API calls needed
|
||||
*
|
||||
* The delimiter character is configurable per server (RFC 5233). Common
|
||||
* choices: "+" (Postfix, Stalwart default), "-" (qmail), ".", "=".
|
||||
*/
|
||||
|
||||
// Constants for tag validation
|
||||
const MAX_TAG_LENGTH = 30;
|
||||
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 function isSupportedSubAddressDelimiter(value: string): value is SubAddressDelimiter {
|
||||
return (SUPPORTED_SUB_ADDRESS_DELIMITERS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export type TagValidationErrorCode =
|
||||
| 'EMPTY'
|
||||
| 'TOO_LONG'
|
||||
@@ -22,10 +33,14 @@ export interface ParsedAddress {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an email address to extract sub-address tag
|
||||
* Example: "user+shopping@example.com" -> { baseUser: "user", tag: "shopping" }
|
||||
* Parse an email address to extract sub-address tag.
|
||||
* The first occurrence of the delimiter in the local part separates the
|
||||
* base user from the tag, matching the behavior of Postfix/qmail/Sieve.
|
||||
*/
|
||||
export function parseSubAddress(email: string): ParsedAddress {
|
||||
export function parseSubAddress(
|
||||
email: string,
|
||||
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
): ParsedAddress {
|
||||
const [localPart, domain] = email.split('@');
|
||||
|
||||
if (!localPart || !domain) {
|
||||
@@ -38,9 +53,9 @@ export function parseSubAddress(email: string): ParsedAddress {
|
||||
};
|
||||
}
|
||||
|
||||
const plusIndex = localPart.indexOf('+');
|
||||
const delimiterIndex = localPart.indexOf(delimiter);
|
||||
|
||||
if (plusIndex === -1) {
|
||||
if (delimiterIndex === -1) {
|
||||
return {
|
||||
localPart,
|
||||
baseUser: localPart,
|
||||
@@ -50,8 +65,8 @@ export function parseSubAddress(email: string): ParsedAddress {
|
||||
};
|
||||
}
|
||||
|
||||
const baseUser = localPart.substring(0, plusIndex);
|
||||
const tag = localPart.substring(plusIndex + 1);
|
||||
const baseUser = localPart.substring(0, delimiterIndex);
|
||||
const tag = localPart.substring(delimiterIndex + delimiter.length);
|
||||
|
||||
return {
|
||||
localPart,
|
||||
@@ -63,18 +78,25 @@ export function parseSubAddress(email: string): ParsedAddress {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a sub-addressed email
|
||||
* Example: generateSubAddress("user@example.com", "shopping") -> "user+shopping@example.com"
|
||||
* Generate a sub-addressed email.
|
||||
* Example: generateSubAddress("user@example.com", "shopping", "+") -> "user+shopping@example.com"
|
||||
*/
|
||||
export function generateSubAddress(baseEmail: string, tag: string): string {
|
||||
export function generateSubAddress(
|
||||
baseEmail: string,
|
||||
tag: string,
|
||||
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
): string {
|
||||
const [localPart, domain] = baseEmail.split('@');
|
||||
|
||||
if (!localPart || !domain || !tag) {
|
||||
return baseEmail;
|
||||
}
|
||||
|
||||
// Remove existing tag if present
|
||||
const cleanLocal = localPart.split('+')[0];
|
||||
// Strip an existing tag if one is already present
|
||||
const existingDelimiterIndex = localPart.indexOf(delimiter);
|
||||
const cleanLocal = existingDelimiterIndex === -1
|
||||
? localPart
|
||||
: localPart.substring(0, existingDelimiterIndex);
|
||||
|
||||
// Sanitize tag (alphanumeric and dash only)
|
||||
const cleanTag = tag.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
|
||||
@@ -83,7 +105,7 @@ export function generateSubAddress(baseEmail: string, tag: string): string {
|
||||
return baseEmail;
|
||||
}
|
||||
|
||||
return `${cleanLocal}+${cleanTag}@${domain}`;
|
||||
return `${cleanLocal}${delimiter}${cleanTag}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user