2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

メーリングリスト移行時の「万が一」に備えて、Amazon SESとAWS Lambdaでサーバーレスなメール転送システムを作った話

2
Last updated at Posted at 2026-06-19

Summary

ChatGPT Image 2026年6月20日 12_21_53.png

  • メーリングリスト(以下、ML)を旧アドレスから新アドレスへ移行した際、旧ML宛てに届いたメールを新MLへ自動転送するサーバーレス構成を Amazon SES・AWS Lambda(と Amazon S3)で構築した
  • メールの件名に「【旧ML宛メール】」を付与し転送元を明示するとともに、本文に送信元メールアドレスを追記して元の送り主が誰かわかるよう工夫した
  • SES でメールを再送信する際は From を SES 認証済みドメイン配下のアドレスに書き換えなければならず、元の From をそのまま使えないという制約を「元 From の @ を別の文字に置換して SES 認証済みドメインのローカルパートに埋め込む」方法で回避した
  • SES のReceipt ruleから Lambda を直接呼び出した場合、Lambda のイベントにメール本文は含まれず messageId のみが渡される。本文・添付ファイルを含む原文が必要な場合は S3 に保存した上でキーを使って取得する必要がある
  • spamVerdict / virusVerdict で PASS でないメールを転送しない設定を Receipt Rules に入れていたが、それでもすり抜けてくるメールが存在し Lambda エラーの原因となった

やらないこと

  • メールサーバー(Postfix 等)の構築・運用
  • 転送システムの可用性担保のためのインフラ管理(EC2 常時稼働など)
  • SPF / DKIMの解説、具体的な設定手順の記載
  • 具体的なLambda実装の紹介

当時は、すでに AWS に移行した後での現場からの追加要望対応であったため、AWS で完結する設計・実装とした。

本記事における課題

背景

特定用途の ML を旧アドレス(例:info@old-company.example.com)から新アドレス(例:contact@new-company.example.com)へ移行することになった。ドメインだけでなくローカルパート(@ より前の部分)も変わっているため、旧アドレス宛にメールを送り続けている送り主が気づきにくい状況だった。利用者への告知は済んでいたが、手元に控えていた旧サイト記載のメールアドレス宛に送られるケースが想定された。

「万が一、旧 ML に届いたメールをそのまま捨てる」という選択肢は運用上採れない。しかし、そのためだけにメールサーバーを立てたり、既存のサーバーに設定を追加したりといった運用負荷の増加は避けたい。

要件の整理

  • 自動転送:旧 ML 宛てのメールを自動的に新 ML へ転送する
  • 転送元の明示:新 ML のメンバーが「これは旧 ML 経由のメールだ」とひと目でわかるようにする
  • 送信者情報の保持:誰から送られてきたメールかを本文から確認できるようにする(From 書き換えが必要なため)
  • 添付ファイルの維持:添付ファイルがある場合、欠落なくそのまま転送する
  • 運用負荷ゼロ:サーバー管理不要のサーバーレス構成とする

やったこと

構成の全体像

送信者
  │
  │ ① メール送信(宛先:旧MLアドレス)
  ▼
Amazon SES(受信)
  │
  │ ② Receipt ruleで S3 に保存
  ▼
Amazon S3
  │
  │ ③ オブジェクト作成イベントで Lambda をトリガー
  ▼
AWS Lambda
  │ ・件名に「【旧ML宛メール】」を付与
  │ ・本文先頭に元の From アドレスを追記
  │ ・From を SES 認証済みドメインのアドレスに書き換え
  │ ・添付ファイルを含む MIME 構造をそのまま維持して転送
  │
  │ ④ SES で送信(send_raw_email)
  ▼
Amazon SES(送信)
  │
  ▼
新メーリングリスト

旧ドメインを Amazon SES で認証し、受信設定を行う

ドメイン認証

Amazon SES コンソールの Verified identities から旧ドメインを登録し、DKIM 認証に必要な CNAME レコードと、メール受信に必要な MX レコードを DNS に追加する。

東京リージョン(ap-northeast-1)での MX レコードの値は以下のとおりである。

10 inbound-smtp.ap-northeast-1.amazonaws.com

Amazon SES のメール受信機能は長らくバージニア北部・オレゴン・アイルランドの 3 リージョンのみの提供だったが、2023 年 9 月に東京リージョン(ap-northeast-1)でも利用可能になった。私が本システムを開発した時は前記のリリース前であったので、オレゴンリージョンにて構築した。

Receipt ruleの設定

SES コンソールの Email receiving > Receipt rule sets でルールセットを作成・有効化し、以下の 2 つのアクションを順番に追加する。

  1. S3 アクション:受信メールを指定の S3 バケットに保存する
  2. Lambda アクション(または S3 イベント通知):後述の Lambda 関数を呼び出す

SESで受信したメールがスパムやマルウェアであると判断されても、判定段階でアクションを止めることはできない。 特定のアドレス宛のみアクションを実行する「受信者条件(recipient condition)」の設定はできるが、スパム判定結果などによってアクションを切り替えるような分岐はルール側ではできない。spamVerdict / virusVerdict による転送スキップは Lambda 内で実装する必要がある。

S3 バケットを準備する

受信メールの保存先 S3 バケットを作成し、SES からの書き込みを許可するバケットポリシーを設定する。

Lambda 関数を実装する

Lambda 関数の役割は以下の 4 つである。

  1. SES イベントに含まれる messageId を使って S3 からメール原文(MIME 形式)を取得する
  2. スパム・ウイルス判定結果を確認し、問題があれば転送をスキップする
  3. メールをパースして件名・本文・送信元アドレスを取得する
  4. 件名・本文を加工し、添付ファイルを含む MIME 構造を維持したまま SES で新 ML へ送信する

SES のReceipt ruleから Lambda を呼び出した場合、Lambda に渡されるイベントには件名・送信元・受信者といったメタデータ(ヘッダー情報)と messageId のみが含まれる。メール本文・添付ファイルはここには含まれない。S3 から MIME 原文ごと取得する必要がある。詳細は後述の「つまづきポイント③」を参照。

SES から Lambda に渡されるイベントの構造は以下の通りである。詳細は公式ドキュメントを参照。 messageId が S3 上のオブジェクトキー(またはそのプレフィックス付きキー)に対応しており、これを使って本文・添付ファイルを含む MIME 原文を取得する。

{
  "Records": [{
    "eventSource": "aws:ses",
    "eventVersion": "1.0",
    "ses": {
      "mail": {
        "timestamp": "2019-08-05T21:30:02.028Z",
        "source": "prvs=144d0cba7=sender@example.com",
        "messageId": "EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000",
        "destination": ["recipient@example.com"],
        "headersTruncated": false,
        "headers": [{
          "name": "Return-Path",
          "value": "<prvs=144d0cba7=sender@example.com>"
        }, {
          "name": "Received",
          "value": "from smtp.example.com [203.0.113.0]) by inbound-smtp.us-east-1.amazonaws.com with SMTP id bsvpsoklfhu7u50iur7h0kk9a2ou0r7iexample for recipient@example.com;Mon, 05 Aug 2019 21:30:02 +0000 (UTC)"
        }, {
          "name": "X-SES-Spam-Verdict",
          "value": "PASS"
        }, {
          "name": "X-SES-Virus-Verdict",
          "value": "PASS"
        }, {
          "name": "Received-SPF",
          "value": "pass (spfCheck: domain of example.com designates 203.0.113.0 as permitted sender) client-ip=203.0.113.0; envelope-from=prvs=144d0cba42=sender@example.com; helo=smtp.example.com;"
        }, {
          "name": "Authentication-Results",
          "value": "amazonses.com; spf=pass (spfCheck: domain of example.com designates 203.0.113.0as permitted sender) client-ip=203.0.113.0; envelope-from=prvs=144d0cba42=sender@example.com; helo=smtp.example.com; dkim=pass header.i=@example.com; dmarc=none header.from=example.com;"
        }, {
          "name": "X-SES-RECEIPT",
          "value": "AEFBQUFBQUFBQUFHbFo0VU81VzVuYmRDNm51nhTVWpabDh6J4V2l5cG5PSHFtNzlBeUk90example"
        }, {
          "name": "X-SES-DKIM-SIGNATURE",
          "value": "a=rsa-sha256; q=dns/txt; b=Cm1emU30VcD6example=; c=relaxed/simple; s=6gbrjpgwjs5zn6fwqknexample; d=amazonses.com; t=1567719002; v=1; bh=DSofsjAoUvyZj6YsBDP5enpRO1otGb7Nes0Qexample=; h=From:To:Cc:Bcc:Subject:Date:Message-ID:MIME-Version:Content-Type:X-SES-RECEIPT;"
        }, {
          "name": "DKIM-Signature",
          "value": "v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; i=@example.com; q=dns/txt; s=example12345; t=1567719001; x=1599255001; h=from:to:subject:date:message-id:references:in-reply-to:mime-version; bh=sjAoUvyZj6YsBDP5enpRO1otGb7s0Qexample=; b=EQw2D4RLOW2IHE9OgfEA4WXp+AENJtaD2+63wmd5J+d+t/xoaiKUGClOS7WhpyOmlipryOz+iOhxUv350xJIHjLTi9Jsnlw76mRK8o4770TaUz620joCVN21n4cxsrRZpv+1kS0EcAxaF30pmwlni+XT4emsVxn7zO0I8example=;"
        }, {
          "name": "Received",
          "value": "from mail.example.com (mail.example.com [203.0.113.0]) by email-inbound-relay-1d-9ec21598.us-east-1.example.com (Postfix) with ESMTPS id 57F83A2042 for <recipient@example.com>; Mon, 5 Aug 2019 21:29:58 +0000 (UTC)"
        }, {
          "name": "From",
          "value": "\"Doe, John\" <sender@example.com>"
        }, {
          "name": "To",
          "value": "\"recipient@example.com\" <recipient@example.com>"
        }, {
          "name": "Subject",
          "value": "This is a test"
        }, {
          "name": "Thread-Topic",
          "value": "This is a test"
        }, {
          "name": "Thread-Index",
          "value": "AQHVZDAaQ58yKI8q7kaAjkhC5stGexample"
        }, {
          "name": "Date",
          "value": "Mon, 5 Aug 2019 21:29:57 +0000"
        }, {
          "name": "Message-ID",
          "value": "<F8098FDD-49A3-442D-9935-F6112example@example.com>"
        }, {
          "name": "References",
          "value": "<1FCED16B-F6B0-4506-A6F0-594DFexample@example.com>"
        }, {
          "name": "In-Reply-To",
          "value": "<1FCED16B-F6B0-4506-A6F0-594DFexample@example.com>"
        }, {
          "name": "Accept-Language",
          "value": "en-US"
        }, {
          "name": "Content-Language",
          "value": "en-US"
        }, {
          "name": "X-MS-Has-Attach",
          "value": ""
        }, {
          "name": "X-MS-TNEF-Correlator",
          "value": ""
        }, {
          "name": "x-ms-exchange-messagesentrepresentingtype",
          "value": "1"
        }, {
          "name": "x-ms-exchange-transport-fromentityheader",
          "value": "Hosted"
        }, {
          "name": "x-originating-ip",
          "value": "[203.0.113.0]"
        }, {
          "name": "Content-Type",
          "value": "multipart/alternative; boundary=\"_000_F8098FDD49A344F6112B195BDAexamplecom_\""
        }, {
          "name": "MIME-Version",
          "value": "1.0"
        }, {
          "name": "Precedence",
          "value": "Bulk"
        }],
        "commonHeaders": {
          "returnPath": "prvs=144d0cba7=sender@example.com",
          "from": ["\"Doe, John\" <sender@example.com>"],
          "date": "Mon, 5 Aug 2019 21:29:57 +0000",
          "to": ["\"recipient@example.com\" <recipient@example.com>"],
          "messageId": "<F8098FDD-49A3-442D-9935-F6112B195BDA@example.com>",
          "subject": "This is a test"
        }
      },
      "receipt": {
        "timestamp": "2019-08-05T21:30:02.028Z",
        "processingTimeMillis": 1205,
        "recipients": ["recipient@example.com"],
        "spamVerdict": {
          "status": "PASS"
        },
        "virusVerdict": {
          "status": "PASS"
        },
        "spfVerdict": {
          "status": "PASS"
        },
        "dkimVerdict": {
          "status": "PASS"
        },
        "dmarcVerdict": {
          "status": "GRAY"
        },
        "action": {
          "type": "Lambda",
          "functionArn": "arn:aws:lambda:us-east-1:123456789012:function:IncomingEmail",
          "invocationType": "Event"
        }
      }
    }
  }]
}

Lambda に付与する IAM ロールには、少なくとも以下の権限が必要である。

  • s3:GetObject(受信メールの読み込み)
  • ses:SendRawEmail(MIME 原文ごとのメール送信)

メール転送の際は、 ses:SendEmail ではなく ses:SendRawEmail が必要になる点に注意。SendEmail は本文テキストと件名を個別に渡す API であり、添付ファイルを扱えない。MIME 構造を維持して転送するには SendRawEmail を使う必要がある。

実装で直面したつまづきポイント

つまづきポイント①:From に元のアドレスをそのまま使えない

SES でメールを送信する場合、From に指定するドメインは SES で認証済みのドメインでなければならない

「転送メールなので送信者が誰かわかるように、元の From をそのまま使いたい」と最初は考えていたが、SES はこれを許可しない。未認証ドメインを From に指定するとエラーになる。

そこで採用したのが、元の From アドレスの @+ に置換し、旧ドメイン(old-company.example.com)のローカルパートに埋め込む方法である。旧ドメインは SES で認証済みのため、このアドレスは SES の送信要件を満たす。なお、+ の文字を使用したのは、プラスアドレッシング(メールの振り分けに使われる user+tag@domain の仕組み)を活用したわけではなく、単純に @ を使用可能な文字に変換してアドレスとして成立させたものであり、他の文字でも構わない。

元の From:      user@sender.example.com
書き換え後 From: user+sender.example.com@old-company.example.com

メールを受け取った担当者は、From 欄を見るだけで元の送信者ドメインを含むアドレスを確認できる。加えて、本文の先頭にも元の From を明示しているため、送信者情報の損失は最小限に抑えられる。

つまづきポイント②:Receipt ruleの S3 → Lambda の順序

Receipt ruleのアクション順序は S3 が先、Lambda が後でなければならない。Lambda が先に起動するとメールがまだ S3 に存在せず、NoSuchKey エラーとなる。

つまづきポイント③:Lambda イベントにメール本文・添付ファイルは含まれない

「SES でメールを受信 → Lambda を起動」という流れを聞いてコードを書き始めると、Lambda のイベントにメール本文が含まれていると思い込んでしまった。実際には、SES から Lambda に渡されるイベントには件名・送信元アドレス・受信日時などのメタデータと messageId のみが含まれており、本文も添付ファイルも一切含まれていない。

// Lambda に渡されるイベントのうち mail 部分
"mail": {
  "messageId": "EXAMPLE7c191be45-e9aedb9a-...",
  "source": "sender@example.com",
  "commonHeaders": {
    "from": ["Sender Name <sender@example.com>"],
    "subject": "件名テキスト"
  }
  //  body も添付ファイルもここには存在しない
}

本文・添付ファイルを含む MIME 原文が必要な場合は、messageId を S3 のオブジェクトキーとして使い、S3 から原文(MIME 形式)をまるごと取得しなければならない。

# messageId → S3 キーの対応
object_key = f"{KEY_PREFIX}{message_id}"
response   = s3_client.get_object(Bucket=BUCKET_NAME, Key=object_key)
raw_email  = response['Body'].read()

まとめ・所感

Amazon SES + S3 + Lambda の組み合わせは、「メールを受け取って何かする」というユースケースにおいて非常にコストパフォーマンスが高い。EC2 等を常時稼働させることなく、月に数件〜数十件程度の転送であれば 数ドル/月 程度で運用できる。

今回の構成で特に運用目線で良かった点は次の 3 つである。

  1. 完全サーバーレスでインフラ管理が不要
  2. 件名プレフィックスと本文ヘッダーにより、担当者が転送メールと通常メールを即座に判別できる
  3. S3 に受信メールの原文が蓄積されるため、後から内容を確認できる(監査証跡)。Lambda が本文・添付ファイルを取得する際も S3 が起点になるため、受信バケットは転送ロジックと証跡保管を兼ねることができる。S3 ライフサイクル設定をすることで、一定期間後に証跡を削除することも簡単に可能

さらに、メール受信時にスパム・マルウェア判定がされていてもLambdaはKickされてしまうため、 spamVerdict / virusVerdict 属性を見てLambda側で処理が必要である。先述の通り、受信したメールは先にS3に保存されるため、スパム・マルウェアに該当する場合はS3から原本を削除し、ログにその旨を記録することを推奨する。

参考リンク

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?