From 6cb26993fb06fd6a4710ac223777980471763ab4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:43:01 +0000 Subject: [PATCH] fix(service-settings,plugin-email): mail provider dropdown lists only providers that deliver (#5094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Mail → Provider offered SMTP | SendGrid | Amazon SES | Postmark, while plugin-email's makeTransport has only ever known log | resend | postmark | smtp. Selecting SendGrid or SES validated, saved, reported success and delivered nothing — the #5087 declared-but-not-delivered shape, one field over. The same field broke the invariant the other way: `resend` had a working transport and was not listed at all. The option list is now exactly the set makeTransport can build: smtp | resend | postmark | log. No capability is lost with SendGrid/SES — both publish SMTP endpoints (smtp.sendgrid.net, email-smtp..amazonaws.com) and the field description names them, so a dead end is replaced by a working route. `log` is listed and labelled "no real delivery": it is the deliberate opt-out, it never claims to send, and it makes "offered" and "deliverable" the same set instead of merely overlapping. Stored sendgrid/ses values keep working the only way they can: applyMailSettings recognises an unsupported provider before the api_key check, keeps the previous transport (a settings row must never fail a boot), and logs at error with both the consequence and the SMTP fix. mail/test refuses the same way and sends nothing. Switching to smtp and saving recovers the transport with no restart. Also: api_key is visible+required for exactly resend|postmark (the old `provider !== 'smtp'` would have demanded a key to save "None (log only)"), and the built-in mail/test fallback rejects any provider outside the manifest's own option list. Held by tests in both directions — EMAIL_TRANSPORT_PROVIDERS is a runtime array (the union is derived from it) and mail-manifest-providers.contract.test.ts asserts set equality with the manifest options, then builds each transport for real. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017MCKJaEomEqg4tvz4SzdNd --- .changeset/mail-provider-options-honest.md | 70 ++++++++ packages/plugins/plugin-email/package.json | 1 + .../src/email-plugin.mail-settings.test.ts | 128 ++++++++++++++ .../plugins/plugin-email/src/email-plugin.ts | 39 +++- packages/plugins/plugin-email/src/index.ts | 16 +- .../mail-manifest-providers.contract.test.ts | 153 ++++++++++++++++ .../plugin-email/src/transports/index.ts | 75 +++++++- .../src/manifests/mail.manifest.test.ts | 166 ++++++++++++++++++ .../src/manifests/mail.manifest.ts | 78 +++++++- .../src/settings-service.test.ts | 6 +- .../service-settings/src/translations/en.ts | 6 +- .../src/translations/es-ES.ts | 6 +- .../src/translations/ja-JP.ts | 6 +- .../src/translations/zh-CN.ts | 5 +- pnpm-lock.yaml | 3 + 15 files changed, 731 insertions(+), 27 deletions(-) create mode 100644 .changeset/mail-provider-options-honest.md create mode 100644 packages/plugins/plugin-email/src/mail-manifest-providers.contract.test.ts create mode 100644 packages/services/service-settings/src/manifests/mail.manifest.test.ts diff --git a/.changeset/mail-provider-options-honest.md b/.changeset/mail-provider-options-honest.md new file mode 100644 index 0000000000..17293a271d --- /dev/null +++ b/.changeset/mail-provider-options-honest.md @@ -0,0 +1,70 @@ +--- +"@objectstack/service-settings": minor +"@objectstack/plugin-email": minor +--- + +fix(service-settings,plugin-email): the mail provider dropdown lists only providers that actually deliver (#5094) + +**Settings → Mail → Provider** offered `SMTP | SendGrid | Amazon SES | Postmark`. +`@objectstack/plugin-email` has never carried a SendGrid or an SES transport — +`makeTransport` knows `log` / `resend` / `postmark` / `smtp` and nothing else. So +selecting either of the two validated, saved, showed a success toast, and then +delivered no mail at all: the same declared-but-not-delivered gap #5087 closed +for SMTP, one field to the left. + +The same field broke the invariant in the other direction at the same time: +**`resend` has shipped a working transport all along and was not on the list**, +so nobody could pick the one HTTP provider that worked. + +**The dropdown is now `SMTP | Resend | Postmark | None (log only — no real +delivery)` — exactly the set `makeTransport` can build.** No email capability was +removed with SendGrid and SES. Both publish SMTP endpoints, and #5087 shipped a +real `SmtpTransport`, so both are configured today as `smtp`: + +| provider | host | port | credentials | +|:---------|:-----|:-----|:------------| +| SendGrid | `smtp.sendgrid.net` | 587 | username `apikey`, password = your API key | +| Amazon SES | `email-smtp..amazonaws.com` | 587 | SES **SMTP credentials** (generated in the SES console — not your AWS access keys) | + +The provider field's own description says this, so the migration is in front of +whoever goes looking for the option that disappeared. + +`log` is listed rather than hidden. It is the one option that does not deliver — +but it does not pretend to: the label says so, `LogTransport` still records every +message to `sys_email`, and "Send test email" answers `ok: false` for it. That +gives an operator the deliberate, visible opt-out AGENTS.md asks a degradation to +be, instead of expressing "no outbound mail" as a half-filled SMTP form. It is +also what makes *offered* and *deliverable* the same set rather than merely +overlapping — which is the property a test can hold. + +**Already saved `sendgrid` or `ses`? Nothing breaks and nothing goes quiet.** The +stored value outlives the dropdown, so `applyMailSettings` now recognises it +explicitly: the previous transport is kept (a settings row written by an older +release must never fail a boot), and the server logs at `error` with both halves +AGENTS.md requires — the consequence (*no mail is delivered through it*) and the +fix (the SMTP settings above), not a bare "unknown provider". It is checked +*before* the API-key check, because "set an API key" is the wrong instruction for +a provider that has nothing to hand a key to. "Send test email" refuses the same +way and sends nothing. Switching the provider to `smtp` and saving recovers the +transport without a restart. + +Two smaller corrections in the same field: + +- `api_key` is now shown and required for exactly `resend` and `postmark` + (`provider === 'resend' || provider === 'postmark'`). It was `provider !== + 'smtp'`, which only worked because every non-SMTP option happened to be an + HTTP API; `required` is enforced server-side wherever the field is visible, so + that expression would have refused to save "None (log only)" until an API key + it never reads had been typed in. +- The built-in `mail/test` fallback (the one that runs when no email plugin is + mounted) rejects any `provider` outside the manifest's own option list instead + of answering "the form is well-formed". + +**Held by a test, in both directions.** `EMAIL_TRANSPORT_PROVIDERS` is now a +runtime array (the `EmailTransportProvider` union is derived from it), and +`plugin-email`'s `mail-manifest-providers.contract.test.ts` asserts set equality +between it and the manifest's option values, then builds a real transport for +each. Adding an option without a transport fails; adding a transport without an +option fails. `RETIRED_EMAIL_PROVIDERS` / `isEmailTransportProvider` / +`unsupportedProviderFix` are exported alongside it for hosts that surface the +same guidance. diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 15bfe4a92e..8be6be66fc 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -25,6 +25,7 @@ "nodemailer": "^9.0.3" }, "devDependencies": { + "@objectstack/service-settings": "workspace:*", "@types/node": "^26.1.2", "@types/nodemailer": "^8.0.1", "typescript": "^6.0.3", diff --git a/packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts b/packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts index 43e992182a..48b05981a6 100644 --- a/packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { EmailServicePlugin } from './email-plugin.js'; import { EmailService, LogTransport } from './email-service.js'; import { SmtpTransport } from './transports/smtp.js'; +import { ResendTransport } from './transports/resend.js'; const nm = vi.hoisted(() => ({ createTransport: vi.fn(), sendMail: vi.fn() })); vi.mock('nodemailer', () => ({ @@ -221,6 +222,110 @@ describe('applyMailSettings — provider=smtp that cannot be built', () => { }); }); +// ── stored provider values with no transport (#5094) ─────────────────────── +// +// `sendgrid` and `ses` sat in the settings dropdown for several releases with +// no transport behind either. #5094 removed the options; it cannot remove the +// rows. A workspace that saved one still resolves `provider: 'sendgrid'` on +// every boot, so this is the one part of that change with live data behind it: +// the read must not throw, must not quietly look configured, and must say what +// to do — SendGrid and SES both publish SMTP endpoints, which is now the route. + +describe('applyMailSettings — a stored provider this build cannot deliver with', () => { + const RETIRED: Array<[string, RegExp]> = [ + ['sendgrid', /smtp\.sendgrid\.net/], + ['ses', /email-smtp\.\.amazonaws\.com/], + ]; + + for (const [provider, migration] of RETIRED) { + it(`keeps the transport, never throws, and names the SMTP migration for provider=${provider}`, async () => { + const { service, ctx } = await boot({ + provider: { value: provider, source: 'global' }, + api_key: { value: 'legacy-key', source: 'global' }, + }); + + // A settings row written by an older release must never be able to kill + // a running server: previous transport kept, boot completed. + expect(transportOf(service)).toBeInstanceOf(LogTransport); + + expect(ctx.logger.error).toHaveBeenCalledTimes(1); + const line = ctx.logger.error.mock.calls[0][0] as string; + expect(line).toContain(`provider='${provider}'`); + // Consequence… + expect(line).toMatch(/NO mail is delivered through it/); + // …and the fix, in the same line (AGENTS.md degradation-log-level). + expect(line).toMatch(/Fix:/); + expect(line).toMatch(migration); + }); + } + + it('reports the missing transport, not a missing api_key, when both are absent', async () => { + // "Set an API key" is the wrong instruction for a provider that has + // nothing to hand the key to — so the unsupported-provider check runs + // before the api_key check. + const { ctx } = await boot({ provider: { value: 'sendgrid', source: 'global' } }); + const line = ctx.logger.error.mock.calls[0][0] as string; + expect(line).not.toMatch(/api_key is empty/); + expect(line).toMatch(/smtp\.sendgrid\.net/); + }); + + it('leaves a working boot-configured SMTP transport in place', async () => { + // Mail may well still be going out (OS_EMAIL_SMTP_* configured the + // transport at boot). The stored provider is still unusable and the + // operator still has to fix it, so it is reported — but nothing that + // currently delivers is torn down on the way. + const { service, ctx } = await boot( + { provider: { value: 'ses', source: 'global' }, api_key: { value: 'k', source: 'global' } }, + { provider: 'smtp', providerOptions: { host: 'smtp.boot.test' } }, + ); + expect((transportOf(service) as SmtpTransport).describe()).toMatchObject({ host: 'smtp.boot.test' }); + expect(ctx.logger.error).toHaveBeenCalledTimes(1); + }); + + it('treats a typo the same way, naming the providers that do work', async () => { + const { ctx } = await boot({ + provider: { value: 'postmarkk', source: 'global' }, + api_key: { value: 'k', source: 'global' }, + }); + const line = ctx.logger.error.mock.calls[0][0] as string; + expect(line).toMatch(/log \/ resend \/ postmark \/ smtp/); + }); + + it('recovers on the next save — the bad value is not sticky', async () => { + const { service, settings } = await boot({ + provider: { value: 'sendgrid', source: 'global' }, + api_key: { value: 'legacy-key', source: 'global' }, + }); + expect(transportOf(service)).toBeInstanceOf(LogTransport); + + await settings.save({ + provider: { value: 'smtp', source: 'global' }, + smtp_host: { value: 'smtp.sendgrid.net', source: 'global' }, + smtp_user: { value: 'apikey', source: 'global' }, + smtp_password: { value: 'legacy-key', source: 'global' }, + }); + + expect((transportOf(service) as SmtpTransport).describe()).toMatchObject({ + host: 'smtp.sendgrid.net', + auth: { user: 'apikey' }, + }); + }); +}); + +describe('applyMailSettings — provider=resend', () => { + it('builds the transport the settings page can finally select', async () => { + // The reverse half of the same invariant: `resend` had a working transport + // all along and was missing from the dropdown (#5094). Now that it can be + // picked, prove picking it does something. + const { service, ctx } = await boot({ + provider: { value: 'resend', source: 'global' }, + api_key: { value: 're_live_key', source: 'global' }, + }); + expect(transportOf(service)).toBeInstanceOf(ResendTransport); + expect(ctx.logger.error).not.toHaveBeenCalled(); + }); +}); + describe('EmailServicePlugin constructor path (CLI / os serve)', () => { it('THROWS when provider=smtp has no host — a boot that cannot deliver fails loudly', async () => { const ctx = fakeCtx({ manifest: { register: () => {} } }); @@ -290,6 +395,29 @@ describe('mail/test action', () => { expect(nm.sendMail).not.toHaveBeenCalled(); }); + it.each(['sendgrid', 'ses'])( + 'refuses to "test" a stored provider=%s and points at SMTP instead', + async (provider) => { + const { settings } = await boot({ + provider: { value: provider, source: 'global' }, + api_key: { value: 'legacy-key', source: 'global' }, + }); + const result = await settings.action('test')!({ + values: { provider, api_key: 'legacy-key', from_email: 'no-reply@example.test' }, + payload: { to: 'admin@example.test' }, + }); + + expect(result).toMatchObject({ ok: false, severity: 'error' }); + // Not "Failed to build sendgrid transport: unknown provider" — the + // operator needs the route that works, not the internal symptom. + expect(result.message).toMatch(provider === 'sendgrid' ? /smtp\.sendgrid\.net/ : /email-smtp/); + expect(result.message).toMatch(/NOTHING was sent/); + expect(nm.sendMail).not.toHaveBeenCalled(); + // ...and it does not ask for an API key it cannot use. + expect(result.message).not.toMatch(/api_key is required/); + }, + ); + it('never reports success while only the LogTransport is active', async () => { const { settings } = await boot({ provider: { value: 'log', source: 'global' } }); const result = await settings.action('test')!({ diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index b3466fa9e3..daf0b32595 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -13,6 +13,8 @@ import { makeTransport, SmtpTransport, smtpOptionsFromMailSettings, + isEmailTransportProvider, + unsupportedProviderFix, type EmailTransportProvider, } from './transports/index.js'; import { BUILTIN_AUTH_TEMPLATES } from './templates/auth-templates.js'; @@ -262,12 +264,24 @@ export class EmailServicePlugin implements Plugin { return { ok: false, severity: 'error', message: `Failed to build SMTP transport: ${err?.message ?? String(err)}` }; } } else if (provider !== 'log') { + // A provider with no transport behind it — a value stored while + // the settings page still offered SendGrid / Amazon SES (#5094). + // Refuse before asking for an API key: nothing here can use one. + if (!isEmailTransportProvider(provider)) { + return { + ok: false, + severity: 'error', + message: `provider='${provider}' is not a provider this server can deliver with, so NOTHING was ` + + 'sent (and nothing has been sent through it since it was saved). Fix: ' + + unsupportedProviderFix(provider), + }; + } if (!apiKey) { return { ok: false, severity: 'error', message: `${provider}: api_key is required.` }; } try { const transport = makeTransport({ - provider: provider as 'resend' | 'postmark', + provider, apiKey, logger: ctx.logger, }); @@ -567,6 +581,12 @@ export class EmailServicePlugin implements Plugin { * still applied. * - `provider = 'resend' | 'postmark'` rebuilds the transport using * `api_key` from settings. + * - anything else — including `sendgrid` / `ses`, which the settings page + * offered for several releases without a transport behind either (#5094) + * and which persisted workspaces still resolve — keeps the previous + * transport and reports at `error` with the SMTP migration that replaces + * it. A settings value written by an older release must not be able to + * stop a server from booting, and must not be able to look configured. * * **This path never throws.** A settings save must not be able to kill a * running server, so a transport that cannot be built leaves the previous @@ -649,6 +669,21 @@ export class EmailServicePlugin implements Plugin { return; } + // A stored provider this build cannot deliver with — checked BEFORE the + // api_key branch, because "set an API key" is the wrong instruction for a + // provider that has no transport to hand the key to. Same shape as every + // other failure here: previous transport kept, error naming the consequence + // and the fix, no throw. Workspaces that saved `sendgrid` / `ses` while the + // settings page still offered them arrive here on every boot (#5094). + if (!isEmailTransportProvider(provider)) { + ctx.logger.error( + `EmailServicePlugin: provider='${provider}' is not a provider this server can deliver with — the ` + + 'previous transport is kept and NO mail is delivered through it. Fix: ' + + unsupportedProviderFix(provider), + ); + return; + } + const apiKey = typeof values.api_key === 'string' ? values.api_key : undefined; if (!apiKey) { ctx.logger.error( @@ -660,7 +695,7 @@ export class EmailServicePlugin implements Plugin { try { const transport = makeTransport({ - provider: provider as 'resend' | 'postmark', + provider, apiKey, logger: ctx.logger, }); diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index 85d7491445..8355f60bbc 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -4,9 +4,14 @@ * @objectstack/plugin-email * * Outbound email delivery for ObjectStack. Registers an `IEmailService` - * implementation backed by a pluggable `IEmailTransport` (SMTP via - * nodemailer, SendGrid, Resend, SES, …) and persists each attempt to - * the `sys_email` system object for audit / activity-stream display. + * implementation backed by a pluggable `IEmailTransport` — SMTP via + * nodemailer, Resend, Postmark — and persists each attempt to the + * `sys_email` system object for audit / activity-stream display. + * + * The list above is exhaustive on purpose: it used to read "SendGrid, …", + * which no transport here has ever implemented (#5094). SendGrid and Amazon + * SES are delivered through `SmtpTransport` against their published SMTP + * endpoints. `EMAIL_TRANSPORT_PROVIDERS` is the machine-readable form. */ export { EmailServicePlugin } from './email-plugin.js'; @@ -20,6 +25,11 @@ export { SmtpTransport, makeTransport, smtpOptionsFromMailSettings, + EMAIL_TRANSPORT_PROVIDERS, + RETIRED_EMAIL_PROVIDERS, + isEmailTransportProvider, + retiredProviderGuidance, + unsupportedProviderFix, type ResendTransportOptions, type PostmarkTransportOptions, type SmtpTransportOptions, diff --git a/packages/plugins/plugin-email/src/mail-manifest-providers.contract.test.ts b/packages/plugins/plugin-email/src/mail-manifest-providers.contract.test.ts new file mode 100644 index 0000000000..ead3fbd305 --- /dev/null +++ b/packages/plugins/plugin-email/src/mail-manifest-providers.contract.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// The `mail` settings dropdown ↔ this package's transports (#5094). +// +// The acceptance criterion of #5094, made executable: **every provider an admin +// can select must be one this package can actually deliver through, and every +// provider this package can deliver through must be selectable.** Those are the +// two halves of one invariant, and both halves were broken at once — +// `sendgrid` / `ses` were offered by `mail.manifest.ts` with no transport +// behind them (the form validated, the save succeeded, no mail was ever sent), +// while `resend` had a working transport that the settings page never listed. +// +// This file is deliberately a CROSS-PACKAGE assertion rather than two mirrored +// literal lists: a literal pinned on each side can be "fixed" by editing the +// other literal, which is exactly how the two drifted apart in the first place. +// `@objectstack/service-settings` is therefore a devDependency here — test-only, +// no runtime edge — so the option list is compared against the real +// `makeTransport`, and adding a dropdown option without a transport (or a +// transport without a dropdown option) fails. + +import { describe, it, expect } from 'vitest'; +import { mailSettingsManifest, mailTestActionHandler } from '@objectstack/service-settings'; +import { + makeTransport, + EMAIL_TRANSPORT_PROVIDERS, + RETIRED_EMAIL_PROVIDERS, + isEmailTransportProvider, +} from './transports/index.js'; +import { LogTransport } from './email-service.js'; +import { ResendTransport } from './transports/resend.js'; +import { PostmarkTransport } from './transports/postmark.js'; +import { SmtpTransport } from './transports/smtp.js'; + +/** The `provider` select's option values, read off the shipped manifest. */ +function providerOptions(): string[] { + const spec = (mailSettingsManifest.specifiers as Array>).find( + (s) => s.key === 'provider', + ); + expect(spec, 'mail manifest must declare a `provider` specifier').toBeDefined(); + return (spec!.options as Array<{ value: string }>).map((o) => o.value); +} + +/** 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('mail settings dropdown ↔ email transports', () => { + it('offers exactly the providers this package can deliver through', () => { + // Set equality, both directions at once: + // ⊆ — no option without a transport (the #5087/#5094 defect shape); + // ⊇ — no transport the settings page hides (the `resend` gap). + expect(new Set(providerOptions())).toEqual(new Set(EMAIL_TRANSPORT_PROVIDERS)); + }); + + it('builds a real transport for every option the dropdown offers', () => { + const built = providerOptions().map((provider) => { + const args = BUILD_ARGS[provider]; + expect(args, `no build recipe for offered provider '${provider}'`).toBeDefined(); + return [provider, makeTransport(args)] as const; + }); + + // Not just "did not throw" — the right transport class for each tag. + const byProvider = Object.fromEntries(built); + expect(byProvider.smtp).toBeInstanceOf(SmtpTransport); + expect(byProvider.resend).toBeInstanceOf(ResendTransport); + expect(byProvider.postmark).toBeInstanceOf(PostmarkTransport); + expect(byProvider.log).toBeInstanceOf(LogTransport); + }); + + it('still lists `smtp` as the default — the one provider that needs no SaaS account', () => { + const spec = (mailSettingsManifest.specifiers as Array>).find( + (s) => s.key === 'provider', + ); + expect(spec!.default).toBe('smtp'); + expect(providerOptions()).toContain('smtp'); + }); + + it('no longer offers sendgrid / ses — they are configured as SMTP', () => { + const offered = providerOptions(); + for (const retired of Object.keys(RETIRED_EMAIL_PROVIDERS)) { + expect(offered).not.toContain(retired); + expect(isEmailTransportProvider(retired)).toBe(false); + } + // The dropdown does not simply drop them on the floor: the field + // description tells an admin where SendGrid / SES went. + const spec = (mailSettingsManifest.specifiers as Array>).find( + (s) => s.key === 'provider', + ); + expect(String(spec!.description)).toMatch(/smtp\.sendgrid\.net/); + expect(String(spec!.description)).toMatch(/email-smtp\..*amazonaws\.com/); + }); + + it('refuses to build a retired provider, and says what to use instead', () => { + expect(() => makeTransport({ provider: 'sendgrid' as never, apiKey: 'k' })) + .toThrow(/smtp\.sendgrid\.net/); + expect(() => makeTransport({ provider: 'ses' as never, apiKey: 'k' })) + .toThrow(/email-smtp\.\.amazonaws\.com/); + // A typo'd / unknown value gets the supported list rather than silence. + expect(() => makeTransport({ provider: 'mailgun' as never, apiKey: 'k' })) + .toThrow(/log \/ resend \/ postmark \/ smtp/); + }); +}); + +describe('mail/test built-in fallback ↔ the same option list', () => { + const base = { from_email: 'ops@example.test' }; + + it('rejects a stored provider the dropdown no longer offers', async () => { + for (const retired of Object.keys(RETIRED_EMAIL_PROVIDERS)) { + const r = await mailTestActionHandler({ + values: { ...base, provider: retired, api_key: 'legacy-key' }, + namespace: 'mail', + actionId: 'test', + ctx: {} as never, + }); + expect(r.ok).toBe(false); + expect(r.severity).toBe('error'); + expect(r.message).toMatch(/NO mail is being sent/); + // Consequence AND fix, per AGENTS.md — never a bare "unknown provider". + expect(r.message).toMatch(/smtp/i); + } + }); + + it('does not demand an API key for providers that take none', async () => { + for (const provider of ['smtp', 'log']) { + const r = await mailTestActionHandler({ + values: { ...base, provider, smtp_host: 'smtp.example.test' }, + namespace: 'mail', + actionId: 'test', + ctx: {} as never, + }); + // ok:false either way — nothing can send without the plugin mounted — + // but never "API key is required" for a provider that has no API. + expect(r.message).not.toMatch(/API key is required/); + } + }); + + it('still demands an API key for resend / postmark', async () => { + for (const provider of ['resend', 'postmark']) { + const r = await mailTestActionHandler({ + values: { ...base, provider }, + namespace: 'mail', + actionId: 'test', + ctx: {} as never, + }); + expect(r.ok).toBe(false); + expect(r.message).toMatch(/API key is required/); + } + }); +}); diff --git a/packages/plugins/plugin-email/src/transports/index.ts b/packages/plugins/plugin-email/src/transports/index.ts index 3706619619..6e6ea4820c 100644 --- a/packages/plugins/plugin-email/src/transports/index.ts +++ b/packages/plugins/plugin-email/src/transports/index.ts @@ -10,8 +10,71 @@ export { ResendTransport, type ResendTransportOptions } from './resend.js'; export { PostmarkTransport, type PostmarkTransportOptions } from './postmark.js'; export { SmtpTransport, smtpOptionsFromMailSettings, type SmtpTransportOptions } from './smtp.js'; +/** + * Transport tags this package can materialise — the single source of truth, + * available at runtime as well as to the type system. + * + * It is a value and not just a union because the settings page's provider + * dropdown must be held equal to it: `mail.manifest.ts` offering a provider + * this array does not carry is the declared-but-not-delivered defect of #5094 + * (SendGrid / Amazon SES), and this array carrying one the dropdown does not + * offer is the same invariant broken the other way (`resend`, which worked all + * along and could not be selected). Both directions are asserted in + * `mail-manifest-providers.contract.test.ts`. + * + * Adding a value here without adding a `case` to {@link makeTransport} does not + * compile; adding one to either without listing it on the settings page fails + * that test. Keep it that way. + */ +export const EMAIL_TRANSPORT_PROVIDERS = ['log', 'resend', 'postmark', 'smtp'] as const; + /** Transport tags this package can materialise. */ -export type EmailTransportProvider = 'log' | 'resend' | 'postmark' | 'smtp'; +export type EmailTransportProvider = (typeof EMAIL_TRANSPORT_PROVIDERS)[number]; + +/** Narrow an arbitrary stored/DB value to a provider this package supports. */ +export function isEmailTransportProvider(value: unknown): value is EmailTransportProvider { + return typeof value === 'string' + && (EMAIL_TRANSPORT_PROVIDERS as readonly string[]).includes(value); +} + +/** + * Provider tags the settings page used to offer and this package never + * implemented, mapped to the migration that replaces them (#5094). + * + * They are kept here, rather than simply forgotten, because the *stored* value + * outlives the dropdown: a workspace that saved `sendgrid` in an earlier + * release still resolves `provider: 'sendgrid'` on every boot. Answering that + * with a bare "unknown provider" would be technically true and useless — the + * operator needs to know that mail is not going out and what to change, and + * both providers publish an SMTP endpoint that works today. + */ +export const RETIRED_EMAIL_PROVIDERS: Readonly> = Object.freeze({ + sendgrid: + "no SendGrid HTTP-API transport was ever implemented. Use provider='smtp' with host " + + "smtp.sendgrid.net, port 587, username 'apikey' and your SendGrid API key as the password.", + ses: + "no Amazon SES HTTP-API transport was ever implemented. Use provider='smtp' with host " + + 'email-smtp..amazonaws.com, port 587, and SES SMTP credentials (generated in the ' + + 'SES console — they are NOT your AWS access keys).', +}); + +/** Migration guidance for a retired provider tag, or `undefined` if it is not one. */ +export function retiredProviderGuidance(provider: string): string | undefined { + return Object.prototype.hasOwnProperty.call(RETIRED_EMAIL_PROVIDERS, provider) + ? RETIRED_EMAIL_PROVIDERS[provider] + : undefined; +} + +/** + * One sentence naming what to do about a provider this package cannot build — + * the retired-provider migration when there is one, otherwise the list of tags + * that do work. Shared by every site that has to refuse such a value so they + * cannot drift apart. + */ +export function unsupportedProviderFix(provider: string): string { + return retiredProviderGuidance(provider) + ?? `pick one of ${EMAIL_TRANSPORT_PROVIDERS.join(' / ')} (Settings → Mail → Provider).`; +} export interface MakeTransportOptions { provider: EmailTransportProvider; @@ -34,6 +97,12 @@ export interface MakeTransportOptions { * cannot be built: `resend`/`postmark` without an `apiKey`, `smtp` without * a `host`. A transport that silently becomes a no-op while the caller * believes mail is configured is the defect #5087 exists to close. + * + * A provider outside {@link EMAIL_TRANSPORT_PROVIDERS} throws too, and the + * message carries the fix: the SMTP migration for a retired tag, the supported + * list otherwise. The `default` arm is unreachable by type, and reached in + * practice by values that predate the type — a `provider` column written when + * the settings page offered more than it could deliver (#5094). */ export function makeTransport(opts: MakeTransportOptions): IEmailTransport { const { provider, apiKey, options = {}, logger } = opts; @@ -57,6 +126,8 @@ export function makeTransport(opts: MakeTransportOptions): IEmailTransport { return new SmtpTransport({ ...smtp, host: smtp.host, logger }); } default: - throw new Error(`makeTransport: unknown provider '${provider}'`); + throw new Error( + `makeTransport: unsupported provider '${provider}' — ${unsupportedProviderFix(String(provider))}`, + ); } } diff --git a/packages/services/service-settings/src/manifests/mail.manifest.test.ts b/packages/services/service-settings/src/manifests/mail.manifest.test.ts new file mode 100644 index 0000000000..0632688410 --- /dev/null +++ b/packages/services/service-settings/src/manifests/mail.manifest.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// The mail manifest's own guard rail (#5094). +// +// The cross-package half of the invariant — "every option has a transport, and +// every transport has an option" — is asserted against the real `makeTransport` +// in `packages/plugins/plugin-email/src/mail-manifest-providers.contract.test.ts`. +// What lives HERE is what an editor of this file needs to see fail in this +// file: the option list itself, and the form logic that hangs off it. + +import { describe, it, expect } from 'vitest'; +import { SettingsManifestSchema } from '@objectstack/spec/system'; +import { mailSettingsManifest, mailTestActionHandler } from './mail.manifest.js'; +import { evaluateVisibility } from '../visibility-eval.js'; + +type Spec = Record; +const specs = () => mailSettingsManifest.specifiers as unknown as Spec[]; +const spec = (key: string) => specs().find((s) => s.key === key)!; +const group = (id: string) => specs().find((s) => s.type === 'group' && s.id === id)!; +const providerValues = () => (spec('provider').options as Array<{ value: string }>).map((o) => o.value); + +describe('mailSettingsManifest', () => { + it('parses against SettingsManifestSchema', () => { + expect(() => SettingsManifestSchema.parse(mailSettingsManifest)).not.toThrow(); + }); + + it('declares namespace=mail, scope=global, version=1', () => { + const parsed = SettingsManifestSchema.parse(mailSettingsManifest); + expect(parsed.namespace).toBe('mail'); + expect(parsed.scope).toBe('global'); + expect(parsed.version).toBe(1); + }); + + it('offers exactly the providers @objectstack/plugin-email can deliver through', () => { + // Changing this list is a CONTRACT change, not a copy edit. Adding a value + // means adding a `case` to `makeTransport` in @objectstack/plugin-email + // (and a `BUILD_ARGS` entry in its contract test); without that, the option + // saves cleanly and then delivers nothing, which is precisely #5087/#5094. + expect(providerValues()).toEqual(['smtp', 'resend', 'postmark', 'log']); + expect(spec('provider').default).toBe('smtp'); + }); + + it('does not offer sendgrid / ses, and points them at SMTP instead', () => { + expect(providerValues()).not.toContain('sendgrid'); + expect(providerValues()).not.toContain('ses'); + const description = String(spec('provider').description); + expect(description).toMatch(/smtp\.sendgrid\.net/); + expect(description).toMatch(/email-smtp\.\.amazonaws\.com/); + }); + + it('labels the non-delivering option as non-delivering', () => { + const log = (spec('provider').options as Array<{ value: string; label: string }>) + .find((o) => o.value === 'log')!; + // `log` is the one option that does not send. It is allowed on an admin + // page only because it says so on the tin. + expect(log.label).toMatch(/no real delivery/i); + }); + + it('shows the SMTP fields for exactly provider=smtp', () => { + const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_password']; + for (const provider of providerValues()) { + const shown = provider === 'smtp'; + expect(evaluateVisibility(group('smtp').visible, { provider })).toBe(shown); + for (const key of smtpKeys) { + expect(evaluateVisibility(spec(key).visible, { provider }), `${key} @ ${provider}`).toBe(shown); + } + } + }); + + it('requires the API key for exactly the providers that have an API', () => { + // `api_key` is `required`, and SettingsService enforces `required` only + // where the field is visible — so a visibility expression that is merely + // "not smtp" makes saving provider=log impossible until an API key nothing + // will ever read has been typed in. + expect(spec('api_key').required).toBe(true); + expect(spec('api_key').encrypted).toBe(true); + for (const provider of providerValues()) { + const needsKey = provider === 'resend' || provider === 'postmark'; + expect(evaluateVisibility(spec('api_key').visible, { provider }), `api_key @ ${provider}`) + .toBe(needsKey); + expect(evaluateVisibility(group('api_key').visible, { provider }), `api_key group @ ${provider}`) + .toBe(needsKey); + } + }); + + it('exposes a test action that POSTs to /api/settings/mail/test', () => { + const test = specs().find((s) => s.type === 'action_button' && s.id === 'test'); + expect(test).toBeDefined(); + expect(test!.handler).toMatchObject({ + kind: 'http', + method: 'POST', + url: '/api/settings/mail/test', + }); + }); +}); + +describe('mailTestActionHandler (fallback — no email plugin mounted)', () => { + const base = { from_email: 'ops@example.test' }; + + it('never reports success — it cannot send anything', async () => { + for (const provider of providerValues()) { + const r = await mailTestActionHandler({ + values: { ...base, provider, smtp_host: 'smtp.example.test', api_key: 'k' }, + namespace: 'mail', + actionId: 'test', + ctx: {} as never, + }); + expect(r.ok, `provider=${provider}`).toBe(false); + } + }); + + it('requires a from address', async () => { + const r = await mailTestActionHandler({ values: { provider: 'smtp' }, namespace: 'mail', actionId: 'test', ctx: {} as never }); + expect(r).toMatchObject({ ok: false, severity: 'error' }); + expect(r.message).toMatch(/from address/i); + }); + + it('requires an SMTP host for provider=smtp', async () => { + const r = await mailTestActionHandler({ values: { ...base, provider: 'smtp' }, namespace: 'mail', actionId: 'test', ctx: {} as never }); + expect(r).toMatchObject({ ok: false, severity: 'error' }); + expect(r.message).toMatch(/SMTP host is required/); + }); + + it.each(['resend', 'postmark'])('requires an API key for provider=%s', async (provider) => { + const r = await mailTestActionHandler({ values: { ...base, provider }, namespace: 'mail', actionId: 'test', ctx: {} as never }); + expect(r).toMatchObject({ ok: false, severity: 'error' }); + expect(r.message).toMatch(/API key is required/); + }); + + it('does not ask provider=log for an API key', async () => { + const r = await mailTestActionHandler({ values: { ...base, provider: 'log' }, namespace: 'mail', actionId: 'test', ctx: {} as never }); + expect(r.message).not.toMatch(/API key is required/); + }); + + it.each(['sendgrid', 'ses'])( + 'refuses a stored provider=%s at error level, with the consequence and the fix', + async (provider) => { + // The value a workspace saved while the dropdown still offered it. This + // handler cannot deliver anything anyway, but answering "the form is + // well-formed" for a provider nothing can send through is the same lie + // #5087 removed from this file. + const r = await mailTestActionHandler({ + values: { ...base, provider, api_key: 'legacy-key' }, + namespace: 'mail', + actionId: 'test', + ctx: {} as never, + }); + expect(r.ok).toBe(false); + expect(r.severity).toBe('error'); + expect(r.message).toMatch(/NO mail is being sent/); + expect(r.message).toMatch(/smtp\.sendgrid\.net|email-smtp/); + expect(r.message).not.toMatch(/well-formed/); + }, + ); + + it('refuses an unknown provider value the same way', async () => { + const r = await mailTestActionHandler({ + values: { ...base, provider: 'mailgun' }, + namespace: 'mail', + actionId: 'test', + ctx: {} as never, + }); + expect(r).toMatchObject({ ok: false, severity: 'error' }); + expect(r.message).toMatch(/smtp \/ resend \/ postmark \/ log/); + }); +}); diff --git a/packages/services/service-settings/src/manifests/mail.manifest.ts b/packages/services/service-settings/src/manifests/mail.manifest.ts index 3fe651f384..447bc127ac 100644 --- a/packages/services/service-settings/src/manifests/mail.manifest.ts +++ b/packages/services/service-settings/src/manifests/mail.manifest.ts @@ -3,6 +3,40 @@ import type { SettingsManifest } from '@objectstack/spec/system'; import type { SettingsActionHandler } from '../settings-service.types.js'; +/** + * ⚠️ This list is a CONTRACT, not a menu of aspirations: every value here must + * be one `@objectstack/plugin-email` can actually build a transport for, and + * every transport it can build must appear here. The two sets are held equal by + * an executable assertion — + * `packages/plugins/plugin-email/src/mail-manifest-providers.contract.test.ts` + * compares these values against `EMAIL_TRANSPORT_PROVIDERS` / `makeTransport` + * and goes red the moment they diverge, in either direction. + * + * Both directions had drifted (#5094): `sendgrid` and `ses` were offered here + * with no transport behind them — selecting one validated, saved, and then + * delivered nothing — while `resend`, which has shipped a working transport all + * along, could not be picked at all. Neither SendGrid nor SES lost any + * capability by being removed: both publish SMTP endpoints, so they are + * configured through `smtp` (see the field description below), which is a + * working route rather than a dead end. + * + * `log` is listed, and labelled for what it does. It is the one option that + * does not deliver, but it does not *pretend* to: the label says so, the + * runtime honours it (`LogTransport` records to `sys_email`), and `mail/test` + * answers `ok: false` for it. That is the deliberate, visible opt-out + * AGENTS.md asks a degradation to be, and it is what makes "offered" and + * "deliverable" exactly the same set instead of merely overlapping. + */ +const PROVIDER_OPTIONS = [ + { value: 'smtp', label: 'SMTP' }, + { value: 'resend', label: 'Resend' }, + { value: 'postmark', label: 'Postmark' }, + { value: 'log', label: 'None (log only — no real delivery)' }, +]; + +/** Providers that need `api_key` — kept in step with the `visible` expressions below. */ +const API_KEY_PROVIDERS: readonly string[] = ['resend', 'postmark']; + // Visibility expressions are written as inline strings here for // readability. The spec's ExpressionInputSchema accepts a bare string // and normalises it at parse time, but the inferred TypeScript output @@ -23,13 +57,14 @@ const manifest = { { type: 'group', id: 'provider', label: 'Provider', required: false, description: 'Choose how this workspace sends outbound email.' }, + // See PROVIDER_OPTIONS above — the option list is a contract with + // @objectstack/plugin-email, not a wish list. { type: 'select', key: 'provider', label: 'Provider', required: true, default: 'smtp', - options: [ - { value: 'smtp', label: 'SMTP' }, - { value: 'sendgrid', label: 'SendGrid' }, - { value: 'ses', label: 'Amazon SES' }, - { value: 'postmark', label: 'Postmark' }, - ], + description: 'Only providers this server can actually deliver through are listed. ' + + 'SendGrid and Amazon SES are configured as SMTP — host smtp.sendgrid.net ' + + '(username "apikey", password = your API key), or email-smtp..amazonaws.com ' + + 'with SES SMTP credentials.', + options: PROVIDER_OPTIONS, }, { type: 'group', id: 'smtp', label: 'SMTP', required: false, visible: "${data.provider === 'smtp'}" }, @@ -44,9 +79,15 @@ const manifest = { { type: 'password', key: 'smtp_password', label: 'Password', required: false, visible: "${data.provider === 'smtp'}" }, - { type: 'group', id: 'api_key', label: 'API key', required: false, visible: "${data.provider !== 'smtp'}" }, + // Named positively — `provider !== 'smtp'` was only ever correct because + // every non-SMTP option happened to be an HTTP API. `log` is not, and a + // REQUIRED field is enforced server-side exactly when it is visible + // (`SettingsService.validatePatch`), so the negative form would refuse to + // save "None (log only)" until an API key it never uses was typed in. + { type: 'group', id: 'api_key', label: 'API key', required: false, + visible: "${data.provider === 'resend' || data.provider === 'postmark'}" }, { type: 'password', key: 'api_key', label: 'API key', required: true, encrypted: true, - visible: "${data.provider !== 'smtp'}" }, + visible: "${data.provider === 'resend' || data.provider === 'postmark'}" }, { type: 'group', id: 'from_address', label: 'From address', required: false }, { type: 'email', key: 'from_email', label: 'From email', required: true, @@ -61,6 +102,9 @@ const manifest = { /** Mail Delivery — SMTP / API provider configuration. */ export const mailSettingsManifest = manifest as unknown as SettingsManifest; +/** Provider values the dropdown offers — the same array the manifest renders. */ +const OFFERED_PROVIDERS: readonly string[] = PROVIDER_OPTIONS.map((o) => o.value); + /** * Built-in FALLBACK handler for `mail/test`. * @@ -75,6 +119,11 @@ export const mailSettingsManifest = manifest as unknown as SettingsManifest; * delivery": a success toast for a mail nobody sent, naming a package that * has never existed. An action button that says "Send test email" must * never report success for a send that did not happen (framework#5087). + * + * It also refuses a `provider` value the dropdown does not offer. Workspaces + * that saved `sendgrid` / `ses` while those options existed still carry the + * value (#5094), and "the form is well-formed" is the wrong answer for a + * provider nothing can deliver through. */ export const mailTestActionHandler: SettingsActionHandler = async ({ values }) => { const provider = String(values.provider ?? 'smtp'); @@ -82,10 +131,21 @@ export const mailTestActionHandler: SettingsActionHandler = async ({ values }) = if (!fromEmail) { return { ok: false, severity: 'error', message: 'Configure a from address before testing.' }; } + if (!OFFERED_PROVIDERS.includes(provider)) { + return { + ok: false, + severity: 'error', + message: `provider='${provider}' is not a delivery provider this server supports, so NO mail is being sent ` + + `(a stored value from an older release, most likely). Fix: pick one of ${OFFERED_PROVIDERS.join(' / ')} ` + + 'in Settings → Mail → Provider and save. SendGrid and Amazon SES are configured as SMTP — ' + + 'smtp.sendgrid.net (username "apikey", password = your API key), or ' + + 'email-smtp..amazonaws.com with SES SMTP credentials.', + }; + } if (provider === 'smtp' && !values.smtp_host) { return { ok: false, severity: 'error', message: 'SMTP host is required.' }; } - if (provider !== 'smtp' && !values.api_key) { + if (API_KEY_PROVIDERS.includes(provider) && !values.api_key) { return { ok: false, severity: 'error', message: 'API key is required.' }; } return { diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index 8a5c72e1ee..05f0434486 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -88,9 +88,9 @@ describe('SettingsService — encryption round-trip', () => { it('persists encrypted=true values via crypto adapter', async () => { const svc = new SettingsService({ env: {}, crypto: new NoopCryptoAdapter() }); svc.registerManifest(mailSettingsManifest); - await svc.setMany('mail', { provider: 'sendgrid', api_key: 'sg-secret-123', from_email: 'a@b.com' }); + await svc.setMany('mail', { provider: 'resend', api_key: 're-secret-123', from_email: 'a@b.com' }); const ns = await svc.getNamespace('mail'); - expect(ns.values.api_key.value).toBe('sg-secret-123'); + expect(ns.values.api_key.value).toBe('re-secret-123'); expect(ns.values.api_key.source).toBe('global'); }); }); @@ -138,7 +138,7 @@ describe('SettingsService — audit sink', () => { audit: { record: (e) => events.push(e) }, }); svc.registerManifest(mailSettingsManifest); - await svc.setMany('mail', { provider: 'sendgrid', api_key: 'top-secret', from_email: 'a@b.com' }); + await svc.setMany('mail', { provider: 'resend', api_key: 'top-secret', from_email: 'a@b.com' }); const apiKeyEvent = events.find((e) => e.key === 'api_key'); expect(apiKeyEvent).toBeTruthy(); expect(apiKeyEvent.encrypted).toBe(true); diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index b6ebf768c7..86bc453cde 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -32,11 +32,13 @@ export const en: TranslationData = { keys: { provider: { label: 'Provider', + help: 'Only providers this server can actually deliver through are listed. ' + + 'SendGrid and Amazon SES are configured as SMTP.', options: { smtp: 'SMTP', - sendgrid: 'SendGrid', - ses: 'Amazon SES', + resend: 'Resend', postmark: 'Postmark', + log: 'None (log only — no real delivery)', }, }, smtp_host: { label: 'Host', help: 'Example: smtp.example.com' }, diff --git a/packages/services/service-settings/src/translations/es-ES.ts b/packages/services/service-settings/src/translations/es-ES.ts index e6f9a6dd38..e8b80e3830 100644 --- a/packages/services/service-settings/src/translations/es-ES.ts +++ b/packages/services/service-settings/src/translations/es-ES.ts @@ -28,11 +28,13 @@ export const esES: TranslationData = { keys: { provider: { label: 'Proveedor', + help: 'Solo se muestran los proveedores con los que este servidor puede entregar realmente. ' + + 'SendGrid y Amazon SES se configuran como SMTP.', options: { smtp: 'SMTP', - sendgrid: 'SendGrid', - ses: 'Amazon SES', + resend: 'Resend', postmark: 'Postmark', + log: 'Ninguno (solo registro — sin entrega real)', }, }, smtp_host: { label: 'Host', help: 'Ejemplo: smtp.example.com' }, diff --git a/packages/services/service-settings/src/translations/ja-JP.ts b/packages/services/service-settings/src/translations/ja-JP.ts index d6b3adb5fe..b9c33d79cf 100644 --- a/packages/services/service-settings/src/translations/ja-JP.ts +++ b/packages/services/service-settings/src/translations/ja-JP.ts @@ -28,11 +28,13 @@ export const jaJP: TranslationData = { keys: { provider: { label: 'プロバイダー', + help: 'このサーバーが実際に配信できるプロバイダーのみを表示しています。' + + 'SendGrid と Amazon SES は SMTP として設定します。', options: { smtp: 'SMTP', - sendgrid: 'SendGrid', - ses: 'Amazon SES', + resend: 'Resend', postmark: 'Postmark', + log: '送信しない(ログのみ — 実際には配信されません)', }, }, smtp_host: { label: 'ホスト', help: '例: smtp.example.com' }, diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 29da823633..23dc48b9f7 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -28,11 +28,12 @@ export const zhCN: TranslationData = { keys: { provider: { label: '服务商', + help: '此处只列出本服务器真正能投递的服务商。SendGrid 与 Amazon SES 通过 SMTP 配置。', options: { smtp: 'SMTP', - sendgrid: 'SendGrid', - ses: 'Amazon SES', + resend: 'Resend', postmark: 'Postmark', + log: '不发送(仅记录日志,不真正投递)', }, }, smtp_host: { label: '主机', help: '示例:smtp.example.com' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d69e1aaec7..3da7be75a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1472,6 +1472,9 @@ importers: specifier: ^9.0.3 version: 9.0.3 devDependencies: + '@objectstack/service-settings': + specifier: workspace:* + version: link:../../services/service-settings '@types/node': specifier: ^26.1.2 version: 26.1.2