Skip to content

Add SES inbound email infrastructure#22

Merged
snopoke merged 21 commits intomainfrom
sk/inbound-email
Apr 29, 2026
Merged

Add SES inbound email infrastructure#22
snopoke merged 21 commits intomainfrom
sk/inbound-email

Conversation

@snopoke
Copy link
Copy Markdown
Contributor

@snopoke snopoke commented Apr 29, 2026

Summary

Adds AWS infrastructure to support inbound email for Open Chat Studio's email channel (dimagi/open-chat-studio#3175).

  • New SesInboundStack: S3 bucket (7-day lifecycle on inbound/, BlockPublicAccess), SNS topic, anymail webhook secret (URL-safe, auto-generated), ReceiptRuleSet with one rule whose recipients are the configured domains and actions are [S3Action, SnsAction(Base64)], and an HTTPS SNS subscription whose endpoint embeds the secret via a CFN dynamic reference.
  • DomainStack extended to create one DKIM-verified EmailIdentity per domain in [EMAIL_DOMAIN] + EMAIL_INBOUND_DOMAINS (CSV, optional).
  • FargateStack consumes the new resources: sns:ConfirmSubscription on the topic ARN, s3:GetObject on <bucket>/inbound/*, plus ANYMAIL_WEBHOOK_SECRET injected into all task definitions.
  • README runbook added (deploy ordering, MX/DKIM records, manual set-active-receipt-rule-set step).
  • 116 pytest tests pass; full cdk synth --all clean.

Design

Spec: docs/superpowers/specs/2026-04-28-inbound-email-design.md
Plan: docs/superpowers/plans/2026-04-28-inbound-email.md

Key decisions: S3+SNS receipt action (supports up to 10 MB attachments; SNS-only caps near 150 KB), one shared receipt rule for all domains, secret embedded via cdk.SecretValue.secrets_manager(...).unsafe_unwrap(), bucket policy hardened with both aws:SourceAccount and aws:SourceArn conditions.

Test Plan

  • Run `uv run pytest ocs_deploy/tests/` — all 116 tests pass.
  • `uv run cdk diff -c ocs_env=dev` against an existing environment — confirm only additive changes (new SesInboundStack resources, new EmailIdentities if extras configured, new IAM statements on Fargate task role).
  • After deploy: `aws ses describe-active-receipt-rule-set` returns the new rule set; send a test email to a configured inbound domain; confirm raw mail lands in `s3:///inbound/` and Django CloudWatch logs show `anymail.signals.inbound` firing.
  • Test with a 10 MB attachment to confirm S3 path works end-to-end.

🤖 Generated with Claude Code

snopoke and others added 21 commits April 28, 2026 15:15
Pairs with dimagi/open-chat-studio#3175. Covers a new SesInboundStack
(S3+SNS receipt action, anymail webhook secret, multi-domain receipt
rule), DomainStack extended to loop over EMAIL_INBOUND_DOMAINS, and the
fargate task-role IAM additions (sns:ConfirmSubscription, s3:GetObject).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
10-task TDD plan to pair with the 2026-04-28 inbound-email design doc.
Each task uses pytest + cdk.assertions.Template for synth-level assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Use stack output references and CDK_REGION instead of hard-coded values
that only happen to be correct when APP_NAME=ocs and region=us-east-1.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…in fargate

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The tests/unit/test_ocs_deploy_stack.py file imported a module that
doesn't exist (ocs_deploy.ocs_deploy_stack), causing a collection error
when running plain `pytest` from the repo root. With it removed, plain
`pytest` discovers only ocs_deploy/tests/ and runs all 116 tests cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Runs the test suite on every pull request and push to main using uv
(picks Python from .python-version) and Node 22 (jsii's supported
release).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
CloudFormation does not resolve {{resolve:secretsmanager:...}} dynamic
references for AWS::SNS::Subscription.Endpoint, so embedding the webhook
secret in the HTTPS subscription URL fails at deploy with "Invalid
parameter: HTTP(S) Endpoint URL". Subscribe a small Python Lambda to the
topic instead; it fetches the webhook secret from Secrets Manager at
runtime and POSTs the SNS notification to anymail's webhook with HTTP
Basic auth.

Also add an optional ANYMAIL_WEBHOOK_DOMAIN config knob (defaulting to
DOMAIN_NAME) so the webhook host can be pinned independently during the
in-flight domain migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Anymail base64-decodes the Authorization header value and string-compares
the decoded result verbatim to ANYMAIL["WEBHOOK_SECRET"] (verified in
django-anymail's get_request_basic_auth + AnymailBasicAuthMixin.validate_request).
The previous Lambda sent base64("<secret>:<secret>") which would only
match if the consumer munged its config the same way. Use the constant
"anymail" username instead so the auth header is standard HTTP Basic and
the Django-side config is a clean f"anymail:{ANYMAIL_WEBHOOK_SECRET}".
README runbook now documents the Django-side WEBHOOK_SECRET format.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@snopoke snopoke merged commit 16bfd95 into main Apr 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant