Skip to content

Commit 7e66067

Browse files
irvingpopclaude
andauthored
Forward email for past_due donor status, add Anthropic domain verification TXT (#187)
The email forwarder Lambda only forwarded when Airtable Status was "active", dropping mail during a Stripe payment grace period. The Airtable filter now matches active or past_due. Also includes an unrelated pending change: an Anthropic domain verification TXT value added to the coders.operationcode.org SPF recordset, and a rename of the email forwarder Lambda's build artifact from the generic lambda_function.zip to ses_email_forwarder.zip for symmetry with bounce_handler.zip. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 54bff55 commit 7e66067

6 files changed

Lines changed: 51 additions & 18 deletions

File tree

EMAIL_FORWARDING.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ External Sender
7575
- Action 2: Invoke Lambda function for email forwarding
7676
4. **Lambda** processes the email:
7777
- Extracts alias (`john482`) from recipient address
78-
- Queries Airtable for mapping (must have `status = "active"`)
78+
- Queries Airtable for mapping (must have `status = "active"` or `"past_due"`)
7979
- Fetches raw email from S3
8080
- Parses and reconstructs email with new headers:
8181
- `From:` changes to `noreply@coders.operationcode.org`
@@ -123,11 +123,11 @@ Critical fields used by the system:
123123
- `Alias`: Email alias (e.g., `john482`)
124124
- `Email`: Destination email address
125125
- `Name`: Donor name (used in logging)
126-
- `Status`: Must be `"active"` for forwarding to work
126+
- `Status`: Must be `"active"` or `"past_due"` for forwarding to work
127127

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

133133
### 3. Lambda Functions
@@ -277,11 +277,11 @@ When a payment fails:
277277

278278
1. **Stripe webhook** triggers (e.g., `invoice.payment_failed`)
279279
2. **Automation updates Airtable** record:
280-
- Set `Status` to `lapsed`
281-
3. **Email forwarding continues** (status check looks for "active" but system is lenient)
280+
- Set `Status` to `past_due`
281+
3. **Email forwarding continues** (Lambda's Airtable filter matches `active` or `past_due`)
282282
4. **Notification sent** to admin channel
283283

284-
**Note**: Current implementation forwards emails regardless of status. If strict enforcement is needed, Lambda code can be modified to check status.
284+
If the subscription is later cancelled, set `Status` to `cancelled` (or any value other than `active`/`past_due`) to stop forwarding.
285285

286286
## Security Considerations
287287

@@ -316,7 +316,7 @@ For 10-20 active aliases receiving ~50 emails/month each:
316316
- Check for Airtable API errors
317317
2. **Verify Airtable**:
318318
- Record exists for alias
319-
- `Status` is `"active"`
319+
- `Status` is `"active"` or `"past_due"`
320320
- `Email` field is populated
321321
3. **Check S3**: Verify email object exists in bucket
322322
4. **SES Receipt Rule**: Ensure rule set is active

lambda/ses_email_forwarder/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ This Lambda function forwards emails received by AWS SES to personal email addre
77
When a donor with recurring donations receives a custom email alias (e.g., `john@coders.operationcode.org`), this Lambda function:
88
1. Receives the email via SES
99
2. Checks Airtable for the alias mapping
10-
3. Validates the donor's status is "active"
10+
3. Validates the donor's status is "active" or "past_due"
1111
4. Forwards the email to the donor's personal email address
1212

1313
## Environment Variables
@@ -61,7 +61,7 @@ pytest tests/ -v
6161
4. Lambda:
6262
- Retrieves email from S3
6363
- Queries Airtable for alias mapping
64-
- Validates donor status is "active"
64+
- Validates donor status is "active" or "past_due"
6565
- Rewrites headers (From, Reply-To)
6666
- Sends email via SES to personal email
6767
5. Original sender receives replies via Reply-To header

lambda/ses_email_forwarder/handler.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,14 @@ def init_sentry():
111111
def lookup_alias_in_airtable(alias: str) -> dict | None:
112112
"""
113113
Query Airtable to find the mapping for a given alias.
114-
Returns the record if found and active, None otherwise.
114+
Returns the record if found and status is active or past_due, None otherwise.
115+
past_due is included so forwarding continues during a payment grace period.
115116
116117
Args:
117118
alias: The email alias (local part before @)
118119
119120
Returns:
120-
dict or None: The Airtable record fields if found and active
121+
dict or None: The Airtable record fields if found and active or past_due
121122
"""
122123
credentials = get_airtable_credentials()
123124
airtable_api_key = credentials['airtable_api_key']
@@ -126,10 +127,10 @@ def lookup_alias_in_airtable(alias: str) -> dict | None:
126127

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

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

@@ -148,9 +149,9 @@ def lookup_alias_in_airtable(alias: str) -> dict | None:
148149
data = json.loads(response.read().decode())
149150
records = data.get('records', [])
150151
if records:
151-
print(f"Found active alias mapping for: {alias}")
152+
print(f"Found forwardable alias mapping for: {alias}")
152153
return records[0]['fields']
153-
print(f"No active alias mapping found for: {alias}")
154+
print(f"No forwardable alias mapping found for: {alias}")
154155
return None
155156
except urllib.error.HTTPError as e:
156157
error_body = e.read().decode()
@@ -343,7 +344,7 @@ def lambda_handler(event, context):
343344
mapping = lookup_alias_in_airtable(alias)
344345

345346
if not mapping:
346-
print(f"No active mapping found for alias: {alias}")
347+
print(f"No forwardable mapping found for alias: {alias}")
347348
# Silently drop emails to unknown aliases
348349
continue
349350

lambda/ses_email_forwarder/tests/test_handler.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,35 @@ def test_lookup_alias_active(self, mock_urlopen):
103103
self.assertEqual(result['Email'], 'test@example.com')
104104
self.assertEqual(result['Name'], 'Test User')
105105

106+
@patch('handler.urllib.request.urlopen')
107+
def test_lookup_alias_past_due(self, mock_urlopen):
108+
"""Test looking up a past_due alias in Airtable (still forwards)."""
109+
with patch.object(handler, 'get_airtable_credentials', return_value={
110+
'airtable_api_key': 'test_key',
111+
'airtable_base_id': 'test_base',
112+
'airtable_table_name': 'Email Aliases'
113+
}):
114+
mock_response = MagicMock()
115+
mock_response.read.return_value = json.dumps({
116+
'records': [{
117+
'id': 'rec124',
118+
'fields': {
119+
'Alias': 'testuser',
120+
'Email': 'test@example.com',
121+
'Name': 'Test User',
122+
'Status': 'past_due'
123+
}
124+
}]
125+
}).encode()
126+
mock_response.__enter__.return_value = mock_response
127+
mock_urlopen.return_value = mock_response
128+
129+
result = handler.lookup_alias_in_airtable('testuser')
130+
131+
self.assertIsNotNone(result)
132+
self.assertEqual(result['Email'], 'test@example.com')
133+
self.assertEqual(result['Name'], 'Test User')
134+
106135
@patch('handler.urllib.request.urlopen')
107136
def test_lookup_alias_not_found(self, mock_urlopen):
108137
"""Test looking up a non-existent alias."""

terraform/route53.tf

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ resource "aws_route53_record" "coders_spf" {
1818
name = "coders.operationcode.org"
1919
type = "TXT"
2020
ttl = 300
21-
records = ["v=spf1 include:amazonses.com ~all"]
21+
records = [
22+
"v=spf1 include:amazonses.com ~all",
23+
"anthropic-domain-verification-59rpeq=AndztPpfh6dzVbbixaTcxhWXS",
24+
]
2225
}
2326

2427
# DKIM records (3 tokens from SES)

terraform/ses_email_forwarding/data.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
data "archive_file" "lambda_zip" {
33
type = "zip"
44
source_dir = "${path.module}/../../lambda/ses_email_forwarder"
5-
output_path = "${path.module}/lambda_function.zip"
5+
output_path = "${path.module}/ses_email_forwarder.zip"
66

77
excludes = [
88
"tests",

0 commit comments

Comments
 (0)