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