| 1 | --- |
| 2 | title: Email receiving |
| 3 | description: Receive inbound email with SES — rule sets and rules that store mail in S3, fan out to SNS, invoke a Lambda, or bounce it, plus IP filters and the active rule set pointer. |
| 4 | --- |
| 5 | |
| 6 | **SES email receiving** turns a domain you've verified into an |
| 7 | inbound mail endpoint. Mail arriving at any address on the domain |
| 8 | is matched against an ordered list of **receipt rules**, and each |
| 9 | rule runs a list of **actions**: store the raw message in S3, |
| 10 | publish a notification to SNS, invoke a Lambda, bounce it back to |
| 11 | the sender, add a header, or stop processing. |
| 12 | |
| 13 | The receiving primitives: |
| 14 | |
| 15 | - A **[`ReceiptRuleSet`](/providers/aws/ses/receiptruleset)** is |
| 16 | the ordered container for rules. An account can hold many, but |
| 17 | only one is active at a time. |
| 18 | - A **[`ReceiptRule`](/providers/aws/ses/receiptrule)** matches |
| 19 | inbound mail by recipient and applies an ordered list of |
| 20 | actions. |
| 21 | - The |
| 22 | **[`ActiveReceiptRuleSet`](/providers/aws/ses/activereceiptruleset)** |
| 23 | is the single rule set SES actually evaluates — an account |
| 24 | singleton pointer. |
| 25 | - A **[`ReceiptFilter`](/providers/aws/ses/receiptfilter)** |
| 26 | allows or blocks inbound mail by source IP, before rules run. |
| 27 | - **[`SendBounce`](/providers/aws/ses/sendbounce)** is a runtime |
| 28 | binding for bouncing a received message from inside a Lambda. |
| 29 | |
| 30 | :::note |
| 31 | Email receiving is only available in a subset of AWS regions — |
| 32 | today **`us-east-1`**, **`us-west-2`**, and **`eu-west-1`**. |
| 33 | Deploy the stack that owns these resources to one of them, and |
| 34 | point your domain's MX record at that region's SES inbound |
| 35 | endpoint (e.g. `inbound-smtp.us-east-1.amazonaws.com`). |
| 36 | ::: |
| 37 | |
| 38 | ## Create the Stack |
| 39 | |
| 40 | The snippets below run inside a Stack's `Effect.gen` body: |
| 41 | |
| 42 | ```typescript |
| 43 | // alchemy.run.ts |
| 44 | import * as Alchemy from "alchemy"; |
| 45 | import * as AWS from "alchemy/AWS"; |
| 46 | import * as SES from "alchemy/AWS/SES"; |
| 47 | import * as Effect from "effect/Effect"; |
| 48 | |
| 49 | export default Alchemy.Stack( |
| 50 | "InboundMail", |
| 51 | { providers: AWS.providers(), state: AWS.state() }, |
| 52 | Effect.gen(function* () { |
| 53 | // resources go here |
| 54 | return {}; |
| 55 | }), |
| 56 | ); |
| 57 | ``` |
| 58 | |
| 59 | ## Create a rule set |
| 60 | |
| 61 | A rule set starts empty — it's just the ordered container the |
| 62 | rules hang off: |
| 63 | |
| 64 | ```typescript |
| 65 | const ruleSet = yield* SES.ReceiptRuleSet("Inbound", {}); |
| 66 | ``` |
| 67 | |
| 68 | Omitting `ruleSetName` lets Alchemy generate a deterministic name |
| 69 | from the app, stage, and logical id. Nothing is evaluated against |
| 70 | inbound mail until a rule set is made active (below). |
| 71 | |
| 72 | ## Store inbound mail in S3 |
| 73 | |
| 74 | The running example is a support inbox: store every raw message |
| 75 | in a bucket, then notify a topic that new mail arrived. Start with |
| 76 | the S3 action: |
| 77 | |
| 78 | ```typescript |
| 79 | const bucket = yield* AWS.S3.Bucket("InboundMail", { |
| 80 | forceDestroy: true, |
| 81 | }); |
| 82 | |
| 83 | const inbound = yield* SES.ReceiptRule("Support", { |
| 84 | ruleSetName: ruleSet.ruleSetName, |
| 85 | recipients: ["support@example.com"], |
| 86 | actions: [ |
| 87 | { |
| 88 | S3Action: { |
| 89 | BucketName: bucket.bucketName, |
| 90 | ObjectKeyPrefix: "support/", |
| 91 | }, |
| 92 | }, |
| 93 | ], |
| 94 | }); |
| 95 | ``` |
| 96 | |
| 97 | SES writes the full raw MIME message to the bucket under the |
| 98 | prefix, keyed by message id. The bucket's policy must allow the |
| 99 | SES service principal (`ses.amazonaws.com`) to `s3:PutObject` — SES |
| 100 | validates this when the rule is created, so add it to the bucket |
| 101 | before the rule. |
| 102 | |
| 103 | ## Fan out to SNS |
| 104 | |
| 105 | Actions run in array order. Add an `SNSAction` after the S3 action |
| 106 | so SES publishes a notification once the message is stored: |
| 107 | |
| 108 | ```typescript |
| 109 | const topic = yield* AWS.SNS.Topic("InboundNotifications", {}); |
| 110 | |
| 111 | const inbound = yield* SES.ReceiptRule("Support", { |
| 112 | ruleSetName: ruleSet.ruleSetName, |
| 113 | recipients: ["support@example.com"], |
| 114 | actions: [ |
| 115 | { |
| 116 | S3Action: { |
| 117 | BucketName: bucket.bucketName, |
| 118 | ObjectKeyPrefix: "support/", |
| 119 | }, |
| 120 | }, |
| 121 | { SNSAction: { TopicArn: topic.topicArn, Encoding: "UTF-8" } }, |
| 122 | ], |
| 123 | }); |
| 124 | ``` |
| 125 | |
| 126 | Anything subscribed to the topic — a queue, a Lambda, an |
| 127 | HTTP endpoint — now fans out from the single notification. The |
| 128 | topic's access policy must allow `ses.amazonaws.com` to |
| 129 | `sns:Publish`. (An `SNSAction` publishes a notification, not the |
| 130 | message body; for large messages the S3 copy is the source of |
| 131 | truth.) |
| 132 | |
| 133 | ## Invoke a Lambda on inbound mail |
| 134 | |
| 135 | A `LambdaAction` hands the matched message to a function. Point it |
| 136 | at the function's ARN and choose `Event` (async, fire-and-forget) |
| 137 | or `RequestResponse` (synchronous, lets the function influence |
| 138 | delivery): |
| 139 | |
| 140 | ```typescript |
| 141 | const rule = yield* SES.ReceiptRule("Process", { |
| 142 | ruleSetName: ruleSet.ruleSetName, |
| 143 | recipients: ["support@example.com"], |
| 144 | actions: [ |
| 145 | { |
| 146 | LambdaAction: { |
| 147 | FunctionArn: processor.functionArn, |
| 148 | InvocationType: "Event", |
| 149 | }, |
| 150 | }, |
| 151 | ], |
| 152 | }); |
| 153 | ``` |
| 154 | |
| 155 | See [Lambda](/aws/compute/lambda) for standing up the `processor` |
| 156 | function itself. SES's permission to invoke it is granted the same |
| 157 | way as S3 and SNS — the function's resource policy must allow |
| 158 | `ses.amazonaws.com` to `lambda:InvokeFunction`. |
| 159 | |
| 160 | ## Order rules with `after` |
| 161 | |
| 162 | Rules evaluate top to bottom. The `after` prop pins a rule |
| 163 | immediately behind another one in the set. Here a catch-all bounce |
| 164 | runs after the support rule, rejecting mail to unknown recipients: |
| 165 | |
| 166 | ```typescript |
| 167 | const bounce = yield* SES.ReceiptRule("BounceUnknown", { |
| 168 | ruleSetName: ruleSet.ruleSetName, |
| 169 | after: inbound.ruleName, |
| 170 | actions: [ |
| 171 | { |
| 172 | BounceAction: { |
| 173 | SmtpReplyCode: "550", |
| 174 | Message: "Mailbox does not exist", |
| 175 | Sender: "mailer-daemon@example.com", |
| 176 | }, |
| 177 | }, |
| 178 | ], |
| 179 | }); |
| 180 | ``` |
| 181 | |
| 182 | A rule with no `recipients` matches everything, so ordering it |
| 183 | last makes it a fallthrough. Omit `after` to place a rule at the |
| 184 | top of the set. |
| 185 | |
| 186 | ## Activate the rule set |
| 187 | |
| 188 | Rules do nothing until their set is the account's active one. |
| 189 | `ActiveReceiptRuleSet` is the pointer SES evaluates against every |
| 190 | inbound message: |
| 191 | |
| 192 | ```typescript |
| 193 | yield* SES.ActiveReceiptRuleSet("Active", { |
| 194 | ruleSetName: ruleSet.ruleSetName, |
| 195 | }); |
| 196 | ``` |
| 197 | |
| 198 | Only one rule set is active per account and region. Re-pointing |
| 199 | this resource at a different set switches receiving over in place. |
| 200 | Destroying it deactivates receiving — but only if the account is |
| 201 | still pointed at the set this resource activated; if something else |
| 202 | has since taken over, the destroy is a no-op. |
| 203 | |
| 204 | ## Filter senders by IP |
| 205 | |
| 206 | `ReceiptFilter` is an account-level allow/block list on the source |
| 207 | IP of inbound connections, applied before any rule runs. Block |
| 208 | filters win over allow filters: |
| 209 | |
| 210 | ```typescript |
| 211 | yield* SES.ReceiptFilter("BlockBadActors", { |
| 212 | ipFilter: { policy: "Block", cidr: "203.0.113.0/24" }, |
| 213 | }); |
| 214 | ``` |
| 215 | |
| 216 | `cidr` takes a single address (`203.0.113.7`) or a range |
| 217 | (`203.0.113.0/24`). Filters have no update API, so changing the |
| 218 | policy or range replaces the filter. |
| 219 | |
| 220 | ## Bounce a message from a Lambda |
| 221 | |
| 222 | When a `LambdaAction` invokes your function synchronously, the |
| 223 | function can bounce the message itself. `SES.SendBounce` is a |
| 224 | runtime binding — an account-level operation with no resource to |
| 225 | scope to, so it's bound with no argument and its implementation is |
| 226 | provided as `SES.SendBounceHttp`: |
| 227 | |
| 228 | ```typescript |
| 229 | // Construction — inside the Lambda's Effect.gen body |
| 230 | const sendBounce = yield* SES.SendBounce(); |
| 231 | ``` |
| 232 | |
| 233 | At runtime, generate the bounce from the id of the message SES |
| 234 | delivered to you: |
| 235 | |
| 236 | ```typescript |
| 237 | yield* sendBounce({ |
| 238 | OriginalMessageId: messageId, |
| 239 | BounceSender: "mailer-daemon@example.com", |
| 240 | BouncedRecipientInfoList: [ |
| 241 | { Recipient: "nobody@example.com", BounceType: "DoesNotExist" }, |
| 242 | ], |
| 243 | }); |
| 244 | ``` |
| 245 | |
| 246 | Provide the implementation on the function with |
| 247 | `Effect.provide(SES.SendBounceHttp)`. You can only bounce a message |
| 248 | within 24 hours of receiving it, and only mail SES actually |
| 249 | received for you. |
| 250 | |
| 251 | ## Where next |
| 252 | |
| 253 | - [Lambda](/aws/compute/lambda) — the function a `LambdaAction` |
| 254 | invokes. |
| 255 | - [S3](/aws/data/s3) — the bucket inbound mail lands in. |
| 256 | - [SNS](/aws/messaging/sns) — fan the notification out to queues |
| 257 | and functions. |
| 258 | |
| 259 | Reference: |
| 260 | |
| 261 | - [ReceiptRuleSet API reference](/providers/aws/ses/receiptruleset) |
| 262 | - [ReceiptRule API reference](/providers/aws/ses/receiptrule) |
| 263 | - [ActiveReceiptRuleSet API reference](/providers/aws/ses/activereceiptruleset) |
| 264 | - [ReceiptFilter API reference](/providers/aws/ses/receiptfilter) |
| 265 | - [SendBounce API reference](/providers/aws/ses/sendbounce) |