Skip to content

Commit 04235f9

Browse files
committed
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 <noreply@anthropic.com>
1 parent 5b14b7f commit 04235f9

15 files changed

Lines changed: 441 additions & 141 deletions

File tree

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,12 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
544544
{ 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')] },
545545
{ 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')] },
546546
{ 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')] },
547+
{
548+
object: 'sys_identity_email',
549+
census: PMAIN,
550+
fate: 'column-drop',
551+
citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: sys_email's sibling for mail about a person (ruling record 6257510973, Q2 A)`,
552+
},
547553
{ 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')] },
548554
{
549555
object: 'sys_webhook',

‎packages/platform-objects/src/audit/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
export { SysNotification } from './sys-notification.object.js';
1313
export { SysAttachment } from './sys-attachment.object.js';
1414
export { SysEmail } from './sys-email.object.js';
15+
export { SysIdentityEmail } from './sys-identity-email.object.js';
1516
export { SysEmailTemplate } from './sys-email-template.object.js';
1617
// sys_saved_report / sys_report_schedule retired with the saved-report stack (#20102).
1718
// sys_approval_request / sys_approval_action moved to @objectstack/plugin-approvals (ADR-0029 K2.b).
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema } from '@objectstack/spec/data';
4+
import { SysEmail } from './sys-email.object.js';
5+
6+
/**
7+
* sys_identity_email — Outbound Identity Mail Log
8+
*
9+
* The deployment-level sibling of `sys_email` (ADR-0131 D7, ruling of record
10+
* 6257510973, letter Q2 A). Identity mail is mail about a PERSON rather than
11+
* an organization — verification, password reset, magic link, email-change
12+
* notice, an account invitation to someone who belongs to no organization
13+
* yet — so it has no organization to stamp. It lives here, with no
14+
* organization column, instead of as an organization-less row in the tenant
15+
* mail log: ⛔ neither a nullable column nor unpersisted mail.
16+
*
17+
* Same row shape as `sys_email` — the fields ARE `sys_email`'s, so the two
18+
* cannot drift — and the same pipeline: `IEmailService.send` with
19+
* `identityMail: true` writes the row, and the queue worker, the outbox drain
20+
* hook, the boot outbox sweep and attachment reclamation read it by id and
21+
* status exactly as they read `sys_email`.
22+
*
23+
* Expand-first: rows an 18.0 install wrote to `sys_email` for this mail stay
24+
* there; the mail pipeline keeps reading them, and moving them is the 18.x
25+
* upgrade ceremony's.
26+
*
27+
* `systemFields: { tenant: false }` removes the injected organization column.
28+
* With no column there is no tenant wall, so who may read is object
29+
* permission (D7): these rows carry password-reset and sign-in links, so the
30+
* platform-only capability, as on `sys_job` — under a wall an organization's
31+
* administrator must not read every person's links.
32+
*
33+
* Typical writers: `IEmailService.send()` / `sendTemplate()` with `identityMail: true`
34+
* (plugin-auth's identity mails).
35+
* Typical readers: the mail delivery pipeline, the platform administrator.
36+
*
37+
* @namespace sys
38+
*/
39+
export const SysIdentityEmail = ObjectSchema.create({
40+
name: 'sys_identity_email',
41+
label: 'Identity Email',
42+
pluralLabel: 'Identity Emails',
43+
icon: 'mail',
44+
isSystem: true,
45+
managedBy: 'append-only',
46+
systemFields: { tenant: false },
47+
requiredPermissions: ['manage_platform_settings'],
48+
description: 'Outbound identity mail log — verification, password reset and sign-in mail about a person, not an organization',
49+
displayNameField: 'subject',
50+
nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
51+
titleFormat: '{subject}',
52+
highlightFields: ['subject', 'to_addresses', 'status', 'sent_at'],
53+
54+
fields: SysEmail.fields,
55+
56+
indexes: [
57+
{ fields: ['status', 'created_at'] },
58+
{ fields: ['related_object', 'related_id'] },
59+
{ fields: ['sent_by'] },
60+
],
61+
62+
enable: {
63+
// [ADR-0103] Engine-owned append-only mail log, as `sys_email`: written
64+
// only by the mail service (SYSTEM_CTX). Reads behind the permission above.
65+
apiMethods: ['get', 'list'],
66+
},
67+
});

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 14 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2008,7 +2008,9 @@ export class AuthManager {
20082008
// the user's mail carries only the deployment default. Best-effort;
20092009
// `undefined` leaves the #14319 ladder exactly as it was.
20102010
const storedLocale = await this.storedRecipientLocale({ id: user.id });
2011-
const mailOrganization = await this.authMailOrganizationArg();
2011+
// [ADR-0131 D7, ruling of record 6257510973, Q2 A] Both mails are
2012+
// about a person, not an organization: identity mail, recorded in
2013+
// the deployment-level `sys_identity_email`, with no organization.
20122014
const result = await email.sendTemplate(invitation
20132015
? {
20142016
// Exactly the variables `auth.account_invitation` declares —
@@ -2028,7 +2030,7 @@ export class AuthManager {
20282030
},
20292031
relatedObject: 'sys_user',
20302032
relatedId: user.id,
2031-
...mailOrganization,
2033+
identityMail: true,
20322034
}
20332035
: {
20342036
template: 'auth.password_reset',
@@ -2043,7 +2045,7 @@ export class AuthManager {
20432045
},
20442046
relatedObject: 'sys_user',
20452047
relatedId: user.id,
2046-
...mailOrganization,
2048+
identityMail: true,
20472049
});
20482050
if (result?.status === 'failed') {
20492051
throw new Error(
@@ -2106,9 +2108,9 @@ export class AuthManager {
21062108
},
21072109
relatedObject: 'sys_user',
21082110
relatedId: user.id,
2109-
// [ADR-0131 D9] The mail is about a user, who belongs to no
2110-
// organization yet: the derivable owner, as for the grant.
2111-
...(await this.authMailOrganizationArg()),
2111+
// [ADR-0131 D7, Q2 A] The mail is about a person, who may belong
2112+
// to no organization yet: identity mail, in `sys_identity_email`.
2113+
identityMail: true,
21122114
});
21132115
if (result?.status === 'failed') {
21142116
throw new Error(
@@ -3881,8 +3883,8 @@ export class AuthManager {
38813883
relatedObject: 'sys_invitation',
38823884
relatedId: invitation.id,
38833885
// commit b706af987 — the invitation HOLDS its organization; thread it so
3884-
// the sys_email row is stamped. Org-less auth mail (reset /
3885-
// verification / magic link) deliberately threads nothing.
3886+
// the sys_email row is stamped: tenant mail. Mail about a person
3887+
// (reset / verification / magic link) is identity mail instead.
38863888
...(invitation.organizationId
38873889
? { organizationId: String(invitation.organizationId) }
38883890
: {}),
@@ -4125,7 +4127,8 @@ export class AuthManager {
41254127
expiresInMinutes: 10,
41264128
appName: this.getAppName(),
41274129
},
4128-
...(await this.authMailOrganizationArg()),
4130+
// [ADR-0131 D7, Q2 A] A sign-in link for a person: identity mail.
4131+
identityMail: true,
41294132
});
41304133
} catch (err: any) {
41314134
console.error(`[AuthManager] sendMagicLink failed: ${err?.message ?? err}`);
@@ -5630,31 +5633,6 @@ export class AuthManager {
56305633
}
56315634
}
56325635

5633-
/**
5634-
* [ADR-0131 D9] The organization a write this manager makes on a
5635-
* tenant-column object carries when nothing on the write names one: the
5636-
* Default Organization under `single` — the only organization there is —
5637-
* and none under a wall, where `tenancy.defaultOrgId()` answers `null`. One
5638-
* reading for every such producer here (the auth mails that are about a user
5639-
* rather than an organization), so they cannot disagree about it. `undefined` when no tenancy service is wired or
5640-
* it cannot answer: the producer then names nothing, as before.
5641-
*/
5642-
private async derivableWriteOrganizationId(): Promise<string | undefined> {
5643-
try {
5644-
const tenancy = this.config.getTenancy?.();
5645-
const organizationId = tenancy ? await tenancy.defaultOrgId() : null;
5646-
return organizationId ?? undefined;
5647-
} catch {
5648-
return undefined;
5649-
}
5650-
}
5651-
5652-
/** The `organizationId` argument of an auth mail that is about a user, not an organization. */
5653-
private async authMailOrganizationArg(): Promise<{ organizationId?: string }> {
5654-
const organizationId = await this.derivableWriteOrganizationId();
5655-
return organizationId ? { organizationId } : {};
5656-
}
5657-
56585636
/**
56595637
* [ADR-0131 D3/D9 — the self-registration grant settles on the first
56605638
* membership] The `user.create.after` half of an admitted self-registration.
@@ -5995,7 +5973,8 @@ export class AuthManager {
59955973
},
59965974
relatedObject: 'sys_user',
59975975
...(from.id ? { relatedId: from.id } : {}),
5998-
...(await this.authMailOrganizationArg()),
5976+
// [ADR-0131 D7, Q2 A] A notice to the account holder: identity mail.
5977+
identityMail: true,
59995978
});
60005979
} catch (err) {
60015980
// Best-effort by design — see the ⛔ above. Logged so an operator can see

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

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,17 @@ import {
8080
type EmailAttachmentStore,
8181
} from './attachment-storage.js';
8282
import { withContentReclaimed } from './sys-email-payload.js';
83+
import { EMAIL_LOG_OBJECTS, TENANT_EMAIL_OBJECT, emailLogObjectOfJob } from './email-log-objects.js';
8384

8485
/** System context — reclamation is platform bookkeeping, not a user query. */
8586
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;
8687

87-
/** Backing object; a constant so the query and the log lines agree. */
88-
export const RECLAIM_OBJECT = 'sys_email';
88+
/**
89+
* Backing object of a job that names none — every job an 18.0 process
90+
* published, and every `sys_email` job since. A `sys_identity_email` job
91+
* names its log in the payload (`email-log-objects.ts`).
92+
*/
93+
export const RECLAIM_OBJECT = TENANT_EMAIL_OBJECT;
8994

9095
/**
9196
* `sys_email.status` values after which content is never needed again.
@@ -188,18 +193,31 @@ export async function reclaimAttachmentContent(
188193

189194
if (keys.length === 0) return { kind: 'noop', rowId };
190195

196+
// The log the row lives in. A payload naming something else is refused,
197+
// not guessed: reading the wrong log finds no row, and "no row" is the
198+
// branch below that DELETES the content.
199+
const object = emailLogObjectOfJob(payload?.object);
200+
if (!object) {
201+
// Stalled, never re-armed: no later run can read a log the job does not name.
202+
return rearmOrStall(
203+
{ ...opts, rearm: undefined }, rowId, 'mail', graceMs,
204+
`the job names '${String(payload?.object)}', which is not a mail log (${EMAIL_LOG_OBJECTS.join(', ')}), `
205+
+ 'so the row its content belongs to cannot be read',
206+
);
207+
}
208+
191209
if (!opts.store) {
192210
// The capability that HOLDS the bytes is not mounted on this process.
193211
// Deleting is impossible; re-arm so a process that has it can finish the
194212
// job, and say so if nothing can.
195213
return rearmOrStall(
196-
opts, rowId, graceMs,
214+
opts, rowId, object, graceMs,
197215
'the storage capability is not mounted on the process running the reclaim job, so the content '
198216
+ 'cannot be deleted here',
199217
);
200218
}
201219

202-
const found = await opts.engine.find(RECLAIM_OBJECT, {
220+
const found = await opts.engine.find(object, {
203221
where: { id: rowId },
204222
limit: 1,
205223
context: SYSTEM_CTX,
@@ -211,9 +229,9 @@ export async function reclaimAttachmentContent(
211229
// them from the payload's own key list is what makes row deletion incapable
212230
// of orphaning content — see the module header.
213231
if (!row) {
214-
await deleteOrThrow(opts.store, keys, rowId, 'the sys_email row no longer exists');
232+
await deleteOrThrow(opts.store, keys, rowId, object, `the ${object} row no longer exists`);
215233
opts.logger?.info?.(
216-
`EmailServicePlugin: reclaimed ${keys.length} out-of-row attachment object(s) for sys_email row `
234+
`EmailServicePlugin: reclaimed ${keys.length} out-of-row attachment object(s) for ${object} row `
217235
+ `'${rowId}', which no longer exists — the content had no remaining reference.`,
218236
);
219237
return { kind: 'orphan-reclaimed', rowId, keys };
@@ -223,8 +241,8 @@ export async function reclaimAttachmentContent(
223241
const status = String(row.status ?? '');
224242
if (!TERMINAL_EMAIL_STATUSES.includes(status)) {
225243
return rearmOrStall(
226-
opts, rowId, graceMs,
227-
`the sys_email row is still at '${status || '(unset)'}', so its content may still be needed for delivery`,
244+
opts, rowId, object, graceMs,
245+
`the ${object} row is still at '${status || '(unset)'}', so its content may still be needed for delivery`,
228246
);
229247
}
230248

@@ -236,8 +254,8 @@ export async function reclaimAttachmentContent(
236254
const age = msSinceLastChange(row, now);
237255
if (age !== undefined && age < graceMs) {
238256
return rearmOrStall(
239-
opts, rowId, graceMs - age,
240-
`the sys_email row was last updated ${Math.round(age / 1000)}s ago, inside the `
257+
opts, rowId, object, graceMs - age,
258+
`the ${object} row was last updated ${Math.round(age / 1000)}s ago, inside the `
241259
+ `${Math.round(graceMs / 1000)}s reclaim grace window`,
242260
);
243261
}
@@ -248,19 +266,19 @@ export async function reclaimAttachmentContent(
248266
// very function that exists to prevent them. This order's failure mode is a
249267
// row that names a key which 404s, which is loud on read (the decoder
250268
// verifies) and fixed by the job's next attempt.
251-
await deleteOrThrow(opts.store, keys, rowId, `the sys_email row is terminal ('${status}')`);
269+
await deleteOrThrow(opts.store, keys, rowId, object, `the ${object} row is terminal ('${status}')`);
252270

253271
const rewritten = withContentReclaimed(row.attachments_json, new Date(now).toISOString());
254272
if (rewritten !== undefined) {
255273
await opts.engine.update(
256-
RECLAIM_OBJECT,
274+
object,
257275
{ id: rowId, attachments_json: rewritten },
258276
{ context: SYSTEM_CTX },
259277
);
260278
}
261279

262280
opts.logger?.info?.(
263-
`EmailServicePlugin: reclaimed ${keys.length} out-of-row attachment object(s) for sys_email row `
281+
`EmailServicePlugin: reclaimed ${keys.length} out-of-row attachment object(s) for ${object} row `
264282
+ `'${rowId}' (status '${status}'). The row keeps filename/contentType/size/hash as audit evidence; only `
265283
+ 'the delivery content was deleted.',
266284
);
@@ -272,12 +290,13 @@ async function deleteOrThrow(
272290
store: EmailAttachmentStore,
273291
keys: string[],
274292
rowId: string,
293+
object: string,
275294
because: string,
276295
): Promise<void> {
277296
const { failed } = await deleteAttachmentKeys(store, keys);
278297
if (failed.length === 0) return;
279298
throw new Error(
280-
`reclaiming attachment content for sys_email row '${rowId}' (${because}) could not delete `
299+
`reclaiming attachment content for ${object} row '${rowId}' (${because}) could not delete `
281300
+ `${failed.length} of ${keys.length} storage object(s): `
282301
+ failed.map((f) => `'${f.key}' (${f.error})`).join('; ')
283302
+ '. Those bytes are still billed and no longer needed; the reclaim job will retry.',
@@ -288,6 +307,7 @@ async function deleteOrThrow(
288307
async function rearmOrStall(
289308
opts: ReclaimAttachmentContentOptions,
290309
rowId: string,
310+
object: string,
291311
delayMs: number,
292312
reason: string,
293313
): Promise<AttachmentReclaimOutcome> {
@@ -301,7 +321,7 @@ async function rearmOrStall(
301321
// for. That is the durability/consequence class AGENTS.md pins at `error`,
302322
// and the line owes both the consequence and the fix.
303323
opts.logger?.error?.(
304-
`EmailServicePlugin: out-of-row attachment content for sys_email row '${rowId}' could NOT be reclaimed and `
324+
`EmailServicePlugin: out-of-row attachment content for ${object} row '${rowId}' could NOT be reclaimed and `
305325
+ `could NOT be rescheduled — ${reason}. Those storage objects will now stay in the backend forever unless `
306326
+ 'they are deleted by hand. Fix: keep the durable queue service (@objectstack/service-queue over an '
307327
+ 'ObjectQL engine) and the storage capability (@objectstack/service-storage) mounted on the process '

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
collectAttachmentParts,
5555
type PersistedEmailAttachment,
5656
} from './sys-email-payload.js';
57+
import type { EmailLogObject } from './email-log-objects.js';
5758

5859
/**
5960
* The slice of the `storage` capability (`IStorageService`) that
@@ -152,10 +153,15 @@ export const EMAIL_ATTACHMENT_RECLAIM_GRACE_MS = 24 * 60 * 60_000;
152153
* content and left it in the bucket forever.
153154
*/
154155
export interface EmailAttachmentReclaimPayload {
155-
/** `sys_email.id` whose content this job reclaims. */
156+
/** `sys_email.id` (or `sys_identity_email.id`) whose content this job reclaims. */
156157
rowId: string;
157158
/** Every storage key written for that row. Authoritative — see above. */
158159
keys: string[];
160+
/**
161+
* The log the row lives in. Written only for `sys_identity_email`; absent
162+
* reads as `sys_email`, which is also every job an 18.0 process published.
163+
*/
164+
object?: EmailLogObject;
159165
}
160166

161167
/** Characters a storage key segment may contain; everything else is folded. */

0 commit comments

Comments
 (0)