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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/sendemail-organization-id.md
Original file line numberDiff line numberDiff line change
@@ -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.
28 changes: 28 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>]>)[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<string, unknown>]>)[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',
Expand Down
6 changes: 6 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
43 changes: 43 additions & 0 deletions packages/plugins/plugin-email/src/email-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, Record<string, any>>();
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: '<m1@x>' })) };
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: '<m2@x>' })) };
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');
});
});
8 changes: 8 additions & 0 deletions packages/plugins/plugin-email/src/email-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
};
Expand DownExpand Up@@ -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);
}
Expand Down
45 changes: 45 additions & 0 deletions packages/plugins/plugin-email/src/send-template.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, Record<string, any>>();
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<string, Record<string, any>>();
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 = {
Expand Down
90 changes: 90 additions & 0 deletions packages/services/service-messaging/src/email-channel.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
});
});
});
16 changes: 16 additions & 0 deletions packages/services/service-messaging/src/email-channel.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -45,6 +52,8 @@ export interface EmailSenderSurface {
to: string | string[];
data?: Record<string, unknown>;
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
Expand DownExpand Up@@ -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.
Expand DownExpand Up@@ -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 };
Expand Down
48 changes: 48 additions & 0 deletions packages/spec/src/contracts/email-service.test.ts
Original file line numberDiff line numberDiff line change
@@ -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');
});
});
Loading
Loading