← 목록

AWS SES Mail Manager를 활용한 메일 수신 자동화 파이프라인

SES Mail Manager로 특정 주소의 수신 메일을 S3에 저장하고, Lambda로 인증코드를 파싱하여 내부 API를 자동 호출하는 메일 수신 자동화 파이프라인 구축

2025-03-15AWSPythonAutomationDevOps

목표

외부에서 특정 메일 주소로 전송되는 인증 메일을 자동으로 수신하고, 메일 본문에서 인증코드를 추출하여 내부 API를 자동 호출하는 파이프라인을 구축한다.

외부 메일 발송
    ↓
[specific-address]@[domain] 으로 수신
    ↓
SES Mail Manager (메일 수신 전용 엔드포인트)
    ↓
S3에 .eml 파일 저장
    ↓
S3 이벤트 → Lambda 트리거
    ↓
메일 파싱 → 인증코드 추출 → 내부 API 호출

왜 Mail Manager인가

기존 도메인의 메일은 외부 네임서버에서 관리되고 있었다. 특정 주소로 오는 메일만 AWS에서 별도로 처리해야 하는 상황에서, 기존 메일 시스템을 건드리지 않고 SES Mail Manager를 통해 별도의 메일 전용 엔드포인트를 만들어 해결했다.


Mail Manager 설정

1. 주소 목록 등록

Amazon SES > Mail Manager에서 수신할 이메일 주소를 개별 등록한다.

[specific-address-1]@[domain]
[specific-address-2]@[domain]

개별 이메일 주소 단위로 수신을 제어할 수 있다.

2. 트래픽 정책 생성

수신 메일에 대한 허용/차단 정책을 설정한다.

3. 규칙 세트 생성

수신된 메일에 대한 처리 규칙을 정의한다.

4. 수신 엔드포인트 생성

엔드포인트 생성 시 발급되는 A Record를 도메인 관리 서비스에서 MX 레코드로 등록해야 한다.

# 엔드포인트 생성 후 발급되는 A Record 예시
[endpoint-id].mail-manager-smtp.amazonaws.com

# 도메인 MX 레코드에 등록
MX  [subdomain].[domain]  [endpoint-id].mail-manager-smtp.amazonaws.com

5. IAM 역할 확인

다음 항목이 올바르게 설정되어야 S3에 메일이 저장된다:

IAM 역할 설정이 잘못되면 메일은 수신되지만 S3에 저장되지 않는다.

6. 설정 확인

# MX 레코드 확인
nslookup -type=MX [subdomain].[domain]
# → [endpoint-id].mail-manager-smtp.amazonaws.com 확인

S3 → Lambda 이벤트 연동

아키텍처

S3 버킷 (메일 .eml 저장)
    ↓ 객체 생성 이벤트
Lambda (ses-mail-parser)
    ├── S3에서 .eml 파일 읽기
    ├── 메일 파싱 (MIME 헤더, multipart)
    ├── 인증코드 추출 (정규식)
    └── 내부 API 호출 (인증코드 전달)

Lambda 권한 구조

Lambda에는 두 가지 권한이 필요하다:

1. Lambda 실행 역할 (Lambda가 할 수 있는 일)
   ├── S3 버킷 객체 읽기 (s3:GetObject)
   └── CloudWatch Logs 쓰기

2. 리소스 기반 정책 (누가 Lambda를 호출할 수 있는지)
   └── S3 서비스가 Lambda를 invoke할 수 있도록 허용

Lambda 실행 역할은 "코드가 실행될 때 무엇을 할 수 있는지", 리소스 기반 정책은 "누가 이 Lambda를 트리거할 수 있는지"를 정의한다.

S3 이벤트 알림 설정

S3 버킷 > 속성 > 이벤트 알림 > 새 이벤트 알림 생성
  이벤트 유형: 객체 생성 (s3:ObjectCreated:*)
  대상: Lambda 함수 (ses-mail-parser)

Lambda 메일 파싱 로직

.eml 파일 구조

S3에 저장되는 .eml 파일은 MIME 원문 전체다:

MIME 헤더 (From, To, Subject, Date...)
    ↓
multipart/alternative
    ├── text/plain (텍스트 본문)
    └── text/html (HTML 본문)

본문 추출

import email
from email import policy
 
def extract_best_body(raw_email: bytes):
    msg = email.message_from_bytes(raw_email, policy=policy.default)
    text_body = None
    html_body = None
 
    if msg.is_multipart():
        for part in msg.walk():
            content_type = part.get_content_type()
            if content_type == 'text/plain' and not text_body:
                text_body = part.get_content()
            elif content_type == 'text/html' and not html_body:
                html_body = part.get_content()
    else:
        content_type = msg.get_content_type()
        content = msg.get_content()
        if content_type == 'text/plain':
            text_body = content
        else:
            html_body = content
 
    return text_body, html_body

인증코드 추출

import re
 
def extract_code(text_body, html_body):
    # text/plain에서 먼저 시도
    for body in [text_body, html_body]:
        if not body:
            continue
        # 6자리 숫자 인증코드 추출
        match = re.search(r'\b(\d{6})\b', body)
        if match:
            return match.group(1)
    return None

메일 본문 예시:

text/plain: "Can't use the link? Enter a code instead: 223638"
text/html:  "<td>Enter a code instead: <b>223638</b></td>"
→ 223638 추출

내부 API 호출

import urllib.request
import json
import os
 
def call_auth_api(code):
    api_url = os.environ.get('AUTH_API_URL')
    payload = json.dumps({'code': code}).encode()
 
    req = urllib.request.Request(
        api_url,
        data=payload,
        headers={'Content-Type': 'application/json'},
    )
    with urllib.request.urlopen(req, timeout=10) as resp:
        return {
            'status': resp.status,
            'body': resp.read().decode(),
        }

Lambda 핸들러

import boto3
 
s3 = boto3.client('s3')
 
def lambda_handler(event, context):
    # S3 이벤트에서 버킷명, 오브젝트 키 추출
    record = event['Records'][0]['s3']
    bucket = record['bucket']['name']
    key = record['object']['key']
 
    # .eml 파일 읽기
    obj = s3.get_object(Bucket=bucket, Key=key)
    raw_email = obj['Body'].read()
 
    # 메일 파싱
    text_body, html_body = extract_best_body(raw_email)
 
    # 인증코드 추출
    code = extract_code(text_body, html_body)
 
    if not code:
        return {'status': 'no_code_found'}
 
    # API 호출
    result = call_auth_api(code)
 
    return {
        'verification_code': code,
        'api_result': result,
    }

CloudWatch Logs에서 추출된 코드와 API 응답을 확인할 수 있다.


보안 고려사항


S3 접근 경로 최적화

내부 EC2에서 S3에 접근할 때 NAT Gateway 경유 대신 S3 Gateway Endpoint를 사용하여 데이터 전송료를 절감한다.

[NAT Gateway 경유]
EC2 → NAT Gateway → S3  (데이터 처리 비용 발생)

[Gateway Endpoint]
EC2 → S3 Gateway Endpoint → S3  (비용 없음)

S3 버킷 정책

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowSESPutObject",
      "Effect": "Allow",
      "Principal": { "Service": "ses.amazonaws.com" },
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::[bucket-name]/*"
    },
    {
      "Sid": "AllowVPCEndpointAccess",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::[bucket-name]",
        "arn:aws:s3:::[bucket-name]/*"
      ],
      "Condition": {
        "StringEquals": {
          "aws:sourceVpce": "[vpce-id]"
        }
      }
    }
  ]
}

정리


배운 점

자동화에서 제일 까다로운 구간은 사람이 만든 포맷이다. 메일 수신, S3 저장, Lambda 트리거까지는 AWS가 다 해주는데, 정작 시간이 걸린 건 MIME multipart 안에서 인증코드를 꺼내는 부분이었다. 정해진 규격이 없는 입력을 다루는 일은 늘 예외 처리가 본론이 된다.

기존 메일 시스템을 건드리지 않고 특정 주소만 AWS로 받는 구조를 고른 것도 같은 맥락이다. 운영 중인 것을 바꾸지 않고 옆에 붙이는 방식이 실패했을 때 되돌리기 쉽다.

문서에서 필요한 값을 뽑아낸다는 점에서 AI Document Processing Pipeline과 닮았다. 다만 그쪽은 규칙으로 잡히지 않아 모델을 썼고, 여기는 정규식으로 충분했다. 어느 쪽을 쓸지는 입력이 얼마나 변하는지가 정한다.


이어 읽기