メールクライアント UI
SMTPでメールを送信・IMAPで受信できるシンプルなメールクライアント。smtplibとimaplibの実践活用を学びます。
1. アプリ概要
SMTPでメールを送信・IMAPで受信できるシンプルなメールクライアント。smtplibとimaplibの実践活用を学びます。
このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★★ です。
このアプリは「ネットワーク」カテゴリです。ネットワーク連携は Python の代表的な活用領域で、I/O 待ちと UI 更新の関係や非同期処理の考え方が他の Web API 連携にも直接活きます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。
動かしながら読むことが理解の最短経路です。まずはコードをコピーして実行し、想定どおりに動くことを確認したうえで解説と照らし合わせてください。
カスタマイズでは「機能追加」「UI 改善」「エラー耐性」の三方向で考えると視野が広がります。練習問題にもそれぞれの方向の具体例を用意しています。
2. 機能一覧
- メールクライアント UIのメイン機能
- 直感的なGUIインターフェース
- 入力値のバリデーション
- エラーハンドリング
- 結果の見やすい表示
- キーボードショートカット対応
3. 事前準備・環境
Python 3.10 以上 / Windows・Mac・Linux すべて対応
以下の環境で動作確認しています。
- Python 3.10 以上
- OS: Windows 10/11・macOS 12+・Ubuntu 20.04+
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
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("1000x680")
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=smtp_f.cget("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=imap_f.cget("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クラスにアプリの全機能をまとめています。__init__メソッドでウィンドウの基本設定を行い、_build_ui()でUI構築、process()でメイン処理を担当します。この分離により、各メソッドの責任が明確になりコードが読みやすくなります。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
LabelFrameによるセクション分け
ttk.LabelFrame を使うことで、入力エリアと結果エリアを視覚的に分けられます。padding引数でフレーム内の余白を設定し、見やすいレイアウトを実現しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
Entryウィジェットとイベントバインド
ttk.Entryで入力フィールドを作成します。bind('
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
Textウィジェットでの結果表示
結果表示にはtk.Textウィジェットを使います。state=tk.DISABLEDでユーザーが直接編集できないようにし、表示前にNORMALに切り替えてからinsert()で内容を更新します。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理とmessagebox
try-except で ValueError と Exception を捕捉し、messagebox.showerror() でユーザーにわかりやすいエラーメッセージを表示します。入力バリデーションは必ず実装しましょう。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app40.py と保存します。
-
2クラスの骨格を作る
App40クラスを定義し、__init__とmainloop()の最小構成を作ります。
-
3タイトルバーを作る
Frameを使ってカラーバー付きのタイトルエリアを作ります。
-
4入力フォームを実装する
LabelFrameとEntryウィジェットで入力エリアを作ります。
-
5処理ロジックを実装する
_calculate()メソッドに計算・処理ロジックを実装します。
-
6結果表示を実装する
TextウィジェットかLabelに結果を表示する_show_result()を実装します。
-
7エラー処理を追加する
try-exceptとmessageboxでエラーハンドリングを追加します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 データのエクスポート機能
計算結果をCSV・TXTファイルに保存するエクスポート機能を追加しましょう。filedialog.asksaveasfilename()でファイル保存ダイアログが使えます。
💡 入力履歴機能
以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。
8. よくある問題と解決法
❌ 日本語フォントが表示されない
原因:システムに日本語フォントが見つからない場合があります。
解決法:font引数を省略するかシステムに合ったフォントを指定してください。
❌ ウィンドウのサイズが変更できない
原因:resizable(False, False)が設定されています。
解決法:resizable(True, True)に変更してください。
9. 練習問題
アプリの理解を深めるための練習問題です。難易度順に挑戦してみてください。
-
課題1:機能拡張
メールクライアント UIに新しい機能を1つ追加してみましょう。どんな機能があると便利か考えてから実装してください。
-
課題2:UIの改善
色・フォント・レイアウトを変更して、より使いやすいUIにカスタマイズしてみましょう。
-
課題3:保存機能の追加
入力値や計算結果をファイルに保存する機能を追加しましょう。jsonやcsvモジュールを使います。