← Back to all posts
2026-09-16 · MailAnvil Team

Send Transactional Emails from React Native Apps with MailAnvil

React Native dominates Indonesia's mobile app scene. From Gojek-style super apps to fintech e-wallets, every app needs transactional emails: OTP verification, password resets, payment receipts. The challenge? React Native runs on the client — you can't call SES directly from the app.

The solution: a thin Express.js backend that receives webhooks from your React Native app and forwards them to MailAnvil's email API. Three lines of backend code, zero email infrastructure to manage.

This tutorial walks through building a complete email notification system for a React Native app — covering OTP verification, password reset, and order confirmation flows.

Prerequisites

Architecture

React Native App → Express.js API → MailAnvil API → SES → Inbox
     ↑ (fetch)         ↑ (POST)         ↑ (queue)     ↑ (deliver)

The React Native app never touches email credentials. It calls your Express.js API, which authenticates with MailAnvil using an API key stored server-side. This keeps credentials secure and gives you full control over rate limiting and validation.

Step 1: Backend Setup

Create an Express.js API that proxies email requests to MailAnvil.

mkdir mailanvil-rn-backend && cd mailanvil-rn-backend
npm init -y && npm install express cors
// server.js
const express = require('express');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(express.json());

const MAILANVIL_API = 'https://api.mailanvil.com/v1';
const MAILANVIL_KEY = process.env.MAILANVIL_KEY;

async function sendEmail({ to, subject, html, text }) {
  const res = await fetch(`${MAILANVIL_API}/send`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${MAILANVIL_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: '[email protected]',
      to: [to],
      subject,
      html,
      text,
    }),
  });
  return res.json();
}

// OTP verification
app.post('/api/send-otp', async (req, res) => {
  const { email, code } = req.body;
  const html = `
    <div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
      <h2 style="color: #ff801f;">Verifikasi Akun Anda</h2>
      <p>Kode OTP Anda:</p>
      <div style="background: #f4f4f4; padding: 16px; text-align: center; font-size: 24px; letter-spacing: 8px; font-weight: bold;">
        ${code}
      </div>
      <p style="color: #666; font-size: 14px;">Kode ini berlaku selama 10 menit. Jangan bagikan kode ini kepada siapapun.</p>
    </div>
  `;
  const result = await sendEmail({
    to: email,
    subject: `Kode Verifikasi: ${code}`,
    html,
    text: `Kode OTP Anda: ${code}. Berlaku 10 menit.`,
  });
  res.json(result);
});

// Password reset
app.post('/api/send-reset', async (req, res) => {
  const { email, resetUrl } = req.body;
  const html = `
    <div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
      <h2 style="color: #ff801f;">Reset Password</h2>
      <p>Klik tombol di bawah untuk reset password Anda:</p>
      <a href="${resetUrl}" style="display: inline-block; background: #ff801f; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">
        Reset Password
      </a>
      <p style="color: #666; font-size: 14px;">Link berlaku selama 1 jam. Jika Anda tidak meminta reset, abaikan email ini.</p>
    </div>
  `;
  const result = await sendEmail({
    to: email,
    subject: 'Reset Password Anda',
    html,
    text: `Reset password: ${resetUrl}`,
  });
  res.json(result);
});

// Order confirmation
app.post('/api/send-order-confirmation', async (req, res) => {
  const { email, orderId, items, total } = req.body;
  const itemRows = items.map(i => `
    <tr>
      <td style="padding: 8px; border-bottom: 1px solid #eee;">${i.name}</td>
      <td style="padding: 8px; border-bottom: 1px solid #eee; text-align: right;">Rp ${i.price.toLocaleString('id-ID')}</td>
    </tr>
  `).join('');

  const html = `
    <div style="font-family: sans-serif; max-width: 500px; margin: 0 auto;">
      <h2 style="color: #ff801f;">Pesanan Diterima ✓</h2>
      <p>Order <strong>#${orderId}</strong></p>
      <table style="width: 100%; border-collapse: collapse;">
        ${itemRows}
        <tr>
          <td style="padding: 12px 8px; font-weight: bold;">Total</td>
          <td style="padding: 12px 8px; text-align: right; font-weight: bold; color: #ff801f;">Rp ${total.toLocaleString('id-ID')}</td>
        </tr>
      </table>
      <p style="color: #666; font-size: 14px;">Pesanan Anda sedang diproses. Terima kasih!</p>
    </div>
  `;
  const result = await sendEmail({
    to: email,
    subject: `Pesanan #${orderId} Diterima`,
    html,
    text: `Pesanan #${orderId} diterima. Total: Rp ${total.toLocaleString('id-ID')}`,
  });
  res.json(result);
});

app.listen(3000, () => console.log('API running on :3000'));

Step 2: React Native Integration

// api/email.js
const API_BASE = 'https://your-api.com'; // your Express.js backend

export async function sendOtp(email, code) {
  const res = await fetch(`${API_BASE}/api/send-otp`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, code }),
  });
  return res.json();
}

export async function sendPasswordReset(email, resetUrl) {
  const res = await fetch(`${API_BASE}/api/send-reset`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, resetUrl }),
  });
  return res.json();
}

export async function sendOrderConfirmation(email, order) {
  const res = await fetch(`${API_BASE}/api/send-order-confirmation`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email,
      orderId: order.id,
      items: order.items,
      total: order.total,
    }),
  });
  return res.json();
}
// screens/RegisterScreen.js
import React, { useState } from 'react';
import { View, TextInput, Button, Alert } from 'react-native';
import { sendOtp } from '../api/email';

export default function RegisterScreen() {
  const [email, setEmail] = useState('');
  const [loading, setLoading] = useState(false);

  const handleRegister = async () => {
    setLoading(true);
    const code = Math.floor(100000 + Math.random() * 900000).toString();
    const result = await sendOtp(email, code);
    setLoading(false);

    if (result.messageId) {
      Alert.alert('Berhasil', 'Kode OTP telah dikirim ke email Anda');
    } else {
      Alert.alert('Gagal', 'Terjadi kesalahan, coba lagi');
    }
  };

  return (
    <View style={{ padding: 20 }}>
      <TextInput
        placeholder="Email"
        value={email}
        onChangeText={setEmail}
        keyboardType="email-address"
        autoCapitalize="none"
      />
      <Button
        title={loading ? 'Mengirim...' : 'Daftar'}
        onPress={handleRegister}
        disabled={loading}
      />
    </View>
  );
}

Step 3: Production Hardening

Three things to add before going live:

1. Rate limiting — prevent abuse on your backend:

// Add to server.js
const rateLimit = new Map();

app.post('/api/send-otp', (req, res, next) => {
  const { email } = req.body;
  const now = Date.now();
  const last = rateLimit.get(email) || 0;
  if (now - last < 60000) { // 1 per minute
    return res.status(429).json({ error: 'Too many requests' });
  }
  rateLimit.set(email, now);
  next();
}, async (req, res) => { /* handler */ });

2. Input validation — never trust client data:

app.post('/api/send-otp', async (req, res) => {
  const { email, code } = req.body;
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return res.status(400).json({ error: 'Invalid email' });
  }
  if (!code || code.length !== 6) {
    return res.status(400).json({ error: 'Invalid code' });
  }
  // ... send email
});

3. Webhooks for delivery tracking — know if emails actually arrive:

// Add webhook endpoint
app.post('/api/webhooks/mailanvil', express.raw({ type: 'application/json' }), (req, res) => {
  const crypto = require('crypto');
  const signature = req.headers['x-signature'];
  const timestamp = req.headers['x-timestamp'];

  // Verify HMAC signature (INV-7)
  const expected = crypto
    .createHmac('sha256', process.env.MAILANVIL_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body}`)
    .digest('hex');

  if (signature !== expected) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(req.body);
  console.log(`Email ${event.email_id}: ${event.status}`);

  // Update your database: delivered, bounced, complained
  res.json({ received: true });
});

Register the webhook in MailAnvil dashboard or via API:

curl -X POST https://api.mailanvil.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://your-api.com/api/webhooks/mailanvil"}'

Indonesian Use Cases

Fintech OTP — Apps like OVO, DANA, and ShopeePay need reliable OTP delivery. MailAnvil's edge infrastructure (Cloudflare Workers) routes OTP emails through the nearest SES region, keeping latency under 200ms for Indonesian recipients. The Rp 149rb/month Starter plan covers 10,000 OTPs — enough for most fintech apps.

E-commerce order confirmations — Tokopedia-style marketplaces send thousands of order confirmations daily. MailAnvil's queue-based architecture handles burst traffic without throttling. The kill switch (INV-5) automatically pauses sending if bounce rates exceed 5%, protecting your domain reputation.

SaaS welcome sequences — Indonesia's growing SaaS ecosystem (Mekari, HashMicro, Paper.id) uses transactional emails for onboarding flows. MailAnvil's template API lets you manage email templates from code, not a dashboard — version-controlled and testable.

Why MailAnvil for React Native Apps

Most email APIs charge in USD. At Rp 16,000/USD, a $40/month Growth plan (100K emails) costs Rp 640,000. MailAnvil's Growth plan: Rp 599,000 — with QRIS and GoPay payment options. No credit card needed for Indonesian developers.

The MCP-native architecture means your AI coding assistants (Cursor, Windsurf, Claude Code) can discover MailAnvil's API automatically. Build email features without reading docs — the AI reads the MCP schema and generates the integration code.

# Install MailAnvil MCP server for your AI assistant
hermes mcp add mailanvil --url https://mcp.mailanvil.com/mcp

What You Built

The full backend is under 100 lines. No email libraries to install, no SMTP configuration, no server maintenance. MailAnvil handles delivery, bounce processing, and suppression management. You focus on your app.

Try MailAnvil free at mailanvil.com — 500 emails/month, no credit card required.