PSA/shared/services/email/unifiedInboundEmailQueue.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

604 lines
18 KiB
TypeScript

import { randomUUID } from 'crypto';
import { createClient, type RedisClientType } from 'redis';
import { getSecret } from '@alga-psa/core/secrets';
import type {
GoogleInboundEmailPointer,
ImapInboundEmailPointer,
MicrosoftInboundEmailPointer,
UnifiedInboundEmailQueueJob,
} from '../../interfaces/inbound-email.interfaces';
const DEFAULT_READY_QUEUE_KEY = 'email:inbound:unified:pointer:ready';
const DEFAULT_PROCESSING_QUEUE_KEY = 'email:inbound:unified:pointer:processing';
const DEFAULT_INFLIGHT_HASH_KEY = 'email:inbound:unified:pointer:inflight';
const DEFAULT_INFLIGHT_LEASE_KEY = 'email:inbound:unified:pointer:lease';
const DEFAULT_DLQ_QUEUE_KEY = 'email:inbound:unified:pointer:dlq';
const DEFAULT_MAX_ATTEMPTS = 5;
const DEFAULT_CLAIM_TTL_MS = 60_000;
const DEFAULT_BLOCK_SECONDS = 1;
const CLAIM_POLL_INTERVAL_MS = 100;
const CLAIM_JOB_LUA = `
local ready = KEYS[1]
local processing = KEYS[2]
local inflightHash = KEYS[3]
local inflightLease = KEYS[4]
local dlq = KEYS[5]
local nowMs = tonumber(ARGV[1])
local claimTtlMs = tonumber(ARGV[2])
local consumerId = ARGV[3]
local claimedAtIso = ARGV[4]
local leaseExpiresAtIso = ARGV[5]
local failedAtIso = ARGV[6]
local payload = redis.call('RPOPLPUSH', ready, processing)
if not payload then
return cjson.encode({ status = 'empty' })
end
local ok, job = pcall(cjson.decode, payload)
if not ok or type(job) ~= 'table' or job['jobId'] == nil then
redis.call('LREM', processing, 1, payload)
redis.call('RPUSH', dlq, cjson.encode({
failedAt = failedAtIso,
reason = 'invalid_queue_payload',
rawPayload = payload,
}))
return cjson.encode({
status = 'invalid',
payloadLength = string.len(payload),
})
end
local jobId = tostring(job['jobId'])
local claim = cjson.encode({
job = job,
originalPayload = payload,
consumerId = consumerId,
claimedAt = claimedAtIso,
leaseExpiresAt = leaseExpiresAtIso,
})
redis.call('HSET', inflightHash, jobId, claim)
redis.call('ZADD', inflightLease, nowMs + claimTtlMs, jobId)
return cjson.encode({
status = 'claimed',
claim = claim,
})
`;
let redisClientPromise: Promise<RedisClientType> | null = null;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function parsePositiveInteger(value: string | undefined, fallback: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.floor(parsed);
}
export interface UnifiedInboundEmailQueueConfig {
readyQueueKey: string;
processingQueueKey: string;
inflightHashKey: string;
inflightLeaseKey: string;
deadLetterQueueKey: string;
maxAttempts: number;
claimTtlMs: number;
claimBlockSeconds: number;
}
export function getUnifiedInboundEmailQueueConfig(): UnifiedInboundEmailQueueConfig {
return {
readyQueueKey: (process.env.UNIFIED_INBOUND_EMAIL_QUEUE_KEY || '').trim() || DEFAULT_READY_QUEUE_KEY,
processingQueueKey:
(process.env.UNIFIED_INBOUND_EMAIL_PROCESSING_QUEUE_KEY || '').trim() ||
DEFAULT_PROCESSING_QUEUE_KEY,
inflightHashKey:
(process.env.UNIFIED_INBOUND_EMAIL_INFLIGHT_HASH_KEY || '').trim() || DEFAULT_INFLIGHT_HASH_KEY,
inflightLeaseKey:
(process.env.UNIFIED_INBOUND_EMAIL_INFLIGHT_LEASE_KEY || '').trim() || DEFAULT_INFLIGHT_LEASE_KEY,
deadLetterQueueKey:
(process.env.UNIFIED_INBOUND_EMAIL_DLQ_KEY || '').trim() || DEFAULT_DLQ_QUEUE_KEY,
maxAttempts: parsePositiveInteger(
process.env.UNIFIED_INBOUND_EMAIL_QUEUE_MAX_ATTEMPTS,
DEFAULT_MAX_ATTEMPTS
),
claimTtlMs: parsePositiveInteger(
process.env.UNIFIED_INBOUND_EMAIL_QUEUE_CLAIM_TTL_MS,
DEFAULT_CLAIM_TTL_MS
),
claimBlockSeconds: parsePositiveInteger(
process.env.UNIFIED_INBOUND_EMAIL_QUEUE_BLOCK_SECONDS,
DEFAULT_BLOCK_SECONDS
),
};
}
async function getRedisClient(): Promise<RedisClientType> {
if (redisClientPromise) {
return redisClientPromise;
}
redisClientPromise = (async () => {
const host = process.env.REDIS_HOST || 'localhost';
const port = process.env.REDIS_PORT || '6379';
const password = await getSecret('redis_password', 'REDIS_PASSWORD');
const options: Parameters<typeof createClient>[0] = {
url: `redis://${host}:${port}`,
};
if (password) {
(options as any).password = password;
}
const client = createClient(options);
client.on('error', (error) => {
console.error('[UnifiedInboundEmailQueue] Redis client error:', error);
});
await client.connect();
return client as RedisClientType;
})();
return redisClientPromise;
}
export type UnifiedInboundEmailQueueJobInput =
| {
tenantId: string;
providerId: string;
provider: 'microsoft';
pointer: MicrosoftInboundEmailPointer;
maxAttempts?: number;
}
| {
tenantId: string;
providerId: string;
provider: 'google';
pointer: GoogleInboundEmailPointer;
maxAttempts?: number;
}
| {
tenantId: string;
providerId: string;
provider: 'imap';
pointer: ImapInboundEmailPointer;
maxAttempts?: number;
};
export interface EnqueueUnifiedInboundEmailQueueJobResult {
job: UnifiedInboundEmailQueueJob;
queueDepth: number;
}
export interface ClaimedUnifiedInboundEmailQueueJob {
job: UnifiedInboundEmailQueueJob;
originalPayload: string;
consumerId: string;
claimedAt: string;
leaseExpiresAt: string;
}
export interface FailUnifiedInboundEmailQueueJobResult {
action: 'retried' | 'dlq';
attempt: number;
queueDepth: number;
}
function getPointerLogFields(
provider: UnifiedInboundEmailQueueJob['provider'],
pointer: MicrosoftInboundEmailPointer | GoogleInboundEmailPointer | ImapInboundEmailPointer
): Record<string, string | number | null> {
if (provider === 'microsoft') {
const microsoftPointer = pointer as MicrosoftInboundEmailPointer;
return {
pointerMessageId: microsoftPointer.messageId,
pointerSubscriptionId: microsoftPointer.subscriptionId,
pointerResource: microsoftPointer.resource || null,
};
}
if (provider === 'google') {
const googlePointer = pointer as GoogleInboundEmailPointer;
return {
pointerHistoryId: googlePointer.historyId,
pointerEmailAddress: googlePointer.emailAddress || null,
pointerPubsubMessageId: googlePointer.pubsubMessageId || null,
};
}
const imapPointer = pointer as ImapInboundEmailPointer;
return {
pointerUid: imapPointer.uid,
pointerMailbox: imapPointer.mailbox,
pointerUidValidity: imapPointer.uidValidity || null,
pointerMessageId: imapPointer.messageId || null,
};
}
function getJobLogFields(job: UnifiedInboundEmailQueueJob): Record<string, string | number | null> {
return {
jobId: job.jobId,
provider: job.provider,
tenantId: job.tenantId,
providerId: job.providerId,
attempt: job.attempt,
maxAttempts: job.maxAttempts,
...getPointerLogFields(job.provider, job.pointer),
};
}
function buildUnifiedInboundEmailQueueJob(
input: UnifiedInboundEmailQueueJobInput
): UnifiedInboundEmailQueueJob {
const queueConfig = getUnifiedInboundEmailQueueConfig();
const maxAttempts =
input.maxAttempts && input.maxAttempts > 0
? Math.floor(input.maxAttempts)
: queueConfig.maxAttempts;
const base = {
jobId: randomUUID(),
schemaVersion: 1 as const,
tenantId: input.tenantId,
providerId: input.providerId,
enqueuedAt: new Date().toISOString(),
attempt: 0,
maxAttempts,
};
switch (input.provider) {
case 'microsoft':
return {
...base,
provider: 'microsoft',
pointer: input.pointer,
};
case 'google':
return {
...base,
provider: 'google',
pointer: input.pointer,
};
case 'imap':
return {
...base,
provider: 'imap',
pointer: input.pointer,
};
default:
throw new Error(`Unsupported provider type: ${(input as any)?.provider}`);
}
}
function parseClaimRecord(value: string): ClaimedUnifiedInboundEmailQueueJob | null {
try {
const parsed = JSON.parse(value) as ClaimedUnifiedInboundEmailQueueJob;
if (!parsed || typeof parsed !== 'object') return null;
if (!parsed.job || typeof parsed.job !== 'object') return null;
if (typeof parsed.originalPayload !== 'string') return null;
if (typeof parsed.consumerId !== 'string') return null;
if (typeof (parsed.job as any).jobId !== 'string') return null;
return parsed;
} catch {
return null;
}
}
const FORBIDDEN_POINTER_PAYLOAD_KEYS = new Set([
'emailData',
'attachments',
'rawMime',
'rawMimeBase64',
'sourceMimeBase64',
'rawSourceBase64',
'body',
'content',
]);
function assertPointerOnlyPayload(input: UnifiedInboundEmailQueueJobInput): void {
const inputAsAny = input as any;
for (const key of Object.keys(inputAsAny)) {
if (FORBIDDEN_POINTER_PAYLOAD_KEYS.has(key)) {
throw new Error(`Queue payload must be pointer-only; forbidden field found: ${key}`);
}
}
const stack: unknown[] = [input.pointer];
while (stack.length > 0) {
const current = stack.pop();
if (!current || typeof current !== 'object') continue;
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
if (FORBIDDEN_POINTER_PAYLOAD_KEYS.has(key)) {
throw new Error(`Queue pointer payload must not contain field: ${key}`);
}
if (value && typeof value === 'object') {
stack.push(value);
}
}
}
}
export async function enqueueUnifiedInboundEmailQueueJob(
input: UnifiedInboundEmailQueueJobInput
): Promise<EnqueueUnifiedInboundEmailQueueJobResult> {
assertPointerOnlyPayload(input);
const queueConfig = getUnifiedInboundEmailQueueConfig();
const client = await getRedisClient();
const job = buildUnifiedInboundEmailQueueJob(input);
let queueDepth: number;
try {
queueDepth = await client.rPush(queueConfig.readyQueueKey, JSON.stringify(job));
} catch (error: any) {
console.error('[UnifiedInboundEmailQueue] enqueue_failed', {
event: 'inbound_email_queue_enqueue_failed',
...getJobLogFields(job),
error: error?.message || String(error),
});
throw error;
}
console.log('[UnifiedInboundEmailQueue] enqueue', {
event: 'inbound_email_queue_enqueue',
...getJobLogFields(job),
queueDepth,
});
return {
job,
queueDepth,
};
}
export async function claimUnifiedInboundEmailQueueJob(params: {
consumerId: string;
blockSeconds?: number;
claimTtlMs?: number;
}): Promise<ClaimedUnifiedInboundEmailQueueJob | null> {
const queueConfig = getUnifiedInboundEmailQueueConfig();
const client = await getRedisClient();
const blockSeconds = Math.max(0, params.blockSeconds ?? queueConfig.claimBlockSeconds);
const claimTtlMs = Math.max(1, params.claimTtlMs ?? queueConfig.claimTtlMs);
const deadline = Date.now() + blockSeconds * 1000;
let claimRecord: ClaimedUnifiedInboundEmailQueueJob | null = null;
while (!claimRecord) {
const now = Date.now();
const claimedAt = new Date(now).toISOString();
const leaseExpiresAt = new Date(now + claimTtlMs).toISOString();
const failedAt = claimedAt;
const rawResult = await (client as any).eval(CLAIM_JOB_LUA, {
keys: [
queueConfig.readyQueueKey,
queueConfig.processingQueueKey,
queueConfig.inflightHashKey,
queueConfig.inflightLeaseKey,
queueConfig.deadLetterQueueKey,
],
arguments: [
String(now),
String(claimTtlMs),
params.consumerId,
claimedAt,
leaseExpiresAt,
failedAt,
],
});
const parsedResult = typeof rawResult === 'string' ? parseClaimRecord(rawResult) : null;
let envelope: any = null;
if (!parsedResult) {
if (typeof rawResult === 'string') {
try {
envelope = JSON.parse(rawResult);
} catch {
envelope = null;
}
} else {
envelope = rawResult;
}
}
if (parsedResult) {
// Backward compatibility if eval ever returns a direct claim payload.
claimRecord = parsedResult;
break;
}
if (!envelope || typeof envelope !== 'object') {
if (Date.now() >= deadline) {
return null;
}
await sleep(CLAIM_POLL_INTERVAL_MS);
continue;
}
if ((envelope as any).status === 'empty') {
if (Date.now() >= deadline) {
return null;
}
await sleep(CLAIM_POLL_INTERVAL_MS);
continue;
}
if ((envelope as any).status === 'invalid') {
console.error('[UnifiedInboundEmailQueue] invalid_payload_dlq', {
event: 'inbound_email_queue_invalid_payload_dlq',
reason: 'invalid_queue_payload',
payloadLength: Number((envelope as any).payloadLength || 0),
});
if (Date.now() >= deadline) {
return null;
}
continue;
}
if ((envelope as any).status === 'claimed' && typeof (envelope as any).claim === 'string') {
const parsedClaim = parseClaimRecord((envelope as any).claim);
if (parsedClaim) {
claimRecord = parsedClaim;
break;
}
}
if (Date.now() >= deadline) {
return null;
}
await sleep(CLAIM_POLL_INTERVAL_MS);
}
console.log('[UnifiedInboundEmailQueue] consume_start', {
event: 'inbound_email_queue_consume_start',
...getJobLogFields(claimRecord.job),
consumerId: params.consumerId,
claimTtlMs,
});
return claimRecord;
}
export async function ackUnifiedInboundEmailQueueJob(
claim: ClaimedUnifiedInboundEmailQueueJob
): Promise<void> {
const queueConfig = getUnifiedInboundEmailQueueConfig();
const client = await getRedisClient();
await client.multi()
.lRem(queueConfig.processingQueueKey, 1, claim.originalPayload)
.hDel(queueConfig.inflightHashKey, claim.job.jobId)
.zRem(queueConfig.inflightLeaseKey, claim.job.jobId)
.exec();
console.log('[UnifiedInboundEmailQueue] ack', {
event: 'inbound_email_queue_ack',
...getJobLogFields(claim.job),
consumerId: claim.consumerId,
});
}
export async function failUnifiedInboundEmailQueueJob(params: {
claim: ClaimedUnifiedInboundEmailQueueJob;
error: string;
}): Promise<FailUnifiedInboundEmailQueueJobResult> {
const queueConfig = getUnifiedInboundEmailQueueConfig();
const client = await getRedisClient();
const nextAttempt = (params.claim.job.attempt || 0) + 1;
const maxAttempts = params.claim.job.maxAttempts || queueConfig.maxAttempts;
const retriedJob: UnifiedInboundEmailQueueJob = {
...params.claim.job,
attempt: nextAttempt,
};
if (nextAttempt >= maxAttempts) {
const execResult = await client.multi()
.lRem(queueConfig.processingQueueKey, 1, params.claim.originalPayload)
.hDel(queueConfig.inflightHashKey, params.claim.job.jobId)
.zRem(queueConfig.inflightLeaseKey, params.claim.job.jobId)
.rPush(
queueConfig.deadLetterQueueKey,
JSON.stringify({
failedAt: new Date().toISOString(),
reason: params.error,
job: retriedJob,
})
)
.exec();
const queueDepthRaw = Array.isArray(execResult) ? execResult[execResult.length - 1] : null;
const queueDepth = Number.isFinite(Number(queueDepthRaw)) ? Number(queueDepthRaw) : 0;
console.error('[UnifiedInboundEmailQueue] dlq', {
event: 'inbound_email_queue_dlq',
...getJobLogFields(retriedJob),
attempt: nextAttempt,
maxAttempts,
queueDepth,
reason: params.error,
consumerId: params.claim.consumerId,
});
return {
action: 'dlq',
attempt: nextAttempt,
queueDepth,
};
}
const execResult = await client.multi()
.lRem(queueConfig.processingQueueKey, 1, params.claim.originalPayload)
.hDel(queueConfig.inflightHashKey, params.claim.job.jobId)
.zRem(queueConfig.inflightLeaseKey, params.claim.job.jobId)
.rPush(queueConfig.readyQueueKey, JSON.stringify(retriedJob))
.exec();
const queueDepthRaw = Array.isArray(execResult) ? execResult[execResult.length - 1] : null;
const queueDepth = Number.isFinite(Number(queueDepthRaw)) ? Number(queueDepthRaw) : 0;
console.warn('[UnifiedInboundEmailQueue] retry', {
event: 'inbound_email_queue_retry',
...getJobLogFields(retriedJob),
attempt: nextAttempt,
maxAttempts,
queueDepth,
reason: params.error,
consumerId: params.claim.consumerId,
});
return {
action: 'retried',
attempt: nextAttempt,
queueDepth,
};
}
export async function reclaimExpiredUnifiedInboundEmailQueueJobs(
limit: number = 20
): Promise<number> {
const queueConfig = getUnifiedInboundEmailQueueConfig();
const client = await getRedisClient();
const now = Date.now();
const reclaimedJobIds = await client.zRangeByScore(
queueConfig.inflightLeaseKey,
0,
now,
{ LIMIT: { offset: 0, count: Math.max(1, limit) } }
);
let reclaimed = 0;
for (const jobId of reclaimedJobIds) {
const claimRecordRaw = await client.hGet(queueConfig.inflightHashKey, jobId);
if (!claimRecordRaw) {
await client.zRem(queueConfig.inflightLeaseKey, jobId);
continue;
}
const claimRecord = parseClaimRecord(claimRecordRaw);
if (!claimRecord) {
await client.multi()
.hDel(queueConfig.inflightHashKey, jobId)
.zRem(queueConfig.inflightLeaseKey, jobId)
.exec();
continue;
}
await client.multi()
.lRem(queueConfig.processingQueueKey, 1, claimRecord.originalPayload)
.hDel(queueConfig.inflightHashKey, jobId)
.zRem(queueConfig.inflightLeaseKey, jobId)
.rPush(queueConfig.readyQueueKey, claimRecord.originalPayload)
.exec();
console.warn('[UnifiedInboundEmailQueue] reclaim', {
event: 'inbound_email_queue_reclaim',
...getJobLogFields(claimRecord.job),
consumerId: claimRecord.consumerId,
claimAgeMs: now - new Date(claimRecord.claimedAt).getTime(),
});
reclaimed += 1;
}
return reclaimed;
}