import { Injectable, Logger, ServiceUnavailableException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { SESv2Client, SendEmailCommand, SendEmailCommandInput, SendEmailCommandOutput, } from "@aws-sdk/client-sesv2"; /** * Outbound mail transport. Amazon SES — the channel the office already uses * for bulk notification, per docs/INSURANCE_FEATURES_SPEC.md §1.3 (the * renewal-notice spec settled on SES for the same reason: established sender * reputation, existing IAM, negligible incremental cost at our volume). * * Mirrors `StorageService` exactly: env-driven config, null client when * unconfigured, `ServiceUnavailableException` on use, never blocks API boot. * When the env vars are missing AND we're in dev/test we fall back to a * console-logging transport so the NotificationsService can be exercised * end-to-end without SES credentials — a missing mail setup in production * still throws, so a real deployment can't accidentally no-op its sends. * * Env: * SES_REGION — required when client is configured * SES_ACCESS_KEY / SES_SECRET_KEY — required * SES_FROM — verified sending identity (e.g. mail@jorgecuadros.com) * SES_FROM_NAME — display name, optional * SES_CONFIGURATION_SET — optional, for bounce/complaint event publishing */ export interface SendArgs { to: string; /** Optional display name; SES will not display it for "to" but we keep it on * the log row so customer-facing audit reads naturally. */ toName?: string; subject: string; /** HTML body. The four notification jobs all produce HTML. */ html: string; /** Optional override of the configured From; rare but useful for the * trust-payment test mail to a different identity. */ from?: string; fromName?: string; /** Marker header kept on every send so a downstream mail-log search for * "X-Tracking: 1" surfaces only this app's outbound traffic. The legacy * PHP sendEmail() always set it; we keep the convention. */ xTracking?: string; } export interface SendResult { /** SES MessageId (or our mock prefix in dev). Stored verbatim on the * notification log row so a SES bounce/complaint webhook can be matched * back to the exact send. */ messageId: string; /** Truncated SES response payload (or empty in dev). 4k cap matches the * notification log column width. */ response: string; } @Injectable() export class MailService { private readonly logger = new Logger(MailService.name); private readonly client: SESv2Client | null; private readonly fromAddress: string | null; private readonly fromName: string; private readonly configurationSet: string | undefined; private readonly devMode: boolean; constructor(config: ConfigService) { const region = config.get("SES_REGION"); const accessKeyId = config.get("SES_ACCESS_KEY"); const secretAccessKey = config.get("SES_SECRET_KEY"); this.fromAddress = config.get("SES_FROM") ?? config.get("MAIL_FROM") ?? null; this.fromName = config.get("SES_FROM_NAME") ?? config.get("MAIL_FROM_NAME") ?? "Information Server"; this.configurationSet = config.get("SES_CONFIGURATION_SET"); // Dev fallback: when nothing is configured, log sends to stdout instead // of throwing. Lets the API boot in a fresh checkout and lets the // notifications UI show "0 sent" meaningfully on `debug=1`. Production // (NODE_ENV !== development) still requires real config. this.devMode = process.env.NODE_ENV !== "production"; if (!region || !accessKeyId || !secretAccessKey || !this.fromAddress) { if (!this.devMode) { this.logger.warn( "SES not configured (SES_REGION / SES_ACCESS_KEY / SES_SECRET_KEY / SES_FROM). " + "Outbound mail will throw ServiceUnavailableException.", ); } this.client = null; return; } this.client = new SESv2Client({ region, credentials: { accessKeyId, secretAccessKey }, }); this.logger.log( `SES mail client configured (region=${region}, from=${this.fromAddress}).`, ); } /** Whether the deployment has a real mail transport. Callers use this to * refuse work up front — a mass-notification job that throws on its * first send half-completes and the log is unrecoverable, so we fail * fast at the controller. */ get available(): boolean { return this.client !== null || this.devMode; } /** True when the underlying transport is the dev console-log fallback. */ get isDevFallback(): boolean { return this.client === null && this.devMode; } private require(): SESv2Client { if (!this.client) { throw new ServiceUnavailableException( "El envío de correo no está configurado.", ); } return this.client; } /** * Send a single HTML email. The dev fallback logs to stdout and returns a * synthetic `dev-` message id; the real transport talks to SES * and returns the SES MessageId. * * Throws `ServiceUnavailableException` when no transport is configured and * we are not in dev — the caller (NotificationsService) catches and records * it on the log row so a failed sweep produces a coherent audit trail * instead of an aborted one. */ async send(args: SendArgs): Promise { const from = `${args.fromName ?? this.fromName} <${ args.from ?? this.fromAddress ?? "" }>`.trim(); if (!this.client) { if (!this.devMode) this.require(); const fakeId = `dev-${Date.now().toString(36)}-${Math.random() .toString(36) .slice(2, 8)}`; this.logger.log( `[dev-mail] to=${args.to} subject="${args.subject}" id=${fakeId} ` + `len=${args.html.length}`, ); return { messageId: fakeId, response: "" }; } const input: SendEmailCommandInput = { FromEmailAddress: from, Destination: { ToAddresses: [args.to] }, Content: { Simple: { Subject: { Data: args.subject, Charset: "UTF-8" }, Body: { Html: { Data: args.html, Charset: "UTF-8" } }, }, }, ...(this.configurationSet ? { ConfigurationSetName: this.configurationSet } : {}), ...(args.xTracking ? { EmailTags: [ { Name: "X-Tracking", Value: args.xTracking }, ], } : {}), }; const out: SendEmailCommandOutput = await this.client.send( new SendEmailCommand(input), ); return { messageId: out.MessageId ?? "", response: JSON.stringify({ MessageId: out.MessageId ?? null }).slice(0, 4096), }; } }