Skip to content

Commit bcfebb0

Browse files
os-zhuangclaude
andauthored
fix(cli,plugin-email)!: OS_EMAIL_PROVIDER=resend/postmark with no apiKey fails the boot instead of becoming a LogTransport (#5132) (#5153)
`resolveEmailCapabilityArg` answered a missing API key by rewriting the provider to `log`, printing a warning, and booting: a server that accepted every send, recorded each in `sys_email` as sent, and delivered nothing. #5087 closed that inside plugin-email (`makeTransport` throws rather than substituting a transport); the CLI kept doing it one layer up, which the #5087 PR itself flagged in this function's docstring. It now refuses every mail configuration it cannot deliver through, the way its neighbouring `smtp` arm already did — resend/postmark with no key, and a provider tag outside the supported set (retired `sendgrid`/`ses` get the SMTP migration). Each message names the consequence and both fixes, per AGENTS.md degradation-log-level. Refusing is only fair because `OS_EMAIL_PROVIDER=log` is how a deployment says "no mail from here" — a test pins that it still boots. The provider vocabulary is read from `@objectstack/plugin-email` (`isEmailTransportProvider` / `unsupportedProviderFix` from #5094, plus the new `API_KEY_EMAIL_PROVIDERS` / `emailProviderRequiresApiKey`) rather than restated in the CLI, and the new constant is tied to `makeTransport` by a compile error in one direction and a contract test in the other. Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82a06af commit bcfebb0

7 files changed

Lines changed: 285 additions & 47 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/plugin-email": minor
3+
"@objectstack/cli": major
4+
---
5+
6+
fix(cli,plugin-email)!: `OS_EMAIL_PROVIDER=resend/postmark` without an API key now fails the boot instead of silently becoming the log transport (#5132)
7+
8+
**BREAKING for one configuration: a delivery provider selected without the
9+
credential it needs.** `os serve` used to answer that by rewriting `provider` to
10+
`log`, printing a warning, and booting normally. The result was a server that
11+
accepted every send, recorded each one in `sys_email` as sent, and delivered
12+
nothing — the warning scrolled past in CI logs and the truth surfaced when a
13+
user reported never receiving a verification code. #5087 closed exactly this gap
14+
inside `@objectstack/plugin-email` (`makeTransport` throws rather than
15+
substituting a transport); the CLI's own capability assembly kept doing it one
16+
layer up, for `resend` / `postmark`.
17+
18+
`resolveEmailCapabilityArg` now refuses every mail configuration it cannot
19+
deliver through, the way its neighbouring `smtp` arm already did:
20+
21+
- `resend` / `postmark` with no `OS_EMAIL_API_KEY` (or `config.email.apiKey`);
22+
- a `provider` tag outside `log` / `smtp` / `resend` / `postmark` — including
23+
the retired `sendgrid` / `ses`, which get their SMTP migration in the message.
24+
25+
**Who is affected:** deployments (typically CI or preview environments) that set
26+
`OS_EMAIL_PROVIDER=resend` or `=postmark` without a key and relied on the
27+
fallback to boot. Nothing else changes — a complete configuration is passed
28+
through untouched, and an unset `OS_EMAIL_PROVIDER` still defaults to `log`.
29+
30+
**Migration — one line, either direction:**
31+
32+
- the environment is *not* meant to send mail → `OS_EMAIL_PROVIDER=log`
33+
(that explicit value is the supported way to say so, and why refusing the
34+
others is fair);
35+
- the environment *is* meant to send mail → set `OS_EMAIL_API_KEY` (or
36+
`config.email.apiKey`).
37+
38+
Both errors name the consequence and both fixes, per AGENTS.md's
39+
degradation-log-level rule.
40+
41+
`@objectstack/plugin-email` gains the vocabulary the CLI reads instead of
42+
restating: `API_KEY_EMAIL_PROVIDERS`, `emailProviderRequiresApiKey()` and the
43+
`ApiKeyEmailProvider` type, alongside `EMAIL_TRANSPORT_PROVIDERS` /
44+
`isEmailTransportProvider` / `unsupportedProviderFix` from #5094. One vocabulary,
45+
two consumers, pinned by a contract test — a second literal list in the CLI is
46+
how the settings dropdown and the transports drifted apart in the first place.

content/docs/deployment/environment-variables.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,8 @@ Auth settings precedence:
124124

125125
| Variable | Type | Default | Description |
126126
|:---|:---|:---|:---|
127-
| `OS_EMAIL_PROVIDER` | enum | `log` | Transport. `log` \| `smtp` \| `resend` \| `postmark`. `log` (default) prints to stdout without sending. |
128-
| `OS_EMAIL_API_KEY` | string || API key for `resend` / `postmark`. |
127+
| `OS_EMAIL_PROVIDER` | enum | `log` | Transport. `log` \| `smtp` \| `resend` \| `postmark`. `log` (default) prints to stdout without sending — it is also how a deployment *declares* that it does not send mail. Any other value is a delivery intent, and a boot that cannot honour it fails loudly instead of substituting the log transport. |
128+
| `OS_EMAIL_API_KEY` | string || API key for `resend` / `postmark`. **Required** when either is selected — a boot without it fails rather than starting with a transport that records every message in `sys_email` as sent and delivers nothing. Set `OS_EMAIL_PROVIDER=log` for environments that should not send mail. |
129129
| `OS_EMAIL_FROM` | email || Default `From:` address. |
130130
| `OS_EMAIL_RETRIES` | number | `0` | Retry count for transient send failures (`0` = no retry). |
131131
| `OS_EMAIL_SMTP_HOST` | string || SMTP server hostname. **Required** when `OS_EMAIL_PROVIDER=smtp` — a boot without it fails loudly rather than starting with a transport that logs mail instead of sending it. |

packages/cli/src/commands/serve-email-capability.test.ts

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,46 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* framework#5087 — what `EmailServicePlugin` is constructed with on the
5-
* `os serve` path, and specifically what happens when SMTP is selected.
4+
* framework#5087 / #5132 — what `EmailServicePlugin` is constructed with on the
5+
* `os serve` path, and what happens when the configuration cannot deliver.
66
*
77
* `OS_EMAIL_PROVIDER=smtp` used to be unreachable: the plugin knew three
88
* providers (`log`/`resend`/`postmark`), so `smtp` fell into the "no apiKey"
99
* arm and was silently rewritten to `log`. The server then booted "fine",
1010
* every send was recorded in `sys_email` as sent, and nothing left the box.
11-
* These pin the opposite: a complete SMTP configuration reaches the plugin,
12-
* and an incomplete one fails the boot instead of degrading into a transport
13-
* that reports success.
11+
* #5087 closed that for `smtp`; the same arm went on doing it to `resend` /
12+
* `postmark` until #5132.
13+
*
14+
* These pin the invariant in one piece: a complete configuration reaches the
15+
* plugin unchanged, and an incomplete one throws — for every provider, not
16+
* just SMTP. The counterpart the throw depends on is pinned too: an operator
17+
* who does not want mail sent says so with `OS_EMAIL_PROVIDER=log`, and that
18+
* still boots.
1419
*/
1520

1621
import { describe, it, expect } from 'vitest';
1722
import { resolveEmailCapabilityArg } from './serve.js';
1823

1924
describe('resolveEmailCapabilityArg', () => {
2025
it('defaults to the log provider when nothing is configured', () => {
21-
const { options, warning } = resolveEmailCapabilityArg({}, {});
26+
const { options } = resolveEmailCapabilityArg({}, {});
2227
expect(options).toMatchObject({ provider: 'log' });
2328
expect(options).not.toHaveProperty('providerOptions');
24-
expect(warning).toBeUndefined();
29+
});
30+
31+
it('boots on an EXPLICIT provider=log — the way to say "this environment does not send mail"', () => {
32+
// The premise of every throw below: refusing an undeliverable provider is
33+
// only fair because "no mail from here" has its own spelling. If this ever
34+
// stops booting, the errors elsewhere in this file stop being actionable.
35+
expect(() => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'log' })).not.toThrow();
36+
expect(resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'log' }).options)
37+
.toMatchObject({ provider: 'log' });
38+
// …including from objectstack.config.ts, and with no API key anywhere.
39+
expect(resolveEmailCapabilityArg({ provider: 'log' }, {}).options).toMatchObject({ provider: 'log' });
2540
});
2641

2742
it('assembles the SMTP connection from OS_EMAIL_SMTP_*', () => {
28-
const { options, warning } = resolveEmailCapabilityArg({}, {
43+
const { options } = resolveEmailCapabilityArg({}, {
2944
OS_EMAIL_PROVIDER: 'smtp',
3045
OS_EMAIL_SMTP_HOST: ' smtp.exmail.qq.com ',
3146
OS_EMAIL_SMTP_PORT: '465',
@@ -34,7 +49,6 @@ describe('resolveEmailCapabilityArg', () => {
3449
OS_EMAIL_SMTP_PASSWORD: 'sekrit',
3550
OS_EMAIL_FROM: 'Acme <no-reply@example.cn>',
3651
});
37-
expect(warning).toBeUndefined();
3852
expect(options).toMatchObject({
3953
provider: 'smtp',
4054
providerOptions: {
@@ -80,25 +94,53 @@ describe('resolveEmailCapabilityArg', () => {
8094
.toThrow(/OS_EMAIL_SMTP_HOST/);
8195
});
8296

83-
it('does not apply the apiKey fallback to smtp', () => {
84-
// The `resend`/`postmark` arm degrades to `log` when the key is missing;
85-
// smtp must never reach it (it needs no apiKey at all).
86-
const { options, warning } = resolveEmailCapabilityArg({}, {
97+
it('never demands an apiKey from smtp — it has no API to key', () => {
98+
const { options } = resolveEmailCapabilityArg({}, {
8799
OS_EMAIL_PROVIDER: 'smtp',
88100
OS_EMAIL_SMTP_HOST: 'smtp.x',
89101
});
90102
expect(options.provider).toBe('smtp');
91-
expect(warning).toBeUndefined();
103+
expect(options).not.toHaveProperty('apiKey');
92104
});
93105

94-
it('keeps the pre-existing resend/postmark behaviour', () => {
95-
const withKey = resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'resend', OS_EMAIL_API_KEY: 're_x' });
96-
expect(withKey.options).toMatchObject({ provider: 'resend', apiKey: 're_x' });
97-
expect(withKey.warning).toBeUndefined();
106+
it('passes a complete resend/postmark configuration through untouched', () => {
107+
const resend = resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'resend', OS_EMAIL_API_KEY: 're_x' });
108+
expect(resend.options).toMatchObject({ provider: 'resend', apiKey: 're_x' });
109+
110+
// The key may equally come from objectstack.config.ts.
111+
const postmark = resolveEmailCapabilityArg({ provider: 'postmark', apiKey: 'pm_x' }, {});
112+
expect(postmark.options).toMatchObject({ provider: 'postmark', apiKey: 'pm_x' });
113+
});
114+
115+
it('THROWS on resend/postmark without an apiKey — no silent LogTransport (#5132)', () => {
116+
// This case used to return `{ provider: 'log' }` plus a warning: the server
117+
// booted, `sys_email` filled with rows marked sent, and no mail was ever
118+
// delivered. It is now the same refusal the neighbouring `smtp` arm makes.
119+
for (const provider of ['resend', 'postmark']) {
120+
const boot = () => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: provider });
121+
expect(boot, provider).toThrow(new RegExp(`provider='${provider}'`));
122+
// Consequence AND fix in the one message (AGENTS.md degradation-log-level).
123+
expect(boot, provider).toThrow(/sys_email as sent and nothing would leave the box/);
124+
expect(boot, provider).toThrow(/OS_EMAIL_API_KEY/);
125+
expect(boot, provider).toThrow(/OS_EMAIL_PROVIDER=log/);
126+
// …and never the old silent rewrite.
127+
expect(boot, provider).not.toThrow(/Falling back to LogTransport/);
128+
}
129+
// config.email.provider is the same declaration by another channel.
130+
expect(() => resolveEmailCapabilityArg({ provider: 'resend' }, {})).toThrow(/OS_EMAIL_API_KEY/);
131+
});
98132

99-
const noKey = resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'postmark' });
100-
expect(noKey.options.provider).toBe('log');
101-
expect(noKey.warning).toMatch(/no apiKey found/);
133+
it('THROWS on a provider tag no transport can deliver, carrying the migration', () => {
134+
// A stored/typo'd tag used to take the same silent `log` path when no key
135+
// was set. `sendgrid` / `ses` get the SMTP migration from plugin-email
136+
// (#5094), anything else gets the supported list — one vocabulary, not a
137+
// second literal maintained here.
138+
expect(() => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'sendgrid' }))
139+
.toThrow(/smtp\.sendgrid\.net/);
140+
expect(() => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'ses', OS_EMAIL_API_KEY: 'k' }))
141+
.toThrow(/email-smtp\.<region>\.amazonaws\.com/);
142+
expect(() => resolveEmailCapabilityArg({}, { OS_EMAIL_PROVIDER: 'mailgun' }))
143+
.toThrow(/log \/ resend \/ postmark \/ smtp/);
102144
});
103145

104146
it('still derives the fallback from-address and template context', () => {

packages/cli/src/commands/serve.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ import { resolveDriverType, resolveStorageDefinition, UnsupportedDriverError } f
1717
import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
1818
import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES } from '@objectstack/spec/kernel';
1919
import { missingProviderMessage } from '../utils/capability-preflight.js';
20+
// The mail provider vocabulary, read from the package that materialises the
21+
// transports rather than restated here (#5132) — `resolveEmailCapabilityArg`
22+
// has to refuse exactly the configurations `makeTransport` cannot build, and
23+
// two literal lists for one vocabulary is the drift #5094 was filed for. Values
24+
// only (no plugin class): `os serve` loads `EmailServicePlugin` itself through
25+
// the capability loop's dynamic import, host copy first.
26+
import { isEmailTransportProvider, emailProviderRequiresApiKey, unsupportedProviderFix } from '@objectstack/plugin-email';
2027
import { resolveObjectStackHome } from '@objectstack/runtime';
2128
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
2229
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
@@ -2269,13 +2276,15 @@ export default class Serve extends Command {
22692276
const cubes = (config as any).analyticsCubes ?? (config as any).cubes ?? [];
22702277
arg = { cubes };
22712278
} else if (cap === 'email') {
2272-
const emailArg = resolveEmailCapabilityArg(
2279+
// Throws on a mail configuration that cannot deliver (#5087,
2280+
// #5132) — the catch below turns that into the boot failure /
2281+
// loud error it should be, never a LogTransport substituted
2282+
// behind the operator's back.
2283+
arg = resolveEmailCapabilityArg(
22732284
(config as any).email ?? {},
22742285
process.env,
22752286
(config as any).appName,
2276-
);
2277-
arg = emailArg.options;
2278-
if (emailArg.warning) console.warn(chalk.yellow(` ⚠ Capability "email": ${emailArg.warning}`));
2287+
).options;
22792288
} else if (cap === 'sms') {
22802289
// Compose SmsServicePlugin options from config.sms + OS_SMS_* env
22812290
// (#2780). Same precedence as email: env beats config. Provider
@@ -2812,12 +2821,16 @@ export function resolveStorageCapabilityArg(envRoot?: string): StorageCapability
28122821
}
28132822

28142823
/**
2815-
* Constructor options for `EmailServicePlugin`, plus an optional warning for
2816-
* the caller to print (degraded, but still bootable, configurations).
2824+
* Constructor options for `EmailServicePlugin`.
2825+
*
2826+
* There is no `warning` channel here any more (#5132). It carried exactly one
2827+
* message — "provider=resend but no apiKey, falling back to LogTransport" —
2828+
* and that fallback is now a throw, because a mail configuration that cannot
2829+
* deliver has no "degraded but still fine" reading: it is a server that
2830+
* accepts every send and delivers nothing.
28172831
*/
28182832
export interface EmailCapabilityArg {
28192833
options: Record<string, unknown>;
2820-
warning?: string;
28212834
}
28222835

28232836
/**
@@ -2829,12 +2842,26 @@ export interface EmailCapabilityArg {
28292842
* shape of Prime Directive #9, grouped with the email vars rather than the bare
28302843
* third-party `SMTP_*` names — layered over `config.email.options`.
28312844
*
2832-
* `provider='smtp'` with no host **throws**. The capability loop turns that into
2833-
* a boot failure, which is the point: the alternative (quietly substituting the
2834-
* LogTransport, as this function's `resend`/`postmark` arm still does for a
2835-
* missing API key) hands the operator a server that accepts every send, records
2836-
* it in `sys_email`, and delivers nothing — the exact declared-but-not-delivered
2837-
* gap #5087 closed inside the plugin.
2845+
* **Every provider that cannot deliver throws** — `smtp` with no host, and
2846+
* (since #5132) `resend`/`postmark` with no API key, or a provider tag outside
2847+
* `EMAIL_TRANSPORT_PROVIDERS` altogether. The capability loop turns that into a
2848+
* loud failure — a hard boot error when the app declared `requires: ['email']`,
2849+
* otherwise a `console.error` and no email service — which is the point: the
2850+
* alternative (quietly substituting the LogTransport, as this function's
2851+
* `resend`/`postmark` arm used to do for a missing API key) hands the operator a
2852+
* server that accepts every send, records it in `sys_email` as sent, and
2853+
* delivers nothing — the exact declared-but-not-delivered gap #5087 closed
2854+
* inside the plugin, left behind one layer up.
2855+
*
2856+
* Refusing is only defensible because "this environment does not send mail" has
2857+
* a way to say itself: `OS_EMAIL_PROVIDER=log` (the default). An operator who
2858+
* names a delivery provider has declared an intent, and the honest answer to an
2859+
* intent we cannot honour is a failure, not a substitute transport.
2860+
*
2861+
* The provider vocabulary and the "needs an API key" question are both read
2862+
* from `@objectstack/plugin-email` — the package that has to materialise the
2863+
* transport — rather than restated here. Two literals describing one vocabulary
2864+
* is how the settings dropdown and the transports drifted apart (#5094).
28382865
*/
28392866
export function resolveEmailCapabilityArg(
28402867
cfgEmail: Record<string, any> = {},
@@ -2891,20 +2918,27 @@ export function resolveEmailCapabilityArg(
28912918
defaultTemplateContext,
28922919
};
28932920

2921+
if (!isEmailTransportProvider(provider)) {
2922+
throw new Error(
2923+
`provider='${provider}' is not a transport this server can deliver through, so no mail would go out — `
2924+
+ `${unsupportedProviderFix(provider)} `
2925+
+ 'On this boot path the provider is OS_EMAIL_PROVIDER or config.email.provider; set '
2926+
+ 'OS_EMAIL_PROVIDER=log if this environment is not meant to send mail.',
2927+
);
2928+
}
28942929
if (provider === 'smtp' && !providerOptions.host) {
28952930
throw new Error(
28962931
"provider='smtp' selects SMTP delivery but no SMTP host is configured — set OS_EMAIL_SMTP_HOST "
28972932
+ '(plus OS_EMAIL_SMTP_PORT / _SECURE / _USER / _PASSWORD) or config.email.options.host, '
28982933
+ 'or choose another provider.',
28992934
);
29002935
}
2901-
if (provider !== 'log' && provider !== 'smtp' && !apiKey) {
2902-
options.provider = 'log';
2903-
return {
2904-
options,
2905-
warning: `provider='${provider}' but no apiKey found (set OS_EMAIL_API_KEY or config.email.apiKey). `
2906-
+ 'Falling back to LogTransport.',
2907-
};
2936+
if (emailProviderRequiresApiKey(provider) && !apiKey) {
2937+
throw new Error(
2938+
`provider='${provider}' selects ${provider} delivery but no API key is configured, so every send would `
2939+
+ 'be recorded in sys_email as sent and nothing would leave the box — set OS_EMAIL_API_KEY '
2940+
+ '(or config.email.apiKey), or set OS_EMAIL_PROVIDER=log if this environment is not meant to send mail.',
2941+
);
29082942
}
29092943
return { options };
29102944
}

packages/plugins/plugin-email/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,18 @@ export {
2626
makeTransport,
2727
smtpOptionsFromMailSettings,
2828
EMAIL_TRANSPORT_PROVIDERS,
29+
API_KEY_EMAIL_PROVIDERS,
2930
RETIRED_EMAIL_PROVIDERS,
3031
isEmailTransportProvider,
32+
emailProviderRequiresApiKey,
3133
retiredProviderGuidance,
3234
unsupportedProviderFix,
3335
type ResendTransportOptions,
3436
type PostmarkTransportOptions,
3537
type SmtpTransportOptions,
3638
type MakeTransportOptions,
3739
type EmailTransportProvider,
40+
type ApiKeyEmailProvider,
3841
} from './transports/index.js';
3942
export {
4043
bootstrapDeclaredEmailTemplates,

0 commit comments

Comments
 (0)