Merge branch 'massive-email-notification' into master
# Conflicts: # .env.example # apps/api/src/app.module.ts
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { MailService } from "./mail.service";
|
||||
|
||||
/** Global so any feature module can inject MailService without re-importing.
|
||||
* Matches the StorageService pattern: env-driven, null when unconfigured,
|
||||
* and never blocks API boot. Notifications use it; renewals reuse it. */
|
||||
@Module({
|
||||
providers: [{ provide: MailService, useFactory: (c: ConfigService) => new MailService(c) }],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,189 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user