
最近有人问“能不能用一个 Python 脚本把每天的库存报表定时发到指定邮箱”答案是可以而且完全不需要引入太重的框架。Python 自带的smtplib配合标准库email已经能把“发文本邮件、发 HTML 邮件、带附件、群发、按 CSV 名单批量发送”这些日常办公场景都覆盖掉。如果公司内部还需要把发邮件能力封装成服务让其他业务系统调用也能用 Flask 包的接口方式实现。这次我们就从办公自动化的角度出发把一套完整的Python 邮件发送服务拆开讲清楚。文章不会只停留在“能发一封邮件”这个层面而是会带你走完这几件事申请 SMTP 授权、封装发送函数、用 CSV 批量发送、用 Flask 提供 HTTP 接口、接入定时任务以及对发送失败做日志和重试。内容偏实操适合正在做 Python 办公自动化、想给团队或自己搭建定时报表通知能力的读者收藏。1. 核心能力速览在动手写代码前先明确这套 Python 邮件发送服务能做到什么程度。能力项说明核心依赖Python 标准库smtplib、email可选Flask、python-dotenv、schedule通信方式SMTP 协议兼容主流邮箱和企业邮箱邮件类型纯文本、HTML 正文、带附件、多收件人批量任务支持 CSV / Excel 名单批量发送适合报表分发场景接口能力可选封装为 HTTP 接口方便其他系统调用定时执行支持 schedule 或系统 cron / 计划任务运行平台Windows、Linux、macOS 均可最低环境Python 3.8 以上即可无需 GPU适合场景定时报表、任务完成通知、监控告警、批量发送内部通知这个方案的核心不在于代码量多少而在于把发送流程标准化。日常办公中邮件内容无非是“固定模板 少量变量”比如收件人姓名、日期、附件名。把可变部分单独抽出来然后把函数封装成可以重复调用的模块后面每次使用只需要传入几个参数。2. 适用场景与使用边界邮件发送服务在办公自动化里是比较稳的一类需求因为它不依赖操作界面也不需要打开浏览器。常见的几种落地场景如下数据报表推送每天定时统计数据库或 Excel 表生成 PDF / Excel 附件后发送给管理层。任务状态通知Python 脚本处理完一批文件后自动把结果汇总并发送给负责人。异常告警监控脚本定期检查网站、接口或服务器状态发现异常时发送告警邮件。批量通知从人员名单中批量发送培训通知、会议提醒等内部邮件。不过有几个边界需要提前想清楚。第一收件人必须合法。无论是企业批量群发还是日常运营邮件都要基于用户同意或内部工作授权。如果是面向外部用户的营销类邮件不建议用个人 SMTP 邮箱裸发一方面到达率不稳定另一方面有垃圾邮件风险应该使用专业的邮件发送平台。第二邮件内容要保留留痕。公司内部使用时不建议永久删除发件记录建议在发送后把本地发送日志、附件列表都保存下来方便后续追溯。第三账号安全是底线。发件邮箱的授权码、SMTP 密码不能硬编码在业务代码里更不能上传到公开仓库。如果代码仓库泄露邮箱很可能被拿来发垃圾邮件。第四不要拿该脚本做任何绕过平台限制的事情。比如频繁换发件人来规避邮件发送限制、伪造他人身份发送这些都属于违规操作。3. 环境准备与前置条件动手之前要准备以下环境。3.1 Python 版本与依赖建议使用 Python 3.8 或更高版本。如果是纯标准库发送邮件其实不需要安装任何第三方包如果要做 HTTP 接口服务再安装 Flask。# 只做标准库邮件发送无需额外安装 # 如果要做 Web API 服务需要安装 Flask pip install Flask python-dotenv # 如果要在 Python 内做定时调度 pip install schedule检查 Python 版本python --version3.2 发件邮箱准备任何一种主流邮箱都可以但需要先开启 SMTP 服务并获取一个授权码而不是邮箱登录密码。绝大多数邮箱的官方客户端、SMTP 配置页面里都可以找到“开启服务”按钮。不同邮箱的 SMTP 服务器地址和端口不同发件邮箱类型SMTP 服务器地址不建议用端口建议用端口QQ 邮箱smtp.qq.com25465SSL163 邮箱smtp.163.com25465SSL126 邮箱smtp.126.com25465SSLOutlook 邮箱smtp-mail.outlook.com25587STARTTLS这里要做一个重要提醒不要使用 25 端口作为生产邮件发送端口。很多云服务器默认封禁 25 端口这是因为该端口长期被大量垃圾邮件滥用。如果脚本部署在云服务器上使用 465 或 587 会更稳。获取授权码的过程一般是登录邮箱官网进入设置找到账户或客户端授权开启 SMTP 服务生成授权码。授权码通常只会完整显示一次生成后要立刻复制保存。3.3 使用 .env 保存配置不建议把邮箱账号、授权码直接写在代码里。这里使用.env文件保存发件基础信息并让 Python 通过python-dotenv读取。# .env 文件示例不要提交到 Git SMTP_HOSTsmtp.qq.com SMTP_PORT465 SMTP_USER你的邮箱地址 SMTP_AUTH_CODE你的授权码代码启动时加载import os from dotenv import load_dotenv load_dotenv() SMTP_HOST os.getenv(SMTP_HOST) SMTP_PORT int(os.getenv(SMTP_PORT, 465)) SMTP_USER os.getenv(SMTP_USER) SMTP_AUTH_CODE os.getenv(SMTP_AUTH_CODE)同时建议在.gitignore中加入.env.env4. 核心实现用 smtplib 发送邮件先写一个最小的可运行版本。这段代码只做一件事登录发件服务器把一封纯文本邮件发送给指定收件人。import os import smtplib from email.message import EmailMessage from dotenv import load_dotenv load_dotenv() def send_text_email(subject: str, content: str, receiver: str): msg EmailMessage() msg[Subject] subject msg[From] os.getenv(SMTP_USER) msg[To] receiver msg.set_content(content) with smtplib.SMTP_SSL( os.getenv(SMTP_HOST), int(os.getenv(SMTP_PORT, 465)), timeout30 ) as server: server.login(os.getenv(SMTP_USER), os.getenv(SMTP_AUTH_CODE)) server.send_message(msg) print(f邮件已发送至: {receiver}) if __name__ __main__: send_text_email( subjectPython 邮件发送测试, content这是一封来自 Python smtplib 的测试邮件。, receiver接收方邮箱example.com )这里的重点有三个EmailMessage负责构建邮件对象字段包含主题、发件人、收件人、正文。SMTP_SSL直接走 SSL 加密连接适合 465 端口。server.login()登录时第二个参数填的是授权码不是邮箱密码。如果使用的是 587 端口需要把连接方式改成SMTP并调用starttls()import smtplib with smtplib.SMTP(os.getenv(SMTP_HOST), 587, timeout30) as server: server.starttls() server.login(os.getenv(SMTP_USER), os.getenv(SMTP_AUTH_CODE)) server.send_message(msg)建议把这两种连接方式合并成一个函数根据端口自动切换。这样代码适配性更强。def get_smtp_server(host: str, port: int): if port 465: return smtplib.SMTP_SSL(host, port, timeout30) server smtplib.SMTP(host, port, timeout30) server.starttls() return server5. 实战功能扩展文本邮件只是基础。办公场景里我们更常遇到的是这样几类需求邮件正文需要带格式、邮件需要带附件、同一封邮件要发给多个人以及同一批人收到的内容要各不相同。5.1 发送 HTML 正文邮件用 HTML 写正文可以呈现表格、段落、高亮等样式适合发数据简报。html_content html body h3库存日报/h3 table border1 cellpadding5 tr th商品/th th库存/th th状态/th /tr tr td商品A/td td120/td td stylecolor: green;充足/td /tr tr td商品B/td td8/td td stylecolor: red;不足/td /tr /table p请相关负责人及时补货。/p /body /html 在EmailMessage中同时设置set_content和add_alternative前者为纯文本降级展示后者为 HTML 渲染展示from email.message import EmailMessage msg EmailMessage() msg[Subject] 库存日报 msg[From] SMTP_USER msg[To] receiver msg.set_content(请使用支持 HTML 的邮箱客户端查看本邮件。) msg.add_alternative(html_content, subtypehtml)5.2 添加附件附件是办公邮件的刚需。添加场景最常见的是 Excel、PDF、图片等文件。读取附件时务必使用二进制模式否则文件内容会被破坏。直接在代码中写作rb不要写成r。import os from email.message import EmailMessage def attach_file(msg: EmailMessage, file_path: str): file_name os.path.basename(file_path) with open(file_path, rb) as f: msg.add_attachment( f.read(), maintypeapplication, subtypeoctet-stream, filenamefile_name )如果你知道自己附件的 MIME 类型可以更精确地指定。比如 PDF 文件是msg.add_attachment(data, maintypeapplication, subtypepdf, filenamereport.pdf)Excel 文件可以指定为msg.add_attachment(data, maintypeapplication, subtypevnd.openxmlformats-officedocument.spreadsheetml.sheet, filenamereport.xlsx)如果对文件的 MIME 类型不确定直接用通用的application/octet-stream也能被大多数客户端识别。在 Python 3.7 中add_attachment会优先使用文件名后缀猜测类型所以多数文件无需手工指定maintype和subtype。5.3 一封邮件发送给多个收件人处理“发给一批人”的场景有两种情况所有人都在收件人列表里直接用逗号拼接。有多个收件人和密送分别设置。msg[To] aexample.com, bexample.com msg[Cc] managerexample.com msg[Bcc] secretexample.com调用send_message前还要额外传一个参数来告诉 SMTP 服务器收件人地址列表。EmailMessage对象里的To只是展示给收件人看的头字段真实的 RCPT TO 地址需要单独指定否则会有部分地址收不到信。from email.utils import getaddresses to_list [addr for _, addr in getaddresses([msg[To]])] cc_list [addr for _, addr in getaddresses([msg[Cc]])] bcc_list [addr for _, addr in getaddresses([msg[Bcc]])] server.send_message(msg, to_addrsto_list cc_list bcc_list)这个细节很容易踩坑。直接调用server.send_message(msg)时Python 会从msg[To]、msg[Cc]、msg[Bcc]中提取收件人但也有一部分自定义场景下提取不全。稳妥做法是显式传to_addrs。5.4 带人名显示的发件人格式如果希望收件人在邮箱里看到的发件人名称不是一串邮箱字符需要使用formataddr。from email.utils import formataddr sender_name 自动化运维系统 sender_addr os.getenv(SMTP_USER) msg[From] formataddr((sender_name, sender_addr)) receiver_name 张三 receiver_addr zhangsanexample.com msg[To] formataddr((receiver_name, receiver_addr))这里要注意中文名称在部分老客户端可能出现编码问题建议配合email.header处理。不过当前主流 Python 版本中直接使用formataddr通常不会出现问题。6. 批量发送与模板渲染办公自动化最常用的是“一份名单每个人收到不同正文”的场景。比如给销售团队每个成员发送周业绩通知每个人的姓名、业绩数字都不一样。6.1 准备名单 CSV准备一个 CSV 文件receiver_list.csvname,email,message 李四,liboexample.com,产品A本周销量很好继续保持。 王五,wangwuexample.com,产品B存在库存积压需要调整策略。 赵六,zhaoliuexample.com,本周客户回访完成率偏低请关注。6.2 批量发送代码import csv import os import smtplib from email.message import EmailMessage from email.utils import formataddr from dotenv import load_dotenv load_dotenv() def render_html(name: str, message: str) - str: return f html body p{name}你好/p p{message}/p p本邮件由 Python 自动发送请勿直接回复。/p /body /html def send_batch(csv_path: str): with open(csv_path, r, encodingutf-8-sig) as f: reader csv.DictReader(f) rows list(reader) with smtplib.SMTP_SSL( os.getenv(SMTP_HOST), int(os.getenv(SMTP_PORT, 465)), timeout30 ) as server: server.login(os.getenv(SMTP_USER), os.getenv(SMTP_AUTH_CODE)) for row in rows: msg EmailMessage() msg[Subject] 本周业务通知 msg[From] formataddr((自动化通知, os.getenv(SMTP_USER))) msg[To] row[email] msg.set_content(f{row[name]}你好{row[message]}) msg.add_alternative(render_html(row[name], row[message]), subtypehtml) server.send_message(msg) print(f已发送: {row[name]} - {row[email]}) if __name__ __main__: send_batch(receiver_list.csv)注意encodingutf-8-sig是为了兼容部分 Excel 保存 CSV 时带 BOM 头的情况。如果你是用 pandas 读取 Excel 生成 DataFrame再循环发送逻辑也类似只是数据源从 CSV 变成了 DataFrame。6.3 批量发送的稳定性优化真实办公环境里名单可能有几十人、几百人此时不能只考虑发送成功这一件事还要考虑失败重试、发送间隔和日志记录。建议每一行发送时单独 try-except不要把整个循环包在一个 try 里否则中间失败会导致后面全部中断。import time fail_list [] for index, row in enumerate(rows, 1): try: # 组装并发送邮件 server.send_message(msg) success_log.append(row[email]) print(f[{index}/{len(rows)}] 成功: {row[email]}) except Exception as exc: fail_list.append({email: row[email], error: str(exc)}) print(f[{index}/{len(rows)}] 失败: {row[email]}, 错误: {exc}) if index % 10 0: time.sleep(1) # 每发送 10 封暂停 1 秒降低触发限流概率发送节奏也很重要。不建议对几百封邮件做无间隔连续发送这会很容易触发邮箱平台的临时限制。保持一定的发送频率阈值并记录失败名单结束后统一重试比一次性无脑发完更可靠。7. 把邮件发送封装成 HTTP 接口服务当多个脚本或业务系统都要发邮件时最合理的方案不是把邮箱账号和 SMTP 配置复制到每个项目里而是单独起一个邮件发送服务别人只需要调用 HTTP 接口。这里使用 Flask 来实现。7.1 服务端代码import os import smtplib from email.message import EmailMessage from email.utils import formataddr from flask import Flask, request, jsonify from dotenv import load_dotenv load_dotenv() app Flask(__name__) def send_mail(subject: str, content: str, to_list: list, file_paths: list None): msg EmailMessage() msg[Subject] subject msg[From] formataddr((自动化邮件服务, os.getenv(SMTP_USER))) msg[To] , .join(to_list) msg.set_content(content) if file_paths and len(file_paths) 0: for file_path in file_paths: with open(file_path, rb) as f: msg.add_attachment( f.read(), maintypeapplication, subtypeoctet-stream, filenameos.path.basename(file_path) ) server smtplib.SMTP_SSL( os.getenv(SMTP_HOST), int(os.getenv(SMTP_PORT, 465)), timeout30 ) server.login(os.getenv(SMTP_USER), os.getenv(SMTP_AUTH_CODE)) server.send_message(msg) server.quit() app.route(/api/email/send, methods[POST]) def email_send_api(): data request.get_json(forceTrue) subject data.get(subject, 无主题) content data.get(content, ) to_list data.get(to_list, []) if not to_list: return jsonify({success: False, message: to_list 不能为空}), 400 try: send_mail(subjectsubject, contentcontent, to_listto_list) return jsonify({success: True, message: 邮件已发送}) except Exception as exc: return jsonify({success: False, message: str(exc)}), 500 if __name__ __main__: app.run(host127.0.0.1, port8000, debugFalse)这里的服务默认只绑定127.0.0.1也就是只有本机能访问。如果确实需要给局域网内其他机器提供服务可以把 host 改成0.0.0.0但建议同时加一层 Token 验证避免接口被滥用。API_TOKEN os.getenv(API_TOKEN, change-me) app.route(/api/email/send, methods[POST]) def email_send_api(): token request.headers.get(X-Token) if token ! API_TOKEN: return jsonify({success: False, message: 无权限访问}), 401 # 后续逻辑不变 ...7.2 调用接口启动服务后在另一个终端里用 curl 测试curl -X POST http://127.0.0.1:8000/api/email/send \ -H Content-Type: application/json \ -H X-Token: change-me \ -d { subject: 来自接口服务的测试邮件, content: 接口调用成功, to_list: [zhangsanexample.com] }Python 侧调用import requests url http://127.0.0.1:8000/api/email/send payload { subject: 来自接口服务的测试邮件, content: 接口调用成功, to_list: [zhangsanexample.com] } headers {X-Token: change-me} resp requests.post(url, jsonpayload, headersheaders, timeout60) print(resp.status_code) print(resp.json())这个封装方式非常灵活。业务部门的 Python 脚本、Java 后端、定时任务都可以通过一次 HTTP POST 完成发信统一由邮件服务集中处理账号配置、发送频率和失败日志。8. 定时任务与自动化调度邮件服务的另一半价值是“不需要人每天手动运行”。落地方式有三种在 Python 进程内部用 schedule、在 Linux 上使用 cron、在 Windows 上使用计划任务。8.1 Python 内部定时调度如果不想依赖系统级调度可以在主进程里使用schedule库。import schedule import time from email_sender import send_daily_report # 每天 9 点发送库存日报 schedule.every().day.at(09:00).do(send_daily_report) # 每 30 分钟检查一次服务状态并发送告警 schedule.every(30).minutes.do(check_service_status) if __name__ __main__: while True: schedule.run_pending() time.sleep(1)使用schedule的注意点是定时任务进程必须长驻运行不能在脚本执行完就退出。适合部署在服务器上或长开机的内网主机中。8.2 Linux cron 调度更适合生产环境的方式是把发邮件的功能封装成一个独立脚本然后用crontab定时执行。# 每天 9 点执行 0 9 * * * cd /opt/email-service /usr/bin/python3 send_daily_report.py logs/send.log 218.3 Windows 计划任务在 Windows 上可以直接使用任务计划程序设置每天固定时间运行python C:\work\email_service\send_daily_report.py需要重点关注的是如果用 Python 脚本读取相对路径的独立应用请先把工作目录设为一个确定的路径。通过 crontab 执行时用cd切换目录通过 Windows 计划任务执行时在“起始位置”字段填入脚本所在目录。9. 资源占用与性能观察邮件发送服务不像 AI 推理那样需要 GPU 显存它属于轻量级网络 I/O 类任务资源占用主要取决于三个因素单封邮件大小、附件体积、发送频率。建议在实际部署时关注以下几个指标。先看启动后的基础资源占用# Linux 查看占用内存 ps aux | grep email_service # Windows 任务管理器中查看 python.exe 的内存占用纯文本邮件的每次发送内存占用很小但加载大附件时会一次性把附件内容读入内存。如果附件达到几十 MB要注意内存消耗。例如用f.read()读取 100 MB 附件时内存峰值会明显上升。处理大附件建议分批读取with open(large_file_path, rb) as f: chunks [] while True: chunk f.read(1024 * 1024) # 每次 1MB if not chunk: break chunks.append(chunk)但EmailMessage.add_attachment最终还是会整体拼接邮件对象所以对超大附件场景一次性发送并不是最优方案。更合理的做法是对附件先压缩或分片或者改用企业网盘链接。观察发送速率时可以使用以下思路记录每次发送耗时time.perf_counter()。记录每分钟发送数量。当连续多次出现超时或认证失败时应该主动降低速率。10. 常见问题与排查方法这里整理了实际使用中最容易踩到的问题按检查顺序排列。问题现象可能原因排查方式解决方案smtplib.SMTPAuthenticationError账号或授权码错误或授权码已过期检查.env中账号和授权码重新生成新的授权码连接超时或socket.gaierrorSMTP 主机名错误或网络无法访问该地址ping 或nslookup smtp.xxx.com确认主机名检查网络是否连通25 端口连接失败云服务器或网络封禁 25 端口查看服务端返回的具体错误码和连接日志改用 465 / 587 端口成功发送但收件人没收到进了垃圾箱或收件人地址有误查看发送日志中的成功记录但不要直接认为成功等于到达在邮箱中查看垃圾箱调整邮件内容降低垃圾邮件特征邮件中文乱码主题或正文编码设置不当查看邮件源码头信息使用EmailMessage而非手动拼接 MIME避免自定义编码头收到的 Excel / PDF 附件无法打开读取文件时用了文本模式检查代码中的open模式附件读取使用rb二进制模式批量发送到一半中断某一封地址格式错误或触发限流查看异常堆栈中是否已经打印邮件地址每封邮件单独 try-except失败记录后继续Flask 服务启动但局域网无法访问绑定地址是127.0.0.1检查app.run的 host 参数和防火墙按需绑定0.0.0.0同时加 Token 校验定时任务执行后没有发送任何邮件crontab 或计划任务工作目录错误查看日志或先手动执行一次脚本切换到固定工作目录使用绝对路径加载配置和文件排查邮件问题的一个通用调试点是看返回异常类型socket.timeout网络层问题优先检查防火墙、端口、服务器可达状态。SMTPAuthenticationError认证层问题优先检查授权码。SMTPRecipientsRefused接收方服务器拒绝通常是该邮箱地址不存在或对方服务器策略。ConnectionRefusedError远程端口没有开放或本地被防火墙拦截。建议在写代码时统一捕获异常并保存到日志文件不要只print到控制台。生产环境里控制台输出很容易在重启后丢失。11. 最佳实践与落地建议到这里整个邮件发送服务的技术骨架已经完整。在真正用于办公环境前还有几个工程化经验值得落实。11.1 先小规模验证再上量第一次运行时不要直接发几百封。先用一个测试收件人列表发 1 到 2 封重点检查正文是否正常、附件是否能打开、会不会进垃圾箱。确认无误后再启动批量任务。11.2 使用统一的模板系统如果邮件形态比较固定建议使用jinja2管理模板而不是在 Python 脚本里用 f-string 拼接超长 HTML。模板独立成文件改文案时不需要动代码。from jinja2 import Environment, FileSystemLoader env Environment(loaderFileSystemLoader(templates)) template env.get_template(report.html) html_content template.render( user_name张三, report_date2026-01-01, items[ {name: 商品A, stock: 120}, {name: 商品B, stock: 8} ] )安装依赖pip install jinja211.3 日志与失败重试每封邮件的发送结果都应该记录。建议记录以下字段时间、收件人、主题、是否成功、失败原因、重试次数。如果发送失败采用指数退避策略重试例如第一次等 5 秒、第二次等 25 秒、第三次等 125 秒最多重试 3 次后进入失败队列。11.4 外部系统调用接口要做鉴权如果你的邮件服务不对公网开放限制到内网访问即可一旦有跨网段调用需求必须加 Token 或基于内部服务网关的鉴权。不建议把邮件服务绑定到公网0.0.0.0且无鉴权。11.5 合规与内容确认在正式发送批量邮件前一定要确认收件人来源合法且对收件人有合理预期。对外群发营销类邮件之前先确认是否完成了公司的合规审核避免一天发出大量投诉或退订。11.6 避免硬编码路径在实际业务中发件脚本运行时会遇到路径问题。比如本地能运行放到计划任务或 cron 里却找不到文件这通常是因为脚本里使用了相对路径./data/xxx.xlsx。建议把项目根目录固定在脚本开头from pathlib import Path BASE_DIR Path(__file__).resolve().parent CSV_PATH BASE_DIR / data / receiver_list.csv ATTACH_PATH BASE_DIR / output / report.xlsx12. 总结与下一步这套 Python 邮件发送服务最适合解决的是“固定模板 批量名单 定时发送”这类重复劳动。核心代码量不多标准库足以覆盖基础场景引入 Flask 后则可以交给团队里多个系统统一调用真正变成一个内部基础服务。先验证的应当是基础邮件是否真的能发送成功。如果你使用的是 QQ 邮箱、163 邮箱这类个人账号可以从授权码申请那一步开始测起如果使用企业邮箱优先确认 SMTP 服务器地址和端口策略再开始编码。最容易踩的坑大概率会在授权码、25 端口封禁和批量中断三处。下一步可以扩展的方向也很多把发件记录写入 SQLite 或 MySQL方便后续追踪接入文件监控新增报表即自动发送把邮件模板改造成支持 Excel 数据填充的通用能力再进一步还可以把这套服务和公司内部已有的监控告警系统打通让它成为任务通知的统一出口。先跑通一版最小可用服务再随着使用场景慢慢加功能这是比较稳的推进方式。