HOWTO · Python

Python を使用して複数のアドレスにメールを送信する

この記事では、Python を使用して複数のアドレスにメールを送信する方法を示します。

このページの内容

同じメッセージを複数のアドレスへ送信する処理は、Python の標準ライブラリだけで実現できます。smtplib が SMTP セッションを管理し、email.message の EmailMessage がヘッダーと本文を保持します。受信者に見せるすべてのアドレスは To または Cc ヘッダーに、非表示にするアドレスは Bcc に指定し、send_message を 1 回呼び出すだけです。各メールボックスへのコピー配信は SMTP サーバーが処理します。

複数のメール受信者の仕組み

メールには 2 つの独立した受信者リストがあります。この違いを理解すると、複数受信者で起きがちな予期しない動作のほとんどを説明できます。

  • エンベロープ受信者は、クライアントが RCPT TO コマンドで SMTP サーバーへ渡すアドレスです。配信は完全にこのリストに基づいて行われます。
  • ヘッダー受信者は、メッセージ内の To、Cc、Bcc 行です。これらはメールクライアント向けの表示情報であり、サーバーはこれらを使って配送先を決めません。

To と Cc は配信上は同じように扱われ、どの受信者にも両方のリストが見えます。Bcc の受信者もほかのアドレスと同様に配信されますが、send_message は送信前に Bcc ヘッダーを取り除くため、誰がブラインドコピーされたかは誰にも表示されません。

server.send_message(message) を呼び出すと、smtplib は To、Cc、Bcc の各ヘッダーを読み取り、アドレスごとに 1 つの RCPT TO を送信します。明示的なリストを to_addrs に渡すこともでき、その場合はヘッダーより優先して配信先が決まります。表示される To 行と実際の配信リストを変えたい場合、たとえば非公開の配布リストを使う場合に便利です。

各アドレスには、バイト列がまったく同じメッセージのコピーが届きます。受信者ごとに内容を変える必要がある場合は、受信者ごとに別の EmailMessage を作成して送信します。このパターンは最後のセクションで説明します。

SMTP サーバーと安全な認証を選択する

実際のメールプロバイダーに接続するには、プロバイダーの SMTP ホスト名とポート番号、アカウント名、認証情報の 3 つが必要です。プロバイダーのドキュメントが最も確実な情報源です。要件は変わるため、コピーしたスニペットを信用せず、最新の値を確認してください。

暗号化された接続には、一般的に次の 2 種類があります。

  • 最初のバイトから暗黙的に TLS を使う方式。通常はポート 465 で、smtplib.SMTP_SSL(host, port) を使用します。
  • 平文接続を STARTTLS で暗号化へアップグレードする方式。通常はポート 587 で、smtplib.SMTP(host, port) を使用し、server.login(...) の前に server.starttls() を呼び出します。

多くのプロバイダーは、SMTP で通常のアカウントパスワードを受け付けなくなっています。アプリパスワード、パスワードとして使う API キー、または OAuth2 のいずれかを要求されるため、プロバイダーがサポートする方法を生成・設定してください。認証情報はソースコードの外に保存します。下の 2 番目の例が使う環境変数は、最も簡単な出発点です。実際の認証情報をリポジトリにコミットしないでください。

複数の受信者向けに EmailMessage を作成する

EmailMessage は、順序付きのヘッダー辞書と本文を組み合わせたもののように振る舞います。まずヘッダーを設定します。

  • message["From"] — 送信者です。多くのプロバイダーでは、認証済みアカウントまたは確認済みエイリアスと一致させる必要があります。不一致は拒否されるよくある原因です。
  • message["To"] — カンマ区切りの文字列です。", ".join(recipients) で Python のリストを結合すると、プログラムを拡張しやすい状態に保てます。
  • message["Cc"] と message["Bcc"] — 省略可能で、同じカンマ区切り形式を使います。
  • message["Subject"] — 件名です。

message.set_content("...") はプレーンテキストの本文を設定し、Content-Type: text/plain も自動で設定します。HTML 版を付ける場合は、set_content の後に message.add_alternative(html, subtype="html") を呼び出し、テキストを優先するクライアント用のフォールバックを残します。message.add_attachment(...) を使うと、PDF や画像などのファイルを添付できます。

smtplib でメッセージを送信する

send_message は作成済みのメッセージを 1 回送信し、各エンベロープ受信者への展開はサーバーに任せます。SMTP オブジェクトをコンテキストマネージャーとして使うと、with ブロックの終了時に QUIT でセッションが正常に閉じられます。

次の完全なスクリプトを実行してください。localhost 上で小さな SMTP テストサーバーを起動し、smtplib を使って、To の 2 件と Cc、Bcc のアドレスへ 1 つの EmailMessage を送信し、サーバーが記録したすべてのエンベロープ受信者を表示します。

このセクションのコードと表示されるラベルや出力は英語のままです。

import smtplib
import socket
import threading
from email.message import EmailMessage

recipients_seen = []

def run_server(sock):
    conn, _ = sock.accept()
    conn.sendall(b"220 localhost ESMTP test server\r\n")
    data_mode = False
    buffer = b""
    while True:
        chunk = conn.recv(1024)
        if not chunk:
            return
        buffer += chunk
        while b"\r\n" in buffer:
            line, buffer = buffer.split(b"\r\n", 1)
            if data_mode:
                if line == b".":
                    data_mode = False
                    conn.sendall(b"250 Message stored\r\n")
                continue
            command = line.decode().split(" ", 1)[0].upper()
            if command == "RCPT":
                address = line.decode().rsplit(":", 1)[-1].strip().strip("<>")
                recipients_seen.append(address)
                conn.sendall(b"250 OK\r\n")
            elif command == "DATA":
                data_mode = True
                conn.sendall(b"354 End data with <CR><LF>.<CR><LF>\r\n")
            elif command == "QUIT":
                conn.sendall(b"221 Bye\r\n")
                return
            else:
                conn.sendall(b"250 OK\r\n")

listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(1)
port = listener.getsockname()[1]
thread = threading.Thread(target=run_server, args=(listener,))
thread.start()

message = EmailMessage()
message["Subject"] = "Team update"
message["From"] = "sender@example.com"
message["To"] = "alice@example.com, bob@example.com"
message["Cc"] = "carol@example.com"
message["Bcc"] = "dave@example.com"
message.set_content("Here is this week's team update.")

with smtplib.SMTP("127.0.0.1", port) as server:
    server.send_message(message)

thread.join()
listener.close()
print("Delivered to these envelope recipients:")
for recipient in recipients_seen:
    print(recipient)
Delivered to these envelope recipients:
alice@example.com
bob@example.com
dave@example.com
carol@example.com

テストサーバーは 4 件の RCPT TO アドレスを記録します。これはヘッダーの各アドレスに 1 件ずつ対応し、dave@example.com も含まれます。このアドレスの Bcc ヘッダーは送信されるコピーから除去されますが、配信先としては有効です。これが、エンベロープとヘッダーの分離が実際に機能している様子です。ローカルのテストサーバーは暗号化なしの通常の SMTP を使うため、このスクリプトでは smtplib.SMTP を使用しています。

実際のプロバイダー経由で送信するには、同じメッセージを次のように調整します。このスクリプトは接続情報を環境変数から読み取り、未設定の場合には設定を促します。

import os
import smtplib
from email.message import EmailMessage

SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")

recipients = [
    "alice@example.com",
    "bob@example.com",
    "carol@example.com",
]

message = EmailMessage()
message["Subject"] = "Team update"
message["From"] = SMTP_USER
message["To"] = ", ".join(recipients)
message.set_content("Here is this week's team update.")

if SMTP_HOST and SMTP_USER and SMTP_PASSWORD:
    with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) as server:
        server.login(SMTP_USER, SMTP_PASSWORD)
        refused = server.send_message(message)
    print("Accepted for all recipients" if not refused else f"Refused: {refused}")
else:
    print("Set SMTP_HOST, SMTP_PORT, SMTP_USER, and SMTP_PASSWORD to send this message.")
Set SMTP_HOST, SMTP_PORT, SMTP_USER, and SMTP_PASSWORD to send this message.

変数を設定し、接続可能なプロバイダーがあれば、同じスクリプトは server.login でログインし、SMTP_SSL 経由で送信して、英語の Accepted for all recipients というメッセージを表示します。

配信エラーを処理し、例を調整する

失敗は、いくつかの予測可能な箇所に集中します。

  • 認証 — 資格情報が間違っている場合、またはプロバイダーがアカウントパスワードではなくアプリパスワードや OAuth2 を要求する場合、server.login は smtplib.SMTPAuthenticationError を発生させます。
  • 接続 — ホスト名が間違っていると socket.gaierror が発生します。ポートが間違っている場合や経路がブロックされている場合は、smtplib.SMTPConnectError またはタイムアウトとして現れます。ポートが暗号化方式と一致しているか確認してください。465 には SMTP_SSL、587 には SMTP と starttls() を使います。
  • 受信者の拒否 — サーバーがすべてのアドレスを拒否した場合、send_message は smtplib.SMTPRecipientsRefused を発生させます。一部だけが拒否された場合でも残りへは送信し、拒否された各アドレスを SMTP ステータスコードとメッセージに対応付けた辞書を返します。何も返さないことを成功と決めつけず、この戻り値を確認してください。

SMTP の 250 応答は、サーバーがメッセージを中継用に受け付けたことを意味し、受信トレイに届いたことは意味しません。プロバイダーはその後もフィルタリング、隔離、またはバウンスを行えるため、実際に送信するときは送信者メールボックスのバウンス通知を確認してください。

例を調整するには、recipients リストを、ファイル、データベース、または環境変数から読み込んだアドレスに置き換えます。内容を受信者ごとに変える場合は、1 つの with ブロック内で接続を開いたままにし、1 人につき 1 つの EmailMessage を作成して send_message を繰り返し呼び出します。セッションを再利用する方が、接続し直すよりもはるかに高速です。大規模なお知らせでは Bcc でアドレスを非公開にできますが、プロバイダーのレート制限に注意してください。非常に大きなリストには、専用のメール送信サービスを使うべきです。