diff --git a/backend/src/entities/cron-jobs/cron-jobs.service.ts b/backend/src/entities/cron-jobs/cron-jobs.service.ts index 8debd11cf..f667dd84c 100644 --- a/backend/src/entities/cron-jobs/cron-jobs.service.ts +++ b/backend/src/entities/cron-jobs/cron-jobs.service.ts @@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import Sentry from '@sentry/minimal'; -import Mail from 'nodemailer/lib/mailer/index.js'; import { Repository } from 'typeorm'; import { UseCaseType } from '../../common/data-injection.tokens.js'; import { Constants } from '../../helpers/constants/constants.js'; @@ -15,6 +14,7 @@ import { ICheckUsersLogsAndUpdateActionsUseCase, } from '../user-actions/use-cases/use-cases-interfaces.js'; import { JobListEntity } from './job-list.entity.js'; +import { buildEmailCronReportMessages } from './utils/email-cron-report.js'; @Injectable() export class CronJobsService { constructor( @@ -72,7 +72,7 @@ export class CronJobsService { ); const batchSize = 10; - const allMailingResults: Array = []; + const allMailingResults: Array = []; for (let i = 0; i < emails.length; i += batchSize) { const emailsBatch = emails.slice(i, i + batchSize); @@ -82,6 +82,8 @@ export class CronJobsService { } catch (error) { console.error(`Error processing batch ${Math.floor(i / batchSize) + 1}: ${getErrorMessage(error)}`); Sentry.captureException(error); + const reason = `batch dispatch threw: ${getErrorMessage(error)}`; + allMailingResults.push(...emailsBatch.map((email) => ({ email, failureReason: reason }))); } await new Promise((resolve) => setTimeout(resolve, 1000)); } @@ -90,7 +92,7 @@ export class CronJobsService { const mailingResultToString = 'Sending emails triggered, but no emails sent (no users found)'; await slackPostMessage(mailingResultToString, Constants.EXCEPTIONS_CHANNELS); } else { - await this.sendEmailResultsToSlack(allMailingResults, emails); + await this.sendEmailResultsToSlack(allMailingResults); } await slackPostMessage(`morning cron finished at ${this.getCurrentTime()}`, Constants.EXCEPTIONS_CHANNELS); } catch (innerError) { @@ -135,70 +137,12 @@ export class CronJobsService { } } - private emailCronResultToSlackString(results: Array): string | null { - try { - let output = '```\n'; - output += 'Idx | Accepted Email | Message ID\n'; - output += '----|---------------------------------|------------------------------------------\n'; - - results.forEach((result, idx) => { - const accepted = result.accepted && result.accepted.length > 0 ? result.accepted.join(', ') : '-'; - const messageId = result.messageId ?? '-'; - const idxStr = String(idx + 1).padEnd(3); - const acceptedStr = accepted.padEnd(32); - output += `${idxStr} | ${acceptedStr} | ${messageId}\n`; - }); - output += '```'; - return output; - } catch (_error) { - return null; - } - } - - private async sendEmailResultsToSlack( - results: Array, - allFoundEmails: Array, - ): Promise { - const filteredResults = results.filter((result): result is ICronMessagingResults => !!result); - const nullResultsCount = results.length - filteredResults.length; - const chunkSize = 20; - - const foundEmails = new Set(); - filteredResults.forEach((result) => { - if (result?.accepted) { - result.accepted.forEach((email) => foundEmails.add(email)); - } - }); - - const emailsNonFoundInResults = allFoundEmails.filter((email) => !foundEmails.has(email)); - - for (let i = 0; i < filteredResults.length; i += chunkSize) { - const chunk = filteredResults.slice(i, i + chunkSize); - const message = this.emailCronResultToSlackString(chunk); - if (!message) { - continue; - } + private async sendEmailResultsToSlack(results: Array): Promise { + const messages = buildEmailCronReportMessages(results); + for (const message of messages) { await slackPostMessage(message, Constants.EXCEPTIONS_CHANNELS); await new Promise((resolve) => setTimeout(resolve, 100)); } - - if (nullResultsCount > 0) { - const timedOutMessage = `The system timed out while sending results to ${nullResultsCount} email addresses`; - if (emailsNonFoundInResults.length > 100) { - await slackPostMessage(timedOutMessage, Constants.EXCEPTIONS_CHANNELS); - for (let i = 0; i < emailsNonFoundInResults.length; i += 100) { - const emailsChunk = emailsNonFoundInResults.slice(i, i + 100); - await slackPostMessage( - `Failed emails (chunk ${i / 100 + 1}): ${emailsChunk.join(', ')}`, - Constants.EXCEPTIONS_CHANNELS, - ); - } - } else { - const timedOutEmailsMessage = `: \n${emailsNonFoundInResults.join(', ')}\n`; - const fullTimedOutMessage = `${timedOutMessage}${timedOutEmailsMessage}`; - await slackPostMessage(fullTimedOutMessage, Constants.EXCEPTIONS_CHANNELS); - } - } } private getCurrentTime(): string { diff --git a/backend/src/entities/cron-jobs/utils/email-cron-report.ts b/backend/src/entities/cron-jobs/utils/email-cron-report.ts new file mode 100644 index 000000000..46a51362c --- /dev/null +++ b/backend/src/entities/cron-jobs/utils/email-cron-report.ts @@ -0,0 +1,72 @@ +import { ICronMessagingResults } from '../../email/email/email.service.js'; + +const SUCCESS_TABLE_CHUNK_SIZE = 20; +const FAILURE_ADDRESSES_CHUNK_SIZE = 100; + +// Pure formatter for the morning email-cron Slack report: one string per Slack +// message, in posting order. Successes render as fixed-width tables; failures +// are grouped by reason so a broken delivery seam names itself instead of +// hiding behind a generic "timed out" line. +export function buildEmailCronReportMessages(results: Array): Array { + const successes = results.filter((result) => !result.failureReason); + const failures = results.filter((result) => !!result.failureReason); + + const reasonGroups = new Map>(); + for (const failure of failures) { + const reason = failure.failureReason as string; + const group = reasonGroups.get(reason); + if (group) { + group.push(failure.email); + } else { + reasonGroups.set(reason, [failure.email]); + } + } + + const messages: Array = []; + + if (results.length > 0 && failures.length === results.length && reasonGroups.size === 1) { + const [onlyReason] = reasonGroups.keys(); + messages.push( + `:warning: email delivery seam appears down (${onlyReason}) — transactional email is likely affected too`, + ); + } + + for (let i = 0; i < successes.length; i += SUCCESS_TABLE_CHUNK_SIZE) { + const chunk = successes.slice(i, i + SUCCESS_TABLE_CHUNK_SIZE); + messages.push(successResultsToTable(chunk)); + } + + if (failures.length > 0) { + messages.push(`Failed to send ${failures.length} of ${results.length} emails:`); + for (const [reason, addresses] of reasonGroups) { + if (addresses.length > FAILURE_ADDRESSES_CHUNK_SIZE) { + for (let i = 0; i < addresses.length; i += FAILURE_ADDRESSES_CHUNK_SIZE) { + const addressesChunk = addresses.slice(i, i + FAILURE_ADDRESSES_CHUNK_SIZE); + messages.push( + `${addresses.length} × ${reason} (chunk ${i / FAILURE_ADDRESSES_CHUNK_SIZE + 1}): ${addressesChunk.join(', ')}`, + ); + } + } else { + messages.push(`${addresses.length} × ${reason} — ${addresses.join(', ')}`); + } + } + } + + return messages; +} + +function successResultsToTable(results: Array): string { + let output = '```\n'; + output += 'Idx | Accepted Email | Message ID\n'; + output += '----|---------------------------------|------------------------------------------\n'; + + results.forEach((result, idx) => { + const accepted = result.accepted && result.accepted.length > 0 ? result.accepted.join(', ') : result.email; + const messageId = result.messageId ?? '-'; + const idxStr = String(idx + 1).padEnd(3); + const acceptedStr = accepted.padEnd(32); + output += `${idxStr} | ${acceptedStr} | ${messageId}\n`; + }); + output += '```'; + return output; +} diff --git a/backend/src/entities/email/email/email.service.ts b/backend/src/entities/email/email/email.service.ts index 083f26e91..16c8e9cb8 100644 --- a/backend/src/entities/email/email/email.service.ts +++ b/backend/src/entities/email/email/email.service.ts @@ -17,9 +17,38 @@ import { EmailGenerator } from './email.generator.js'; import { IMessage } from './email.interface.js'; export interface ICronMessagingResults { + email: string; messageId?: string; accepted?: Array; rejected?: Array; + // Set when the dispatch failed or was suppressed — the Slack cron report + // groups failures by this string. + failureReason?: string; +} + +// Reminder-cron view of one dispatch: either a transporter result or a named +// failure reason. Transactional callers keep the info|null contract instead. +export type EmailDispatchOutcome = + | { ok: true; info: SMTPTransport.SentMessageInfo; deliveryError?: string } + | { ok: false; reason: string }; + +export function mapReminderOutcomeToCronResult(email: string, outcome: EmailDispatchOutcome): ICronMessagingResults { + if (outcome.ok === false) { + return { email, failureReason: outcome.reason }; + } + const { messageId, accepted, rejected } = outcome.info; + const base: ICronMessagingResults = { + email, + messageId: messageId ? messageId : undefined, + accepted: accepted ? accepted : undefined, + rejected: rejected ? rejected : undefined, + }; + // The saas webhook is best-effort: SMTP failure still comes back as 2xx with + // the address in `rejected` — surface it as a failure, not a success row. + if (!accepted || accepted.length === 0) { + base.failureReason = `rejected by saas transporter${outcome.deliveryError ? `: ${outcome.deliveryError}` : ''}`; + } + return base; } // Plan 15 Phase 3: the core composes no letters and transports no email in any mode. @@ -63,27 +92,29 @@ export class EmailService { }); } - public async sendRemindersToUsers(userEmails: Array): Promise> { + public async sendRemindersToUsers(userEmails: Array): Promise> { const queue = new PQueue({ concurrency: 3 }); - const mailingResults: Array = []; + const mailingResults: Array = []; for (const email of userEmails) { try { - const result = await queue.add(async () => { - return await this.dispatchEmail('reminder', email, {}); + const outcome = await queue.add(async () => { + return await this.dispatchEmailWithOutcome('reminder', email, {}); }); - mailingResults.push(result); + mailingResults.push( + mapReminderOutcomeToCronResult(email, outcome ?? { ok: false, reason: 'dispatch returned no outcome' }), + ); } catch (error) { this.logger.error(`Failed to send reminder to ${email}: ${getErrorMessage(error)}`); Sentry.captureException(error); - mailingResults.push(null); + mailingResults.push({ email, failureReason: `dispatch threw: ${getErrorMessage(error)}` }); } } await queue.onIdle(); - return this.buildMailingResults(mailingResults); + return mailingResults; } public async send2faEnabledInCompany( @@ -192,30 +223,45 @@ export class EmailService { // Plan 15 Phase 3 seam — the single gate every outgoing letter passes through: // - test mode -> no-op (unchanged semantics); // - SaaS mode -> saas-side composer webhook, mapped onto a SentMessageInfo-compatible - // object (null on any failure — email never fails the parent operation); + // object (a failure never fails the parent operation); // - self-hosted -> suppressed entirely (plan 15 rev 5: self-hosted sends NOTHING, ever). + // Transactional callers use this null-contract wrapper; the reminder cron uses + // dispatchEmailWithOutcome so the failure reason survives into its Slack report. private async dispatchEmail( type: string, to: string, params: Record, ): Promise { + const outcome = await this.dispatchEmailWithOutcome(type, to, params); + return outcome.ok ? outcome.info : null; + } + + private async dispatchEmailWithOutcome( + type: string, + to: string, + params: Record, + ): Promise { if (isTest()) { - return null; + return { ok: false, reason: 'suppressed: test env' }; } - if (isSaaS()) { - const webhookResult = await this.saasEmailGatewayService.sendEmail(type, to, params); - if (!webhookResult) { - return null; - } - const sentLike: Pick = { - messageId: webhookResult.messageId ?? '', - accepted: webhookResult.accepted ?? [], - rejected: webhookResult.rejected ?? [], - }; - return sentLike as SMTPTransport.SentMessageInfo; + if (!isSaaS()) { + this.logger.debug(`email suppressed (self-hosted): ${type}`); + return { ok: false, reason: 'suppressed: not SaaS' }; } - this.logger.debug(`email suppressed (self-hosted): ${type}`); - return null; + const webhookOutcome = await this.saasEmailGatewayService.sendEmail(type, to, params); + if (webhookOutcome.ok === false) { + return { ok: false, reason: webhookOutcome.reason }; + } + const sentLike: Pick = { + messageId: webhookOutcome.result.messageId ?? '', + accepted: webhookOutcome.result.accepted, + rejected: webhookOutcome.result.rejected, + }; + return { + ok: true, + info: sentLike as SMTPTransport.SentMessageInfo, + deliveryError: webhookOutcome.result.deliveryError, + }; } // Dead code since plan 15 Phase 3 (kept for Phase 7 deletion). @@ -234,20 +280,4 @@ export class EmailService { } }); } - - private buildMailingResults( - results: Array, - ): Array { - return results.map((result) => { - if (!result) { - return null; - } - const { messageId, accepted, rejected } = result; - return { - messageId: messageId ? messageId : undefined, - accepted: accepted ? accepted : undefined, - rejected: rejected ? rejected : undefined, - }; - }); - } } diff --git a/backend/src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.ts b/backend/src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.ts index 2c17acb6d..41bc0a680 100644 --- a/backend/src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.ts +++ b/backend/src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; import { WinstonLogger } from '../../../entities/logging/winston-logger.js'; import { isSaaS } from '../../../helpers/app/is-saas.js'; import { getErrorMessage } from '../../../helpers/get-error-message.js'; @@ -7,10 +8,18 @@ import { generateSaaSJwt } from './utils/generate-saas-jwt.js'; export type SentEmailWebhookResultDs = { messageId?: string; - accepted?: Array; - rejected?: Array; + accepted: Array; + rejected: Array; + // Delivery error detail the saas transporter reported alongside a 2xx + // best-effort response (rejected letters still come back as 201). + deliveryError?: string; }; +// The webhook call either produced a delivery result or failed with a named +// reason ('http 404', 'fetch failed: …', 'timed out after 4000ms', …) — the +// reason is what the cron report surfaces instead of a bare null. +export type EmailWebhookOutcome = { ok: true; result: SentEmailWebhookResultDs } | { ok: false; reason: string }; + // Plan 15 Phase 3: the core no longer composes or transports letters — it fires the // saas-side composer webhook (POST /webhook/email/send) with the letter type and params. @Injectable() @@ -21,14 +30,11 @@ export class SaasEmailGatewayService { constructor(private readonly logger: WinstonLogger) {} // An email failure must never fail the parent operation: ANY failure (non-2xx status, - // network error, timeout) is logged as a warning and swallowed — the caller gets null. - public async sendEmail( - type: string, - to: string, - params: Record, - ): Promise { + // network error, timeout) is logged and swallowed — the caller gets a named + // { ok: false, reason } instead of an exception. + public async sendEmail(type: string, to: string, params: Record): Promise { if (!isSaaS()) { - return null; + return { ok: false, reason: 'suppressed: not SaaS' }; } try { const jwtToken = generateSaaSJwt(); @@ -42,19 +48,36 @@ export class SaasEmailGatewayService { signal: AbortSignal.timeout(SaasEmailGatewayService.REQUEST_TIMEOUT_MS), }); if (res.status > 299) { - this.logger.warn(`Email webhook rejected "${type}" letter to "${to}": status ${res.status}`); - return null; + const body = await this.bodyToJSON(res); + const bodyMessage = typeof body.message === 'string' ? `: ${body.message}` : ''; + const reason = `http ${res.status}${bodyMessage}`; + this.logger.warn(`Email webhook rejected "${type}" letter to "${to}": ${reason}`); + Sentry.captureMessage(`Email webhook rejected "${type}" letter: ${reason}`); + return { ok: false, reason }; } const body = await this.bodyToJSON(res); return { - messageId: typeof body.messageId === 'string' ? body.messageId : undefined, - accepted: Array.isArray(body.accepted) ? (body.accepted as Array) : [], - rejected: Array.isArray(body.rejected) ? (body.rejected as Array) : [], + ok: true, + result: { + messageId: typeof body.messageId === 'string' ? body.messageId : undefined, + accepted: Array.isArray(body.accepted) ? (body.accepted as Array) : [], + rejected: Array.isArray(body.rejected) ? (body.rejected as Array) : [], + deliveryError: typeof body.error === 'string' ? body.error : undefined, + }, }; } catch (error) { - this.logger.warn(`Email webhook dispatch of "${type}" letter to "${to}" failed: ${getErrorMessage(error)}`); - return null; + const reason = this.describeFetchError(error); + this.logger.warn(`Email webhook dispatch of "${type}" letter to "${to}" failed: ${reason}`); + Sentry.captureException(error); + return { ok: false, reason }; + } + } + + private describeFetchError(error: unknown): string { + if (error instanceof Error && error.name === 'TimeoutError') { + return `timed out after ${SaasEmailGatewayService.REQUEST_TIMEOUT_MS}ms`; } + return `fetch failed: ${getErrorMessage(error)}`; } private async bodyToJSON(res: Response): Promise> { diff --git a/backend/test/ava-tests/unit-tests/email-cron-report.test.ts b/backend/test/ava-tests/unit-tests/email-cron-report.test.ts new file mode 100644 index 000000000..d14cb7983 --- /dev/null +++ b/backend/test/ava-tests/unit-tests/email-cron-report.test.ts @@ -0,0 +1,77 @@ +import test from 'ava'; +import { buildEmailCronReportMessages } from '../../../src/entities/cron-jobs/utils/email-cron-report.js'; +import { ICronMessagingResults } from '../../../src/entities/email/email/email.service.js'; + +function success(email: string, messageId = ``): ICronMessagingResults { + return { email, messageId, accepted: [email], rejected: [] }; +} + +function failure(email: string, failureReason: string): ICronMessagingResults { + return { email, failureReason }; +} + +test('all successes -> one table message, no failure lines, no banner', (t) => { + const messages = buildEmailCronReportMessages([success('a@x.com'), success('b@y.com')]); + t.is(messages.length, 1); + t.true(messages[0].includes('a@x.com')); + t.true(messages[0].includes('')); + t.false(messages.some((m) => m.includes('Failed to send'))); + t.false(messages.some((m) => m.includes('seam appears down'))); +}); + +test('successes are chunked into tables of 20', (t) => { + const results = Array.from({ length: 45 }, (_, i) => success(`user${i}@x.com`)); + const messages = buildEmailCronReportMessages(results); + const tables = messages.filter((m) => m.startsWith('```')); + t.is(tables.length, 3); +}); + +test('failures are grouped by reason with matching count and address list', (t) => { + const messages = buildEmailCronReportMessages([ + success('ok@x.com'), + failure('a@x.com', 'http 404'), + failure('b@x.com', 'http 404'), + failure('c@x.com', 'fetch failed: ECONNREFUSED'), + ]); + t.true(messages.some((m) => m === 'Failed to send 3 of 4 emails:')); + const notFoundLine = messages.find((m) => m.startsWith('2 × http 404')); + t.truthy(notFoundLine); + t.true((notFoundLine as string).includes('a@x.com, b@x.com')); + t.true(messages.some((m) => m === '1 × fetch failed: ECONNREFUSED — c@x.com')); + // a mixed run with successes present must not raise the seam-down banner + t.false(messages.some((m) => m.includes('seam appears down'))); +}); + +test('all failed with a single reason -> seam-down banner leads the report', (t) => { + const messages = buildEmailCronReportMessages([ + failure('a@x.com', 'suppressed: not SaaS'), + failure('b@x.com', 'suppressed: not SaaS'), + ]); + t.true(messages[0].includes('email delivery seam appears down (suppressed: not SaaS)')); + t.true(messages[0].includes('transactional email is likely affected too')); + t.true(messages.some((m) => m === 'Failed to send 2 of 2 emails:')); +}); + +test('all failed with mixed reasons -> no banner', (t) => { + const messages = buildEmailCronReportMessages([ + failure('a@x.com', 'http 404'), + failure('b@x.com', 'timed out after 4000ms'), + ]); + t.false(messages.some((m) => m.includes('seam appears down'))); + t.true(messages.some((m) => m === 'Failed to send 2 of 2 emails:')); +}); + +test('failure groups larger than 100 addresses are chunked', (t) => { + const results = Array.from({ length: 120 }, (_, i) => failure(`user${i}@x.com`, 'http 401')); + const messages = buildEmailCronReportMessages(results); + const chunk1 = messages.find((m) => m.startsWith('120 × http 401 (chunk 1):')); + const chunk2 = messages.find((m) => m.startsWith('120 × http 401 (chunk 2):')); + t.truthy(chunk1); + t.truthy(chunk2); + t.is((chunk1 as string).split('@x.com').length - 1, 100); + t.is((chunk2 as string).split('@x.com').length - 1, 20); +}); + +test('empty input -> no messages', (t) => { + t.deepEqual(buildEmailCronReportMessages([]), []); +}); diff --git a/backend/test/ava-tests/unit-tests/email-dispatch-outcome.test.ts b/backend/test/ava-tests/unit-tests/email-dispatch-outcome.test.ts new file mode 100644 index 000000000..ca0c43473 --- /dev/null +++ b/backend/test/ava-tests/unit-tests/email-dispatch-outcome.test.ts @@ -0,0 +1,59 @@ +import test from 'ava'; +import SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'; +import { + EmailDispatchOutcome, + EmailService, + mapReminderOutcomeToCronResult, +} from '../../../src/entities/email/email/email.service.js'; +import { EmailTransporterService } from '../../../src/entities/email/transporter/email-transporter-service.js'; +import { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; +import { SaasEmailGatewayService } from '../../../src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.js'; + +function sentInfo(accepted: Array, rejected: Array = []): SMTPTransport.SentMessageInfo { + return { messageId: '', accepted, rejected } as SMTPTransport.SentMessageInfo; +} + +test('failed outcome -> result carries the reason', (t) => { + const outcome: EmailDispatchOutcome = { ok: false, reason: 'http 404' }; + t.deepEqual(mapReminderOutcomeToCronResult('a@x.com', outcome), { + email: 'a@x.com', + failureReason: 'http 404', + }); +}); + +test('accepted delivery -> success row without failureReason', (t) => { + const outcome: EmailDispatchOutcome = { ok: true, info: sentInfo(['a@x.com']) }; + const result = mapReminderOutcomeToCronResult('a@x.com', outcome); + t.is(result.failureReason, undefined); + t.is(result.messageId, ''); + t.deepEqual(result.accepted, ['a@x.com']); +}); + +test('saas best-effort rejection (empty accepted) -> failure, not a success row', (t) => { + const outcome: EmailDispatchOutcome = { ok: true, info: sentInfo([], ['a@x.com']) }; + const result = mapReminderOutcomeToCronResult('a@x.com', outcome); + t.is(result.failureReason, 'rejected by saas transporter'); +}); + +test('saas rejection with delivery error detail -> detail appended to the reason', (t) => { + const outcome: EmailDispatchOutcome = { + ok: true, + info: sentInfo([], ['a@x.com']), + deliveryError: 'smtp auth failed', + }; + const result = mapReminderOutcomeToCronResult('a@x.com', outcome); + t.is(result.failureReason, 'rejected by saas transporter: smtp auth failed'); +}); + +test('sendRemindersToUsers in test env -> every result names the suppression', async (t) => { + const emailService = new EmailService( + {} as unknown as EmailTransporterService, + {} as unknown as SaasEmailGatewayService, + { error: () => {}, debug: () => {} } as unknown as WinstonLogger, + ); + const results = await emailService.sendRemindersToUsers(['a@x.com', 'b@y.com']); + t.deepEqual(results, [ + { email: 'a@x.com', failureReason: 'suppressed: test env' }, + { email: 'b@y.com', failureReason: 'suppressed: test env' }, + ]); +}); diff --git a/backend/test/ava-tests/unit-tests/saas-email-gateway.test.ts b/backend/test/ava-tests/unit-tests/saas-email-gateway.test.ts new file mode 100644 index 000000000..5841bd278 --- /dev/null +++ b/backend/test/ava-tests/unit-tests/saas-email-gateway.test.ts @@ -0,0 +1,117 @@ +import test from 'ava'; +import type { WinstonLogger } from '../../../src/entities/logging/winston-logger.js'; + +// The gateway reads IS_SAAS / MICROSERVICE_JWT_SECRET / SAAS_URL through +// appConfig — set them before the module (and its appConfig import) loads. +process.env.IS_SAAS = '1'; +process.env.MICROSERVICE_JWT_SECRET = 'unit-test-secret'; +process.env.SAAS_URL = 'http://saas.unit.test'; + +const { SaasEmailGatewayService } = await import( + '../../../src/microservices/gateways/saas-gateway.ts/saas-email-gateway.service.js' +); + +const loggerStub = { warn: () => {}, debug: () => {} } as unknown as WinstonLogger; +const realFetch = globalThis.fetch; + +function stubFetch(impl: () => Promise): void { + globalThis.fetch = impl as unknown as typeof fetch; +} + +test.afterEach.always(() => { + globalThis.fetch = realFetch; + process.env.IS_SAAS = '1'; +}); + +// env + global fetch are mutated per test -> serial only +test.serial('2xx with delivery result -> ok outcome with mapped fields', async (t) => { + stubFetch( + async () => + new Response(JSON.stringify({ messageId: '', accepted: ['a@x.com'], rejected: [] }), { status: 201 }), + ); + const gateway = new SaasEmailGatewayService(loggerStub); + const outcome = await gateway.sendEmail('reminder', 'a@x.com', {}); + t.deepEqual(outcome, { + ok: true, + result: { messageId: '', accepted: ['a@x.com'], rejected: [], deliveryError: undefined }, + }); +}); + +test.serial('2xx best-effort rejection -> ok outcome carrying the saas delivery error', async (t) => { + stubFetch( + async () => + new Response(JSON.stringify({ messageId: null, accepted: [], rejected: ['a@x.com'], error: 'smtp down' }), { + status: 201, + }), + ); + const gateway = new SaasEmailGatewayService(loggerStub); + const outcome = await gateway.sendEmail('reminder', 'a@x.com', {}); + t.true(outcome.ok); + if (outcome.ok) { + t.deepEqual(outcome.result.rejected, ['a@x.com']); + t.is(outcome.result.deliveryError, 'smtp down'); + } +}); + +test.serial('2xx with non-JSON body -> ok outcome with empty arrays', async (t) => { + stubFetch(async () => new Response('not json', { status: 201 })); + const gateway = new SaasEmailGatewayService(loggerStub); + const outcome = await gateway.sendEmail('reminder', 'a@x.com', {}); + t.deepEqual(outcome, { + ok: true, + result: { messageId: undefined, accepted: [], rejected: [], deliveryError: undefined }, + }); +}); + +test.serial('non-2xx with JSON message -> http status and body message in the reason', async (t) => { + stubFetch( + async () => + new Response(JSON.stringify({ message: 'Missing email param "link" for email type "reminder"' }), { + status: 400, + }), + ); + const gateway = new SaasEmailGatewayService(loggerStub); + const outcome = await gateway.sendEmail('reminder', 'a@x.com', {}); + t.deepEqual(outcome, { ok: false, reason: 'http 400: Missing email param "link" for email type "reminder"' }); +}); + +test.serial('non-2xx without body -> bare http status reason', async (t) => { + stubFetch(async () => new Response(null, { status: 401 })); + const gateway = new SaasEmailGatewayService(loggerStub); + const outcome = await gateway.sendEmail('reminder', 'a@x.com', {}); + t.deepEqual(outcome, { ok: false, reason: 'http 401' }); +}); + +test.serial('network error -> "fetch failed" reason', async (t) => { + stubFetch(async () => { + throw new TypeError('fetch failed'); + }); + const gateway = new SaasEmailGatewayService(loggerStub); + const outcome = await gateway.sendEmail('reminder', 'a@x.com', {}); + t.false(outcome.ok); + if (outcome.ok === false) { + t.true(outcome.reason.startsWith('fetch failed:')); + } +}); + +test.serial('abort timeout -> named timeout reason', async (t) => { + stubFetch(async () => { + throw Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' }); + }); + const gateway = new SaasEmailGatewayService(loggerStub); + const outcome = await gateway.sendEmail('reminder', 'a@x.com', {}); + t.deepEqual(outcome, { ok: false, reason: 'timed out after 4000ms' }); +}); + +test.serial('IS_SAAS unset -> suppression names itself without any network call', async (t) => { + delete process.env.IS_SAAS; + let fetchCalled = false; + stubFetch(async () => { + fetchCalled = true; + return new Response(null, { status: 201 }); + }); + const gateway = new SaasEmailGatewayService(loggerStub); + const outcome = await gateway.sendEmail('reminder', 'a@x.com', {}); + t.deepEqual(outcome, { ok: false, reason: 'suppressed: not SaaS' }); + t.false(fetchCalled); +});