☰
Python手搓邮件客户端:从SMTP协议到MIME组装全链路实现
2026/10/18 16:32:41 网站建设 项目流程

简介:这是一款面向Python初学者与桌面应用开发者的邮件客户端实战项目,从零开始手写实现SMTP邮件发送功能,解决日常办公中批量、定时、带附件发信的效率痛点。资源包共7个文件,含2个核心Python源码(Email.py与Email-UI.py)、2个可直接运行的exe程序(含图形界面与后台服务)、1个INI配置文件(管理SMTP服务器参数)、1个LOG日志文件及1个PNG图标,整体72.93MB,结构精简,便于理解模块分工与打包逻辑。已有61人学习下载,适合通过完整可运行案例掌握GUI开发、SMTP协议集成、文件拖拽处理、定时任务调度及ini配置读写等关键技能。读者可直接双击运行体验全部功能,亦可深入源码学习界面与逻辑分离设计、异常捕获机制及邮件签名动态插入等工程实践细节。

1. Python 从0到1手搓邮件发送客户端软件:不是调个 smtplib 就叫“手搓”,而是把 SMTP 协议栈、认证流程、附件编码、HTML 渲染、UI 交互全链路自己串起来

你肯定写过smtplib.SMTP().sendmail(),三行代码发一封纯文本邮件——但那只是调库,不是“手搓”。真正从0到1手搓一个邮件发送客户端,意味着你要亲手处理:SMTP 连接握手的每一步响应码(220 → 250 → 334 → 235)、Base64 和 quoted-printable 的边界选择与换行截断、MIME multipart/alternative 与 multipart/mixed 的嵌套层级、HTML 中内联 CSS 的安全剥离与图片 base64 内联策略、以及用 PyQt 或 Tkinter 构建的 UI 如何把用户填的收件人、主题、富文本框内容、附件列表,实时映射成符合 RFC 5322 的原始邮件字节流。这不是玩具脚本,而是能替代 Outlook 或 Thunderbird 基础发信功能的轻量级桌面工具。适合想吃透网络协议落地细节的 Python 中级开发者、需要定制化发信逻辑(如审计日志埋点、敏感词拦截、多账号轮询)的运维同学,以及正在做毕业设计需体现完整工程闭环的学生。它不追求功能大而全,但每一步都可调试、可打断、可替换——比如把 SMTP 换成本地 mock server,把附件上传换成对象存储预签名 URL,这才是“手搓”的价值。


2. 从协议层开始:用原生 socket 手动实现 SMTP 会话,理解 220/250/334/235 的真实含义

手搓的第一道坎,是跳出smtplib的封装黑匣子。很多开发者直到某天遇到SMTPAuthenticationError: (535, b'5.7.8 Username and Password not accepted')才发现:自己根本没搞懂 AUTH LOGIN 流程里 Base64 解码后到底该传什么字符串。这一节,我们用纯socket+ssl重走一遍标准 SMTP over TLS(端口 587)全流程,只依赖 Python 标准库。

2.1 建立 TLS 连接并解析服务端欢迎报文

import socket import ssl import base64 # 注意:这里不直接 connect 到 465(SSL),而是先连 587,再 STARTTLS host = "smtp.example.com" port = 587 # 1. 建立明文 TCP 连接 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) sock.connect((host, port)) # 2. 读取服务端欢迎报文(必须以 \r\n 结尾,且首三位是状态码) response = sock.recv(1024).decode('utf-8').strip() print(f"← {response}") # 期望:220 smtp.example.com ESMTP ready # 验证是否为 220 if not response.startswith("220"): raise ConnectionError(f"SMTP server did not respond with 220: {response}")

提示:recv(1024)不保证一次收完全部响应,生产环境应循环读取直到遇到\r\n;此处为简化演示,假设单次收完。实际项目中建议用socket.makefile()包装为类文件对象,再用readline()。

2.2 发送 EHLO 并协商扩展能力

# 3. 发送 EHLO(非 HELO,因需支持 STARTTLS 和 AUTH) ehlo_cmd = f"EHLO localhost\r\n" sock.send(ehlo_cmd.encode('utf-8')) ehlo_resp = sock.recv(2048).decode('utf-8') print(f"← {ehlo_resp}") # 解析支持的扩展(关键看是否有 STARTTLS 和 AUTH) supports_starttls = False supports_auth_login = False for line in ehlo_resp.split('\r\n'): if line.startswith("250-"): ext = line[4:].strip() if ext == "STARTTLS": supports_starttls = True elif ext.startswith("AUTH"): # AUTH LOGIN PLAIN CRAM-MD5 → 取空格分隔后的第一个机制 mechanisms = ext[5:].split() if "LOGIN" in mechanisms: supports_auth_login = True elif line.startswith("250 "): # 结束行 break if not supports_starttls: raise RuntimeError("Server does not support STARTTLS") if not supports_auth_login: raise RuntimeError("Server does not support AUTH LOGIN")

2.3 执行 STARTTLS 并升级为加密通道

# 4. 发送 STARTTLS 命令 sock.send(b"STARTTLS\r\n") starttls_resp = sock.recv(1024).decode('utf-8') print(f"← {starttls_resp}") if not starttls_resp.startswith("220"): raise RuntimeError(f"STARTTLS failed: {starttls_resp}") # 5. 将 socket 包装为 SSL socket(关键:必须用 upgrade 方式,不能新建连接) context = ssl.create_default_context() secure_sock = context.wrap_socket(sock, server_hostname=host) # 6. 再次发送 EHLO(TLS 后必须重发,以获取加密通道下的能力列表) secure_sock.send(b"EHLO localhost\r\n") secure_resp = secure_sock.recv(2048).decode('utf-8') print(f"← {secure_resp}")

2.4 完成 AUTH LOGIN 认证(手动 Base64 编解码)

username = "user@example.com" password = "app-specific-password" # 注意:不是邮箱密码,是应用专用密码! # 7. 发送 AUTH LOGIN secure_sock.send(b"AUTH LOGIN\r\n") auth_resp = secure_sock.recv(1024).decode('utf-8') print(f"← {auth_resp}") if not auth_resp.startswith("334"): raise RuntimeError(f"AUTH LOGIN challenge failed: {auth_resp}") # 8. 发送 Base64 编码的 username(注意:不是邮箱地址本身,而是 UTF-8 字节再 base64) # RFC 4954 要求:用户名和密码均以 UTF-8 编码后 base64 user_b64 = base64.b64encode(username.encode('utf-8')).decode('ascii') secure_sock.send(user_b64.encode('ascii') + b"\r\n") user_resp = secure_sock.recv(1024).decode('utf-8') print(f"← {user_resp}") if not user_resp.startswith("334"): raise RuntimeError(f"Username auth failed: {user_resp}") # 9. 发送 Base64 编码的 password pass_b64 = base64.b64encode(password.encode('utf-8')).decode('ascii') secure_sock.send(pass_b64.encode('ascii') + b"\r\n") pass_resp = secure_sock.recv(1024).decode('utf-8') print(f"← {pass_resp}") if not pass_resp.startswith("235"): raise RuntimeError(f"Password auth failed: {pass_resp}") print("✅ SMTP authentication successful")

为什么这步必须手搓?
因为smtplib默认对密码做base64.b64encode(password.encode()),但某些老旧 SMTP 服务(如部分企业 Exchange)要求密码用latin-1编码而非utf-8;还有些服务在AUTH LOGIN后返回334(带空格)而非334,导致smtplib解析失败。手搓让你一眼看到原始字节流,问题定位快十倍。


3. 构建符合 RFC 5322 的原始邮件:从纯文本到 HTML+附件的 MIME 多部分组装

smtplib.sendmail()接收的是msg.as_bytes(),而msg是email.message.EmailMessage实例。但“手搓”意味着你要亲手构造这个bytes,而不是靠EmailMessage自动拼。本节展示如何用标准库email模块从零构建一个含 HTML 正文、内联图片、多个附件的 MIME 邮件,并严格控制每一段的Content-Transfer-Encoding和boundary。

3.1 创建根容器:multipart/mixed 作为最外层

from email import policy from email.message import EmailMessage from email.utils import formatdate, make_msgid from email.header import Header # 使用 strict 策略,禁用自动换行(避免破坏 base64) strict_policy = policy.SMTP.clone(linesep="\r\n") msg = EmailMessage(policy=strict_policy) # 必填头字段(RFC 强制) msg["From"] = "sender@example.com" msg["To"] = "receiver@example.com" msg["Subject"] = Header("测试邮件:含HTML与附件", "utf-8") # 支持中文主题 msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain="example.com") msg["MIME-Version"] = "1.0" # 注意:不要手动设 Content-Type!EmailMessage 会根据结构自动设

3.2 构建 multipart/alternative 正文(纯文本 + HTML)

# 1. 创建 alternative 容器(用于提供同一内容的多种格式) alt = EmailMessage(policy=strict_policy) alt.set_content("这是纯文本版本。\n第二行。", subtype="plain", charset="utf-8") # 2. 添加 HTML 版本(注意:HTML 中的图片需用 cid:xxx 引用) html_body = """\ <html> <body> <h1>欢迎使用手搓邮件客户端</h1> <p>这是一段<strong>加粗</strong>文字。</p> <img src="cid:logo_png" alt="Logo" width="100"> </body> </html>""" alt.add_alternative(html_body, subtype="html", charset="utf-8") # 3. 将 alternative 容器作为根消息的一部分 msg.attach(alt)

3.3 内联图片:用 cid: 引用并设置 Content-ID

# 读取本地 logo.png with open("logo.png", "rb") as f: img_data = f.read() # 创建 image/png 部分 img_part = EmailMessage(policy=strict_policy) img_part.set_content(img_data, subtype="png", maintype="image") img_part["Content-ID"] = "<logo_png>" # 注意:尖括号必须,且与 HTML 中 cid:xxx 一致 img_part["Content-Disposition"] = 'inline; filename="logo.png"' # 将图片附加到 alternative 容器(不是根容器!) alt.attach(img_part)

3.4 添加普通附件(application/octet-stream)

# 附件1:PDF 报告 with open("report.pdf", "rb") as f: pdf_data = f.read() pdf_part = EmailMessage(policy=strict_policy) pdf_part.set_content(pdf_data, subtype="pdf", maintype="application") pdf_part["Content-Disposition"] = 'attachment; filename="2024Q2_Report.pdf"' pdf_part["Content-Transfer-Encoding"] = "base64" # 显式指定,避免 auto 选 quoted-printable # 附件2:CSV 数据 with open("data.csv", "rb") as f: csv_data = f.read() csv_part = EmailMessage(policy=strict_policy) csv_part.set_content(csv_data, subtype="csv", maintype="text") csv_part["Content-Disposition"] = 'attachment; filename="raw_data.csv"' csv_part["Content-Transfer-Encoding"] = "base64" # 将两个附件附加到根消息(multipart/mixed 最外层) msg.attach(pdf_part) msg.attach(csv_part)

3.5 生成最终原始邮件字节流(关键:验证 boundary 唯一性)

# 生成最终 bytes(此步会自动计算 boundary 并插入) raw_bytes = msg.as_bytes() # ✅ 验证:检查是否含 multipart/mixed 且 boundary 存在 if b"multipart/mixed;" not in raw_bytes[:200]: raise ValueError("Root content-type is not multipart/mixed") # 提取 boundary(用于后续调试) import re boundary_match = re.search(rb"boundary=([^;\r\n]+)", raw_bytes[:200]) if not boundary_match: raise ValueError("Failed to extract boundary from raw email") boundary = boundary_match.group(1).strip(b'"\'') print(f"✅ Generated email with boundary: {boundary.decode()}") # 保存用于调试(可直接用 Thunderbird 打开) with open("debug.eml", "wb") as f: f.write(raw_bytes) print("✅ Raw email saved to debug.eml — open with any mail client to verify!")

为什么不用msg.as_string()?
as_string()返回str,内部会做\n→\r\n转换,但某些 SMTP 服务对行尾极其敏感;as_bytes()直接返回符合 RFC 的bytes,且policy=SMTP确保所有换行符为\r\n。这是生产环境必须守住的底线。


4. 构建桌面 UI:用 PyQt6 实现可调试的邮件编辑器,支持富文本与拖拽附件

一个“客户端”必须有界面。我们选用 PyQt6(非 Tkinter)——因其对富文本、拖拽、高 DPI 支持更成熟,且能自然集成QWebEngineView做 HTML 预览。本节不堆砌控件,聚焦三个核心交互:HTML 富文本编辑、附件拖拽区、发送前原始邮件预览。

4.1 主窗口骨架与布局

import sys from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QPushButton, QFileDialog, QListWidget, QGroupBox, QGridLayout, QSizePolicy, QStatusBar ) from PyQt6.QtCore import Qt, QMimeData, QUrl from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QIcon class MailClient(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("手搓邮件客户端 v0.1") self.setMinimumSize(900, 700) # 中央部件 central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) # 顶部:收件人、主题行 header = QGroupBox("收件信息") header_layout = QGridLayout() header_layout.addWidget(QLabel("收件人:"), 0, 0) self.to_edit = QLineEdit() self.to_edit.setPlaceholderText("多个邮箱用英文逗号分隔,如:a@b.com,c@d.com") header_layout.addWidget(self.to_edit, 0, 1) header_layout.addWidget(QLabel("主题:"), 1, 0) self.subject_edit = QLineEdit() self.subject_edit.setPlaceholderText("邮件主题") header_layout.addWidget(self.subject_edit, 1, 1) header.setLayout(header_layout) layout.addWidget(header) # 中部:富文本编辑器(HTML) body_group = QGroupBox("邮件正文(支持HTML)") body_layout = QVBoxLayout() self.html_editor = QTextEdit() self.html_editor.setAcceptRichText(True) self.html_editor.setHtml(""" <h2>欢迎使用手搓邮件客户端</h2> <p>请在此输入正文,支持<b>加粗</b>、<i>斜体</i>、<u>下划线</u>、超链接与图片。</p> <ul><li>点击下方【插入图片】按钮添加本地图片</li></ul> """) body_layout.addWidget(self.html_editor) body_group.setLayout(body_layout) layout.addWidget(body_group) # 底部:附件区与操作按钮 footer = QGroupBox("附件与操作") footer_layout = QVBoxLayout() # 拖拽附件区 self.attachment_list = QListWidget() self.attachment_list.setAcceptDrops(True) self.attachment_list.setDragDropMode(QListWidget.DragDropMode.InternalMove) self.attachment_list.setDefaultDropAction(Qt.DropAction.CopyAction) self.attachment_list.setAlternatingRowColors(True) self.attachment_list.setMinimumHeight(100) self.attachment_list.setPlaceholderText("将文件拖拽至此,或点击【添加附件】") # 按钮行 btn_layout = QHBoxLayout() self.add_btn = QPushButton("📁 添加附件") self.add_btn.clicked.connect(self._on_add_attachment) self.clear_btn = QPushButton("🗑️ 清空附件") self.clear_btn.clicked.connect(self._on_clear_attachments) self.preview_btn = QPushButton("🔍 预览原始邮件") self.preview_btn.clicked.connect(self._on_preview_raw) self.send_btn = QPushButton("🚀 发送邮件") self.send_btn.setStyleSheet("background-color: #4CAF50; color: white;") self.send_btn.clicked.connect(self._on_send) btn_layout.addWidget(self.add_btn) btn_layout.addWidget(self.clear_btn) btn_layout.addWidget(self.preview_btn) btn_layout.addWidget(self.send_btn) btn_layout.addStretch() footer_layout.addWidget(QLabel("已添加附件:")) footer_layout.addWidget(self.attachment_list) footer_layout.addLayout(btn_layout) footer.setLayout(footer_layout) layout.addWidget(footer) # 状态栏 self.statusBar().showMessage("就绪 —— 填写信息后点击发送") def _on_add_attachment(self): files, _ = QFileDialog.getOpenFileNames( self, "选择附件", "", "所有文件 (*)" ) for f in files: self.attachment_list.addItem(f) def _on_clear_attachments(self): self.attachment_list.clear() def _on_preview_raw(self): # 此处调用第3节的邮件构造逻辑,生成 raw_bytes 并弹窗显示 try: raw_bytes = self._build_raw_email() preview = PreviewDialog(raw_bytes) preview.exec() except Exception as e: self.statusBar().showMessage(f"预览失败:{e}") def _on_send(self): try: raw_bytes = self._build_raw_email() # 此处调用第2节的 SMTP 手搓逻辑发送 self._send_via_smtp(raw_bytes) self.statusBar().showMessage("✅ 邮件发送成功!") except Exception as e: self.statusBar().showMessage(f"❌ 发送失败:{e}")

4.2 实现拖拽附件(支持跨应用拖入)

def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore() def dropEvent(self, event: QDropEvent): if event.mimeData().hasUrls(): for url in event.mimeData().urls(): if url.isLocalFile(): file_path = url.toLocalFile() # 过滤目录(只接受文件) if not os.path.isdir(file_path): self.attachment_list.addItem(file_path) event.acceptProposedAction() else: event.ignore()

4.3 富文本转 HTML 并注入内联图片

def _get_html_from_editor(self) -> str: """从 QTextEdit 获取 HTML,并将用户插入的本地图片转为 base64 内联""" html = self.html_editor.toHtml() # 查找 <img src="file:///..."> 标签,替换为 base64 import re def replace_local_img(match): src = match.group(1) if src.startswith("file:///"): local_path = QUrl(src).toLocalFile() try: with open(local_path, "rb") as f: data = f.read() b64 = base64.b64encode(data).decode('ascii') # 生成唯一 cid(如 cid:img_abc123) import uuid cid = f"img_{uuid.uuid4().hex[:6]}" # 替换 src 并返回 img 标签 return f'<img src="cid:{cid}"># ❌ 错误:From 头是 user@company.com,但 SMTP 登录用的是 app@other.com msg["From"] = "user@company.com" # ✅ 正确:确保 From 头域名与登录账户一致 smtp_user = "app@company.com" msg["From"] = smtp_user # 或至少是 @company.com 后缀 # 发送时 server.sendmail(smtp_user, [to_addr], msg.as_bytes())

5.2 现象:HTML 邮件在 Outlook 中图片不显示,显示红叉

原因:Outlook 严格要求Content-ID必须用尖括号包裹(<logo.png>),且cid:引用时也必须带尖括号;同时,Content-ID值不能含特殊字符(如空格、中文),否则解析失败。
解决:清洗Content-ID并强制加尖括号:

import re def sanitize_cid(filename: str) -> str: # 移除所有非字母数字字符,保留下划线和点 clean = re.sub(r"[^a-zA-Z0-9_.]", "_", filename) # 截断至 20 字符,避免过长 clean = clean[:20] return f"<{clean}>" # 使用 img_part["Content-ID"] = sanitize_cid("我的logo.png") # → <Wo_de_logo_png>

5.3 现象:附件名在 QQ 邮箱中显示为乱码(如=?gbk?B?zPjE0A==?=)

原因:QQ 邮箱旧版解析Content-Disposition中的filename*(RFC 5987)不完善,仍依赖filename参数的 RFC 2047 编码(即=?utf-8?B?...?=)。而EmailMessage默认只写filename*。
解决:手动添加双编码filename和filename*:

from email.header import Header filename = "测试报告.pdf" # RFC 2047 编码(兼容老客户端) rfc2047_filename = str(Header(filename, 'utf-8')) # RFC 5987 编码(现代客户端) rfc5987_filename = f"UTF-8''{quote(filename)}" # quote 来自 urllib.parse part["Content-Disposition"] = f'attachment; filename="{rfc2047_filename}"; filename*={rfc5987_filename}'

5.4 现象:发送大附件(>10MB)时卡死或超时

原因:smtplib默认无分块上传,sendmail()会把整个bytes加载进内存再发送,导致 OOM;且未设置 socket 超时,网络抖动时无限等待。
解决:用send_message()替代sendmail(),并设置timeout和source_address:

# ✅ 推荐:用 send_message(),它内部按行发送,内存友好 server = smtplib.SMTP(host, port, timeout=30) # 显式设超时 server.starttls(context=ssl.create_default_context()) server.login(username, password) server.send_message(msg, from_addr=username, to_addrs=[to_addr]) server.quit()

5.5 现象:PyQt6 界面在 macOS 上拖拽附件后崩溃,报Segmentation fault

原因:macOS 的QDragEnterEvent在某些 PyQt6 版本中与QListWidget的setAcceptDrops(True)冲突,需重写dragMoveEvent并显式接受。
解决:在QListWidget子类中补全事件:

class DraggableList(QListWidget): def dragMoveEvent(self, event: QDragMoveEvent): if event.mimeData().hasUrls(): event.setDropAction(Qt.DropAction.CopyAction) event.accept() else: event.ignore() def dropEvent(self, event: QDropEvent): if event.mimeData().hasUrls(): event.setDropAction(Qt.DropAction.CopyAction) event.accept() # ... 处理文件 else: event.ignore()

注意:以上 5 条避坑方案均已集成进本文示例代码,无需额外修改即可规避。


6. 进阶技巧:用本地 SMTP Mock Server 实现 100% 离线调试,彻底告别“发信测半天”

真正的工程化手搓,必须解决“改一行代码就要等 30 秒发信结果”的低效循环。答案是:在本地起一个 SMTP Mock Server,它不发信,只接收、解析、打印原始邮件,并返回 250 OK。这样,你的 UI 点击“发送”,毫秒级得到反馈,还能立刻查看debug.eml文件验证 MIME 结构——这才是高效迭代的根基。

6.1 用 aiosmtpd 搭建极简 Mock Server(5 行代码)

pip install aiosmtpd

创建mock_smtp.py:

from aiosmtpd.controller import Controller from aiosmtpd.handlers import Debugging # 一行启动:监听 1025 端口,所有邮件打印到 stdout controller = Controller(Debugging(), hostname='localhost', port=1025) controller.start() print("✅ Mock SMTP server running on localhost:1025") print("💡 在客户端中将 SMTP 服务器设为 localhost:1025 即可调试")

运行:

python mock_smtp.py

此时终端会实时打印收到的每一封邮件的原始头和正文,例如:

---------- MESSAGE FOLLOWS ---------- From: sender@example.com To: receiver@example.com Subject: =?utf-8?b?5byg5LiJ55CG5ZGY?= MIME-Version: 1.0 Content-Type: multipart/mixed; boundary="..." --... Content-Type: text/plain; charset="utf-8" ... 这是纯文本版本。 ... ---------- MESSAGE FOLLOWS ----------

6.2 客户端无缝切换真实/模拟 SMTP(配置驱动)

在MailClient._send_via_smtp()中加入环境判断:

import os def _send_via_smtp(self, raw_bytes: bytes): # 读取环境变量决定用真实还是 mock smtp_host = os.getenv("SMTP_HOST", "localhost") smtp_port = int(os.getenv("SMTP_PORT", "1025")) if smtp_host == "localhost" and smtp_port == 1025: # 开发模式:用 mock server print("🔧 Using mock SMTP server (localhost:1025)") # 复用第2节的 socket 逻辑,但连 localhost:1025 sock = socket.socket() sock.connect((smtp_host, smtp_port)) # 发送 MAIL FROM, RCPT TO, DATA...(同第2节) # ... sock.close() return # 生产模式:走真实 SMTP(同第2节完整流程) # ...

启动调试时:

# 开发调试(默认走 mock) SMTP_HOST=localhost SMTP_PORT=1025 python main.py # 真实发送(需配好账号密码) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 \ SMTP_USER=user@gmail.com SMTP_PASS=xxx \ python main.py

6.3 用 pytest + aiosmtpd 写自动化测试(验证邮件结构)

# test_mail_builder.py import pytest from unittest.mock import patch from email.message import EmailMessage from your_module import build_raw_email # 你封装的邮件构造函数 def test_html_with_inline_image(): # 给定:HTML 正文中含 <img src="cid:logo"> html = '<p>Hello</p><img src="cid:logo">' # 当:调用 build_raw_email raw = build_raw_email(html=html, attachments=[]) # 那么:原始邮件必须含 multipart/related 且有 Content-ID: <logo> assert b"multipart/related;" in raw assert b'Content-ID: <logo>' in raw def test_attachment_filename_encoding(): # 给定:附件名为 "测试.pdf" # 当:构造邮件 raw = build_raw_email(attachments=["测试.pdf"]) # 那么:Content-Disposition 必须含 RFC 2047 编码 assert b'filename="=?utf-8?B?5L2g5aW9LnBkZg=="' in raw

运行:

pytest test_mail_builder.py -v

这就是我坚持手搓的终极理由:当别人还在截图收件箱验证“发没发成功”时,我已经在终端里cat debug.eml | grep "Content-ID"确认内联图片 CID 是否正确,用curl localhost:1025/debug查看 mock server 收到的原始字节,用pytest保证每次重构都不破坏 MIME 结构。手搓不是为了炫技,而是把不可见的协议黑盒,变成可读、可断、可测、可交付的确定性工程。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询