「月末になると、取引先ごとに請求書PDFを添付してメールを書くのが地味にしんどい…」
「宛名や金額をコピペし間違えて、別の会社の請求書を送ってしまったらと思うとこわい…」

フリーランスや個人事業をしていると、こんな悩みありますよね。3社くらいなら手作業でもなんとかなりますが、毎月くり返すとなると、うっかりミスがいちばんの心配です。

この記事では、Python の標準ライブラリだけ(smtplib と email.message.EmailMessage)を使って、CSVの一覧から宛名・金額を差し込んだ請求書メールをPDF添付つきで一括送信する仕組みを作ります。

  • 対象:Pythonの基本(for文・関数・辞書)がわかる初〜中級者
  • 使うもの:Python 3(標準ライブラリのみ)+練習用に aiosmtpd
  • ゴール:「送る前に確認できる」「PDFが無ければ止まる」「2回実行しても二重送信しない」送信スクリプト

いきなり本物のメールを送るのはこわいので、自分のPCの中だけで動くテスト用メールサーバーで試してから本番に切り替える流れにしています。一緒に見ていきましょう!🚀

全体の流れ:一覧 → 下書き確認 → 送信

email invoice laptop
email invoice laptop / Photo by RDNE Stock project via Pexels

イメージとしては、「宛名ラベルの一覧表を作って、封筒に詰めて、中身を確認してからポストに入れる」流れをPythonにやってもらう感じです。

  1. invoices.csv に宛先・宛名・金額・PDFファイル名を書く
  2. make_mails.py でメールを組み立て、送る前に .eml ファイルとして保存して確認する
  3. send_mails.py で送信する(送信済みは記録して、2回目はスキップ)

フォルダ構成はこんな感じです。

請求メール/
├─ invoices.csv
├─ make_mails.py
├─ send_mails.py
└─ invoices/
   ├─ invoice_tanaka_202610.pdf
   ├─ invoice_suzuki_202610.pdf
   └─ invoice_sato_202610.pdf

一覧のCSVは次のとおりです(UTF-8で保存します)。

宛先メール,宛名,件名の月,金額,PDFファイル
tanaka@example.com,株式会社タナカ商事 田中様,2026年10月,165000,invoice_tanaka_202610.pdf
suzuki@example.com,スズキデザイン 鈴木様,2026年10月,55000,invoice_suzuki_202610.pdf
sato@example.com,佐藤工務店 佐藤様,2026年10月,88000,invoice_sato_202610.pdf

ステップ1:メールを組み立てて、送る前に確認する

まずは送らずに作るだけのスクリプトです。ここが重要です👇

  • EmailMessage の set_content() で本文、add_attachment() でPDFを添付
  • 本文はテンプレート文字列に format() で宛名・月・金額を差し込む({amount:,} で3桁区切り)
  • PDFが見つからない行はメールを作らず、エラーとして別に集める
  • 作ったメールは outbox/ に .eml で保存。ダブルクリックするとメールソフトで「届いたときの見た目」を確認できます
import csv
from email.message import EmailMessage
from pathlib import Path

FROM_ADDR = "yamada@example.com"   # 自分の送信元アドレス
PDF_DIR = Path("invoices")          # 請求書PDFを置いたフォルダ
OUTBOX = Path("outbox")             # 送信前の確認用フォルダ

BODY = """{name}

いつもお世話になっております。山田です。
{month}分の請求書をお送りします。

ご請求金額:{amount:,}円(税込)

PDFを添付しておりますので、ご確認をお願いいたします。
"""

def build_message(row):
    """CSVの1行から、PDF添付つきのメールを1通つくる"""
    msg = EmailMessage()
    msg["From"] = FROM_ADDR
    msg["To"] = row["宛先メール"]
    msg["Subject"] = f"【請求書】{row['件名の月']}分のご請求"
    msg.set_content(BODY.format(name=row["宛名"], month=row["件名の月"],
                                amount=int(row["金額"])))
    pdf = PDF_DIR / row["PDFファイル"]
    msg.add_attachment(pdf.read_bytes(), maintype="application",
                       subtype="pdf", filename=pdf.name)
    return msg

def load_messages(csv_path="invoices.csv"):
    """全行を読み、PDFが無い行はエラーとして別に返す"""
    messages, errors = [], []
    with open(csv_path, encoding="utf-8", newline="") as f:
        for row in csv.DictReader(f):
            if not (PDF_DIR / row["PDFファイル"]).exists():
                errors.append(f"PDFが見つかりません: {row['PDFファイル']}({row['宛名']})")
                continue
            messages.append(build_message(row))
    return messages, errors

if __name__ == "__main__":
    messages, errors = load_messages()
    OUTBOX.mkdir(exist_ok=True)
    for i, msg in enumerate(messages, 1):
        # 送る前に .eml で保存(ダブルクリックでメールソフトで開ける)
        eml = OUTBOX / f"{i:02d}_{msg['To']}.eml"
        eml.write_bytes(bytes(msg))
        print(f"[{i}] {msg['To']} / {msg['Subject']} / 添付: {msg.get_payload()[1].get_filename()}")
    for e in errors:
        print("[注意]", e)
    print(f"作成 {len(messages)}通・エラー {len(errors)}件 → {OUTBOX}/ を確認してください")
実行結果:PDFが無い1件を[注意]として表示
実行結果:PDFが無い1件を[注意]として表示

わざと佐藤工務店さんのPDFを置かずに実行してみました。

2通だけ作られて、PDFが無い1件は[注意]として表示されました。ここで気づければ、PDFを置き忘れたまま送ってしまう事故を防げます😊

outbox/01_tanaka@example.com.eml を開くと、件名「【請求書】2026年10月分のご請求」、本文の宛名「株式会社タナカ商事 田中様」と「ご請求金額:165,000円(税込)」、添付の invoice_tanaka_202610.pdf がちゃんと入っていました。

ちなみに日本語の件名は、メールの中では =?utf-8?b?44CQ6KuL...?= のような形に自動で変換(エンコード)されています。EmailMessage を使うと、この文字化け対策を自分で書かなくてよいのがうれしいポイントです。

ステップ2:テスト用サーバーに送ってみる

次は送信です。ただ、いきなり取引先に届くと困るので、まずは練習用のメールサーバーを自分のPCで動かします。aiosmtpd を入れて、ターミナルをもう1つ開いて次を実行してください。

pip install aiosmtpd
python -m aiosmtpd -n -l localhost:8025

これで localhost:8025 に「受け取ったメールを画面に表示するだけ」のサーバーが立ちます。外には一切送られません。

送信スクリプトはこちらです。ポイントをまとめるとこんな感じです👇

  • connect() で、テスト用(localhost)と本番(Gmailなど)を環境変数で切り替える
  • パスワードはコードに書かず、環境変数 SMTP_USER / SMTP_PASS から読む
  • PDFが足りない行が1件でもあれば、1通も送らずに中止
  • 送れたら sent_log.txt に「宛先|件名」を1行追記。次に実行したとき、記録にあるものはスキップ
import os
import smtplib
import ssl
import time
from pathlib import Path

from make_mails import load_messages

SENT_LOG = Path("sent_log.txt")  # 送信済みの宛先を1行ずつ記録

def connect():
    host = os.environ.get("SMTP_HOST", "smtp.gmail.com")
    port = int(os.environ.get("SMTP_PORT", "465"))
    if host == "localhost":  # 練習用のテストサーバー(ログイン不要)
        return smtplib.SMTP(host, port)
    context = ssl.create_default_context()
    server = smtplib.SMTP_SSL(host, port, context=context)
    # パスワードはコードに書かず、環境変数から読む
    server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
    return server

def main():
    sent = set(SENT_LOG.read_text(encoding="utf-8").splitlines()) if SENT_LOG.exists() else set()
    messages, errors = load_messages()
    if errors:
        print("PDFが足りない行があるので、送信を中止します")
        for e in errors:
            print("  ", e)
        return

    with connect() as server:
        for msg in messages:
            key = f"{msg['To']}|{msg['Subject']}"
            if key in sent:
                print(f"スキップ(送信済み): {msg['To']}")
                continue
            server.send_message(msg)
            with SENT_LOG.open("a", encoding="utf-8") as f:
                f.write(key + "\n")
            print(f"送信しました: {msg['To']}")
            time.sleep(1)  # 連続送信を避けて1秒あける

if __name__ == "__main__":
    main()

環境変数をセットして実行します。

# Windows(PowerShell)
$env:SMTP_HOST = "localhost"
$env:SMTP_PORT = "8025"
python send_mails.py

# Mac / Linux
SMTP_HOST=localhost SMTP_PORT=8025 python send_mails.py
実行結果:テスト用サーバーへ3通送信
実行結果:テスト用サーバーへ3通送信

先ほどのPDFが足りない状態で実行すると、「PDFが足りない行があるので、送信を中止します」と表示されて、1通も送られませんでした。佐藤工務店さんのPDFを置いてから実行し直した結果がこちらです。

3通とも送れて、テスト用サーバーの画面には3通分のメールと、それぞれの Content-Disposition: attachment; filename="invoice_tanaka_202610.pdf" のような添付の情報が表示されました。

では、もう一度同じコマンドを実行したらどうなるでしょう? 実行せずに答えられますか?

スキップ(送信済み): tanaka@example.com
スキップ(送信済み): suzuki@example.com
スキップ(送信済み): sato@example.com

正解は「全部スキップ」です。sent_log.txt にはこう記録されています。

tanaka@example.com|【請求書】2026年10月分のご請求
suzuki@example.com|【請求書】2026年10月分のご請求
sato@example.com|【請求書】2026年10月分のご請求

途中でエラーが出て止まったときも、送れたところまでは記録されているので、直して再実行すれば残りだけが送られます。「さっきの1通目、送れたっけ…?」と不安にならずに済むのが、この記録の役割です。

ステップ3:本番(Gmail)に切り替える

テストで問題なければ、本番のメールサーバーに切り替えます。Gmailの場合はこうです。

  • サーバー:smtp.gmail.com、ポート:465(SSL)。コードの初期値がこれになっています
  • SMTP_USER は自分のGmailアドレス、SMTP_PASS はアプリパスワード(ふだんのログインパスワードではありません)
  • アプリパスワードは、Googleアカウントで2段階認証プロセスをオンにしてから作成します。会社・学校の管理アカウントなどでは、設定によって作れないこともあります
  • FROM_ADDR も自分のGmailアドレスに書き換えます

⚠️ 本番の前に、CSVの宛先を全部自分のアドレスにして1回送ってみるのがおすすめです。届いたメールの見た目と添付を確認してから、本物の一覧に戻しましょう。そのときは、テストで作った sent_log.txt を消してから本番を実行してくださいね(残っていると全部スキップされます)。

アプリパスワードの作り方は、Googleの公式ヘルプ「アプリ パスワードでログインする」にまとまっています。

まとめ

  • EmailMessage を使うと、日本語の件名・本文やPDF添付を短いコードで組み立てられる
  • 送る前に .eml で保存しておけば、届いたときの見た目をメールソフトで確認できる
  • PDFが1件でも足りなければ送信を中止し、送れたものは記録して二重送信を防ぐ
  • 練習は aiosmtpd のテスト用サーバーで。本番は環境変数を切り替えるだけ

毎月の請求メールは、1回仕組みを作れば「CSVを更新して実行するだけ」になります。手作業のコピペミスが心配な方は、まずテスト用サーバーで動かすところから、ぜひ試してみてください😊

📚 関連商品・おすすめ書籍

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

もしも

スッキリわかるPython入門 第2版 (スッキリわかる入門シリーズ)

初心者に定番のPython入門書

Amazonで見る
実践Claude Code入門―現場で活用するためのAIコーディングの思考法

もしも

実践Claude Code入門―現場で活用するためのAIコーディングの思考法

AIコーディングの現場活用法を学ぶ一冊

Amazonで見る
Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

もしも

Python Web開発実践入門 ―― FastAPIによるWebAPI開発と非同期処理

FastAPIでWebAPI開発を実践的に学ぶ

Amazonで見る

※本記事にはアフィリエイトリンクが含まれます。

ABOUT ME
やまちゃん
これまで学生と社会人を合わせて5000人以上にプログラミング学習を指導。 ゼロからイチをわかりやすく解説する専門家として活動しており、本業ではArduinoを用いたIoT開発とロボットプログラミングが専門。 Pythonを用いたアプリ開発、ウェブアプリケーションの開発で業務の効率化をサポートしています。