refactor: Switch from SendGrid to AWS SES for email
- Replace httpx/SendGrid with boto3/SES - Add boto3 to requirements.txt - Create AWS_SES_SETUP.md documentation - Remove SendGrid setup doc
This commit is contained in:
parent
f1d8a14f75
commit
9af0bc4b96
3 changed files with 183 additions and 22 deletions
|
|
@ -1,43 +1,56 @@
|
|||
"""
|
||||
Email Service - SendGrid integration for verification and password reset emails.
|
||||
Email Service - AWS SES integration for verification and password reset emails.
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import httpx
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY", "")
|
||||
AWS_REGION = os.getenv("AWS_SES_REGION", "us-east-1")
|
||||
EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
|
||||
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.runfoo.run")
|
||||
|
||||
async def send_email(to: str, subject: str, html_content: str) -> bool:
|
||||
"""Send email via SendGrid API"""
|
||||
if not SENDGRID_API_KEY:
|
||||
# AWS credentials from environment (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
||||
# or from IAM role if running on AWS infrastructure
|
||||
|
||||
def get_ses_client():
|
||||
"""Get boto3 SES client"""
|
||||
return boto3.client('ses', region_name=AWS_REGION)
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, html_content: str) -> bool:
|
||||
"""Send email via AWS SES"""
|
||||
# Dev mode - just log
|
||||
if not os.getenv("AWS_ACCESS_KEY_ID"):
|
||||
print(f"[EMAIL DEV MODE] To: {to}, Subject: {subject}")
|
||||
print(f"[EMAIL DEV MODE] Content: {html_content[:200]}...")
|
||||
return True
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://api.sendgrid.com/v3/mail/send",
|
||||
headers={
|
||||
"Authorization": f"Bearer {SENDGRID_API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"personalizations": [{"to": [{"email": to}]}],
|
||||
"from": {"email": EMAIL_FROM, "name": "Elmeg"},
|
||||
"subject": subject,
|
||||
"content": [{"type": "text/html", "value": html_content}]
|
||||
try:
|
||||
client = get_ses_client()
|
||||
response = client.send_email(
|
||||
Source=EMAIL_FROM,
|
||||
Destination={'ToAddresses': [to]},
|
||||
Message={
|
||||
'Subject': {'Data': subject, 'Charset': 'UTF-8'},
|
||||
'Body': {
|
||||
'Html': {'Data': html_content, 'Charset': 'UTF-8'}
|
||||
}
|
||||
}
|
||||
)
|
||||
return response.status_code in (200, 202)
|
||||
print(f"Email sent to {to}, MessageId: {response['MessageId']}")
|
||||
return True
|
||||
except ClientError as e:
|
||||
print(f"Failed to send email: {e.response['Error']['Message']}")
|
||||
return False
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
"""Generate a secure random token"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
async def send_verification_email(email: str, token: str) -> bool:
|
||||
"""Send email verification link"""
|
||||
verify_url = f"{FRONTEND_URL}/verify-email?token={token}"
|
||||
|
|
@ -61,7 +74,8 @@ async def send_verification_email(email: str, token: str) -> bool:
|
|||
</div>
|
||||
"""
|
||||
|
||||
return await send_email(email, "Verify your Elmeg account", html)
|
||||
return send_email(email, "Verify your Elmeg account", html)
|
||||
|
||||
|
||||
async def send_password_reset_email(email: str, token: str) -> bool:
|
||||
"""Send password reset link"""
|
||||
|
|
@ -86,13 +100,16 @@ async def send_password_reset_email(email: str, token: str) -> bool:
|
|||
</div>
|
||||
"""
|
||||
|
||||
return await send_email(email, "Reset your Elmeg password", html)
|
||||
return send_email(email, "Reset your Elmeg password", html)
|
||||
|
||||
|
||||
# Token expiration helpers
|
||||
def get_verification_expiry() -> datetime:
|
||||
"""48 hour expiry for email verification"""
|
||||
return datetime.utcnow() + timedelta(hours=48)
|
||||
|
||||
|
||||
def get_reset_expiry() -> datetime:
|
||||
"""1 hour expiry for password reset"""
|
||||
return datetime.utcnow() + timedelta(hours=1)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ argon2-cffi
|
|||
psycopg2-binary
|
||||
requests
|
||||
beautifulsoup4
|
||||
boto3
|
||||
|
|
|
|||
143
docs/AWS_SES_SETUP.md
Normal file
143
docs/AWS_SES_SETUP.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# AWS SES Email Setup
|
||||
|
||||
## Overview
|
||||
|
||||
The Elmeg platform uses AWS SES (Simple Email Service) for sending transactional emails (verification, password reset). Cost: ~$0.10 per 1,000 emails.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AWS Account
|
||||
- Domain: `elmeg.xyz` (for verified sender)
|
||||
- Production server: `tangible-aacorn` / `nexus-vector`
|
||||
|
||||
---
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Verify Email Domain in SES
|
||||
|
||||
1. Go to AWS Console → SES → Verified Identities
|
||||
2. Click "Create identity" → Choose "Domain"
|
||||
3. Enter: `elmeg.xyz`
|
||||
4. Enable "Use a custom MAIL FROM domain" (optional)
|
||||
5. Add the provided DNS records to your domain
|
||||
|
||||
**Required DNS Records** (example):
|
||||
|
||||
```
|
||||
Type: TXT
|
||||
Name: _amazonses.elmeg.xyz
|
||||
Value: [provided by AWS]
|
||||
|
||||
Type: CNAME
|
||||
Name: [dkim1]._domainkey.elmeg.xyz
|
||||
Value: [provided by AWS]
|
||||
```
|
||||
|
||||
### 2. Move Out of SES Sandbox
|
||||
|
||||
By default, SES is in sandbox mode (can only send to verified emails).
|
||||
|
||||
1. Go to SES → Account dashboard
|
||||
2. Click "Request production access"
|
||||
3. Fill out:
|
||||
- Mail type: Transactional
|
||||
- Website URL: <https://elmeg.runfoo.run>
|
||||
- Use case: User registration verification, password reset
|
||||
4. Wait for approval (usually 24hrs)
|
||||
|
||||
### 3. Create IAM User for SES
|
||||
|
||||
1. Go to IAM → Users → Create user
|
||||
2. Name: `elmeg-ses-sender`
|
||||
3. Attach policy: `AmazonSESFullAccess` (or create custom with `ses:SendEmail` only)
|
||||
4. Create access key for "Application running outside AWS"
|
||||
5. Save:
|
||||
- Access Key ID
|
||||
- Secret Access Key
|
||||
|
||||
### 4. Configure Production Server
|
||||
|
||||
SSH into server and update environment:
|
||||
|
||||
```bash
|
||||
ssh root@tangible-aacorn
|
||||
cd /srv/containers/elmeg-demo
|
||||
```
|
||||
|
||||
Edit `docker-compose.yml`, add to `backend` service:
|
||||
|
||||
```yaml
|
||||
backend:
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://elmeg:elmeg@db:5432/elmeg
|
||||
- AWS_ACCESS_KEY_ID=AKIA...
|
||||
- AWS_SECRET_ACCESS_KEY=...
|
||||
- AWS_SES_REGION=us-east-1
|
||||
- EMAIL_FROM=noreply@elmeg.xyz
|
||||
- FRONTEND_URL=https://elmeg.runfoo.run
|
||||
```
|
||||
|
||||
Or create `.env` file:
|
||||
|
||||
```env
|
||||
AWS_ACCESS_KEY_ID=AKIA...
|
||||
AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||
AWS_SES_REGION=us-east-1
|
||||
EMAIL_FROM=noreply@elmeg.xyz
|
||||
FRONTEND_URL=https://elmeg.runfoo.run
|
||||
```
|
||||
|
||||
### 5. Add boto3 to Requirements
|
||||
|
||||
```bash
|
||||
# Add to backend/requirements.txt
|
||||
boto3>=1.28.0
|
||||
```
|
||||
|
||||
### 6. Run Migration & Restart
|
||||
|
||||
```bash
|
||||
docker compose build backend
|
||||
docker compose exec backend python migrations/add_email_verification.py
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
### 7. Test
|
||||
|
||||
1. Register at <https://elmeg.runfoo.run/register>
|
||||
2. Check for verification email
|
||||
3. Test forgot password at <https://elmeg.runfoo.run/forgot-password>
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `AWS_ACCESS_KEY_ID` | IAM user access key | `AKIA...` |
|
||||
| `AWS_SECRET_ACCESS_KEY` | IAM user secret | `wJalr...` |
|
||||
| `AWS_SES_REGION` | SES region | `us-east-1` |
|
||||
| `EMAIL_FROM` | Verified sender address | `noreply@elmeg.xyz` |
|
||||
| `FRONTEND_URL` | Base URL for links | `https://elmeg.runfoo.run` |
|
||||
|
||||
---
|
||||
|
||||
## Cost
|
||||
|
||||
- $0.10 per 1,000 emails
|
||||
- No monthly minimum
|
||||
- First 62,000 emails/month free if sending from EC2
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "Email address is not verified" | Domain not verified in SES, or still in sandbox |
|
||||
| "Access Denied" | Check IAM permissions for ses:SendEmail |
|
||||
| Emails not sending | Check `docker compose logs backend` |
|
||||
| Emails in spam | Complete DKIM setup, verify domain |
|
||||
Loading…
Add table
Reference in a new issue