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;