PSA/shared/lib/email/addressUtils.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

142 lines
3.4 KiB
TypeScript

export interface ParsedEmailAddress {
email: string;
name?: string;
}
const EMAIL_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+/i;
function trimWrapperCharacters(value: string): string {
return value.replace(/^[\s"'(<]+/, '').replace(/[\s"')>,;:]+$/, '');
}
function normalizeDisplayName(value?: string): string | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().replace(/^["']+|["']+$/g, '');
return normalized || undefined;
}
function splitAddressHeader(value: string): string[] {
const parts: string[] = [];
let current = '';
let inQuotes = false;
let angleDepth = 0;
let previous = '';
for (const char of value) {
if (char === '"' && previous !== '\\') {
inQuotes = !inQuotes;
} else if (!inQuotes && char === '<') {
angleDepth += 1;
} else if (!inQuotes && char === '>' && angleDepth > 0) {
angleDepth -= 1;
}
if ((char === ',' || char === ';') && !inQuotes && angleDepth === 0) {
const trimmed = current.trim();
if (trimmed) {
parts.push(trimmed);
}
current = '';
previous = char;
continue;
}
current += char;
previous = char;
}
const trailing = current.trim();
if (trailing) {
parts.push(trailing);
}
return parts;
}
export function parseEmailAddress(value?: string | null): ParsedEmailAddress | null {
if (typeof value !== 'string') {
return null;
}
let candidate = value.trim();
if (!candidate) {
return null;
}
candidate = candidate.replace(/^mailto:/i, '').trim();
const angleMatch = candidate.match(/^(.*)<([^>]+)>.*$/);
if (angleMatch) {
const inner = trimWrapperCharacters(angleMatch[2] || '');
const match = inner.match(EMAIL_PATTERN);
if (match) {
const email = trimWrapperCharacters(match[0]).toLowerCase();
if (email.includes('@')) {
return {
email,
name: normalizeDisplayName(angleMatch[1]),
};
}
}
}
const emailMatch = candidate.match(EMAIL_PATTERN);
if (!emailMatch) {
return null;
}
const email = trimWrapperCharacters(emailMatch[0]).toLowerCase();
if (!email || !email.includes('@')) {
return null;
}
const prefix = candidate.slice(0, emailMatch.index ?? 0).trim();
const name = prefix ? normalizeDisplayName(prefix) : undefined;
return {
email,
name,
};
}
export function normalizeEmailAddress(value?: string | null): string | null {
return parseEmailAddress(value)?.email ?? null;
}
/**
* Extract a domain from an email address, using the same normalization rules as
* `normalizeEmailAddress` (display-name stripping, `mailto:` removal, lowercase).
*/
export function extractEmailDomain(value?: string | null): string | null {
const normalized = normalizeEmailAddress(value);
if (!normalized) {
return null;
}
const atIndex = normalized.lastIndexOf('@');
if (atIndex <= 0 || atIndex >= normalized.length - 1) {
return null;
}
const domain = normalized.slice(atIndex + 1).trim().toLowerCase();
return domain || null;
}
export function parseEmailAddressList(value?: string | null): ParsedEmailAddress[] {
if (typeof value !== 'string') {
return [];
}
const trimmed = value.trim();
if (!trimmed) {
return [];
}
return splitAddressHeader(trimmed)
.map((entry) => parseEmailAddress(entry))
.filter((entry): entry is ParsedEmailAddress => Boolean(entry));
}