|
| 1 | +# Email Forwarding System for Operation Code |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +The email forwarding system allows Operation Code donors with recurring donations to receive personalized email aliases at `@coders.operationcode.org`. Emails sent to these aliases are automatically forwarded to the donor's personal email address. |
| 6 | + |
| 7 | +## System Architecture |
| 8 | + |
| 9 | +``` |
| 10 | +External Sender |
| 11 | + │ |
| 12 | + ▼ |
| 13 | +┌─────────────────────────────────────────┐ |
| 14 | +│ Route 53 DNS │ |
| 15 | +│ • MX: coders → SES inbound endpoint │ |
| 16 | +│ • SPF, DKIM records for authentication │ |
| 17 | +│ • Custom MAIL FROM (bounce subdomain) │ |
| 18 | +│ • DMARC policy (quarantine) │ |
| 19 | +└─────────────────────────────────────────┘ |
| 20 | + │ |
| 21 | + ▼ |
| 22 | +┌─────────────────────────────────────────┐ |
| 23 | +│ AWS SES (Email Receiving) │ |
| 24 | +│ • Receives all @coders.operationcode │ |
| 25 | +│ • Receipt rule set (active) │ |
| 26 | +│ • Spam/virus scanning │ |
| 27 | +└─────────────────────────────────────────┘ |
| 28 | + │ |
| 29 | + ├──────────────────┬─────────────────┐ |
| 30 | + ▼ ▼ ▼ |
| 31 | +┌────────────┐ ┌─────────────────┐ ┌────────────────┐ |
| 32 | +│ S3 Bucket │ │ Lambda Function │ │ Configuration │ |
| 33 | +│ │ │ Email │ │ Set │ |
| 34 | +│ Stores raw │ │ Forwarder │ │ │ |
| 35 | +│ emails for │ │ │ │ Routes bounces │ |
| 36 | +│ 7 days │ │ 1. Parse alias │ │ & complaints │ |
| 37 | +│ │ │ 2. Query │ │ to SNS │ |
| 38 | +│ │ │ Airtable │ │ │ |
| 39 | +│ │ │ 3. Fetch from │ └────────────────┘ |
| 40 | +│ │ │ S3 │ │ |
| 41 | +│ │ │ 4. Rewrite │ │ |
| 42 | +│ │ │ headers │ ▼ |
| 43 | +│ │ │ 5. Forward via │ ┌────────────────┐ |
| 44 | +│ │ │ SES │ │ SNS Topics │ |
| 45 | +└────────────┘ └─────────────────┘ │ • Bounces │ |
| 46 | + │ │ • Complaints │ |
| 47 | + ┌─────────────────┼────────────┘ │ |
| 48 | + │ │ │ |
| 49 | + ▼ ▼ ▼ |
| 50 | +┌────────────┐ ┌──────────────┐ ┌──────────────────┐ |
| 51 | +│ Airtable │ │ SES Sending │ │ Lambda Function │ |
| 52 | +│ │ │ │ │ Bounce │ |
| 53 | +│ Email │ │ Forwards to │ │ Handler │ |
| 54 | +│ Aliases │ │ personal │ │ │ |
| 55 | +│ Base │ │ email │ │ Updates Airtable │ |
| 56 | +│ │ │ │ │ on bounces │ |
| 57 | +│ Fields: │ │ From: │ └──────────────────┘ |
| 58 | +│ • Alias │ │ noreply@ │ |
| 59 | +│ • Email │ │ coders... │ |
| 60 | +│ • Name │ │ │ |
| 61 | +│ • Status │ │ Envelope: │ |
| 62 | +│ │ │ bounce. │ |
| 63 | +│ │ │ coders... │ |
| 64 | +└────────────┘ └──────────────┘ |
| 65 | +``` |
| 66 | + |
| 67 | +## How Email Flows |
| 68 | + |
| 69 | +### Incoming Email Flow |
| 70 | + |
| 71 | +1. **External sender** sends email to `john482@coders.operationcode.org` |
| 72 | +2. **DNS (Route 53)** directs email to AWS SES via MX record |
| 73 | +3. **SES** receives email and applies receipt rules: |
| 74 | + - Action 1: Store raw email in S3 bucket |
| 75 | + - Action 2: Invoke Lambda function for email forwarding |
| 76 | +4. **Lambda** processes the email: |
| 77 | + - Extracts alias (`john482`) from recipient address |
| 78 | + - Queries Airtable for mapping (must have `status = "active"`) |
| 79 | + - Fetches raw email from S3 |
| 80 | + - Parses and reconstructs email with new headers: |
| 81 | + - `From:` changes to `noreply@coders.operationcode.org` |
| 82 | + - `Reply-To:` set to original sender |
| 83 | + - `To:` set to donor's personal email |
| 84 | + - Original headers preserved in `X-Original-*` headers |
| 85 | + - Sends forwarded email via SES using configuration set |
| 86 | +5. **SES** sends email with: |
| 87 | + - **From header**: `noreply@coders.operationcode.org` |
| 88 | + - **Envelope sender (MAIL FROM)**: `bounce.coders.operationcode.org` |
| 89 | + - DKIM signature applied |
| 90 | +6. **Recipient** receives email at their personal address |
| 91 | + |
| 92 | +### Bounce/Complaint Handling |
| 93 | + |
| 94 | +1. **SES** detects bounce or complaint |
| 95 | +2. **Configuration set** routes event to appropriate SNS topic |
| 96 | +3. **SNS** invokes Lambda function for bounce/complaint handling |
| 97 | +4. **Lambda** processes notification: |
| 98 | + - Parses bounce/complaint data |
| 99 | + - Updates Airtable record status if needed |
| 100 | + - Logs event details |
| 101 | + |
| 102 | +## Key Components |
| 103 | + |
| 104 | +### 1. DNS Configuration (Route53) |
| 105 | + |
| 106 | +**Main Domain Records** (`coders.operationcode.org`): |
| 107 | +- **MX**: Points to SES inbound endpoint |
| 108 | +- **SPF (TXT)**: Authorizes SES to send mail |
| 109 | +- **DKIM (CNAME × 3)**: Email authentication signatures |
| 110 | +- **DMARC (TXT)**: Email policy with quarantine enforcement |
| 111 | + |
| 112 | +**Custom MAIL FROM Subdomain** (`bounce.coders.operationcode.org`): |
| 113 | +- **MX**: Points to SES feedback endpoint |
| 114 | +- **SPF (TXT)**: Authorizes SES for bounce handling |
| 115 | + |
| 116 | +This configuration enables **DMARC alignment** by ensuring the envelope sender domain matches the organizational domain. |
| 117 | + |
| 118 | +### 2. Airtable Database |
| 119 | + |
| 120 | +**Table**: `Email Aliases` |
| 121 | + |
| 122 | +Critical fields used by the system: |
| 123 | +- `Alias`: Email alias (e.g., `john482`) |
| 124 | +- `Email`: Destination email address |
| 125 | +- `Name`: Donor name (used in logging) |
| 126 | +- `Status`: Must be `"active"` for forwarding to work |
| 127 | + |
| 128 | +**Status Values**: |
| 129 | +- `active`: Forwarding enabled |
| 130 | +- `lapsed`: Payment issue (still forwards, but marked) |
| 131 | +- `cancelled`: Forwarding disabled |
| 132 | + |
| 133 | +### 3. Lambda Functions |
| 134 | + |
| 135 | +#### Email Forwarder Lambda |
| 136 | +- **Runtime**: Python 3.12 (arm64) |
| 137 | +- **Timeout**: 30 seconds |
| 138 | +- **Memory**: 256 MB |
| 139 | +- **Trigger**: SES receipt rule |
| 140 | +- **Purpose**: Forward emails to donors |
| 141 | + |
| 142 | +**Environment Variables**: |
| 143 | +- `EMAIL_BUCKET`: S3 bucket name |
| 144 | +- `AIRTABLE_SECRET_NAME`: Secrets Manager secret reference (cross-region) |
| 145 | +- `FORWARD_FROM_EMAIL`: Configured no-reply address |
| 146 | +- `AWS_SES_REGION`: SES region |
| 147 | +- `ENVIRONMENT`: Environment name |
| 148 | + |
| 149 | +**Permissions**: |
| 150 | +- Read from S3 bucket |
| 151 | +- Send raw email via SES |
| 152 | +- Read secrets from Secrets Manager (us-east-2) |
| 153 | +- Write CloudWatch Logs |
| 154 | + |
| 155 | +#### Bounce Handler Lambda |
| 156 | +- **Runtime**: Python 3.12 (arm64) |
| 157 | +- **Timeout**: 30 seconds |
| 158 | +- **Memory**: 256 MB |
| 159 | +- **Trigger**: SNS topics (bounces and complaints) |
| 160 | +- **Purpose**: Track delivery issues in Airtable |
| 161 | + |
| 162 | +**Environment Variables**: |
| 163 | +- `AIRTABLE_SECRET_NAME`: Secrets Manager secret reference |
| 164 | +- `ENVIRONMENT`: Environment name |
| 165 | + |
| 166 | +**Permissions**: |
| 167 | +- Read secrets from Secrets Manager |
| 168 | +- Write CloudWatch Logs |
| 169 | + |
| 170 | +### 4. S3 Bucket |
| 171 | + |
| 172 | +**Region**: `us-east-1` |
| 173 | + |
| 174 | +**Features**: |
| 175 | +- Server-side encryption (AES256) |
| 176 | +- Lifecycle policy: Delete objects after 7 days |
| 177 | +- Bucket policy: Only SES can write, only Lambda can read |
| 178 | + |
| 179 | +### 5. SES Configuration |
| 180 | + |
| 181 | +**Domain Identity**: `coders.operationcode.org` |
| 182 | +- DKIM enabled (3 CNAME records) |
| 183 | +- Custom MAIL FROM domain: `bounce.coders.operationcode.org` |
| 184 | + |
| 185 | +**Receipt Rule Set**: Active rule set configured to: |
| 186 | +- Receive all emails to `coders.operationcode.org` |
| 187 | +- **Actions**: |
| 188 | + 1. Store in S3 |
| 189 | + 2. Invoke Lambda |
| 190 | + |
| 191 | +**Configuration Set**: Configured to: |
| 192 | +- Route bounce events to SNS topic |
| 193 | +- Route complaint events to SNS topic |
| 194 | +- Enable reputation metrics |
| 195 | + |
| 196 | +## Email Authentication & Deliverability |
| 197 | + |
| 198 | +### DKIM (DomainKeys Identified Mail) |
| 199 | +- AWS SES automatically signs all outgoing emails |
| 200 | +- Three DKIM selectors provide redundancy |
| 201 | +- Validates that email came from Operation Code domain |
| 202 | + |
| 203 | +### SPF (Sender Policy Framework) |
| 204 | +Records at two levels: |
| 205 | +1. **Main domain** (`coders.operationcode.org`): Authorizes SES to send |
| 206 | +2. **Bounce subdomain** (`bounce.coders.operationcode.org`): Authorizes SES for envelope sender |
| 207 | + |
| 208 | +### DMARC (Domain-based Message Authentication) |
| 209 | +- **Policy**: `p=quarantine` (failed emails go to spam) |
| 210 | +- **Alignment**: `adkim=r; aspf=r` (relaxed mode) |
| 211 | + - Allows `bounce.coders.operationcode.org` to align with `coders.operationcode.org` |
| 212 | + - Allows `noreply@coders.operationcode.org` in From header |
| 213 | +- **Coverage**: `pct=100` (applies to all messages) |
| 214 | + |
| 215 | +### Custom MAIL FROM Domain |
| 216 | +- **Purpose**: Achieves DMARC alignment |
| 217 | +- **Implementation**: `bounce.coders.operationcode.org` |
| 218 | +- **How it works**: |
| 219 | + - Email headers show: `From: noreply@coders.operationcode.org` |
| 220 | + - Email envelope shows: `MAIL FROM: bounce.coders.operationcode.org` |
| 221 | + - Both domains share organizational domain (`operationcode.org`) |
| 222 | + - DMARC passes with relaxed alignment |
| 223 | +- **Fallback**: If DNS fails, SES uses `amazonses.com` as envelope sender |
| 224 | + |
| 225 | +## Secrets Management |
| 226 | + |
| 227 | +Sensitive credentials stored in **AWS Secrets Manager**: |
| 228 | + |
| 229 | +**Configuration**: |
| 230 | +- Cross-region access (Lambda in us-east-1, Secrets in us-east-2) |
| 231 | +- Contains Airtable API credentials and table configuration |
| 232 | +- Contains monitoring/alerting DSN for error tracking |
| 233 | + |
| 234 | +**Secret Contents**: |
| 235 | +- Airtable API key |
| 236 | +- Airtable base ID and table name |
| 237 | +- Error monitoring DSN |
| 238 | + |
| 239 | +## Monitoring & Observability |
| 240 | + |
| 241 | +### CloudWatch Logs |
| 242 | +- Lambda functions log to CloudWatch Logs (14-day retention) |
| 243 | +- Logs capture: |
| 244 | + - Incoming email metadata |
| 245 | + - Alias lookups (success/failure) |
| 246 | + - Forwarding operations |
| 247 | + - Errors and exceptions |
| 248 | + |
| 249 | +### Error Monitoring Integration |
| 250 | +- Both Lambda functions integrated with error monitoring service |
| 251 | +- Error tracking and alerting |
| 252 | +- Transaction sampling for performance monitoring |
| 253 | +- Environment tagging for filtering |
| 254 | + |
| 255 | +### SES Metrics |
| 256 | +- Configuration set enables reputation tracking |
| 257 | +- Bounce and complaint rates monitored |
| 258 | +- Available in SES console |
| 259 | + |
| 260 | +## Provisioning New Aliases |
| 261 | + |
| 262 | +The system is designed to integrate with external automation (e.g., Zapier): |
| 263 | + |
| 264 | +1. **Stripe subscription created** (recurring donation) |
| 265 | +2. **Automation generates alias** (e.g., `firstname123`) |
| 266 | +3. **Airtable record created** with: |
| 267 | + - `Alias`: Generated alias |
| 268 | + - `Email`: Donor's email address |
| 269 | + - `Name`: Donor's name |
| 270 | + - `Status`: `active` |
| 271 | + - `Stripe customer_id` and `subscription_id` |
| 272 | +4. **Email immediately functional** (no infrastructure changes needed) |
| 273 | + |
| 274 | +## Handling Lapsed Payments |
| 275 | + |
| 276 | +When a payment fails: |
| 277 | + |
| 278 | +1. **Stripe webhook** triggers (e.g., `invoice.payment_failed`) |
| 279 | +2. **Automation updates Airtable** record: |
| 280 | + - Set `Status` to `lapsed` |
| 281 | +3. **Email forwarding continues** (status check looks for "active" but system is lenient) |
| 282 | +4. **Notification sent** to admin channel |
| 283 | + |
| 284 | +**Note**: Current implementation forwards emails regardless of status. If strict enforcement is needed, Lambda code can be modified to check status. |
| 285 | + |
| 286 | +## Security Considerations |
| 287 | + |
| 288 | +1. **Secrets**: Stored in Secrets Manager, never in code or environment variables |
| 289 | +2. **S3 Bucket**: Private, restrictive bucket policy |
| 290 | +3. **Email Retention**: Automatic deletion after 7 days |
| 291 | +4. **Spam Protection**: SES provides built-in scanning |
| 292 | +5. **Cross-Region Access**: Lambda in us-east-1 securely reads secrets from us-east-2 |
| 293 | +6. **IAM Roles**: Least-privilege permissions for all resources |
| 294 | + |
| 295 | +## Cost Estimate |
| 296 | + |
| 297 | +For 10-20 active aliases receiving ~50 emails/month each: |
| 298 | + |
| 299 | +**Monthly AWS Costs**: |
| 300 | +- SES Receiving: ~$0.10 |
| 301 | +- SES Sending: ~$0.10 |
| 302 | +- S3 Storage & Requests: ~$0.02 |
| 303 | +- Lambda: $0.00 (within free tier) |
| 304 | +- Secrets Manager: ~$0.40/secret/month |
| 305 | + |
| 306 | +**Total**: ~$0.60-0.80/month |
| 307 | + |
| 308 | +**Note**: First 12 months may be less due to AWS Free Tier covering SES and Lambda usage. |
| 309 | + |
| 310 | +## Troubleshooting |
| 311 | + |
| 312 | +### Email Not Forwarded |
| 313 | + |
| 314 | +1. **Check CloudWatch Logs**: Lambda function logs |
| 315 | + - Look for alias lookup failures |
| 316 | + - Check for Airtable API errors |
| 317 | +2. **Verify Airtable**: |
| 318 | + - Record exists for alias |
| 319 | + - `Status` is `"active"` |
| 320 | + - `Email` field is populated |
| 321 | +3. **Check S3**: Verify email object exists in bucket |
| 322 | +4. **SES Receipt Rule**: Ensure rule set is active |
| 323 | + |
| 324 | +### Emails Going to Spam |
| 325 | + |
| 326 | +1. **Check DKIM**: Verify all 3 CNAME records are in DNS |
| 327 | +2. **Check SPF**: Verify both SPF records exist (main + bounce subdomain) |
| 328 | +3. **Check DMARC**: Verify DMARC record exists and alignment is working |
| 329 | +4. **Check Email Headers**: Look for authentication results |
| 330 | + |
| 331 | +### DNS Issues |
| 332 | + |
| 333 | +Use these commands to verify DNS propagation: |
| 334 | +```bash |
| 335 | +dig MX coders.operationcode.org |
| 336 | +dig TXT coders.operationcode.org |
| 337 | +dig TXT bounce.coders.operationcode.org |
| 338 | +dig MX bounce.coders.operationcode.org |
| 339 | +dig TXT _dmarc.coders.operationcode.org |
| 340 | +``` |
| 341 | + |
| 342 | +## Implementation Files |
| 343 | + |
| 344 | +**Infrastructure (Terraform)**: |
| 345 | +- [terraform/ses_email_forwarding.tf](terraform/ses_email_forwarding.tf) - Module invocation |
| 346 | +- [terraform/ses_email_forwarding/main.tf](terraform/ses_email_forwarding/main.tf) - SES and Lambda resources |
| 347 | +- [terraform/ses_email_forwarding/bounce_handling.tf](terraform/ses_email_forwarding/bounce_handling.tf) - Bounce handling infrastructure |
| 348 | +- [terraform/route53.tf](terraform/route53.tf) - DNS records |
| 349 | + |
| 350 | +**Application Code**: |
| 351 | +- [lambda/ses_email_forwarder/handler.py](lambda/ses_email_forwarder/handler.py) - Email forwarding logic |
| 352 | +- [lambda/ses_bounce_handler/handler.py](lambda/ses_bounce_handler/handler.py) - Bounce processing logic |
| 353 | + |
| 354 | +**Documentation**: |
| 355 | +- [plans/ses-email-forwarding-guide.md](plans/ses-email-forwarding-guide.md) - Original implementation plan |
| 356 | + |
| 357 | +## Differences from Original Plan |
| 358 | + |
| 359 | +The actual implementation differs from the original plan in these ways: |
| 360 | + |
| 361 | +1. **Secrets Management**: Uses AWS Secrets Manager instead of Lambda environment variables |
| 362 | +2. **Cross-Region Architecture**: Secrets in us-east-2, SES/Lambda in us-east-1 |
| 363 | +3. **Bounce Handling**: Added comprehensive bounce/complaint handling with SNS and second Lambda |
| 364 | +4. **DMARC Configuration**: Added custom MAIL FROM domain and DMARC policy with quarantine |
| 365 | +5. **Error Monitoring**: Added error monitoring and alerting integration |
| 366 | +6. **Airtable Field Names**: Uses `Email` and `Alias` instead of `personal_email` and `alias` |
| 367 | +7. **Configuration Set**: Added for bounce tracking and reputation metrics |
| 368 | +8. **Encryption**: S3 bucket uses server-side encryption |
| 369 | + |
| 370 | +## Future Enhancements |
| 371 | + |
| 372 | +Potential improvements to consider: |
| 373 | + |
| 374 | +1. **DLQ (Dead Letter Queue)**: Capture and retry failed forwarding attempts |
| 375 | +2. **CloudWatch Alarms**: Alert on high error rates or unusual volume |
| 376 | +3. **Email Analytics**: Dashboard showing forwarding metrics |
| 377 | +4. **Alias Validation**: Prevent duplicate aliases at creation time |
| 378 | +5. **Self-Service Portal**: Allow donors to manage their own aliases |
| 379 | +6. **Reply-From Feature**: Enable sending FROM the alias address (requires additional SES configuration) |
0 commit comments