Rilis · 2 menit baca · 21 Oktober 2026

Catat dulu, email kemudian

Saat sebuah form mengirim email, bagian yang rapuh adalah email-nya. Batasi request, tulis ke database, lalu kirim email dan catat hasilnya. Provider yang down tidak boleh membuatmu kehilangan lead.

Robot tukang pos kartun mencap surat di buku besar sambil memasukkan surat lain ke kotak pos, di bawah awan badai di atas kantor pos
1
row yang ditulis sebelum email apa pun dikirim
3
status email di row itu: ok, failed, atau skipped
1
idempotency key per request

01 · Titik gagalnya

Provider email suatu saat pasti down.

Versi form kontak yang menggoda adalah satu langkah: terima form, panggil API email, tampilkan "Terima kasih". Saat API itu timeout, bounce, atau kena kuota, pengunjung tetap melihat pesan yang sopan, dan lead-nya hilang. Tidak ada yang tahu lead itu pernah ada.

02 · Urutannya

Cek, simpan, kirim, lalu catat.

  • Validasi input dan batasi pemanggil sebelum menulis apa pun. Endpoint publik yang mengirim email dari domain kamu bisa menghabiskan kuota dan reputasi pengirim.
  • Tulis request ke database. Kalau ini gagal, beri tahu pengunjung bahwa ia gagal.
  • Kirim email.
  • Tulis kembali bagaimana hasil email-nya.

03 · Di situs ini

Form permintaan di situs ini bekerja persis seperti itu.

Row-nya berakhir dengan salah satu dari tiga status email: ok, failed, atau skipped. skipped untuk development lokal yang tidak punya key email: row-nya tetap masuk, jadi alurnya bisa diuji tanpa mengirim apa pun.

await env.DB.prepare(
  "INSERT INTO requests (id, name, email, reason, locale) VALUES (?, ?, ?, ?, ?)",
).bind(id, name, email, reason, locale).run();

// Tercatat apa pun hasilnya, jadi email yang gagal tidak menghilangkan request
let emailStatus = "skipped";
if (env.RESEND_API_KEY) {
  const ok = await sendEmail(env, { to: env.OWNER, replyTo: email, ...mail,
    idempotencyKey: `request-${id}` });
  emailStatus = ok ? "ok" : "failed";
}
await env.DB.prepare("UPDATE requests SET email_status = ? WHERE id = ?")
  .bind(emailStatus, id).run();

04 · Idempotency key

Percobaan ulang tidak boleh mengirim dua email.

Setiap request mendapat id sebelum hal lain terjadi, dan panggilan email memakai id itu sebagai idempotency key di provider. Kalau panggilan diulang setelah timeout, provider mengirim satu email, bukan dua.

05 · Pola yang sama, tiga kali

Saya terus memakainya karena pola ini terus terbukti benar.

  • Di situs sebuah agensi kreatif, pengunjung meminta credentials deck agensinya. Request-nya dibatasi dulu, lalu disimpan sebagai pending lewat fungsi database, lalu dikirim lewat email dengan PDF terlampir, lalu diubah menjadi sent atau failed. Fungsi database itu ada karena key publik tidak boleh membaca tabelnya kembali, jadi insert lalu select biasa akan gagal.
  • Di situs sebuah brand produk, pesan kontak disimpan bersama log email, dan setiap alamat email maksimal mengirim 3 pesan per jam.
  • Di situs ini, seperti di atas.

06 · Yang belum ada

Belum ada retry otomatis.

Row yang gagal tetap terlihat di log, dan memang itu intinya: tidak ada yang hilang diam-diam. Langkah berikutnya adalah job terjadwal kecil yang mengirim ulang row failed. Saya belum membutuhkannya, jadi belum saya bangun.

Tulisan berikutnyaApakah AGENTS.md mengubah apa yang dibangun coding agent?