Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/21785-email-template-overlay-survives-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/plugin-email": patch
---

An email template edited through `PUT /api/v1/meta/email_template/:name` (the Studio editor's door) now keeps the admin's wording in `sys_email_template` across a restart. Before, the boot sweep wrote the package wording back over the sending row while `GET /meta` kept serving the admin's, so mail went out with the package wording after every boot.

Clause-②: no

- The cause: on a deployment with a Default Organization the admin's save is an org-scoped overlay, and boot hydration keeps org-scoped overlays out of the registry the sweep read. An env-wide overlay was already kept.
- `EmailServicePlugin`'s boot sweep now projects the effective template: the layered list `protocol.getMetaItems` serves, read in the organization `tenancy.defaultOrgId()` names. That is the Default Organization under the `single` posture. A host without a `protocol` service reads the registry as before.
- A failed effective read projects nothing for that boot, so the rows keep their last projection. It does not fall back to the package wording.
- Seed-not-clobber is unchanged. A row an admin created (`managed_by: 'admin'`) or edited through the data API (`customized: true`) is still never overwritten.
- The published API is unchanged. The exported `bootstrapDeclaredEmailTemplates` keeps its signature and still reads the registry, so a caller outside the plugin sees the same behaviour as before.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { describe, expect, it, vi } from 'vitest';
import {
bootstrapDeclaredEmailTemplates,
bootstrapEffectiveEmailTemplates,
upsertDeclaredEmailTemplate,
deactivateDeclaredEmailTemplate,
mapTemplateToRow,
Expand Down Expand Up @@ -438,3 +439,137 @@ describe('declared email templates carrying the `content` alias spelling (#8378)
expect(warn.mock.calls[0][1].name).toBe('ops.digest');
});
});

// ---------------------------------------------------------------------------
// [#21785] The boot sweep projects the EFFECTIVE template
// ---------------------------------------------------------------------------

/**
* A `protocol.getMetaItems` stand-in that answers the way the layered list
* does for ONE org-scoped overlay: read in the overlay's organization, the
* overlay wins its slot; read env-wide (no organization), the declaration is
* served. Each item carries the `_diagnostics` read decoration the real
* served list carries, which the strict schema refuses unless it is stripped.
* Every request is recorded, so the organization the sweep read in is
* asserted rather than assumed.
*/
function layeredProtocol(declared: any[], overlay?: { organizationId: string; items: any[] }) {
const requests: Array<{ type: string; organizationId?: string }> = [];
return {
requests,
async getMetaItems(request: { type: string; organizationId?: string }) {
requests.push({ ...request });
const items = overlay && request.organizationId === overlay.organizationId ? overlay.items : declared;
return { type: request.type, items: items.map((i) => ({ ...i, _diagnostics: { valid: true } })) };
},
};
}

const ORG = 'org_default';
const PACKAGE_WORDING = 'Reset your password, {{user.name}}';
const OVERLAY_WORDING = 'Admin reworded: reset for {{user.name}}';

/** The row as the live path leaves it after `PUT /meta`: package provenance, overlay wording, NOT customized. */
function rowProjectedFromOverlay(over: Record<string, any> = {}): any {
return {
id: 'etpl_seeded',
name: 'auth.password_reset',
locale: 'en-US',
subject: OVERLAY_WORDING,
managed_by: 'package',
customized: false,
...over,
};
}

describe('bootstrapDeclaredEmailTemplates — the effective template (#21785)', () => {
it('projects the org-scoped overlay the metadata door serves, read in the default organization', async () => {
// The registry holds ONLY the declaration: boot hydration leaves an
// org-scoped overlay out of it. Reading it is the reverted-on-restart defect.
const engine = new FakeEngine({
rows: { [TABLE]: [rowProjectedFromOverlay()] },
declared: { email_template: [declaredTemplate()] },
});
const protocol = layeredProtocol(
[declaredTemplate()],
{ organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] },
);

const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, {
protocol,
tenancy: { defaultOrgId: async () => ORG },
});

expect(protocol.requests).toEqual([{ type: 'email_template', organizationId: ORG }]);
expect(result).toEqual({ seeded: 1, skipped: 0 });
expect(rowsOf(engine)).toHaveLength(1);
expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING);
});

it('reads env-wide when the tenancy service names no organization (a walled posture never guesses one)', async () => {
const engine = new FakeEngine({ rows: { [TABLE]: [rowProjectedFromOverlay()] } });
const protocol = layeredProtocol(
[declaredTemplate()],
{ organizationId: ORG, items: [declaredTemplate({ subject: OVERLAY_WORDING })] },
);

await bootstrapEffectiveEmailTemplates(engine as any, undefined, {
protocol,
tenancy: { defaultOrgId: async () => null },
});

// No organization on the request: the tenancy contract named none, so the
// read is env-wide and the declaration is what the row carries.
expect(protocol.requests).toEqual([{ type: 'email_template' }]);
expect(rowsOf(engine)[0].subject).toBe(PACKAGE_WORDING);
});

it('projects nothing on a failed effective read, never the package layer in its place', async () => {
const engine = new FakeEngine({
rows: { [TABLE]: [rowProjectedFromOverlay()] },
declared: { email_template: [declaredTemplate()] },
});
const warn = vi.fn();
const protocol = {
async getMetaItems(): Promise<never> { throw new Error('sys_metadata read failed'); },
};

const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, {
protocol,
tenancy: { defaultOrgId: async () => ORG },
}, { warn });

expect(result).toEqual({ seeded: 0, skipped: 0 });
expect(rowsOf(engine)[0].subject).toBe(OVERLAY_WORDING);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][1]).toEqual({ error: 'sys_metadata read failed' });
});

it('keeps seed-not-clobber over the effective read: admin-authored and customized rows are skipped', async () => {
const engine = new FakeEngine({
rows: {
[TABLE]: [
{ id: 'a', name: 'ops.digest', locale: 'en-US', subject: 'Admin original', managed_by: 'admin' },
rowProjectedFromOverlay({ subject: 'Data-door wording', customized: true }),
],
},
});
const warn = vi.fn();
const protocol = layeredProtocol([], {
organizationId: ORG,
items: [
declaredTemplate({ name: 'ops.digest', category: 'notification', subject: 'Overlay digest' }),
declaredTemplate({ subject: OVERLAY_WORDING }),
],
});

const result = await bootstrapEffectiveEmailTemplates(engine as any, undefined, {
protocol,
tenancy: { defaultOrgId: async () => ORG },
}, { warn });

expect(result).toEqual({ seeded: 0, skipped: 2 });
expect(rowsOf(engine).map((r) => r.subject)).toEqual(['Admin original', 'Data-door wording']);
expect(warn).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,18 @@
* boot-only bridge would leave a Studio save inert until the next restart — the
* same bug, half-fixed. {@link upsertDeclaredEmailTemplate} is exported for the
* live `metadata.subscribe('email_template', …)` path in EmailServicePlugin.
*
* ## What the boot sweep projects (#21785)
* The EFFECTIVE template — what the metadata door serves, a Studio overlay
* included — not the package's declaration. The live path already projects
* the overlay when the admin saves it, so a boot sweep reading the package
* layer reverted the sending row on every restart while `GET /meta` kept
* serving the admin's wording. See {@link readDeclared}.
*/

import type { IDataEngine } from '@objectstack/spec/contracts';
import type { GetMetaItemsRequest, GetMetaItemsResponse } from '@objectstack/spec/api';
import { stripReadDecorations } from '@objectstack/spec/kernel';
import {
EmailTemplateDefinitionSchema,
type EmailTemplateDefinition,
Expand Down Expand Up @@ -102,9 +111,68 @@ function uid(prefix: string): string {
}

/**
* Read declared `email_template` items from the ObjectQL registry (where the
* manifest decomposition parks `stack.emailTemplates`), falling back to the
* metadata service. Both reads hand back the authoring document itself.
* The two kernel services the boot sweep reads the EFFECTIVE templates through
* (#21785). Both are optional: a host that registers no `protocol` has no
* metadata door, so nothing can overlay a declaration there and the registry
* read below is already the effective one.
*
* Module-internal: exported for EmailServicePlugin's boot wiring only and
* deliberately NOT re-exported from the package entry — see
* {@link bootstrapEffectiveEmailTemplates}.
*/
export interface EffectiveEmailTemplateSources {
/** The `protocol` service. `getMetaItems` is the layered list `GET /meta/email_template` serves. */
protocol?: { getMetaItems(request: GetMetaItemsRequest): Promise<GetMetaItemsResponse> };
/** The `tenancy` service. `defaultOrgId()` is the organization an org-less read resolves in. */
tenancy?: { defaultOrgId(): Promise<string | null> };
}

/** {@link readDeclared}'s answer when the effective read did not happen. */
const EFFECTIVE_READ_FAILED = Symbol('email-template-effective-read-failed');

/**
* Read the `email_template` items the boot sweep projects: the EFFECTIVE
* items, as the metadata door serves them, when a `protocol` is registered;
* otherwise the declared items from the ObjectQL registry (where the manifest
* decomposition parks `stack.emailTemplates`), falling back to the metadata
* service. Every read hands back the authoring document itself.
*
* ## [#21785] Why the effective read, and in which organization
*
* The registry holds the package's declaration and only the ENV-WIDE overlays
* boot hydration (`loadMetaFromDb`) registers. `email_template` is
* `allowOrgOverride: true`, so an admin saving through `PUT /meta` with an
* active organization — every Studio save on a `single`-posture deployment,
* where the Default Organization is bootstrapped — writes an ORG-SCOPED
* overlay, which hydration deliberately leaves out of the process-wide
* registry. The live path projected that overlay into the sending row at save
* time; this sweep then read the package layer and wrote the package wording
* back on every boot, while `GET /meta/email_template/:name` kept serving the
* admin's wording. Measured on the showcase before the fix: the env-wide
* overlay survived (the registry lists it after the package entry), the
* org-scoped one reverted.
*
* So the sweep reads what the door reads — `protocol.getMetaItems`, the
* layered list (org overlay over env-wide overlay over package), one item per
* `(name, locale)` slot — and resolves the organization the way every other
* org-less reader of org-overridable metadata does: `tenancy.defaultOrgId()`,
* as the anonymous form doors read a form (`@objectstack/rest`). That answers
* the Default Organization under `single` (ADR-0131: the organization IS the
* environment there) and `null` whenever a walled posture was requested (the
* tenancy contract never guesses an organization there), where the read is
* env-wide. The sending row stays org-agnostic: template resolution keys on
* `(name, locale)` only, and per-organization template rows are a capability
* no ruling has opened.
*
* The served items carry read decorations (`_diagnostics`) the strict schema
* refuses, so each is passed through the shared `stripReadDecorations` — the
* same treatment the plugin's single-item effective read applies.
*
* A failed effective read is NOT answered from the registry. The registry
* holds the package wording, so falling back would revert every overlay
* projection on a transient storage error — this defect again, by a second
* route. It answers {@link EFFECTIVE_READ_FAILED} and the sweep projects
* nothing; every row keeps its last projection until the next boot.
*
* ## [#8378] Why there is no `i?.content ?? i` here any more
*
Expand Down Expand Up @@ -150,7 +218,30 @@ function uid(prefix: string): string {
* then died at the `filter(Boolean)` below — the template was dropped with
* no warning, no count, nothing (the ADR-0078 silent-loss shape).
*/
function readDeclared(engine: any, metadataService: any, type: string): any[] {
async function readDeclared(
engine: any,
metadataService: any,
type: string,
sources: EffectiveEmailTemplateSources | undefined,
logger: Logger | undefined,
): Promise<unknown[] | typeof EFFECTIVE_READ_FAILED> {
const protocol = sources?.protocol;
if (typeof protocol?.getMetaItems === 'function') {
try {
const organizationId = typeof sources?.tenancy?.defaultOrgId === 'function'
? await sources.tenancy.defaultOrgId()
: null;
const listed = await protocol.getMetaItems({ type, ...(organizationId ? { organizationId } : {}) });
return listed.items.filter(Boolean).map(stripReadDecorations);
} catch (err: any) {
logger?.warn?.(
'[email] effective email-template read failed — no declared template re-materialized this boot; '
+ 'every sys_email_template row keeps its last projection',
{ error: err?.message ?? String(err) },
);
return EFFECTIVE_READ_FAILED;
}
}
try {
const reg = engine?._registry;
if (reg?.listItems) {
Expand Down Expand Up @@ -272,17 +363,48 @@ export async function deactivateDeclaredEmailTemplate(
}

/**
* Materialize every declared email template into `sys_email_template`.
* Idempotent and safe to run on every boot.
* Materialize every declared email template into `sys_email_template` from the
* ObjectQL registry (falling back to the metadata service). Idempotent and safe
* to run on every boot.
*
* The published signature, unchanged: an external caller reads the registry
* exactly as before — the declarations plus the env-wide overlays boot
* hydration registered.
*/
export async function bootstrapDeclaredEmailTemplates(
engine: IDataEngine,
metadataService: any,
logger?: Logger,
object = EMAIL_TEMPLATE_OBJECT,
): Promise<BootstrapDeclaredEmailTemplatesResult> {
const declared = readDeclared(engine, metadataService, 'email_template');
if (declared.length === 0) return { seeded: 0, skipped: 0 };
// [#21785] The one sweep body, with no sources. The plugin's own boot wiring
// calls the effective form directly. Said here, not in the docblock above:
// the docblock ships in the published `.d.ts`, and the module-internal name
// is not part of that surface.
return bootstrapEffectiveEmailTemplates(engine, metadataService, undefined, logger, object);
}

/**
* [#21785] The boot sweep EmailServicePlugin runs: materialize every
* `email_template` into `sys_email_template` as the metadata door serves it —
* an overlay of the declaration included — when `sources.protocol` is given,
* and from the registry otherwise (see {@link readDeclared}). The one sweep
* body; {@link bootstrapDeclaredEmailTemplates} is this with no sources.
*
* Module-internal: NOT re-exported from the package entry (`src/index.ts`),
* and the package's `exports` map names only that entry, so this function and
* {@link EffectiveEmailTemplateSources} add nothing to the published surface.
* No caller outside this package needs them.
*/
export async function bootstrapEffectiveEmailTemplates(
engine: IDataEngine,
metadataService: any,
sources: EffectiveEmailTemplateSources | undefined,
logger?: Logger,
object = EMAIL_TEMPLATE_OBJECT,
): Promise<BootstrapDeclaredEmailTemplatesResult> {
const declared = await readDeclared(engine, metadataService, 'email_template', sources, logger);
if (declared === EFFECTIVE_READ_FAILED || declared.length === 0) return { seeded: 0, skipped: 0 };

let seeded = 0;
let skipped = 0;
Expand Down
14 changes: 12 additions & 2 deletions packages/plugins/plugin-email/src/email-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,11 @@ import type {
SettingsUnsubscribe,
} from '@objectstack/spec/system';
import {
bootstrapDeclaredEmailTemplates,
bootstrapEffectiveEmailTemplates,
upsertDeclaredEmailTemplate,
deactivateDeclaredEmailTemplate,
mapTemplateToRow,
type EffectiveEmailTemplateSources,
} from './bootstrap-declared-email-templates.js';
import {
bindEmailTemplateProvenanceStamp,
Expand Down Expand Up @@ -1091,8 +1092,17 @@ export class EmailServicePlugin implements Plugin {
let metadataService: IMetadataService | undefined;
try { metadataService = ctx.getService<IMetadataService>('metadata'); } catch { /* optional */ }

// [#21785] The sweep projects the EFFECTIVE template — what `GET /meta`
// serves, an org-scoped Studio overlay included — through the protocol's
// layered list, read in `tenancy.defaultOrgId()`'s organization. Both are
// optional: a host without them has no metadata door to overlay through.
let protocol: EffectiveEmailTemplateSources['protocol'];
try { protocol = ctx.getService('protocol'); } catch { /* optional */ }
let tenancy: EffectiveEmailTemplateSources['tenancy'];
try { tenancy = ctx.getService('tenancy'); } catch { /* optional */ }

try {
await bootstrapDeclaredEmailTemplates(engine, metadataService, ctx.logger as any);
await bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy }, ctx.logger as any);
} catch (err: any) {
ctx.logger.warn(
'EmailServicePlugin: declared email-template bootstrap failed (built-in templates still serve): '
Expand Down
Loading
Loading