中級者向け No.093

メール一括送信ツール

CSVで宛先リストを取り込みsmtplibで差し込みメールを一括送信するツール。送信前の件数確認と結果一覧表示付き。

🎯 難易度: ★★★ 📦 ライブラリ: tkinter(標準ライブラリ), smtplib(標準) ⏱️ 制作時間: 30〜90分

1. アプリ概要

CSVで宛先リストを取り込みsmtplibで差し込みメールを一括送信するツール。送信前の件数確認と結果一覧表示付き。

このアプリはutilカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★★★ です。

このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。

メール一括送信ツール 実行画面(Windows)
実行画面(Windows)
メール一括送信ツール 実行画面(Linux Mint)
実行画面(Linux Mint)

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())・「追加」ボタンで操作
  • ttk.Treeview による表形式の一覧表示
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • messagebox.askyesno()messagebox.showerror()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「メール一括送信ツール」・サイズ 960x645 で起動

3. 事前準備・環境

ℹ️
動作確認環境

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

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

  • Python 3.10 以上
  • 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)

        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()

    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       = body_tpl.replace("{name}", recipient["name"])

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

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

ウィジェット構成

画面は tk.Frame×6(ほかにループでも生成)・tk.Label×8(ほかにループでも生成)・ttk.PanedWindowtk.LabelFramettk.Entry(ループで生成)・tk.Checkbuttonttk.Button×8・ttk.Treeviewttk.Scrollbar×2・tk.Texttk.Buttonttk.Progressbartk.Toplevel で構成しています。見出し付きの枠(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())・「追加」ボタンを command= で接続しています。

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

例外処理

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

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

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

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

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

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

  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.PanedWindowtk.LabelFramettk.Entry(ループで生成)・tk.Checkbuttonttk.Button×8・ttk.Treeviewttk.Scrollbar×2・tk.Texttk.Buttonttk.Progressbartk.Toplevel を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  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()_ok)につなぎます。

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

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

  7. 7
    動作確認する

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

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

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

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

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

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

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

💡 設定ダイアログ

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

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

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

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

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

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

原因: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の本を比較しています。