This document describes the architecture and implementation plan for an email forwarding system that:
- Allows donors who set up recurring donations to receive a custom email alias (e.g.,
john@coders.operationcode.org) - Forwards emails sent to that alias to the donor's personal email address
- Stores alias mappings in Airtable (integrated with existing automation workflows)
- Notifies a Slack channel when new aliases are created
- Monitors for lapsed payments and alerts accordingly
┌─────────────────────────────────────────────────────────────────────────────────┐
│ Route 53 │
│ MX record: coders.operationcode.org → inbound-smtp.us-east-1.amazonaws.com │
│ TXT records: SPF, DKIM verification │
└─────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│ AWS SES (Email Receiving) │
│ │
│ Receipt Rule Set: "coders-email-forwarding" │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ Rule: "forward-to-members" │ │
│ │ Recipients: coders.operationcode.org │ │
│ │ Actions: │ │
│ │ 1. Store in S3 (opcode-ses-incoming-emails bucket) │ │
│ │ 2. Invoke Lambda (ses-email-forwarder) │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ S3 Bucket │ │ Lambda: ses-email-forwarder │
│ opcode-ses-incoming-emails/ │ │ │
│ └── emails/{message-id} │ │ 1. Parse recipient (alias) │
│ (raw email stored) │ │ 2. Query Airtable for mapping │
│ │ │ 3. Fetch email from S3 │
└──────────────────────────────────┘ │ 4. Rewrite headers │
│ 5. Forward via SES │
└──────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────┐
│ Airtable │ │ SES Send │
│ │ │ │
│ Email Aliases │ │ Forward to │
│ Base/Table │ │ personal │
│ │ │ email │
└──────────────────┘ └──────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ NEW DONOR PROVISIONING │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Stripe Payment Link ──► Stripe Subscription Created ──► Zapier Trigger │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Generate Alias │ │
│ │ (firstname123) │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Create Airtable │ │
│ │ Record │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Slack Notify │ │
│ │ #new-members │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ LAPSED PAYMENT HANDLING │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ Stripe ──► invoice.payment_failed ──► Zapier Trigger │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Find Airtable │ │
│ │ Record by Email │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Update Status │ │
│ │ → "lapsed" │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Slack Alert │ │
│ │ #payment-issues │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
Add these records to the operationcode.org hosted zone for the coders subdomain:
| Type | Name | Value | TTL |
|---|---|---|---|
| MX | coders.operationcode.org | 10 inbound-smtp.us-east-1.amazonaws.com |
300 |
| TXT | coders.operationcode.org | v=spf1 include:amazonses.com ~all |
300 |
| CNAME | {selector1}._domainkey.coders.operationcode.org |
{provided by SES} |
300 |
| CNAME | {selector2}._domainkey.coders.operationcode.org |
{provided by SES} |
300 |
| CNAME | {selector3}._domainkey.coders.operationcode.org |
{provided by SES} |
300 |
Note: The DKIM CNAME records will be provided by SES during domain verification. There will be 3 of them.
Base Name: Operation Code Automation (or existing base)
Table Name: Email Aliases
| Field Name | Field Type | Description | Example |
|---|---|---|---|
alias |
Single line text (Primary) | The local part of the email | john482 |
full_email |
Formula | {alias} & "@coders.operationcode.org" |
john482@coders.operationcode.org |
personal_email |
Donor's real email address | john@gmail.com |
|
donor_name |
Single line text | Full name | John Smith |
status |
Single select | Options: active, lapsed, cancelled |
active |
stripe_customer_id |
Single line text | For payment tracking | cus_ABC123 |
stripe_subscription_id |
Single line text | Subscription reference | sub_XYZ789 |
last_payment_date |
Date | Last successful payment | 2026-01-15 |
created_at |
Created time | Auto-populated | 2026-01-01 |
notes |
Long text | Admin notes |
Views to Create:
Active Aliases- Filter: status = "active"Lapsed (30+ days)- Filter: status = "lapsed" OR last_payment_date < 30 days agoAll Aliases- No filter
Bucket Name: opcode-ses-incoming-emails
Configuration:
- Region:
us-east-1(must match SES region) - Versioning: Disabled (optional, enable if you want email history)
- Encryption: SSE-S3 (default)
- Lifecycle Rule: Delete objects after 7 days (emails are ephemeral, just for forwarding)
Bucket Policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSESPuts",
"Effect": "Allow",
"Principal": {
"Service": "ses.amazonaws.com"
},
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::opcode-ses-incoming-emails/*",
"Condition": {
"StringEquals": {
"AWS:SourceAccount": "${AWS_ACCOUNT_ID}"
}
}
}
]
}Runtime: Python 3.12
Memory: 256 MB
Timeout: 30 seconds
Architecture: arm64 (Graviton, cheaper)
Environment Variables:
| Variable | Value |
|---|---|
EMAIL_BUCKET |
opcode-ses-incoming-emails |
AIRTABLE_API_KEY |
pat... (Personal Access Token) |
AIRTABLE_BASE_ID |
app... (from Airtable URL) |
AIRTABLE_TABLE_NAME |
Email Aliases |
FORWARD_FROM_EMAIL |
noreply@coders.operationcode.org |
AWS_SES_REGION |
us-east-1 |
IAM Role Policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::opcode-ses-incoming-emails/*"
},
{
"Effect": "Allow",
"Action": [
"ses:SendRawEmail"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}Lambda Function Code:
import boto3
import email
import os
import json
import urllib.request
import urllib.error
from email import policy
from email.parser import BytesParser
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
# Configuration from environment variables
EMAIL_BUCKET = os.environ['EMAIL_BUCKET']
AIRTABLE_API_KEY = os.environ['AIRTABLE_API_KEY']
AIRTABLE_BASE_ID = os.environ['AIRTABLE_BASE_ID']
AIRTABLE_TABLE_NAME = os.environ['AIRTABLE_TABLE_NAME']
FORWARD_FROM_EMAIL = os.environ['FORWARD_FROM_EMAIL']
AWS_SES_REGION = os.environ.get('AWS_SES_REGION', 'us-east-1')
s3_client = boto3.client('s3')
ses_client = boto3.client('ses', region_name=AWS_SES_REGION)
def lookup_alias_in_airtable(alias: str) -> dict | None:
"""
Query Airtable to find the mapping for a given alias.
Returns the record if found and active, None otherwise.
"""
url = f"https://api.airtable.com/v0/{AIRTABLE_BASE_ID}/{urllib.parse.quote(AIRTABLE_TABLE_NAME)}"
# Filter for exact alias match
params = urllib.parse.urlencode({
'filterByFormula': f"AND({{alias}} = '{alias}', {{status}} = 'active')",
'maxRecords': 1
})
full_url = f"{url}?{params}"
req = urllib.request.Request(
full_url,
headers={
'Authorization': f'Bearer {AIRTABLE_API_KEY}',
'Content-Type': 'application/json'
}
)
try:
with urllib.request.urlopen(req) as response:
data = json.loads(response.read().decode())
records = data.get('records', [])
if records:
return records[0]['fields']
return None
except urllib.error.HTTPError as e:
print(f"Airtable API error: {e.code} - {e.read().decode()}")
return None
def get_email_from_s3(message_id: str) -> bytes:
"""Retrieve the raw email from S3."""
response = s3_client.get_object(
Bucket=EMAIL_BUCKET,
Key=message_id
)
return response['Body'].read()
def forward_email(raw_email: bytes, forward_to: str, original_recipient: str) -> dict:
"""
Parse the original email and forward it to the destination address.
Rewrites headers to comply with SES requirements while preserving
the original sender information.
"""
# Parse the original email
original_msg = BytesParser(policy=policy.default).parsebytes(raw_email)
# Extract original headers
original_from = original_msg['From']
original_subject = original_msg['Subject'] or '(no subject)'
original_to = original_msg['To']
original_date = original_msg['Date']
original_message_id = original_msg['Message-ID']
# Create new message
new_msg = MIMEMultipart('mixed')
# Set headers for forwarded message
# SES requires From to be a verified identity
new_msg['From'] = FORWARD_FROM_EMAIL
new_msg['To'] = forward_to
new_msg['Subject'] = original_subject
new_msg['Reply-To'] = original_from # Replies go to original sender
# Add custom headers to preserve original info
new_msg['X-Original-From'] = original_from
new_msg['X-Original-To'] = original_recipient
new_msg['X-Forwarded-For'] = original_recipient
# Handle multipart messages (with attachments) vs simple messages
if original_msg.is_multipart():
# Copy all parts from original message
for part in original_msg.walk():
content_type = part.get_content_type()
content_disposition = str(part.get('Content-Disposition', ''))
if content_type == 'multipart/mixed' or content_type == 'multipart/alternative':
continue
if 'attachment' in content_disposition:
# Handle attachments
new_part = MIMEBase(*content_type.split('/'))
new_part.set_payload(part.get_payload(decode=True))
encoders.encode_base64(new_part)
new_part.add_header(
'Content-Disposition',
'attachment',
filename=part.get_filename() or 'attachment'
)
new_msg.attach(new_part)
else:
# Handle body parts
payload = part.get_payload(decode=True)
if payload:
if content_type == 'text/plain':
new_msg.attach(MIMEText(payload.decode('utf-8', errors='replace'), 'plain'))
elif content_type == 'text/html':
new_msg.attach(MIMEText(payload.decode('utf-8', errors='replace'), 'html'))
else:
# Simple message without attachments
payload = original_msg.get_payload(decode=True)
if payload:
content_type = original_msg.get_content_type()
if content_type == 'text/html':
new_msg.attach(MIMEText(payload.decode('utf-8', errors='replace'), 'html'))
else:
new_msg.attach(MIMEText(payload.decode('utf-8', errors='replace'), 'plain'))
# Send via SES
response = ses_client.send_raw_email(
Source=FORWARD_FROM_EMAIL,
Destinations=[forward_to],
RawMessage={'Data': new_msg.as_bytes()}
)
return response
def handler(event, context):
"""
Lambda handler for SES incoming email events.
Event structure:
{
"Records": [{
"eventSource": "aws:ses",
"eventVersion": "1.0",
"ses": {
"mail": {
"messageId": "...",
"source": "sender@example.com",
"destination": ["recipient@coders.operationcode.org"]
},
"receipt": {
"recipients": ["recipient@coders.operationcode.org"],
...
}
}
}]
}
"""
print(f"Received event: {json.dumps(event)}")
for record in event.get('Records', []):
ses_data = record.get('ses', {})
mail_data = ses_data.get('mail', {})
message_id = mail_data.get('messageId')
recipients = mail_data.get('destination', [])
source = mail_data.get('source', 'unknown')
print(f"Processing message {message_id} from {source} to {recipients}")
for recipient in recipients:
# Extract alias from recipient address
# e.g., "john482@coders.operationcode.org" -> "john482"
if '@' not in recipient:
print(f"Invalid recipient format: {recipient}")
continue
alias = recipient.split('@')[0].lower()
print(f"Looking up alias: {alias}")
# Query Airtable for the mapping
mapping = lookup_alias_in_airtable(alias)
if not mapping:
print(f"No active mapping found for alias: {alias}")
# Optionally: bounce the email or silently drop
continue
forward_to = mapping.get('personal_email')
donor_name = mapping.get('donor_name', 'Member')
if not forward_to:
print(f"No personal_email in mapping for alias: {alias}")
continue
print(f"Forwarding to: {forward_to} ({donor_name})")
try:
# Get the raw email from S3
raw_email = get_email_from_s3(message_id)
# Forward it
response = forward_email(raw_email, forward_to, recipient)
print(f"Successfully forwarded. SES MessageId: {response.get('MessageId')}")
except Exception as e:
print(f"Error forwarding email: {str(e)}")
raise
return {
'statusCode': 200,
'body': 'Processed'
}Required Python Packages:
- None beyond standard library (boto3 is included in Lambda runtime)
Lambda Resource-based Policy (allow SES to invoke):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowSESInvoke",
"Effect": "Allow",
"Principal": {
"Service": "ses.amazonaws.com"
},
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:${AWS_ACCOUNT_ID}:function:ses-email-forwarder",
"Condition": {
"StringEquals": {
"AWS:SourceAccount": "${AWS_ACCOUNT_ID}"
}
}
}
]
}- Go to SES Console → Identities → Create Identity
- Select "Domain"
- Enter:
coders.operationcode.org - Enable "Easy DKIM"
- SES will provide DNS records to add to Route 53
Rule Set Name: coders-email-forwarding
Rule Configuration:
- Rule Name:
forward-to-members - Recipients:
coders.operationcode.org(catches all addresses on this subdomain) - Actions (in order):
- S3 Action:
- Bucket:
opcode-ses-incoming-emails - Object key prefix: (leave empty)
- Bucket:
- Lambda Action:
- Function:
ses-email-forwarder - Invocation type:
Event(asynchronous)
- Function:
- S3 Action:
Important: The rule set must be set as the "Active" rule set.
After domain verification, verify that noreply@coders.operationcode.org can send emails:
- The domain verification covers all addresses on that domain
- No additional email verification needed
Trigger:
- App: Stripe
- Event: New Subscription
Action 1: Code by Zapier (Generate Alias)
// Input: customer_name, customer_email from Stripe
const firstName = inputData.customer_name.split(' ')[0].toLowerCase();
const randomSuffix = Math.floor(Math.random() * 900) + 100; // 3 digits
const alias = `${firstName}${randomSuffix}`;
return { alias: alias };Action 2: Airtable - Create Record
- Base: Operation Code Automation
- Table: Email Aliases
- Fields:
- alias:
{{alias from Step 2}} - personal_email:
{{Customer Email from Stripe}} - donor_name:
{{Customer Name from Stripe}} - status:
active - stripe_customer_id:
{{Customer ID from Stripe}} - stripe_subscription_id:
{{Subscription ID from Stripe}} - last_payment_date:
{{Current Date}}
- alias:
Action 3: Slack - Send Channel Message
- Channel:
#coders-members(or appropriate channel) - Message:
🎉 *New Coders Member!*
• Name: {{donor_name}}
• Email alias: {{alias}}@coders.operationcode.org
• Forwards to: {{personal_email}}
Trigger:
- App: Stripe
- Event: Invoice Payment Failed
Action 1: Airtable - Find Record
- Base: Operation Code Automation
- Table: Email Aliases
- Search Field:
stripe_customer_id - Search Value:
{{Customer ID from Stripe}}
Action 2: Airtable - Update Record (only if found)
- Record ID:
{{Record ID from Step 2}} - status:
lapsed
Action 3: Slack - Send Channel Message
- Channel:
#coders-admin(or appropriate channel) - Message:
⚠️ *Payment Failed - Member Status Updated*
• Name: {{donor_name from Airtable}}
• Email alias: {{alias}}@coders.operationcode.org
• Personal email: {{personal_email}}
• Stripe Customer: {{Customer ID}}
The member's email forwarding is still active but marked as lapsed.
Trigger:
- App: Stripe
- Event: Subscription Updated (filter for status = "canceled")
Action 1: Airtable - Find Record
- Search by
stripe_subscription_id
Action 2: Airtable - Update Record
- status:
cancelled
Action 3: Slack - Send Channel Message
📧 *Subscription Cancelled*
• Name: {{donor_name}}
• Email alias: {{alias}}@coders.operationcode.org (now inactive)
Trigger:
- App: Schedule by Zapier
- Event: Every Week on Monday
Action 1: Airtable - Find Records
- View:
Lapsed (30+ days)
Action 2: Slack - Send Channel Message
📊 *Weekly Lapsed Members Report*
{{Count}} members with lapsed payments:
{{List of names and aliases}}
- 10-20 active email aliases
- Each user receives ~50 emails/month (500-1000 total incoming)
- Average email size: 50KB
- All emails are forwarded
| Service | Usage | Unit Cost | Monthly Cost |
|---|---|---|---|
| SES Receiving | 1,000 emails | $0.10/1,000 | $0.10 |
| SES Receiving (chunks) | ~200 chunks (larger emails) | $0.09/1,000 | $0.02 |
| SES Sending | 1,000 emails (forwarded) | $0.10/1,000 | $0.10 |
| SES Outbound Data | ~50MB | $0.12/GB | $0.01 |
| S3 Storage | ~50MB (7-day retention) | $0.023/GB | ~$0.00 |
| S3 Requests | ~2,000 PUT/GET | $0.005/1,000 | $0.01 |
| Lambda Invocations | 1,000 | Free tier (1M/mo) | $0.00 |
| Lambda Compute | ~500 GB-seconds | Free tier (400K/mo) | $0.00 |
| Route 53 | Hosted zone already exists | — | $0.00 |
| Airtable | Free tier or existing plan | — | $0.00 |
| Service | Free Tier Allowance | Your Usage | Status |
|---|---|---|---|
| SES | 3,000 messages/mo | ~2,000 | ✅ Covered |
| Lambda Requests | 1M/mo | ~1,000 | ✅ Covered |
| Lambda Compute | 400K GB-sec/mo | ~500 | ✅ Covered |
| S3 Storage | 5GB | ~50MB | ✅ Covered |
First 12 months: Essentially $0
After free tier expires: ~$0.25-0.50/month
-
S3 Bucket
- Create bucket
opcode-ses-incoming-emailsin us-east-1 - Apply bucket policy for SES access
- Configure lifecycle rule (7-day expiration)
- Create bucket
-
SES Domain Verification
- Add
coders.operationcode.orgas identity in SES - Copy DKIM CNAME records
- Request production access (exit sandbox) if not already done
- Add
-
Route 53 DNS Records
- Add MX record for
coderssubdomain - Add SPF TXT record
- Add DKIM CNAME records (3)
- Wait for verification (up to 72 hours, usually faster)
- Add MX record for
-
Lambda Function
- Create IAM role with required permissions
- Deploy
ses-email-forwarderfunction - Configure environment variables
- Add resource-based policy for SES invocation
-
SES Receipt Rules
- Create rule set
coders-email-forwarding - Create rule with S3 + Lambda actions
- Set rule set as active
- Create rule set
- Create
Email Aliasestable with schema above - Create views: Active Aliases, Lapsed, All
- Generate Airtable Personal Access Token
- Test API access
- Create Zap: Stripe Subscription → Airtable + Slack
- Create Zap: Stripe Payment Failed → Update Airtable + Slack
- Create Zap: Stripe Cancelled → Update Airtable + Slack
- (Optional) Create Zap: Weekly lapsed report
- Create test record in Airtable manually
- Send test email to
test@coders.operationcode.org - Verify email arrives at destination
- Test with email containing attachment
- Test non-existent alias (should not forward)
- Test lapsed status (should not forward)
- Simulate Stripe subscription via test mode
- Verify full end-to-end flow
- Check MX record propagation:
dig MX coders.operationcode.org - Verify domain is verified in SES console
- Check that receipt rule set is active
- Check CloudWatch Logs for Lambda function
- Verify Airtable API key is valid
- Check that alias exists in Airtable with status = "active"
- Verify S3 bucket has the email object
- Ensure SPF record is correct
- Verify DKIM is passing (check email headers)
- Consider adding DMARC record:
_dmarc.coders.operationcode.org TXT "v=DMARC1; p=none; rua=mailto:admin@operationcode.org"
- Increase timeout to 60 seconds
- Check Airtable API response time
- Check for large attachments (>10MB may fail)
- Airtable API Key: Store in Lambda environment variables (encrypted at rest)
- S3 Bucket: Not public, only SES can write, only Lambda can read
- Email Content: Stored temporarily in S3, deleted after 7 days
- Spam Protection: SES provides built-in spam/virus scanning
- Rate Limiting: Consider CloudWatch alarm for unusual volume spikes
- User Self-Service: Allow donors to choose their own alias via a web form
- Alias Validation: Check for duplicates before creating
- Email Analytics: Track forwarding success/failure rates
- Bounce Handling: Update Airtable if forwarding fails
- Custom Reply-From: Allow sending FROM the alias (requires more SES config)