AWS SES로 발신, 반송 및 수신 이메일 설정하기

AWS SES를 발신, 반송 및 수신 이메일에 사용하기 위해 제가 구성한 설정을 공유하고자 합니다. SES 서비스에는 확실히 미묘한 부분이 있으며, 정확히 어떻게 작동하는지 이해하는 데 상당한 시행착오가 필요했습니다. 이는 단계별로 따를 수 있는 가이드라기보다 뇌가 덤프(brain-dump)에 가깝습니다. 필요 없을 수도 있지만, 사용은 본인의 책임 하에 이루어져야 합니다. 그리고 타인이 작성한 코드를 구현할 때는 반드시 항상 내용을 읽고 이해해야 합니다.

배경:

저는 AWS에서 Discourse를 배포하고, 신뢰성과 중복성을 보장하기 위해 가능한 모든 AWS 서비스를 활용하고 있습니다. 개발자로서 저는 명령줄과 코드에 더 익숙하며, IaC 자동화를 사용하길 원했습니다. 제 전체 환경은 Terraform으로 배포되고 있지만, 웹 콘솔을 통해 항목들을 가능한 한 정렬해 보았습니다. IAM 및 정책 문서는 이 글의 범위를 벗어납니다만, 필요한 부분이 있는 곳은 언급해 두었습니다.

단일 애플리케이션을 위해 Postfix 인스턴스를 실행하는 것은 과할 것 같았습니다. POP3 메일박스를 사용하는 것은 너무 90년대적인 방식입니다. 그래서 저는 AWS의 깊은 숲으로 들어갔습니다.

제 탐구에 도움이 된 매우 유용한 게시물들을 몇 가지 찾았습니다.

mail-receiver 컨테이너도 Discourse가 메시지를 처리하는 방식을 이해하는 데 도움이 되었습니다.

처음에는 AWS 웹훅 엔드포인트가 수신 메시지를 처리할 것으로 기대했지만, 코드를 살펴본 후에는 그렇게 되지 않음을 깨달았습니다. @dltj 님의 훌륭한 예시를 기반으로 제 람다 수신기 코드를 작성했습니다. S3 대신 메시지 전달에 SNS를 사용하기로 결정했습니다.

사전 요구 사항

  • AWS 계정
  • DNS 및 이메일 관련 레코드 유형에 대한 실무 지식
  • 변경 사항을 수행할 수 있는 도메인(또는 서브도메인)

참고 사항

  • 문서화된 모든 항목은 동일한 AWS 리전 내에서 생성되어야 합니다
  • **굵은 이탤릭체 텍스트 이처럼 은 구현에 따라 달라지는 값입니다
  • 이탤릭체 텍스트 는 변수 이름, 고정 값 또는 UI 요소의 이름입니다

단계

  1. 이메일 수신을 지원하는 AWS 리전 중 하나에서 Simple Email Service(SES) 도메인 식별자, your.domain, 을 생성합니다.

  2. 도메인 식별자를 검증합니다.

  3. 피드백 알림을 위한 Simple Notification Service(SNS) 토픽, feedback-sns-topic, 을 생성합니다.
    a. aws_sns_topic_arn_allowlist 설정에 feedback-sns-topic 토픽의 ARN을 추가합니다.

  4. your.domain 도메인 식별자를 구성합니다.
    a. 이메일 피드백 전달을 활성화합니다.
    b. 반송 및 불만(전달이 아님) 피드백 알림에 SNS feedback-sns-topic 토픽을 사용하도록 구성합니다.

  5. SNS feedback-sns-topic 토픽에 구독을 생성합니다.
    a. 프로토콜은 HTTPS입니다(아직도 HTTP를 사용 중이시죠?)
    b. 엔드포인트를 https://your.domain/webhooks/aws 로 설정합니다(VERP 게시물 참조)
    c. 원시 메시지 전달비활성화 되어 있는지 확인합니다

  6. 수신 이메일을 위한 다른 SNS 토픽, incoming-sns-topic, 을 생성합니다.

  7. 기존 활성 규칙 세트가 없다면 SES 이메일 수신 규칙 세트, inbound-mail-set, 를 생성합니다. 이미 존재한다면, 활성 규칙 세트는 하나만 존재할 수 있으므로 그것을 사용합니다.

  8. inbound-mail-set 수신 규칙 세트에 수신 규칙을 생성합니다.
    a. 수신자 조건을 your.domain 으로 설정합니다.
    b. SNS 토픽 incoming-sns-topic 에 게시하는 작업을 추가하고, 인코딩을 Base64 로 설정합니다.

  9. Discourse 인스턴스에서 system 사용자를 위한 API 키를 생성하고, email 리소스에 대해 receive email 작업을 허용합니다.

  10. Secret Manager에서 다음 키와 각 값이 포함된 시크릿, email-handler-secret, 을 생성합니다:

    • api_endpoint - https://your.domain/admin/email/handle_mail
    • api_key - 9단계에서 생성한 값
    • api_username - 9단계에서 다른 것을 사용하지 않았다면 system
  11. requestsaws-lambda-powertools 라이브러리를 포함하는 python3.10 런타임용 람다 레이어, lambda-receiver-layer, 를 생성합니다.

  12. python3.10 런타임용 람다 함수, email-receiver-lambda, 를 수신기 코드를 사용하여 생성합니다:

# 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)
  1. email-receiver-lambda 람다 함수를 구성합니다:
    a. 레이어 lambda-receiver-layer 를 추가합니다
    b. AWS Parameter Store 에 대한 리전별 레이어를 추가합니다
    c. 값이 email-handler-secret 인 환경 변수 SECRET_NAME 을 추가합니다
    d. 추가적인 세부 정보를 기록하고 싶다면, 값이 true 인 환경 변수 POWERTOOLS_LOGGER_LOG_EVENT 를 추가합니다

  2. 람다 함수 email-receiver-lambda 에 시크릿 email-handler-secret 에 대한 IAM 권한 secretsmanager:GetSecretValue 를 부여합니다.

  3. SNS 토픽 incoming-sns-topic 에 구독을 생성합니다.
    a. 프로토콜은 AWS Lambda입니다
    b. 엔드포인트를 email-receiver-lambda 의 ARN으로 설정합니다

  4. incoming-sns-topic 토픽의 SNS 구독이 email-receiver-lambda 를 호출하려면 IAM 권한이 필요하지만, 콘솔을 통해 구성할 경우 자동으로 처리된다고 믿습니다.

디버깅 목적이나 일반적인 자기 괴롭힘을 위해, 알림을 모니터링할 수 있도록 두 SNS 토픽 중 하나에 이메일 구독을 추가할 수 있습니다.

몇 번에 걸쳐 이 내용을 작성했지만, 모든 것이 포함된 것 같습니다. 시간이 허락하는 한 일반적인 질문에 답변해 드릴 수 있습니다.

9개의 좋아요

Updates to original post

Version 2

# 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 os
from enum import Enum
from typing import Literal, Optional

import requests
from aws_lambda_powertools import Logger
from aws_lambda_powertools.logging import utils
from aws_lambda_powertools.utilities.parser import BaseModel, event_parser
from aws_lambda_powertools.utilities.parser.models import SnsModel, SesMessage, SnsRecordModel, SesMail, SesReceipt, SesMailCommonHeaders
from aws_lambda_powertools.utilities.typing import LambdaContext


class Secret(BaseModel):
    api_endpoint: str
    api_username: str
    api_key:      str


class SnsSesActionEncoding(str, Enum):
    BASE64 = 'BASE64'
    UTF8   = 'UTF8'


class SnsSesReceiptAction(BaseModel):
    type:     Literal['SNS']
    encoding: SnsSesActionEncoding
    topicArn: str


class SnsSesReceipt(SesReceipt):
    action: SnsSesReceiptAction


class SnsSesMailCommonHeaders(SesMailCommonHeaders):
    returnPath: Optional[str]


class SnsSesMail(SesMail):
    commonHeaders: SnsSesMailCommonHeaders


class SnsSesMessage(SesMessage):
    notificationType: str  # TODO: Are there other values besides 'Received'?
    content:          str
    mail:             SnsSesMail
    receipt:          SnsSesReceipt


try:
    SECRET_NAME       = os.environ['SECRET_NAME']
    AWS_SESSION_TOKEN = os.environ['AWS_SESSION_TOKEN']
except KeyError as e:
    raise RuntimeError(f'Missing {e} environment variable')

AWS_EXTENSION_PORT = os.getenv('PARAMETERS_SECRETS_EXTENSION_HTTP_PORT', 2773)

logger = Logger(service=os.getenv('AWS_LAMBDA_FUNCTION_NAME'), log_uncaught_exceptions=True, use_rfc3339=True)

utils.copy_config_to_registered_loggers(source_logger=logger)


def get_secret() -> Secret:
    # AWS Parameters and Secrets Lambda Extension
    # https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_lambda.html

    response = requests.get(
        url=f'http://localhost:{AWS_EXTENSION_PORT}/secretsmanager/get?secretId={SECRET_NAME}',
        headers={
            'X-Aws-Parameters-Secrets-Token': AWS_SESSION_TOKEN
        }
    )

    try:
        response.raise_for_status()
    except Exception:
        logger.critical(response.text)
        raise

    return Secret.parse_raw(response.json()['SecretString'])


def handle_record(record: SnsRecordModel):
    sns_record       = record.Sns
    sns_ses_message  = SnsSesMessage.parse_raw(record.Sns.Message)
    mail_destination = ','.join(sns_ses_message.mail.destination)

    logger.info(f'Processing SNS {sns_ses_message.notificationType} notification record with MessageId {sns_record.MessageId} from {sns_ses_message.mail.source} to {mail_destination}')

    # 'email' is deprecated, but just in case something is configured incorrectly
    body_key = 'email_encoded' if sns_ses_message.receipt.action.encoding is SnsSesActionEncoding.BASE64 else 'email'
    secret   = get_secret()

    response = requests.post(
        url=secret.api_endpoint,
        headers={
            'Api-Username': secret.api_username,
            'Api-Key':      secret.api_key,
        },
        json={
            body_key: sns_ses_message.content
        }
    )

    try:
        response.raise_for_status()
    except Exception:
        logger.critical(response.text)
        raise

    logger.info(f'Endpoint response: {response.text}')


@event_parser(model=SnsModel)
@logger.inject_lambda_context
def lambda_handler(event: SnsModel, context: LambdaContext):
    for record in event.Records:
        handle_record(record)
1개의 좋아요

Thanks for this guide @dlambert :smiley:

I was doing great, until I got to step 11:

Where / how do I create this? :thinking:

Do you get it working?

I also get stuck at step 11. don’t know what to do next. anyone could help?

1개의 좋아요

No, sorry, I gave up and we disabled all reply-by-email functionality, using SES for simple outbound email only :cry:

I tried to follow all the steps to setup but in the end, I am getting this error in Cloudwatch can anyone help with this?

[ERROR] HTTPError: 403 Client Error: Forbidden for url: https://forum.siteurl.com/admin/email/handle_mail
Traceback (most recent call last):
  File "/opt/python/aws_lambda_powertools/middleware_factory/factory.py", line 135, in wrapper
    response = middleware()
  File "/opt/python/aws_lambda_powertools/utilities/data_classes/event_source.py", line 39, in event_source
    return handler(data_class(event), context)
  File "/opt/python/aws_lambda_powertools/logging/logger.py", line 453, in decorate
    return lambda_handler(event, context, *args, **kwargs)
  File "/var/task/lambda_function.py", line 107, in lambda_handler
    handle_record(record)
  File "/var/task/lambda_function.py", line 100, in handle_record
    response.raise_for_status()
  File "/opt/python/requests/models.py", line 1021, in raise_for_status
    raise HTTPError(http_error_msg, response=self)

Okay, it was because of Cloudflare disabling resolved the issue. maybe later on I’ll write here how I made it work following all the steps. :slight_smile:

1개의 좋아요

This is what I did.

Installed Python 3.10 on my PC, after step 10.

Then run these commands.

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

pip install requests aws-lambda-powertools -t ./python

touch ./python/__init__.py

As I had issues with urllib3

Here are additional steps so you don’t get that error.

In your lambda-receiver-layer directory create this file requirements.txt

add the following line in this file requirements.txt:

urllib3<2

Then Run the following command

pip install -r requirements.txt -t layer

Now another folder will be created inside lambda-receiver-layer directory named layer

Copy all the contents of layer to python folder

Now, right-click on the Python folder and click ‘Compress to ZIP’ rename this zip to lambda-receiver-layer

Now, Go back to the AWS Management Console, go to the Lambda service, and navigate to “Layers.” Click on “Create Layer,” put this in the name lambda-receiver-layer and upload the zip archive you created. In runtime add Python 3.10 then click create.

Now follow back from step 12 from the original post.

I’m getting stuck at step 11, where do I paste the python code?

I need urgent help to fix my SMTP Bounces in several instances, I’ve posted a Marketplace job Fix AWS SNS Bounce

I’m stuck at point 14, anybody can clarify what I’ve to do?

If anyone in 2025 is wondering if version 2 still works, I can confirm that it does.

A few hiccups that you might come across:

  • Make sure you are configuring the rulesets in Configuration > Email receiving in the console, not the rulesets in Mail Manager > Rule sets. The Mail Manager stuff costs a lot of money, especially with those ingress endpoints.
  • You need an MX record in your DNS to receive reply emails to send to AWS SES. If you already have an MX record for your root domain for general email stuff (i.e., using Google Workspace emails for general business stuff for an address like contact@example.com), you will want to use a subdomain for your replies. In my case, I made an MX record on reply.example.com to send the replies to inbound-smtp.<REGION>.amazonaws.com. Look at this documentation for more details.
  • You can use CloudWatch to see how things are working. If you see an error where a certain library/module isn’t loading, you likely misconfigured your Lambda Layer or didn’t connect it to the function. Check that the ZIP file that you upload has the correct directory structure that looks like python/lib/python3.10/site-packages/; see this documentation. I recommend just looking up some online tutorials on creating a Lambda Layer.

The code still works with ARM64 - you just need to configure your Lambda layer with the correct architecture by downloading the ARM-based Python libraries.

When all is said and done, you should see the received emails in your admin logs.

1개의 좋아요

I followed this guide using v2 on a new deployment today and it worked just fine! Thanks!

And I used python 3.14, not 3.10, mostly without issue. Just needed to add one more library.

For step 11, my command looks like this to build the libraries layer:

LAYER_NAME=lambda-receiver-layer
PYVER=3.14
mkdir -p layer/python

docker run --rm -v "$PWD":/var/task public.ecr.aws/sam/build-python${PYVER}:latest \
  /bin/bash -lc "pip install -U pip && pip install -t layer/python \
  requests aws-lambda-powertools 'urllib3<2' pydantic"

# Zip it in the required structure: zip must contain top-level 'python/' folder
cd layer
zip -r ../${LAYER_NAME}.zip python
cd ..
echo "Created: ${LAYER_NAME}.zip"

# Deploy to AWS Lambda:
aws lambda publish-layer-version \
  --layer-name lambda-receiver-layer \
  --zip-file fileb://lambda-receiver-layer.zip \
  --compatible-runtimes python3.14 \
  --compatible-architectures arm64

방금 이 설정을 마쳤는데, **raw message delivery(원시 메시지 전달)**는 **ENABLED(활성화)**가 아니라 **DISABLED(비활성화)**되어 있어야 한다고 생각합니다.

raw message delivery가 활성화된 상태에서는 SNS 바운스(반송) 알림에 discourse가 메시지를 검증하는 데 필요한 SNS 메타데이터가 포함되지 않습니다. 액세스 로그에는 다음과 같은 항목들이 포함되어 있었습니다:

"POST /webhooks/aws HTTP/1.1" "Amazon Simple Notification Service Agent" "-" 406 414 "-" 0.008 0.008 "-" "-" "-" "-" "-" "-" "-"

즉, HTTP 상태 코드 406, "Not Acceptable(허용되지 않음)"입니다.

raw message delivery를 비활성화한 후, 액세스 로그에는 다음과 같은 내용이 표시되었습니다:

"POST /webhooks/aws HTTP/1.1" "Amazon Simple Notification Service Agent" "-" 200 402 "-" 0.022 0.022 "-" "-" "-" "-" "-" "-" "-"

즉, HTTP 상태 코드 200, "OK"입니다.