ガイド · Python 3のSMTP

プロダクトチームはPython 3のSMTP送信をどう安全に実装すべきですか?

Python 3のSMTP送信は、ブラウザやユーザーが制御するコードではなく、認可されたサーバー側のワーカーの背後で実装します。メッセージはEmailMessageで組み立て、エンベロープの受信者は表示用のヘッダーとは分けて扱い、検証付きのSSLコンテキストを作成し、接続のタイムアウトに有限の値を設定します。接続開始時からTLSを使う場合はSMTP_SSLを、明示的にアップグレードする場合はSMTP.starttls()の後にEHLOを使います。認証情報はシークレットマネージャーから読み込み、send_message()を呼び出し、拒否された受信者の結果を確認して、試行の正確な結果を保存してください。再試行は一時的な失敗に限って上限付きのバックオフで行い、SMTPで受け付けられたことを受信トレイへの到達の証拠とみなしてはいけません。

認可されたメール操作を1つ定義する

アカウントの確認、領収書、要求されたアラート、セキュリティ通知など、承認されたプロダクトのイベントから始めます。SMTP接続を開く前に、永続的な送信ジョブを保存してください。このジョブには、安定した業務イベントのキー、テナント、メッセージの種類、テンプレートのリビジョン、承認されたエンベロープの送信者と受信者、表示上のFromのID、同意または必要性の根拠、現時点でのサプレッションの結果を含めます。ブラウザ、モバイル、テンプレート、ユーザーの入力によって、SMTPホスト、認証情報、エンベロープの送信者、任意のヘッダー、制限のない受信者を選べてはいけません。呼び出し元とテナントを認可し、アドレスを検証し、受信者数と添付ファイル数に上限を設け、改行文字によるインジェクションを防ぎます。ジョブの取得は一度だけにし、追記専用の試行履歴を残してください。Pythonのsmtplibはプロトコルのクライアントであり、業務上の冪等性、テナントの分離、同意、サプレッション、永続的なキューは提供しません。これらの制御は、それを取り巻くアプリケーションが担います。

EmailMessageで構造化されたメッセージを組み立てる

ヘッダーやMIMEの文字列を連結するのではなく、email.message.EmailMessageを使います。検証済みの値からFrom、To、Subject、安定したアプリケーション用の相関ヘッダーを設定し、プレーンテキストにはset_contentを、必要に応じてHTMLパートにはadd_alternativeを呼び出し、add_attachmentは明示的にサポートするファイル形式とサイズに限って使います。テキストとHTMLは、承認済みの同じテンプレートのリビジョンから生成しましょう。信頼できない値は出力のコンテキストに合わせてエスケープし、ユーザーの生のHTMLをそのままレンダリングしないでください。秘密情報、アクセストークン、不要な個人データ、内部のデータベースキーを、ヘッダー、件名、トラッキング用のフィールド、添付ファイル名に含めてはいけません。emailパッケージはポリシーに従ってシリアライズし、フラット化の際にMIMEの境界を生成することがあります。そのため、後の完全性の確認が正確なバイト列に依存する場合は、最終的にシリアライズされた表現に対して署名やハッシュを行ってください。SMTPのエンベロープは分けて扱います。表示上のToやCcのヘッダーは読み手に向けた情報であり、RCPT TOコマンドを制御するのは転送用の受信者リストです。

暗黙的TLSかSTARTTLSかを意図的に選ぶ

サーバーが接続の開始時からTLSを要求する場合はSMTP_SSLを使います。平文の接続にSMTPを使うのは、ドキュメント化されたサーバーのワークフローで直ちにSTARTTLSでアップグレードすることが求められている場合に限ります。Pythonのsmtplibのドキュメントによると、starttlsは以降のSMTPコマンドをTLSの中に置くもので、その後クライアントはehloを再度呼び出すべきとされています。必要なTLSへのアップグレードの前に認証してはいけません。証明書の検証とホスト名の確認に安全なクライアントのデフォルト設定が使われるよう、コンテキストはssl.create_default_contextで作成し、想定するサーバーのホスト名はライブラリの通常の接続手順で渡します。暗号化が必要な場合、STARTTLSに対応していない、証明書の検証に失敗した、ホスト名が一致しない、TLSのネゴシエーションに失敗した、といった状況は即座に停止すべき条件として扱ってください。本番環境を動かすために検証を無効にしたり、検証しないコンテキストで代用したりしてはいけません。ホップ間のTLSが保護するのはSMTP接続であり、保存されたメッセージのコンテンツ、プロバイダーでの処理、受信側での保存、最終的なメールボックスではありません。

認証情報はサーバー側に置き、スコープを絞る

SMTPのユーザー名、パスワード、トークンは、実行時に管理されたシークレットサービスから読み込みます。ソース管理、Dockerのレイヤー、Gitにコミットされた設定、URL、コマンドライン引数、デバッグ出力、アナリティクス、例外レポート、テストのスナップショット、ノートブック、チケット、プロンプトに含めてはいけません。アカウント全体の管理者用の秘密情報よりも、1つの環境、送信ドメイン、許可されたワークロードにスコープを絞った認証情報を優先してください。本番と開発やCIは分けます。ローテーションは日常業務にしましょう。代替の認証情報を発行し、ワーカーを更新し、管理された配信テストを実行し、認証と結果の証拠を確認してから、古い認証情報を失効させます。秘密情報へのアクセスは送信プロセスに限定し、管理者による読み取りは監査してください。Pythonのloginメソッドはサーバーが提示する認証方式を試しますが、そのサーバー、接続のセキュリティ、アカウント、認証方式が許容できるかどうかは、引き続きアプリケーションが判断する必要があります。認証の失敗が繰り返される場合は、パスワードをすばやく再試行するのではなく、そのコホートを一時停止して調査を始めるべきです。

明示的なタイムアウトと接続の寿命の上限を設ける

接続やブロッキングする操作がワーカーを無期限に占有しないよう、SMTPまたはSMTP_SSLには有限のタイムアウトを渡します。ソケットのタイムアウト1つだけではキューの滞留時間を十分に制御できないため、外側にジョブの期限とキャンセルのポリシーも適用してください。アクセスを直列化し、状態が安全であることを確認できていない限り、共有のSMTPオブジェクトを並行するタスク間で使い回してはいけません。シンプルな設計では、上限を設けたバッチごとに接続を1つ開き、サーバーに挨拶し、必要ならTLSを確立し、認証し、少数のメッセージを送信してからquitを呼び出し、エラーや寿命の上限に達した接続は破棄します。再利用すればオーバーヘッドを減らせますが、サーバーからの切断、タイムアウト、中途半端な状態の後にあいまいさが増します。接続あたりのメッセージ数に上限を設け、再接続は意図的に行いましょう。接続のレイテンシー、TLSのネゴシエーション、認証、コマンドのレイテンシー、サーバーからの切断、ジョブの滞留時間を、認証情報やメッセージのコンテンツをログに残さずに監視してください。SMTPサーバーは、Pythonとは無関係に変わりうる制限を課すことがあります。

メッセージを1通送信し、一部の受信者の結果を保持する

SMTP.sendmailは転送用のエンベロープにfrom_addrとto_addrsを使い、メッセージのヘッダーは書き換えません。SMTP.send_messageはEmailMessageをシリアライズし、明示的なエンベロープの値が渡されない場合はデフォルト値を導出します。本番のコードでは、Bccの扱いとテナントの認可があいまいにならないよう、承認されたエンベロープの送信者と受信者リストを明示的に渡してください。Pythonのドキュメントによると、sendmailは少なくとも1人の受信者が受け付けられれば正常に戻り、拒否された受信者ごとの辞書を返します。つまり、例外が発生しなかったことは、すべての受信者で成功したことと同じではありません。受け付けられた受信者と拒否された受信者の範囲は、ステータスコードと機密情報を除いた診断情報とともに、別々に保存してください。一部の受信者だけが拒否された場合に、受け付けられた受信者に再試行してはいけません。共有のメッセージ送信試行は保持しつつ、各受信者を独立して認可された結果として扱います。後のDATA段階での例外はRCPTでの拒否とは異なるため、個別に分類する必要があります。

例外を段階と恒久性で分類する

smtplibの例外は明示的に処理し、SMTPのコードと機密情報を除いたサーバーのメッセージを保持します。SMTPConnectErrorやタイムアウトは一時的なこともありますが、ホスト、ポート、ファイアウォールの誤りや障害を示している場合もあります。STARTTLSやSMTPUTF8の後にSMTPNotSupportedErrorが発生した場合は、その機能を必要とする構成を停止すべきです。SMTPAuthenticationErrorには、やみくもな再試行ではなく、認証情報、アカウント、認証方式、TLSの調査が必要です。SMTPSenderRefusedとSMTPRecipientsRefusedには、IDや受信者の範囲に応じた判断が必要です。SMTPDataErrorはDATAに対する予期しない応答を表し、拡張ステータスに応じて、コンテンツ、ポリシー、クォータ、あるいは受信側の一時的な挙動を示している可能性があります。プロバイダー固有のドキュメントを尊重しつつ、4xxの応答は上限付きの再試行の候補、5xxはその試行にとって恒久的なものとして分類します。指数バックオフ、ジッター、試行回数とキューの滞留時間の上限、デッドレターの状態を使ってください。サプレッション、苦情、配信停止、認可の取り消し、無効な受信者の証拠があった後に再試行してはいけません。

あいまいな送信結果を突き合わせる

クライアントがメッセージのデータを送信した後、サーバーの最終応答を確認する前にネットワークのタイムアウトや切断が起きた場合、結果はあいまいです。Pythonが例外を発生させても、サーバーが責任を引き受けている可能性があります。すぐに新たな論理的な送信を作成してはいけません。その試行を不明としてマークし、安定したイベントとトレースの識別子を保持したうえで、可能であればプロバイダーのログや後から届く配信イベントを照会してください。SMTPサービスに冪等性の仕組みや検索可能な相関情報がない場合は、メッセージの種類、経過時間、重複による害、ユーザー体験に基づいてプロダクトとしての判断を定めます。セキュリティアラートやパスワードリセットのメッセージは、領収書や金融関連の通知とは重複のリスクが異なります。元の試行と再試行へのリンクは台帳に残しておきましょう。SMTPはエンドツーエンドでExactly-Onceの配信を提供しないため、それを主張してはいけません。この分岐は、DATAの受け付け前後を含むプロトコルの各段階で接続を切断する、管理されたテスト用サーバーでテストしてください。

SMTPでの受け付けと、配信やエンゲージメントを区別する

send_messageの呼び出しが成功したということは、Pythonのドキュメントに記載された意味の範囲で、観察したSMTPの段階で少なくとも1人の受信者が受け付けられたことを意味します。すべての受信者が受け付けられたこと、宛先サーバーがその後もメッセージを保持したこと、メッセージが受信トレイのフォルダに届いたこと、人が読んだことを証明するものではありません。プロバイダーへの送信、受信サーバーによる受け付け、一時的または恒久的な失敗、後から届くバウンス、苦情、配信停止、メールボックスでの振り分け、エンゲージメントは、それぞれ別の証拠としてモデル化してください。利用可能であれば認証済みのプロバイダーのイベントを取り込み、重複を排除し、発生時刻と処理時刻を分けて保持します。恒久的なバウンス、苦情、配信停止は、以後の送信の直前に適用します。開封やクリックは転送の証拠ではなく、プライバシー保護技術の影響を受けることもあります。テナントを混在させない安全なコホート、テンプレートのリビジョン、送信ドメイン、ステータスの分類、時間ごとに、プライバシーに配慮して最小化した集計指標を保持しましょう。拒否の急増、結果が不明の件数、キューの滞留時間、TLSの失敗、認証の失敗、受信者数の異常な広がりにはアラートを設定してください。

実際の顧客にメールを送らずにローカルでテストする

メッセージ構築、ヘッダーインジェクションの拒否、受信者の認可、Bccの削除、プレーンテキスト版とHTML版、Unicode処理、添付ファイルの上限、サプレッションチェックをユニットテストします。管理されたローカルSMTPテストサーバーまたはプロトコルフィクスチャを使用して、グリーティングの失敗、STARTTLSの欠如、証明書の失敗、認証エラー、RCPTの部分的な受け付け、DATAの4xxおよび5xx応答、切断、遅延応答をシミュレートしてください。本番相当の秘密情報または顧客コンテンツに、非推奨の未認証デバッグサービスを使用しないでください。統合テストでは、明示的なクォータとクリーンアップを伴う専用アカウントと管理された受信者を使用してください。生の受信メッセージ、認証結果、表示ヘッダー、返信動作、イベント相関を検証します。フィクスチャとログ全体で秘密情報スキャンを実行してください。

SendHQの位置付け

SendHQは、検証済みドメイン送信、配信イベント、サプレッションのためのワークスペース単位のメールAPIを文書化しています。このガイドはPythonの標準ライブラリSMTPクライアントを対象としています。現在の連携方法とAPI契約については、SendHQのドキュメントを参照してください。

よくある質問

PythonのSMTPの認証情報をクライアントのコードに置いてもよいですか?

いいえ。環境とワークロードの範囲を狭く絞り、アクセスを監査し、定期的にローテーションし、ログに残さない形で、サーバー側のシークレットマネージャーに保存してください。

PythonでSMTP_SSLを使うべきなのはどんなときですか?

接続の開始時からTLSが必要な場合はSMTP_SSLを使います。SMTPとstarttlsの組み合わせは、ドキュメント化された明示的なアップグレードのワークフローで、失敗時には安全側に倒して停止する場合に限って使ってください。

starttlsの後にEHLOを再度呼び出すべきですか?

はい。Pythonのsmtplibのドキュメントでは、保護された接続の中で機能を改めて検出するため、starttlsの後にehloを再度呼び出すよう記載されています。

send_messageが成功すれば、すべての受信者が受け付けられたということですか?

いいえ。Pythonは少なくとも1人の受信者が受け付けられれば正常に戻り、拒否された受信者は別途返します。受信者ごとの結果は独立して保存し、処理してください。

SMTPAuthenticationErrorが発生した後はどうすべきですか?

影響を受けた構成を一時停止し、TLS、サーバー、アカウント、秘密情報、提示されている認証方式を調べてください。やみくもに認証情報を再試行すると、アカウントのロックアウトや侵害の兆候を増幅させかねません。

SMTPDataErrorはすべて再試行すべきですか?

いいえ。正確なステータスと診断情報を保持したうえで、一時的な4xxの状況と、ポリシー、コンテンツ、クォータ、設定に関する恒久的な5xxの失敗を区別してください。

SMTPで受け付けられたかどうかで、受信トレイへの到達が決まりますか?

いいえ。それは範囲の限られた転送の証拠です。その後のリレー、受信側のフィルタリング、バウンス、メールボックスのルール、フォルダへの振り分け、人によるエンゲージメントは別の結果です。

このガイドはSendHQ固有の連携を扱っていますか?

いいえ。Pythonの標準ライブラリSMTPクライアントを対象としています。現在の連携方法とAPI契約については、SendHQのドキュメントを参照してください。

出典