中級者向け No.093

メール一括送信ツール

CSVで宛先リストを取り込みsmtplibで差し込みメールを一括送信するツール。送信前プレビューと結果一覧表示付き。

🎯 難易度: ★★★ 📦 ライブラリ: tkinter(標準ライブラリ), smtplib(標準) 🧩 作るもの: 宛名差し込み・プレビュー付きのSMTP一括送信(全348行)

1. アプリ概要

宛名だけを変えた同じ文面を手で何通も作ると、宛名の書き換え漏れが起きがちです。CSV で取り込んだ宛先リストの名前を本文の {name} に差し込んで smtplib で1件ずつ送り、送信前のプレビューと ✔/✘ の結果一覧で確かめられるようにしています。

CSV は UTF-8(BOM 付きも可)で開き、email(または「メール」「mail」)列に値がある行だけを宛先に加え、名前は name か「名前」列から読みます。

メール一括送信ツール 実行画面(Windows)。「接続テスト」の右に「プレビュー」ボタンが並んでいます
実行画面(Windows・送信前プレビュー追加後に撮影)
メール一括送信ツール 実行画面(Linux Mint)。「接続テスト」の右に「プレビュー」ボタンが並んでいます
実行画面(Linux Mint・送信前プレビュー追加後に撮影)

※ プレビューは、宛先リストの1件目の差し込み結果を別ウィンドウで表示します(送信はしません)。

2. 機能一覧

  • 「+ 追加」ボタン(_add_recipient())・「📂 CSVインポート」ボタン(_import_csv())・「🗑 削除」ボタン(_remove_recipient())・「全クリア」ボタン(_clear_recipients())・「ファイル選択」ボタン(_pick_attachment())・「クリア」ボタン(lambda: (self.att_var.set('(なし)'), setattr(self, '_attachment', None)))・「📧 一括送信開始」ボタン(_send_all())・「🔧 接続テスト」ボタン(_test_connection())・「👁 プレビュー」ボタン(_preview())・「追加」ボタン・「閉じる」ボタンで操作
  • ttk.Treeview による表形式の一覧表示
  • CSV ファイルの読み込み
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • messagebox.askyesno()・messagebox.showerror()・messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「メール一括送信ツール」・サイズ 960x645 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認) / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認)
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

✅ 追加インストール不要(標準ライブラリのみ使用)
app093.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import smtplib
import threading
import csv
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from datetime import datetime


class App093:
    """メール一括送信ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("メール一括送信ツール")
        self.root.geometry("960x645")
        self.root.minsize(960, 645)
        self.root.configure(bg="#1e1e1e")
        self._recipients = []    # [{"name": ..., "email": ..., "status": ...}]
        self._attachment = None
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📧 メール一括送信ツール",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        tk.Label(self.root,
                 text="⚠ 送信テストは必ずダミーメールサービス (Mailtrap等) で行ってください。",
                 bg="#fff3cd", fg="#856404", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X)

        # メインPaned
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: SMTP + 宛先
        left = tk.Frame(paned, bg="#1e1e1e", width=420)
        paned.add(left, weight=0)

        # SMTP設定
        smtp_f = tk.LabelFrame(left, text="SMTP設定", bg="#252526",
                                fg="#ccc", font=("Arial", 9), padx=6, pady=4)
        smtp_f.pack(fill=tk.X, padx=4, pady=4)

        fields = [
            ("SMTPサーバー",   "smtp_host", "smtp.gmail.com"),
            ("ポート",         "smtp_port", "587"),
            ("ユーザー名",     "smtp_user", "your@email.com"),
            ("パスワード",     "smtp_pass", ""),
            ("差出人名",       "from_name", "送信者名"),
        ]
        self._smtp_vars = {}
        for i, (label, key, default) in enumerate(fields):
            tk.Label(smtp_f, text=f"{label}:", bg="#252526", fg="#ccc",
                     font=("Arial", 9), width=12, anchor="e").grid(
                row=i, column=0, sticky="e", pady=1)
            v = tk.StringVar(value=default)
            self._smtp_vars[key] = v
            show = "*" if key == "smtp_pass" else ""
            ttk.Entry(smtp_f, textvariable=v, width=22,
                      show=show).grid(row=i, column=1, padx=4, pady=1, sticky="w")

        self.tls_var = tk.BooleanVar(value=True)
        tk.Checkbutton(smtp_f, text="TLS/STARTTLS を使用",
                       variable=self.tls_var,
                       bg="#252526", fg="#ccc", selectcolor="#3c3c3c",
                       activebackground="#252526").grid(
            row=len(fields), column=0, columnspan=2, sticky="w")

        # 宛先リスト
        tk.Label(left, text="宛先一覧", bg="#1e1e1e", fg="#888",
                 font=("Arial", 9)).pack(anchor="w", padx=4, pady=(8, 0))
        btn_row = tk.Frame(left, bg="#1e1e1e")
        btn_row.pack(fill=tk.X, padx=4)
        ttk.Button(btn_row, text="+ 追加",
                   command=self._add_recipient).pack(side=tk.LEFT, padx=2)
        ttk.Button(btn_row, text="📂 CSVインポート",
                   command=self._import_csv).pack(side=tk.LEFT, padx=2)
        ttk.Button(btn_row, text="🗑 削除",
                   command=self._remove_recipient).pack(side=tk.LEFT, padx=2)
        ttk.Button(btn_row, text="全クリア",
                   command=self._clear_recipients).pack(side=tk.LEFT, padx=2)

        cols = ("status", "name", "email")
        self.rec_tree = ttk.Treeview(left, columns=cols, show="headings", height=10)
        self.rec_tree.heading("status", text="状態")
        self.rec_tree.heading("name",   text="名前")
        self.rec_tree.heading("email",  text="メールアドレス")
        self.rec_tree.column("status", width=60,  anchor="center")
        self.rec_tree.column("name",   width=100, anchor="w")
        self.rec_tree.column("email",  width=200, anchor="w")
        rsb = ttk.Scrollbar(left, command=self.rec_tree.yview)
        self.rec_tree.configure(yscrollcommand=rsb.set)
        rsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.rec_tree.pack(fill=tk.BOTH, expand=True, padx=(4, 0))
        self.rec_tree.tag_configure("sent",  foreground="#26a69a")
        self.rec_tree.tag_configure("error", foreground="#ef5350")

        # 右: メール本文
        right = tk.Frame(paned, bg="#1e1e1e")
        paned.add(right, weight=1)

        tk.Label(right, text="件名:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(anchor="w", padx=4)
        self.subject_var = tk.StringVar(value="お知らせ")
        ttk.Entry(right, textvariable=self.subject_var).pack(
            fill=tk.X, padx=4, pady=2)

        tk.Label(right, text="本文 ({name} で宛先名を差し込み):",
                 bg="#1e1e1e", fg="#ccc", font=("Arial", 9)).pack(
            anchor="w", padx=4)
        self.body_text = tk.Text(right, bg="#0d1117", fg="#c9d1d9",
                                  font=("Arial", 10), relief=tk.FLAT,
                                  insertbackground="white", wrap=tk.WORD)
        bsb = ttk.Scrollbar(right, command=self.body_text.yview)
        self.body_text.configure(yscrollcommand=bsb.set)
        bsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.body_text.pack(fill=tk.BOTH, expand=True, padx=(4, 0))
        self.body_text.insert("1.0",
                               "{name} 様\n\nいつもお世話になっております。\n\nよろしくお願いいたします。")

        # 添付ファイル
        att_row = tk.Frame(right, bg="#1e1e1e")
        att_row.pack(fill=tk.X, padx=4, pady=2)
        tk.Label(att_row, text="添付:", bg="#1e1e1e", fg="#ccc",
                 font=("Arial", 9)).pack(side=tk.LEFT)
        self.att_var = tk.StringVar(value="(なし)")
        tk.Label(att_row, textvariable=self.att_var, bg="#1e1e1e",
                 fg="#888", font=("Arial", 9)).pack(side=tk.LEFT, padx=4)
        ttk.Button(att_row, text="ファイル選択",
                   command=self._pick_attachment).pack(side=tk.LEFT)
        ttk.Button(att_row, text="クリア",
                   command=lambda: (self.att_var.set("(なし)"),
                                    setattr(self, "_attachment", None))
                   ).pack(side=tk.LEFT, padx=2)

        # 送信ボタン
        btn_row2 = tk.Frame(right, bg="#1e1e1e", pady=4)
        btn_row2.pack(fill=tk.X, padx=4)
        tk.Button(btn_row2, text="📧 一括送信開始", command=self._send_all,
                  bg="#1565c0", fg="white", relief=tk.FLAT,
                  font=("Arial", 12, "bold"), padx=20, pady=6,
                  activebackground="#0d47a1", bd=0).pack(side=tk.LEFT)
        ttk.Button(btn_row2, text="🔧 接続テスト",
                   command=self._test_connection).pack(side=tk.LEFT, padx=8)
        ttk.Button(btn_row2, text="👁 プレビュー",
                   command=self._preview).pack(side=tk.LEFT)

        self.progress = ttk.Progressbar(self.root, mode="determinate")
        self.progress.pack(fill=tk.X, padx=8)
        self.status_var = tk.StringVar(value="SMTP設定と宛先を入力してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#252526", fg="#858585", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    # ── 宛先操作 ─────────────────────────────────────────────
    def _add_recipient(self):
        dlg = tk.Toplevel(self.root)
        dlg.title("宛先追加")
        dlg.configure(bg="#252526")
        dlg.grab_set()
        name_var  = tk.StringVar()
        email_var = tk.StringVar()
        for label, var in [("名前:", name_var), ("メール:", email_var)]:
            row = tk.Frame(dlg, bg="#252526")
            row.pack(fill=tk.X, padx=12, pady=4)
            tk.Label(row, text=label, bg="#252526", fg="#ccc",
                     width=8, anchor="w").pack(side=tk.LEFT)
            ttk.Entry(row, textvariable=var, width=26).pack(side=tk.LEFT)
        def _ok():
            n = name_var.get().strip()
            e = email_var.get().strip()
            if not e:
                return
            self._recipients.append({"name": n, "email": e, "status": "未送信"})
            self.rec_tree.insert("", tk.END, values=("─", n, e))
            dlg.destroy()
        ttk.Button(dlg, text="追加", command=_ok).pack(pady=8)

    def _import_csv(self):
        path = filedialog.askopenfilename(
            filetypes=[("CSV", "*.csv"), ("すべて", "*.*")])
        if not path:
            return
        try:
            with open(path, encoding="utf-8-sig", newline="") as f:
                reader = csv.DictReader(f)
                for row in reader:
                    name  = row.get("name", row.get("名前", ""))
                    email = row.get("email", row.get("メール", row.get("mail", "")))
                    if email:
                        self._recipients.append(
                            {"name": name, "email": email, "status": "未送信"})
                        self.rec_tree.insert("", tk.END,
                                              values=("─", name, email))
            self.status_var.set(f"インポート完了: {len(self._recipients)} 件")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _remove_recipient(self):
        for sel in self.rec_tree.selection():
            idx = self.rec_tree.index(sel)
            if idx < len(self._recipients):
                del self._recipients[idx]
            self.rec_tree.delete(sel)

    def _clear_recipients(self):
        self._recipients.clear()
        self.rec_tree.delete(*self.rec_tree.get_children())

    def _pick_attachment(self):
        path = filedialog.askopenfilename()
        if path:
            self._attachment = path
            self.att_var.set(os.path.basename(path))

    # ── SMTP ────────────────────────────────────────────────
    def _get_smtp(self):
        host = self._smtp_vars["smtp_host"].get()
        port = int(self._smtp_vars["smtp_port"].get())
        user = self._smtp_vars["smtp_user"].get()
        pwd  = self._smtp_vars["smtp_pass"].get()
        tls  = self.tls_var.get()
        s = smtplib.SMTP(host, port, timeout=10)
        if tls:
            s.starttls()
        if user and pwd:
            s.login(user, pwd)
        return s

    def _test_connection(self):
        self.status_var.set("接続テスト中...")
        def _do():
            try:
                s = self._get_smtp()
                s.quit()
                self.root.after(0, self.status_var.set, "接続テスト: 成功")
            except Exception as e:
                self.root.after(0, self.status_var.set, f"接続テスト: 失敗 - {e}")
        threading.Thread(target=_do, daemon=True).start()

    @staticmethod
    def _merge_body(template, recipient):
        """本文テンプレートの {name} を宛先名に差し込む"""
        return template.replace("{name}", recipient["name"])

    def _preview(self):
        """1件目の宛先で差し込み結果(宛先・件名・本文)を表示する。送信はしない"""
        if not self._recipients:
            messagebox.showwarning("警告", "宛先を追加してください")
            return
        rec = self._recipients[0]
        from_name  = self._smtp_vars["from_name"].get()
        from_email = self._smtp_vars["smtp_user"].get()
        body = self._merge_body(self.body_text.get("1.0", tk.END), rec)
        att = os.path.basename(self._attachment) if self._attachment else "(なし)"
        win = tk.Toplevel(self.root)
        win.title(f"送信プレビュー(1件目 / 全 {len(self._recipients)} 件)")
        win.geometry("520x420")
        win.configure(bg="#252526")
        txt = tk.Text(win, bg="#0d1117", fg="#c9d1d9", font=("Arial", 10),
                      relief=tk.FLAT, wrap=tk.WORD)
        txt.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
        txt.insert("1.0",
                   f"From: {from_name} <{from_email}>\n"
                   f"To: {rec['email']}  ({rec['name']})\n"
                   f"Subject: {self.subject_var.get()}\n"
                   f"添付: {att}\n" + "─" * 40 + "\n" + body)
        txt.configure(state=tk.DISABLED)
        ttk.Button(win, text="閉じる", command=win.destroy).pack(pady=(0, 8))

    def _build_msg(self, recipient):
        from_name  = self._smtp_vars["from_name"].get()
        from_email = self._smtp_vars["smtp_user"].get()
        subject    = self.subject_var.get()
        body_tpl   = self.body_text.get("1.0", tk.END)
        body       = self._merge_body(body_tpl, recipient)

        msg = MIMEMultipart()
        msg["From"]    = f"{from_name} <{from_email}>"
        msg["To"]      = recipient["email"]
        msg["Subject"] = subject
        msg.attach(MIMEText(body, "plain", "utf-8"))

        if self._attachment:
            with open(self._attachment, "rb") as f:
                part = MIMEBase("application", "octet-stream")
                part.set_payload(f.read())
            encoders.encode_base64(part)
            part.add_header("Content-Disposition",
                             f'attachment; filename="{os.path.basename(self._attachment)}"')
            msg.attach(part)
        return msg

    def _send_all(self):
        if not self._recipients:
            messagebox.showwarning("警告", "宛先を追加してください")
            return
        if not messagebox.askyesno("確認",
                                    f"{len(self._recipients)} 件にメールを送信しますか?"):
            return
        self.progress["maximum"] = len(self._recipients)
        self.progress["value"]   = 0
        threading.Thread(target=self._do_send, daemon=True).start()

    def _do_send(self):
        ok = err = 0
        try:
            smtp = self._get_smtp()
        except Exception as e:
            self.root.after(0, self.status_var.set, f"SMTP接続失敗: {e}")
            return

        iids = self.rec_tree.get_children()
        for i, (rec, iid) in enumerate(zip(self._recipients, iids)):
            try:
                msg = self._build_msg(rec)
                smtp.sendmail(self._smtp_vars["smtp_user"].get(),
                               [rec["email"]], msg.as_string())
                rec["status"] = "送信済"
                self.root.after(0, self.rec_tree.item, iid,
                                 {"values": ("✔", rec["name"], rec["email"]),
                                  "tags": ("sent",)})
                ok += 1
            except Exception as e:
                rec["status"] = f"エラー: {e}"
                self.root.after(0, self.rec_tree.item, iid,
                                 {"values": (f"✘", rec["name"], rec["email"]),
                                  "tags": ("error",)})
                err += 1
            self.root.after(0, self.progress.configure, {"value": i + 1})

        smtp.quit()
        self.root.after(0, self.status_var.set,
                         f"送信完了: {ok} 件成功 / {err} 件失敗")


if __name__ == "__main__":
    root = tk.Tk()
    app = App093(root)
    root.mainloop()

5. コード解説

メール一括送信ツールのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App093 クラスにアプリの全機能をまとめています(メソッド14個・全348行)。__init__ ではタイトル「メール一括送信ツール」とウィンドウサイズ 960x645 を設定します。あわせて状態を保持する変数(self._attachment)を初期化し、最後に _build_ui() で画面を組み立てます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×6(ほかにループでも生成)・tk.Label×8(ほかにループでも生成)・ttk.PanedWindow・tk.LabelFrame・ttk.Entry(ループで生成)・tk.Checkbutton・ttk.Button×10・ttk.Treeview・ttk.Scrollbar×2・tk.Text×2・tk.Button・ttk.Progressbar・tk.Toplevel×2 で構成しています。見出し付きの枠(LabelFrame)は「SMTP設定」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「+ 追加」ボタン(_add_recipient())・「📂 CSVインポート」ボタン(_import_csv())・「🗑 削除」ボタン(_remove_recipient())・「全クリア」ボタン(_clear_recipients())・「ファイル選択」ボタン(_pick_attachment())・「クリア」ボタン(lambda: (self.att_var.set('(なし)'), setattr(self, '_attachment', None)))・「📧 一括送信開始」ボタン(_send_all())・「🔧 接続テスト」ボタン(_test_connection())・「👁 プレビュー」ボタン(_preview())・「追加」ボタン・「閉じる」ボタンを command= で接続しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で Exception を捕捉しています。あわせて messagebox.askyesno()・messagebox.showerror()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app093.py と保存します。使うのは csv・datetime・email・os・smtplib・threading・tkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App093 クラスを定義し、__init__ と、末尾の root = tk.Tk() ~ mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("メール一括送信ツール")・geometry("960x645")・configure(bg="#1e1e1e")・minsize(960, 645) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×6(ほかにループでも生成)・tk.Label×8(ほかにループでも生成)・ttk.PanedWindow・tk.LabelFrame・ttk.Entry(ループで生成)・tk.Checkbutton・ttk.Button×10・ttk.Treeview・ttk.Scrollbar×2・tk.Text×2・tk.Button・ttk.Progressbar・tk.Toplevel×2 を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_add_recipient()・_import_csv()・_remove_recipient()・_clear_recipients()・_pick_attachment()・lambda: (self.att_var.set('(なし)'), setattr(self, '_attachment', None))・_send_all()・_test_connection()・_preview()・_ok・win.destroy)につなぎます。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _preview()(24行)・_add_recipient()(22行)・_import_csv()(19行)など を実装します。

  7. 7
    動作確認する

    python app093.py で起動し、各ボタンが反応することを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 結果をファイルに保存する

このコードに保存処理はないため、表示中の数値や結果はアプリを閉じると消えます。テキストファイルへ書き出して、次回起動時に読み込む機能を追加してみましょう。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:Noto Sans JP は見出しの1か所だけで、注意の帯・本文欄・送信開始ボタンなど font を指定した部品は Arial、ttk の入力欄やボタン・宛先一覧の表は指定が無く Tk の標準フォントです。本文は MIMEText(body, "plain", "utf-8") の書式なしテキストとして組み立てるので、送るメールに書体の情報は付かず、受信側の書体は相手のメールソフトが選びます。本文欄(120 行目)とプレビュー画面(268 行目)は同じ font=("Arial", 10) なので、書き換えるか消すかを2か所で合わせると入力時と確認時の書体が一致します。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("960x645") の固定サイズで起動するためです。

解決法:geometry() と minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(960, 645) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。試すときの宛先は自分のアドレスかダミーの受信サービスだけにし、実在の相手へ一斉送信しないでください。SMTP のパスワードはコードや CSV に書かず、画面の入力欄に都度入れる今の作りのままにします。保存する機能は足さない前提で、下の2問に取り組んでください。なお、広告や宣伝を含むメールを実際に一斉送信する場合は、特定電子メール法の規制対象になります。同意を得た相手にだけ送ること、送信者の氏名・連絡先を書くこと、受信拒否の連絡先を書くことが求められます。

  1. 課題1:送信に失敗した理由を画面で確かめられるようにする

    _do_send() は失敗したとき rec["status"] へ理由を入れますが、一覧に出るのは ✘ だけです。理由はどこにも表示されません。行を選んだら、その理由を下の帯に出してください。

    期待結果:宛先を追加した直後に行をクリックすると帯に「未送信」と出る。送信に失敗した行をクリックすると「エラー: ...」と理由に変わる(SMTPの設定が無くても、前半だけで配線の確認はできます)。

    合格条件

    • 送信済みの行を選ぶと「送信済」と出る
    • 「全クリア」のあとに一覧を触っても落ちない
    • 送信し直すと、帯に出る理由も新しいものに変わる

    ヒント①(どこを触るか): 触るのは2か所です。_build_ui() で self.rec_tree に <<TreeviewSelect>> のバインドを1つ足し、選ばれた行の状態を帯に出すメソッドを1つ書きます。
    ヒント②(使うもの): 何番目かは self.rec_tree.index(sel) で分かります(_remove_recipient() と同じ書き方です)。理由は self._recipients[idx]["status"] に入っています。表示は self.status_var.set(...) です。
    つまずきやすい点: self.rec_tree.selection() は空のタプルを返すことがあるため、先頭を取り出す前に中身の有無を確かめてください。_do_send() は別スレッドで動きますが、行を選んだときの処理はメインスレッドなので after() は要りません。状態 列の ✔・✘ は見た目だけで、理由は self._recipients 側にしかありません。

  2. 課題2:宛先アドレスの形を確かめてから追加する

    「+ 追加」は @ の無い文字列でもそのまま一覧に入ります。CSV インポートも、email 列に何か入っていれば通します。形の違う宛先は入れず、いくつ弾いたかを帯に出してください。

    期待結果:「+ 追加」で abc と入れると一覧に入らず、案内が出てダイアログは開いたまま残る。

    合格条件

    • name@example.com はこれまでどおり追加できる
    • CSV インポートで形の違う行を飛ばし、帯に取り込んだ件数と飛ばした件数が出る
    • 「🗑 削除」「全クリア」の動きは変わらない

    ヒント①(どこを触るか): 触るのは2か所です。_add_recipient() の中の _ok() にある if not e: と、_import_csv() の if email: の判定を厳しくします。
    ヒント②(使うもの): 先頭に import re を足し、re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", e) のような形で確かめます。案内は messagebox.showwarning(...) が使えます(_send_all() と同じ書き方です)。
    つまずきやすい点: _ok() の中で return せずに進むと dlg.destroy() が走り、ダイアログが閉じます。self._recipients への追加と self.rec_tree.insert(...) は必ずセットで飛ばしてください。片方だけ飛ばすと _do_send() の zip(self._recipients, iids) がずれ、別の人の名前で送られます。

  3. 課題3:保存機能の追加

    処理結果をファイルに保存する機能を追加しましょう。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。