自己编写邮件服务器和客户端

1. 引言

电子邮件是互联网上最基础也最常用的应用之一。虽然今天我们大多使用现成的邮件服务(如 Gmail、Outlook、企业邮箱),但亲手编写一套邮件服务器和客户端,能让你深入理解邮件从发送到接收的完整链路,掌握 SMTP、POP3/IMAP 等核心协议的工作原理。

本文将从零开始,用 Python 编写一个可运行的邮件服务器和客户端。服务器负责接收和存储邮件,客户端负责发送和收取邮件。我们会先介绍邮件系统的基本架构,再逐步实现服务器端和客户端,最后给出完整的测试流程。

2. 邮件系统基础架构

在动手写代码之前,先了解邮件系统的基本组成。一个完整的邮件系统通常包含以下几个角色:

  • 邮件用户代理(MUA):即邮件客户端,负责撰写、发送和阅读邮件。

  • 邮件传输代理(MTA):即邮件服务器,负责邮件的路由和转发。

  • 邮件投递代理(MDA) :可以把它理解为「邮局里的分拣员」。当 MTA 把邮件送到收件人所在的服务器后,MDA 负责把邮件放进收件人自己的「信箱」里------也就是写入收件人在服务器上的邮箱存储目录。在本文的示例中,服务器收到 DATA 命令后把邮件保存到 mailboxes/bob@example.com/ 目录,这一步就是 MDA 的职责。邮件发送和接收主要依赖以下协议:

  • SMTP(简单邮件传输协议):用于客户端向服务器发送邮件,以及服务器之间转发邮件,默认端口 25。

  • POP3(邮局协议第 3 版):用于客户端从服务器下载邮件,默认端口 110。

  • IMAP(互联网消息访问协议):用于客户端在线管理服务器上的邮件,默认端口 143。

本文的示例将实现一个简化版 SMTP 服务器和一个支持 SMTP 发送、POP3 接收的客户端。

3. 环境准备

在开始之前,请确保你的开发环境满足以下要求:

  • Python 3.8 及以上版本
  • 无需安装任何第三方库,全部使用 Python 标准库实现

你可以先创建一个项目目录,并在其中新建以下文件:

text 复制代码
mail_project/
├── server.py      # 邮件服务器
├── client.py      # 邮件客户端
└── mailboxes/     # 邮件存储目录(服务器自动创建)

4. 实现邮件服务器

邮件服务器的核心职责是:监听 SMTP 端口,接收客户端发来的邮件,解析邮件内容,并将邮件保存到对应收件人的邮箱目录中。

4.1 服务器主框架

服务器启动后监听 2525 端口,等待客户端连接。当客户端连上后,服务器先发送欢迎信息,然后进入命令循环,逐行读取客户端发来的命令并作出响应。

下面用类似 telnet 的方式展示服务器启动后,客户端连接并交互的完整流程(S: 表示服务器响应,C: 表示客户端发送的命令):

text 复制代码
$ python server.py
邮件服务器已启动,监听端口 2525

# 客户端连接
S: 220 Welcome to My Mail Server

# 客户端问候服务器,开始会话
C: HELO alice
S: 250 Hello

# 指定发件人地址
C: MAIL FROM:<alice@example.com>
S: 250 OK

# 指定收件人地址
C: RCPT TO:<bob@example.com>
S: 250 OK

# 开始传输邮件正文
C: DATA
S: 354 End with <CR><LF>.<CR><LF>

# 邮件正文内容(以单独一行的 "." 结束)
C: From: alice@example.com
C: To: bob@example.com
C: Subject: 你好,这是测试邮件
C:
C: 这是一封来自自建邮件服务器的测试邮件。
C: .
S: 250 Message accepted

# 结束会话
C: QUIT
S: 221 Bye

4.2 处理 SMTP 命令

从上面的交互流程可以看到,SMTP 会话由一系列命令组成。我们至少需要实现以下命令:

  • HELO / EHLO:客户端问候服务器,开始会话。
  • MAIL FROM:指定发件人地址。
  • RCPT TO:指定收件人地址。
  • DATA:开始传输邮件正文内容。
  • QUIT:结束会话。

服务器收到 DATA 命令后进入数据模式,此时客户端发送的所有行都被当作邮件正文收集起来,直到收到单独一行的 . 才结束。随后服务器把邮件保存到收件人的邮箱目录中,并返回 250 Message accepted。

如果客户端发送了无法识别的命令,服务器会返回 500 Command unrecognized;如果客户端在数据模式下发送了空行或非法内容,服务器也会给出相应的错误响应。

5. 实现邮件客户端

邮件客户端需要完成两件事:通过 SMTP 发送邮件,通过 POP3 接收邮件。下面同样用类似 telnet 的方式展示客户端与服务器之间的交互流程。

5.1 发送邮件

客户端通过 SMTP 协议向服务器发送邮件。整个交互流程如下(S: 表示服务器响应,C: 表示客户端发送的命令):

text 复制代码
# 客户端连接服务器
S: 220 Welcome to My Mail Server

# 客户端问候服务器,开始会话
C: HELO alice
S: 250 Hello

# 指定发件人地址
C: MAIL FROM:<alice@example.com>
S: 250 OK

# 指定收件人地址
C: RCPT TO:<bob@example.com>
S: 250 OK

# 开始传输邮件正文
C: DATA
S: 354 End with <CR><LF>.<CR><LF>

# 邮件正文内容(以单独一行的 "." 结束)
C: From: alice@example.com
C: To: bob@example.com
C: Subject: 你好,这是测试邮件
C:
C: 这是一封来自自建邮件服务器的测试邮件。
C: .
S: 250 Message accepted

# 结束会话
C: QUIT
S: 221 Bye

5.2 接收邮件

客户端通过 POP3 协议从服务器接收邮件。整个交互流程如下(S: 表示服务器响应,C: 表示客户端发送的命令):

text 复制代码
# 客户端连接服务器
S: +OK POP3 server ready

# 客户端发送用户名进行认证
C: USER bob@example.com
S: +OK User accepted

# 客户端发送密码完成认证
C: PASS 123456
S: +OK Mailbox locked and ready

# 客户端列出邮箱中的邮件
C: LIST
S: +OK 1 messages
S: 1 320
S: .

# 客户端读取第 1 封邮件
C: RETR 1
S: +OK message follows
S: From: alice@example.com
S: To: bob@example.com
S: Subject: 你好,这是测试邮件
S:
S: 这是一封来自自建邮件服务器的测试邮件。
S: .

# 客户端结束会话
C: QUIT
S: +OK Bye

6. 扩展:增加 POP3 服务器支持

上面的客户端示例中,接收邮件依赖一个 POP3 服务器。为了让整套系统完全自包含,我们可以在服务器端也实现一个简单的 POP3 服务。POP3 服务器的交互流程如下(S: 表示服务器响应,C: 表示客户端发送的命令):

text 复制代码
# 客户端连接服务器
S: +OK POP3 server ready

# 客户端发送用户名进行认证
C: USER bob@example.com
S: +OK User accepted

# 客户端发送密码完成认证
C: PASS 123456
S: +OK Mailbox locked and ready

# 客户端列出邮箱中的邮件
C: LIST
S: +OK 2 messages
S: 1 320
S: 2 280
S: .

# 客户端读取第 1 封邮件
C: RETR 1
S: +OK message follows
S: From: alice@example.com
S: To: bob@example.com
S: Subject: 你好,这是测试邮件
S:
S: 这是一封来自自建邮件服务器的测试邮件。
S: .

# 客户端读取不存在的邮件
C: RETR 99
S: -ERR no such message

# 客户端结束会话
C: QUIT
S: +OK Bye

POP3 服务器需要实现的核心命令包括:

  • USER <用户名>:客户端发送用户名进行认证。
  • PASS <密码>:客户端发送密码完成认证。
  • LIST:列出邮箱中的邮件编号和大小。
  • RETR <编号>:读取指定编号的邮件内容。
  • QUIT:结束会话。

7. 测试流程

完成以上设计后,按以下步骤进行测试:

  1. 启动服务器:
bash 复制代码
python server.py
  1. 在另一个终端中,用 telnet 或 nc 连接服务器,模拟客户端发送邮件:
bash 复制代码
telnet 127.0.0.1 2525

连接后,按照第 4 节展示的交互流程,依次输入 HELO、MAIL FROM、RCPT TO、DATA、邮件正文和 .,最后输入 QUIT 结束会话。

  1. 查看服务器端日志,确认邮件已保存到 mailboxes/bob@example.com/ 目录。

  2. 在另一个终端中,用 telnet 或 nc 连接 POP3 服务器,模拟客户端接收邮件:

bash 复制代码
telnet 127.0.0.1 1110

连接后,按照第 5 节展示的交互流程,依次输入 USER、PASS、LIST、RETR 1,最后输入 QUIT 结束会话。

如果一切正常,你应该能在控制台看到刚才发送的邮件内容。

8. 总结与进一步扩展

通过本文的实践,你已经亲手实现了一个简化版的邮件服务器和客户端,理解了 SMTP 和 POP3 协议的核心交互流程。这个项目虽然简单,却涵盖了邮件系统最本质的机制。

如果你想继续深入,可以考虑以下扩展方向:

  • 增加 IMAP 协议支持,实现邮件的在线管理和文件夹操作。
  • 加入 TLS/SSL 加密,保障邮件传输安全。
  • 实现邮件附件(MIME 多部分)的解析和发送。
  • 增加用户认证和访问控制,防止未授权访问。
  • 将邮件存储改为数据库(如 SQLite),便于检索和管理。

邮件系统是一个庞大而精妙的工程,希望本文能成为你探索网络协议和系统编程的起点。

9. 搭建实用的邮件服务器确实不简单

需要安装 sendmail、dovocot等软件,需要申请互联网IP,需要申请DNS域名,需要申请证书

DNS申请原理,参考 https://blog.csdn.net/ggaofengg/article/details/164187662

免费证书也是可以申请的,但前提是需要有dns域名。

自己签发证书,可以没有域名,但其它邮件服务器不认,你的邮件会被当成垃圾邮件。

搭建邮件服务器确实曾以"配置复杂、坑多难调"著称,尤其是传统的 Sendmail 或 Postfix 组合,需要手动处理 DNS 记录、SSL 证书、反垃圾邮件策略等,稍有不慎就会导致邮件被拒收或进入垃圾箱。

不过,随着技术演进,现在已有多种‌极简方案‌能大幅降低门槛,甚至实现"5分钟部署"。以下是针对不同场景的简化搭建思路:

🚀 方案一:极致轻量化个人邮箱(推荐个人/小团队)

如果你只是想要一个专属域名的邮箱,且服务器配置较低,‌PMail‌ 是目前最简化的选择之一。

核心优势‌:

极简部署‌:基于 Go 语言开发,二进制文件仅 15MB,内存占用不到 10MB,1核512M内存的 VPS 即可流畅运行。

一键启动‌:支持 Docker 或二进制直接运行,无需复杂的依赖安装。

自动 SSL‌:内置 ACME 协议,自动申请和更新 Let's Encrypt 证书,无需手动配置 HTTPS/SMTPS。

Web 向导配置‌:提供图形化初始化界面,引导完成域名、数据库和管理员账户设置。

部署示例‌:

docker run -p 25:25 -p 80:80 -p 443:443 -p 587:587 -p 993:993 -p 995:995

-v $(pwd)/config:/work/config ghcr.io/jinnrry/pmail:latest

启动后访问 Web 界面即可完成剩余配置,支持 DKIM/SPF 自动检测,确保高送达率。

我试了一下

sudo k3s ctr run --rm --net-host

--env setup_port=8008

--env http_proxy=http://127.0.0.1:1

--env https_proxy=http://127.0.0.1:1

--env no_proxy=127.0.0.1,localhost

--mount type=bind,src=/opt/pmail-config,dst=/work/config,options=rbind:rw

ghcr.io/jinnrry/pmail:latest pmail-server

用这种方式启动的容器,不能用kubectl命令,也不能用crictl命令查询和删除,crictl命令和docker命令比较接近,但它只能看到kubectl启动的pod中的容器

只能用最原始的ctr命令删除 sudo k3s ctr tasks kill pmail-server ; sleep 3; sudo k3s ctr c rm pmail-server

要点:

--net-host:host 网络直通端口

--env setup_port=8008:bootstrap 界面用 8008 端口

http_proxy=127.0.0.1:1:让 ip-api.com 那个无超时请求立刻连接失败,跳过卡死

挂载 /work/config(配置和 sqlite 数据库 pmail.db 都在这里,持久化)

Web 管理界面:浏览器打开 http://10.137.239.107:8008/

注意:这台机器上 8008 是我们自己起的容器。如果是正式部署 ,建议用 Kubernetes Deployment + Service(NodePort) 的方式,避免用 --net-host 直接占用宿主机端口。

二、初始化(第一次运行)

我通过 API 完整走完了 setup 向导,等价于你在浏览器里点一遍:

步骤 配置

数据库 sqlite → ./config/pmail.db

管理员 admin / Admin12345

域名 test.local

SSL 自签名证书(/opt/pmail-config/ssl/)+ 生成的 DKIM 密钥

配置文件在 /opt/pmail-config/config.json,其中 httpPort:8008(避开 traefik)、httpsEnabled:2(只用 HTTP)。

三、测试结果(全部通过)

当前运行的服务端口:8008(Web)、25(SMTP)、465(SMTP+SSL)、587(SMTP+STARTTLS)、110(POP3)、995(POP3+SSL)、993(IMAP+SSL)

测试项 结果

Web 界面 ✅ 200,正常返回前端

API ping ✅ pong

登录(admin/test2) ✅ 返回账户+域名信息

SMTP 发信 → IMAP 收信 ✅ 外部域名发件投递到 INBOX,主题/收发件人正确

SMTP SSL 465 认证登录 ✅

SMTP STARTTLS 587 ✅

POP3 110 ✅ 能列出邮件

Web 收件箱 API ✅ 显示邮件列表

四、一个重要的使用说明(测试时踩到的)

PMail 有反伪造检测:如果发件域名是本地配置的域名(如 test.local)且 SPF 校验失败,邮件会被标记为"伪造"投到"已删除"文件夹。我最初用 admin@test.local → test2@test.local 测试,邮件就进了 Deleted 而不是收件箱------这是正常的邮件安全行为,不是 bug。

本地测试:用非本地域名发件(如 someone@example.com → test2@test.local),验证通过 ✅

真实使用:配置好域名的 SPF/DKIM DNS 记录后,本地用户互发不会被误判

五、邮件客户端配置(真实使用)

IMAP:imap.test.local,端口 993 (SSL)

SMTP:smtp.test.local,端口 465 (SSL) 或 587 (STARTTLS)

POP3:pop.test.local,端口 110/995

当前容器还在运行,你可以直接打开 http://10.137.239.107:8008/ 用 admin/Admin12345 登录体验。需要我帮你把它整理成一个正式的 K8s Deployment YAML,或者把 test.local 换成你真实的域名重新配置吗?

echo "=== create test2 ==="

curl --noproxy '*' -s -b /tmp/pmail.cookies -X POST http://127.0.0.1:8008/api/user/create -H 'Content-Type: application/json' -d '{"username":"test2","password":"Test2123","account":"test2"}'

echo; echo "=== user list ===";

curl --noproxy '*' -s -b /tmp/pmail.cookies -X POST http://127.0.0.1:8008/api/user/list -H 'Content-Type: application/json' -d '{"current_page":1,"page_size":10}'|jq

python3 << 'EOF'

import sqlite3

con = sqlite3.connect("/opt/pmail-config/pmail.db")

cur = con.cursor()

cur.execute("SELECT name FROM sqlite_master WHERE type='table'")

print("tables", r\[0 for r in cur.fetchall()])

for t in "email","email_queue","mail_queue","send_queue","email_send","user_mail":

try:

cur.execute(f"SELECT count(*) FROM {t}")

print(f"{t} count:", cur.fetchone()0)

except Exception as e:

pass

con.close()

EOF

可以直接查询数据库,不需要密码。这就是sqllite的安全模型,依赖操作系统对这个db文件的权限

sqlite3 /opt/pmail-config/pmail.db "SELECT id,account,name,is_admin FROM user;"

相关推荐
java1234_小锋1 小时前
【技术专题】Mysql8 数据库 - Mysql8 添加,更新,删除数据
数据库·mysql
PaperData1 小时前
2015-2024年各省绿色贸易数据
数据库
小羊没烦恼!1 小时前
系统内部模块(子系统)之间的耦合以及模块(子系统)划分
java·开发语言·前端·数据库·算法·c#
麦壳饼2 小时前
深入探讨:SonnetDB 的文件格式与存储布局
数据库·sonnetdb
for_ever_love__2 小时前
Redis 持久化讲透:RDB、AOF 与混合持久化怎么选
java·数据库·redis·持久化·aof·区别·rdb
l1t2 小时前
测试bicdb 20261007提交的0.2版功能
数据库
内存漫游3 小时前
锁与死锁——数据库为什么会发生事务等待?
数据库
赛博守夜人3 小时前
跨境业务与出海合规之八:面向海外合规监管突击搜查(Dawn Raid)的信息安全应急响应(IR)与合规取证
大数据·数据库
冰暮流星3 小时前
mysql之索引创建,查看,删除
数据库·mysql