diff --git a/.changeset/email-provider-smtp.md b/.changeset/email-provider-smtp.md new file mode 100644 index 0000000000..b05bcc3292 --- /dev/null +++ b/.changeset/email-provider-smtp.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `EmailProviderSchema` 补上 `smtp`,并把 SMTP 的 TSDoc 与实际能力对齐 (#5104) + +`EmailProviderSchema` 停在 `log` / `resend` / `postmark`,而它自己的 TSDoc 还在 +告诉作者「Self-hosted SMTP is intentionally NOT shipped in plugin-email; apps +that need SMTP register a custom `IEmailTransport` themselves」。#5087 之后这两 +句都不成立:`@objectstack/plugin-email` 已内置 `SmtpTransport`(ADR-0012, +`nodemailer` 惰性 import),CLI 接受 `OS_EMAIL_PROVIDER=smtp` 并读取 +`OS_EMAIL_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` / `_PASSWORD`。 + +结果是 declared ≠ implemented,而且是 **spec 落后于运行时** 的那一侧:用 +`EmailServiceConfig` 标注 `objectstack.config.ts` 的作者,写 +`provider: 'smtp'` 会拿到类型错误 —— 而这个 provider 早就能正常投递;生成的参考 +文档 `system/email-config` 也只列三个值,读到的 AI 作者会认为 SMTP 不受支持。 + +**改动(纯加值,非破坏性):** + +- `EmailProviderSchema` 增加 `'smtp'`。已有配置不受影响,无需 ADR-0087 + conversion / migration。 +- 重写该段 TSDoc:SMTP 由 plugin-email 内置,`nodemailer` 惰性加载;并写明 + `sendgrid` / `ses` **不是**成员 —— 两者从未实现 HTTP-API transport,都通过 + `provider: 'smtp'` 连各自的 SMTP 端点(#5094)。 +- `provider` 与 `options` 补 `.describe()`,所以参考文档现在正面说明: + `provider: 'smtp'` 的连接参数放在 `options` 的 `host`(必填)/ `port` / + `secure` / `user` / `password`,与 `OS_EMAIL_SMTP_*` 一一对应,env 优先。 + +**运行时零改动。** 这一单只把契约追平既成事实;plugin-email 与 CLI 未被修改。 + +新增的跨包契约测试把这份 provider 词表与 `@objectstack/plugin-email` 的 +`EMAIL_TRANSPORT_PROVIDERS`(`makeTransport` 实际 switch 的数组)双向锁死, +两侧任一方单独增删都会红 —— 下一个 provider 必须两边都有意识地改。 diff --git a/content/docs/references/system/email-config.mdx b/content/docs/references/system/email-config.mdx index 27738bbc35..217b236968 100644 --- a/content/docs/references/system/email-config.mdx +++ b/content/docs/references/system/email-config.mdx @@ -23,6 +23,14 @@ Resolution order in `serve.ts`: 3. Default → provider='log' (LogTransport, no real send) +SMTP delivery is built in (ADR-0012): select it with provider='smtp' + +and supply the connection through `options` (host / port / secure / + +user / password) or the matching OS_EMAIL_SMTP_HOST / _PORT / + +_SECURE / _USER / _PASSWORD environment variables. + **Source:** `packages/spec/src/system/email-config.zod.ts` @@ -58,6 +66,7 @@ const result = EmailAddressConfigSchema.parse(data); * `log` * `resend` * `postmark` +* `smtp` --- @@ -68,12 +77,12 @@ const result = EmailAddressConfigSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **provider** | `Enum<'log' \| 'resend' \| 'postmark'>` | ✅ | | +| **provider** | `Enum<'log' \| 'resend' \| 'postmark' \| 'smtp'>` | ✅ | Transport to deliver through (OS_EMAIL_PROVIDER env). Default log — boots, sends nothing | | **apiKey** | `string` | optional | Provider API key (or OS_EMAIL_API_KEY env) | | **defaultFrom** | `{ name?: string; address: string }` | optional | | | **retries** | `integer` | optional | Retry attempts on transport throw | | **persist** | `boolean` | optional | Persist to sys_email (default true) | -| **options** | `Record` | optional | | +| **options** | `Record` | optional | Provider-specific extras. smtp: host (required) / port / secure / user / password, mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD. postmark: messageStream | --- diff --git a/packages/plugins/plugin-email/src/transports/spec-provider-parity.contract.test.ts b/packages/plugins/plugin-email/src/transports/spec-provider-parity.contract.test.ts new file mode 100644 index 0000000000..fe5a568188 --- /dev/null +++ b/packages/plugins/plugin-email/src/transports/spec-provider-parity.contract.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `EmailProviderSchema` (spec) ↔ `EMAIL_TRANSPORT_PROVIDERS` (this package) — #5104. +// +// The third side of one vocabulary. `mail-manifest-providers.contract.test.ts` +// already holds the settings dropdown equal to the transports; this holds the +// *authoring contract* equal to them too. All three describe "which providers +// exist", and every time two of them were left to drift the result was a +// deployment that accepted a provider it could not deliver through (#5094's +// `sendgrid` / `ses`) or refused one it could (#5087's `smtp`, and the spec +// enum this test was written for: an author annotating `objectstack.config.ts` +// with `EmailServiceConfig` got a type error for `provider: 'smtp'` months +// after the transport shipped). +// +// It is a CROSS-PACKAGE assertion for the reason spelled out in the manifest +// contract test: two mirrored literals can always be "fixed" by editing the +// other literal. `@objectstack/spec` is a real dependency of this package, so +// the comparison costs nothing and no runtime edge is added — this file is +// test-only. +// +// Note the asymmetry with the spec-side companion +// (`packages/spec/src/system/email-config.test.ts`): that package excludes +// `**/*.test.ts` from its `tsconfig.json`, so a type-level witness written +// there is never compiled (#5286) and can only be a runtime check. This +// package's tsconfig includes its tests, so the compile-time half below is +// real — `pnpm --filter @objectstack/plugin-email typecheck` fails on a +// mismatch before any test runs. + +import { describe, it, expect } from 'vitest'; +import { EmailProviderSchema } from '@objectstack/spec/system'; +import type { EmailProvider } from '@objectstack/spec/system'; +import { + makeTransport, + EMAIL_TRANSPORT_PROVIDERS, + RETIRED_EMAIL_PROVIDERS, + type EmailTransportProvider, +} from './index.js'; + +/** + * Compile-time halves of the same invariant: each union must be assignable to + * the other. A member added on one side only collapses its alias to `never`, + * and `true` stops being assignable — a type error in this package's + * `typecheck`, not a deferred test failure. + */ +type SpecAssignableToTransport = EmailProvider extends EmailTransportProvider ? true : never; +type TransportAssignableToSpec = EmailTransportProvider extends EmailProvider ? true : never; +const MUTUALLY_ASSIGNABLE: [SpecAssignableToTransport, TransportAssignableToSpec] = [true, true]; + +/** Minimal credentials that let each provider be built for real. */ +const BUILD_ARGS: Record[0]> = { + log: { provider: 'log' }, + resend: { provider: 'resend', apiKey: 're_test_key' }, + postmark: { provider: 'postmark', apiKey: 'pm-test-key' }, + smtp: { provider: 'smtp', options: { host: 'smtp.example.test' } }, +}; + +describe('EmailProviderSchema ↔ EMAIL_TRANSPORT_PROVIDERS', () => { + it('declares exactly the providers this package can materialise', () => { + // Set equality, both directions at once: + // ⊆ — no spec value without a transport (authors promised a provider + // every boot would refuse); + // ⊇ — no transport the spec hides (`smtp`, for three releases). + expect(new Set(EmailProviderSchema.options)).toEqual(new Set(EMAIL_TRANSPORT_PROVIDERS)); + }); + + it('agrees at the type level in both directions', () => { + expect(MUTUALLY_ASSIGNABLE).toEqual([true, true]); + }); + + it('builds a real transport for every provider the spec declares', () => { + // Set equality alone would be satisfied by two identically-wrong lists. + // This is the direction that matters to an author: what the contract + // permits, `makeTransport` delivers. + for (const provider of EmailProviderSchema.options) { + const args = BUILD_ARGS[provider]; + expect(args, `no build recipe for spec provider '${provider}'`).toBeDefined(); + expect(() => makeTransport(args), provider).not.toThrow(); + } + }); + + it('never re-admits a retired provider tag', () => { + // `sendgrid` / `ses` have migration guidance rather than a transport + // (#5094). Declaring either in the spec would tell authors to write a + // value every boot path throws on — the same defect as the missing + // `smtp`, pointed the other way. + for (const retired of Object.keys(RETIRED_EMAIL_PROVIDERS)) { + expect(EmailProviderSchema.safeParse(retired).success, retired).toBe(false); + expect(EMAIL_TRANSPORT_PROVIDERS, retired).not.toContain(retired); + } + }); +}); diff --git a/packages/spec/src/system/email-config.test.ts b/packages/spec/src/system/email-config.test.ts new file mode 100644 index 0000000000..b8fe593769 --- /dev/null +++ b/packages/spec/src/system/email-config.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `EmailProviderSchema` ↔ what a deployment can actually deliver through (#5104). +// +// The enum used to stop at `log` / `resend` / `postmark` while its own TSDoc +// told authors that "self-hosted SMTP is intentionally NOT shipped in +// plugin-email". #5087 shipped `SmtpTransport`, `OS_EMAIL_PROVIDER=smtp` and +// the `OS_EMAIL_SMTP_*` variables — so an author annotating +// `objectstack.config.ts` with `EmailServiceConfig` got a type error for a +// provider the runtime had been serving since. Declared ≠ implemented, with +// the spec on the *lagging* side. +// +// These assertions are RUNTIME `safeParse` checks on purpose. `packages/spec` +// excludes `**/*.test.ts` from its `tsconfig.json`, so `tsc --noEmit` never +// reads this file (#5286) and any `Assert< Equal< … > >`-style type-level +// witness written here would be a phantom check that passes because nothing +// type-checks it. What the enum *accepts* is observable at runtime; that is +// what we pin. +// +// The set equality below is deliberately a literal. It is the spec-side half +// of a two-sided pin: `spec-provider-parity.contract.test.ts` in +// `@objectstack/plugin-email` compares this same enum against +// `EMAIL_TRANSPORT_PROVIDERS`, the array `makeTransport` switches on. Adding a +// provider therefore has to be a conscious edit on both sides — a literal here +// alone could be "fixed" by editing the other literal, which is exactly how the +// settings dropdown and the transports drifted apart in #5094. + +import { describe, it, expect } from 'vitest'; +import { EmailProviderSchema, EmailServiceConfigSchema } from './email-config.zod'; + +/** + * Every provider tag `@objectstack/plugin-email` can materialise today, + * measured on `main`: `EMAIL_TRANSPORT_PROVIDERS` in + * `packages/plugins/plugin-email/src/transports/index.ts`. + */ +const DELIVERABLE_PROVIDERS = ['log', 'resend', 'postmark', 'smtp'] as const; + +/** + * Tags the mail settings page once offered with no transport behind them + * (#5094). `makeTransport` and the CLI's `resolveEmailCapabilityArg` both + * throw on these and point at `provider='smtp'` instead, so the spec must not + * declare them — that would be the same declared ≠ implemented defect this + * file exists to close, pointing the other way. + */ +const REFUSED_PROVIDERS = ['sendgrid', 'ses', 'mailgun'] as const; + +describe('EmailProviderSchema', () => { + it('accepts exactly the providers plugin-email can deliver through', () => { + const accepted = DELIVERABLE_PROVIDERS.filter( + (p) => EmailProviderSchema.safeParse(p).success, + ); + expect(accepted).toEqual([...DELIVERABLE_PROVIDERS]); + }); + + it('accepts smtp — shipped by plugin-email since #5087 (ADR-0012)', () => { + // The single assertion #5104 is about: green after the enum gained + // 'smtp', red on any revert of it. + expect(EmailProviderSchema.safeParse('smtp').success).toBe(true); + }); + + it('rejects provider tags no transport implements', () => { + for (const provider of REFUSED_PROVIDERS) { + expect(EmailProviderSchema.safeParse(provider).success, provider).toBe(false); + } + }); +}); + +describe('EmailServiceConfigSchema', () => { + it('type-checks a config for every deliverable provider', () => { + for (const provider of DELIVERABLE_PROVIDERS) { + const parsed = EmailServiceConfigSchema.safeParse({ provider }); + expect(parsed.success, provider).toBe(true); + if (parsed.success) expect(parsed.data.provider).toBe(provider); + } + }); + + it('carries the SMTP connection on `options`, mirroring OS_EMAIL_SMTP_*', () => { + const parsed = EmailServiceConfigSchema.safeParse({ + provider: 'smtp', + defaultFrom: { name: 'Acme', address: 'no-reply@acme.test' }, + options: { + host: 'smtp.exmail.qq.com', + port: 465, + secure: true, + user: 'ops@acme.test', + password: 'sekrit', + }, + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.options).toMatchObject({ host: 'smtp.exmail.qq.com', port: 465 }); + } + }); + + it('refuses a config naming a provider that cannot deliver', () => { + for (const provider of REFUSED_PROVIDERS) { + expect(EmailServiceConfigSchema.safeParse({ provider }).success, provider).toBe(false); + } + }); + + it('still defaults to log so an unconfigured deployment boots', () => { + const parsed = EmailServiceConfigSchema.safeParse({}); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.provider).toBe('log'); + }); +}); diff --git a/packages/spec/src/system/email-config.zod.ts b/packages/spec/src/system/email-config.zod.ts index 0a76ca6de0..d565b03243 100644 --- a/packages/spec/src/system/email-config.zod.ts +++ b/packages/spec/src/system/email-config.zod.ts @@ -15,20 +15,42 @@ import { lazySchema } from '../shared/lazy-schema'; * 1. `config.email.*` from objectstack.config.ts * 2. `OS_EMAIL_*` environment variables (override per setting) * 3. Default → provider='log' (LogTransport, no real send) + * + * SMTP delivery is built in (ADR-0012): select it with provider='smtp' + * and supply the connection through `options` (host / port / secure / + * user / password) or the matching OS_EMAIL_SMTP_HOST / _PORT / + * _SECURE / _USER / _PASSWORD environment variables. */ /** - * SaaS / log transport selector. + * Outbound transport selector. + * + * - `log` — LogTransport (development / CI; prints, no real delivery). + * - `resend` — Resend HTTPS API (https://resend.com). Requires `apiKey`. + * - `postmark`— Postmark HTTPS API (https://postmarkapp.com). Requires `apiKey`. + * - `smtp` — any SMTP relay, self-hosted or managed. Shipped inside + * `@objectstack/plugin-email` as `SmtpTransport` (ADR-0012); + * its `nodemailer` dependency is a lazy import, so a + * deployment that never selects `smtp` never loads it. + * Connection settings live in `options` — see + * {@link EmailServiceConfigSchema}. * - * - `log` — LogTransport (development / CI; no real delivery). - * - `resend` — Resend HTTPS API (https://resend.com). - * - `postmark`— Postmark HTTPS API (https://postmarkapp.com). + * This list is the operator-facing half of one vocabulary: the other half is + * `EMAIL_TRANSPORT_PROVIDERS` in `@objectstack/plugin-email`, the array + * `makeTransport` switches on. A cross-package contract test in that package + * holds the two equal in both directions, because a value declared here with + * no transport behind it advertises a provider no deployment can deliver + * through — the declared-but-not-delivered defect of #5087 / #5094 — and a + * transport missing from here is a capability authors are told they cannot + * have. * - * Self-hosted SMTP is intentionally NOT shipped in plugin-email; apps - * that need SMTP register a custom `IEmailTransport` themselves and - * pass it via `EmailServicePluginOptions.transport`. + * `sendgrid` and `ses` are deliberately NOT members: no HTTP-API transport for + * either was ever implemented. Both publish an SMTP endpoint, so both are + * configured as provider='smtp' — for SES, host `email-smtp.REGION.amazonaws.com` + * with SES SMTP credentials (generated in the SES console; they are not AWS + * access keys). */ -export const EmailProviderSchema = lazySchema(() => z.enum(['log', 'resend', 'postmark'])); +export const EmailProviderSchema = lazySchema(() => z.enum(['log', 'resend', 'postmark', 'smtp'])); export type EmailProvider = z.infer; export const EmailAddressConfigSchema = lazySchema(() => z.object({ @@ -42,7 +64,8 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({ * Transport provider. Defaults to `'log'` so unconfigured deployments * still boot — but mail will not actually be delivered. */ - provider: EmailProviderSchema.default('log'), + provider: EmailProviderSchema.default('log') + .describe('Transport to deliver through (OS_EMAIL_PROVIDER env). Default log — boots, sends nothing'), /** * API key for the selected provider (`resend` / `postmark`). Read @@ -69,9 +92,21 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({ persist: z.boolean().optional().describe('Persist to sys_email (default true)'), /** - * Provider-specific extras (e.g. Postmark `messageStream`). Free-form - * object the transport may consume. + * Provider-specific extras. Free-form object the selected transport + * consumes; the keys each provider reads are: + * + * - `smtp` — `host` (required), `port`, `secure`, `user`, `password`. + * Each mirrors one environment variable, and env wins: + * `OS_EMAIL_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` / `_PASSWORD`. + * Booting provider='smtp' with no host resolved from either source is + * a hard error, never a silent fall back to `log`. + * - `postmark` — `messageStream`. + * - `log` / `resend` — nothing. */ - options: z.record(z.string(), z.unknown()).optional(), + options: z.record(z.string(), z.unknown()).optional() + .describe( + 'Provider-specific extras. smtp: host (required) / port / secure / user / password, ' + + 'mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD. postmark: messageStream', + ), })); export type EmailServiceConfig = z.infer;