Skip to content

Commit 37e38d1

Browse files
os-zhuangclaude
andauthored
feat(spec): EmailProviderSchema 补上 'smtp',TSDoc 与 plugin-email 实际能力对齐 (#5104) (#5308)
`EmailProviderSchema` 停在 log/resend/postmark,而它自己的 TSDoc 还在说 「Self-hosted SMTP is intentionally NOT shipped in plugin-email」。#5087 之后 两句都不成立:plugin-email 已内置 SmtpTransport(ADR-0012,nodemailer 惰性 import),CLI 接受 OS_EMAIL_PROVIDER=smtp 与 OS_EMAIL_SMTP_* 一族。 于是形成 spec 落后于运行时的 declared != implemented:用 EmailServiceConfig 标注 objectstack.config.ts 的作者写 provider: 'smtp' 会拿到类型错误,而该 provider 早就能正常投递。 - EmailProviderSchema 增加 'smtp'(纯加值,无需 ADR-0087 conversion) - 重写 TSDoc:SMTP 内置、nodemailer 惰性加载;写明 sendgrid/ses 不是成员 (从未实现 HTTP-API transport,走 provider='smtp',见 #5094) - provider / options 补 .describe(),参考文档正面说明 smtp 连接参数与 OS_EMAIL_SMTP_* 的一一对应 - 跨包契约测试把 spec 词表与 plugin-email 的 EMAIL_TRANSPORT_PROVIDERS 双向 锁死(运行时集合相等 + 编译期互相可赋值) - 重新生成 content/docs/references/system/email-config.mdx 运行时零改动:plugin-email 与 CLI 未被修改,新增的仅为测试文件。 Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f17114 commit 37e38d1

5 files changed

Lines changed: 289 additions & 14 deletions

File tree

‎.changeset/email-provider-smtp.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `EmailProviderSchema` 补上 `smtp`,并把 SMTP 的 TSDoc 与实际能力对齐 (#5104)
6+
7+
`EmailProviderSchema` 停在 `log` / `resend` / `postmark`,而它自己的 TSDoc 还在
8+
告诉作者「Self-hosted SMTP is intentionally NOT shipped in plugin-email; apps
9+
that need SMTP register a custom `IEmailTransport` themselves」。#5087 之后这两
10+
句都不成立:`@objectstack/plugin-email` 已内置 `SmtpTransport`(ADR-0012,
11+
`nodemailer` 惰性 import),CLI 接受 `OS_EMAIL_PROVIDER=smtp` 并读取
12+
`OS_EMAIL_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` / `_PASSWORD`。
13+
14+
结果是 declared ≠ implemented,而且是 **spec 落后于运行时** 的那一侧:用
15+
`EmailServiceConfig` 标注 `objectstack.config.ts` 的作者,写
16+
`provider: 'smtp'` 会拿到类型错误 —— 而这个 provider 早就能正常投递;生成的参考
17+
文档 `system/email-config` 也只列三个值,读到的 AI 作者会认为 SMTP 不受支持。
18+
19+
**改动(纯加值,非破坏性):**
20+
21+
- `EmailProviderSchema` 增加 `'smtp'`。已有配置不受影响,无需 ADR-0087
22+
conversion / migration。
23+
- 重写该段 TSDoc:SMTP 由 plugin-email 内置,`nodemailer` 惰性加载;并写明
24+
`sendgrid` / `ses` **不是**成员 —— 两者从未实现 HTTP-API transport,都通过
25+
`provider: 'smtp'` 连各自的 SMTP 端点(#5094)。
26+
- `provider` 与 `options` 补 `.describe()`,所以参考文档现在正面说明:
27+
`provider: 'smtp'` 的连接参数放在 `options` 的 `host`(必填)/ `port` /
28+
`secure` / `user` / `password`,与 `OS_EMAIL_SMTP_*` 一一对应,env 优先。
29+
30+
**运行时零改动。** 这一单只把契约追平既成事实;plugin-email 与 CLI 未被修改。
31+
32+
新增的跨包契约测试把这份 provider 词表与 `@objectstack/plugin-email` 的
33+
`EMAIL_TRANSPORT_PROVIDERS`(`makeTransport` 实际 switch 的数组)双向锁死,
34+
两侧任一方单独增删都会红 —— 下一个 provider 必须两边都有意识地改。

‎content/docs/references/system/email-config.mdx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ Resolution order in `serve.ts`:
2323

2424
3. Default → provider='log' (LogTransport, no real send)
2525

26+
SMTP delivery is built in (ADR-0012): select it with provider='smtp'
27+
28+
and supply the connection through `options` (host / port / secure /
29+
30+
user / password) or the matching OS_EMAIL_SMTP_HOST / _PORT /
31+
32+
_SECURE / _USER / _PASSWORD environment variables.
33+
2634
<Callout type="info">
2735
**Source:** `packages/spec/src/system/email-config.zod.ts`
2836
</Callout>
@@ -58,6 +66,7 @@ const result = EmailAddressConfigSchema.parse(data);
5866
* `log`
5967
* `resend`
6068
* `postmark`
69+
* `smtp`
6170

6271

6372
---
@@ -68,12 +77,12 @@ const result = EmailAddressConfigSchema.parse(data);
6877

6978
| Property | Type | Required | Description |
7079
| :--- | :--- | :--- | :--- |
71-
| **provider** | `Enum<'log' \| 'resend' \| 'postmark'>` | ✅ | |
80+
| **provider** | `Enum<'log' \| 'resend' \| 'postmark' \| 'smtp'>` | ✅ | Transport to deliver through (OS_EMAIL_PROVIDER env). Default log — boots, sends nothing |
7281
| **apiKey** | `string` | optional | Provider API key (or OS_EMAIL_API_KEY env) |
7382
| **defaultFrom** | `{ name?: string; address: string }` | optional | |
7483
| **retries** | `integer` | optional | Retry attempts on transport throw |
7584
| **persist** | `boolean` | optional | Persist to sys_email (default true) |
76-
| **options** | `Record<string, any>` | optional | |
85+
| **options** | `Record<string, any>` | optional | Provider-specific extras. smtp: host (required) / port / secure / user / password, mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD. postmark: messageStream |
7786

7887

7988
---
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `EmailProviderSchema` (spec) ↔ `EMAIL_TRANSPORT_PROVIDERS` (this package) — #5104.
4+
//
5+
// The third side of one vocabulary. `mail-manifest-providers.contract.test.ts`
6+
// already holds the settings dropdown equal to the transports; this holds the
7+
// *authoring contract* equal to them too. All three describe "which providers
8+
// exist", and every time two of them were left to drift the result was a
9+
// deployment that accepted a provider it could not deliver through (#5094's
10+
// `sendgrid` / `ses`) or refused one it could (#5087's `smtp`, and the spec
11+
// enum this test was written for: an author annotating `objectstack.config.ts`
12+
// with `EmailServiceConfig` got a type error for `provider: 'smtp'` months
13+
// after the transport shipped).
14+
//
15+
// It is a CROSS-PACKAGE assertion for the reason spelled out in the manifest
16+
// contract test: two mirrored literals can always be "fixed" by editing the
17+
// other literal. `@objectstack/spec` is a real dependency of this package, so
18+
// the comparison costs nothing and no runtime edge is added — this file is
19+
// test-only.
20+
//
21+
// Note the asymmetry with the spec-side companion
22+
// (`packages/spec/src/system/email-config.test.ts`): that package excludes
23+
// `**/*.test.ts` from its `tsconfig.json`, so a type-level witness written
24+
// there is never compiled (#5286) and can only be a runtime check. This
25+
// package's tsconfig includes its tests, so the compile-time half below is
26+
// real — `pnpm --filter @objectstack/plugin-email typecheck` fails on a
27+
// mismatch before any test runs.
28+
29+
import { describe, it, expect } from 'vitest';
30+
import { EmailProviderSchema } from '@objectstack/spec/system';
31+
import type { EmailProvider } from '@objectstack/spec/system';
32+
import {
33+
makeTransport,
34+
EMAIL_TRANSPORT_PROVIDERS,
35+
RETIRED_EMAIL_PROVIDERS,
36+
type EmailTransportProvider,
37+
} from './index.js';
38+
39+
/**
40+
* Compile-time halves of the same invariant: each union must be assignable to
41+
* the other. A member added on one side only collapses its alias to `never`,
42+
* and `true` stops being assignable — a type error in this package's
43+
* `typecheck`, not a deferred test failure.
44+
*/
45+
type SpecAssignableToTransport = EmailProvider extends EmailTransportProvider ? true : never;
46+
type TransportAssignableToSpec = EmailTransportProvider extends EmailProvider ? true : never;
47+
const MUTUALLY_ASSIGNABLE: [SpecAssignableToTransport, TransportAssignableToSpec] = [true, true];
48+
49+
/** Minimal credentials that let each provider be built for real. */
50+
const BUILD_ARGS: Record<string, Parameters<typeof makeTransport>[0]> = {
51+
log: { provider: 'log' },
52+
resend: { provider: 'resend', apiKey: 're_test_key' },
53+
postmark: { provider: 'postmark', apiKey: 'pm-test-key' },
54+
smtp: { provider: 'smtp', options: { host: 'smtp.example.test' } },
55+
};
56+
57+
describe('EmailProviderSchema ↔ EMAIL_TRANSPORT_PROVIDERS', () => {
58+
it('declares exactly the providers this package can materialise', () => {
59+
// Set equality, both directions at once:
60+
// ⊆ — no spec value without a transport (authors promised a provider
61+
// every boot would refuse);
62+
// ⊇ — no transport the spec hides (`smtp`, for three releases).
63+
expect(new Set(EmailProviderSchema.options)).toEqual(new Set(EMAIL_TRANSPORT_PROVIDERS));
64+
});
65+
66+
it('agrees at the type level in both directions', () => {
67+
expect(MUTUALLY_ASSIGNABLE).toEqual([true, true]);
68+
});
69+
70+
it('builds a real transport for every provider the spec declares', () => {
71+
// Set equality alone would be satisfied by two identically-wrong lists.
72+
// This is the direction that matters to an author: what the contract
73+
// permits, `makeTransport` delivers.
74+
for (const provider of EmailProviderSchema.options) {
75+
const args = BUILD_ARGS[provider];
76+
expect(args, `no build recipe for spec provider '${provider}'`).toBeDefined();
77+
expect(() => makeTransport(args), provider).not.toThrow();
78+
}
79+
});
80+
81+
it('never re-admits a retired provider tag', () => {
82+
// `sendgrid` / `ses` have migration guidance rather than a transport
83+
// (#5094). Declaring either in the spec would tell authors to write a
84+
// value every boot path throws on — the same defect as the missing
85+
// `smtp`, pointed the other way.
86+
for (const retired of Object.keys(RETIRED_EMAIL_PROVIDERS)) {
87+
expect(EmailProviderSchema.safeParse(retired).success, retired).toBe(false);
88+
expect(EMAIL_TRANSPORT_PROVIDERS, retired).not.toContain(retired);
89+
}
90+
});
91+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `EmailProviderSchema` ↔ what a deployment can actually deliver through (#5104).
4+
//
5+
// The enum used to stop at `log` / `resend` / `postmark` while its own TSDoc
6+
// told authors that "self-hosted SMTP is intentionally NOT shipped in
7+
// plugin-email". #5087 shipped `SmtpTransport`, `OS_EMAIL_PROVIDER=smtp` and
8+
// the `OS_EMAIL_SMTP_*` variables — so an author annotating
9+
// `objectstack.config.ts` with `EmailServiceConfig` got a type error for a
10+
// provider the runtime had been serving since. Declared ≠ implemented, with
11+
// the spec on the *lagging* side.
12+
//
13+
// These assertions are RUNTIME `safeParse` checks on purpose. `packages/spec`
14+
// excludes `**/*.test.ts` from its `tsconfig.json`, so `tsc --noEmit` never
15+
// reads this file (#5286) and any `Assert< Equal< … > >`-style type-level
16+
// witness written here would be a phantom check that passes because nothing
17+
// type-checks it. What the enum *accepts* is observable at runtime; that is
18+
// what we pin.
19+
//
20+
// The set equality below is deliberately a literal. It is the spec-side half
21+
// of a two-sided pin: `spec-provider-parity.contract.test.ts` in
22+
// `@objectstack/plugin-email` compares this same enum against
23+
// `EMAIL_TRANSPORT_PROVIDERS`, the array `makeTransport` switches on. Adding a
24+
// provider therefore has to be a conscious edit on both sides — a literal here
25+
// alone could be "fixed" by editing the other literal, which is exactly how the
26+
// settings dropdown and the transports drifted apart in #5094.
27+
28+
import { describe, it, expect } from 'vitest';
29+
import { EmailProviderSchema, EmailServiceConfigSchema } from './email-config.zod';
30+
31+
/**
32+
* Every provider tag `@objectstack/plugin-email` can materialise today,
33+
* measured on `main`: `EMAIL_TRANSPORT_PROVIDERS` in
34+
* `packages/plugins/plugin-email/src/transports/index.ts`.
35+
*/
36+
const DELIVERABLE_PROVIDERS = ['log', 'resend', 'postmark', 'smtp'] as const;
37+
38+
/**
39+
* Tags the mail settings page once offered with no transport behind them
40+
* (#5094). `makeTransport` and the CLI's `resolveEmailCapabilityArg` both
41+
* throw on these and point at `provider='smtp'` instead, so the spec must not
42+
* declare them — that would be the same declared ≠ implemented defect this
43+
* file exists to close, pointing the other way.
44+
*/
45+
const REFUSED_PROVIDERS = ['sendgrid', 'ses', 'mailgun'] as const;
46+
47+
describe('EmailProviderSchema', () => {
48+
it('accepts exactly the providers plugin-email can deliver through', () => {
49+
const accepted = DELIVERABLE_PROVIDERS.filter(
50+
(p) => EmailProviderSchema.safeParse(p).success,
51+
);
52+
expect(accepted).toEqual([...DELIVERABLE_PROVIDERS]);
53+
});
54+
55+
it('accepts smtp — shipped by plugin-email since #5087 (ADR-0012)', () => {
56+
// The single assertion #5104 is about: green after the enum gained
57+
// 'smtp', red on any revert of it.
58+
expect(EmailProviderSchema.safeParse('smtp').success).toBe(true);
59+
});
60+
61+
it('rejects provider tags no transport implements', () => {
62+
for (const provider of REFUSED_PROVIDERS) {
63+
expect(EmailProviderSchema.safeParse(provider).success, provider).toBe(false);
64+
}
65+
});
66+
});
67+
68+
describe('EmailServiceConfigSchema', () => {
69+
it('type-checks a config for every deliverable provider', () => {
70+
for (const provider of DELIVERABLE_PROVIDERS) {
71+
const parsed = EmailServiceConfigSchema.safeParse({ provider });
72+
expect(parsed.success, provider).toBe(true);
73+
if (parsed.success) expect(parsed.data.provider).toBe(provider);
74+
}
75+
});
76+
77+
it('carries the SMTP connection on `options`, mirroring OS_EMAIL_SMTP_*', () => {
78+
const parsed = EmailServiceConfigSchema.safeParse({
79+
provider: 'smtp',
80+
defaultFrom: { name: 'Acme', address: 'no-reply@acme.test' },
81+
options: {
82+
host: 'smtp.exmail.qq.com',
83+
port: 465,
84+
secure: true,
85+
user: 'ops@acme.test',
86+
password: 'sekrit',
87+
},
88+
});
89+
expect(parsed.success).toBe(true);
90+
if (parsed.success) {
91+
expect(parsed.data.options).toMatchObject({ host: 'smtp.exmail.qq.com', port: 465 });
92+
}
93+
});
94+
95+
it('refuses a config naming a provider that cannot deliver', () => {
96+
for (const provider of REFUSED_PROVIDERS) {
97+
expect(EmailServiceConfigSchema.safeParse({ provider }).success, provider).toBe(false);
98+
}
99+
});
100+
101+
it('still defaults to log so an unconfigured deployment boots', () => {
102+
const parsed = EmailServiceConfigSchema.safeParse({});
103+
expect(parsed.success).toBe(true);
104+
if (parsed.success) expect(parsed.data.provider).toBe('log');
105+
});
106+
});

‎packages/spec/src/system/email-config.zod.ts‎

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,42 @@ import { lazySchema } from '../shared/lazy-schema';
1515
* 1. `config.email.*` from objectstack.config.ts
1616
* 2. `OS_EMAIL_*` environment variables (override per setting)
1717
* 3. Default → provider='log' (LogTransport, no real send)
18+
*
19+
* SMTP delivery is built in (ADR-0012): select it with provider='smtp'
20+
* and supply the connection through `options` (host / port / secure /
21+
* user / password) or the matching OS_EMAIL_SMTP_HOST / _PORT /
22+
* _SECURE / _USER / _PASSWORD environment variables.
1823
*/
1924

2025
/**
21-
* SaaS / log transport selector.
26+
* Outbound transport selector.
27+
*
28+
* - `log` — LogTransport (development / CI; prints, no real delivery).
29+
* - `resend` — Resend HTTPS API (https://resend.com). Requires `apiKey`.
30+
* - `postmark`— Postmark HTTPS API (https://postmarkapp.com). Requires `apiKey`.
31+
* - `smtp` — any SMTP relay, self-hosted or managed. Shipped inside
32+
* `@objectstack/plugin-email` as `SmtpTransport` (ADR-0012);
33+
* its `nodemailer` dependency is a lazy import, so a
34+
* deployment that never selects `smtp` never loads it.
35+
* Connection settings live in `options` — see
36+
* {@link EmailServiceConfigSchema}.
2237
*
23-
* - `log` — LogTransport (development / CI; no real delivery).
24-
* - `resend` — Resend HTTPS API (https://resend.com).
25-
* - `postmark`— Postmark HTTPS API (https://postmarkapp.com).
38+
* This list is the operator-facing half of one vocabulary: the other half is
39+
* `EMAIL_TRANSPORT_PROVIDERS` in `@objectstack/plugin-email`, the array
40+
* `makeTransport` switches on. A cross-package contract test in that package
41+
* holds the two equal in both directions, because a value declared here with
42+
* no transport behind it advertises a provider no deployment can deliver
43+
* through — the declared-but-not-delivered defect of #5087 / #5094 — and a
44+
* transport missing from here is a capability authors are told they cannot
45+
* have.
2646
*
27-
* Self-hosted SMTP is intentionally NOT shipped in plugin-email; apps
28-
* that need SMTP register a custom `IEmailTransport` themselves and
29-
* pass it via `EmailServicePluginOptions.transport`.
47+
* `sendgrid` and `ses` are deliberately NOT members: no HTTP-API transport for
48+
* either was ever implemented. Both publish an SMTP endpoint, so both are
49+
* configured as provider='smtp' — for SES, host `email-smtp.REGION.amazonaws.com`
50+
* with SES SMTP credentials (generated in the SES console; they are not AWS
51+
* access keys).
3052
*/
31-
export const EmailProviderSchema = lazySchema(() => z.enum(['log', 'resend', 'postmark']));
53+
export const EmailProviderSchema = lazySchema(() => z.enum(['log', 'resend', 'postmark', 'smtp']));
3254
export type EmailProvider = z.infer<typeof EmailProviderSchema>;
3355

3456
export const EmailAddressConfigSchema = lazySchema(() => z.object({
@@ -42,7 +64,8 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({
4264
* Transport provider. Defaults to `'log'` so unconfigured deployments
4365
* still boot — but mail will not actually be delivered.
4466
*/
45-
provider: EmailProviderSchema.default('log'),
67+
provider: EmailProviderSchema.default('log')
68+
.describe('Transport to deliver through (OS_EMAIL_PROVIDER env). Default log — boots, sends nothing'),
4669

4770
/**
4871
* API key for the selected provider (`resend` / `postmark`). Read
@@ -69,9 +92,21 @@ export const EmailServiceConfigSchema = lazySchema(() => z.object({
6992
persist: z.boolean().optional().describe('Persist to sys_email (default true)'),
7093

7194
/**
72-
* Provider-specific extras (e.g. Postmark `messageStream`). Free-form
73-
* object the transport may consume.
95+
* Provider-specific extras. Free-form object the selected transport
96+
* consumes; the keys each provider reads are:
97+
*
98+
* - `smtp` — `host` (required), `port`, `secure`, `user`, `password`.
99+
* Each mirrors one environment variable, and env wins:
100+
* `OS_EMAIL_SMTP_HOST` / `_PORT` / `_SECURE` / `_USER` / `_PASSWORD`.
101+
* Booting provider='smtp' with no host resolved from either source is
102+
* a hard error, never a silent fall back to `log`.
103+
* - `postmark` — `messageStream`.
104+
* - `log` / `resend` — nothing.
74105
*/
75-
options: z.record(z.string(), z.unknown()).optional(),
106+
options: z.record(z.string(), z.unknown()).optional()
107+
.describe(
108+
'Provider-specific extras. smtp: host (required) / port / secure / user / password, '
109+
+ 'mirroring OS_EMAIL_SMTP_HOST / _PORT / _SECURE / _USER / _PASSWORD. postmark: messageStream',
110+
),
76111
}));
77112
export type EmailServiceConfig = z.infer<typeof EmailServiceConfigSchema>;

0 commit comments

Comments
 (0)