Replaces the four legacy PHP scripts under email.notifications/send*.php with a single NestJS module. Four jobs (outstanding payments, payment confirmations, account-status alerts with day-of-week gates, trust payment confirmations) share one MailService modelled on StorageService: env-driven SES client, null fallback in dev with console logging, refuses to send in production when unconfigured. Schema adds email_notification_log (every attempt, sent/failed/skipped) and account_status_history (one row per threshold hit, Job 3). Enums encode the legacy wire shape so external log scrapers keep parsing notificationType keys verbatim. Web adds /notificaciones with four trigger cards, a flags panel, and a paginated log browser. New notification:send ability gates all four endpoints at MANAGER, matching the renewal:send trust tier.
190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
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<string>("SES_REGION");
|
|
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
|
|
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
|
|
this.fromAddress =
|
|
config.get<string>("SES_FROM") ??
|
|
config.get<string>("MAIL_FROM") ??
|
|
null;
|
|
this.fromName =
|
|
config.get<string>("SES_FROM_NAME") ??
|
|
config.get<string>("MAIL_FROM_NAME") ??
|
|
"Information Server";
|
|
this.configurationSet = config.get<string>("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-<timestamp>` 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<SendResult> {
|
|
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),
|
|
};
|
|
}
|
|
}
|