From 04235f9bb2dd60696770c96323346c022367087c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 12:55:35 +0000 Subject: [PATCH 1/7] feat(plugin-email,plugin-auth): identity mail persists to sys_identity_email, the deployment-level sibling of sys_email (WIP) Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL Co-authored-by: Claude --- .../utils/organization-ownership-inventory.ts | 6 + packages/platform-objects/src/audit/index.ts | 1 + .../src/audit/sys-identity-email.object.ts | 67 +++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 49 ++----- .../plugin-email/src/attachment-reclaim.ts | 50 +++++-- .../plugin-email/src/attachment-storage.ts | 8 +- .../plugin-email/src/email-log-objects.ts | 46 ++++++ .../plugins/plugin-email/src/email-plugin.ts | 109 ++++++++++---- .../plugins/plugin-email/src/email-service.ts | 136 +++++++++++++----- packages/plugins/plugin-email/src/index.ts | 9 ++ .../src/internal-header-readback.ts | 24 +++- .../plugins/plugin-email/src/outbox-sweep.ts | 33 +++-- packages/spec/src/contracts/email-service.ts | 28 +++- .../system/constants/platform-object-names.ts | 1 + scripts/platform-object-tenancy-census.json | 15 +- 15 files changed, 441 insertions(+), 141 deletions(-) create mode 100644 packages/platform-objects/src/audit/sys-identity-email.object.ts create mode 100644 packages/plugins/plugin-email/src/email-log-objects.ts diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index a3abc052ee7..7fdc69b9b37 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -544,6 +544,12 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { object: 'sys_comment', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; a reply's parent comment carries the organization`, anchors: [parent('parent_id', 'sys_comment')] }, { object: 'sys_comment_reaction', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); the reaction's comment carries the organization`, anchors: [parent('comment_id', 'sys_comment')] }, { object: 'sys_email', census: P1, fate: 'attribution', citation: `${D7}: sys_email is stamped at its producers; the related record carries the organization`, anchors: [subject('related_object', 'related_id')] }, + { + object: 'sys_identity_email', + census: PMAIN, + fate: 'column-drop', + citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: sys_email's sibling for mail about a person (ruling record 6257510973, Q2 A)`, + }, { object: 'sys_http_delivery', census: P1, fate: 'attribution', citation: `${D7}: a delivery is stamped from its webhook's organization`, anchors: [parent('ref_id', 'sys_webhook')] }, { object: 'sys_webhook', diff --git a/packages/platform-objects/src/audit/index.ts b/packages/platform-objects/src/audit/index.ts index 937efe826b4..3fe76ef375c 100644 --- a/packages/platform-objects/src/audit/index.ts +++ b/packages/platform-objects/src/audit/index.ts @@ -12,6 +12,7 @@ export { SysNotification } from './sys-notification.object.js'; export { SysAttachment } from './sys-attachment.object.js'; export { SysEmail } from './sys-email.object.js'; +export { SysIdentityEmail } from './sys-identity-email.object.js'; export { SysEmailTemplate } from './sys-email-template.object.js'; // sys_saved_report / sys_report_schedule retired with the saved-report stack (#20102). // sys_approval_request / sys_approval_action moved to @objectstack/plugin-approvals (ADR-0029 K2.b). diff --git a/packages/platform-objects/src/audit/sys-identity-email.object.ts b/packages/platform-objects/src/audit/sys-identity-email.object.ts new file mode 100644 index 00000000000..13de05d4d61 --- /dev/null +++ b/packages/platform-objects/src/audit/sys-identity-email.object.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema } from '@objectstack/spec/data'; +import { SysEmail } from './sys-email.object.js'; + +/** + * sys_identity_email — Outbound Identity Mail Log + * + * The deployment-level sibling of `sys_email` (ADR-0131 D7, ruling of record + * 6257510973, letter Q2 A). Identity mail is mail about a PERSON rather than + * an organization — verification, password reset, magic link, email-change + * notice, an account invitation to someone who belongs to no organization + * yet — so it has no organization to stamp. It lives here, with no + * organization column, instead of as an organization-less row in the tenant + * mail log: ⛔ neither a nullable column nor unpersisted mail. + * + * Same row shape as `sys_email` — the fields ARE `sys_email`'s, so the two + * cannot drift — and the same pipeline: `IEmailService.send` with + * `identityMail: true` writes the row, and the queue worker, the outbox drain + * hook, the boot outbox sweep and attachment reclamation read it by id and + * status exactly as they read `sys_email`. + * + * Expand-first: rows an 18.0 install wrote to `sys_email` for this mail stay + * there; the mail pipeline keeps reading them, and moving them is the 18.x + * upgrade ceremony's. + * + * `systemFields: { tenant: false }` removes the injected organization column. + * With no column there is no tenant wall, so who may read is object + * permission (D7): these rows carry password-reset and sign-in links, so the + * platform-only capability, as on `sys_job` — under a wall an organization's + * administrator must not read every person's links. + * + * Typical writers: `IEmailService.send()` / `sendTemplate()` with `identityMail: true` + * (plugin-auth's identity mails). + * Typical readers: the mail delivery pipeline, the platform administrator. + * + * @namespace sys + */ +export const SysIdentityEmail = ObjectSchema.create({ + name: 'sys_identity_email', + label: 'Identity Email', + pluralLabel: 'Identity Emails', + icon: 'mail', + isSystem: true, + managedBy: 'append-only', + systemFields: { tenant: false }, + requiredPermissions: ['manage_platform_settings'], + description: 'Outbound identity mail log — verification, password reset and sign-in mail about a person, not an organization', + displayNameField: 'subject', + nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + titleFormat: '{subject}', + highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'], + + fields: SysEmail.fields, + + indexes: [ + { fields: ['status', 'created_at'] }, + { fields: ['related_object', 'related_id'] }, + { fields: ['sent_by'] }, + ], + + enable: { + // [ADR-0103] Engine-owned append-only mail log, as `sys_email`: written + // only by the mail service (SYSTEM_CTX). Reads behind the permission above. + apiMethods: ['get', 'list'], + }, +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 0ccf501585e..fc295fb6679 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2008,7 +2008,9 @@ export class AuthManager { // the user's mail carries only the deployment default. Best-effort; // `undefined` leaves the #14319 ladder exactly as it was. const storedLocale = await this.storedRecipientLocale({ id: user.id }); - const mailOrganization = await this.authMailOrganizationArg(); + // [ADR-0131 D7, ruling of record 6257510973, Q2 A] Both mails are + // about a person, not an organization: identity mail, recorded in + // the deployment-level `sys_identity_email`, with no organization. const result = await email.sendTemplate(invitation ? { // Exactly the variables `auth.account_invitation` declares — @@ -2028,7 +2030,7 @@ export class AuthManager { }, relatedObject: 'sys_user', relatedId: user.id, - ...mailOrganization, + identityMail: true, } : { template: 'auth.password_reset', @@ -2043,7 +2045,7 @@ export class AuthManager { }, relatedObject: 'sys_user', relatedId: user.id, - ...mailOrganization, + identityMail: true, }); if (result?.status === 'failed') { throw new Error( @@ -2106,9 +2108,9 @@ export class AuthManager { }, relatedObject: 'sys_user', relatedId: user.id, - // [ADR-0131 D9] The mail is about a user, who belongs to no - // organization yet: the derivable owner, as for the grant. - ...(await this.authMailOrganizationArg()), + // [ADR-0131 D7, Q2 A] The mail is about a person, who may belong + // to no organization yet: identity mail, in `sys_identity_email`. + identityMail: true, }); if (result?.status === 'failed') { throw new Error( @@ -3881,8 +3883,8 @@ export class AuthManager { relatedObject: 'sys_invitation', relatedId: invitation.id, // commit b706af987 — the invitation HOLDS its organization; thread it so - // the sys_email row is stamped. Org-less auth mail (reset / - // verification / magic link) deliberately threads nothing. + // the sys_email row is stamped: tenant mail. Mail about a person + // (reset / verification / magic link) is identity mail instead. ...(invitation.organizationId ? { organizationId: String(invitation.organizationId) } : {}), @@ -4125,7 +4127,8 @@ export class AuthManager { expiresInMinutes: 10, appName: this.getAppName(), }, - ...(await this.authMailOrganizationArg()), + // [ADR-0131 D7, Q2 A] A sign-in link for a person: identity mail. + identityMail: true, }); } catch (err: any) { console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`); @@ -5630,31 +5633,6 @@ export class AuthManager { } } - /** - * [ADR-0131 D9] The organization a write this manager makes on a - * tenant-column object carries when nothing on the write names one: the - * Default Organization under `single` — the only organization there is — - * and none under a wall, where `tenancy.defaultOrgId()` answers `null`. One - * reading for every such producer here (the auth mails that are about a user - * rather than an organization), so they cannot disagree about it. `undefined` when no tenancy service is wired or - * it cannot answer: the producer then names nothing, as before. - */ - private async derivableWriteOrganizationId(): Promise { - try { - const tenancy = this.config.getTenancy?.(); - const organizationId = tenancy ? await tenancy.defaultOrgId() : null; - return organizationId ?? undefined; - } catch { - return undefined; - } - } - - /** The `organizationId` argument of an auth mail that is about a user, not an organization. */ - private async authMailOrganizationArg(): Promise<{ organizationId?: string }> { - const organizationId = await this.derivableWriteOrganizationId(); - return organizationId ? { organizationId } : {}; - } - /** * [ADR-0131 D3/D9 — the self-registration grant settles on the first * membership] The `user.create.after` half of an admitted self-registration. @@ -5995,7 +5973,8 @@ export class AuthManager { }, relatedObject: 'sys_user', ...(from.id ? { relatedId: from.id } : {}), - ...(await this.authMailOrganizationArg()), + // [ADR-0131 D7, Q2 A] A notice to the account holder: identity mail. + identityMail: true, }); } catch (err) { // Best-effort by design — see the ⛔ above. Logged so an operator can see diff --git a/packages/plugins/plugin-email/src/attachment-reclaim.ts b/packages/plugins/plugin-email/src/attachment-reclaim.ts index 79e24d9f34e..f9bd8b2f962 100644 --- a/packages/plugins/plugin-email/src/attachment-reclaim.ts +++ b/packages/plugins/plugin-email/src/attachment-reclaim.ts @@ -80,12 +80,17 @@ import { type EmailAttachmentStore, } from './attachment-storage.js'; import { withContentReclaimed } from './sys-email-payload.js'; +import { EMAIL_LOG_OBJECTS, TENANT_EMAIL_OBJECT, emailLogObjectOfJob } from './email-log-objects.js'; /** System context — reclamation is platform bookkeeping, not a user query. */ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; -/** Backing object; a constant so the query and the log lines agree. */ -export const RECLAIM_OBJECT = 'sys_email'; +/** + * Backing object of a job that names none — every job an 18.0 process + * published, and every `sys_email` job since. A `sys_identity_email` job + * names its log in the payload (`email-log-objects.ts`). + */ +export const RECLAIM_OBJECT = TENANT_EMAIL_OBJECT; /** * `sys_email.status` values after which content is never needed again. @@ -188,18 +193,31 @@ export async function reclaimAttachmentContent( if (keys.length === 0) return { kind: 'noop', rowId }; + // The log the row lives in. A payload naming something else is refused, + // not guessed: reading the wrong log finds no row, and "no row" is the + // branch below that DELETES the content. + const object = emailLogObjectOfJob(payload?.object); + if (!object) { + // Stalled, never re-armed: no later run can read a log the job does not name. + return rearmOrStall( + { ...opts, rearm: undefined }, rowId, 'mail', graceMs, + `the job names '${String(payload?.object)}', which is not a mail log (${EMAIL_LOG_OBJECTS.join(', ')}), ` + + 'so the row its content belongs to cannot be read', + ); + } + if (!opts.store) { // The capability that HOLDS the bytes is not mounted on this process. // Deleting is impossible; re-arm so a process that has it can finish the // job, and say so if nothing can. return rearmOrStall( - opts, rowId, graceMs, + opts, rowId, object, graceMs, 'the storage capability is not mounted on the process running the reclaim job, so the content ' + 'cannot be deleted here', ); } - const found = await opts.engine.find(RECLAIM_OBJECT, { + const found = await opts.engine.find(object, { where: { id: rowId }, limit: 1, context: SYSTEM_CTX, @@ -211,9 +229,9 @@ export async function reclaimAttachmentContent( // them from the payload's own key list is what makes row deletion incapable // of orphaning content — see the module header. if (!row) { - await deleteOrThrow(opts.store, keys, rowId, 'the sys_email row no longer exists'); + await deleteOrThrow(opts.store, keys, rowId, object, `the ${object} row no longer exists`); opts.logger?.info?.( - `EmailServicePlugin: reclaimed ${keys.length} out-of-row attachment object(s) for sys_email row ` + `EmailServicePlugin: reclaimed ${keys.length} out-of-row attachment object(s) for ${object} row ` + `'${rowId}', which no longer exists — the content had no remaining reference.`, ); return { kind: 'orphan-reclaimed', rowId, keys }; @@ -223,8 +241,8 @@ export async function reclaimAttachmentContent( const status = String(row.status ?? ''); if (!TERMINAL_EMAIL_STATUSES.includes(status)) { return rearmOrStall( - opts, rowId, graceMs, - `the sys_email row is still at '${status || '(unset)'}', so its content may still be needed for delivery`, + opts, rowId, object, graceMs, + `the ${object} row is still at '${status || '(unset)'}', so its content may still be needed for delivery`, ); } @@ -236,8 +254,8 @@ export async function reclaimAttachmentContent( const age = msSinceLastChange(row, now); if (age !== undefined && age < graceMs) { return rearmOrStall( - opts, rowId, graceMs - age, - `the sys_email row was last updated ${Math.round(age / 1000)}s ago, inside the ` + opts, rowId, object, graceMs - age, + `the ${object} row was last updated ${Math.round(age / 1000)}s ago, inside the ` + `${Math.round(graceMs / 1000)}s reclaim grace window`, ); } @@ -248,19 +266,19 @@ export async function reclaimAttachmentContent( // very function that exists to prevent them. This order's failure mode is a // row that names a key which 404s, which is loud on read (the decoder // verifies) and fixed by the job's next attempt. - await deleteOrThrow(opts.store, keys, rowId, `the sys_email row is terminal ('${status}')`); + await deleteOrThrow(opts.store, keys, rowId, object, `the ${object} row is terminal ('${status}')`); const rewritten = withContentReclaimed(row.attachments_json, new Date(now).toISOString()); if (rewritten !== undefined) { await opts.engine.update( - RECLAIM_OBJECT, + object, { id: rowId, attachments_json: rewritten }, { context: SYSTEM_CTX }, ); } opts.logger?.info?.( - `EmailServicePlugin: reclaimed ${keys.length} out-of-row attachment object(s) for sys_email row ` + `EmailServicePlugin: reclaimed ${keys.length} out-of-row attachment object(s) for ${object} row ` + `'${rowId}' (status '${status}'). The row keeps filename/contentType/size/hash as audit evidence; only ` + 'the delivery content was deleted.', ); @@ -272,12 +290,13 @@ async function deleteOrThrow( store: EmailAttachmentStore, keys: string[], rowId: string, + object: string, because: string, ): Promise { const { failed } = await deleteAttachmentKeys(store, keys); if (failed.length === 0) return; throw new Error( - `reclaiming attachment content for sys_email row '${rowId}' (${because}) could not delete ` + `reclaiming attachment content for ${object} row '${rowId}' (${because}) could not delete ` + `${failed.length} of ${keys.length} storage object(s): ` + failed.map((f) => `'${f.key}' (${f.error})`).join('; ') + '. Those bytes are still billed and no longer needed; the reclaim job will retry.', @@ -288,6 +307,7 @@ async function deleteOrThrow( async function rearmOrStall( opts: ReclaimAttachmentContentOptions, rowId: string, + object: string, delayMs: number, reason: string, ): Promise { @@ -301,7 +321,7 @@ async function rearmOrStall( // for. That is the durability/consequence class AGENTS.md pins at `error`, // and the line owes both the consequence and the fix. opts.logger?.error?.( - `EmailServicePlugin: out-of-row attachment content for sys_email row '${rowId}' could NOT be reclaimed and ` + `EmailServicePlugin: out-of-row attachment content for ${object} row '${rowId}' could NOT be reclaimed and ` + `could NOT be rescheduled — ${reason}. Those storage objects will now stay in the backend forever unless ` + 'they are deleted by hand. Fix: keep the durable queue service (@objectstack/service-queue over an ' + 'ObjectQL engine) and the storage capability (@objectstack/service-storage) mounted on the process ' diff --git a/packages/plugins/plugin-email/src/attachment-storage.ts b/packages/plugins/plugin-email/src/attachment-storage.ts index 49cbeb31645..57044441c1c 100644 --- a/packages/plugins/plugin-email/src/attachment-storage.ts +++ b/packages/plugins/plugin-email/src/attachment-storage.ts @@ -54,6 +54,7 @@ import { collectAttachmentParts, type PersistedEmailAttachment, } from './sys-email-payload.js'; +import type { EmailLogObject } from './email-log-objects.js'; /** * The slice of the `storage` capability (`IStorageService`) that @@ -152,10 +153,15 @@ export const EMAIL_ATTACHMENT_RECLAIM_GRACE_MS = 24 * 60 * 60_000; * content and left it in the bucket forever. */ export interface EmailAttachmentReclaimPayload { - /** `sys_email.id` whose content this job reclaims. */ + /** `sys_email.id` (or `sys_identity_email.id`) whose content this job reclaims. */ rowId: string; /** Every storage key written for that row. Authoritative — see above. */ keys: string[]; + /** + * The log the row lives in. Written only for `sys_identity_email`; absent + * reads as `sys_email`, which is also every job an 18.0 process published. + */ + object?: EmailLogObject; } /** Characters a storage key segment may contain; everything else is folded. */ diff --git a/packages/plugins/plugin-email/src/email-log-objects.ts b/packages/plugins/plugin-email/src/email-log-objects.ts new file mode 100644 index 00000000000..51bcbb5f66c --- /dev/null +++ b/packages/plugins/plugin-email/src/email-log-objects.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two objects the mail pipeline persists to and reads back from. + * + * `sys_email` is the tenant mail log; `sys_identity_email` is its + * deployment-level sibling for mail about a person rather than an + * organization (ADR-0131 D7, ruling of record 6257510973, letter Q2 A). One + * row shape, one pipeline: every reader that re-reads a row by id or by + * status — the queue worker, the outbox drain hook, the boot outbox sweep, + * attachment reclamation, the internal-header readback — names the object it + * reads from one of these, never a literal of its own. + * + * A row id is minted by the service and unique across both objects; what a + * reader cannot recover from the id is WHICH object holds it, so a durable + * job carries the object beside the id. A job with no object is one an 18.0 + * process published, when `sys_email` was the only log — it reads + * `sys_email` (expand-first: those rows and jobs stay where they are). + */ + +/** The tenant mail log. */ +export const TENANT_EMAIL_OBJECT = 'sys_email'; + +/** The deployment-level identity mail log. */ +export const IDENTITY_EMAIL_OBJECT = 'sys_identity_email'; + +/** Both logs, tenant first. */ +export const EMAIL_LOG_OBJECTS = [TENANT_EMAIL_OBJECT, IDENTITY_EMAIL_OBJECT] as const; + +export type EmailLogObject = (typeof EMAIL_LOG_OBJECTS)[number]; + +/** True when `value` names one of the two mail logs. */ +export function isEmailLogObject(value: unknown): value is EmailLogObject { + return value === TENANT_EMAIL_OBJECT || value === IDENTITY_EMAIL_OBJECT; +} + +/** + * The object a durable job's `object` member names: absent ⇒ `sys_email` (a + * job published before the sibling existed), a log's name ⇒ that log, and + * anything else ⇒ `undefined`, which the caller refuses loudly — reading a + * guessed object would deliver or reclaim the wrong row. + */ +export function emailLogObjectOfJob(object: unknown): EmailLogObject | undefined { + if (object === undefined || object === null) return TENANT_EMAIL_OBJECT; + return isEmailLogObject(object) ? object : undefined; +} diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 8e8ca0e6d6a..e7545d5d86e 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -8,7 +8,7 @@ import type { IQueueService, QueueBackoffPolicy, } from '@objectstack/spec/contracts'; -import { SysEmail, SysEmailTemplate } from '@objectstack/platform-objects/audit'; +import { SysEmail, SysEmailTemplate, SysIdentityEmail } from '@objectstack/platform-objects/audit'; import { EmailService, LogTransport, @@ -35,6 +35,12 @@ import type { import { bindEmailTemplateDoor, unbindEmailTemplateDoor } from './email-template-door.js'; import { sweepStrandedOutbox, type OutboxSweepResult } from './outbox-sweep.js'; import { readInternalHeadersJson } from './internal-header-readback.js'; +import { + EMAIL_LOG_OBJECTS, + TENANT_EMAIL_OBJECT, + emailLogObjectOfJob, + isEmailLogObject, +} from './email-log-objects.js'; import { EMAIL_ATTACHMENT_RECLAIM_QUEUE, EMAIL_ATTACHMENT_RECLAIM_GRACE_MS, @@ -261,6 +267,18 @@ function resolveDataEngine(ctx: PluginContext): IDataEngine | null { } } +/** Two sweeps' counts as one: every count added, `truncated` when either was. */ +function sumSweepResults(a: OutboxSweepResult, b: OutboxSweepResult): OutboxSweepResult { + return { + scanned: a.scanned + b.scanned, + requeued: a.requeued + b.requeued, + sent: a.sent + b.sent, + failed: a.failed + b.failed, + skipped: a.skipped + b.skipped, + truncated: a.truncated || b.truncated, + }; +} + /** * EmailServicePlugin — registers the `email` service. * @@ -383,7 +401,9 @@ export class EmailServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysEmail, SysEmailTemplate], + // `sys_identity_email` is `sys_email`'s deployment-level sibling for mail + // about a person (ADR-0131 D7, ruling of record 6257510973, Q2 A). + objects: [SysEmail, SysIdentityEmail, SysEmailTemplate], emailTemplates: BUILTIN_AUTH_TEMPLATES, }); @@ -442,8 +462,10 @@ export class EmailServicePlugin implements Plugin { const persistence: EmailPersistence | undefined = this.options.persist === false ? undefined : { - async insert(row) { - const created = await (engine as any).insert('sys_email', row, { + // `object` is the log the service chose for the row; absent (an + // older caller) is the tenant log, as before the sibling existed. + async insert(row, object = TENANT_EMAIL_OBJECT) { + const created = await (engine as any).insert(object, row, { context: SYSTEM_CTX, }); // The engine's OWN answer is forwarded, not laundered into @@ -456,8 +478,8 @@ export class EmailServicePlugin implements Plugin { // we asked for. return created?.id ? { id: String(created.id) } : { id: String(row.id) }; }, - async update(id, patch) { - await (engine as any).update('sys_email', { id, ...patch }, { + async update(id, patch, object = TENANT_EMAIL_OBJECT) { + await (engine as any).update(object, { id, ...patch }, { context: SYSTEM_CTX, }); }, @@ -468,8 +490,8 @@ export class EmailServicePlugin implements Plugin { // delivery paths above re-read rows through `find`, which omits the // column for every caller including `SYSTEM_CTX`. Inert — and never // calls the accessor — when the column is not flagged. - async readHeadersJson(rowIds) { - return readInternalHeadersJson(engine as any, rowIds); + async readHeadersJson(rowIds, object = TENANT_EMAIL_OBJECT) { + return readInternalHeadersJson(engine as any, rowIds, object); }, }; @@ -490,7 +512,7 @@ export class EmailServicePlugin implements Plugin { if (persistence) this.service.setPersistence(persistence); this.service.setTemplateLoader(templateLoader); this.engineWiring = { engine, persistence }; - ctx.logger.info('EmailServicePlugin: sys_email persistence + template loader enabled'); + ctx.logger.info('EmailServicePlugin: sys_email / sys_identity_email persistence + template loader enabled'); return persistence; } @@ -724,7 +746,10 @@ export class EmailServicePlugin implements Plugin { 'afterInsert', async (hookCtx: any) => { try { - if (hookCtx?.object !== 'sys_email') return; + // Both logs: a row inserted into either, at `queued`, is an + // outbox entry (the sibling shares the row shape, ADR-0131 D7). + const object = hookCtx?.object; + if (!isEmailLogObject(object)) return; const row = hookCtx?.result; if (!row || typeof row !== 'object') return; if (row.status !== 'queued' || row.message_id) return; @@ -738,7 +763,7 @@ export class EmailServicePlugin implements Plugin { setTimeout(() => { void (async () => { try { - const rows = await (engine as any).find('sys_email', { + const rows = await (engine as any).find(object, { where: { id: rowId }, limit: 1, context: SYSTEM_CTX, @@ -746,14 +771,14 @@ export class EmailServicePlugin implements Plugin { const fresh = Array.isArray(rows) ? rows[0] : (rows as any)?.data?.[0]; const target = fresh ?? row; if (target.status !== 'queued' || target.message_id) return; - await svc.deliverPersistedRow(target); + await svc.deliverPersistedRow(target, undefined, object); } catch (err: any) { // `error`, not `warn` (#5161): the insert returned, the row // is there, everything looks normal — and the mail was NOT // sent. Nothing else in this process will look at that row // again, so a line nobody reads is the whole loss. ctx.logger.error( - `EmailServicePlugin: outbox drain FAILED for sys_email row '${rowId}' — that message was ` + `EmailServicePlugin: outbox drain FAILED for ${object} row '${rowId}' — that message was ` + 'NOT sent and the row stays at `queued`; nothing retries it in this process. Fix: it is ' + 'picked up by the boot outbox sweep on the next restart; to have failures retried and ' + 'dead-lettered instead of waiting for one, turn on Settings → Mail → "Durable queue ' @@ -775,7 +800,7 @@ export class EmailServicePlugin implements Plugin { }, { packageId: DRAIN_PKG }, ); - ctx.logger.info('EmailServicePlugin: sys_email outbox drain hook installed'); + ctx.logger.info('EmailServicePlugin: sys_email / sys_identity_email outbox drain hook installed'); } // ── 'email.send.async' SUBSCRIBER ──────────────────────────────── @@ -809,7 +834,20 @@ export class EmailServicePlugin implements Plugin { return; } - const rows = await (engine as any).find('sys_email', { + // The log the row lives in: the job names it for the sibling, and a + // job naming none is `sys_email` — every job an 18.0 process + // published included (expand-first, ADR-0131 D7 / Q2 A). + const object = emailLogObjectOfJob(data?.object); + if (!object) { + // A job no retry can fix: report it, do not burn the budget. + ctx.logger.error( + `EmailServicePlugin: an ${EMAIL_SEND_QUEUE} job for row '${rowId}' names '${String(data?.object)}', ` + + `which is not a mail log (${EMAIL_LOG_OBJECTS.join(', ')}) — that message will NOT be delivered. ` + + 'Fix: publish delivery jobs through IEmailService.send(), which names the log it wrote.', + ); + return; + } + const rows = await (engine as any).find(object, { where: { id: rowId }, limit: 1, context: SYSTEM_CTX, @@ -822,9 +860,9 @@ export class EmailServicePlugin implements Plugin { // accepted and can never be delivered is a durability loss, and // it will look like nothing happened at all. ctx.logger.error( - `EmailServicePlugin: sys_email row '${rowId}' referenced by an ${EMAIL_SEND_QUEUE} job no longer ` + `EmailServicePlugin: ${object} row '${rowId}' referenced by an ${EMAIL_SEND_QUEUE} job no longer ` + 'exists — that message will NEVER be delivered and the caller was already told it was queued. ' - + 'Fix: stop deleting sys_email rows in `queued` state (it is an append-only log), or drain the ' + + `Fix: stop deleting ${object} rows in \`queued\` state (it is an append-only log), or drain the ` + 'queue before purging.', ); return; @@ -835,7 +873,7 @@ export class EmailServicePlugin implements Plugin { // maxAttempts: 1 — the QUEUE owns retrying. Letting the row loop // retry underneath it would multiply the two budgets together. - const result = await svc.deliverPersistedRow(row, { maxAttempts: 1, priorAttempts }); + const result = await svc.deliverPersistedRow(row, { maxAttempts: 1, priorAttempts }, object); if (result.status === 'failed') { // Force the queue to retry / DLQ by throwing throw new Error(result.error ?? 'email send failed'); @@ -881,11 +919,11 @@ export class EmailServicePlugin implements Plugin { // being re-armed is the one currently running, so // reusing its key would let the running message's own // idempotency record suppress its successor. - idempotencyKey: `sys_email_attachments:${payload.rowId}:rearm:${Date.now()}`, + idempotencyKey: `${payload.object ?? TENANT_EMAIL_OBJECT}_attachments:${payload.rowId}:rearm:${Date.now()}`, maxAttempts: 5, retries: 4, backoff: { type: 'exponential', delayMs: 60_000, maxDelayMs: 60 * 60_000 }, - metadata: { object: 'sys_email', rowId: payload.rowId }, + metadata: { object: payload.object ?? TENANT_EMAIL_OBJECT, rowId: payload.rowId }, }, ); return true; @@ -958,21 +996,30 @@ export class EmailServicePlugin implements Plugin { // the very failure it exists to prevent. A stranded-row report is also // not grounds to refuse an otherwise healthy boot; the gate above is // where this plugin refuses. + // + // Both logs, each on its own: `sys_email` (which keeps every identity + // row an 18.0 install wrote there — expand-first) and its sibling + // `sys_identity_email`. One log failing to sweep does not stop the + // other, and the settled result is their sum. if (persistence) { const svc = this.service; this.outboxSweepSettled = (async () => { - try { - return await sweepStrandedOutbox({ engine: engine as any, service: svc, logger: ctx.logger }); - } catch (err: any) { - ctx.logger.error( - 'EmailServicePlugin: the boot sweep of stranded sys_email rows FAILED to run — messages accepted ' - + 'before the last restart are still sitting at `queued`, nothing else looks at them, and the ' - + 'server will keep reporting healthy with that mail undelivered. Fix: make sure sys_email is ' - + 'readable by the system context (schema sync ran, the datasource is up), then restart to ' - + `re-sweep. Cause: ${err?.message ?? err}`, - ); - return undefined; + let total: OutboxSweepResult | undefined; + for (const object of EMAIL_LOG_OBJECTS) { + try { + const swept = await sweepStrandedOutbox({ engine: engine as any, service: svc, logger: ctx.logger, object }); + total = total ? sumSweepResults(total, swept) : swept; + } catch (err: any) { + ctx.logger.error( + `EmailServicePlugin: the boot sweep of stranded ${object} rows FAILED to run — messages accepted ` + + 'before the last restart are still sitting at `queued`, nothing else looks at them, and the ' + + `server will keep reporting healthy with that mail undelivered. Fix: make sure ${object} is ` + + 'readable by the system context (schema sync ran, the datasource is up), then restart to ' + + `re-sweep. Cause: ${err?.message ?? err}`, + ); + } } + return total; })(); } diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index 8a51efc8419..915db7fa759 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -36,6 +36,11 @@ import { type EmailAttachmentReclaimPayload, type EmailAttachmentStorage, } from './attachment-storage.js'; +import { + IDENTITY_EMAIL_OBJECT, + TENANT_EMAIL_OBJECT, + type EmailLogObject, +} from './email-log-objects.js'; /** * Queue topic durable email delivery is published to and consumed from @@ -54,8 +59,14 @@ export const EMAIL_SEND_QUEUE = 'email.send.async'; * `send()` with the raw input, which inserted a fresh row per retry. */ export interface EmailSendQueuePayload { - /** `sys_email.id` of the row to deliver. */ + /** `sys_email.id` (or `sys_identity_email.id`) of the row to deliver. */ rowId: string; + /** + * The log the row lives in. Written only for `sys_identity_email`; absent + * reads as `sys_email`, which is also every job an 18.0 process published + * (see `email-log-objects.ts`). + */ + object?: EmailLogObject; } /** @@ -151,8 +162,13 @@ export interface EmailPersistence { * Persist `row` and confirm it by returning **`row.id`** — see the contract * on {@link EmailPersistence}. Returning any other id throws. */ - insert(row: Record): Promise<{ id: string } | string>; - update?(id: string, patch: Record): Promise; + insert(row: Record, object?: EmailLogObject): Promise<{ id: string } | string>; + /** + * `object` names the log the row lives in: `sys_identity_email` for + * identity mail, `sys_email` otherwise. An implementation written before + * the sibling existed ignores it and keeps every row in its one store. + */ + update?(id: string, patch: Record, object?: EmailLogObject): Promise; /** * [#8149] Recover the `internal: true` `headers_json` column for rows the * engine's generic read path handed back without it. @@ -165,7 +181,7 @@ export interface EmailPersistence { * {@link ../internal-header-readback} for why the probe is the schema flag * and never the absence of the key. */ - readHeadersJson?(rowIds: readonly string[]): Promise | undefined>; + readHeadersJson?(rowIds: readonly string[], object?: EmailLogObject): Promise | undefined>; } /** @@ -213,6 +229,32 @@ function addressPart(addr: EmailAddress): string { return String(obj.address ?? '').trim(); } +/** + * The log a send is recorded in (ADR-0131 D7, ruling of record 6257510973, + * letter Q2 A): identity mail — about a person, not an organization — in the + * deployment-level `sys_identity_email`; everything else in `sys_email`. + */ +function emailLogObjectOf(input: Partial | undefined): EmailLogObject { + return input?.identityMail === true ? IDENTITY_EMAIL_OBJECT : TENANT_EMAIL_OBJECT; +} + +/** + * Identity mail has no organization: its log carries no organization column. + * A caller naming both has one of them wrong, and dropping either silently + * would write the row somewhere its caller did not mean — so the send is + * refused, as every other malformed input is, and recorded as rejected. + */ +function assertIdentityMailCarriesNoOrganization(input: Partial | undefined): void { + if (input?.identityMail === true && input.organizationId) { + throw new Error( + 'VALIDATION_FAILED: identityMail and organizationId cannot be sent together — identity mail is about ' + + `a person, not an organization, and its log (${IDENTITY_EMAIL_OBJECT}) has no organization column. ` + + 'Fix: drop organizationId for verification, password-reset, magic-link and other mail about a person, ' + + 'or drop identityMail for mail that belongs to the organization.', + ); + } +} + /** * Would {@link formatAddress} accept this address — i.e. can a message ever * be sent with it? @@ -666,6 +708,7 @@ export class EmailService implements IEmailService { private async sendInternal(input: SendEmailInput, allowQueue: boolean): Promise { let normalized: NormalizedEmailMessage; try { + assertIdentityMailCarriesNoOrganization(input); normalized = normalizeMessage(input, this.options.defaultFrom); } catch (err: any) { // Validation failures must surface to the caller — AND leave a record. @@ -704,6 +747,9 @@ export class EmailService implements IEmailService { // while it under-describes its own message, which the boot sweep would // then deliver stripped. const id = newId(); + // The log this message is recorded in, decided once: every write and + // every job below names the same object (ADR-0131 D7, Q2 A). + const object = emailLogObjectOf(input); // ── OUT-OF-ROW ATTACHMENT CONTENT (#5172) ────────────────────────────── // Over the in-row budget, in queue mode, with the storage capability @@ -745,7 +791,8 @@ export class EmailService implements IEmailService { // Commit b706af987 — pass-through ONLY. This writer runs under a constant system // context, so the input's organization is the one fact it may stamp: // no resolution, no default, no fabrication (a wrong organization_id is - // worse than a null). Absent ⇒ the column stays unwritten. + // worse than a null). Absent ⇒ the column stays unwritten. Identity mail + // never reaches here with one (refused above): its log has no column. ...(input.organizationId ? { organization_id: input.organizationId } : {}), status: 'queued', attempt_count: 0, @@ -770,10 +817,10 @@ export class EmailService implements IEmailService { let res: { id: string } | string | undefined; let insertThrew = false; try { - res = await this.options.persistence.insert(baseRow); + res = await this.options.persistence.insert(baseRow, object); } catch (err: any) { insertThrew = true; - this.options.logger?.warn('EmailService: sys_email persist failed (non-fatal)', { error: err?.message }); + this.options.logger?.warn(`EmailService: ${object} persist failed (non-fatal)`, { error: err?.message }); } if (!insertThrew) { // Deliberately OUTSIDE the catch above. An insert that *fails* is an @@ -804,7 +851,7 @@ export class EmailService implements IEmailService { this.reportQueueDegradation( 'the sys_email row could not be persisted, so a queued job would have nothing to deliver', ); - } else if (await this.publishRow(queue, id)) { + } else if (await this.publishRow(queue, id, object)) { // The row is in the database at `queued` and the job is in the // queue's own store: a process death here loses neither. return { id, status: 'queued' }; @@ -814,7 +861,7 @@ export class EmailService implements IEmailService { // failed, or queue mode is off for this send): the in-memory message is // still whole, so the mail goes out — and the content still has to be // reclaimed afterwards, which is why the keys travel with the delivery. - return await this.deliverNormalized(id, normalized, undefined, storageKeys); + return await this.deliverNormalized(id, normalized, undefined, storageKeys, object); } finally { // Release EXACTLY what was reserved above — the one id, and only here. // Here and not earlier: the reservation has to outlive the whole body, @@ -858,6 +905,10 @@ export class EmailService implements IEmailService { if (!persistence) return; const reason = String((err as any)?.message ?? err ?? 'rejected').slice(0, 900); const raw = (input ?? {}) as Partial; + // Recorded in the log the message was meant for. Identity mail's log has + // no organization column, so a refused `organizationId` beside + // `identityMail` (one of the rejections recorded here) is not written. + const object = emailLogObjectOf(raw); const row: Record = { id: newId(), from_address: rawAddressText(raw.from ?? this.options.defaultFrom) || UNSTATED_COLUMN, @@ -871,7 +922,7 @@ export class EmailService implements IEmailService { ...(raw.relatedObject ? { related_object: raw.relatedObject } : {}), ...(raw.relatedId ? { related_id: raw.relatedId } : {}), ...(raw.sentBy ? { sent_by: raw.sentBy } : {}), - ...(raw.organizationId ? { organization_id: raw.organizationId } : {}), + ...(raw.organizationId && object === TENANT_EMAIL_OBJECT ? { organization_id: raw.organizationId } : {}), status: 'failed', // Prefixed so the column distinguishes the two ways a send fails: this // message never reached a transport, so reading it as "the SMTP host @@ -880,10 +931,10 @@ export class EmailService implements IEmailService { attempt_count: 0, }; try { - await persistence.insert(row); + await persistence.insert(row, object); } catch (persistErr: any) { this.options.logger?.warn( - 'EmailService: sys_email rejection record could not be persisted (non-fatal)', + `EmailService: ${object} rejection record could not be persisted (non-fatal)`, { error: persistErr?.message, rejection: reason }, ); } @@ -1044,11 +1095,14 @@ export class EmailService implements IEmailService { * already committed at `queued`, and leaving it for nobody to pick up is * the durability gap this feature exists to close. */ - private async publishRow(queue: IQueueService, rowId: string): Promise { + private async publishRow(queue: IQueueService, rowId: string, object: EmailLogObject): Promise { const wiring = this.options.queueDelivery; if (!wiring) return false; + // `object` rides only for the sibling: a `sys_email` job keeps the exact + // payload 18.0 published, which is also how an 18.0 job is read. + const payload: EmailSendQueuePayload = object === TENANT_EMAIL_OBJECT ? { rowId } : { rowId, object }; try { - await queue.publish(EMAIL_SEND_QUEUE, { rowId }, { + await queue.publish(EMAIL_SEND_QUEUE, payload, { maxAttempts: wiring.maxAttempts, // `maxAttempts` is the canonical field; `retries` is its legacy // spelling and is the ONLY one `MemoryQueueAdapter` reads. Both are @@ -1059,9 +1113,9 @@ export class EmailService implements IEmailService { backoff: wiring.backoff, // One row, one job: a re-publish for the same row (a retried caller, // a redelivered upstream event) collapses onto the existing message - // instead of racing a second worker onto the same sys_email row. - idempotencyKey: `sys_email:${rowId}`, - metadata: { object: 'sys_email', rowId }, + // instead of racing a second worker onto the same row. + idempotencyKey: `${object}:${rowId}`, + metadata: { object, rowId }, }); return true; } catch (err: any) { @@ -1085,11 +1139,11 @@ export class EmailService implements IEmailService { * shared key is what makes sweeping a row that still has a pending job * collapse onto that job instead of racing a second worker onto the row. */ - async enqueuePersistedRow(rowId: string): Promise { + async enqueuePersistedRow(rowId: string, object: EmailLogObject = TENANT_EMAIL_OBJECT): Promise { if (!rowId) return false; const queue = this.options.queueDelivery?.resolve(); if (!queue) return false; - return this.publishRow(queue, rowId); + return this.publishRow(queue, rowId, object); } /** @@ -1123,6 +1177,7 @@ export class EmailService implements IEmailService { normalized: NormalizedEmailMessage, opts?: DeliverAttemptOptions, reclaimKeys?: string[], + object: EmailLogObject = TENANT_EMAIL_OBJECT, ): Promise { // Defaults reproduce the pre-#5160 loop exactly. const maxAttempts = Math.max(1, opts?.maxAttempts ?? (this.options.retries ?? 0) + 1); @@ -1138,8 +1193,8 @@ export class EmailService implements IEmailService { message_id: messageId, sent_at: new Date().toISOString(), attempt_count: priorAttempts + attempt, - }); - await this.scheduleAttachmentReclaim(rowId, reclaimKeys); + }, object); + await this.scheduleAttachmentReclaim(rowId, reclaimKeys, object); return { id: rowId, status, messageId }; } catch (err: any) { lastError = err; @@ -1154,8 +1209,8 @@ export class EmailService implements IEmailService { status: 'failed', error: errMessage, attempt_count: priorAttempts + maxAttempts, - }); - await this.scheduleAttachmentReclaim(rowId, reclaimKeys); + }, object); + await this.scheduleAttachmentReclaim(rowId, reclaimKeys, object); return { id: rowId, status: 'failed', error: errMessage }; } @@ -1174,12 +1229,16 @@ export class EmailService implements IEmailService { * (retention, purge) reclaim the content instead of orphaning it — see * `attachment-reclaim.ts`. */ - private async scheduleAttachmentReclaim(rowId: string, keys: string[] | undefined): Promise { + private async scheduleAttachmentReclaim( + rowId: string, + keys: string[] | undefined, + object: EmailLogObject, + ): Promise { if (!keys || keys.length === 0) return; const queue = this.options.queueDelivery?.resolve(); if (!queue) { this.options.logger?.error?.( - `EmailService: ${keys.length} attachment storage object(s) for sys_email row '${rowId}' cannot be ` + `EmailService: ${keys.length} attachment storage object(s) for ${object} row '${rowId}' cannot be ` + 'scheduled for reclamation — no durable queue service is available now, although one was when the ' + 'content was stored. Those bytes will stay in the storage backend until they are deleted by ' + 'hand. Fix: keep @objectstack/service-queue (over an ObjectQL engine) mounted for the life of the ' @@ -1190,23 +1249,26 @@ export class EmailService implements IEmailService { try { await queue.publish( EMAIL_ATTACHMENT_RECLAIM_QUEUE, - { rowId, keys }, + // `object` rides only for the sibling, as on the delivery job: the + // reclaimer re-reads the row, and reading the wrong log would find + // no row and delete content a queued row still needs. + object === TENANT_EMAIL_OBJECT ? { rowId, keys } : { rowId, keys, object }, { delay: EMAIL_ATTACHMENT_RECLAIM_GRACE_MS, // One row, one reclaim job: every retry of a message finalizes the // row again, and each of those would otherwise arm its own copy. - idempotencyKey: `sys_email_attachments:${rowId}`, + idempotencyKey: `${object}_attachments:${rowId}`, // A reclaim that cannot run is a byte leak, not a lost message, so // it gets a real budget and then the DLQ, where it is visible. maxAttempts: 5, retries: 4, backoff: { type: 'exponential', delayMs: 60_000, maxDelayMs: 60 * 60_000 }, - metadata: { object: 'sys_email', rowId }, + metadata: { object, rowId }, }, ); } catch (err: any) { this.options.logger?.error?.( - `EmailService: could not publish the attachment-reclaim job for sys_email row '${rowId}' ` + `EmailService: could not publish the attachment-reclaim job for ${object} row '${rowId}' ` + `(${String(err?.message ?? err)}). Its ${keys.length} storage object(s) will stay in the backend ` + 'until deleted by hand. Fix: check the durable queue service, then delete leftovers under the ' + 'sys_email/attachments/ prefix.', @@ -1230,6 +1292,7 @@ export class EmailService implements IEmailService { async deliverPersistedRow( row: Record, opts?: DeliverAttemptOptions, + object: EmailLogObject = TENANT_EMAIL_OBJECT, ): Promise { const rowId = String(row?.id ?? ''); if (!rowId) throw new Error('deliverPersistedRow: row.id is required'); @@ -1249,7 +1312,7 @@ export class EmailService implements IEmailService { // boot's sweep) delivers it intact. Marking it `failed` here would burn // the one durable record of a message that is still perfectly // deliverable. Inert when nothing redacts: no accessor call at all. - const recoveredHeaders = await this.options.persistence?.readHeadersJson?.([rowId]); + const recoveredHeaders = await this.options.persistence?.readHeadersJson?.([rowId], object); const source = withRecoveredHeaders(row, recoveredHeaders); // Keys read from the ROW, so a row delivered by the queue worker (which @@ -1269,10 +1332,10 @@ export class EmailService implements IEmailService { status: 'failed', error: errMessage, attempt_count: Math.max(0, opts?.priorAttempts ?? 0), - }); + }, object); return { id: rowId, status: 'failed', error: errMessage }; } - return this.deliverNormalized(rowId, normalized, opts, reclaimKeys); + return this.deliverNormalized(rowId, normalized, opts, reclaimKeys, object); } /** @@ -1288,12 +1351,12 @@ export class EmailService implements IEmailService { return (storageKey: string) => fetchAttachmentContent(storage, storageKey); } - private async updateRow(id: string, patch: Record): Promise { + private async updateRow(id: string, patch: Record, object: EmailLogObject): Promise { if (!this.options.persistence?.update) return; try { - await this.options.persistence.update(id, patch); + await this.options.persistence.update(id, patch, object); } catch (err: any) { - this.options.logger?.warn('EmailService: sys_email update failed (non-fatal)', { id, error: err?.message }); + this.options.logger?.warn(`EmailService: ${object} update failed (non-fatal)`, { id, error: err?.message }); } } @@ -1443,6 +1506,9 @@ export class EmailService implements IEmailService { // Commit b706af987 — sendTemplate is itself a producer of send(): forward the // caller's organization so the sys_email row it persists is stamped. ...(input.organizationId ? { organizationId: input.organizationId } : {}), + // [ADR-0131 D7, Q2 A] …and its identity-mail routing, so the row lands in + // `sys_identity_email`. `send()` refuses the two together. + ...(input.identityMail ? { identityMail: true } : {}), }; return this.send(sendInput); } diff --git a/packages/plugins/plugin-email/src/index.ts b/packages/plugins/plugin-email/src/index.ts index ca79c7f3883..c3aa51380e3 100644 --- a/packages/plugins/plugin-email/src/index.ts +++ b/packages/plugins/plugin-email/src/index.ts @@ -106,6 +106,15 @@ export { type EmailTransportProvider, type ApiKeyEmailProvider, } from './transports/index.js'; +// [ADR-0131 D7, Q2 A] The two mail logs: tenant `sys_email` and its +// deployment-level identity-mail sibling. +export { + TENANT_EMAIL_OBJECT, + IDENTITY_EMAIL_OBJECT, + EMAIL_LOG_OBJECTS, + isEmailLogObject, + type EmailLogObject, +} from './email-log-objects.js'; export { sweepStrandedOutbox, OUTBOX_OBJECT, diff --git a/packages/plugins/plugin-email/src/internal-header-readback.ts b/packages/plugins/plugin-email/src/internal-header-readback.ts index 95d7a34a553..0d8265a9ee8 100644 --- a/packages/plugins/plugin-email/src/internal-header-readback.ts +++ b/packages/plugins/plugin-email/src/internal-header-readback.ts @@ -63,8 +63,14 @@ * privileged read there at all. */ -/** The object this seam reads, and the one column it recovers. */ -export const SYS_EMAIL_OBJECT = 'sys_email'; +import { TENANT_EMAIL_OBJECT, type EmailLogObject } from './email-log-objects.js'; + +/** + * The object this seam reads by default, and the one column it recovers. + * `sys_identity_email` shares `sys_email`'s fields — the column and its flag + * included — so the same seam serves both logs, named per call. + */ +export const SYS_EMAIL_OBJECT = TENANT_EMAIL_OBJECT; export const HEADERS_COLUMN = 'headers_json'; /** @@ -91,11 +97,14 @@ export interface InternalFieldResolvingEngine { * `true` value does not enrol a field in the redaction, so it must not enrol * one in the recovery either. */ -export function isHeadersColumnRedacted(engine: InternalFieldResolvingEngine): boolean { +export function isHeadersColumnRedacted( + engine: InternalFieldResolvingEngine, + object: EmailLogObject = SYS_EMAIL_OBJECT, +): boolean { if (typeof engine?.getSchema !== 'function') return false; let schema: { fields?: Record } | undefined; try { - schema = engine.getSchema(SYS_EMAIL_OBJECT) as typeof schema; + schema = engine.getSchema(object) as typeof schema; } catch { // An engine that cannot describe the object cannot be redacting it on a // path this package controls; treat the row's own value as authoritative. @@ -117,12 +126,13 @@ export function isHeadersColumnRedacted(engine: InternalFieldResolvingEngine): b export async function readInternalHeadersJson( engine: InternalFieldResolvingEngine | undefined, rowIds: readonly string[], + object: EmailLogObject = SYS_EMAIL_OBJECT, ): Promise | undefined> { if (!engine || rowIds.length === 0) return undefined; - if (!isHeadersColumnRedacted(engine)) return undefined; + if (!isHeadersColumnRedacted(engine, object)) return undefined; if (typeof engine.resolveInternalField !== 'function') { throw new Error( - `EmailService: ${SYS_EMAIL_OBJECT}.${HEADERS_COLUMN} is declared \`internal: true\`, but this ` + `EmailService: ${object}.${HEADERS_COLUMN} is declared \`internal: true\`, but this ` + 'data engine does not implement resolveInternalField() — the custom headers this message was ' + 'authored with are stored but cannot be recovered, and a message must not be sent missing them: ' + 'a missing header does not announce itself, so the send would succeed while silently deviating ' @@ -130,7 +140,7 @@ export async function readInternalHeadersJson( + 'intact once an engine that implements the privileged accessor is mounted.', ); } - return engine.resolveInternalField(SYS_EMAIL_OBJECT, rowIds, HEADERS_COLUMN); + return engine.resolveInternalField(object, rowIds, HEADERS_COLUMN); } /** diff --git a/packages/plugins/plugin-email/src/outbox-sweep.ts b/packages/plugins/plugin-email/src/outbox-sweep.ts index 3c8caf1db33..6971cef4a55 100644 --- a/packages/plugins/plugin-email/src/outbox-sweep.ts +++ b/packages/plugins/plugin-email/src/outbox-sweep.ts @@ -53,12 +53,16 @@ */ import type { SendEmailResult } from '@objectstack/spec/contracts'; +import { TENANT_EMAIL_OBJECT, type EmailLogObject } from './email-log-objects.js'; /** System read context — a boot sweep is not an end-user query. */ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; -/** Backing object; a constant so the query and the log line agree. */ -export const OUTBOX_OBJECT = 'sys_email'; +/** + * Default backing object; the sweep reads the log it is handed, so the query + * and the log lines agree. The plugin sweeps both logs (`email-log-objects.ts`). + */ +export const OUTBOX_OBJECT = TENANT_EMAIL_OBJECT; /** * How old a `queued` row must be before the sweep treats it as stranded @@ -121,8 +125,12 @@ interface SweepLogger { export interface OutboxSweepService { isServiceManaged(id: string): boolean; /** Publish the row as a durable job; false ⇒ queue delivery is not in force. */ - enqueuePersistedRow(rowId: string): Promise; - deliverPersistedRow(row: Record): Promise; + enqueuePersistedRow(rowId: string, object?: EmailLogObject): Promise; + deliverPersistedRow( + row: Record, + opts?: undefined, + object?: EmailLogObject, + ): Promise; } export interface SweepStrandedOutboxOptions { @@ -130,6 +138,8 @@ export interface SweepStrandedOutboxOptions { engine: { find(object: string, opts: Record): Promise }; service: OutboxSweepService; logger?: SweepLogger; + /** The log to sweep. Default {@link OUTBOX_OBJECT} (`sys_email`). */ + object?: EmailLogObject; /** Clock seam for tests. */ now?: () => number; /** Override {@link OUTBOX_SWEEP_MIN_AGE_MS} (tests). */ @@ -170,6 +180,7 @@ export async function sweepStrandedOutbox( opts: SweepStrandedOutboxOptions, ): Promise { const { engine, service, logger } = opts; + const object = opts.object ?? OUTBOX_OBJECT; const now = opts.now?.() ?? Date.now(); const minAgeMs = opts.minAgeMs ?? OUTBOX_SWEEP_MIN_AGE_MS; const limit = Math.max(1, opts.limit ?? OUTBOX_SWEEP_LIMIT); @@ -177,7 +188,7 @@ export async function sweepStrandedOutbox( // One row past the batch so "there are more" is a fact, not an inference // from a full page (a page that is exactly full is the common false positive). - const page = toRows(await engine.find(OUTBOX_OBJECT, { + const page = toRows(await engine.find(object, { where: { status: 'queued', created_at: { $lt: cutoff } }, orderBy: [{ field: 'created_at', order: 'asc' }], limit: limit + 1, @@ -206,14 +217,14 @@ export async function sweepStrandedOutbox( if (service.isServiceManaged(rowId)) { result.skipped++; continue; } try { - if (await service.enqueuePersistedRow(rowId)) { + if (await service.enqueuePersistedRow(rowId, object)) { result.requeued++; continue; } // Inline mode — or a publish that failed, in which case delivering // inline is what `send()` itself falls back to: the row is already // committed, and leaving it for nobody is the bug being fixed. - const delivered = await service.deliverPersistedRow(row); + const delivered = await service.deliverPersistedRow(row, undefined, object); if (delivered.status === 'failed') result.failed++; else result.sent++; } catch (err: any) { @@ -221,7 +232,7 @@ export async function sweepStrandedOutbox( if (!rowErrorReported) { rowErrorReported = true; const message = - `EmailServicePlugin: outbox sweep could not advance sys_email row '${rowId}' — that message stays ` + `EmailServicePlugin: outbox sweep could not advance ${object} row '${rowId}' — that message stays ` + 'at `queued`, undelivered, and nothing will look at it again until the next restart, while the ' + 'server keeps reporting healthy. Fix: the cause below comes from the datasource or the queue, ' + 'not from the message itself (a message that cannot be sent is recorded as `failed` on its own ' @@ -236,7 +247,7 @@ export async function sweepStrandedOutbox( } logger?.info?.( - `EmailServicePlugin: outbox sweep — ${result.scanned} sys_email row(s) stranded at 'queued' for over ` + `EmailServicePlugin: outbox sweep — ${result.scanned} ${object} row(s) stranded at 'queued' for over ` + `${humanMs(minAgeMs)} (accepted, never delivered): ${result.requeued} re-queued for durable delivery, ` + `${result.sent} sent inline, ${result.failed} failed, ${result.skipped} skipped` + (result.truncated @@ -246,10 +257,10 @@ export async function sweepStrandedOutbox( if (result.failed > 0) { const summary = - `EmailServicePlugin: ${result.failed} stranded sys_email row(s) could NOT be delivered by the boot ` + `EmailServicePlugin: ${result.failed} stranded ${object} row(s) could NOT be delivered by the boot ` + 'sweep. Those messages were accepted by the platform and have still never reached a recipient; ' + 'nothing retries them in this process. Fix: read the failures with ' - + "`SELECT id, error FROM sys_email WHERE status = 'failed'`, fix the transport (Settings → Mail), and " + + `\`SELECT id, error FROM ${object} WHERE status = 'failed'\`, fix the transport (Settings → Mail), and ` + 'turn on Settings → Mail → "Durable queue delivery" so future failures are retried and dead-lettered ' + 'instead of depending on the next restart.'; // Same defect as the per-row report above, one scope out (#9748). No diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index a1d44ed5040..66bf1840586 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -77,12 +77,28 @@ export interface SendEmailInput { * export and cleanup script that filters by organization, which is worse * than a null. Producers that already hold one (e.g. the messaging email * channel's `delivery.notification.organizationId`) thread it here; omit - * it when the caller genuinely has none (auth verification / - * password-reset mail) — absent stays legal and the row is simply - * unstamped. Forward-stamping only; pre-existing org-less rows are not + * it when the caller genuinely has none — absent stays legal and the row + * is simply unstamped. Mail about a person rather than an organization + * (auth verification, password reset) is {@link identityMail} instead. Forward-stamping only; pre-existing org-less rows are not * backfilled. */ organizationId?: string; + /** + * Identity mail: a message about a PERSON rather than an organization — + * verification, password reset, magic link, email-change notice, an + * account invitation to someone who belongs to no organization yet. + * + * `true` routes the persisted row to `sys_identity_email`, the + * deployment-level sibling of `sys_email` with the same row shape and no + * organization column (ADR-0131 D7, ruling of record 6257510973, letter + * Q2 A), delivered by the same outbox, status and attachment pipeline. + * Absent or `false` keeps the row in `sys_email`, the tenant mail log. + * + * Identity mail has no organization to carry, so `organizationId` alongside + * `identityMail: true` is refused as a validation failure — never one of the + * two silently dropped. + */ + identityMail?: boolean; } /** @@ -221,6 +237,12 @@ export interface SendTemplateInput { * opts into no overlay resolution. */ organizationId?: string; + /** + * Identity mail, forwarded into the {@link SendEmailInput} this template + * send performs. Same contract as {@link SendEmailInput.identityMail}: the + * row goes to `sys_identity_email`, and `organizationId` beside it is refused. + */ + identityMail?: boolean; } /** diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index e22d4abc04c..86f8c8d6d41 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -52,6 +52,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly Date: Mon, 19 Oct 2026 13:15:41 +0000 Subject: [PATCH 2/7] test(plugin-email,plugin-auth): pin identity mail in sys_identity_email and the 18.0 sys_email dual read Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL Co-authored-by: Claude --- .../src/identity-mail-sibling.test.ts | 119 +++++++++++++ .../src/producers-name-organization.test.ts | 114 ------------- .../src/attachment-reclaim.test.ts | 53 +++++- .../src/email-plugin.outbox-sweep.test.ts | 156 ++++++++++++++++++ .../plugin-email/src/outbox-sweep.test.ts | 2 +- .../plugin-email/src/send-template.test.ts | 51 ++++++ 6 files changed, 378 insertions(+), 117 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts delete mode 100644 packages/plugins/plugin-auth/src/producers-name-organization.test.ts diff --git a/packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts b/packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts new file mode 100644 index 00000000000..2df161af138 --- /dev/null +++ b/packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7, ruling of record 6257510973, letter Q2 A] The auth mails + * AuthManager sends about a PERSON — verification, password reset, magic link, + * the email-change notice — are identity mail: each send says + * `identityMail: true` and names no organization, in every posture, so the + * mail service records it in `sys_identity_email`, the deployment-level + * sibling of `sys_email` with no organization column. + * + * The organization invitation is not identity mail: it holds its + * organization, which it threads, and its row stays tenant mail in + * `sys_email` (#11303 decision 2). + * + * Before this stage the identity sends carried the Default Organization under + * `single` (ADR-0131 D9's derivation) and nothing under a wall — an + * organization-less `sys_email` row. Neither shape is written any more. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AuthManager } from './auth-manager'; + +vi.mock('better-auth', () => ({ + betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })), +})); +vi.mock('better-auth/plugins/organization', () => ({ + organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })), +})); +vi.mock('better-auth/plugins/magic-link', () => ({ + magicLink: vi.fn((opts: any) => ({ id: 'magic-link', _opts: opts })), +})); +vi.mock('better-auth/plugins/two-factor', () => ({ + twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })), +})); +vi.mock('better-auth/plugins/custom-session', () => ({ + customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })), +})); +vi.mock('better-auth/plugins/haveibeenpwned', () => ({ + haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })), +})); + +const USER = { id: 'u1', email: 'ada@example.com', name: 'Ada' }; +const DEFAULT_ORG = 'org_default'; + +type Tenancy = { defaultOrgId(): Promise } | undefined; + +/** `single`: the Default Organization. A wall: `null`. */ +const SINGLE: Tenancy = { defaultOrgId: async () => DEFAULT_ORG }; +const WALLED: Tenancy = { defaultOrgId: async () => null }; + +const IDENTITY_TEMPLATES = ['auth.password_reset', 'auth.verify_email', 'auth.magic_link', 'auth.email_change_notice']; + +/** Boot an AuthManager with a recording email service, drive the five auth sends, return what was sent. */ +async function driveAuthMail(tenancy: Tenancy) { + const { betterAuth } = await import('better-auth'); + let config: any; + (betterAuth as any).mockImplementation((captured: any) => { + config = captured; + return { handler: vi.fn(), api: {} }; + }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + emailAndPassword: { enabled: true }, + emailVerification: { sendOnSignUp: true }, + plugins: { magicLink: true }, + ...(tenancy ? { getTenancy: () => tenancy } : {}), + } as never); + const sent: any[] = []; + manager.setEmailService({ + async send() { return { id: 'e', status: 'sent' }; }, + async sendTemplate(input: any) { sent.push(input); return { id: `e${sent.length}`, status: 'sent' }; }, + } as never); + await manager.getAuthInstance(); + warnSpy.mockRestore(); + + await config.emailAndPassword.sendResetPassword( + { user: USER, url: 'http://x/reset', token: 't' }, + new Request('http://x/api/v1/auth/request-password-reset'), + ); + await config.emailVerification.sendVerificationEmail({ user: USER, url: 'http://x/verify', token: 't' }); + await config.plugins.find((p: any) => p.id === 'magic-link')._opts.sendMagicLink({ + email: USER.email, url: 'http://x/magic', token: 't', + }); + await config.hooks.after({ + path: '/change-email', + body: { newEmail: 'new@example.com' }, + context: { __osChangeEmailFrom: { email: USER.email, name: USER.name, id: USER.id }, returned: { status: true } }, + }); + await config.plugins.find((p: any) => p.id === 'organization')._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'inv1', organizationId: 'org_northwind', role: 'member' }, + organization: { name: 'Northwind' }, + inviter: { user: { email: 'dana@example.com', name: 'Dana' } }, + }); + return sent; +} + +describe('[ADR-0131 D7, Q2 A] mail about a person is identity mail, in every posture', () => { + for (const [posture, tenancy] of [['single', SINGLE], ['walled', WALLED], ['no tenancy service', undefined]] as const) { + it(`${posture}: the four identity sends say identityMail and name no organization`, async () => { + const sent = await driveAuthMail(tenancy); + const identity = sent.filter((s) => IDENTITY_TEMPLATES.includes(s.template)); + expect(identity.map((s) => s.template).sort()).toEqual([...IDENTITY_TEMPLATES].sort()); + for (const s of identity) { + expect(s.identityMail, s.template).toBe(true); + expect('organizationId' in s, s.template).toBe(false); + } + }); + + it(`${posture}: the organization invitation stays tenant mail, stamped with its organization`, async () => { + const sent = await driveAuthMail(tenancy); + const invitation = sent.find((s) => s.template === 'auth.invitation'); + expect(invitation).toMatchObject({ relatedObject: 'sys_invitation', organizationId: 'org_northwind' }); + expect('identityMail' in invitation).toBe(false); + }); + } +}); diff --git a/packages/plugins/plugin-auth/src/producers-name-organization.test.ts b/packages/plugins/plugin-auth/src/producers-name-organization.test.ts deleted file mode 100644 index 5e1eb4a5e18..00000000000 --- a/packages/plugins/plugin-auth/src/producers-name-organization.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [ADR-0131 D9] The auth mails AuthManager sends about a USER (`sys_email`, - * through `IEmailService.sendTemplate`) name the one organization they can - * derive: the Default Organization under `single`, which - * `tenancy.defaultOrgId()` answers, and none under a wall, where it answers - * `null` (the registrant belongs to no organization yet). - * - * Under `single` this is the owner the engine derives for an org-less system - * insert, so the stamp moves no reader; under a wall the row stays org-less - * and its fate is the engine's. Both halves are pinned per writer. - * - * The self-registration grant is no longer one of these writers: it is - * written WITH the registrant's first membership, owned by that membership's - * organization (ADR-0131 D3) — `self-registration-grant-first-membership.test.ts`. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { AuthManager } from './auth-manager'; - -vi.mock('better-auth', () => ({ - betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })), -})); -vi.mock('better-auth/plugins/organization', () => ({ - organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })), -})); -vi.mock('better-auth/plugins/magic-link', () => ({ - magicLink: vi.fn((opts: any) => ({ id: 'magic-link', _opts: opts })), -})); -vi.mock('better-auth/plugins/two-factor', () => ({ - twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })), -})); -vi.mock('better-auth/plugins/custom-session', () => ({ - customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })), -})); -vi.mock('better-auth/plugins/haveibeenpwned', () => ({ - haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })), -})); - -const USER = { id: 'u1', email: 'ada@example.com', name: 'Ada' }; -const DEFAULT_ORG = 'org_default'; - -type Tenancy = { defaultOrgId(): Promise } | undefined; - -/** `single`: the Default Organization. A wall: `null`. */ -const SINGLE: Tenancy = { defaultOrgId: async () => DEFAULT_ORG }; -const WALLED: Tenancy = { defaultOrgId: async () => null }; - -/** Boot an AuthManager with a recording email service; returns the better-auth config and the sends. */ -async function bootMail(tenancy: Tenancy) { - const { betterAuth } = await import('better-auth'); - let capturedConfig: any; - (betterAuth as any).mockImplementation((config: any) => { - capturedConfig = config; - return { handler: vi.fn(), api: {} }; - }); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const manager = new AuthManager({ - secret: 'test-secret-at-least-32-chars-long', - baseUrl: 'http://localhost:3000', - emailAndPassword: { enabled: true }, - emailVerification: { sendOnSignUp: true }, - ...(tenancy ? { getTenancy: () => tenancy } : {}), - } as never); - const sent: any[] = []; - manager.setEmailService({ - async send() { return { id: 'e', status: 'sent' }; }, - async sendTemplate(input: any) { sent.push(input); return { id: 'e1', status: 'sent' }; }, - } as never); - await manager.getAuthInstance(); - warnSpy.mockRestore(); - return { config: capturedConfig, sent }; -} - -async function sendVerification(tenancy: Tenancy) { - const { config, sent } = await bootMail(tenancy); - await config.emailVerification.sendVerificationEmail({ user: USER, url: 'http://x/verify', token: 't' }); - return sent; -} - -async function sendReset(tenancy: Tenancy) { - const { config, sent } = await bootMail(tenancy); - await config.emailAndPassword.sendResetPassword( - { user: USER, url: 'http://x/reset', token: 't' }, - new Request('http://x/api/v1/auth/request-password-reset'), - ); - return sent; -} - -describe('[ADR-0131 D9] the auth mails about a user name the derivable organization', () => { - it('the verification mail carries the Default Organization under single', async () => { - const sent = await sendVerification(SINGLE); - expect(sent).toHaveLength(1); - expect(sent[0]).toMatchObject({ template: 'auth.verify_email', relatedObject: 'sys_user', organizationId: DEFAULT_ORG }); - }); - - it('the verification mail names no organization under a wall, nor with no tenancy service', async () => { - for (const tenancy of [WALLED, undefined]) { - const sent = await sendVerification(tenancy); - expect(sent).toHaveLength(1); - expect(sent[0].template).toBe('auth.verify_email'); - expect('organizationId' in sent[0]).toBe(false); - } - }); - - it('the password-reset mail carries the Default Organization under single and none under a wall', async () => { - const single = await sendReset(SINGLE); - expect(single[0]).toMatchObject({ template: 'auth.password_reset', organizationId: DEFAULT_ORG }); - const walled = await sendReset(WALLED); - expect(walled[0].template).toBe('auth.password_reset'); - expect('organizationId' in walled[0]).toBe(false); - }); -}); diff --git a/packages/plugins/plugin-email/src/attachment-reclaim.test.ts b/packages/plugins/plugin-email/src/attachment-reclaim.test.ts index d5f5f4f84cc..5260dd83748 100644 --- a/packages/plugins/plugin-email/src/attachment-reclaim.test.ts +++ b/packages/plugins/plugin-email/src/attachment-reclaim.test.ts @@ -47,8 +47,8 @@ const storageColumn = JSON.stringify([{ storageKey: KEYS[0], }]); -function fakeEngine(rows: Array>) { - const tables = new Map([[RECLAIM_OBJECT, rows.map((r) => ({ ...r }))]]); +function fakeEngine(rows: Array>, table: string = RECLAIM_OBJECT) { + const tables = new Map([[table, rows.map((r) => ({ ...r }))]]); const rowsOf = (t: string) => tables.get(t) ?? []; const engine = { rows: (t = RECLAIM_OBJECT) => [...rowsOf(t)], @@ -320,3 +320,52 @@ describe('failure handling', () => { expect(out.kind).toBe('reclaimed'); }); }); + +describe('[ADR-0131 D7, Q2 A] the job reads the mail log it names', () => { + const IDENTITY = 'sys_identity_email'; + + it('a sys_identity_email job reclaims and rewrites the sibling row', async () => { + const engine = fakeEngine([terminalRow()], IDENTITY); + const store = fakeStore(); + + const out = await reclaimAttachmentContent({ rowId: 'row-1', keys: KEYS, object: IDENTITY }, { + engine, store, now: () => NOW, logger: logger(), + }); + + expect(out).toMatchObject({ kind: 'reclaimed', rowId: 'row-1' }); + expect(store.objects.size).toBe(0); + expect(engine.updates).toEqual([expect.objectContaining({ table: IDENTITY, id: 'row-1' })]); + expect(JSON.parse(engine.rows(IDENTITY)[0].attachments_json)[0].storageKey).toBeUndefined(); + }); + + it('a sys_identity_email job whose row is still queued keeps the content and re-arms', async () => { + const engine = fakeEngine([terminalRow({ status: 'queued', message_id: undefined })], IDENTITY); + const store = fakeStore(); + const rearm = vi.fn(async () => true); + + const out = await reclaimAttachmentContent({ rowId: 'row-1', keys: KEYS, object: IDENTITY }, { + engine, store, now: () => NOW, rearm, + }); + + expect(out.kind).toBe('rearmed'); + expect(store.objects.size).toBe(1); + }); + + it('a job naming no mail log stalls without deleting or re-arming', async () => { + const engine = fakeEngine([terminalRow()]); + const store = fakeStore(); + const rearm = vi.fn(async () => true); + const log = logger(); + + const out = await reclaimAttachmentContent( + { rowId: 'row-1', keys: KEYS, object: 'sys_user' as never }, + { engine, store, now: () => NOW, rearm, logger: log }, + ); + + expect(out.kind).toBe('stalled'); + expect(rearm).not.toHaveBeenCalled(); + expect(store.objects.size).toBe(1); + expect(engine.updates).toEqual([]); + expect(String(log.error.mock.calls[0]?.[0])).toMatch(/names 'sys_user', which is not a mail log/); + }); +}); diff --git a/packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts b/packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts index 5d3f832100d..75996e98920 100644 --- a/packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.outbox-sweep.test.ts @@ -605,3 +605,159 @@ describe('a template send from an earlier-starting plugin\'s kernel:ready (#2394 await kernel.shutdown(); }); }); + +// ── identity mail: the deployment-level sibling (ADR-0131 D7, Q2 A) ──────── +// +// Identity mail — about a person, not an organization — persists to +// `sys_identity_email`, and the same pipeline serves it. Expand-first: rows an +// 18.0 install wrote to `sys_email` for that mail (organization-less, +// `related_object: 'sys_user'`), and the `{ rowId }` jobs it queued for them, +// stay where they are and keep being read and delivered from there. + +const IDENTITY = 'sys_identity_email'; + +/** A row as an 18.0 install wrote a verification mail: in `sys_email`, no organization. */ +const eighteenZeroIdentityRow = (id: string, createdAt: string) => ({ + id, + from_address: 'no-reply@example.test', + to_addresses: 'registrant@example.test', + subject: 'Verify your email', + body_text: 'verify: http://x/verify?token=t', + related_object: 'sys_user', + related_id: 'usr_registrant', + status: 'queued', + attempt_count: 0, + created_at: createdAt, +}); + +const identityInput = { + to: 'registrant@example.test', + from: 'no-reply@example.test', + subject: 'Verify your email', + text: 'verify: http://x/verify?token=t', + relatedObject: 'sys_user', + relatedId: 'usr_registrant', + identityMail: true, +}; + +describe('[ADR-0131 D7, Q2 A] identity mail is written to sys_identity_email', () => { + it('a fresh send lands in the sibling, with no organization key, and sys_email gets nothing', async () => { + const h = await boot(); + await h.ready(); + + const res = await h.service().send(identityInput); + + expect(res.status).toBe('sent'); + const rows = h.engine.rows(IDENTITY); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ id: res.id, status: 'sent', related_object: 'sys_user', message_id: '' }); + expect('organization_id' in rows[0]).toBe(false); + expect(h.sysEmail()).toEqual([]); + }); + + it('tenant mail is unchanged: no identityMail, the row is sys_email\'s and keeps its organization', async () => { + const h = await boot(); + await h.ready(); + + const res = await h.service().send({ ...identityInput, identityMail: undefined, organizationId: 'org_a' }); + + expect(h.sysEmail()).toEqual([expect.objectContaining({ id: res.id, organization_id: 'org_a', status: 'sent' })]); + expect(h.engine.rows(IDENTITY)).toEqual([]); + }); + + it('identityMail beside organizationId is refused, and the refusal is recorded in the sibling without the organization', async () => { + const h = await boot(); + await h.ready(); + + await expect(h.service().send({ ...identityInput, organizationId: 'org_a' })).rejects.toThrow( + /^VALIDATION_FAILED: identityMail and organizationId cannot be sent together/, + ); + + expect(h.transport.send).not.toHaveBeenCalled(); + const rows = h.engine.rows(IDENTITY); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ status: 'failed' }); + expect(String(rows[0].error)).toMatch(/^rejected before delivery: VALIDATION_FAILED/); + expect('organization_id' in rows[0]).toBe(false); + expect(h.sysEmail()).toEqual([]); + }); + + it('queue mode: the job names the sibling, and the worker delivers the sibling row in place', async () => { + const h = await boot({ queue: true, plugin: { queueDelivery: true } }); + await h.ready(); + + const res = await h.service().send(identityInput); + + expect(res.status).toBe('queued'); + expect(h.jobs()).toHaveLength(1); + expect(JSON.parse(h.jobs()[0].payload_json)).toEqual({ rowId: res.id, object: IDENTITY }); + expect(h.jobs()[0].idempotency_key).toBe(`${IDENTITY}:${res.id}`); + + await h.adapter.pollOnce(); + + expect(h.engine.rows(IDENTITY)).toEqual([expect.objectContaining({ id: res.id, status: 'sent', attempt_count: 1 })]); + expect(h.sysEmail()).toEqual([]); + expect(h.jobs()[0]).toMatchObject({ status: 'completed' }); + }); + + it('the outbox drain hook delivers a queued row inserted into the sibling', async () => { + const h = await boot(); + await h.ready(); + + await h.engine.insert(IDENTITY, { ...eighteenZeroIdentityRow('id-drained', ago(0)) }); + await flush(); + + expect(h.transport.send).toHaveBeenCalledTimes(1); + expect(h.engine.rows(IDENTITY)[0]).toMatchObject({ id: 'id-drained', status: 'sent' }); + }); + + it('the boot sweep delivers a row stranded in the sibling', async () => { + const h = await boot(); + h.engine.seed(IDENTITY, eighteenZeroIdentityRow('id-stranded', ago(min(30)))); + + const swept = await h.ready(); + + expect(swept).toMatchObject({ scanned: 1, sent: 1 }); + expect(h.engine.rows(IDENTITY)[0]).toMatchObject({ id: 'id-stranded', status: 'sent' }); + expect(infoLines(h.ctx).join('\n')).toMatch(/outbox sweep — 1 sys_identity_email row\(s\) stranded/); + }); +}); + +describe('[ADR-0131 D7, Q2 A] expand-first: identity rows an 18.0 install wrote to sys_email are still read', () => { + it('the boot sweep delivers an 18.0 identity row from sys_email, in place, and moves nothing', async () => { + const h = await boot(); + h.engine.seed('sys_email', eighteenZeroIdentityRow('row-18-0', ago(min(30)))); + + const swept = await h.ready(); + + expect(swept).toMatchObject({ scanned: 1, sent: 1 }); + expect(h.sysEmail()).toEqual([expect.objectContaining({ id: 'row-18-0', status: 'sent', related_object: 'sys_user' })]); + expect(h.engine.rows(IDENTITY)).toEqual([]); + }); + + it('an 18.0 { rowId } job with no object reads sys_email and delivers that row', async () => { + const h = await boot({ queue: true, plugin: { queueDelivery: true } }); + h.engine.seed('sys_email', eighteenZeroIdentityRow('row-18-0-job', ago(3_000))); + await h.ready(); + // The job exactly as an 18.0 process published it: no `object` member. + await h.adapter.publish(EMAIL_SEND_QUEUE, { rowId: 'row-18-0-job' }); + + await h.adapter.pollOnce(); + + expect(h.sysEmail()).toEqual([expect.objectContaining({ id: 'row-18-0-job', status: 'sent', attempt_count: 1 })]); + expect(h.engine.rows(IDENTITY)).toEqual([]); + }); + + it('a job naming no mail log is refused loudly, and no row anywhere is touched', async () => { + const h = await boot({ queue: true, plugin: { queueDelivery: true } }); + h.engine.seed('sys_email', eighteenZeroIdentityRow('row-x', ago(3_000))); + await h.ready(); + await h.adapter.publish(EMAIL_SEND_QUEUE, { rowId: 'row-x', object: 'sys_user' }); + + await h.adapter.pollOnce(); + + expect(h.transport.send).not.toHaveBeenCalled(); + expect(h.sysEmail()[0]).toMatchObject({ status: 'queued' }); + expect(errorLines(h.ctx).join('\n')).toMatch(/names 'sys_user', which is not a mail log/); + }); +}); diff --git a/packages/plugins/plugin-email/src/outbox-sweep.test.ts b/packages/plugins/plugin-email/src/outbox-sweep.test.ts index e4fa91b619c..90da19dfa8b 100644 --- a/packages/plugins/plugin-email/src/outbox-sweep.test.ts +++ b/packages/plugins/plugin-email/src/outbox-sweep.test.ts @@ -194,7 +194,7 @@ describe('routing follows the configured delivery mode', () => { const res = await sweepStrandedOutbox({ engine, service, now: () => NOW }); - expect(service.enqueuePersistedRow).toHaveBeenCalledWith('row-a'); + expect(service.enqueuePersistedRow).toHaveBeenCalledWith('row-a', 'sys_email'); expect(service.deliverPersistedRow).toHaveBeenCalledTimes(1); expect(res).toMatchObject({ requeued: 0, sent: 1 }); }); diff --git a/packages/plugins/plugin-email/src/send-template.test.ts b/packages/plugins/plugin-email/src/send-template.test.ts index 1799fd65182..357d0e1a52b 100644 --- a/packages/plugins/plugin-email/src/send-template.test.ts +++ b/packages/plugins/plugin-email/src/send-template.test.ts @@ -85,6 +85,57 @@ describe('EmailService.sendTemplate', () => { expect(rows.get(res.id)?.organization_id).toBe('org_apex'); }); + it('forwards identityMail: the row and both of its writes go to sys_identity_email (ADR-0131 D7, Q2 A)', async () => { + const transport = new CaptureTransport(); + const writes: Array<{ verb: string; object: string | undefined }> = []; + const rows = new Map>(); + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader: makeLoader([sampleTemplate]), + persistence: { + async insert(row, object) { writes.push({ verb: 'insert', object }); rows.set(row.id, { ...row }); return { id: row.id }; }, + async update(id, patch, object) { + writes.push({ verb: 'update', object }); + const cur = rows.get(id); + if (cur) rows.set(id, { ...cur, ...patch }); + }, + }, + }); + const res = await svc.sendTemplate({ + template: 'auth.password_reset', + to: 'alice@x.com', + data: { user: { name: 'Alice' }, resetUrl: 'https://x.com/r/abc' }, + identityMail: true, + }); + expect(res.status).toBe('sent'); + expect(writes).toEqual([ + { verb: 'insert', object: 'sys_identity_email' }, + { verb: 'update', object: 'sys_identity_email' }, + ]); + expect('organization_id' in (rows.get(res.id) ?? {})).toBe(false); + }); + + it('without identityMail every write names sys_email, as before (control)', async () => { + const transport = new CaptureTransport(); + const objects: Array = []; + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader: makeLoader([sampleTemplate]), + persistence: { + async insert(row, object) { objects.push(object); return { id: row.id }; }, + async update(_id, _patch, object) { objects.push(object); }, + }, + }); + await svc.sendTemplate({ + template: 'auth.password_reset', + to: 'alice@x.com', + data: { user: { name: 'Alice' }, resetUrl: 'https://x.com/r/abc' }, + }); + expect(objects).toEqual(['sys_email', 'sys_email']); + }); + it('an org-less sendTemplate writes NO organization_id (over-denial control, #11741)', async () => { const transport = new CaptureTransport(); const rows = new Map>(); From 10d2b85d0fca107cff116824911d2c42ec1bfe0f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:19:34 +0000 Subject: [PATCH 3/7] test(dogfood): a sign-up's verification mail lands in sys_identity_email, not sys_email Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL Co-authored-by: Claude --- ...signup-organization-census.dogfood.test.ts | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/qa/dogfood/test/signup-organization-census.dogfood.test.ts b/packages/qa/dogfood/test/signup-organization-census.dogfood.test.ts index 9b6dd8a54d7..3db72811c1c 100644 --- a/packages/qa/dogfood/test/signup-organization-census.dogfood.test.ts +++ b/packages/qa/dogfood/test/signup-organization-census.dogfood.test.ts @@ -1,13 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // // [ADR-0131 D9] The organization census of one self-registration, end to end -// on the real stack: a sign-up through `POST /auth/sign-up/email` lands three +// on the real stack: a sign-up through `POST /auth/sign-up/email` lands two // framework writes on tenant-column objects — the declared self-registration -// grant (`sys_user_permission_set`), the verification mail (`sys_email`) and -// the activity about the records the sign-up creates (`sys_activity`). Each -// producer names the organization it can derive, so on a `single` boot — -// where the Default Organization is the only organization there is — the -// sign-up leaves no row without one on any of the three tables. +// grant (`sys_user_permission_set`) and the activity about the records the +// sign-up creates (`sys_activity`). Each producer names the organization it +// can derive, so on a `single` boot — where the Default Organization is the +// only organization there is — the sign-up leaves no row without one on +// either table, nor on `sys_email`. +// +// [ADR-0131 D7, ruling of record 6257510973, Q2 A] The verification mail is +// identity mail — about a person, not an organization — so it is written to +// `sys_identity_email`, `sys_email`'s deployment-level sibling with no +// organization column, and not to the tenant mail log. // // Under a wall the same sign-up has no organization to derive (the registrant // belongs to none yet); those rows' fate is not settled by this file. @@ -19,7 +24,9 @@ import { EmailServicePlugin } from '@objectstack/plugin-email'; import { AuditPlugin } from '@objectstack/plugin-audit'; const SYS = { context: { isSystem: true } }; -const CENSUS_TABLES = ['sys_user_permission_set', 'sys_email', 'sys_activity'] as const; +const CENSUS_TABLES = ['sys_user_permission_set', 'sys_email', 'sys_activity', 'sys_identity_email'] as const; +/** The tenant-column tables the sign-up must write to — the mail is no longer one of them. */ +const WRITTEN_TENANT_TABLES = ['sys_user_permission_set', 'sys_activity'] as const; const REGISTRANT = 'census-registrant@dogfood.test'; /** A transport that accepts every message, so the verification mail persists as sent. */ @@ -87,15 +94,17 @@ describe('[ADR-0131 D9] a self-registration on a single boot leaves no organizat expect(registrant?.id, 'the registrant exists').toBeTruthy(); const grants = await ql.find('sys_user_permission_set', { where: { user_id: registrant.id }, ...SYS }); expect(grants.map((g: any) => g.permission_set)).toEqual(['member_default']); - const mails = await ql.find('sys_email', { where: { related_object: 'sys_user', related_id: registrant.id }, ...SYS }); - expect(mails.length, 'the verification mail persisted').toBeGreaterThan(0); + const mails = await ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: registrant.id }, ...SYS }); + expect(mails.length, 'the verification mail persisted, in the identity-mail log').toBeGreaterThan(0); + const tenantMails = await ql.find('sys_email', { where: { related_object: 'sys_user', related_id: registrant.id }, ...SYS }); + expect(tenantMails, 'no verification mail in the tenant mail log').toEqual([]); const activity = await ql.find('sys_activity', { where: { object_name: 'sys_user', record_id: registrant.id }, ...SYS }); expect(activity.length, 'the registrant\'s creation is on the activity stream').toBeGreaterThan(0); }); - it('every row the sign-up wrote on the three tables names the Default Organization', async () => { + it('every row the sign-up wrote on a tenant-column table names the Default Organization', async () => { const census: Record = {}; - for (const table of CENSUS_TABLES) { + for (const table of CENSUS_TABLES.filter((t) => t !== 'sys_identity_email')) { const written = (await rowsOf(table)).filter((r) => !beforeSignUp.get(table)!.has(r.id)); census[table] = { written: written.length, @@ -103,10 +112,26 @@ describe('[ADR-0131 D9] a self-registration on a single boot leaves no organizat foreign: written.filter((r) => r.organization_id != null && r.organization_id !== defaultOrgId).map((r) => r.id), }; } - for (const table of CENSUS_TABLES) { + for (const table of WRITTEN_TENANT_TABLES) { expect(census[table].written, `${table}: the sign-up wrote rows here`).toBeGreaterThan(0); + } + for (const table of Object.keys(census)) { expect(census[table].orgLess, `${table}: rows without an organization`).toEqual([]); expect(census[table].foreign, `${table}: rows owned by another organization`).toEqual([]); } }); + + // [ADR-0131 D7, Q2 A] The verification mail is identity mail. + it('sys_identity_email carries no organization column, and the sign-up\'s mail row names none', async () => { + // Read through the registry's schema, which carries the injected columns. + const schema = ql.getSchema?.('sys_identity_email'); + expect(schema, 'sys_identity_email is registered').toBeTruthy(); + expect(Object.keys(schema.fields ?? {})).not.toContain('organization_id'); + // Non-vacuity: the same read sees the injected column on the tenant mail log. + expect(Object.keys(ql.getSchema('sys_email')?.fields ?? {})).toContain('organization_id'); + const registrant = await ql.findOne('sys_user', { where: { email: REGISTRANT }, ...SYS }); + const [mail] = await ql.find('sys_identity_email', { where: { related_id: registrant.id }, ...SYS }); + expect(mail).toMatchObject({ related_object: 'sys_user', subject: expect.any(String) }); + expect('organization_id' in mail).toBe(false); + }); }); From ebe85c6d331f877a56bb30e43b253094871f1cd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:20:53 +0000 Subject: [PATCH 4/7] =?UTF-8?q?chore(changeset):=20identity=20mail=20sibli?= =?UTF-8?q?ng=20(Clause-=E2=91=A1=20yes,=20widening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL Co-authored-by: Claude --- .changeset/24531-identity-mail-sibling.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/24531-identity-mail-sibling.md diff --git a/.changeset/24531-identity-mail-sibling.md b/.changeset/24531-identity-mail-sibling.md new file mode 100644 index 00000000000..c3f05200ee4 --- /dev/null +++ b/.changeset/24531-identity-mail-sibling.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/platform-objects': minor +'@objectstack/plugin-email': minor +'@objectstack/plugin-auth': minor +'@objectstack/cli': patch +--- + +feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column + +Clause-②: yes (widening) + +ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing. + +- **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links. +- **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written. +- **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`. +- **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`. +- **What a reader of `sys_email` sees.** + - **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall. + - **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed. + - **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`. +- **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release. From eda859a640235607309004b8307c64326fb448cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:34:10 +0000 Subject: [PATCH 5/7] fix(platform-objects): write sys_identity_email's fields as a literal held equal to sys_email's; regenerate the tenant-audit census Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 22 +-- ...08-tenant-audit-write-call-sites.counts.md | 18 +- .../audit/sys-identity-email.object.test.ts | 34 ++++ .../src/audit/sys-identity-email.object.ts | 185 +++++++++++++++++- 4 files changed, 234 insertions(+), 25 deletions(-) create mode 100644 packages/platform-objects/src/audit/sys-identity-email.object.test.ts diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 3f71d3bdc5b..17a6c38d3f0 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -189,7 +189,7 @@ reproduce them. Where it disagrees, it disagrees on the page: | :--- | :--- | ---: | | 175 write call sites | quoted in the merged changeset | **231** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 231** decidable, **71** undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **157 of 231** decidable, **74** undecidable | | 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 95 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | @@ -201,7 +201,7 @@ at any commit. Two structural facts do plausibly widen this reading against any hand or regex one, and both are counted in the generated tables below: the 40 sites reached -through an erased (`any`) receiver, and the 52 that name their object through a +through an erased (`any`) receiver, and the 51 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. @@ -224,9 +224,9 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | | write call sites on the application surface | **231** | -| …whose object name is statically decidable | 160 | -| …whose object name is chosen at run time | 71 | -| …against an object with tenancy ENABLED | 159 | +| …whose object name is statically decidable | 157 | +| …whose object name is chosen at run time | 74 | +| …against an object with tenancy ENABLED | 156 | | …against an object that declares tenancy off | 1 | | threading a tenant context | 171 | | PROVABLY carrying none (options read, no context key) | **8** | @@ -244,10 +244,10 @@ cannot read, and they are neither in nor out. | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 108 | -| object name spelled through a `const` | 52 | +| object name spelled inline | 106 | +| object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 7 | -| object name is some other run-time expression | 64 | +| object name is some other run-time expression | 67 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-19 at `2623d6d6c`. +Measured on 2026-10-19 at `ebe85c6d3`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 670 | +| tracked non-test sources scanned | 672 | | engine-shaped types recognised | 79 | -| declared objects in the registry | 116 | +| declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 166 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 7626b4b1f53..9522c1e6add 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -34,9 +34,9 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| | Write call sites | 231 | -| Object name statically decidable | 160 | -| Object name chosen at run time | 71 | -| Against a tenancy-enabled object | 159 | +| Object name statically decidable | 157 | +| Object name chosen at run time | 74 | +| Against a tenancy-enabled object | 156 | | Against an object declaring tenancy off | 1 | | Threading a tenant context | 171 | | Provably carrying none | 8 | @@ -90,13 +90,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-19 at `2623d6d6c`. +Measured on 2026-10-19 at `ebe85c6d3`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 670 | +| tracked non-test sources scanned | 672 | | engine-shaped types recognised | 79 | -| declared objects in the registry | 116 | +| declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 166 | ## Every site @@ -157,9 +157,9 @@ Measured on 2026-10-19 at `2623d6d6c`. | `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 | | `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 | | `packages/plugins/plugin-auth/src/sso-client-secret.ts` | `update` | `sys_sso_provider` | disabled | elevated | 1 | -| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `sys_email` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `sys_email` | enabled | elevated | 1 | -| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `sys_email` | enabled | elevated | 1 | +| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `object` | undecidable | elevated | 1 | +| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `object` | undecidable | elevated | 1 | +| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | diff --git a/packages/platform-objects/src/audit/sys-identity-email.object.test.ts b/packages/platform-objects/src/audit/sys-identity-email.object.test.ts new file mode 100644 index 00000000000..0f93ea19ab0 --- /dev/null +++ b/packages/platform-objects/src/audit/sys-identity-email.object.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D7, ruling of record 6257510973, Q2 A] `sys_identity_email` is + * `sys_email`'s deployment-level sibling: the same row shape, so one mail + * pipeline serves both, and no organization column. + */ + +import { describe, it, expect } from 'vitest'; +import { SysEmail } from './sys-email.object.js'; +import { SysIdentityEmail } from './sys-identity-email.object.js'; + +describe('sys_identity_email — the identity-mail sibling of sys_email', () => { + it('declares exactly sys_email\'s fields — the row shape the pipeline reads and writes', () => { + expect(Object.keys(SysIdentityEmail.fields)).toEqual(Object.keys(SysEmail.fields)); + expect(SysIdentityEmail.fields).toEqual(SysEmail.fields); + }); + + it('keys the same indexes, so the by-status and by-id reads cost the same', () => { + expect(SysIdentityEmail.indexes).toEqual(SysEmail.indexes); + }); + + it('opts out of the injected organization column; sys_email keeps it', () => { + expect(SysIdentityEmail.systemFields).toEqual({ tenant: false }); + expect(SysEmail.systemFields?.tenant).not.toBe(false); + expect(Object.keys(SysIdentityEmail.fields)).not.toContain('organization_id'); + }); + + it('is append-only, readable by get/list, and behind the platform-only capability', () => { + expect(SysIdentityEmail.managedBy).toBe('append-only'); + expect(SysIdentityEmail.enable?.apiMethods).toEqual(['get', 'list']); + expect(SysIdentityEmail.requiredPermissions).toEqual(['manage_platform_settings']); + }); +}); diff --git a/packages/platform-objects/src/audit/sys-identity-email.object.ts b/packages/platform-objects/src/audit/sys-identity-email.object.ts index 13de05d4d61..0885f512a47 100644 --- a/packages/platform-objects/src/audit/sys-identity-email.object.ts +++ b/packages/platform-objects/src/audit/sys-identity-email.object.ts @@ -1,7 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { ObjectSchema } from '@objectstack/spec/data'; -import { SysEmail } from './sys-email.object.js'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; /** * sys_identity_email — Outbound Identity Mail Log @@ -14,8 +13,8 @@ import { SysEmail } from './sys-email.object.js'; * organization column, instead of as an organization-less row in the tenant * mail log: ⛔ neither a nullable column nor unpersisted mail. * - * Same row shape as `sys_email` — the fields ARE `sys_email`'s, so the two - * cannot drift — and the same pipeline: `IEmailService.send` with + * Same row shape as `sys_email` — its fields, held equal by a test — and the + * same pipeline: `IEmailService.send` with * `identityMail: true` writes the row, and the queue worker, the outbox drain * hook, the boot outbox sweep and attachment reclamation read it by id and * status exactly as they read `sys_email`. @@ -51,7 +50,183 @@ export const SysIdentityEmail = ObjectSchema.create({ titleFormat: '{subject}', highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'], - fields: SysEmail.fields, + // `sys_email`'s fields, written out: the source scans that judge an + // object's columns (`check:keyed-text-bounds`) read a literal, never a + // reference. `sys-identity-email.object.test.ts` holds the two equal, so + // the row shape cannot drift; the reasoning behind each field lives on + // `sys_email`. + fields: { + id: Field.text({ + label: 'Email ID', + required: true, + readonly: true, + group: 'System', + }), + + message_id: Field.text({ + label: 'Message-ID', + required: false, + maxLength: 255, + description: 'RFC-5322 Message-ID assigned by the transport', + group: 'Envelope', + }), + + from_address: Field.text({ + label: 'From', + required: true, + maxLength: 320, + searchable: true, + group: 'Envelope', + }), + + to_addresses: Field.text({ + label: 'To', + required: true, + maxLength: 4000, + searchable: true, + description: 'Comma-separated recipient addresses', + group: 'Envelope', + }), + + cc_addresses: Field.text({ + label: 'Cc', + required: false, + maxLength: 4000, + group: 'Envelope', + }), + + bcc_addresses: Field.text({ + label: 'Bcc', + required: false, + maxLength: 4000, + group: 'Envelope', + }), + + reply_to: Field.text({ + label: 'Reply-To', + required: false, + maxLength: 320, + group: 'Envelope', + }), + + subject: Field.text({ + label: 'Subject', + required: true, + maxLength: 998, + searchable: true, + group: 'Content', + }), + + body_text: Field.textarea({ + label: 'Body (text)', + required: false, + searchable: true, + group: 'Content', + }), + + body_html: Field.textarea({ + label: 'Body (HTML)', + required: false, + group: 'Content', + }), + + headers_json: Field.textarea({ + label: 'Headers (JSON)', + required: false, + internal: true, + description: + 'Custom headers supplied with the message when it was sent, as a JSON object of name → value. ' + + 'Written in both delivery modes (it is audit evidence as much as delivery input). ' + + 'Absent on rows written before this column existed, which read back as "no custom headers". ' + + 'Never returned on the generic data path — headers are the ordinary place a ' + + 'credential goes; the delivery paths recover it through the engine\'s privileged accessor.', + group: 'Content', + }), + + attachments_json: Field.textarea({ + label: 'Attachments (JSON)', + required: false, + description: + 'Attachments as a JSON array of { filename, contentType?, size, hash, cid?, contentForm, ' + + 'inline?, storageKey?, contentReclaimedAt? }. Content up to the plugin-email budget ' + + '(SYS_EMAIL_ATTACHMENT_LIMIT_BYTES, 256 KiB combined raw — ~350 KB of base64 at worst) is base64 in ' + + '`inline`; larger content goes to the file-storage capability and the element carries `storageKey` ' + + 'instead, so the row stays bounded either way. filename/contentType/size/hash are PERMANENT audit ' + + 'evidence; out-of-row content is a delivery artifact and is deleted a grace window after the row ' + + 'reaches a terminal state, at which point `storageKey` is replaced by `contentReclaimedAt`.', + group: 'Content', + }), + + status: Field.select( + ['queued', 'sent', 'failed'], + { + label: 'Status', + required: true, + defaultValue: 'queued', + description: 'Lifecycle state — queued when the message is submitted for sending, before the first delivery attempt', + group: 'State', + }, + ), + + error: Field.textarea({ + label: 'Error', + required: false, + description: + 'Why the message failed — a transport error, or the validation that rejected it before delivery.', + group: 'State', + }), + + attempt_count: Field.number({ + label: 'Attempts', + required: false, + defaultValue: 0, + description: 'Number of delivery attempts performed by the service', + group: 'State', + }), + + sent_at: Field.datetime({ + label: 'Sent At', + required: false, + description: 'Set when status transitions to "sent"', + group: 'State', + }), + + related_object: Field.text({ + label: 'Related Object', + required: false, + maxLength: 100, + description: 'Object name of the related record (e.g. lead, opportunity)', + group: 'Source', + }), + + related_id: Field.text({ + label: 'Related Record', + required: false, + maxLength: 100, + description: 'Record id within related_object', + group: 'Source', + }), + + sent_by: Field.lookup('sys_user', { + label: 'Sent By', + required: false, + group: 'Source', + }), + + created_at: Field.datetime({ + label: 'Created At', + required: true, + defaultValue: 'NOW()', + readonly: true, + group: 'System', + }), + + updated_at: Field.datetime({ + label: 'Updated At', + required: false, + group: 'System', + }), + }, indexes: [ { fields: ['status', 'created_at'] }, From 344aeb8a3a2e034bb7b38964a211efc404356255 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:50:13 +0000 Subject: [PATCH 6/7] test(platform-objects): pin sys_identity_email's shape against sys_email Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL Co-authored-by: Claude --- .../src/audit/sys-identity-email.object.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/platform-objects/src/audit/sys-identity-email.object.test.ts b/packages/platform-objects/src/audit/sys-identity-email.object.test.ts index 0f93ea19ab0..43ca845f99f 100644 --- a/packages/platform-objects/src/audit/sys-identity-email.object.test.ts +++ b/packages/platform-objects/src/audit/sys-identity-email.object.test.ts @@ -22,7 +22,9 @@ describe('sys_identity_email — the identity-mail sibling of sys_email', () => it('opts out of the injected organization column; sys_email keeps it', () => { expect(SysIdentityEmail.systemFields).toEqual({ tenant: false }); - expect(SysEmail.systemFields?.tenant).not.toBe(false); + // Non-vacuity: the tenant log does not opt out (neither `false` nor `{ tenant: false }`). + const tenantLog = SysEmail.systemFields; + expect(tenantLog === false || (typeof tenantLog === 'object' && tenantLog.tenant === false)).toBe(false); expect(Object.keys(SysIdentityEmail.fields)).not.toContain('organization_id'); }); From 3bc88f5f15744356f20e86bdeaeb7a392bd976cf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:09:56 +0000 Subject: [PATCH 7/7] docs(plugins): plugin-email records identity mail on sys_identity_email Claude-Session: https://claude.ai/code/session_016CvQEdxxYYVa8C8AjKZBtL Co-authored-by: Claude --- content/docs/plugins/packages.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/plugins/packages.mdx b/content/docs/plugins/packages.mdx index 56f21b59c18..6d9c8fdc29f 100644 --- a/content/docs/plugins/packages.mdx +++ b/content/docs/plugins/packages.mdx @@ -438,7 +438,7 @@ metadata it names. **Email Plugin** — Outbound email channel and templates. -- **Features**: Provider transports (Postmark, Resend), a minimal mustache-style `{{placeholder}}` template renderer, delivery tracking on `sys_email` +- **Features**: Provider transports (Postmark, Resend), a minimal mustache-style `{{placeholder}}` template renderer, delivery tracking on `sys_email` (identity mail — verification, password reset, sign-in links — on its deployment-level sibling `sys_identity_email`) - **When to use**: Transactional and workflow-driven email ### @objectstack/plugin-webhooks