208 lines
5.9 KiB
TypeScript
208 lines
5.9 KiB
TypeScript
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",
|
||
},
|
||
});
|
||
}
|