Skip to content

Email receiving

SES email receiving turns a domain you’ve verified into an inbound mail endpoint. Mail arriving at any address on the domain is matched against an ordered list of receipt rules, and each rule runs a list of actions: store the raw message in S3, publish a notification to SNS, invoke a Lambda, bounce it back to the sender, add a header, or stop processing.

The receiving primitives:

  • A ReceiptRuleSet is the ordered container for rules. An account can hold many, but only one is active at a time.
  • A ReceiptRule matches inbound mail by recipient and applies an ordered list of actions.
  • The ActiveReceiptRuleSet is the single rule set SES actually evaluates — an account singleton pointer.
  • A ReceiptFilter allows or blocks inbound mail by source IP, before rules run.
  • SendBounce is a runtime binding for bouncing a received message from inside a Lambda.

The snippets below run inside a Stack’s Effect.gen body:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as AWS from "alchemy/AWS";
import * as SES from "alchemy/AWS/SES";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"InboundMail",
{ providers: AWS.providers(), state: AWS.state() },
Effect.gen(function* () {
// resources go here
return {};
}),
);

A rule set starts empty — it’s just the ordered container the rules hang off:

const ruleSet = yield* SES.ReceiptRuleSet("Inbound", {});

Omitting ruleSetName lets Alchemy generate a deterministic name from the app, stage, and logical id. Nothing is evaluated against inbound mail until a rule set is made active (below).

The running example is a support inbox: store every raw message in a bucket, then notify a topic that new mail arrived. Start with the S3 action:

const bucket = yield* AWS.S3.Bucket("InboundMail", {
forceDestroy: true,
});
const inbound = yield* SES.ReceiptRule("Support", {
ruleSetName: ruleSet.ruleSetName,
recipients: ["support@example.com"],
actions: [
{
S3Action: {
BucketName: bucket.bucketName,
ObjectKeyPrefix: "support/",
},
},
],
});

SES writes the full raw MIME message to the bucket under the prefix, keyed by message id. The bucket’s policy must allow the SES service principal (ses.amazonaws.com) to s3:PutObject — SES validates this when the rule is created, so add it to the bucket before the rule.

Actions run in array order. Add an SNSAction after the S3 action so SES publishes a notification once the message is stored:

const topic = yield* AWS.SNS.Topic("InboundNotifications", {});
const inbound = yield* SES.ReceiptRule("Support", {
ruleSetName: ruleSet.ruleSetName,
recipients: ["support@example.com"],
actions: [
{
S3Action: {
BucketName: bucket.bucketName,
ObjectKeyPrefix: "support/",
},
},
{ SNSAction: { TopicArn: topic.topicArn, Encoding: "UTF-8" } },
],
});

Anything subscribed to the topic — a queue, a Lambda, an HTTP endpoint — now fans out from the single notification. The topic’s access policy must allow ses.amazonaws.com to sns:Publish. (An SNSAction publishes a notification, not the message body; for large messages the S3 copy is the source of truth.)

A LambdaAction hands the matched message to a function. Point it at the function’s ARN and choose Event (async, fire-and-forget) or RequestResponse (synchronous, lets the function influence delivery):

const rule = yield* SES.ReceiptRule("Process", {
ruleSetName: ruleSet.ruleSetName,
recipients: ["support@example.com"],
actions: [
{
LambdaAction: {
FunctionArn: processor.functionArn,
InvocationType: "Event",
},
},
],
});

See Lambda for standing up the processor function itself. SES’s permission to invoke it is granted the same way as S3 and SNS — the function’s resource policy must allow ses.amazonaws.com to lambda:InvokeFunction.

Rules evaluate top to bottom. The after prop pins a rule immediately behind another one in the set. Here a catch-all bounce runs after the support rule, rejecting mail to unknown recipients:

const bounce = yield* SES.ReceiptRule("BounceUnknown", {
ruleSetName: ruleSet.ruleSetName,
after: inbound.ruleName,
actions: [
{
BounceAction: {
SmtpReplyCode: "550",
Message: "Mailbox does not exist",
Sender: "mailer-daemon@example.com",
},
},
],
});

A rule with no recipients matches everything, so ordering it last makes it a fallthrough. Omit after to place a rule at the top of the set.

Rules do nothing until their set is the account’s active one. ActiveReceiptRuleSet is the pointer SES evaluates against every inbound message:

yield* SES.ActiveReceiptRuleSet("Active", {
ruleSetName: ruleSet.ruleSetName,
});

Only one rule set is active per account and region. Re-pointing this resource at a different set switches receiving over in place. Destroying it deactivates receiving — but only if the account is still pointed at the set this resource activated; if something else has since taken over, the destroy is a no-op.

ReceiptFilter is an account-level allow/block list on the source IP of inbound connections, applied before any rule runs. Block filters win over allow filters:

yield* SES.ReceiptFilter("BlockBadActors", {
ipFilter: { policy: "Block", cidr: "203.0.113.0/24" },
});

cidr takes a single address (203.0.113.7) or a range (203.0.113.0/24). Filters have no update API, so changing the policy or range replaces the filter.

When a LambdaAction invokes your function synchronously, the function can bounce the message itself. SES.SendBounce is a runtime binding — an account-level operation with no resource to scope to, so it’s bound with no argument and its implementation is provided as SES.SendBounceHttp:

// init — inside the Lambda's Effect.gen body
const sendBounce = yield* SES.SendBounce();

At runtime, generate the bounce from the id of the message SES delivered to you:

yield* sendBounce({
OriginalMessageId: messageId,
BounceSender: "mailer-daemon@example.com",
BouncedRecipientInfoList: [
{ Recipient: "nobody@example.com", BounceType: "DoesNotExist" },
],
});

Provide the implementation on the function with Effect.provide(SES.SendBounceHttp). You can only bounce a message within 24 hours of receiving it, and only mail SES actually received for you.

  • Lambda — the function a LambdaAction invokes.
  • S3 — the bucket inbound mail lands in.
  • SNS — fan the notification out to queues and functions.

Reference: