Email Templates and Delivery Monitoring
Email Templates and Delivery Monitoring
The Feature
Marketflow sends transactional emails for eight events: application received, application accepted, application rejected, payment receipt, market day reminder, vendor check-in confirmation, document request, and password reset. Each email follows a consistent template with the Marketflow branding. Failed deliveries are logged and retried.
The Decision
HTML email templates are defined inline in Python, not in separate template files. For eight email types with simple layouts, a template engine (Jinja2, React Email) adds complexity without enough benefit. Each email function takes the dynamic values as parameters and returns the rendered HTML. If the email designs need to become more sophisticated (with headers, footers, images), migrating to React Email templates is straightforward.
The Implementation
Base Email Template
# backend/app/services/email_templates.py
def base_template(content: str, preview_text: str = "") -> str:
"""Wrap email content in a consistent base template."""
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #2563eb;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
}}
.footer {{
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
font-size: 14px;
color: #6b7280;
}}
</style>
</head>
<body>
<span style="display:none">{preview_text}</span>
{content}
<div class="footer">
<p>Marketflow - Farmers Market Management</p>
<p>You received this email because of your Marketflow account activity.</p>
</div>
</body>
</html>"""
def application_received_email(vendor_name: str, market_name: str) -> str:
return base_template(
f"""
<h2>Application Received</h2>
<p>Hi {vendor_name},</p>
<p>Your application to <strong>{market_name}</strong> has been received.
The market organizer will review your application and documents shortly.</p>
<p>You can check the status of your application in the vendor portal:</p>
<p><a class="button" href="https://marketflow.app/vendor">View Application Status</a></p>
""",
preview_text=f"Your application to {market_name} is under review",
)
def application_accepted_email(
vendor_name: str,
market_name: str,
stall_number: str | None = None,
) -> str:
stall_info = ""
if stall_number:
stall_info = f"<p>Your assigned stall number is <strong>{stall_number}</strong>.</p>"
return base_template(
f"""
<h2>Application Accepted</h2>
<p>Hi {vendor_name},</p>
<p>Your application to <strong>{market_name}</strong> has been accepted.</p>
{stall_info}
<p>Log in to view your upcoming market days and stall assignment:</p>
<p><a class="button" href="https://marketflow.app/vendor">Go to Vendor Portal</a></p>
""",
preview_text=f"You've been accepted to {market_name}!",
)
def application_rejected_email(vendor_name: str, market_name: str) -> str:
return base_template(
f"""
<h2>Application Update</h2>
<p>Hi {vendor_name},</p>
<p>Thank you for your interest in <strong>{market_name}</strong>.</p>
<p>Unfortunately, we are unable to accept your application at this time.
We encourage you to apply again for upcoming market seasons.</p>
""",
preview_text=f"Update on your {market_name} application",
)
def payment_receipt_email(
vendor_name: str,
market_name: str,
amount_cents: int,
period: str,
) -> str:
amount = f"${amount_cents / 100:.2f}"
return base_template(
f"""
<h2>Payment Receipt</h2>
<p>Hi {vendor_name},</p>
<p>Your payment of <strong>{amount}</strong> for <strong>{market_name}</strong>
({period}) has been processed successfully.</p>
<p>View your billing history in the vendor portal:</p>
<p><a class="button" href="https://marketflow.app/vendor/billing">View Billing</a></p>
""",
preview_text=f"Payment of {amount} received",
)
Email Sending with Error Handling
# backend/app/services/email.py
import logging
import resend
from app.config import settings
logger = logging.getLogger(__name__)
resend.api_key = settings.resend_api_key
FROM_ADDRESS = "Marketflow <[email protected]>"
async def send_email(
to: str,
subject: str,
html: str,
) -> str | None:
"""Send an email via Resend. Returns the email ID on success, None on failure."""
try:
result = resend.Emails.send({
"from": FROM_ADDRESS,
"to": to,
"subject": subject,
"html": html,
})
logger.info(f"Email sent: {result['id']} to {to}")
return result["id"]
except resend.exceptions.ResendError as e:
logger.error(f"Failed to send email to {to}: {e}")
return None
Integration with Application Workflow
# backend/app/routers/applications.py (relevant section)
from app.services.email import send_email
from app.services.email_templates import (
application_accepted_email,
application_rejected_email,
new_application_notification_email,
)
@router.post("/{application_id}/accept")
async def accept_application(
application_id: str,
stall_number: str | None = None,
market: Market = Depends(get_market_for_organizer),
db: AsyncSession = Depends(get_db),
):
application = await get_application(db, application_id, market.id)
application.status = "accepted"
application.stall_number = stall_number
await db.commit()
# Send acceptance email (fire and forget, don't block the response)
await send_email(
to=application.vendor.email,
subject=f"Your application to {market.name} has been accepted",
html=application_accepted_email(
vendor_name=application.vendor.name,
market_name=market.name,
stall_number=stall_number,
),
)
return {"status": "accepted"}
Local Email Testing
# For local development, use Resend's test API key
# or override the send function to log instead of sending:
# backend/app/services/email.py
import os
if os.getenv("ENVIRONMENT") == "development":
async def send_email(to: str, subject: str, html: str) -> str | None:
logger.info(f"[DEV] Would send email to {to}: {subject}")
logger.debug(f"[DEV] Email HTML:\n{html}")
return "dev-email-id"
The Trap
# TRAP: Sending emails synchronously in the request path
@router.post("/{application_id}/accept")
async def accept_application(...):
application.status = "accepted"
await db.commit()
await send_email(...) # If Resend is slow, the response is slow
await send_email(...) # Two emails = double the latency
return {"status": "accepted"}
# Organizer waits for both emails to send before seeing the response
# SAFE: At this scale, the latency is acceptable
# Resend's API typically responds in 100-200ms
# Two emails add 200-400ms to the response
# This is noticeable but not a problem at Marketflow's scale
# When it becomes a problem, move to a background task queue
At 50 customers, sending emails synchronously in the request path is acceptable. The Resend API responds in 100-200 milliseconds. Adding a task queue (Celery, ARQ, or even a simple background task) adds complexity that is not justified until email volume increases or latency becomes noticeable. The code is structured so that extracting send_email calls into a background queue later requires changing only the call site, not the email service itself.
The Cost
| Email Type | Monthly Volume (50 customers) | Free Tier (100/day) |
|---|---|---|
| Application notifications | ~20 | Within limit |
| Payment receipts | ~50 | Within limit |
| Market day reminders | ~100 | Within limit |
| Document requests | ~10 | Within limit |
| Total | ~180 | ~6/day average |
At 180 emails per month (approximately 6 per day), Resend’s free tier of 100 emails per day provides substantial headroom. The paid tier ($20/month for 50,000 emails) becomes necessary only when the platform has hundreds of active vendors across multiple markets.