199 lines
5.5 KiB
TypeScript
199 lines
5.5 KiB
TypeScript
/**
|
|
* 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 SubAddressDelimiterPreset = (typeof SUPPORTED_SUB_ADDRESS_DELIMITERS)[number];
|
|
|
|
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'
|
|
| 'INVALID_CHARS'
|
|
| null;
|
|
|
|
export interface ParsedAddress {
|
|
localPart: string;
|
|
baseUser: string;
|
|
tag: string | null;
|
|
domain: string;
|
|
fullAddress: string;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
delimiter: string = DEFAULT_SUB_ADDRESS_DELIMITER,
|
|
): ParsedAddress {
|
|
const [localPart, domain] = email.split('@');
|
|
|
|
if (!localPart || !domain) {
|
|
return {
|
|
localPart: localPart || '',
|
|
baseUser: localPart || '',
|
|
tag: null,
|
|
domain: domain || '',
|
|
fullAddress: email,
|
|
};
|
|
}
|
|
|
|
const delimiterIndex = localPart.indexOf(delimiter);
|
|
|
|
if (delimiterIndex === -1) {
|
|
return {
|
|
localPart,
|
|
baseUser: localPart,
|
|
tag: null,
|
|
domain,
|
|
fullAddress: email,
|
|
};
|
|
}
|
|
|
|
const baseUser = localPart.substring(0, delimiterIndex);
|
|
const tag = localPart.substring(delimiterIndex + delimiter.length);
|
|
|
|
return {
|
|
localPart,
|
|
baseUser,
|
|
tag: tag || null,
|
|
domain,
|
|
fullAddress: email,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a sub-addressed email.
|
|
* Example: generateSubAddress("user@example.com", "shopping", "+") -> "user+shopping@example.com"
|
|
*/
|
|
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;
|
|
}
|
|
|
|
// 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();
|
|
|
|
if (!cleanTag) {
|
|
return baseEmail;
|
|
}
|
|
|
|
return `${cleanLocal}${delimiter}${cleanTag}@${domain}`;
|
|
}
|
|
|
|
/**
|
|
* Extract domain from recipient email for tag suggestions
|
|
*/
|
|
export function extractDomain(email: string): string | null {
|
|
const match = email.match(/@([^@]+)$/);
|
|
return match ? match[1].toLowerCase() : null;
|
|
}
|
|
|
|
/**
|
|
* Suggest tags based on recipient domain
|
|
*/
|
|
export function suggestTagsForDomain(domain: string): string[] {
|
|
const domainLower = domain.toLowerCase();
|
|
|
|
// Common domain-based suggestions
|
|
const suggestions: Record<string, string[]> = {
|
|
'amazon.com': ['amazon', 'shopping', 'orders'],
|
|
'amazon.fr': ['amazon', 'shopping', 'orders'],
|
|
'amazon.de': ['amazon', 'shopping', 'orders'],
|
|
'amazon.co.uk': ['amazon', 'shopping', 'orders'],
|
|
'ebay.com': ['ebay', 'shopping'],
|
|
'ebay.fr': ['ebay', 'shopping'],
|
|
'paypal.com': ['paypal', 'payments'],
|
|
'facebook.com': ['facebook', 'social'],
|
|
'twitter.com': ['twitter', 'social'],
|
|
'x.com': ['twitter', 'social'],
|
|
'linkedin.com': ['linkedin', 'professional'],
|
|
'github.com': ['github', 'dev', 'notifications'],
|
|
'gitlab.com': ['gitlab', 'dev', 'notifications'],
|
|
'stackoverflow.com': ['stackoverflow', 'dev'],
|
|
'reddit.com': ['reddit', 'social'],
|
|
'netflix.com': ['netflix', 'entertainment'],
|
|
'spotify.com': ['spotify', 'music'],
|
|
'steam.com': ['steam', 'gaming'],
|
|
'discord.com': ['discord', 'gaming'],
|
|
};
|
|
|
|
// Check for exact domain match
|
|
if (suggestions[domainLower]) {
|
|
return suggestions[domainLower];
|
|
}
|
|
|
|
// Extract main domain (e.g., "mail.google.com" -> "google")
|
|
const parts = domainLower.split('.');
|
|
const mainDomain = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
|
|
|
// Generic suggestions based on domain name
|
|
return [mainDomain, 'newsletter', 'registration'];
|
|
}
|
|
|
|
/**
|
|
* Validate if a tag is safe to use
|
|
*/
|
|
export function isValidTag(tag: string): boolean {
|
|
return TAG_REGEX.test(tag);
|
|
}
|
|
|
|
/**
|
|
* Get validation error code for an invalid tag
|
|
* Returns an error code that should be translated by the calling component
|
|
*/
|
|
export function getTagValidationError(tag: string): TagValidationErrorCode {
|
|
if (!tag) {
|
|
return 'EMPTY';
|
|
}
|
|
|
|
if (tag.length > MAX_TAG_LENGTH) {
|
|
return 'TOO_LONG';
|
|
}
|
|
|
|
if (!/^[a-zA-Z0-9-]+$/.test(tag)) {
|
|
return 'INVALID_CHARS';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Export MAX_TAG_LENGTH for use in translations
|
|
export { MAX_TAG_LENGTH };
|