email service and template using resend
This commit is contained in:
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user