AWS SES (Simple Email Service)
Category: Notification — Email
Package: @leadmetrics/provider-email → SesProvider
External SDK: @aws-sdk/client-ses
Purpose
AWS SES is the email option for tenants who are already on AWS. They use their own AWS account and SES configuration — the platform never touches their AWS credentials for anything other than sending email on their behalf. Emails arrive from their verified SES domain with full SPF/DKIM, giving excellent deliverability.
There is no platform SES default — the platform uses SendGrid. SES is tenant-only and only activates when configured and verified.
Config Structure
Tenant config (stored in notification_providers.config, encrypted)
interface SesConfig {
accessKeyId: string; // AWS IAM access key — must have ses:SendRawEmail permission
secretAccessKey: string; // AWS IAM secret key
region: string; // AWS region where SES is set up, e.g. "ap-south-1", "us-east-1"
fromAddress: string; // Must be verified in the tenant's SES account
fromName: string; // Display name shown in email client
}Required IAM permissions
The IAM user/role used must have only the minimum required permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ses:SendRawEmail"],
"Resource": "*"
}
]
}SendRawEmail is used (rather than SendEmail) to support attachments as raw MIME parts.
Integration Pattern
Provider class (packages/provider-email/src/providers/ses.ts)
import { SESClient, SendRawEmailCommand } from '@aws-sdk/client-ses';
import * as nodemailer from 'nodemailer';
class SesProvider implements EmailProvider {
readonly name = 'ses';
private client: SESClient;
private transporter: nodemailer.Transporter;
constructor(private cfg: SesConfig) {
this.client = new SESClient({
region: cfg.region,
credentials: {
accessKeyId: cfg.accessKeyId,
secretAccessKey: cfg.secretAccessKey,
},
});
// Use nodemailer with SES transport for MIME construction
this.transporter = nodemailer.createTransport({
SES: { ses: this.client, aws: { SendRawEmailCommand } },
});
}
async send(message: EmailMessage): Promise<EmailSendResult> {
const info = await this.transporter.sendMail({
to: message.to.map(r => `"${r.name}" <${r.email}>`).join(', '),
from: `"${this.cfg.fromName}" <${this.cfg.fromAddress}>`,
replyTo: message.replyTo
? `"${message.replyTo.name}" <${message.replyTo.email}>`
: undefined,
subject: message.subject,
html: message.html,
text: message.text,
attachments: message.attachments?.map(a => ({
filename: a.filename,
content: a.content,
contentType: a.contentType,
})),
});
return {
messageId: info.messageId,
provider: 'ses',
};
}
async verify(): Promise<void> {
// SES doesn't have a "ping" endpoint — send a real test email to the from-address
await this.transporter.sendMail({
to: this.cfg.fromAddress,
from: `"${this.cfg.fromName}" <${this.cfg.fromAddress}>`,
subject: 'Leadmetrics — SES connection test',
text: 'This is a test email from Leadmetrics to verify your AWS SES configuration.',
});
}
}Why SendRawEmail via nodemailer-SES transport
The AWS SDK SendEmailCommand does not support attachments as multipart MIME. By routing through nodemailer with the SES transport, we get proper MIME construction with attachment boundaries, while still using the tenant’s IAM credentials via the @aws-sdk/client-ses client.
SES Sandbox vs Production
New AWS SES accounts start in sandbox mode: they can only send to verified email addresses. Tenants must request production access from AWS before using SES to send to their end users. The platform UI should surface a warning when first verifying an SES provider:
“AWS SES may be in sandbox mode. Verify that production access has been granted in your AWS console before using this for client notifications.”
Test Cases
Unit tests (packages/provider-email/src/providers/ses.test.ts)
| Test | Approach |
|---|---|
send() calls nodemailer sendMail with correct MIME structure | Mock nodemailer.createTransport; spy on sendMail; assert fields |
send() passes attachment Buffer as multipart | Assert attachment content field intact |
send() returns messageId | Mock response includes message ID |
send() throws on invalid credentials (SES AuthFailure) | Mock sendMail throws AWS SDK error; assert propagated |
send() throws when from-address not verified in SES | Mock 400 MessageRejected; assert propagated |
verify() sends self-addressed test email | Assert to === fromAddress |
Integration tests
| Test | Approach |
|---|---|
| Sends against SES in us-east-1 sandbox | Use test IAM credentials; send to verified test address; check SES send statistics |
| Attachment arrives with correct MIME type | Assert multipart/mixed content-type in raw SES message |
| Error on unverified from-address | Configure with unverified sender; assert MessageRejected error |
Local development
AWS SES cannot be easily mocked locally. Options:
- Use SMTP in dev — configure the dev tenant with Mailpit SMTP instead of SES
- LocalStack — run
localstack/localstackwithSERVICES=ses; pointbaseUrltohttp://localhost:4566 - Real SES sandbox — use a dedicated AWS dev account with a verified test domain
Related
- SendGrid Provider — platform default email
- SMTP Provider — alternative for non-AWS tenants
- AWS S3 — S3 used for attachment storage before SES dispatch
- Notification Providers — resolution pattern