email service and template using resend

This commit is contained in:
2026-05-18 20:47:05 +07:00
parent f0ce22bbb8
commit bf5c97c442
16 changed files with 1152 additions and 2 deletions
+207
View File
@@ -0,0 +1,207 @@
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",
},
});
}