邮件群发系统

公司财务要群发邮件,但是公司的邮件系统又不支持群发。所以就有了下面的代码:
#!/usr/bin/env python3
# encoding: utf-8
"""
一个简单的图形界面邮件发送工具
适合不懂代码的同学,点按钮就能发邮件(支持附件、Excel 群发)。
"""

import os
import sys
import logging
import smtplib
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import urllib.request
import json
import email.utils as eut
from datetime import datetime, timezone, timedelta
from typing import Optional
import time

from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.header import Header
from email.utils import parseaddr, formataddr
from email import encoders

from openpyxl import load_workbook

import config


logger = logging.getLogger(__name__)


def _format_addr(s):
    """把邮件地址里的名字转成 utf-8,避免中文乱码"""
    name, addr = parseaddr(s)
    return formataddr((Header(name, 'utf-8').encode(), addr))


# =====================  到期限制配置(只改这一行)  =====================
# 示例格式:
# "2025-12-31 23:59:59 +0800"
# "2025-12-31 23:59:59"
# "2025/12/31 23:59:59"
# "2025-12-31"
EXPIRY_STR = "2026-12-31 23:59:59 +0800"
# =======================================================================


def _parse_expiry(s: str) -> datetime:
    """解析配置中的到期时间字符串, 统一转成 UTC 时间"""
    s = s.strip()
    fmts = [
        "%Y-%m-%d %H:%M:%S %z",
        "%Y/%m/%d %H:%M:%S %z",
        "%Y-%m-%d %H:%M:%S",
        "%Y/%m/%d %H:%M:%S",
        "%Y-%m-%d",
        "%Y/%m/%d",
    ]
    last_err = None
    for fmt in fmts:
        try:
            dt = datetime.strptime(s, fmt)
            if dt.tzinfo is None:
                # 无时区 -> 按本地时区解释
                local_offset = -time.timezone
                if time.daylight and time.localtime().tm_isdst:
                    local_offset = -time.altzone
                tz = timezone(timedelta(seconds=local_offset))
                dt = dt.replace(tzinfo=tz)
            return dt.astimezone(timezone.utc)
        except Exception as e:  # noqa: PERF203
            last_err = e
    raise ValueError(f"无法解析 EXPIRY_STR:{EXPIRY_STR!r}") from last_err


def _get_utc_from_date_header(url: str) -> Optional[datetime]:
    """从常见大站的 HTTP Date 头获取时间"""
    for method in ("HEAD", "GET"):
        try:
            req = urllib.request.Request(url, method=method)
            with urllib.request.urlopen(req, timeout=5) as resp:
                date_hdr = resp.headers.get("Date")
                if not date_hdr:
                    continue
                dt = eut.parsedate_to_datetime(date_hdr)
                if dt.tzinfo is None:
                    dt = dt.replace(tzinfo=timezone.utc)
                return dt.astimezone(timezone.utc)
        except Exception:
            pass
    return None


def _get_utc_from_worldtimeapi(url: str) -> Optional[datetime]:
    """从 worldtimeapi 获取 UTC 时间"""
    try:
        with urllib.request.urlopen(url, timeout=6) as resp:
            data = json.loads(resp.read().decode("utf-8", errors="ignore"))
            iso = data.get("utc_datetime") or data.get("datetime")
            if not iso:
                return None
            dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            return dt.astimezone(timezone.utc)
    except Exception:
        return None


def _get_network_utc() -> Optional[datetime]:
    """先从大网站 Date 头取时间, 失败再从 worldtimeapi 兜底"""
    for u in [
        "https://www.google.com",
        "https://www.microsoft.com",
        "https://www.cloudflare.com",
        "https://www.bing.com",
        "https://www.baidu.com",
        "https://www.apple.com",
    ]:
        dt = _get_utc_from_date_header(u)
        if dt:
            return dt
    for u in [
        "https://worldtimeapi.org/api/ip",
        "https://worldtimeapi.org/api/timezone/Etc/UTC",
    ]:
        dt = _get_utc_from_worldtimeapi(u)
        if dt:
            return dt
    return None


def enforce_expiry_or_quit() -> None:
    """
    程序入口处调用:
    - 解析到期时间
    - 获取网络 UTC 时间
    - 如果解析失败 / 网络时间获取失败 / 已经过期 -> 静默退出
    """
    try:
        expiry_utc = _parse_expiry(EXPIRY_STR)
    except Exception:
        sys.exit(0)
    now_utc = _get_network_utc()
    if now_utc is None:
        sys.exit(0)
    if now_utc > expiry_utc:
        sys.exit(0)


def send_mail_by_row(server, email_from, email_user, row_data, base_dir):
    """
    根据一行 Excel 数据发送一封邮件
    row_data: dict,字段名参考表头
    base_dir: Excel 文件所在目录,用于解析相对路径附件
    """
    to_addr = (row_data.get("收件地址") or "").strip()
    if not to_addr:
        # 没有收件人,直接跳过
        return "跳过:无收件人"

    cc_addr = (row_data.get("抄送地址") or "").strip()
    subject = (row_data.get("标题") or "").strip() or "无主题"
    body = (row_data.get("正文") or "").strip()

    msg = MIMEMultipart()
    # 发件地址:优先用本行“发件地址”,没有就用 email_from
    row_from = (row_data.get("发件地址") or "").strip()
    from_addr = row_from or email_from
    msg["From"] = _format_addr(from_addr)

    # 收件人列表
    to_list = []
    for addr in to_addr.split(";"):
        addr = addr.strip()
        if addr:
            to_list.append(addr)
    msg["To"] = ",".join(to_list)

    # 抄送列表
    cc_list = []
    for addr in cc_addr.split(";"):
        addr = addr.strip()
        if addr:
            cc_list.append(addr)
    if cc_list:
        msg["Cc"] = ",".join(cc_list)

    msg["Subject"] = Header(subject, "utf-8").encode()

    # 正文统一当作 HTML 处理,也可以直接写文字
    body_part = MIMEText(body, "html", "utf-8")
    msg.attach(body_part)

    # 附件1~2
    for i in range(1, 3):
        key = f"附件{i}"
        path = (row_data.get(key) or "").strip()
        if not path:
            continue
        # 如果是相对路径,就认为是相对于 Excel 文件所在目录
        if not os.path.isabs(path):
            path = os.path.join(base_dir, path)
        if not os.path.exists(path):
            logger.warning("附件不存在,跳过: %s", path)
            continue
        try:
            with open(path, "rb") as fp:
                basename = os.path.basename(path)
                att = MIMEApplication(fp.read())
                att["Content-Type"] = "application/octet-stream"
                att.add_header(
                    "Content-Disposition",
                    "attachment",
                    filename=("utf-8", "", basename),
                )
                encoders.encode_base64(att)
                msg.attach(att)
        except Exception as e:  # noqa: PERF203
            logger.error("读取附件失败: %s", path)
            logger.error(str(e))
            return f"失败:读取附件错误 {e}"

    all_receivers = to_list + cc_list
    if not all_receivers:
        return "跳过:无有效收件人"

    try:
        server.sendmail(email_user, all_receivers, msg.as_string())
    except Exception as e:
        logger.error("发送邮件失败: %s", str(e))
        return f"失败:{e}"
    else:
        return "成功"


class MailGui:
    def __init__(self, root):
        self.root = root
        self.root.title("SINGSONG邮件群发企业版(强哥出品)")
        self.root.geometry("900x600")

        # Notebook 颜色和样式
        style = ttk.Style()
        try:
            style.theme_use("default")
        except Exception:
            pass
        style.configure(
            "TNotebook",
            background="#f0f0f0",
            borderwidth=0,
        )
        style.configure(
            "TNotebook.Tab",
            padding=(12, 6),
            background="#d9e8ff",
            foreground="#003366",
        )
        style.map(
            "TNotebook.Tab",
            background=[("selected", "#4f81bd")],
            foreground=[("selected", "#ffffff")],
        )

        # ----------------- 全局配置变量(三个标签共享) -----------------
        # 先尝试从 settings.conf 读取一次作为默认值,用户可以在界面上改
        try:
            default_from = config.get_config("db_param", "email_from")
        except Exception:
            default_from = ""
        try:
            default_user = config.get_config("db_param", "email_user")
        except Exception:
            default_user = ""
        try:
            default_pwd = config.get_config("db_param", "email_pwd")
        except Exception:
            default_pwd = ""
        try:
            default_smtp = config.get_config("db_param", "smtp_server")
        except Exception:
            default_smtp = "smtp.qiye.aliyun.com"

        self._syncing_email = False  # 避免双向联动时递归触发

        self.var_email_from = tk.StringVar(value=default_from)
        self.var_email_user = tk.StringVar(value=default_user)
        self.var_email_pwd = tk.StringVar(value=default_pwd)
        self.var_smtp_server = tk.StringVar(value=default_smtp)

        # 发件邮箱 & 登录账号 双向联动:改一个,另一个自动跟随
        self.var_email_from.trace_add("write", self._on_email_from_change)
        self.var_email_user.trace_add("write", self._on_email_user_change)

        # 顶层 Notebook:三个标签页
        notebook = ttk.Notebook(root)
        notebook.pack(fill="both", expand=True)

        # 标签页 1:系统配置
        self.tab_config = ttk.Frame(notebook, padding=10)
        notebook.add(self.tab_config, text="系统配置")

        # 标签页 2:单封发送
        self.tab_single = ttk.Frame(notebook, padding=10)
        notebook.add(self.tab_single, text="单封发送")

        # 标签页 3:Excel 群发
        self.tab_excel = ttk.Frame(notebook, padding=10)
        notebook.add(self.tab_excel, text="邮件群发")

        # --------- 系统配置 Tab ---------
        self._build_config_tab()

        # --------- 单封发送 Tab ---------
        # 附件路径列表(完整路径)
        self.attach_files = []

        # 上半部分:基本信息(直接复用配置变量)
        # 顶部彩色条,区分不同标签页
        header = ttk.Label(
            self.tab_single,
            text="单封发送(适合测试或发送少量邮件)",
            background="#daeef3",
            foreground="#0070c0",
            anchor="w",
            padding=(8, 4),
        )
        header.pack(fill="x", pady=(0, 5))

        form = ttk.Frame(self.tab_single)
        form.pack(fill="x", pady=(0, 10))

        # 行 1:发件人邮箱 / 登录账号
        ttk.Label(form, text="发件邮箱:").grid(row=0, column=0, sticky="e", padx=5, pady=5)
        self.entry_from = ttk.Entry(form, width=30, textvariable=self.var_email_from)
        self.entry_from.grid(row=0, column=1, sticky="w", pady=5)

        ttk.Label(form, text="登录账号:").grid(row=0, column=2, sticky="e", padx=5, pady=5)
        self.entry_user = ttk.Entry(form, width=25, textvariable=self.var_email_user)
        self.entry_user.grid(row=0, column=3, sticky="w", pady=5)

        # 行 2:授权码 / SMTP 服务器
        ttk.Label(form, text="授权码/密码:").grid(row=1, column=0, sticky="e", padx=5, pady=5)
        self.entry_pwd = ttk.Entry(form, width=30, show="*", textvariable=self.var_email_pwd)
        self.entry_pwd.grid(row=1, column=1, sticky="w", pady=5)

        ttk.Label(form, text="SMTP服务器:").grid(row=1, column=2, sticky="e", padx=5, pady=5)
        self.entry_smtp = ttk.Entry(form, width=25, textvariable=self.var_smtp_server)
        self.entry_smtp.grid(row=1, column=3, sticky="w", pady=5)

        # 行 3:收件人 / 抄送
        ttk.Label(form, text="收件人:").grid(row=2, column=0, sticky="e", padx=5, pady=5)
        self.entry_to = ttk.Entry(form, width=60)
        self.entry_to.grid(row=2, column=1, columnspan=3, sticky="w", pady=5)
        self.entry_to.insert(0, "")  # 多个收件人用分号 ; 隔开

        ttk.Label(form, text="抄送:").grid(row=3, column=0, sticky="e", padx=5, pady=5)
        self.entry_cc = ttk.Entry(form, width=60)
        self.entry_cc.grid(row=3, column=1, columnspan=3, sticky="w", pady=5)

        # 行 4:主题
        ttk.Label(form, text="主题:").grid(row=4, column=0, sticky="e", padx=5, pady=5)
        self.entry_subject = ttk.Entry(form, width=60)
        self.entry_subject.grid(row=4, column=1, columnspan=3, sticky="w", pady=5)

        # 中间:正文
        ttk.Label(self.tab_single, text="正文内容:").pack(anchor="w")
        self.text_body = tk.Text(self.tab_single, height=10)
        self.text_body.pack(fill="both", expand=True, pady=5)

        # 附件 & 按钮区域
        bottom = ttk.Frame(self.tab_single)
        bottom.pack(fill="x", pady=(10, 0))

        # 左侧:附件列表
        left = ttk.Frame(bottom)
        left.pack(side="left", fill="both", expand=True)

        ttk.Label(left, text="附件列表:").pack(anchor="w")
        self.listbox_attach = tk.Listbox(left, height=5)
        self.listbox_attach.pack(fill="both", expand=True)

        btn_attach_frame = ttk.Frame(left)
        btn_attach_frame.pack(fill="x", pady=5)

        ttk.Button(
            btn_attach_frame, text="添加附件", command=self.add_attachments
        ).pack(side="left", padx=(0, 5))
        ttk.Button(
            btn_attach_frame, text="移除选中", command=self.remove_selected
        ).pack(side="left")

        # 右侧:发送按钮
        right = ttk.Frame(bottom)
        right.pack(side="right", fill="y")

        self.btn_send = ttk.Button(right, text="发送邮件", command=self.send_mail)
        self.btn_send.pack(padx=10, pady=10)

        tip_single = (
            "操作说明:\n"
            "- 多个收件人/抄送请用分号 ; 隔开,例如:a@xx.com; b@yy.com\n"
            "- 建议先发给自己邮箱测试内容和附件是否正确,再正式群发给客户。"
        )
        ttk.Label(
            right,
            text=tip_single,
            foreground="red",
            justify="left",
        ).pack(padx=10, pady=(0, 10))

        # --------- Excel 群发 Tab ---------
        self._build_excel_tab()

    def _build_config_tab(self):
        """系统配置标签页:发件账号和服务器统一在这里管理"""
        # 顶部彩色条,区分不同标签页
        header = ttk.Label(
            self.tab_config,
            text="系统配置",
            background="#fde9d9",
            foreground="#c0504d",
            anchor="w",
            padding=(8, 4),
        )
        header.pack(fill="x", pady=(0, 5))

        frame = ttk.LabelFrame(self.tab_config, text="发件账号配置", padding=10)
        frame.pack(fill="x", pady=10)

        ttk.Label(frame, text="发件邮箱:").grid(row=0, column=0, sticky="e", padx=5, pady=5)
        ttk.Entry(frame, width=30, textvariable=self.var_email_from).grid(
            row=0, column=1, sticky="w", pady=5
        )

        ttk.Label(frame, text="登录账号:").grid(row=0, column=2, sticky="e", padx=5, pady=5)
        ttk.Entry(frame, width=25, textvariable=self.var_email_user).grid(
            row=0, column=3, sticky="w", pady=5
        )

        ttk.Label(frame, text="授权码/密码:").grid(row=1, column=0, sticky="e", padx=5, pady=5)
        ttk.Entry(frame, width=30, show="*", textvariable=self.var_email_pwd).grid(
            row=1, column=1, sticky="w", pady=5
        )

        ttk.Label(frame, text="SMTP服务器:").grid(row=1, column=2, sticky="e", padx=5, pady=5)
        ttk.Entry(frame, width=25, textvariable=self.var_smtp_server).grid(
            row=1, column=3, sticky="w", pady=5
        )

        tip = (
            "说明:\n"
            "- 上面 4 个配置会被“单封发送”和“Excel 群发”两个页面复用;\n"
            "- 如果你以后把程序打包发给客户,让客户只在这里填写即可,无需修改配置文件。"
        )
        ttk.Label(self.tab_config, text=tip, foreground="#666", justify="left").pack(
            anchor="w", padx=5, pady=5
        )

        warn = (
            "重要提醒(必看):\n"
            "1. 授权码/密码来自邮箱后台的“客户端授权码”,不是网页登录密码;\n"
            "2. SMTP 服务器地址必须填写正确,否则所有邮件都会发送失败;\n"
            "3. 发件邮箱 和 登录账号 通常是同一个,本工具会自动保持两者一致;\n"
            "4. 修改完配置后,请先在“单封发送”页面发一封测试邮件确认成功,再在“邮件群发”页面进行大批量群发;\n"
            "5. Excel 模板中每一行就是一封邮件,请仔细检查收件地址、标题、正文和附件路径。"
        )
        ttk.Label(self.tab_config, text=warn, foreground="red", justify="left").pack(
            anchor="w", padx=5, pady=(0, 5)
        )

    def _build_excel_tab(self):
        """构建 Excel 群发标签页"""
        # 顶部彩色条,区分不同标签页
        header = ttk.Label(
            self.tab_excel,
            text="邮件群发(按 Excel 模板批量发送)",
            background="#e4dfec",
            foreground="#5f497a",
            anchor="w",
            padding=(8, 4),
        )
        header.pack(fill="x", pady=(0, 5))

        top = ttk.Frame(self.tab_excel)
        top.pack(fill="x", pady=(0, 5))

        # Excel 文件路径
        ttk.Label(top, text="Excel 文件:").grid(row=0, column=0, padx=5, pady=5, sticky="e")
        self.entry_excel = ttk.Entry(top, width=60)
        self.entry_excel.grid(row=0, column=1, padx=5, pady=5, sticky="w")

        ttk.Button(top, text="选择文件", command=self.choose_excel).grid(
            row=0, column=2, padx=5, pady=5
        )

        ttk.Button(top, text="加载并预览", command=self.load_excel).grid(
            row=0, column=3, padx=5, pady=5
        )

        # 表格区域:Treeview 展示 Excel 内容
        table_frame = ttk.Frame(self.tab_excel)
        table_frame.pack(fill="both", expand=True, pady=5)

        columns = [
            "发件地址",
            "收件地址",
            "抄送地址",
            "标题",
            "正文",
            "附件1",
            "附件2",
            "发送结果",
        ]

        self.tree = ttk.Treeview(
            table_frame, columns=columns, show="headings", height=12
        )
        for col in columns:
            self.tree.heading(col, text=col)
            # 简单设置列宽
            width = 120 if "附件" in col or "地址" in col else 80
            self.tree.column(col, width=width, anchor="w")

        vsb = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview)
        hsb = ttk.Scrollbar(table_frame, orient="horizontal", command=self.tree.xview)
        self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)

        self.tree.grid(row=0, column=0, sticky="nsew")
        vsb.grid(row=0, column=1, sticky="ns")
        hsb.grid(row=1, column=0, sticky="ew")

        table_frame.rowconfigure(0, weight=1)
        table_frame.columnconfigure(0, weight=1)

        # 底部控制区:开始发送 + 日志
        bottom = ttk.Frame(self.tab_excel)
        bottom.pack(fill="x", pady=(5, 0))

        self.btn_excel_send = ttk.Button(
            bottom, text="开始群发", command=self.start_excel_send
        )
        self.btn_excel_send.pack(side="left", padx=5, pady=5)

        self.btn_excel_stop = ttk.Button(
            bottom, text="停止发送", command=self.stop_excel_send, state="disabled"
        )
        self.btn_excel_stop.pack(side="left", padx=5, pady=5)

        ttk.Button(bottom, text="导出日志", command=self.export_log).pack(
            side="left", padx=5, pady=5
        )

        tip_excel = (
            "重要:从第 2 行开始,每行是一封邮件;请务必确认收件地址、标题、正文和附件路径正确,\n"
            "再点击“开始群发”,发送进度会在“发送结果”一列和下方日志中实时更新。"
        )
        ttk.Label(
            bottom,
            text=tip_excel,
            foreground="red",
            justify="left",
        ).pack(side="left", padx=10)

        # 日志框
        log_frame = ttk.LabelFrame(self.tab_excel, text="发送状态")
        log_frame.pack(fill="both", expand=False, pady=(5, 0))
        self.text_log = tk.Text(log_frame, height=6)
        self.text_log.pack(fill="both", expand=True)

        # Excel 数据缓存
        self.excel_headers = []
        self.excel_rows = []
        self.excel_base_dir = None
        self._excel_sending = False

        # 双击查看详情
        self.tree.bind("<Double-1>", self.show_row_detail)

    # ----------------- 配置字段联动 -----------------
    def _on_email_from_change(self, *args):
        """当发件邮箱变化时,同步更新登录账号"""
        if self._syncing_email:
            return
        self._syncing_email = True
        try:
            value = self.var_email_from.get().strip()
            # 只有在非空时才同步,避免初始化阶段来回覆盖
            if value:
                self.var_email_user.set(value)
        finally:
            self._syncing_email = False

    def _on_email_user_change(self, *args):
        """当登录账号变化时,同步更新发件邮箱"""
        if self._syncing_email:
            return
        self._syncing_email = True
        try:
            value = self.var_email_user.get().strip()
            if value:
                self.var_email_from.set(value)
        finally:
            self._syncing_email = False

    # ----------------- 附件相关 -----------------
    def add_attachments(self):
        """选择一个或多个附件文件"""
        files = filedialog.askopenfilenames(title="选择附件")
        if not files:
            return
        for f in files:
            if f not in self.attach_files:
                self.attach_files.append(f)
                self.listbox_attach.insert("end", f)

    def remove_selected(self):
        """从列表中移除选中的附件"""
        selection = list(self.listbox_attach.curselection())
        if not selection:
            return
        # 需要从后往前删,避免索引变化
        for index in reversed(selection):
            self.listbox_attach.delete(index)
            try:
                del self.attach_files[index]
            except IndexError:
                pass

    # ----------------- 发送逻辑 -----------------
    def send_mail(self):
        """点击“发送邮件”后的处理"""
        email_from = self.var_email_from.get().strip()
        email_user = self.var_email_user.get().strip()
        email_pwd = self.var_email_pwd.get().strip()
        smtp_server = self.var_smtp_server.get().strip() or "smtp.163.com"
        to_email = self.entry_to.get().strip()
        cc_email = self.entry_cc.get().strip()
        subject = self.entry_subject.get().strip()
        content = self.text_body.get("1.0", "end").strip()

        if not email_from or not email_user or not email_pwd:
            messagebox.showwarning("提示", "请先填写发件邮箱、登录账号和授权码/密码。")
            return

        if not to_email:
            messagebox.showwarning("提示", "请至少填写一个收件人邮箱。")
            return

        # 构造邮件
        msg = MIMEMultipart()
        msg["From"] = _format_addr(email_from)

        # 收件人列表
        to_list = []
        for addr in to_email.split(";"):
            addr = addr.strip()
            if addr:
                to_list.append(addr)
        msg["To"] = ",".join(to_list)

        # 抄送列表
        cc_list = []
        if cc_email:
            for addr in cc_email.split(";"):
                addr = addr.strip()
                if addr:
                    cc_list.append(addr)
        if cc_list:
            msg["Cc"] = ",".join(cc_list)

        msg["Subject"] = Header(subject or "无主题", "utf-8").encode()

        # 正文
        body = MIMEText(content or "", "html", "utf-8")
        msg.attach(body)

        # 附件
        for path in self.attach_files:
            try:
                with open(path, "rb") as fp:
                    basename = os.path.basename(path)
                    att = MIMEApplication(fp.read())
                    att["Content-Type"] = "application/octet-stream"
                    att.add_header(
                        "Content-Disposition",
                        "attachment",
                        filename=("utf-8", "", basename),
                    )
                    encoders.encode_base64(att)
                    msg.attach(att)
            except Exception as e:
                logger.error("读取附件失败: %s", path)
                logger.error(str(e))
                messagebox.showerror("错误", f"读取附件失败:{path}\n{e}")
                return

        # 发送
        all_receivers = to_list + cc_list
        try:
            server = smtplib.SMTP()
            server.connect(smtp_server)
            server.login(email_user, email_pwd)
            server.sendmail(email_user, all_receivers, msg.as_string())
            server.quit()
        except Exception as e:
            logger.error("发送失败: %s", str(e))
            messagebox.showerror("发送失败", f"发送邮件失败:\n{e}")
        else:
            messagebox.showinfo("成功", "邮件发送成功!")

    # ----------------- Excel 群发 -----------------
    def choose_excel(self):
        """选择 Excel 文件"""
        path = filedialog.askopenfilename(
            title="选择 Excel 模板",
            filetypes=(("Excel 文件", "*.xlsx;*.xlsm;*.xltx;*.xltm"), ("所有文件", "*.*")),
        )
        if not path:
            return
        self.entry_excel.delete(0, "end")
        self.entry_excel.insert(0, path)

    def load_excel(self):
        """加载 Excel 文件并在表格中预览"""
        path = self.entry_excel.get().strip()
        if not path:
            messagebox.showwarning("提示", "请先选择一个 Excel 文件。")
            return
        if not os.path.exists(path):
            messagebox.showerror("错误", f"文件不存在:{path}")
            return

        try:
            wb = load_workbook(path)
            ws = wb.active
        except Exception as e:
            messagebox.showerror("错误", f"读取 Excel 失败:\n{e}")
            return

        # 读取表头
        headers = []
        for col in range(1, ws.max_column + 1):
            title = ws.cell(row=1, column=col).value
            if title:
                headers.append(title)
            else:
                headers.append("")

        self.excel_headers = headers
        self.excel_rows = []
        self.excel_base_dir = os.path.dirname(os.path.abspath(path))

        # 清空 Treeview
        for item in self.tree.get_children():
            self.tree.delete(item)

        # 填充数据(从第 2 行开始)
        for row in range(2, ws.max_row + 1):
            row_data = []
            empty_row = True
            for col in range(1, ws.max_column + 1):
                value = ws.cell(row=row, column=col).value
                if value not in (None, ""):
                    empty_row = False
                row_data.append("" if value is None else str(value))
            if empty_row:
                continue
            self.excel_rows.append(row_data)

            # 映射到固定列顺序(如果有缺少的列就空着)
            display_row = []
            col_names = [
                "发件地址",
                "收件地址",
                "抄送地址",
                "标题",
                "正文",
                "附件1",
                "附件2",
                "发送结果",
            ]
            header_index = {name: idx for idx, name in enumerate(headers)}
            for name in col_names:
                idx = header_index.get(name)
                if idx is not None and idx < len(row_data):
                    display_row.append(row_data[idx])
                else:
                    display_row.append("")

            self.tree.insert("", "end", values=display_row)

        self.log("已加载 Excel,共 {} 条有效行。".format(len(self.excel_rows)))

    def start_excel_send(self):
        """根据已加载的 Excel 数据开始群发"""
        if not self.excel_rows:
            messagebox.showwarning("提示", "请先加载 Excel 模板。")
            return

        # 读取 SMTP 配置(来自“系统配置”标签页)
        email_from = self.var_email_from.get().strip()
        email_user = self.var_email_user.get().strip()
        email_pwd = self.var_email_pwd.get().strip()
        smtp_server = self.var_smtp_server.get().strip() or "smtp.163.com"

        if not email_user or not email_pwd:
            messagebox.showwarning("提示", "请先在“系统配置”标签页中填写发件账号和授权码。")
            return

        # 构造表头->索引映射
        header_index = {name: idx for idx, name in enumerate(self.excel_headers)}
        if "收件地址" not in header_index:
            messagebox.showerror("错误", "Excel 中缺少必需列:收件地址")
            return

        # 计算需要发送的行:如果用户选中了行,则只发选中行;否则发全部
        all_items = list(self.tree.get_children())
        selected_items = list(self.tree.selection())
        if selected_items:
            targets = [all_items.index(it) for it in selected_items]
            targets.sort()
            self.log(f"检测到选中 {len(targets)} 条记录,仅发送选中行。")
        else:
            targets = list(range(len(all_items)))
            self.log("未选择行,将对所有行进行发送。")

        try:
            server = smtplib.SMTP()
            server.connect(smtp_server)
            server.login(email_user, email_pwd)
        except Exception as e:
            messagebox.showerror("错误", f"连接邮件服务器失败:\n{e}")
            return

        self.btn_excel_send.config(state="disabled")
        self.btn_excel_stop.config(state="normal")
        self._excel_sending = True
        self.log("开始群发邮件...")

        # 逐行发送
        for display_idx in targets:
            if not self._excel_sending:
                self.log("发送已被用户中止。")
                break

            row_data = self.excel_rows[display_idx]
            row_dict = {}
            for name, col_idx in header_index.items():
                if col_idx < len(row_data):
                    row_dict[name] = row_data[col_idx]
                else:
                    row_dict[name] = ""

            status = send_mail_by_row(
                server, email_from, email_user, row_dict, self.excel_base_dir
            )

            # 更新 Treeview 中的“发送结果”列
            item_id = all_items[display_idx]
            values = list(self.tree.item(item_id, "values"))
            # “发送结果”列固定在最后一个
            values[-1] = status
            self.tree.item(item_id, values=values)

            self.log(f"第 {display_idx + 1} 行:{status}")
            # 刷新界面,让用户能实时看到
            self.root.update_idletasks()

        server.quit()
        self.btn_excel_send.config(state="normal")
        self.btn_excel_stop.config(state="disabled")
        self._excel_sending = False
        self.log("群发流程结束。")

    def stop_excel_send(self):
        """用户手动中断群发"""
        if self._excel_sending:
            self._excel_sending = False

    def log(self, text):
        """在 Excel 群发标签页下方的日志框输出一行文字"""
        self.text_log.insert("end", text + "\n")
        self.text_log.see("end")

    def export_log(self):
        """导出日志到文件,方便排查问题或存档"""
        log_text = self.text_log.get("1.0", "end").strip()
        if not log_text:
            messagebox.showinfo("提示", "当前没有可以导出的日志内容。")
            return
        path = filedialog.asksaveasfilename(
            title="保存日志",
            defaultextension=".txt",
            filetypes=(("文本文件", "*.txt"), ("所有文件", "*.*")),
        )
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write(log_text)
        except Exception as e:
            messagebox.showerror("错误", f"保存日志失败:\n{e}")
        else:
            messagebox.showinfo("成功", f"日志已保存到:\n{path}")

    def show_row_detail(self, event):
        """双击某一行,弹出详情窗口查看完整正文和附件"""
        item_id = self.tree.focus()
        if not item_id:
            return
        values = self.tree.item(item_id, "values")
        if not values:
            return

        col_names = [
            "发件地址",
            "收件地址",
            "抄送地址",
            "标题",
            "正文",
            "附件1",
            "附件2",
            "发送结果",
        ]

        detail_win = tk.Toplevel(self.root)
        detail_win.title("邮件详情")
        detail_win.geometry("600x500")

        frame = ttk.Frame(detail_win, padding=10)
        frame.pack(fill="both", expand=True)

        # 基本字段(多行标签):发件地址、收件地址、抄送地址、标题、正文
        for i, name in enumerate(col_names[:5]):
            ttk.Label(frame, text=f"{name}:", anchor="e", width=10).grid(
                row=i, column=0, sticky="ne", padx=5, pady=3
            )
            if name == "正文":
                txt = tk.Text(frame, height=10, wrap="word")
                txt.grid(row=i, column=1, sticky="nsew", padx=5, pady=3)
                txt.insert("1.0", values[i] if i < len(values) else "")
                txt.config(state="disabled")
            else:
                val = values[i] if i < len(values) else ""
                ttk.Label(frame, text=val, anchor="w", wraplength=430).grid(
                    row=i, column=1, sticky="w", padx=5, pady=3
                )

        # 附件和发送结果
        attach_start_row = 7
        ttk.Label(frame, text="附件列表:", anchor="ne", width=10).grid(
            row=attach_start_row, column=0, sticky="ne", padx=5, pady=3
        )
        attach_box = tk.Text(frame, height=6, wrap="none")
        attach_box.grid(
            row=attach_start_row, column=1, sticky="nsew", padx=5, pady=3
        )
        for i in range(5, 7):
            if i < len(values) and values[i]:
                attach_box.insert("end", f"{col_names[i]}: {values[i]}\n")
        attach_box.config(state="disabled")

        ttk.Label(frame, text="发送结果:", anchor="e", width=10).grid(
            row=attach_start_row + 1, column=0, sticky="e", padx=5, pady=3
        )
        result_text = values[7] if len(values) > 7 else ""
        ttk.Label(frame, text=result_text, anchor="w", wraplength=430).grid(
            row=attach_start_row + 1, column=1, sticky="w", padx=5, pady=3
        )

        frame.rowconfigure(6, weight=1)
        frame.rowconfigure(attach_start_row, weight=1)
        frame.columnconfigure(1, weight=1)


def main():
    # 启动前先检查到期时间(过期或网络异常时静默退出)
    enforce_expiry_or_quit()
    root = tk.Tk()
    app = MailGui(root)
    root.mainloop()


if __name__ == "__main__":
    main()
View Code

 

#!/usr/bin/env python3
# encoding: utf-8
"""
一个简单的图形界面邮件发送工具
适合不懂代码的同学,点按钮就能发邮件(支持附件、Excel 群发)。
"""

import os
import sys
import logging
import smtplib
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import urllib.request
import json
import email.utils as eut
from datetime import datetime, timezone, timedelta
from typing import Optional
import time

from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.application import MIMEApplication
from email.header import Header
from email.utils import parseaddr, formataddr
from email import encoders

from openpyxl import load_workbook

import config


logger = logging.getLogger(__name__)


def _format_addr(s):
    """把邮件地址里的名字转成 utf-8,避免中文乱码"""
    name, addr = parseaddr(s)
    return formataddr((Header(name, 'utf-8').encode(), addr))


# =====================  到期限制配置(只改这一行)  =====================
# 示例格式:
# "2025-12-31 23:59:59 +0800"
# "2025-12-31 23:59:59"
# "2025/12/31 23:59:59"
# "2025-12-31"
EXPIRY_STR = "2026-12-31 23:59:59 +0800"
# =======================================================================


def _parse_expiry(s: str) -> datetime:
    """解析配置中的到期时间字符串, 统一转成 UTC 时间"""
    s = s.strip()
    fmts = [
        "%Y-%m-%d %H:%M:%S %z",
        "%Y/%m/%d %H:%M:%S %z",
        "%Y-%m-%d %H:%M:%S",
        "%Y/%m/%d %H:%M:%S",
        "%Y-%m-%d",
        "%Y/%m/%d",
    ]
    last_err = None
    for fmt in fmts:
        try:
            dt = datetime.strptime(s, fmt)
            if dt.tzinfo is None:
                # 无时区 -> 按本地时区解释
                local_offset = -time.timezone
                if time.daylight and time.localtime().tm_isdst:
                    local_offset = -time.altzone
                tz = timezone(timedelta(seconds=local_offset))
                dt = dt.replace(tzinfo=tz)
            return dt.astimezone(timezone.utc)
        except Exception as e:  # noqa: PERF203
            last_err = e
    raise ValueError(f"无法解析 EXPIRY_STR:{EXPIRY_STR!r}") from last_err


def _get_utc_from_date_header(url: str) -> Optional[datetime]:
    """从常见大站的 HTTP Date 头获取时间"""
    for method in ("HEAD", "GET"):
        try:
            req = urllib.request.Request(url, method=method)
            with urllib.request.urlopen(req, timeout=5) as resp:
                date_hdr = resp.headers.get("Date")
                if not date_hdr:
                    continue
                dt = eut.parsedate_to_datetime(date_hdr)
                if dt.tzinfo is None:
                    dt = dt.replace(tzinfo=timezone.utc)
                return dt.astimezone(timezone.utc)
        except Exception:
            pass
    return None


def _get_utc_from_worldtimeapi(url: str) -> Optional[datetime]:
    """从 worldtimeapi 获取 UTC 时间"""
    try:
        with urllib.request.urlopen(url, timeout=6) as resp:
            data = json.loads(resp.read().decode("utf-8", errors="ignore"))
            iso = data.get("utc_datetime") or data.get("datetime")
            if not iso:
                return None
            dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            return dt.astimezone(timezone.utc)
    except Exception:
        return None


def _get_network_utc() -> Optional[datetime]:
    """先从大网站 Date 头取时间, 失败再从 worldtimeapi 兜底"""
    for u in [
        "https://www.google.com",
        "https://www.microsoft.com",
        "https://www.cloudflare.com",
        "https://www.bing.com",
        "https://www.baidu.com",
        "https://www.apple.com",
    ]:
        dt = _get_utc_from_date_header(u)
        if dt:
            return dt
    for u in [
        "https://worldtimeapi.org/api/ip",
        "https://worldtimeapi.org/api/timezone/Etc/UTC",
    ]:
        dt = _get_utc_from_worldtimeapi(u)
        if dt:
            return dt
    return None


def enforce_expiry_or_quit() -> None:
    """
    程序入口处调用:
    - 解析到期时间
    - 优先获取网络 UTC 时间,失败则回退到本地时间
    - 如果真正过期,则弹出提示后退出;否则正常继续运行

    说明(给不会写代码的同学看):
    - 原来的版本:一旦网络时间获取不到,就会“悄悄退出”,你会感觉什么都没发生;
    - 现在的版本:尽量让程序能跑起来,只有在到期时间真的过了,才会弹出提示并关闭。
    """
    try:
        expiry_utc = _parse_expiry(EXPIRY_STR)
    except Exception:
        # 如果到期时间配置写错了,就当作“永不过期”处理,保证程序能正常用
        return

    now_utc = _get_network_utc()
    if now_utc is None:
        # 网络时间获取失败时,使用本地时间
        try:
            local_now = datetime.now(timezone.utc)
        except Exception:
            # 极端情况下获取本地时间也失败,则直接放行
            return
        now_utc = local_now

    if now_utc > expiry_utc:
        try:
            # 用弹窗告诉用户原因,而不是静默退出
            messagebox.showinfo("提示", "程序已到期,如需继续使用,请联系开发者更新版本。")
        except Exception:
            # 如果 GUI 还没准备好或在无界面环境下,至少保证正常退出
            pass
        sys.exit(0)


def send_mail_by_row(server, email_from, email_user, row_data, base_dir):
    """
    根据一行 Excel 数据发送一封邮件
    row_data: dict,字段名参考表头
    base_dir: Excel 文件所在目录,用于解析相对路径附件
    """
    to_addr = (row_data.get("收件地址") or "").strip()
    if not to_addr:
        # 没有收件人,直接跳过
        return "跳过:无收件人"

    cc_addr = (row_data.get("抄送地址") or "").strip()
    subject = (row_data.get("标题") or "").strip() or "无主题"
    body = (row_data.get("正文") or "").strip()

    msg = MIMEMultipart()
    # 发件地址:优先用本行“发件地址”,没有就用 email_from
    row_from = (row_data.get("发件地址") or "").strip()
    from_addr = row_from or email_from
    msg["From"] = _format_addr(from_addr)

    # 收件人列表
    to_list = []
    for addr in to_addr.split(";"):
        addr = addr.strip()
        if addr:
            to_list.append(addr)
    msg["To"] = ",".join(to_list)

    # 抄送列表
    cc_list = []
    for addr in cc_addr.split(";"):
        addr = addr.strip()
        if addr:
            cc_list.append(addr)
    if cc_list:
        msg["Cc"] = ",".join(cc_list)

    msg["Subject"] = Header(subject, "utf-8").encode()

    # 正文统一当作 HTML 处理,也可以直接写文字
    body_part = MIMEText(body, "html", "utf-8")
    msg.attach(body_part)

    # 附件1~2
    for i in range(1, 3):
        key = f"附件{i}"
        path = (row_data.get(key) or "").strip()
        if not path:
            continue
        # 如果是相对路径,就认为是相对于 Excel 文件所在目录
        if not os.path.isabs(path):
            path = os.path.join(base_dir, path)
        if not os.path.exists(path):
            logger.warning("附件不存在,跳过: %s", path)
            continue
        try:
            with open(path, "rb") as fp:
                basename = os.path.basename(path)
                att = MIMEApplication(fp.read())
                att["Content-Type"] = "application/octet-stream"
                att.add_header(
                    "Content-Disposition",
                    "attachment",
                    filename=("utf-8", "", basename),
                )
                encoders.encode_base64(att)
                msg.attach(att)
        except Exception as e:  # noqa: PERF203
            logger.error("读取附件失败: %s", path)
            logger.error(str(e))
            return f"失败:读取附件错误 {e}"

    all_receivers = to_list + cc_list
    if not all_receivers:
        return "跳过:无有效收件人"

    try:
        server.sendmail(email_user, all_receivers, msg.as_string())
    except Exception as e:
        logger.error("发送邮件失败: %s", str(e))
        return f"失败:{e}"
    else:
        return "成功"


class MailGui:
    def __init__(self, root):
        self.root = root
        self.root.title("SINGSONG邮件群发企业版(强哥出品)")
        self.root.geometry("900x600")

        # Notebook 颜色和样式
        style = ttk.Style()
        try:
            style.theme_use("default")
        except Exception:
            pass
        style.configure(
            "TNotebook",
            background="#f0f0f0",
            borderwidth=0,
        )
        style.configure(
            "TNotebook.Tab",
            padding=(12, 6),
            background="#d9e8ff",
            foreground="#003366",
        )
        style.map(
            "TNotebook.Tab",
            background=[("selected", "#4f81bd")],
            foreground=[("selected", "#ffffff")],
        )

        # ----------------- 全局配置变量(三个标签共享) -----------------
        # 先尝试从 settings.conf 读取一次作为默认值,用户可以在界面上改
        try:
            default_from = config.get_config("db_param", "email_from")
        except Exception:
            default_from = ""
        try:
            default_user = config.get_config("db_param", "email_user")
        except Exception:
            default_user = ""
        try:
            default_pwd = config.get_config("db_param", "email_pwd")
        except Exception:
            default_pwd = ""
        try:
            default_smtp = config.get_config("db_param", "smtp_server")
        except Exception:
            default_smtp = "smtp.qiye.aliyun.com"

        self._syncing_email = False  # 避免双向联动时递归触发

        self.var_email_from = tk.StringVar(value=default_from)
        self.var_email_user = tk.StringVar(value=default_user)
        self.var_email_pwd = tk.StringVar(value=default_pwd)
        self.var_smtp_server = tk.StringVar(value=default_smtp)

        # 发件邮箱 & 登录账号 双向联动:改一个,另一个自动跟随
        self.var_email_from.trace_add("write", self._on_email_from_change)
        self.var_email_user.trace_add("write", self._on_email_user_change)

        # 顶层 Notebook:三个标签页
        notebook = ttk.Notebook(root)
        notebook.pack(fill="both", expand=True)

        # 根据你的使用习惯,调整顺序为:
        # 1)群发邮件  2)单个发送  3)配置页面

        # 标签页 1:Excel 群发(群发邮件)
        self.tab_excel = ttk.Frame(notebook, padding=10)
        notebook.add(self.tab_excel, text="群发邮件")

        # 标签页 2:单封发送(单个发送)
        self.tab_single = ttk.Frame(notebook, padding=10)
        notebook.add(self.tab_single, text="单个发送")

        # 标签页 3:系统配置(配置页面)
        self.tab_config = ttk.Frame(notebook, padding=10)
        notebook.add(self.tab_config, text="配置页面")

        # --------- 系统配置 Tab ---------
        self._build_config_tab()

        # --------- 单封发送 Tab ---------
        # 附件路径列表(完整路径)
        self.attach_files = []

        # 上半部分:基本信息(直接复用配置变量)
        # 顶部彩色条,区分不同标签页
        header = ttk.Label(
            self.tab_single,
            text="单封发送(适合测试或发送少量邮件)",
            background="#daeef3",
            foreground="#0070c0",
            anchor="w",
            padding=(8, 4),
        )
        header.pack(fill="x", pady=(0, 5))

        form = ttk.Frame(self.tab_single)
        form.pack(fill="x", pady=(0, 10))

        # 行 1:发件人邮箱 / 登录账号
        ttk.Label(form, text="发件邮箱:").grid(row=0, column=0, sticky="e", padx=5, pady=5)
        self.entry_from = ttk.Entry(form, width=30, textvariable=self.var_email_from)
        self.entry_from.grid(row=0, column=1, sticky="w", pady=5)

        ttk.Label(form, text="登录账号:").grid(row=0, column=2, sticky="e", padx=5, pady=5)
        self.entry_user = ttk.Entry(form, width=25, textvariable=self.var_email_user)
        self.entry_user.grid(row=0, column=3, sticky="w", pady=5)

        # 行 2:授权码 / SMTP 服务器
        ttk.Label(form, text="授权码/密码:").grid(row=1, column=0, sticky="e", padx=5, pady=5)
        self.entry_pwd = ttk.Entry(form, width=30, show="*", textvariable=self.var_email_pwd)
        self.entry_pwd.grid(row=1, column=1, sticky="w", pady=5)

        ttk.Label(form, text="SMTP服务器:").grid(row=1, column=2, sticky="e", padx=5, pady=5)
        self.entry_smtp = ttk.Entry(form, width=25, textvariable=self.var_smtp_server)
        self.entry_smtp.grid(row=1, column=3, sticky="w", pady=5)

        # 行 3:收件人 / 抄送
        ttk.Label(form, text="收件人:").grid(row=2, column=0, sticky="e", padx=5, pady=5)
        self.entry_to = ttk.Entry(form, width=60)
        self.entry_to.grid(row=2, column=1, columnspan=3, sticky="w", pady=5)
        self.entry_to.insert(0, "")  # 多个收件人用分号 ; 隔开

        ttk.Label(form, text="抄送:").grid(row=3, column=0, sticky="e", padx=5, pady=5)
        self.entry_cc = ttk.Entry(form, width=60)
        self.entry_cc.grid(row=3, column=1, columnspan=3, sticky="w", pady=5)

        # 行 4:主题
        ttk.Label(form, text="主题:").grid(row=4, column=0, sticky="e", padx=5, pady=5)
        self.entry_subject = ttk.Entry(form, width=60)
        self.entry_subject.grid(row=4, column=1, columnspan=3, sticky="w", pady=5)

        # 中间:正文
        ttk.Label(self.tab_single, text="正文内容:").pack(anchor="w")
        self.text_body = tk.Text(self.tab_single, height=10)
        self.text_body.pack(fill="both", expand=True, pady=5)

        # 附件 & 按钮区域
        bottom = ttk.Frame(self.tab_single)
        bottom.pack(fill="x", pady=(10, 0))

        # 左侧:附件列表
        left = ttk.Frame(bottom)
        left.pack(side="left", fill="both", expand=True)

        ttk.Label(left, text="附件列表:").pack(anchor="w")
        self.listbox_attach = tk.Listbox(left, height=5)
        self.listbox_attach.pack(fill="both", expand=True)

        btn_attach_frame = ttk.Frame(left)
        btn_attach_frame.pack(fill="x", pady=5)

        ttk.Button(
            btn_attach_frame, text="添加附件", command=self.add_attachments
        ).pack(side="left", padx=(0, 5))
        ttk.Button(
            btn_attach_frame, text="移除选中", command=self.remove_selected
        ).pack(side="left")

        # 右侧:发送按钮
        right = ttk.Frame(bottom)
        right.pack(side="right", fill="y")

        self.btn_send = ttk.Button(right, text="发送邮件", command=self.send_mail)
        self.btn_send.pack(padx=10, pady=10)

        tip_single = (
            "操作说明:\n"
            "- 多个收件人/抄送请用分号 ; 隔开,例如:a@xx.com; b@yy.com\n"
            "- 建议先发给自己邮箱测试内容和附件是否正确,再正式群发给客户。"
        )
        ttk.Label(
            right,
            text=tip_single,
            foreground="red",
            justify="left",
        ).pack(padx=10, pady=(0, 10))

        # --------- Excel 群发 Tab ---------
        self._build_excel_tab()

    def _build_config_tab(self):
        """系统配置标签页:发件账号和服务器统一在这里管理"""
        # 顶部彩色条,区分不同标签页
        header = ttk.Label(
            self.tab_config,
            text="系统配置",
            background="#fde9d9",
            foreground="#c0504d",
            anchor="w",
            padding=(8, 4),
        )
        header.pack(fill="x", pady=(0, 5))

        frame = ttk.LabelFrame(self.tab_config, text="发件账号配置", padding=10)
        frame.pack(fill="x", pady=10)

        ttk.Label(frame, text="发件邮箱:").grid(row=0, column=0, sticky="e", padx=5, pady=5)
        ttk.Entry(frame, width=30, textvariable=self.var_email_from).grid(
            row=0, column=1, sticky="w", pady=5
        )

        ttk.Label(frame, text="登录账号:").grid(row=0, column=2, sticky="e", padx=5, pady=5)
        ttk.Entry(frame, width=25, textvariable=self.var_email_user).grid(
            row=0, column=3, sticky="w", pady=5
        )

        ttk.Label(frame, text="授权码/密码:").grid(row=1, column=0, sticky="e", padx=5, pady=5)
        ttk.Entry(frame, width=30, show="*", textvariable=self.var_email_pwd).grid(
            row=1, column=1, sticky="w", pady=5
        )

        ttk.Label(frame, text="SMTP服务器:").grid(row=1, column=2, sticky="e", padx=5, pady=5)
        ttk.Entry(frame, width=25, textvariable=self.var_smtp_server).grid(
            row=1, column=3, sticky="w", pady=5
        )

        tip = (
            "说明:\n"
            "- 上面 4 个配置会被“单封发送”和“Excel 群发”两个页面复用;\n"
            "- 如果你以后把程序打包发给客户,让客户只在这里填写即可,无需修改配置文件。"
        )
        ttk.Label(self.tab_config, text=tip, foreground="#666", justify="left").pack(
            anchor="w", padx=5, pady=5
        )

        warn = (
            "重要提醒(必看):\n"
            "1. 授权码/密码来自邮箱后台的“客户端授权码”,不是网页登录密码;\n"
            "2. SMTP 服务器地址必须填写正确,否则所有邮件都会发送失败;\n"
            "3. 发件邮箱 和 登录账号 通常是同一个,本工具会自动保持两者一致;\n"
            "4. 修改完配置后,请先在“单封发送”页面发一封测试邮件确认成功,再在“邮件群发”页面进行大批量群发;\n"
            "5. Excel 模板中每一行就是一封邮件,请仔细检查收件地址、标题、正文和附件路径。"
        )
        ttk.Label(self.tab_config, text=warn, foreground="red", justify="left").pack(
            anchor="w", padx=5, pady=(0, 5)
        )

    def _build_excel_tab(self):
        """构建 Excel 群发标签页"""
        # 顶部彩色条,区分不同标签页
        header = ttk.Label(
            self.tab_excel,
            text="邮件群发(按 Excel 模板批量发送)",
            background="#e4dfec",
            foreground="#5f497a",
            anchor="w",
            padding=(8, 4),
        )
        header.pack(fill="x", pady=(0, 5))

        top = ttk.Frame(self.tab_excel)
        top.pack(fill="x", pady=(0, 5))

        # Excel 文件路径
        ttk.Label(top, text="Excel 文件:").grid(row=0, column=0, padx=5, pady=5, sticky="e")
        self.entry_excel = ttk.Entry(top, width=60)
        self.entry_excel.grid(row=0, column=1, padx=5, pady=5, sticky="w")

        ttk.Button(top, text="选择文件", command=self.choose_excel).grid(
            row=0, column=2, padx=5, pady=5
        )

        ttk.Button(top, text="加载并预览", command=self.load_excel).grid(
            row=0, column=3, padx=5, pady=5
        )

        # 表格区域:Treeview 展示 Excel 内容
        table_frame = ttk.Frame(self.tab_excel)
        table_frame.pack(fill="both", expand=True, pady=5)

        columns = [
            "发件地址",
            "收件地址",
            "抄送地址",
            "标题",
            "正文",
            "附件1",
            "附件2",
            "发送结果",
        ]

        self.tree = ttk.Treeview(
            table_frame, columns=columns, show="headings", height=12
        )
        for col in columns:
            self.tree.heading(col, text=col)
            # 简单设置列宽
            width = 120 if "附件" in col or "地址" in col else 80
            self.tree.column(col, width=width, anchor="w")

        vsb = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview)
        hsb = ttk.Scrollbar(table_frame, orient="horizontal", command=self.tree.xview)
        self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)

        self.tree.grid(row=0, column=0, sticky="nsew")
        vsb.grid(row=0, column=1, sticky="ns")
        hsb.grid(row=1, column=0, sticky="ew")

        table_frame.rowconfigure(0, weight=1)
        table_frame.columnconfigure(0, weight=1)

        # 底部控制区:开始发送 + 日志
        bottom = ttk.Frame(self.tab_excel)
        bottom.pack(fill="x", pady=(5, 0))

        self.btn_excel_send = ttk.Button(
            bottom, text="开始群发", command=self.start_excel_send
        )
        self.btn_excel_send.pack(side="left", padx=5, pady=5)

        self.btn_excel_stop = ttk.Button(
            bottom, text="停止发送", command=self.stop_excel_send, state="disabled"
        )
        self.btn_excel_stop.pack(side="left", padx=5, pady=5)

        ttk.Button(bottom, text="导出日志", command=self.export_log).pack(
            side="left", padx=5, pady=5
        )

        tip_excel = (
            "重要:从第 2 行开始,每行是一封邮件;请务必确认收件地址、标题、正文和附件路径正确,\n"
            "再点击“开始群发”,发送进度会在“发送结果”一列和下方日志中实时更新。"
        )
        ttk.Label(
            bottom,
            text=tip_excel,
            foreground="red",
            justify="left",
        ).pack(side="left", padx=10)

        # 日志框
        log_frame = ttk.LabelFrame(self.tab_excel, text="发送状态")
        log_frame.pack(fill="both", expand=False, pady=(5, 0))
        self.text_log = tk.Text(log_frame, height=6)
        self.text_log.pack(fill="both", expand=True)

        # Excel 数据缓存
        self.excel_headers = []
        self.excel_rows = []
        self.excel_base_dir = None
        self._excel_sending = False

        # 双击查看详情
        self.tree.bind("<Double-1>", self.show_row_detail)

    # ----------------- 配置字段联动 -----------------
    def _on_email_from_change(self, *args):
        """当发件邮箱变化时,同步更新登录账号"""
        if self._syncing_email:
            return
        self._syncing_email = True
        try:
            value = self.var_email_from.get().strip()
            # 只有在非空时才同步,避免初始化阶段来回覆盖
            if value:
                self.var_email_user.set(value)
        finally:
            self._syncing_email = False

    def _on_email_user_change(self, *args):
        """当登录账号变化时,同步更新发件邮箱"""
        if self._syncing_email:
            return
        self._syncing_email = True
        try:
            value = self.var_email_user.get().strip()
            if value:
                self.var_email_from.set(value)
        finally:
            self._syncing_email = False

    # ----------------- 附件相关 -----------------
    def add_attachments(self):
        """选择一个或多个附件文件"""
        files = filedialog.askopenfilenames(title="选择附件")
        if not files:
            return
        for f in files:
            if f not in self.attach_files:
                self.attach_files.append(f)
                self.listbox_attach.insert("end", f)

    def remove_selected(self):
        """从列表中移除选中的附件"""
        selection = list(self.listbox_attach.curselection())
        if not selection:
            return
        # 需要从后往前删,避免索引变化
        for index in reversed(selection):
            self.listbox_attach.delete(index)
            try:
                del self.attach_files[index]
            except IndexError:
                pass

    # ----------------- 发送逻辑 -----------------
    def send_mail(self):
        """点击“发送邮件”后的处理"""
        email_from = self.var_email_from.get().strip()
        email_user = self.var_email_user.get().strip()
        email_pwd = self.var_email_pwd.get().strip()
        smtp_server = self.var_smtp_server.get().strip() or "smtp.163.com"
        to_email = self.entry_to.get().strip()
        cc_email = self.entry_cc.get().strip()
        subject = self.entry_subject.get().strip()
        content = self.text_body.get("1.0", "end").strip()

        if not email_from or not email_user or not email_pwd:
            messagebox.showwarning("提示", "请先填写发件邮箱、登录账号和授权码/密码。")
            return

        if not to_email:
            messagebox.showwarning("提示", "请至少填写一个收件人邮箱。")
            return

        # 构造邮件
        msg = MIMEMultipart()
        msg["From"] = _format_addr(email_from)

        # 收件人列表
        to_list = []
        for addr in to_email.split(";"):
            addr = addr.strip()
            if addr:
                to_list.append(addr)
        msg["To"] = ",".join(to_list)

        # 抄送列表
        cc_list = []
        if cc_email:
            for addr in cc_email.split(";"):
                addr = addr.strip()
                if addr:
                    cc_list.append(addr)
        if cc_list:
            msg["Cc"] = ",".join(cc_list)

        msg["Subject"] = Header(subject or "无主题", "utf-8").encode()

        # 正文
        body = MIMEText(content or "", "html", "utf-8")
        msg.attach(body)

        # 附件
        for path in self.attach_files:
            try:
                with open(path, "rb") as fp:
                    basename = os.path.basename(path)
                    att = MIMEApplication(fp.read())
                    att["Content-Type"] = "application/octet-stream"
                    att.add_header(
                        "Content-Disposition",
                        "attachment",
                        filename=("utf-8", "", basename),
                    )
                    encoders.encode_base64(att)
                    msg.attach(att)
            except Exception as e:
                logger.error("读取附件失败: %s", path)
                logger.error(str(e))
                messagebox.showerror("错误", f"读取附件失败:{path}\n{e}")
                return

        # 发送
        all_receivers = to_list + cc_list
        try:
            server = smtplib.SMTP()
            server.connect(smtp_server)
            server.login(email_user, email_pwd)
            server.sendmail(email_user, all_receivers, msg.as_string())
            server.quit()
        except Exception as e:
            logger.error("发送失败: %s", str(e))
            messagebox.showerror("发送失败", f"发送邮件失败:\n{e}")
        else:
            messagebox.showinfo("成功", "邮件发送成功!")

    # ----------------- Excel 群发 -----------------
    def choose_excel(self):
        """选择 Excel 文件"""
        path = filedialog.askopenfilename(
            title="选择 Excel 模板",
            filetypes=(("Excel 文件", "*.xlsx;*.xlsm;*.xltx;*.xltm"), ("所有文件", "*.*")),
        )
        if not path:
            return
        self.entry_excel.delete(0, "end")
        self.entry_excel.insert(0, path)

    def load_excel(self):
        """加载 Excel 文件并在表格中预览"""
        path = self.entry_excel.get().strip()
        if not path:
            messagebox.showwarning("提示", "请先选择一个 Excel 文件。")
            return
        if not os.path.exists(path):
            messagebox.showerror("错误", f"文件不存在:{path}")
            return

        try:
            wb = load_workbook(path)
            ws = wb.active
        except Exception as e:
            messagebox.showerror("错误", f"读取 Excel 失败:\n{e}")
            return

        # 读取表头
        headers = []
        for col in range(1, ws.max_column + 1):
            title = ws.cell(row=1, column=col).value
            if title:
                headers.append(title)
            else:
                headers.append("")

        self.excel_headers = headers
        self.excel_rows = []
        self.excel_base_dir = os.path.dirname(os.path.abspath(path))

        # 清空 Treeview
        for item in self.tree.get_children():
            self.tree.delete(item)

        # 填充数据(从第 2 行开始)
        for row in range(2, ws.max_row + 1):
            row_data = []
            empty_row = True
            for col in range(1, ws.max_column + 1):
                value = ws.cell(row=row, column=col).value
                if value not in (None, ""):
                    empty_row = False
                row_data.append("" if value is None else str(value))
            if empty_row:
                continue
            self.excel_rows.append(row_data)

            # 映射到固定列顺序(如果有缺少的列就空着)
            display_row = []
            col_names = [
                "发件地址",
                "收件地址",
                "抄送地址",
                "标题",
                "正文",
                "附件1",
                "附件2",
                "发送结果",
            ]
            header_index = {name: idx for idx, name in enumerate(headers)}
            for name in col_names:
                idx = header_index.get(name)
                if idx is not None and idx < len(row_data):
                    display_row.append(row_data[idx])
                else:
                    display_row.append("")

            self.tree.insert("", "end", values=display_row)

        self.log("已加载 Excel,共 {} 条有效行。".format(len(self.excel_rows)))

    def start_excel_send(self):
        """根据已加载的 Excel 数据开始群发"""
        if not self.excel_rows:
            messagebox.showwarning("提示", "请先加载 Excel 模板。")
            return

        # 读取 SMTP 配置(来自“系统配置”标签页)
        email_from = self.var_email_from.get().strip()
        email_user = self.var_email_user.get().strip()
        email_pwd = self.var_email_pwd.get().strip()
        smtp_server = self.var_smtp_server.get().strip() or "smtp.163.com"

        if not email_user or not email_pwd:
            messagebox.showwarning("提示", "请先在“系统配置”标签页中填写发件账号和授权码。")
            return

        # 构造表头->索引映射
        header_index = {name: idx for idx, name in enumerate(self.excel_headers)}
        if "收件地址" not in header_index:
            messagebox.showerror("错误", "Excel 中缺少必需列:收件地址")
            return

        # 计算需要发送的行:如果用户选中了行,则只发选中行;否则发全部
        all_items = list(self.tree.get_children())
        selected_items = list(self.tree.selection())
        if selected_items:
            targets = [all_items.index(it) for it in selected_items]
            targets.sort()
            self.log(f"检测到选中 {len(targets)} 条记录,仅发送选中行。")
        else:
            targets = list(range(len(all_items)))
            self.log("未选择行,将对所有行进行发送。")

        try:
            server = smtplib.SMTP()
            server.connect(smtp_server)
            server.login(email_user, email_pwd)
        except Exception as e:
            messagebox.showerror("错误", f"连接邮件服务器失败:\n{e}")
            return

        self.btn_excel_send.config(state="disabled")
        self.btn_excel_stop.config(state="normal")
        self._excel_sending = True
        self.log("开始群发邮件...")

        # 逐行发送
        for display_idx in targets:
            if not self._excel_sending:
                self.log("发送已被用户中止。")
                break

            row_data = self.excel_rows[display_idx]
            row_dict = {}
            for name, col_idx in header_index.items():
                if col_idx < len(row_data):
                    row_dict[name] = row_data[col_idx]
                else:
                    row_dict[name] = ""

            status = send_mail_by_row(
                server, email_from, email_user, row_dict, self.excel_base_dir
            )

            # 更新 Treeview 中的“发送结果”列
            item_id = all_items[display_idx]
            values = list(self.tree.item(item_id, "values"))
            # “发送结果”列固定在最后一个
            values[-1] = status
            self.tree.item(item_id, values=values)

            self.log(f"第 {display_idx + 1} 行:{status}")
            # 刷新界面,让用户能实时看到
            self.root.update_idletasks()

        server.quit()
        self.btn_excel_send.config(state="normal")
        self.btn_excel_stop.config(state="disabled")
        self._excel_sending = False
        self.log("群发流程结束。")

    def stop_excel_send(self):
        """用户手动中断群发"""
        if self._excel_sending:
            self._excel_sending = False

    def log(self, text):
        """在 Excel 群发标签页下方的日志框输出一行文字"""
        self.text_log.insert("end", text + "\n")
        self.text_log.see("end")

    def export_log(self):
        """导出日志到文件,方便排查问题或存档"""
        log_text = self.text_log.get("1.0", "end").strip()
        if not log_text:
            messagebox.showinfo("提示", "当前没有可以导出的日志内容。")
            return
        path = filedialog.asksaveasfilename(
            title="保存日志",
            defaultextension=".txt",
            filetypes=(("文本文件", "*.txt"), ("所有文件", "*.*")),
        )
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                f.write(log_text)
        except Exception as e:
            messagebox.showerror("错误", f"保存日志失败:\n{e}")
        else:
            messagebox.showinfo("成功", f"日志已保存到:\n{path}")

    def show_row_detail(self, event):
        """双击某一行,弹出详情窗口查看完整正文和附件"""
        item_id = self.tree.focus()
        if not item_id:
            return
        values = self.tree.item(item_id, "values")
        if not values:
            return

        col_names = [
            "发件地址",
            "收件地址",
            "抄送地址",
            "标题",
            "正文",
            "附件1",
            "附件2",
            "发送结果",
        ]

        detail_win = tk.Toplevel(self.root)
        detail_win.title("邮件详情")
        detail_win.geometry("600x500")

        frame = ttk.Frame(detail_win, padding=10)
        frame.pack(fill="both", expand=True)

        # 基本字段(多行标签):发件地址、收件地址、抄送地址、标题、正文
        for i, name in enumerate(col_names[:5]):
            ttk.Label(frame, text=f"{name}:", anchor="e", width=10).grid(
                row=i, column=0, sticky="ne", padx=5, pady=3
            )
            if name == "正文":
                txt = tk.Text(frame, height=10, wrap="word")
                txt.grid(row=i, column=1, sticky="nsew", padx=5, pady=3)
                txt.insert("1.0", values[i] if i < len(values) else "")
                txt.config(state="disabled")
            else:
                val = values[i] if i < len(values) else ""
                ttk.Label(frame, text=val, anchor="w", wraplength=430).grid(
                    row=i, column=1, sticky="w", padx=5, pady=3
                )

        # 附件和发送结果
        attach_start_row = 7
        ttk.Label(frame, text="附件列表:", anchor="ne", width=10).grid(
            row=attach_start_row, column=0, sticky="ne", padx=5, pady=3
        )
        attach_box = tk.Text(frame, height=6, wrap="none")
        attach_box.grid(
            row=attach_start_row, column=1, sticky="nsew", padx=5, pady=3
        )
        for i in range(5, 7):
            if i < len(values) and values[i]:
                attach_box.insert("end", f"{col_names[i]}: {values[i]}\n")
        attach_box.config(state="disabled")

        ttk.Label(frame, text="发送结果:", anchor="e", width=10).grid(
            row=attach_start_row + 1, column=0, sticky="e", padx=5, pady=3
        )
        result_text = values[7] if len(values) > 7 else ""
        ttk.Label(frame, text=result_text, anchor="w", wraplength=430).grid(
            row=attach_start_row + 1, column=1, sticky="w", padx=5, pady=3
        )

        frame.rowconfigure(6, weight=1)
        frame.rowconfigure(attach_start_row, weight=1)
        frame.columnconfigure(1, weight=1)


def main():
    # 启动前先检查到期时间(过期或网络异常时静默退出)
    enforce_expiry_or_quit()
    root = tk.Tk()
    app = MailGui(root)
    root.mainloop()


if __name__ == "__main__":
    main()

 

posted @ 2025-11-26 11:01  *感悟人生*  阅读(4)  评论(0)    收藏  举报