Advertisement
shinemic

邮件批量分发工具

Jan 8th, 2024
667
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 19.70 KB | None | 0 0
  1. import logging
  2. import mimetypes
  3. import os
  4. import re
  5. import smtplib
  6. import socket
  7. import sys
  8. import traceback
  9. from copy import deepcopy
  10. from email.headerregistry import Address
  11. from email.message import EmailMessage
  12. from itertools import groupby
  13. from operator import itemgetter
  14. from pathlib import Path
  15. from typing import Dict, List
  16.  
  17. import pyexcel
  18. import tabulate
  19.  
  20. __version__ = "1.2.0 (20231019)"
  21. __author__ = "科技部 · 牛逼科 · 数智组"
  22. __email__ = "mumu@mumu.com"
  23.  
  24.  
  25. class BatchEmailParseError(Exception):
  26.     pass
  27.  
  28.  
  29. class BatchEmailSendError(Exception):
  30.     pass
  31.  
  32.  
  33. def _filter_row(row_index, row):
  34.     """
  35.    过滤空行 helper 函数
  36.    """
  37.  
  38.     result = [element for element in row if element != ""]
  39.  
  40.     return len(result) == 0
  41.  
  42.  
  43. class BatchEmailSender:
  44.     """
  45.    批量邮件发送
  46.    """
  47.  
  48.     LOG_FORMAT = "[%(asctime)s.%(msecs)03d] [%(levelname)5s] | %(message)s"
  49.     LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
  50.     LOG_LEVEL = logging.DEBUG
  51.  
  52.     SENDMAIL_ALL_OPTIONS = {"是否发送", "附件干预"}
  53.  
  54.     def __init__(self, /, **kwargs):
  55.         """
  56.        批量邮件发送类初始化
  57.  
  58.        @kwparam       config_file : 邮件分发配置文件(xlsx 文件格式)
  59.        @kwparam [opt] log_file    : 日志文件路径
  60.        """
  61.  
  62.         # 运行环境相关信息
  63.         self.cur_dir = Path(sys.argv[0]).parent
  64.  
  65.         # 方便起见,构造函数中直接对所有命名参数赋给实例
  66.         for arg_k, arg_v in kwargs.items():
  67.             setattr(self, arg_k, arg_v)
  68.  
  69.         self.set_logger()
  70.  
  71.         # SMTP 配置组,键为参数配置中的配置名,值为包括 SMTP server 信息的登录信息字典
  72.         self.smtp_servers: Dict[str, Dict] = {}
  73.  
  74.     def set_logger(self):
  75.         """
  76.        日志设置
  77.        """
  78.  
  79.         if not hasattr(self, "log_file"):
  80.             cur_file = Path(sys.argv[0])
  81.             self.log_file = cur_file.parent / (cur_file.stem + ".log")
  82.  
  83.         self.o_logfile = Path(self.log_file)
  84.         self.o_logfile.parent.mkdir(parents=True, exist_ok=True)
  85.         self.logger = logging.getLogger(self.o_logfile.stem)
  86.         self.logger.setLevel(self.LOG_LEVEL)
  87.         handler = logging.FileHandler(self.o_logfile, encoding="UTF-8")
  88.         handler.setFormatter(logging.Formatter(self.LOG_FORMAT, self.LOG_DATE_FMT))
  89.         self.logger.addHandler(handler)
  90.  
  91.         self.logger.info("-" * 80)
  92.         self.logger.info(f"{Path(sys.argv[0]).stem}: 程序开始运行")
  93.  
  94.     def parse_config(self):
  95.         """
  96.        解析 Excel 配置文件
  97.        """
  98.  
  99.         if not Path(self.config_file).is_file():
  100.             self.logger.error(f"配置文件 [{self.config_file}] 不存在!")
  101.             raise BatchEmailParseError(f"配置文件解析错误:{self.config_file}")
  102.             sys.exit(1)
  103.  
  104.         self.logger.info(f"配置文件解析中 [{self.config_file}]")
  105.  
  106.         book = pyexcel.get_book(file_name=self.config_file)
  107.  
  108.         # 参数表 Sheet 设置
  109.         self.sheet_config = book["参数配置"]
  110.         self.sheet_config.name_columns_by_row(0)
  111.         del self.sheet_config.row[_filter_row]
  112.  
  113.         # 仅供日志输出用
  114.         sheet_config_view = list(self.sheet_config.records)
  115.         for d in sheet_config_view:
  116.             d["登录密码"] = "******"
  117.  
  118.         self.logger.debug(
  119.             "参数配置信息:\n"
  120.             + tabulate.tabulate(sheet_config_view, headers="keys", tablefmt="simple")
  121.         )
  122.  
  123.         # 邮件分发信息
  124.         self.sheet_data = book["邮件分发"]
  125.         self.sheet_data.name_columns_by_row(0)
  126.         del self.sheet_data.row[_filter_row]
  127.  
  128.         self.logger.info(f"配置文件解析完成")
  129.         print(f"配置文件解析完成")
  130.  
  131.     def smtp_login(self, timeout=5, /, **login_info):
  132.         """
  133.        连接至邮箱服务器
  134.        """
  135.  
  136.         (
  137.             profile,
  138.             smtp_host,
  139.             smtp_port,
  140.             smtp_ssl,
  141.             sender_mail,
  142.             sender_password,
  143.         ) = itemgetter("配置名称", "邮箱服务器", "邮箱服务端口", "SSL加密", "发件人邮箱", "登录密码")(login_info)
  144.  
  145.         auth_status = False
  146.         smtp_info = f"{sender_mail}/******@{smtp_host}:{smtp_port}"
  147.  
  148.         smtp_method = smtplib.SMTP_SSL if smtp_ssl == "是" else smtplib.SMTP
  149.  
  150.         try:
  151.             self.logger.info(f"尝试登录至 SMTP 服务器 ({smtp_info})")
  152.             server = smtp_method(smtp_host, smtp_port, timeout=timeout)
  153.             server.login(sender_mail, sender_password)
  154.         except smtplib.SMTPAuthenticationError:
  155.             self.logger.error(f"发件人信息配置错误:{smtp_info}")
  156.             print(f"发件人信息配置错误,详见日志 {self.o_logfile.resolve()}")
  157.         except (smtplib.SMTPConnectError, socket.gaierror):
  158.             self.logger.error(f"邮箱服务器配置错误:{smtp_info}")
  159.             print(f"邮件服务器配置错误,详见日志 {self.o_logfile.resolve()}")
  160.         except (socket.timeout, smtplib.SMTPServerDisconnected):
  161.             self.logger.error(f"连接邮箱服务器超时:{smtp_info}")
  162.             print(f"连接邮箱服务器超时,详见日志 {self.o_logfile.resolve()}")
  163.         else:
  164.             print(f"成功连接邮箱服务器 ({smtp_info})\n")
  165.             self.logger.info(f"成功连接邮箱服务器 ({smtp_info})")
  166.             self.smtp_servers[profile] = {
  167.                 "server": server,
  168.                 **login_info,
  169.             }
  170.             auth_status = True
  171.  
  172.         return auth_status
  173.  
  174.     @staticmethod
  175.     def _parse_single_option(option_str):
  176.         """
  177.        解析单条选项
  178.        """
  179.  
  180.         mat = re.search(r"(\S+)\s*[::]\s*(\S+)", option_str)
  181.         if (
  182.             mat
  183.             and len(mat.groups()) == 2
  184.             and mat.group(1) in BatchEmailSender.SENDMAIL_ALL_OPTIONS
  185.             and mat.group(2)
  186.         ):
  187.             option = {mat.group(1): mat.group(2)}
  188.             return option
  189.         else:
  190.             return dict()
  191.  
  192.     @staticmethod
  193.     def parse_options(option_str):
  194.         """
  195.        解析单元格选项(可为多个)
  196.        """
  197.  
  198.         options_res = {}
  199.         raw_options = list(map(str.strip, re.split(r"[\n;;]+", option_str.strip())))
  200.         for option in raw_options:
  201.             options_res.update(BatchEmailSender._parse_single_option(option))
  202.  
  203.         return options_res
  204.  
  205.     def run(self):
  206.         """
  207.        批量发送邮件
  208.        """
  209.  
  210.         self.parse_config()
  211.  
  212.         # 发送顺序:对 (发件配置, 序号) 进行排列,每个发件配置内依次发件
  213.         send_data = list(
  214.             map(
  215.                 itemgetter(1),
  216.                 (
  217.                     sorted(
  218.                         enumerate(self.sheet_data.records),
  219.                         key=lambda x: (x[1]["发件配置"], x[0]),
  220.                     )
  221.                 ),
  222.             )
  223.         )
  224.  
  225.         # 无待发邮件
  226.         if not send_data:
  227.             print("无待发邮件,退出程序")
  228.             self.logger.warning("无待发邮件,退出程序")
  229.             return
  230.  
  231.         send_data_view = deepcopy(send_data)
  232.  
  233.         for record in send_data_view:
  234.             if len(record["邮件正文"]) > 60:
  235.                 record["邮件正文"] = record["邮件正文"][:30] + " ... " + record["邮件正文"][-30:]
  236.  
  237.         self.logger.info(
  238.             "发件信息:\n"
  239.             + tabulate.tabulate(
  240.                 send_data_view,
  241.                 headers="keys",
  242.                 tablefmt="simple",
  243.                 maxcolwidths=[40] * len(self.sheet_data.colnames),
  244.             )
  245.         )
  246.  
  247.         # 邮件发送结果
  248.         send_result = {}
  249.  
  250.         self.logger.info("邮件准备分发中...")
  251.         self.logger.info("")
  252.         print("邮件准备分发...\n")
  253.  
  254.         # 分组发送邮件
  255.         for profile, send_info in groupby(send_data, key=itemgetter("发件配置")):
  256.             # 根据 profile 获取发送配置信息
  257.             for profile_record in self.sheet_config.records:
  258.                 if profile_record["配置名称"] == profile:
  259.                     login_info = profile_record
  260.                     break
  261.             else:
  262.                 print(f"没有找到 <{profile}> 的配置信息")
  263.                 continue
  264.  
  265.             # 尝试登录
  266.             login_status = self.smtp_login(**login_info)
  267.  
  268.             # 如登录失败,尝试登录下一组配置
  269.             if not login_status:
  270.                 continue
  271.  
  272.             # 防止迭代器失效,提前展开并存储
  273.             send_info = list(send_info)
  274.  
  275.             for info in send_info:
  276.                 # 发件人
  277.                 sender_username, sender_domain = self.smtp_servers[profile][
  278.                     "发件人邮箱"
  279.                 ].split("@")
  280.                 sender_display_name = self.smtp_servers[profile]["发件人显示名"].strip()
  281.                 if sender_display_name:
  282.                     mail_sender_address = Address(
  283.                         sender_display_name, sender_username, sender_domain
  284.                     )
  285.                 else:
  286.                     mail_sender_address = Address(sender_username, sender_domain)
  287.  
  288.                 # 收件人组
  289.                 mail_receiver_address = self.parse_address(info["收件人"])
  290.                 mail_receiver_view = "; ".join(
  291.                     f"({i}) {c}" for i, c in enumerate(mail_receiver_address, 1)
  292.                 )
  293.  
  294.                 # 抄送组
  295.                 mail_cc_address = self.parse_address(info["抄送"])
  296.                 mail_cc_view = "; ".join(
  297.                     f"({i}) {c}" for i, c in enumerate(mail_cc_address, 1)
  298.                 )
  299.  
  300.                 # 密送组
  301.                 mail_bcc_address = self.parse_address(info["密送"])
  302.                 mail_bcc_view = "; ".join(
  303.                     f"({i}) {c}" for i, c in enumerate(mail_bcc_address, 1)
  304.                 )
  305.  
  306.                 # 主题
  307.                 mail_subject = info["邮件主题"]
  308.  
  309.                 # 正文
  310.                 mail_content_view = mail_content = info["邮件正文"]
  311.                 if len(mail_content) > 100:
  312.                     mail_content_view = mail_content[:50] + " ... " + mail_content[-50:]
  313.  
  314.                 # 附件路径
  315.                 mail_attaches = []
  316.                 if info["附件路径"].strip():
  317.                     mail_attaches = list(
  318.                         map(
  319.                             lambda s: s.strip(' "'),
  320.                             re.split(r"\s*[,;]\s*", info["附件路径"].strip()),
  321.                         )
  322.                     )
  323.  
  324.                 # 发送选项
  325.                 mail_options = BatchEmailSender.parse_options(info["发送选项"])
  326.  
  327.                 # -------------------------------------------------------
  328.                 # 日志输出发件信息
  329.  
  330.                 self.logger.info(f"发件人:{mail_sender_address}")
  331.                 self.logger.info(f"收件人:{mail_receiver_view}")
  332.  
  333.                 if mail_cc_address:
  334.                     self.logger.info(f"抄送:{mail_cc_view}")
  335.  
  336.                 if mail_bcc_address:
  337.                     self.logger.info(f"密送:{mail_bcc_view}")
  338.  
  339.                 self.logger.info(f"主题:{mail_subject}")
  340.                 self.logger.info(f"正文:{mail_content_view}")
  341.                 if mail_attaches:
  342.                     self.logger.info("附件:" + "; ".join(mail_attaches))
  343.  
  344.                 for option_idx, (option_key, option_val) in enumerate(
  345.                     mail_options.items(), 1
  346.                 ):
  347.                     self.logger.info(
  348.                         f"选项 #{option_idx} | [{option_key}]: [{option_val}]"
  349.                     )
  350.  
  351.                 # -------------------------------------------------------
  352.  
  353.                 send_info_abstract = "\n".join(
  354.                     filter(
  355.                         len,
  356.                         [
  357.                             f"配置:{profile}",
  358.                             f"发件:{mail_sender_address}",
  359.                             "收件:" + "; ".join(map(str, mail_receiver_address)),
  360.                             ("抄送:" + "; ".join(map(str, mail_cc_address)))
  361.                             if mail_cc_address
  362.                             else "",
  363.                             ("密送:" + "; ".join(map(str, mail_bcc_address)))
  364.                             if mail_bcc_address
  365.                             else "",
  366.                             f"主题:{mail_subject}",
  367.                         ],
  368.                     )
  369.                 )
  370.  
  371.                 print(f"邮件发送中...\n{send_info_abstract}")
  372.  
  373.                 # 邮件发送干预
  374.                 # -----------
  375.  
  376.                 # 问题附件路径(不符文件名规范或该路径下文件不存在)
  377.                 problem_attaches = []
  378.  
  379.                 # :: 是否发送 :: 如该选项为「否」,直接停止发送
  380.                 if mail_options.get("是否发送", "是") == "否":
  381.                     print("发送选项中该邮件设置为不发送!")
  382.                     print("-" * 40, end="\n\n")
  383.                     self.logger.warning("邮件发送失败")
  384.                     self.logger.info("")
  385.                     send_result["失败"] = send_result.get("失败", 0) + 1
  386.                     continue
  387.  
  388.                 # :: 附件干预 :: 如该选项为「否」,直接停止发送
  389.                 if mail_options.get("附件干预", "否") == "是":
  390.                     for attach_file in mail_attaches:
  391.                         if not (
  392.                             Path(attach_file).is_file() and Path(attach_file).exists()
  393.                         ):
  394.                             problem_attaches.append(attach_file)
  395.                     if problem_attaches:
  396.                         problem_attaches_str = "、".join(problem_attaches)
  397.                         print(f"附件 {problem_attaches_str} 不存在,不发送该邮件")
  398.                         print("-" * 40, end="\n\n")
  399.                         send_result["失败"] = send_result.get("失败", 0) + 1
  400.                         self.logger.warning("邮件发送失败")
  401.                         self.logger.info("")
  402.                         continue
  403.  
  404.                 # -------------------------------------------------------
  405.  
  406.                 try:
  407.                     self.send_email(
  408.                         self.smtp_servers[profile]["server"],
  409.                         mail_sender_address,
  410.                         mail_receiver_address,
  411.                         mail_cc_address,
  412.                         mail_bcc_address,
  413.                         mail_subject,
  414.                         mail_content,
  415.                         mail_attaches,
  416.                     )
  417.                 except Exception as e:
  418.                     send_result["失败"] = send_result.get("失败", 0) + 1
  419.                     self.logger.exception(e)
  420.                     self.logger.error("邮件发送失败")
  421.                     print(f"邮件发送失败,详见日志 {self.log_file}")
  422.                 else:
  423.                     send_result["成功"] = send_result.get("成功", 0) + 1
  424.                     self.logger.info("邮件发送成功")
  425.                     print("邮件发送成功")
  426.                     print("-" * 40, end="\n\n")
  427.                 finally:
  428.                     self.logger.info("")
  429.  
  430.         send_conclusion = (
  431.             f"批量发送完成,成功发件 {send_result.get('成功', 0)} 封,失败 {send_result.get('失败', 0)} 封"
  432.         )
  433.         self.logger.info(send_conclusion)
  434.         print("\n" + send_conclusion)
  435.  
  436.         self.logger.info(f"{Path(sys.argv[0]).stem}: 程序运行结束")
  437.         self.logger.info("=" * 80)
  438.         self.logger.info("")
  439.  
  440.     @staticmethod
  441.     def parse_address(addr_str: str) -> List[Address]:
  442.         """
  443.        将收件 / 抄送 / 密送组人员解析为 Address 列表
  444.        """
  445.  
  446.         addr_list = []
  447.  
  448.         split_addrs = re.split(r"\s*[,;]\s*", addr_str)
  449.         for addr in map(str.strip, split_addrs):
  450.             # 匹配两种类型邮箱地址:
  451.             #   - display name <rec1@xx.com>
  452.             #   - rec2@xx.com
  453.             mat = re.search(
  454.                 r"^(?P<display_name>.*?)\s*<(?P<comp_username>\S+)@(?P<comp_domain>\S+)>"
  455.                 r"|^(?P<username>\S+)@(?P<domain>\S+)",
  456.                 addr,
  457.                 re.VERBOSE,
  458.             )
  459.  
  460.             # 错误地址类型,忽略
  461.             if not mat:
  462.                 continue
  463.  
  464.             # 带有显示名称类型地址
  465.             if mat["display_name"]:
  466.                 addr_list.append(
  467.                     Address(
  468.                         mat["display_name"], mat["comp_username"], mat["comp_domain"]
  469.                     )
  470.                 )
  471.             # 单纯邮箱地址
  472.             elif mat["username"]:
  473.                 addr_list.append(
  474.                     Address(username=mat["username"], domain=mat["domain"])
  475.                 )
  476.  
  477.         return tuple(addr_list)
  478.  
  479.     def send_email(
  480.         self,
  481.         server: smtplib.SMTP,
  482.         sender: Address,
  483.         receiver: List[Address],
  484.         cc: List[Address],
  485.         bcc: List[Address],
  486.         subject: str,
  487.         content: str,
  488.         attach_files=None,
  489.     ):
  490.         """
  491.        单封邮件发送
  492.        """
  493.  
  494.         msg = EmailMessage()
  495.         msg["Subject"] = subject
  496.         msg["From"] = sender
  497.         msg["To"] = receiver
  498.  
  499.         if cc:
  500.             msg["Cc"] = cc
  501.  
  502.         if bcc:
  503.             msg["Bcc"] = bcc
  504.  
  505.         msg.set_content(content, charset="utf-8", subtype="html")
  506.  
  507.         for attach_file in attach_files:
  508.             o_attach_file = Path(attach_file)
  509.  
  510.             # 附件路径需存在
  511.             if not o_attach_file.is_file():
  512.                 continue
  513.  
  514.             ctype, encoding = mimetypes.guess_type(o_attach_file)
  515.             if ctype is None or encoding is not None:
  516.                 ctype = "application/octet-stream"
  517.             maintype, subtype = ctype.split("/", 1)
  518.             msg.add_attachment(
  519.                 o_attach_file.read_bytes(),
  520.                 maintype=maintype,
  521.                 subtype=subtype,
  522.                 filename=o_attach_file.name,
  523.             )
  524.  
  525.         try:
  526.             server.send_message(msg)
  527.         except Exception:
  528.             traceback.print_exc()
  529.  
  530.  
  531. if __name__ == "__main__":
  532.     print("\n")
  533.     print(f"----------------------------------------------------------------")
  534.     print(f"                                                        ")
  535.     print(f"                        邮件批量分发工具                    ")
  536.     print(f"                      ====================                  ")
  537.     print(f"                                                        ")
  538.     print(f"        版本信息:{__version__}")
  539.     print(f"        开发作者:{__author__}")
  540.     print(f"        问题反馈:{__email__}")
  541.     print(f"                                                        ")
  542.     print(f"----------------------------------------------------------------")
  543.     print("\n")
  544.  
  545.     if len(sys.argv) == 2:
  546.         config_file = sys.argv[1]
  547.     else:
  548.         # 防止路径中包含空格报错
  549.         while True:
  550.             config_file = input("邮件配置文件路径:").strip("\" \t'")
  551.             if config_file:
  552.                 break
  553.  
  554.     sender = BatchEmailSender(config_file=config_file)
  555.     try:
  556.         sender.run()
  557.     except BatchEmailParseError as e:
  558.         print(e)
  559.     except Exception:
  560.         traceback.print_exc()
  561.         print(f"发生错误,详见日志:{Path(sender.log_file).resolve()}")
  562.  
  563.     print("\n按任意键继续. . . ", end="")
  564.     os.system("pause > nul")
  565.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement