Mengirim email OTP dan reset password yang andal: idempotency, retry, dan keamanan

Praktik terbaik email OTP, 2FA, dan reset password: masa berlaku, idempotency key, retry yang aman, rate limit, dan contoh kode API.

Tim CapiMailTerbit 3 menit baca
Daftar isi
  1. Prinsip keamanan isi email
  2. Masalah klasik: email terkirim dua kali
  3. Kapan harus retry, kapan tidak
  4. Diterima antrean bukan berarti terkirim
  5. Uji semua skenario tanpa mengirim email sungguhan

Email OTP dan reset password adalah email yang paling sering ditunggu pengguna. Kalau tidak sampai, pengguna tidak bisa masuk. Kalau sampai dua kali dengan kode berbeda, pengguna bingung kode mana yang benar. Panduan ini membahas cara merancang alur pengiriman yang cepat, tidak ganda, dan aman.

Prinsip keamanan isi email

  • Masa berlaku singkat. OTP umumnya berlaku beberapa menit. Tautan reset password sebaiknya berlaku singkat dan hanya bisa dipakai sekali.
  • Simpan hash, bukan nilai asli. Simpan hash dari kode atau token di database, sama seperti password.
  • Batasi percobaan. Batasi jumlah verifikasi gagal per kode dan per akun untuk mencegah tebakan brute force.
  • Jangan bocorkan keberadaan akun. Halaman "lupa password" sebaiknya menampilkan pesan yang sama, baik email terdaftar maupun tidak.
  • Sebutkan konteks. Tulis untuk apa kode itu, berapa lama berlaku, dan apa yang harus dilakukan jika pengguna tidak memintanya.

Masalah klasik: email terkirim dua kali

Bayangkan server Anda memanggil API email, lalu koneksi terputus sebelum respons diterima. Apakah email sudah masuk antrean? Anda tidak tahu. Kalau Anda mengulang tanpa pengaman, pengguna bisa menerima dua email. Untuk OTP, ini berarti dua kode berbeda di inbox yang sama.

Solusinya adalah idempotency key: pengenal unik per tindakan logis yang Anda kirim bersama permintaan. Jika permintaan dengan kunci dan isi yang sama diulang, API mengembalikan pesan yang sudah ada alih-alih mengirim lagi.

Mengirim OTP lewat CapiMail dengan idempotency key per permintaan OTP· ts
const res = await fetch("https://capimail.com/api/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CAPIMAIL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "Keamanan Akun <keamanan@contoh.com>",
    to: [user.email],
    subject: "Kode verifikasi Anda",
    text: `Kode Anda: ${code}. Berlaku 10 menit. Abaikan email ini jika Anda tidak memintanya.`,
    html: `<p>Kode Anda: <strong>${code}</strong></p><p>Berlaku 10 menit.</p>`,
    // Satu kunci per permintaan OTP, BUKAN per percobaan HTTP.
    idempotency_key: `otp-${otpRequest.id}`,
  }),
});

Dengan SDK TypeScript resmi, bentuknya lebih ringkas:

ts
import { CapiMail } from "@capimail/sdk";

const capimail = new CapiMail({ apiKey: process.env.CAPIMAIL_API_KEY! });

await capimail.emails.send({
  from: "keamanan@contoh.com",
  to: [user.email],
  subject: "Atur ulang password Anda",
  html: `<a href="${resetUrl}">Atur ulang password</a>`,
  idempotencyKey: `reset-${resetToken.id}`,
});

Kapan harus retry, kapan tidak

ResponsArtiTindakan
202Email masuk antrean dengan amanSelesai. Simpan message_id.
Timeout atau koneksi putusStatus tidak diketahuiUlangi dengan idempotency key yang sama.
429Melebihi batas kirimTunggu sesuai header Retry-After, lalu ulangi dengan kunci yang sama.
503Pengiriman sementara dijedaUlangi nanti dengan backoff dan kunci yang sama.
422Validasi gagal atau penerima di-suppressJangan ulangi. Perbaiki permintaan atau alamatnya.
401 / 403Kunci, scope, atau domain bermasalahJangan ulangi. Periksa konfigurasi.

Gunakan exponential backoff dengan jitter untuk percobaan ulang, dan batasi jumlah percobaan. Jika pengguna menekan "kirim ulang kode", itu adalah tindakan baru: buat kode baru, batalkan kode lama, dan gunakan idempotency key baru.

Diterima antrean bukan berarti terkirim

Respons 202 dari CapiMail berarti email tersimpan dengan aman di antrean, belum berarti sampai di inbox. Status terkirim yang sebenarnya datang dari event penyedia: delivered, hard_bounced, complained, dan seterusnya. Pantau status ini lewat endpoint GET /api/v1/emails/{message_id} atau webhook status pengiriman.

Uji semua skenario tanpa mengirim email sungguhan

Kunci cm_test_ menjalankan siklus lengkap secara simulasi: message_id nyata dan webhook yang ditandatangani, tanpa mengirim email atau memakai kredit. Hasilnya ditentukan oleh bagian lokal alamat penerima pertama:

text
bounce+otp1@contoh.com     -> hard bounce
complaint+otp1@contoh.com  -> complaint
fail+otp1@contoh.com       -> gagal
apa-saja@contoh.com        -> terkirim

Detail lengkap ada di dokumentasi API.

Pertanyaan yang sering diajukan

Berapa lama masa berlaku OTP email yang ideal?

Umumnya beberapa menit. Cukup lama untuk pengguna membuka inbox, tetapi cukup singkat untuk membatasi risiko jika kode bocor. Sebutkan masa berlakunya di dalam email.

Apa itu idempotency key pada API email?

Idempotency key adalah pengenal unik per tindakan yang dikirim bersama permintaan. Jika permintaan yang sama diulang dengan kunci yang sama, API mengembalikan pesan yang sudah ada sehingga email tidak terkirim dua kali.

Apakah respons 202 berarti email sudah sampai?

Tidak. Respons 202 berarti email masuk antrean dengan aman. Status terkirim ditentukan oleh event penyedia, misalnya delivered atau bounced.

Semua artikel