どちらの接続方式も同じメッセージを同じリレーアカウントへ運び、違いは TLS がどこで始まるかであって、何が送られるかではありません。一方は平文で開いて接続内で昇格し、もう一方は最初のバイトから暗号化されます。この一つの違いが、厳格なリレーポリシーが何を受け入れるか、中間装置が何を観察できるか、そしてどのエラーメッセージを半日かけて読むことになるかを決めます。プライベートネットワーク上のアプリケーションが社内の送信サービスへメッセージを渡すとき、私がこの二つをどう選ぶかを示します。

二つの方式が実際に分かれるところ

どちらの場合も仕事は同じです。承認されたアカウントとして認証し、許可された送信者を名乗り、受信者を列挙し、リレーが責任を引き受けるのを待ちます。変わるのは操作の順序です。587 番ポートは平文の SMTP セッションとして始まり、EHLO 応答で STARTTLS を通知し、クライアントが求めるときだけ昇格します。465 番ポートは、SMTP コマンドを一つ発する前に TLS ハンドシェイクを期待します。

送信とリレーは別の仕事であり、ポート番号は入れ替えのきく習慣ではありません。RFC 6409 はメッセージ送信を独立したサービスとして記述し、RFC 8314 は現代のリレーの大半が 465 番で公開する暗黙的 TLS の方式を扱っています。リレーがどちらかを文書化しているなら、その文書は以前の勤務先で身につけた好みより優先されます。

587 番の STARTTLS が与えるもの

昇格は SMTP の会話の中で起きるため、リレーはそれを通知でき、クライアントはそれなしでの続行を拒否できます。Python では、サーバーが拡張を提示しないときに starttls() が例外を投げ、これによりダウングレードの試みは静かな平文ログインではなく目に見える失敗になります。この振る舞いがこの方式の主な根拠であり、偶然ではなく意図して頼る価値があります。

露出の窓は小さいものの現実です。最初の EHLO 交換は読むことができ、昇格前には資格情報を運ぶものはありません。アプリケーションのサブネットと社内リレーの間のプライベートな経路では、その窓は通常受け入れられます。自分で制御しない経路では、まさにそれが一部のチームがもう一方の方式に固執する理由です。

STARTTLS の機能を削ぎ落とす傍受装置は、フォールバックではなく送信の失敗を生みます。それが望む結果であり、経路上で何かが SMTP を書き換えていると突き止める最速の方法でもあります。

465 番の暗黙的 TLS が変えるもの

平文の段階は一切ありません。クライアントはソケットが開くと同時に TLS ハンドシェイクを行い、証明書を自身のトラストストアに対して検証し、その後に初めて EHLO、AUTH、メッセージを交換します。交渉の起点となる平文の状態が存在しないため、セッションをダウングレードすることはできず、これにより傍受による障害の一つの種類が丸ごと消えます。

引き換えになるのは診断しやすさです。465 番で平文 SMTP を返すリレーや、誤った証明書名で TLS を終端するロードバランサーは、ハンドシェイクで失敗し、メールのエラーではなく SSL エラーを返します。それが起きたとき、問題はアプリケーションより下にあり、メッセージをどう組み立てても直りません。

一つの送信関数、二つのトランスポート

コードは方式の間でほとんど変わりません。だからこそ選択はライブラリの都合ではなく、リレーのポリシーから行うべきです。まず設定をエクスポートし、次に設定されたポートからトランスポートを選ぶ単一のスクリプトを実行します。

export SMTP_HOST=smtp.relay.internal
export SMTP_PORT=587
export SMTP_USER=svc-notify
export [email protected]
export [email protected]
export SMTP_PASSWORD="$(cat /run/secrets/smtp_password)"
import os, smtplib, ssl
from email.message import EmailMessage

ctx = ssl.create_default_context()
msg = EmailMessage()
msg["From"] = os.environ["MAIL_FROM"]
msg["To"] = os.environ["MAIL_TEST_TO"]
msg["Subject"] = "relay submission test"
msg.set_content("One authorized test message from the app.")

host = os.environ["SMTP_HOST"]
port = int(os.environ.get("SMTP_PORT", "587"))

if port == 465:
    client = smtplib.SMTP_SSL(host, port, context=ctx, timeout=15)
else:
    client = smtplib.SMTP(host, port, timeout=15)
    client.ehlo()
    client.starttls(context=ctx)
    client.ehlo()

with client:
    client.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
    refused = client.send_message(msg)

print("refused:", refused)

create_default_context() は連鎖とホスト名を検証するため、プライベート CA は検証しないコンテキストで回避するのではなく、トラストストアにインストールする必要があります。send_message() はリレーが拒否した受信者の辞書を返します。空の辞書はすべて受け入れたことを意味します。十五秒のタイムアウトは出発点であり、あなたのネットワーク向けに調整された値ではありません。

パスワードはイメージの外、ログの外に置きます。実行時にマウントして環境へ読み込む秘密は問題ありませんが、環境をログ行に echo するのは問題ですし、AUTH 交換のデバッグトレースも同様です。

習慣ではなく、リレーのポリシーと経路で選ぶ

三つの条件が決めます。第一に、リレーが実際に何を通知するか。587 番で STARTTLS だけを待ち受けるサービスには、どれほど暗黙的 TLS を好んでも 465 番では到達できません。第二に、アプリケーションとリレーの間の経路が、読めてしまう EHLO 交換を受け入れてよいほど信頼できるか。第三に、途中の何かが SMTP を書き換えたりプロキシしたりするか。これがダウングレードの危険を理論上のものにするか現実のものにするかを決めます。

リレーが両方を提供するとき、私は無人で動くサービスには 465 番を、人が最初の配備を見守るサービスには 587 番を選びます。失敗のメッセージが指す層が違うからというだけの理由です。この好みは私のものであり、ポリシーではありません。

どちらを選んでも、ポート、信頼の材料、送信者ドメインを配備と一緒に記録してください。そのうえで承認済みのテストメッセージを一つ送り、リレーが受け入れたことを確認します。送信の結果と、後日の配信レポートは別々の記録として保ってください。リレーへの受け渡しの成功はアプリケーションの責任の終わりであり、メッセージが受信箱に届いた証拠ではありません。

関連記事