Merge branch 'massive-email-notification' into master
# Conflicts: # .env.example # apps/api/src/app.module.ts
This commit is contained in:
@@ -1,27 +0,0 @@
|
||||
import { ServiceUnavailableException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { MailService } from "./mail.service";
|
||||
|
||||
function config(values: Record<string, string>): ConfigService {
|
||||
return { get: (key: string) => values[key] } as ConfigService;
|
||||
}
|
||||
|
||||
describe("MailService", () => {
|
||||
it("uses a no-op provider when SES is absent in development", async () => {
|
||||
const service = new MailService(config({ NODE_ENV: "development" }));
|
||||
|
||||
expect(service.available).toBe(true);
|
||||
await expect(
|
||||
service.send({ to: "test@example.com", subject: "Test", html: "<p>Test</p>" }),
|
||||
).resolves.toEqual({ providerId: expect.stringMatching(/^dev-/) });
|
||||
});
|
||||
|
||||
it("keeps production bootable but refuses sends when SES is absent", async () => {
|
||||
const service = new MailService(config({ NODE_ENV: "production" }));
|
||||
|
||||
expect(service.available).toBe(false);
|
||||
await expect(
|
||||
service.send({ to: "test@example.com", subject: "Test", html: "<p>Test</p>" }),
|
||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
SendEmailCommand,
|
||||
SESv2Client,
|
||||
} from "@aws-sdk/client-sesv2";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface MailMessage {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface MailProvider {
|
||||
send(message: MailMessage): Promise<{ providerId: string }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MailService implements MailProvider {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
private readonly client: SESv2Client | null;
|
||||
private readonly from: string | null;
|
||||
private readonly configurationSet: string | undefined;
|
||||
private readonly developmentNoop: boolean;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const region = config.get<string>("SES_REGION");
|
||||
const from = config.get<string>("SES_FROM");
|
||||
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
|
||||
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
|
||||
this.configurationSet = config.get<string>("SES_CONFIGURATION_SET") || undefined;
|
||||
this.developmentNoop =
|
||||
config.get<string>("NODE_ENV") !== "production" &&
|
||||
!region &&
|
||||
!from &&
|
||||
!accessKeyId &&
|
||||
!secretAccessKey;
|
||||
|
||||
if (this.developmentNoop) {
|
||||
this.logger.warn("SES no configurado; los correos se registrarán sin enviarse.");
|
||||
this.client = null;
|
||||
this.from = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!region || !from || !accessKeyId || !secretAccessKey) {
|
||||
this.logger.warn("SES no configurado; las notificaciones por correo están deshabilitadas.");
|
||||
this.client = null;
|
||||
this.from = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new SESv2Client({
|
||||
region,
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
});
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
get available(): boolean {
|
||||
return this.developmentNoop || (this.client !== null && this.from !== null);
|
||||
}
|
||||
|
||||
async send(message: MailMessage): Promise<{ providerId: string }> {
|
||||
if (this.developmentNoop) {
|
||||
const providerId = `dev-${randomUUID()}`;
|
||||
this.logger.log(`Correo de renovación simulado (${providerId}).`);
|
||||
return { providerId };
|
||||
}
|
||||
|
||||
if (!this.client || !this.from) {
|
||||
throw new ServiceUnavailableException(
|
||||
"El servicio de correo no está configurado.",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.client.send(
|
||||
new SendEmailCommand({
|
||||
FromEmailAddress: this.from,
|
||||
Destination: { ToAddresses: [message.to] },
|
||||
Content: {
|
||||
Simple: {
|
||||
Subject: { Data: message.subject, Charset: "UTF-8" },
|
||||
Body: { Html: { Data: message.html, Charset: "UTF-8" } },
|
||||
},
|
||||
},
|
||||
ConfigurationSetName: this.configurationSet,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!result.MessageId) {
|
||||
throw new Error("SES no devolvió identificador de mensaje.");
|
||||
}
|
||||
|
||||
return { providerId: result.MessageId };
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RenewalsController } from "./renewals.controller";
|
||||
import { MailService } from "./mail.service";
|
||||
import { RenewalsService } from "./renewals.service";
|
||||
|
||||
@Module({
|
||||
controllers: [RenewalsController],
|
||||
providers: [MailService, RenewalsService],
|
||||
providers: [RenewalsService],
|
||||
})
|
||||
export class RenewalsModule {}
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { Cron } from "@nestjs/schedule";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import { MailService } from "../mail/mail.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import {
|
||||
renewalLetterSelect,
|
||||
toRenewalLetterRow,
|
||||
} from "../reports/renewal-letter";
|
||||
import { MailService } from "./mail.service";
|
||||
import { renderRenewalEmail } from "./renewal-email";
|
||||
|
||||
export const RENEWAL_CADENCE = [
|
||||
@@ -135,7 +135,12 @@ export class RenewalsService {
|
||||
try {
|
||||
const letter = toRenewalLetterRow(policy, cadence.generation);
|
||||
const message = renderRenewalEmail(letter);
|
||||
const result = await this.mail.send({ to, ...message });
|
||||
const result = await this.mail.send({
|
||||
to,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
xTracking: "renewals",
|
||||
});
|
||||
const sentAt = new Date();
|
||||
|
||||
await this.prisma.renewalNotice.upsert({
|
||||
@@ -151,20 +156,20 @@ export class RenewalsService {
|
||||
channel: "EMAIL",
|
||||
sentAt,
|
||||
sentById: userId,
|
||||
providerMessageId: result.providerId,
|
||||
providerMessageId: result.messageId,
|
||||
},
|
||||
update: {
|
||||
channel: "EMAIL",
|
||||
sentAt,
|
||||
sentById: userId,
|
||||
providerMessageId: result.providerId,
|
||||
providerMessageId: result.messageId,
|
||||
},
|
||||
});
|
||||
sent++;
|
||||
void this.audit.log(userId, "renewalNotice.send", {
|
||||
policyId: policy.id,
|
||||
generation: cadence.generation,
|
||||
providerMessageId: result.providerId,
|
||||
providerMessageId: result.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
|
||||
Reference in New Issue
Block a user