Channels
Email with Amazon SES
Send email through your own Amazon SES account with Ray. AWS setup, channel config fields, recipient, per-send from and reply-to, custom headers, attachments, bounce handling and errors.
The ses_email channel sends email through your own Amazon SES account using the SES v2 API. Ray renders the email_html template, applies suppressions, paces sends to your SES rate, and adds addresses to a suppression list when SES reports hard bounces or complaints. API calls go to https://ray-api.gege.mn with Authorization: Bearer $RAY_API_KEY.
| Channel type | ses_email |
| Template kind | email_html (shared with SMTP) |
| Recipient | { "email", "name"?, "cc"?, "bcc"?, "from"?, "replyTo"?, "headers"?, "attachments"? } |
| Pacing | ratePerMinute, default 840, max 1600 |
| Suppression | Hard bounces and complaints, via SNS |
| Click tracking | Yes (Starter plan and above) |
What you need from AWS
- A verified sending identity. In the AWS console open Amazon SES, pick the region you will send from, go to Identities, click Create identity, and verify a domain (recommended, via the DNS records SES shows you) or a single email address. Your from address must belong to this identity.
- Production access. New SES accounts are in the sandbox: they can only send to verified addresses and have a low sending rate. In Account dashboard, request production access before sending to real users. Note your maximum send rate there.
- An IAM user with permission to send. In IAM, create a user and attach a policy that allows sending, for example:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["ses:SendEmail", "ses:SendRawEmail"], "Resource": "*" }
]
}
- An access key for that user. In the user's Security credentials tab, create an access key and copy the access key ID and secret access key.
- Optional, for bounce and complaint handling: a configuration set and an SNS topic. See Bounces and complaints.
Channel config
Create the config in the dashboard: Channels, New channel, Amazon SES. Credentials are stored encrypted.
| Field | Required | Notes |
|---|---|---|
| Name | yes | Internal label, for example transactional. |
AWS region (region) |
yes | The region of your SES identity, for example us-east-1. |
Access key ID (accessKeyId) |
yes | At least 16 characters. |
Secret access key (secretAccessKey) |
yes | At least 20 characters. Leave blank when editing to keep the stored secret. |
From address (fromAddress) |
yes | A valid email address on a verified identity. The default; a send can override it with from. |
From name (fromName) |
no | Display name, sent as From: "Name" <address>. The default; a send can override it. |
SES configuration set (configurationSet) |
no | Needed to receive bounce and complaint events. |
SNS topic ARN (snsTopicArn) |
no | The only SNS topic allowed to deliver bounce and complaint events. Leave blank to pin the first topic that confirms a subscription. |
Rate per minute (ratePerMinute) |
no | Default 840. Max 1600. Set to your SES maximum send rate times 60 (a sandbox account allows about 60). |
Find the config's id with GET /channels; its kind is ses_email.
Template content
email_html content, used both in templates and as inline content on POST /send:
| Field | Type | Notes |
|---|---|---|
subject |
string, 1 to 998 | Params are inserted as-is; a line break in the rendered subject fails the send with 400. |
bodyHtml |
string | Params are HTML-escaped. |
bodyText |
string | Plain-text alternative. Params are inserted as-is. |
Ray sends the HTML and text bodies together as alternatives. See Templates for syntax.
Recipient
{
"email": "[email protected]",
"name": "Ada Lovelace",
"cc": [{ "email": "[email protected]", "name": "Team" }],
"bcc": [{ "email": "[email protected]" }],
"from": { "email": "[email protected]", "name": "Example Billing" },
"replyTo": [{ "email": "[email protected]", "name": "Support" }],
"headers": { "X-Entity-Ref-ID": "invoice-1042" },
"attachments": [
{
"filename": "invoice-1042.pdf",
"content": "JVBERi0xLjQK",
"contentType": "application/pdf"
}
]
}
| Field | Type | Notes |
|---|---|---|
email |
string | Required. The primary recipient, and the address checked against suppressions. |
name |
string, 1 to 255 | Optional display name. No line breaks. |
cc |
array, max 50 | [{ "email", "name"? }]. |
bcc |
array, max 50 | [{ "email", "name"? }]. |
from |
object | { "email"?, "name"? }. Overrides the config's from address and name for this send. The address must be on the config's from-address domain. See below. |
replyTo |
array, 1 to 10 | [{ "email", "name"? }]. Where replies go. |
headers |
object, max 15 | Extra message headers, { "Name": "value" }. See below. |
attachments |
array, max 20 | See below. |
SES allows at most 50 recipients per message across To, Cc and Bcc combined. Only the primary email is checked against the suppression list.
From, reply-to and headers
from changes the sender for one send. Each field falls back to the channel config on its own: { "name": "Ada at Example" } keeps the config's address and only changes the display name, and { "email": "[email protected]" } keeps the config's fromName.
The address must be on the same domain as the config's fromAddress: with [email protected] configured, [email protected] works, but [email protected] or an address on another domain is rejected with 400 before anything is sent. This keeps the choice of sending domain with the workspace admins who edit channel configs, so an API key can't send as another domain your AWS account happens to have verified. To send from another domain, create a channel config for it. SES also checks the address: it must belong to a verified identity in the config's region, or SES rejects the message with MessageRejected and the delivery fails without a retry.
replyTo sets the Reply-To header, so replies go to those addresses instead of the from address.
headers adds custom headers, for example List-Unsubscribe, In-Reply-To and References for threading, or your own X- tags:
| Rule | Limit |
|---|---|
| Name | Printable ASCII, no colon or spaces, 1 to 126 characters. Names are case-insensitive, so the same name can't appear twice. |
| Value | Printable ASCII, no line breaks, 1 to 995 characters. Name and value together at most 996. |
| Count | At most 15 headers (the SES limit). |
| Not allowed | From, Sender, Reply-To, To, Cc, Bcc, Subject, Date, Message-ID, Return-Path, MIME-Version, Content-Type, Content-Disposition, Content-Transfer-Encoding. Ray sets these itself; use the recipient's own fields instead. |
| Provider controls | Any X-SES-* header, SendGrid's X-SMTPAPI, and the equivalents for relays an SMTP config may use: X-MC-* (Mandrill), X-MSYS-* (SparkPost), X-PM-* (Postmark), X-Mailgun-*, X-MJ-* and X-Mailjet-* (Mailjet), X-Mailin-* (Brevo). These change provider routing, such as the configuration set that reports bounces to Ray, so they stay on the channel config. |
A request that breaks one of these rules fails with 400 before anything is sent.
Attachments
| Field | Type | Notes |
|---|---|---|
filename |
string, 1 to 255 | No line breaks. |
content |
base64 string | File bytes, standard base64-encoded (the examples use a tiny placeholder). Max 14,000,000 characters (about 10 MiB of file). |
contentType |
string | Optional MIME type, for example application/pdf. |
disposition |
"attachment" or "inline" |
Default "attachment". |
contentId |
string | For inline images: reference it from bodyHtml as <img src="cid:logo">. |
All attachments on one recipient may total at most 34,000,000 base64 characters (about 25 MiB), and the whole POST /send body at most 45 MiB. Ray stores attachment bytes encrypted until the delivery finishes, then deletes them; GET /sends/{sendId} shows attachment metadata only.
Send an email
curl -X POST https://ray-api.gege.mn/send \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-1042" \
-d '{
"channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
"content": {
"subject": "Invoice {{invoiceNumber}}",
"bodyHtml": "<p>Hi {{name}},</p><p>Your invoice for <b>{{amount}}</b> is attached.</p>",
"bodyText": "Hi {{name}}, your invoice for {{amount}} is attached."
},
"params": { "name": "Ada", "invoiceNumber": "1042", "amount": "$59.00" },
"recipient": {
"email": "[email protected]",
"name": "Ada Lovelace",
"attachments": [
{ "filename": "invoice-1042.pdf", "content": "JVBERi0xLjQK", "contentType": "application/pdf" }
]
},
"externalUserId": "user_123",
"showInFeed": true
}'
import { readFile } from "node:fs/promises";
const pdf = await readFile("./invoice-1042.pdf");
const res = await fetch("https://ray-api.gege.mn/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RAY_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "invoice-1042",
},
body: JSON.stringify({
channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", // an email_html template
params: { name: "Ada", invoiceNumber: "1042", amount: "$59.00" },
recipient: {
email: "[email protected]",
name: "Ada Lovelace",
attachments: [
{ filename: "invoice-1042.pdf", content: pdf.toString("base64"), contentType: "application/pdf" },
],
},
}),
});
if (res.status !== 202) throw new Error(`Ray ${res.status}: ${await res.text()}`);
const { sendId } = (await res.json()) as { sendId: string };
The delivery row's providerMessageId is the SES message id.
Bounces and complaints
Ray can add addresses to the channel config's suppression list when SES reports a permanent (hard) bounce or a complaint. Future sends to a suppressed address are recorded as suppressed and not sent. Transient (soft) bounces do not suppress. You can review and remove suppressions in the dashboard under Suppressions.
Setup:
- In Amazon SNS (same region), create a standard topic, for example
ses-events. - Subscribe Ray to the topic: protocol HTTPS, endpoint
https://ray-api.gege.mn/webhooks/ses_email/<channelConfigId>(your config's id). Leave raw message delivery disabled. Ray verifies the SNS signature and confirms the subscription automatically. - In Amazon SES, create a configuration set, add an event destination of type Amazon SNS for Bounces and Complaints, and choose the topic.
- In the Ray channel config, set SES configuration set to that configuration set's name. Ray sends every message with it.
Topic pinning: SNS signatures only prove that some AWS topic signed a message, so Ray accepts events only from one topic per channel config. If snsTopicArn is blank, the topic that completes the first subscription confirmation is pinned. Messages from any other topic are rejected, and notifications for a config that has no pinned topic are refused. To move to a different topic, paste its ARN into the config.
Errors
Provider errors appear on the delivery row as providerError: { "name", "message" }.
| Result | SES errors |
|---|---|
failed_terminal (not retried) |
SenderNotAllowedError (a scheduled send's from.email is no longer on the config's domain after a config edit), MessageRejected (for example an unverified address while in the sandbox), MailFromDomainNotVerifiedException, AccountSendingPausedException, ConfigurationSetDoesNotExistException, ConfigurationSetSendingPausedException, AccountSuspendedException, InvalidParameterValue, InvalidEmailAddress, NotAuthorized, AccessDeniedException, and any other HTTP 4xx |
failed_retryable (retried) |
TooManyRequestsException, ThrottlingException, Throttling, RequestThrottled, HTTP 429, HTTP 5xx, network errors and timeouts |
Common fixes:
MessageRejected: Email address is not verified: the account is in the SES sandbox, or the from address isn't on a verified identity in this region.AccessDeniedExceptionorNotAuthorized: the IAM user lacksses:SendEmail, or the key belongs to a different account.ConfigurationSetDoesNotExistException: the configuration set name is wrong or in another region.- Frequent throttling retries: lower
ratePerMinuteto your SES maximum send rate times 60.
Request errors from POST /send (for example recipient: Invalid email address) are listed in Errors.