Files
setrip/lib/email/send.ts
T

208 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Resend } from "resend";
import { prisma } from "@/lib/prisma";
import { renderEmail, type EmailTemplate } from "@/lib/email/templates";
/**
* Email sender — idempotent, dengan fallback retry queue.
*
* Flow:
* 1. Cek `EmailSent` by `idempotencyKey`. Kalau sudah terkirim, skip (return).
* 2. Render template → `{ subject, html }`.
* 3. Try sync send via Resend.
* 4. Sukses → insert `EmailSent`.
* 5. Gagal → insert `EmailJob` (cron retry).
*
* Caller pattern: `void emailService.send(...)` (fire-and-forget). Service ini
* tidak throw — semua error di-handle internal supaya server action tidak gagal.
*/
interface SendInput {
to: string;
idempotencyKey: string;
template: EmailTemplate;
}
let _resend: Resend | null = null;
function getResend(): Resend | null {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) return null;
if (!_resend) _resend = new Resend(apiKey);
return _resend;
}
function emailFrom(): string {
return process.env.EMAIL_FROM ?? "SeTrip <onboarding@resend.dev>";
}
export const emailService = {
async send(input: SendInput): Promise<void> {
try {
// 1. Idempotency check
const existing = await prisma.emailSent.findUnique({
where: { idempotencyKey: input.idempotencyKey },
select: { id: true },
});
if (existing) return;
// 2. Render
const rendered = renderEmail(input.template);
// 3. Try sync send
const resend = getResend();
if (!resend) {
// Env tidak di-set — enqueue saja supaya tetap ter-log.
await enqueueJob(input, rendered);
console.warn(
"[email] RESEND_API_KEY tidak di-set, email di-queue:",
input.template.template,
input.to
);
return;
}
try {
const result = await resend.emails.send({
from: emailFrom(),
to: input.to,
subject: rendered.subject,
html: rendered.html,
});
if (result.error) {
throw new Error(result.error.message ?? "Resend send failed");
}
// 4. Mark sent
await prisma.emailSent.create({
data: {
idempotencyKey: input.idempotencyKey,
to: input.to,
template: input.template.template,
subject: rendered.subject,
providerMessageId: result.data?.id ?? null,
},
});
} catch (err) {
// 5. Enqueue retry
await enqueueJob(input, rendered);
console.error(
"[email] sync send failed, queued for retry:",
input.template.template,
input.to,
err
);
}
} catch (err) {
// Catch-all — jangan biarkan email error ngerusak action utama.
console.error("[email] unexpected error:", err);
}
},
/**
* Process pending/failed jobs di queue. Dipanggil dari cron handler.
* Max 5 attempts dengan exponential backoff (5min × 2^attempts).
*/
async processQueue(limit = 50): Promise<{
picked: number;
succeeded: number;
failed: number;
}> {
const now = new Date();
const jobs = await prisma.emailJob.findMany({
where: {
status: { in: ["PENDING", "FAILED"] },
attempts: { lt: 5 },
scheduledAt: { lte: now },
},
orderBy: { scheduledAt: "asc" },
take: limit,
});
let succeeded = 0;
let failed = 0;
const resend = getResend();
if (!resend) {
console.warn("[email] processQueue: RESEND_API_KEY tidak di-set, skip");
return { picked: jobs.length, succeeded: 0, failed: 0 };
}
for (const job of jobs) {
// Re-check idempotency — bisa jadi email sudah terkirim oleh sync attempt sejak job di-enqueue.
const alreadySent = await prisma.emailSent.findUnique({
where: { idempotencyKey: job.idempotencyKey },
select: { id: true },
});
if (alreadySent) {
await prisma.emailJob.update({
where: { id: job.id },
data: { status: "SUCCESS", lastAttemptAt: now },
});
succeeded++;
continue;
}
// Mark PROCESSING (best-effort lock)
await prisma.emailJob.update({
where: { id: job.id },
data: { status: "PROCESSING", attempts: job.attempts + 1, lastAttemptAt: now },
});
try {
const result = await resend.emails.send({
from: emailFrom(),
to: job.to,
subject: job.subject,
html: job.html,
});
if (result.error) throw new Error(result.error.message ?? "Resend failed");
await prisma.$transaction([
prisma.emailSent.create({
data: {
idempotencyKey: job.idempotencyKey,
to: job.to,
template: job.template,
subject: job.subject,
providerMessageId: result.data?.id ?? null,
},
}),
prisma.emailJob.update({
where: { id: job.id },
data: { status: "SUCCESS" },
}),
]);
succeeded++;
} catch (err) {
const nextAttempt = job.attempts + 1;
const backoffMin = Math.min(60, 5 * Math.pow(2, nextAttempt - 1));
await prisma.emailJob.update({
where: { id: job.id },
data: {
status: "FAILED",
lastError: err instanceof Error ? err.message : String(err),
scheduledAt: new Date(now.getTime() + backoffMin * 60 * 1000),
},
});
failed++;
}
}
return { picked: jobs.length, succeeded, failed };
},
};
async function enqueueJob(
input: SendInput,
rendered: { subject: string; html: string }
): Promise<void> {
await prisma.emailJob.create({
data: {
idempotencyKey: input.idempotencyKey,
to: input.to,
template: input.template.template,
subject: rendered.subject,
html: rendered.html,
status: "PENDING",
},
});
}