资讯动态

Python实现HTML格式电子邮件群发:SMTP直连、批量发送与送达率优化实战

发布时间:2026/10/15 1:31:23 来源:尧图企业网站定制
1. 邮件群发这件事为什么值得单独拎出来做做后端开发或者运维的朋友大概率都遇到过这样的需求给一批用户发送通知邮件、营销邮件或者系统告警邮件。看起来很简单对吧调个邮件发送库循环一下收件人列表完事。但真正上手做过的人都知道这里面坑多得很——发出去的邮件排版乱七八糟、图片显示不出来、被邮件服务商限流、进了垃圾箱、甚至发到一半连接断了导致重复发送。我前后在几个项目里都做过邮件群发相关的功能从最开始用纯文本凑合到后来老老实实做HTML模板、处理内联样式、控制发送频率、加退订链接踩过的坑基本能写一本小册子。这篇文章就把我积累的这些经验完整地梳理一遍从方案选型到代码实现到线上排障尽量讲透。HTML格式电子邮件群发说白了就是两件事的组合一是邮件内容用HTML来写这样能做出排版精美的通知、报表、推广内容二是批量发送给多个收件人而不是一封一封手动发。听起来不复杂但要做好涉及的知识点其实挺多的——SMTP协议的基本原理、HTML在邮件客户端里的渲染限制、批量发送的并发控制、发送失败的重试策略、以及怎么避免被判定为垃圾邮件。这篇文章适合谁看如果你是一个后端开发者需要在自己的项目里集成邮件群发功能或者你是一个运维人员需要定期给团队发送系统报表又或者你是一个独立开发者想给自己的产品加一个用户通知模块——那这篇内容应该能帮你少走不少弯路。我会尽量用通俗的语言把每个环节讲清楚代码示例以Python为主但思路是通用的换成Java、Node.js或者Go都一样。2. 整体方案设计与技术选型2.1 为什么选HTML而不是纯文本先说一个最基础的问题为什么我们要费劲用HTML来写邮件而不是直接发纯文本纯文本邮件的优势是兼容性无敌任何邮件客户端都能正常显示而且不容易被垃圾邮件过滤器盯上。但它的缺点也很明显——没法做排版。你想想如果是要发一份数据报表、一个订单详情、或者一个活动通知纯文本堆在一起用户看着就头疼。HTML邮件就不一样了。你可以做表格、加粗重点信息、插入图片、设置品牌色用户体验直接上一个大台阶。根据我自己的经验同样的通知内容HTML格式的邮件点击率比纯文本高出不少用户也更愿意认真看。但HTML邮件也有它的代价。最大的问题是邮件客户端对HTML和CSS的支持程度参差不齐。你在浏览器里写得漂漂亮亮的页面到了某些邮件客户端里可能就面目全非。这个后面会详细讲怎么处理。2.2 发送方式的选择SMTP直连 vs 第三方API做邮件群发第一个要做的决策就是自己搭SMTP服务器直连发送还是用第三方的邮件发送服务我两种方式都用过各有各的适用场景。下面这张表是我总结的对比对比维度SMTP直连发送第三方邮件API成本低只需要一台服务器按量计费量大时成本可观送达率取决于IP信誉新IP很难专业服务商维护送达率高开发复杂度中等需要自己处理连接池、重试低调API就行可控性完全可控受服务商限制适合场景内部系统通知、小批量发送面向外部用户的大批量发送我的建议是这样的如果是公司内部系统的告警邮件、报表邮件收件人都是自己人那SMTP直连完全够用成本也低。但如果是面向外部用户的产品通知、营销邮件量比较大那还是老老实实用第三方服务不然送达率会让你怀疑人生。这篇文章主要讲SMTP直连的实现方式因为这是最通用的方案理解了之后换成第三方API也就是换个调用层的事。2.3 技术栈选型具体到代码层面我用的是Python的smtplib和email标准库。为什么不用第三方库比如yagmail或者sendgrid的SDK两个原因一是标准库足够用不需要额外依赖二是标准库更灵活遇到问题也更容易排查。核心用到的模块有这几个smtplib负责和SMTP服务器通信建立连接、认证、发送email.mime.multipart构建多部分邮件体同时包含HTML和纯文本email.mime.text构建文本内容部分email.mime.image构建内联图片部分email.utils处理邮件地址格式化、消息ID生成等如果你用的是其他语言对应的库也大同小异。Java有JavaMailNode.js有nodemailerGo有net/smtp标准库。核心概念是一样的。3. 核心细节解析与实操要点3.1 邮件结构为什么一定要做multipart很多人第一次写HTML邮件的时候直接就把HTML字符串塞进邮件体发出去了。这样能发出去但有个隐患如果收件人的邮件客户端不支持HTML虽然现在很少了或者用户设置了只看纯文本那这封邮件就是一片空白或者一堆标签。正确的做法是使用multipart/alternative类型的邮件体同时包含纯文本版本和HTML版本。邮件客户端会根据自身能力选择显示哪个版本。纯文本版本不是随便写写就行的它应该是HTML内容的一个合理降级——把关键信息用纯文本表达出来。from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText msg MIMEMultipart(alternative) msg[Subject] 您的订单已发货 msg[From] noreplyexample.com msg[To] userexample.com text_content 您的订单已发货快递单号SF1234567890 html_content htmlbodyh2您的订单已发货/h2p快递单号strongSF1234567890/strong/p/body/html msg.attach(MIMEText(text_content, plain, utf-8)) msg.attach(MIMEText(html_content, html, utf-8))注意attach的顺序先纯文本后HTML。这是RFC规范建议的顺序邮件客户端会优先显示最后能解析的那个部分。3.2 HTML邮件里的CSS怎么写才靠谱这是HTML邮件开发中最容易翻车的地方。我总结了几条铁律第一所有样式必须内联。外部CSS文件和style标签在大部分邮件客户端里会被忽略。Gmail算是支持得比较好的但Outlook桌面版基本不认。所以你得把样式直接写在元素的style属性里。第二别用flexbox和grid。这些现代布局方式在邮件客户端里支持极差。老老实实用table布局虽然老土但兼容性最好。我试过用div加float在某些客户端里直接错位。第三图片要慎重。很多邮件客户端默认不加载图片用户需要手动点击“显示图片”。所以重要的信息不要放在图片里图片只做装饰。如果一定要用图片记得加alt属性。第四宽度控制在600px以内。这是邮件设计的经典宽度能适配大部分客户端的预览窗格。table width600 cellpadding0 cellspacing0 border0 stylemargin:0 auto; font-family: Arial, sans-serif; tr td stylebackground-color: #4A90D9; padding: 20px; text-align: center; h1 stylecolor: #ffffff; margin: 0; font-size: 24px;订单发货通知/h1 /td /tr tr td stylepadding: 30px 20px; background-color: #f9f9f9; p stylefont-size: 16px; color: #333333;您好您的订单已发货。/p p stylefont-size: 16px; color: #333333;快递单号strong stylecolor: #4A90D9;SF1234567890/strong/p /td /tr /table3.3 内联图片的处理方式如果你需要在邮件里嵌入图片比如公司Logo、产品图有两种方式一种是引用外部URL另一种是作为附件内联。外部URL的方式简单但有个问题很多客户端默认不加载外部图片用户看到的是一个占位框。而且如果图片服务器挂了邮件里的图就没了。内联附件的方式是把图片作为邮件的一部分发送用Content-ID来引用。这样图片一定会显示但会增加邮件体积。我的做法是Logo这种小图标用内联大图用外部URL。from email.mime.image import MIMEImage with open(logo.png, rb) as f: img MIMEImage(f.read()) img.add_header(Content-ID, logo) img.add_header(Content-Disposition, inline) msg.attach(img) # HTML里这样引用 html_content img srccid:logo altLogo width120注意Content-ID的值要用尖括号包起来引用的时候用cid:前缀。3.4 批量发送的并发控制群发最忌讳的就是无脑循环。你写个for循环一千个收件人挨个发中间任何一个环节卡住后面的就全堵着。而且很多SMTP服务器对连接数和发送频率都有限制你发太快直接被掐断。我的做法是分三步走第一步分批。把收件人列表按每批50到100个切分。每批之间间隔几秒避免触发频率限制。第二步复用连接。不要每发一封就重新建立SMTP连接那样握手开销太大。用SMTP对象的sendmail方法在同一个连接里连续发送。第三步异常隔离。单个收件人发送失败不能影响整批。用try-except包住每一封的发送逻辑记录失败原因最后统一重试。import smtplib import time from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText def send_batch(smtp_server, smtp_port, username, password, recipients, subject, html_template): failed [] batch_size 50 for i in range(0, len(recipients), batch_size): batch recipients[i:ibatch_size] try: server smtplib.SMTP(smtp_server, smtp_port, timeout30) server.starttls() server.login(username, password) for recipient in batch: try: msg MIMEMultipart(alternative) msg[Subject] subject msg[From] username msg[To] recipient html html_template.replace({{email}}, recipient) msg.attach(MIMEText(html, html, utf-8)) server.sendmail(username, [recipient], msg.as_string()) except Exception as e: failed.append((recipient, str(e))) server.quit() except Exception as e: for r in batch: failed.append((r, f连接异常: {str(e)})) time.sleep(3) return failed这段代码里几个关键点timeout30防止连接卡死starttls()启用加密传输每批之间sleep(3)给服务器喘息时间异常按收件人粒度记录。4. 完整实操流程与核心环节实现4.1 环境准备与SMTP账号配置动手之前你需要准备一个SMTP账号。如果你用的是企业邮箱比如腾讯企业邮、阿里企业邮一般在管理后台都能找到SMTP服务器地址和端口。个人邮箱的话大部分也支持SMTP但通常需要单独开启并生成授权码。常见的SMTP配置参数邮件服务SMTP服务器端口加密方式企业邮箱Asmtp.example-a.com587STARTTLS企业邮箱Bsmtp.example-b.com465SSL自建邮件服务mail.internal.com25无内网端口587配合starttls()端口465配合SMTP_SSL()这是两个最常见的组合。端口25是传统的不加密端口现在大部分服务商都不推荐用了。注意很多邮箱服务商要求使用“授权码”而不是登录密码来连接SMTP。授权码是在邮箱设置里单独生成的和你的登录密码不一样。我第一次做的时候不知道这个用登录密码试了半天一直认证失败。4.2 构建HTML邮件模板模板我建议单独放在一个文件里用占位符标记需要动态替换的内容。这样改样式的时候不用动代码改代码的时候不用管样式。!DOCTYPE html html head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 /head body stylemargin:0; padding:0; background-color:#f4f4f4; table width100% cellpadding0 cellspacing0 border0 stylebackground-color:#f4f4f4; padding:20px 0; tr td aligncenter table width600 cellpadding0 cellspacing0 border0 stylebackground-color:#ffffff; border-radius:8px; overflow:hidden; tr td stylebackground-color:#2C3E50; padding:24px 30px; h1 stylecolor:#ffffff; margin:0; font-size:20px; font-weight:normal;系统通知/h1 /td /tr tr td stylepadding:30px; p stylefont-size:15px; color:#333333; line-height:1.6;尊敬的 {{username}}您好/p p stylefont-size:15px; color:#333333; line-height:1.6;{{content}}/p table width100% cellpadding0 cellspacing0 border0 stylemargin:20px 0; background-color:#f8f9fa; border-radius:4px; tr td stylepadding:16px 20px; p stylemargin:0; font-size:14px; color:#666666;时间{{timestamp}}/p p stylemargin:8px 0 0 0; font-size:14px; color:#666666;单号{{order_id}}/p /td /tr /table p stylefont-size:13px; color:#999999; line-height:1.6;此邮件由系统自动发送请勿直接回复。/p /td /tr tr td stylebackground-color:#f8f9fa; padding:16px 30px; text-align:center; p stylemargin:0; font-size:12px; color:#aaaaaa;copy; 2025 某某团队/p /td /tr /table /td /tr /table /body /html这个模板里我用了嵌套table来做卡片效果外层table负责背景色和居中内层table负责内容区域。所有样式都是内联的宽度固定600px在手机上也基本能正常显示。4.3 收件人列表的清洗与去重这一步很多人会忽略但非常重要。你的收件人列表里可能有重复的邮箱、格式错误的邮箱、已经退订的邮箱。如果不做清洗轻则浪费发送额度重则被服务商判定为异常行为。我一般会做这几件事用正则校验邮箱格式把明显不合法的剔除用集合去重确保每个邮箱只发一次检查退订列表把已退订的排除统计有效收件人数量和预期对比import re def clean_recipients(raw_list, unsubscribedNone): email_pattern re.compile(r^[a-zA-Z0-9._%-][a-zA-Z0-9.-]\.[a-zA-Z]{2,}$) unsubscribed set(unsubscribed or []) seen set() valid [] for email in raw_list: email email.strip().lower() if not email_pattern.match(email): continue if email in unsubscribed: continue if email in seen: continue seen.add(email) valid.append(email) return valid4.4 发送频率控制与重试机制发送频率这块我的经验值是单IP每分钟不超过30封每小时不超过500封。超过这个量很多邮件服务商就会开始限流或者直接拒收。重试机制我采用的是指数退避策略第一次失败等5秒重试第二次等25秒第三次等125秒。重试三次还失败就放弃记录到失败列表里人工介入处理。import time def send_with_retry(server, from_addr, to_addr, msg_str, max_retries3): delays [5, 25, 125] for attempt in range(max_retries): try: server.sendmail(from_addr, [to_addr], msg_str) return True, None except smtplib.SMTPRecipientsRefused as e: return False, f收件人被拒绝: {e} except smtplib.SMTPServerDisconnected: if attempt max_retries - 1: time.sleep(delays[attempt]) continue return False, 服务器连接断开 except Exception as e: if attempt max_retries - 1: time.sleep(delays[attempt]) continue return False, str(e) return False, 重试次数耗尽注意SMTPRecipientsRefused这种错误重试是没用的说明收件人地址被服务器明确拒绝了直接标记失败就行。只有连接类错误才值得重试。4.5 发送结果记录与日志群发一定要有日志。不然出了问题你都不知道发给谁了、谁成功了、谁失败了。我一般会记录这几个字段收件人邮箱、发送时间、发送状态、失败原因、重试次数。日志我建议同时输出到两个地方一个是本地的日志文件方便排查另一个是数据库或者消息队列方便后续做统计和告警。import logging import json from datetime import datetime logger logging.getLogger(email_sender) def log_result(recipient, status, reasonNone, retries0): record { recipient: recipient, status: status, reason: reason, retries: retries, timestamp: datetime.now().isoformat() } logger.info(json.dumps(record, ensure_asciiFalse))5. 常见问题与排查技巧实录5.1 邮件进了垃圾箱怎么办这是被问得最多的问题。邮件进了垃圾箱原因可能有很多我按排查优先级列一下第一检查发件人域名有没有配置SPF和DKIM记录。这两个是邮件身份验证的核心机制。SPF是告诉收件方“哪些IP有权代表我的域名发邮件”DKIM是用密钥对邮件签名。没有这两个记录你的邮件很容易被判定为伪造。第二检查邮件内容里有没有垃圾邮件特征词。比如“免费”、“中奖”、“点击领取”、“限时特惠”这类词堆多了肯定进垃圾箱。HTML里如果全是图片没有文字也容易被判定为垃圾邮件。第三检查发送频率。新IP突然大量发送几乎必进垃圾箱。建议新IP先从小量开始慢慢把信誉养起来。第四检查退订链接。正规的群发邮件底部应该有退订链接没有的话也会影响评分。实操心得我一般会在正式群发之前先给自己的几个不同邮箱Gmail、Outlook、企业邮箱各发一封测试邮件看看实际到达情况。这个步骤花不了几分钟但能避免大翻车。5.2 中文乱码问题中文乱码是邮件开发里的经典问题。根本原因就一个编码不一致。解决办法也很简单所有地方都统一用UTF-8。具体来说MIMEText的第三个参数指定utf-8邮件头里的中文用email.header.Header来编码HTML里的meta charsetutf-8别忘了加。from email.header import Header msg[Subject] Header(系统通知您的订单已发货, utf-8)如果收件人名字里有中文To字段也要用Header处理。5.3 发送速度慢的优化思路如果你觉得发送速度太慢可以从这几个方面优化复用SMTP连接不要每封都重新连接适当增大批量大小但不要超过服务商的限制如果服务商支持使用多线程或者异步发送检查是不是网络延迟导致的可以换个网络环境测试但要注意速度不是越快越好。发太快被限流反而更慢。找到一个稳定的节奏比追求极限速度更重要。5.4 常见错误码速查表错误码含义处理方式550收件人不存在或被拒绝从列表中移除该地址552邮件内容过大压缩内容或拆分附件554被判定为垃圾邮件检查内容、频率、SPF/DKIM421服务不可用稍后重试450邮箱暂时不可用延迟后重试535认证失败检查用户名和授权码5.5 几个容易忽略的细节邮件主题不要用感叹号堆砌。“好消息”这种主题在垃圾邮件过滤器里是减分项。发件人名称要规范。用“某某团队”比用“noreply”更友好也更不容易被拦截。测试环境不要用真实收件人。我见过有人在测试的时候把整个用户列表跑了一遍结果用户收到了测试邮件非常尴尬。测试环境用固定的几个测试邮箱就行。保留发送记录至少30天。万一用户说没收到你可以查记录确认发送状态。6. 进阶优化与扩展思路6.1 模板引擎的引入当邮件模板变多、变量变复杂的时候手动做字符串替换就不够用了。这时候可以引入模板引擎比如Jinja2。它支持条件判断、循环、过滤器写复杂模板会舒服很多。from jinja2 import Template template Template(open(email_template.html).read()) html template.render(username张三, content您的订单已发货, order_idSF1234567890)用模板引擎的好处是模板和逻辑分离改模板不用动Python代码非技术人员也能参与维护。6.2 异步发送与队列化如果收件人数量很大比如几万封同步发送会让请求阻塞很久。这时候可以考虑把发送任务丢到消息队列里用后台worker异步处理。架构大概是这样的Web层把发送任务写入队列比如RabbitMQ、Redis队列后台worker从队列里取任务执行发送更新状态。这样Web层可以立即返回用户体验好很多。6.3 发送统计与可视化发送完成之后你肯定想知道成功了多少、失败了多少、失败原因分布是什么样的。这些数据可以做成一个简单的看板用图表展示出来。我一般会统计这几个指标总发送量、成功率、失败原因TOP5、平均发送耗时。这些数据能帮你快速判断发送质量也能在出问题的时候快速定位。6.4 退订机制的设计正规的群发邮件必须提供退订方式。最简单的做法是在邮件底部放一个退订链接用户点击后跳转到一个页面确认退订然后把该邮箱加入退订列表。退订链接里一般会带一个加密的token用来标识用户身份防止别人恶意退订。token可以用邮箱地址加盐做哈希生成。import hashlib def generate_unsubscribe_token(email, secret_key): raw f{email}:{secret_key} return hashlib.sha256(raw.encode()).hexdigest()[:16]用户点击退订链接后服务端验证token通过则把邮箱加入退订表。后续群发时自动排除退订用户。7. 一些个人体会做邮件群发这个功能技术难度其实不算高但细节特别多。我最大的体会是不要等到发送量大了才去关注送达率。从一开始就把SPF、DKIM配好把退订机制做好把发送频率控制住后面会省很多事。另一个体会是日志和监控比发送逻辑本身更重要。发送逻辑写一次就基本不用动了但出了问题全靠日志来排查。我现在的习惯是任何批量操作都必须有完整的日志记录这已经成了肌肉记忆。还有一点测试要充分。不同的邮件客户端渲染效果真的差别很大我一般至少会在三四个不同的客户端里测试模板效果确认没问题再正式发。这个时间投入绝对值得。最后分享一个小技巧如果你不确定一封邮件会不会被判定为垃圾邮件可以先用一个专门的邮件评分工具跑一下它会给出一个分数和改进建议。虽然不能百分百准确但能帮你避开大部分明显的坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑