PSA/shared/services/email/__tests__/processInboundEmailInApp.inboundRules.test.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

409 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { EmailMessageDetails } from '../../../interfaces/inbound-email.interfaces';
const withAdminTransactionMock = vi.fn();
const parseEmailReplyBodyMock = vi.fn();
const findTicketByReplyTokenMock = vi.fn();
const findTicketByEmailThreadMock = vi.fn();
const resolveInboundTicketDefaultsMock = vi.fn();
const resolveEffectiveInboundTicketDefaultsMock = vi.fn();
const findContactByEmailMock = vi.fn();
const findClientIdByInboundEmailDomainMock = vi.fn();
const findValidClientPrimaryContactIdMock = vi.fn();
const findEmailProviderMailboxAddressMock = vi.fn();
const upsertTicketWatchListRecipientsMock = vi.fn();
const createTicketFromEmailMock = vi.fn();
const createCommentFromEmailMock = vi.fn();
const processInboundEmailArtifactsBestEffortMock = vi.fn();
const evaluateInboundEmailRulesMock = vi.fn();
function buildEmailData(overrides: Partial<EmailMessageDetails> = {}): EmailMessageDetails {
return {
id: 'email-1',
provider: 'google',
providerId: 'provider-1',
tenant: 'tenant-1',
receivedAt: '2026-06-10T00:00:00.000Z',
from: { email: 'alerts@huntress.com', name: 'Huntress Alerts' },
to: [{ email: 'support@example.com', name: 'Support' }],
subject: 'Critical Alert (Acme Corp) - EDR detection',
body: { text: 'Incident details follow.', html: undefined },
attachments: [],
...overrides,
};
}
function makeQueryBuilder(firstResult: unknown) {
const builder: any = {
select: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
andWhereRaw: vi.fn().mockReturnThis(),
andWhere: vi.fn((arg: unknown) => {
if (typeof arg === 'function') {
const scopedWhere: any = {
whereRaw: vi.fn().mockReturnThis(),
orWhereRaw: vi.fn().mockReturnThis(),
};
arg.call(scopedWhere);
}
return builder;
}),
whereNotNull: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
first: vi.fn().mockResolvedValue(firstResult),
};
return builder;
}
vi.mock('@alga-psa/db', () => ({
withAdminTransaction: (callback: (trx: any) => Promise<any>) => withAdminTransactionMock(callback),
}));
vi.mock('../../../workflow/actions/emailWorkflowActions', () => ({
parseEmailReplyBody: (...args: any[]) => parseEmailReplyBodyMock(...args),
findTicketByReplyToken: (...args: any[]) => findTicketByReplyTokenMock(...args),
findTicketByEmailThread: (...args: any[]) => findTicketByEmailThreadMock(...args),
resolveInboundTicketDefaults: (...args: any[]) => resolveInboundTicketDefaultsMock(...args),
resolveEffectiveInboundTicketDefaults: (...args: any[]) =>
resolveEffectiveInboundTicketDefaultsMock(...args),
findContactByEmail: (...args: any[]) => findContactByEmailMock(...args),
findClientIdByInboundEmailDomain: (...args: any[]) => findClientIdByInboundEmailDomainMock(...args),
findValidClientPrimaryContactId: (...args: any[]) => findValidClientPrimaryContactIdMock(...args),
findEmailProviderMailboxAddress: (...args: any[]) => findEmailProviderMailboxAddressMock(...args),
upsertTicketWatchListRecipients: (...args: any[]) => upsertTicketWatchListRecipientsMock(...args),
createTicketFromEmail: (...args: any[]) => createTicketFromEmailMock(...args),
createCommentFromEmail: (...args: any[]) => createCommentFromEmailMock(...args),
}));
vi.mock('../processInboundEmailArtifacts', () => ({
processInboundEmailArtifactsBestEffort: (...args: any[]) =>
processInboundEmailArtifactsBestEffortMock(...args),
}));
vi.mock('../inboundEmailRules', () => ({
evaluateInboundEmailRules: (...args: any[]) => evaluateInboundEmailRulesMock(...args),
}));
const PROVIDER_DEFAULTS = {
client_id: 'default-client-id',
board_id: 'board-id',
status_id: 'status-id',
priority_id: 'priority-id',
category_id: undefined,
subcategory_id: undefined,
location_id: undefined,
entered_by: 'entered-by-user',
};
describe('processInboundEmailInApp: inbound email rules integration', () => {
beforeEach(() => {
vi.clearAllMocks();
withAdminTransactionMock.mockImplementation(async (callback: (trx: any) => Promise<any>) => {
const trx = vi.fn(() => makeQueryBuilder(undefined));
return callback(trx);
});
parseEmailReplyBodyMock.mockResolvedValue({
sanitizedText: 'Incident details follow.',
sanitizedHtml: undefined,
confidence: 0.95,
strategy: 'plain',
appliedHeuristics: [],
warnings: [],
tokens: {},
});
findTicketByReplyTokenMock.mockResolvedValue(null);
findTicketByEmailThreadMock.mockResolvedValue(null);
findContactByEmailMock.mockResolvedValue(null);
findClientIdByInboundEmailDomainMock.mockResolvedValue(null);
findValidClientPrimaryContactIdMock.mockResolvedValue(null);
findEmailProviderMailboxAddressMock.mockResolvedValue('support@example.com');
upsertTicketWatchListRecipientsMock.mockResolvedValue({ updated: true, watchList: [] });
resolveInboundTicketDefaultsMock.mockResolvedValue(PROVIDER_DEFAULTS);
resolveEffectiveInboundTicketDefaultsMock.mockResolvedValue({
defaults: PROVIDER_DEFAULTS,
source: 'provider_default',
});
createTicketFromEmailMock.mockResolvedValue({ ticket_id: 'ticket-1', ticket_number: 'T-1' });
createCommentFromEmailMock.mockResolvedValue('comment-1');
processInboundEmailArtifactsBestEffortMock.mockResolvedValue(undefined);
evaluateInboundEmailRulesMock.mockResolvedValue({ outcome: { kind: 'none' }, trace: [] });
});
async function run(emailOverrides: Partial<EmailMessageDetails> = {}) {
const { processInboundEmailInApp } = await import('../processInboundEmailInApp');
return processInboundEmailInApp({
tenantId: 'tenant-1',
providerId: 'provider-1',
emailData: buildEmailData(emailOverrides),
});
}
it('skip outcome suppresses the ticket and reports the rule', async () => {
evaluateInboundEmailRulesMock.mockResolvedValue({
outcome: { kind: 'skip', ruleId: 'rule-9', ruleName: 'Status updates', via: 'action' },
trace: [{ ruleId: 'rule-9' }],
});
const result = await run();
expect(result).toMatchObject({
outcome: 'skipped',
reason: 'rule_skip',
rule: { ruleId: 'rule-9', ruleName: 'Status updates' },
});
expect(createTicketFromEmailMock).not.toHaveBeenCalled();
expect(createCommentFromEmailMock).not.toHaveBeenCalled();
expect(processInboundEmailArtifactsBestEffortMock).not.toHaveBeenCalled();
});
it('skip outcome works without configured inbound defaults', async () => {
resolveInboundTicketDefaultsMock.mockResolvedValue(null);
evaluateInboundEmailRulesMock.mockResolvedValue({
outcome: { kind: 'skip', ruleId: 'rule-9', ruleName: 'Status updates', via: 'action' },
trace: [{ ruleId: 'rule-9' }],
});
const result = await run();
expect(result).toMatchObject({ outcome: 'skipped', reason: 'rule_skip' });
// The skip resolved before defaults were ever needed.
expect(resolveInboundTicketDefaultsMock).not.toHaveBeenCalled();
});
it('assign_client outcome wins over sender/domain matching and uses the client primary contact', async () => {
evaluateInboundEmailRulesMock.mockResolvedValue({
outcome: {
kind: 'assign_client',
ruleId: 'rule-1',
ruleName: 'Huntress routing',
clientId: 'client-acme',
extractedValue: 'acme corp',
matchSource: 'rule_extraction',
},
trace: [{ ruleId: 'rule-1' }],
});
findValidClientPrimaryContactIdMock.mockResolvedValue('acme-primary-contact');
const result = await run();
expect(result).toMatchObject({ outcome: 'created', ticketId: 'ticket-1' });
expect(findClientIdByInboundEmailDomainMock).not.toHaveBeenCalled();
expect(findValidClientPrimaryContactIdMock).toHaveBeenCalledWith('client-acme', 'tenant-1');
expect(createTicketFromEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
client_id: 'client-acme',
contact_id: 'acme-primary-contact',
email_metadata: expect.objectContaining({
appliedRuleId: 'rule-1',
appliedRuleName: 'Huntress routing',
clientMatchSource: 'rule_extraction',
}),
}),
'tenant-1'
);
});
it('assign_client keeps the sender contact when it belongs to the assigned client', async () => {
evaluateInboundEmailRulesMock.mockResolvedValue({
outcome: {
kind: 'assign_client',
ruleId: 'rule-1',
ruleName: 'Huntress routing',
clientId: 'client-acme',
extractedValue: 'acme corp',
matchSource: 'rule_extraction',
},
trace: [{ ruleId: 'rule-1' }],
});
findContactByEmailMock.mockResolvedValue({
contact_id: 'contact-in-acme',
client_id: 'client-acme',
email: 'alerts@huntress.com',
name: 'Huntress Integration',
client_name: 'Acme Corp',
});
await run();
expect(findValidClientPrimaryContactIdMock).not.toHaveBeenCalled();
expect(createTicketFromEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
client_id: 'client-acme',
contact_id: 'contact-in-acme',
}),
'tenant-1'
);
expect(createCommentFromEmailMock).toHaveBeenCalledWith(
expect.objectContaining({ contact_id: 'contact-in-acme' }),
'tenant-1'
);
});
it('assign_client overrides an exact sender contact match in a different client', async () => {
evaluateInboundEmailRulesMock.mockResolvedValue({
outcome: {
kind: 'assign_client',
ruleId: 'rule-1',
ruleName: 'Huntress routing',
clientId: 'client-acme',
extractedValue: 'acme corp',
matchSource: 'rule_extraction',
},
trace: [{ ruleId: 'rule-1' }],
});
findContactByEmailMock.mockResolvedValue({
contact_id: 'contact-other',
client_id: 'client-other',
email: 'alerts@huntress.com',
name: 'Huntress Vendor Contact',
client_name: 'Huntress (vendor)',
});
findValidClientPrimaryContactIdMock.mockResolvedValue('acme-primary-contact');
await run();
expect(createTicketFromEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
client_id: 'client-acme',
contact_id: 'acme-primary-contact',
}),
'tenant-1'
);
// The mismatched sender contact must not be attributed as the comment author.
expect(createCommentFromEmailMock).toHaveBeenCalledWith(
expect.objectContaining({ contact_id: undefined }),
'tenant-1'
);
// Destination cascade sees the rule client via the domain-match slot.
expect(resolveEffectiveInboundTicketDefaultsMock).toHaveBeenCalledWith(
expect.objectContaining({
matchedContactId: null,
domainMatchedClientId: 'client-acme',
})
);
});
it('set_destination outcome applies the rule defaults above the cascade', async () => {
const ruleDefaults = {
client_id: null,
board_id: 'security-board',
status_id: 'sec-status',
priority_id: 'sec-priority',
entered_by: null,
};
evaluateInboundEmailRulesMock.mockResolvedValue({
outcome: {
kind: 'set_destination',
ruleId: 'rule-2',
ruleName: 'Security routing',
defaults: ruleDefaults,
},
trace: [{ ruleId: 'rule-2' }],
});
findContactByEmailMock.mockResolvedValue({
contact_id: 'contact-123',
client_id: 'client-123',
email: 'alerts@huntress.com',
name: 'Known Sender',
client_name: 'Client Co',
});
await run();
expect(resolveEffectiveInboundTicketDefaultsMock).not.toHaveBeenCalled();
expect(createTicketFromEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
board_id: 'security-board',
status_id: 'sec-status',
priority_id: 'sec-priority',
// Sender matching still attributes the client/contact.
client_id: 'client-123',
contact_id: 'contact-123',
}),
'tenant-1'
);
});
it('fallback_destination outcome creates the ticket at the fallback even without provider defaults', async () => {
resolveInboundTicketDefaultsMock.mockResolvedValue(null);
const fallbackDefaults = {
client_id: 'triage-client',
board_id: 'triage-board',
status_id: 'triage-status',
priority_id: 'triage-priority',
entered_by: null,
};
evaluateInboundEmailRulesMock.mockResolvedValue({
outcome: {
kind: 'fallback_destination',
ruleId: 'rule-1',
ruleName: 'Huntress routing',
defaults: fallbackDefaults,
},
trace: [{ ruleId: 'rule-1' }],
});
const result = await run();
expect(result).toMatchObject({ outcome: 'created' });
expect(createTicketFromEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
client_id: 'triage-client',
board_id: 'triage-board',
email_metadata: expect.objectContaining({
appliedRuleId: 'rule-1',
}),
}),
'tenant-1'
);
});
it('none outcome leaves the legacy pipeline behavior unchanged', async () => {
const result = await run();
expect(result).toMatchObject({ outcome: 'created', ticketId: 'ticket-1' });
expect(createTicketFromEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
client_id: 'default-client-id',
board_id: 'board-id',
email_metadata: expect.objectContaining({
clientMatchSource: 'provider_default',
}),
}),
'tenant-1'
);
const ticketPayload = createTicketFromEmailMock.mock.calls[0][0];
expect(ticketPayload.email_metadata.appliedRuleId).toBeUndefined();
});
it('rules are not evaluated for replies that thread onto existing tickets', async () => {
findTicketByReplyTokenMock.mockResolvedValue(null);
findTicketByEmailThreadMock.mockResolvedValue({ ticketId: 'ticket-existing' });
// Threaded-reply path needs comment dedup + reopen policy lookups to resolve.
withAdminTransactionMock.mockImplementation(async (callback: (trx: any) => Promise<any>) => {
const trx = vi.fn((table: string) => {
if (table === 'tickets') {
return makeQueryBuilder({
ticket_id: 'ticket-existing',
board_id: 'board-id',
status_id: 'status-open',
is_closed: false,
closed_at: null,
});
}
return makeQueryBuilder(undefined);
});
return callback(trx);
});
const result = await run({
inReplyTo: '<original@example.com>',
});
expect(result).toMatchObject({ outcome: 'replied', ticketId: 'ticket-existing' });
expect(evaluateInboundEmailRulesMock).not.toHaveBeenCalled();
});
});