Merge branch 'massive-email-notification' into master
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m7s

# Conflicts:
#	.env.example
#	apps/api/src/app.module.ts
This commit is contained in:
2026-08-02 02:05:36 -07:00
23 changed files with 2883 additions and 139 deletions
@@ -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);
});
});
-102
View File
@@ -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 -2
View File
@@ -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 {}
+10 -5
View File
@@ -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({