SES を smarthost として使う転送設定 の履歴(No.1)

更新


公開メモ

AWS SES を smarthost にした exim4 でメール転送する(SRS + From 書き換え + バウンス対策)

AWS SES を smarthost に使う exim4 サーバーで、/etc/aliases.forward による外部転送がうまくいかずハマったので、原因と最終的な設定をまとめる。

環境

  • Ubuntu + exim4 4.97(split 構成: /etc/exim4/conf.d/)
  • 送信は AWS SES を smarthost として利用(email-smtp.ap-northeast-1.amazonaws.com:587)
  • ドメイン myserver.com は SES でドメイン検証済み(DKIM 設定済み)
  • info@myserver.com 宛のメールを /etc/aliases で自分の外部メールボックスへ転送したい

問題:転送メールが SES に拒否される

外部の sender@example.com から info@myserver.com に届いたメールを、素朴にそのまま転送しようとすると SES が拒否する。

2026-... ** sender@example.com <info@myserver.com> R=smarthost T=remote_smtp_smarthost
  ... SMTP error from remote mail server after end of data:
  554 Message rejected: Email address is not verified.
  The following identities failed the check in region AP-NORTHEAST-1: sender@example.com

このとき、転送メールは MAIL FROM(エンベロープ送信者)も From: ヘッダーも sender@example.com のまま。どちらも自ドメインではないことに注意。

なぜ起きるか:SES は複数の送信者フィールドを検証する

SES は汎用のメールリレーではなく、「自分が所有する検証済みドメインから送る」ためのサービス。送信メールの送信者フィールドが検証済みドメインのものであることを要求する。

Email address is not verified. ... This error could apply to the "From", "Source", "Sender", or "Return-Path" address.

つまり From / Source / Sender / Return-Path のいずれかが未検証なら弾かれる。素朴な転送では、このうち次の2つが元の外部差出人のままで未検証になる。

どちらも自ドメイン(myserver.com)の検証済みアドレスに直す必要がある。

この検証は after end of data(メッセージ本文を送り終えた後)に行われる。そのため両方を一度に直さないと気づきにくい。実際、MAIL FROM だけ SRS で直しても、From: が外部のままだと同じ 554 が after end of data で返り続ける。これで「From: も独立に検証されている」ことが分かる。

もう一つの罠:バウンスの MAIL FROM:<> は 501

上の 554 とは別に、null sender(空の MAIL FROM)も SES に拒否される。これは after end of data ではなく、MAIL FROM コマンドの応答で即座に返る。

... after MAIL FROM:<>: 501 Invalid MAIL FROM address provided

バウンス(DSN)は RFC 上 MAIL FROM を空にするのが正しい。

転送先からのバウンスを中継したり、ローカル配信失敗でバウンスを生成したりすると、この空 MAIL FROM が SES に弾かれ、メッセージが frozen になって溜まる。

解決の方針

これら(554 = MAIL FROM と From: の未検証、501 = null sender)への対処を送信者フィールドごとに分けて示す。

SES の検証を通すには、送信者を表すヘッダー/エンベロープを自ドメインの検証済みアドレスに書き換えるしかない(信じられないことにそれが公式に推奨されている)。そして、書き換える対象ごとに、使うべきアドレスも手法も異なる。混同しやすいので、先に整理しておく。

書き換える対象書き換え先対策する問題
From: ヘッダー(転送メール)forwarding-service-noreply@myserver.com554(From: が未検証)
MAIL FROM(通常の転送)SRS エンコード(SRS0=...@myserver.comSPF 失敗 / MAIL FROM 未検証
MAIL FROM(自サーバーからのバウンスメールの <>)bounces@myserver.com501(null sender 拒否)
  • forwarding-service-noreply@myserver.com … 転送メールの From: 用のダミーアドレス。それと分かる名前にした。元の From: は表示名と Reply-To: に退避する
  • bounces@myserver.com … バウンスの空 MAIL FROM 用。:blackhole: にしてループを防ぐ
  • この2つは役割が違うので別アドレスにする

以下、それぞれについて、なぜその方針かを一次情報とともに示す。

From: ヘッダーの書き換え(554 対策)

  • SES は送信するメールの From: ヘッダーが認証済みドメインのものであるかを検証する ため、転送メールの From: が検証済みアドレス(myserver.com ドメインのもの)でない限り 554 エラーで配信してくれない
    • AWS 公式ドキュメントに「This error could apply to the "From", "Source", "Sender", or "Return-Path" address」と明記されている(AWS: Amazon SES email sending errors
    • エラーが after end of data(DATA 後)で返るのも、From: を見て弾いている証拠
  • 元の差出人は From: の表示名と Reply-To: に退避する
    • From: を書き換えてしまうとどこから来たメールか分からなくなる
      • From: を "元の From: の内容" <forwarding-service-noreply@myserver.com> とすると、メーラー上で元の From: の内容も参照できるようになる(SES の仕様を知らなければなぜそんなことになっているのか戸惑うだろうけど、恐らくこれが最善)
    • From: を書き換えてしまうと返信メールの宛先がおかしくなる
      • 元の Reply-To: が空のとき Reply-To: に元の From: を入れておく
      • そうすれば返信時に元の差出人宛のメールが書ける(元のメールで Reply-To: が指定されていればそのままで構わない)
    • 万一 forwarding-service-noreply@myserver.com に向けて返信が送られても no such user でエラーになり、返信先でないことに気づける
    • この「From: を検証済みアドレスにし、元の送信者情報を保持する」方式は、AWS 公式の転送ソリューション(SES 受信 → S3 → Lambda → SES 送信)の Lambda が行う再構成と同じ考え方(AWS Blog: Forward incoming email to an external destination

MAIL FROM の書き換え:通常の転送(SRS)

  • SES はエンベロープ送信者(MAIL FROM)が認証済みドメインのものであるかを検証するため、転送メールの MAIL FROM を SRS(Sender Rewriting Scheme)で書き換える
    • SRS は転送時にエンベロープ送信者を可逆な形で自ドメインアドレスへ書き換える標準手法
    • exim4 は SRS をビルトインで持つ(srs_encode / inbound_srs)(Wikipedia: Sender Rewriting Scheme
    • なぜ必要か:メールを転送すると、エンベロープ送信者(MAIL FROM / Return-Path)は元の差出人のまま。だが実際に送信しているのは転送サーバーなので、受信側が SPF を検証すると「元差出人ドメインが許可していない IP から来た」と判定され失敗する。SES を smarthost にしている場合は、未検証アドレスの MAIL FROM がそもそも拒否される
    • 何をするか:エンベロープ送信者を、自ドメイン(myserver.com)の検証済みアドレスへ書き換える。このとき元の差出人を復元可能な形でエンコードして埋め込む
    • アドレスの形式(SRS0 の例): SRS0=<hash>=<時刻>=example.com=sender@myserver.com
      • <hash> HMAC ハッシュ(SRS_SECRET で計算。改ざん検知用)
      • <時刻> タイムスタンプ(古いアドレスの失効に使う)
      • <example.com> 元のドメイン
      • <sender> 元のローカルパート
      • <myserver.com> 自ドメイン(SES 検証済み)
    • バウンスの復路(デコード):転送先で配送に失敗すると、バウンスは SRS0=...@myserver.com 宛に返る。自サーバーは SRS_SECRET でハッシュを検証し、元の差出人(sender@example.com)にデコードする。これで「誰に返すべきか」が分かる
    • ただし、復元した元差出人へ再送する際の MAIL FROM は <>(null)になる。SES の 501 を避けるこの再送の書き換えは次節(null sender 対策)で扱う
    • ハッシュがある理由:このハッシュ検証により、自サーバーが実際に生成した SRS アドレスだけがデコードされる。第三者が SRS0=...@myserver.com を偽造して送りつけても検証に失敗するので、転送サーバーが偽バウンスの踏み台(バックスキャッタ = スパムのバウンスが無関係な第三者に届く現象)にされるのを防げる
    • SRS0 と SRS1:初回の書き換えは SRS0。すでに SRS でエンコードされたアドレスをさらに転送する多段転送では SRS1 が使われる

MAIL FROM の書き換え:バウンスの null sender(501 対策)

バウンス(DSN)は RFC 5321 で MAIL FROM を空(<>)にするのが正しい。だが SES は MAIL FROM:<> を拒否する(501)

... after MAIL FROM:<>: 501 Invalid MAIL FROM address provided

この空 MAIL FROM は、次の2つの場面で発生する。

  1. 転送メールのバウンスを元差出人へ再送するとき:転送先で配送に失敗すると、SES は SRS アドレス(SRS0=...@myserver.com)宛に DSN を返す。前節で説明したようにこれは srs_bounce でデコードされて元差出人へ再送されるが、この再送メールの MAIL FROM は <> になる
  2. ローカル配信が失敗したとき:メールボックス満杯や処理スクリプトのエラー等で、自サーバーが外部差出人へバウンスを生成する場合(宛先不明はそもそも受信時に弾くのでここには該当しない)

どちらも MAIL FROM を検証済みの bounces@myserver.com に書き換えて 501 を回避する。

  • bounces@myserver.com は /etc/aliases で :blackhole: にしておく
  • これは中継したバウンスがさらにバウンスして戻ってきたときにループしないようにするため
  • バウンスメールを返そうとする → バウンスされて戻ってくる → バウンスメールを返そうとする → ... の無限ループ
  • バウンスメールを返そうとするときに送信元を確実に届く先(ローカルアドレス)にすることで、バウンスメールがバウンスされて戻ってきたときに、再度失敗することが絶対に生じなくなる
  • このバウンスのバウンスは、送信先も、送信元も、どちらも到達不能なメールなので、確実に届く先(ローカルアドレス bounces)は、/etc/aliases で :blackhole: にしてしまう
  • 誰も受け取らないメール を単に捨ててしまうという対応

実装について

ここまでの書き換えは、すべて exim に router / transport を追加することで行う(独自スクリプトや外部プロセスは使わない)。しかもディストリビューションが用意する設定ファイルを書き換えるのではなく、split 構成(/etc/exim4/conf.d/)に別ファイルを追加する形にした。パッケージ管理下のファイルに手を入れずに済むため、アップグレードで設定が壊れにくくなることを期待している。

(ただし DCsmarthost や +local_domains、ルーターの番号順といったディストリビューション側の定義には依存しているので、完全に独立しているわけではない。また /etc/aliases への bounces エントリだけは標準どおり追記している。)

  • From: の退避・書き換え … transport の headers_remove / headers_add(Exim spec: Generic options for transports
  • MAIL FROM の書き換え … transport の return_path(SRS エンコード、または bounces 用アドレスへの差し替え)
  • 「外部宛 + null sender」の振り分け … router の domains / senders 条件

参考リンク

設定手順

1. SRS シークレットを生成

openssl rand -base64 24

2. SRS マクロを定義

/etc/exim4/conf.d/main/000_srs

# SRS (Sender Rewriting Scheme) の設定
# /etc/aliases や .forward で外部転送するとき MAIL FROM を書き換えて SES の検証を通すため
SRS_SECRET = (上で生成した文字列)

3. SRS バウンス受信ルーター

転送先からのバウンスが SRS0=...@myserver.com 宛に届いたとき、元の差出人にデコードする。

/etc/exim4/conf.d/router/00_srs_bounce

# SRS でエンコードされたバウンスアドレスを元の差出人にデコードして転送する
srs_bounce:
  driver = redirect
  senders = :
  domains = +local_domains
  condition = ${if inbound_srs {$local_part} {SRS_SECRET}}
  data = $srs_recipient
  no_more

srs_bounce_invalid:
  driver = redirect
  senders = :
  domains = +local_domains
  condition = ${if inbound_srs {$local_part} {}}
  allow_fail
  data = :fail: Invalid SRS recipient address

4. 転送検出ルーター + null sender バウンスルーター

/etc/aliases による転送は、$local_part@$domain と $original_local_part@$original_domain が一致しないことで検出できる。

/etc/exim4/conf.d/router/199_srs_forward

# /etc/aliases・.forward による外部転送を SES 経由で送る。
# 転送判定は「現在の宛先 != 元の宛先」(condition)。
# 専用トランスポート remote_forwarded_smtp_smarthost で 
# MAIL FROM を SRS、From: を検証済みアドレスに書き換える。
srs_forward:
  driver = manualroute
  domains = ! +local_domains
  condition = ${if !eq {$local_part@$domain}{$original_local_part@$original_domain}}
  transport = remote_forwarded_smtp_smarthost
  route_list = * DCsmarthost byname
  host_find_failed = ignore
  same_domain_copy_routing = yes
  no_more

# 受理後に失敗したメール(mailbox full 等)が外部差出人へ返す
# null sender バウンスを SES 経由で配送する
#  (remote_bounce_smtp_smarthostで MAIL FROM を書き換え)
# 順序は必ず srs_forward の後。
bounce_smarthost:
  driver = manualroute
  domains = ! +local_domains
  senders = :
  transport = remote_bounce_smtp_smarthost
  route_list = * DCsmarthost byname
  host_find_failed = ignore
  same_domain_copy_routing = yes
  no_more

senders = : が null sender にマッチする。メインの smarthost ルーター(200_exim4-config_primary)より前に置くことで、null sender を素の transport(MAIL FROM:<> のまま → 501)に渡さず横取りする。199 のファイル内に書けば 200 より前になる。なお転送バウンスの再送(前節の場面1)は srs_bounce でデコード後 srs_forward 側にマッチするので、bounce_smarthost には来ない(順序が重要な理由)。

5. 転送専用トランスポート

転送メール本体に加え、その配送失敗で SES が返したバウンスの元差出人への再送もこの transport を通る(後者は return_path の null 分岐で bounces@ になり、From: も書き換わる)。

/etc/exim4/conf.d/transport/31_srs_forwarded_smtp_smarthost

# /etc/aliases や .forward による外部転送を SES 経由で送る専用トランスポート
# MAIL FROM を SRS、From: を検証済みアドレスに書き換える。
remote_forwarded_smtp_smarthost:
  driver = smtp
  max_rcpt = 1
  return_path = ${if eq{$return_path}{}{bounces@myserver.com}{${srs_encode {SRS_SECRET} {$return_path} {$original_domain}}}}
  headers_remove = From
  headers_add = ${if def:h_reply-to: {}{Reply-To: $h_from:\n}}From: "${sg{$h_from:}{"}{\\"}}" <forwarding-service-noreply@myserver.com>
  hosts_try_auth = <; ${if exists{CONFDIR/passwd.client} \
        {\
        ${lookup{$host}nwildlsearch{CONFDIR/passwd.client}{$host_address}}\
        }\
        {} \
      }
  tls_tempfail_tryclear = false
  • return_path: 通常は SRS エンコード。null sender(バウンス再送時)は bounces@myserver.com に書き換えて 501 回避
  • headers_remove / headers_add: From: を書き換え。元の From: 全体を表示名に入れ、${sg{...}{"}{\\"}} でダブルクオートをエスケープ。Reply-To: が無ければ元の From: を退避
  • headers_add は論理的に 1 行で書くこと。折り返しで From: の前に実改行が入ると update-exim4.conf が option setting expected で失敗する

書き換え結果:

転送前転送後
MAIL FROMsender@example.comSRS0=xxxx=...=sender@myserver.com
From:Sender Name <sender@example.com>"Sender Name <sender@example.com>" <forwarding-service-noreply@myserver.com>
Reply-To:(なし)Sender Name <sender@example.com>

6. null sender バウンス専用トランスポート

ローカル配信失敗(前節の場面2)で自サーバーが生成するバウンス用。From: は既に Mailer-Daemon@myserver.com(検証済みドメイン)なので、転送用と違い From: 書き換えは不要。MAIL FROM を bounces@myserver.com にするだけ。

/etc/exim4/conf.d/transport/32_bounce_smtp_smarthost

# 受理後に失敗したメール(mailbox full 等)が外部差出人へ返す
# null sender バウンスを MAIL FROM を書き換えて SES 経由で配送する
remote_bounce_smtp_smarthost:
  driver = smtp
  return_path = bounces@myserver.com
  hosts_try_auth = <; ${if exists{CONFDIR/passwd.client} \
        {\
        ${lookup{$host}nwildlsearch{CONFDIR/passwd.client}{$host_address}}\
        }\
        {} \
      }
  tls_tempfail_tryclear = false

7. ループ対策エイリアス

bounces@myserver.com に届くのは「バウンスがさらにバウンスして戻ってきた」送信先も送信元もどちらも送信不能な時の DSN のみ。ローカルで破棄してループを断つ。定義が無いと unknown user で逆にバウンスを生むので、破棄する場合でも定義は必須。

/etc/aliases

bounces: :blackhole:

8. 適用

sudo update-exim4.conf && sudo systemctl restart exim4

動作確認

転送とバウンス再送

/etc/aliases に shouldbounce: bounce@simulator.amazonses.com を足し、実在の外部アドレスから shouldbounce@myserver.com に送る。

  1. 転送本体が SES に受理(=> bounce@simulator.amazonses.com ... 250 Ok)
  2. SES が返したバウンスが SRS デコードされ、元の差出人へ再送されて 250 Ok(以前はここが 501 で frozen になっていた)

null sender バウンス経路

mailbox full は再現しづらいので、null sender を直接注入してテストする。

ドライラン(送信せず経路だけ確認):

exim4 -bt -f '' test@gmail.com

出力に router = bounce_smarthost, transport = remote_bounce_smtp_smarthost が出れば OK。

実送信:

echo -e "Subject: test\n\ntest" | exim4 -f '' you@gmail.com

ログ:

<= <> U=ubuntu P=local ...
=> you@gmail.com R=bounce_smarthost T=remote_bounce_smtp_smarthost ... 250 Ok

ハマったところ

  • エラーが after end of data なら From: ヘッダーの問題。MAIL FROM の話だと思って SRS だけ入れても解決しない。SES は From: も検証する
  • /etc/aliases のパイプ(|command)は使えない。初め、aliases の転送先に '|/bin/false' としてスクリプト失敗時の動作を試そうとしたが Debian の system_aliases ルーターは pipe_transport 未設定なので defer する。テストは null sender 注入が手軽
  • 未定義アドレス宛は frozen にならない。exim は RCPT 時点で 550 Unrouteable address として拒否するので、バウンスを出すのは送信側 MTA。受理後に失敗するケース(mailbox full 等)だけが null sender バウンスを生む

まとめ

SES を smarthost にした転送の肝は「SES は MAIL FROM と From: の両方が認証済みドメインであることを検証し、null sender も拒否する」という点。にわかには信じられないが、MAIL FROM と From: の両方が認証済みドメインに無理やり書き換えてしまうことがベストプラクティス(どうしようもないので次善策)とされている。

書き換える対象ごとに、別のアドレス・手法を使うのがよさそう。

  • 転送メールの From: → forwarding-service-noreply@myserver.com に書き換え
    • ダミーアドレスであることが分かる名前
    • 元情報は From: の表示名に入れることでメーラー上で確認可能にする
    • Reply-To: が空の時はそこにも元の From: の内容を入れて、返信先がおかしくならないようにする
  • 通常の MAIL FROM → SRS でエンコード(バウンス復路を確保)
  • バウンスの null sender → bounces@myserver.com に書き換える
    • バウンスのバウンスが生じるのはかなりしょうもないケースなのでこれでいい
    • 実際の届け先は :blackhole:

AWS 公式の転送方式(SES 受信 → S3 → Lambda → SES 送信)でも Lambda 内で From: を書き換えて再構成しており、やっていることは同じ。それを exim4 の router/transport で実現した形になる。


Counter: 36 (from 2010/06/03), today: 4, yesterday: 3