Skip to content

Commit fccbd9d

Browse files
feat(spec, plugin-email): IEmailService.isConfigured() answers whether mail is delivered (#25125)
Closes #25065 Clause-②: yes (widening: an optional member on `IEmailService`) Metadata-protocol: none ## What - **Contract** (`packages/spec/src/contracts/email-service.ts`): `IEmailService` gains one OPTIONAL member, `isConfigured?(): boolean`, with the same name and shape as `ISmsService.isConfigured()`. Its TSDoc says: `true` means the active transport hands mail to a real delivery provider; `false` means no mail leaves the process (sends still resolve and audit); a `log` transport is never delivering, in any environment; the answer is read at call time and follows runtime transport swaps; consumers treat an ABSENT member as unknown and must never read it as delivering. - **Implementation** (`packages/plugins/plugin-email/src/email-service.ts`): `EmailService.isConfigured()` returns `false` while the active transport is the `LogTransport` and `true` for any other transport. It reads the live transport on every call, so it follows `setTransport` swaps, including the ones the plugin makes from the `mail` settings namespace. - **Changeset**: `@objectstack/spec` + `@objectstack/plugin-email` at `patch`. Under ruling A (#25105, landed in #25114) the level follows the metadata protocol only. `IEmailService` is a service contract, not an authorable key, value or metadata type, so the kind is `none` and the level is `patch`. The `Clause-②` widening line stays as the claim states it. - **Out of this PR**: no consumer reads the member yet. The sign-up path in #25060 and the `plugin-auth` readers (`hasEmailTransport()` / `isEmailServiceAvailable()`) are out of this claim. #25060 remains open. ## Tests (final head `1fe7845d18`) - `pnpm --filter @objectstack/plugin-email test`: 36 files / 649 tests passed. `typecheck` (tsc + test typecheck) exit 0. - New plugin-email cases: log transport gives false; live transport gives true; runtime swap log to live to log; registered plugin service on the default boot gives false; saving SMTP settings turns it true; swapping SMTP to Resend stays true. - Spec: `src/contracts/email-service.test.ts` passes 6/6. The type test pins that the member is optional (an implementation without it stays assignable), and uses `@ts-expect-error` on a non-boolean answer. `typecheck`, including `check:test-typecheck`, exits 0, so that directive is compiled. `test:repo`: 72 files / 1237 tests passed. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date against a freshly built dist. No baseline moved, so the api-surface snapshot does not record interface members. - Ablation: `scripts/ablation-replace.mjs` on the `isConfigured` body. The subject is imported from `src` through relative paths, so there is no dist leg. Leg 1 (`return true`) and leg 2 (`return false`) each ran with the marker on disk (1 hit, anchor 0 hits). Each turned 4 of the new tests red (2 files failed, 4 failed / 50 passed). Both restores were verified: blob equals the HEAD blob `279f67336b27` and `git diff HEAD` is empty. - `node scripts/pm/dispatch-gates.mjs --commands` (90 commands, plus the 5 roster families under touched directories) all exited 0. The changeset gates were re-run after the regrade commit. ## Acceptance notes - The CI-only families that `dispatch-gates` names (shard attestations, type-check workspace lanes, Build Core) were not measured locally. CI runs them. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e9b100 commit fccbd9d

6 files changed

Lines changed: 145 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/plugin-email': patch
4+
---
5+
6+
feat(spec, plugin-email): `IEmailService.isConfigured()` answers whether mail sent through the service is actually delivered
7+
8+
Clause-②: yes (widening: an optional member on `IEmailService`)
9+
Metadata-protocol: none
10+
11+
- **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.
12+
- **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.
13+
- **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.
14+
- **Unchanged.** Sending, templates, persistence and the boot warning for the `log` transport behave as before. No consumer reads the member yet.

‎packages/plugins/plugin-email/src/email-plugin.mail-settings.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,40 @@ describe('mail/test action', () => {
430430
expect(result.message).not.toMatch(/Configuration looks valid|plugin-mail/);
431431
});
432432
});
433+
434+
describe('registered email service — isConfigured follows the mail settings', () => {
435+
it('answers false on the out-of-the-box boot (LogTransport, mail never configured)', async () => {
436+
const { service } = await boot({});
437+
expect(transportOf(service)).toBeInstanceOf(LogTransport);
438+
expect(service.isConfigured()).toBe(false);
439+
});
440+
441+
it('turns true when a saved SMTP config replaces the LogTransport at runtime', async () => {
442+
const { service, settings } = await boot({ provider: { value: 'log', source: 'global' } });
443+
expect(service.isConfigured()).toBe(false);
444+
445+
await settings.save({
446+
provider: { value: 'smtp', source: 'global' },
447+
smtp_host: { value: 'smtp.live.test', source: 'global' },
448+
});
449+
450+
expect(transportOf(service)).toBeInstanceOf(SmtpTransport);
451+
expect(service.isConfigured()).toBe(true);
452+
});
453+
454+
it('stays true across a swap from one live transport to another', async () => {
455+
const { service, settings } = await boot({
456+
provider: { value: 'smtp', source: 'global' },
457+
smtp_host: { value: 'smtp.live.test', source: 'global' },
458+
});
459+
expect(service.isConfigured()).toBe(true);
460+
461+
await settings.save({
462+
provider: { value: 'resend', source: 'global' },
463+
api_key: { value: 're_test_key', source: 'global' },
464+
});
465+
466+
expect(transportOf(service)).toBeInstanceOf(ResendTransport);
467+
expect(service.isConfigured()).toBe(true);
468+
});
469+
});

‎packages/plugins/plugin-email/src/email-service.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,29 @@ describe('LogTransport', () => {
6565
});
6666
});
6767

68+
describe('EmailService.isConfigured — does mail sent through it get delivered', () => {
69+
const live = () => ({ send: vi.fn(async () => ({ messageId: '<m@x>' })) });
70+
71+
it('answers false while the transport is the LogTransport', () => {
72+
const svc = new EmailService({ transport: new LogTransport(), defaultFrom: 'no@reply.com' });
73+
expect(svc.isConfigured()).toBe(false);
74+
});
75+
76+
it('answers true for a live (non-log) transport', () => {
77+
const svc = new EmailService({ transport: live(), defaultFrom: 'no@reply.com' });
78+
expect(svc.isConfigured()).toBe(true);
79+
});
80+
81+
it('follows a runtime transport swap in both directions', () => {
82+
const svc = new EmailService({ transport: new LogTransport(), defaultFrom: 'no@reply.com' });
83+
expect(svc.isConfigured()).toBe(false);
84+
svc.setTransport(live());
85+
expect(svc.isConfigured()).toBe(true);
86+
svc.setTransport(new LogTransport());
87+
expect(svc.isConfigured()).toBe(false);
88+
});
89+
});
90+
6891
describe('EmailService', () => {
6992
function makePersistence() {
7093
const rows = new Map<string, Record<string, any>>();

‎packages/plugins/plugin-email/src/email-service.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,17 @@ export class EmailService implements IEmailService {
654654
this.options.transport = transport;
655655
}
656656

657+
/**
658+
* `IEmailService.isConfigured` — whether mail sent through this service is
659+
* delivered. `false` while the active transport is the {@link LogTransport}
660+
* (it only logs, in every environment), `true` for any other transport.
661+
* Read from the live transport on every call, so it follows
662+
* {@link setTransport} hot swaps from the `mail` settings namespace.
663+
*/
664+
isConfigured(): boolean {
665+
return !(this.options.transport instanceof LogTransport);
666+
}
667+
657668
/** Replace the default `from` address used when callers omit `input.from`. */
658669
setDefaultFrom(from: EmailAddress | undefined): void {
659670
this.options.defaultFrom = from;

‎packages/spec/src/contracts/email-service.test.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import type { SendEmailInput, SendTemplateInput } from './email-service';
4+
import type { IEmailService, SendEmailInput, SendTemplateInput } from './email-service';
55

66
/**
77
* Commit b706af987 (Decision 2 of #11303) — `SendEmailInput` is widened with an
@@ -77,3 +77,35 @@ describe('Email Service Contract — SendTemplateInput.org retired', () => {
7777
expect(input.template).toBe('auth.password_reset');
7878
});
7979
});
80+
81+
/**
82+
* `IEmailService.isConfigured` — the OPTIONAL deliverability member, shaped
83+
* like `ISmsService.isConfigured()`. Two contract facts pinned here:
84+
* - an implementation without the member stays assignable (the widening is
85+
* additive; absent reads as "unknown", never as delivering);
86+
* - when present it is a zero-argument function returning `boolean`.
87+
*/
88+
describe('Email Service Contract — isConfigured (optional deliverability member)', () => {
89+
const base = {
90+
send: async () => ({ id: 'e1', status: 'sent' as const }),
91+
sendTemplate: async () => ({ id: 'e1', status: 'sent' as const }),
92+
renderTemplate: async () => ({ subject: 's', html: 'h', text: 't' }),
93+
};
94+
95+
it('keeps an implementation without isConfigured assignable (absent = unknown)', () => {
96+
const legacy: IEmailService = { ...base };
97+
expect(legacy.isConfigured).toBeUndefined();
98+
});
99+
100+
it('accepts isConfigured(): boolean and refuses a non-boolean answer at compile time', () => {
101+
const svc: IEmailService = { ...base, isConfigured: () => false };
102+
expect(svc.isConfigured?.()).toBe(false);
103+
104+
type Member = NonNullable<IEmailService['isConfigured']>;
105+
const ok: Member = () => true;
106+
// @ts-expect-error isConfigured answers a boolean, not a string
107+
const bad: Member = () => 'yes';
108+
expect(ok()).toBe(true);
109+
expect(typeof bad).toBe('function');
110+
});
111+
});

‎packages/spec/src/contracts/email-service.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,31 @@ export interface IEmailService {
336336
* - `MISSING_VARIABLES` — declared `required` variables absent from `data`.
337337
*/
338338
renderTemplate(input: RenderTemplateInput): Promise<RenderTemplateResult>;
339+
340+
/**
341+
* Whether mail sent through this service is actually delivered — the
342+
* deliverability fact, answered by the one package that owns the transport.
343+
* Same name and shape as `ISmsService.isConfigured()` (`./sms-service`).
344+
*
345+
* - `true` — the active transport hands messages to a real delivery
346+
* provider (SMTP, an API provider, an injected transport). It says the
347+
* transport is live, not that any one send will succeed: a send can still
348+
* resolve `status: 'failed'`.
349+
* - `false` — no delivery transport is active: `send()` / `sendTemplate()`
350+
* still resolve and still record their audit row, but no mail leaves the
351+
* process. A `log` transport (plugin-email's development default) is
352+
* NEVER delivering, in any environment — it always answers `false`.
353+
*
354+
* The answer is read at call time and follows runtime transport swaps (for
355+
* example a transport rebuilt from the `mail` settings namespace), so
356+
* consumers call it when they decide, never cache it at boot.
357+
*
358+
* Optional so existing implementations stay valid. A consumer MUST treat an
359+
* absent member as UNKNOWN, never as `true`: a flow whose success depends on
360+
* mail reaching someone (a sign-up answered as a success on the promise of a
361+
* mail, a verification or reset link) reads absent the same as `false` and
362+
* keeps its explicit refusal — reading "a service is wired" as delivery
363+
* reopens the #15587 black hole (a fake success with no mail).
364+
*/
365+
isConfigured?(): boolean;
339366
}

0 commit comments

Comments
 (0)