メール受信 · 2026年9月22日
Amazon SESでメールを受信する方法:S3とLambda
Amazon SES、S3、Lambdaを使って、本番運用に耐えるメール受信パイプラインを構築します。MIMEの安全性、テナントのルーティング、冪等性、再試行、スレッド化まで扱います。
Amazon SESでメールを受信する最短で確実な経路は次のとおりです。ドメインを検証し、MXレコードをSESの受信エンドポイントに向け、受け付けたすべてのメッセージをS3に保存してから、Lambdaを非同期で呼び出して解析・永続化します。S3アクションはLambdaアクションより前に置きます。SESのメッセージIDを冪等キーとして扱い、ルーティングにはSMTPのエンベロープ受信者を使い、ヘッダーや添付ファイルを信頼するのではなく、安全でないコンテンツは隔離してください。
構築するアーキテクチャ
SESでルートドメインのすべてのメールを受信する必要がない限り、inbound.example.comのような専用のサブドメインを使ってください。そうすれば、アプリケーションのメールを従業員の受信トレイと分けておけるうえ、ロールバックもメールの移行ではなくDNSの変更で済みます。
本番環境での処理の流れは次のとおりです。
sender
-> SES inbound SMTP endpoint
-> active SES receipt rule
-> S3 raw-message object
-> asynchronous Lambda action
-> MIME parser and policy checks
-> application database and private attachment storage
Amazon SESの受信ルールは、アクションを順番に実行します。コードがメッセージ本文を必要とする場合について、AWSはS3を先、Lambdaを後にするパターンを明示的にドキュメント化しています。Lambdaアクションを直接使うと、受け取れるのはメタデータと一部のヘッダーだけで、本文全体は受け取れません。本文は生のMIMEオブジェクトとしてS3に残ります(AWS SESのメール受信の概念)。
この分離は有用です。SMTPに面したステップは元のメッセージをすばやく保存し、解析、インデックス作成、通知、ビジネスロジックは受け付け後に行われます。データベースの一時的な障害のせいで、送信者のメールサーバーにSMTPトランザクションをやり直させるべきではありません。
1. 対応リージョンを選び、ドメインを検証する
SESのメール受信は、一部のAWSリージョンでのみ利用できます。最新のSES受信エンドポイントの一覧からリージョンを選び、該当するAWSドキュメントで明示的に許可されていない限り、SES、Lambda、SNS、KMSのリソースはそのリージョンにまとめてください。
メールを受信するルートドメインまたはサブドメインそのものについて、SESのドメインIDを作成します。ドメイン検証には、SESが提示するDNSレコードの公開が必要です。受信のための検証はドメインの管理権限を証明するもので、受信トラフィックをSESに送るMXルートの設定とは別物です(AWSのドメイン検証ガイド)。
専用のサブドメインの場合、DNSレコードは概念的には次のようになります。
inbound.example.com. MX 10 inbound-smtp.us-east-1.amazonaws.com.
us-east-1は選択したリージョンに置き換えてください。AWSはMXの値を10 inbound-smtp.<region>.amazonaws.comとしてドキュメント化しています(AWSのMXレコードガイド)。Google Workspace、Microsoft 365、その他のメールボックスプロバイダーを通じてルートドメインでメールを受信している人がまだいる場合は、ルートドメインをSESに向けないでください。
テストの前に、公開したレコードを複数のリゾルバーから確認してください。
dig MX inbound.example.com +short
DNSで見えることが証明するのは、ルートが公開されていることだけです。セットアップ完了とする前に、テスト用アドレスに管理されたメッセージを送り、SESがそれを保存したことを確認してください。
2. 処理する前に生のメッセージを保存する
パブリックアクセスをブロックし、ライフサイクルポリシーを設定し、実用上最も狭いIAM権限を付与したプライベートなS3バケットを作成します。次に、受信者の条件が受信用ドメインまたは特定のアドレスに一致するSESの受信ルールを作成します。
最初のアクションでは、生のメッセージをS3に配信します。inbound/のようなオブジェクトプレフィックスを付けると、保持ルールやアクセスポリシーの範囲を絞りやすくなります。SESは、変更を加えていない生のMIMEコンテンツを保存します。AWSの現在のドキュメントでは、S3に保存する場合のデフォルトの最大サイズは40 MBですが、メッセージ全体を含むSNSアクションの最大サイズははるかに小さい150 KBです(AWSのS3受信アクション)。実際の返信や添付ファイルを扱ううえでS3のほうが安全なデフォルトなのは、このサイズの差があるためです。
受信アクションでSESのオプションのKMS設定を有効にする場合は、暗号化の詳細をよく読んでください。この機能でSESが使うのは通常のS3サーバー側暗号化ではなく、クライアント側暗号化です。そのため、読み取り側は互換性のあるクライアントでオブジェクトを復号する必要があります。安易に有効にして、インシデント対応中にNodeのパーサーが保存済みのバイト列を読めないと気づく、といった事態は避けてください。
SESには、意図したバケットとプレフィックスへの書き込み権限だけを付与します。Lambdaには、同じ場所に対するs3:GetObjectだけを付与します。関数にバケットの管理権限は必要ありません。
3. 非同期のLambdaアクションを追加する
受信ルールでは、LambdaアクションをS3アクションの後に置きます。SESがルールの評価を続けるべきかを関数が判断する必要がない限り、非同期呼び出しを使ってください。AWSは通常の処理には非同期実行を推奨し、同期実行はメールフローの判断用としています(AWSのLambda受信アクション)。
SESが割り当てるmail.messageIdは、プレフィックスを設定していない場合、そのままS3のオブジェクトキーになります。プレフィックスを設定している場合は、その前にプレフィックスを付けます。次のNode.jsのスケルトンは、生のメッセージを取得して解析します。@aws-sdk/client-s3と、mailparserのような保守されているMIMEパーサーをデプロイ成果物に同梱し、バージョンを固定してください。
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { simpleParser } from "mailparser";
const s3 = new S3Client({});
const bucket = process.env.INBOUND_BUCKET;
const prefix = process.env.INBOUND_PREFIX || "inbound/";
export async function handler(event) {
for (const record of event.Records || []) {
const ses = record.ses;
const messageId = ses?.mail?.messageId;
const recipients = ses?.receipt?.recipients || [];
if (!messageId || !/^[A-Za-z0-9._-]+$/.test(messageId)) {
throw new Error("Missing or invalid SES message ID");
}
// claimOnce must be an atomic insert with a unique constraint.
if (!(await claimOnce(messageId))) continue;
try {
const object = await s3.send(new GetObjectCommand({
Bucket: bucket,
Key: `${prefix}${messageId}`,
}));
const raw = Buffer.from(await object.Body.transformToByteArray());
const parsed = await simpleParser(raw, {
skipHtmlToText: true,
skipTextToHtml: true,
});
await saveInboundMessage({
providerMessageId: messageId,
envelopeRecipients: recipients,
envelopeFrom: ses.mail.source,
headerMessageId: parsed.messageId || null,
inReplyTo: parsed.inReplyTo || null,
references: parsed.references || [],
subject: parsed.subject || "",
text: parsed.text || "",
html: parsed.html || null,
attachments: parsed.attachments,
receivedAt: ses.mail.timestamp,
});
await markComplete(messageId);
} catch (error) {
await releaseOrMarkFailed(messageId, String(error));
throw error;
}
}
}
プレースホルダーの関数はアプリケーション固有のストレージを表していますが、その契約は重要です。claimOnceは、プロバイダーのメッセージIDに対するデータベースの一意制約か、条件付き書き込みを使う必要があります。読み取ってから挿入する方式では競合が起こります。オペレーターがprocessing、complete、quarantined、failedを区別できるよう、処理状態を保存してください。
Toヘッダーではなくエンベロープ受信者でルーティングする
目に見えるToやCcフィールドは、送信者が指定するメッセージの内容です。BCC、転送、意図的な改ざんなどにより、実際の宛先が含まれていないことがあります。SESの受信条件はSMTPのエンベロープ受信者を使います。またAWSは、メッセージがどこに配信されたかを判断する際には、SES通知に含まれる受信者を使うよう下流の処理に求めています(AWSのメール受信の概念)。
この区別によって、テナントをまたぐバグを防げます。reply+tenant-a@inbound.example.comが受信したメッセージのToにtenant-b@example.comと表示されていても、表示用のヘッダーではなく、エンベロープアドレスに対する認証済みのアプリケーション側の対応付けを使ってルーティングしてください。
アドレスで顧客や会話を識別する場合は、推測できないランダムな返信トークンを使ってください。トークンは保存時にハッシュ化し、必要に応じて失効させ、有効なワークスペースに対応しないアドレスは拒否します。ticket-42のような予測可能なローカルパートは、他のユーザーのスレッドにメッセージを差し込んでくださいと言っているようなものです。
MIMEを悪意のある入力として解析する
メールは、入れ子構造を持つ数十年前からの入力形式です。RFC 5322がメッセージのヘッダーと本文を定義し、MIMEがマルチパートのコンテンツと転送エンコーディングを追加しています(RFC 5322、RFC 2045)。空行や境界文字列で自前で分割するのではなく、保守されているパーサーを使ってください。
コンテンツをプロダクトで利用可能にする前に、次の制限を適用します。
- デコード後の合計バイト数、添付ファイルの数、個々の添付ファイルのサイズ、MIMEの入れ子の深さ、解析時間に上限を設ける。
- 添付ファイルは生成したオブジェクト名で非公開に保存する。送信者のファイル名をパスとして使わない。
- 宣言されたContent-Typeやファイル名はヒントとして扱う。可能な限りコンテンツから種類を判定する。
- 添付ファイルの内容を実行しない。ダウンロード前に添付ファイルをスキャンまたは隔離する。
- HTMLは厳格な許可リストでサニタイズし、リモート画像はデフォルトでブロックし、隔離されたコンテキストでレンダリングする。自動分析にはプレーンテキストを優先する。
- 生の本文、アドレス、トークン、添付ファイルの内容を通常のアプリケーションログに出力しない。
SESはSPF、DKIM、DMARC、スパム、ウイルスの判定結果を報告できますが、AWSによれば、SESはこれらの結果を公開するだけで、ビジネス上のポリシーを自動的に適用するわけではありません。失敗したものを拒否するか、隔離するか、警告付きで表示するかを決めてください。認証のパスは、特定の仕組みのもとでドメインを識別するものであり、コンテンツが安全であることや、人間が作成したことを証明するものではありません。
再試行を平凡なものにする
Lambdaの非同期呼び出しでは、失敗した関数が再試行されることがあります。またAWSは、関数がエラーを返さなかった場合でも重複配信が起こりうると警告しています。失敗時の送信先またはデッドレターキューを設定し、処理の失敗にアラームを設定してください(AWS Lambdaの再試行の動作)。
冪等性は、下流のすべての副作用を対象にする必要があります。
- SESのメッセージIDを一意制約のもとで挿入する。
- 可能な限り、解析したコンテンツとスレッドのリンクを1つのトランザクションで永続化する。
- 通知、チケット作成、エージェントの作業は、メッセージIDとアクション種別を組み合わせたキーでアウトボックスに入れる。
- 永続的な書き込みが成功してから、レコードを完了としてマークする。
- 再処理は、情報が欠落したログエントリではなく、元のS3オブジェクトから行う。
SESのLambdaアクションではなくS3の通知から処理をトリガーする場合も、同じルールが当てはまります。Amazon S3の通知はAt-Least-Once配信を前提に設計されており、順序どおりに届く保証はありません(AWS S3のイベント通知)。
件名を信頼せずにメッセージをスレッド化する
スレッドの照合候補を出すには、解析したMessage-ID、In-Reply-To、Referencesフィールドを使います。件名がRe:で始まることだけを根拠にスレッド化してはいけません。また、何かを関連付ける前に、エンベロープアドレスまたは返信トークンが同じワークスペースと会話に属していることを確認してください。
自動返信には別のポリシーが必要です。Auto-Submittedなどのシグナルを検出し、返信のループを生まないようにしてください。RFC 3834は、自動応答について明確な識別と控えめな動作を推奨しています(RFC 3834)。AIエージェントが返信を下書きする場合も、送信は明示的かつ冪等な副作用として扱ってください。想定外の受信者、機密性の高い内容、元のサポートやプロダクトのワークフローから外れる操作には、ユーザーの承認を必須にしてください。メッセージを受信したからといって、無関係なマーケティングへの包括的な同意が得られたわけではありません。
本番環境チェックリスト
- 受信リージョンでSESのメール受信がサポートされています。
- ドメインIDが検証済みで、MXレコードが正しく解決されます。
- Receipt ruleには限定的な受信者条件があり、意図したルールセットが有効です。
- S3アクションは非同期のLambda処理より前に実行されます。
- バケットはプライベートで、アクセスは最小権限となっており、保持期間が文書化されています。
- SESメッセージIDにはデータベースの一意制約があります。
- ルーティングでは、表示される
ToまたはCcヘッダーではなく、エンベロープ受信者を使用します。 - MIME、HTML、リンク、添付ファイルは信頼できない入力として扱われます。
- 失敗したイベントは監視対象の送信先に届き、再処理できます。
- スレッドの照合によってワークスペースの所有権が強制されます。
- 自動返信にはループ防止、同意の範囲、送信の冪等性があります。
- 管理されたテストで、プレーンテキスト、HTML、Bcc、重複配信、大きな添付ファイル、不正なMIME、パーサーの失敗をカバーします。
SESを直接使うのは、AWSネイティブな制御を求め、DNS、IAM、MIMEの解析、テナントの分離、保持期間、再試行の処理、運用アラートを自分たちで担う準備ができているチームに適しています。こうしたアプリケーションの基本機能を、より範囲の狭いメールAPIの背後にまとめたい場合は、SendHQが、送信用のトランザクションメールに加えて、受信用アドレス、保持されたメッセージ、スレッド、ワークスペース単位のアクセスを提供します。いずれにしても、生のメッセージを復元可能な状態に保ち、下流のすべての操作を再実行しても安全なものにしてください。