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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?