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",
},
});
}
+353
View File
@@ -0,0 +1,353 @@
import { siteUrl } from "@/lib/site";
import { formatRupiah } from "@/lib/utils";
/**
* Registry semua template email. Setiap entry punya:
* - param shape (kontrak data yang harus dipassing caller)
* - render function → `{ subject, html }`
*
* Style: plain HTML inline-style untuk kompatibilitas client email
* (Outlook, Gmail mobile, dst). Hindari CSS modern (flexbox, grid, var).
*/
const PRIMARY = "#0d9488"; // primary-600
const NEUTRAL_700 = "#404040";
const NEUTRAL_500 = "#737373";
const RED_600 = "#dc2626";
const AMBER_600 = "#d97706";
const EMERALD_600 = "#059669";
function shell(opts: { title: string; bodyHtml: string; ctaLabel?: string; ctaUrl?: string }): string {
const cta = opts.ctaLabel && opts.ctaUrl
? `<p style="margin: 24px 0;">
<a href="${opts.ctaUrl}" style="display:inline-block;background:${PRIMARY};color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;font-size:14px;">${opts.ctaLabel}</a>
</p>`
: "";
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${opts.title}</title></head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:${NEUTRAL_700};">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f5;padding:24px 0;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;padding:32px;max-width:90%;">
<tr><td>
<p style="margin:0 0 8px;font-size:12px;font-weight:bold;color:${PRIMARY};letter-spacing:1px;">SETRIP</p>
${opts.bodyHtml}
${cta}
<hr style="border:none;border-top:1px solid #e5e5e5;margin:24px 0;">
<p style="margin:0;font-size:11px;color:${NEUTRAL_500};">
Email ini dikirim otomatis dari SeTrip. Kalau ada pertanyaan, balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a>.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
}
// ============================================================================
// KYC verification
// ============================================================================
export interface KycApprovedData {
userName: string;
}
function kycApproved(d: KycApprovedData) {
return {
subject: "✅ Verifikasi organizer SeTrip kamu sudah disetujui",
html: shell({
title: "KYC Approved",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">🎉 Selamat ${d.userName}!</h1>
<p style="margin:0 0 12px;">Pengajuan verifikasi organizer kamu sudah <strong>disetujui</strong>. Mulai sekarang kamu bisa:</p>
<ul style="margin:0 0 16px;padding-left:20px;">
<li>Membuat trip <strong>berbayar</strong></li>
<li>Menerima payout otomatis ke rekening setelah trip selesai</li>
<li>Mendapat badge ✅ Verified Organizer di profil publik</li>
</ul>
`,
ctaLabel: "Buat Trip Sekarang",
ctaUrl: `${siteUrl}/create-trip`,
}),
};
}
export interface KycRejectedData {
userName: string;
rejectionReason: string;
}
function kycRejected(d: KycRejectedData) {
return {
subject: "❌ Pengajuan verifikasi SeTrip kamu ditolak",
html: shell({
title: "KYC Rejected",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Pengajuan ditolak</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, pengajuan verifikasi organizer kamu belum bisa kami setujui.</p>
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Alasan:</p>
<p style="margin:8px 0 0;font-size:14px;">${escapeHtml(d.rejectionReason)}</p>
</div>
<p style="margin:0 0 12px;">Perbaiki data sesuai catatan di atas lalu ajukan ulang lewat halaman verifikasi.</p>
`,
ctaLabel: "Perbaiki & Ajukan Ulang",
ctaUrl: `${siteUrl}/verify`,
}),
};
}
export interface KycReuploadRequestData {
userName: string;
fields: string[];
note: string;
}
function kycReuploadRequest(d: KycReuploadRequestData) {
const fieldList = d.fields
.map((f) => `<li>${escapeHtml(reuploadFieldLabel(f))}</li>`)
.join("");
return {
subject: "🔄 Admin minta kamu upload ulang data verifikasi",
html: shell({
title: "KYC Re-upload Request",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${AMBER_600};">Perlu upload ulang</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, admin meminta kamu mengirim ulang beberapa data verifikasi.</p>
<div style="background:#fffbeb;border-left:4px solid ${AMBER_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Field yang perlu di-upload ulang:</p>
<ul style="margin:8px 0 0;padding-left:20px;font-size:14px;">${fieldList}</ul>
<p style="margin:12px 0 0;font-weight:600;font-size:13px;">Catatan admin:</p>
<p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.note)}</p>
</div>
<p style="margin:0 0 12px;">Buka halaman verifikasi, perbaiki field yang diminta, lalu submit ulang. Banner akan otomatis hilang setelah submit.</p>
`,
ctaLabel: "Submit Ulang",
ctaUrl: `${siteUrl}/verify`,
}),
};
}
function reuploadFieldLabel(field: string): string {
switch (field) {
case "ktpImage":
return "Foto KTP";
case "liveness":
return "Foto liveness (pegang kertas SETRIP)";
case "nik":
return "NIK";
case "bankInfo":
return "Info rekening";
case "address":
return "Alamat";
default:
return field;
}
}
// ============================================================================
// Refund
// ============================================================================
export interface RefundCreatedData {
userName: string;
tripTitle: string;
amount: number;
reason: string;
}
function refundCreated(d: RefundCreatedData) {
return {
subject: `Pengajuan refund untuk "${d.tripTitle}" sudah kami terima`,
html: shell({
title: "Refund Created",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;">Laporan refund diterima</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, kami sudah menerima laporan refund kamu.</p>
<table cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin:16px 0;background:#fafafa;border-radius:8px;">
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;">Trip</td><td style="padding:12px 16px;font-weight:600;">${escapeHtml(d.tripTitle)}</td></tr>
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;border-top:1px solid #e5e5e5;">Nominal refund</td><td style="padding:12px 16px;font-weight:600;border-top:1px solid #e5e5e5;">${formatRupiah(d.amount)}</td></tr>
<tr><td style="padding:12px 16px;color:${NEUTRAL_500};font-size:13px;border-top:1px solid #e5e5e5;">Reason</td><td style="padding:12px 16px;font-weight:600;border-top:1px solid #e5e5e5;">${escapeHtml(d.reason)}</td></tr>
</table>
<p style="margin:0;font-size:14px;color:${NEUTRAL_500};">Admin akan review dalam 13 hari kerja. Kami kabari lagi via email.</p>
`,
}),
};
}
export interface RefundSucceededData {
userName: string;
tripTitle: string;
amount: number;
adminNote: string;
}
function refundSucceeded(d: RefundSucceededData) {
return {
subject: `✅ Refund ${formatRupiah(d.amount)} sudah dikirim`,
html: shell({
title: "Refund Succeeded",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Refund sudah dikirim</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, refund untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah kami transfer.</p>
<p style="margin:0 0 12px;font-size:24px;font-weight:bold;color:${EMERALD_600};">${formatRupiah(d.amount)}</p>
${d.adminNote ? `<div style="background:#f0fdf4;border-left:4px solid ${EMERALD_600};padding:12px 16px;margin:16px 0;"><p style="margin:0;font-weight:600;font-size:13px;">Catatan admin:</p><p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p></div>` : ""}
<p style="margin:0;font-size:14px;">Cek rekening kamu — biasanya masuk dalam 1×24 jam (tergantung bank). Kalau lebih dari 3 hari belum muncul, balas email ini.</p>
`,
}),
};
}
export interface RefundFailedData {
userName: string;
tripTitle: string;
amount: number;
adminNote: string;
}
function refundFailed(d: RefundFailedData) {
return {
subject: `⚠️ Refund untuk "${d.tripTitle}" gagal diproses`,
html: shell({
title: "Refund Failed",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Refund gagal diproses</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, refund kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sebesar <strong>${formatRupiah(d.amount)}</strong> gagal diproses.</p>
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Catatan admin:</p>
<p style="margin:4px 0 0;font-size:14px;">${escapeHtml(d.adminNote)}</p>
</div>
<p style="margin:0;font-size:14px;">Balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a> untuk koordinasi lanjutan. Kami akan bantu proses ulang.</p>
`,
}),
};
}
// ============================================================================
// Payment
// ============================================================================
export interface PaymentPaidData {
userName: string;
tripTitle: string;
tripId: string;
amount: number;
}
function paymentPaid(d: PaymentPaidData) {
return {
subject: `✅ Pembayaran ${formatRupiah(d.amount)} untuk "${d.tripTitle}" terkonfirmasi`,
html: shell({
title: "Payment Confirmed",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Pembayaran terkonfirmasi 🎉</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, pembayaran kamu untuk trip <strong>${escapeHtml(d.tripTitle)}</strong> sudah masuk.</p>
<p style="margin:0 0 12px;font-size:20px;font-weight:bold;">${formatRupiah(d.amount)}</p>
<p style="margin:0 0 12px;font-size:14px;">Sampai jumpa di trip! Detail meeting point + itinerary lengkap bisa dicek di halaman trip.</p>
`,
ctaLabel: "Lihat Detail Trip",
ctaUrl: `${siteUrl}/trips/${d.tripId}`,
}),
};
}
export interface BookingApprovedData {
userName: string;
tripTitle: string;
tripId: string;
amount: number;
}
function bookingApproved(d: BookingApprovedData) {
const isFree = d.amount <= 0;
return {
subject: isFree
? `✅ Kamu disetujui ikut "${d.tripTitle}"`
: `✅ Kamu disetujui ikut "${d.tripTitle}" — siap bayar`,
html: shell({
title: "Booking Approved",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${EMERALD_600};">Disetujui organizer!</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, organizer sudah <strong>menyetujui</strong> kamu untuk ikut trip <strong>${escapeHtml(d.tripTitle)}</strong>.</p>
${
isFree
? `<p style="margin:0 0 12px;font-size:14px;">Trip ini <strong>gratis</strong> — tidak perlu bayar. Cek detail meeting point + itinerary di halaman trip.</p>`
: `<p style="margin:0 0 12px;font-size:14px;">Untuk mengamankan slot, selesaikan pembayaran <strong>${formatRupiah(d.amount)}</strong> via Midtrans (BCA VA, GoPay, QRIS, dll). Dana ditahan SeTrip sampai trip selesai (escrow).</p>`
}
`,
ctaLabel: isFree ? "Lihat Detail Trip" : "Bayar Sekarang",
ctaUrl: isFree
? `${siteUrl}/trips/${d.tripId}`
: `${siteUrl}/trips/${d.tripId}/payment`,
}),
};
}
// ============================================================================
// Account moderation
// ============================================================================
export interface AccountSuspendedData {
userName: string;
reason: string;
}
function accountSuspended(d: AccountSuspendedData) {
return {
subject: "⛔ Akun SeTrip kamu ditangguhkan",
html: shell({
title: "Account Suspended",
bodyHtml: `
<h1 style="margin:0 0 16px;font-size:22px;color:${RED_600};">Akun ditangguhkan</h1>
<p style="margin:0 0 12px;">Halo ${d.userName}, akun SeTrip kamu sedang dalam status <strong>ditangguhkan</strong>. Selama status ini berlaku, kamu tidak bisa login atau melakukan aksi (join trip, bikin trip, dll).</p>
<div style="background:#fef2f2;border-left:4px solid ${RED_600};padding:12px 16px;margin:16px 0;">
<p style="margin:0;font-weight:600;font-size:13px;">Alasan:</p>
<p style="margin:8px 0 0;font-size:14px;">${escapeHtml(d.reason)}</p>
</div>
<p style="margin:0;font-size:14px;">Kalau menurut kamu ini kesalahan atau ingin klarifikasi, balas email ini atau hubungi <a href="mailto:support@setrip.id" style="color:${PRIMARY};">support@setrip.id</a> dengan subject &quot;Banding suspend akun&quot;.</p>
`,
}),
};
}
// ============================================================================
// Registry — discriminated union supaya type-safe per template.
// ============================================================================
export type EmailTemplate =
| { template: "kyc_approved"; data: KycApprovedData }
| { template: "kyc_rejected"; data: KycRejectedData }
| { template: "kyc_reupload_request"; data: KycReuploadRequestData }
| { template: "refund_created"; data: RefundCreatedData }
| { template: "refund_succeeded"; data: RefundSucceededData }
| { template: "refund_failed"; data: RefundFailedData }
| { template: "payment_paid"; data: PaymentPaidData }
| { template: "booking_approved"; data: BookingApprovedData }
| { template: "account_suspended"; data: AccountSuspendedData };
export function renderEmail(input: EmailTemplate): {
subject: string;
html: string;
} {
switch (input.template) {
case "kyc_approved":
return kycApproved(input.data);
case "kyc_rejected":
return kycRejected(input.data);
case "kyc_reupload_request":
return kycReuploadRequest(input.data);
case "refund_created":
return refundCreated(input.data);
case "refund_succeeded":
return refundSucceeded(input.data);
case "refund_failed":
return refundFailed(input.data);
case "payment_paid":
return paymentPaid(input.data);
case "booking_approved":
return bookingApproved(input.data);
case "account_suspended":
return accountSuspended(input.data);
}
}
// Escape HTML supaya tidak ada XSS via data user (mis. tripTitle dari user input).
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}