Python手搓邮件客户端:从SMTP协议到MIME组装全链路实现 简介这是一款面向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, b5.7.8 Username and Password not accepted)才发现自己根本没搞懂 AUTH LOGIN 流程里 Base64 解码后到底该传什么字符串。这一节我们用纯socketssl重走一遍标准 SMTP over TLS端口 587全流程只依赖 Python 标准库。2.1 建立 TLS 连接并解析服务端欢迎报文import socket import ssl import base64 # 注意这里不直接 connect 到 465SSL而是先连 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(fSMTP server did not respond with 220: {response})提示recv(1024)不保证一次收完全部响应生产环境应循环读取直到遇到\r\n此处为简化演示假设单次收完。实际项目中建议用socket.makefile()包装为类文件对象再用readline()。2.2 发送 EHLO 并协商扩展能力# 3. 发送 EHLO非 HELO因需支持 STARTTLS 和 AUTH ehlo_cmd fEHLO 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(bSTARTTLS\r\n) starttls_resp sock.recv(1024).decode(utf-8) print(f← {starttls_resp}) if not starttls_resp.startswith(220): raise RuntimeError(fSTARTTLS failed: {starttls_resp}) # 5. 将 socket 包装为 SSL socket关键必须用 upgrade 方式不能新建连接 context ssl.create_default_context() secure_sock context.wrap_socket(sock, server_hostnamehost) # 6. 再次发送 EHLOTLS 后必须重发以获取加密通道下的能力列表 secure_sock.send(bEHLO localhost\r\n) secure_resp secure_sock.recv(2048).decode(utf-8) print(f← {secure_resp})2.4 完成 AUTH LOGIN 认证手动 Base64 编解码username userexample.com password app-specific-password # 注意不是邮箱密码是应用专用密码 # 7. 发送 AUTH LOGIN secure_sock.send(bAUTH LOGIN\r\n) auth_resp secure_sock.recv(1024).decode(utf-8) print(f← {auth_resp}) if not auth_resp.startswith(334): raise RuntimeError(fAUTH 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(fUsername 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(fPassword 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(policystrict_policy) # 必填头字段RFC 强制 msg[From] senderexample.com msg[To] receiverexample.com msg[Subject] Header(测试邮件含HTML与附件, utf-8) # 支持中文主题 msg[Date] formatdate(localtimeTrue) msg[Message-ID] make_msgid(domainexample.com) msg[MIME-Version] 1.0 # 注意不要手动设 Content-TypeEmailMessage 会根据结构自动设3.2 构建 multipart/alternative 正文纯文本 HTML# 1. 创建 alternative 容器用于提供同一内容的多种格式 alt EmailMessage(policystrict_policy) alt.set_content(这是纯文本版本。\n第二行。, subtypeplain, charsetutf-8) # 2. 添加 HTML 版本注意HTML 中的图片需用 cid:xxx 引用 html_body \ html body h1欢迎使用手搓邮件客户端/h1 p这是一段strong加粗/strong文字。/p img srccid:logo_png altLogo width100 /body /html alt.add_alternative(html_body, subtypehtml, charsetutf-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(policystrict_policy) img_part.set_content(img_data, subtypepng, maintypeimage) img_part[Content-ID] logo_png # 注意尖括号必须且与 HTML 中 cid:xxx 一致 img_part[Content-Disposition] inline; filenamelogo.png # 将图片附加到 alternative 容器不是根容器 alt.attach(img_part)3.4 添加普通附件application/octet-stream# 附件1PDF 报告 with open(report.pdf, rb) as f: pdf_data f.read() pdf_part EmailMessage(policystrict_policy) pdf_part.set_content(pdf_data, subtypepdf, maintypeapplication) pdf_part[Content-Disposition] attachment; filename2024Q2_Report.pdf pdf_part[Content-Transfer-Encoding] base64 # 显式指定避免 auto 选 quoted-printable # 附件2CSV 数据 with open(data.csv, rb) as f: csv_data f.read() csv_part EmailMessage(policystrict_policy) csv_part.set_content(csv_data, subtypecsv, maintypetext) csv_part[Content-Disposition] attachment; filenameraw_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 bmultipart/mixed; not in raw_bytes[:200]: raise ValueError(Root content-type is not multipart/mixed) # 提取 boundary用于后续调试 import re boundary_match re.search(rbboundary([^;\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且policySMTP确保所有换行符为\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(多个邮箱用英文逗号分隔如ab.com,cd.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 ulli点击下方【插入图片】按钮添加本地图片/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 srcfile:///... 标签替换为 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 fimg_{uuid.uuid4().hex[:6]} # 替换 src 并返回 img 标签 return fimg srccid:{cid}># ❌ 错误From 头是 usercompany.com但 SMTP 登录用的是 appother.com msg[From] usercompany.com # ✅ 正确确保 From 头域名与登录账户一致 smtp_user appcompany.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_png5.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 fUTF-8{quote(filename)} # quote 来自 urllib.parse part[Content-Disposition] fattachment; 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, timeout30) # 显式设超时 server.starttls(contextssl.create_default_context()) server.login(username, password) server.send_message(msg, from_addrusername, 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 Server5 行代码pip install aiosmtpd创建mock_smtp.pyfrom aiosmtpd.controller import Controller from aiosmtpd.handlers import Debugging # 一行启动监听 1025 端口所有邮件打印到 stdout controller Controller(Debugging(), hostnamelocalhost, port1025) controller.start() print(✅ Mock SMTP server running on localhost:1025) print( 在客户端中将 SMTP 服务器设为 localhost:1025 即可调试)运行python mock_smtp.py此时终端会实时打印收到的每一封邮件的原始头和正文例如---------- MESSAGE FOLLOWS ---------- From: senderexample.com To: receiverexample.com Subject: ?utf-8?b?5byg5LiJ55CG5ZGY? MIME-Version: 1.0 Content-Type: multipart/mixed; boundary... --... Content-Type: text/plain; charsetutf-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_HOSTlocalhost SMTP_PORT1025 python main.py # 真实发送需配好账号密码 SMTP_HOSTsmtp.gmail.com SMTP_PORT587 \ SMTP_USERusergmail.com SMTP_PASSxxx \ python main.py6.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 srccid:logo html pHello/pimg srccid:logo # 当调用 build_raw_email raw build_raw_email(htmlhtml, attachments[]) # 那么原始邮件必须含 multipart/related 且有 Content-ID: logo assert bmultipart/related; in raw assert bContent-ID: logo in raw def test_attachment_filename_encoding(): # 给定附件名为 测试.pdf # 当构造邮件 raw build_raw_email(attachments[测试.pdf]) # 那么Content-Disposition 必须含 RFC 2047 编码 assert bfilename?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 结构。手搓不是为了炫技而是把不可见的协议黑盒变成可读、可断、可测、可交付的确定性工程。希望帮到你。本文还有配套的精品资源点击获取