I thought I’d share the configuration I came up with to use AWS SES for outgoing, bounce, and incoming email. There’s definitely some nuance to the SES service, and it took a good deal of trial and error to understand exactly how it works. This is more of a brain-dump than step-by-step-follow-the-dotted-line. It should be unnecessary, but use at your own risk. And by all means always read through and understand any code written by others you implement.
Background:
I’m working to deploy Discourse in AWS and utilize all their services I can to ensure reliability and redundancy. As a developer I’m more comfortable with the command line and code, and wanted to use IaC automation. My whole environment is being deployed with Terraform, but I’ve tried to click through the web console and line things up as best I can. IAM and policy documents are beyond the scope of this, but I believe I’ve called out where things are needed.
Running a Postfix instance seems like over kill for a single application. Using a POP3 mailbox is so very 90’s. So down the AWS rabbit hole I went.
I did find some extremely useful posts which aided my quest
- AWS SES / AWS Lambda mail receiver endpoint code?
- How to use Amazon SES for sending emails to users?
- Configure VERP to handle bouncing e-mails
The mail-receiver container also helped me understand how Discourse digests messages
- Configure direct-delivery incoming email for self-hosted sites
- Update mail-receiver to the release version
Initially I expected the AWS webhook endpoint to handle incoming messages, but after going though the code realized it wouldn’t. I based my lambda receiver code on the excellent example by @dltj. I opted to use SNS for message delivery instead of S3.
Prereqs
- AWS Account
- Working knowledge of DNS and the email related record types
- A domain (or subdomain) in which you can make changes
Notes
- Everything documented must be created in the same AWS region
- Bold italicized text like this are your implementation specific values
- Italicized text are names of variables, fixed values, or UI elements
Steps
-
Create a Simple Email Service (SES) domain identity, your.domain, in one of the AWS regions supporting email receiving
-
Verify domain identity
-
Create a Simple Notification Service (SNS) topic, feedback-sns-topic, for feedback notifications
-
Configure the your.domain domain identity
a. Enable email feedback forwarding
b. Configure bounce and complaint (not delivery) feedback notifications to use SNS feedback-sns-topic topic -
Create a subscription on the SNS feedback-sns-topic topic
a. Protocol is HTTPS (you’re not still using HTTP are you?)
b. Set endpoint to https://your.domain/webhooks/aws (see VERP post)
c. Select enable raw message delivery -
Create another SNS topic, incoming-sns-topic, for incoming email
-
Create an SES email receiving rule set, inbound-mail-set, if there isn’t an existing active one. If so use that as there can only be one active rule set
-
Create a receipt rule in the inbound-mail-set receiving rule set
a. Set recipient condition to your.domain
b. Add action to publish to SNS topic incoming-sns-topic, encoding Base64 -
Create API key in your Discourse instance for user system, granting receive email action on the email resource
-
Create a secret in Secret Manager, email-handler-secret, with the following keys and their respective values:
- api_endpoint - https://your.domain/admin/email/handle_mail
- api_key - from step 9
- api_username - system, unless you used something different in step 9
-
Create a Lambda layer, lambda-receiver-layer, for the python3.10 runtime containing the requests and aws-lambda-powertools libraries
-
Create a lambda function, email-receiver-lambda, for the python3.10 runtime with the receiver code:
# Copyright (c) 2023 Derek J. Lambert
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import json
import os
from typing import TypedDict
import requests
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities import parameters
from aws_lambda_powertools.utilities.data_classes import event_source
from aws_lambda_powertools.utilities.data_classes.sns_event import SNSEvent, SNSEventRecord
from aws_lambda_powertools.utilities.typing import LambdaContext
class Secret(TypedDict):
api_endpoint: str
api_username: str
api_key: str
service = os.getenv('AWS_LAMBDA_FUNCTION_NAME')
logger = Logger(log_uncaught_exceptions=True, service=service)
try:
SECRET_NAME = os.environ['SECRET_NAME']
except KeyError as e:
raise RuntimeError(f'Missing {e} environment variable')
AWS_EXTENSION_PORT = os.getenv('PARAMETERS_SECRETS_EXTENSION_HTTP_PORT', 2773)
EXTENSION_ENDPOINT = f'http://localhost:{AWS_EXTENSION_PORT}/secretsmanager/get?secretId={SECRET_NAME}'
def get_secret() -> Secret:
return parameters.get_secret(SECRET_NAME, transform='json')
def handle_record(record: SNSEventRecord):
sns = record.sns
sns_message = json.loads(sns.message)
try:
message_type = sns_message['notificationType']
message_mail = sns_message['mail']
message_content = sns_message['content']
message_receipt = sns_message['receipt']
except KeyError as exc:
raise RuntimeError(f'Key {exc} missing from message')
try:
receipt_action = message_receipt['action']
except KeyError as exc:
raise RuntimeError(f'Key {exc} missing from receipt')
try:
action_encoding = receipt_action['encoding']
except KeyError as exc:
raise RuntimeError(f'Key {exc} missing from action')
try:
mail_source = message_mail['source']
mail_destination = ','.join(message_mail['destination'])
except KeyError as exc:
raise RuntimeError(f'Key {exc} missing from mail')
logger.info(f'Processing SNS {message_type} {sns.get_type} record with MessageId {sns.message_id} from {mail_source} to {mail_destination}')
# 'email' is deprecated, but just in case something is configured incorrectly
body_key = 'email_encoded' if action_encoding == 'BASE64' else 'email'
request_body = {
body_key: message_content
}
secret = get_secret()
headers = {
'Api-Username': secret['api_username'],
'Api-Key': secret['api_key'],
}
response = requests.post(url=secret['api_endpoint'], headers=headers, json=request_body)
logger.info(response.text)
response.raise_for_status()
@event_source(data_class=SNSEvent)
@logger.inject_lambda_context
def lambda_handler(event: SNSEvent, context: LambdaContext):
for record in event.records:
handle_record(record)
-
Configure email-receiver-lambda lambda function:
a. Add layer lambda-receiver-layer
b. Add region-specific layer for AWS Parameter Store
c. Add environment variable SECRET_NAME with the value email-handler-secret
d. If you’d like additional details logged, add environment variable POWERTOOLS_LOGGER_LOG_EVENT with value true -
Grant lambda function email-receiver-lambda IAM permission secretsmanager:GetSecretValue for secret email-handler-secret
-
Create a subscription on the SNS topic incoming-sns-topic
a. Protocol is AWS Lambda
b. Set endpoint to ARN of email-receiver-lambda -
IAM permissions will be needed for the SNS subscription on incoming-sns-topic topic to invoke email-receiver-lambda, but I believe this will be done automatically when configured through the console
For debugging purposes, or general self annoyance, you can add an email subscription to either of the SNS topics to monitor the notifications.
I put this down in a couple sittings, but I think it’s everything. I can try and answer general questions as time permits.