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 "; } export const emailService = { async send(input: SendInput): Promise { 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 { await prisma.emailJob.create({ data: { idempotencyKey: input.idempotencyKey, to: input.to, template: input.template.template, subject: rendered.subject, html: rendered.html, status: "PENDING", }, }); }