Bulk Email Tool
TQ BulkEmailer v1.0
───────────────────────────────────────────────────────────────────
Sends personalized bulk emails via Outlook COM automation.
• Reads recipient list from an Excel file (.xlsx)
• Parses <<placeholders>> from a Word template (.docx)
• Placeholders in subject line AND body are both optional
• Unmapped / missing placeholders silently become blank
• Sends through the user's active Outlook session (no credentials)
• CC supports multiple addresses separated by ; or ,
───────────────────────────────────────────────────────────────────

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import threading
import re
import os
from pathlib import Path
from datetime import datetime
# ── Dependency check (friendly error before anything else) ────────────────────
_missing = []
for _pkg in ("pandas", "docx", "win32com", "openpyxl"):
try:
__import__(_pkg)
except ImportError:
_missing.append(_pkg)
if _missing:
import tkinter as _tk
from tkinter import messagebox as _mb
_r = _tk.Tk(); _r.withdraw()
_mb.showerror(
"Missing Dependencies",
"Please install required packages and relaunch:\n\n"
" pip install pandas openpyxl python-docx pywin32\n\n"
f"Missing: {', '.join(_missing)}"
)
raise SystemExit(1)
import pandas as pd
from docx import Document
import win32com.client as win32
import pythoncom
# ── Colour palette ────────────────────────────────────────────────────────────
BG = "#1C1C2E"
SURFACE = "#252538"
PANEL = "#2A2A42"
BORDER = "#3D3D5C"
ACCENT = "#7B68EE"
SEL = "#4B46A0"
OK = "#3DDC84"
ERR = "#FF6B6B"
WARN = "#FFD166"
FG = "#E8E6FF"
FG2 = "#9896C8"
INP = "#16162A"
WHITE = "#FFFFFF"
# ── Core helpers ──────────────────────────────────────────────────────────────
def extract_placeholders(text: str) -> list:
"""Return sorted unique placeholder names found in <<name>> patterns."""
return sorted(set(re.findall(r"<<([^>]+)>>", text)))
def merge_text(template: str, mapping: dict) -> str:
"""
Replace every <<key>> in template with its mapped value.
Placeholders with no mapping quietly become an empty string.
"""
result = template
for ph in extract_placeholders(template):
result = result.replace(f"<<{ph}>>", str(mapping.get(ph, "")) )
return result
def fmt_cell(val) -> str:
"""Convert a DataFrame cell to a clean string; format dates readably."""
try:
if pd.isna(val):
return ""
except (TypeError, ValueError):
pass
if hasattr(val, "strftime"): # datetime / Timestamp
return val.strftime("%m/%d/%Y")
return str(val).strip()
def read_excel_df(path: str) -> pd.DataFrame:
df = pd.read_excel(path)
df.columns = [str(c).strip() for c in df.columns]
return df
def read_docx_text(path: str) -> str:
"""Extract full text from a .docx file, preserving paragraph breaks."""
doc = Document(path)
return "\n".join(p.text for p in doc.paragraphs)
def parse_cc(raw: str) -> list:
"""Split a ; or , separated CC string into a clean list."""
if not raw.strip():
return []
return [p.strip() for p in re.split(r"[;,]", raw) if p.strip()]
def get_outlook_info() -> tuple:
"""
Return (display_name, smtp_address) of the default Outlook account.
Returns ("", "") if Outlook is unavailable.
"""
try:
ol = win32.Dispatch("Outlook.Application")
ns = ol.GetNamespace("MAPI")
acct = ns.Accounts.Item(1)
return str(acct.DisplayName), str(acct.SmtpAddress)
except Exception:
return "", ""
def send_one_outlook(to_addr: str, cc_addrs: list,
subject: str, body: str, attachments: list) -> None:
"""
Send a single email via Outlook COM.
Caller must have already called pythoncom.CoInitialize() on this thread.
"""
ol = win32.Dispatch("Outlook.Application")
mail = ol.CreateItem(0) # 0 = olMailItem
mail.To = to_addr
mail.Subject = subject
mail.Body = body
if cc_addrs:
mail.CC = "; ".join(cc_addrs)
for att in attachments:
mail.Attachments.Add(os.path.abspath(att))
mail.Send()
# ── Reusable widget helpers ───────────────────────────────────────────────────
'''
def make_btn(parent, text, cmd, bg=ACCENT, fg=WHITE, **kw):
return tk.Button(
parent, text=text, command=cmd, bg=bg, fg=fg,
relief="flat", font=("Segoe UI", 9, "bold"),
padx=12, pady=6, cursor="hand2",
activebackground=SEL, activeforeground=WHITE, **kw
)
'''
def make_btn(parent, text, cmd, bg=ACCENT, fg=WHITE, **kw):
kw.setdefault('padx', 12)
kw.setdefault('pady', 6)
return tk.Button(
parent, text=text, command=cmd, bg=bg, fg=fg,
relief="flat", font=("Segoe UI", 9, "bold"),
cursor="hand2",
activebackground=SEL, activeforeground=WHITE, **kw
)
def section_lbl(parent, text, **kw):
return tk.Label(parent, text=text, bg=SURFACE, fg=FG2,
font=("Segoe UI", 8, "bold"), **kw)
def input_entry(parent, textvariable=None, **kw):
return tk.Entry(
parent, bg=INP, fg=FG, insertbackground=FG,
relief="flat", font=("Segoe UI", 10),
highlightthickness=1, highlightbackground=BORDER,
highlightcolor=ACCENT, textvariable=textvariable, **kw
)
def hint_lbl(parent, text):
return tk.Label(parent, text=text, bg=SURFACE, fg=FG2,
font=("Segoe UI", 8, "italic"))
# ── Preview dialog ────────────────────────────────────────────────────────────
class PreviewDialog(tk.Toplevel):
def __init__(self, parent, to_addr: str, cc: str, subject: str, body: str):
super().__init__(parent)
self.title("Preview — first recipient")
self.geometry("700x580")
self.configure(bg=BG)
self.resizable(True, True)
self.grab_set()
# Header
hdr = tk.Frame(self, bg=SURFACE, pady=10, padx=14)
hdr.pack(fill="x")
tk.Label(hdr, text="👁 Email Preview (first recipient)",
bg=SURFACE, fg=FG, font=("Segoe UI", 11, "bold")).pack(anchor="w")
for label, value in [("To", to_addr), ("CC", cc or "—"), ("Subject", subject)]:
tk.Label(hdr, text=f"{label+':':<10}{value}",
bg=SURFACE, fg=FG2, font=("Consolas", 9)).pack(anchor="w", pady=1)
# Body
body_txt = scrolledtext.ScrolledText(
self, bg=INP, fg=FG, font=("Segoe UI", 10),
wrap="word", relief="flat", padx=14, pady=10,
insertbackground=FG
)
body_txt.insert("1.0", body)
body_txt.config(state="disabled")
body_txt.pack(fill="both", expand=True, padx=14, pady=10)
make_btn(self, " Close ", self.destroy).pack(pady=(0, 12))
# ── Main application ──────────────────────────────────────────────────────────
class BulkEmailApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("TQ BulkEmailer v1.0")
self.geometry("1120x800")
self.minsize(940, 680)
self.configure(bg=BG)
# ── State ──────────────────────────────────────────────────────────
self.df: pd.DataFrame | None = None
self.template_text: str = ""
self.placeholders: list = []
self.attachments: list = []
self.ph_col_vars: dict = {} # { placeholder_name : tk.StringVar }
self.email_col_var = tk.StringVar()
self.subject_var = tk.StringVar()
self.cc_var = tk.StringVar()
self.sending = False
self._apply_styles()
self._build_ui()
self.after(200, self._probe_outlook) # probe after window is up
# ── Outlook probe ─────────────────────────────────────────────────────────
def _probe_outlook(self):
name, addr = get_outlook_info()
if addr:
self.sender_lbl.config(
text=f"✉ Sending as: {name} <{addr}>", fg=OK)
self._log(f"Outlook connected — sending as: {addr}", "ok")
else:
self.sender_lbl.config(
text="⚠ Outlook not detected — please open Outlook and relaunch", fg=ERR)
self._log("Outlook not detected. Open Outlook desktop first.", "err")
# ── ttk style ─────────────────────────────────────────────────────────────
def _apply_styles(self):
s = ttk.Style(self)
s.theme_use("clam")
s.configure("Dark.Treeview",
background=INP, foreground=FG,
fieldbackground=INP, borderwidth=0,
font=("Segoe UI", 9), rowheight=24)
s.configure("Dark.Treeview.Heading",
background=PANEL, foreground=FG2,
font=("Segoe UI", 8, "bold"), relief="flat")
s.map("Dark.Treeview",
background=[("selected", SEL)],
foreground=[("selected", WHITE)])
s.configure("Accent.Horizontal.TProgressbar",
troughcolor=PANEL, background=ACCENT,
borderwidth=0, thickness=10)
s.configure("TCombobox",
fieldbackground=INP, background=PANEL,
foreground=FG, selectbackground=SEL, arrowcolor=FG2)
s.map("TCombobox", fieldbackground=[("readonly", INP)])
# ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self):
self._build_topbar()
body = tk.Frame(self, bg=BG)
body.pack(fill="both", expand=True, padx=16, pady=10)
self._build_file_row(body)
self._build_mid(body)
self._build_compose(body)
self._build_actions(body)
self._build_log(body)
# Top bar — app title + sender display
def _build_topbar(self):
bar = tk.Frame(self, bg=SURFACE, height=52)
bar.pack(fill="x")
bar.pack_propagate(False)
tk.Label(bar, text="✉ TQ BulkEmailer",
bg=SURFACE, fg=FG, font=("Segoe UI", 14, "bold")).pack(side="left", padx=16)
self.sender_lbl = tk.Label(bar, text="Checking Outlook…",
bg=SURFACE, fg=FG2, font=("Segoe UI", 9))
self.sender_lbl.pack(side="right", padx=16)
# File loading buttons + status labels
def _build_file_row(self, parent):
frm = tk.Frame(parent, bg=BG)
frm.pack(fill="x", pady=(0, 8))
make_btn(frm, "📂 Load Mail List", self._load_excel, bg=PANEL).pack(side="left")
self.xl_lbl = tk.Label(frm, text="No file loaded", bg=BG, fg=FG2, font=("Segoe UI", 9))
self.xl_lbl.pack(side="left", padx=10)
make_btn(frm, "📄 Load Template", self._load_template, bg=PANEL).pack(side="left", padx=(28, 0))
self.tpl_lbl = tk.Label(frm, text="No file loaded", bg=BG, fg=FG2, font=("Segoe UI", 9))
self.tpl_lbl.pack(side="left", padx=10)
# Middle section: recipient table (left) + mapping panel (right)
def _build_mid(self, parent):
frm = tk.Frame(parent, bg=BG)
frm.pack(fill="both", expand=True, pady=(0, 8))
frm.columnconfigure(0, weight=55)
#frm.columnconfigure(1, weight=45)
frm.columnconfigure(1, weight=45, minsize=340)
frm.rowconfigure(0, weight=1)
# ── Recipient preview table ────────────────────────────────────────
left = tk.Frame(frm, bg=SURFACE)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
section_lbl(left, " RECIPIENTS").pack(anchor="w", padx=10, pady=(6, 2))
tree_wrap = tk.Frame(left, bg=SURFACE)
tree_wrap.pack(fill="both", expand=True, padx=8, pady=(0, 8))
self.tree = ttk.Treeview(tree_wrap, style="Dark.Treeview",
show="headings", selectmode="browse")
vsb = ttk.Scrollbar(tree_wrap, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(tree_wrap, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
vsb.pack(side="right", fill="y")
hsb.pack(side="bottom", fill="x")
self.tree.pack(fill="both", expand=True)
# ── Placeholder mapping panel ──────────────────────────────────────
right = tk.Frame(frm, bg=SURFACE)
right.grid(row=0, column=1, sticky="nsew", padx=(6, 0))
section_lbl(right, " PLACEHOLDER MAPPING").pack(anchor="w", padx=10, pady=(6, 2))
tk.Label(right,
text="All mappings are optional — unmapped placeholders become blank",
bg=SURFACE, fg=FG2, font=("Segoe UI", 8, "italic")
).pack(anchor="w", padx=10, pady=(0, 6))
# Email-to column selector (always visible, highlighted in warning colour)
email_box = tk.Frame(right, bg=PANEL)
email_box.pack(fill="x", padx=8, pady=(0, 8))
tk.Label(email_box, text="📧 Send To (email column)",
bg=PANEL, fg=WARN, font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(6, 2))
self.email_col_cb = ttk.Combobox(
email_box, textvariable=self.email_col_var,
state="readonly", font=("Segoe UI", 9))
self.email_col_cb.pack(fill="x", padx=8, pady=(0, 8))
# Scrollable canvas for <<placeholder>> → column rows
canvas_frm = tk.Frame(right, bg=SURFACE)
canvas_frm.pack(fill="both", expand=True, padx=(8, 0), pady=(0, 8))
self.map_canvas = tk.Canvas(canvas_frm, bg=SURFACE, highlightthickness=0)
map_scroll = ttk.Scrollbar(canvas_frm, orient="vertical",
command=self.map_canvas.yview)
self.map_canvas.configure(yscrollcommand=map_scroll.set)
map_scroll.pack(side="right", fill="y", padx=(0, 4))
self.map_canvas.pack(fill="both", expand=True)
self.map_inner = tk.Frame(self.map_canvas, bg=SURFACE)
self._map_win = self.map_canvas.create_window(
(0, 0), window=self.map_inner, anchor="nw")
self.map_inner.bind("<Configure>",
lambda _e: self.map_canvas.configure(
scrollregion=self.map_canvas.bbox("all")))
self.map_canvas.bind("<Configure>",
lambda e: self.map_canvas.itemconfig(self._map_win, width=e.width))
self._map_show_hint()
# Compose area: Subject, CC, Attachments
def _build_compose(self, parent):
frm = tk.Frame(parent, bg=SURFACE)
frm.pack(fill="x", pady=(0, 8))
grid = tk.Frame(frm, bg=SURFACE)
grid.pack(fill="x", padx=12, pady=10)
grid.columnconfigure(1, weight=1)
def row_label(text, r):
tk.Label(grid, text=text, bg=SURFACE, fg=FG2,
font=("Segoe UI", 9)).grid(
row=r, column=0, sticky="e", padx=(0, 10), pady=5)
# Subject
row_label("Subject", 0)
subj_wrap = tk.Frame(grid, bg=SURFACE)
subj_wrap.grid(row=0, column=1, sticky="ew", pady=5)
input_entry(subj_wrap, textvariable=self.subject_var).pack(fill="x")
hint_lbl(subj_wrap,
"Tip: placeholders like <<Branch>> work here too").pack(anchor="w", pady=(2, 0))
# CC
row_label("CC", 1)
cc_wrap = tk.Frame(grid, bg=SURFACE)
cc_wrap.grid(row=1, column=1, sticky="ew", pady=5)
input_entry(cc_wrap, textvariable=self.cc_var).pack(fill="x")
hint_lbl(cc_wrap,
"Separate multiple addresses with ; or ,").pack(anchor="w", pady=(2, 0))
# Attachments
row_label("Attachments", 2)
att_frm = tk.Frame(grid, bg=SURFACE)
att_frm.grid(row=2, column=1, sticky="ew", pady=5)
make_btn(att_frm, "+ Add", self._add_attachment, bg=PANEL, pady=4).pack(side="left")
make_btn(att_frm, "− Remove", self._remove_attachment, bg=PANEL, pady=4).pack(side="left", padx=6)
self.att_lbl = tk.Label(att_frm, text="None", bg=SURFACE, fg=FG2, font=("Segoe UI", 9))
self.att_lbl.pack(side="left", padx=8)
# Action row: Preview / Send / Clear + progress
def _build_actions(self, parent):
frm = tk.Frame(parent, bg=BG)
frm.pack(fill="x", pady=(0, 6))
self.count_lbl = tk.Label(frm, text="", bg=BG, fg=FG2, font=("Segoe UI", 9))
self.count_lbl.pack(side="left")
make_btn(frm, "✖ Clear All", self._clear_all, bg=PANEL).pack(side="right")
self.send_btn = make_btn(frm, "▶ Send All", self._confirm_send, bg=ACCENT)
self.send_btn.pack(side="right", padx=6)
make_btn(frm, "👁 Preview", self._preview, bg=PANEL).pack(side="right", padx=(0, 6))
self.prog_lbl = tk.Label(frm, text="", bg=BG, fg=FG2, font=("Segoe UI", 9))
self.prog_lbl.pack(side="right", padx=(0, 6))
self.progress = ttk.Progressbar(
frm, style="Accent.Horizontal.TProgressbar", mode="determinate", length=160)
self.progress.pack(side="right", padx=(0, 4))
# Log panel
def _build_log(self, parent):
frm = tk.Frame(parent, bg=SURFACE)
frm.pack(fill="x", pady=(0, 4))
section_lbl(frm, " LOG").pack(anchor="w", padx=10, pady=(6, 2))
self.log_box = scrolledtext.ScrolledText(
frm, height=7, bg=INP, fg=FG,
font=("Consolas", 9), relief="flat",
padx=10, pady=6, wrap="word",
insertbackground=FG, state="disabled"
)
self.log_box.pack(fill="x", padx=8, pady=(0, 8))
self.log_box.tag_config("ok", foreground=OK)
self.log_box.tag_config("err", foreground=ERR)
self.log_box.tag_config("warn", foreground=WARN)
self.log_box.tag_config("info", foreground=FG2)
# ── File loading ──────────────────────────────────────────────────────────
def _load_excel(self):
path = filedialog.askopenfilename(
title="Select Mail List (Excel)",
filetypes=[("Excel files", "*.xlsx *.xls")]
)
if not path:
return
try:
self.df = read_excel_df(path)
cols = list(self.df.columns)
self._populate_tree(cols)
self._refresh_column_dropdowns(cols)
self.xl_lbl.config(
text=f"{Path(path).name} ({len(self.df)} recipients)", fg=OK)
self.count_lbl.config(text=f"{len(self.df)} recipient(s)")
self._log(
f"Loaded: {Path(path).name} — {len(self.df)} rows | "
f"Columns: {', '.join(cols)}", "ok")
except Exception as exc:
messagebox.showerror("Excel Error", str(exc))
self._log(f"Failed to load Excel: {exc}", "err")
def _load_template(self):
path = filedialog.askopenfilename(
title="Select Email Template (Word)",
filetypes=[("Word Documents", "*.docx")]
)
if not path:
return
try:
self.template_text = read_docx_text(path)
self.placeholders = extract_placeholders(self.template_text)
ph_str = (", ".join(f"<<{p}>>" for p in self.placeholders)
if self.placeholders else "none found")
self.tpl_lbl.config(
text=f"{Path(path).name} ({len(self.placeholders)} placeholder(s): {ph_str})",
fg=OK)
self._build_mapping_rows()
self._log(
f"Loaded template: {Path(path).name} — Placeholders: {ph_str}", "ok")
except Exception as exc:
messagebox.showerror("Template Error", str(exc))
self._log(f"Failed to load template: {exc}", "err")
# ── Tree ──────────────────────────────────────────────────────────────────
def _populate_tree(self, cols: list):
self.tree.delete(*self.tree.get_children())
self.tree["columns"] = cols
for c in cols:
self.tree.heading(c, text=c)
self.tree.column(c, width=120, minwidth=80, stretch=True)
for _, row in self.df.iterrows():
self.tree.insert("", "end", values=[fmt_cell(v) for v in row])
# ── Mapping panel ─────────────────────────────────────────────────────────
def _refresh_column_dropdowns(self, cols: list):
"""Update email-column combobox; rebuild mapping rows if template loaded."""
self.email_col_cb["values"] = cols
if not self.email_col_var.get(): # auto-detect email column
for c in cols:
if "email" in c.lower():
self.email_col_var.set(c)
break
if self.placeholders:
self._build_mapping_rows()
def _map_show_hint(self):
tk.Label(self.map_inner,
text="Load a template to see placeholders",
bg=SURFACE, fg=FG2,
font=("Segoe UI", 9, "italic")).pack(pady=20)
def _build_mapping_rows(self):
"""Rebuild one dropdown row per placeholder in the mapping canvas."""
for w in self.map_inner.winfo_children():
w.destroy()
self.ph_col_vars.clear()
cols = list(self.df.columns) if self.df is not None else []
if not self.placeholders:
tk.Label(self.map_inner,
text="No <<placeholders>> found in template.",
bg=SURFACE, fg=FG2, font=("Segoe UI", 9, "italic")).pack(pady=16)
return
for ph in self.placeholders:
row = tk.Frame(self.map_inner, bg=SURFACE)
row.pack(fill="x", pady=4, padx=4)
# Placeholder badge
tk.Label(row, text=f"<<{ph}>>",
bg=PANEL, fg=WARN, font=("Consolas", 9, "bold"),
padx=8, pady=5, width=20, anchor="w").pack(side="left")
tk.Label(row, text=" →", bg=SURFACE, fg=FG2,
font=("Segoe UI", 9)).pack(side="left")
var = tk.StringVar()
cb = ttk.Combobox(row, textvariable=var,
values=["(not mapped)"] + cols,
state="readonly", width=24, font=("Segoe UI", 9))
best = self._suggest_column(ph, cols)
var.set(best if best else "(not mapped)")
cb.pack(side="left", padx=8)
self.ph_col_vars[ph] = var
def _suggest_column(self, ph: str, cols: list) -> str:
"""
Try to auto-match a placeholder to the best column name.
Exact match first, then substring match. Returns "" if no guess.
"""
ph_key = ph.lower().replace(" ", "").replace("_", "")
for col in cols:
col_key = col.lower().replace(" ", "").replace("_", "")
if ph_key == col_key:
return col
for col in cols:
col_key = col.lower().replace(" ", "").replace("_", "")
if ph_key in col_key or col_key in ph_key:
return col
return ""
# ── Merge helpers ─────────────────────────────────────────────────────────
def _row_mapping(self, row) -> dict:
"""Build {placeholder: resolved_value} dict for one DataFrame row."""
mapping = {}
for ph, var in self.ph_col_vars.items():
col = var.get()
if col and col != "(not mapped)" and col in self.df.columns:
mapping[ph] = fmt_cell(row[col])
else:
mapping[ph] = "" # unmapped → empty (silently)
return mapping
# ── Attachments ───────────────────────────────────────────────────────────
def _add_attachment(self):
paths = filedialog.askopenfilenames(title="Select file(s) to attach")
if paths:
self.attachments.extend(paths)
self._refresh_att_label()
for p in paths:
self._log(f"Attachment added: {Path(p).name}", "info")
def _remove_attachment(self):
if self.attachments:
removed = self.attachments.pop()
self._refresh_att_label()
self._log(f"Attachment removed: {Path(removed).name}", "info")
def _refresh_att_label(self):
n = len(self.attachments)
if n == 0:
self.att_lbl.config(text="None", fg=FG2)
else:
names = ", ".join(Path(p).name for p in self.attachments)
self.att_lbl.config(text=f"{n} file(s): {names}", fg=OK)
# ── Preview ───────────────────────────────────────────────────────────────
def _preview(self):
if self.df is None or not self.template_text:
messagebox.showwarning("Not Ready", "Load a mail list and a template first.")
return
if not self.email_col_var.get():
messagebox.showwarning("Not Ready", "Please select the email column.")
return
row = self.df.iloc[0]
mapping = self._row_mapping(row)
to_addr = fmt_cell(row[self.email_col_var.get()])
subject = merge_text(self.subject_var.get(), mapping)
body = merge_text(self.template_text, mapping)
cc_list = parse_cc(self.cc_var.get())
PreviewDialog(self, to_addr=to_addr, cc="; ".join(cc_list),
subject=subject, body=body)
# ── Send flow ─────────────────────────────────────────────────────────────
def _confirm_send(self):
if self.sending:
return
if self.df is None or len(self.df) == 0:
messagebox.showwarning("Not Ready", "No mail list loaded.")
return
if not self.template_text:
messagebox.showwarning("Not Ready", "No email template loaded.")
return
if not self.email_col_var.get():
messagebox.showwarning("Not Ready", "Please select the email column.")
return
if not self.subject_var.get().strip():
messagebox.showwarning("Not Ready", "Subject line cannot be empty.")
return
_, sender = get_outlook_info()
n = len(self.df)
go = messagebox.askyesno(
"Confirm Send",
f"You are about to send {n} personalised email(s).\n\n"
f"From: {sender or 'your Outlook account'}\n"
f"Subject: {self.subject_var.get()}\n"
f"CC: {self.cc_var.get() or '—'}\n\n"
"This cannot be undone. Proceed?"
)
if go:
self._start_send()
def _start_send(self):
self.sending = True
self.send_btn.config(state="disabled", text="Sending…")
self.progress["maximum"] = len(self.df)
self.progress["value"] = 0
self.prog_lbl.config(text="")
threading.Thread(target=self._send_thread, daemon=True).start()
def _send_thread(self):
"""Worker thread — COM must be initialised per-thread."""
pythoncom.CoInitialize()
sent = failed = 0
total = len(self.df)
cc_list = parse_cc(self.cc_var.get())
for i, (_, row) in enumerate(self.df.iterrows(), start=1):
to_addr = fmt_cell(row[self.email_col_var.get()])
mapping = self._row_mapping(row)
subject = merge_text(self.subject_var.get(), mapping)
body = merge_text(self.template_text, mapping)
try:
send_one_outlook(to_addr, cc_list, subject, body, self.attachments)
sent += 1
self.after(0, self._log, f"✓ {to_addr}", "ok")
except Exception as exc:
failed += 1
self.after(0, self._log, f"✗ {to_addr} — {exc}", "err")
self.after(0, self._tick, i, total)
pythoncom.CoUninitialize()
self.after(0, self._send_done, sent, failed)
def _tick(self, done: int, total: int):
self.progress["value"] = done
self.prog_lbl.config(text=f"{done} / {total}")
def _send_done(self, sent: int, failed: int):
self.sending = False
self.send_btn.config(state="normal", text="▶ Send All")
self.prog_lbl.config(text="Done")
summary = f"{sent} sent" + (f", {failed} failed" if failed else "")
self._log(f"{'─' * 50}", "info")
self._log(f"Completed — {summary}", "warn" if failed else "ok")
messagebox.showinfo("Done", f"Bulk email complete.\n\n{summary}")
# ── Clear ─────────────────────────────────────────────────────────────────
def _clear_all(self):
if self.sending:
return
self.df = None
self.template_text = ""
self.placeholders = []
self.attachments = []
self.ph_col_vars.clear()
self.tree.delete(*self.tree.get_children())
self.tree["columns"] = []
self.xl_lbl.config(text="No file loaded", fg=FG2)
self.tpl_lbl.config(text="No file loaded", fg=FG2)
self.att_lbl.config(text="None", fg=FG2)
self.count_lbl.config(text="")
self.subject_var.set("")
self.cc_var.set("")
self.email_col_var.set("")
self.email_col_cb["values"] = []
self.progress["value"] = 0
self.prog_lbl.config(text="")
for w in self.map_inner.winfo_children():
w.destroy()
self._map_show_hint()
self._log("Cleared.", "info")
# ── Log ───────────────────────────────────────────────────────────────────
def _log(self, msg: str, tag: str = "info"):
ts = datetime.now().strftime("%H:%M:%S")
self.log_box.config(state="normal")
self.log_box.insert("end", f"[{ts}] {msg}\n", tag)
self.log_box.see("end")
self.log_box.config(state="disabled")
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
app = BulkEmailApp()
app.mainloop()