Files
KCGL/inventory-backend/app/utils/email_service.py
yueli 1d6beaa376 feat(daily-report): 正文保持摘要,新增 Excel 附件给全量明细
日报此前把明细压到极简(修改只列变更字段≥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 级日志 —— 降级后邮件
看起来完全正常,不留显眼日志的话附件静默丢失可以持续几个月没人发现。
2026-09-28 09:46:25 +08:00

480 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
邮件通知服务
使用 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)