@kne/fastify-message
v2.1.1
Published
管理消息发送,支持邮件、短信等消息,模板基于 Liquid(@kne/message-template)
Keywords
Readme
fastify-message
描述
管理消息发送,支持邮件、短信等消息,模板基于 Liquid(@kne/message-template)
安装
npm i --save @kne/fastify-message概述
项目概述
fastify-message 是一个基于 Fastify 框架的消息管理插件,提供模板管理和消息发送功能,支持邮件,并通过 senders 扩展短信等渠道。
模板语法基于 @kne/message-template(Liquid 白名单子集),与前端 components-thirdparty:TemplateEditor 编辑器、预览共用同一套语法与渲染规则。
主要特性
- 邮件 / 短信模板管理:新建、修改、删除、复制、启用 / 禁用,系统模板只读
- Liquid 模板语法:变量
{{ name }}、原样输出{{ name | raw }}、条件{% if %}…{% else %}…{% endif %} - 保存与导入前校验语法白名单,非法模板拒绝保存
- 邮件 HTML 模式自动转义变量,短信文本模式不转义
- 模板变量声明(
variables),预览时使用示例值 - 系统级(目录导入)和业务级(接口创建)模板分类;业务模板启用后覆盖同编码系统模板
- 发送记录追踪(成功 / 失败 / 待发送及失败原因)与发送统计,失败记录可重发
- 支持异步发送与失败重试,SMTP 连接池复用
- 租户模板与租户 SMTP:
tenantId由宿主项目自定义;mailSettingTemplateCodes白名单 ∩ 平台开关决定哪些系统模板开放给租户定制;services.mailSetting自带 SMTP 存储与租户模板定制,方法集合固定挂载、不随插件参数增删,只有发送取值会随getEmailConfig改用宿主实现(如 fastify-tenant) - 兼容 PostgreSQL、MySQL、SQLite,启动时自动升级表结构
安装
npm install @kne/fastify-message基础配置
const fastify = require('fastify')();
fastify.register(require('@kne/fastify-message'), {
name: 'message', // 插件注册名称
templateDir: path.resolve(__dirname, './messageTemplate'), // 系统模板目录
emailConfig: {
host: 'smtp.example.com',
port: 465,
secure: true,
user: '[email protected]',
pass: 'your-password',
defaultSubject: '系统通知' // 默认邮件主题
},
retry: { times: 2, delay: 1000 }, // 发送失败重试次数与间隔(毫秒),默认不重试
// 可选:按租户返回 SMTP 配置 { host, port, secure, user, pass, fromName, fromAddress },返回空时使用 emailConfig
// 配合 fastify-tenant 时传 fastify.tenant.services.mail.resolveEmailConfig;不传时由 services.mailSetting 读包内表 t_message_tenant_email_config(mailSetting 的方法始终完整挂载)
getEmailConfig: async ({ tenantId }) => fastify.tenant.services.mail.resolveEmailConfig({ tenantId }),
// 可选:允许开放给租户定制的系统模板编码(白名单),也可以是 ({ tenantId, type }) => codes;不传时为全部系统邮件与短信模板
mailSettingTemplateCodes: ['welcome'],
autoUpgrade: true, // 启动时自动补齐表字段与索引、迁移 1.x 模板,默认开启;关闭后需自行调用 services.upgradeSchema()
isTest: false // 测试模式下不会真实发送邮件
});模板格式
模板文件扩展名为 .liquid(也支持 .html、.txt),用注释标记分段:
<!-- subject -->
欢迎加入 {{ appName }}
<!-- description -->
新用户注册成功后发送
<!-- variables -->
[{ "name": "username", "label": "用户名", "example": "张三" }, { "name": "appName", "label": "应用名", "example": "LeapIn" }]
<!-- html -->
<h1>你好,{{ username }}!</h1>
{% if vip %}<p>尊敬的会员,欢迎回来</p>{% else %}<p>欢迎使用 {{ appName }}</p>{% endif %}| 分段 | 说明 |
|----|----|
| subject | 邮件主题(文本模式渲染) |
| description | 模板描述 |
| variables | 变量声明 JSON 数组 [{ name, label, example }],name 支持 a.b 路径 |
| html | 邮件正文(HTML 模式渲染,变量自动转义) |
| text | 短信正文;邮件中作为纯文本备用正文,缺省时由 html 自动转换 |
| 其它 | 进入 extra,按文本模式渲染后传给发送器,如短信服务商的 templateCode |
没有任何分段标记时,整个文件作为正文。语法不在白名单内(如过滤器、for 循环、<%= %>)的文件会被跳过并记录错误日志。
模板命名规则
模板文件命名格式:code_type_name.liquid
- code: 模板编码
- type: 模板类型(可选,默认为0)
- 0: 邮件
- 1: 短信
- name: 模板名称(可选,默认使用code)
示例:welcome_0_欢迎邮件.liquid、verify_1_验证码.liquid
模板选择规则
sendMessage 未传 level 时,先使用启用中的同编码业务模板(level 1),没有再使用系统模板(level 0)。因此可以在后台把系统模板复制为业务模板(复制后保留编码、默认停用),修改后启用即可替换线上内容;停用业务模板后自动回退到系统模板。停用的模板不能发送。
租户模板与租户 SMTP
tenantId 是由宿主项目定义的租户标识(字符串),可以是 fastify-tenant 的租户,也可以是业务自己的"客户"、"组织"等实体 id;插件不校验租户是否存在,也不依赖任何租户插件。模板带 tenantId 字段(空字符串为全局模板),唯一索引为 code + type + level + tenantId。
sendMessage传tenantId时,先在该租户启用的模板中按上面的规则选择,没有再回退到全局模板;停用租户模板即恢复全局内容- 邮件发送时统一调用
services.mailSetting.getEmailConfig({ tenantId }):传了插件配置getEmailConfig时内部转调宿主实现,没传时读包内t_message_tenant_email_config;返回配置则用租户 SMTP 发送,发件人为fromName/fromAddress(缺省为user),不再走senders[0];返回空则沿用全局emailConfig/senders - 发送记录保存
tenantId,重发与启动补发同样使用租户模板和 SMTP template.copy({ id, tenantId })把全局模板复制为租户模板;模板列表接口不传filter[tenantId]时只返回全局模板verifyEmailConfig({ config, to })校验 SMTP 能否登录,传to时再发一封测试邮件
租户消息设置(mailSetting)
插件自带租户消息设置的默认实现(2.1.1 起),开箱即用;用 @kne/fastify-tenant 时可继续用它的实现,两者通过 getEmailConfig 切换:
- 内置:不传
getEmailConfig时,services.mailSetting用内置表t_message_tenant_email_config存租户 SMTP,并提供smtpDetail/smtpSave/smtpRemove/smtpTest与templateList/templateDetail/templateCustomize/templateSave/templateSetStatus/templateReset/templatePreview;发送时自动读取该租户已启用的配置 - 外置:传了
getEmailConfig(如fastify.tenant.services.mail.resolveEmailConfig)时,services.mailSetting.getEmailConfig内部转调宿主实现,内置表不再参与发送;其余方法读写内置表,行为不变 - 方法集合固定:
services.mailSetting在任何插件参数下都完整挂载同一套方法(含getEmailConfig/smtpSave/smtpRemove/smtpTest与全部template*),增删参数不会让某个方法变成undefined - 只按
getEmailConfig有无决定发送取值来源,没有额外开关;租户鉴权与租户是否存在由宿主项目负责,插件只要求tenantId非空
租户模板开放范围(tenantTemplate)
系统模板可给租户定制的范围(白名单 ∩ 平台开关),mailSetting 的模板方法复用同一份判断:
- 白名单:插件配置
mailSettingTemplateCodes,只有其中的全局系统模板(level 0)可以给租户定制,不传时为全部 - 平台开关:白名单内的模板默认开放(
tenantOpen为true),平台在消息模板管理中通过POST /templates/:id/tenant-open逐个关闭;GET /templates每条返回tenantCustomizable标明能否设置 services.tenantTemplate.list/get返回白名单 ∩ 已开放的系统模板,供 fastify-tenant 的租户端使用;平台代租户管理时传includeTenantClosed: true,只受白名单限制
模板目录中删除的文件,对应的系统模板会在下次启动时被停用(不删除,保留发送记录关联);文件恢复后需在后台手动启用。
使用示例
// 发送邮件
await fastify.message.services.sendMessage({
type: 0, // 0: 邮件
name: '[email protected]', // 收件人
code: 'welcome', // 模板编码
props: { // 模板变量
username: 'John',
appName: 'LeapIn'
},
options: {
title: '系统通知', // 发件人名称
attachments: [] // 附件列表,内容不入库,发送记录只保存文件名等描述
},
userId, // 可选,发送人
async: false // 为 true 时立即返回待发送记录,后台发送
});
// 重发失败记录
await fastify.message.services.record.retry({ id: recordId });
// 导入模板文件
await fastify.message.services.includeTemplate('./messageTemplate');从 1.x 升级到 2.0
2.0 将模板引擎从 lodash template(EJS 风格)切换为 Liquid,属于破坏性变更:
- 模板文件中的
<%= name %>改为{{ name }},需要输出原始 HTML 的变量改为{{ name | raw }};<% if (a) { %>…<% } %>改为{% if a %}…{% endif %} - 模板文件扩展名由
.ejs改为.liquid(.ejs文件不再导入) - 数据库无需手工执行 SQL:启动时(
autoUpgrade默认开启)自动为模板表、记录表补齐新字段,把code + type + level + tenantId索引改为唯一索引并创建记录status索引,PostgreSQL / MySQL / SQLite 通用。存在重复编码时跳过唯一索引并输出告警,处理重复数据后重启即可;也可以手动调用services.upgradeSchema() - 数据库中已有模板自动迁移(
services.migrateTemplates()):1.x 的合并内容拆成subject/content/extra,<%= x %>、<%- x %>改为{{ x }}(2.0 默认转义输出);含<% if %>等逻辑语法的模板会跳过并在日志中列出,需在后台人工改写 - 短信发送器收到的
content为{ text, ...extra },邮件为{ subject, html, text, ...extra } sendMessage未传level时会优先使用同编码的启用业务模板;只想用系统模板时显式传level: 0- 发送失败在模块内部处理:写入发送记录(
status = 1,error为原因)、输出错误日志并计入失败统计,sendMessage不会返回 rejected Promise,业务按返回记录的status判断即可 - 记录表
content字段类型由JSONB改为JSON(已有 PostgreSQL 表无需修改)
安全提示
{{ name | raw }} 会原样输出 HTML,变量来自用户输入时有注入风险,只用于可信内容。发件人名称 options.title 会自动去除换行并转义引号。
示例
API
HTTP 接口 (API)
基础路径:/api/v1/message
GET /statistics
获取消息统计数据概览。
权限类型:statistics
查询参数:
| 参数 | 类型 | 必填 | 默认值 | 描述 | |------|--------|----|-----|-----------------------------------| | range | String | 否 | 7d | 时间范围: 7d=近7天, 1m=近1个月, 1y=近1年 |
响应示例:
{
"range": "7d",
"rangeLabel": "近7天",
"totalRecords": 256,
"byType": {
"0": 200,
"1": 56
},
"byCode": {
"welcome": 100,
"verify": 80,
"notify": 76
},
"templateStats": {
"total": 5,
"byStatus": {
"0": 4,
"1": 1
},
"byType": {
"0": 3,
"1": 2
}
},
"recentTrend": [
{ "date": "2026-05-07", "count": 10 },
{ "date": "2026-05-08", "count": 15 },
{ "date": "2026-05-09", "count": 8 }
],
"recentTrendByType": [
{ "date": "2026-05-07", "type": 0, "count": 8 },
{ "date": "2026-05-07", "type": 1, "count": 2 },
{ "date": "2026-05-08", "type": 0, "count": 12 },
{ "date": "2026-05-08", "type": 1, "count": 3 }
]
}响应字段说明:
| 字段 | 类型 | 描述 | |------------------------|--------|---------------------------| | range | String | 当前时间范围参数 | | rangeLabel | String | 时间范围中文描述 | | totalRecords | Number | 时间范围内发送记录数 | | byType | Object | 按消息类型统计(键:0=邮件,1=短信) | | byCode | Object | 按模板编码统计 | | templateStats | Object | 模板统计信息 | | templateStats.total | Number | 模板总数 | | templateStats.byStatus | Object | 按模板状态统计(键:0=启用,1=禁用) | | templateStats.byType | Object | 按模板类型统计(键:0=邮件,1=短信) | | recentTrend | Array | 时间范围内发送趋势 | | recentTrendByType | Array | 时间范围内按类型发送趋势 |
GET /statistics/sse
SSE 实时推送当天消息统计数据。
权限类型:statistics
查询参数:
| 参数 | 类型 | 必填 | 默认值 | 描述 | |----------|--------|----|-----|-----------------------| | interval | Number | 否 | 5 | 推送间隔时间(秒),最小1秒,默认5秒 |
说明:该接口为 Server-Sent Events (SSE) 接口,仅推送当天的实时统计数据,建立连接后按指定间隔自动推送。数据按小时粒度展示发送趋势。
事件格式:
data事件:推送当天统计数据(JSON格式)error事件:推送错误信息
响应示例:
{
"date": "2026-05-13",
"totalRecords": 42,
"byType": {
"0": 35,
"1": 7
},
"byCode": {
"welcome": 20,
"verify": 12,
"notify": 10
},
"hourlyTrend": [
{ "hour": 8, "count": 5 },
{ "hour": 9, "count": 12 },
{ "hour": 10, "count": 8 }
],
"hourlyTrendByType": [
{ "hour": 8, "type": 0, "count": 4 },
{ "hour": 8, "type": 1, "count": 1 },
{ "hour": 9, "type": 0, "count": 10 },
{ "hour": 9, "type": 1, "count": 2 }
]
}响应字段说明:
| 字段 | 类型 | 描述 | |--------------------------|--------|-----------------------------| | date | String | 当天日期 | | totalRecords | Number | 当天发送记录数 | | byType | Object | 按消息类型统计(键:0=邮件,1=短信) | | byCode | Object | 按模板编码统计 | | hourlyTrend | Array | 按小时发送趋势 | | hourlyTrend[].hour | Number | 小时(0-23) | | hourlyTrend[].count | Number | 发送数量 | | hourlyTrendByType | Array | 按小时按类型发送趋势 | | hourlyTrendByType[].hour | Number | 小时(0-23) | | hourlyTrendByType[].type | Number | 消息类型 | | hourlyTrendByType[].count | Number | 发送数量 |
使用示例:
// 默认5秒推送一次
const eventSource = new EventSource('/api/v1/message/statistics/sse');
// 每10秒推送一次
const eventSource = new EventSource('/api/v1/message/statistics/sse?interval=10');
// 注意:浏览器原生 EventSource 不支持自定义 headers,
// 如需认证可通过 URL 参数传递 token,或使用第三方库如 eventsource-parser
eventSource.onmessage = (event) => {
const statistics = JSON.parse(event.data);
console.log('当天实时统计:', statistics);
};
eventSource.addEventListener('error', (event) => {
const error = JSON.parse(event.data);
console.error('统计错误:', error);
});GET /records
获取消息发送记录列表。
权限类型:record
查询参数:
| 参数 | 类型 | 必填 | 默认值 | 描述 | |--------------|--------|----|-----|----------------------| | currentPage | Number | 否 | 1 | 页码 | | perPage | Number | 否 | 20 | 每页数量 | | filter[type] | Number | 否 | - | 发送类型:0=邮件,1=短信 | | filter[code] | String | 否 | - | 模板编码(精确匹配) | | filter[name] | String | 否 | - | 发送对象/邮箱/手机号(精确匹配) | | filter[status] | Number | 否 | - | 发送状态:0=成功,1=失败,2=待发送 | | filter[templateId] | String | 否 | - | 模板ID | | filter[startTime] | String | 否 | - | 发送时间起(ISO 时间,含) | | filter[endTime] | String | 否 | - | 发送时间止(ISO 时间,含) |
响应示例:
{
"totalCount": 100,
"currentPage": 1,
"perPage": 20,
"pageData": [
{
"id": "1",
"name": "[email protected]",
"type": 0,
"code": "welcome",
"props": { "username": "John" },
"content": { "subject": "欢迎", "html": "<p>你好</p>" },
"status": 0,
"error": null,
"retryCount": 0,
"templateId": "1",
"userId": null,
"createdAt": "2026-05-12T10:00:00.000Z",
"updatedAt": "2026-05-12T10:00:01.000Z"
}
]
}GET /records/:id
获取单条发送记录详情。
权限类型:record
路径参数:
| 参数 | 类型 | 必填 | 描述 | |----|--------|----|------| | id | String | 是 | 记录ID |
错误响应:
| 状态码 | 描述 | |-----|------| | 404 | 记录不存在 |
POST /records/:id/retry
按模版当前内容和记录中的变量重发失败或待发送的记录,返回更新后的记录。
权限类型:record:retry
路径参数:
| 参数 | 类型 | 必填 | 描述 | |----|--------|----|------| | id | String | 是 | 记录ID |
错误响应:
| 状态码 | 描述 | |-----|------| | 400 | 记录已发送成功,或模版已禁用 | | 404 | 记录或模版不存在 |
GET /templates
获取消息模版列表。
权限类型:template
查询参数:
| 参数 | 类型 | 必填 | 默认值 | 描述 |
|-----------------|--------|----|-----|------------------|
| currentPage | Number | 否 | 1 | 页码 |
| perPage | Number | 否 | 20 | 每页数量 |
| filter[type] | Number | 否 | - | 模版类型:0=邮件,1=短信 |
| filter[code] | String | 否 | - | 模版编码(精确匹配) |
| filter[level] | Number | 否 | - | 模版级别:0=系统,1=业务 |
| filter[status] | Number | 否 | - | 状态:0=启用,1=禁用 |
| filter[tenantId] | String | 否 | - | 所属租户,不传时只返回全局模版 |
| filter[keyword] | String | 否 | - | 按模版名称、编码模糊搜索(不区分大小写,%、_ 按字面匹配) |
每条模版额外返回 tenantCustomizable:是否为 mailSettingTemplateCodes 白名单内的全局系统模版,为 true 时可通过 POST /templates/:id/tenant-open 设置是否开放给租户。
响应示例:
{
"totalCount": 10,
"currentPage": 1,
"perPage": 20,
"pageData": [
{
"id": "1",
"name": "欢迎邮件",
"code": "welcome",
"type": 0,
"level": 0,
"status": 0,
"subject": "欢迎 {{ username }}",
"content": "<p>你好,{{ username }}</p>",
"description": "注册成功后发送",
"variables": [{ "name": "username", "label": "用户名", "example": "张三" }],
"extra": {},
"tenantOpen": true,
"tenantCustomizable": true,
"createdAt": "2026-05-12T10:00:00.000Z",
"updatedAt": "2026-05-12T10:00:00.000Z"
}
]
}GET /templates/:id
获取单个消息模版详情。
权限类型:template
路径参数:
| 参数 | 类型 | 必填 | 描述 | |----|--------|----|------| | id | String | 是 | 模版ID |
错误响应:
| 状态码 | 描述 | |-----|------| | 404 | 模版不存在 |
POST /templates
新建业务模版(level=1,默认启用),保存前校验 Liquid 语法白名单。
权限类型:template:manage
请求体:
| 参数 | 类型 | 必填 | 描述 |
|-------------|--------|----|--------------------------------------|
| name | String | 是 | 模版名称 |
| code | String | 是 | 模版编码,同类型业务模版内唯一 |
| type | Number | 是 | 模版类型:0=邮件,1=短信 |
| subject | String | 否 | 邮件主题模版 |
| content | String | 否 | 模版正文:邮件为 HTML,短信为纯文本 |
| description | String | 否 | 模版描述 |
| variables | Array | 否 | 变量声明 [{ name, label, example }] |
| extra | Object | 否 | 其它分段,如短信服务商模版编码 |
错误响应:
| 状态码 | 描述 | |-----|--------------------| | 400 | 模版语法错误 | | 409 | 模版编码已存在 |
PUT /templates/:id
修改业务模版,类型不可修改,请求体字段同新建(均为可选)。
权限类型:template:manage
错误响应:
| 状态码 | 描述 | |-----|-----------------------| | 400 | 模版语法错误 | | 403 | 系统模版不可修改,可复制后修改副本 | | 404 | 模版不存在 | | 409 | 模版编码已存在 |
POST /templates/:id/status
启用 / 禁用模版,禁用后无法通过接口发送。
权限类型:template:manage
| 参数 | 类型 | 必填 | 描述 | |--------|--------|----|--------------| | status | Number | 是 | 0=启用,1=禁用 |
POST /templates/:id/tenant-open
设置系统模版是否开放给租户端定制。只能设置白名单内的全局系统模版(tenantCustomizable 为 true),其它返回 403。关闭后租户端(tenantTemplate.list / get)不再返回、也不能定制该模版,已定制的租户模版仍按原状态参与发送;平台代租户管理(传 includeTenantClosed)不受影响。
权限类型:template:manage
请求体:
| 参数 | 类型 | 必填 | 描述 | |------------|---------|----|---------| | tenantOpen | Boolean | 是 | 是否开放给租户 |
POST /templates/:id/copy
复制为禁用状态的业务模版副本,与已有业务模版编码冲突时追加 _copy 后缀。系统模版可通过复制得到可编辑的副本。
权限类型:template:manage
DELETE /templates/:id
删除业务模版,系统模版返回 403。
权限类型:template:manage
POST /templates/render
渲染模版(预览)。传 template 时渲染未保存的内容,props 不传时使用 variables[].example。
权限类型:template
请求体:
| 参数 | 类型 | 必填 | 描述 |
|------------|--------|----|-----------------------------------------------------|
| templateId | String | 否 | 模版ID,未传 template 时必填 |
| template | Object | 否 | 未保存的模版 { type, subject, content, variables, extra } |
| props | Object | 否 | 模版变量 |
响应示例:
{
"type": 0,
"subject": "欢迎 张三",
"html": "<p>你好,张三</p>",
"text": "你好,张三",
"props": { "username": "张三" }
}短信返回 { type: 1, text, props, ...extra }。语法错误返回 400。
POST /templates/send
根据模版发送消息。模版类型由模版自身决定,无需指定。
权限类型:template:send
请求体:
| 参数 | 类型 | 必填 | 描述 | |-----------|--------|----|---------------| | templateId | String | 是 | 模版ID | | name | String | 是 | 发送对象(邮箱/手机号) | | props | Object | 否 | 模版变量 |
请求示例:
{
"templateId": "1",
"name": "[email protected]",
"props": {
"username": "John",
"content": "Welcome!"
}
}响应示例:
{
"success": true,
"recordId": "12"
}投递失败时同样写入发送记录(status = 1),接口仍返回 200,success 为 false,失败原因见发送记录。
错误响应:
| 状态码 | 描述 | |-----|------------| | 400 | 模版已禁用,无法发送消息 | | 404 | 模版不存在 |
服务方法 (Services)
includeTemplate
从指定目录导入 .liquid / .html / .txt 模板文件到数据库(level=0,同编码同类型覆盖)。语法校验不通过或扩展名不支持的文件会被跳过并记录日志;目录中已不存在的系统模板会被停用。
| 参数 | 类型 | 必填 | 描述 |
|-----|--------|----|----------|
| dir | String | 是 | 模板文件目录路径 |
| options.disableMissing | Boolean | 否 | 是否停用目录中已不存在的系统模板,默认 true |
| 返回值 | 类型 | 描述 | |-----|---------------|------| | - | Promise | 无返回值 |
示例:
await fastify.message.services.includeTemplate('./templates');messageTemplate
根据模板编码和参数生成消息内容。未传 level 时优先使用启用的业务模板(level 1),没有再使用系统模板;模板不存在抛出 404,模板已停用抛出 400(错误对象带 statusCode)。
| 参数 | 类型 | 必填 | 默认值 | 描述 | |-------|--------|----|-----|------------| | code | String | 是 | - | 模板编码 | | type | Number | 否 | 0 | 模板类型(0:邮件) | | level | Number | 否 | - | 模板级别,不传时业务模板优先 | | tenantId | String | 否 | - | 租户ID,传入时优先使用该租户启用的模板,没有再回退全局模板 | | props | Object | 是 | - | 模板变量 |
| 返回值字段 | 类型 | 描述 | |-----------------|--------|--------| | content.subject | String | 邮件主题 | | content.html | String | HTML内容 | | content.text | String | 纯文本内容 | | props | Object | 模板变量 | | code | String | 模板编码 | | type | Number | 模板类型 | | level | Number | 实际使用的模板级别 | | templateId | Number | 模板ID |
示例:
const message = await fastify.message.services.messageTemplate({
code: 'welcome',
props: {
username: 'John',
content: 'Welcome!'
}
});parseTemplate
解析模板文本中的注释分段,返回 { [分段名]: 内容 },没有分段标记时返回空对象。
| 参数 | 类型 | 必填 | 描述 | |------|--------|----|--------| | text | String | 是 | 模板文本内容 |
parseTemplateFile
把模板文件内容解析为模版字段 { subject, description, variables, content, extra }:邮件正文取 html 分段,短信正文取 text 分段,其余分段进入 extra。
| 参数 | 类型 | 必填 | 默认值 | 描述 | |------|--------|----|-----|---------------| | text | String | 是 | - | 模板文件内容 | | type | Number | 否 | 0 | 模板类型:0=邮件,1=短信 |
validateTemplate
校验 { subject, content, extra } 的 Liquid 语法白名单(基于 @kne/message-template 的 validateTemplateHtml),不通过时抛出 Error('模版语法错误(字段): 原因')。
renderMessage
渲染模版,发送与预览共用。
| 参数 | 类型 | 必填 | 描述 |
|----------|--------|----|----------------------------------------------|
| template | Object | 是 | { type, subject, content, extra } |
| props | Object | 否 | 模板变量 |
- 邮件:返回
{ subject, html, text, ...extra },subject文本模式渲染,html变量自动转义({{ name | raw }}原样输出),text缺省时由 html 转换 - 短信:返回
{ text, ...extra },文本模式渲染不转义
sendMessage
发送消息(邮件/短信),先写入待发送记录,发送后更新为成功或失败。按插件配置 retry: { times, delay } 重试;SMTP 连接按配置复用连接池,插件关闭时释放。
| 参数 | 类型 | 必填 | 默认值 | 描述 |
|---------|--------|----|-----|---------------|
| type | Number | 否 | 0 | 消息类型(0:邮件) |
| name | String | 是 | - | 接收者(邮件地址/手机号) |
| props | Object | 否 | {} | 模板变量 |
| code | String | 是 | - | 模板编码 |
| level | Number | 否 | - | 模板级别,不传时业务模板优先 |
| tenantId | String | 否 | - | 租户ID(宿主项目自定义):优先使用租户模板;邮件通过 services.mailSetting.getEmailConfig 取租户 SMTP(内置/外置由插件参数 getEmailConfig 决定),写入记录供重发使用 |
| client | Object | 否 | - | 客户端配置(覆盖默认配置) |
| options | Object | 否 | - | 发送选项 |
| userId | String | 否 | - | 发送人,写入记录 |
| async | Boolean | 否 | false | 为 true 时立即返回待发送记录,后台发送 |
返回发送记录(Record 模型实例),不会 reject:投递失败、模版不存在或已停用时记录 status = 1、error 为原因,并输出错误日志、计入失败统计;业务通过 record.status 判断结果。仅发送记录本身写库失败时返回 null(同样只记日志)。
options 参数详情:
| 字段 | 类型 | 必填 | 描述 |
|-------------|--------|----|-------|
| title | String | 否 | 发件人名称(自动去除换行、转义引号) |
| subject | String | 否 | 邮件主题 |
| attachments | Array | 否 | 附件列表,未传时使用插件配置 attachments;发送记录中只保存 filename 等描述,不保存附件内容 |
示例:
await fastify.message.services.sendMessage({
type: 0,
name: '[email protected]',
code: 'welcome',
props: {
username: 'John',
content: 'Welcome!'
},
options: {
title: 'System',
attachments: [
{
filename: 'welcome.pdf',
path: './welcome.pdf'
}
]
}
});verifyEmailConfig
校验 SMTP 配置能否连接并登录,传 to 时再发送一封测试邮件。失败抛出 400(message 为 SMTP 错误原因)。
| 参数 | 类型 | 必填 | 描述 |
|---------|--------|----|----|
| config | Object | 是 | { host, port, secure, user, pass, fromName, fromAddress },port 默认 465、secure 默认 true |
| to | String | 否 | 测试邮件收件人 |
| subject | String | 否 | 测试邮件主题 |
| html | String | 否 | 测试邮件正文 |
返回 { success: true }。
tenantTemplate
系统模板开放给租户的范围:mailSettingTemplateCodes 白名单 ∩ 平台 tenantOpen 开关。这里只提供范围查询,租户 SMTP 存储与租户模板副本的增删改查见 mailSetting(自带默认实现;接 @kne/fastify-tenant 时也可用它的实现,鉴权始终由宿主负责)。
| 方法 | 参数 | 说明 |
|----|----|----|
| list | { tenantId, type, includeTenantClosed } | 可给该租户定制的系统模板(全局、启用、level 0),按 code 排序;type 为 0 邮件(默认)/ 1 短信,其它返回 400;includeTenantClosed 为 true 时忽略平台开关(平台代管) |
| get | { tenantId, code, type, includeTenantClosed } | 取单个可定制系统模板;不在白名单返回 403,不存在或未开放返回 404 |
| isCustomizable | template | 是否为白名单内的全局系统模板 |
| setTenantOpen | { id, tenantOpen } | 设置系统模板是否开放给租户,同 POST /templates/:id/tenant-open |
| withTenantCustomizable | list | 给模板列表逐条加上 tenantCustomizable,GET /templates 使用 |
可定制的模板为全局、启用、level = 0 的邮件和短信模板;除 templateList 外,type 缺省为 0(邮件),其它值返回 400。配置了插件参数 mailSettingTemplateCodes(数组或 ({ tenantId, type }) => codes,平台端判断 tenantCustomizable 时不传 tenantId)时只允许白名单内的编码,其它编码返回 403。
模板的 tenantOpen(默认 true)为 false 时,template* 方法视为该模板不存在(列表不返回,其它方法返回 404);平台代租户管理时传 includeTenantClosed: true 忽略该限制。租户端由宿主自行鉴权后调用,不要传 includeTenantClosed。
mailSetting
租户消息设置的包内默认实现(2.1.1 起):租户 SMTP 配置存储 + 系统模板的租户定制。services.mailSetting 的方法集合固定,任何插件参数下都完整挂载同一套方法,不会因为传了 getEmailConfig 而少挂某个方法;插件参数只影响发送取值:getEmailConfig 是唯一入口,传了宿主实现时由它内部转调(此时包内 SMTP 表不参与发送),没传时读包内表。
| 方法 | 参数 | 说明 |
|----|----|----|
| getEmailConfig | { tenantId } | 发送取值入口。传了插件参数 getEmailConfig 时原样返回宿主实现的结果,否则返回该租户启用的包内配置(含 pass);未配置、停用或 tenantId 为空返回 null,发送回退到全局 emailConfig |
| smtpDetail | { tenantId } | 配置详情,不回传 pass 只给 hasPassword;未配置返回 { configured: false, enabled: false, port: 465, secure: true, hasPassword: false } |
| smtpSave | { tenantId, ...SMTP 字段 } | 保存配置;pass 为空时保留原密码;enabled !== false 时先做连接测试,失败返回 400 且不落库 |
| smtpRemove | { tenantId } | 删除该租户配置 |
| smtpTest | { tenantId, to, ...SMTP 字段 } | 用已保存密码合并后连接测试,传 to 时再发一封测试邮件 |
| templateList | { tenantId, type, codes, includeTenantClosed } | 可定制模板列表并标注租户定制状态;不传 type 时邮件在前、短信在后;codes 在可定制范围内再取交集 |
| templateDetail | { tenantId, code, type, includeTenantClosed } | { customized, template },未定制时返回系统模板 |
| templateCustomize | { tenantId, code, type, userId, includeTenantClosed } | 以系统模板为底稿创建租户副本,保留编码与名称并直接启用;已定制则直接返回 |
| templateSave | { tenantId, code, type, subject, content, description, userId, includeTenantClosed } | 只改主题、正文与说明;未定制时保存即创建副本,校验失败不留副本 |
| templateSetStatus | { tenantId, code, type, status, includeTenantClosed } | 启停租户副本;未定制返回 404 |
| templateReset | { tenantId, code, type, includeTenantClosed } | 删除租户副本,恢复使用系统模板;未定制返回 404 |
| templatePreview | { tenantId, code, type, template, props, includeTenantClosed } | 传 template 时渲染未保存内容,变量定义沿用当前生效模板 |
| withTenantCustomizable | list | 2.0.x 兼容入口,等同于 tenantTemplate.withTenantCustomizable |
| setTenantOpen | { id, tenantOpen } | 2.0.x 兼容入口,等同于 tenantTemplate.setTenantOpen |
tenantId 必填,为空返回 400(统一转成字符串落库,避免数字与字符串产生两份配置)。
可定制范围(白名单 ∩ tenantOpen ∩ level = 0 ∩ 启用)复用 tenantTemplate,mailSetting 只在其之上维护租户副本(level = 1 + tenantId)。租户副本启用后,messageTemplate 与发送只覆盖该租户,其它租户与全局不受影响。
statistics.getOverview
获取消息统计数据概览。
| 参数 | 类型 | 必填 | 默认值 | 描述 | |------|--------|----|-----|-----------------------------------| | range | String | 否 | 7d | 时间范围: 7d=近7天, 1m=近1个月, 1y=近1年 |
| 返回值字段 | 类型 | 描述 | |---------------------------|--------|---------------------------| | range | String | 当前时间范围参数 | | rangeLabel | String | 时间范围中文描述 | | totalRecords | Number | 时间范围内发送记录数 | | byType | Object | 按消息类型统计(键:0=邮件,1=短信) | | byCode | Object | 按模板编码统计 | | templateStats | Object | 模板统计信息 | | templateStats.total | Number | 模板总数 | | templateStats.byStatus | Object | 按模板状态统计(键:0=启用,1=禁用) | | templateStats.byType | Object | 按模板类型统计(键:0=邮件,1=短信) | | recentTrend | Array | 时间范围内发送趋势 | | recentTrend[].date | String | 日期 | | recentTrend[].count | Number | 发送数量 | | recentTrendByType | Array | 时间范围内按类型发送趋势 | | recentTrendByType[].date | String | 日期 | | recentTrendByType[].type | Number | 消息类型 | | recentTrendByType[].count | Number | 发送数量 |
示例:
// 近7天统计(默认)
const stats = await fastify.message.services.statistics.getOverview();
// 近1个月统计
const stats = await fastify.message.services.statistics.getOverview({ range: '1m' });
// 近1年统计
const stats = await fastify.message.services.statistics.getOverview({ range: '1y' });
console.log('时间范围:', stats.rangeLabel);
console.log('发送数:', stats.totalRecords);statistics.getRealtime
获取当天实时统计数据,按小时粒度展示发送趋势。
| 返回值字段 | 类型 | 描述 | |--------------------------------|--------|-----------------------------| | date | String | 当天日期 | | totalRecords | Number | 当天发送记录数 | | byType | Object | 按消息类型统计(键:0=邮件,1=短信) | | byCode | Object | 按模板编码统计 | | hourlyTrend | Array | 按小时发送趋势 | | hourlyTrend[].hour | Number | 小时(0-23) | | hourlyTrend[].count | Number | 发送数量 | | hourlyTrendByType | Array | 按小时按类型发送趋势 | | hourlyTrendByType[].hour | Number | 小时(0-23) | | hourlyTrendByType[].type | Number | 消息类型 | | hourlyTrendByType[].count | Number | 发送数量 |
示例:
const realtime = await fastify.message.services.statistics.getRealtime();
console.log('当天日期:', realtime.date);
console.log('当天发送数:', realtime.totalRecords);
console.log('按小时趋势:', realtime.hourlyTrend);record.list
查询发送记录列表。
| 参数 | 类型 | 必填 | 默认值 | 描述 | |------------|--------|----|-----|------| | filter | Object | 否 | {} | 过滤条件 | | perPage | Number | 否 | 20 | 每页数量 | | currentPage | Number | 否 | 1 | 页码 |
filter 为 Sequelize where 条件,HTTP 接口会转换 type、code、name、status、templateId、tenantId 与时间范围
record.detail
获取单条发送记录详情,不存在时抛出 404。
| 参数 | 类型 | 必填 | 描述 | |----|--------|----|------| | id | String | 是 | 记录ID |
record.retry
重发失败或待发送的记录,参数 { id },返回更新后的记录;再次投递失败不抛错,记录保持 status = 1。记录不存在(404)、已成功(400)、模版缺失或停用时抛错。
processPendingRecords
补发所有待发送(status = 2)的记录,插件启动时自动执行(isTest 时不执行)。返回 { total }。
upgradeSchema
为模板表、记录表补齐缺失字段(含 tenantId),把 code + type + level(或 2.0.0 的同名唯一索引)改为 code + type + level + tenantId 唯一索引(先释放已删除模板的编码;存在重复数据时跳过并告警),并创建记录表 status 索引。这两个索引不在模型中声明(1.x 老库执行 sequelize.sync 时新列尚不存在,建索引会失败并阻断启动),新库同样由本方法创建。PostgreSQL / MySQL / SQLite 通用,autoUpgrade 开启时启动自动执行。返回 { template: [新增列], record: [新增列], uniqueIndex: 'created' | 'exists' | 'duplicated' | 'failed' | 'skipped', statusIndex: 'created' | 'exists' | 'skipped' }。
migrateTemplates
迁移 1.x 模板内容:合并分段拆成 subject / content / extra,<%= x %>、<%- x %> 改为 {{ x }};含 EJS 逻辑语法或迁移后校验不通过的模板跳过。autoUpgrade 开启时启动自动执行。返回 { migrated: [...], skipped: [{ id, code, type, level, reason }] }。
template.list
查询消息模版列表。
| 参数 | 类型 | 必填 | 默认值 | 描述 | |------------|--------|----|-----|------| | filter | Object | 否 | {} | 过滤条件 | | perPage | Number | 否 | 20 | 每页数量 | | currentPage | Number | 否 | 1 | 页码 |
filter 支持的字段:type、code、level、status、tenantId;keyword 按名称、编码模糊搜索。服务方法不会自动限定 tenantId,需要只查全局模板时传 tenantId: ''
template.detail
获取单个消息模版详情。
| 参数 | 类型 | 必填 | 描述 | |----|--------|----|------| | id | String | 是 | 模版ID |
template.create / save / remove / setStatus / copy / render
对应 HTTP 接口的服务方法,参数与接口请求体一致(save / remove / setStatus / copy 额外传 id)。错误对象带 statusCode(400 语法错误、403 系统模版只读、404 模版不存在、409 编码重复)。
remove软删除前会把编码改写为原编码#deleted#id,删除后可重建同编码模板copy复制系统模板时保留编码、默认停用,启用后即覆盖同编码系统模板;传tenantId时复制为该租户的模板,只影响该租户的发送create可传tenantId创建租户模板,编码在同租户、同类型的业务模板内唯一render的props与variables[].example合并,props优先
template.send
根据模版ID发送消息。
| 参数 | 类型 | 必填 | 默认值 | 描述 | |-----------|--------|----|-----|---------------| | templateId | String | 是 | - | 模版ID | | name | String | 是 | - | 接收者(邮箱/手机号) | | props | Object | 否 | {} | 模版变量 | | userId | String | 否 | - | 发送人 |
| 返回值字段 | 类型 | 描述 | |---------|---------|--------| | success | Boolean | 是否发送成功 | | recordId | String | 发送记录ID |
错误(带 statusCode):
- 模版不存在 → 404
模版不存在 - 模版已禁用 → 400
模版已禁用,无法发送消息
示例:
const result = await fastify.message.services.template.send({
templateId: '1',
name: '[email protected]',
props: { username: 'John' }
});数据模型 (Models)
Template 模型
| 字段 | 类型 | 默认值 | 描述 |
|-----------|--------|-----|------------|
| id | Number | - | 主键 |
| code | String | - | 模板编码 |
| type | Number | 0 | 模板类型 |
| name | String | - | 模板名称 |
| subject | String | - | 邮件主题模版(Liquid) |
| content | String | - | 模板正文(Liquid):邮件为 HTML,短信为纯文本 |
| description | String | - | 模板描述 |
| variables | Array | - | 变量声明 [{ name, label, example }] |
| extra | Object | - | 其它分段(Liquid) |
| level | Number | 1 | 模板级别(0:系统,1:业务) |
| status | Number | 0 | 模板状态(0:启用) |
| tenantId | String | '' | 所属租户,空字符串为全局模板 |
| tenantOpen | Boolean | true | 系统模板是否开放给租户端定制(仍受 mailSettingTemplateCodes 限制) |
| createdAt | Date | - | 创建时间 |
| updatedAt | Date | - | 更新时间 |
索引:code + type + level + tenantId 唯一。
TenantEmailConfig 模型
租户 SMTP 配置,表名 t_message_tenant_email_config(paranoid: false)。
| 字段 | 类型 | 默认值 | 描述 | |-------------|---------|------|---------------------------| | id | Number | - | 主键 | | tenantId | String | - | 租户标识,由宿主项目定义(如客户 id) | | enabled | Boolean | true | 是否启用,停用时发送回退到全局配置 | | host | String | - | SMTP 服务器 | | port | Number | 465 | SMTP 端口 | | secure | Boolean | true | 是否使用 SSL | | user | String | - | SMTP 账号 | | pass | String | - | SMTP 密码,接口不回传 | | fromName | String | - | 发件人名称 | | fromAddress | String | - | 发件人地址,为空时使用 SMTP 账号 | | createdAt | Date | - | 创建时间 | | updatedAt | Date | - | 更新时间 |
索引:tenantId 唯一。
Record 模型
| 字段 | 类型 | 默认值 | 描述 | |------------|--------|-----|------| | id | Number | - | 主键 | | type | Number | - | 消息类型 | | code | String | - | 模板编码 | | templateId | Number | - | 模板ID | | props | Object | - | 模板变量 | | name | String | - | 接收者 | | content | Object | - | 实际发送内容(JSON),附件只保存描述 | | options | Object | - | 发送参数,重发时使用 | | status | Number | 0 | 发送状态(0:成功,1:失败,2:待发送) | | error | String | - | 失败原因 | | retryCount | Number | 0 | 重试次数 | | userId | Number | - | 发送人 | | tenantId | String | - | 所属租户,重发时使用租户模板与 SMTP | | createdAt | Date | - | 创建时间 | | updatedAt | Date | - | 更新时间 |
