メールサーバを 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 の標準ライブラリだけで書いた。

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 で届く
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 で常駐させる。

ini
# /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.target
bash
sudo systemctl daemon-reload
sudo systemctl enable --now mailhook

Stalwart の設定

管理画面:Settings › MTA › Filters › MTA Hooks

項目値
Endpoint URLhttp://127.0.0.1:8090/
Enabletrue
Run on stagesDATA のみ(本文が届くのは DATA 段階だけ)
Timeout30 秒
TempFail on Errorオフ
AuthenticationAnonymous(127.0.0.1 だけで待ち受けるため)

TempFail on Error をオンにすると、受け口が止まっているときに全メールの受信が 451 で保留される。オフなら、受け口が止まっていてもメールは届き、スクリプトが動かないだけで済む。

ハマったところ

フックが呼ばれない

設定を保存しても、受け口に何も届かない。Stalwart のログにも mta-hook の行が出ていなかった。Stalwart を再起動したら呼ばれるようになった。 MTA Hook を新しく作ったときは、再起動しないと反映されないようだ(v1.0.1)。

宛先がサブアドレス変換後になっている

動作確認のためにリクエストをファイルに保存して見ると、エンベロープの宛先が user-hook@ ではなく user@ になっていた。

bash
$ 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' を通すようにした。

動作確認

bash
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@ 宛てのメールは、次の順番で処理される。

user-hook@ 宛てのメールが処理される順番。① SMTP で受信 → ② サブアドレス変換(エンベロープは user@ になり、To ヘッダは user-hook@ のまま)→ ③ MTA Hook(hook.py が script.sh を実行して accept を返す)→ ④ Sieve(To / Cc が user-hook@ なら discard、ほかの宛先は受信箱へ)

ポイントは 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 に接続する。

bash
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 番を外部に開ける必要がある

参考