SES Email Receiving

Inbound email setup for receiving forwarded invoices from customers.

Overview

Two receiving domains across two AWS accounts, both routing to the same S3 bucket:

Domain AWS Account Profile Use Case
mail.getorcha.com Management orcha Customer-facing, per-client plus-addressing
mail.prod.getorcha.com Production orcha-prod Internal/testing

Flow: SES → S3 (in prod account) → SQS (email-acquire queue) → worker processing

flowchart LR
    subgraph Management Account
        SES1[SES: mail.getorcha.com]
    end
    subgraph Production Account
        SES2[SES: mail.prod.getorcha.com]
        S3[S3 Bucket]
        SQS[email-acquire queue]
        Worker[Email Acquire Worker]
    end
    Customer[Customer Email] -->|documents+clientcode@| SES1
    Customer -->|documents@| SES2
    SES1 -->|cross-account write| S3
    SES2 --> S3
    S3 -->|notification| SQS
    SQS -->|polls| Worker

Two-Account SES Configuration

Management Account (orcha profile)

Handles mail.getorcha.com - the primary customer-facing domain.

Receipt Rule Set: orcha-mail-forwarding

Rule Recipients ScanEnabled Notes
<client>-no-scan documents+<clientcode>@mail.getorcha.com false Per-client rules, bypass spam filter
forward-to-prod-bucket documents@mail.getorcha.com true Catch-all for base address

Rules are evaluated in order. Client-specific rules have a StopAction to prevent the catch-all from also firing.

Why ScanEnabled=false? Some legitimate forwarded invoices trigger SES spam detection. Client-specific rules can disable scanning when needed.

Production Account (orcha-prod profile)

Handles mail.prod.getorcha.com - secondary domain.

Receipt Rule Set: v1-orcha-prod-inbound

Infrastructure defined in infra/stacks/foundation_stack.py.

DNS Structure

Both domains have MX records pointing to SES:

dig MX mail.getorcha.com +short
# 10 inbound-smtp.eu-central-1.amazonaws.com.

dig MX mail.prod.getorcha.com +short
# 10 inbound-smtp.eu-central-1.amazonaws.com.
Account Route53 Zone MX Record Location
Management (orcha) getorcha.com mail.getorcha.com MX record here
Production (orcha-prod) prod.getorcha.com mail.prod.getorcha.com MX record here

Shared Infrastructure (Production Account)

S3 Bucket

SQS Queue

Debugging Checklist

First: Identify which account handles the email based on domain:

1. Verify MX record resolves

dig MX mail.getorcha.com +short
dig MX mail.prod.getorcha.com +short
# Expected: 10 inbound-smtp.eu-central-1.amazonaws.com.

2. Check SES domain verification

# For mail.getorcha.com
aws ses get-identity-verification-attributes \
  --profile orcha --region eu-central-1 \
  --identities mail.getorcha.com

# For mail.prod.getorcha.com
aws ses get-identity-verification-attributes \
  --profile orcha-prod --region eu-central-1 \
  --identities mail.prod.getorcha.com
# Expected: "VerificationStatus": "Success"

3. Check active receipt rule set

# Management account (mail.getorcha.com)
aws ses describe-active-receipt-rule-set \
  --profile orcha --region eu-central-1
# Expected: RuleSetName = orcha-mail-forwarding

# Production account (mail.prod.getorcha.com)
aws ses describe-active-receipt-rule-set \
  --profile orcha-prod --region eu-central-1
# Expected: RuleSetName = v1-orcha-prod-inbound

4. Check if recipient has a matching rule

Receipt rules match exact addresses. Plus-addressed emails (e.g., documents+clientcode@) need explicit rules.

# Check management account rules
aws ses describe-active-receipt-rule-set \
  --profile orcha --region eu-central-1 \
  --query 'Rules[].{Name:Name,Recipients:Recipients,ScanEnabled:ScanEnabled}'

# Check production account rules
aws ses describe-active-receipt-rule-set \
  --profile orcha-prod --region eu-central-1 \
  --query 'Rules[].{Name:Name,Recipients:Recipients,ScanEnabled:ScanEnabled}'

5. Check S3 for received emails

# List recent emails (both accounts write here)
aws s3 ls s3://v1-orcha-ses-emails-700558745280/ \
  --profile orcha-prod --region eu-central-1

# Search for specific email by message ID or date
aws s3 ls s3://v1-orcha-ses-emails-700558745280/ \
  --profile orcha-prod --region eu-central-1 \
  --recursive | grep "2026-02-23"

6. Check SQS for pending messages

aws sqs get-queue-attributes \
  --profile orcha-prod --region eu-central-1 \
  --queue-url https://sqs.eu-central-1.amazonaws.com/700558745280/v1-orcha-global-email-acquire \
  --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible

# Check DLQ for failed messages
aws sqs get-queue-attributes \
  --profile orcha-prod --region eu-central-1 \
  --queue-url https://sqs.eu-central-1.amazonaws.com/700558745280/v1-orcha-global-email-acquire-dlq \
  --attribute-names ApproximateNumberOfMessages

Common Failure Modes

Symptom Likely Cause
Email bounces immediately MX record missing or wrong
Email accepted but not in S3 Receipt rule set not active, or recipient doesn't match rule
Plus-addressed email not received No explicit rule for that plus-address (rules are exact match)
Email rejected as spam ScanEnabled=true and content triggered spam filter
Email in S3 but not processed SQS notification not configured, or worker not running
Sender gets 550 rejection SES domain not verified
Works for some senders, not others SES sandbox mode (only verified senders allowed)

Adding a New Client-Specific Rule

When a client's forwarded emails are being rejected by spam scanning, add a dedicated rule:

# First, list existing rules to find positioning
aws ses describe-active-receipt-rule-set \
  --profile orcha --region eu-central-1 \
  --query 'Rules[].Name'

# Add new rule (position before the catch-all)
aws ses create-receipt-rule \
  --profile orcha --region eu-central-1 \
  --rule-set-name orcha-mail-forwarding \
  --after "<previous-rule-name>" \
  --rule '{
    "Name": "<client>-no-scan",
    "Enabled": true,
    "Recipients": ["documents+<clientcode>@mail.getorcha.com"],
    "Actions": [
      {"S3Action": {"BucketName": "v1-orcha-ses-emails-700558745280"}},
      {"StopAction": {"Scope": "RuleSet"}}
    ],
    "ScanEnabled": false
  }'

Rules are evaluated in order. Use --after to position the rule before forward-to-prod-bucket (the catch-all). Include StopAction to prevent the catch-all from also matching.

SES Sandbox Mode

New SES accounts start in sandbox mode:

Check sandbox status:

aws ses get-account \
  --profile orcha-prod --region eu-central-1
# Look for "ProductionAccessEnabled": true