From dbb651e0812c8af1871741f8afb0260b6e7bf96b Mon Sep 17 00:00:00 2001 From: Irving Popovetsky Date: Thu, 29 Jan 2026 10:22:57 -0800 Subject: [PATCH 1/2] Fix DMARC --- terraform/route53.tf | 23 ++++++++++++++++++++++- terraform/ses_email_forwarding.tf | 13 +++++++++++++ terraform/ses_email_forwarding/main.tf | 11 +++++++++++ terraform/ses_email_forwarding/outputs.tf | 15 +++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/terraform/route53.tf b/terraform/route53.tf index 9c037fe..4e14ae9 100644 --- a/terraform/route53.tf +++ b/terraform/route53.tf @@ -31,11 +31,32 @@ resource "aws_route53_record" "coders_dkim" { records = ["${module.ses_email_forwarder.ses_dkim_tokens[count.index]}.dkim.amazonses.com"] } +# Custom MAIL FROM domain - MX record +resource "aws_route53_record" "coders_bounce_mx" { + zone_id = data.aws_route53_zone.operationcode.zone_id + name = "bounce.coders.operationcode.org" + type = "MX" + ttl = 300 + records = ["10 feedback-smtp.us-east-1.amazonses.com"] +} + +# Custom MAIL FROM domain - SPF record +resource "aws_route53_record" "coders_bounce_spf" { + zone_id = data.aws_route53_zone.operationcode.zone_id + name = "bounce.coders.operationcode.org" + type = "TXT" + ttl = 300 + records = ["v=spf1 include:amazonses.com ~all"] +} + # DMARC record for email policy +# p=quarantine: Failed authentication emails are sent to spam +# adkim=r, aspf=r: Relaxed alignment (allows subdomain alignment like bounce.coders.operationcode.org) +# pct=100: Apply policy to 100% of failing messages resource "aws_route53_record" "coders_dmarc" { zone_id = data.aws_route53_zone.operationcode.zone_id name = "_dmarc.coders.operationcode.org" type = "TXT" ttl = 300 - records = ["v=DMARC1; p=none; rua=mailto:admin@operationcode.org"] + records = ["v=DMARC1; p=quarantine; adkim=r; aspf=r; pct=100"] } diff --git a/terraform/ses_email_forwarding.tf b/terraform/ses_email_forwarding.tf index fe64c6d..821b00a 100644 --- a/terraform/ses_email_forwarding.tf +++ b/terraform/ses_email_forwarding.tf @@ -30,3 +30,16 @@ output "ses_dkim_tokens" { description = "DKIM tokens for DNS configuration" value = module.ses_email_forwarder.ses_dkim_tokens } + +output "ses_mail_from_domain" { + description = "Custom MAIL FROM domain for DMARC alignment" + value = module.ses_email_forwarder.mail_from_domain +} + +output "ses_mail_from_dns_records" { + description = "DNS records required for custom MAIL FROM domain" + value = { + mx_record = "MX: ${module.ses_email_forwarder.mail_from_domain} -> ${module.ses_email_forwarder.mail_from_mx_record}" + spf_record = "TXT: ${module.ses_email_forwarder.mail_from_domain} -> ${module.ses_email_forwarder.mail_from_spf_record}" + } +} diff --git a/terraform/ses_email_forwarding/main.tf b/terraform/ses_email_forwarding/main.tf index 647a2a9..7e820ad 100644 --- a/terraform/ses_email_forwarding/main.tf +++ b/terraform/ses_email_forwarding/main.tf @@ -204,6 +204,17 @@ resource "aws_ses_domain_dkim" "coders" { domain = aws_ses_domain_identity.coders.domain } +# Custom MAIL FROM domain for DMARC alignment +# This configures SES to use bounce.coders.operationcode.org as the envelope sender +resource "aws_ses_domain_mail_from" "coders" { + domain = aws_ses_domain_identity.coders.domain + mail_from_domain = "bounce.${aws_ses_domain_identity.coders.domain}" + + # BehaviorOnMXFailure: UseDefaultValue = use amazonses.com if DNS fails + # RejectMessage = reject emails if DNS fails (more strict) + behavior_on_mx_failure = "UseDefaultValue" +} + # SES Receipt Rule Set resource "aws_ses_receipt_rule_set" "main" { rule_set_name = "coders-email-forwarding" diff --git a/terraform/ses_email_forwarding/outputs.tf b/terraform/ses_email_forwarding/outputs.tf index b7675c6..df7c12a 100644 --- a/terraform/ses_email_forwarding/outputs.tf +++ b/terraform/ses_email_forwarding/outputs.tf @@ -48,3 +48,18 @@ output "ses_configuration_set_name" { description = "Name of the SES configuration set" value = aws_ses_configuration_set.main.name } + +output "mail_from_domain" { + description = "Custom MAIL FROM domain for DMARC alignment" + value = aws_ses_domain_mail_from.coders.mail_from_domain +} + +output "mail_from_mx_record" { + description = "MX record value for the custom MAIL FROM domain (add this to DNS)" + value = "10 feedback-smtp.us-east-1.amazonses.com" +} + +output "mail_from_spf_record" { + description = "SPF TXT record value for the custom MAIL FROM domain (add this to DNS)" + value = "v=spf1 include:amazonses.com ~all" +} From 47fa1170eb11deea99ee16af8318e0515860b6bd Mon Sep 17 00:00:00 2001 From: Irving Popovetsky Date: Thu, 29 Jan 2026 10:44:35 -0800 Subject: [PATCH 2/2] add documentation --- EMAIL_FORWARDING.md | 379 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 EMAIL_FORWARDING.md diff --git a/EMAIL_FORWARDING.md b/EMAIL_FORWARDING.md new file mode 100644 index 0000000..28f9881 --- /dev/null +++ b/EMAIL_FORWARDING.md @@ -0,0 +1,379 @@ +# Email Forwarding System for Operation Code + +## Overview + +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. + +## System Architecture + +``` +External Sender + │ + ▼ +┌─────────────────────────────────────────┐ +│ Route 53 DNS │ +│ • MX: coders → SES inbound endpoint │ +│ • SPF, DKIM records for authentication │ +│ • Custom MAIL FROM (bounce subdomain) │ +│ • DMARC policy (quarantine) │ +└─────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ AWS SES (Email Receiving) │ +│ • Receives all @coders.operationcode │ +│ • Receipt rule set (active) │ +│ • Spam/virus scanning │ +└─────────────────────────────────────────┘ + │ + ├──────────────────┬─────────────────┐ + ▼ ▼ ▼ +┌────────────┐ ┌─────────────────┐ ┌────────────────┐ +│ S3 Bucket │ │ Lambda Function │ │ Configuration │ +│ │ │ Email │ │ Set │ +│ Stores raw │ │ Forwarder │ │ │ +│ emails for │ │ │ │ Routes bounces │ +│ 7 days │ │ 1. Parse alias │ │ & complaints │ +│ │ │ 2. Query │ │ to SNS │ +│ │ │ Airtable │ │ │ +│ │ │ 3. Fetch from │ └────────────────┘ +│ │ │ S3 │ │ +│ │ │ 4. Rewrite │ │ +│ │ │ headers │ ▼ +│ │ │ 5. Forward via │ ┌────────────────┐ +│ │ │ SES │ │ SNS Topics │ +└────────────┘ └─────────────────┘ │ • Bounces │ + │ │ • Complaints │ + ┌─────────────────┼────────────┘ │ + │ │ │ + ▼ ▼ ▼ +┌────────────┐ ┌──────────────┐ ┌──────────────────┐ +│ Airtable │ │ SES Sending │ │ Lambda Function │ +│ │ │ │ │ Bounce │ +│ Email │ │ Forwards to │ │ Handler │ +│ Aliases │ │ personal │ │ │ +│ Base │ │ email │ │ Updates Airtable │ +│ │ │ │ │ on bounces │ +│ Fields: │ │ From: │ └──────────────────┘ +│ • Alias │ │ noreply@ │ +│ • Email │ │ coders... │ +│ • Name │ │ │ +│ • Status │ │ Envelope: │ +│ │ │ bounce. │ +│ │ │ coders... │ +└────────────┘ └──────────────┘ +``` + +## How Email Flows + +### Incoming Email Flow + +1. **External sender** sends email to `john482@coders.operationcode.org` +2. **DNS (Route 53)** directs email to AWS SES via MX record +3. **SES** receives email and applies receipt rules: + - Action 1: Store raw email in S3 bucket + - Action 2: Invoke Lambda function for email forwarding +4. **Lambda** processes the email: + - Extracts alias (`john482`) from recipient address + - Queries Airtable for mapping (must have `status = "active"`) + - Fetches raw email from S3 + - Parses and reconstructs email with new headers: + - `From:` changes to `noreply@coders.operationcode.org` + - `Reply-To:` set to original sender + - `To:` set to donor's personal email + - Original headers preserved in `X-Original-*` headers + - Sends forwarded email via SES using configuration set +5. **SES** sends email with: + - **From header**: `noreply@coders.operationcode.org` + - **Envelope sender (MAIL FROM)**: `bounce.coders.operationcode.org` + - DKIM signature applied +6. **Recipient** receives email at their personal address + +### Bounce/Complaint Handling + +1. **SES** detects bounce or complaint +2. **Configuration set** routes event to appropriate SNS topic +3. **SNS** invokes Lambda function for bounce/complaint handling +4. **Lambda** processes notification: + - Parses bounce/complaint data + - Updates Airtable record status if needed + - Logs event details + +## Key Components + +### 1. DNS Configuration (Route53) + +**Main Domain Records** (`coders.operationcode.org`): +- **MX**: Points to SES inbound endpoint +- **SPF (TXT)**: Authorizes SES to send mail +- **DKIM (CNAME × 3)**: Email authentication signatures +- **DMARC (TXT)**: Email policy with quarantine enforcement + +**Custom MAIL FROM Subdomain** (`bounce.coders.operationcode.org`): +- **MX**: Points to SES feedback endpoint +- **SPF (TXT)**: Authorizes SES for bounce handling + +This configuration enables **DMARC alignment** by ensuring the envelope sender domain matches the organizational domain. + +### 2. Airtable Database + +**Table**: `Email Aliases` + +Critical fields used by the system: +- `Alias`: Email alias (e.g., `john482`) +- `Email`: Destination email address +- `Name`: Donor name (used in logging) +- `Status`: Must be `"active"` for forwarding to work + +**Status Values**: +- `active`: Forwarding enabled +- `lapsed`: Payment issue (still forwards, but marked) +- `cancelled`: Forwarding disabled + +### 3. Lambda Functions + +#### Email Forwarder Lambda +- **Runtime**: Python 3.12 (arm64) +- **Timeout**: 30 seconds +- **Memory**: 256 MB +- **Trigger**: SES receipt rule +- **Purpose**: Forward emails to donors + +**Environment Variables**: +- `EMAIL_BUCKET`: S3 bucket name +- `AIRTABLE_SECRET_NAME`: Secrets Manager secret reference (cross-region) +- `FORWARD_FROM_EMAIL`: Configured no-reply address +- `AWS_SES_REGION`: SES region +- `ENVIRONMENT`: Environment name + +**Permissions**: +- Read from S3 bucket +- Send raw email via SES +- Read secrets from Secrets Manager (us-east-2) +- Write CloudWatch Logs + +#### Bounce Handler Lambda +- **Runtime**: Python 3.12 (arm64) +- **Timeout**: 30 seconds +- **Memory**: 256 MB +- **Trigger**: SNS topics (bounces and complaints) +- **Purpose**: Track delivery issues in Airtable + +**Environment Variables**: +- `AIRTABLE_SECRET_NAME`: Secrets Manager secret reference +- `ENVIRONMENT`: Environment name + +**Permissions**: +- Read secrets from Secrets Manager +- Write CloudWatch Logs + +### 4. S3 Bucket + +**Region**: `us-east-1` + +**Features**: +- Server-side encryption (AES256) +- Lifecycle policy: Delete objects after 7 days +- Bucket policy: Only SES can write, only Lambda can read + +### 5. SES Configuration + +**Domain Identity**: `coders.operationcode.org` +- DKIM enabled (3 CNAME records) +- Custom MAIL FROM domain: `bounce.coders.operationcode.org` + +**Receipt Rule Set**: Active rule set configured to: +- Receive all emails to `coders.operationcode.org` +- **Actions**: + 1. Store in S3 + 2. Invoke Lambda + +**Configuration Set**: Configured to: +- Route bounce events to SNS topic +- Route complaint events to SNS topic +- Enable reputation metrics + +## Email Authentication & Deliverability + +### DKIM (DomainKeys Identified Mail) +- AWS SES automatically signs all outgoing emails +- Three DKIM selectors provide redundancy +- Validates that email came from Operation Code domain + +### SPF (Sender Policy Framework) +Records at two levels: +1. **Main domain** (`coders.operationcode.org`): Authorizes SES to send +2. **Bounce subdomain** (`bounce.coders.operationcode.org`): Authorizes SES for envelope sender + +### DMARC (Domain-based Message Authentication) +- **Policy**: `p=quarantine` (failed emails go to spam) +- **Alignment**: `adkim=r; aspf=r` (relaxed mode) + - Allows `bounce.coders.operationcode.org` to align with `coders.operationcode.org` + - Allows `noreply@coders.operationcode.org` in From header +- **Coverage**: `pct=100` (applies to all messages) + +### Custom MAIL FROM Domain +- **Purpose**: Achieves DMARC alignment +- **Implementation**: `bounce.coders.operationcode.org` +- **How it works**: + - Email headers show: `From: noreply@coders.operationcode.org` + - Email envelope shows: `MAIL FROM: bounce.coders.operationcode.org` + - Both domains share organizational domain (`operationcode.org`) + - DMARC passes with relaxed alignment +- **Fallback**: If DNS fails, SES uses `amazonses.com` as envelope sender + +## Secrets Management + +Sensitive credentials stored in **AWS Secrets Manager**: + +**Configuration**: +- Cross-region access (Lambda in us-east-1, Secrets in us-east-2) +- Contains Airtable API credentials and table configuration +- Contains monitoring/alerting DSN for error tracking + +**Secret Contents**: +- Airtable API key +- Airtable base ID and table name +- Error monitoring DSN + +## Monitoring & Observability + +### CloudWatch Logs +- Lambda functions log to CloudWatch Logs (14-day retention) +- Logs capture: + - Incoming email metadata + - Alias lookups (success/failure) + - Forwarding operations + - Errors and exceptions + +### Error Monitoring Integration +- Both Lambda functions integrated with error monitoring service +- Error tracking and alerting +- Transaction sampling for performance monitoring +- Environment tagging for filtering + +### SES Metrics +- Configuration set enables reputation tracking +- Bounce and complaint rates monitored +- Available in SES console + +## Provisioning New Aliases + +The system is designed to integrate with external automation (e.g., Zapier): + +1. **Stripe subscription created** (recurring donation) +2. **Automation generates alias** (e.g., `firstname123`) +3. **Airtable record created** with: + - `Alias`: Generated alias + - `Email`: Donor's email address + - `Name`: Donor's name + - `Status`: `active` + - `Stripe customer_id` and `subscription_id` +4. **Email immediately functional** (no infrastructure changes needed) + +## Handling Lapsed Payments + +When a payment fails: + +1. **Stripe webhook** triggers (e.g., `invoice.payment_failed`) +2. **Automation updates Airtable** record: + - Set `Status` to `lapsed` +3. **Email forwarding continues** (status check looks for "active" but system is lenient) +4. **Notification sent** to admin channel + +**Note**: Current implementation forwards emails regardless of status. If strict enforcement is needed, Lambda code can be modified to check status. + +## Security Considerations + +1. **Secrets**: Stored in Secrets Manager, never in code or environment variables +2. **S3 Bucket**: Private, restrictive bucket policy +3. **Email Retention**: Automatic deletion after 7 days +4. **Spam Protection**: SES provides built-in scanning +5. **Cross-Region Access**: Lambda in us-east-1 securely reads secrets from us-east-2 +6. **IAM Roles**: Least-privilege permissions for all resources + +## Cost Estimate + +For 10-20 active aliases receiving ~50 emails/month each: + +**Monthly AWS Costs**: +- SES Receiving: ~$0.10 +- SES Sending: ~$0.10 +- S3 Storage & Requests: ~$0.02 +- Lambda: $0.00 (within free tier) +- Secrets Manager: ~$0.40/secret/month + +**Total**: ~$0.60-0.80/month + +**Note**: First 12 months may be less due to AWS Free Tier covering SES and Lambda usage. + +## Troubleshooting + +### Email Not Forwarded + +1. **Check CloudWatch Logs**: Lambda function logs + - Look for alias lookup failures + - Check for Airtable API errors +2. **Verify Airtable**: + - Record exists for alias + - `Status` is `"active"` + - `Email` field is populated +3. **Check S3**: Verify email object exists in bucket +4. **SES Receipt Rule**: Ensure rule set is active + +### Emails Going to Spam + +1. **Check DKIM**: Verify all 3 CNAME records are in DNS +2. **Check SPF**: Verify both SPF records exist (main + bounce subdomain) +3. **Check DMARC**: Verify DMARC record exists and alignment is working +4. **Check Email Headers**: Look for authentication results + +### DNS Issues + +Use these commands to verify DNS propagation: +```bash +dig MX coders.operationcode.org +dig TXT coders.operationcode.org +dig TXT bounce.coders.operationcode.org +dig MX bounce.coders.operationcode.org +dig TXT _dmarc.coders.operationcode.org +``` + +## Implementation Files + +**Infrastructure (Terraform)**: +- [terraform/ses_email_forwarding.tf](terraform/ses_email_forwarding.tf) - Module invocation +- [terraform/ses_email_forwarding/main.tf](terraform/ses_email_forwarding/main.tf) - SES and Lambda resources +- [terraform/ses_email_forwarding/bounce_handling.tf](terraform/ses_email_forwarding/bounce_handling.tf) - Bounce handling infrastructure +- [terraform/route53.tf](terraform/route53.tf) - DNS records + +**Application Code**: +- [lambda/ses_email_forwarder/handler.py](lambda/ses_email_forwarder/handler.py) - Email forwarding logic +- [lambda/ses_bounce_handler/handler.py](lambda/ses_bounce_handler/handler.py) - Bounce processing logic + +**Documentation**: +- [plans/ses-email-forwarding-guide.md](plans/ses-email-forwarding-guide.md) - Original implementation plan + +## Differences from Original Plan + +The actual implementation differs from the original plan in these ways: + +1. **Secrets Management**: Uses AWS Secrets Manager instead of Lambda environment variables +2. **Cross-Region Architecture**: Secrets in us-east-2, SES/Lambda in us-east-1 +3. **Bounce Handling**: Added comprehensive bounce/complaint handling with SNS and second Lambda +4. **DMARC Configuration**: Added custom MAIL FROM domain and DMARC policy with quarantine +5. **Error Monitoring**: Added error monitoring and alerting integration +6. **Airtable Field Names**: Uses `Email` and `Alias` instead of `personal_email` and `alias` +7. **Configuration Set**: Added for bounce tracking and reputation metrics +8. **Encryption**: S3 bucket uses server-side encryption + +## Future Enhancements + +Potential improvements to consider: + +1. **DLQ (Dead Letter Queue)**: Capture and retry failed forwarding attempts +2. **CloudWatch Alarms**: Alert on high error rates or unusual volume +3. **Email Analytics**: Dashboard showing forwarding metrics +4. **Alias Validation**: Prevent duplicate aliases at creation time +5. **Self-Service Portal**: Allow donors to manage their own aliases +6. **Reply-From Feature**: Enable sending FROM the alias address (requires additional SES configuration)