PyQt5 + SMTP:构建带数据库的桌面邮件客户端开发实战
2026/9/14 6:20:46 网站建设 项目流程

简介:基于Python+smtp协议+PyQt5的邮件发送程序,是一份面向计算机相关专业毕业设计、课程实践的高分完整项目。项目基于Pycharm与Python3.6开发,利用smtplib和PyQt5封装QQ邮箱SMTP客户端,实现授权登录后向QQ、网易等邮箱发送文本和附件,覆盖邮件头构造、MIME编码、服务器连接与消息投递等核心环节,是深入理解SMTP协议在TCP/IP协议簇中的作用和桌面GUI开发的良好载体。资源压缩包共31个文件,总大小9.64MB,包含7个Python源文件、11个pyc编译文件、1个UI界面文件、xml工程配置、ini系统配置、Markdown说明文档以及程序演示视频,既有可直接运行的完整代码,也有配置文件和文档辅助二次开发。该项目已有72人学习。演示视频直观展示登录与发送过程,配合README可快速掌握smtplib与PyQt5的协作方式;各模块目录清晰,适合作为毕业设计参考、课程设计实战或Python网络编程与桌面应用开发练习。

1. 从毕业设计到可交付的邮件客户端,差的不只是UI

毕设题目里同时出现 SMTP、PyQt5 和数据库,几乎是“社交类桌面端”项目的标配组合。邮件发送程序本身不难,但很多同学卡在几个点上:smtplib 发信总是超时或报错,PyQt5 界面一按发送按钮就假死,数据库表建好了却不知道存什么。真正的高分关键,是让这些模块形成一条清晰的流水线——界面收集输入,线程处理耗时任务,SMTP 负责对话服务器,数据库记录每次发送的状态。这篇文章会从协议层开始,把每一环的代码和参数摊开讲清楚,最后落到打包和开箱即用上。适合正在做类似选题、或者想把邮件功能集成到桌面项目里的开发者,照着写就能跑通。

2. SMTP 协议握手细节与 smtplib 的正确封装

2.1 SMTP 会话是怎么建立起来的

SMTP(Simple Mail Transfer Protocol)是纯文本命令协议,端口 25 是明文,465 是隐式 SSL,587 是 STARTTLS。现代邮箱服务商都要求认证,所以实际流程是:连接服务器、EHLO 声明身份、认证、发送 MAIL FROM、RCPT TO、DATA 数据体、QUIT。用代码实现时,smtplib 把这套命令封装成了loginsendmail等方法,但理解底层顺序对排查授权码失效、被拒收之类的问题非常有帮助。

# 用一个最简单的例子看握手的核心 import smtplib server = smtplib.SMTP_SSL('smtp.qq.com', 465, timeout=10) server.login('你的邮箱@qq.com', '授权码') # 注意不是登录密码 server.sendmail('你的邮箱@qq.com', ['目标@example.com'], 'Subject: test\r\nFrom: 你的邮箱@qq.com\r\nTo: 目标@example.com\r\n\r\n正文') server.quit()

这里的三个关键参数是SMTP_SSL的 host、port 和 login 的凭据。QQ 邮箱、163 邮箱、Gmail 用的都是同一个 smtplib,只是地址和端口不同。timeout建议显式设置,否则程序在服务器无响应时会挂很久。sendmail的第三个参数是完整的邮件原文,包括头部和空白行分隔的正文。

2.2 授权码、端口和常见配置速查表

不是所有邮箱都支持直接用密码登录,这往往是毕设里第一个劝退点。以 QQ 邮箱为例,需要在网页版设置里开启 SMTP 服务,生成一个独立授权码;163 邮箱类似。Gmail 则需要应用专用密码。整理一张常用配置表,做项目时直接对照:

邮箱服务SMTP 服务器SSL 端口STARTTLS 端口认证说明
QQ 邮箱smtp.qq.com465587授权码,非密码
163 邮箱smtp.163.com465994授权码,客户端授权
Outlooksmtp-mail.outlook.com465587邮箱密码或应用密码
Gmailsmtp.gmail.com465587应用专用密码

写程序时应该把这些配置抽出来,作为发件人配置存入数据库,而不是硬编码在界面里。这样换一个邮箱不用改代码,只改配置项,毕设答辩时也能多讲两句“我的设计考虑了可配置性”。

2.3 封装一个自定义异常的消息发送器

smtplib 的异常类型不少,SMTPAuthenticationErrorSMTPRecipientsRefusedSMTPConnectionError如果全部抛给界面层,用户只会看到一堆英文堆栈。常见做法是自定义一个发送器类,把异常转换成中文提示,并返回发送结果字典,方便 UI 层展示和数据库记录。

import smtplib from email.mime.text import MIMEText from email.header import Header class MailSender: def __init__(self, host, port, user, auth_code, use_ssl=True): self.host = host self.port = port self.user = user self.auth_code = auth_code self.use_ssl = use_ssl def send(self, to_list, subject, content, use_html=True): msg = MIMEText(content, 'html' if use_html else 'plain', 'utf-8') msg['From'] = Header(self.user) msg['To'] = Header(','.join(to_list)) msg['Subject'] = Header(subject, 'utf-8') try: if self.use_ssl: server = smtplib.SMTP_SSL(self.host, self.port, timeout=10) else: server = smtplib.SMTP(self.host, self.port, timeout=10) server.starttls() server.login(self.user, self.auth_code) server.sendmail(self.user, to_list, msg.as_string()) return {'success': True, 'message': '发送成功'} except smtplib.SMTPAuthenticationError: return {'success': False, 'message': '授权码错误或账号未开启SMTP服务'} except smtplib.SMTPRecipientsRefused: return {'success': False, 'message': '收件人地址被服务器拒绝'} except smtplib.SMTPException as e: return {'success': False, 'message': f'SMTP错误: {e}'} finally: try: server.quit() except Exception: pass

这段代码里server.starttls()SMTP_SSL只能选一条路。用 465 端口必须走第一条,用 587 端口必须走第二条,混用会报错。另外sendmail的第二个参数是收件人列表,如果只发一个地址,也要写成['a@b.com']的形式,否则部分服务商解析会出问题。

3. PyQt5 界面搭建与发送线程分离

3.1 用 QFormLayout 快速搭出发送界面

PyQt5 的界面代码如果手写会比较繁琐,但作为毕设,不建议引入 Qt Designer 生成的 uic 文件,因为换一台电脑重新编译容易踩坑。手写布局反而更可控。一个标准的邮件发送界面需要四块区域:发件人信息区(邮箱、授权码)、收件人区(支持多条)、主题与正文区、状态栏与发送按钮。

from PyQt5.QtWidgets import (QWidget, QLabel, QLineEdit, QTextEdit, QPushButton, QVBoxLayout, QFormLayout, QHBoxLayout, QListWidget, QMessageBox) class MainWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle('SMTP 邮件客户端') self.resize(720, 600) layout = QVBoxLayout(self) form = QFormLayout() self.edit_host = QLineEdit('smtp.qq.com') self.edit_port = QLineEdit('465') self.edit_user = QLineEdit() self.edit_auth = QLineEdit() self.edit_auth.setEchoMode(QLineEdit.Password) form.addRow('SMTP 服务器', self.edit_host) form.addRow('端口', self.edit_port) form.addRow('邮箱账号', self.edit_user) form.addRow('授权码', self.edit_auth) layout.addLayout(form) self.list_recipients = QListWidget() layout.addWidget(QLabel('收件人列表(每行一个邮箱)')) layout.addWidget(self.list_recipients) self.edit_subject = QLineEdit() layout.addWidget(QLabel('邮件主题')) layout.addWidget(self.edit_subject) self.edit_content = QTextEdit() layout.addWidget(QLabel('邮件正文(支持HTML)')) layout.addWidget(self.edit_content) self.btn_send = QPushButton('发送邮件') self.btn_send.clicked.connect(self.on_send_clicked) layout.addWidget(self.btn_send)

布局用QFormLayout的好处是标签和输入框自动对齐,不需要手动计算坐标。setEchoMode(Password)会让授权码输入框显示为圆点,避免录屏演示时泄露凭据。收件人列表可以直接交互添加,也可以提供“从通讯录导入”按钮,这个功能放到数据库章节处理。

3.2 QThread 线程里执行网络请求,避免界面假死

如果在主线程直接调用smtplib发送,网络延迟高时整个窗口会无响应,操作系统会提示“程序未响应”,这是毕设答辩时的重大减分项。正确做法是把发送动作放到 QThread 子线程里,通过信号把结果传回主线程更新 UI。

from PyQt5.QtCore import QThread, pyqtSignal class SendWorker(QThread): finished = pyqtSignal(dict) def __init__(self, sender, to_list, subject, content, parent=None): super().__init__(parent) self.sender = sender self.to_list = to_list self.subject = subject self.content = content def run(self): result = self.sender.send(self.to_list, self.subject, self.content) self.finished.emit(result) class MainWindow(QWidget): def on_send_clicked(self): # 构造 MailSender 对象 sender = MailSender( self.edit_host.text().strip(), int(self.edit_port.text().strip()), self.edit_user.text().strip(), self.edit_auth.text().strip(), use_ssl=True if int(self.edit_port.text().strip()) == 465 else False ) recipients = [self.list_recipients.item(i).text() for i in range(self.list_recipients.count())] if not recipients or not self.edit_user.text().strip(): QMessageBox.warning(self, '提示', '请填写发件人和收件人') return self.btn_send.setEnabled(False) self.worker = SendWorker(sender, recipients, self.edit_subject.text(), self.edit_content.toHtml()) self.worker.finished.connect(self.on_send_done) self.worker.start() def on_send_done(self, result): self.btn_send.setEnabled(True) if result['success']: QMessageBox.information(self, '结果', result['message']) else: QMessageBox.critical(self, '错误', result['message']) # 这里后续插入数据库记录逻辑

关键点在于self.worker必须保存为实例变量,否则 Python 的垃圾回收会把 QThread 对象回收,导致程序闪退。连接信号时用finished.connect(self.on_send_done),在槽函数里重新启用按钮,这样用户不会因为重复点击而发出多封邮件。toHtml()方法会生成富文本,配合 HTML 邮件模板可以实现图文排版。

3.3 收件人添加与友好校验

收件人列表如果只是个 QLineEdit,用户输错一个字符就会导致全部退回。常见的做法是提供“添加收件人”输入框,点击按钮后执行基本格式校验,再添加到 QListWidget 中。顺便把恶意字符过滤掉,防止有人通过邮件头注入。

import re EMAIL_RE = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$') def add_recipient(self): text = self.edit_recipient.text().strip() if not EMAIL_RE.match(text): QMessageBox.warning(self, '格式错误', '请输入正确的邮箱地址') return if text in [self.list_recipients.item(i).text() for i in range(self.list_recipients.count())]: QMessageBox.information(self, '重复', '该收件人已存在') return self.list_recipients.addItem(text) self.edit_recipient.clear()

正则表达式^[\w.+-]+@[\w-]+\.[\w.-]+$覆盖了绝大多数正常邮箱格式,但不会校验域名是否存在。真正的 MX 记录校验需要网络查询,毕设里不必要。注意QMessageBox.informationwarning的使用场景区分,前者给提示,后者给错误。

4. 用 SQLite 管理发件配置、通讯录和发送日志

4.1 为什么需要数据库而不只是配置文件

题目里明确提到“数据库”,常见选型是 SQLite,因为它是单文件数据库,不需要安装服务,打包进项目后用户拿到就能直接跑。数据库在这里要解决三个问题:持久化发件人配置,避免每次启动重新输入;维护常用联系人通讯录;记录每封邮件的状态,形成发送历史。如果只用一个 ini 文件,列表数据的增删改查会非常麻烦。

4.2 建表语句与初始化函数

import sqlite3 from contextlib import closing DB_PATH = 'mail_client.db' def init_db(): with closing(sqlite3.connect(DB_PATH)) as conn: cur = conn.cursor() cur.execute(''' CREATE TABLE IF NOT EXISTS send_config ( id INTEGER PRIMARY KEY AUTOINCREMENT, host TEXT NOT NULL, port INTEGER NOT NULL, user TEXT NOT NULL, auth_code TEXT NOT NULL, use_ssl INTEGER DEFAULT 1, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ''') cur.execute(''' CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, name TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ''') cur.execute(''' CREATE TABLE IF NOT EXISTS send_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, to_list TEXT NOT NULL, subject TEXT, content_preview TEXT, status TEXT CHECK(status IN ('success','fail')), error_msg TEXT, sent_at TEXT DEFAULT CURRENT_TIMESTAMP ) ''') conn.commit()

closing确保连接在异常时也能关闭。CHECK约束限制了 status 字段只能取两个值,从数据库层面杜绝脏数据。send_log里只存正文的前几百字符作为预览,避免数据库文件无限膨胀,完整邮件本身并不重要——发送成功后副本没必要替用户保存。

4.3 将发送记录写入数据库的时机

发送结果回来之后,要把 result 和邮件基本信息写进日志表。注意收件人列表要序列化成逗号分隔的字符串,便于后续在界面用 QTableWidget 展示。如果发送失败,error_msg 字段记录返回的中文错误提示,这对答辩时演示“程序能处理异常”很有说服力。

def save_send_log(to_list, subject, content, success, error_msg=''): with closing(sqlite3.connect(DB_PATH)) as conn: cur = conn.cursor() cur.execute(''' INSERT INTO send_log (to_list, subject, content_preview, status, error_msg) VALUES (?, ?, ?, ?, ?) ''', ( ','.join(to_list), subject, content[:200], 'success' if success else 'fail', error_msg )) conn.commit()

on_send_done槽函数中调用save_send_log,传参直接用self.worker.to_list等属性。注意不要在子线程里直接操作 SQLite,虽然 SQLite 支持多线程连接,但需要额外配置,常见的稳妥方案是把数据库写入放回主线程。这里因为on_send_done本来就在主线程执行,所以天然安全。

4.4 通讯录导入减少手工输入

通讯录功能可以让用户把 Excel 里的邮箱列表粘贴进来,或者从界面上手工添加。用 QListWidget 展示通讯录,双击将邮箱填入收件人列表。对应数据库的 contacts 表,提供去重插入和模糊搜索。

def import_contacts_from_text(self, text): lines = text.strip().splitlines() with closing(sqlite3.connect(DB_PATH)) as conn: cur = conn.cursor() for line in lines: line = line.strip() if not line: continue # 支持 “姓名 邮箱” 或 纯邮箱 parts = line.split() if len(parts) == 2 and '@' in parts[1]: cur.execute('INSERT OR IGNORE INTO contacts (name, email) VALUES (?, ?)', (parts[0], parts[1])) elif '@' in line: cur.execute('INSERT OR IGNORE INTO contacts (name, email) VALUES (?, ?)', (None, line)) conn.commit()

INSERT OR IGNORE依赖 contacts 表的UNIQUE约束,重复导入同一邮箱不会报错。这个函数可以从界面上的“导入”按钮触发,把导入结果用状态栏提示给用户。

5. 打包发布、环境配置与首次启动自动建库

5.1 PyInstaller 打包命令与资源路径适配

写好的 PyQt5 程序要交付给老师或用户运行,最稳妥的方式是打包成单个 exe。PyInstaller 是这个场景下的默认选择,PySide6 和 PyQt5 的打包方式略有区别,PyQt5 相对成熟,打包前需要做两件事:在项目目录下创建main.py作为入口,然后执行打包命令。

pip install pyinstaller pyinstaller -F -w -i app.ico --add-data "db_init.sql;." main.py

-F生成单文件,-w去掉控制台窗口,-i指定图标。--add-data用于打包数据库初始化脚本,注意分号在 Windows 上表示路径分隔符,Linux 上用冒号。如果忽略这个参数,程序在别人电脑上运行时可能找不到资源文件,这就是常见的“在我电脑上能跑,换台机器就报错”。

5.2 避免 OneFile 模式下数据库路径混乱

PyInstaller 的单文件模式有个大坑:程序运行时产生的临时目录是sys._MEIPASS,而用户需要持久保存的数据库文件应该放在 exe 旁边的目录,而不是临时解压出来的目录。常规做法是检测是否运行在打包环境中,动态调整数据库路径。

import os, sys def get_db_path(): if getattr(sys, 'frozen', False): base = os.path.dirname(sys.executable) else: base = os.path.dirname(os.path.abspath(__file__)) return os.path.join(base, 'mail_client.db')

init_db()里把DB_PATH改成调用这个函数,这样开发环境数据库存源码目录,打包后数据库存 exe 同级目录,两边互不干扰。注意sys.executable在打包后的 exe 中和在 Python 解释器中的值不同,用frozen标记判断。

5.3 首次启动自动建库与演示视频录制技巧

程序首次启动时,如果发现数据库文件不存在,自动调用init_db()并写入默认发件人配置,用户体验就是“开箱即用”。要把这一步放在窗口初始化之前完成,防止界面操作时访问不存在的表。

# main.py 入口代码 import sys from PyQt5.QtWidgets import QApplication from db_manager import init_db, get_db_path if __name__ == '__main__': # 先准备数据库 db_file = get_db_path() init_db(db_file) # 再启动界面 app = QApplication(sys.argv) from ui_main import MainWindow window = MainWindow() window.show() sys.exit(app.exec_())

演示视频的录制不必用专业软件,Windows 自带的 Xbox Game Bar(Win+G)或者离线版 OBS 都可以。录屏前先清空数据库,演示时依次展示三步:填写收件人、点击发送、查看发送日志。视频时长控制在 3 分钟以内,重点突出“界面不卡顿”和“失败提示友好”,这两点正是毕设评分表里“系统健壮性”的加分项。

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

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

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

立即咨询