297 lines
8.7 KiB
TypeScript
297 lines
8.7 KiB
TypeScript
import { AuthenticationResults } from './jmap/types';
|
|
import { parseUnsubscribeUrls } from './validation';
|
|
|
|
type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
|
type SpfEntry = NonNullable<NonNullable<AuthenticationResults['spf']>['all']>[number];
|
|
|
|
/**
|
|
* Severity ranking for SPF results. Higher = more severe / more actionable.
|
|
* A hard `fail` is a definitive policy violation and must outrank ambiguous
|
|
* states like `temperror`, so a spoofed message isn't softened to a
|
|
* "temporary failure" headline when one identity hard-fails.
|
|
*/
|
|
const SPF_SEVERITY: Record<SpfResult, number> = {
|
|
fail: 6,
|
|
softfail: 5,
|
|
permerror: 4,
|
|
temperror: 3,
|
|
neutral: 2,
|
|
none: 1,
|
|
pass: 0,
|
|
};
|
|
|
|
/**
|
|
* Whether the authentication results indicate the visible From identity can't
|
|
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
|
|
* would otherwise imply the message legitimately came from one of the user's
|
|
* own identities (e.g. the "via <identity>" badge).
|
|
*/
|
|
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
|
if (!auth) return false;
|
|
// DMARC aligns the visible From with SPF/DKIM, so a DMARC fail is the
|
|
// strongest single spoofing signal.
|
|
if (auth.dmarc?.result === 'fail') return true;
|
|
// Otherwise a hard SPF fail with no valid DKIM signature means the sender
|
|
// isn't authorized for the envelope domain.
|
|
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Parse Authentication-Results header to extract SPF, DKIM, DMARC results
|
|
*/
|
|
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
|
const results: AuthenticationResults = {};
|
|
|
|
type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
|
type DmarcResult = 'pass' | 'fail' | 'none';
|
|
type DmarcPolicy = 'reject' | 'quarantine' | 'none';
|
|
|
|
// Parse SPF. A single Authentication-Results header can carry more than one
|
|
// SPF result when the server evaluates multiple identities (HELO and MAIL
|
|
// FROM). Collect them all and surface the most severe as the headline so a
|
|
// hard MAIL FROM `fail` isn't softened to a HELO `temperror`.
|
|
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
|
|
const spfResults: SpfEntry[] = [];
|
|
let spfM: RegExpExecArray | null;
|
|
while ((spfM = spfRegex.exec(header)) !== null) {
|
|
spfResults.push({
|
|
result: spfM[1] as SpfResult,
|
|
identity: spfM[2] as SpfEntry['identity'],
|
|
domain: spfM[3],
|
|
});
|
|
}
|
|
if (spfResults.length > 0) {
|
|
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
|
// Most severe wins; on a tie prefer the MAIL FROM identity (more meaningful
|
|
// than HELO) and otherwise keep the first occurrence.
|
|
const primary = spfResults.reduce((best, cur) => {
|
|
if (severity(cur.result) > severity(best.result)) return cur;
|
|
if (
|
|
severity(cur.result) === severity(best.result) &&
|
|
best.identity !== 'mailfrom' &&
|
|
cur.identity === 'mailfrom'
|
|
) return cur;
|
|
return best;
|
|
});
|
|
results.spf = {
|
|
result: primary.result,
|
|
domain: primary.domain,
|
|
...(spfResults.length > 1 ? { all: spfResults } : {}),
|
|
};
|
|
}
|
|
|
|
// Parse DKIM
|
|
const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s]+))?(?:\s+header\.s=([^\s]+))?/);
|
|
if (dkimMatch) {
|
|
results.dkim = {
|
|
result: dkimMatch[1] as DkimResult,
|
|
domain: dkimMatch[2],
|
|
selector: dkimMatch[3]
|
|
};
|
|
}
|
|
|
|
// Parse DMARC
|
|
const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s]+))?(?:\s+policy\.dmarc=(\w+))?/);
|
|
if (dmarcMatch) {
|
|
results.dmarc = {
|
|
result: dmarcMatch[1] as DmarcResult,
|
|
domain: dmarcMatch[2],
|
|
policy: dmarcMatch[3] as DmarcPolicy | undefined
|
|
};
|
|
}
|
|
|
|
// Parse IP reverse lookup
|
|
const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
|
|
if (iprevMatch) {
|
|
results.iprev = {
|
|
result: iprevMatch[1] as 'pass' | 'fail',
|
|
ip: iprevMatch[2]
|
|
};
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Parse spam score from X-Spam-Result or X-Spam-Status headers
|
|
*/
|
|
export function parseSpamScore(header: string): { score: number; status: string } | null {
|
|
// Try X-Spam-Status format: "No, score=-0.25"
|
|
const statusMatch = header.match(/^(Yes|No),?\s+score=([-\d.]+)/i);
|
|
if (statusMatch) {
|
|
return {
|
|
status: statusMatch[1].toLowerCase(),
|
|
score: parseFloat(statusMatch[2])
|
|
};
|
|
}
|
|
|
|
// Try to extract just the score
|
|
const scoreMatch = header.match(/score[=:]?\s*([-\d.]+)/i);
|
|
if (scoreMatch) {
|
|
const score = parseFloat(scoreMatch[1]);
|
|
return {
|
|
score,
|
|
status: score > 5 ? 'spam' : 'ham'
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse Received headers to extract mail routing path
|
|
*/
|
|
interface ReceivedHeaderInfo {
|
|
from: string;
|
|
by: string;
|
|
timestamp?: string;
|
|
protocol?: string;
|
|
id?: string;
|
|
}
|
|
|
|
export function parseReceivedHeaders(headers: string[]): ReceivedHeaderInfo[] {
|
|
const path: ReceivedHeaderInfo[] = [];
|
|
|
|
for (const header of headers) {
|
|
const fromMatch = header.match(/from\s+([^\s]+)(?:\s+\([^)]+\))?/);
|
|
const byMatch = header.match(/by\s+([^\s]+)/);
|
|
const dateMatch = header.match(/;\s+(.+)$/);
|
|
const protoMatch = header.match(/with\s+(\w+)/);
|
|
const idMatch = header.match(/id\s+([^\s;]+)/);
|
|
|
|
if (fromMatch || byMatch) {
|
|
path.push({
|
|
from: fromMatch?.[1] || 'unknown',
|
|
by: byMatch?.[1] || 'unknown',
|
|
timestamp: dateMatch?.[1],
|
|
protocol: protoMatch?.[1],
|
|
id: idMatch?.[1]
|
|
});
|
|
}
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human readable size
|
|
*/
|
|
export function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
}
|
|
|
|
/**
|
|
* Get security status color and icon based on result
|
|
*/
|
|
export function getSecurityStatus(result?: string): {
|
|
color: string;
|
|
icon: 'check' | 'x' | 'alert' | 'minus';
|
|
bgColor: string;
|
|
borderColor: string;
|
|
} {
|
|
switch (result) {
|
|
case 'pass':
|
|
return {
|
|
color: 'text-green-700 dark:text-green-400',
|
|
icon: 'check',
|
|
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
|
borderColor: 'border-l-4 border-green-600 dark:border-green-500'
|
|
};
|
|
case 'fail':
|
|
case 'permerror':
|
|
return {
|
|
color: 'text-red-700 dark:text-red-400',
|
|
icon: 'x',
|
|
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
|
borderColor: 'border-l-4 border-red-600 dark:border-red-500'
|
|
};
|
|
case 'softfail':
|
|
case 'neutral':
|
|
case 'temperror':
|
|
return {
|
|
color: 'text-warning',
|
|
icon: 'alert',
|
|
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
|
borderColor: 'border-l-4 border-warning'
|
|
};
|
|
default:
|
|
return {
|
|
color: 'text-gray-700 dark:text-gray-400',
|
|
icon: 'minus',
|
|
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
|
borderColor: 'border-l-4 border-gray-400 dark:border-gray-600'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse X-Spam-LLM header to extract AI verdict and explanation
|
|
*/
|
|
export function parseSpamLLM(header: string): { verdict: string; explanation: string } | null {
|
|
// Format: "LEGITIMATE (explanation)" or "SPAM (explanation)"
|
|
// Trim the header first to remove any leading/trailing whitespace
|
|
const trimmed = header.trim();
|
|
const match = trimmed.match(/^(LEGITIMATE|SPAM|SUSPICIOUS)\s*\((.+)\)\s*$/i);
|
|
|
|
if (match) {
|
|
return {
|
|
verdict: match[1].toUpperCase(),
|
|
explanation: match[2].trim()
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract list headers (List-Unsubscribe, List-Id, etc.)
|
|
*/
|
|
interface ListHeaders {
|
|
listId?: string;
|
|
listUnsubscribe?: {
|
|
http?: string;
|
|
mailto?: string;
|
|
preferred?: 'http' | 'mailto';
|
|
};
|
|
listHelp?: string;
|
|
listPost?: string;
|
|
}
|
|
|
|
export function extractListHeaders(headers: Record<string, string | string[]>): ListHeaders {
|
|
const result: ListHeaders = {};
|
|
|
|
if (headers['List-Id']) {
|
|
result.listId = Array.isArray(headers['List-Id'])
|
|
? headers['List-Id'][0]
|
|
: headers['List-Id'];
|
|
}
|
|
|
|
if (headers['List-Unsubscribe']) {
|
|
const unsub = Array.isArray(headers['List-Unsubscribe'])
|
|
? headers['List-Unsubscribe'][0]
|
|
: headers['List-Unsubscribe'];
|
|
|
|
const parsed = parseUnsubscribeUrls(unsub);
|
|
if (parsed.preferred) {
|
|
result.listUnsubscribe = parsed;
|
|
}
|
|
}
|
|
|
|
if (headers['List-Help']) {
|
|
result.listHelp = Array.isArray(headers['List-Help'])
|
|
? headers['List-Help'][0]
|
|
: headers['List-Help'];
|
|
}
|
|
|
|
if (headers['List-Post']) {
|
|
result.listPost = Array.isArray(headers['List-Post'])
|
|
? headers['List-Post'][0]
|
|
: headers['List-Post'];
|
|
}
|
|
|
|
return result;
|
|
} |