diff --git a/.changeset/sendemail-organization-id.md b/.changeset/sendemail-organization-id.md new file mode 100644 index 0000000000..452cd5324d --- /dev/null +++ b/.changeset/sendemail-organization-id.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-email': minor +'@objectstack/service-messaging': minor +'@objectstack/plugin-auth': minor +--- + +Widen `SendEmailInput` / `SendTemplateInput` with an optional `organizationId`, threaded from producers that already hold an organization, so `plugin-email`'s writer stamps `sys_email.organization_id` at the source (#11741, Decision 2 of #11303). + +- `@objectstack/spec`: `SendEmailInput.organizationId?` and `SendTemplateInput.organizationId?` — optional, pass-through only; absent stays legal (auth verification / password-reset mail carries none). +- `@objectstack/plugin-email`: `EmailService.send()` stamps the value verbatim onto the persisted `sys_email` row; `sendTemplate()` forwards it to `send()`. No in-adapter resolution or fabrication — the writer runs under a constant system context and only passes through what the input carries. +- `@objectstack/service-messaging`: the email channel threads `delivery.notification.organizationId` on both of its arms (plain `send` and the `sendTemplate` template path). +- `@objectstack/plugin-auth`: `sendInvitationEmail` threads the invitation's own `organizationId`; org-less auth mail (reset / verification / magic link / email-change notice) is unchanged. + +Forward-stamping only: existing org-less `sys_email` rows are not backfilled. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 65a684ede7..abd5fbc6f6 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -2379,6 +2379,34 @@ describe('AuthManager', () => { expect(data.acceptUrl).toBe('http://localhost:3000/_console/accept-invitation/tok456'); }); + // #11741 — the invitation producer HOLDS an organization (the invitation + // row's own organizationId), so it threads that exact value into + // SendTemplateInput for the sys_email.organization_id stamp. Auth mail + // that genuinely has no organization (password reset / verification / + // magic link) stays unstamped AND un-refused — the over-denial control. + it('sendInvitationEmail threads the invitation organizationId into sendTemplate (#11741)', async () => { + const { capturedConfig, emailService } = await boot(); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await orgPlugin._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'inv42', organizationId: 'org_apex', role: 'member' }, + organization: { name: 'Apex' }, + inviter: { user: { email: 'admin@example.com' } }, + }); + expect(emailService.sendTemplate).toHaveBeenCalledTimes(1); + const invitationInput = (emailService.sendTemplate.mock.calls as unknown as Array<[Record]>)[0]?.[0]; + expect(invitationInput?.organizationId).toBe('org_apex'); + }); + + it('sendResetPassword carries NO organizationId and is not refused — auth mail is genuinely org-less (#11741)', async () => { + const { capturedConfig, emailService } = await boot(); + const sendResetPassword = capturedConfig.emailAndPassword.sendResetPassword; + await sendResetPassword({ user: { id: 'u1', email: 'real@example.com' }, url: 'http://x/reset', token: 't' }); + expect(emailService.sendTemplate).toHaveBeenCalledTimes(1); + const resetInput = (emailService.sendTemplate.mock.calls as unknown as Array<[Record]>)[0]?.[0]; + expect(resetInput).not.toHaveProperty('organizationId'); + }); + it('sendInvitationEmail honours a custom uiBasePath (Console mounted elsewhere)', async () => { const { capturedConfig, emailService } = await boot({ baseUrl: 'https://acme.example.com', diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 9fd8eb6c3b..bc1825a4f5 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2685,6 +2685,12 @@ export class AuthManager { }, relatedObject: 'sys_invitation', relatedId: invitation.id, + // #11741 — 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. + ...(invitation.organizationId + ? { organizationId: String(invitation.organizationId) } + : {}), }); } catch (err: any) { // Do NOT rethrow: the invitation row was already persisted by diff --git a/packages/plugins/plugin-email/src/email-service.test.ts b/packages/plugins/plugin-email/src/email-service.test.ts index 26b606783f..b34a337302 100644 --- a/packages/plugins/plugin-email/src/email-service.test.ts +++ b/packages/plugins/plugin-email/src/email-service.test.ts @@ -338,3 +338,46 @@ describe('rowToNormalized', () => { expect(() => rowToNormalized({ to_addresses: 'a@b.com', from_address: 'c@d.com', subject: 'x' })).toThrow(/body/); }); }); + +// ── #11741 — sys_email organization stamping ──────────────────────────────── +// The writer runs under a constant SYSTEM context, so the ONLY organization a +// row can carry is the one the input carries: `SendEmailInput.organizationId` +// is stamped onto `sys_email.organization_id` verbatim (pass-through), and its +// absence writes nothing — never refused, never resolved, never fabricated +// (a wrong organization is silently authoritative to every org-filtered +// report/export, which is worse than a null). +describe('sys_email organization stamping (#11741)', () => { + function makePersistence() { + const rows = new Map>(); + const p: EmailPersistence = { + async insert(row) { rows.set(row.id, { ...row }); return { id: row.id }; }, + async update(id, patch) { + const cur = rows.get(id); + if (cur) rows.set(id, { ...cur, ...patch }); + }, + }; + return { p, rows }; + } + + it("send() stamps input.organizationId onto the row's organization_id (identity pin)", async () => { + const transport = { send: vi.fn(async () => ({ messageId: '' })) }; + const { p, rows } = makePersistence(); + const svc = new EmailService({ transport, defaultFrom: 'no@reply.com', persistence: p }); + const res = await svc.send({ to: 'a@b.com', subject: 'Hi', text: 'x', organizationId: 'org_apex' }); + expect(res.status).toBe('sent'); + const row = rows.get(res.id); + expect(row?.organization_id).toBe('org_apex'); + // The stamp survives the terminal update (sent_at/status patch does not + // rewrite it). + expect(row?.status).toBe('sent'); + }); + + it('over-denial control: an org-less send writes NO organization_id and is not refused', async () => { + const transport = { send: vi.fn(async () => ({ messageId: '' })) }; + const { p, rows } = makePersistence(); + const svc = new EmailService({ transport, defaultFrom: 'no@reply.com', persistence: p }); + const res = await svc.send({ to: 'a@b.com', subject: 'Hi', text: 'x' }); + expect(res.status).toBe('sent'); + expect(rows.get(res.id)).not.toHaveProperty('organization_id'); + }); +}); diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index 326e9c16a0..e031b07a83 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -671,6 +671,11 @@ export class EmailService implements IEmailService { ...(input.relatedObject ? { related_object: input.relatedObject } : {}), ...(input.relatedId ? { related_id: input.relatedId } : {}), ...(input.sentBy ? { sent_by: input.sentBy } : {}), + // #11741 — 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. + ...(input.organizationId ? { organization_id: input.organizationId } : {}), status: 'queued', attempt_count: 0, }; @@ -1294,6 +1299,9 @@ export class EmailService implements IEmailService { ...(input.relatedObject ? { relatedObject: input.relatedObject } : {}), ...(input.relatedId ? { relatedId: input.relatedId } : {}), ...(input.sentBy ? { sentBy: input.sentBy } : {}), + // #11741 — 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 } : {}), }; return this.send(sendInput); } diff --git a/packages/plugins/plugin-email/src/send-template.test.ts b/packages/plugins/plugin-email/src/send-template.test.ts index 03e50dfdc9..1799fd6518 100644 --- a/packages/plugins/plugin-email/src/send-template.test.ts +++ b/packages/plugins/plugin-email/src/send-template.test.ts @@ -60,6 +60,51 @@ describe('EmailService.sendTemplate', () => { expect(msg.text).toContain('reset: https://x.com/r/abc'); }); + it('threads organizationId through to the sys_email row — sendTemplate is a producer of send() (#11741)', async () => { + const transport = new CaptureTransport(); + const rows = new Map>(); + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader: makeLoader([sampleTemplate]), + persistence: { + async insert(row) { rows.set(row.id, { ...row }); return { id: row.id }; }, + async update(id, patch) { + 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' }, + organizationId: 'org_apex', + }); + expect(res.status).toBe('sent'); + expect(rows.get(res.id)?.organization_id).toBe('org_apex'); + }); + + it('an org-less sendTemplate writes NO organization_id (over-denial control, #11741)', async () => { + const transport = new CaptureTransport(); + const rows = new Map>(); + const svc = new EmailService({ + transport, + defaultFrom: { address: 'no-reply@x.com' }, + templateLoader: makeLoader([sampleTemplate]), + persistence: { + async insert(row) { rows.set(row.id, { ...row }); return { id: row.id }; }, + }, + }); + const res = await svc.sendTemplate({ + template: 'auth.password_reset', + to: 'alice@x.com', + data: { user: { name: 'Alice' }, resetUrl: 'https://x.com/r/abc' }, + }); + expect(res.status).toBe('sent'); + expect(rows.get(res.id)).not.toHaveProperty('organization_id'); + }); + it('renders a datetime hole in the input reference timezone (ADR-0053 Phase 2)', async () => { const transport = new CaptureTransport(); const tpl: EmailTemplateRow = { diff --git a/packages/services/service-messaging/src/email-channel.test.ts b/packages/services/service-messaging/src/email-channel.test.ts index 5b13814c57..f531e3a209 100644 --- a/packages/services/service-messaging/src/email-channel.test.ts +++ b/packages/services/service-messaging/src/email-channel.test.ts @@ -277,4 +277,94 @@ describe('email channel', () => { expect(email.sent[0]).toEqual({ to: 'ada@example.com', subject: 'Deal closed', text: 'Acme signed' }); }); }); + + // ── #11741 — organization threading. This channel is the producer the + // ruling names as HOLDING an organization (`delivery.notification + // .organizationId`, the tenant stamp the outbox snapshots per delivery), + // so it threads that value into the email service's input on BOTH of its + // arms; plugin-email's writer then stamps `sys_email.organization_id` + // verbatim. Identity pins, not counts: each pin asserts the exact value + // THIS producer stamped. The over-denial control pins the other half of + // the ruling: a delivery genuinely without an organization sends WITHOUT + // one — never refused, never given a fabricated stamp. + describe('organization threading (#11741)', () => { + /** Email service double recording both arms' inputs. */ + function orgEmail() { + const sent: any[] = []; + const templated: any[] = []; + return { + sent, + templated, + service: { + async send(input: any) { sent.push(input); return { id: 'email_row_1' }; }, + async sendTemplate(input: any) { templated.push(input); return { id: 'email_row_9', status: 'sent' }; }, + }, + }; + } + + it('the plain arm threads notification.organizationId into SendEmailInput (identity pin)', async () => { + const email = orgEmail(); + const ch = channel(() => email.service, fakeData({ users: { user_1: 'ada@example.com' } })); + const r = await ch.send(silentCtx(), delivery({ organizationId: 'org_apex' })); + expect(r.ok).toBe(true); + expect(email.sent).toHaveLength(1); + // Exact shape: the stamp is the delivery's OWN organization, and + // nothing else about the input moved. + expect(email.sent[0]).toEqual({ + to: 'ada@example.com', + subject: 'Deal closed', + text: 'Acme signed', + organizationId: 'org_apex', + }); + }); + + it('the template arm threads notification.organizationId into sendTemplate (identity pin)', async () => { + const email = orgEmail(); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + getDefaultTemplateLocale: () => 'ja-JP', + }); + const r = await ch.send(silentCtx(), delivery({ + organizationId: 'org_apex', + title: 'deal.won', + body: '', + payload: { template: 'crm.large_deal_won', templateData: { dealName: 'Acme' } }, + })); + expect(r.ok).toBe(true); + expect(email.templated).toHaveLength(1); + expect(email.templated[0]).toEqual({ + template: 'crm.large_deal_won', + to: 'ada@example.com', + data: { dealName: 'Acme' }, + locale: 'ja-JP', + organizationId: 'org_apex', + }); + }); + + it('over-denial control: an org-less delivery sends WITHOUT an organization and is not refused (both arms)', async () => { + const email = orgEmail(); + const data = fakeData({ users: { user_1: 'ada@example.com' } }); + const ch = createEmailChannel({ + getEmail: () => email.service, + getData: () => data, + store: new NotificationTemplateStore({ getData: () => data }), + }); + const plain = await ch.send(silentCtx(), delivery()); + expect(plain.ok).toBe(true); + expect(email.sent).toHaveLength(1); + expect(email.sent[0]).not.toHaveProperty('organizationId'); + + const templated = await ch.send(silentCtx(), delivery({ + title: 'deal.won', + body: '', + payload: { template: 'crm.large_deal_won' }, + })); + expect(templated.ok).toBe(true); + expect(email.templated).toHaveLength(1); + expect(email.templated[0]).not.toHaveProperty('organizationId'); + }); + }); }); diff --git a/packages/services/service-messaging/src/email-channel.ts b/packages/services/service-messaging/src/email-channel.ts index 2283d4f9a6..75ac110d88 100644 --- a/packages/services/service-messaging/src/email-channel.ts +++ b/packages/services/service-messaging/src/email-channel.ts @@ -30,6 +30,13 @@ export interface EmailSenderSurface { subject: string; html?: string; text?: string; + /** + * Structural mirror of `SendEmailInput.organizationId` (#11741) — + * the tenant stamp for `sys_email.organization_id`, threaded from + * `delivery.notification.organizationId` when the delivery holds + * one. Optional: an org-less delivery sends without it. + */ + organizationId?: string; }): Promise<{ id?: string } | unknown>; /** * Structural mirror of `IEmailService.sendTemplate` (#9205) — resolves a @@ -45,6 +52,8 @@ export interface EmailSenderSurface { to: string | string[]; data?: Record; locale?: string; + /** Same tenant stamp as on `send` (#11741) — forwarded by the email service into the send it performs. */ + organizationId?: string; }): Promise<{ id?: string; status?: string; error?: string } | unknown>; /** * Structural mirror of `IEmailService.renderTemplate` (#9225) — resolves a @@ -190,6 +199,11 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel to: address, ...(data !== undefined ? { data } : {}), ...(templateLocale ? { locale: templateLocale } : {}), + // #11741 — this channel HOLDS the organization (the + // tenant stamp the outbox snapshots per delivery), so + // it threads it for the sys_email.organization_id + // stamp. Absent stays absent — never fabricated. + ...(n.organizationId ? { organizationId: n.organizationId } : {}), })) as { id?: unknown; status?: unknown; error?: unknown } | undefined; // `IEmailService.send` reports transport failure as // `status: 'failed'` rather than throwing — surface it. @@ -222,6 +236,8 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel subject: rendered.subject, ...(rendered.html !== undefined ? { html: rendered.html } : {}), ...(rendered.text !== undefined ? { text: rendered.text } : {}), + // #11741 — same threading as the template arm above. + ...(n.organizationId ? { organizationId: n.organizationId } : {}), }); const id = result?.id; return { ok: true, externalId: id != null ? String(id) : undefined }; diff --git a/packages/spec/src/contracts/email-service.test.ts b/packages/spec/src/contracts/email-service.test.ts new file mode 100644 index 0000000000..0a9c352f5b --- /dev/null +++ b/packages/spec/src/contracts/email-service.test.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import type { SendEmailInput, SendTemplateInput } from './email-service'; + +/** + * #11741 (Decision 2 of #11303) — `SendEmailInput` is widened with an + * OPTIONAL `organizationId` so producers that already hold an organization + * can thread it to `plugin-email`'s writer, which stamps + * `sys_email.organization_id` verbatim. + * + * Two contract facts pinned here: + * - the pre-widening shape stays legal byte-identically — a caller that has + * no organization (auth verification / password-reset mail) passes the + * same object it always passed and is not refused; + * - the widened shape carries `organizationId` as a plain optional string on + * BOTH `SendEmailInput` and `SendTemplateInput` (the template entry point + * forwards it into the send it performs). + */ +describe('Email Service Contract — organization widening (#11741)', () => { + it('accepts the pre-widening SendEmailInput shape unchanged (organizationId optional, absent legal)', () => { + const legacy: SendEmailInput = { to: 'a@b.com', subject: 'Hi', text: 'x' }; + expect(legacy).not.toHaveProperty('organizationId'); + expect(legacy.organizationId).toBeUndefined(); + }); + + it('accepts a SendEmailInput carrying organizationId (the sys_email.organization_id pass-through stamp)', () => { + const widened: SendEmailInput = { + to: 'a@b.com', + subject: 'Hi', + text: 'x', + organizationId: 'org_apex', + }; + expect(widened.organizationId).toBe('org_apex'); + }); + + it('accepts a SendTemplateInput carrying organizationId (forwarded to the underlying send)', () => { + const legacy: SendTemplateInput = { template: 'auth.password_reset', to: 'a@b.com' }; + expect(legacy.organizationId).toBeUndefined(); + + const widened: SendTemplateInput = { + template: 'auth.password_reset', + to: 'a@b.com', + organizationId: 'org_apex', + }; + expect(widened.organizationId).toBe('org_apex'); + }); +}); diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 97b1391499..375d775487 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -67,6 +67,22 @@ export interface SendEmailInput { relatedId?: string; /** User id for `sent_by` audit linkage. */ sentBy?: string; + /** + * Organization (tenant) id stamped verbatim onto the persisted + * `sys_email.organization_id` (#11741, Decision 2 of #11303). + * + * Pass-through only: the email service's writer runs under a constant + * system context and MUST NOT resolve or fabricate an organization — a + * wrong `organization_id` is silently authoritative to every report, + * 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 + * backfilled. + */ + organizationId?: string; } /** @@ -184,6 +200,16 @@ export interface SendTemplateInput { relatedId?: string; /** User id for `sent_by` audit linkage. */ sentBy?: string; + /** + * Organization (tenant) id forwarded into the {@link SendEmailInput} this + * template send performs, and stamped from there onto + * `sys_email.organization_id` (#11741). Same contract as + * {@link SendEmailInput.organizationId}: pass-through only, optional, + * absent stays legal. Distinct from {@link SendTemplateInput.org}, which + * addresses template org-overlay *resolution*, not the delivery row's + * tenant stamp. + */ + organizationId?: string; } /**