
工作流引擎跑到生产环境之后业务方提的需求里十有八九会冒出一条某个审批节点要通过邮件通知到人。Camunda 本身把「发邮件」这件事做成了引擎内置的一等公民但真到落地的时候坑往往不在「能不能发出去」而在「模板变量怎么传」「附件路径怎么找」「异步失败为什么静默」「委托代码里拿不到流程变量」这些细节上。这篇就把 Camunda 发邮件的两条主路径——内置 Mail Task 和自定义 JavaDelegate 委托——从配置到代码完整拆一遍顺带把我在实际项目里踩过的几个坑摊开讲清楚适合正在用 Camunda 做审批流、需要接入邮件通知的开发者参考也适合刚接触 BPMN 想搞明白「引擎到底替我们做了什么」的同学。1. 先搞清楚 Camunda 发邮件到底有几种姿势很多人一上来就搜「Camunda 发邮件代码」结果搜到一堆 XML 片段和 Java 类名反而更晕。其实把选项理清楚后面选型就顺了。Camunda 发邮件本质上只有两条路外加一条「半自动」的混合路线。1.1 内置 Mail Task零代码但边界清晰BPMN 里拖一个 Service Task把类型设成mail填几个字段就能发信。它的实现类是引擎自带的org.camunda.bpm.engine.impl.mail.MailActivityBehavior底层用的是 Apache Commons Email。这条路最大的好处是不用写一行 Java流程建模人员自己就能配完坏处是能力被框死在它暴露的那几个字段里——收件人、抄送、主题、正文、附件、编码、HTML 开关就这些。你想在正文里做复杂逻辑、想根据条件动态拼多个附件、想在发送失败时做重试补偿它都不管。所以判断标准很简单纯通知、内容静态或只做简单变量替换用 Mail Task需要查库、算逻辑、拼复杂内容、控制发送时机走委托代码。1.2 JavaDelegate 委托能力全开但要自己兜底写一个实现JavaDelegate接口的类在execute方法里用 JavaMail 或者 Commons Email 自己发。流程里配一个 Service Taskcamunda:class指向你的类全名。这条路什么都能干但引擎只负责在节点到达时调用你一次发没发成功、失败了怎么办全靠你自己写。事务边界、异常处理、幂等性都是你的责任。1.3 混合路线Mail Task 打底 执行监听器补刀还有一种常见做法是 Mail Task 照用但在它前后挂 Execution Listener在start或end事件里做变量准备或结果记录。这样既保留了零代码的便利又能塞一点自定义逻辑进去。适合「模板基本固定、只是收件人需要动态算」这类场景。下面这张表把三条路的关键差异摆在一起选型时对着看就行维度内置 Mail TaskJavaDelegate 委托混合路线是否需要写代码否是少量正文动态能力仅变量替换完全自由变量替换 监听器逻辑附件控制静态路径或变量任意动态生成同 Mail Task失败重试依赖异步作业重试自己实现依赖异步作业重试事务控制引擎托管自己控制引擎托管适用场景标准通知复杂业务邮件半动态通知2. 内置 Mail Task 的字段配置与变量替换机制决定用 Mail Task 之后接下来要弄明白它每个字段吃什么东西、变量是怎么进去的。这块看起来简单但字段名拼错、变量名对不上是最高频的翻车点。2.1 必填字段与连接信息从哪来Mail Task 的字段分两类一类是连接信息SMTP 服务器地址、端口、用户名、密码一类是邮件内容收件人、主题、正文等。连接信息有两种给法第一种是直接在 BPMN 的camunda:field里写死比如bpmn:serviceTask idsendMail name发送通知 camunda:typemail bpmn:extensionElements camunda:field namemailServercamunda:stringsmtp.example.com/camunda:string/camunda:field camunda:field namemailServerPortcamunda:string465/camunda:string/camunda:field camunda:field namemailServerUseSSLcamunda:stringtrue/camunda:string/camunda:field camunda:field namemailServerUsernamecamunda:stringnotifyexample.com/camunda:string/camunda:field camunda:field namemailServerPasswordcamunda:string******/camunda:string/camunda:field /bpmn:extensionElements /bpmn:serviceTask第二种是走流程引擎配置在ProcessEngineConfiguration里统一设mailServer系列属性BPMN 里就不重复写了。生产环境强烈建议走第二种原因很直接密码写进 BPMN 文件等于跟着流程定义一起进了数据库的 ACT_RE_PROCDEF 表谁有流程查看权限谁就能看到明文密码这是实打实的安全隐患。统一配置在引擎层改密码只改一处流程定义不用重新部署。提示如果非要在 BPMN 里写连接信息至少把密码做成流程变量从外部注入别硬编码。2.2 收件人、主题、正文的字段名对照Mail Task 认的字段名是固定的拼错一个字母它就当没看见邮件发出去是空的或者直接报错。常用的几个to收件人多个用逗号分隔cc抄送bcc密送from发件人不填则用连接配置里的用户名subject主题text正文纯文本html正文 HTML和text二选一charset编码中文场景建议显式写UTF-8attachments附件多个用逗号分隔这里有个容易忽略的点text和html同时配了会怎样实测下来引擎优先用htmltext被忽略。所以别想着「配两个做降级」它不做这个事。2.3 变量替换${}表达式在字段里怎么生效Mail Task 的字段值支持表达式写${assigneeEmail}这样的占位符引擎在节点执行时用当前流程变量替换。这是它「动态」能力的全部来源。举个完整例子camunda:field nameto camunda:expression${applicant.email}/camunda:expression /camunda:field camunda:field namesubject camunda:string您的申请【${applyNo}】已通过/camunda:string /camunda:field camunda:field nametext camunda:string您好 ${applicant.name}您提交的申请已于 ${approveTime} 审批通过。/camunda:string /camunda:field注意camunda:expression和camunda:string的区别前者把整个字段值当表达式求值结果直接作为字段内容后者是字符串模板里面的${}会被替换。收件人这种「值本身就是一个变量」的用expression主题正文这种「字符串里嵌变量」的用string。搞混了就会出现「收件人变成字面量${applicant.email}」这种哭笑不得的情况。还有一个坑变量为 null 时表达式会抛异常整个节点失败。所以要么保证变量一定有值要么在表达式里做兜底比如${applicant.email!defaultexample.com}用 Freemarker 风格的空值默认值语法Camunda 的表达式引擎支持这个。3. 委托代码实现从接口签名到发送逻辑Mail Task 搞不定的场景就得自己写委托。这一节把代码骨架、变量获取、发送实现、异常处理串起来讲。3.1 JavaDelegate 接口的最小实现骨架实现org.camunda.bpm.engine.delegate.JavaDelegate核心就一个execute(DelegateExecution execution)方法。骨架长这样public class SendApprovalMailDelegate implements JavaDelegate { private static final Logger LOG LoggerFactory.getLogger(SendApprovalMailDelegate.class); Override public void execute(DelegateExecution execution) throws Exception { // 1. 取流程变量 String applyNo (String) execution.getVariable(applyNo); String to (String) execution.getVariable(applicantEmail); // 2. 组装内容 // 3. 发送 // 4. 记录结果 } }DelegateExecution是引擎传进来的上下文流程变量、当前节点 ID、流程实例 ID、业务 key 全从它身上拿。这里有个新手常问的问题委托类能不能用 Spring 注入能但前提是这个类被 Spring 管理。做法是把类加上ComponentBPMN 里用camunda:delegateExpression${sendApprovalMailDelegate}而不是camunda:class。用class的话引擎每次 new 一个新实例注入的东西全是 null这个坑后面还会细说。3.2 流程变量的读取方式与类型陷阱execution.getVariable(xxx)返回Object强转的时候类型对不上就ClassCastException。几个实测经验数字类型的变量如果是从表单或外部接口传进来的很可能是Integer或Long甚至String别想当然按Integer转。稳妥做法是先String.valueOf()再处理。日期变量在流程里存的是java.util.Date取出来格式化要自己转别指望它自动变成字符串。复杂对象比如整个申请人实体如果是以序列化形式存的取出来能直接用但如果跨版本部署、类结构变了反序列化会炸。建议流程变量只存扁平的基础类型和 ID实体靠 ID 回查这是我在多个项目里验证过的稳妥做法。取变量的代码可以稍微封装一下减少重复private String getStr(DelegateExecution execution, String name) { Object v execution.getVariable(name); return v null ? null : String.valueOf(v); }3.3 用 Commons Email 发送的实际代码Camunda 自带 Commons Email 依赖直接用它最省事不用额外引 JavaMail。完整发送逻辑HtmlEmail email new HtmlEmail(); email.setHostName(mailHost); email.setSmtpPort(mailPort); email.setAuthentication(mailUser, mailPass); email.setSSLOnConnect(true); email.setCharset(UTF-8); email.addTo(to); if (cc ! null !cc.isEmpty()) { email.addCc(cc.split(,)); } email.setFrom(fromAddr, 审批中心); email.setSubject(subject); email.setHtmlMsg(htmlBody); email.setTextMsg(您的邮件客户端不支持 HTML请升级。); // 附件 if (attachmentPath ! null) { File f new File(attachmentPath); if (f.exists()) { email.attach(f); } else { LOG.warn(附件不存在跳过{}, attachmentPath); } } email.send();几个细节值得说setSSLOnConnect(true)对应 465 端口如果用 587 端口走 STARTTLS要改成setStartTLSEnabled(true)两者别同时开否则握手会出问题。setFrom的第二个参数是显示名中文要确保编码正确不然收件人看到的是乱码。附件用File直接 attach 最简单但路径必须是引擎所在服务器能访问到的路径这个后面单独讲。3.4 异常处理什么该抛什么该吞委托里抛异常引擎会把当前节点标记为失败事务回滚如果配了异步作业还会进重试队列。所以异常处理的核心判断是这个失败该不该让流程停下来邮件服务器连不上、认证失败属于基础设施问题抛出去让引擎重试是合理的配了异步的话默认重试 3 次。收件人地址为空属于数据问题抛出去流程卡住等人修数据也合理。附件文件找不到属于可降级问题记 warn 日志、跳过附件继续发比整个流程失败更符合业务预期。我一般会包一层 try-catch把「可降级」的异常吞掉记日志「不可降级」的重新抛try { email.send(); execution.setVariable(mailSent, true); } catch (EmailException e) { LOG.error(邮件发送失败, applyNo{}, applyNo, e); execution.setVariable(mailSent, false); execution.setVariable(mailError, e.getMessage()); throw e; // 让引擎感知失败 }把发送结果写回流程变量后面可以挂网关判断也可以给运维留排查线索这个习惯很值。4. 附件、模板与异步这三个高频坑前面讲的是「正常路径」这一节专门讲实际项目里最容易翻车的地方。这三个问题我在不同项目里都遇到过排查过程值得完整还原。4.1 附件路径相对路径为什么总是找不到第一次用 Mail Task 配附件写了个attachments字段值是report.pdf本地跑得好好的部署到服务器就报文件不存在。原因很朴素相对路径是相对于 JVM 的工作目录本地 IDE 跑的时候工作目录是项目根目录服务器上跑的时候工作目录可能是 Tomcat 的 bin 目录或者 systemd 配置的某个路径两者根本不是一回事。排查链路是这样的先看日志里报的绝对路径是什么异常信息里通常带再对比服务器上文件实际在哪最后统一改成绝对路径或者配置化的路径。稳妥做法是把附件根目录做成配置项Value(${mail.attachment.base:/data/attachments/}) private String attachmentBase;拼路径的时候用Paths.get(attachmentBase, fileName).toString()别用字符串拼接Windows 和 Linux 的斜杠方向不一样Paths会帮你处理。还有一个更隐蔽的坑附件是流程中间生成的比如上一个节点导出了 PDF 存到临时目录这个节点去发。如果临时目录被清理策略扫掉了或者两个节点不在同一台机器上集群部署附件就找不到了。集群场景下附件必须放共享存储这是硬约束别想着用本地磁盘糊弄。4.2 邮件模板字符串拼接 vs 模板引擎正文内容一复杂字符串拼接就没法看了。我见过最夸张的一个委托类正文是十几个StringBuilder.append拼出来的改一个措辞要翻半天。这时候该上模板引擎。Camunda 生态里最顺手的是 Freemarker因为它的表达式语法和 Camunda 的${}一致学习成本低。做法是把模板文件放resources/templates/下委托里加载渲染Configuration cfg new Configuration(Configuration.VERSION_2_3_31); cfg.setClassForTemplateLoading(this.getClass(), /templates); cfg.setDefaultEncoding(UTF-8); Template tpl cfg.getTemplate(approval.ftl); StringWriter writer new StringWriter(); tpl.process(variables, writer); String html writer.toString();variables就是一个MapString, Object把流程变量塞进去即可。这里有个细节Freemarker 对 null 值默认报错模板里写${applicant.name}而 name 为 null 就炸。要么在模板里写${applicant.name!}做默认值要么在 Configuration 里设setClassicCompatible(true)让 null 变空串。我倾向前者显式一点出问题好定位。模板方案还有个额外好处改文案不用重新部署流程定义。模板文件单独放改完重启或者热加载即可流程定义纹丝不动。这对业务方频繁改措辞的场景太重要了。4.3 异步执行为什么邮件发了两次这个坑最吓人。现象是用户收到两封一模一样的邮件查日志发现委托执行了两次。根因通常是把发邮件的节点配成了异步camunda:asyncBeforetrue然后作业重试机制在「发送成功但后续步骤失败」时把整个异步作业重跑了一遍邮件就又发了一次。要理解这个得先明白异步作业的边界asyncBefore意味着「进入这个节点前先落一条作业记录由作业执行器异步跑」。如果委托里邮件发出去了但方法返回前抛了异常比如后面写数据库失败引擎认为这个作业失败重试时会从头再执行一遍委托邮件自然重发。解决办法有几个层次最直接发邮件这种「有副作用且不可撤销」的操作不要放在会重试的异步节点里或者确保委托方法在邮件发出后不再抛异常。加幂等发送前查一下流程变量mailSent已经是 true 就跳过。但注意变量写入和邮件发送不在一个原子操作里极端情况下仍有窗口。用独立的事务邮件发送放到事务提交后的事件监听器里end事件的take之后确保业务事务成功了才发。我实际项目里的做法是第二种加第三种结合委托里先查mailSent标记发送成功后立刻setVariable同时把节点配成同步执行避免作业重试带来的重复。这套组合拳下来重复发送基本绝迹。5. 把配置、代码、测试串成一条可复现的链路光看片段容易「眼睛会了手不会」这一节把从零到跑通的完整步骤列出来照着做能复现。5.1 引擎层邮件配置的完整参数如果走统一配置推荐在application.yml或引擎配置文件里设这些camunda: bpm: generic-properties: properties: mailServer: smtp.example.com mailServerPort: 465 mailServerUseSSL: true mailServerUsername: notifyexample.com mailServerPassword: ${MAIL_PASSWORD} mailServerDefaultFrom: notifyexample.com密码用环境变量注入别写明文。mailServerDefaultFrom设了之后BPMN 里不写from字段也能发出去省事。5.2 一个完整的 BPMN 片段与委托类对照Mail Task 版本零代码bpmn:serviceTask idnotifyApplicant name通知申请人 camunda:typemail bpmn:extensionElements camunda:field nametocamunda:expression${applicantEmail}/camunda:expression/camunda:field camunda:field namesubjectcamunda:string申请 ${applyNo} 审批结果/camunda:string/camunda:field camunda:field namehtmlcamunda:string${resultHtml}/camunda:string/camunda:field camunda:field namecharsetcamunda:stringUTF-8/camunda:string/camunda:field /bpmn:extensionElements /bpmn:serviceTask委托版本resultHtml在代码里算好再发bpmn:serviceTask idnotifyApplicant name通知申请人 camunda:delegateExpression${sendApprovalMailDelegate}/对应委托类加Component(sendApprovalMailDelegate)里面把模板渲染、附件处理、发送全包了。两种写法在流程图上长得一样区别全在扩展元素里。5.3 本地怎么验证邮件真的发出去了没有真实 SMTP 服务器的时候别硬连生产邮箱。本地起一个假 SMTP 服务比如 GreenMail 或者 MailHog 这类工具把mailServer指向localhost对应端口邮件会被拦截下来在 Web 界面里看。这样既能验证字段拼得对不对、变量替换生效没有、附件带没带上又不会误发给真实用户。验证清单我一般这么过一遍收件人、抄送、主题、正文是否都正确渲染中文有没有乱码。变量为 null 时是否按预期兜底而不是整个节点失败。附件是否存在、文件名是否正常、大小是否合理。故意把 SMTP 端口改错看异常是否按预期抛出、流程是否停在节点上。配成异步后故意让后续节点失败看邮件会不会重发验证幂等逻辑。这五条过完基本能覆盖 90% 的上线风险。6. 几个只有踩过才知道的经验最后聊几个零散但很值钱的点都是文档里不会写、但实际会遇到的。委托类用class还是delegateExpression直接决定你能不能注入 Spring Bean。用camunda:class时引擎反射 new 实例Spring 容器管不着它Autowired全是 null。要么改成delegateExpression配合Component要么在类里用静态方式拿 ApplicationContext。前者是正道后者是权宜之计。这个区别我在早期项目里栽过排查了半天才发现注入没生效。流程变量里别塞大对象。有人图省事把整个附件字节数组或者完整实体塞进流程变量结果 ACT_RU_VARIABLE 表膨胀查询变慢历史表也跟着遭殃。变量只存 ID 和必要字段大内容走文件存储或业务库。邮件正文里的链接要带流程实例 ID。通知邮件经常要带「点击查看详情」的链接链接里拼上${execution.processInstanceId}用户点进来能直接定位到具体流程体验好很多。这个execution在 Mail Task 的表达式里可以直接用委托里就是execution.getProcessInstanceId()。测试环境把收件人重定向。测试环境跑流程时收件人变量可能指向真实用户邮箱一不小心就给客户发了测试邮件。稳妥做法是在测试环境的邮件配置里加一个「重定向到指定邮箱」的开关所有邮件都发到测试组邮箱正文里保留原始收件人信息。这个开关用配置项控制生产环境关掉。日志里别打完整邮件正文。正文里可能有敏感信息日志级别开到 debug 时全打出来审计过不了。打收件人、主题、流程实例 ID 就够了正文最多打长度。这套东西跑顺之后Camunda 发邮件其实没什么神秘的无非是把「引擎替你做了什么」和「你得自己兜什么」分清楚。Mail Task 适合标准通知委托适合复杂场景异步和幂等是必须想明白的两件事附件路径和模板渲染是高频翻车点。把这些边界摸透剩下的就是按业务往里填内容了。