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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
142 lines
3.4 KiB
TypeScript
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));
|
|
}
|