329 lines
9.5 KiB
TypeScript
329 lines
9.5 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,
|
||
html: rendered.html,
|
||
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,
|
||
html: job.html,
|
||
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 };
|
||
},
|
||
|
||
/**
|
||
* Admin "Retry now" untuk satu EmailJob — kirim ulang langsung tanpa
|
||
* menunggu cron. Idempotent: kalau email sudah tercatat terkirim, job
|
||
* ditandai SUCCESS tanpa kirim ulang.
|
||
*/
|
||
async retryJob(jobId: string): Promise<{ ok: boolean; error?: string }> {
|
||
const job = await prisma.emailJob.findUnique({ where: { id: jobId } });
|
||
if (!job) return { ok: false, error: "Email job tidak ditemukan" };
|
||
|
||
const alreadySent = await prisma.emailSent.findUnique({
|
||
where: { idempotencyKey: job.idempotencyKey },
|
||
select: { id: true },
|
||
});
|
||
if (alreadySent) {
|
||
await prisma.emailJob.update({
|
||
where: { id: jobId },
|
||
data: { status: "SUCCESS", lastAttemptAt: new Date() },
|
||
});
|
||
return { ok: true };
|
||
}
|
||
|
||
const resend = getResend();
|
||
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
|
||
|
||
const now = new Date();
|
||
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,
|
||
html: job.html,
|
||
providerMessageId: result.data?.id ?? null,
|
||
},
|
||
}),
|
||
prisma.emailJob.update({
|
||
where: { id: jobId },
|
||
data: {
|
||
status: "SUCCESS",
|
||
attempts: job.attempts + 1,
|
||
lastAttemptAt: now,
|
||
},
|
||
}),
|
||
]);
|
||
return { ok: true };
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : String(err);
|
||
await prisma.emailJob.update({
|
||
where: { id: jobId },
|
||
data: {
|
||
status: "FAILED",
|
||
attempts: job.attempts + 1,
|
||
lastAttemptAt: now,
|
||
lastError: message,
|
||
},
|
||
});
|
||
return { ok: false, error: message };
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Admin "Resend" untuk EmailSent yang sudah pernah terkirim — mis. user
|
||
* lapor tidak menerima. Pakai idempotencyKey turunan supaya tidak bentrok
|
||
* dengan email asli. Butuh `html` tersimpan (row lama tidak bisa di-resend).
|
||
*/
|
||
async resendEmail(
|
||
emailSentId: string
|
||
): Promise<{ ok: boolean; error?: string }> {
|
||
const original = await prisma.emailSent.findUnique({
|
||
where: { id: emailSentId },
|
||
});
|
||
if (!original) return { ok: false, error: "Email tidak ditemukan" };
|
||
if (!original.html) {
|
||
return {
|
||
ok: false,
|
||
error:
|
||
"Body email lama tidak tersimpan — tidak bisa di-resend (dikirim sebelum fitur ini ada).",
|
||
};
|
||
}
|
||
|
||
const resend = getResend();
|
||
if (!resend) return { ok: false, error: "RESEND_API_KEY belum di-set" };
|
||
|
||
try {
|
||
const result = await resend.emails.send({
|
||
from: emailFrom(),
|
||
to: original.to,
|
||
subject: original.subject,
|
||
html: original.html,
|
||
});
|
||
if (result.error) throw new Error(result.error.message ?? "Resend failed");
|
||
await prisma.emailSent.create({
|
||
data: {
|
||
idempotencyKey: `${original.idempotencyKey}#resend-${Date.now()}`,
|
||
to: original.to,
|
||
template: original.template,
|
||
subject: original.subject,
|
||
html: original.html,
|
||
providerMessageId: result.data?.id ?? null,
|
||
},
|
||
});
|
||
return { ok: true };
|
||
} catch (err) {
|
||
return {
|
||
ok: false,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
};
|
||
}
|
||
},
|
||
};
|
||
|
||
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",
|
||
},
|
||
});
|
||
}
|