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

215 lines
6.6 KiB
TypeScript

/**
* Pure condition/extraction evaluation for inbound email rules.
* No database access — the same functions back production processing and the
* settings-UI live tester.
*/
import { extractEmailDomain, normalizeEmailAddress } from '../../../lib/email/addressUtils';
import type {
ExtractAssignClientActionConfig,
InboundEmailExtraction,
InboundEmailRuleCondition,
InboundEmailRuleConditionResult,
InboundEmailRuleEmailInput,
} from './types';
export const MAX_REGEX_PATTERN_LENGTH = 512;
export const MAX_BODY_TEXT_LENGTH = 100_000;
/** Invalid patterns are logged once per pattern per process, not once per email. */
const reportedInvalidPatterns = new Set<string>();
function compileRulePattern(pattern: string, context: string): RegExp | null {
if (typeof pattern !== 'string' || !pattern || pattern.length > MAX_REGEX_PATTERN_LENGTH) {
return null;
}
try {
return new RegExp(pattern, 'i');
} catch {
if (!reportedInvalidPatterns.has(pattern)) {
reportedInvalidPatterns.add(pattern);
console.warn(`inboundEmailRules: invalid regex pattern (treated as non-matching) in ${context}`, {
pattern: pattern.slice(0, 100),
});
}
return null;
}
}
export function buildRuleEmailInput(emailData: {
from?: { email?: string };
to?: Array<{ email?: string }>;
cc?: Array<{ email?: string }>;
subject?: string;
body?: { text?: string; html?: string };
}): InboundEmailRuleEmailInput {
const fromAddress = normalizeEmailAddress(emailData.from?.email) ?? '';
const recipients = [...(emailData.to ?? []), ...(emailData.cc ?? [])]
.map((recipient) => normalizeEmailAddress(recipient?.email))
.filter((email): email is string => Boolean(email));
return {
fromAddress,
fromDomain: fromAddress ? (extractEmailDomain(fromAddress) ?? '') : '',
toAddresses: recipients,
subject: emailData.subject ?? '',
bodyText: (emailData.body?.text ?? '').slice(0, MAX_BODY_TEXT_LENGTH),
};
}
function operatorMatches(
candidate: string,
condition: InboundEmailRuleCondition
): boolean {
const haystack = candidate.toLowerCase();
const needle = (condition.value ?? '').toLowerCase();
switch (condition.operator) {
case 'equals':
return haystack === needle;
case 'contains':
return needle.length > 0 && haystack.includes(needle);
case 'starts_with':
return needle.length > 0 && haystack.startsWith(needle);
case 'ends_with':
return needle.length > 0 && haystack.endsWith(needle);
case 'matches_regex': {
const pattern = compileRulePattern(condition.value, `condition on ${condition.field}`);
return pattern ? pattern.test(candidate) : false;
}
default:
return false;
}
}
export function evaluateCondition(
condition: InboundEmailRuleCondition,
input: InboundEmailRuleEmailInput
): boolean {
switch (condition.field) {
case 'from_address':
return operatorMatches(input.fromAddress, condition);
case 'from_domain':
return operatorMatches(input.fromDomain, condition);
case 'to_address':
return input.toAddresses.some((address) => operatorMatches(address, condition));
case 'subject':
return operatorMatches(input.subject, condition);
case 'body_text':
return operatorMatches(input.bodyText, condition);
default:
return false;
}
}
/** ALL-of semantics: every condition must pass. An empty list never matches. */
export function evaluateConditions(
conditions: InboundEmailRuleCondition[],
input: InboundEmailRuleEmailInput
): { matched: boolean; results: InboundEmailRuleConditionResult[] } {
if (!Array.isArray(conditions) || conditions.length === 0) {
return { matched: false, results: [] };
}
const results = conditions.map((condition) => ({
condition,
passed: evaluateCondition(condition, input),
}));
return {
matched: results.every((result) => result.passed),
results,
};
}
function escapeRegExp(literal: string): string {
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Compile a friendly extraction template to a regex so templates and raw
* regex share one extraction code path. Group 1 is the extracted value.
*/
export function extractionToRegexSource(extraction: InboundEmailExtraction): string | null {
switch (extraction.type) {
case 'between': {
if (!extraction.start || !extraction.end) return null;
const start = escapeRegExp(extraction.start);
const end = escapeRegExp(extraction.end);
return `${start}([\\s\\S]*?)${end}`;
}
case 'after': {
if (!extraction.marker) return null;
return `${escapeRegExp(extraction.marker)}\\s*([^\\r\\n]+)`;
}
case 'before': {
if (!extraction.marker) return null;
return `([^\\r\\n]+?)\\s*${escapeRegExp(extraction.marker)}`;
}
case 'regex':
return extraction.pattern || null;
default:
return null;
}
}
/** Trim, collapse internal whitespace, lowercase. Empty result = no value. */
export function normalizeExtractedValue(value: string | null | undefined): string {
return (value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
}
/**
* Run an extract_assign_client extraction against the email. Returns the raw
* (un-normalized) captured value, or null when the pattern doesn't match,
* captures nothing, or is invalid.
*/
export function extractValue(
config: ExtractAssignClientActionConfig,
input: InboundEmailRuleEmailInput
): string | null {
const source = config.source === 'body_text' ? input.bodyText : input.subject;
if (!source) {
return null;
}
const regexSource = extractionToRegexSource(config.extraction);
if (!regexSource) {
return null;
}
const occurrence =
config.extraction.type !== 'regex' ? (config.extraction.occurrence ?? 'first') : 'first';
if (occurrence === 'last') {
const globalPattern = compileRulePatternGlobal(regexSource);
if (!globalPattern) return null;
let lastCapture: string | null = null;
for (const match of source.matchAll(globalPattern)) {
if (typeof match[1] === 'string') {
lastCapture = match[1];
}
}
return lastCapture;
}
const pattern = compileRulePattern(regexSource, 'extraction');
if (!pattern) return null;
const match = source.match(pattern);
// Capture group 1 is required; a pattern without one extracts nothing.
return match && typeof match[1] === 'string' ? match[1] : null;
}
function compileRulePatternGlobal(pattern: string): RegExp | null {
if (!pattern || pattern.length > MAX_REGEX_PATTERN_LENGTH) {
return null;
}
try {
return new RegExp(pattern, 'gi');
} catch {
return null;
}
}