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. requests 및 aws-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개의 좋아요

원본 게시물 업데이트

  • 최근 Lambda Powertools가 뛰어난 Pydantic 라이브러리를 지원한다는 사실을 알게 되어 스크립트를 해당 라이브러리를 사용하도록 업데이트했습니다. Lambda 레이어인 lambda-receiver-layer에서 aws-lambda-powertools는 parser 추가 항목이 포함되어야 합니다(예: aws-lambda-powertools[parser])

  • 또한 자격 증명 검색을 위해 AWS Parameters and Secrets Lambda Extension을 실제로 사용하지 않고, Powertools의 기능(호출 간 캐싱을 하지 않음)을 사용하고 있음을 알게 되었습니다.

  • 당분간 Lambda 레이어인 lambda-receiver-layer의 requests 라이브러리 버전이 2.29.0보다 크다면 urllib3 라이브러리를 1.x 버전으로 고정해야 합니다(예: urllib3<2). requests의 후기 버전은 현재 boto3 라이브러리와 충돌하는 urllib3 2버전을 설치합니다.

버전 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: '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'은 비추천(deprecated)이지만, 설정 오류에 대비하여
    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개의 좋아요

이 가이드를 공유해 주셔서 감사합니다 @dlambert :smiley:

11단계까지 잘 진행하다가 막혔습니다:

어디서, 어떻게 이 레이어를 생성해야 하나요? :thinking:

잘 되나요?

저도 11단계에서 막힙니다. 다음에 뭘 해야 할지 모르겠어요. 도와주실 분 없나요?

1개의 좋아요

아니요, 죄송합니다. 포기해서 이메일 답장 기능을 모두 비활성화했고, SES는 단순한 발신 전용 이메일에만 사용하고 있습니다 :cry:

설치를 위해 모든 단계를 따라 해봤지만, 결국 Cloudwatch에서 이 오류가 발생합니다. 도와줄 수 있는 분 계신가요?

[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)

오케이, Cloudflare를 비활성화했더니 문제가 해결됐어요. 나중에 모든 단계를 따라 어떻게 해결했는지 여기에 적어볼게요. :slight_smile:

1개의 좋아요

다음은 제가 수행한 작업입니다.

10단계 이후에 PC에 Python 3.10을 설치했습니다.

그런 다음 다음 명령어를 실행했습니다.

mkdir lambda-receiver-layer

cd lambda-receiver-layer

mkdir python

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

touch ./python/__init__.py

urllib3 관련 문제가 있었기 때문에

해당 오류가 발생하지 않도록 하기 위한 추가 단계는 다음과 같습니다.

lambda-receiver-layer 디렉터리 안에 requirements.txt라는 파일을 생성합니다.

이 requirements.txt 파일에 다음 줄을 추가합니다:

urllib3<2

그런 다음 다음 명령어를 실행합니다.

pip install -r requirements.txt -t layer

이제 lambda-receiver-layer 디렉터리 안에 layer라는 이름의 새 폴더가 생성됩니다.

layer 폴더의 모든 내용을 python 폴더로 복사합니다.

이제 Python 폴더를 마우스 오른쪽 버튼으로 클릭하고 'ZIP으로 압축’을 클릭한 뒤, 이 zip 파일의 이름을 lambda-receiver-layer로 변경합니다.

이제 AWS Management Console로 돌아가서 Lambda 서비스로 이동하고 "Layers"로 이동합니다. "Create Layer"를 클릭하고 이름에 lambda-receiver-layer를 입력한 후, 방금 만든 zip 아카이브를 업로드합니다. 런타임에 Python 3.10을 추가한 후 create를 클릭합니다.

이제 원래 게시물의 12단계부터 다시 따르세요.

11단계에서 막혀 있는데, Python 코드를 어디에 붙여넣어야 하나요?

여러 인스턴스에서 SMTP 바운스 문제를 긴급히 해결해야 합니다. #marketplace에 작업을 게시했습니다: Fix AWS SNS Bounce

14단계에서 막혔는데, 무엇을 해야 하는지 설명해 주실 수 있나요?

2025년 현재 버전 2가 여전히 작동하는지 궁금하신 분이 계신다면, 작동한다는 것을 확인해 드립니다.

사용 중 겪을 수 있는 몇 가지 문제점:

  • 콘솔에서 설정 > 이메일 수신 부분에서 룰셋(Rulesets)을 구성해야 합니다. 메일 매니저 > 룰셋 부분의 룰셋은 아닙니다. 메일 매니저 관련 서비스는 비용이 많이 듭니다. 특히 ingress endpoints를 사용하는 경우 더욱 그러합니다.
  • AWS SES로 회신 이메일을 전송하려면 DNS에 MX 레코드가 필요합니다. 일반 이메일 용도로 루트 도메인에 이미 MX 레코드가 있는 경우(예: contact@example.com과 같은 일반 비즈니스 용도로 Google Workspace 이메일을 사용하는 경우), 회신을 위해 서브도메인을 사용하는 것이 좋습니다. 제 경우에는 reply.example.com에 MX 레코드를 만들어 회신을 inbound-smtp.<REGION>.amazonaws.com으로 보내도록 설정했습니다. 자세한 내용은 이 문서를 참고하세요.
  • CloudWatch를 사용하여 시스템이 어떻게 작동하는지 확인할 수 있습니다. 특정 라이브러리/모듈이 로드되지 않는 오류가 보인다면 Lambda Layer 구성이 잘못되었거나 함수에 연결하지 않은 것입니다. 업로드하는 ZIP 파일이 python/lib/python3.10/site-packages/와 같은 올바른 디렉터리 구조를 가지고 있는지 확인하세요. 이 문서를 참고하세요. Lambda Layer 생성에 대한 온라인 튜토리얼을 찾아보는 것을 권장합니다.

이 코드는 여전히 ARM64와 호환됩니다. 다만 ARM 기반 Python 라이브러리를 다운로드하여 Lambda layer를 올바른 아키텍처로 구성해야 합니다.

모든 설정이 완료되면 관리자 로그에서 수신된 이메일을 볼 수 있습니다.

1개의 좋아요

오늘 v2를 사용하여 새 배포 환경에서 이 가이드를 따라 해 보았는데, 문제없이 잘 작동했습니다! 감사합니다!

그리고 Python 3.10이 아닌 3.14를 사용했는데, 거의 문제없이 잘 돌아갔습니다. 라이브러리를 하나 더 추가해야 했을 뿐입니다.

11단계에서 라이브러리 레이어를 빌드하기 위해 제가 사용한 명령어는 다음과 같습니다:

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 파일에는 최상위 'python/' 폴더가 포함되어야 합니다
cd layer
zip -r ../${LAYER_NAME}.zip python
cd ..
echo "Created: ${LAYER_NAME}.zip"

# 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"입니다.