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
215 lines
6.6 KiB
TypeScript
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;
|
|
}
|
|
}
|