SpringBoot3+IMAP 企业邮箱入 OA:本地索引、三栏收件箱与业务单据挂邮件
🌐 文档地址:https://ruoyioffice.com
📦 源码1·GitHub:https://github.com/yuqing2026/ruoyi-office
📦 源码2·GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office
📦 源码3·Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬 微信:17156169080(备注「RuoYi Office」)
本文由 RuoYi Office 团队整理,结合实际项目分享企业软件的设计思路与开发经验。
一句话:OA 里「发验证码 / 发待办提醒」用的是系统发信通道;员工每天要处理的是自己的企业邮箱。RuoYi Office 把 IMAP 收信、SMTP 写信做成办公菜单里的三栏客户端,列表走本地索引,正文按需回源。
▲ 连接器用 Jakarta Mail;索引表扛列表和未读;UI 是文件夹 + 邮件列表 + 读信/写信
引言:为什么「系统已经会发邮件」还不够
很多后台脚手架都有邮件模块:配一个 SMTP,就能发重置密码、流程超时提醒。上线以后行政和销售会问另一句话:
「能不能在 OA 里看我的企业邮箱?别再切网页版邮箱,待办和邮件分两处。」
这不是同一件事。
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
如果把员工邮箱做成「每次打开都对 IMAP 全量 LIST」,页面会卡,邮件服务器也会被打满。RuoYi Office 的取舍是:信封信息同步进本地索引,翻页和搜索打数据库;点进一封再 IMAP FETCH 正文和附件。
本文按真实代码写这一层,不讲「怎么申请腾讯企业邮」,只讲接到 OA 之后怎么分层。
一、业务设计:三层拆开
1.1 连接层:只懂协议
ImapSmtpConnector 用 Jakarta Mail 做四件事:测连、列出规范文件夹、增量拉新信、SMTP 发送。它不认识租户套餐,也不渲染 HTML。
测连必须 IMAP、SMTP 都过。只通收信、发信 465 被防火墙拦,上线当天就会被销售投诉「能看不能回」。
连接器内部自己组 Session / Store / Transport,业务服务只传 MailServerConfig(主机、端口、SSL、用户名、密码)。不要在 Controller 里拼 Properties,否则每个接口一套超时和协议开关,排障时对不上。
文件夹别名是连接层的一部分:Sent、Sent Items、Sent Mail 都归到规范名 Sent;Trash、Deleted Items、Bin 归到 Trash。中文文件夹名若没进别名表,同步会跳过该夹——这是配置问题,不是「同步坏了」。
1.2 索引层:列表不回源
表 oa_mail_message_index 存 account、规范文件夹、IMAP UID、Message-ID、主题、发件人、收件人、时间、已读、是否有附件、摘要。草稿可以额外存 body_html,因为草稿往往还没上服务器,或 UID 是本地占位。
文件夹水位在 oa_mail_folder_state:规范名、真实 IMAP 名、last_uid。下次同步只 FETCH 大于 last_uid 的新信。
同步结果写 oa_mail_sync_log,账号上记 last_sync_time / last_error,排障不用翻应用日志。
1.3 产品层:Outlook 式三栏
收件箱页把侧栏收起,主区三列:
FolderPane:账号切换、文件夹、同步、去设置; MessageList:关键字、已读筛选、分页; ReadingPane或内嵌 ComposePane:读信 / 回复 / 写信。
独立「写信」菜单走 /oa/mail/compose,给习惯全屏编辑的人。附件、富文本、选账号,和内嵌写信共用组件。
发送成功后:SMTP 把信交出去,同时尽量在 SENT 索引里看到自己。失败要保留草稿,不要把编辑器里的 HTML 清掉。附件先上传到文件存储,再在 MIME 里挂 ByteArrayDataSource,避免把大文件读进邮件索引字段。
二、配置:模板、策略、账号,不要写死主机名
2.1 服务模板
腾讯企业邮、阿里云邮箱、自建 Docker 邮服,IMAP 主机和端口不一样,但对产品都是「一种 Profile」。oa_mail_provider_profile 存 IMAP/SMTP 主机、端口、SSL、显示名。账号绑定可以引用模板,也可以临时填自定义主机(租户策略允许时)。
▲ 效果:模板是表单不是空表格。演示环境可挂内网 Docker 邮服,生产换成企业邮主机即可
2.2 租户策略
oa_mail_tenant_policy 控制本租户能不能让员工自己绑外部邮箱、分配方式(管理员指定 / 按规则开通)等。私有化场景经常是:IT 统一开通,员工不能随便填 QQ 邮箱当工作邮箱。
2.3 用户账号
oa_mail_account 绑定登录用户与邮箱地址、密码(或授权码)、引用的模板、开通状态。绑定后提供「测试连接」。失败原因写回账号,避免只弹一个「同步失败」。
管理员分配走单独权限 oa:mail:assign,和「自己改密码」分开,避免全员都能给别人开箱。
测连代码把 IMAP 开 INBOX 和 SMTP connect 做成一次事务式检查,缺一不可:
public void testConnection(MailServerConfig config) throws MessagingException {Store store = null;try {store = openStore(config);Folder folder = store.getFolder("INBOX");folder.open(Folder.READ_ONLY);folder.close(false);} finally {closeQuietly(store);}Transport transport = null;try {Session session = createSmtpSession(config);String protocol = Boolean.TRUE.equals(config.getSmtpSsl()) ? "smtps" : "smtp";transport = session.getTransport(protocol);transport.connect(config.getSmtpHost(),config.getSmtpPort() != null ? config.getSmtpPort() : 25,config.getUsername(), config.getPassword());} finally {if (transport != null) {transport.close();}}}
只测 IMAP 的结果是:收件箱能刷,点回复才发现 465 出不去。
三、PC 端:先看三栏读信,再看写信
3.1 收件箱:文件夹 + 列表 + 正文(效果页)
路径:办公 → 邮箱 → 收件箱(/oa/mail/inbox)。左侧是 INBOX / 未读 / 已发送 / 草稿 / 已删除。点一封邮件,右侧读信区出主题、收发件人、HTML 正文、附件下载。读信时会尝试把 IMAP 已读状态写回索引。
▲ 效果:点开一封信后右侧出主题、收发件人、正文,以及回复/转发/转流程
未读是虚拟文件夹:查询仍走 INBOX,只加 seen=false。IMAP 侧「已删除」名称五花八门(Trash / Deleted Items / Bin),连接器用别名映射到规范名 Trash。
3.2 写信:独立页或三栏内嵌
点「写邮件」可在三栏右侧切到 ComposePane,也可进独立写信页。回复会带上 Re:,转发带 Fwd:,并引用原文 HTML。草稿可以先落本地索引(本地 UID),同步后再与服务器草稿夹对齐。
▲ 效果:SMTP 发送前仍是 OA 登录态;附件走同一套文件存储,不把二进制塞进索引表
3.3 账号页(本文最多一张「表」)
邮箱账号设置列出当前用户已绑定的地址、最近同步时间、错误信息,可测连、改授权码。
▲ 唯一偏列表的图:用来证明「一人一账号、失败可回看」,读信效果以上面三栏为准
四、同步引擎:增量 UID,不要每次全量
4.1 一次同步扫四个规范夹
syncAccount 先 listCanonicalFolders,把服务器上的 Sent/Drafts/Trash 别名对上规范名。某个规范夹在服务器不存在就记状态、跳过,不把整个同步判失败。
每个夹调用 syncOneFolder:读 last_uid,只拉新 UID,已存在的 (account, folder, uid) 跳过。成功数和失败信息进同步日志。
private int syncOneFolder(OaMailAccountDO account, MailServerConfig config,MailFolderInfo folderInfo) throws Exception {OaMailFolderStateDO state = folderStateMapper.selectByAccountFolder(account.getId(), folderInfo.getCanonical());long lastUid = state != null && state.getLastUid() != null ? state.getLastUid() : 0L;List<MailFetchedMessage> fetched = imapSmtpConnector.fetchNewMessages(config, folderInfo.getImapName(), lastUid);long maxUid = lastUid;int success = 0;for (MailFetchedMessage msg : fetched) {if (msg.getUid() == null) continue;if (messageIndexMapper.selectByAccountFolderUid(account.getId(), folderInfo.getCanonical(), msg.getUid()) != null) {maxUid = Math.max(maxUid, msg.getUid());continue;}messageIndexMapper.insert(OaMailMessageIndexDO.builder().accountId(account.getId()).folder(folderInfo.getCanonical()).uid(msg.getUid()).subject(msg.getSubject()).fromAddr(msg.getFromAddr()).snippet(msg.getSnippet()).hasAttach(msg.getHasAttach()).seen(msg.getSeen()).build());success++;maxUid = Math.max(maxUid, msg.getUid());}upsertFolderState(account.getId(), folderInfo.getCanonical(),folderInfo.getImapName(), maxUid);return success;}
增量同步的代价是:服务器上被改已读、被移动的旧信,不会在下一次增量里自动追上。产品上用「点开详情回写 seen」补一刀;彻底对齐需要后续 UIDVALIDITY / FLAGS 同步,那是下一阶段的事,本文不装已经做完。
4.2 列表走索引,详情再 FETCH
分页接口直接 selectPage 索引表。详情里:若是本地草稿 UID,用表里的 body_html;否则按账号配置打开 IMAP,按文件夹真实名 + UID 拉正文,附件落 oa_mail_attachment。拉失败时仍返回信封,避免整页白屏。
public OaMailMessageDetailRespVO getMessageDetail(Long id) {OaMailMessageIndexDO index = getOwnedMessage(id);OaMailMessageDetailRespVO resp = BeanUtils.toBean(index, OaMailMessageDetailRespVO.class);if (OaMailFolderNames.isLocalDraftUid(index.getUid())) {resp.setBodyHtml(index.getBodyHtml());resp.setAttachments(toAttachmentVos(attachmentMapper.selectByMessageIndexId(id)));return resp;}MailMessageDetail detail = imapSmtpConnector.fetchMessageDetail(accountService.resolveConfig(account),resolveImapFolder(account.getId(), index.getFolder()),index.getUid());if (detail != null) {resp.setBodyHtml(detail.getBodyHtml());saveAttachments(id, detail.getAttachments());if (Boolean.TRUE.equals(detail.getSeen()) && !Boolean.TRUE.equals(index.getSeen())) {OaMailMessageIndexDO update = new OaMailMessageIndexDO();update.setId(id);update.setSeen(true);messageIndexMapper.updateById(update);}}resp.setAttachments(toAttachmentVos(attachmentMapper.selectByMessageIndexId(id)));return resp;}
4.3 前端三栏如何拼
收件箱挂载时把侧栏折叠,离开再恢复,避免菜单把三栏挤成窄条。选中邮件、点回复,只换右栏组件,不整页跳转。
<div class="flex h-full min-h-0 overflow-hidden rounded-md border"><FolderPane :accounts="accounts" :folder="folder"@sync="handleSync" @compose="startCompose()" /><MessageList :messages="messages" :selected-id="selectedId"@select="openMessage" /><ComposePane v-if="composing" :model-value="composeModel" @sent="afterSent" /><ReadingPane v-else :detail="detail" @reply="startReply('reply')" @bpm="toBpm" /></div>
五、邮件挂业务:先有关联表,再接待办和流程
读信区提供「转待办 / 发起流程」入口,写入 oa_mail_biz_link:biz_type 目前是 TODO 或 BPM,带标题、流程 key 等。
必须说清楚边界:一期先落关联记录,真正创建个人待办、真正 submitProcessInstance 还是扩展点(代码里对两类 type 打了 stub 日志)。这样设计的好处是产品入口和表结构先稳定,不会等 BPM 联调完才让用户看见按钮。
不要把「能点按钮」宣传成「邮件一键变审批单已经闭环」。闭环要等 bizId 回填、从待办能跳回这封邮件。
读信区 @bpm="toBpm" 目前是把关联请求打到 createMailBizLink。产品验收时分两档:
- P0
:关联行能查到,类型、标题、邮件 ID 正确; - P1
:TODO 真插入待办、BPM 真发起实例并回写 bizId。
把 P1 当成 P0,会在客户演示里当场翻车。
六、和系统邮件模块怎么共存
平台仍需要「忘记密码」「流程超时」这种系统 SMTP。那是基础设施/系统管理里的发信账号和模板,发件人往往是 noreply@。
OA 邮箱客户端是另一条线:员工自己的收件箱。两套配置不要共用一张账号表,否则管理员改系统发信密码,会误伤全员 IMAP。RuoYi Office 把它们分在不同菜单:系统「邮箱管理」vs 办公「邮箱 / 收件箱」。
验收时用两封信区分:
-
走流程超时提醒——收件人邮箱里能看到,但 OA 收件箱不一定同步得到(取决于那封信是否进了员工 IMAP); -
用 OA 客户端写给同事——对方 OA 收件箱同步后应出现,已发送里也应有。
如果 2 失败、1 成功,说明系统 SMTP 通、员工 IMAP/SMTP 不通,去查账号模板而不是「邮件模块坏了」。
七、数据结构
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
索引表不存完整 MIME。附件走文件存储,和云盘一样避免把邮件库撑成对象存储。
八、技术亮点
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
九、快速体验
在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
-
打开 办公 → 邮箱 → 邮箱服务模板,看 IMAP/SMTP 是否已有演示模板; - 邮箱账号设置
里确认当前用户已绑定账号,必要时点测试连接; -
进 收件箱,点同步,再点一封有正文的信; -
点写邮件或回复,观察右栏切到写信而不整页刷新; -
若演示邮服未启动,列表可能为空——这恰好说明客户端依赖真实 IMAP,不是假数据。
源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
本地排障顺序:
-
账号页点测试连接——IMAP、SMTP 是否都过; -
同步日志有没有 successCount,last_error是否端口/认证; -
索引表有信封但详情无正文——FETCH 文件夹名是否映射错; -
能收不能发——查 465/587 与 SSL 开关,而不是再同步一次。
相关阅读:系统通知与站内信、Flowable 待办工作台。邮箱客户端补的是「外部世界写进来的信」,待办补的是「系统里待你处理的任务」。
常见问题(FAQ)
OA 里已经有邮件通知,为什么还要 IMAP 客户端?
通知是系统推给你的事件。客户询价、供应商对账仍走企业邮箱。两套入口可以同时存在,不要用通知模块冒充收件箱。
为什么不每次打开都实时 LIST 全部邮件?
可以,但翻页会慢,而且容易触发邮服限流。信封进本地索引后,搜索和未读计数都是数据库查询。正文仍实时 FETCH,保证看到的是服务器最新内容。
授权码和登录密码有什么区别?
多数企业邮要求 IMAP 使用客户端专用授权码,不能用网页登录密码。测连失败时先核对该项,再查 993/465 端口和 SSL 开关。
邮件能直接变成审批单吗?
现在能把邮件和 TODO/BPM 类型写进关联表,并从读信区点入口。真正创建待办、发起流程实例仍是下一阶段。先不要按「已经闭环」去验收。
移动端有邮箱吗?
当前以 PC 三栏为主,UniApp 尚未做对等页。和云盘相反:云盘移动端先做浏览;邮箱移动端要处理 HTML 正文、附件、回复引用,工作量更大,所以后做。
结语
把邮箱塞进 OA,关键不是再画一个「发邮件」按钮,而是承认 系统 SMTP 和员工 IMAP 是两条产品线。RuoYi Office 用连接器管协议,用索引表管列表,用三栏页管阅读和回复,用模板/策略/账号管租户差异,再用 oa_mail_biz_link 把「这封信」和未来的待办、流程钉在一起。
如果你正在给现有 SpringBoot3 后台加企业邮箱,建议按这个顺序验收:测连双通 → 增量同步有 last_uid → 列表不打 IMAP → 点开有正文 → 发送能在已发送里看到。这五步过了,再谈规则引擎和一键转审批。
你们现在邮件是网页版企业邮、还是全员 POP 到 Outlook?评论区说说痛点。
如果这篇对你有用,欢迎点赞支持。
我们会持续分享企业软件开发实践与 RuoYi Office 产品进展。
💡 想要体验 RuoYi Office 的强大功能?
🌐 在线演示:https://ruoyioffice.com/web/(账号 admin / admin123)
📦 源码仓库:GitHub:https://github.com/yuqing2026/ruoyi-office | GitCode:https://gitcode.com/zhouzhongyan/ruoyi-office | Gitee:https://gitee.com/yqzy1688/ruoyi-office
💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」
⭐ 如果觉得不错,请给个 Star 支持一下!

