中級者向け No.40

メールクライアント UI

SMTPでメールを送信・IMAPで受信できるシンプルなメールクライアント。smtplibとimaplibの実践活用を学びます。

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

1. アプリ概要

SMTPでメールを送信・IMAPで受信できるシンプルなメールクライアント。smtplibとimaplibの実践活用を学びます。

使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★★ です。

動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。

カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。

メールクライアント UI 実行画面(Windows)
実行画面(Windows)
メールクライアント UI 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「🔄 メールを取得」ボタン(_fetch_mail())・「↩ 返信」ボタン(_reply())・「🗑 削除」ボタン(_delete_mail())・「📤 送信」ボタン(_send_mail())・「🗑 クリア」ボタン(_clear_compose())・「💾 設定を保存 / 接続テスト」ボタン(_test_connection())で操作
  • イベント <<TreeviewSelect>> から _on_mail_select() を実行
  • ttk.Treeview による表形式の一覧表示
  • messagebox.askyesno()・messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「メールクライアント UI」・サイズ 1000x857 で起動

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. 完全なソースコード

💡
コードのコピー方法

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

✅ 追加インストール不要(標準ライブラリのみ使用)
app40.py
import tkinter as tk
from tkinter import ttk, messagebox, scrolledtext
import smtplib
import imaplib
import email
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import decode_header
import threading
import os


class App40:
    """メールクライアント UI"""

    def __init__(self, root):
        self.root = root
        self.root.title("メールクライアント UI")
        self.root.geometry("1000x857")
        self.root.minsize(1000, 857)
        self.root.configure(bg="#f8f9fc")
        self._messages = []
        self._imap = None
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#1a237e", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="📧 メールクライアント UI",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#1a237e", fg="white").pack(side=tk.LEFT, padx=12)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # ── 受信タブ ──────────────────────────────────────────────
        recv_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(recv_tab, text="📥 受信")
        self._build_recv_tab(recv_tab)

        # ── 送信タブ ──────────────────────────────────────────────
        send_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(send_tab, text="✉ 送信")
        self._build_send_tab(send_tab)

        # ── アカウント設定タブ ────────────────────────────────────
        settings_tab = tk.Frame(notebook, bg="#f8f9fc")
        notebook.add(settings_tab, text="⚙ 設定")
        self._build_settings_tab(settings_tab)

        self.status_var = tk.StringVar(value="設定タブでアカウントを設定してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)

    # ── 受信タブ ──────────────────────────────────────────────────

    def _build_recv_tab(self, parent):
        # ツールバー
        bar = tk.Frame(parent, bg="#f8f9fc")
        bar.pack(fill=tk.X, padx=8, pady=6)
        ttk.Button(bar, text="🔄 メールを取得",
                   command=self._fetch_mail).pack(side=tk.LEFT, padx=4)
        tk.Label(bar, text="フォルダ:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(12, 2))
        self.folder_var = tk.StringVar(value="INBOX")
        self.folder_cb = ttk.Combobox(bar, textvariable=self.folder_var,
                                       values=["INBOX", "Sent", "Drafts", "Trash"],
                                       width=14)
        self.folder_cb.pack(side=tk.LEFT, padx=4)
        tk.Label(bar, text="件数:", bg="#f8f9fc",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        self.fetch_count_var = tk.IntVar(value=20)
        ttk.Spinbox(bar, from_=5, to=200,
                    textvariable=self.fetch_count_var, width=5).pack(side=tk.LEFT)

        # メール一覧 + 本文
        paned = ttk.PanedWindow(parent, orient=tk.VERTICAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=8)

        # メール一覧
        list_f = tk.Frame(paned, bg="#f8f9fc")
        paned.add(list_f, weight=1)
        cols = ("from_", "subject", "date", "size")
        self.mail_tree = ttk.Treeview(list_f, columns=cols,
                                       show="headings", height=8)
        for c, h, w in [("from_", "差出人", 180), ("subject", "件名", 300),
                         ("date", "日時", 140), ("size", "サイズ", 70)]:
            self.mail_tree.heading(c, text=h)
            self.mail_tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(list_f, command=self.mail_tree.yview)
        self.mail_tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.mail_tree.pack(fill=tk.BOTH, expand=True)
        self.mail_tree.bind("<<TreeviewSelect>>", self._on_mail_select)
        self.mail_tree.tag_configure("unread", font=("Arial", 10, "bold"))
        self.mail_tree.tag_configure("read", font=("Arial", 10))

        # 本文
        body_f = ttk.LabelFrame(paned, text="本文", padding=4)
        paned.add(body_f, weight=2)

        # ヘッダー情報
        self.mail_header_var = tk.StringVar(value="")
        tk.Label(body_f, textvariable=self.mail_header_var,
                 bg="#e8eaf6", fg="#283593", font=("Arial", 9),
                 anchor="w", padx=6).pack(fill=tk.X, pady=2)

        self.body_text = tk.Text(body_f, bg="#ffffff", fg="#333",
                                  font=("Arial", 11), relief=tk.FLAT,
                                  state=tk.DISABLED, wrap=tk.WORD)
        body_sb = ttk.Scrollbar(body_f, command=self.body_text.yview)
        self.body_text.configure(yscrollcommand=body_sb.set)
        body_sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.body_text.pack(fill=tk.BOTH, expand=True)

        # 返信ボタン
        btn_f = tk.Frame(parent, bg="#f8f9fc")
        btn_f.pack(fill=tk.X, padx=8, pady=4)
        ttk.Button(btn_f, text="↩ 返信",
                   command=self._reply).pack(side=tk.LEFT, padx=4)
        ttk.Button(btn_f, text="🗑 削除",
                   command=self._delete_mail).pack(side=tk.LEFT, padx=4)

        self.recv_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.recv_progress.pack(fill=tk.X, padx=8, pady=2)

    # ── 送信タブ ──────────────────────────────────────────────────

    def _build_send_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        for lbl, attr, show in [("宛先 (To):", "to_var", ""),
                                 ("CC:", "cc_var", ""),
                                 ("件名:", "subject_var", "")]:
            row = tk.Frame(parent, bg="#f8f9fc")
            row.pack(fill=tk.X, padx=8, pady=3)
            tk.Label(row, text=lbl, width=12, anchor="e", **lbl_s).pack(side=tk.LEFT)
            var = tk.StringVar()
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=60).pack(side=tk.LEFT, padx=4,
                                                              fill=tk.X, expand=True)

        # 本文
        tk.Label(parent, text="本文:", **lbl_s).pack(anchor="w", padx=8, pady=2)
        self.send_body = tk.Text(parent, bg="#ffffff", fg="#333",
                                  font=("Arial", 11), relief=tk.FLAT,
                                  height=16)
        sb = ttk.Scrollbar(parent, command=self.send_body.yview)
        self.send_body.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.send_body.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)

        btn_f = tk.Frame(parent, bg="#f8f9fc")
        btn_f.pack(fill=tk.X, padx=8, pady=4)
        ttk.Button(btn_f, text="📤 送信",
                   command=self._send_mail).pack(side=tk.LEFT, padx=4)
        ttk.Button(btn_f, text="🗑 クリア",
                   command=self._clear_compose).pack(side=tk.LEFT, padx=4)

        self.send_progress = ttk.Progressbar(parent, mode="indeterminate")
        self.send_progress.pack(fill=tk.X, padx=8, pady=2)

    # ── 設定タブ ──────────────────────────────────────────────────

    def _build_settings_tab(self, parent):
        lbl_s = {"bg": "#f8f9fc", "font": ("Arial", 10)}

        # 送信 (SMTP)
        smtp_f = ttk.LabelFrame(parent, text="SMTP設定(送信)", padding=10)
        smtp_f.pack(fill=tk.X, padx=8, pady=6)
        for lbl, attr, val, show in [
            ("SMTPサーバー:", "smtp_host_var", "smtp.gmail.com", ""),
            ("ポート:", "smtp_port_var", "587", ""),
            ("ユーザー:", "smtp_user_var", "", ""),
            ("パスワード:", "smtp_pass_var", "", "*"),
        ]:
            row = tk.Frame(smtp_f, bg=ttk.Style().lookup("TLabelframe", "background"))
            row.pack(fill=tk.X, pady=2)
            tk.Label(row, text=lbl, width=16, anchor="e",
                     bg=row.cget("bg"),
                     font=("Arial", 10)).pack(side=tk.LEFT)
            var = tk.StringVar(value=val)
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=34, show=show).pack(side=tk.LEFT, padx=4)

        self.smtp_tls_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(smtp_f, text="TLS/STARTTLS を使用",
                         variable=self.smtp_tls_var).pack(anchor="w")

        # 受信 (IMAP)
        imap_f = ttk.LabelFrame(parent, text="IMAP設定(受信)", padding=10)
        imap_f.pack(fill=tk.X, padx=8, pady=6)
        for lbl, attr, val, show in [
            ("IMAPサーバー:", "imap_host_var", "imap.gmail.com", ""),
            ("ポート:", "imap_port_var", "993", ""),
        ]:
            row = tk.Frame(imap_f, bg=ttk.Style().lookup("TLabelframe", "background"))
            row.pack(fill=tk.X, pady=2)
            tk.Label(row, text=lbl, width=16, anchor="e",
                     bg=row.cget("bg"),
                     font=("Arial", 10)).pack(side=tk.LEFT)
            var = tk.StringVar(value=val)
            setattr(self, attr, var)
            ttk.Entry(row, textvariable=var, width=34, show=show).pack(side=tk.LEFT, padx=4)

        self.imap_ssl_var = tk.BooleanVar(value=True)
        ttk.Checkbutton(imap_f, text="SSL を使用",
                         variable=self.imap_ssl_var).pack(anchor="w")

        # メモ
        tk.Label(parent,
                 text="※ Gmailの場合はアプリパスワードを使用してください。\n"
                      "  Google アカウント → セキュリティ → アプリパスワードで生成。",
                 bg="#fff3cd", fg="#856404", font=("Arial", 9),
                 anchor="w", padx=8, justify=tk.LEFT).pack(fill=tk.X, padx=8, pady=4)

        ttk.Button(parent, text="💾 設定を保存 / 接続テスト",
                   command=self._test_connection).pack(padx=8, pady=4)

    # ── メール操作 ────────────────────────────────────────────────

    def _fetch_mail(self):
        host = self.imap_host_var.get().strip()
        if not host:
            messagebox.showwarning("警告", "設定タブでIMAPサーバーを設定してください")
            return
        self.recv_progress.start(10)
        self.status_var.set("メール取得中...")
        threading.Thread(target=self._do_fetch_mail, daemon=True).start()

    def _do_fetch_mail(self):
        try:
            host = self.imap_host_var.get().strip()
            port = int(self.imap_port_var.get() or 993)
            user = self.smtp_user_var.get().strip()
            passwd = self.smtp_pass_var.get()
            folder = self.folder_var.get()
            count = self.fetch_count_var.get()

            if self.imap_ssl_var.get():
                imap = imaplib.IMAP4_SSL(host, port)
            else:
                imap = imaplib.IMAP4(host, port)

            imap.login(user, passwd)
            imap.select(folder)

            # 最新N件のIDを取得
            _, data = imap.search(None, "ALL")
            ids = data[0].split()
            fetch_ids = ids[-count:] if len(ids) >= count else ids
            fetch_ids = list(reversed(fetch_ids))

            messages = []
            for uid in fetch_ids:
                _, msg_data = imap.fetch(uid, "(RFC822.SIZE FLAGS RFC822.HEADER)")
                if not msg_data or not msg_data[0]:
                    continue
                raw_header = None
                size = 0
                flags = ""
                for part in msg_data:
                    if isinstance(part, tuple):
                        info = part[0].decode()
                        if "RFC822.SIZE" in info:
                            import re
                            m = re.search(r"RFC822\.SIZE (\d+)", info)
                            if m:
                                size = int(m.group(1))
                        if "FLAGS" in info:
                            flags = info
                        raw_header = part[1]
                if raw_header is None:
                    continue
                try:
                    msg = email.message_from_bytes(raw_header)
                    from_ = self._decode_header(msg.get("From", ""))
                    subject = self._decode_header(msg.get("Subject", ""))
                    date = msg.get("Date", "")[:25]
                    unread = "\\Seen" not in flags
                    messages.append({
                        "uid": uid,
                        "from": from_,
                        "subject": subject,
                        "date": date,
                        "size": size,
                        "unread": unread,
                    })
                except Exception:
                    pass

            self._imap = imap
            self._messages = messages
            self.root.after(0, self._show_mail_list, messages)
        except Exception as e:
            self.root.after(0, self.recv_progress.stop)
            self.root.after(0, self.status_var.set, f"取得エラー: {e}")

    def _show_mail_list(self, messages):
        self.recv_progress.stop()
        self.mail_tree.delete(*self.mail_tree.get_children())
        for msg in messages:
            tag = "unread" if msg.get("unread") else "read"
            self.mail_tree.insert("", "end",
                                   iid=str(msg["uid"]),
                                   values=(msg["from"][:30],
                                           msg["subject"][:60],
                                           msg["date"],
                                           self._fmt_size(msg["size"])),
                                   tags=(tag,))
        self.status_var.set(f"{len(messages)} 件取得")

    def _on_mail_select(self, event):
        sel = self.mail_tree.selection()
        if not sel or not self._imap:
            return
        uid = sel[0].encode()
        threading.Thread(target=self._load_mail_body,
                         args=(uid,), daemon=True).start()

    def _load_mail_body(self, uid):
        try:
            _, data = self._imap.fetch(uid, "(RFC822)")
            if not data or not data[0]:
                return
            raw = data[0][1]
            msg = email.message_from_bytes(raw)
            from_ = self._decode_header(msg.get("From", ""))
            subject = self._decode_header(msg.get("Subject", ""))
            date = msg.get("Date", "")
            body = self._extract_body(msg)
            self.root.after(0, self._show_body, from_, subject, date, body)
        except Exception as e:
            self.root.after(0, self.status_var.set, f"本文取得エラー: {e}")

    def _extract_body(self, msg):
        body = ""
        if msg.is_multipart():
            for part in msg.walk():
                ct = part.get_content_type()
                cd = str(part.get("Content-Disposition") or "")
                if ct == "text/plain" and "attachment" not in cd:
                    payload = part.get_payload(decode=True)
                    if payload:
                        charset = part.get_content_charset() or "utf-8"
                        body = payload.decode(charset, errors="replace")
                        break
        else:
            payload = msg.get_payload(decode=True)
            if payload:
                charset = msg.get_content_charset() or "utf-8"
                body = payload.decode(charset, errors="replace")
        return body

    def _show_body(self, from_, subject, date, body):
        self.mail_header_var.set(f"From: {from_}  |  件名: {subject}  |  日付: {date}")
        self.body_text.config(state=tk.NORMAL)
        self.body_text.delete("1.0", tk.END)
        self.body_text.insert("1.0", body)
        self.body_text.config(state=tk.DISABLED)

    def _decode_header(self, value):
        if not value:
            return ""
        parts = decode_header(value)
        decoded = []
        for part, enc in parts:
            if isinstance(part, bytes):
                decoded.append(part.decode(enc or "utf-8", errors="replace"))
            else:
                decoded.append(part)
        return "".join(decoded)

    def _reply(self):
        sel = self.mail_tree.selection()
        if not sel:
            return
        uid = sel[0]
        msg_data = next((m for m in self._messages if str(m["uid"]) == uid), None)
        if msg_data:
            self.to_var.set(msg_data.get("from", ""))
            self.subject_var.set("Re: " + msg_data.get("subject", ""))

    def _delete_mail(self):
        sel = self.mail_tree.selection()
        if not sel:
            return
        if messagebox.askyesno("確認", "選択したメールを削除しますか?") and self._imap:
            for uid in sel:
                try:
                    self._imap.store(uid.encode(), "+FLAGS", "\\Deleted")
                except Exception:
                    pass
            try:
                self._imap.expunge()
            except Exception:
                pass
            self._fetch_mail()

    # ── 送信 ─────────────────────────────────────────────────────

    def _send_mail(self):
        to = self.to_var.get().strip()
        if not to:
            messagebox.showwarning("警告", "宛先を入力してください")
            return
        host = self.smtp_host_var.get().strip()
        if not host:
            messagebox.showwarning("警告", "設定タブでSMTPサーバーを設定してください")
            return
        self.send_progress.start(10)
        threading.Thread(target=self._do_send_mail, daemon=True).start()

    def _do_send_mail(self):
        try:
            host = self.smtp_host_var.get().strip()
            port = int(self.smtp_port_var.get() or 587)
            user = self.smtp_user_var.get().strip()
            passwd = self.smtp_pass_var.get()
            to = self.to_var.get().strip()
            cc = self.cc_var.get().strip()
            subject = self.subject_var.get().strip()
            body = self.send_body.get("1.0", tk.END).strip()

            msg = MIMEMultipart("alternative")
            msg["From"] = user
            msg["To"] = to
            if cc:
                msg["Cc"] = cc
            msg["Subject"] = subject
            msg.attach(MIMEText(body, "plain", "utf-8"))

            if self.smtp_tls_var.get():
                smtp = smtplib.SMTP(host, port, timeout=15)
                smtp.ehlo()
                smtp.starttls()
                smtp.ehlo()
            else:
                smtp = smtplib.SMTP_SSL(host, port, timeout=15)

            smtp.login(user, passwd)
            recipients = [to] + ([cc] if cc else [])
            smtp.sendmail(user, recipients, msg.as_string())
            smtp.quit()

            self.root.after(0, self.send_progress.stop)
            self.root.after(0, self.status_var.set, f"送信完了: {to}")
            self.root.after(0, messagebox.showinfo, "完了", "メールを送信しました")
        except Exception as e:
            self.root.after(0, self.send_progress.stop)
            self.root.after(0, messagebox.showerror, "送信エラー", str(e))

    def _clear_compose(self):
        self.to_var.set("")
        self.cc_var.set("")
        self.subject_var.set("")
        self.send_body.delete("1.0", tk.END)

    def _test_connection(self):
        host = self.smtp_host_var.get().strip()
        if not host:
            messagebox.showwarning("警告", "SMTPサーバーを入力してください")
            return
        self.status_var.set("接続テスト中...")
        threading.Thread(target=self._do_test_connection, daemon=True).start()

    def _do_test_connection(self):
        try:
            host = self.smtp_host_var.get().strip()
            port = int(self.smtp_port_var.get() or 587)
            if self.smtp_tls_var.get():
                smtp = smtplib.SMTP(host, port, timeout=10)
                smtp.ehlo()
                smtp.starttls()
            else:
                smtp = smtplib.SMTP_SSL(host, port, timeout=10)
            smtp.quit()
            self.root.after(0, self.status_var.set,
                            f"✅ SMTP接続成功: {host}:{port}")
        except Exception as e:
            self.root.after(0, self.status_var.set,
                            f"❌ SMTP接続失敗: {e}")

    def _fmt_size(self, size):
        if size < 1024:
            return f"{size}B"
        elif size < 1024 * 1024:
            return f"{size//1024}KB"
        return f"{size//1024//1024}MB"


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

5. コード解説

メールクライアント UIのコードを、実際に書かれている実装に沿って解説します。

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

App40 クラスにアプリの全機能をまとめています(メソッド21個・全496行)。__init__ ではタイトル「メールクライアント UI」とウィンドウサイズ 1000x857 を設定します。あわせて状態を保持する変数(self._imap)を初期化し、最後に _build_ui() で画面を組み立てます。

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

ウィジェット構成

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

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

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

「🔄 メールを取得」ボタン(_fetch_mail())・「↩ 返信」ボタン(_reply())・「🗑 削除」ボタン(_delete_mail())・「📤 送信」ボタン(_send_mail())・「🗑 クリア」ボタン(_clear_compose())・「💾 設定を保存 / 接続テスト」ボタン(_test_connection())を command= で接続しています。また bind("<<TreeviewSelect>>", ...) から _on_mail_select() を呼べるようにイベントも登録しています。

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

例外処理

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

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

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

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

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

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

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

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

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

    title("メールクライアント UI")・geometry("1000x857")・configure(bg="#f8f9fc")・minsize(1000, 857) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_fetch_mail()・_reply()・_delete_mail()・_send_mail()・_clear_compose()・_test_connection())につなぎます。bind("<<TreeviewSelect>>", ...) の登録も忘れずに。

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

    アプリの本体である _show_mail_list()(13行)・_show_body()(6行)・_delete_mail()(15行)など を実装します。

  7. 7
    動作確認する

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

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

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

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

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

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

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

💡 配色をまとめて管理する

背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

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

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

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

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

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

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

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

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。どちらの課題も、自分のメールアカウントに接続できる状態が前提です。試すのは自分宛てに送ったテストメールだけにしてください。練習中に「🗑 削除」は押さないでください(\Deleted を付けて expunge() するため、サーバーからメールが消えます)。Gmail などは通常のパスワードではなくアプリパスワードが必要です。このアプリは設定をファイルに保存せず、起動のたびに入力する作りなので、パスワードをコードやテキストファイルへ書き出す改造は足さないでください。追加のライブラリは要りません。

  1. 課題1:返信ボタンを押しても画面が変わらない

    「↩ 返信」は宛先と件名を送信タブへ入れますが、画面は受信タブのままです。何も起きていないように見えるので、押したら送信タブへ切り替わるようにしてください。

    期待結果:メールを取得して一覧から1件選び、「↩ 返信」を押す。画面は受信タブのままで、ステータスバーも変わらない。自分で「✉ 送信」タブを開くと、宛先には差出人、件名には Re: の付いた件名が入っている。直したあとは、押した時点で送信タブへ切り替わる。

    合格条件

    • 1件も選んでいないときは、これまでどおり何も起きない
    • 切り替わった送信タブに、選んだメールの差出人が宛先として入っている
    • 本文欄は空のままで、勝手に文章が入らない

    ヒント①(どこを触るか): 触るのは2か所です。_build_ui() の notebook = ttk.Notebook(self.root) のすぐ下と、_reply() の末尾です。
    ヒント②(使うもの): self.notebook = notebook の1行を足すと、_reply() から self.notebook.select(1) で送信タブを開けます。タブは add() した順に 0・1・2 で、送信タブは2番目です。
    つまずきやすい点: notebook は _build_ui() の中のローカル変数です。_reply() にそのまま notebook.select(1) と書くと NameError: name 'notebook' is not defined になります。件名は元が Re: ... でも先頭にもう一度 Re: が付き、Re: Re: ... と重なります。宛先に入るのは 名前 <アドレス> の形の文字列です。

  2. 課題2:接続テストが送信側しか見ていない

    「💾 設定を保存 / 接続テスト」は SMTP だけを試します。IMAP の設定が誤っていても成功の表示が出て、受信のときに初めて分かります。受信側も確かめてください。なお、このボタンは名前に反して設定を保存しません。パスワードが平文で残るため、保存する機能は足さないでください。

    期待結果:設定タブの IMAPサーバー欄をわざと存在しない名前に変えて、「💾 設定を保存 / 接続テスト」を押す。ステータスバーに出るのは ✅ SMTP接続成功: ... か ❌ SMTP接続失敗: ... のどちらかだけで、IMAP のことは1文字も出ない。直したあとは、IMAP 側の結果も続けて分かる。

    合格条件

    • SMTPサーバー欄が空のときは、これまでどおり警告だけでテストが始まらない
    • IMAP の確認ではログインをせず、つながるかどうかだけを見る
    • SMTP と IMAP のどちらが失敗したのか、文面で区別できる

    ヒント①(どこを触るか): 触るのは _do_test_connection() の中の1か所です。
    ヒント②(使うもの): imaplib.IMAP4_SSL(host, port) でつなぎ、logout() で切ります。login() を呼ばなければパスワードは送りません。ポートは int(self.imap_port_var.get() or 993) の形で数値にします(_do_fetch_mail() と同じ書き方です)。
    つまずきやすい点: _do_test_connection() は別スレッドで動くため、表示は self.root.after(0, ...) 経由にします。SSL のチェックを外したときは imaplib.IMAP4 を使うことになりますが、その場合パスワードは暗号化されません。応答の返らないホスト名を入れると、SMTP 側の timeout=10 の分だけ待たされます。

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

    入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。

🚀
次に挑戦するアプリ

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

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

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

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

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