← Back to all posts
2026-08-26 · MailAnvil Team

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?

Prasyarat

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.pycompare_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:

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.