简介:基于Java技术栈的Web邮件系统开源简化项目,适合初级至中级Java开发者及有前端基础的程序员,用于学习邮件客户端的完整开发流程。压缩包共237个文件,体积约2.4MB,以Java源码、class文件、HTML页面和GIF演示图为主,另有JS/CSS、JAR依赖及XML/properties配置,目录清晰。项目覆盖邮件收发解析、地址管理、用户管理等模块,关键类包括EmailManage、EmailAction、ParseMimeMessage、AddressAction,综合运用JavaMail、ORM映射与安全框架,可作为理解SMTP/IMAP协议落地和Java Web分层设计的参考。已有108人学习下载,体量精炼,适合直接阅读源码快速建立邮件系统认知并二次扩展。
1. MeyboMail Web:越精简越能看清 Java 邮件系统的骨架
做 Java Web 开发的人,多少都动过自己写邮件客户端的念头,但一查资料就被 SMTP、IMAP、MIME、JavaMail 这些名词劝退。MeyboMail Web 开源简化版的价值在于,它把完整邮件系统的核心类压缩到很小的集合里:EmailManage、EmailAction、ParseMimeMessage、AddressAction、UserManage、XMLTool。每个类名就是职责,合起来就是一条从浏览器登录到收信解析、联系人管理的闭环。对初级和中级 Java 开发者来说,这比啃官方文档直观得多;对没碰过协议层的业务开发来说,它是能快速看懂邮件服务如何与 Web 层、数据库协作的标本。下文按类拆协议处理、请求流转、部署方式和踩坑点,让你拿到的不是一堆 class 文件,而是能复现的技术链路。
2. 先看邮件核心:ParseMimeMessage 与 JavaMail 的解析链路
2.1 MIME 嵌套结构:为什么要用递归而不是 for 循环
HTTP 报文是「请求行 + Header + Body」,边界清晰;MIME 邮件麻烦在 Body 本身是嵌套的。一封带 HTML 正文和附件的邮件,顶层是 multipart/mixed,里面又套了 multipart/alternative(text/plain 与 text/html 二选一)和 application/octet-stream(附件)。嵌得越深,越容易把解析逻辑写成「只处理一层就返回」的半成品。
MeyboMail 里的 ParseMimeMessage 这个类,职责就是把 javax.mail.Message 转成内部可存储、可展示的结构。常见做法是写一个递归方法,遇到 Multipart 就继续往下拆,直到拿到文本或附件:
private void parsePart(BodyPart part, MailContent content) throws Exception { String disposition = part.getDisposition(); if (Part.ATTACHMENT.equalsIgnoreCase(disposition) || Part.INLINE.equalsIgnoreCase(disposition)) { content.getAttachments().add(saveAttachment(part)); return; } Object body = part.getContent(); if (body instanceof Multipart) { Multipart mp = (Multipart) body; for (int i = 0; i < mp.getCount(); i++) { parsePart(mp.getBodyPart(i), content); } } else if (body instanceof String) { content.setText(part.getContentType(), (String) body); } else { content.getAttachments().add(saveAttachment(part)); } }这段逻辑里有三个关键判断,写的时候特别容易漏。第一,disposition为 null 的 part 不能直接当附件,它可能是 multipart/related 里的内嵌图片引用,也可能是正文的一部分,按附件处理会把 HTML 邮件里 base64 的 CID 图片全存成乱码文件。第二,body instanceof Multipart说明还有子层,必须递归进去,很多简化版在这里只做一层 for 循环,遇到「附件里套转发邮件」的场景就直接解析失败。第三,body instanceof String才是纯文本正文,InputStream 等类型按附件落盘是保守做法,至少不丢内容。
参数说明:saveAttachment里建议用part.getFileName()拿文件名,用part.getSize()拿大小。但注意getFileName()返回的是 MIME 编码后的字符串,形如=?GBK?B?xxxxxx?=,要用MimeUtility.decodeText()解码,这个细节后面避坑章单独展开。
2.2 IMAP 收件链路:Session、Store、Folder 三层怎么配合
解析之前必须先连上邮件服务器,这是所有收件逻辑的前提。JavaMail 的 IMAP 收件链路是固定的三层:Session 管配置,Store 管连接,Folder 管目录,顺序不能乱。简化版通常把服务器参数放在 Config 里,运行时从 XML 读取,代码大致是这样:
Properties props = new Properties(); props.put("mail.store.protocol", "imap"); props.put("mail.imap.host", Config.get("imap.host", "imap.example.com")); props.put("mail.imap.port", Config.get("imap.port", "993")); props.put("mail.imap.ssl.enable", "true"); props.put("mail.imap.connectiontimeout", "15000"); props.put("mail.imap.timeout", "15000"); Session session = Session.getInstance(props); session.setDebug(Config.getBoolean("imap.debug", false)); Store store = session.getStore("imap"); store.connect(Config.get("mail.user"), Config.get("mail.password")); Folder inbox = store.getFolder("INBOX"); inbox.open(Folder.READ_ONLY); int total = inbox.getMessageCount(); Message[] messages = inbox.getMessages(Math.max(1, total - 19), total); for (Message message : messages) { MailContent content = parseMimeMessage.parse(message); emailManage.save(message, content); }这段代码有几个容易出问题的点,主要集中在参数和连接管理上。第一次对接真实邮箱如果不注意这些细节,常见的表现是连不上、读超时、误拉全量邮件,这些问题在日志里看着都不像同一个原因,实际排查起来很花时间。下面逐一说清楚,都是实测过的场景。
getMessages(start, end)的 start 和 end 是邮件在文件夹里的顺序号,不是数据库自增 id,getMessages(1, 20)拉的是最早 20 封,getMessages(total - 19, total)才是最近 20 封。第一次对接时千万不要用getMessages()全量拉取,生产邮箱上千封邮件,全量解析加写库,Tomcat 默认的 200 个工作线程很快会被大附件请求占满。
connectiontimeout和timeout两个参数很多人不设,结果是邮件服务器假死时线程卡在 socket 读上,直到 TCP 默认超时才抛异常。我习惯把两个都设成 15000,单位是毫秒,再配上mail.imap.writetimeout,三层超时都控制住,至少保证一个请求线程不会无限期挂在那。
Folder.READ_ONLY和READ_WRITE的区别也要说清:只读打开不会改服务器上的已读标记,适合做展示;读写打开才能调message.setFlag(Flags.Flag.SEEN, true)。简化版如果只做收件箱展示,用只读就够了;如果做了「已读/未读」状态同步,必须用 READ_WRITE,否则前端标了已读刷新又变回未读,用户第一反应就是功能坏了。
2.3 EmailManage:解析结果和存储的衔接点
EmailManage 的职责在简化版里非常具体:判断邮件是否已存在、组装存储对象、写入数据库。去重是最容易漏的逻辑,IMAP 不保证两次getMessages()返回的是同一批对象,定时拉取不做去重,收件箱必然出现重复邮件。标准做法是拿 Message-ID 头做唯一键:
String messageId = message.getHeader("Message-ID") != null ? message.getHeader("Message-ID")[0] : UUID.nameUUIDFromBytes( (message.getSentDate() + message.getSubject()).getBytes()).toString();参数说明:Message-ID 不是每个客户端都会生成,有的邮件客户端缺失这个头,直接用发件时间加主题拼一个兜底 ID。上面用UUID.nameUUIDFromBytes生成的是可重复的伪随机 ID,同一封邮件重试时算出的 ID 一致;不能用UUID.randomUUID(),那东西每次都不一样,去重直接失效。
存储结构上,建议至少拆两张表:邮件主表存 message_id、subject、from、to、sent_date,邮件详情表存解析后的正文和附件路径。简化版如果只建一张表,后面加「按附件类型搜索」功能时只能全表扫描,性能会很尴尬。class 列表里没有表结构定义,这里是这类邮件项目最通用的建表思路,不是它实际用的 DDL。
3. Web 层请求流转:EmailAction、AddressAction 与 UserManage 的分工
3.1 从 Action 后缀看架构:不是标准 MVC 但职责清晰
拿到 class 文件先别急着反编译,光看命名就能猜出项目架构。MeyboMail 简化版里有 EmailAction、AddressAction、AddressGroupAction,同时有 UserManage、EmailManage,说明它走的是「Action 处理请求、Manage 处理业务」的轻量分层,比主流 Spring Boot 那种 Controller-Service-Mapper 少一层。对教学项目来说少一层反而好理解,因为每个类的名字就是它该做的事。
如果只有 class 没有源码,最快的摸底方式是 javap,JDK 自带,不用装额外工具:
javap -p EmailAction.class-p参数显示所有方法和字段,输出里能看到doGet、doPost这些入口方法。比javap -c看字节码高效得多,因为你要的是方法名和 URL 的映射关系,不是具体逻辑实现。
从这类邮件项目的常见写法看,EmailAction 主要围绕四个操作:收件箱列表、读信详情、写信发送、删除或移动邮件。对应 URL 大致是:
GET /mail/list GET /mail/read?id=123 POST /mail/send POST /mail/delete用 Servlet 实现时就是doGet里根据request.getParameter("action")做 if/else 分发;用 Spring Boot 就是 @GetMapping 注解直接绑。MeyboMail 是较早的开源项目,更可能是前一种写法。迁移到 Spring Boot 的成本不高,把每个 if 分支拆成一个 @RequestMapping 方法就行,但要注意 Servlet 里的request.getParameter取值方式和 Spring MVC 的 @RequestParam 略有差异,迁移完要重测一遍所有请求参数,尤其是中文参数和特殊字符,否则很容易在参数编码上翻车。
3.2 地址簿模块:EmailAddress 与 AddressGroup 的多对多模型
地址簿看起来是邮件系统里最简单的部分,但它是 Java Web 面试里「多对多关系」最经典的落地场景。EmailAddress 是联系人实体,字段通常包含 id、user_id、display_name、email、created_at;AddressGroup 是分组实体,字段是 id、user_id、group_name。中间需要一张关联表把两边串起来:
CREATE TABLE email_address ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, display_name VARCHAR(64), email VARCHAR(128) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_user_email (user_id, email) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE address_group ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, group_name VARCHAR(64) NOT NULL, UNIQUE KEY uk_user_group (user_id, group_name) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; CREATE TABLE address_group_mapping ( address_id INT NOT NULL, group_id INT NOT NULL, PRIMARY KEY (address_id, group_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;这套表结构看起来简单,真正落到项目里却有三点值得细说,都是设计邮件地址簿时容易被忽略的约束条件。如果你按网上常见的单表方案做,后面加分组功能时大概率要回头补表、加关联或者改查询逻辑,数据一多迁移成本很高。下面把这三点展开讲。
第一,user_id必须出现在两张主表里,查询时全程带上,这是最简单的数据隔离方式,不然 A 用户能看到 B 用户的联系人。第二,UNIQUE KEY uk_user_group防止同一用户建两个同名分组,这个约束在应用层做会漏,必须在数据库层兜底。第三,address_group_mapping用双主键而不是自增 id,多对多关联表不需要业务主键,双主键天然去重,重复往组里加同一个联系人直接报主键冲突,省得再写判断逻辑。
AddressAction 和 AddressGroupAction 的职责划分建议是:AddressAction 管联系人的增删改查,AddressGroupAction 管组的增删改查,而「往组里加联系人」这个操作放 AddressGroupAction 更合理,因为它的入参是 group_id 加 address_id,操作的是 mapping 表,跟联系人本身的字段无关。
3.3 用户会话:UserManage 与 Config 的协同
UserManage 在早期 Servlet 项目里通常就管登录校验和 Session 管理。登录成功后的标准动作是request.getSession().setAttribute("user", user),后续每个请求从 Session 取出用户,取不到就重定向到登录页。注意 Session 超时时间,Tomcat 默认 30 分钟,邮件系统的用户习惯长时间挂机,把 session-timeout 配到 60 分钟是常见做法,否则用户写了半天邮件点发送时发现 Session 过期被踢回登录页,草稿还得重写,这种体验基本劝退。
Config 这个类在这个项目里担当配置中心角色,不是 Spring Cloud Config,而是一个读 XML 或 properties 的普通类。XMLTool 这个类名透露了配置存储格式大概率是 XML,它至少应该提供三个方法:
public class XMLTool { public static String get(String key) { ... } public static String get(String key, String defaultValue) { ... } public static boolean getBoolean(String key, boolean defaultValue) { ... } }参数说明:所有读取都带默认值,这是配置类的第一原则。邮件服务器 IP 偶尔写错、端口漏配,如果代码里全是Config.get("xxx")然后直接 NPE,排错成本会非常高。带默认值至少能把「配置不存在」和「连接失败」区分开;更稳的写法是启动时做一次必填项校验,缺失直接 fail-fast,避免服务启动成功但功能全挂的黑匣子状态。
4. 部署与启动:把 class 文件变成能用的 Web 邮件服务
4.1 环境准备:JDK、Tomcat、MySQL 的版本搭配
先确认运行环境。MeyboMail 是 Java 项目,依赖 Servlet 容器也就是 Tomcat 或 Jetty,数据库用 MySQL 或 PostgreSQL,邮件协议走 JavaMail。如果拿到的是 .war 包,直接丢进 Tomcat 的 webapps 目录启动即可;如果拿到的是 class 文件加配置文件,需要自己重新打成 war 包,这个下面会说。
我建议的版本组合是 JDK 8 + Tomcat 8.5/9 + MySQL 5.7。这个组合兼容性最好,JDK 8 自带的 javax.mail 相关能力不会碰到模块化问题,MySQL 5.7 的驱动 mysql-connector-java 下载也方便。如果你本机是 JDK 11 以上,要注意 javax.xml.bind 已经不在 JDK 里了,XMLTool 如果用了 JAXB 解析 XML,需要额外引入两个依赖:
<dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>org.glassfish.jaxb</groupId> <artifactId>jaxb-runtime</artifactId> <version>2.3.3</version> </dependency>参数说明:javax.mail 在 JDK 9 之后也被拆出去了,如果项目里直接用 JavaMail API,需要额外引入com.sun.mail:javax.mail:1.6.2。与其跟模块系统纠缠半天,不如直接用 JDK 8 跑这类遗留项目,省下的时间够调好几个 bug。
打成 war 包时注意 web.xml 里的 servlet 映射路径。MeyboMail 如果用的是 Servlet 写法,web.xml 里会有一堆<servlet-mapping>,部署到 Tomcat 后应用路径默认是 war 包名,比如meybomail.war对应http://localhost:8080/meybomail/。如果映射里写死了根路径/,多个应用会冲突,这时要改 war 包名或调 server.xml 的 Context 路径。
4.2 配置文件与数据库:XMLTool 背后那张 config.xml
MeyboMail 的配置天然落在 XML 里,典型结构大致长这样:
<?xml version="1.0" encoding="UTF-8"?> <config> <database> <jdbc url="jdbc:mysql://localhost:3306/meybomail?useSSL=false&characterEncoding=utf8"/> <username>mail_user</username> <password>change_me</password> </database> <mail> <imap host="imap.example.com" port="993" ssl="true"/> <smtp host="smtp.example.com" port="465" ssl="true"/> <user>test@example.com</user> <password>mail_password</password> </mail> <app> <upload-dir>/data/meybomail/attachments</upload-dir> <session-timeout>60</session-timeout> </app> </config>这个文件有两个 XML 转义坑,第一次配必踩。第一,JDBC URL 里的&必须写成&,否则 XML 解析直接失败,Tomcat 日志报org.xml.sax.SAXParseException。第二,密码里有<、>等特殊字符要转义,否则解析出来的字符串会被截断。
XMLTool 解析这种文件的逻辑不复杂,DOM 遍历加getElementsByTagName就能拿到每个节点值。要注意它读的是 classpath 还是绝对路径:建议配绝对路径,因为 classpath 方式在 Tomcat 重启后可能读到旧的缓存配置,改了半天配置不生效,最后发现读的是WEB-INF/classes下的旧文件,这类问题在邮件项目里相当常见。
数据库初始化时注意字符集:
CREATE DATABASE meybomail DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;说明:utf8mb4 不是 utf8,MySQL 的 utf8 最大只支持 3 字节,存 emoji 和部分生僻字会报Incorrect string value。邮件正文里出现 emoji 的概率非常高,用 utf8 是给自己埋雷。建表语句里所有 VARCHAR 字段的 COLLATE 也要跟库一致,否则 JOIN 时字符集不一致会走不了索引。
4.3 启动验证:怎么确认服务真的在干活
启动 Tomcat 之后不要急着登录,按顺序做三个检查。第一,看 catalina.out 日志里有没有Deployment of web application archive ... has finished,这句话表示应用部署完成。第二,直接访问http://localhost:8080/meybomail/,看能否返回登录页。第三,用 curl 模拟一次登录请求,确认 Session 机制正常:
curl -i -c cookies.txt \ -d "username=test@example.com&password=123456" \ http://localhost:8080/meybomail/login返回 302 且 Location 指向收件箱页,说明登录成功。再用-b cookies.txt带着 Cookie 访问收件箱列表接口,拿到 200 说明整条链路通了。-c是把服务端返回的 Set-Cookie 存到文件,-b是请求时带上文件里的 Cookie,这两个参数组合是排查登录类问题的基本姿势。
验证收件功能时,建议用一个测试邮箱发两封邮件:一封纯文本,一封带附件,确认 ParseMimeMessage 能把两类邮件都解析入库。附件目录的写权限是最容易被忽略的——Tomcat 进程如果以 tomcat 用户跑,upload-dir 建在/root下,运行时必然抛AccessDeniedException,日志里还只给一句笼统的FileNotFoundException,排查半天才发现是权限问题。
5. 避坑记录:邮件系统最容易翻车的三个场景
5.1 正文乱码:不是全局设 UTF-8 就完事
现象:收件箱列表里主题显示正常,点开详情后正文全是「锟斤拷」或问号。
原因:邮件正文的编码由 MIME 头里的 charset 决定,常见的有 UTF-8、GB2312、ISO-8859-1。很多简化版代码解析时直接用part.getContent().toString()拿字符串,JavaMail 的 getContent 已经按 charset 解码过,但部分实现又在 Web 层做了一次new String(body.getBytes(), "UTF-8"),双重转码把 UTF-8 字节流按平台默认编码重新解释,必然乱码。
解决:读取正文后不要做任何手动编码转换,直接part.getContent().toString()。只有拿到 InputStream 时才需要手动指定 charset,比如这样:
BufferedReader reader = new BufferedReader( new InputStreamReader(part.getInputStream(), extractCharset(part.getContentType())) );这里extractCharset是从 Content-Type 头里把 charset 抠出来,常见实现是用正则匹配:
private String extractCharset(String contentType) { if (contentType == null) return "UTF-8"; Matcher m = Pattern.compile("charset=[\"']?([^;\"']+)", Pattern.CASE_INSENSITIVE) .matcher(contentType); return m.find() ? m.group(1).trim() : "UTF-8"; }邮件乱码算是邮件系统里的玄学问题,但只要你坚持「解析层不手动转码、展示层不猜编码」两条原则,乱码能减少九成。出现乱码时先查原始 MIME 头里的 charset,再查代码里有几处编码转换,基本能找到根因。
5.2 大附件拖死线程池
现象:有人发了一封带 20MB 附件的邮件后,整个 Web 应用响应变慢,Tomcat 线程数飙升,其他用户登录都卡住。
原因:收件解析是在请求线程里同步执行的,读 InputStream 需要整封邮件读完才返回。20MB 附件在慢速网络下可能耗时几分钟,期间该线程一直占着 Tomcat 工作线程。简化版如果定时拉取时一次拉几十封,每封都带大附件,线程池很快被打满。
解决:把附件解析改成异步任务,并给单封邮件大小设上限。用 ExecutorService 提交收件任务是常见改法:
ExecutorService mailExecutor = Executors.newFixedThreadPool(4); mailExecutor.submit(() -> { try { MailContent content = parseMimeMessage(message); emailManage.save(message, content); } catch (Exception e) { log.error("parse mail failed, messageNumber={}", message.getMessageNumber(), e); } });参数说明:固定线程池大小 4 是保守值,具体看服务器 CPU 核数和邮箱日均邮件量。另外在 IMAP 拉取时用 FetchProfile 只取头信息,正文和附件在用户点击查看详情时再懒加载,这是大邮件系统的标准做法。简化版不一定要做这么彻底,但至少要把列表展示和详情解析拆成两个接口,不要在列表页就把每封邮件的正文都解析出来。
补充一个判断标准,方便你自查:如果收件箱列表接口的响应时间超过 3 秒,去数据库看邮件详情表,发现大部分邮件的正文字段都有值,说明列表页在同步解析正文,这就是性能瓶颈所在。把同步改异步后这个数字会明显降下来。
5.3 附件文件名乱码与路径穿越
现象:从 QQ 邮箱或网易邮箱收的附件,下载时文件名变成=?GBK?B?xxxxxx?=一串;部分异常邮件里文件名带../,保存时直接穿透到其他目录。
原因:part.getFileName()返回的是 MIME 编码头,形如=?UTF-8?B?5paH5qGj5LqL5Lu2LnR4dA==?=,不解码直接存库就是这样。路径穿越是因为保存附件时直接把文件名拼进路径:
String path = uploadDir + "/" + fileName; // 危险写法解决:文件名先解码,再做路径规范化并校验前缀:
String realName = MimeUtility.decodeText(part.getFileName()); String safeName = new File(realName).getName(); Path target = Paths.get(uploadDir).resolve(safeName).normalize(); if (!target.startsWith(Paths.get(uploadDir).toAbsolutePath())) { throw new SecurityException("invalid attachment path"); }参数说明:new File(realName).getName()会剥掉路径部分,把../../etc/passwd变成passwd,这是最省事的一层防护。normalize()后再用 startsWith 校验是双保险,因为某些系统上 file 名的编码经过两次解码后可能又拼出路径。附件文件名里的中文保留原样,前端下载时由浏览器做 URL 编码,后端不要手动拼带中文的下载链接,否则 Tomcat 默认的 ISO-8859-1 编码会让文件名再次乱码。
6. 进阶:把简化版改造成可追踪的邮件处理管道
6.1 给邮件加状态字段
简化版的收件逻辑是「拉取 → 解析 → 入库」三步走完,中间任何一步失败,邮件就静默丢了。我拿到这类项目做改造时,第一件事是给邮件表加 status 字段,把黑匣子变成可见流程:
ALTER TABLE email ADD COLUMN status TINYINT DEFAULT 0 COMMENT '0待解析 1解析成功 2解析失败 3附件下载中';拉取动作只负责把 Message-ID 和邮件编号先落库,状态置 0,由定时任务扫描 status=0 的记录去解析。这样即使解析进程崩溃,重启后还能从数据库里捡回没完成的任务。
6.2 失败重试与告警
有了状态字段,还要一张失败记录表。重试三次仍失败的邮件标记为 status=2,不再自动重试,转人工处理:
if (retryCount > 3) { mailAlertService.send("parse mail failed permanently: " + messageId); updateStatus(messageId, 2); }从这一步开始,邮件系统不再是黑匣子,每封邮件处于哪个阶段、卡在哪一步,查数据库一眼就能定位。排查问题从「看日志猜」变成「看状态机」,效率完全不一样。
6.3 我的固定验证动作
改造完这套状态机后,我自己有个固定验证流程:发一封同时包含纯文本、HTML、一个 PDF 附件、一个 5MB 大附件的测试邮件,然后在数据库里查这封邮件的 status 变化,确认它依次经过「待解析 → 附件下载中 → 解析成功」。从那以后我每次改邮件解析逻辑,都强制走一遍这个流程,不验证完不合并代码。这个习惯帮我挡住了至少三次正文乱码回归。希望这套排查思路也能帮到你。
本文还有配套的精品资源,点击获取