☰
Camunda邮件发送全解析:内置邮件任务与委托代码实践
2026/10/27 4:12:36 网站建设 项目流程

工作流引擎跑通业务审批之后,几乎绕不开一个需求:流程走到某个节点,得给人发一封邮件。可能是通知审批人待办、可能是抄送结果给发起人、也可能是把关键数据推送给外部干系人。Camunda 作为主流的工作流引擎,邮件发送这块的能力其实相当完整,但很多刚接触的朋友容易卡在两个地方:一是内置的邮件任务到底怎么配、配完为什么不生效;二是稍微复杂一点的需求(比如动态收件人、带附件、发信失败要重试)就得写委托代码,而委托代码的类怎么挂、Spring Bean 怎么注入、表达式怎么传参,文档里讲得比较散。

这篇内容就围绕 Camunda 发送邮件这件事,把内置邮件任务和委托代码两条路线都拆开讲透。我会从整体设计思路讲起,说清楚什么场景该用哪种方式,然后把 BPMN 配置、参数含义、委托类的写法、常见报错排查一个个过一遍。适合已经能跑起 Camunda 流程、想把手里的通知环节做扎实的开发者,也适合刚接手工作流模块、需要快速补齐邮件能力的朋友。文中涉及的配置和代码都按常见实践给出,你可以直接对照着改。

1. 整体方案设计与选型思路

1.1 为什么邮件发送要分两条路线

Camunda 里发邮件,本质上就两种做法:用 BPMN 自带的Mail Task(邮件任务),或者用JavaDelegate / ExecutionListener 委托代码自己调邮件客户端。这两条路线不是谁替代谁的关系,而是覆盖不同复杂度的场景。

Mail Task 的好处是零代码。你在建模器里拖一个邮件任务节点,填上收件人、主题、正文,流程部署上去就能发。它的底层其实是 Camunda 内置的一个委托类在干活,把 BPMN 上的字段映射成 JavaMail 的参数。对于"流程到这一步,固定给某个人或某个角色发一封格式固定的通知"这种需求,用它最省事,改起来也不用重新编译打包。

但 Mail Task 的短板也很明显。收件人、正文这些字段虽然支持表达式,可一旦涉及复杂逻辑——比如要根据流程变量拼一个 HTML 表格、要查数据库拿到一组动态收件人、要带一个运行时生成的附件、要在发信失败时做补偿——纯靠 BPMN 字段就捉襟见肘了。这时候就得上委托代码,把发信逻辑写进 Java 类里,流程节点只负责调用。

我一般的判断标准是这样的:通知内容静态、收件人可枚举、不需要附件和复杂格式,用 Mail Task;其余情况一律上委托代码。这个分界线很实用,能避免为了简单需求写一堆代码,也能防止硬用 Mail Task 把 BPMN 搞得极其臃肿。

1.2 内置邮件任务的底层机制

要理解 Mail Task 为什么这么配,得知道它背后是谁在干活。Camunda 的邮件任务在 BPMN 里对应的是serviceTask,camunda:type为mail,引擎在解析时会把它绑定到内置的org.camunda.bpm.engine.impl.mail.MailActivityBehavior这个行为类上。这个类内部用的是 JavaMail(javax.mail,新版本是jakarta.mail)的 API,通过一个MailSender抽象去真正投递。

它读取的配置分两层:一层是流程引擎级别的邮件配置(SMTP 服务器地址、端口、认证信息),另一层是节点级别的字段(收件人、主题、正文、附件等)。引擎级配置通常放在camunda.cfg.xml或者 Spring Boot 的application.yml里,节点级配置就写在 BPMN 的extensionElements里。这个分层设计很关键——SMTP 连接信息是全局共享的,不该在每个节点重复写;而收件人、正文是每个节点各不相同的,放在节点上。

提示:Mail Task 依赖引擎级的邮件配置,如果camunda.cfg.xml里没配mailServer相关属性,节点配得再对也会在运行时抛异常。这是新手最常见的坑之一。

1.3 委托代码路线的定位

委托代码走的是另一条路:流程节点是一个普通的serviceTask,camunda:class指向你写的 JavaDelegate 实现类,或者camunda:delegateExpression指向一个 Spring Bean。发信逻辑完全由你的代码控制,想怎么拼内容、怎么查收件人、怎么处理异常都行。

这条路线灵活,但代价是要自己管 SMTP 连接、自己处理异常、自己保证幂等。所以选它之前要想清楚:是不是真的需要这么复杂。很多团队一上来就全用委托代码,结果每个流程都重复写一遍发信逻辑,维护成本很高。更好的做法是抽一个公共的发信服务 Bean,各个委托类只负责组装参数、调用这个服务,把"怎么发"和"发什么"分开。

2. 内置邮件任务的核心配置与实操

2.1 引擎级 SMTP 配置怎么写

先解决全局配置。如果你用的是 Spring Boot 集成 Camunda,最省事的方式是在application.yml里配:

camunda: bpm: generic-properties: properties: mailServerHost: smtp.example.com mailServerPort: 465 mailServerDefaultFrom: noreply@example.com mailServerUsername: noreply@example.com mailServerPassword: your-password mailServerUseSSL: true mailServerUseTLS: false

这几个属性的含义要拎清楚。mailServerHost和mailServerPort是 SMTP 服务器地址和端口,465 一般是 SSL 端口,25 或 587 是普通/TLS 端口。mailServerDefaultFrom是默认发件人,节点上不写发件人时就用它。mailServerUsername和mailServerPassword是认证信息,很多企业邮箱要求认证才能发。mailServerUseSSL和mailServerUseTLS二选一,别同时开,否则握手会出问题。

如果是传统的camunda.cfg.xml配置方式,写法是:

<bean id="processEngineConfiguration" class="org.camunda.bpm.engine.impl.cfg.StandaloneProcessEngineConfiguration"> <property name="mailServerHost" value="smtp.example.com"/> <property name="mailServerPort" value="465"/> <property name="mailServerDefaultFrom" value="noreply@example.com"/> <property name="mailServerUsername" value="noreply@example.com"/> <property name="mailServerPassword" value="your-password"/> <property name="mailServerUseSSL" value="true"/> </bean>

这里有个容易忽略的点:密码不要硬编码在配置文件里提交到代码仓库。生产环境建议用环境变量或者配置中心注入,Spring Boot 里写成${MAIL_PASSWORD}这种占位符,由外部提供实际值。我见过不少项目把邮箱密码明文写在 yml 里,一旦仓库权限管理不严就是安全隐患。

2.2 BPMN 邮件任务的字段配置

引擎级配置好了,节点级就好办了。在建模器里拖一个邮件任务,核心字段有这么几个:

字段名含义是否支持表达式
camunda:to收件人,多个用逗号分隔支持
camunda:from发件人,不填用默认支持
camunda:cc抄送支持
camunda:bcc密送支持
camunda:subject邮件主题支持
camunda:text纯文本正文支持
camunda:htmlHTML 正文支持
camunda:charset字符编码,默认 UTF-8支持
camunda:attachments附件,引用流程变量支持

对应的 XML 大概长这样:

<bpmn:serviceTask id="sendNotice" name="发送审批通知" camunda:type="mail"> <bpmn:extensionElements> <camunda:field name="to"> <camunda:string>${approverEmail}</camunda:string> </camunda:field> <camunda:field name="subject"> <camunda:string>您有一条待审批任务:${taskName}</camunda:string> </camunda:field> <camunda:field name="text"> <camunda:string>您好,流程 ${processName} 已流转到您处,请及时处理。</camunda:string> </camunda:field> </bpmn:extensionElements> </bpmn:serviceTask>

注意to字段里用了${approverEmail},这是流程变量表达式。也就是说收件人可以是动态的,只要流程里有个叫approverEmail的变量就行。这一点很多人不知道,以为 Mail Task 只能写死收件人,其实表达式是支持的,只是复杂逻辑拼不出来而已。

2.3 HTML 正文与附件的处理细节

想让邮件好看点,用html字段而不是text。但这里有个坑:HTML 正文里如果包含流程变量,变量值里的特殊字符(比如<、&)可能破坏 HTML 结构,甚至引发注入问题。稳妥的做法是在流程变量赋值时就做好转义,或者干脆用委托代码拼 HTML,在代码里用工具类转义。

附件字段attachments的用法比较特殊,它引用的是流程变量里的一个Map<String, DataSource>或者Map<String, File>结构。也就是说你得先在流程里准备好附件对象,存进变量,节点上引用变量名。实际项目里附件往往是运行时生成的(比如导出的 PDF 报表),这种场景 Mail Task 处理起来就别扭,还是委托代码更顺手。

注意:附件变量必须是引擎能识别的类型,随便塞个byte[]是不行的。常见做法是包一层ByteArrayDataSource,再放进 Map 里。

2.4 一个完整的邮件任务落地示例

假设有个请假审批流程,走到经理审批节点时给经理发一封通知。流程变量里有managerEmail、applicantName、leaveDays。BPMN 配置如下:

<bpmn:serviceTask id="notifyManager" name="通知经理审批" camunda:type="mail"> <bpmn:extensionElements> <camunda:field name="to"> <camunda:string>${managerEmail}</camunda:string> </camunda:field> <camunda:field name="subject"> <camunda:string>请假审批待办:${applicantName}</camunda:string> </camunda:field> <camunda:field name="html"> <camunda:string> <![CDATA[ <p>您好,</p> <p>员工 <b>${applicantName}</b> 提交了 <b>${leaveDays}</b> 天请假申请,请审批。</p> ]]> </camunda:string> </camunda:field> </bpmn:extensionElements> </bpmn:serviceTask>

部署后启动流程,设置好这三个变量,流程走到该节点就会自动发信。实测下来,只要 SMTP 配置正确,这一步非常稳。但要注意 HTML 内容用 CDATA 包起来,否则 XML 里的尖括号会被当成标签解析,部署时直接报错。

3. 委托代码发送邮件的完整实现

3.1 什么时候必须上委托代码

前面说了判断标准,这里再具体化几个典型场景,遇到这些就别犹豫,直接写委托代码:

  • 收件人需要查库或调接口动态获取:比如按组织架构查某角色的所有成员邮箱,Mail Task 的表达式搞不定。
  • 正文需要复杂拼装:比如根据流程变量生成一个多行表格、带条件分支的内容块。
  • 需要带运行时生成的附件:导出的报表、生成的合同 PDF。
  • 需要发信失败重试或补偿:Mail Task 失败会抛异常中断流程,委托代码里可以捕获、记录、决定是否重试。
  • 需要统一发信模板和日志:多个流程共用一套发信逻辑,抽成服务 Bean 更易维护。

3.2 JavaDelegate 与 Spring Bean 的挂载方式

委托类有两种挂载方式,区别在于类的实例由谁管理。

第一种是camunda:class,直接写全限定类名,引擎每次执行时 new 一个实例。这种方式简单,但类里没法用 Spring 注入的依赖(比如你注入的JavaMailSender),因为实例不是 Spring 管的。

<bpmn:serviceTask id="sendMail" name="发送邮件" camunda:class="com.example.delegate.SendMailDelegate"/>

第二种是camunda:delegateExpression,写一个表达式指向 Spring Bean,比如${sendMailDelegate}。这种方式下委托类是个 Spring Bean,可以正常注入依赖,是实际项目里的首选。

<bpmn:serviceTask id="sendMail" name="发送邮件" camunda:delegateExpression="${sendMailDelegate}"/>

对应的 Bean:

@Component("sendMailDelegate") public class SendMailDelegate implements JavaDelegate { @Autowired private MailService mailService; @Override public void execute(DelegateExecution execution) { // 发信逻辑 } }

我强烈建议用第二种。理由很实在:发信几乎必然要用到配置好的邮件客户端、模板引擎、日志组件,这些都得靠依赖注入。用camunda:class的话,你要么在类里手动 new 一堆东西,要么用静态工具类,代码又丑又难测。

3.3 用 Spring 的 JavaMailSender 发信

如果项目是 Spring Boot 环境,最顺手的发信工具是 Spring 自带的JavaMailSender。先在application.yml里配好标准邮件参数:

spring: mail: host: smtp.example.com port: 465 username: noreply@example.com password: your-password default-encoding: UTF-8 properties: mail: smtp: auth: true ssl: enable: true

然后委托类里注入JavaMailSender,组装MimeMessage发送:

@Component("sendMailDelegate") public class SendMailDelegate implements JavaDelegate { @Autowired private JavaMailSender mailSender; @Override public void execute(DelegateExecution execution) { String to = (String) execution.getVariable("approverEmail"); String applicant = (String) execution.getVariable("applicantName"); try { MimeMessage message = mailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setFrom("noreply@example.com"); helper.setTo(to); helper.setSubject("审批待办:" + applicant); helper.setText("<p>请及时处理审批任务。</p>", true); mailSender.send(message); } catch (MessagingException e) { throw new BpmnError("MAIL_SEND_FAILED", "邮件发送失败:" + e.getMessage()); } } }

这里用MimeMessageHelper的setText(..., true)表示内容是 HTML。第二个构造参数true表示支持 multipart,带附件时必须要它。

3.4 动态收件人与多收件人的处理

动态收件人是委托代码的主场。假设要按角色查一组邮箱,代码大概这样:

List<String> emails = userService.findEmailsByRole("MANAGER"); if (emails.isEmpty()) { // 没有收件人时的处理策略 execution.setVariable("mailSkipped", true); return; } helper.setTo(emails.toArray(new String[0]));

这里有个设计决策值得说:收件人为空时怎么办?直接抛异常会让流程卡死,静默跳过又可能漏通知。我的做法是记一条 warn 日志,同时设置一个流程变量标记"邮件被跳过",后续节点或人工可以据此判断。这样既不阻断流程,又留下了可追溯的痕迹。

多收件人还有个细节:setTo传数组是"收件人",setCc是"抄送",setBcc是"密送"。如果收件人很多(比如几十个),要注意 SMTP 服务器可能有单封邮件收件人数量限制,超了会被拒。这种情况建议分批发送,或者改用邮件列表地址。

3.5 带附件的邮件怎么发

带附件时MimeMessageHelper的构造参数必须是true,然后用addAttachment:

byte[] pdfBytes = reportService.generatePdf(execution.getProcessInstanceId()); helper.addAttachment("审批单.pdf", new ByteArrayResource(pdfBytes), "application/pdf");

附件名建议带上业务标识,方便收件人识别。附件内容如果是运行时生成的,注意生成逻辑要放在 try 块里,生成失败和发送失败要区分开处理——生成失败是业务问题,发送失败是通道问题,两者的补偿策略不一样。

提示:附件大小受 SMTP 服务器限制,常见是 10MB 到 25MB。大附件不要硬塞,改成生成下载链接放进正文更靠谱。

4. 常见问题与排查技巧实录

4.1 邮件发不出去的排查顺序

邮件发送失败的原因五花八门,我总结了一个从外到内的排查顺序,按这个走能少绕很多弯路:

排查层级检查项典型现象
网络层SMTP 服务器地址、端口是否可达连接超时、Connection refused
认证层用户名密码是否正确、是否要求授权码535 认证失败
协议层SSL/TLS 配置是否匹配端口握手失败、SSLException
引擎层引擎级邮件配置是否加载提示 mailServer 未配置
节点层字段名拼写、表达式变量是否存在收件人为空、变量解析失败
内容层正文编码、附件大小乱码、附件被拒

先说网络层。很多企业内网访问外部 SMTP 需要开通白名单,本地开发能发、服务器上发不出去,八成是网络策略问题。用telnet smtp.example.com 465测一下端口通不通,比看日志快。

认证层最常见的坑是邮箱授权码。不少邮箱服务商在开启客户端发信时,要求用专门的授权码而不是登录密码。配置里填了登录密码,就会一直报 535 认证失败。这个错误信息很明确,看到 535 先怀疑密码/授权码。

协议层的问题集中在 SSL 和 TLS 混用。465 端口配mailServerUseSSL=true,587 端口配mailServerUseTLS=true,配反了就是握手失败。我踩过一次,把 587 配成 SSL,日志里一堆SSLException,查了半天才发现是端口和协议对不上。

4.2 表达式解析失败的典型场景

Mail Task 里用表达式,最常见的报错是变量不存在。比如to字段写了${approverEmail},但流程走到这个节点时变量还没设置,引擎解析表达式就会抛PropertyNotFoundException。

排查这类问题,先确认变量是在哪个节点设置的、设置时机是否早于邮件节点。有个隐蔽的情况:变量是在并行网关的另一条分支上设置的,而邮件节点所在分支先执行了,这时候变量确实还不存在。解决办法要么调整流程结构,要么在邮件节点前加个判断,变量为空时走兜底逻辑。

还有一种情况是变量名拼写错误,approverEmail写成approveEmail,这种低级错误反而最难发现,因为报错信息不会告诉你"你拼错了",只会说变量找不到。建议变量名统一用 IDE 的重构功能管理,别手敲。

4.3 委托代码里的异常处理策略

委托代码发信失败,异常怎么处理直接决定流程的健壮性。三种常见策略:

第一种,直接抛RuntimeException,流程中断,事务回滚。适合"邮件必须发出去才能继续"的强一致场景,但风险是 SMTP 抖动一下整个流程就卡住。

第二种,抛BpmnError,走 BPMN 里定义的错误边界事件,进入补偿分支。适合需要业务层面处理失败的场景,比如转人工重发。

第三种,捕获异常、记日志、设置流程变量标记,流程继续往下走。适合"通知是辅助功能,发不出去不影响主流程"的场景。

我的经验是:通知类邮件用第三种,关键凭证类邮件用第二种,几乎不用第一种。因为 SMTP 是个外部依赖,稳定性不受你控制,把主流程的生死绑在它身上不划算。用第三种时一定要记足够详细的日志(收件人、主题、异常堆栈),方便事后补发。

try { mailSender.send(message); execution.setVariable("mailStatus", "SENT"); } catch (Exception e) { log.error("邮件发送失败, to={}, subject={}", to, subject, e); execution.setVariable("mailStatus", "FAILED"); execution.setVariable("mailError", e.getMessage()); }

这样流程继续走,但留下了mailStatus变量,后续可以加个定时任务扫描失败记录做补发。

4.4 中文乱码与编码问题

中文乱码基本都出在编码没统一。三个地方要一致:BPMN 文件本身的编码(UTF-8)、camunda:charset字段(默认 UTF-8)、邮件客户端的解码方式。Mail Task 里如果正文有中文,显式写上charset字段最保险。

委托代码里,MimeMessageHelper构造时传"UTF-8",setText和setSubject都用它。有个细节:主题的中文编码在某些老邮件客户端上会出问题,MimeMessageHelper内部会做编码处理,一般不用管,但如果发现主题乱码而正文正常,可以试试手动MimeUtility.encodeText。

4.5 重复发送与幂等控制

流程重试、节点回滚、人工干预都可能导致邮件重复发送。用户收到两封一模一样的通知,体验很差。控制幂等的常见做法是发信前查一下"这个流程实例的这个节点是否已经发过"。

简单实现是在流程变量里记一个标记:

String sentFlag = (String) execution.getVariable("mailSent_" + execution.getCurrentActivityId()); if ("true".equals(sentFlag)) { return; // 已发过,跳过 } // 发信... execution.setVariable("mailSent_" + execution.getCurrentActivityId(), "true");

用当前活动节点 ID 做 key,能区分不同节点的发送记录。这个方案简单有效,缺点是流程变量会多一些。更严谨的做法是落库记录发送日志,用流程实例 ID + 节点 ID 做唯一约束,但那就重了,看项目规模取舍。

5. 两条路线的取舍与组合实践

5.1 混合使用的架构建议

实际项目里,我很少纯用一条路线,更多是混合。典型架构是这样:底层抽一个MailService,封装发信、模板渲染、日志、重试;简单的固定通知用 Mail Task 直接配;复杂的用委托类调MailService。

这样分层的好处是职责清晰。MailService只管"怎么把一封邮件可靠地发出去",不关心业务;Mail Task 和委托类只管"发什么内容给谁",不关心 SMTP 细节。改 SMTP 配置只动一处,加新通知类型只加节点或委托类,互不影响。

5.2 模板化正文的落地方式

正文内容多了以后,硬编码在 BPMN 或 Java 里都难维护。我的做法是把邮件模板抽成独立文件(比如 FreeMarker 或 Thymeleaf 模板),委托类里渲染模板生成正文。模板文件放资源目录,改文案不用重新编译。

String html = templateEngine.process("mail/approval-notice", new Context(Map.of("applicant", applicant, "days", leaveDays))); helper.setText(html, true);

模板化之后,运营同学改个措辞、调个排版,都不用找开发。这个投入在通知类需求多的项目里回报很高。

5.3 发信性能与异步化考量

同步发信会阻塞流程执行。SMTP 响应慢的时候(比如网络抖动、服务器排队),一个节点可能卡好几秒。流程并发量大的时候,这会拖慢整体吞吐。

如果对实时性要求不高,可以把发信异步化:委托类里只把发信任务丢进消息队列或线程池,流程立即往下走。代价是要处理异步失败的情况,复杂度上升。我的建议是:并发量不大时同步发就行,别过度设计;并发量上来、或者 SMTP 明显成为瓶颈时,再考虑异步。判断依据看监控里邮件节点的平均耗时,超过一两秒就该考虑优化了。

6. 实操心得与避坑清单

6.1 配置层面的经验

配置这块,我踩过的坑集中在"环境差异"上。本地开发用个人邮箱测试,服务器上用企业邮箱,两边的 SMTP 参数、认证方式、端口策略都可能不同。建议把邮件配置按环境隔离,本地、测试、生产各一套,别指望一套配置通吃。

还有个小技巧:配置好之后,先写个最简单的测试用例单独验证发信通道,别一上来就跑整个流程。通道不通的时候,流程里排查干扰因素太多,单独测能快速定位是配置问题还是流程问题。

6.2 代码层面的经验

委托类里取流程变量,注意类型转换。execution.getVariable返回Object,直接强转可能ClassCastException。稳妥点用getVariable加类型判断,或者用 Camunda 提供的类型化取值方法。

发信日志要记全。收件人、主题、流程实例 ID、节点 ID、发送结果,这几项缺一不可。出问题的时候,这些信息能让你快速定位是哪封邮件、哪个流程、哪个环节出的岔子。日志里别记邮件正文全文(可能含敏感信息),记个摘要或模板名就行。

6.3 上线前的检查清单

上线前过一遍这个清单,能挡掉大部分低级问题:

  • SMTP 配置在生产环境验证过,不是只测了本地
  • 密码/授权码通过外部注入,没硬编码在仓库里
  • 收件人为空、变量缺失的兜底逻辑写好了
  • 发信失败的日志和补偿机制就位
  • 中文编码在目标邮件客户端上验证过
  • 附件大小在 SMTP 限制之内
  • 重复发送的幂等控制考虑过

这套东西看着琐碎,但每一条背后都是真实踩过的坑。邮件发送本身不复杂,复杂的是把它做稳、做可靠。把通道配好、把异常兜住、把日志留全,剩下的就是业务逻辑的事了。

我个人在实际项目里的体会是,邮件通知这种"辅助功能"最容易被轻视,前期随便配配能跑就行,等到生产环境出问题、用户投诉收不到通知,回头补日志、补补偿、补幂等,成本比一开始就做扎实高得多。所以哪怕需求再简单,发信这块也值得多花半天把基础打牢。

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

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

立即咨询