From 425df5cc84b476059d70d2786026ee262c04a826 Mon Sep 17 00:00:00 2001 From: Irving Popovetsky Date: Sun, 9 Aug 2026 15:44:29 -0700 Subject: [PATCH] Forward email for past_due donor status, add Anthropic domain verification TXT 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 --- EMAIL_FORWARDING.md | 14 ++++----- lambda/ses_email_forwarder/README.md | 4 +-- lambda/ses_email_forwarder/handler.py | 15 +++++----- .../ses_email_forwarder/tests/test_handler.py | 29 +++++++++++++++++++ terraform/route53.tf | 5 +++- terraform/ses_email_forwarding/data.tf | 2 +- 6 files changed, 51 insertions(+), 18 deletions(-) diff --git a/EMAIL_FORWARDING.md b/EMAIL_FORWARDING.md index 28f9881..e1bf372 100644 --- a/EMAIL_FORWARDING.md +++ b/EMAIL_FORWARDING.md @@ -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` @@ -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 @@ -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 @@ -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 diff --git a/lambda/ses_email_forwarder/README.md b/lambda/ses_email_forwarder/README.md index c290238..17b6772 100644 --- a/lambda/ses_email_forwarder/README.md +++ b/lambda/ses_email_forwarder/README.md @@ -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 @@ -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 diff --git a/lambda/ses_email_forwarder/handler.py b/lambda/ses_email_forwarder/handler.py index 1866981..3e72307 100644 --- a/lambda/ses_email_forwarder/handler.py +++ b/lambda/ses_email_forwarder/handler.py @@ -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'] @@ -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 }) @@ -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() @@ -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 diff --git a/lambda/ses_email_forwarder/tests/test_handler.py b/lambda/ses_email_forwarder/tests/test_handler.py index 9f2941d..99fc984 100644 --- a/lambda/ses_email_forwarder/tests/test_handler.py +++ b/lambda/ses_email_forwarder/tests/test_handler.py @@ -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.""" diff --git a/terraform/route53.tf b/terraform/route53.tf index 4e14ae9..6f83346 100644 --- a/terraform/route53.tf +++ b/terraform/route53.tf @@ -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) diff --git a/terraform/ses_email_forwarding/data.tf b/terraform/ses_email_forwarding/data.tf index 2f36104..9fae31d 100644 --- a/terraform/ses_email_forwarding/data.tf +++ b/terraform/ses_email_forwarding/data.tf @@ -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",