This guide assumes you have completed the inbound-install.md setup. It covers the full pipeline for receiving mail through Amazon SES: SNS topic, IAM policy, SES receipt rules, optional dep install, production config, and the behavioral guarantees the package provides once the pipeline is wired.
Overview
Amazon SES inbound works through a two-leg pipeline:
SES Receipt Rules → S3 (raw MIME body) + SNS topic (notification) → your endpointWhen SES receives a message matching your receipt rule:
- SES stores the raw MIME body in your S3 bucket (the S3 action).
- SES publishes a notification to your SNS topic (the SNS action).
- SNS delivers the notification to your HTTPS endpoint via subscription.
- The ingress plug verifies the SNS X.509 signature, reads the S3 object key from the notification, fetches the raw MIME bytes from S3, and processes the message.
This is distinct from SES sending-event webhooks (delivery/bounce/complaint
notifications) — those flow through a different AWS pipeline and are handled by the core
mailglass package's SES webhook adapter.
Mount Path
Mount the ingress plug on a dedicated SES route in your Phoenix router:
forward "/inbound/:tenant_id/ses",
MailglassInbound.Ingress.Plug,
provider: :ses,
router: MyApp.MailglassInboundRouterThe ingress plug is verify-first:
- read the exact request bytes from
conn.private[:raw_body] - verify the SNS X.509 signature
- dispatch on SNS message type (Notification, SubscriptionConfirmation, UnsubscribeConfirmation)
- for Notifications: fetch raw MIME from S3
- resolve tenant scope
- normalize into
%MailglassInbound.InboundMessage{} - persist one canonical row plus one raw evidence row
- dispatch mailbox execution only for newly inserted records
Plug.Parsers Wiring
Your endpoint must include the caching body reader from the install guide:
plug Plug.Parsers,
parsers: [:json],
pass: ["*/*"],
json_decoder: Jason,
body_reader: {MailglassInbound.Ingress.CachingBodyReader, :read_body, []}Without this body_reader, SNS signature verification fails — the raw bytes that SNS signs
are not available for X.509 verification.
SNS Topic Setup
Open the SNS console and create a new topic.
- Type: Standard (not FIFO — SNS HTTP subscriptions are not supported on FIFO topics).
- Name: e.g.
mailglass-inbound-ses. - Note the Topic ARN — you will need it when configuring the SES receipt rule.
Create an HTTPS subscription to your endpoint:
- Protocol:
HTTPS - Endpoint:
https://your-app.example.com/inbound/YOUR_TENANT_ID/ses - Click Create subscription.
- Protocol:
Confirm the subscription manually. After SNS delivers a
SubscriptionConfirmationPOST to your endpoint, the ingress plug validates theSubscribeURLhost against the hardcoded SNS trust policy (SSRF guard) and returns200 OK, but it does not follow the URL. SNS requires an HTTP GET to theSubscribeURLto complete confirmation.Visit the SNS console → your subscription → Request confirmation (or retrieve the
SubscribeURLfrom the SNS delivery attempt logs and curl it):curl "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&..."The subscription status changes from
PendingConfirmationtoConfirmedonce SNS receives your GET. Until confirmed, noNotificationmessages are delivered. See SubscribeURL Trust Policy for details on the host validation that guards against SSRF.
IAM Policy
The SES receipt rule needs permission to write the raw message body to your S3 bucket. Your application (or the environment where it runs) needs permission to read from that bucket.
SES Delivery Role
Attach a policy to the IAM role used by your SES receipt rule action that allows s3:PutObject:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/inbound/*"
}
]
}Replace YOUR-BUCKET-NAME with your actual bucket name and inbound/ with the key prefix
you configured in the receipt rule S3 action.
Application Read Policy
Attach a policy to your application's IAM role (or instance profile / pod identity) that allows reading from the bucket:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
}
]
}Replace YOUR-BUCKET-NAME with your actual bucket name. The /* suffix is required because
the object key is the SES message ID, which varies per message.
SES Receipt Rule Setup
- Open the SES console → Email Receiving → Rule Sets.
- Select an existing rule set or create a new one and activate it.
- Create a new rule:
- Recipient conditions: add the address or domain patterns you want to receive mail for
(e.g.
support@mg.example.comor@mg.example.comfor all addresses on the domain). - Action 1: S3 — store to
YOUR-BUCKET-NAMEwith key prefixinbound/. SES stores the raw MIME body ats3://YOUR-BUCKET-NAME/inbound/{messageId}. - Action 2: SNS — publish notification to your SNS topic ARN. The SNS notification includes the S3 bucket name and object key, which the ingress plug uses to fetch the raw MIME body.
- Recipient conditions: add the address or domain patterns you want to receive mail for
(e.g.
- Save the rule.
Order matters: The S3 action must come before the SNS action. SNS can deliver the notification before S3 write-after-read consistency is guaranteed. The plug retries the S3 fetch to handle this — see S3 Consistency.
Optional Deps Install
SES inbound requires three optional packages plus an HTTP client. Add them to your
mix.exs dependencies:
defp deps do
[
# existing deps ...
{:ex_aws, "~> 2.7"},
{:ex_aws_s3, "~> 2.5"},
{:sweet_xml, "~> 0.7"},
# Choose one HTTP client that ex_aws supports:
{:hackney, "~> 1.20"}
# {:req, "~> 0.5"}
# {:finch, "~> 0.19"}
]
endex_aws and ex_aws_s3 are the AWS SDK; sweet_xml is required by ex_aws_s3 for
XML response parsing; the HTTP client is required by ex_aws for making AWS API requests.
Any HTTP client that ex_aws supports will work — hackney is the most commonly used.
AWS Credentials
ex_aws resolves credentials from the standard AWS chain: environment variables
(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION), ECS task roles, EC2 instance
profiles, or EKS pod identity. No mailglass-specific credential configuration is needed.
Configuration
Warning: The default
s3_fetcheris the Fake adapter, which is test-only and performs no real S3 fetch. You must explicitly configure the production fetcher or your application will silently process no inbound mail in production.
Configure the SES inbound provider in your runtime config:
# config/runtime.exs
config :mailglass_inbound, :ses,
s3_fetcher: MailglassInbound.S3Fetcher.ExAwsS3MailglassInbound.S3Fetcher.ExAwsS3 is the real adapter that calls ExAws.S3.get_object/2
to fetch the message body. It is gated behind the optional deps above — the config key is
nil (and therefore resolves to the Fake adapter) unless you set it explicitly.
Optional Tuning Knobs
config :mailglass_inbound, :ses,
s3_fetcher: MailglassInbound.S3Fetcher.ExAwsS3,
# How long X.509 signing certificates are cached (default: 3600 seconds / 1 hour)
cert_cache_ttl_seconds: 3600,
# S3 retry configuration (default: 3 attempts, 250ms/1000ms/2000ms backoff)
s3_retry_opts: [max_attempts: 3, backoff_ms: [250, 1_000, 2_000]]SubscribeURL Trust Policy
When SNS sends a SubscriptionConfirmation message to your endpoint, the notification
includes a SubscribeURL that SNS uses to verify the subscription. The ingress plug
validates this URL against a hardcoded trust policy before taking any action.
The trust policy enforces that the SubscribeURL host matches:
^sns\.[a-zA-Z0-9\-]{3,}\.amazonaws\.com(\.cn)?$URLs that do not match this pattern are rejected immediately — the plug returns 401
without following the URL. This prevents an attacker from injecting a forged
SubscriptionConfirmation that points to an internal service (an SSRF attack).
No adopter-configurable allowlist is needed or provided. A dynamic allowlist would be exploitable — if an adopter misconfigured it to include a broader pattern, a forged subscription confirmation could be used to make your server issue arbitrary HTTPS requests. The hardcoded AWS SNS host pattern is the correct security boundary.
S3 Consistency
S3 provides read-after-write consistency for new objects, but SNS can deliver the
notification in the brief window before the S3 write is fully visible to all readers. If the
S3 GetObject call returns a "not found" or transient error, the plug retries:
- Up to 3 attempts total.
- Backoff: 250ms after the first attempt, 1000ms after the second, 2000ms after the third.
- After 3 failed attempts, the plug returns a structured
S3FetchErrorand the request returns500so SNS can retry later.
The retry behavior is configurable via s3_retry_opts if your workload has different
latency characteristics.
KMS Limitation
If you enable client-side KMS encryption on your S3 bucket — where the object is
encrypted by the SES client using a KMS key before upload — the raw bytes returned by
GetObject will be KMS ciphertext. The ingress plug does not hold or use any KMS keys and
cannot decrypt this ciphertext.
The result is a degraded record: the MIME parser processes the ciphertext bytes and
produces a record with empty normalized fields (text_body, html_body, from, to,
etc.). The raw ciphertext bytes are preserved in the evidence row. No crash occurs.
Use bucket-level server-side encryption (SSE) instead:
- SSE-S3 (
AES256): S3 manages the keys transparently. No changes needed on the read path —GetObjectreturns plaintext bytes. - SSE-KMS with a bucket policy: S3 decrypts using the specified KMS key on
GetObject. Ensure your application's IAM role haskms:Decryptpermission on the key.
Client-side KMS encryption (where encryption happens before upload using the AWS SDK's
S3EncryptionClient) is not supported and produces unusable records. Use SSE.
Testing in Development
In the test and development environments, the s3_fetcher key defaults to the Fake adapter
(MailglassInbound.S3Fetcher.Fake), which returns a pre-configured raw MIME body without
making any real S3 calls. You do not need AWS credentials to run the test suite.
Use the built-in fixture helper to construct test payloads:
# Keyword list — map is not accepted here
payload = MailglassInbound.Fixtures.build_ses_sns_payload(subject: "SES inbound test")
# To test with a custom body:
payload = MailglassInbound.Fixtures.build_ses_sns_payload(
subject: "SES inbound test",
text_body: "Custom body content"
)Note:
:bucketand:keyare fixture-internal constants and cannot be overridden via options. To control the message content, use the supported options above (:subject,:text_body,:html_body,:from,:to).
The Fake adapter is wired by default when no s3_fetcher is configured in the test
environment. In production, the s3_fetcher must be set explicitly to
MailglassInbound.S3Fetcher.ExAwsS3.
Persistence Semantics
A verified SES notification writes two records before mailbox execution:
- one canonical normalized row in
mailglass_inbound_records - one linked raw evidence row in
mailglass_inbound_evidence
The evidence row carries the SNS payload JSON, selected request headers, verification
facts, parse warnings, and attachment blobs. The raw MIME bytes fetched from S3 are stored
in the evidence raw_mime column.
Duplicate requests (same tenant_id, provider, and provider_message_id) collapse on
the canonical row — no second record is created and no second mailbox execution is
dispatched.
SubscriptionConfirmation and UnsubscribeConfirmation messages from SNS are handled
as control-plane messages: they return 200 OK immediately and create no records.
Mailbox execution is dispatched after persistence commits. The Oban-backed path is the
durable route. Without Oban, Task.Supervisor fallback is bounded best-effort only —
no automatic retry on execution failure.