PSA/shared/lib/email/listRewriteSender.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

238 lines
8.0 KiB
TypeScript

import {
parseEmailAddress,
parseEmailAddressList,
extractEmailDomain,
type ParsedEmailAddress,
} from './addressUtils';
/**
* Recovering the real author of mailing-list / Google-Group rewritten inbound mail.
*
* When a sender's domain publishes a strict DMARC policy (p=quarantine/reject),
* mailing-list software (Google Groups, Mailman, …) rewrites the visible `From:`
* header to the list address to avoid a DMARC failure, e.g.:
*
* From: 'Jane Doe' via support <support@lists.example.com>
*
* The true author (jane.doe@vendor.example) survives in `X-Original-From` /
* `X-Original-Sender` / `Reply-To`. This module detects that rewrite and recovers
* the original sender so downstream contact-matching, watch-list seeding, and
* notifications use the human author rather than the list address.
*
* Safety: the recovered sender is only trusted when the receiving MX's
* `Authentication-Results` (or `ARC-Authentication-Results`) show DKIM/DMARC/SPF
* passing in alignment with the recovered domain. This prevents a spammer who
* runs their own list software from injecting an arbitrary `X-Original-Sender`.
* For direct (non-list) mail no list markers are present and we return null, so
* behaviour is unchanged.
*/
export type HeaderBag = Record<string, string>;
export interface ListRewriteResolution {
sender: ParsedEmailAddress;
listAddress: string;
via: 'x-original-from' | 'x-original-sender' | 'reply-to';
}
const ENV_FLAG = 'INBOUND_RESOLVE_LIST_ORIGINAL_SENDER';
function isFeatureEnabled(): boolean {
return (process.env[ENV_FLAG] || 'true').toLowerCase() !== 'false';
}
/**
* Two domains are considered aligned when they are equal or one is a subdomain
* of the other (relaxed DMARC-style alignment).
*/
function domainsAligned(a: string | null, b: string | null): boolean {
if (!a || !b) {
return false;
}
if (a === b) {
return true;
}
return a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
}
/**
* Scan Authentication-Results / ARC-Authentication-Results for a passing
* mechanism whose domain aligns with `candidateDomain`. The header is added by
* the receiving MX, so the values reflect the *original* message's auth as
* evaluated at delivery — exactly what we need to trust the recovered sender.
*/
function authResultsTrustDomain(headers: HeaderBag, candidateDomain: string | null): boolean {
if (!candidateDomain) {
return false;
}
const combined = [headers['authentication-results'], headers['arc-authentication-results']]
.filter(Boolean)
.join('; ')
.toLowerCase();
if (!combined) {
return false;
}
const domainMatchers: RegExp[] = [
// dkim=pass ... header.i=@domain | header.d=domain | dkdomain=domain
/dkim=pass[^;]*?(?:header\.i=@?|header\.d=|dkdomain=)([a-z0-9.-]+)/g,
// dmarc=pass ... header.from=domain | fromdomain=domain
/dmarc=pass[^;]*?(?:header\.from=|fromdomain=)([a-z0-9.-]+)/g,
// spf=pass ... smtp.mailfrom=local@domain | spfdomain=domain
/spf=pass[^;]*?(?:smtp\.mailfrom=[^@;\s]*@|spfdomain=)([a-z0-9.-]+)/g,
];
for (const matcher of domainMatchers) {
let match: RegExpExecArray | null;
while ((match = matcher.exec(combined)) !== null) {
const authDomain = (match[1] || '').replace(/[.>,;\s]+$/, '');
if (domainsAligned(candidateDomain, authDomain)) {
return true;
}
}
}
return false;
}
/**
* Resolve the list address the `From:` header may have been rewritten to, using
* the list markers a relay adds. Returns null when the message carries no
* list/group markers (i.e. ordinary direct mail).
*/
function resolveListAddress(headers: HeaderBag, fromEmail: string | null): string | null {
const beenThere = parseEmailAddress(headers['x-beenthere'])?.email ?? null;
const listPost = parseEmailAddress(headers['list-post'])?.email ?? null;
const sender = parseEmailAddress(headers['sender'])?.email ?? null;
const hasListMarkers = Boolean(
headers['x-beenthere'] ||
headers['list-id'] ||
headers['list-post'] ||
headers['mailing-list']
);
// A differing Sender header is itself a relay signal even without List-* headers.
const senderIsRelay = Boolean(sender && fromEmail && sender !== fromEmail);
if (!hasListMarkers && !senderIsRelay) {
return null;
}
return beenThere || listPost || sender;
}
/**
* Pure resolution logic (no env / no I/O) so it is straightforward to unit test.
* `headers` keys must be lowercased.
*/
export function computeListRewriteSender(
headers: HeaderBag,
from: ParsedEmailAddress | null
): ListRewriteResolution | null {
const fromEmail = from?.email ?? null;
const listAddress = resolveListAddress(headers, fromEmail);
// Only act on the rewrite case: the visible From was replaced with the list
// address. Normal list mail (From preserved) is left untouched.
if (!listAddress || !fromEmail || fromEmail !== listAddress) {
return null;
}
const candidates: Array<{ via: ListRewriteResolution['via']; value?: string }> = [
{ via: 'x-original-from', value: headers['x-original-from'] },
{ via: 'x-original-sender', value: headers['x-original-sender'] },
{ via: 'reply-to', value: headers['reply-to'] },
];
for (const candidate of candidates) {
if (!candidate.value) {
continue;
}
const parsed = candidate.via === 'reply-to'
? parseEmailAddressList(candidate.value)[0] ?? null
: parseEmailAddress(candidate.value);
if (!parsed) {
continue;
}
// Ignore values that just point back at the list itself.
if (parsed.email === listAddress) {
continue;
}
// Trust anchor: the recovered sender's domain must pass DKIM/DMARC/SPF per
// the receiving MX's Authentication-Results. This rejects forged
// X-Original-Sender values from third-party / spam list servers.
if (!authResultsTrustDomain(headers, extractEmailDomain(parsed.email))) {
return null;
}
return { sender: parsed, listAddress, via: candidate.via };
}
return null;
}
/**
* Build a lowercased header bag from a mailparser `ParsedMail`. Prefers
* `headerLines` (raw, order-preserving) so the *topmost* Authentication-Results
* (added by our own MX) wins; falls back to the parsed `headers` Map.
*/
function extractHeaderBag(parsed: any): HeaderBag {
const bag: HeaderBag = {};
const lines: Array<{ key?: string; line?: string }> | undefined = parsed?.headerLines;
if (Array.isArray(lines)) {
for (const entry of lines) {
const key = typeof entry?.key === 'string' ? entry.key.toLowerCase() : '';
const line = typeof entry?.line === 'string' ? entry.line : '';
if (!key || key in bag) {
// Keep the first occurrence (topmost header) — matters for
// Authentication-Results, which is prepended by the final MTA.
continue;
}
const colon = line.indexOf(':');
bag[key] = colon >= 0 ? line.slice(colon + 1).trim() : '';
}
return bag;
}
const map: Map<string, unknown> | undefined = parsed?.headers;
if (map && typeof map.forEach === 'function') {
map.forEach((value: unknown, key: string) => {
const lower = key.toLowerCase();
if (typeof value === 'string') {
bag[lower] = value;
} else if (value && typeof value === 'object' && 'text' in (value as any)) {
bag[lower] = String((value as any).text ?? '');
} else if (value != null) {
bag[lower] = String(value);
}
});
}
return bag;
}
/**
* Resolve the original sender from a mailparser `ParsedMail`, honouring the
* feature flag. Returns null when disabled, when the message is not a list
* rewrite, or when the recovered sender cannot be trusted.
*/
export function resolveListRewriteSender(parsed: any): ListRewriteResolution | null {
if (!isFeatureEnabled()) {
return null;
}
const fromValue = parsed?.from?.value?.[0];
const from: ParsedEmailAddress | null = fromValue?.address
? { email: String(fromValue.address).toLowerCase(), name: fromValue.name || undefined }
: parseEmailAddress(parsed?.from?.text);
return computeListRewriteSender(extractHeaderBag(parsed), from);
}