Skip to content

plugin-email: 实现 SMTP transport —— 设置页可选 SMTP 但后端无实现 #5087

Description

@baozhoutao

问题

plugin-email 目前只有 Resend / Postmark 两个真实投递 transport,没有 SMTP transport。但 Mail Delivery 设置页(packages/services/service-settings/src/manifests/mail.manifest.ts)把 SMTP 列为默认 provider,并提供 host / port / TLS / username / password 完整表单——用户填完保存后,transport 实际原地不动(仍是 LogTransport),邮件只写日志和 sys_email 表,不会真正发出。界面承诺与后端实现之间存在缺口。

代码里的现状注释(packages/plugins/plugin-email/src/email-plugin.ts → applyMailSettings):

No SMTP transport ships in core; … Users wanting real SMTP install @objectstack/plugin-mail-smtp and configure it via constructor opts.

而 @objectstack/plugin-mail-smtp 这个包在仓库中并不存在,该指引目前无法落地。

影响

  • 选择 SMTP 的用户会误以为已配置成功(表单校验通过、保存成功),实际邮件静默不发——declared-but-not-delivered。
  • 国内部署场景无任何可用发信通道:Resend / Postmark 均为国外 SaaS,国内可达性与对国内邮箱(QQ / 163 等)的送达率均不可靠;国内正路是企业邮箱或阿里云邮件推送 / 腾讯云 SES 的 SMTP 通道,SMTP 一通即全部打通。

建议范围

  1. 在 plugin-email 新增 SmtpTransport(实现 IEmailTransport),支持 host / port / secure(TLS) / user / password,与 mail 设置命名空间现有字段(smtp_host / smtp_port / smtp_secure / smtp_user / smtp_password)对齐。
  2. applyMailSettings 中 provider === 'smtp' 分支从「no-op + 日志」改为真实构建/热替换 SMTP transport;mail/test 动作(发送测试邮件)同步走通。
  3. CLI serve 装配路径(OS_EMAIL_PROVIDER=smtp + 相应 env)打通。
  4. 实现方式待定:引入 nodemailer 依赖,或按仓库零依赖偏好(Resend/Postmark 均为纯 fetch)自研最小 SMTP 客户端——需要权衡 STARTTLS / AUTH 的实现成本。

不在本 issue 范围

设置页还列出 SendGrid / Amazon SES 选项,同样后端无实现——属另一问题,按一事一议另行处理(SMTP 打通后,这两家也可直接走各自的 SMTP 端点作为过渡)。

Activity

  1. changed the title [-]plugin-email: 实现 SMTP transport —— 设置页可选 SMTP 但后端无实现,国内部署无可用发信通道[/-] [+]plugin-email: 实现 SMTP transport —— 设置页可选 SMTP 但后端无实现[/+] on Aug 4, 2026
  2. self-assigned this
    on Aug 4, 2026
  3. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    认领:PM 循环第 1 轮
    会话:session_017MCKJaEomEqg4tvz4SzdNd
    分支:claude/issue-5087-smtp-transport
    Worktree:objectstack-issue-5087
    域:domain:services(落地包 packages/plugins/plugin-email,按「域 = 修复落地的包」定级,不按标题词汇)
    文件面:packages/plugins/plugin-email/**、packages/services/service-settings/src/manifests/mail.manifest.ts、packages/cli/src/commands/serve.ts、content/docs/deployment/environment-variables.mdx、.changeset/(越界即停,报告说明)

    分诊:前提核验(对 origin/main @ 30ff55b)

    issue 描述逐条属实,无过时前提:

    • git grep "SmtpTransport" origin/main -- packages/ 零命中;反查邻近词 ResendTransport / PostmarkTransport 命中正常,零命中成立。
    • @objectstack/plugin-mail-smtp 全仓只在注释与文档里出现(email-plugin.ts:478/499、content/docs/deployment/environment-variables.mdx:127/134),没有任何包,该指引确实无法落地。
    • mail.manifest.ts 的 provider 默认值就是 'smtp',smtp_host 还是 required: true;applyMailSettings 对 smtp 走的是 no-op 分支;mailTestActionHandler 一路返回 ok: true + 「Wire @objectstack/plugin-mail for actual delivery」——表单校验通过、测试按钮报成功、邮件不发,declared-but-not-delivered 成立。
    • 这几个文件今日无合并churn(最近一次 packages/plugins/plugin-email 相关合并是 feat(spec,runtime,hono)!: 入站 rateLimit 从零建 seam —— 授权预算真正产生 429 (#4910, #4937) #5006),不存在同日改写。

    顺带核实、不构成额外工作:smtp_password 虽未写 encrypted: true,但 SettingsService 的注册逻辑是 if (spec.encrypted || spec.type === 'password'),type: 'password' 已隐式入 encryptedKeys,静态存储侧无缺口——不要为此改 manifest。

    决策:第 4 条(nodemailer vs 自研最小 SMTP 客户端)取 nodemailer

    这一条不上升给维护者 —— 它不改变产品语义,也不改变公共契约形状(两种做法都实现同一个既有的 IEmailTransport),且仓库既有规范已经给出答案。这是 PM 的决定,维护者拥有否决窗口而非审批门。

    轴一 · 项目长远合理性。 「仓库零依赖偏好」经核验不是仓库规范:plugin-auth 直接依赖 better-auth / jose / @noble/hashes,driver-sql 依赖 knex 并把 pg/mysql2/tedious 列 peer,plugin-hono-server 依赖 hono。Resend/Postmark 之所以是纯 fetch,是因为那两家本身就是 HTTP API,不是因为存在禁依赖的规定。方向上,ADR-0012 明确记载「M1 ships smtp and sendgrid transports in core,SMTP is non-negotiable for on-prem/air-gapped(using nodemailer)」;ADR-0030 取代它的是分层与收件箱漂移部分,对 transport 只字未改,故该方向未被推翻。反过来,自研 SMTP 要正确覆盖 STARTTLS 协商、AUTH PLAIN/LOGIN/CRAM-MD5、MIME multipart、quoted-printable/base64、RFC 2047 中文主题头编码、dot-stuffing、超时与连接复用——这正是 Prime Directive #5 所禁的临时补丁式选项。

    轴二 · 防 AI 犯错。 半成品 SMTP 客户端会把本 issue 报的缺陷下沉一层:它对多数服务器可用、只对 163/QQ 这类对 AUTH 与 TLS 行为挑剔的服务器静默失败,比现在这个「原地不动」更难被发现,也更容易被后来的 agent 用「放宽一点就通了」的方式改坏协议实现。成熟库 + 响亮错误,是让 AI 在结构上更难写错的那一侧。

    配套约束(交派发词):nodemailer 作为 plugin-email 的正式 dependency,但在 SmtpTransport 内部惰性 await import(),使非 SMTP 部署与非 Node 运行时不会加载 node:net/node:tls;任何构建失败一律响亮——绝不允许回落成 LogTransport 后继续报成功,那等于原样保留本 issue 的缺陷。

    派发中(os-dev / opus),PR 落地后回帖。


    Generated by Claude Code

  4. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    实现完成,草稿 PR:#5106(分支 claude/issue-5087-smtp-transport,会话 session_017MCKJaEomEqg4tvz4SzdNd)。

    按 PM 决策用 nodemailer(惰性 await import),未碰 packages/spec / objectql / lint / objectui。三条入口(设置页热替换、CLI OS_EMAIL_SMTP_*、构造期 providerOptions)共用同一份选项映射;构造期缺 host 直接抛错、设置期保留旧 transport 但打 error 日志,mail/test 改为真实投递并如实回报 SMTP 服务器的报错——原来那句「Configuration looks valid … Wire @objectstack/plugin-mail for actual delivery」已从代码中消失(内置 stub 改为 ok: false 并说明未发送)。

    新增 33 个用例(plugin-email 77 → 110),含一个用真 nodemailer + 进程内假 SMTP 服务器跑完整 EHLO/AUTH/DATA 的 wire 测试,验证中文主题 RFC 2047 编码与中文 HTML 正文的传输编码可还原,不出外网。PR 在 4821d3eb3 上 CI 全绿(TypeScript Type Check / ESLint / Test Core ×3 / Dogfood ×3 / Dogfood Verify CLI / Build Core / Build Docs / Temporal Conformance / Validate Package Dependencies(含 OSV 扫 nodemailer)/ Check Changeset 均 success)。

    顺带发现,已另行立单:#5104(本 issue 的 sub-issue —— packages/spec 的 EmailProviderSchema 仍缺 'smtp',TSDoc 还写着 SMTP 不随 plugin-email 发布,落地后为假;因禁止碰 spec 而拆出)、#5105(finding —— config-resolution 文档示例给出并不存在的 email provider)。SendGrid / SES 的同形缺口已在 #5094,本 PR 不涉及。


    Generated by Claude Code


    Generated by Claude Code

  5. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    复核结论:ACCEPT —— PR #5106,转 ready 并入合并队列。

    判据取自 GitHub,不取报告自述:最终 head 4821d3eb 上 8 个工作流全部 success(CI、Lint & Type Check、Validate Dependencies、Spec Liveness Check、Duplicate Fix Guard、Console Pin Freshness、Docs Drift Check、PR Automation)。入队前另核两项:本 PR 未碰 .gitattributes 里 merge=os-regen 的七条生成物路径中的任何一条(改的 content/docs/deployment/environment-variables.mdx 是手写页,不在 content/docs/references/** 下),因此不存在那个「exit 0、零冲突标记却静默吞掉一侧改动」的风险;plugin-email / serve.ts / mail.manifest.ts 在飞期间 main 上无他人改动。

    落地了什么

    三条入口共用同一份 smtpOptionsFromMailSettings 映射(设置页热替换 / CLI OS_EMAIL_SMTP_* / 构造期 providerOptions),不会各自漂出不同的「Use TLS」语义。TLS 一个开关、连接行为由端口推导:465 隐式 TLS,其余端口强制 STARTTLS —— 服务器不肯升级就让这封信失败,而不是把凭据明文发出去。nodemailer 是正式 dependency 但首次 send 才惰性 import,不选 SMTP 的部署与非 Node 运行时不加载 node:net/node:tls;OSV 扫描确认 9.0.3 零传递依赖。

    本 issue 的核心缺陷——「说谎的成功提示」——已从代码里彻底消失:原来那句 ok: true +「Configuration looks valid … Wire @objectstack/plugin-mail for actual delivery」被换成由 plugin-email 注册的真实投递 handler(与 storage/test 同套路),用屏幕上的配置新建一次性 transport 去发,如实回传 SMTP 服务器原话(535 …),测试信同样落 sys_email,用完即关。dev 还多堵了两个我没要求的洞:没挂 email 插件时的内置兜底改成 ok: false 并说明「没有发出任何邮件」;LogTransport 状态下按测试按钮也不再报成功。

    响亮性分两路处理,我认可这个判断:构造/CLI 路径缺 host 直接抛错让启动失败(不再退回 LogTransport 那种「每封都成功、一封都没发出」);设置热替换路径不抛错(一次保存不该把跑着的服务器打挂),保留旧 transport 但打 error 级日志并同时给出后果与修复。并且用 settings 的 source 区分出厂默认态与运维显式选择,避免把 error 训练成可跳过的噪音。

    dev 自己发现并修掉的一个真实缺陷

    首版有个误报:通过 OS_EMAIL_SMTP_* 在启动期配好 SMTP、而设置页从未填过 host 时,applyMailSettings 会喊「没配 host,邮件没在投递」——但邮件其实正在正常投递。e42a568c 加了 this.service.options.transport instanceof SmtpTransport 的判断改走 info,并补了针对这一部署形态的用例。这正是本 issue 要消灭的那类假信号的镜像版本(这次是假警报而非假成功),自查出来并修掉是好事。

    派生工作(均已归位,不在本 PR)


    Generated by Claude Code

  6. os-zhuang commented on Aug 4, 2026

    @os-zhuang
    Contributor

    路过发现,记在这里而不另开单 —— 修复面正好落在本单(与 PR #5106)已经要改的文件里,单开会是 #5104 的近似孪生。

    @objectstack/plugin-mail-smtp 这个包从来不存在

    做 #5105(config-resolution 的 email 示例)核对 provider 取值时,顺带全仓搜了一遍。除 #5104 已经记录的 spec TSDoc(「SMTP 不随 plugin-email 发布,自带 IEmailTransport」)之外,还有另外两处给出了一个不同的补救说法 —— 让用户去装一个独立包:

    • content/docs/deployment/environment-variables.mdx:127 —— OS_EMAIL_PROVIDER 那一行:「Real SMTP delivery requires the separate @objectstack/plugin-mail-smtp package.」
    • 同文件 :132-134 的引述块重复一遍,并称该包「reads ...」
    • packages/plugins/plugin-email/src/email-plugin.ts:478 与 :499 的注释:「delivery requires @objectstack/plugin-mail-smtp, which is not ...」/「wanting real SMTP install @objectstack/plugin-mail-smtp」

    而 packages/plugins/ 下只有 plugin-email,全仓 git grep plugin-mail-smtp origin/main 的命中全部是上面这四处自我引用 —— 没有该包的目录、没有 package.json、没有任何发布痕迹。也就是说照文档执行 npm i @objectstack/plugin-mail-smtp 今天直接 404。

    为什么挂在本单而不是 #5104

    两点:

    1. 文件面属于本单。 spec: EmailProviderSchema 缺 'smtp',TSDoc 仍称 SMTP 不随 plugin-email 发布(#5087 落地后为假) #5104 被 PM 明确圈在 packages/spec + 生成物 email-config.mdx;上面四处都在它范围外,而 email-plugin.ts 正是本单要动的文件,environment-variables.mdx 的 OS_EMAIL_PROVIDER 枚举行也会因本单新增 smtp 而必须改。
    2. 它与 spec: EmailProviderSchema 缺 'smtp',TSDoc 仍称 SMTP 不随 plugin-email 发布(#5087 落地后为假) #5104 不是同一个断言。 spec: EmailProviderSchema 缺 'smtp',TSDoc 仍称 SMTP 不随 plugin-email 发布(#5087 落地后为假) #5104 说的是「TSDoc 称 SMTP 不随包发布,plugin-email: 实现 SMTP transport —— 设置页可选 SMTP 但后端无实现 #5087 落地后为假」;这里是另一条补救路径 —— 「去装那个独立包」—— 而它指向的包从未存在过,与 plugin-email: 实现 SMTP transport —— 设置页可选 SMTP 但后端无实现 #5087 落不落地无关,今天就是错的。

    建议

    本单重写这些文案时,请连带删掉/改写这四处对 @objectstack/plugin-mail-smtp 的指引,而不是只把「不支持 SMTP」改成「支持 SMTP」—— 后者容易把这个假包名原样抄进新文案里。落地后正确说法应是:SMTP 由 @objectstack/plugin-email 内置(nodemailer 惰性 import),无需额外安装包。

    若认为这超出本单验收范围,我可以按 Prime Directive #10 另立一单,请示下。


    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions