PostfixとAmazon SES間の通信にSMTPではなくAWS API(HTTPS)を利用してメールを送信してみました
初めに
だいぶ前ですがPostfixをリレーしてAmazon SESでメールを送信する方法を紹介させていただきました。
この方法は設定も手軽で必要な場合の設定や管理コストは比較的軽微ですが、AWS APIと比べるとIAMユーザを接続して永続的な認証情報を発行する必要がある、SMTPというプロトコルの仕様上何度も通信を往復する必要がある(ネットワーク遅延の影響を受けやすい)といったデメリットがあります。
送信にAWS APIを利用できたらなぁ...何か方法ないかなと思って一つ思いつくことがあったので今回それを試してみることにしました。
※ 読んでいただくとわかりますが管理がかなり辛いと思いますので実用性は低く、実験的なものだと思った方が良いです
pipeを使った外部コマンド転送
シンプルにメールの送受信を行うだけだと触る機会も少ないので知らない方もいるかと思いますが、Postfixのメール送受信等の各種処理は通信を受け付けてるPostfix(正確にはmaster)それ自体が直接処理するのではなく受け付けたものに応じたコマンドを実行し処理するという形になります。
その辺りのマッピングはmaster.cfで定義され、またデフォルトのコマンドとしてはこの辺りに用意されています(環境や設定によるためあくまで一例)
sh-4.2$ ps aux | grep postfix
root 964 0.0 0.2 21108 1020 ? Ss 05:32 0:00 /usr/libexec/postfix/master -w
##
sh-4.2$ ls /usr/libexec/postfix/
aliasesdb chroot-update dnsblog lmtp master oqmgr postfix-files post-install proxymap scache smtpd tlsproxy virtual
anvil cleanup error local master.cf pickup postfix-script postmulti-script qmgr showq spawn trivial-rewrite
bounce discard flush main.cf nqmgr pipe postfix-wrapper postscreen qmqpd smtp tlsmgr verify
Postfixから呼び出されるこれらのコマンドはPostfixの内部プロトコルで処理できる必要があるため直接の呼び出しは敷居が高いのですが、標準で用意されているpipe
を経由することでシェルの引数として値を引き渡し処理させることが可能です。
今回はこれを利用します。
実装
実際に設定して送信してみます。今回はEC2インスタンス上のpostfixを利用するためIAMユーザ及びそのアクセスキーは発行せずIAMロールを利用しての送信となります。
引き渡し後の処理本体
今回はシェルで実装しており以下の通りです。だいぶ汚いですがそこはご愛嬌で...
渡されるデータの形式上最低限必要なメールボディと件名だけ抽出する形をとっているので実際にはもっと組み込みが必要です。
認可認証にはIAMロールを利用するため今回AWS CLIのプロファイル設定は特に行なっていません。
#!/bin/bash
SENDER="$1"
RECIPIENT="$2"
EMAIL_CONTENT=$(cat)
## 本文とメールタイトルを抽出する
subject=$(echo "$EMAIL_CONTENT" | grep -i -o "^Subject:.*" | sed 's/^Subject: *//')
body=$(echo "$EMAIL_CONTENT" | sed -n '/^$/,$p' | tail -n +2)
echo "------------" >> /tmp/ses-api.log
echo "SENDER: $SENDER" >> /tmp/ses-api.log
echo "RECIPIENT: $RECIPIENT" >> /tmp/ses-api.log
echo "------------" >> /tmp/ses-api.log
echo "RAW EMAIL CONTENT: $EMAIL_CONTENT" >> /tmp/ses-api.log
echo "------------" >> /tmp/ses-api.log
echo "SUBJECT: $subject" >> /tmp/ses-api.log
echo "ACTUAL EMAIL BODY: $body" >> /tmp/ses-api.log
result=$(/usr/bin/aws ses send-email \
--from "$SENDER" \
--destination "ToAddresses=$RECIPIENT" \
--message "Subject={Data='$subject'},Body={Text={Data='$body'}}" \
2>&1)
echo "SES API RESPONSE: $result" >> /tmp/ses-api.log
第n引数に何が入っているかに関しては後述のmaster.cfにユーザ側で任意で指定します。
(今回は第一引数に送信者、第二引数に受信者を利用する想定)
pipeコマンドのマニュアル上に記載が見当たらなかったのですが各種ヘッダやメール本文は標準入力から渡ってくるようです。
順序が前後しますが実際に上記のスクリプトが出力されたログは以下の通りで、RAW EMAIL CONTENT
の部分に出力されている部分で実際に標準出力で渡ってきた値になります。
------------
SENDER: postfix@example.com
RECIPIENT: receiver@example.com
------------
RAW EMAIL CONTENT:
Return-Path: <postfix@example.com>
Delivered-To: receiver@example.com
Received: from localhost (localhost [127.0.0.1])
by ip-xxx-xxx-xxx-xxx.ap-northeast-1.compute.internal (Postfix) with SMTP id EB405AFD839
for <receiver@example.com>; Thu, 26 Jun 2025 07:13:29 +0000 (UTC)
From: postfix@example.com
To: receiver@example.com
Subject: test mail
Message-Id: <20250626071329.EB405AFD839@ip-xxx-xxx-xxx-xxx.ap-northeast-1.compute.internal>
Date: Thu, 26 Jun 2025 07:13:29 +0000 (UTC)
Hello.
im from ses.
------------
SUBJECT: test mail
ACTUAL EMAIL BODY:
Hello.
im from ses.
SES API RESPONSE: {
"MessageId": "xxxxxx-000000"
}
ただ、Amazon SESのAPI側の引数にこのヘッダや本文を丸っと渡して処理できるような引数はないので、各種ヘッダとメールボディは分離し対応する値に引き渡す必要があります。
今回の記事としてはそこまでやる予定はないので本文と件名だけ抽出しています。
サービス定義の追加
上記のコマンドを呼び出す用のサービスとしてmaster.cfに以下のような行を追加しses-api
を定義します。/usr/local/bin/ses-api.sh
の内容は上記の内容の通りです。
ses-api unix - n n - - pipe
flags=DRhu argv=/usr/local/bin/ses-api.sh ${sender} ${recipient}
これでこのサービスが呼び出される際に/usr/local/bin/ses-api.sh
に第一引数に送信元、第二引数に送信先が指定され実行される形になります。
サービス定義の割り当て
上記はあくまでサービスの"定義"となるので、実際に利用される条件を定義します。
default_transportを変更しても良いのですが、個人的にはマッピングルールの一覧化という意味でtransportファイルに書き込むのが好きなのでこちらに設定します。
(あと検証でその辺りの設定触ると後で変えたの忘れて動作がおかしい!!!とかやりがちなので...)
## 全てのメールがses-apiサービスで処理されるように設定
* ses-api:
# 内部エラーとかもses-apiで処理されてしまうので除外しておく
MAIL-DEAMON local
ハッシュテーブル化するのを忘れないようにしましょう。
$ postmap /etc/postfix/transport
$ ls -ltr | grep transport
-rw-r--r-- 1 root root 12565 Jun 25 07:22 transport
-rw-r--r-- 1 root root 12288 Jun 25 07:25 transport.db
## reloadも忘れないように
$ service postfix reload
送信
実際に送信して確認します。PostfixとのSMTPの対話はいつも通りtelnetを利用します。
$ telnet localhost 25
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
220 ip-192-168-4-157.ap-northeast-1.compute.internal ESMTP Postfix
MAIL FROM: postfix@example.com
RCPT TO: receiver@example.com
DATA
From: postfix@example.com
To: receiver@example.com
Subject: test mail
250 2.1.0 Ok
250 2.1.5 Ok
354 End data with <CR><LF>.<CR><LF>
Hello.
im from ses.
.
250 2.0.0 Ok: queued as EB405AFD839
実行するとrelay=ses-api
となっている部分で先ほど登録されたses-api
により処理されていることが確認できます。
# journalctl -u postfix --no-pager | tail -10
Jun 26 07:13:24 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/smtpd[37793]: warning: non-SMTP command from localhost[127.0.0.1]: .
Jun 26 07:13:24 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/smtpd[37793]: disconnect from localhost[127.0.0.1] mail=1 rcpt=1 data=1 unknown=0/2 commands=3/5
Jun 26 07:13:26 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/smtpd[37793]: connect from localhost[127.0.0.1]
Jun 26 07:13:29 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/smtpd[37793]: improper command pipelining after MAIL from localhost[127.0.0.1]: RCPT TO: receiver@example.com\r\nDATA\r\nFrom: postfix@example.com\r\nTo: receiver@example.com
Jun 26 07:13:29 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/smtpd[37793]: EB405AFD839: client=localhost[127.0.0.1]
Jun 26 07:13:38 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/cleanup[37796]: EB405AFD839: message-id=<20250626071329.EB405AFD839@ip-192-168-4-157.ap-northeast-1.compute.internal>
Jun 26 07:13:38 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/qmgr[33947]: EB405AFD839: from=<postfix@example.com>, size=456, nrcpt=1 (queue active)
Jun 26 07:13:39 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/pipe[37798]: EB405AFD839: to=<receiver@example.com>, relay=ses-api, delay=9.3, delays=8.5/0/0/0.85, dsn=2.0.0, status=sent (delivered via ses-api service)
Jun 26 07:13:39 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/qmgr[33947]: EB405AFD839: removed
Jun 26 07:13:39 ip-192-168-4-157.ap-northeast-1.compute.internal postfix/smtpd[37793]: disconnect from localhost[127.0.0.1] mail=1 rcpt=1 data=1 quit=1 commands=4
先に貼った内容と同一ですがses-api.log
の出力は以下の通りです。
------------
SENDER: postfix@example.com
RECIPIENT: receiver@example.com
------------
RAW EMAIL CONTENT:
Return-Path: <postfix@example.com>
Delivered-To: receiver@example.com
Received: from localhost (localhost [127.0.0.1])
by ip-xxx-xxx-xxx-xxx.ap-northeast-1.compute.internal (Postfix) with SMTP id EB405AFD839
for <receiver@example.com>; Thu, 26 Jun 2025 07:13:29 +0000 (UTC)
From: postfix@example.com
To: receiver@example.com
Subject: test mail
Message-Id: <20250626071329.EB405AFD839@ip-xxx-xxx-xxx-xxx.ap-northeast-1.compute.internal>
Date: Thu, 26 Jun 2025 07:13:29 +0000 (UTC)
Hello.
im from ses.
------------
SUBJECT: test mail
ACTUAL EMAIL BODY:
Hello.
im from ses.
SES API RESPONSE: {
"MessageId": "xxxxxx-000000"
}
GMail側で付与されたヘッダを一部省略していますが、受信したメールソースは以下のような形です。
当然ですが今回はPostfix側で付与されたヘッダは付与しない形でAWS APIをよび出されてしまうためそれまでのヘッダは継承されていません。
Delivered-To: receiver@example.com
Received: by 2002:a05:6f02:8389:b0:8b:2152:453f with SMTP id b9csp85297rcf;
Thu, 26 Jun 2025 00:13:41 -0700 (PDT)
...
Return-Path: <xxxxxx@ses.example.com>
Received: from e234-9.smtp-out.ap-northeast-1.amazonses.com (e234-9.smtp-out.ap-northeast-1.amazonses.com. [23.251.234.9])
by mx.google.com with ESMTPS id d2e1a72fcca58-749b5ddc5ebsi7913208b3a.38.2025.06.26.00.13.40
for <receiver@example.com>
(version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128);
Thu, 26 Jun 2025 00:13:40 -0700 (PDT)
Received-SPF: pass (google.com: domain of 01060197ab15c0b3-a71e5b63-0097-42c2-ac99-cb2b184209fd-000000@ses.example.com designates 23.251.234.9 as permitted sender) client-ip=23.251.234.9;
Authentication-Results: mx.google.com;
dkim=pass header.i=@example.com header.s=ddbgaa2agxzjz76ngpudxk3hxrbozkyj header.b=tY4Du7gE;
dkim=pass header.i=@amazonses.com header.s=suwteswkahkjx5z3rgaujjw4zqymtlt2 header.b=BY0qxAgs;
spf=pass (google.com: domain of xxxxxx-000000@ses.example.com designates 23.251.234.9 as permitted sender) smtp.mailfrom=xxxxxx-000000@ses.example.com;
dmarc=pass (p=REJECT sp=REJECT dis=NONE) header.from=example.com
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple; s=ddbgaa2agxzjz76ngpudxk3hxrbozkyj; d=example.com; t=1750922019; h=From:To:Subject:MIME-Version:Content-Type:Content-Transfer-Encoding:Message-ID:Date; bh=xdAzkGVuokkj5wh9rdhluHe06Wqz7gmPdjFFLtJzPsI=; b=tY4Du7gESpgCYG9M3S3yx6DskMss2cTkKR9Q7jTYOvxEooLfnA9KFcEX8F28ljrK Wb1oihH5o9ic9hWE5LC/Zgkjj/OTSIoW8NGn8XtiM4i+tIdT9D+zdv56uzYa5zqujmt Ab+/v+W8vsubAVzuzZVgt16QJW5sYA4fV+rzdKedCYxxyVI0mkA/VDoTgtiSeNJUeAy w3BF4KePV5YIb35aKF+XLdfRhzfGochb/W94WIOjCXh7MTSh1axceGOoNbUaxPr2Eu7 0hO4LuUfG2PiohZ6HOeUfQbdP8/Lqz35QAdlwNgTIyXLYj4ALYtEppTD8ZBtFBJZwaO hnC6+utp/w==
DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/simple; s=suwteswkahkjx5z3rgaujjw4zqymtlt2; d=amazonses.com; t=1750922019; h=From:To:Subject:MIME-Version:Content-Type:Content-Transfer-Encoding:Message-ID:Date:Feedback-ID; bh=xdAzkGVuokkj5wh9rdhluHe06Wqz7gmPdjFFLtJzPsI=; b=BY0qxAgsqrgfw/4k0/rCa33nPq0712N5f8lKS/7fXhArFF3qYNkaVtoxA9p0ot9m Rzhh6cIf9GJ2Y9/65SqrjPmufZd4Vx9JEMGYde+Rj0RJunZecV0gRMozM3pSl0FCmbm uWxHtGY2JujVcJTX9PrRpuBVPODCtfOM9+sO2/mY=
From: postfix@example.com
To: receiver@example.com
Subject: test mail
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
Message-ID: <0xxxxxx-000000@ap-northeast-1.amazonses.com>
Date: Thu, 26 Jun 2025 07:13:39 +0000
Feedback-ID: ::1.ap-northeast-1.l/xxxxxx+MQx0Zi4=:AmazonSES
X-SES-Outgoing: 2025.06.26-23.251.234.9
Hello.
im from ses.
終わりに
PostfixをリレーしてAmazon SESでメールを送る際のプロトコルをSMTPではなくHTTPS(AWS API)にしてみました。
この方法を利用することでEC2上であれば永続的なアクセスキーを利用せずIAMロールの一時的なアクセスキーが利用可能であり、
またネットワークの弱い環境であれば通信RTTの影響を低減することが可能となります。
ただ見ての通り実現はPostfixから飛んできたデータを自前でゴリゴリ書き込んで処理しているだけですので、メールフォーマットに関する理解も必要ですし、そもそもこれを管理して運用していこうとするとだいぶ厳しいと思います。
あくまで実験的なもの、もしやるのであればこんな感じで...と考えていただくのがいいのではないかと思います。
(Postfixの仕様に詳しいわけじゃないのでうまく別デーモンを経由していくともっとシンプルにできたりするんでしょうか)