Skip to content

Commit ab510f4

Browse files
feat(settings,plugin-email): state the live mail transport on Setup → Email (#25448)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuACdPWFJTLDcTrByFCHLzqb)_ Part of #25335 — the server half. The card stays open for the console half (see *What is left*). Clause-②: yes (widening: service-settings exports SettingsService.registerLiveStatus / liveStatusOf, SettingsLiveStatus, SettingsLiveStatusProvider and SETTINGS_LIVE_STATUS_BANNER_ID; nothing is refused) Metadata-protocol: none ## What changed Setup → Email showed what is stored, not what is in use. Out of the box the `mail` namespace stores provider `smtp` with an empty host, so the form reads as SMTP while every message goes to the development log and is recorded in `sys_email` as sent. The page now states the live transport at the top. - **`@objectstack/service-settings`:** `SettingsService.registerLiveStatus(namespace, provider)` and `liveStatusOf(namespace, ctx)`. `GET /api/settings/:namespace` asks the provider on every read and puts its answer first in `manifest.specifiers`, as a spec-valid `info_banner` with the reserved id `live_status` (`SETTINGS_LIVE_STATUS_BANNER_ID`). The registered manifest and in-process `getNamespace()` are unchanged. The banner is added in the route, not in `getNamespace()`, because the mail plugin calls `getNamespace('mail')` while rebuilding the transport the statement waits on. A provider that throws gives an error-severity "Live status unavailable" banner and one `warn` line; the statement is never silently dropped. - **`@objectstack/plugin-email`:** registers the `mail` statement (`src/live-transport-status.ts`). The branch is `EmailService.isConfigured()`: - `false` (log transport): **"No mail leaves this machine"**, warning severity. The body says messages are recorded in `sys_email` as sent and none is delivered. - `true`: **"Mail is delivered through SMTP (host:port)"**, or Resend, Postmark, or a host-supplied transport, at info severity. - The provider first waits for any `mail` re-apply still running. A read made just after a save then names the transport that save built. **Landing:** the claim expected `plugin-email` and `service-settings`, and that is where the change landed. The page is built from `GET /api/settings/:namespace` (manifest plus values). The descriptor lives in `service-settings/src/manifests/mail.manifest.ts`, and the live fact lives in `plugin-email`. No `packages/spec` change: `info_banner` (`bannerText`, `bannerSeverity`) already exists, and the console already renders it. **One pattern:** the SMS page does not state its live sender today, so there was no reader shape to copy. `registerLiveStatus` is namespace-generic; `service-sms` can register the same statement from `ISmsService.isConfigured()`. ## What is left — the console half (not done here) The statement follows a swap **on the next read of the page**, not yet in place after Save. `@object-ui/console` 18.0.0 (`SettingsView`, read from the npm tarball because this session has no objectui access) merges only the PUT response's `values` into the page after a save. It keeps the manifest from its first GET and never refetches, so the banner keeps the pre-save statement until the page is reloaded. The fix is a console change: refetch `GET /api/settings/:namespace` after a successful save, or after any action. The server already answers the new statement on that read. `Seam: runtime:service-settings GET /api/settings/:namespace manifest.specifiers[0] (info_banner id live_status) → renderer:@object-ui/console SettingsView save handler (merges PUT values only; manifest never re-read)` ## Tests - `plugin-email/src/live-transport-status.pin.test.ts` (5 tests): pins the reading of `isConfigured()`. It covers the out-of-the-box state, a swap saved from the page (read before the re-apply settles), and that the branch is `isConfigured()`, not the transport class. - `service-settings/src/settings-routes.live-status.pin.test.ts` (6 tests): the banner heads the GET payload and parses under `SettingsNamespacePayloadSchema`. The provider is asked on every read, the registered manifest stays untouched, nothing is added without a provider, a throwing provider gets an error banner, and an unknown namespace is refused. - Three route-test fakes gained `liveStatusOf: async () => undefined` (the read route now calls it). - `pnpm --filter @objectstack/plugin-email test`: 37 files / 676 tests passed. Typecheck passed (`tsc --noEmit && check:test-typecheck`). - `pnpm --filter @objectstack/service-settings test`: 60 files / 995 tests passed. Typecheck passed. **Ablations** (`scripts/ablation-replace.mjs`; every leg restored to the HEAD blob with an empty `git diff HEAD`): 1. `if (!source.isConfigured())` replaced by `if (!(source.options.transport instanceof LogTransport))`: 5 tests went red, but with `ReferenceError: LogTransport is not defined`. That is a crash, not the mutation, so this run does not count; it is reported here, not hidden. 2. The same branch replaced by `source.options.transport.constructor.name === 'LogTransport'`: 1 failed, 4 passed. The red test is "the branch is isConfigured(), not the transport class". 3. The same branch replaced by `if (false)`: 4 failed, 1 passed. 4. `await Promise.allSettled([...applying]);` deleted from the plugin: 1 failed, the swap test (`expected 'warning' to be 'info'`). ## Gates (local, at 4789209) `node scripts/pm/dispatch-gates.mjs --commands` derived 70 commands, and all 70 exited 0. `--ran` reconciliation: "70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero — all 70 recorded an exit code and none of them is 3)". One gate needed a fix along the way: on the first sweep, `check:engine-double-contract` flagged the new pin's fake engine `update()`. The fake now declares only `find()`, which is all this boot reads. Everything CI runs beyond the derived set is CI's. ## Acceptance notes - The `live_status` banner has no zh-CN, ja-JP or es-ES text. Its text is computed per request, and the console's banner lookup by id would replace it with a fixed translation. A translated statement needs per-state ids or a parameterised lookup. - With no email plugin mounted, the page states nothing new. The `mail/test` fallback already says no email service is mounted. - `origin/main` moved 7 commits since the base `f0fdd45bbc`. None touch `plugin-email` or `service-settings`, so no merge was taken. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b3fddd1 commit ab510f4

12 files changed

Lines changed: 469 additions & 2 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
'@objectstack/plugin-email': patch
4+
---
5+
6+
feat: Setup → Email states which mail transport is live, and says so when no mail leaves this machine
7+
8+
Clause-②: yes (widening: service-settings exports SettingsService.registerLiveStatus / liveStatusOf, SettingsLiveStatus, SettingsLiveStatusProvider and SETTINGS_LIVE_STATUS_BANNER_ID; nothing is refused)
9+
Metadata-protocol: none
10+
11+
The Email page showed what is stored, not what is in use. Out of the box the `mail` namespace stores provider `smtp` with an empty host, so the form read as SMTP while every message went to the development log and was recorded in `sys_email` as sent.
12+
13+
- **`@objectstack/service-settings`:** `SettingsService.registerLiveStatus(namespace, provider)` lets the package that owns a live object state it on that namespace's page. `GET /api/settings/:namespace` asks the provider on every read and puts its answer first in the manifest's specifiers, as an `info_banner` with the reserved id `live_status` (`SETTINGS_LIVE_STATUS_BANNER_ID`). The registered manifest and in-process `getNamespace()` are unchanged. If the provider throws, the page says the live status could not be read; the statement is never silently dropped. New exports: `SettingsLiveStatus`, `SettingsLiveStatusProvider`, `SETTINGS_LIVE_STATUS_BANNER_ID`.
14+
- **`@objectstack/plugin-email`:** registers the `mail` statement from `EmailService.isConfigured()`. When it answers `false` (the log transport), the banner reads "No mail leaves this machine". When it answers `true`, the banner names the live transport, for example "Mail is delivered through SMTP (smtp.example.com:587)". The statement waits for any settings re-apply still running, so a read made just after a save names the transport that save built.

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type {
3535
import { bindEmailTemplateDoor, unbindEmailTemplateDoor } from './email-template-door.js';
3636
import { sweepStrandedOutbox, type OutboxSweepResult } from './outbox-sweep.js';
3737
import { readInternalHeadersJson } from './internal-header-readback.js';
38+
import { describeLiveMailTransport, type LiveMailTransportStatus } from './live-transport-status.js';
3839
import {
3940
EMAIL_LOG_OBJECTS,
4041
TENANT_EMAIL_OBJECT,
@@ -154,6 +155,11 @@ interface MailSettingsSurface {
154155
ctx?: { userId?: string };
155156
}) => Promise<unknown>,
156157
): void;
158+
/** State the live transport at the top of Setup → Email (service-settings' `registerLiveStatus`). */
159+
registerLiveStatus?(
160+
namespace: string,
161+
provider: () => Promise<LiveMailTransportStatus | undefined>,
162+
): void;
157163
}
158164

159165
/** The identity object a `mail/test` press reads the caller's own address from. */
@@ -593,7 +599,11 @@ export class EmailServicePlugin implements Plugin {
593599
try {
594600
const settings = ctx.getService<MailSettingsSurface>('settings');
595601
if (settings && typeof settings.createClient === 'function') {
596-
const applySettings = async (phase: 'boot' | 'saved' = 'boot') => {
602+
// Re-applies still running. The page's live-transport statement
603+
// waits for them, so a read made just after a save names the
604+
// transport that save built, not the one it replaced.
605+
const applying = new Set<Promise<void>>();
606+
const applySettingsNow = async (phase: 'boot' | 'saved') => {
597607
try {
598608
const payload = await settings.getNamespace('mail');
599609
const values: Record<string, unknown> = {};
@@ -607,6 +617,12 @@ export class EmailServicePlugin implements Plugin {
607617
ctx.logger.warn('EmailServicePlugin: failed to apply mail settings: ' + (err?.message ?? err));
608618
}
609619
};
620+
const applySettings = (phase: 'boot' | 'saved' = 'boot'): Promise<void> => {
621+
const run = applySettingsNow(phase);
622+
applying.add(run);
623+
void run.finally(() => applying.delete(run));
624+
return run;
625+
};
610626
await applySettings('boot');
611627
// Subscribe to namespace changes; rebuild on every update.
612628
if (typeof settings.subscribe === 'function') {
@@ -616,6 +632,18 @@ export class EmailServicePlugin implements Plugin {
616632
ctx.logger.info('EmailServicePlugin: bound to settings:changed for namespace=mail');
617633
}
618634

635+
// Setup → Email states which transport is LIVE. The form shows what
636+
// is stored, and the stored default (provider `smtp`, no host) reads
637+
// as SMTP while every message goes to the development log. Asked on
638+
// each page read, after any re-apply still running, so it follows a
639+
// transport swap made from the same page.
640+
if (typeof settings.registerLiveStatus === 'function') {
641+
settings.registerLiveStatus('mail', async () => {
642+
await Promise.allSettled([...applying]);
643+
return this.service ? describeLiveMailTransport(this.service) : undefined;
644+
});
645+
}
646+
619647
// Register the `mail/test` action handler so pressing "Send test
620648
// email" actually delivers one. This OVERRIDES the built-in
621649
// fallback in service-settings, which can only validate the form
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Setup → Email states which transport is LIVE. The out-of-the-box `mail`
4+
// namespace stores provider `smtp` with an empty host, so the form reads as
5+
// SMTP while every message goes to the development log and is recorded as
6+
// sent. This pins the page's reading of `IEmailService.isConfigured()`: the
7+
// statement this plugin registers through `registerLiveStatus`, and that it
8+
// follows a transport swap made from the same page.
9+
10+
import { describe, it, expect, vi } from 'vitest';
11+
import { EmailServicePlugin } from './email-plugin.js';
12+
import { EmailService, LogTransport } from './email-service.js';
13+
import { SmtpTransport } from './transports/smtp.js';
14+
import { describeLiveMailTransport, type LiveMailTransportStatus } from './live-transport-status.js';
15+
16+
vi.mock('nodemailer', () => {
17+
const createTransport = vi.fn(() => ({ sendMail: vi.fn(), close: vi.fn() }));
18+
return { default: { createTransport }, createTransport };
19+
});
20+
21+
interface Resolved { value: unknown; source?: string }
22+
23+
function fakeSettings(values: Record<string, Resolved>) {
24+
const listeners: Array<() => void> = [];
25+
let liveStatus: (() => Promise<LiveMailTransportStatus | undefined>) | undefined;
26+
return {
27+
createClient: () => ({}),
28+
getNamespace: async () => ({ values }),
29+
subscribe: (_ns: string, cb: () => void) => { listeners.push(cb); },
30+
registerAction: () => {},
31+
registerLiveStatus: (ns: string, fn: () => Promise<LiveMailTransportStatus | undefined>) => {
32+
if (ns === 'mail') liveStatus = fn;
33+
},
34+
/** Patch the snapshot and emit settings:changed, WITHOUT waiting for the re-apply. */
35+
emitSave(patch: Record<string, Resolved>) {
36+
Object.assign(values, patch);
37+
for (const l of listeners) l();
38+
},
39+
/** What the page states, as the settings GET route asks for it. */
40+
read: () => liveStatus!(),
41+
get registered() { return liveStatus !== undefined; },
42+
};
43+
}
44+
45+
async function boot(mailValues: Record<string, Resolved>) {
46+
const settings = fakeSettings({
47+
provider: { value: 'smtp', source: 'default' },
48+
smtp_port: { value: 587, source: 'default' },
49+
from_email: { value: 'no-reply@example.test', source: 'global' },
50+
...mailValues,
51+
});
52+
// Only what this boot reads: the settings binding waits for an engine, and
53+
// nothing in these tests writes a row.
54+
const engine = { async find() { return []; } };
55+
const services: Record<string, unknown> = { manifest: { register: () => {} }, objectql: engine, settings };
56+
const hooks: Record<string, Array<() => Promise<void> | void>> = {};
57+
const ctx = {
58+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
59+
getService: <T,>(name: string): T => {
60+
if (!(name in services)) throw new Error(`service '${name}' not registered`);
61+
return services[name] as T;
62+
},
63+
registerService: (name: string, svc: unknown) => { services[name] = svc; },
64+
hook: (name: string, fn: () => Promise<void> | void) => { (hooks[name] ??= []).push(fn); },
65+
};
66+
const plugin = new EmailServicePlugin({});
67+
await plugin.init(ctx as never);
68+
await plugin.start(ctx as never);
69+
for (const fn of hooks['kernel:ready'] ?? []) await fn();
70+
return { settings, service: services.email as EmailService };
71+
}
72+
73+
describe('describeLiveMailTransport — the reading of isConfigured()', () => {
74+
it('isConfigured() false: says nothing leaves this machine', () => {
75+
const svc = new EmailService({ transport: new LogTransport() });
76+
expect(svc.isConfigured()).toBe(false);
77+
const s = describeLiveMailTransport(svc);
78+
expect(s.bannerSeverity).toBe('warning');
79+
expect(s.label).toBe('No mail leaves this machine');
80+
});
81+
82+
it('isConfigured() true: names the live transport', () => {
83+
const svc = new EmailService({ transport: new SmtpTransport({ host: 'smtp.example.test', port: 2525 }) });
84+
expect(svc.isConfigured()).toBe(true);
85+
const s = describeLiveMailTransport(svc);
86+
expect(s.bannerSeverity).toBe('info');
87+
expect(s.label).toBe('Mail is delivered through SMTP (smtp.example.test:2525)');
88+
});
89+
90+
it('the branch is isConfigured(), not the transport class', () => {
91+
const smtp = new SmtpTransport({ host: 'smtp.example.test' });
92+
expect(describeLiveMailTransport({ isConfigured: () => false, options: { transport: smtp } }).label)
93+
.toBe('No mail leaves this machine');
94+
expect(describeLiveMailTransport({ isConfigured: () => true, options: { transport: new LogTransport() } }).bannerSeverity)
95+
.toBe('info');
96+
});
97+
});
98+
99+
describe('EmailServicePlugin — the statement Setup → Email makes', () => {
100+
it('the out-of-the-box state (provider smtp, no host) says nothing leaves this machine', async () => {
101+
const { settings, service } = await boot({});
102+
expect(settings.registered).toBe(true);
103+
expect(service.isConfigured()).toBe(false);
104+
expect((await settings.read())?.label).toBe('No mail leaves this machine');
105+
});
106+
107+
it('follows a transport swap saved from the same page, read before the re-apply settles', async () => {
108+
const { settings, service } = await boot({});
109+
expect((await settings.read())?.bannerSeverity).toBe('warning');
110+
111+
settings.emitSave({
112+
provider: { value: 'smtp', source: 'global' },
113+
smtp_host: { value: 'smtp.saved.test', source: 'global' },
114+
});
115+
// The re-apply is still running here; the statement waits for it.
116+
const after = await settings.read();
117+
expect(service.isConfigured()).toBe(true);
118+
expect(after?.bannerSeverity).toBe('info');
119+
expect(after?.label).toBe('Mail is delivered through SMTP (smtp.saved.test:587)');
120+
});
121+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { IEmailTransport } from '@objectstack/spec/contracts';
4+
import { SmtpTransport } from './transports/smtp.js';
5+
import { ResendTransport } from './transports/resend.js';
6+
import { PostmarkTransport } from './transports/postmark.js';
7+
8+
/**
9+
* The statement Setup → Email makes about the LIVE transport. The page's form
10+
* shows what is stored, and that can differ from what is live: the out-of-the-box
11+
* `mail` namespace stores provider `smtp` with an empty host while every message
12+
* goes to the development log and is recorded as sent. The settings service
13+
* places this statement at the top of the page (`registerLiveStatus`).
14+
*
15+
* Same shape as service-settings' `SettingsLiveStatus`. It is restated here
16+
* because this plugin reaches the settings service through a structural surface
17+
* and takes no package dependency on it.
18+
*/
19+
export interface LiveMailTransportStatus {
20+
label: string;
21+
bannerText: string;
22+
bannerSeverity: 'info' | 'warning';
23+
}
24+
25+
/** The part of `EmailService` the statement reads. */
26+
export interface LiveMailTransportSource {
27+
/** `IEmailService.isConfigured`: does mail sent through the service get delivered? */
28+
isConfigured(): boolean;
29+
options: { transport: IEmailTransport };
30+
}
31+
32+
/** A name an administrator recognises for the live transport. */
33+
function transportName(transport: IEmailTransport): string {
34+
if (transport instanceof SmtpTransport) {
35+
const { host, port } = transport.describe() as { host?: unknown; port?: unknown };
36+
return `SMTP (${String(host)}:${String(port)})`;
37+
}
38+
if (transport instanceof ResendTransport) return 'Resend';
39+
if (transport instanceof PostmarkTransport) return 'Postmark';
40+
return 'a transport supplied by the host application';
41+
}
42+
43+
/**
44+
* Describe the live transport from `isConfigured()`, read at call time, so the
45+
* statement follows a transport rebuilt from the `mail` namespace.
46+
*
47+
* `isConfigured()` decides the branch: `false` means no mail leaves this machine,
48+
* whatever the form says. The transport is read only to name it.
49+
*/
50+
export function describeLiveMailTransport(source: LiveMailTransportSource): LiveMailTransportStatus {
51+
if (!source.isConfigured()) {
52+
return {
53+
label: 'No mail leaves this machine',
54+
bannerSeverity: 'warning',
55+
bannerText: 'The live transport is the development log. Every message this server sends '
56+
+ '(invitations, password resets, notifications) is recorded in `sys_email` as sent, '
57+
+ 'but none is delivered to anyone. To deliver mail, choose a provider below, complete '
58+
+ 'its settings and save.',
59+
};
60+
}
61+
const name = transportName(source.options.transport);
62+
return {
63+
label: `Mail is delivered through ${name}`,
64+
bannerSeverity: 'info',
65+
bannerText: `The live transport is ${name}. Messages this server sends are handed to it for `
66+
+ 'delivery. A single send can still fail; "Send test email" below reports the provider\'s own answer.',
67+
};
68+
}

‎packages/services/service-settings/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export {
3535
} from './local-crypto-provider.js';
3636
export {
3737
type SettingsActionHandler,
38+
type SettingsLiveStatus,
39+
type SettingsLiveStatusProvider,
40+
SETTINGS_LIVE_STATUS_BANNER_ID,
3841
type SettingsAuditSink,
3942
type SettingsContext,
4043
type SettingsDiagnosticsLogger,

‎packages/services/service-settings/src/settings-routes.anonymous-floor.pin.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ function permissiveService() {
110110
},
111111
// `ReadonlySet<string>` is the real shape the redaction helpers read.
112112
secretKeysOf: () => new Set<string>(),
113+
// The read route's live-status lookup — nothing to state here.
114+
liveStatusOf: async () => undefined,
113115
setMany: async (_ns: string, _values: unknown, ctx: SettingsContext) => { seen.push({ method: 'setMany', ctx }); return {}; },
114116
runAction: async (_ns: string, _id: string, _input: unknown, ctx: SettingsContext) => {
115117
seen.push({ method: 'runAction', ctx });

‎packages/services/service-settings/src/settings-routes.auth-gate.pin.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ function permissiveService() {
154154
},
155155
// `ReadonlySet<string>` is the real shape the redaction helpers read.
156156
secretKeysOf: () => new Set<string>(),
157+
// The read route's live-status lookup — nothing to state here.
158+
liveStatusOf: async () => undefined,
157159
setMany: async (_ns: string, _values: unknown, ctx: SettingsContext) => {
158160
seen.push({ member: 'setMany', ctx });
159161
return {};

‎packages/services/service-settings/src/settings-routes.authz-outage-relay.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ function permissiveService() {
7474
// makes the redaction helpers' `.size` read `undefined` and the PUT door
7575
// answers 500, i.e. the control would fail for a fixture reason.
7676
secretKeysOf: () => new Set<string>(),
77+
// The read route's live-status lookup — nothing to state here.
78+
liveStatusOf: async () => undefined,
7779
setMany: async () => ({}),
7880
runAction: async () => ({ ok: true }),
7981
} as any;

0 commit comments

Comments
 (0)