Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/email-provider-smtp.md
Original file line number Diff line number Diff line change
@@ -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 必须两边都有意识地改。
13 changes: 11 additions & 2 deletions content/docs/references/system/email-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Callout type="info">
**Source:** `packages/spec/src/system/email-config.zod.ts`
</Callout>
Expand Down Expand Up @@ -58,6 +66,7 @@ const result = EmailAddressConfigSchema.parse(data);
* `log`
* `resend`
* `postmark`
* `smtp`


---
Expand All @@ -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<string, any>` | optional | |
| **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 |


---
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Parameters<typeof makeTransport>[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);
}
});
});
106 changes: 106 additions & 0 deletions packages/spec/src/system/email-config.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
59 changes: 47 additions & 12 deletions packages/spec/src/system/email-config.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof EmailProviderSchema>;

export const EmailAddressConfigSchema = lazySchema(() => z.object({
Expand All @@ -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
Expand All @@ -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<typeof EmailServiceConfigSchema>;
Loading