Implements Phase 2 infrastructure for participant registration and authentication: Database Models: - Add Participant model with exchange scoping and soft deletes - Add MagicToken model for passwordless authentication - Add participants relationship to Exchange model - Include proper indexes and foreign key constraints Migration Infrastructure: - Generate Alembic migration for new models - Create entrypoint.sh script for automatic migrations on container startup - Update Containerfile to use entrypoint script and include uv binary - Remove db.create_all() in favor of migration-based schema management This establishes the foundation for implementing stories 4.1-4.3, 5.1-5.3, and 10.1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
980 lines
26 KiB
Markdown
980 lines
26 KiB
Markdown
# Notifications Component Design - v0.1.0
|
|
|
|
**Version**: 0.1.0
|
|
**Date**: 2025-12-22
|
|
**Status**: Initial Design
|
|
|
|
## Introduction
|
|
|
|
This document defines the email notification system for Sneaky Klaus. The notification service handles all transactional and reminder emails sent to participants and administrators using Resend as the email delivery provider.
|
|
|
|
## Requirements
|
|
|
|
### Functional Requirements
|
|
|
|
1. **Transactional Emails**: Send immediate emails in response to user actions
|
|
2. **Reminder Emails**: Send scheduled reminder emails before exchange date
|
|
3. **Admin Notifications**: Notify admin of important exchange events (opt-in)
|
|
4. **Magic Link Delivery**: Include authentication tokens in emails
|
|
5. **Template Management**: Maintain consistent branded email templates
|
|
6. **Error Handling**: Gracefully handle email delivery failures
|
|
|
|
### Non-Functional Requirements
|
|
|
|
1. **Reliability**: Guarantee delivery or retry on failure
|
|
2. **Performance**: Send emails asynchronously without blocking requests
|
|
3. **Auditability**: Log all email send attempts
|
|
4. **Deliverability**: Follow email best practices (SPF, DKIM, unsubscribe links)
|
|
|
|
## Email Types
|
|
|
|
### Participant Emails
|
|
|
|
| Email Type | Trigger | Recipient | Time-Sensitive |
|
|
|------------|---------|-----------|----------------|
|
|
| Registration Confirmation | Participant registers | Participant | Yes (immediate) |
|
|
| Magic Link | Participant requests access | Participant | Yes (immediate) |
|
|
| Match Notification | Matching complete | All participants | Yes (immediate) |
|
|
| Reminder Email | Scheduled (pre-exchange) | Opted-in participants | Yes (scheduled) |
|
|
| Withdrawal Confirmation | Participant withdraws | Participant | Yes (immediate) |
|
|
|
|
### Admin Emails
|
|
|
|
| Email Type | Trigger | Recipient | Time-Sensitive |
|
|
|------------|---------|-----------|----------------|
|
|
| Password Reset | Admin requests reset | Admin | Yes (immediate) |
|
|
| New Registration | Participant registers | Admin | No (opt-in) |
|
|
| Participant Withdrawal | Participant withdraws | Admin | No (opt-in) |
|
|
| Matching Complete | Matching succeeds | Admin | No (opt-in) |
|
|
| Data Purge Warning | 7 days before purge | Admin | No |
|
|
|
|
## Notification Service Architecture
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph "Application Layer"
|
|
Route[Route Handler]
|
|
Service[Business Logic]
|
|
end
|
|
|
|
subgraph "Notification Service"
|
|
NS[NotificationService]
|
|
TemplateEngine[Template Renderer]
|
|
EmailQueue[Email Queue]
|
|
end
|
|
|
|
subgraph "External Services"
|
|
Resend[Resend API]
|
|
end
|
|
|
|
subgraph "Storage"
|
|
DB[(Database)]
|
|
Templates[Email Templates]
|
|
end
|
|
|
|
Route --> Service
|
|
Service --> NS
|
|
NS --> TemplateEngine
|
|
TemplateEngine --> Templates
|
|
NS --> EmailQueue
|
|
EmailQueue --> Resend
|
|
NS --> DB
|
|
Resend --> DB
|
|
|
|
style Resend fill:#f9f,stroke:#333
|
|
style Templates fill:#bfb,stroke:#333
|
|
```
|
|
|
|
## Implementation Structure
|
|
|
|
### Service Class
|
|
|
|
```python
|
|
class NotificationService:
|
|
"""
|
|
Centralized service for all email notifications.
|
|
"""
|
|
|
|
def __init__(self, resend_client: ResendClient = None):
|
|
self.resend = resend_client or ResendClient(api_key=get_resend_api_key())
|
|
self.template_renderer = EmailTemplateRenderer()
|
|
self.logger = logging.getLogger(__name__)
|
|
|
|
# Participant Emails
|
|
def send_registration_confirmation(self, participant_id: int) -> EmailResult
|
|
def send_magic_link(self, participant_id: int, token: str) -> EmailResult
|
|
def send_match_notification(self, participant_id: int) -> EmailResult
|
|
def send_reminder_email(self, participant_id: int) -> EmailResult
|
|
def send_withdrawal_confirmation(self, participant_id: int) -> EmailResult
|
|
|
|
# Admin Emails
|
|
def send_password_reset(self, admin_email: str, token: str) -> EmailResult
|
|
def send_admin_notification(self, exchange_id: int, notification_type: NotificationType) -> EmailResult
|
|
def send_data_purge_warning(self, exchange_id: int) -> EmailResult
|
|
|
|
# Batch Operations
|
|
def send_match_notifications_batch(self, exchange_id: int) -> BatchEmailResult
|
|
|
|
# Internal Methods
|
|
def _send_email(self, email_request: EmailRequest) -> EmailResult
|
|
def _log_email_send(self, email_request: EmailRequest, result: EmailResult)
|
|
```
|
|
|
|
## Email Templates
|
|
|
|
### Template Structure
|
|
|
|
All email templates use Jinja2 with HTML and plain text versions:
|
|
|
|
**Directory Structure**:
|
|
```
|
|
templates/emails/
|
|
├── base.html # Base template with header/footer
|
|
├── base.txt # Plain text base
|
|
├── participant/
|
|
│ ├── registration_confirmation.html
|
|
│ ├── registration_confirmation.txt
|
|
│ ├── magic_link.html
|
|
│ ├── magic_link.txt
|
|
│ ├── match_notification.html
|
|
│ ├── match_notification.txt
|
|
│ ├── reminder.html
|
|
│ ├── reminder.txt
|
|
│ └── withdrawal_confirmation.html
|
|
│ └── withdrawal_confirmation.txt
|
|
└── admin/
|
|
├── password_reset.html
|
|
├── password_reset.txt
|
|
├── new_registration.html
|
|
├── new_registration.txt
|
|
├── participant_withdrawal.html
|
|
├── participant_withdrawal.txt
|
|
├── matching_complete.html
|
|
├── matching_complete.txt
|
|
├── data_purge_warning.html
|
|
└── data_purge_warning.txt
|
|
```
|
|
|
|
### Base Template
|
|
|
|
**base.html**:
|
|
```html
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{% block title %}Sneaky Klaus{% endblock %}</title>
|
|
<style>
|
|
/* Inline CSS for email client compatibility */
|
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
|
.header { background-color: #d32f2f; color: white; padding: 20px; text-align: center; }
|
|
.content { padding: 30px; background-color: #f9f9f9; }
|
|
.button { display: inline-block; padding: 12px 24px; background-color: #d32f2f; color: white; text-decoration: none; border-radius: 4px; }
|
|
.footer { text-align: center; font-size: 12px; color: #666; padding: 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1>🎅 Sneaky Klaus</h1>
|
|
</div>
|
|
<div class="content">
|
|
{% block content %}{% endblock %}
|
|
</div>
|
|
<div class="footer">
|
|
<p>You received this email because you're part of a Sneaky Klaus Secret Santa exchange.</p>
|
|
{% block unsubscribe %}{% endblock %}
|
|
<p>© {{ current_year }} Sneaky Klaus</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
**base.txt**:
|
|
```text
|
|
SNEAKY KLAUS
|
|
=============
|
|
|
|
{% block content %}{% endblock %}
|
|
|
|
---
|
|
You received this email because you're part of a Sneaky Klaus Secret Santa exchange.
|
|
{% block unsubscribe %}{% endblock %}
|
|
|
|
© {{ current_year }} Sneaky Klaus
|
|
```
|
|
|
|
## Participant Email Specifications
|
|
|
|
### 1. Registration Confirmation
|
|
|
|
**Trigger**: Immediately after participant registration
|
|
|
|
**Subject**: "Welcome to {exchange_name}!"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"participant_name": str,
|
|
"exchange_name": str,
|
|
"exchange_date": datetime,
|
|
"budget": str,
|
|
"magic_link_url": str,
|
|
"app_url": str
|
|
}
|
|
```
|
|
|
|
**Content** (HTML version):
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Welcome to {{ exchange_name }}!</h2>
|
|
|
|
<p>Hi {{ participant_name }},</p>
|
|
|
|
<p>You've successfully registered for the Secret Santa exchange!</p>
|
|
|
|
<p><strong>Exchange Details:</strong></p>
|
|
<ul>
|
|
<li><strong>Event Date:</strong> {{ exchange_date|format_date }}</li>
|
|
<li><strong>Gift Budget:</strong> {{ budget }}</li>
|
|
</ul>
|
|
|
|
<p>You can update your gift ideas or view participant information anytime using the link below:</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ magic_link_url }}" class="button">Access My Registration</a>
|
|
</p>
|
|
|
|
<p><small>This link will expire in 1 hour. You can request a new one anytime from the registration page.</small></p>
|
|
|
|
<p>When participants are matched, you'll receive another email with your Secret Santa assignment.</p>
|
|
|
|
<p>Happy gifting!</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
**Plain Text Version**: Similar content without HTML formatting
|
|
|
|
---
|
|
|
|
### 2. Magic Link
|
|
|
|
**Trigger**: Participant requests access to registration
|
|
|
|
**Subject**: "Access Your Sneaky Klaus Registration"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"participant_name": str,
|
|
"exchange_name": str,
|
|
"magic_link_url": str,
|
|
"expiration_minutes": int # 60
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Access Your Registration</h2>
|
|
|
|
<p>Hi {{ participant_name }},</p>
|
|
|
|
<p>You requested access to your registration for <strong>{{ exchange_name }}</strong>.</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ magic_link_url }}" class="button">Access My Registration</a>
|
|
</p>
|
|
|
|
<p><small>This link will expire in {{ expiration_minutes }} minutes and can only be used once.</small></p>
|
|
|
|
<p>If you didn't request this link, you can safely ignore this email.</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. Match Notification
|
|
|
|
**Trigger**: Matching complete (sent to all participants)
|
|
|
|
**Subject**: "Your Secret Santa Assignment for {exchange_name}"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"participant_name": str,
|
|
"exchange_name": str,
|
|
"exchange_date": datetime,
|
|
"budget": str,
|
|
"recipient_name": str,
|
|
"recipient_gift_ideas": str,
|
|
"magic_link_url": str,
|
|
"participant_count": int
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Your Secret Santa Assignment</h2>
|
|
|
|
<p>Hi {{ participant_name }},</p>
|
|
|
|
<p>Participants have been matched for <strong>{{ exchange_name }}</strong>! 🎁</p>
|
|
|
|
<div style="background-color: white; padding: 20px; border-radius: 8px; border-left: 4px solid #d32f2f; margin: 20px 0;">
|
|
<h3 style="margin-top: 0;">You're buying for:</h3>
|
|
<p style="font-size: 18px; font-weight: bold; margin: 10px 0;">{{ recipient_name }}</p>
|
|
|
|
{% if recipient_gift_ideas %}
|
|
<p><strong>Gift Ideas:</strong></p>
|
|
<p style="white-space: pre-wrap;">{{ recipient_gift_ideas }}</p>
|
|
{% else %}
|
|
<p><em>No gift ideas provided yet.</em></p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<p><strong>Exchange Details:</strong></p>
|
|
<ul>
|
|
<li><strong>Gift Budget:</strong> {{ budget }}</li>
|
|
<li><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</li>
|
|
<li><strong>Total Participants:</strong> {{ participant_count }}</li>
|
|
</ul>
|
|
|
|
<p>You can view this information anytime by clicking the link below:</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ magic_link_url }}" class="button">View My Assignment</a>
|
|
</p>
|
|
|
|
<p><strong>Remember:</strong> Keep your assignment secret! The fun is in the surprise. 🤫</p>
|
|
|
|
<p>Happy shopping!</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 4. Reminder Email
|
|
|
|
**Trigger**: Scheduled (based on admin configuration, e.g., 7 days, 3 days, 1 day before exchange)
|
|
|
|
**Subject**: "Reminder: {exchange_name} is {days_until} days away!"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"participant_name": str,
|
|
"exchange_name": str,
|
|
"exchange_date": datetime,
|
|
"days_until": int,
|
|
"recipient_name": str,
|
|
"recipient_gift_ideas": str,
|
|
"budget": str,
|
|
"magic_link_url": str
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Don't Forget! {{ exchange_name }} is Coming Up</h2>
|
|
|
|
<p>Hi {{ participant_name }},</p>
|
|
|
|
<p>This is a friendly reminder that <strong>{{ exchange_name }}</strong> is only <strong>{{ days_until }} day{{ 's' if days_until != 1 else '' }}</strong> away!</p>
|
|
|
|
<p>You're buying for: <strong>{{ recipient_name }}</strong></p>
|
|
|
|
{% if recipient_gift_ideas %}
|
|
<p><strong>Their Gift Ideas:</strong></p>
|
|
<p style="white-space: pre-wrap; background-color: white; padding: 15px; border-radius: 4px;">{{ recipient_gift_ideas }}</p>
|
|
{% endif %}
|
|
|
|
<p><strong>Gift Budget:</strong> {{ budget }}</p>
|
|
<p><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ magic_link_url }}" class="button">View Full Details</a>
|
|
</p>
|
|
|
|
<p>Happy shopping! 🎁</p>
|
|
{% endblock %}
|
|
|
|
{% block unsubscribe %}
|
|
<p><a href="{{ unsubscribe_url }}">Don't want reminders? Update your preferences</a></p>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 5. Withdrawal Confirmation
|
|
|
|
**Trigger**: Participant withdraws from exchange
|
|
|
|
**Subject**: "You've withdrawn from {exchange_name}"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"participant_name": str,
|
|
"exchange_name": str
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Withdrawal Confirmed</h2>
|
|
|
|
<p>Hi {{ participant_name }},</p>
|
|
|
|
<p>You've successfully withdrawn from <strong>{{ exchange_name }}</strong>.</p>
|
|
|
|
<p>You will no longer receive any emails about this exchange.</p>
|
|
|
|
<p>If this was a mistake, please contact the exchange organizer to re-register.</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
## Admin Email Specifications
|
|
|
|
### 1. Password Reset
|
|
|
|
**Trigger**: Admin requests password reset
|
|
|
|
**Subject**: "Password Reset Request - Sneaky Klaus"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"reset_link_url": str,
|
|
"expiration_minutes": int # 60
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Password Reset Request</h2>
|
|
|
|
<p>You requested a password reset for your Sneaky Klaus admin account.</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ reset_link_url }}" class="button">Reset Password</a>
|
|
</p>
|
|
|
|
<p><small>This link will expire in {{ expiration_minutes }} minutes and can only be used once.</small></p>
|
|
|
|
<p>If you didn't request this reset, you can safely ignore this email. Your password will not be changed.</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 2. New Registration (Admin Notification)
|
|
|
|
**Trigger**: Participant registers (if admin has enabled this notification)
|
|
|
|
**Subject**: "New Participant in {exchange_name}"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"exchange_name": str,
|
|
"participant_name": str,
|
|
"participant_email": str,
|
|
"participant_count": int,
|
|
"max_participants": int,
|
|
"exchange_url": str
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>New Participant Registered</h2>
|
|
|
|
<p>A new participant has joined <strong>{{ exchange_name }}</strong>:</p>
|
|
|
|
<ul>
|
|
<li><strong>Name:</strong> {{ participant_name }}</li>
|
|
<li><strong>Email:</strong> {{ participant_email }}</li>
|
|
</ul>
|
|
|
|
<p><strong>Participant Count:</strong> {{ participant_count }} / {{ max_participants }}</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ exchange_url }}" class="button">View Exchange</a>
|
|
</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 3. Participant Withdrawal (Admin Notification)
|
|
|
|
**Trigger**: Participant withdraws (if admin has enabled this notification)
|
|
|
|
**Subject**: "Participant Withdrew from {exchange_name}"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"exchange_name": str,
|
|
"participant_name": str,
|
|
"participant_count": int,
|
|
"exchange_url": str
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Participant Withdrew</h2>
|
|
|
|
<p><strong>{{ participant_name }}</strong> has withdrawn from <strong>{{ exchange_name }}</strong>.</p>
|
|
|
|
<p><strong>Remaining Participants:</strong> {{ participant_count }}</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ exchange_url }}" class="button">View Exchange</a>
|
|
</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 4. Matching Complete (Admin Notification)
|
|
|
|
**Trigger**: Matching succeeds (if admin has enabled this notification)
|
|
|
|
**Subject**: "Matching Complete for {exchange_name}"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"exchange_name": str,
|
|
"participant_count": int,
|
|
"exchange_date": datetime,
|
|
"exchange_url": str
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Matching Complete! 🎉</h2>
|
|
|
|
<p>Participants have been successfully matched for <strong>{{ exchange_name }}</strong>.</p>
|
|
|
|
<p><strong>Details:</strong></p>
|
|
<ul>
|
|
<li><strong>Participants Matched:</strong> {{ participant_count }}</li>
|
|
<li><strong>Exchange Date:</strong> {{ exchange_date|format_date }}</li>
|
|
</ul>
|
|
|
|
<p>All participants have been notified of their assignments via email.</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ exchange_url }}" class="button">View Exchange</a>
|
|
</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
---
|
|
|
|
### 5. Data Purge Warning
|
|
|
|
**Trigger**: 7 days before exchange data is purged (30 days after completion)
|
|
|
|
**Subject**: "Data Purge Scheduled for {exchange_name}"
|
|
|
|
**Template Variables**:
|
|
```python
|
|
{
|
|
"exchange_name": str,
|
|
"purge_date": datetime,
|
|
"days_until_purge": int,
|
|
"exchange_url": str
|
|
}
|
|
```
|
|
|
|
**Content**:
|
|
```html
|
|
{% extends "emails/base.html" %}
|
|
{% block content %}
|
|
<h2>Data Purge Scheduled</h2>
|
|
|
|
<p>The exchange <strong>{{ exchange_name }}</strong> will be automatically deleted in <strong>{{ days_until_purge }} days</strong> ({{ purge_date|format_date }}).</p>
|
|
|
|
<p>All participant data, matches, and exchange details will be permanently removed as per the 30-day retention policy.</p>
|
|
|
|
<p>If you need to keep this data, please export it before the purge date.</p>
|
|
|
|
<p style="text-align: center;">
|
|
<a href="{{ exchange_url }}" class="button">View Exchange</a>
|
|
</p>
|
|
{% endblock %}
|
|
```
|
|
|
|
## Resend Integration
|
|
|
|
### Configuration
|
|
|
|
```python
|
|
import resend
|
|
|
|
class ResendClient:
|
|
"""Wrapper for Resend API."""
|
|
|
|
def __init__(self, api_key: str):
|
|
resend.api_key = api_key
|
|
self.from_email = "noreply@sneakyklaus.app" # Configured domain
|
|
self.from_name = "Sneaky Klaus"
|
|
|
|
def send_email(self, request: EmailRequest) -> EmailResult:
|
|
"""
|
|
Send email via Resend API.
|
|
|
|
Args:
|
|
request: EmailRequest with to, subject, html, text
|
|
|
|
Returns:
|
|
EmailResult with success status and message ID
|
|
"""
|
|
try:
|
|
params = {
|
|
"from": f"{self.from_name} <{self.from_email}>",
|
|
"to": [request.to_email],
|
|
"subject": request.subject,
|
|
"html": request.html_body,
|
|
"text": request.text_body,
|
|
}
|
|
|
|
# Optional: Add tags for tracking
|
|
if request.tags:
|
|
params["tags"] = request.tags
|
|
|
|
response = resend.Emails.send(params)
|
|
|
|
return EmailResult(
|
|
success=True,
|
|
message_id=response["id"],
|
|
timestamp=datetime.utcnow()
|
|
)
|
|
|
|
except resend.exceptions.ResendError as e:
|
|
logger.error(f"Resend API error: {str(e)}")
|
|
return EmailResult(
|
|
success=False,
|
|
error=str(e),
|
|
timestamp=datetime.utcnow()
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error sending email: {str(e)}")
|
|
return EmailResult(
|
|
success=False,
|
|
error="Internal error",
|
|
timestamp=datetime.utcnow()
|
|
)
|
|
```
|
|
|
|
### Email Request Model
|
|
|
|
```python
|
|
@dataclass
|
|
class EmailRequest:
|
|
"""Email send request."""
|
|
to_email: str
|
|
subject: str
|
|
html_body: str
|
|
text_body: str
|
|
tags: Optional[dict] = None # For analytics/tracking
|
|
|
|
@dataclass
|
|
class EmailResult:
|
|
"""Email send result."""
|
|
success: bool
|
|
message_id: Optional[str] = None
|
|
error: Optional[str] = None
|
|
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
```
|
|
|
|
### Email Tags (Optional)
|
|
|
|
For analytics and troubleshooting:
|
|
|
|
```python
|
|
tags = {
|
|
"type": "match_notification",
|
|
"exchange_id": "123",
|
|
"environment": "production"
|
|
}
|
|
```
|
|
|
|
## Error Handling & Retries
|
|
|
|
### Retry Strategy
|
|
|
|
```python
|
|
def send_email_with_retry(request: EmailRequest, max_retries: int = 3) -> EmailResult:
|
|
"""
|
|
Send email with exponential backoff retry.
|
|
|
|
Args:
|
|
request: EmailRequest to send
|
|
max_retries: Maximum retry attempts
|
|
|
|
Returns:
|
|
EmailResult
|
|
"""
|
|
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(max_retries),
|
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
retry=retry_if_exception_type(resend.exceptions.ResendError)
|
|
)
|
|
def _send():
|
|
return resend_client.send_email(request)
|
|
|
|
try:
|
|
return _send()
|
|
except Exception as e:
|
|
logger.error(f"Failed to send email after {max_retries} attempts: {str(e)}")
|
|
return EmailResult(success=False, error=f"Failed after {max_retries} retries")
|
|
```
|
|
|
|
### Failure Logging
|
|
|
|
All email send attempts logged to database for audit:
|
|
|
|
```python
|
|
class EmailLog(db.Model):
|
|
"""Audit log for email sends."""
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
to_email = db.Column(db.String(255), nullable=False)
|
|
subject = db.Column(db.String(500), nullable=False)
|
|
email_type = db.Column(db.String(50), nullable=False)
|
|
success = db.Column(db.Boolean, nullable=False)
|
|
message_id = db.Column(db.String(255), nullable=True)
|
|
error = db.Column(db.Text, nullable=True)
|
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
|
```
|
|
|
|
## Batch Email Sending
|
|
|
|
For sending to multiple recipients (e.g., match notifications):
|
|
|
|
```python
|
|
def send_match_notifications_batch(self, exchange_id: int) -> BatchEmailResult:
|
|
"""
|
|
Send match notifications to all participants in exchange.
|
|
|
|
Args:
|
|
exchange_id: Exchange ID
|
|
|
|
Returns:
|
|
BatchEmailResult with success count and failures
|
|
"""
|
|
participants = Participant.query.filter_by(
|
|
exchange_id=exchange_id,
|
|
withdrawn_at=None
|
|
).all()
|
|
|
|
results = []
|
|
failed = []
|
|
|
|
for participant in participants:
|
|
result = self.send_match_notification(participant.id)
|
|
results.append(result)
|
|
|
|
if not result.success:
|
|
failed.append({
|
|
"participant_id": participant.id,
|
|
"participant_email": participant.email,
|
|
"error": result.error
|
|
})
|
|
|
|
# Rate limit: Small delay between sends
|
|
time.sleep(0.1)
|
|
|
|
return BatchEmailResult(
|
|
total=len(participants),
|
|
successful=len([r for r in results if r.success]),
|
|
failed=len(failed),
|
|
failures=failed
|
|
)
|
|
```
|
|
|
|
## Testing
|
|
|
|
### Email Testing in Development
|
|
|
|
**Option 1: Resend Test Mode**
|
|
- Use Resend's test API key
|
|
- Emails sent to test mode, not delivered
|
|
|
|
**Option 2: MailHog / MailCatcher**
|
|
- Local SMTP server for testing
|
|
- View emails in web UI
|
|
|
|
**Option 3: Mock in Unit Tests**
|
|
```python
|
|
from unittest.mock import patch
|
|
|
|
class TestNotificationService(unittest.TestCase):
|
|
|
|
@patch('notification_service.ResendClient.send_email')
|
|
def test_send_registration_confirmation(self, mock_send):
|
|
mock_send.return_value = EmailResult(success=True, message_id="test-123")
|
|
|
|
service = NotificationService()
|
|
result = service.send_registration_confirmation(participant_id=1)
|
|
|
|
self.assertTrue(result.success)
|
|
mock_send.assert_called_once()
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
```python
|
|
class TestNotificationIntegration(TestCase):
|
|
|
|
def test_full_match_notification_workflow(self):
|
|
"""Test complete match notification workflow."""
|
|
# Setup
|
|
exchange = create_test_exchange()
|
|
participants = [create_test_participant(exchange) for _ in range(5)]
|
|
execute_matching(exchange.id)
|
|
|
|
# Execute
|
|
service = NotificationService()
|
|
result = service.send_match_notifications_batch(exchange.id)
|
|
|
|
# Assert
|
|
self.assertEqual(result.total, 5)
|
|
self.assertEqual(result.successful, 5)
|
|
self.assertEqual(result.failed, 0)
|
|
|
|
# Verify emails logged
|
|
logs = EmailLog.query.filter_by(email_type="match_notification").all()
|
|
self.assertEqual(len(logs), 5)
|
|
```
|
|
|
|
## Deliverability Best Practices
|
|
|
|
### SPF, DKIM, DMARC
|
|
|
|
Configure DNS records for Resend domain:
|
|
- **SPF**: Authorize Resend to send on your behalf
|
|
- **DKIM**: Sign emails cryptographically
|
|
- **DMARC**: Define policy for failed authentication
|
|
|
|
**Example DNS Configuration**:
|
|
```
|
|
TXT @ "v=spf1 include:_spf.resend.com ~all"
|
|
CNAME resend._domainkey resend.domainkey.resend.com
|
|
TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:admin@example.com"
|
|
```
|
|
|
|
### Unsubscribe Links
|
|
|
|
For reminder emails, include unsubscribe link:
|
|
|
|
```python
|
|
unsubscribe_url = f"{app_url}/participant/exchange/{exchange_id}/edit"
|
|
```
|
|
|
|
Participants can disable reminders via profile edit.
|
|
|
|
### Email Content Best Practices
|
|
|
|
1. **Clear Subject Lines**: Descriptive and concise
|
|
2. **Plain Text Alternative**: Always include text version
|
|
3. **Inline CSS**: Email clients strip external stylesheets
|
|
4. **Mobile Responsive**: Use responsive design techniques
|
|
5. **Clear Call-to-Action**: Prominent buttons/links
|
|
6. **Avoid Spam Triggers**: No all-caps, excessive punctuation, spam keywords
|
|
|
|
## Performance Considerations
|
|
|
|
### Asynchronous Sending
|
|
|
|
For non-critical emails, send asynchronously:
|
|
|
|
```python
|
|
from threading import Thread
|
|
|
|
def send_email_async(email_request: EmailRequest):
|
|
"""Send email in background thread."""
|
|
thread = Thread(target=lambda: notification_service.send_email(email_request))
|
|
thread.start()
|
|
```
|
|
|
|
**Note**: For production, use proper background job queue (see background-jobs.md)
|
|
|
|
### Rate Limiting
|
|
|
|
Resend has rate limits (depends on plan):
|
|
- Free: 100 emails/day
|
|
- Paid: Higher limits
|
|
|
|
**Mitigation**:
|
|
- Batch operations with delays between sends
|
|
- Implement queue for large batches
|
|
- Monitor usage and implement backoff
|
|
|
|
## Security Considerations
|
|
|
|
### Token Inclusion
|
|
|
|
Magic links and password reset tokens:
|
|
- **URL Structure**: `{app_url}/auth/participant/magic/{token}`
|
|
- **Token Format**: 32-byte random, base64url encoded
|
|
- **Security**: Tokens hashed in database, original never stored
|
|
|
|
### Email Spoofing Prevention
|
|
|
|
- Use authenticated Resend domain
|
|
- Configure SPF/DKIM/DMARC
|
|
- Never allow user-controlled "from" addresses
|
|
|
|
### Sensitive Data
|
|
|
|
- **Never include**: Passwords, full tokens (only links)
|
|
- **Include only necessary**: Participant names, gift ideas (expected in context)
|
|
- **Audit log**: Track all emails sent
|
|
|
|
## Future Enhancements
|
|
|
|
1. **HTML Email Builder**: Visual template editor for admin
|
|
2. **Localization**: Multi-language email templates
|
|
3. **A/B Testing**: Test different email content for engagement
|
|
4. **Analytics**: Track open rates, click rates (Resend webhooks)
|
|
5. **Custom Branding**: Allow admin to customize email header/colors
|
|
6. **Email Queue Dashboard**: Admin view of pending/failed emails
|
|
|
|
## References
|
|
|
|
- [Resend Documentation](https://resend.com/docs)
|
|
- [Jinja2 Template Documentation](https://jinja.palletsprojects.com/)
|
|
- [Email Deliverability Best Practices](https://www.mailgun.com/blog/email/email-deliverability-best-practices/)
|
|
- [Data Model Specification](../data-model.md)
|
|
- [API Specification](../api-spec.md)
|