Python从零实现POP3与SMTP邮件服务端:协议状态机与安卓客户端实战
2026/9/15 11:43:51 网站建设 项目流程

简介:基于Python POP3和SMTP的邮件服务端与安卓移动客户端课程设计,适合计算机网络、软件工程等方向的课程实践,也可作为学习邮件协议实现的参考项目。项目完整实现了一个邮件服务器,除基本收发邮件外,还支持新用户注册、用户管理、群发邮件、服务器参数修改、管理员密码重置、邮件与IP地址过滤等;同时配套安卓客户端,分为普通用户端和管理员端,前者可注册、收发邮件、修改个人资料,后者支持群发、浏览用户信息和删除用户。压缩包共400个文件,以283个Python源码文件为主体,并包含dll、pyd、exe等运行库以及虚拟环境配置,整体仅12.31MB,便于直接导入运行与二次开发。目前已有201人学习下载。包内除源代码外还附带PDF说明文档、配置文件等,目录结构清晰,能帮助快速理解POP3/SMTP交互流程、客户端与服务器端架构及管理员功能设计。

1. 基于Python的POP3和SMTP邮件服务端,配上安卓移动客户端,这个课程设计的核心不是调用现成的邮箱服务,而是把两种协议从零实现一遍。只有亲手处理过USER/PASSSTAT/RETRMAIL/RCPT/DATA这些命令,你才会对协议状态机有真正的肌肉记忆。项目里除了最基本的收发,还要求注册新用户、管理员群发、修改服务器参数、IP过滤,这些功能会逼着你在协议栈之外再设计一套管理接口。这篇博文会拆开服务端的命令处理与存储结构,讲清楚安卓端如何用Java socket对接,最后给出一个可以重复跑的自动化验证方案。适合正在做网络课设、又不想只抄一个JavaMail Demo的开发者。

2. POP3和SMTP协议状态机:服务端代码的核心骨架

2.1 为什么用socketserver而不是asyncio

教材里常见的实现方式是Pythonsocketserver模块的ThreadingTCPServerStreamRequestHandler。原因是POP3和SMTP都是文本行协议,每个请求以\r\n结尾,StreamRequestHandlerrfile.readline()天然适合按行处理。如果用asyncio,状态切换和随时可能关闭的连接管理会复杂很多,课程设计阶段容易在事件循环里写出一堆难以调试的协程。常见的做法是同时配置allow_reuse_address = Truedaemon_threads = True,这样开发时不会因为端口被占用而反复改端口,程序退出时线程也不会挂住整个进程。

socketserver的多线程模型在这个项目里完全够用,因为邮件服务端的瓶颈通常不在连接数,而在磁盘IO和网络带宽。即便是几十个客户端同时连接,每个线程只要不阻塞在readline()上,资源开销就是可控的。真正的难点在于:每个连接有自己的状态,比如当前用户、是否通过认证、处于授权还是事务阶段,这些状态只属于当前处理线程,不能设计成全局变量。

提示:使用ThreadingTCPServer时,每一个新连接都会创建一个新线程,所以处理器类里的self就是该连接唯一的对话状态。

2.2 POP3命令表与状态跳转

POP3有严格的三个状态:授权、事务、更新。状态跳转由命令触发,下面这张表是必须实现的命令集,任何一条漏了都会在课程设计答辩时被追问。

命令参数允许状态成功响应
USER用户名授权+OK
PASS密码授权+OK-ERR
STAT事务+OK消息数 总字节数
LIST无或消息编号事务+OK多行列表
RETR消息编号事务+OK邮件内容
DELE消息编号事务+OK标记删除
NOOP事务+OK
RSET事务+OK撤销删除标记
QUIT授权或事务+OK真正删除标记邮件

注意DELE只是把消息编号加入“待删除”集合,不会立刻从磁盘移除。真正删除发生在QUIT命令进入UPDATE状态时。如果你在DELE之后立刻用文件系统查看邮件目录,会发现文件还在,这是协议设计的正常行为,不是bug。

2.3 实现POP3处理器的骨架代码

import socketserver import os import json class Pop3Handler(socketserver.StreamRequestHandler): def setup(self): super().setup() self.state = 'AUTHORIZATION' self.username = None self.deleted = set() self.messages = load_maildir(self.username) # 从maildir目录加载邮件列表 def handle(self): self.wfile.write(b'+OK POP3 server ready\r\n') while True: raw = self.rfile.readline() if not raw: break line = raw.strip().decode('utf-8', errors='replace') command, *args = line.split(' ') command = command.upper() if command == 'USER': self._user(args[0] if args else '') elif command == 'PASS': self._pass(args[0] if args else '') elif command == 'STAT': self._stat() elif command == 'LIST': self._list(args[0] if args else None) elif command == 'RETR': self._retr(args[0] if args else None) elif command == 'DELE': self._dele(args[0] if args else None) elif command == 'QUIT': self._quit() break else: self.wfile.write(b'-ERR unknown command\r\n') def _user(self, arg): if self.state != 'AUTHORIZATION': self.wfile.write(b'-ERR invalid state\r\n') return self.username = arg self.wfile.write(b'+OK send PASS\r\n') def _quit(self): # 真正删除已标记的消息 for num in self.deleted: os.remove(self.messages[num].path) self.wfile.write(b'+OK bye\r\n')

这段代码里,load_maildir会将邮件的元信息(编号、路径、大小)加载到self.messages列表,setup在每次连接创建时执行,相当于重新开始一次会话。_user并不检查用户名是否存在,只做记录,真正的认证在_pass里完成。这样可以防止攻击者探测已有用户名,虽然在课程设计里安全级别不高,但写对协议流程对答辩有加分。_quit里遍历self.deleted,通过消息编号找到文件路径并删除,这里要小心如果文件已经被外部清理,需要用os.path.exists做保护。

2.4 SMTP的DATA流程与透明填充

SMTP的命令比POP3更少,但DATA阶段的处理最容易出错。服务器在收到DATA后,要不断读取客户端发来的行,直到单独一行只有一个点号为止。协议规定,如果邮件正文行以点开头,发送方必须额外加一个点,接收方在这里要还原。这是一个典型的状态机边界场景。

def _data(self): if not self.rcpt_to: self.wfile.write(b'503 Bad sequence\r\n') return self.wfile.write(b'354 End data with <CR><LF>.<CR><LF>\r\n') lines = [] while True: raw = self.rfile.readline() if raw in (b'.\r\n', b'.\n'): break if raw.startswith(b'..'): raw = raw[1:] lines.append(raw) content = b''.join(lines) save_mail(self.rcpt_to, content) # 保存到每个收件人目录 self.wfile.write(b'250 OK\r\n')

raw in (b'.\r\n', b'.\n')用于兼容不同客户端的换行习惯,但严格按RFC应该是b'.\r\n'。透明填充的还原必须放在判断结束点之后,否则可能会出现以单点开头但实际是正文内容的情况。save_mail这里会遍历self.rcpt_to这个收件人列表,向每个用户目录写入一份副本,这也是群发功能的基础。

SMTP处理器还需要在HELO之后才允许MAIL FROM,在RCPT TO之后才允许DATA,这些序列都可以用一个expect_state变量来验证。如果客户端跳过步骤直接发DATA,应该返回503 Bad sequence而不是默默接受。

3. 用户和邮件存储:注册、认证、过滤在服务端的落地

3.1 存储选型:JSON文件加maildir目录

服务端除了协议解析,还要维护用户数据和邮件数据。常见做法是不引入数据库,直接使用文件系统:用户信息放在users.json,邮件按maildir格式存放在每个用户的目录下。maildir的优点是每封邮件是一个独立文件,删除、统计字节数、备份都容易,而且不会出现传统mbox格式中“一个文件被多个线程同时写”的锁问题。

{ "alice": { "salt": "1f8b...", "password_hash": "e5e0...", "is_admin": false, "ip_filter": [ "192.168.1.0/24" ] }, "admin": { "salt": "9c2a...", "password_hash": "7f3b...", "is_admin": true, "ip_filter": [] } }

密码存储采用“盐+哈希”而不是明文。salt是每个用户独立的随机十六进制字符串,注册时生成并保存,认证时用相同的盐拼接密码再计算哈希值,与存储值比较。这样即使users.json泄漏,也不能直接用彩虹表反推出密码。ip_filter字段是可选的,空列表表示不限IP,非空则列出允许的网段。

3.2 注册接口:使用独立控制端口

POP3和SMTP协议本身没有注册和用户管理命令,因此服务端需要额外开一个控制端口(比如8888),客户端通过这个端口发送JSON请求完成注册、管理员登录、删除用户等操作。控制端口和协议端口分离,可以避免污染POP3命令集,也方便用HTTP格式实现,因为安卓端的HttpURLConnection使用起来比原始socket要方便得多。

from http.server import BaseHTTPRequestHandler, HTTPServer import json class ControlHandler(BaseHTTPRequestHandler): def do_POST(self): length = int(self.headers.get('Content-Length', 0)) payload = json.loads(self.rfile.read(length)) if self.path == '/register': ok, msg = user_manager.register(payload['username'], payload['password']) self._respond(200 if ok else 400, msg) elif self.path == '/admin/delete_user': # 管理员权限校验 if not admin_logged_in(payload.get('token')): self._respond(403, 'forbidden') else: user_manager.delete(payload['username']) self._respond(200, 'deleted') else: self._respond(404, 'not found') def _respond(self, code, msg): body = json.dumps({'message': msg}).encode() self.send_response(code) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body)

控制接口的参数放在请求体里,服务端用json.loads解析。/register是开放接口,任何人都可以注册普通用户;/admin/delete_user必须在请求体携带管理员token,token可以在管理员登录时生成并保存在服务端内存中。这里用admin_logged_in函数做伪代码,实际实现时可以用一个dict保存token到用户的映射。

控制端口和协议端口的职责划分很重要:POP3端口只负责收信,SMTP端口只负责发信,控制端口只负责用户管理。这样任何一个端口出错都不会影响其他两条链路,也方便在答辩时单独演示。

3.3 IP过滤与内容过滤的实现位置

IP过滤需要在两个层次生效。第一层是全局过滤,在socketserver的连接处理器中检查self.client_address[0],如果命中黑名单就直接断开。第二层是用户级过滤,比如管理员设置某用户只能在特定网段登录,这需要在PASS认证通过后再次校验客户端的IP是否在ip_filter列表内。

import ipaddress def ip_allowed(ip, filter_rules): if not filter_rules: return True for rule in filter_rules: if ipaddress.ip_address(ip) in ipaddress.ip_network(rule): return True return False

邮件内容过滤则可以放在SMTP的RCPT TO阶段,检查收件人是否在禁止列表中;也可以在DATA结束后对完整邮件做关键词扫描。内容扫描会影响性能,建议在课程设计中只针对Subject字段做简单匹配,比如广告关键词。如果命中,返回554 Transaction failed,同时不保存邮件,并在日志中记录发件人和收件人。

过滤规则存放在server_config.json里,管理员通过控制端口修改后调用reload_config()重新加载。实际上,这个重载可以简单到用一个全局字典和一把锁,更新时替换字典引用,不需要重启进程。

3.4 修改服务器参数与管理员密码

管理员端还应有修改服务器参数的功能。可以在server_config.json中定义smtp_portpop3_portmax_message_sizemail_quota等参数,管理员的登录信息则独立存储在admin用户字段中。通过/admin/config接口读取和更新这些参数,服务端接收新配置后先做合法性校验,再写回文件并替换全局配置字典。

修改管理员密码的流程与普通注册类似:客户端必须提交旧密码和两次新密码,服务端先校验旧密码,然后生成新的随机盐和密码哈希。切忌直接在users.json里用明文覆盖,否则一旦配置文件被读到,整个管理权限就形同虚设。

4. 安卓客户端:从socket到JavaMail的封装

4.1 客户端分层:协议逻辑与UI

安卓端的主要工作是用Java实现POP3和SMTP客户端。课程设计中不推荐直接把socket代码写在Activity里,否则界面旋转一次就会重建连接。常见做法是写一个MailClient单例,内部维护Pop3ClientSmtpClient两个对象,再通过回调把收发结果抛给UI线程。

由于安卓自带java.net.Socket,可以不依赖第三方库。但如果要解析MIME格式的邮件正文和附件,自己写会非常繁琐。更现实的方案是使用JavaMail库,并添加mail.jaractivation.jarlibs目录。JavaMail本身支持自定义服务器地址和端口,只需要配置mail.smtp.hostmail.pop3.port等属性。

4.2 用Socket实现POP3收信

当不使用JavaMail时,自己实现POP3收信的流程是:建立socket、发送USER/PASS、发送STAT获取邮件数、循环RETR读取每封邮件。下面是一个最小可运行的示例方法。

public List<String> fetchMails() throws IOException { Socket socket = new Socket(host, 1100); BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); check(in.readLine()); out.write("USER " + username + "\r\n"); out.flush(); check(in.readLine()); out.write("PASS " + password + "\r\n"); out.flush(); check(in.readLine()); out.write("STAT\r\n"); out.flush(); String stat = in.readLine(); int count = Integer.parseInt(stat.split(" ")[1]); List<String> contents = new ArrayList<>(); for (int i = 1; i <= count; i++) { out.write("RETR " + i + "\r\n"); out.flush(); StringBuilder sb = new StringBuilder(); String line; while ((line = in.readLine()) != null) { if (line.equals(".")) break; sb.append(line).append("\n"); } contents.add(sb.toString()); } out.write("QUIT\r\n"); out.flush(); socket.close(); return contents; } private void check(String response) throws IOException { if (!response.startsWith("+OK")) throw new IOException("unexpected: " + response); }

check方法会在收到-ERR时抛出异常,避免继续发后续命令造成死等。RETR的结束标记是单独一个点,Java的readLine()会自动剥离\r\n,所以用line.equals(".")判断是准确的。如果邮件正文很大会有内存问题,课程设计不用处理,但可以在更复杂的情况下用文件流写盘,这里只是为了跑通流程。

4.3 SMTP发信与管理员群发

SMTP发信类似,区别是发信前要发送HELO,然后依次MAIL FROM、多个RCPT TO,最后进入DATA。管理员群发时,需要一个一个RCPT TO,服务端会对每个地址做是否存在的校验;如果某个地址无效,可以跳过或者让管理员选择继续。

public void sendMail(List<String> rcptList, String from, String subject, String content) throws IOException { Socket socket = new Socket(host, 2525); BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); check(in.readLine()); out.write("HELO client\r\n"); out.flush(); check(in.readLine()); out.write("MAIL FROM:<" + from + ">\r\n"); out.flush(); check(in.readLine()); for (String rcpt : rcptList) { out.write("RCPT TO:<" + rcpt + ">\r\n"); out.flush(); String resp = in.readLine(); if (!resp.startsWith("250")) { // 跳过无效地址 continue; } } out.write("DATA\r\n"); out.flush(); check(in.readLine()); out.write("Subject: " + subject + "\r\n"); out.write("From: " + from + "\r\n"); out.write("\r\n"); out.write(content); out.write("\r\n.\r\n"); out.flush(); check(in.readLine()); out.write("QUIT\r\n"); out.flush(); }

注意RCPT允许部分失败,所以不要遇到一个550就全部中断。群发几百人的场景下,逐条发送RCPT在延迟上会略高,但在课程设计范围内没有问题。如果追求效率,可以在服务端增加一个BCAST扩展命令,只是那样就不符合标准SMTP了,不建议。

4.4 注册与管理接口的HTTP封装

客户端点“注册”按钮时,并不直接走POP3/SMTP,而是请求服务端的控制端口。用HttpURLConnection将用户名和密码以JSON格式POST过去,服务端返回200400。管理员登录成功后,服务端返回一个token,客户端保存它,后续的删除用户、群发请求都带着这个token。

接口方法参数返回
/registerPOSTusername, password200/400
/admin/list_usersGET-用户列表JSON
/admin/delete_userPOSTusername, token200/403
/admin/broadcastPOSTsubject, content, token202已排队

/admin/broadcast不需要客户端自己去循环发SMTP,而是让服务端在后台线程遍历所有普通用户地址发送。这样安卓端只需要发一次请求,不会因为用户数量多而超时。服务端的后台线程要复用同一个SMTP会话,尽量减少握手次数。

5. 验证完整流程:从telnet到自动化脚本

5.1 先用telnet验证协议基础

服务端启动后,第一件事是用telnet手工测试POP3。命令如下:

$ telnet localhost 1100 +OK POP3 server ready USER alice +OK PASS 123456 +OK alice's maildrop has 2 messages STAT +OK 2 389 RETR 1 +OK Subject: test hello . QUIT +OK bye

如果RETR后看不到点号结束符,说明你服务器端DATA结尾判断有问题。SMTP可以用telnet localhost 2525模仿,确保MAIL FROMRCPT TODATA之后返回250354。手工测试能快速排除命令解析、响应格式等低级错误。

5.2 用标准库自动化测试

手工测试通过后,用Python标准库的smtplibpoplib写一个端到端脚本。这两个库本身就是成熟的POP3/SMTP客户端,如果它们都连不通,那说明你的服务端有细节没实现。

import smtplib import poplib import requests # 1. 注册用户 r = requests.post('http://localhost:8888/register', json={'username': 'tester', 'password': '123456'}) assert r.status_code == 200 # 2. 发送邮件 s = smtplib.SMTP('localhost', 2525) s.sendmail('tester@local', ['other@local'], 'Subject: greeting\r\n\r\nhello pop3') s.quit() # 3. 登录POP3收信 p = poplib.POP3('localhost', 1100) p.user('tester') p.pass_('123456') assert p.stat()[0] == 1 resp, lines, octets = p.retr(1) assert b'hello pop3' in b'\n'.join(lines) p.quit()

这里sendmail会自动处理SMTP命令序列和DATA段落,而poplib会发送USER/PASS并读取响应。如果你的服务端对命令大小写敏感,标准库默认发大写,也测不出来,但你的手工telnet测试最好用小写试一次。实际上,POP3和SMTP命令是大小写无关的,服务端应该upper()后再匹配。

5.3 三个容易忽略的坑

第一,所有协议行必须以\r\n结尾。Python里写网络服务端时,如果你用wfile.write('+OK\r\n')没问题,但用print\n就会导致部分客户端解析失败。安卓端的Java代码尤其要注意,不要用println

第二,POP3的LISTRETR等多行响应都以.单独成行结束。如果邮件正文中的某一行本来就是.,发送方必须转义成..,接收方收到后再还原。这个逻辑在SMTP的DATA阶段和第2章提到过,同样适用于POP3的响应体。

第三,ThreadingTCPServerdaemon_threads属性要设为True。否则你Ctrl+C结束服务端时,所有被阻塞的客户端线程会阻止程序退出,看起来像“无法关闭”。你可以在构造函数里直接赋值daemon_threads = True(注意是class级别的属性)。

自动化脚本跑通后,整个课程设计就覆盖了注册、发信、收信、过滤和群发的完整链路。剩下的时间可以用来整理协议时序图和写实验报告,重点突出你在状态机和边界条件上的处理。

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

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

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

立即咨询