Stalwart の MTA Hook で .forward のパイプ処理を置き換える

目次
メールサーバを Postfix + Dovecot から Stalwart に移行した(移行の記録)。
旧サーバでは、Postfix の .forward-xxx で特定のサブアドレス宛てのメールをスクリプトに渡していた。
$ cat ~/.forward-hook
"| /home/<ユーザー>/script.sh"user-hook@poppo-ya.com 宛てのメールが、標準入力で script.sh に渡される。うちの場合は、本文(base64)をデコードして画像にするスクリプトを動かしていた。
Stalwart には .forward のような仕組みも、Sieve からコマンドを実行する機能も無い。そこで MTA Hook で置き換えた。
代わりになる方法
| 方法 | 内容 | 向いている用途 |
|---|---|---|
| MTA Hook | 受信ごとに、エンベロープ・ヘッダ・本文を JSON で HTTP POST する | 本文を使ってプログラムを動かす |
| Webhook | 受信などのイベントを通知する。本文は含まれない | 届いたことをきっかけに何かを起動する |
| Sieve + 監視 | Sieve で専用フォルダに振り分け、別のプログラムが IMAP で監視する | 即時性が要らない場合 |
本文が必要なので MTA Hook にした。サーバ内に 127.0.0.1 で待ち受ける小さな HTTP サーバ(受け口)を立て、宛先に応じてスクリプトを実行する。
外部 → Stalwart ──POST(JSON)──→ 受け口 (127.0.0.1:8090) ──→ script.sh
│ │
└→ メールボックスに配送 └→ {"action":"accept"} を返す受け口(hook.py)
Python の標準ライブラリだけで書いた。
#!/usr/bin/env python3
# Stalwart MTA Hook の受け口。特定の宛先のメールをプログラムに渡す。
import json, subprocess, threading
from email.utils import getaddresses
from http.server import BaseHTTPRequestHandler, HTTPServer
# 宛先 → 実行するコマンド(.forward-xxx の代わり)
TARGETS = {
"user-hook@poppo-ya.com": ["/bin/bash", "/home/ubuntu/mailhook/script.sh"],
}
def recipients(req):
# エンベロープはサブアドレス変換後(user@)なので、ヘッダの宛先も見る
addrs = {t["address"].lower() for t in req.get("envelope", {}).get("to", [])}
for name, value in req.get("message", {}).get("headers", []):
if name.lower() in ("to", "cc", "delivered-to", "x-original-to"):
for _, addr in getaddresses([value]):
addrs.add(addr.lower())
return addrs
def build_raw(msg):
# ヘッダと本文から1通のメールを組み立て直す。改行は LF にそろえる
lines = [f"{name}: {value.strip()}" for name, value in msg.get("headers", [])]
raw = "\n".join(lines) + "\n\n" + msg.get("contents", "")
return raw.replace("\r\n", "\n").encode()
def run(cmd, data):
try:
subprocess.run(cmd, input=data, timeout=300)
except Exception as e:
print(f"command failed: {cmd}: {e}", flush=True)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
try:
req = json.loads(body)
for r in recipients(req):
if r in TARGETS:
print(f"run {TARGETS[r]} for {r}", flush=True)
raw = build_raw(req.get("message", {}))
threading.Thread(target=run, args=(TARGETS[r], raw)).start()
except Exception as e:
print(f"hook error: {e}", flush=True)
# 何があっても必ず accept を返す(メール自体は止めない)
out = b'{"action":"accept"}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
def log_message(self, *args):
pass
HTTPServer(("127.0.0.1", 8090), Handler).serve_forever()ポイントは次の 3 つ。
- 必ず
acceptを返す。rejectやdiscardを返すと、メールそのものが拒否・破棄される - スクリプトは別スレッドで実行する。 処理に時間がかかっても Stalwart を待たせない
- メールを組み立て直す。 リクエストは元のメールそのものではなく、ヘッダの一覧(
[名前, 値]の配列)と本文(contents)に分かれた JSON で届く
{
"envelope": { "to": [ { "address": "user@poppo-ya.com" } ] },
"message": {
"headers": [ ["To", "user-hook@poppo-ya.com"], ... ],
"contents": "5LqI5a6a44Gu44OG44K544OI44Gn44GZCg==\r\n\r\n\r\n",
"size": ...
}
}systemd で常駐させる。
# /etc/systemd/system/mailhook.service
[Unit]
Description=Stalwart MTA hook receiver
After=network.target
[Service]
User=ubuntu
ExecStart=/usr/bin/python3 /home/ubuntu/mailhook/hook.py
Restart=always
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now mailhookStalwart の設定
管理画面:Settings › MTA › Filters › MTA Hooks
| 項目 | 値 |
|---|---|
| Endpoint URL | http://127.0.0.1:8090/ |
| Enable | true |
| Run on stages | DATA のみ(本文が届くのは DATA 段階だけ) |
| Timeout | 30 秒 |
| TempFail on Error | オフ |
| Authentication | Anonymous(127.0.0.1 だけで待ち受けるため) |
TempFail on Error をオンにすると、受け口が止まっているときに全メールの受信が 451 で保留される。オフなら、受け口が止まっていてもメールは届き、スクリプトが動かないだけで済む。
ハマったところ
フックが呼ばれない
設定を保存しても、受け口に何も届かない。Stalwart のログにも mta-hook の行が出ていなかった。Stalwart を再起動したら呼ばれるようになった。 MTA Hook を新しく作ったときは、再起動しないと反映されないようだ(v1.0.1)。
宛先がサブアドレス変換後になっている
動作確認のためにリクエストをファイルに保存して見ると、エンベロープの宛先が user-hook@ ではなく user@ になっていた。
$ jq '.envelope.to' last-request.json
[ { "address": "user@poppo-ya.com" } ]Stalwart はサブアドレスの変換を済ませてからフックを呼ぶ。To / Cc ヘッダには元のアドレスが残っているので、ヘッダでも判定するようにした(recipients())。
そのため、BCC で送られたメールは検出できない。送ってくるサービスが To にアドレスを入れていれば問題ない。
本文が空になる(CRLF)
スクリプトは動いたが、本文が取り出せず白紙の画像ができた。渡したメールの行末に \r が残っていた。
$ cat -A /tmp/org | head
Content-Transfer-Encoding: base64^M$
^M$
5LqI5a6a44Gu44OG44K544OI44Gn44GZCg==^M$Postfix はパイプ先に LF の改行で渡していたが、MTA Hook の本文は CRLF のまま。空行が \r だけの行になり、「最初の空行より後ろが本文」という判定が成り立たなかった。
受け口の build_raw() で LF にそろえ、念のためスクリプト側でも tr -d '\r' を通すようにした。
動作確認
swaks --to user-hook@poppo-ya.com --server 127.0.0.1 \
--ehlo test.example.com --from test@example.com \
--header "Content-Type: text/plain; charset=UTF-8" \
--header "Content-Transfer-Encoding: base64" \
--body "$(echo '予定のテストです' | base64)"本文がデコードされ、スクリプトが旧サーバと同じように動いた。
.forward との違い
- 受信箱にも配送される。
.forwardはコマンドに渡すだけだったが、MTA Hook の後もメールは普通に配送される。残したくなければ Sieve で捨てる(次の節) - 元のメールそのものではない。 組み立て直しているので、ヘッダの折り返しや並び順が元と少し違うことがある。件名や本文を読む程度なら問題ない
受信箱に残さない:Sieve で捨てる
アカウントに個人用の Sieve スクリプトを登録して有効にすると、メールが受信箱に入る直前に実行される。user-hook@ 宛てのメールは、次の順番で処理される。

ポイントは 2 つ。
- ③ MTA Hook は ④ Sieve より前に動く。 Sieve で捨てても、スクリプトはその前に実行されている
- ② でエンベロープの宛先は
user@に変わる。 サブアドレスの判定にはenvelope :detail "to" "hook"を使うのが定番だが、Stalwart ではこれが使えない(フックと同じ理由)。ここでもTo/Ccヘッダで判定する
# main.sieve
# user-hook 宛てはスクリプトで処理済みなので、受信箱には残さない
if address :is ["to", "cc"] "user-hook@poppo-ya.com" {
discard;
stop;
}address と discard は Sieve の基本機能なので、require は要らない。
スクリプトは ManageSieve(4190 番)で登録する。4190 番は外部に開けていないので、サーバ上から 127.0.0.1 に接続する。
ss -tlnp | grep 4190 # Stalwart が待ち受けているか確認
sudo apt install sieve-connect
sieve-connect --server 127.0.0.1 --port 4190 --notlsverify \
--user <ユーザー>@poppo-ya.com \
--upload --localsieve main.sieve --remotesieve main
sieve-connect --server 127.0.0.1 --port 4190 --notlsverify \
--user <ユーザー>@poppo-ya.com --activate --remotesieve main
sieve-connect --server 127.0.0.1 --port 4190 --notlsverify \
--user <ユーザー>@poppo-ya.com --list--notlsverify は、127.0.0.1 で接続すると証明書の名前と一致しないため。通信は TLS で暗号化されたまま。
user-hook@ 宛てのテストメールでスクリプトが動き、受信箱(と Junk)には届かないこと、普通の宛先のメールは今までどおり届くことを確かめる。
注意点は次のとおり。
- 有効にできるスクリプトは 1 つだけ。 振り分けのルールを増やすときは、
main.sieveに書き足してアップロードし直す - 不在通知(vacation)を使うと
mainが無効になる。 Stalwart の不在通知はvacationというスクリプトに切り替わるので、使った後は--activateでmainを有効に戻す - Thunderbird のアドオン「Sieve」でも編集できるが、その場合は 4190 番を外部に開ける必要がある
