From a8b708d99108933cca88f5e82330f4774d56a696 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 08:57:37 +0000 Subject: [PATCH 1/2] feat(spec, plugin-email): IEmailService.isConfigured() answers whether mail is delivered Adds an optional isConfigured(): boolean member to IEmailService, shaped like ISmsService.isConfigured(). plugin-email's EmailService answers false while its transport is the LogTransport and true otherwise, read from the live transport so it follows runtime swaps from the mail settings. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .../25065-email-service-is-configured.md | 13 +++++++ .../src/email-plugin.mail-settings.test.ts | 37 +++++++++++++++++++ .../plugin-email/src/email-service.test.ts | 23 ++++++++++++ .../plugins/plugin-email/src/email-service.ts | 11 ++++++ .../spec/src/contracts/email-service.test.ts | 34 ++++++++++++++++- packages/spec/src/contracts/email-service.ts | 27 ++++++++++++++ 6 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 .changeset/25065-email-service-is-configured.md diff --git a/.changeset/25065-email-service-is-configured.md b/.changeset/25065-email-service-is-configured.md new file mode 100644 index 00000000000..4007b2a7de9 --- /dev/null +++ b/.changeset/25065-email-service-is-configured.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-email': minor +--- + +feat(spec, plugin-email): `IEmailService.isConfigured()` answers whether mail sent through the service is actually delivered + +Clause-②: yes (widening: an optional member on `IEmailService`) + +- **The contract.** `IEmailService` (`@objectstack/spec/contracts`) gains one optional member, `isConfigured?(): boolean`, with the same name and shape as `ISmsService.isConfigured()`. `true` means the active transport hands mail to a real delivery provider. `false` means no mail leaves the process: sends still resolve and still write their audit row. A `log` transport is never delivering, in any environment. The answer is read at call time and follows a transport swapped in at runtime. +- **Absent means unknown.** The member is optional, so every existing implementation stays valid. A consumer must never read an absent member as delivering: a flow whose success depends on a mail reaching someone treats absent the same as `false` and keeps its explicit refusal, so a service that is merely wired is never mistaken for one that delivers. +- **The implementation.** `@objectstack/plugin-email`'s `EmailService.isConfigured()` answers `false` while its transport is the `LogTransport` (the default when no mail transport is configured) and `true` for any other transport. It follows the transport the plugin rebuilds from the `mail` settings, so saving an SMTP or API-provider configuration turns it `true` without a restart. +- **Unchanged.** Sending, templates, persistence and the boot warning for the `log` transport behave as before. No consumer reads the member yet. 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 d11515cefef..81b5bd18ebc 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 @@ -430,3 +430,40 @@ describe('mail/test action', () => { expect(result.message).not.toMatch(/Configuration looks valid|plugin-mail/); }); }); + +describe('registered email service — isConfigured follows the mail settings', () => { + it('answers false on the out-of-the-box boot (LogTransport, mail never configured)', async () => { + const { service } = await boot({}); + expect(transportOf(service)).toBeInstanceOf(LogTransport); + expect(service.isConfigured()).toBe(false); + }); + + it('turns true when a saved SMTP config replaces the LogTransport at runtime', async () => { + const { service, settings } = await boot({ provider: { value: 'log', source: 'global' } }); + expect(service.isConfigured()).toBe(false); + + await settings.save({ + provider: { value: 'smtp', source: 'global' }, + smtp_host: { value: 'smtp.live.test', source: 'global' }, + }); + + expect(transportOf(service)).toBeInstanceOf(SmtpTransport); + expect(service.isConfigured()).toBe(true); + }); + + it('stays true across a swap from one live transport to another', async () => { + const { service, settings } = await boot({ + provider: { value: 'smtp', source: 'global' }, + smtp_host: { value: 'smtp.live.test', source: 'global' }, + }); + expect(service.isConfigured()).toBe(true); + + await settings.save({ + provider: { value: 'resend', source: 'global' }, + api_key: { value: 're_test_key', source: 'global' }, + }); + + expect(transportOf(service)).toBeInstanceOf(ResendTransport); + expect(service.isConfigured()).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-email/src/email-service.test.ts b/packages/plugins/plugin-email/src/email-service.test.ts index d500a7f2e95..1ef726efc3b 100644 --- a/packages/plugins/plugin-email/src/email-service.test.ts +++ b/packages/plugins/plugin-email/src/email-service.test.ts @@ -65,6 +65,29 @@ describe('LogTransport', () => { }); }); +describe('EmailService.isConfigured — does mail sent through it get delivered', () => { + const live = () => ({ send: vi.fn(async () => ({ messageId: '' })) }); + + it('answers false while the transport is the LogTransport', () => { + const svc = new EmailService({ transport: new LogTransport(), defaultFrom: 'no@reply.com' }); + expect(svc.isConfigured()).toBe(false); + }); + + it('answers true for a live (non-log) transport', () => { + const svc = new EmailService({ transport: live(), defaultFrom: 'no@reply.com' }); + expect(svc.isConfigured()).toBe(true); + }); + + it('follows a runtime transport swap in both directions', () => { + const svc = new EmailService({ transport: new LogTransport(), defaultFrom: 'no@reply.com' }); + expect(svc.isConfigured()).toBe(false); + svc.setTransport(live()); + expect(svc.isConfigured()).toBe(true); + svc.setTransport(new LogTransport()); + expect(svc.isConfigured()).toBe(false); + }); +}); + describe('EmailService', () => { function makePersistence() { const rows = new Map>(); diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index 915db7fa759..279f67336b2 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -654,6 +654,17 @@ export class EmailService implements IEmailService { this.options.transport = transport; } + /** + * `IEmailService.isConfigured` — whether mail sent through this service is + * delivered. `false` while the active transport is the {@link LogTransport} + * (it only logs, in every environment), `true` for any other transport. + * Read from the live transport on every call, so it follows + * {@link setTransport} hot swaps from the `mail` settings namespace. + */ + isConfigured(): boolean { + return !(this.options.transport instanceof LogTransport); + } + /** Replace the default `from` address used when callers omit `input.from`. */ setDefaultFrom(from: EmailAddress | undefined): void { this.options.defaultFrom = from; diff --git a/packages/spec/src/contracts/email-service.test.ts b/packages/spec/src/contracts/email-service.test.ts index afbabe7d042..195bd954500 100644 --- a/packages/spec/src/contracts/email-service.test.ts +++ b/packages/spec/src/contracts/email-service.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import type { SendEmailInput, SendTemplateInput } from './email-service'; +import type { IEmailService, SendEmailInput, SendTemplateInput } from './email-service'; /** * Commit b706af987 (Decision 2 of #11303) — `SendEmailInput` is widened with an @@ -77,3 +77,35 @@ describe('Email Service Contract — SendTemplateInput.org retired', () => { expect(input.template).toBe('auth.password_reset'); }); }); + +/** + * `IEmailService.isConfigured` — the OPTIONAL deliverability member, shaped + * like `ISmsService.isConfigured()`. Two contract facts pinned here: + * - an implementation without the member stays assignable (the widening is + * additive; absent reads as "unknown", never as delivering); + * - when present it is a zero-argument function returning `boolean`. + */ +describe('Email Service Contract — isConfigured (optional deliverability member)', () => { + const base = { + send: async () => ({ id: 'e1', status: 'sent' as const }), + sendTemplate: async () => ({ id: 'e1', status: 'sent' as const }), + renderTemplate: async () => ({ subject: 's', html: 'h', text: 't' }), + }; + + it('keeps an implementation without isConfigured assignable (absent = unknown)', () => { + const legacy: IEmailService = { ...base }; + expect(legacy.isConfigured).toBeUndefined(); + }); + + it('accepts isConfigured(): boolean and refuses a non-boolean answer at compile time', () => { + const svc: IEmailService = { ...base, isConfigured: () => false }; + expect(svc.isConfigured?.()).toBe(false); + + type Member = NonNullable; + const ok: Member = () => true; + // @ts-expect-error isConfigured answers a boolean, not a string + const bad: Member = () => 'yes'; + expect(ok()).toBe(true); + expect(typeof bad).toBe('function'); + }); +}); diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 66bf1840586..9e22ede9d41 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -336,4 +336,31 @@ export interface IEmailService { * - `MISSING_VARIABLES` — declared `required` variables absent from `data`. */ renderTemplate(input: RenderTemplateInput): Promise; + + /** + * Whether mail sent through this service is actually delivered — the + * deliverability fact, answered by the one package that owns the transport. + * Same name and shape as `ISmsService.isConfigured()` (`./sms-service`). + * + * - `true` — the active transport hands messages to a real delivery + * provider (SMTP, an API provider, an injected transport). It says the + * transport is live, not that any one send will succeed: a send can still + * resolve `status: 'failed'`. + * - `false` — no delivery transport is active: `send()` / `sendTemplate()` + * still resolve and still record their audit row, but no mail leaves the + * process. A `log` transport (plugin-email's development default) is + * NEVER delivering, in any environment — it always answers `false`. + * + * The answer is read at call time and follows runtime transport swaps (for + * example a transport rebuilt from the `mail` settings namespace), so + * consumers call it when they decide, never cache it at boot. + * + * Optional so existing implementations stay valid. A consumer MUST treat an + * absent member as UNKNOWN, never as `true`: a flow whose success depends on + * mail reaching someone (a sign-up answered as a success on the promise of a + * mail, a verification or reset link) reads absent the same as `false` and + * keeps its explicit refusal — reading "a service is wired" as delivery + * reopens the #15587 black hole (a fake success with no mail). + */ + isConfigured?(): boolean; } From 1fe7845d183a573acf5b188889f3f3c2791baf05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 14:32:29 +0000 Subject: [PATCH 2/2] chore(changeset): grade the isConfigured widening patch with Metadata-protocol none IEmailService is a service contract, not authorable metadata, so under the level rule now on main the entry declares Metadata-protocol: none and ships patch for both packages; the Clause-2 widening line stays as claimed. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz --- .changeset/25065-email-service-is-configured.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/25065-email-service-is-configured.md b/.changeset/25065-email-service-is-configured.md index 4007b2a7de9..e263769a4cd 100644 --- a/.changeset/25065-email-service-is-configured.md +++ b/.changeset/25065-email-service-is-configured.md @@ -1,11 +1,12 @@ --- -'@objectstack/spec': minor -'@objectstack/plugin-email': minor +'@objectstack/spec': patch +'@objectstack/plugin-email': patch --- feat(spec, plugin-email): `IEmailService.isConfigured()` answers whether mail sent through the service is actually delivered Clause-②: yes (widening: an optional member on `IEmailService`) +Metadata-protocol: none - **The contract.** `IEmailService` (`@objectstack/spec/contracts`) gains one optional member, `isConfigured?(): boolean`, with the same name and shape as `ISmsService.isConfigured()`. `true` means the active transport hands mail to a real delivery provider. `false` means no mail leaves the process: sends still resolve and still write their audit row. A `log` transport is never delivering, in any environment. The answer is read at call time and follows a transport swapped in at runtime. - **Absent means unknown.** The member is optional, so every existing implementation stays valid. A consumer must never read an absent member as delivering: a flow whose success depends on a mail reaching someone treats absent the same as `false` and keeps its explicit refusal, so a service that is merely wired is never mistaken for one that delivers.