日报此前把明细压到极简(修改只列变更字段≥3 的记录、新增只给模块计数), "199 条新增只看到 3 个模块名",查不到某条具体记录改了什么。 但也不能全塞进正文:实测某日 354 条修改展开近 3000 行,客户端渲染卡顿, 部分企业邮件网关会把超长自动邮件判为垃圾或截断 —— 发件方照样收到 250, 表面看一切正常,这比"信息少"更难排查。 故拆成两路:正文保持摘要(render),附件给全量明细(render_excel), 六个工作表不设 DETAIL_MIN_FIELDS 之类的阈值。 - email_service: send_email/send_email_async 支持 attachments。数据是内存 字节、不落盘;中文文件名走 RFC 2231;.xlsx 用官方 MIME 类型,写错会让 Outlook 当成未知二进制、附件无法直接打开。 - audit_export_service: 新增。审计日志 → 可读值/Excel 的共用层。 「同一条「实际审批人ID: 7」一边翻了人名一边没翻」这类漂移,比不翻译更 难发现,故取值/翻译/排版逻辑集中在此,由日报与审计页导出共用。 - daily_report_service: 私有副本改为消费上述共用层(纯重构)。 附件大小与重构前逐字节一致(490.5K / 193.9K / 10.3K),行为未变。 附件生成失败时降级为纯文本正文继续发送,但记 error 级日志 —— 降级后邮件 看起来完全正常,不留显眼日志的话附件静默丢失可以持续几个月没人发现。
480 lines
18 KiB
Python
480 lines
18 KiB
Python
"""
|
||
邮件通知服务
|
||
使用 Python smtplib + email.mime 实现,支持 TLS/SSL SMTP 连接
|
||
从环境变量或 Flask config 读取邮件配置
|
||
"""
|
||
import os
|
||
import smtplib
|
||
import ssl
|
||
import logging
|
||
from email.mime.application import MIMEApplication
|
||
from email.mime.text import MIMEText
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.header import Header
|
||
from email.utils import formatdate, make_msgid
|
||
from typing import List, Union
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 附件扩展名 → MIME 子类型。
|
||
#
|
||
# ★ .xlsx 必须用官方类型,不能图省事写 'xlsx':那样 Content-Type 会变成
|
||
# application/xlsx,Outlook 等客户端会当成未知二进制,附件无法双击直接
|
||
# 打开(得先存盘再手动改后缀),用户只会反馈"附件打不开"。
|
||
_XLSX_SUBTYPE = 'vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
_ATTACHMENT_SUBTYPES = {
|
||
'.xlsx': _XLSX_SUBTYPE,
|
||
'.xls': 'vnd.ms-excel',
|
||
'.csv': 'vnd.ms-excel',
|
||
'.pdf': 'pdf',
|
||
'.zip': 'zip',
|
||
}
|
||
|
||
|
||
def _attachment_subtype(filename: str) -> str:
|
||
"""按扩展名给出 MIME 子类型;未登记的回落到 octet-stream(即默认值)"""
|
||
ext = os.path.splitext(filename or '')[1].lower()
|
||
return _ATTACHMENT_SUBTYPES.get(ext, 'octet-stream')
|
||
|
||
|
||
def _normalize_attachment(att):
|
||
"""
|
||
附件入参 → (文件名, 字节)。接受两种写法,避免调用方纠结:
|
||
|
||
{'filename': '日报.xlsx', 'data': b'...'}
|
||
('日报.xlsx', b'...')
|
||
"""
|
||
if isinstance(att, dict):
|
||
return att.get('filename') or 'attachment', att.get('data') or b''
|
||
filename, data = att
|
||
return filename or 'attachment', data or b''
|
||
|
||
|
||
def _get_config():
|
||
"""
|
||
读取邮件配置,优先从 Flask app config,回退到环境变量
|
||
"""
|
||
try:
|
||
from flask import current_app
|
||
return {
|
||
'server': current_app.config.get('MAIL_SERVER', os.getenv('MAIL_SERVER')),
|
||
'port': current_app.config.get('MAIL_PORT', int(os.getenv('MAIL_PORT', 587))),
|
||
'username': current_app.config.get('MAIL_USERNAME', os.getenv('MAIL_USERNAME')),
|
||
'password': current_app.config.get('MAIL_PASSWORD', os.getenv('MAIL_PASSWORD')),
|
||
'sender': current_app.config.get('MAIL_DEFAULT_SENDER', os.getenv('MAIL_DEFAULT_SENDER')),
|
||
'use_tls': current_app.config.get('MAIL_USE_TLS', os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes')),
|
||
'use_ssl': current_app.config.get('MAIL_USE_SSL', os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes')),
|
||
'enabled': current_app.config.get('MAIL_ENABLED', os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes')),
|
||
}
|
||
except RuntimeError:
|
||
# 不在 Flask 上下文时,直接读环境变量
|
||
return {
|
||
'server': os.getenv('MAIL_SERVER'),
|
||
'port': int(os.getenv('MAIL_PORT', 587)),
|
||
'username': os.getenv('MAIL_USERNAME'),
|
||
'password': os.getenv('MAIL_PASSWORD'),
|
||
'sender': os.getenv('MAIL_DEFAULT_SENDER'),
|
||
'use_tls': os.getenv('MAIL_USE_TLS', 'true').lower() in ('true', '1', 'yes'),
|
||
'use_ssl': os.getenv('MAIL_USE_SSL', 'false').lower() in ('true', '1', 'yes'),
|
||
'enabled': os.getenv('MAIL_ENABLED', 'false').lower() in ('true', '1', 'yes'),
|
||
}
|
||
|
||
|
||
def send_email(to_email: Union[str, List[str]], subject: str, content: str,
|
||
cfg: dict = None, attachments: list = None):
|
||
"""
|
||
通用邮件发送函数
|
||
|
||
Args:
|
||
to_email: 收件人,单个邮箱字符串或列表
|
||
subject: 邮件主题
|
||
content: 邮件正文(纯文本)
|
||
cfg: 可选,预先获取的配置字典(用于异步线程传参,避免丢失 Flask context)
|
||
attachments: 可选,附件列表。每项为
|
||
{'filename': '日报.xlsx', 'data': b'...'} 或 ('日报.xlsx', b'...')。
|
||
数据是**字节**而非路径 —— 调用方在内存里生成,不落盘。
|
||
|
||
发送失败时打印日志,不抛出异常
|
||
"""
|
||
if cfg is None:
|
||
cfg = _get_config()
|
||
|
||
# ★ 不要把 cfg 整个打出来 —— 它含 'password'(邮箱授权码),
|
||
# 一 print 就落到 stdout → docker logs,任何能看日志的人都拿得到发信凭证。
|
||
# 只打非敏感字段,密码只报"有没有设"。
|
||
_pw_state = '已设置' if cfg.get('password') else '空'
|
||
print(
|
||
f"[DEBUG send_email] server={cfg.get('server')} port={cfg.get('port')} "
|
||
f"sender={cfg.get('sender')} ssl={cfg.get('use_ssl')} tls={cfg.get('use_tls')} "
|
||
f"enabled={cfg.get('enabled')} password={_pw_state}"
|
||
)
|
||
|
||
# 发送总开关
|
||
if not cfg.get('enabled'):
|
||
print(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
|
||
logger.info(f"[Email] 邮件功能已禁用 (MAIL_ENABLED=false),跳过发送: {subject}")
|
||
return
|
||
|
||
# 配置完整性检查
|
||
if not cfg.get('server') or not cfg.get('username') or not cfg.get('password'):
|
||
print(f"[Email] 邮件配置不完整 server={cfg.get('server')} username={cfg.get('username')} password={'已设' if cfg.get('password') else '空'},跳过发送")
|
||
logger.warning("[Email] 邮件配置不完整 (MAIL_SERVER/USERNAME/PASSWORD 缺失),跳过发送")
|
||
return
|
||
|
||
# 标准化收件人列表
|
||
recipients = [to_email] if isinstance(to_email, str) else [r.strip() for r in to_email if r.strip()]
|
||
if not recipients:
|
||
print("[Email] 收件人地址为空,跳过发送")
|
||
logger.warning("[Email] 收件人地址为空,跳过发送")
|
||
return
|
||
|
||
try:
|
||
# ★ 显式 'mixed':虽然 MIMEMultipart() 默认就是 mixed,但正文+附件
|
||
# 的语义要求就在这里,写出来才不会被后人"顺手"改成 related/alternative。
|
||
msg = MIMEMultipart('mixed')
|
||
msg['From'] = cfg['sender']
|
||
msg['To'] = ', '.join(recipients)
|
||
msg['Subject'] = Header(subject, 'utf-8')
|
||
# ★ Date 与 Message-ID 是 RFC 5322 的**必需/强烈建议**头,
|
||
# smtplib 不会自动补。缺了它们,收件方的反垃圾引擎会显著加分
|
||
# ("来源不明的自动化邮件"),很容易被判成垃圾或进隔离区 ——
|
||
# 而发件服务器照样回 250,客户端完全看不出来。
|
||
# 实测本项目的邮件此前两个头都没有。
|
||
msg['Date'] = formatdate(localtime=True)
|
||
msg['Message-ID'] = make_msgid(domain=(cfg['sender'].split('@')[-1] or None))
|
||
msg.attach(MIMEText(content, 'plain', 'utf-8'))
|
||
|
||
# 附件:内容已在内存里,直接 base64 编码挂上,不经过文件系统
|
||
attached = 0
|
||
for att in (attachments or []):
|
||
filename, data = _normalize_attachment(att)
|
||
if not data:
|
||
logger.warning(f"[Email] 附件 {filename} 内容为空,已跳过")
|
||
continue
|
||
part = MIMEApplication(data, _subtype=_attachment_subtype(filename))
|
||
# ★ 中文文件名必须走 RFC 2231(('utf-8','',name) 三元组),
|
||
# 直接塞原始中文会生成非法的 Content-Disposition 头,
|
||
# 客户端显示成乱码或一串 =?utf-8?b?...?= 原文。
|
||
part.add_header('Content-Disposition', 'attachment',
|
||
filename=('utf-8', '', filename))
|
||
msg.attach(part)
|
||
attached += 1
|
||
|
||
print(
|
||
f"DEBUG: 准备向服务器提交发信请求,收件人: {recipients} "
|
||
f"发件人: {cfg['username']} 附件: {attached} 个"
|
||
)
|
||
|
||
if cfg.get('use_ssl'):
|
||
context = ssl.create_default_context()
|
||
with smtplib.SMTP_SSL(cfg['server'], cfg.get('port', 465), context=context) as server:
|
||
server.login(cfg['username'], cfg['password'])
|
||
server.sendmail(cfg['username'], recipients, msg.as_string())
|
||
else:
|
||
with smtplib.SMTP(cfg['server'], cfg.get('port', 587)) as server:
|
||
if cfg.get('use_tls'):
|
||
server.starttls(context=ssl.create_default_context())
|
||
server.login(cfg['username'], cfg['password'])
|
||
server.sendmail(cfg['username'], recipients, msg.as_string())
|
||
|
||
logger.info(f"[Email] 发送成功 -> {recipients}: {subject}")
|
||
|
||
except smtplib.SMTPAuthenticationError:
|
||
print(f"!!! 邮件发送核心报错: SMTPAuthenticationError - 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD(授权码)")
|
||
logger.error("[Email] 邮箱认证失败,请检查 MAIL_USERNAME / MAIL_PASSWORD(授权码)")
|
||
except smtplib.SMTPRecipientsRefused as e:
|
||
print(f"!!! 邮件发送核心报错: SMTPRecipientsRefused - 收件人被服务器拒绝: {e}")
|
||
logger.error(f"[Email] 收件人被服务器拒绝: {e}")
|
||
except smtplib.SMTPException as e:
|
||
print(f"!!! 邮件发送核心报错: SMTPException - {e}")
|
||
logger.error(f"[Email] SMTP 异常: {e}")
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
print(f"!!! 邮件发送核心报错: {type(e).__name__} - {e}")
|
||
logger.error(f"[Email] 发送邮件时发生未知异常: {e}")
|
||
|
||
|
||
def send_email_async(to_email: Union[str, List[str]], subject: str, content: str,
|
||
attachments: list = None):
|
||
"""
|
||
异步发送邮件(守护线程,不阻塞主请求线程)。
|
||
|
||
用法:直接替代 send_email() 调用,接口完全相同。
|
||
在主线程中预取配置,传入守护线程,避免线程内丢失 Flask context 导致
|
||
_get_config() 回退到 os.getenv() 而 docker-compose 未设置 MAIL_* 环境变量。
|
||
"""
|
||
import threading
|
||
cfg = _get_config()
|
||
t = threading.Thread(
|
||
target=send_email,
|
||
args=(to_email, subject, content),
|
||
kwargs={'cfg': cfg, 'attachments': attachments},
|
||
daemon=True
|
||
)
|
||
t.start()
|
||
|
||
|
||
def send_outbound_new_request_notify(to_emails: List[str], request_no: str,
|
||
applicant_name: str = '', remark: str = '',
|
||
items: list = None, is_applicant_notify: bool = False):
|
||
"""
|
||
通知审批人有新的出库申请单待审批(可附带物料清单)
|
||
或通知申请人其申请已提交(is_applicant_notify=True 时)
|
||
"""
|
||
rows = []
|
||
rows.append("名称 | 规格 | 计划数量")
|
||
rows.append("-" * 40)
|
||
if items:
|
||
for item in items:
|
||
name = item.get('name', '-') or '-'
|
||
spec = item.get('spec_model', '-') or '-'
|
||
qty = item.get('quantity', '-') or '-'
|
||
rows.append(f"{name} | {spec} | {qty}")
|
||
else:
|
||
rows.append("(无物料明细)")
|
||
|
||
if is_applicant_notify:
|
||
subject = f"【已提交】您的出库申请单 {request_no} 已提交"
|
||
content = f"""您好,
|
||
|
||
您的出库申请单 {request_no} 已成功提交,等待审批。
|
||
|
||
申请单号:{request_no}
|
||
申请人:{applicant_name or '未知'}
|
||
备注说明:{remark or '无'}
|
||
|
||
物料清单如下:
|
||
{chr(10).join(rows)}
|
||
|
||
---
|
||
您可以点击下方链接查看申请状态:
|
||
https://172.16.0.198/outbound/selection
|
||
---
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
else:
|
||
subject = f"【待审批】出库申请单 {request_no}"
|
||
content = f"""您好,
|
||
|
||
您有一笔新的出库审批申请待处理:
|
||
|
||
申请单号:{request_no}
|
||
申请人:{applicant_name or '未知'}
|
||
备注说明:{remark or '无'}
|
||
|
||
物料清单如下:
|
||
{chr(10).join(rows)}
|
||
|
||
---
|
||
⚡ 快速通道:
|
||
请点击下方链接直接进入系统审批:
|
||
https://172.16.0.198/outbound/approval
|
||
---
|
||
|
||
请登录仓库管理系统进行审批。
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
send_email_async(to_emails, subject, content)
|
||
|
||
|
||
def send_borrow_new_request_notify(to_emails: List[str], request_no: str,
|
||
applicant_name: str = '', remark: str = '',
|
||
items: list = None, is_applicant_notify: bool = False):
|
||
"""
|
||
通知审批人有新的借库申请单待审批(可附带物料清单)
|
||
或通知申请人其申请已提交(is_applicant_notify=True 时)
|
||
"""
|
||
rows = []
|
||
rows.append("名称 | 规格 | 计划数量")
|
||
rows.append("-" * 40)
|
||
if items:
|
||
for item in items:
|
||
name = item.get('name', '-') or '-'
|
||
spec = item.get('spec_model', '-') or '-'
|
||
qty = item.get('quantity', '-') or '-'
|
||
rows.append(f"{name} | {spec} | {qty}")
|
||
else:
|
||
rows.append("(无物料明细)")
|
||
|
||
if is_applicant_notify:
|
||
subject = f"【已提交】您的借库申请单 {request_no} 已提交"
|
||
content = f"""您好,
|
||
|
||
您的借库申请单 {request_no} 已成功提交,等待审批。
|
||
|
||
申请单号:{request_no}
|
||
申请人:{applicant_name or '未知'}
|
||
备注说明:{remark or '无'}
|
||
|
||
物料清单如下:
|
||
{chr(10).join(rows)}
|
||
|
||
---
|
||
您可以点击下方链接查看申请状态:
|
||
https://172.16.0.198/operation/borrow_apply
|
||
---
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
else:
|
||
subject = f"【待审批】借库申请单 {request_no}"
|
||
content = f"""您好,
|
||
|
||
您有一笔新的借库审批申请待处理:
|
||
|
||
申请单号:{request_no}
|
||
申请人:{applicant_name or '未知'}
|
||
备注说明:{remark or '无'}
|
||
|
||
物料清单如下:
|
||
{chr(10).join(rows)}
|
||
|
||
---
|
||
⚡ 快速通道:
|
||
请点击下方链接直接进入系统审批:
|
||
https://172.16.0.198/operation/borrow_approval
|
||
---
|
||
|
||
请登录仓库管理系统进行审批。
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
send_email_async(to_emails, subject, content)
|
||
|
||
|
||
def send_outbound_approval_result_notify(to_emails: List[str], request_no: str,
|
||
is_passed: bool, reject_reason: str = '',
|
||
applicant_name: str = ''):
|
||
"""
|
||
通知出库审批结果
|
||
"""
|
||
if is_passed:
|
||
subject = f"【已通过】出库申请单 {request_no}"
|
||
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||
|
||
您的出库申请单 {request_no} 已审批通过,请联系仓库管理员领取物料。
|
||
|
||
请登录仓库管理系统查看详情。
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
else:
|
||
subject = f"【已驳回】出库申请单 {request_no}"
|
||
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||
|
||
出库申请单 {request_no} 已被审批驳回。
|
||
|
||
驳回原因:{reject_reason or '未填写'}
|
||
|
||
请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
send_email_async(to_emails, subject, content)
|
||
|
||
|
||
def send_borrow_approval_result_notify(to_emails: List[str], request_no: str,
|
||
is_passed: bool, reject_reason: str = '',
|
||
applicant_name: str = ''):
|
||
"""
|
||
通知借库审批结果
|
||
"""
|
||
if is_passed:
|
||
subject = f"【已通过】借库申请单 {request_no}"
|
||
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||
|
||
您的借库申请单 {request_no} 已审批通过,请前往仓库扫码借出。
|
||
|
||
请登录仓库管理系统查看详情。
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
else:
|
||
subject = f"【已驳回】借库申请单 {request_no}"
|
||
content = f"""{"尊敬的 " + applicant_name + ",您好" if applicant_name else "您好"},
|
||
|
||
借库申请单 {request_no} 已被审批驳回。
|
||
|
||
驳回原因:{reject_reason or '未填写'}
|
||
|
||
请登录仓库管理系统查看详情,并根据驳回原因调整后重新提交申请。
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
send_email_async(to_emails, subject, content)
|
||
|
||
|
||
def send_outbound_dispatch_notify(to_emails: List[str], request_no: str,
|
||
applicant_name: str = '', items: list = None):
|
||
"""
|
||
通知库管备货出库(包含完整物料清单)
|
||
"""
|
||
print(f"[DEBUG send_outbound_dispatch_notify] 入参 items={items}")
|
||
|
||
rows = []
|
||
rows.append("名称 | 规格 | 库位 | 计划数量")
|
||
rows.append("-" * 50)
|
||
if items:
|
||
for item in items:
|
||
name = item.get('name', '-') or '-'
|
||
spec = item.get('spec_model', '-') or '-'
|
||
loc = item.get('warehouse_location', '-') or '-'
|
||
qty = item.get('quantity', '-') or '-'
|
||
rows.append(f"{name} | {spec} | {loc} | {qty}")
|
||
else:
|
||
rows.append("(无物料明细)")
|
||
|
||
subject = f"【待出库】出库申请单 {request_no} 已审批通过"
|
||
content = f"""您好,
|
||
|
||
出库申请单 {request_no} 已审批通过,请按以下清单准备备货:
|
||
|
||
{chr(10).join(rows)}
|
||
|
||
申请人:{applicant_name or '未知'}
|
||
|
||
请登录仓库管理系统执行"按单出库"操作。
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
send_email_async(to_emails, subject, content)
|
||
|
||
|
||
def send_borrow_dispatch_notify(to_emails: List[str], request_no: str,
|
||
applicant_name: str = '', items: list = None):
|
||
"""
|
||
通知库管备货借库(包含完整物料清单)
|
||
"""
|
||
print(f"[DEBUG send_borrow_dispatch_notify] 入参 items={items}")
|
||
|
||
rows = []
|
||
rows.append("名称 | 规格 | 库位 | 计划数量")
|
||
rows.append("-" * 50)
|
||
if items:
|
||
for item in items:
|
||
name = item.get('name', '-') or '-'
|
||
spec = item.get('spec_model', '-') or '-'
|
||
loc = item.get('warehouse_location', '-') or '-'
|
||
qty = item.get('quantity', '-') or '-'
|
||
rows.append(f"{name} | {spec} | {loc} | {qty}")
|
||
else:
|
||
rows.append("(无物料明细)")
|
||
|
||
subject = f"【待借库】借库申请单 {request_no} 已审批通过"
|
||
content = f"""您好,
|
||
|
||
借库申请单 {request_no} 已审批通过,请按以下清单准备备货:
|
||
|
||
{chr(10).join(rows)}
|
||
|
||
申请人:{applicant_name or '未知'}
|
||
|
||
请登录仓库管理系统执行"扫码借库"操作。
|
||
|
||
此邮件由系统自动发送,请勿回复。
|
||
"""
|
||
send_email_async(to_emails, subject, content)
|