Kirim Email OTP Verifikasi dengan Flask + MailAnvil
Flask itu framework Python yang paling banyak dipakai buat prototipe cepat dan microservice di Indonesia — simpel, ringan, gak maksa struktur. Fintech, auth service, dan SaaS lokal banyak yang jalan di atas Flask.
Tapi ada satu komponen yang bikin developer salah langkah: kirim email OTP verifikasi.
OTP itu jantung keamanan app. Kalau email-nya nyangkut di spam, atau lambat 10 detik, user langsung kabur. Dan jangan lupa: OTP harus aman (di-hash, ada expiry, ada rate limit), bukan cuma "kirim 6 digit random".
Tutorial ini kasih kamu cara paling bersih kirim email OTP dari Flask — pakai MailAnvil REST API, satu service class, tanpa pusing SMTP dan tanpa celah keamanan.
Kenapa MailAnvil?
- Harga Rupiah — gak kena markup kurs USD 12-35%
- Bayar QRIS/GoPay — gak perlu kartu kredit internasional
- REST API simpel — satu endpoint
POST /v1/send - Dokumentasi Bahasa Indonesia — contoh kode dalam konteks lokal
- MCP-native — AI agent kamu bisa kirim email langsung
- Idempotency-Key — aman retry tanpa OTP duplikat
Prasyarat
- Python 3.10+
- Flask 3.x (
pip install flask) - Library
requests(pip install requests) - Akun MailAnvil (daftar gratis di mailanvil.com)
- Domain terverifikasi di MailAnvil
Struktur Project
myapp/
├── app.py # route /request-otp dan /verify-otp
├── email.py # service class MailAnvil + helper OTP
├── requirements.txt
└── .env
Step 1: Simpan API Key di Environment
Jangan hardcode API key. Simpan di env:
# config.py
import os
MAILANVIL_API_KEY = os.environ.get("MAILANVIL_API_KEY", "")
MAILANVIL_API_BASE = "https://api.mailanvil.com/v1"
MAILANVIL_FROM = "Security <[email protected]>"
# .env
export MAILANVIL_API_KEY="re_..."
Step 2: Service Class yang Reusable
Bikin email.py:
import requests
from config import MAILANVIL_API_KEY, MAILANVIL_API_BASE
class MailAnvilError(Exception):
"""Raised when the MailAnvil API returns a non-2xx response."""
def __init__(self, status_code, code, message):
self.status_code = status_code
self.code = code
self.message = message
super().__init__(f"MailAnvil API error {status_code}: {code} — {message}")
class MailAnvilClient:
"""Thin wrapper around the MailAnvil /v1/send endpoint."""
def __init__(self, api_key=MAILANVIL_API_KEY, api_base=MAILANVIL_API_BASE):
self.api_key = api_key
self.api_base = api_base
def send(self, *, to, subject, from_email, html=None, text=None, idempotency_key=None):
if not self.api_key:
raise MailAnvilError(401, "missing_api_key", "MAILANVIL_API_KEY not set")
payload = {
"from": from_email,
"to": to if isinstance(to, list) else [to],
"subject": subject,
}
if html:
payload["html"] = html
if text:
payload["text"] = text
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
resp = requests.post(
f"{self.api_base}/send",
json=payload,
headers=headers,
timeout=15,
)
if resp.status_code != 202:
body = resp.json()
err = body.get("error", {})
raise MailAnvilError(
resp.status_code,
err.get("code", "unknown"),
err.get("message", resp.text),
)
return resp.json()
mailanvil = MailAnvilClient()
Step 3: Generate & Simpan OTP dengan Aman
Jangan simpan OTP plaintext. Hash dengan SHA-256 (salt otomatis via secrets), kasih expiry 5 menit:
# email.py (lanjutan)
import hashlib
import secrets
import time
OTP_TTL_SECONDS = 300 # 5 menit
def generate_otp(length=6) -> str:
"""OTP numerik 6 digit. Pakai `secrets`, bukan `random` — aman secara kriptografis."""
return "".join(str(secrets.randbelow(10)) for _ in range(length))
def hash_otp(otp: str) -> str:
return hashlib.sha256(otp.encode()).hexdigest()
def otp_email(recipient_email, recipient_name, otp):
return {
"to": recipient_email,
"from_email": MAILANVIL_FROM,
"subject": f"{otp} — Kode verifikasi kamu",
"html": f"""<!DOCTYPE html>
<html>
<body style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 40px 20px; background: #000; color: #fff;">
<div style="text-align: center; padding: 40px 0;">
<h1 style="color: #ff801f;">Kode Verifikasi</h1>
<p style="font-size: 16px; color: #aaa;">Hai {recipient_name}, gunakan kode di bawah untuk masuk:</p>
<p style="font-size: 40px; font-weight: 700; letter-spacing: 12px; color: #ff801f; margin: 24px 0;">{otp}</p>
<p style="font-size: 14px; color: #666;">Kode berlaku 5 menit. Jangan bagikan ke siapa pun.</p>
</div>
<p style="font-size: 12px; color: #444; text-align: center; margin-top: 40px;">Kalau kamu tidak minta kode ini, abaikan email ini.</p>
</body>
</html>""",
}
Step 4: Route Request & Verify OTP
app.py — simpan hash OTP di dict sederhana (production: pindah ke Redis/DB), kirim via MailAnvil:
from flask import Flask, jsonify, request
from email import mailanvil, otp_email, generate_otp, hash_otp, MailAnvilError
from email import OTP_TTL_SECONDS
import time
app = Flask(__name__)
# production: ganti dengan Redis (`setex`) — dict ini cuma buat contoh
otp_store = {} # email -> (hash_otp, expires_at)
@app.post("/request-otp")
def request_otp():
body = request.get_json(force=True)
email = body.get("email", "").strip().lower()
name = body.get("name", "")
if not email:
return jsonify({"error": "email required"}), 400
otp = generate_otp()
otp_store[email] = (hash_otp(otp), time.time() + OTP_TTL_SECONDS)
try:
mailanvil.send(
**otp_email(email, name, otp),
idempotency_key=f"otp-{email}-{int(time.time())}", # aman retry
)
except MailAnvilError as e:
# email gagal = jangan kasih OTP baru; log dan kasih tahu user coba lagi
app.logger.error("otp email failed for %s: %s", email, e)
return jsonify({"error": "gagal kirim OTP, coba lagi"}), 502
return jsonify({"status": "sent"}), 200
@app.post("/verify-otp")
def verify_otp():
body = request.get_json(force=True)
email = body.get("email", "").strip().lower()
otp = body.get("otp", "").strip()
stored = otp_store.get(email)
if not stored:
return jsonify({"error": "OTP tidak ditemukan"}), 400
hash_stored, expires_at = stored
if time.time() > expires_at:
del otp_store[email]
return jsonify({"error": "OTP kedaluwarsa"}), 400
if not secrets.compare_digest(hash_stored, hash_otp(otp)):
return jsonify({"error": "OTP salah"}), 400
del otp_store[email] # OTP sekali pakai — hapus setelah dipakai
return jsonify({"status": "verified"}), 200
if __name__ == "__main__":
app.run(debug=True)
Jangan lupa import secrets di app.py — compare_digest itu constant-time, mencegah timing attack.
Production Tips
1. Rate Limit Request OTP
OTP endpoint itu sasaran abuse (biaya email + brute-force). Batasi per email:
# contoh sederhana dengan dict counter (production: Redis + sliding window)
# maksimal 3 request per 10 menit per email
Minimal: kasih jeda 60 detik antar request OTP untuk email yang sama.
2. Hash, Jangan Plaintext
Selalu simpan hash OTP, bukan OTP-nya. Kalau DB bocor, OTP tetap aman. compare_digest untuk verifikasi.
3. Idempotency Key untuk Safe Retry
Kalau request timeout dan kamu retry, idempotency key bikin email yang sama gak terkirim dua kali — user gak bakal dapat dua OTP beda yang bikin bingung.
4. Kirim di Background Thread
Jangan blocking HTTP response:
import threading
def send_async(**kwargs):
threading.Thread(target=mailanvil.send, kwargs=kwargs, daemon=True).start()
Untuk scale-up, ganti dengan Celery + Redis.
Perbandingan dengan Approach Lain
| Approach | Dependency | Blocking | Retry | Harga IDR |
|---|---|---|---|---|
smtplib + Gmail/SMTP |
0 (stdlib) | Ya | Manual | Tergantung SMTP |
| Flask-Mail + provider | 1 dep + akun | Provider-dependent | Manual | USD + kurs |
| Resend SDK | 1 dep | Tidak | Built-in | USD + kurs |
| MailAnvil REST | 1 (requests) |
Tidak | Idempotency key | IDR + QRIS |
requests udah jadi dependency standar hampir semua Flask project — kemungkinan besar udah kepasang.
Kesimpulan
Flask + MailAnvil = kombinasi simpel dan aman buat OTP verification. Kamu dapet:
- ✅ OTP aman — hash SHA-256, expiry 5 menit, constant-time compare, sekali pakai
- ✅ Non-blocking — background thread, response tetep cepet
- ✅ Idempotency key — aman retry tanpa OTP duplikat
- ✅ Harga Rupiah, bayar QRIS — gak pusing kurs USD
- ✅ Dashboard + monitoring — bounce/complaint tracking built-in
Coba sendiri: daftar di mailanvil.com, verifikasi domain, dan jalankan contoh kode di atas dalam 5 menit.
Butuh API key? Daftar early access di mailanvil.com — gratis 500 email/bulan.