Example Client

The example_client.py demonstrates how to integrate your application with genro-mail-proxy.

Overview

This standalone server shows the recommended integration pattern:

  1. Local Persistence - Write messages to your application’s database first

  2. Async Submission - Submit messages to mail service via REST API

  3. Immediate Trigger - Optionally trigger run-now for fast delivery

  4. Delivery Reports - Receive confirmations via proxy_sync endpoint

This pattern ensures:

  • Never lose messages - Committed locally before submission

  • Decoupled architecture - Mail service downtime doesn’t block your app

  • Fast delivery - 99% of messages sent in <2 seconds via run-now trigger

  • Guaranteed delivery - Polling ensures missed triggers are recovered

  • Full tracking - Local database tracks message lifecycle

Quick Start

Installation

# Install dependencies
pip install fastapi uvicorn aiohttp

# Configure recipient email
nano example_config.ini
# Edit: recipient_email = your@email.com

# Start the example client
python3 example_client.py

The server will start on http://localhost:8081.

Configuration

Edit example_config.ini:

[mail_service]
url = http://localhost:8000
api_token = your-secret-token

[test]
# CHANGE THIS to your actual email address
recipient_email = your@email.com
sender_email = test@example.com
sender_name = Example Client Test

[database]
path = example_client.db

Usage Examples

Send Single Test Email

curl -X POST http://localhost:8081/send-test-email

Response:

{
  "status": "success",
  "message": "Successfully queued 1 test email(s)",
  "messages": ["test_1761215432000_1234"],
  "mail_service_response": {
    "queued": 1,
    "rejected": []
  },
  "note": "Check /stats endpoint to monitor delivery status"
}

Send Multiple Test Emails

# Send 5 test emails
curl -X POST http://localhost:8081/send-test-email?count=5

Custom Subject Line

curl -X POST "http://localhost:8081/send-test-email?subject=Integration%20Test"

View Message Statistics

curl http://localhost:8081/stats

Response:

{
  "status": "success",
  "statistics": {
    "total": 10,
    "pending": 0,
    "submitted": 2,
    "delivered": 7,
    "failed": 1
  }
}

List Pending Messages

curl http://localhost:8081/messages

Response:

{
  "status": "success",
  "count": 2,
  "messages": [
    {
      "id": "test_1761215432000_1234",
      "recipient": "your@email.com",
      "subject": "Test Email from Example Client",
      "status": "submitted",
      "created_at": "2025-10-23T10:30:32",
      "submitted_at": "2025-10-23T10:30:33"
    }
  ]
}

Architecture

Integration Flow

┌──────────────────┐
│  Your Application│
│  (example_client)│
└────────┬─────────┘
         │
         │ 1. Write to local DB
         ├─────────────────────────────┐
         │                             ▼
         │                    ┌─────────────────┐
         │                    │  Local Database │
         │                    │  (example_client│
         │                    │      .db)       │
         │                    └─────────────────┘
         │
         │ 2. POST /commands/add-messages
         │
         ▼
┌──────────────────┐
│ genro-mail-proxy │────────► SMTP Server
└────────┬─────────┘
         │
         │ 3. POST /delivery-report (to your app)
         │    (delivery reports)
         │
         ▼
┌──────────────────┐
│  Your Application│
│  (example_client)│
│                  │
│ 4. Update local  │
│    DB status     │
└──────────────────┘

Message States

Messages transition through these states in the local database:

  1. pending - Created locally, not yet submitted to mail service

  2. submitted - Sent to mail service, awaiting delivery

  3. delivered - Confirmation received from mail service

  4. failed - Permanent error reported by mail service

Database Schema

The example client maintains this schema:

CREATE TABLE outbound_emails (
    id TEXT PRIMARY KEY,
    recipient TEXT NOT NULL,
    subject TEXT NOT NULL,
    body TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    submitted_at INTEGER,
    delivered_at INTEGER,
    error TEXT,
    status TEXT DEFAULT 'pending'
);

CREATE INDEX idx_status ON outbound_emails(status);

Integration Pattern Explained

Why This Pattern?

This pattern solves the common problem: What happens if the mail service is down?

❌ Direct SMTP approach:

# BAD: If SMTP server is down, message is lost
smtp.send_message(msg)
# User's email is gone forever!

❌ Naive proxy approach:

# BAD: If mail service is down, message is lost
requests.post('http://mail-service/send', json=msg)
# User's email is gone forever!

✅ Decoupled approach (this example):

# GOOD: Persist locally FIRST
db.execute("INSERT INTO outbound_emails ...")
db.commit()  # Message is safe!

# THEN submit to mail service (best effort)
try:
    requests.post('http://mail-service/add-messages', json=msg)
    requests.post('http://mail-service/run-now')  # trigger immediate send
except ConnectionError:
    # No problem! Polling will pick it up later
    pass

# Your polling loop ensures delivery
# Even if trigger fails, message will be sent within 5 minutes

Benefits

  1. Local-first persistence - Message committed before network calls

  2. Resilient to downtime - Mail service can be restarted without data loss

  3. Non-blocking - Your application doesn’t wait for SMTP

  4. Auditable - Full lifecycle tracked in your database

  5. Testable - Easy to mock and test

  6. Monitorable - Query local DB for delivery status

Performance Characteristics

Typical latencies:

  • Write to local DB: <5ms

  • Submit to mail service: 10-30ms (non-blocking HTTP POST)

  • Trigger run-now: 5-15ms (best-effort)

  • Actual SMTP delivery: 100-500ms (handled asynchronously)

From user’s perspective:

  • API response time: ~50ms (local DB write + HTTP POST)

  • Email arrives in inbox: <2 seconds (99% with run-now trigger)

  • Fallback via polling: <5 minutes (1% if trigger fails)

Code Walkthrough

Step 1: Create Message Locally

async def create_test_message(self):
    """Write to YOUR database first."""
    message_id = f"test_{int(time.time() * 1000)}"

    # Local persistence FIRST
    conn = sqlite3.connect(self.db_path)
    conn.execute("""
        INSERT INTO outbound_emails (id, recipient, subject, body, created_at)
        VALUES (?, ?, ?, ?, ?)
    """, (message_id, recipient, subject, body, now))
    conn.commit()
    conn.close()

    return {'id': message_id, 'to': recipient, ...}

Key point: Message is safe in your database before any network calls.

Step 2: Submit to Mail Service

async def submit_to_mail_service(self, messages):
    """Hand off to mail service."""
    headers = {'X-API-Token': self.api_token}
    url = f"{self.mail_service_url}/commands/add-messages"

    async with aiohttp.ClientSession() as session:
        async with session.post(url, json={'messages': messages}, headers=headers) as resp:
            result = await resp.json()

            # Update local tracking
            conn = sqlite3.connect(self.db_path)
            for msg in messages:
                conn.execute("""
                    UPDATE outbound_emails
                    SET submitted_at = ?, status = 'submitted'
                    WHERE id = ?
                """, (now, msg['id']))
            conn.commit()

Key point: Non-blocking submission, local DB tracks submission timestamp.

Step 3: Trigger Immediate Dispatch

async def trigger_immediate_dispatch(self):
    """Wake up SMTP loop (best-effort)."""
    try:
        headers = {'X-API-Token': self.api_token}
        async with session.post(f"{self.url}/commands/run-now", headers=headers):
            logger.info("Triggered immediate dispatch")
    except aiohttp.ClientError as e:
        logger.warning(f"Trigger failed (non-fatal): {e}")
        # Not a problem! Polling will handle it

Key point: This is a best-effort optimization, not required for correctness.

Step 4: Receive Delivery Reports

@app.post("/delivery-report")
async def delivery_report(request: Request):
    """Handle delivery confirmations from mail service."""
    data = await request.json()
    reports = data.get('reports', [])

    conn = sqlite3.connect(client.db_path)
    for report in reports:
        if report['status'] == 'sent':
            conn.execute("""
                UPDATE outbound_emails
                SET delivered_at = ?, status = 'delivered'
                WHERE id = ?
            """, (now, report['id']))
        elif report['status'] == 'error':
            conn.execute("""
                UPDATE outbound_emails
                SET error = ?, status = 'failed'
                WHERE id = ?
            """, (report['error'], report['id']))
    conn.commit()

    return {'status': 'ok', 'processed': len(reports)}

Key point: Your application knows the final delivery status.

API Reference

Endpoints

POST /send-test-email

Generate and send test email(s).

Query Parameters:

  • count (int, optional): Number of emails to send (1-100, default 1)

  • subject (str, optional): Custom subject line

Response:

{
  "status": "success",
  "message": "Successfully queued 1 test email(s)",
  "messages": ["test_1761215432000_1234"],
  "mail_service_response": {
    "queued": 1,
    "rejected": []
  }
}

GET /messages

List pending messages.

Response:

{
  "status": "success",
  "count": 2,
  "messages": [
    {
      "id": "test_1761215432000_1234",
      "recipient": "your@email.com",
      "subject": "Test Email",
      "status": "submitted",
      "created_at": "2025-10-23T10:30:32",
      "submitted_at": "2025-10-23T10:30:33"
    }
  ]
}

GET /stats

Get message statistics.

Response:

{
  "status": "success",
  "statistics": {
    "total": 10,
    "pending": 0,
    "submitted": 2,
    "delivered": 7,
    "failed": 1
  }
}

POST /email/mailproxy/mp_endpoint/proxy_sync

Receive delivery reports from mail service (internal endpoint).

Request Body:

{
  "reports": [
    {
      "id": "test_1761215432000_1234",
      "status": "sent",
      "timestamp": 1761215435
    }
  ]
}

Response:

{
  "status": "ok",
  "processed": 1
}

Testing

End-to-End Test

  1. Start mail service:

    # In terminal 1
    cd /path/to/genro-mail-proxy
    python3 main.py
    
  2. Start example client:

    # In terminal 2
    python3 example_client.py
    
  3. Send test email:

    # In terminal 3
    curl -X POST http://localhost:8081/send-test-email
    
  4. Monitor delivery:

    # Check example client stats
    curl http://localhost:8081/stats
    
    # Check mail service status
    curl http://localhost:8000/status
    
  5. Verify email:

    Check your inbox for the test email (configured in example_config.ini).

Expected Timeline

T+0ms:    POST /send-test-email
T+5ms:    Message written to example_client.db (status=pending)
T+30ms:   Submitted to mail service (status=submitted)
T+35ms:   run-now trigger sent
T+40ms:   Mail service wakes SMTP loop
T+500ms:  SMTP delivery completes
T+5000ms: Delivery report posted to proxy_sync (status=delivered)

Total user-visible latency: ~35ms (steps 1-3) Total email delivery time: ~500ms (includes SMTP) Confirmation received: ~5 seconds (via proxy_sync)

Troubleshooting

Client Won’t Start

Error: ModuleNotFoundError: No module named 'fastapi'

Solution:

pip install fastapi uvicorn aiohttp

Mail Service Connection Refused

Error: Mail service unavailable: Cannot connect to host localhost:8000

Solution:

  1. Verify mail service is running:

    curl http://localhost:8000/status
    
  2. Check example_config.ini URL is correct:

    [mail_service]
    url = http://localhost:8000  # Match mail service port
    

Authentication Failed

Error: Mail service error: Invalid API token

Solution:

Ensure example_config.ini token matches the mail service token:

# example_config.ini
[mail_service]
api_token = your-secret-token

# Check mail service token with CLI
mail-proxy myserver token

Messages Stuck in “submitted” State

Symptoms: curl http://localhost:8081/stats shows submitted > 0 for extended period.

Diagnosis:

  1. Check mail service is processing:

    curl http://localhost:8000/status
    
  2. Check for delivery errors:

    curl http://localhost:8000/messages | jq '.messages[] | select(.error_ts != null)'
    
  3. Check SMTP account configuration:

    curl http://localhost:8000/accounts
    

Common causes:

  • Mail service test_mode=true (requires manual run-now)

  • Invalid SMTP credentials

  • Rate limiting (check deferred_ts)

  • Network connectivity to SMTP server

Solutions:

See the FAQ for common issues and solutions.

Adapting for Your Application

To integrate this pattern into your application:

  1. Add outbound_emails table to your existing database schema

  2. Create wrapper function around your existing email-sending code:

    async def send_email(recipient, subject, body):
        # 1. Write to YOUR database
        message_id = await db.insert_email(recipient, subject, body)
    
        # 2. Submit to mail service
        await mail_service.add_messages([{
            'id': message_id,
            'to': recipient,
            'subject': subject,
            'body': body
        }])
    
        # 3. Trigger immediate dispatch (best-effort)
        await mail_service.run_now()
    
        return message_id
    
  3. Add proxy_sync endpoint to receive delivery reports

  4. Add polling mechanism (optional, for applications that don’t receive run-now triggers)

Multi-tenant Integration

For multi-tenant deployments, each tenant can have their own sync endpoint. See Multi-tenancy Architecture for the complete architecture.

The proxy sends delivery reports with this payload structure:

{
  "delivery_report": [
    {
      "id": "msg-001",
      "account_id": "smtp-tenant1",
      "priority": 2,
      "sent_ts": 1705750800,
      "error_ts": null,
      "error": null,
      "deferred_ts": null
    }
  ]
}

Your endpoint should respond with a JSON object:

{
  "ok": true,
  "queued": 0,
  "next_sync_after": null
}

Response fields:

  • ok (bool, required): true if reports processed successfully

  • queued (int, optional): Pending messages count. When > 0, triggers immediate resync.

  • next_sync_after (int, optional): Unix timestamp for “Do Not Disturb” feature.

See Protocols and APIs for full response specification.

See Also