- Python
Python smtplibでメール送信|添付・HTML・Gmail対応コード
Pythonでメールを送るには、メールの中身を email.message.EmailMessage で組み立て、標準ライブラリの smtplib でSMTPサーバーに渡します。 どちらも標準ライブラリなので、追加のインストールは不要です。
検索で見つかる解説には MIMEText や MIMEMultipart を組み合わせる書き方が多いのですが、Python 3.6以降は EmailMessage を使うと、日本語の件名・HTML本文・添付ファイルを短いコードで正しく組み立てられます。 この記事では、テキスト+HTML・PDF/画像/CSVの添付・CC/BCC・Reply-Toまで入った完成コードを、ローカルに立てたテスト用SMTPサーバー(aiosmtpd)で実際に受信し、生のヘッダーとMIME構造を出力で確認しました。SMTP_SSL(465番相当)とSTARTTLS(587番相当)も自己署名証明書で動かし、認証エラーや宛先拒否で出る実際の例外も一覧にしています。
検証環境:送信側 Python 3.11.15(標準ライブラリのみ)、受信側 Python 3.14.7 + aiosmtpd 1.4.6。外部のメールサーバーには接続していないため、Gmail・Microsoft 365の部分は公式情報に基づく設定値です。
メール送信の早見表
| やりたいこと | コード | 補足 |
|---|---|---|
| メッセージを作る | msg = EmailMessage() |
from email.message import EmailMessage |
| 件名・差出人・宛先 | msg["Subject"] = "..."、msg["From"]、msg["To"] |
日本語は自動でエンコードされる |
| 差出人に表示名 | formataddr(("総務チーム", "soumu@example.com")) |
from email.utils import formataddr |
| 複数の宛先 | msg["To"] = "a@example.com, b@example.com" |
カンマ区切り |
| CC / BCC | msg["Cc"] = ... / msg["Bcc"] = ... |
Bccは send_message が送信前に取り除く |
| 返信先 | msg["Reply-To"] = "reply@example.com" |
返信を別アドレスで受けたいとき |
| 日付・Message-ID | formatdate(localtime=True) / make_msgid(domain=...) |
自動では付かないので付ける |
| テキスト本文 | msg.set_content("本文") |
最初に呼ぶ |
| HTML本文 | msg.add_alternative("<p>..</p>", subtype="html") |
テキストの代替として追加 |
| 添付ファイル | msg.add_attachment(data, maintype="application", subtype="pdf", filename="a.pdf") |
MIMEタイプは mimetypes.guess_type で |
| 平文で接続 | smtplib.SMTP(host, 25) |
社内リレーやテスト用 |
| SSLで接続(465) | smtplib.SMTP_SSL(host, 465, context=ctx) |
接続した時点から暗号化 |
| STARTTLS(587) | s = smtplib.SMTP(host, 587); s.starttls(context=ctx) |
平文で接続して暗号化に切り替え |
| ログイン | s.login(user, password) |
パスワードは環境変数から |
| 送信 | s.send_message(msg) |
戻り値は拒否された宛先の辞書 |
| やり取りを表示 | s.set_debuglevel(1) |
認証情報もログに出る点に注意 |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Python smtplibでメール送信|添付・HTML・Gmail対応コード」
EmailMessageとMIMETextの違い
EmailMessage は、Python 3.6で標準になった新しいメール作成APIです。 MIMEText / MIMEMultipart を使う従来の書き方も動きますが、日本語やマルチパートの扱いを自分で組み立てる必要があります。
| 項目 | MIMEText / MIMEMultipart(従来) | EmailMessage(現在の推奨) |
|---|---|---|
| import | email.mime.text など複数 |
email.message の1つ |
| 日本語件名 | Header("件名", "utf-8") で包む |
文字列を代入するだけ |
| テキスト+HTML | MIMEMultipart("alternative") に2つ attach |
set_content → add_alternative |
| 添付 | MIMEBase + encoders.encode_base64 + ヘッダー設定 |
add_attachment(bytes, maintype, subtype, filename) |
| マルチパートの入れ子 | 自分で組む | 呼んだ順に自動で組み立てる |
| 送信 | sendmail(from, to_list, msg.as_string()) |
send_message(msg)(宛先はヘッダーから) |
Pythonの公式ドキュメント(email パッケージの使用例)も EmailMessage を使った書き方で説明しています。これから書くコードは EmailMessage を選ぶと、短く読みやすくなります。
完成コード:テキスト+HTML・添付・CC/BCC・Reply-To
次のスクリプトは、接続方法(平文・SSL・STARTTLS)と認証情報を環境変数で切り替えられる完成形です。 同じフォルダに report.pdf・chart.png・stock.csv を置いて実行します。
# send_mail.py Python 3.11以降 / 標準ライブラリのみ
import mimetypes
import os
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
from pathlib import Path
SMTP_HOST = os.environ.get("SMTP_HOST", "127.0.0.1")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "7010"))
SMTP_MODE = os.environ.get("SMTP_MODE", "plain") # plain / ssl / starttls
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASS = os.environ.get("SMTP_PASS", "") # パスワードはコードに書かない
CA_FILE = os.environ.get("SMTP_CAFILE") # 自己署名証明書のテスト時だけ指定
def build_message() -> EmailMessage:
msg = EmailMessage()
msg["Subject"] = "【お知らせ】10月の備品棚卸しについて"
msg["From"] = formataddr(("総務チーム", "soumu@example.com"))
msg["To"] = "tanaka@example.com, suzuki@example.com"
msg["Cc"] = "leader@example.com"
msg["Reply-To"] = "soumu-reply@example.com"
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain="example.com")
msg["Bcc"] = "audit@example.com" # 送信時にヘッダーから取り除かれる
# 1) テキスト本文
msg.set_content("各位\n\n10月の備品棚卸しを行います。\n添付の資料をご確認ください。\n")
# 2) HTML本文(テキストの代替として追加)
msg.add_alternative("""\
<html><body>
<p>各位</p>
<p><b>10月の備品棚卸し</b>を行います。<br>添付の資料をご確認ください。</p>
</body></html>
""", subtype="html")
# 3) 添付ファイル(PDF・画像・CSV)
for path in [Path("report.pdf"), Path("chart.png"), Path("stock.csv")]:
ctype, _ = mimetypes.guess_type(path.name)
maintype, subtype = (ctype or "application/octet-stream").split("/", 1)
msg.add_attachment(path.read_bytes(), maintype=maintype, subtype=subtype, filename=path.name)
return msg
def send(msg: EmailMessage) -> dict:
ctx = ssl.create_default_context(cafile=CA_FILE)
if SMTP_MODE == "ssl":
server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=ctx, timeout=30)
else:
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30)
with server:
if SMTP_MODE == "starttls":
server.starttls(context=ctx)
if SMTP_USER:
server.login(SMTP_USER, SMTP_PASS)
# To・Cc・Bcc の全員に送り、Bcc ヘッダーは送信データから取り除く
return server.send_message(msg)
if __name__ == "__main__":
msg = build_message()
refused = send(msg)
print("送信完了 mode=", SMTP_MODE, "拒否された宛先:", refused)
組み立ての順番が大事です。 set_content(テキスト)→ add_alternative(HTML)→ add_attachment(添付)の順に呼ぶと、EmailMessage が自動で multipart/alternative を multipart/mixed の中に入れた正しい構造にしてくれます。
| ポイント | 理由 |
|---|---|
| HTMLだけでなくテキスト本文も入れる | HTMLを表示しない環境でも読める。迷惑メール判定でも自然な形 |
Date と Message-ID を付ける |
EmailMessage は自動で付けない。無いとサーバー側で付与されたり判定に影響したりする |
ssl.create_default_context() を使う |
証明書とホスト名の検証が有効になる |
timeout=30 を指定 |
指定しないと応答がないサーバーで長く待ち続ける |
with で接続を開く |
抜けるときに QUIT を送って確実に切断 |
テスト用SMTPサーバーで受信して中身を確かめる
本物のメールサーバーに送る前に、ローカルのテスト用SMTPサーバーで受け取って中身を確認すると、誤送信の心配なく何度でも試せます。 ここでは aiosmtpd(asyncioベースのSMTPサーバーライブラリ)で、平文・SMTP_SSL・STARTTLSの3つのポートを立てました。
pip install aiosmtpd
# 自己署名証明書(テスト用)
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 1 \
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
# server.py aiosmtpd 1.4.6 で受信内容を保存するテスト用SMTPサーバー
import ssl, time
from pathlib import Path
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import AuthResult, LoginPassword
class SaveHandler:
def __init__(self): self.n = 0
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
if address.startswith("nouser@"):
return "550 5.1.1 User unknown" # 存在しない宛先の再現
envelope.rcpt_tos.append(address)
return "250 OK"
async def handle_DATA(self, server, session, envelope):
self.n += 1
Path(f"received_{self.n}.eml").write_bytes(envelope.content)
print(f"[受信{self.n}] MAIL FROM={envelope.mail_from} RCPT TO={envelope.rcpt_tos} {len(envelope.content)} bytes", flush=True)
return "250 OK"
def auth(server, session, envelope, mechanism, auth_data):
ok = isinstance(auth_data, LoginPassword) and auth_data.login == b"user@example.com" and auth_data.password == b"app-password-1234"
return AuthResult(success=ok, handled=False)
ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ctx.load_cert_chain("cert.pem", "key.pem")
h = SaveHandler()
plain = Controller(h, hostname="127.0.0.1", port=7010) # 平文(デバッグ用)
smtps = Controller(h, hostname="127.0.0.1", port=7011, ssl_context=ctx, # SMTP_SSL(465相当)
authenticator=auth, auth_require_tls=False)
starttls = Controller(h, hostname="127.0.0.1", port=7012, tls_context=ctx, # STARTTLS(587相当)
require_starttls=True, authenticator=auth)
for c in (plain, smtps, starttls): c.start()
print("listening 7010(plain) 7011(SSL) 7012(STARTTLS)", flush=True)
while True: time.sleep(3600)
AuthResult の handled=False は、認証失敗時にサーバーが 535 の応答を返すための指定です(省略すると、今回の環境では失敗時に応答が返らずクライアントがタイムアウトしました)。
3つの接続方法で送信した結果
python3 server.py &
python3 send_mail.py
SMTP_MODE=ssl SMTP_PORT=7011 SMTP_HOST=localhost SMTP_USER=user@example.com \
SMTP_PASS=app-password-1234 SMTP_CAFILE=cert.pem python3 send_mail.py
SMTP_MODE=starttls SMTP_PORT=7012 SMTP_HOST=localhost SMTP_USER=user@example.com \
SMTP_PASS=app-password-1234 SMTP_CAFILE=cert.pem python3 send_mail.py
送信完了 mode= plain 拒否された宛先: {}
送信完了 mode= ssl 拒否された宛先: {}
送信完了 mode= starttls 拒否された宛先: {}
サーバー側の表示です。
[受信1] MAIL FROM=soumu@example.com RCPT TO=['tanaka@example.com', 'suzuki@example.com', 'audit@example.com', 'leader@example.com'] 21046 bytes
[受信2] MAIL FROM=soumu@example.com RCPT TO=['tanaka@example.com', 'suzuki@example.com', 'audit@example.com', 'leader@example.com'] 21046 bytes
[受信3] MAIL FROM=soumu@example.com RCPT TO=['tanaka@example.com', 'suzuki@example.com', 'audit@example.com', 'leader@example.com'] 21047 bytes
send_message はTo・Cc・Bccの4アドレスすべてを宛先(RCPT TO)にしています。 Bccの audit@example.com も届いています。
受信した生のメール(ヘッダーとMIME構造)
受信したファイルを次のスクリプトで表示しました。
# show.py 受信した生メールを解析して表示
import sys
from email import policy
from email.parser import BytesParser
raw = open(sys.argv[1], "rb").read()
print("---- 生ヘッダー ----")
print(raw.split(b"\r\n\r\n", 1)[0].decode())
msg = BytesParser(policy=policy.default).parsebytes(raw)
print("---- デコード後 ----")
for h in ["Subject", "From", "To", "Cc", "Bcc", "Reply-To"]:
print(f"{h}: {msg[h]}")
print("---- MIME構造 ----")
def walk(part, depth=0):
fn = part.get_filename()
print(" " * depth + part.get_content_type(), f"filename={fn}" if fn else "", part.get("Content-Transfer-Encoding", ""))
if part.is_multipart():
for p in part.iter_parts(): walk(p, depth + 1)
walk(msg)
print("---- テキスト本文 ----")
print(msg.get_body(("plain",)).get_content())
---- 生ヘッダー ----
Subject: =?utf-8?b?44CQ44GK55+l44KJ44Gb44CRMTDmnIjjga7lgpnlk4Hmo5rljbjjgZc=?=
=?utf-8?b?44Gr44Gk44GE44Gm?=
From: =?utf-8?b?57eP5YuZ44OB44O844Og?= <soumu@example.com>
To: tanaka@example.com, suzuki@example.com
Cc: leader@example.com
Reply-To: soumu-reply@example.com
Date: Thu, 01 Oct 2026 23:44:57 +0900
Message-ID: <179086589767.15526.2655510360244307588@example.com>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="===============3376227236009347709=="
---- デコード後 ----
Subject: 【お知らせ】10月の備品棚卸しについて
From: 総務チーム <soumu@example.com>
To: tanaka@example.com, suzuki@example.com
Cc: leader@example.com
Bcc: None
Reply-To: soumu-reply@example.com
---- MIME構造 ----
multipart/mixed
multipart/alternative
text/plain 8bit
text/html base64
application/pdf filename=report.pdf base64
image/png filename=chart.png base64
text/csv filename=stock.csv base64
---- テキスト本文 ----
各位
10月の備品棚卸しを行います。
添付の資料をご確認ください。
ここから確認できることをまとめます。
| 確認項目 | 結果 |
|---|---|
| 日本語の件名・表示名 | =?utf-8?b?...?= 形式(MIMEエンコード)に自動変換され、受信側で正しく戻る |
| Bcc | 生ヘッダーに Bcc 行がない(デコード後も None)。他の受信者に見えない |
| 本文の構造 | multipart/mixed の中に multipart/alternative(テキスト+HTML)と添付3つ |
| 添付ファイル | PDF・PNG・CSVがそれぞれ正しいMIMEタイプ・ファイル名・base64で入っている |
生メールの本文部分は、次のように境界線(boundary)で区切られています(添付のbase64部分は省略)。
--===============3376227236009347709==
Content-Type: multipart/alternative;
boundary="===============..."
--===============...
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 8bit
各位
...
--===============3376227236009347709==
Content-Type: application/pdf
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="report.pdf"
MIME-Version: 1.0
JVBERi0xLjYKJenr8b8K...(以下省略)
テキスト本文は 8bit(UTF-8のまま)で送られています。8BITMIMEに対応していないサーバーを経由する可能性があるなら、msg.set_content(text, cte="base64") のように指定すると、本文もbase64で送れます(Content-Transfer-Encoding: base64 になることを確認済み)。
SMTPのやり取りを見る(STARTTLSとログイン)
set_debuglevel(1) を付けると、サーバーとのやり取りが表示されます(一部の行を省略)。
send: 'ehlo [127.0.0.1]\r\n'
reply: b'250-STARTTLS\r\n'
send: 'STARTTLS\r\n'
reply: b'220 Ready to start TLS\r\n'
send: 'ehlo [127.0.0.1]\r\n'
reply: b'250-AUTH LOGIN PLAIN\r\n'
send: 'AUTH PLAIN AHVzZXJAZXhhbXBsZS5jb20AYXBwLXBhc3N3b3JkLTEyMzQ=\r\n'
reply: b'235 2.7.0 Authentication successful\r\n'
send: 'QUIT\r\n'
reply: b'221 Bye\r\n'
STARTTLSの前はAUTH(認証)が案内されず、暗号化に切り替えた後に初めて AUTH LOGIN PLAIN が出てくる流れがわかります。なお、AUTH PLAIN の後ろはユーザー名とパスワードをbase64にしただけの文字列で、簡単に元に戻せます。デバッグログをファイルや共有チャットに残さないようにしてください。
SMTP_SSLとSTARTTLSの違い
| 項目 | SMTP_SSL | STARTTLS |
|---|---|---|
| よく使うポート | 465 | 587 |
| 暗号化のタイミング | 接続した瞬間から | 平文で接続 → starttls() で切り替え |
| Pythonのコード | smtplib.SMTP_SSL(host, 465, context=ctx) |
s = smtplib.SMTP(host, 587)、s.starttls(context=ctx) |
| 間違えたときの症状(テスト用サーバーで確認) | STARTTLS用ポートにSMTP_SSLでつなぐと ssl.SSLError: [SSL: WRONG_VERSION_NUMBER] wrong version number |
SSL用ポートに SMTP でつなぐと応答待ちになり SMTPServerDisconnected: Connection unexpectedly closed: timed out |
サービスがどちらのポートを案内しているかを確認し、465ならSMTP_SSL、587ならSMTP+starttls() と覚えておけば迷いません。どちらも context=ssl.create_default_context() を渡すと、証明書の検証が有効になります。
Gmail・Microsoft 365の設定表
外部のメールサービスから送るときは、サーバー名・ポート・認証方式をサービスの公式情報で確認します。 2026年10月時点の主な設定は次のとおりです(この記事の環境からは外部サーバーに接続していないため、以下は公式情報に基づく値で、送信は未実行です)。
| 項目 | Gmail(個人のGoogleアカウント) | Microsoft 365(Exchange Online) |
|---|---|---|
| SMTPサーバー | smtp.gmail.com |
smtp.office365.com |
| ポート | 465(SSL)または 587(STARTTLS) | 587(STARTTLS) |
| ユーザー名 | Gmailアドレス | Microsoft 365のメールアドレス |
| パスワード | アプリパスワード(16桁) | 基本認証は廃止予定。OAuth 2.0へ移行 |
| 前提 | 2段階認証プロセスを有効にする | 管理者がメールボックスのSMTP AUTHを有効にする |
| 注意点 | 1日あたりの送信数に上限がある | 基本認証によるSMTP AUTHは2026年12月末に無効化予定と発表(2026年1月時点) |
Gmailのアプリパスワード
Googleのヘルプによると、アプリパスワードは2段階認証プロセスを有効にしたアカウントで作成でき、次の場合は作成画面が表示されません。
| アプリパスワードが使えない主なケース |
|---|
| 2段階認証プロセスをセキュリティキーだけで設定している |
| 職場・学校などの組織が管理するアカウント |
| 高度な保護機能プログラムを有効にしている |
Googleは「Googleでログイン」(OAuth)に対応したアプリではそちらを使うよう案内しており、アプリパスワードはそれが使えない場合の手段という位置づけです。また、Googleアカウントのパスワードを変更するとアプリパスワードは取り消されるので、変更後は作り直します。
# Gmail の例(STARTTLS)。値は環境変数で渡す
export SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_MODE=starttls
export SMTP_USER=yourname@gmail.com
read -s SMTP_PASS && export SMTP_PASS # 画面に表示せず入力
python3 send_mail.py
Gmailから送る場合は、From を SMTP_USER と同じアドレスにしておくと、差出人が書き換えられたり判定に影響したりするのを避けられます。
Microsoft 365はOAuthへの移行を前提に
Microsoftは、Exchange OnlineのSMTP AUTH(クライアント送信)で基本認証(ユーザー名+パスワード)を廃止する方針を発表しています。当初の予定から延期され、2026年1月の更新では、既存テナントでは2026年12月末に無効化、2027年1月以降の新しいテナントでは最初から使えないとされています。これから作るスクリプトは、OAuth 2.0(MSALでアクセストークンを取得し、XOAUTH2 で認証)を前提にすると移行の手間がかかりません。
# OAuth 2.0 で認証する形(トークン取得は MSAL などで行う。未実行)
def xoauth2(user, token):
return f"user={user}\x01auth=Bearer {token}\x01\x01"
with smtplib.SMTP("smtp.office365.com", 587, timeout=30) as s:
s.starttls(context=ssl.create_default_context())
s.auth("XOAUTH2", lambda challenge=None: xoauth2(user, access_token))
s.send_message(msg)
smtplib.SMTP.auth(mechanism, authobject) は任意の認証方式を使うためのメソッドで、authobject が返した文字列をsmtplibがbase64にして送ります。テナントの設定やアプリ登録が必要なため、Microsoftの公式ドキュメントの手順に沿って準備してください。
認証情報を環境変数で管理する
パスワードやトークンをコードに書かず、環境変数や .env ファイル(Gitに含めない)から読み込みます。
| 方法 | 書き方 | 向いている場面 |
|---|---|---|
| 環境変数 | os.environ["SMTP_PASS"] |
サーバー・CI・コンテナ |
.env ファイル |
python-dotenv の load_dotenv() |
手元の開発 |
| OSの資格情報ストア | keyring.get_password("smtp", user) |
個人のPCで定期実行 |
| クラウドのシークレット管理 | 各サービスのSDKで取得 | 本番環境 |
os.environ["SMTP_PASS"] のように角かっこで読むと、設定し忘れたときに KeyError ですぐ気づけます。.gitignore に .env を入れておくことも忘れずに。
複数の宛先に1通ずつ送る(間隔・再試行つき)
宛名を変えて1人ずつ送るときは、1回の接続で複数通を送り、間隔をあけ、宛先エラーと一時エラーを分けて扱います。
# bulk.py 1通ずつ宛名を変えて送る(1回の接続で複数通・間隔あり・一時エラーは再試行)
import os, smtplib, time
from email.message import EmailMessage
members = [("田中", "tanaka@example.com"), ("鈴木", "suzuki@example.com"),
("存在しない宛先", "nouser@example.com"), ("佐藤", "sato@example.com")]
HOST, PORT = os.environ.get("SMTP_HOST", "127.0.0.1"), int(os.environ.get("SMTP_PORT", "7010"))
INTERVAL = 1.0 # 1通ごとの間隔(秒)。サービスの送信上限に合わせる
def make(name, addr):
m = EmailMessage()
m["Subject"] = "備品返却のお願い"
m["From"] = "soumu@example.com"
m["To"] = addr
m.set_content(f"{name}さん\n\n貸出中の備品の返却期限が近づいています。\n")
return m
ok, ng = [], []
with smtplib.SMTP(HOST, PORT, timeout=30) as s:
for name, addr in members:
for attempt in range(1, 4): # 最大3回
try:
s.send_message(make(name, addr))
ok.append(addr); break
except smtplib.SMTPRecipientsRefused as e: # 宛先エラーは再試行しない
ng.append((addr, e.recipients[addr])); break
except smtplib.SMTPResponseException as e:
if 400 <= e.smtp_code < 500 and attempt < 3: # 4xx は一時エラー
time.sleep(2 ** attempt); continue
ng.append((addr, (e.smtp_code, e.smtp_error))); break
time.sleep(INTERVAL)
print("成功:", ok)
print("失敗:", ng)
$ time python3 bulk.py
成功: ['tanaka@example.com', 'suzuki@example.com', 'sato@example.com']
失敗: [('nouser@example.com', (550, b'5.1.1 User unknown'))]
real 0m4.076s
1件が宛先エラーでも残りは送られ、失敗した宛先と理由が記録されます。 SMTPの応答コードは、4xxが「一時的なエラー(時間をおけば通る可能性がある)」、5xxが「恒久的なエラー」です。4xxだけを間隔を広げながら再試行すると、サーバーに負担をかけずに済みます。
| 送り方 | 書き方 | 向いている場面 |
|---|---|---|
| 1通に全員(To/Cc) | msg["To"] = "a, b, c" |
受信者同士がアドレスを知ってよい連絡 |
| 1通に全員(Bcc) | msg["Bcc"] = "a, b, c" |
同じ文面を、互いのアドレスを見せずに |
| 1人ずつ | ループで send_message |
宛名や内容を人ごとに変える |
送信数が多い場合は、各サービスの1日・1分あたりの上限を確認し、INTERVAL で調整します。大量の配信には、配信専用のメール送信サービスを使う方法もあります。
例外の一覧と対処(実際のエラー文)
テスト用サーバーで、よくある失敗を再現しました。
[wrong_password] smtplib.SMTPAuthenticationError: (535, b'5.7.8 Authentication credentials invalid')
[login_without_tls] smtplib.SMTPNotSupportedError: SMTP AUTH extension not supported by server.
[no_starttls] smtplib.SMTPSenderRefused: (530, b'Must issue a STARTTLS command first', 'soumu@example.com')
[refused_port] builtins.ConnectionRefusedError: [Errno 111] Connection refused
[no_cafile] ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate (_ssl.c:1016)
[partial_reject] 戻り値: {'nouser@example.com': (550, b'5.1.1 User unknown')}
[all_reject] smtplib.SMTPRecipientsRefused: {'nouser@example.com': (550, b'5.1.1 User unknown')}
[bad_host] socket.gaierror: [Errno -2] Name or service not known
| 例外・症状 | 主な原因 | 対処 |
|---|---|---|
SMTPAuthenticationError (535) |
ユーザー名・パスワードの誤り。Gmailで通常のパスワードを使った | アプリパスワードを使う。Microsoft 365はOAuthを検討 |
SMTPNotSupportedError: SMTP AUTH extension not supported |
STARTTLSの前に login した/サーバーが認証に非対応 |
starttls() の後に login() |
SMTPSenderRefused (530 Must issue a STARTTLS) |
暗号化せずに送信しようとした | starttls() を呼ぶ |
SMTPRecipientsRefused |
全員の宛先が拒否された | アドレスを確認。e.recipients に理由 |
send_message の戻り値が空でない |
一部の宛先だけ拒否された | 戻り値の辞書を必ず確認する |
ConnectionRefusedError |
ポート違い、サーバー停止、ファイアウォール | ホスト名とポートを確認 |
socket.gaierror |
ホスト名の誤り、DNSが引けない | サーバー名のつづりを確認 |
ssl.SSLCertVerificationError |
証明書が検証できない(自己署名など) | 正しいホスト名で接続。テストなら cafile= で証明書を指定 |
ssl.SSLError: WRONG_VERSION_NUMBER |
STARTTLS用ポート(587)にSMTP_SSLで接続した | 587は SMTP + starttls() |
SMTPServerDisconnected: ... timed out |
SSL用ポート(465)に SMTP で接続した、通信の遮断 |
465は SMTP_SSL |
UnicodeEncodeError: 'ascii' codec can't encode ... |
日本語を含む文字列をそのまま sendmail に渡した |
EmailMessage と send_message を使う |
一部の宛先だけ拒否されたときは例外にならず、戻り値の辞書に入る点は見落としやすいところです。上の完成コードでも戻り値を表示しているのはこのためです。
try:
refused = send(msg)
if refused:
print("一部の宛先に届きませんでした:", refused)
except smtplib.SMTPAuthenticationError:
print("認証に失敗しました。ユーザー名とアプリパスワードを確認してください")
except smtplib.SMTPRecipientsRefused as e:
print("すべての宛先が拒否されました:", e.recipients)
except (smtplib.SMTPException, OSError) as e: # 接続エラーは OSError の仲間
print("送信できませんでした:", type(e).__name__, e)
smtplib の例外はすべて smtplib.SMTPException の子クラスで、さらに SMTPException 自体も OSError の子クラスです。接続エラー(ConnectionRefusedError・socket.gaierror・ssl.SSLError)も OSError の仲間なので、最後に OSError で受けておけば取りこぼしがありません。
迷惑メールに入りにくくするための送信ドメイン認証(概要)
受信側は「そのドメインから送ってよいサーバーか」「途中で書き換えられていないか」を確かめています。 その仕組みがSPF・DKIM・DMARCで、どれもPythonのコードではなく、送信に使うドメインのDNSとメールサーバー側で設定します。
| 仕組み | 何をするか | 設定する場所 |
|---|---|---|
| SPF | 送信してよいサーバーのIPアドレスをDNSで宣言 | DNSのTXTレコード |
| DKIM | メールに電子署名を付け、改ざんされていないことを証明 | 送信サーバーで署名、公開鍵をDNSへ |
| DMARC | SPF・DKIMの結果が合わないときの扱い(何もしない・隔離・拒否)と報告先を宣言 | DNSのTXTレコード(_dmarc.) |
GmailやMicrosoft 365のSMTPから自分のアカウントで送る場合は、サービス側の設定が使われます。独自ドメインのアドレスを From にして送るときは、そのドメインのSPF・DKIM・DMARCを整えておくと、迷惑メールに振り分けられにくくなります。Googleは大量に送る送信者に向けて、これらの設定を求めるガイドラインを公開しています。
コード側でできることは、本文で扱った次の点です。
- テキスト本文を必ず入れる(HTMLだけにしない)
Date・Message-IDを付けるFromを認証したアカウントのアドレスにそろえる- 短時間に大量に送らない(間隔をあける)
よくある質問
Q. MIMETextを使ったコードと、EmailMessageを使ったコードはどちらがよいですか? A. どちらも動きますが、EmailMessageのほうが日本語件名・HTML・添付を短く正しく書けます。Python 3.6以降なら、新しく書くコードはEmailMessageがおすすめです。
Q. Gmailで SMTPAuthenticationError になります。
A. Googleアカウントの通常のパスワードではなく、2段階認証プロセスを有効にしたうえで作成したアプリパスワード(16桁)を使います。組織が管理するアカウントなど、アプリパスワードを作成できない場合もあります。
Q. Bccに入れたアドレスが他の受信者に見えてしまいませんか?
A. send_message は送信前に Bcc ヘッダーを取り除きます。本文の検証でも、受信した生メールに Bcc 行がないこと、Bccの宛先にも配送されていることを確認しました。sendmail を使う場合は、Bccをヘッダーに書かず宛先リストにだけ入れます。
Q. 本物のメールアドレスに送らずに動作確認する方法は? A. 本文のようにaiosmtpdでテスト用SMTPサーバーを立てると、受信したメールをファイルに保存して中身を確認できます。テストのたびに実際の宛先へ送らずに済みます。
まとめ
- メールの中身は
EmailMessage、送信はsmtplib。どちらも標準ライブラリで、追加インストールは不要 set_content→add_alternative→add_attachmentの順で、テキスト+HTML+添付の正しいMIME構造ができる- 日本語件名は自動でエンコードされ、Bccは送信時に取り除かれることを生メールで確認した
- 465はSMTP_SSL、587はSMTP+
starttls()。ログインは暗号化の後 - Gmailはアプリパスワード、Microsoft 365は基本認証の廃止予定に合わせてOAuthへ
- 一部の宛先拒否は戻り値、全員の拒否は例外。最後に
OSErrorで受けると取りこぼさない - SPF・DKIM・DMARCはDNS側で整える。コード側ではテキスト本文・Date・Message-IDを付ける
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Python smtplibでメール送信|添付・HTML・Gmail対応コード」
株式会社RJCでは、Pythonによるメール送信の自動化を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。