Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions EMAIL_FORWARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ External Sender
- 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"`)
- Queries Airtable for mapping (must have `status = "active"` or `"past_due"`)
- Fetches raw email from S3
- Parses and reconstructs email with new headers:
- `From:` changes to `noreply@coders.operationcode.org`
Expand Down Expand Up @@ -123,11 +123,11 @@ 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`: Must be `"active"` or `"past_due"` for forwarding to work

**Status Values**:
- `active`: Forwarding enabled
- `lapsed`: Payment issue (still forwards, but marked)
- `past_due`: Payment issue, grace period (forwarding still enabled)
- `cancelled`: Forwarding disabled

### 3. Lambda Functions
Expand Down Expand Up @@ -277,11 +277,11 @@ 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)
- Set `Status` to `past_due`
3. **Email forwarding continues** (Lambda's Airtable filter matches `active` or `past_due`)
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.
If the subscription is later cancelled, set `Status` to `cancelled` (or any value other than `active`/`past_due`) to stop forwarding.

## Security Considerations

Expand Down Expand Up @@ -316,7 +316,7 @@ For 10-20 active aliases receiving ~50 emails/month each:
- Check for Airtable API errors
2. **Verify Airtable**:
- Record exists for alias
- `Status` is `"active"`
- `Status` is `"active"` or `"past_due"`
- `Email` field is populated
3. **Check S3**: Verify email object exists in bucket
4. **SES Receipt Rule**: Ensure rule set is active
Expand Down
4 changes: 2 additions & 2 deletions lambda/ses_email_forwarder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This Lambda function forwards emails received by AWS SES to personal email addre
When a donor with recurring donations receives a custom email alias (e.g., `john@coders.operationcode.org`), this Lambda function:
1. Receives the email via SES
2. Checks Airtable for the alias mapping
3. Validates the donor's status is "active"
3. Validates the donor's status is "active" or "past_due"
4. Forwards the email to the donor's personal email address

## Environment Variables
Expand Down Expand Up @@ -61,7 +61,7 @@ pytest tests/ -v
4. Lambda:
- Retrieves email from S3
- Queries Airtable for alias mapping
- Validates donor status is "active"
- Validates donor status is "active" or "past_due"
- Rewrites headers (From, Reply-To)
- Sends email via SES to personal email
5. Original sender receives replies via Reply-To header
Expand Down
15 changes: 8 additions & 7 deletions lambda/ses_email_forwarder/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,14 @@ def init_sentry():
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.
Returns the record if found and status is active or past_due, None otherwise.
past_due is included so forwarding continues during a payment grace period.

Args:
alias: The email alias (local part before @)

Returns:
dict or None: The Airtable record fields if found and active
dict or None: The Airtable record fields if found and active or past_due
"""
credentials = get_airtable_credentials()
airtable_api_key = credentials['airtable_api_key']
Expand All @@ -126,10 +127,10 @@ def lookup_alias_in_airtable(alias: str) -> dict | None:

url = f"https://api.airtable.com/v0/{airtable_base_id}/{urllib.parse.quote(airtable_table_name)}"

# Filter for exact alias match and active status
# Filter for exact alias match and status of active or past_due
# Note: Airtable field names are case-sensitive
params = urllib.parse.urlencode({
'filterByFormula': f"AND({{Alias}} = '{alias}', {{Status}} = 'active')",
'filterByFormula': f"AND({{Alias}} = '{alias}', OR({{Status}} = 'active', {{Status}} = 'past_due'))",
'maxRecords': 1
})

Expand All @@ -148,9 +149,9 @@ def lookup_alias_in_airtable(alias: str) -> dict | None:
data = json.loads(response.read().decode())
records = data.get('records', [])
if records:
print(f"Found active alias mapping for: {alias}")
print(f"Found forwardable alias mapping for: {alias}")
return records[0]['fields']
print(f"No active alias mapping found for: {alias}")
print(f"No forwardable alias mapping found for: {alias}")
return None
except urllib.error.HTTPError as e:
error_body = e.read().decode()
Expand Down Expand Up @@ -343,7 +344,7 @@ def lambda_handler(event, context):
mapping = lookup_alias_in_airtable(alias)

if not mapping:
print(f"No active mapping found for alias: {alias}")
print(f"No forwardable mapping found for alias: {alias}")
# Silently drop emails to unknown aliases
continue

Expand Down
29 changes: 29 additions & 0 deletions lambda/ses_email_forwarder/tests/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,35 @@ def test_lookup_alias_active(self, mock_urlopen):
self.assertEqual(result['Email'], 'test@example.com')
self.assertEqual(result['Name'], 'Test User')

@patch('handler.urllib.request.urlopen')
def test_lookup_alias_past_due(self, mock_urlopen):
"""Test looking up a past_due alias in Airtable (still forwards)."""
with patch.object(handler, 'get_airtable_credentials', return_value={
'airtable_api_key': 'test_key',
'airtable_base_id': 'test_base',
'airtable_table_name': 'Email Aliases'
}):
mock_response = MagicMock()
mock_response.read.return_value = json.dumps({
'records': [{
'id': 'rec124',
'fields': {
'Alias': 'testuser',
'Email': 'test@example.com',
'Name': 'Test User',
'Status': 'past_due'
}
}]
}).encode()
mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response

result = handler.lookup_alias_in_airtable('testuser')

self.assertIsNotNone(result)
self.assertEqual(result['Email'], 'test@example.com')
self.assertEqual(result['Name'], 'Test User')

@patch('handler.urllib.request.urlopen')
def test_lookup_alias_not_found(self, mock_urlopen):
"""Test looking up a non-existent alias."""
Expand Down
5 changes: 4 additions & 1 deletion terraform/route53.tf
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ resource "aws_route53_record" "coders_spf" {
name = "coders.operationcode.org"
type = "TXT"
ttl = 300
records = ["v=spf1 include:amazonses.com ~all"]
records = [
"v=spf1 include:amazonses.com ~all",
"anthropic-domain-verification-59rpeq=AndztPpfh6dzVbbixaTcxhWXS",
]
}

# DKIM records (3 tokens from SES)
Expand Down
2 changes: 1 addition & 1 deletion terraform/ses_email_forwarding/data.tf
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
data "archive_file" "lambda_zip" {
type = "zip"
source_dir = "${path.module}/../../lambda/ses_email_forwarder"
output_path = "${path.module}/lambda_function.zip"
output_path = "${path.module}/ses_email_forwarder.zip"

excludes = [
"tests",
Expand Down
Loading