@xtalpi/science-mail-sdk
v0.1.0
Published
Mail SDK for XtalPi Science Space
Keywords
Readme
@xtalpi/science-mail-sdk
XtalPi Science 客户服务空间邮件投递 SDK。
负责账户激活与密码重置邮件的模板、文案、输入校验、SMTP 传输、超时与传输层重试、错误分类与脱敏。不感知业务规则、部署环境与日志,全部运行参数由构造函数注入。
本包保持为纯 TypeScript 包,不依赖 NestJS、TypeORM、Space App Entity、数据库或对象存储。运行时仅依赖 nodemailer。
安装
pnpm add @xtalpi/science-mail-sdk使用
import { MailSdk, MailSendError } from '@xtalpi/science-mail-sdk';
const mail = new MailSdk({
transport: {
type: 'smtp',
host: 'smtp.example.com',
port: 465,
secure: true,
username: '[email protected]',
password: '***',
},
from: 'XtalPi Science <[email protected]>',
timeoutMs: 10_000,
maxRetries: 1,
});
const result = await mail.sendAccountActivation({
to: '[email protected]',
recipientName: 'Alice',
activationUrl: 'https://space.example.com/activate?token=...',
expiresAt: new Date(Date.now() + 72 * 3600_000),
audience: 'member',
});
// result.status === 'sent'传输方式
构造时通过 transport.type 选择 SMTP 或 Microsoft Graph,其余对外接口一致。
SMTP(见上例)适用于支持 SMTP 的服务商。Microsoft 365 / Exchange Online 推荐用 Graph:
const mail = new MailSdk({
transport: {
type: 'graph',
tenantId: '<entra-tenant-id>',
clientId: '<app-client-id>',
clientSecret: '***',
sender: '[email protected]', // Graph /users/{sender}/sendMail
},
from: 'XtalPi Science <[email protected]>',
timeoutMs: 10_000,
maxRetries: 1,
});Graph 方式走 OAuth2 客户端凭证(app-only),需在 Entra 为应用授予 Mail.Send(Application 类型)权限并经管理员同意;该授权属部署接入事项。Graph 发送成功时 messageId 固定为 null。
构造参数(MailSdkConfig)
调用方在 new MailSdk(config) 时注入运行参数;SDK 不读环境变量或配置文件。
| 参数 | 类型 | 说明 |
|---|---|---|
| transport | SmtpTransportConfig \| GraphTransportConfig | 传输方式,见上节;按 type 区分 |
| from | string | 发件人,形如 XtalPi Science <[email protected]> 或纯邮箱;Graph 时地址须与 transport.sender 一致 |
| timeoutMs | number | 单次发送/连通性检查超时(毫秒),必填正整数,无默认值 |
| maxRetries | number | 传输层可重试错误的额外重试次数,必填正整数;总尝试次数为 1 + maxRetries |
SMTP transport 字段:host、port、secure、username、password。
Graph transport 字段:tenantId、clientId、clientSecret、sender(发信邮箱,对应 Graph /users/{sender}/sendMail)。
配置不完整时构造函数同步抛出 MailSendError(code=invalid_input)。
发送方法与入参
sendAccountActivation(input):运营账户邀请、成员邀请及重发。sendPasswordReset(input):两个身份域的密码重置。verifyConnection():验证凭据与连通性,不发送邮件;无入参。
sendAccountActivation / sendPasswordReset 入参
两种方法字段几乎相同,仅链接字段名不同。业务层负责组装完整 URL(含一次性 Token)与过期时刻;SDK 只做校验与渲染。
| 参数 | 激活 | 重置 | 类型 | 约束与语义 |
|---|---|---|---|---|
| to | ✔ | ✔ | string | 收件人邮箱;合法格式、最长 255。业务层已做小写规范化时,SDK 不改变大小写 |
| recipientName | ✔ | ✔ | string | 正文称呼(Hi {name},);去除首尾空白后长度 1–255 |
| activationUrl | ✔ | — | string | 完整激活链接;http/https 绝对 URL,最长 2048;渲染为按钮 href 与备用纯文本链接 |
| resetUrl | — | ✔ | string | 完整重置链接;约束同 activationUrl |
| expiresAt | ✔ | ✔ | Date | 链接有效期截止时刻;须为有效 Date 且不早于调用时刻;邮件中统一渲染为 UTC,如 2026-11-13 09:28 UTC |
| audience | ✔ | ✔ | 'operator' \| 'member' | 选择身份域文案变体,不直接出现在正文 |
audience 与场景的组合决定主题与正文措辞(4 套固定变体):
| 方法 | audience | 用途 |
|---|---|---|
| sendAccountActivation | operator | 平台运营账户邀请/重发 |
| sendAccountActivation | member | 租户成员邀请/重发 |
| sendPasswordReset | operator | 运营侧密码重置 |
| sendPasswordReset | member | 成员侧密码重置 |
入参示例:
await mail.sendAccountActivation({
to: '[email protected]',
recipientName: 'Alice',
activationUrl: 'https://space.example.com/activate?token=...',
expiresAt: new Date(Date.now() + 72 * 3600_000),
audience: 'member',
});
await mail.sendPasswordReset({
to: '[email protected]',
recipientName: 'Bob',
resetUrl: 'https://space.example.com/operator/reset?token=...',
expiresAt: new Date(Date.now() + 3600_000),
audience: 'operator',
});入参校验失败(invalid_input)
发送前 SDK 按上表逐项校验;任一字段不满足约束即失败,不会发起 SMTP/Graph 网络请求。抛出的错误固定为:
error instanceof MailSendError
error.code === 'invalid_input'
error.retryable === false
error.message === 'The mail request input is invalid.' // 中性描述,不含具体字段值| 字段 | 含义 |
|---|---|
| code | 稳定分类码,供 Space App 写入 dispatch_error_code 或对客户端映射;入参问题一律为 invalid_input |
| retryable | 是否值得业务层触发显式重发。入参错误为 false:改对参数前重发没有意义,SDK 传输层也不会因该错误重试 |
| message | 已脱敏的英文说明;不含收件人、链接、Token 或哪个字段出错的细节 |
典型失败示例(均得到上述同一组 code / retryable):
| 参数 | 失败情形 |
|---|---|
| to | 空串、缺 @、含空白的非法格式、长度超过 255 |
| recipientName | 仅空白、trim 后为空、trim 后超过 255 |
| activationUrl / resetUrl | 相对路径、非 http/https(如 javascript:)、无法解析为 URL、长度超过 2048 |
| expiresAt | 非 Date、Invalid Date、时刻早于调用时刻 |
| audience | 非 'operator' / 'member' |
业务层处理建议:将 invalid_input 记为投递失败并修正入参后由运营/用户再次触发;不要按可重试依赖故障自动重发。
调用方传参与 SDK 内置内容的边界
调用方只传变量数据,不传主题、正文片段、HTML、多语言、抄送或附件。
| 邮件内容 | 由谁决定 |
|---|---|
| 收件人、称呼名、行动链接、过期时刻、身份域变体 | 上层传参(上表) |
| 发件人展示名与地址、传输凭据、超时与重试 | 构造参数 MailSdkConfig |
| 主题行、标题、正文说明、按钮文案、重置安全提示 | SDK 模板(按方法 × audience 选择) |
| 品牌名、配色、布局骨架、页脚、UTC 时间格式 | SDK 公共布局(写死) |
成功返回值 MailSentResult
| 字段 | 类型 | 说明 |
|---|---|---|
| status | 'sent' | 邮件服务已接受发送请求;不表示已进入收件箱或已读 |
| messageId | string \| null | 服务商侧消息标识;SMTP 可能有值,Graph 固定为 null |
| acceptedAt | Date | 接受发送请求的时间 |
错误处理
发送失败抛出 MailSendError:
try {
await mail.sendPasswordReset(input);
} catch (error) {
if (error instanceof MailSendError) {
// error.code:稳定分类码(见下表)
// error.retryable:是否值得业务层触发显式重发(业务层不自动重试)
// error.message:已脱敏中性英文,不含收件人、链接、Token 或凭据
}
throw error;
}| code | retryable | 含义 |
|---|---|---|
| invalid_input | false | 构造配置或发送入参校验失败 |
| authentication_failed | false | SMTP 凭据无效,或 Graph 鉴权/权限失败 |
| rate_limited | true | 服务商限流(如 SMTP 421/450/452、Graph 429) |
| dependency_unavailable | true | 网络失败、DNS 失败或上游 5xx |
| operation_timed_out | true | 单次尝试超过 timeoutMs |
| invalid_response | false | 上游响应无法解释 |
retryable=true 时 SDK 会按 maxRetries 在传输层再试;最终仍失败时,业务层仅在人工/用户显式重发场景下再次调用,不自动循环重试。
手动发送(真实收件箱)
用于核对全部模板变体的主题与排版。会向 MAIL_TO 连续发送 4 封:激活/重置 × operator/member。
cp .env.example .env # 填入真实凭据与收件邮箱
pnpm build
pnpm manual:send.env 不会被提交。用 MAIL_TRANSPORT 选择传输方式:
MAIL_TRANSPORT=smtp(默认):填MAIL_HOST/MAIL_PORT/MAIL_SECURE/MAIL_USER/MAIL_PASS。常见端口465+MAIL_SECURE=true,或587+MAIL_SECURE=false(由服务商做 STARTTLS)。MAIL_TRANSPORT=graph:填MAIL_GRAPH_TENANT_ID/MAIL_GRAPH_CLIENT_ID/MAIL_GRAPH_CLIENT_SECRET/MAIL_GRAPH_SENDER。
开发
pnpm typecheck
pnpm test # unit + e2e
pnpm test:unit
pnpm test:e2e
pnpm build
pnpm test:smoke # 针对构建产物
pnpm check # typecheck + test + build + smoke构建产物同时支持:
- ESM:
dist/esm/index.js - CommonJS:
dist/cjs/index.cjs - TypeScript 类型声明:
dist/esm/index.d.ts
执行 pnpm publish 前会通过 prepublishOnly 自动运行 check。
