Amazon SES
This page is for a store sending through Amazon SES that wants delivered, bounced and complained figures on its campaign screens. Read Delivery reports and providers first for what a provider webhook is and what the store does with each event.
SES has no webhooks. It publishes events to an SNS topic, and getting from "a message bounced" to "a POST arrives at this store" takes six calls. The Set up in Amazon SES button makes all six.
Before you begin
- An AWS account with SES out of the sandbox, and a verified identity (a domain or an address).
- An access key id and secret access key, and the AWS region you send from.
- The
grav-plugin-email-amazonplugin at 1.1.2 or later, with the Grav Email plugin at 5.2.0 or later on PHP 8.1 or later. - The Email plugin's Mail Engine set to Amazon SES.
What the IAM policy must allow
The same access key sends the mail and sets the reports up. Grant these actions on the key's own user or role under IAM, not in SES.
| Action | What it does | Needed for |
|---|---|---|
sns:CreateTopic |
Creates the topic, or reuses one with that name. | Set up |
sns:GetTopicAttributes |
Reads the topic's current policy, so existing statements survive. | Set up |
sns:SetTopicAttributes |
Adds the one statement that lets SES publish to the topic. | Set up |
sns:Subscribe |
Subscribes the store's webhook address over HTTPS. | Set up |
sns:ListSubscriptionsByTopic |
Checks whether the address is already subscribed. Optional. | Set up |
ses:GetConfigurationSet |
Does the configuration set exist? | Set up |
ses:CreateConfigurationSet |
Creates it if not. | Set up |
ses:GetConfigurationSetEventDestinations |
Is the event destination already there? | Set up |
ses:CreateConfigurationSetEventDestination |
Creates it. | Set up |
ses:UpdateConfigurationSetEventDestination |
Updates it in place. | Set up |
ses:PutEmailIdentityConfigurationSetAttributes |
Makes the configuration set the identity's default. | Set up, only when Sending identity is filled in |
ses:GetEmailIdentity |
Reads the identity's DKIM tokens and MAIL FROM domain. | The Health tab only |
ses:SendEmail |
Sending, on the api and https transports. |
Sending |
ses:SendRawEmail |
Sending, on the smtp transport. AWS's own SMTP-credentials policy grants this one. |
Sending |
Important
sns:SetTopicAttributes is a permissions-management action, so a tight IAM policy often leaves it out, and that step fails silently. See Troubleshooting.
When a call is refused, the button prints Amazon's own sentence and names the step, so you know which action to add.
There is no verification key to paste. Amazon signs every message with a certificate whose address travels in the message.
Configure the transport
The transport is a Grav plugin, so its own settings live in the Grav admin's plugin list. The webhook that reports deliveries back is set up from the Providers card in the KahunaCart admin, in the next section.
Important
Everything in SES is per region, and regions do not share anything. A domain verified in Ohio does not exist in Virginia. Same account, same domain, and SES there will refuse to send as it.
So the region is not really a choice. It is wherever your identities are already verified, and every other setting on this page has to match it: the topic, the configuration set and the IAM policy.
-
1
In the Grav admin's plugin list, open Email Amazon and set Transport.
Read the warning below the table first. The short version is that API leaves custom headers behind, so it is the wrong choice for newsletters.
-
2
Fill in Access Key, Secret Key and Region.
The region is the one your senders are verified in. If you are not sure, the SES console shows nothing at all in the wrong one.
-
3
Fill in Configuration set and SNS topic under the delivery-reports fields.
The defaults,
grav-emailandgrav-email-events, are fine unless something else on the account already uses those names. Pick names nothing else uses: a configuration set is account-wide and shared with anything else sending through SES. -
4
Leave Sending identity empty unless you know you want it.
Filling it in makes this configuration set the default for that identity, so every message from that address or domain publishes here, including messages sent by something other than this store. That is the one setting on this page that reaches outside the plugin. Left empty, the step is skipped and said so plainly.
-
5
Open Email in the same plugin list and set Mail Engine to Amazon SES.
| Field | Default | What it does |
|---|---|---|
enabled |
on | The plugin's own switch. |
transport |
https |
https, api or smtp. |
access_key, secret_key |
empty | Used on api and https, and by the Set up button and the domain lookup on every transport. |
region |
empty | The AWS region. |
username, password |
empty | SMTP transport only. |
configuration_set |
empty | The set that publishes events. When filled in, the plugin stamps X-SES-CONFIGURATION-SET on every message it sends. |
sns_topic |
empty | The topic events publish to. An existing topic of that name is reused. |
identity |
empty | The verified domain or address, as it appears under Identities in the SES console. |
Warning
The api transport drops custom headers. List-Unsubscribe and List-Unsubscribe-Post never reach the wire, so Gmail draws no unsubscribe button. Use https or smtp for bulk mail. The Health tab says so too.
Important
Fill Region in. Left empty, mail quietly goes to eu-west-1 and the Set up button refuses to make a single call.
Set the reports up in one click
-
1
Go to KahunaCart → Add-ons → Newsletters → Settings → Providers and click Generate a secret on the Amazon SES card.
-
2
Click Set up in Amazon SES.
Six calls, listed below. Each failure names which step it was, because an
AccessDeniedon the topic and one on the event destination need different IAM actions added and read identically otherwise.
The button runs six steps in order.
- Create the SNS topic, or reuse an existing one with that name. The default name is
grav-email-events. - Read the topic's policy, then write it back with one statement added, letting
ses.amazonaws.compublish to that topic, conditioned on your own account id. Everything else on the policy stays. The statement is replaced rather than appended, so pressing twice changes nothing. - Subscribe the store's webhook address over HTTPS.
- Create the configuration set if it does not exist. The default name is
grav-email. - Create or update the event destination named
grav-email-sns, listening forDELIVERY,BOUNCE,COMPLAINT,OPEN,CLICKandREJECT. - Make the configuration set the identity's default, only when Sending identity is filled in.
Caution
Step 6 affects every message sent from that identity, including mail sent by something other than Grav.
Leave Sending identity empty and step 6 is skipped, with a sentence saying what is still outstanding.
The confirmation handshake
The first thing SNS sends to a new subscription is a SubscriptionConfirmation asking the endpoint to fetch a URL. The store makes that request itself, over HTTPS only, only to a host matching sns.<region>.amazonaws.com, and never to a URL carrying credentials. You do not have to click anything.
Nothing is published to a subscription until it is confirmed.
An UnsubscribeConfirmation is noted and never confirmed. Confirming it would resubscribe the store to a topic somebody had just removed it from.
Do it by hand instead
-
1
In the SES console, go to Configuration sets and create one, or open the one you send with.
-
2
On its Event destinations tab, add a destination of type Amazon SNS, pick or create a topic, and tick Delivery, Bounce, Complaint and Reject.
-
3
In the SNS console, open that topic and create a subscription with protocol HTTPS and the store's webhook address as the endpoint.
Amazon posts a confirmation straight away and the store answers it for you. Check the subscription afterwards: one still reading PendingConfirmation looks registered in every list and delivers nothing.
-
4
Either set the configuration set as the default on the verified domain under Identities, or send with an
X-SES-CONFIGURATION-SETheader naming it.Do the first one only if you are happy that everything sending as that identity publishes here.
Important
Without step 4 the destination exists and no event is ever published. If you reused an existing topic rather than letting the SES wizard create one, add the ses.amazonaws.com / SNS:Publish statement to its access policy yourself.
There is no separate header list to fill in. SES returns the message's headers on an event-publishing notification without being asked.
What each event means
| SES event | Becomes | Notes |
|---|---|---|
Delivery |
delivered | |
Bounce with bounceType: Permanent |
hard bounce | The address is suppressed and the subscriber is set to bounced. Includes the Suppressed and OnAccountSuppressionList subtypes. |
Bounce with Transient or Undetermined |
soft bounce | Three in thirty days are treated as a hard one. |
Complaint |
complaint | The address is suppressed and the subscriber is unsubscribed. |
Open |
open | Stamped where the store's own pixel has not already. |
Click |
click | The same. |
Reject |
refused message, always | See below. |
Send, Rendering Failure, DeliveryDelay, Subscription |
skipped with a note | Logged, not acted on. |
A complaint whose feedback type is not-spam is skipped.
A bounce produces one event per bounced recipient.
A Reject is always the message and never the address
SES never refuses a send because of the recipient. An address it will not deliver to bounces, and the account-level suppression list produces a permanent Bounce with the subtype Suppressed, not a Reject. AWS's own documented reason for a Reject is Bad content, meaning SES detected a virus.
So a Reject marks the send bounced and touches nothing about the person, whatever its reason string says.
Provider quirks
Both spellings of the type field are read. Event publishing calls it eventType; an identity feedback notification calls it notificationType.
The send id survives header truncation in the message tags. SES truncates the header list on a message with around 10 KB of headers and sets headersTruncated. The store reads the send id from the header list first and from mail.tags second.
commonHeaders.messageId is not the store's Message-ID. On an event it is the id SES assigned; on a notification it is the original. The parser reads neither and joins on the header list instead.
An identity feedback notification carries no headers unless you asked for them. For a notification sent straight from a verified identity rather than through a configuration set, tick Include original email headers on the identity's page, once per feedback type. Event publishing carries them without being asked.
Amazon signs, and the certificate is fetched and cached. The store checks the signature against a certificate fetched from the address in the message, only over HTTPS and only from a host matching sns.<region>.amazonaws.com, and keeps it under user/data/email-amazon/ for thirty days. Both of Amazon's signature versions are accepted, because an SNS topic defaults to version 1.
A message with an attachment keeps its headers even on the api transport, because the Symfony bridge falls back to raw MIME for those. That is not a reason to use api for campaigns.
DNS for the Health tab
| Record | Value | Notes |
|---|---|---|
| SPF | v=spf1 include:amazonses.com ~all |
AWS publishes this on the MAIL FROM subdomain. Without a custom MAIL FROM, SPF is implicit because SES sends from a subdomain of amazonses.com. |
| DKIM | three CNAMEs at <token>._domainkey.yourdomain.com pointing at <token>.dkim.amazonses.com |
Easy DKIM defaults to 2048-bit. Do not put a leading underscore in front of the token. |
| MAIL FROM | one MX record on your subdomain pointing at feedback-smtp.<region>.amazonses.com, priority 10 |
AWS requires exactly one MX record there; several make the custom MAIL FROM setup fail. Detection can take up to 72 hours. |
SES aligns a custom return path with an MX record on a subdomain of your own domain, so there is no return-path zone to check a name against.
With the ses:GetEmailIdentity permission on the key, the Health tab reads the real DKIM tokens and the MAIL FROM domain off your account. Without it, put the token into deliverability.dkim_selector on KahunaCart → Add-ons → Newsletters → Settings → Health.
Checking it actually works
Important
The numbers on a campaign screen are not evidence that SES is reporting anything. Opens and clicks are counted by the store's own tracking pixel and click redirect, and those run whether or not an SNS message has ever arrived. A campaign can show delivered, opened and clicked while the topic sits there unsubscribed.
The Providers card is the honest answer. If it says nothing has been received, nothing has.
Send a one-recipient campaign to an address you can read, open it, press a link, then check the Amazon SES card. On SES this check earns its keep twice over, because an unconfirmed subscription is invisible everywhere else.
Troubleshooting
| Symptom | Cause |
|---|---|
| Everything looks green in SES and no event ever arrives | The topic's access policy is missing the statement letting SES publish to it. SES accepts the event destination, reports success and publishes nothing. |
| The destination and the topic are right and still nothing arrives | The messages are not being sent with the configuration set. Fill in Configuration set so the plugin stamps the header, or fill in Sending identity and press Set up again. |
| Events stop at the subscription | The SNS subscription was never confirmed. Check the Grav log for a failed fetch. |
| The Set up button makes no calls at all | Region is empty. |
| The Set up button says Amazon refused it | The step named in the message tells you which action to add to the IAM policy. |
| No unsubscribe button in Gmail | The api transport drops custom headers. Switch to https or smtp. |
| Every signature refused | The installation has no cURL, or nowhere to write the cached certificate under user/data/email-amazon/. |
A Reject did not suppress anybody |
It is not meant to. See the Reject rule above. |