feat(plugin-email): SmtpTransport —— 真实 SMTP 投递、设置热替换、不再说谎的 mail/test (#5087) - #5106
Merged
Conversation
… hot-swap, honest mail/test (#5087) The Mail Delivery settings page defaults to SMTP and offers a full host/port/TLS/user/password form, while nothing behind it delivered: `applyMailSettings` treated `provider: 'smtp'` as a no-op and `mail/test` answered `ok: true, "Configuration looks valid … Wire @objectstack/plugin-mail for actual delivery"` — a success toast for a message nobody sent, naming a package that has never existed. Deployments in China had no working channel at all (Resend/Postmark are overseas SaaS). - `SmtpTransport` (ADR-0012: SMTP in core via nodemailer), with nodemailer imported lazily on first send so non-SMTP deployments and non-Node runtimes never load node:net / node:tls. - One TLS toggle, wire behaviour derived from the port: implicit TLS on 465, REQUIRED STARTTLS elsewhere (a server that will not upgrade fails the send instead of leaking credentials in the clear). - Three doors share one options reader: settings hot-swap, `os serve` (OS_EMAIL_SMTP_HOST/_PORT/_SECURE/_USER/_PASSWORD, layered over config.email.options), and constructor providerOptions. - Loud failure everywhere: the construction path throws (boot fails) rather than degrading to a LogTransport that reports success; the settings path keeps the previous transport but logs at error with consequence + fix; and `mail/test` now performs a REAL delivery and reports the SMTP server's own error text. The built-in fallback mail/test handler answers ok:false and says plainly that nothing was sent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 27 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…y configured one (#5087) The settings page legitimately carries no host in the deployment shape this feature enables — SMTP configured through OS_EMAIL_SMTP_* at boot, Settings → Mail never opened. Mail IS being delivered there, so the "no host configured" error was a false alarm on every start. Report it only when nothing can actually deliver; escalating a healthy boot is how error logs become skimmable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
…tionale Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
This was referenced Aug 4, 2026
os-zhuang
marked this pull request as ready for review
August 4, 2026 05:03
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5087
做了什么
设置页 Mail Delivery 一直把 SMTP 当默认 provider、给了完整的 host / port / TLS / user / password 表单,而后端什么都没有:
applyMailSettings对provider === 'smtp'是 no-op(「transport unchanged」),mail/test无条件返回ok: true+「Configuration looks valid … Wire@objectstack/plugin-mailfor actual delivery」——一封没人发出的邮件的成功提示,还指向一个从来不存在的包。国内部署因此没有任何可用发信通道。本 PR 按 PM 决策(nodemailer,不自研 SMTP 客户端)落地:
SmtpTransport(packages/plugins/plugin-email/src/transports/smtp.ts),实现既有的IEmailTransport,未改packages/spec。nodemailer是plugin-email的正式 dependency,但在首次 send 时才await import('nodemailer'),所以不选 SMTP 的部署与非 Node 运行时不会加载node:net/node:tls。465→ 隐式 TLS(SMTPS);其它端口 → 强制 STARTTLS 升级(服务器不肯升级就让这封信失败,而不是把凭据明文发出去);secure: false→ 明文连接,服务器提供 STARTTLS 才机会性升级。逃生舱transportOptions可直接透传 nodemailer 选项。smtp_host/smtp_port/smtp_secure/smtp_user/smtp_password,保存即换,不重启);os serve:OS_EMAIL_PROVIDER=smtp+ 新增OS_EMAIL_SMTP_HOST/_PORT/_SECURE/_USER/_PASSWORD(Prime Directive [WIP] Create a new release version #9 的OS_{DOMAIN}_{FEATURE}形状,叠在config.email.options之上);new EmailServicePlugin({ provider: 'smtp', providerOptions: { host, ... } })。OS_MAIL_SMTP_HOST);mail/test真的发信:plugin-email注册的 handler(覆盖 service-settings 里的内置 stub,与storage/test同一套路)对smtp总是用屏幕上的那份配置新建一次性 transport 去发,如实回报成功或 SMTP 服务器自己的报错(535 … authentication failed),测试信同样落sys_email;transport 用完即关。LogTransport 状态下不再报成功,而是ok: false+ warning 说明「只写了日志」。ok: false+ 明确说明「没有发出任何测试邮件」,并指向emailcapability。content/docs/deployment/environment-variables.mdx里「Real SMTP delivery requires the separate@objectstack/plugin-mail-smtppackage」删除,补上新的OS_EMAIL_SMTP_*表格与设置页 /OS_MAIL_*两条通道的关系;email-plugin.ts同源注释一并改。未碰content/docs/releases/。.changeset/smtp-transport-plugin-email.md(用户可见新能力)。测试
新增 32 个用例,
pnpm --filter @objectstack/plugin-email test由 77 → 109 全绿:transports/smtp.test.ts(mockcreateTransport):缺 host / 非法端口拒绝构造;首次 send 之前不加载 nodemailer;587 默认 + requireTLS、465 隐式 TLS、secure:false、有 user 才带 auth、transportOptions覆盖;字段映射;535 认证失败原样抛出;构造失败不被缓存(改完设置下一次 send 就能好);describe()不含密码;makeTransport('smtp')缺 host 抛错。transports/smtp.wire.test.ts(真 nodemailer + 进程内假 SMTP 服务器,不出网):完整跑通 EHLO / AUTH / MAIL / RCPT / DATA;中文主题按 RFC 2047 编码且能还原、中文 HTML 正文经传输编码后可还原、charset=utf-8;AUTH 被拒(535)时 send 抛错且服务器一条 DATA 都没收到。email-plugin.mail-settings.test.ts:设置里的 SMTP 真的被换上(instanceof SmtpTransport+ 连接参数)、再保存一次能再换;换上后发信走 nodemailer 且sys_email仍然记录(queued → sent + message_id,中文主题/正文原样落库);缺 host 时保留旧 transport + error 日志(出厂默认态则不吵);构造期provider:'smtp'无 host 抛错;mail/test的四条路径(真发/535 失败/缺 host/LogTransport 不报成功,并断言旧文案已消失)。packages/cli/src/commands/serve-email-capability.test.ts:新抽出的resolveEmailCapabilityArg(与resolveStorageCapabilityArg同型)——OS_EMAIL_SMTP_*组装、env 覆盖config.email.options、smtp不再落进「缺 apiKey → 退回 log」那条分支、缺 host 抛错;resend/postmark 旧行为保持不变。跑过:
pnpm --filter @objectstack/plugin-email test(109 passed)、typecheck干净;pnpm --filter @objectstack/service-settings test(197 passed);pnpm --filter @objectstack/cli test(69 files / 609 passed)、typecheck干净;eslint干净;check:doc-authoring/check:published-files/check:durability-log-level/check:startup-registry-verdict/check:adr-anchors全绿。已 merge 最新origin/main(#5097、#5046)后重建并复跑受影响包。顺带发现(未在本 PR 修)
packages/spec/src/system/email-config.zod.ts的EmailProviderSchema仍是['log','resend','postmark'],TSDoc 还写着「Self-hosted SMTP is intentionally NOT shipped in plugin-email」——本 PR 落地后是假话。因派发约束禁止碰packages/spec(E1(#5040 执行器契约面):IMetadataService.matchEndpoint?/generateOpenApi?+IHttpServer.setFallbackHandler?可选契约方法与契约测试 #5080 / 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 同批在飞)而单独立单。finding):content/docs/protocol/kernel/config-resolution.mdx的示例用了并不存在的provider: 'console'/OS_EMAIL_PROVIDER=mailhog与fromAddress键。🤖 Generated with Claude Code
https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd
Generated by Claude Code