1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自宅鯖のアラートをGmailで受けたい!Postfix+Cloudflare REST APIで監視メールを届けた話

1
Posted at

自宅鯖を運用していると、ProxmoxやZabbixのアラートをGmailに飛ばしたくなりますよね〜。

障害に気づくための監視なのに、管理画面を開かないと通知を見られないのはちょっとつらい。とはいえ、自宅回線からPostfixで直接メールを送ろうとすると、外向きTCP 25が通らなかったり、逆引きDNSや送信元評価まで気にする必要が出てきたりします。

そこで今回は、LAN内のシステムからは普通のSMTPで受け、インターネット側はHTTPSに変換する構成にしました。

最終的な経路はこれです。

Proxmox / Zabbix
    ↓ LAN内 SMTP :25
Postfix
    ↓ pipe transport
Python中継プログラム
    ↓ HTTPS :443
Cloudflare Email Service REST API
    ↓
Cloudflareで検証済みのGmail

この記事では、実際に構築してProxmox・Zabbixのテスト通知まで通した構成を、ドメイン、メールアドレス、IPアドレスなどの環境固有値をすべて例示値へ置き換えて紹介します。

Cloudflareの料金や機能名は変わる可能性があります。本稿のCloudflareに関する記述は2026年8月26日に公式ドキュメントで再確認しています。構築時にも最新情報を確認してください。

先に結論

今回の構成で実現できたことは次のとおりです。

  • ProxmoxとZabbixは、LAN内のPostfixへSMTPで通知を送る
  • Postfixは宛先を監視用アドレス1件だけに制限する
  • Python中継プログラムがメールをCloudflare REST API用のJSONへ変換する
  • APIの宛先は、Cloudflareで検証したGmailアドレスに固定する
  • インターネット向けの通信はTCP 443だけを使う
  • Cloudflareの任意宛先向け有料送信は使わない

Cloudflare公式では、アカウント内の検証済みDestination Addressへの送信は、Email Routingだけを設定したアカウントでも無料と案内されています。任意の宛先へ送る用途はWorkers Paidが必要ですが、今回のように自分のGmailへ固定する用途なら無料枠の対象です。

なお、Cloudflareは送信機能をEmail Sending Betaとして案内しています。無料条件だけでなく、Beta中の仕様変更も見込んで運用します。

最終構成

図には2本の経路があります。ここは少し紛らわしいところです。

用途 経路 Cloudflareの機能
自宅鯖からの監視通知 Postfix → Python → REST API → Gmail 検証済み宛先へのAPI送信
外部から独自ドメイン宛の受信 外部送信者 → Cloudflare MX → Gmail Email Routing

Email RoutingのMXサーバーは、こちらから監視メールを投げ込むための無料SMTPリレーではありません。外部から独自ドメイン宛に届いたメールを受信・転送する機能です。

自宅鯖の通知は、PostfixからCloudflareのREST APIへ送ります。同じGmailをDestination Addressとして使いますが、通信経路は別物です。

直接SMTPをやめた理由

最初は、PostfixからCloudflareのMXへ普通にSMTP配送しようとしました。ところが、メールはキューへ入るものの外向きTCP 25でタイムアウトしました。

connect to routeX.mx.cloudflare.net[<CLOUDFLARE_MX_IP>]:25:
Connection timed out

自宅回線や上流ネットワークでは、迷惑メール対策で外向きTCP 25が制限されていることがあります。ここを無理に突破しても、直接配送では公開IPの逆引きDNS、SPF、DKIM、DMARC、送信元IPの評価まで面倒を見る必要があります。

途中でOpenDKIMも試しましたが、最終構成では使っていません。Python側で元メールをAPI用JSONへ組み直し、Cloudflareが送信側の認証を処理するため、Postfix側で付けたローカルDKIM署名を引き継ぐ構成ではないからです。

Cloudflareには認証付きSMTPの送信口もありますが、そちらはEmail Sendingのオンボードが前提です。今回は「検証済みの自分のGmailだけに無料で送りたい」という条件だったので、REST APIを選びました。

この記事で使う例示値

以下はすべて説明用の値です。自分の環境へ読み替えてください。

項目 例示値
独自ドメイン example.com
監視用アドレス monitoring@example.com
転送先Gmail your-account@gmail.com
メールゲートウェイのホスト名 mail-gateway.example.com
AlmaLinux LXCのIP 192.168.10.50
LAN 192.168.10.0/24
Cloudflare Account ID <CLOUDFLARE_ACCOUNT_ID>
Cloudflare APIトークン 本文には記載しない

ホスト名は、mail-gateway.example.comのようなFQDNにしました。.localはmDNSで使われる特別な名前なので、Postfixのバナー、HELO、ログへ出す名前としては避けたほうが混乱しません。

この構成ではメールゲートウェイへインターネットから接続しないため、ホスト名をLAN内DNSで解決できれば十分です。LAN内IPをCloudflareの公開DNSへ登録する必要はありません。

前提条件

  • 独自ドメインをCloudflare DNSで管理している
  • Cloudflare Email Routingを有効化できる
  • 受信先のGmailアドレスを所有している
  • AlmaLinux 10の専用LXCを用意できる
  • ProxmoxやZabbixからLXCのTCP 25へ接続できる
  • LXCからCloudflare APIのTCP 443へ接続できる

今回作るLXCは、メールボックスを持つ一般的なメールサーバーではありません。IMAPやPOP3は提供せず、LAN内の監視通知をCloudflare APIへ橋渡しする専用ゲートウェイです。

Cloudflare側の準備

独自ドメインをEmail Routingへオンボードする

まだEmail Routingを有効にしていない場合は、CloudflareダッシュボードのComputeEmail ServiceEmail Routingから独自ドメインをオンボードします。

Cloudflareが案内するMX、SPF、DKIMレコードを追加し、Email Routingのステータスが有効になったことを確認します。Cloudflare DNSを使っていれば、通常はダッシュボードから必要なレコードを自動設定できます。

この工程は、検証済みGmailへ無料送信するときのFromドメインを登録するためにも必要です。一方、後述のRouting Ruleは、外部から届くメールも受信したい場合だけ作ります。

Email Routingのオンボードでは、独自ドメインのMXレコードがCloudflare向けに変わります。すでに別のメールサービスで受信しているドメインでは、既存メールへの影響を確認してから変更してください。

DNSレコードと送受信設定の違いは、Cloudflare公式のDomain configurationにまとまっています。

GmailをDestination Addressとして検証する

Cloudflareダッシュボードで、次の順に開きます。

Compute
  └ Email Service
      └ Email Routing
          └ Destination Addresses

your-account@gmail.comを追加すると、Gmailへ確認メールが届きます。メール内のリンクを開き、ステータスが検証済みになったことを確認します。

この検証が終わっていないと、REST APIの固定宛先として使えません。

外部からも受信する場合だけRouting Ruleを作る

独自ドメインのアドレスへ外部から届いたメールもGmailで読みたい場合は、Email Routingに次のルールを作ります。

monitoring@example.com
    ↓
your-account@gmail.com

このRouting Ruleは外部受信用です。自宅鯖からREST APIで送るだけなら、API送信そのものにRouting Ruleは使いません。

APIトークンを作る

サーバー専用のAPIトークンを作成します。

  • 権限: Email Sending: Edit
  • 対象: 使用するCloudflareアカウントだけ
  • 用途が分かる名前を付ける
  • 可能なら送信元IPや有効期限も制限する

個人ユーザーにひも付くトークンより、長期運用ではAccount API Tokenのほうが扱いやすいです。Cloudflareも、継続的なシステム連携にはAccount API Tokenを案内しています。

トークンの文字列は作成時にしか表示されません。記事、Git、作業ログ、画面共有には出さず、安全な場所へ一時保管します。

Account IDを控える

APIのエンドポイントにはZone IDではなくAccount IDを使います。

POST https://api.cloudflare.com/client/v4/accounts/<CLOUDFLARE_ACCOUNT_ID>/email/sending/send

REST APIはBearerトークンで認証します。仕様はCloudflare公式のREST APIガイドで確認できます。

無料の検証済み宛先向け送信でも、FromにはCloudflareへ登録したEmail Routingドメイン上のアドレスを使います。この例ならmonitoring@example.comです。

AlmaLinux 10 LXCを準備する

ホスト名を設定する

hostnamectl set-hostname mail-gateway.example.com
hostnamectl

LAN内DNSがない場合は、必要に応じてProxmoxやZabbix側の名前解決も整えます。ただし、このあとSMTPサーバーにはIPアドレスを指定できるため、必須ではありません。

パッケージを入れる

dnf install -y dnf-plugins-core epel-release
dnf config-manager --set-enabled crb

dnf install -y \
  postfix postfix-lmdb python3 rsyslog \
  ca-certificates bind-utils nmap-ncat curl swaks nano

LMDB形式の宛先マップを使うため、postfix-lmdbを入れています。swaksはSMTPの疎通試験に使います。

API中継専用ユーザーを作る

getent group cfmail >/dev/null || groupadd --system cfmail

id cfmail >/dev/null 2>&1 || \
  useradd --system \
    --gid cfmail \
    --home-dir /nonexistent \
    --shell /sbin/nologin \
    cfmail

Python中継プログラムはrootではなく、このログイン不可ユーザーで動かします。

SMTPをREST APIへ変換する

Postfixのpipe transportから、RFC 822形式のメールが標準入力へ渡されます。中継プログラムは次の処理を担当します。

  1. Postfixが渡したenvelope recipientを検査する
  2. 元メールから件名、plain本文、HTML本文を取り出す
  3. APIのToを検証済みGmailへ固定する
  4. APIのFromを監視用アドレスへ固定する
  5. Cloudflare REST APIへHTTPSでPOSTする
  6. 成功、一時失敗、恒久失敗を終了コードでPostfixへ返す

監視用途なので、元メールのToやFromは信用しません。Postfixと中継プログラムの両方で宛先を固定するのがポイントです。

中継プログラムの簡易実装

以下は、ProxmoxやZabbixのテキスト・HTML通知を送るための簡易実装です。配置先は/usr/local/libexec/postfix/cloudflare_email_relay.pyとします。

添付ファイル、詳細なMIME異常処理、ファイル所有者検査、API応答サイズ制限、syslogへの追跡ID出力までは含めていません。実運用では必要に応じて追加してください。

cloudflare_email_relay.pyを開く
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import configparser
import json
import re
import socket
import ssl
import sys
import urllib.error
import urllib.request
from email import policy
from email.message import Message
from email.parser import BytesParser

CONFIG_PATH = "/etc/postfix/cloudflare-email-relay.conf"
API_BASE = "https://api.cloudflare.com/client/v4/accounts"
EX_OK = 0
EX_UNAVAILABLE = 69
EX_TEMPFAIL = 75
ADDRESS_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")


class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
    """Bearerトークンを別ホストへ渡さないため、リダイレクトを拒否する。"""

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def fail(exit_code: int, smtp_status: str, event: str) -> None:
    """Postfixが再試行可否を判断できる終了コードで終わる。"""

    print(f"{smtp_status} {event}", file=sys.stderr, flush=True)
    raise SystemExit(exit_code)


def required(section, key: str) -> str:
    value = section.get(key, "").strip()
    if not value:
        fail(EX_TEMPFAIL, "4.3.0", f"missing_config_{key}")
    return value


def load_settings() -> dict:
    parser = configparser.ConfigParser(interpolation=None)
    try:
        with open(CONFIG_PATH, "r", encoding="utf-8") as config_file:
            parser.read_file(config_file)
        section = parser["cloudflare"]
    except (OSError, KeyError, configparser.Error):
        fail(EX_TEMPFAIL, "4.3.0", "configuration_unavailable")

    account_id = required(section, "account_id")
    if not re.fullmatch(r"[0-9a-fA-F]{32}", account_id):
        fail(EX_TEMPFAIL, "4.3.0", "invalid_account_id")

    token_path = required(section, "api_token_file")
    try:
        with open(token_path, "r", encoding="utf-8") as token_file:
            api_token = token_file.read().strip()
    except OSError:
        fail(EX_TEMPFAIL, "4.3.0", "token_unavailable")

    if not api_token or any(char.isspace() for char in api_token):
        fail(EX_TEMPFAIL, "4.3.0", "invalid_token")

    try:
        timeout = section.getfloat("timeout_seconds", fallback=20.0)
        max_bytes = section.getint(
            "max_message_bytes",
            fallback=5 * 1024 * 1024,
        )
    except ValueError:
        fail(EX_TEMPFAIL, "4.3.0", "invalid_numeric_config")

    if timeout < 1.0 or timeout > 45.0:
        fail(EX_TEMPFAIL, "4.3.0", "invalid_timeout_seconds")
    if max_bytes < 1024 or max_bytes > 25 * 1024 * 1024:
        fail(EX_TEMPFAIL, "4.3.0", "invalid_max_message_bytes")

    settings = {
        "account_id": account_id,
        "api_token": api_token,
        "recipient": required(section, "recipient"),
        "accepted_recipient": required(section, "accepted_recipient"),
        "from_address": required(section, "from_address"),
        "from_name": section.get("from_name", "Home Lab Monitoring").strip(),
        "timeout": timeout,
        "max_bytes": max_bytes,
    }

    for key in ("recipient", "accepted_recipient", "from_address"):
        if not ADDRESS_RE.fullmatch(settings[key]):
            fail(EX_TEMPFAIL, "4.3.0", f"invalid_{key}")

    return settings


def text_part(message: Message, subtype: str) -> str:
    """指定した形式の本文を取得する。"""

    try:
        part = message.get_body(preferencelist=(subtype,))
    except (AttributeError, KeyError, TypeError, ValueError):
        part = None

    if part is None:
        if (
            not message.is_multipart()
            and message.get_content_maintype() == "text"
            and message.get_content_subtype() == subtype
        ):
            part = message
        else:
            return ""

    try:
        content = part.get_content()
    except (KeyError, LookupError, TypeError, UnicodeError, ValueError):
        return ""

    return content if isinstance(content, str) else ""


def build_payload(raw_message: bytes, settings: dict) -> bytes:
    try:
        message = BytesParser(policy=policy.default).parsebytes(raw_message)
    except Exception:
        fail(EX_UNAVAILABLE, "5.6.0", "invalid_rfc822_message")

    plain = text_part(message, "plain")
    html = text_part(message, "html")
    if not plain and not html:
        fail(EX_UNAVAILABLE, "5.6.0", "message_body_not_found")

    subject = str(message.get("Subject", "(no subject)"))
    subject = subject.replace("\r", " ").replace("\n", " ")[:998]

    sender = {
        "address": settings["from_address"],
        "name": settings["from_name"],
    }
    payload = {
        "to": settings["recipient"],
        "from": sender,
        "subject": subject,
        "headers": {"Auto-Submitted": "auto-generated"},
    }
    if plain:
        payload["text"] = plain
    if html:
        payload["html"] = html

    try:
        return json.dumps(payload, ensure_ascii=False).encode("utf-8")
    except (TypeError, ValueError, UnicodeError):
        fail(EX_UNAVAILABLE, "5.6.0", "json_encode_failed")


def response_address_set(result: dict, key: str) -> set:
    """API応答の宛先配列を検証して、小文字の集合へ変換する。"""

    values = result.get(key)
    if not isinstance(values, list):
        fail(EX_TEMPFAIL, "4.4.1", f"cloudflare_{key}_invalid")
    if any(not isinstance(item, str) for item in values):
        fail(EX_TEMPFAIL, "4.4.1", f"cloudflare_{key}_invalid")
    return {item.casefold() for item in values}


def send(payload: bytes, settings: dict) -> None:
    endpoint = (
        f"{API_BASE}/{settings['account_id']}/email/sending/send"
    )
    request = urllib.request.Request(
        endpoint,
        data=payload,
        method="POST",
        headers={
            "Authorization": f"Bearer {settings['api_token']}",
            "Content-Type": "application/json; charset=utf-8",
            "Accept": "application/json",
            "User-Agent": "home-lab-postfix-cloudflare-relay/1.0",
        },
    )

    tls_context = ssl.create_default_context()
    tls_context.minimum_version = ssl.TLSVersion.TLSv1_2
    opener = urllib.request.build_opener(
        urllib.request.ProxyHandler({}),
        NoRedirectHandler(),
        urllib.request.HTTPSHandler(context=tls_context),
    )

    try:
        with opener.open(request, timeout=settings["timeout"]) as response:
            response_data = json.loads(response.read().decode("utf-8"))
    except urllib.error.HTTPError as error:
        if error.code == 429 or error.code >= 500:
            fail(EX_TEMPFAIL, "4.4.1", f"cloudflare_http_{error.code}")
        if error.code in (401, 403, 404):
            fail(EX_TEMPFAIL, "4.3.0", f"cloudflare_http_{error.code}")
        fail(EX_UNAVAILABLE, "5.6.0", f"cloudflare_http_{error.code}")
    except (
        json.JSONDecodeError,
        OSError,
        socket.timeout,
        ssl.SSLError,
        TimeoutError,
        UnicodeDecodeError,
        urllib.error.URLError,
    ):
        fail(EX_TEMPFAIL, "4.4.1", "cloudflare_temporary_failure")

    if not isinstance(response_data, dict):
        fail(EX_TEMPFAIL, "4.4.1", "cloudflare_response_invalid")
    if response_data.get("success") is not True:
        fail(EX_TEMPFAIL, "4.4.1", "cloudflare_api_rejected")

    result = response_data.get("result")
    if not isinstance(result, dict):
        fail(EX_TEMPFAIL, "4.4.1", "cloudflare_result_missing")
    target = settings["recipient"].casefold()
    delivered = response_address_set(result, "delivered")
    queued = response_address_set(result, "queued")
    bounced = response_address_set(result, "permanent_bounces")

    if target in delivered or target in queued:
        raise SystemExit(EX_OK)
    if target in bounced:
        fail(EX_UNAVAILABLE, "5.1.1", "cloudflare_permanent_bounce")
    fail(EX_TEMPFAIL, "4.4.1", "cloudflare_acceptance_unconfirmed")


def main() -> None:
    parser = argparse.ArgumentParser()
    mode = parser.add_mutually_exclusive_group(required=True)
    mode.add_argument("--check-config", action="store_true")
    mode.add_argument("--recipient")
    args = parser.parse_args()

    settings = load_settings()
    if args.check_config:
        print("configuration_ok")
        raise SystemExit(EX_OK)

    if args.recipient.casefold() != settings["accepted_recipient"].casefold():
        fail(EX_TEMPFAIL, "4.1.1", "recipient_not_permitted")

    raw_message = sys.stdin.buffer.read(settings["max_bytes"] + 1)
    if not raw_message:
        fail(EX_UNAVAILABLE, "5.6.0", "empty_message")
    if len(raw_message) > settings["max_bytes"]:
        fail(EX_UNAVAILABLE, "5.3.4", "message_too_large")

    send(build_payload(raw_message, settings), settings)


if __name__ == "__main__":
    main()

プログラムは、Cloudflareの応答に対象Gmailがdeliveredまたはqueuedとして含まれる場合だけ終了コード0を返します。

  • 0: 配送成功としてPostfixキューから削除
  • 75: 一時失敗としてPostfixが再試行
  • 69: 恒久失敗としてバウンス処理

APIのタイムアウト直前にCloudflare側だけが受理していると、Postfixの再試行で通知が重複する可能性は残ります。監視通知では許容しやすい挙動ですが、課金メールや注文メールへ流用する場合は、別途べき等性を設計したほうが安全です。

この簡易実装では、401、403、404も終了コード75へ寄せています。Cloudflare上では認証・権限・Account IDなどの設定エラーですが、修正後にキューから再配送できるよう、通知をすぐ捨てないための判断です。その代わり、設定を直すまでキューは滞留します。postqueue -pの監視と、Postfixのmaximal_queue_lifetimeを含む保持期間の確認が必要です。

また、ProxyHandler({})で環境変数のHTTPプロキシを使わないようにしています。外向き通信にプロキシが必須の環境では、そのプロキシを信頼できるか確認したうえで実装を調整してください。

プログラムを配置する

install -d -o root -g root -m 0755 \
  /usr/local/libexec/postfix

install -o root -g root -m 0644 \
  cloudflare_email_relay.py \
  /usr/local/libexec/postfix/cloudflare_email_relay.py

python3 -m py_compile \
  /usr/local/libexec/postfix/cloudflare_email_relay.py

cfmailユーザーがスクリプトを書き換えられない所有権にします。Pythonインタープリターから読むため、実行ビットは不要です。

設定ファイルを作る

/etc/postfix/cloudflare-email-relay.confを作ります。

[cloudflare]
account_id = <CLOUDFLARE_ACCOUNT_ID>
api_token_file = /etc/postfix/cloudflare-email-api-token

# Postfixから受け付ける宛先
accepted_recipient = monitoring@example.com

# Cloudflare APIで実際に送る固定宛先と差出人
recipient = your-account@gmail.com
from_address = monitoring@example.com
from_name = Home Lab Monitoring

timeout_seconds = 20
max_message_bytes = 5242880

accepted_recipient、Postfixの宛先マップ、Proxmox・Zabbixが指定するToは、すべてmonitoring@example.comへそろえます。

recipientには、Cloudflareで検証済みのGmail実アドレスを指定します。

APIトークンを保存する

トークンをコマンドラインへ直接書くと、シェル履歴や作業収録へ残ることがあります。非表示入力で1行だけ保存します。

umask 0077
read -rsp 'Cloudflare API token: ' MAIL_RELAY_TOKEN
printf '\n'
printf '%s\n' "$MAIL_RELAY_TOKEN" \
  > /etc/postfix/cloudflare-email-api-token
unset MAIL_RELAY_TOKEN

chown root:cfmail \
  /etc/postfix/cloudflare-email-relay.conf \
  /etc/postfix/cloudflare-email-api-token

chmod 0640 \
  /etc/postfix/cloudflare-email-relay.conf \
  /etc/postfix/cloudflare-email-api-token

確認のためにcatheadでトークンを表示しないようにします。漏えいした場合は、ファイルを消すだけでは足りません。Cloudflare側でトークンを失効し、作り直します。

API通信なしで設定を検査する

runuser -u cfmail -- \
  /usr/bin/python3 \
  /usr/local/libexec/postfix/cloudflare_email_relay.py \
  --check-config

次の表示と終了コード0を確認します。

configuration_ok

これは設定ファイルを読めるかどうかの試験です。APIトークンの有効性、権限、Destination Addressの検証状態は、あとで実際に1通送って確認します。

Postfixを中継専用に設定する

以下は専用LXCを前提にした設定です。既存のメールサーバーへmain.cfを丸ごと貼り付けると、現在の配送を壊す可能性があります。

許可する宛先を1件だけ登録する

/etc/postfix/relay_recipientsへ次の1行を保存します。

monitoring@example.com    OK

LMDBを作成し、検索できるか確認します。

postmap lmdb:/etc/postfix/relay_recipients

postmap -q \
  'monitoring@example.com' \
  lmdb:/etc/postfix/relay_recipients

結果がOKなら成功です。

postmap -qは、検索するキーだけでなく、最後にlmdb:/etc/postfix/relay_recipientsまで指定する必要があります。ここを忘れるとusageエラーになります。

master.cfへpipeサービスを追加する

/etc/postfix/master.cfの末尾へ追加します。

cfapi    unix  -       n       n       -       1       pipe
  user=cfmail:cfmail argv=/usr/bin/python3 /usr/local/libexec/postfix/cloudflare_email_relay.py --recipient ${recipient}

2行目の先頭には空白が必要です。${recipient}はPostfixがenvelope recipientへ展開します。

main.cfを設定する

/etc/postfix/main.cfの主要部分です。

myhostname = mail-gateway.example.com
mydomain = example.com
myorigin = $mydomain

inet_interfaces = 127.0.0.1, 192.168.10.50
inet_protocols = ipv4

mydestination = $myhostname, localhost.localdomain, localhost
mynetworks = 127.0.0.0/8, 192.168.10.0/24

relay_domains = example.com
relay_recipient_maps = lmdb:/etc/postfix/relay_recipients

smtpd_client_restrictions = permit_mynetworks, reject
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
smtpd_recipient_restrictions = check_recipient_access lmdb:/etc/postfix/relay_recipients, reject

smtpd_sasl_auth_enable = no
disable_vrfy_command = yes
smtpd_helo_required = yes
message_size_limit = 5242880
authorized_submit_users =

relay_transport = cfapi:
cfapi_destination_recipient_limit = 1
cfapi_destination_concurrency_limit = 1
cfapi_time_limit = 60s

relayhost =
transport_maps =
sender_dependent_relayhost_maps =
virtual_alias_domains =
virtual_alias_maps =
virtual_mailbox_domains =

smtpd_milters =
non_smtpd_milters =
milter_default_action = accept

mydestination$mydomainを入れないのがポイントです。example.com宛をローカルメールボックスへ配送せず、relay domainとしてcfapiへ渡します。

mynetworksへLANの/24全体を入れると、そのセグメントの端末はPostfixへ接続できます。ゲストWi-Fiや信頼できない端末も同じLANにいる場合は、Proxmox・Zabbixなどの送信元IPを個別に列挙したほうが安全です。

設定を検査して起動する

postfix check
postconf -M cfapi/unix

systemctl enable --now rsyslog postfix
systemctl restart postfix

systemctl is-active rsyslog postfix
ss -ltnp 'sport = :25'

TCP 25を許可するFirewallルールは、Proxmox Firewall、LXC Firewall、AlmaLinuxのfirewalldなど、有効な層すべてで確認します。許可元はLAN内の監視システムだけにし、WANへは公開しません。

この手順はSELinux Enforcingでは動作確認していません。Enforcing環境では、PostfixのpipeからPythonを実行し、外部HTTPSへ接続する部分がポリシーに触れる可能性があります。AVC拒否を確認し、必要な権限だけを個別に設計してください。SELinuxを無効化すること自体を解決策にはしません。

ローカルから1通送る

swaksでPostfixへ試験メールを投入します。

swaks \
  --server 127.0.0.1 \
  --port 25 \
  --helo mail-gateway.example.com \
  --from monitoring@example.com \
  --to monitoring@example.com \
  --header 'Subject: Home lab alert test' \
  --body 'Postfix to Cloudflare REST API to Gmail'

swaksがHELOを自動判定できない環境では、--heloを付けないと送信前に止まります。

ログとキューを確認します。

tail -n 100 /var/log/maillog
postqueue -p

成功時は、Postfixのログに次の要素が出ます。

relay=cfapi
dsn=2.0.0
status=sent (delivered via cfapi service)

キューが空になり、Gmailへ件名・本文・差出人が期待どおり届けば、基本経路は完成です。

許可していない宛先も試す

次は、登録していないアドレスをToへ指定します。

swaks \
  --server 127.0.0.1 \
  --port 25 \
  --helo mail-gateway.example.com \
  --from monitoring@example.com \
  --to other@example.com \
  --header 'Subject: This mail must be rejected' \
  --body 'recipient allowlist test'

RCPT TOの段階で554 5.7.1などの5xx応答になれば、宛先制限が働いています。これは障害ではなく、意図した拒否です。

Proxmoxから送る

ProxmoxのNotification TargetでSMTPを選び、次の値を設定します。バージョンによって画面名や配置は多少変わります。

項目
Server 192.168.10.50
Port 25
Connection security なし
Authentication なし
From monitoring@example.com
Recipient monitoring@example.com

GmailのSMTPを選ぶのではなく、LAN内のPostfixを指定します。ToもGmail実アドレスではなく、Postfixが許可している監視用アドレスです。

設定後にTest通知を実行し、GmailとPostfixログの両方を確認します。

Proxmoxの通知機能は、公式のSMTP notification targetも参照してください。

Zabbixから送る

ZabbixではAlertsMedia typesからEmailを開き、次のように設定します。

項目
Email provider Generic SMTP
SMTP server 192.168.10.50
SMTP server port 25
Email monitoring@example.com
SMTP helo ZabbixサーバーのFQDN
Connection security なし
Authentication なし
Message format HTMLまたはPlain text

ユーザー側のMediaにあるSend tomonitoring@example.comへそろえます。その後、Media typeのTestを実行します。

Zabbixは、Media typeを作っただけでは本番通知を送りません。対象ユーザーのMedia、Trigger action、Problem operation、Recovery operationも確認します。詳しい項目はZabbixのEmail media typeにまとまっています。

Zabbixメールを少し読みやすくする

Message formatをHTMLにしているのに、本文へ改行だけを書いた場合、Gmail上では1行につながって見えることがあります。HTMLを選ぶなら、<p><br><table>などのタグを使います。タグを書きたくなければPlain textを選ぶほうが素直です。

たとえばProblem通知は、次のようにすると内容を拾いやすくなります。

件名:

[Zabbix][{EVENT.SEVERITY}] {HOST.NAME}: {EVENT.NAME}

本文:

<h2 style="color:#c62828;">障害を検知しました</h2>
<table style="border-collapse:collapse;">
  <tr><th style="text-align:left;padding:4px 12px 4px 0;">Host</th><td>{HOST.NAME}</td></tr>
  <tr><th style="text-align:left;padding:4px 12px 4px 0;">Severity</th><td>{EVENT.SEVERITY}</td></tr>
  <tr><th style="text-align:left;padding:4px 12px 4px 0;">Problem</th><td>{EVENT.NAME}</td></tr>
  <tr><th style="text-align:left;padding:4px 12px 4px 0;">Started</th><td>{EVENT.DATE} {EVENT.TIME}</td></tr>
  <tr><th style="text-align:left;padding:4px 12px 4px 0;">Event ID</th><td>{EVENT.ID}</td></tr>
</table>
<p>{EVENT.OPDATA}</p>
<p><a href="{TRIGGER.URL}">Zabbixで確認する</a></p>

Recovery通知には{EVENT.RECOVERY.DATE}{EVENT.RECOVERY.TIME}{EVENT.DURATION}を入れておくと、「いつ直ったか」「何分続いたか」がひと目で分かります。

付録: Thunderbirdで受信したい場合

このLXCにはメールボックスがないので、Thunderbirdの受信サーバーへ192.168.10.50を指定しても受信できません。

受信はGmailのIMAPを使います。

項目
Protocol IMAP
Host imap.gmail.com
Port 993
Connection security SSL/TLS
Authentication OAuth2
Username your-account@gmail.com

LAN内から監視用アドレスへ試験送信したい場合だけ、Thunderbirdの送信サーバーへ192.168.10.50:25、暗号化なし、認証なしを指定できます。これは信頼できるLAN内限定です。普段の個人メールを送るための汎用SMTPとしては使いません。

実際にハマったところ

postmap -qの引数が足りなかった

これは失敗します。

postmap -q 'monitoring@example.com'

検索対象のマップまで書きます。

postmap -q \
  'monitoring@example.com' \
  lmdb:/etc/postfix/relay_recipients

また、lmdb:/etc/postfix/relay_recipientsは単独で実行するコマンドではありません。

swaksにHELOが必要だった

LXC内でホスト名を自動判定できず、Helo string requiredで止まりました。--helo mail-gateway.example.comを明示するとSMTP試験を続行できました。

外向きTCP 25がタイムアウトした

Postfixのキューへ入るところまでは正常でも、Cloudflare MXへの配送でタイムアウトしました。キューだけ見て「Postfixが壊れている」と判断せず、ログにある次の配送先とポートを確認するのが大事でした。

最終構成はHTTPSのTCP 443なので、この制限を受けません。

OpenDKIMで時間を使ったが、最終構成では不要だった

秘密鍵形式やDNS公開鍵の確認まで進めましたが、REST API経路ではCloudflare側が送信認証を担当します。直接SMTP構成の設定を、そのままAPI構成へ持ち込まないほうが整理しやすいです。

554 5.7.1が返った

許可していない宛先をテストしたときの554 5.7.1は、relay_recipientscheck_recipient_accessが効いた結果でした。ログのエラー番号だけでなく、何を試した直後かも合わせて見ます。

セキュリティと運用上の注意

  • TCP 25をインターネットへ公開しない
  • 可能ならmynetworksをProxmox・Zabbixなどの送信元IPだけに絞る
  • Postfixの宛先マップとPythonのaccepted_recipientで二重に制限する
  • APIのToとFromを固定し、元メールのヘッダーを信用しない
  • APIトークンはroot:cfmail 0640とし、コードへ埋め込まない
  • トークン、件名、本文をログへ出さない
  • Postfixキューの滞留も監視する
  • キュー監視の通知先は、できればメール以外の経路も用意する
  • 構築後にLXCを再起動し、自動起動と再配送を確認する

Cloudflareの文書では、検証済み宛先への送信は通常の月間・日次送信枠に算入されません。一方で、メールサイズなどの技術的な上限はあります。CloudflareのLimitsでは、一般送信は5MiB、検証済み宛先は25MiBとされています。本構成では、Postfixと中継プログラムの上限を5MiBにそろえました。

最後に、次の受け入れ試験まで通しておくと安心です。

  1. ローカルswaks送信
  2. 未許可宛先の拒否
  3. ProxmoxのTest通知
  4. ZabbixのMedia Test
  5. ZabbixのProblem通知とRecovery通知
  6. LXC再起動後の再送信
  7. postqueue -pが空であること

まとめ

自宅鯖の通知をGmailへ届けるために、インターネットへメールサーバーを公開する必要はありませんでした。

LAN内ではProxmoxやZabbixが使いやすいSMTPを残し、外向きだけHTTPSへ変換する。宛先は自分の検証済みGmailに固定する。この割り切りで、外向きTCP 25の制限と、汎用メールリレーを持つ怖さの両方を避けられました。

Postfixは配送キューとLAN内SMTPの受け口に専念し、Cloudflareがインターネット側の配送を担当します。自宅鯖の監視通知には、かなり相性のいい構成でした。

参考資料

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?