Skip to content

Commit 54bff55

Browse files
authored
Fix DMARC (#186)
* Fix DMARC * add documentation
1 parent b734d5a commit 54bff55

File tree

5 files changed

+440
-1
lines changed

5 files changed

+440
-1
lines changed

EMAIL_FORWARDING.md

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
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

Comments
 (0)