xinops 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xinops/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ # Xinops/__init__.py
2
+
3
+ # 包版本号(与 setup.cfg 中的 version 保持一致)
4
+ __version__ = "1.0.0"
5
+
6
+ # 各功能模块 Mixin 导入
7
+ from ._core import CoreMixin
8
+ from ._wecom import WeComMixin
9
+ from ._email import EmailMixin
10
+ from ._hive import HiveMixin
11
+ from ._excel import ExcelMixin
12
+ from ._export import ExportMixin
13
+ from ._misc import MiscMixin
14
+
15
+ # 飞书表格上传器(独立类,不参与 Mixin 组装)
16
+ from ._feishu import FeishuSheetUploader
17
+
18
+
19
+ # 组装为对外统一的类:方法名与 Functions_d 1.46 完全一致,业务脚本仅需改 import
20
+ class DataProcessingAndMessaging(CoreMixin, WeComMixin, EmailMixin, HiveMixin,
21
+ ExcelMixin, ExportMixin, MiscMixin):
22
+ """数据处理与消息推送工具类(由多个功能 Mixin 组装而成)"""
23
+ pass
24
+
25
+
26
+ # 明确指定包对外暴露的成员(规范导入)
27
+ __all__ = ["DataProcessingAndMessaging", "FeishuSheetUploader", "__version__"]
xinops/_core.py ADDED
@@ -0,0 +1,213 @@
1
+ # -*- coding:utf-8 -*-
2
+ """核心模块:日志初始化、脚本生命周期管理、日期时间与通用工具方法"""
3
+ import datetime
4
+ import inspect
5
+ import logging
6
+ import os
7
+ import time
8
+ import psutil
9
+ import yaml
10
+
11
+
12
+ class CoreMixin:
13
+ """基础设施 Mixin:日志器、运行状态属性、密钥配置加载、通用工具方法"""
14
+
15
+ def __init__(self, enable_console_log=None):
16
+ # -------------------------- 1. 主类日志初始化 --------------------------
17
+ caller_frame = inspect.stack()[1]
18
+ caller_filename = caller_frame.filename
19
+ caller_filename = os.path.abspath(caller_filename)
20
+ script_dir = os.path.dirname(caller_filename)
21
+ log_filename = os.path.splitext(os.path.basename(caller_filename))[0] + ".log"
22
+ self.main_log_file = os.path.join(script_dir, log_filename)
23
+
24
+ self.logger = logging.getLogger("main_logger")
25
+ self.logger.setLevel(logging.INFO)
26
+ self.logger.propagate = False # 防止日志扩散
27
+ if not self.logger.handlers:
28
+ file_handler = logging.FileHandler(self.main_log_file, encoding='utf-8')
29
+ file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
30
+ self.logger.addHandler(file_handler)
31
+ if enable_console_log:
32
+ console_handler = logging.StreamHandler()
33
+ console_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
34
+ self.logger.addHandler(console_handler)
35
+
36
+ self.logger.info("\n" * 1) # 1行空行,可自行修改数字
37
+ self.logger.info("初始化 DataProcessingAndMessaging 类")
38
+
39
+ # -------------------------- 2. 主类核心参数初始化 --------------------------
40
+ self.start_time = None
41
+ self.current_script_name = None
42
+ self.log_filename = None
43
+ self.current_script_names = None
44
+ self.current_path = None
45
+ self.path = None
46
+
47
+ # -------------------------- 2.1 密钥配置加载(外部yaml,代码不再硬编码) --------------------------
48
+ sec = self._load_secrets(script_dir)
49
+
50
+ # 企业微信消息发送参数(环境变量 > 配置文件)
51
+ self.corpid = os.getenv("WECOM_CORPID") or sec.get("wecom_corpid")
52
+ self.corpsecret = os.getenv("WECOM_CORPSECRET") or sec.get("wecom_corpsecret")
53
+ self.agentid = str(sec.get("wecom_agentid", "1000026"))
54
+
55
+ # -------------------------- 3. 企业微信文档功能初始化 --------------------------
56
+ self.WECHAT_DOC_CORP_ID = os.getenv("WECOM_DOC_CORPID") or sec.get("wecom_doc_corpid")
57
+ self.WECHAT_DOC_SECRET = os.getenv("WECOM_DOC_SECRET") or sec.get("wecom_doc_corpsecret")
58
+ self.WECHAT_DOC_SPACE_ID = None
59
+ self.WECHAT_DOC_LOG_FILE = os.path.join(script_dir, "docs_operation.log")
60
+ self.wechat_doc_access_token = None
61
+ self._wechat_doc_logger = None # 企微文档独立日志器(延迟初始化)
62
+
63
+ # -------------------------- 邮件SMTP全局配置 --------------------------
64
+ self.SMTP_SENDER = sec.get("smtp_sender")
65
+ self.SMTP_PWD = sec.get("smtp_pwd")
66
+ self.SMTP_HOST = sec.get("smtp_host", "smtp.exmail.qq.com")
67
+ self.SMTP_PORT = int(sec.get("smtp_port", 465))
68
+ self.SMTP_TIMEOUT = 300 # 超时延长至5分钟
69
+ self.MAIL_RETRY_TIMES = 3 # 失败重试3次
70
+ self.MAIL_RETRY_INTERVAL = 5 # 重试间隔5秒
71
+
72
+ # -------------------------- Hive连接配置(run_sql使用) --------------------------
73
+ self.hive_host = os.getenv("HIVE_HOST") or sec.get("hive_host")
74
+ self.hive_port = int(sec.get("hive_port", 10023))
75
+ self.hive_user = os.getenv("HIVE_USER") or sec.get("hive_user")
76
+ self.hive_pwd = os.getenv("HIVE_PWD") or sec.get("hive_pwd")
77
+
78
+ # -------------------------- 密钥配置文件加载 --------------------------
79
+ def _load_secrets(self, script_dir):
80
+ """按优先级查找密钥配置文件(xinops_secrets.yaml 或 pypi_ku_secrets.yaml):
81
+ 1. 环境变量 XINOPS_CONFIG / PYPI_KU_CONFIG 指定的路径
82
+ 2. 调用方脚本所在目录及其各级上层目录(最多向上3层)
83
+ 3. 用户主目录
84
+ 配置文件放在代码仓库之外,切勿提交到 git 或打包进 PyPI。
85
+ """
86
+ candidates = []
87
+ for env_key in ("XINOPS_CONFIG", "PYPI_KU_CONFIG"):
88
+ env_path = os.getenv(env_key)
89
+ if env_path:
90
+ candidates.append(env_path)
91
+
92
+ file_names = ("xinops_secrets.yaml", "pypi_ku_secrets.yaml")
93
+ # 从脚本所在目录开始向上查找(本目录 + 上3层)
94
+ d = script_dir
95
+ for _ in range(4):
96
+ for fn in file_names:
97
+ candidates.append(os.path.join(d, fn))
98
+ parent = os.path.dirname(d)
99
+ if parent == d: # 已到根目录
100
+ break
101
+ d = parent
102
+
103
+ # 用户主目录兜底
104
+ home = os.path.expanduser("~")
105
+ for fn in file_names:
106
+ candidates.append(os.path.join(home, fn))
107
+
108
+ for path in candidates:
109
+ if os.path.exists(path):
110
+ try:
111
+ with open(path, encoding="utf-8") as f:
112
+ return yaml.safe_load(f) or {}
113
+ except yaml.YAMLError as e:
114
+ raise ValueError(f"密钥配置文件格式错误({path}):{e}")
115
+
116
+ raise FileNotFoundError(
117
+ "未找到密钥配置文件 xinops_secrets.yaml(或 pypi_ku_secrets.yaml),已查找以下位置:\n"
118
+ + "\n".join(candidates)
119
+ + "\n请把配置文件放到业务脚本上层目录,或设置环境变量 XINOPS_CONFIG 指向实际路径"
120
+ )
121
+
122
+ # -------------------------- 脚本路径和文件名初始化 --------------------------
123
+ def Start_Get_filepath_and_filename(self):
124
+ self.start_time = time.time()
125
+ # ========== 新增:运行开始时追加10行空行分隔 ==========
126
+ # self.logger.info('\n' * 5)
127
+ caller_frame = inspect.stack()[1]
128
+ self.current_script_name = caller_frame.filename
129
+ self.log_filename = os.path.splitext(self.current_script_name)[0] + ".log"
130
+ self.current_script_names = os.path.basename(self.current_script_name)
131
+ self.current_path = os.path.dirname(os.path.abspath(self.current_script_name))
132
+ self.path = self.current_path + os.sep
133
+ print(f"当前时间:{self.get_date_and_time('%Y-%m-%d %H:%M:%S', 0)}")
134
+ print(f"开始执行脚本:{self.current_script_names}")
135
+ self.logger.info(f"开始执行脚本:{self.current_script_names}")
136
+
137
+ # -------------------------- 脚本结束处理 --------------------------
138
+ def End_operation(self):
139
+ print(f"脚本:{self.current_script_names} 执行成功")
140
+ self.logger.info(f"脚本:{self.current_script_names} 执行成功")
141
+ end_time = time.time()
142
+ elapsed_time = round(end_time - self.start_time, 0)
143
+ print(f"运行时间:{elapsed_time} 秒")
144
+ self.logger.info(f"运行时间:{elapsed_time} 秒")
145
+ # self.logger.info('\n' * 2)
146
+
147
+ # -------------------------- 获取指定格式的日期时间 --------------------------
148
+ def get_date_and_time(self, format_type, days):
149
+ today = datetime.datetime.today()
150
+ target_date = today - datetime.timedelta(days=days)
151
+ result = target_date.strftime(format_type)
152
+ return result
153
+
154
+ # -------------------------- 获取文件大小(KB) --------------------------
155
+ def get_FileSize(self, img_name):
156
+ fsize = os.path.getsize(img_name)
157
+ fsize = fsize / float(1024)
158
+ return round(fsize, 2)
159
+
160
+ # -------------------------- 列标转换(A,B,C...) --------------------------
161
+ def column_label(self, n):
162
+ result = ""
163
+ while n > 0:
164
+ n, remainder = divmod(n - 1, 26)
165
+ result = chr(65 + remainder) + result
166
+ return result
167
+
168
+ # -------------------------- 辅助方法:检查文件是否被占用 --------------------------
169
+ def is_file_locked(self, file_path):
170
+ if not os.path.exists(file_path):
171
+ return False
172
+ try:
173
+ with open(file_path, 'a', encoding='utf-8') as f:
174
+ pass
175
+ return False
176
+ except IOError:
177
+ return True
178
+
179
+ # -------------------------- 辅助方法:安全关闭Excel进程 --------------------------
180
+ def _safe_quit_excel(self, app):
181
+ """安全关闭Excel进程,优化时序检查逻辑"""
182
+ try:
183
+ pid = app.pid
184
+ # 先尝试正常退出
185
+ app.quit()
186
+
187
+ # 增加短暂延迟,等待进程自然退出(解决时序问题)
188
+ time.sleep(0.5)
189
+
190
+ # 检查进程是否还存在
191
+ if psutil.pid_exists(pid):
192
+ # 尝试优雅终止
193
+ proc = psutil.Process(pid)
194
+ proc.terminate() # 发送终止信号
195
+
196
+ # 等待进程退出,最多等待3秒
197
+ try:
198
+ proc.wait(timeout=3)
199
+ self.logger.debug(f"Excel进程(PID: {pid})已被强制终止")
200
+ except psutil.TimeoutExpired:
201
+ # 超时后强制杀死进程
202
+ proc.kill()
203
+ self.logger.debug(f"Excel进程(PID: {pid})超时未退出,已强制杀死")
204
+ else:
205
+ # 进程已正常退出,不记录警告
206
+ self.logger.debug(f"Excel进程(PID: {pid})已正常退出")
207
+
208
+ except psutil.NoSuchProcess:
209
+ # 进程在检查前已退出,属于正常情况,不记录警告
210
+ self.logger.debug(f"Excel进程已提前退出(PID: {pid})")
211
+ except Exception as e:
212
+ # 其他未知错误才记录警告
213
+ self.logger.warning(f"关闭Excel进程时发生异常: {str(e)}")
xinops/_email.py ADDED
@@ -0,0 +1,338 @@
1
+ # -*- coding:utf-8 -*-
2
+ """邮件模块:简洁版与增强版邮件发送(yagmail / smtplib)"""
3
+ import os
4
+ import time
5
+ import smtplib
6
+ import yagmail
7
+ from email.mime.multipart import MIMEMultipart
8
+ from email.mime.text import MIMEText
9
+ from email.mime.application import MIMEApplication
10
+ from email.mime.image import MIMEImage
11
+
12
+
13
+ class EmailMixin:
14
+ """邮件发送 Mixin"""
15
+
16
+ # -------------------------- 发送邮件(旧版) --------------------------
17
+ def sende_email(self, name, contact_name, title, rec, file=None, cc=None, bcc=None, remarks=None):
18
+ """
19
+ 简洁版邮件发送(支持重复调用,自动过滤无效参数)
20
+ :param name: 收件人称呼(如"各位")
21
+ :param contact_name: 联系人姓名(如"董养")
22
+ :param title: 邮件主题
23
+ :param rec: 收件人邮箱(单个字符串或列表)
24
+ :param file: 附件路径(单个字符串、列表,或None)
25
+ :param cc: 抄送邮箱(单个字符串、列表,或None)
26
+ :param bcc: 密送邮箱(单个字符串、列表,或None)
27
+ :param remarks: 附加备注(可选,默认None,传入文本字符串时追加到正文)
28
+ """
29
+ # 1. 内部工具:格式化邮箱(转列表+过滤无效值)
30
+ def format_email(emails):
31
+ if not emails:
32
+ return None
33
+ # 单个邮箱转列表,列表直接使用
34
+ email_list = [emails] if isinstance(emails, str) else emails
35
+ # 过滤:非空字符串 + 简单格式校验(含@和.)
36
+ valid_emails = [
37
+ e.strip() for e in email_list
38
+ if isinstance(e, str) and e.strip() and '@' in e.strip() and '.' in e.strip()
39
+ ]
40
+ return valid_emails if valid_emails else None
41
+ # 2. 格式化所有邮箱参数
42
+ to_emails = format_email(rec)
43
+ cc_emails = format_email(cc)
44
+ bcc_emails = format_email(bcc)
45
+ # 3. 核心参数校验(提前报错,避免无效连接)
46
+ if not to_emails:
47
+ err_msg = "邮件发送失败:无有效收件人邮箱"
48
+ self.logger.error(err_msg)
49
+ raise ValueError(err_msg)
50
+ if not title.strip():
51
+ err_msg = "邮件发送失败:邮件主题不能为空"
52
+ self.logger.error(err_msg)
53
+ raise ValueError(err_msg)
54
+ # 4. 附件处理(校验存在性,支持单个/列表)
55
+ attachments = None
56
+ if file:
57
+ file_list = [file] if isinstance(file, str) else file
58
+ attachments = []
59
+ for f in file_list:
60
+ f = f.strip()
61
+ if not os.path.exists(f):
62
+ err_msg = f"邮件发送失败:附件不存在 -> {f}"
63
+ self.logger.error(err_msg)
64
+ raise FileNotFoundError(err_msg)
65
+ attachments.append(f)
66
+ # ==========【已删除此处硬编码smtp_conf字典,全部读取实例self属性】==========
67
+ # 5. 邮件内容(新增remarks逻辑:有值则追加到"请查收!"下方,保持缩进一致)
68
+ email_content = f"{name} 好:\n附件为《{title}》,请查收!"
69
+ if remarks and isinstance(remarks, str) and remarks.strip():
70
+ formatted_remarks = remarks.strip().replace('\n', '\n') # 处理多行备注
71
+ email_content += f"\n{formatted_remarks}"
72
+ email_content += f"\n\n如有疑问请联系{contact_name},谢谢~"
73
+ try:
74
+ # 上下文管理器:自动关闭连接,重复调用不泄露资源
75
+ # 直接使用self实例上面初始化的邮箱配置
76
+ smtp_conf = {
77
+ "user": self.SMTP_SENDER,
78
+ "password": self.SMTP_PWD,
79
+ "host": self.SMTP_HOST,
80
+ "port": self.SMTP_PORT,
81
+ "smtp_ssl": True,
82
+ "smtp_starttls": False
83
+ }
84
+ with yagmail.SMTP(**smtp_conf) as yag:
85
+ yag.send(
86
+ to=to_emails,
87
+ subject=title.strip(),
88
+ contents=email_content,
89
+ attachments=attachments,
90
+ cc=cc_emails,
91
+ bcc=bcc_emails
92
+ )
93
+ # 简洁日志:关键信息+统计,便于排查
94
+ log_msg = f"邮件发送成功 | 主题:{title.strip()} | 收件人:{len(to_emails)}人"
95
+ if cc_emails:
96
+ log_msg += f" | 抄送:{len(cc_emails)}人"
97
+ if bcc_emails:
98
+ log_msg += f" | 密送:{len(bcc_emails)}人"
99
+ if attachments:
100
+ log_msg += f" | 附件:{len(attachments)}个"
101
+ if remarks and remarks.strip():
102
+ log_msg += f" | 包含备注:{remarks.strip()[:20]}..." # 日志显示备注前20字(避免过长)
103
+ self.logger.info(log_msg)
104
+ print(log_msg)
105
+ # 7. 分类异常捕获(易排查问题)
106
+ except yagmail.SMTPAuthenticationError:
107
+ err_msg = "邮件发送失败:SMTP账号/密码/授权码错误"
108
+ self.logger.error(err_msg)
109
+ raise ValueError(err_msg)
110
+ except yagmail.SMTPConnectionError:
111
+ err_msg = "邮件发送失败:SMTP服务器连接失败(检查host/port/防火墙)"
112
+ self.logger.error(err_msg)
113
+ raise ConnectionError(err_msg)
114
+ except FileNotFoundError:
115
+ raise # 附件错误已提前处理,直接抛出
116
+ except Exception as e:
117
+ err_msg = f"邮件发送失败:{str(e)}"
118
+ self.logger.error(err_msg, exc_info=True)
119
+ raise RuntimeError(err_msg)
120
+
121
+ # -------------------------- 发送邮件(新版-优化增强版+重试+防超时) --------------------------
122
+ def send_email_new(self, recipient_emails, cc_emails=None, bcc_emails=None, subject="", html_body="",
123
+ attachments=None):
124
+ # 1. 内部工具:格式化邮箱(和旧版完全对齐,转列表+过滤无效值)
125
+ def format_email(emails):
126
+ if not emails:
127
+ return []
128
+ email_list = [emails] if isinstance(emails, str) else emails
129
+ valid_emails = [
130
+ e.strip() for e in email_list
131
+ if isinstance(e, str) and e.strip() and '@' in e.strip() and '.' in e.strip()
132
+ ]
133
+ return valid_emails
134
+
135
+ # 2. 内部工具:自动识别附件MIME类型(支持全格式文件)
136
+ def get_mime_type(file_path):
137
+ suffix = os.path.splitext(file_path)[1].lower()
138
+ mime_map = {
139
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
140
+ '.xls': 'application/vnd.ms-excel',
141
+ '.pdf': 'application/pdf',
142
+ '.doc': 'application/msword',
143
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
144
+ '.ppt': 'application/vnd.ms-powerpoint',
145
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
146
+ '.jpg': 'image/jpeg',
147
+ '.jpeg': 'image/jpeg',
148
+ '.png': 'image/png',
149
+ '.gif': 'image/gif',
150
+ '.txt': 'text/plain',
151
+ '.csv': 'text/csv',
152
+ '.zip': 'application/zip',
153
+ '.rar': 'application/x-rar-compressed'
154
+ }
155
+ return mime_map.get(suffix, 'application/octet-stream')
156
+
157
+ # 3. 参数标准化处理
158
+ subject = subject.strip()
159
+ to_emails = format_email(recipient_emails)
160
+ cc_emails = format_email(cc_emails)
161
+ bcc_emails = format_email(bcc_emails)
162
+ attachments = attachments or []
163
+ if isinstance(attachments, str):
164
+ attachments = [attachments.strip()]
165
+
166
+ # 4. 核心参数校验
167
+ if not to_emails:
168
+ err_msg = "❌ 邮件发送失败:无有效收件人邮箱"
169
+ self.logger.error(err_msg)
170
+ print(err_msg)
171
+ raise ValueError(err_msg)
172
+ if not subject:
173
+ err_msg = "❌ 邮件发送失败:邮件主题不能为空"
174
+ self.logger.error(err_msg)
175
+ print(err_msg)
176
+ raise ValueError(err_msg)
177
+
178
+ # 5. 控制台打印
179
+ print(f"📧 开始发送邮件 - 主题: {subject}")
180
+ print(f"📥 收件人: {', '.join(to_emails)}")
181
+ if cc_emails:
182
+ print(f"📋 抄送: {', '.join(cc_emails)}")
183
+ if bcc_emails:
184
+ print(f"🔒 密送: {', '.join(bcc_emails)}")
185
+ if attachments:
186
+ attach_names = [os.path.basename(att) for att in attachments]
187
+ print(f"📎 附件: {', '.join(attach_names)}")
188
+
189
+ # 6. 日志记录
190
+ self.logger.info(f"开始发送邮件 - 主题:{subject}")
191
+ self.logger.info(f"收件人: {', '.join(to_emails)}")
192
+ if cc_emails:
193
+ self.logger.info(f"抄送: {', '.join(cc_emails)}")
194
+ if bcc_emails:
195
+ self.logger.info(f"密送: {', '.join(bcc_emails)}")
196
+ if attachments:
197
+ attach_names = [os.path.basename(att) for att in attachments]
198
+ self.logger.info(f"附件: {', '.join(attach_names)}")
199
+
200
+ # 所有收件人提前合并去重
201
+ all_recipients = list(set(to_emails + cc_emails + bcc_emails))
202
+ last_exception = None
203
+
204
+ # 7. 循环重试发送(解决网络超时/抖动)
205
+ for retry_idx in range(1, self.MAIL_RETRY_TIMES + 1):
206
+ try:
207
+ # 8. 构建邮件对象
208
+ msg = MIMEMultipart()
209
+ msg['From'] = self.SMTP_SENDER
210
+ msg['To'] = ', '.join(to_emails)
211
+ if cc_emails:
212
+ msg['Cc'] = ', '.join(cc_emails)
213
+ if bcc_emails:
214
+ msg['Bcc'] = ', '.join(bcc_emails)
215
+ msg['Subject'] = subject
216
+
217
+ # 9. 邮件正文
218
+ body = MIMEText(html_body, 'html', 'utf-8')
219
+ msg.attach(body)
220
+
221
+ # 10. 附件处理 + 安全解码 + 中文文件名修复
222
+ valid_attachments = []
223
+ for attachment in attachments:
224
+ attachment = attachment.strip()
225
+ if not os.path.exists(attachment):
226
+ error_msg = f"❌ 邮件发送失败:附件不存在 -> {attachment}"
227
+ self.logger.error(error_msg)
228
+ print(error_msg)
229
+ raise FileNotFoundError(error_msg)
230
+
231
+ file_name = os.path.basename(attachment)
232
+ mime_type = get_mime_type(attachment)
233
+
234
+ # 二进制读取,避免大文件/编码报错
235
+ try:
236
+ with open(attachment, 'rb') as file:
237
+ file_content = file.read()
238
+ except Exception as e:
239
+ err = f"❌ 读取附件失败 {attachment}:{str(e)}"
240
+ self.logger.error(err, exc_info=True)
241
+ print(err)
242
+ raise RuntimeError(err)
243
+
244
+ # 构造附件
245
+ if mime_type.startswith('image/'):
246
+ part = MIMEImage(file_content, _subtype=mime_type.split('/')[1])
247
+ elif mime_type in ('text/plain', 'text/csv'):
248
+ # 文本文件容错解码
249
+ text = file_content.decode("utf-8", errors="replace")
250
+ part = MIMEText(text, _subtype=mime_type.split('/')[1], _charset='utf-8')
251
+ else:
252
+ part = MIMEApplication(file_content, _subtype=mime_type.split('/')[1])
253
+
254
+ # 修复中文附件名乱码(RFC2231标准)
255
+ part.add_header('Content-Disposition', 'attachment', filename=file_name)
256
+ part.set_param("filename*", f"utf-8''{file_name}", header="Content-Disposition")
257
+ part['Content-Type'] = f'{mime_type}'
258
+ msg.attach(part)
259
+
260
+ valid_attachments.append(file_name)
261
+ self.logger.debug(f"已添加附件: {file_name}")
262
+ print(f"✅ 已添加附件: {file_name}")
263
+
264
+ # 11. 连接SMTP服务器(延长超时 + 双重ehlo提升兼容性)
265
+ print(f"🔌 第{retry_idx}次尝试连接 SMTP {self.SMTP_HOST}:{self.SMTP_PORT}")
266
+ self.logger.info(f"第{retry_idx}次连接 SMTP 服务器")
267
+
268
+ # 465端口使用SMTP_SSL,不需要starttls
269
+ with smtplib.SMTP_SSL(self.SMTP_HOST, self.SMTP_PORT, timeout=self.SMTP_TIMEOUT) as smtp:
270
+ smtp.ehlo()
271
+ smtp.login(self.SMTP_SENDER, self.SMTP_PWD)
272
+ print(f"🔐 SMTP服务器登录成功")
273
+ smtp.sendmail(self.SMTP_SENDER, all_recipients, msg.as_string())
274
+
275
+ # 发送成功
276
+ success_msg = f"✅ 邮件发送成功 | 主题:{subject} | 收件人:{len(to_emails)}人"
277
+ if cc_emails:
278
+ success_msg += f" | 抄送:{len(cc_emails)}人"
279
+ if bcc_emails:
280
+ success_msg += f" | 密送:{len(bcc_emails)}人"
281
+ if valid_attachments:
282
+ success_msg += f" | 附件:{len(valid_attachments)}个"
283
+
284
+ self.logger.info(success_msg)
285
+ print(success_msg)
286
+ return True
287
+
288
+ # 可重试异常:超时、连接断开
289
+ except (TimeoutError, smtplib.SMTPServerDisconnected) as e:
290
+ last_exception = e
291
+ err = f"⚠️ 第{retry_idx}次发送超时/连接断开:{str(e)}"
292
+ self.logger.warning(err)
293
+ print(err)
294
+ if retry_idx < self.MAIL_RETRY_TIMES:
295
+ print(f"⏳ {self.MAIL_RETRY_INTERVAL} 秒后进行重试...")
296
+ time.sleep(self.MAIL_RETRY_INTERVAL)
297
+ continue
298
+
299
+ # 账号密码错误:致命异常,直接终止重试
300
+ except smtplib.SMTPAuthenticationError:
301
+ err_msg = "❌ 邮件发送失败:SMTP账号/密码错误,终止重试"
302
+ self.logger.error(err_msg, exc_info=True)
303
+ print(err_msg)
304
+ raise ValueError(err_msg)
305
+
306
+ # 连接失败
307
+ except smtplib.SMTPConnectError:
308
+ err_msg = "❌ 邮件发送失败:SMTP服务器连接失败(检查网络/防火墙)"
309
+ self.logger.error(err_msg, exc_info=True)
310
+ print(err_msg)
311
+ raise ConnectionError(err_msg)
312
+
313
+ # 其他SMTP协议异常
314
+ except smtplib.SMTPException as e:
315
+ err_msg = f"❌ 邮件发送失败:SMTP协议错误 - {str(e)}"
316
+ self.logger.error(err_msg, exc_info=True)
317
+ print(err_msg)
318
+ raise RuntimeError(err_msg)
319
+
320
+ # 文件类异常,直接抛出
321
+ except (FileNotFoundError, RuntimeError):
322
+ raise
323
+
324
+ # 通用未知异常
325
+ except Exception as e:
326
+ last_exception = e
327
+ err = f"⚠️ 第{retry_idx}次发送未知异常:{str(e)}"
328
+ self.logger.error(err, exc_info=True)
329
+ print(err)
330
+ if retry_idx < self.MAIL_RETRY_TIMES:
331
+ time.sleep(self.MAIL_RETRY_INTERVAL)
332
+ continue
333
+
334
+ # 所有重试全部失败
335
+ final_err = f"❌ 邮件{self.MAIL_RETRY_TIMES}次重试全部失败,最终异常:{str(last_exception)}"
336
+ self.logger.error(final_err, exc_info=True)
337
+ print(final_err)
338
+ raise RuntimeError(final_err)