diff --git a/MIGRATIONS.md b/MIGRATIONS.md
index 7a9ddf3dd..d4ff9e9a6 100644
--- a/MIGRATIONS.md
+++ b/MIGRATIONS.md
@@ -4,6 +4,24 @@ Breaking changes and upgrade notes for downstream projects.
---
+## Organizations: welcome email now sent after signup provisioning (2026-09-25)
+
+Every successful signup that provisions a workspace — organizations enabled or
+disabled — now fires a fire-and-forget `welcome` email to the new user. Never
+sent on the A4 convergence path (existing membership) and never on the manual
+"create another org" flow, so it fires exactly once per real new workspace.
+Gated on `mailer.isConfigured()`: a disabled mailer, a synchronous throw, or a
+rejected send can never break or delay the signup / OAuth redirect response.
+
+**What you will see:** every new signup receives a welcome email once the
+mailer is configured. To opt out, set `organizations.welcomeEmail.enabled:
+false` in the project config (default `true`, fail-open — read with `?? true`
+so an absent key never silently disables it). To customize the copy, edit the
+same-named template at `config/templates/welcome.html` (`{{displayName}}`,
+`{{appName}}`, `{{url}}`, `{{appContact}}`, and an optional
+`{{#if orgName}}` block, omitted in B2C mode). No schema change, no migration
+to run.
+
## Billing: public plans listing now requires a plan tag (2026-09-25)
`GET /api/billing/plans` no longer falls back to a Stripe product's raw id when
diff --git a/config/templates/welcome.html b/config/templates/welcome.html
new file mode 100644
index 000000000..856bd3f21
--- /dev/null
+++ b/config/templates/welcome.html
@@ -0,0 +1,43 @@
+
+
+
+ {{appName}}
+
+
+
+ Hello {{displayName}},
+
+ Welcome to {{appName}}{{#if orgName}} — your workspace {{orgName}} is ready{{/if}}.
+
+ Here's where to pick things up:
+
+ Button not working? {{url}}
+ The {{appName}} Team.
+
+ Please do not reply to this email, you can contact us here.
+
+
diff --git a/modules/organizations/config/organizations.development.config.js b/modules/organizations/config/organizations.development.config.js
index 636cfecd2..d89608f54 100644
--- a/modules/organizations/config/organizations.development.config.js
+++ b/modules/organizations/config/organizations.development.config.js
@@ -17,6 +17,10 @@ const config = {
// is always auto-provisioned (same path as a mailer-not-configured env).
// emailVerified stays server-only; this policy only gates the existing checks.
emailVerification: { mode: 'strict' },
+ // Fail-open: read with `?? true` at the call site so an absent key (a
+ // downstream project that predates this option) never silently disables
+ // the welcome email — only an explicit `false` does.
+ welcomeEmail: { enabled: true },
roles: ['owner', 'admin', 'member'],
roleDescriptions: {
owner: 'Full control — manage organization settings, members, roles, and billing.',
diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js
index d4246280f..35ab9af3a 100644
--- a/modules/organizations/services/organizations.service.js
+++ b/modules/organizations/services/organizations.service.js
@@ -6,6 +6,7 @@ import mailer from '../../../lib/helpers/mailer/index.js';
import logger from '../../../lib/services/logger.js';
import policy from '../../../lib/middlewares/policy.js';
import serializeAbilities from '../../../lib/helpers/abilities.js';
+import getBaseUrl from '../../../lib/helpers/getBaseUrl.js';
import OrganizationsRepository from '../repositories/organizations.repository.js';
import MembershipRepository from '../repositories/organizations.membership.repository.js';
import UserService from '../../users/services/users.service.js';
@@ -118,6 +119,57 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat
throw new Error('Failed to create organization: slug conflict after maximum retries');
};
+/**
+ * Fire-and-forget welcome email sent once a fresh signup provisions a real
+ * workspace. Called from BOTH create branches of `handleSignupOrganization`
+ * (organizations enabled or disabled) — never from the A4 convergence path
+ * (existing membership, an early return above those branches) and never from
+ * the manual "create another org" flow (`organizations.crud.service.js`), so
+ * it fires exactly once per real new workspace with no dedup field needed.
+ * With strict email verification the send happens naturally after
+ * verification, since provisioning itself is deferred until then.
+ *
+ * Gated on `config.organizations.welcomeEmail.enabled` (default `true`,
+ * fail-open — read with `?? true` so an absent key on a downstream project
+ * never silently disables the email) and `mailer.isConfigured()`. Never
+ * awaited by the caller: a disabled toggle, a disabled mailer, a synchronous
+ * throw, or a rejected send must never break or delay the signup / OAuth
+ * redirect response.
+ * @param {Object} user - The newly signed-up user (id/_id, email, firstName, lastName).
+ * @param {string} [orgName] - Organization display name. Omitted in B2C mode
+ * (organizations disabled) — the template must not require it.
+ * @param {string} [orgId] - Organization id, for failure logging only (never
+ * passed to the template) — always in scope, including B2C mode.
+ * @returns {void}
+ */
+const sendWelcomeEmail = (user, orgName, orgId) => {
+ if (!(config.organizations?.welcomeEmail?.enabled ?? true)) return;
+ if (!mailer.isConfigured()) return;
+ const userId = user.id || user._id;
+ const onError = (err) => logger.warn('organizations: welcome email failed', {
+ userId: userId ? String(userId) : undefined,
+ ...(orgId ? { orgId: String(orgId) } : {}),
+ message: err?.message,
+ stack: err?.stack,
+ });
+ try {
+ mailer.sendMail({
+ template: 'welcome',
+ to: user.email,
+ subject: `Welcome to ${config.app.title}`,
+ params: {
+ displayName: [user.firstName, user.lastName].filter(Boolean).join(' '),
+ url: getBaseUrl(),
+ appName: config.app.title,
+ appContact: config.app.contact,
+ ...(orgName ? { orgName } : {}),
+ },
+ }).catch(onError);
+ } catch (err) {
+ onError(err);
+ }
+};
+
/**
* Handle organization provisioning during the signup flow.
*
@@ -230,7 +282,10 @@ const handleSignupOrganization = async (user) => {
});
emitProvisioned(organization);
- return buildResult(organization, membership);
+ const result = await buildResult(organization, membership);
+ // B2C mode — the workspace is a hidden default, never named to the user.
+ sendWelcomeEmail(user, undefined, organization._id);
+ return result;
}
// Case 2: Organizations enabled — always provision a workspace for the user.
@@ -285,8 +340,10 @@ const handleSignupOrganization = async (user) => {
});
emitProvisioned(organization);
+ const result = await buildResult(organization, membership);
+ sendWelcomeEmail(user, organization.name, organization._id);
return {
- ...(await buildResult(organization, membership)),
+ ...result,
...(suggestedJoin ? { suggestedJoin } : {}),
};
};
diff --git a/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js b/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js
index c8ba5aa9f..718c9f7ac 100644
--- a/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js
+++ b/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js
@@ -38,7 +38,7 @@ jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
const mockIsConfigured = jest.fn();
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
- default: { isConfigured: mockIsConfigured, sendMail: jest.fn() },
+ default: { isConfigured: mockIsConfigured, sendMail: jest.fn().mockResolvedValue(null) },
}));
const mockOrganizationsRepositoryCreate = jest.fn();
diff --git a/modules/organizations/tests/organizations.emailVerification.unit.tests.js b/modules/organizations/tests/organizations.emailVerification.unit.tests.js
index 1eeb74665..f97101964 100644
--- a/modules/organizations/tests/organizations.emailVerification.unit.tests.js
+++ b/modules/organizations/tests/organizations.emailVerification.unit.tests.js
@@ -21,7 +21,7 @@ jest.unstable_mockModule('../lib/events.js', () => ({
const mockIsConfigured = jest.fn();
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
- default: { isConfigured: mockIsConfigured, sendMail: jest.fn() },
+ default: { isConfigured: mockIsConfigured, sendMail: jest.fn().mockResolvedValue(null) },
}));
const mockOrganizationsRepositoryCreate = jest.fn();
diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js
index 555407c7b..55341aafe 100644
--- a/modules/organizations/tests/organizations.service.signup.unit.tests.js
+++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js
@@ -19,7 +19,7 @@ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
const mockIsConfigured = jest.fn().mockReturnValue(false);
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
- default: { isConfigured: mockIsConfigured },
+ default: { isConfigured: mockIsConfigured, sendMail: jest.fn().mockResolvedValue(null) },
}));
const mockOrgCreate = jest.fn();
diff --git a/modules/organizations/tests/organizations.service.silent.catch.unit.tests.js b/modules/organizations/tests/organizations.service.silent.catch.unit.tests.js
index e1ac7a928..0ce9b454f 100644
--- a/modules/organizations/tests/organizations.service.silent.catch.unit.tests.js
+++ b/modules/organizations/tests/organizations.service.silent.catch.unit.tests.js
@@ -56,7 +56,7 @@ jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
}));
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
- default: { isConfigured: jest.fn().mockReturnValue(false) },
+ default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn().mockResolvedValue(null) },
}));
jest.unstable_mockModule('../../../config/index.js', () => ({
diff --git a/modules/organizations/tests/organizations.service.welcomeEmail.unit.tests.js b/modules/organizations/tests/organizations.service.welcomeEmail.unit.tests.js
new file mode 100644
index 000000000..92f3e7d13
--- /dev/null
+++ b/modules/organizations/tests/organizations.service.welcomeEmail.unit.tests.js
@@ -0,0 +1,259 @@
+/**
+ * Unit tests — welcome email after signup (Node#4116).
+ *
+ * Contract (see `sendWelcomeEmail` in organizations.service.js):
+ * - Sent from BOTH create branches of `handleSignupOrganization` (organizations
+ * enabled or disabled) — exactly once per real new workspace.
+ * - NEVER sent on the A4 convergence path (existing active membership).
+ * - Gated on `config.organizations.welcomeEmail.enabled` (default true) AND
+ * `mailer.isConfigured()` — either gate off means no send.
+ * - B2C mode (organizations disabled) omits `orgName` from the template params.
+ * - Fire-and-forget: a rejecting send, or a send call that doesn't even return
+ * a promise, must never break the signup flow.
+ */
+import mongoose from 'mongoose';
+import { jest, describe, test, expect, beforeEach } from '@jest/globals';
+
+// --- Mocks (must precede dynamic imports) ---
+
+const mockIsConfigured = jest.fn().mockReturnValue(true);
+const mockSendMail = jest.fn().mockResolvedValue({ accepted: ['a@b.com'], rejected: [] });
+jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
+ default: { isConfigured: mockIsConfigured, sendMail: mockSendMail },
+}));
+
+const mockOrgCreate = jest.fn();
+const mockOrgList = jest.fn().mockResolvedValue([]);
+const mockOrgExists = jest.fn().mockResolvedValue(false);
+jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({
+ default: {
+ create: mockOrgCreate,
+ list: mockOrgList,
+ exists: mockOrgExists,
+ remove: jest.fn().mockResolvedValue({}),
+ },
+}));
+
+const mockMembershipCreate = jest.fn();
+const mockMembershipFindOne = jest.fn().mockResolvedValue(null);
+jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({
+ default: {
+ create: mockMembershipCreate,
+ deleteMany: jest.fn().mockResolvedValue({}),
+ list: jest.fn().mockResolvedValue([]),
+ findOne: mockMembershipFindOne,
+ },
+}));
+
+const mockUpdateById = jest.fn().mockResolvedValue({});
+jest.unstable_mockModule('../../users/services/users.service.js', () => ({
+ default: { updateById: mockUpdateById },
+}));
+
+const mockDefineAbilityFor = jest.fn().mockResolvedValue({ rules: [] });
+jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
+ default: { defineAbilityFor: mockDefineAbilityFor },
+}));
+
+jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
+ default: jest.fn().mockReturnValue(['ability-stub']),
+}));
+
+jest.unstable_mockModule('../helpers/organizations.slug.js', () => ({
+ /**
+ * Lowercase and hyphenate a string for use as a slug (test stub).
+ * @param {string} str - The string to slugify.
+ * @returns {string} The slugified string.
+ */
+ slugify: (str) => str.toLowerCase().replace(/\s+/g, '-'),
+ generateOrganizationSlug: jest.fn().mockResolvedValue('alice-org'),
+}));
+
+const mockLoggerWarn = jest.fn();
+jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
+ default: { error: jest.fn(), warn: mockLoggerWarn, info: jest.fn() },
+}));
+
+jest.unstable_mockModule('../lib/events.js', () => ({
+ default: { emit: jest.fn(), on: jest.fn() },
+}));
+
+// Config store — MUST be mutated in-place (jest.unstable_mockModule captures the
+// default export value at import time; reassigning the variable breaks the binding).
+const configStore = { organizations: {}, app: { title: 'Acme App', contact: 'hi@acme.test' }, cors: { origin: 'https://app.acme.test' } };
+jest.unstable_mockModule('../../../config/index.js', () => ({
+ default: configStore,
+}));
+
+// --- Dynamic import after all mocks ---
+const { default: OrganizationsService } = await import('../services/organizations.service.js');
+
+/**
+ * Configure config mock and repository happy-path defaults for a fresh signup.
+ * Must mutate configStore's `organizations` key in-place.
+ * @param {Object} orgConfig - `config.organizations` values.
+ * @returns {Object} The fake organization document `OrganizationsRepository.create` resolves to.
+ */
+function setupConfig(orgConfig) {
+ configStore.organizations = { publicDomains: [], ...orgConfig };
+ const fakeOrg = {
+ _id: new mongoose.Types.ObjectId(),
+ name: 'Acme Corp',
+ slug: 'acme',
+ domain: '',
+ plan: 'free',
+ /**
+ * Serialize the fake organization to its public JSON shape (test stub).
+ * @returns {{_id: import('mongoose').Types.ObjectId, name: string}} The serialized organization.
+ */
+ toJSON() {
+ return { _id: this._id, name: this.name };
+ },
+ };
+ mockOrgCreate.mockResolvedValue(fakeOrg);
+ mockMembershipCreate.mockResolvedValue({ _id: new mongoose.Types.ObjectId(), role: 'owner' });
+ return fakeOrg;
+}
+
+/**
+ * Build a minimal user object for testing.
+ * @param {string} email
+ * @returns {Object}
+ */
+function makeUser(email = 'alice@example.com') {
+ return {
+ id: new mongoose.Types.ObjectId().toString(),
+ _id: new mongoose.Types.ObjectId().toString(),
+ email,
+ firstName: 'Alice',
+ lastName: 'Smith',
+ emailVerified: true,
+ };
+}
+
+describe('handleSignupOrganization — welcome email (Node#4116):', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIsConfigured.mockReturnValue(true);
+ mockSendMail.mockResolvedValue({ accepted: ['a@b.com'], rejected: [] });
+ mockOrgExists.mockResolvedValue(false);
+ mockOrgList.mockResolvedValue([]);
+ mockMembershipFindOne.mockResolvedValue(null);
+ mockUpdateById.mockResolvedValue({});
+ mockDefineAbilityFor.mockResolvedValue({ rules: [] });
+ });
+
+ test('sent once on a fresh create, orgs enabled — includes orgName', async () => {
+ const fakeOrg = setupConfig({ enabled: true, autoCreate: false, domainMatching: false });
+ const user = makeUser('alice@corp.example.com');
+
+ const result = await OrganizationsService.handleSignupOrganization(user);
+
+ expect(result.organization).not.toBeNull();
+ expect(mockSendMail).toHaveBeenCalledTimes(1);
+ expect(mockSendMail).toHaveBeenCalledWith({
+ template: 'welcome',
+ to: user.email,
+ subject: 'Welcome to Acme App',
+ params: {
+ displayName: 'Alice Smith',
+ url: 'https://app.acme.test',
+ appName: 'Acme App',
+ appContact: 'hi@acme.test',
+ orgName: fakeOrg.name,
+ },
+ });
+ });
+
+ test('sent once on a fresh create, orgs disabled (B2C) — no orgName in params', async () => {
+ setupConfig({ enabled: false });
+ const user = makeUser('bob@example.com');
+
+ const result = await OrganizationsService.handleSignupOrganization(user);
+
+ expect(result.organization).not.toBeNull();
+ expect(mockSendMail).toHaveBeenCalledTimes(1);
+ const { params } = mockSendMail.mock.calls[0][0];
+ expect(params).not.toHaveProperty('orgName');
+ });
+
+ test('NOT sent on the A4 convergence path (existing active membership)', async () => {
+ setupConfig({ enabled: true });
+ const existingOrg = { _id: new mongoose.Types.ObjectId(), name: 'Existing Org' };
+ mockMembershipFindOne.mockResolvedValue({ _id: new mongoose.Types.ObjectId(), role: 'owner', status: 'active', organizationId: existingOrg });
+ const user = makeUser('carol@example.com');
+
+ const result = await OrganizationsService.handleSignupOrganization(user);
+
+ expect(result.organization).toBe(existingOrg);
+ expect(mockOrgCreate).not.toHaveBeenCalled();
+ expect(mockSendMail).not.toHaveBeenCalled();
+ });
+
+ test('NOT sent when config.organizations.welcomeEmail.enabled is false', async () => {
+ setupConfig({ enabled: true, welcomeEmail: { enabled: false } });
+ const user = makeUser('dave@example.com');
+
+ const result = await OrganizationsService.handleSignupOrganization(user);
+
+ expect(result.organization).not.toBeNull();
+ expect(mockOrgCreate).toHaveBeenCalled();
+ expect(mockSendMail).not.toHaveBeenCalled();
+ });
+
+ test('NOT sent when the mailer is not configured — signup still succeeds', async () => {
+ setupConfig({ enabled: true });
+ mockIsConfigured.mockReturnValue(false);
+ const user = makeUser('erin@example.com');
+
+ const result = await OrganizationsService.handleSignupOrganization(user);
+
+ expect(result.organization).not.toBeNull();
+ expect(mockSendMail).not.toHaveBeenCalled();
+ });
+
+ test('a rejecting sendMail does not break signup — failure is traceable (userId + orgId)', async () => {
+ const fakeOrg = setupConfig({ enabled: true });
+ mockSendMail.mockRejectedValueOnce(new Error('smtp down'));
+ const user = makeUser('frank@example.com');
+
+ const result = await OrganizationsService.handleSignupOrganization(user);
+ // Flush the fire-and-forget promise chain so its .catch() runs before we assert.
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(result.organization).not.toBeNull();
+ expect(mockSendMail).toHaveBeenCalledTimes(1);
+ expect(mockLoggerWarn).toHaveBeenCalledWith('organizations: welcome email failed', expect.objectContaining({
+ message: 'smtp down',
+ userId: user.id,
+ orgId: String(fakeOrg._id),
+ }));
+ });
+
+ test('a rejecting sendMail in B2C mode still logs orgId (hidden default org is in scope)', async () => {
+ const fakeOrg = setupConfig({ enabled: false });
+ mockSendMail.mockRejectedValueOnce(new Error('smtp down'));
+ const user = makeUser('heidi@example.com');
+
+ const result = await OrganizationsService.handleSignupOrganization(user);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(result.organization).not.toBeNull();
+ expect(mockLoggerWarn).toHaveBeenCalledWith('organizations: welcome email failed', expect.objectContaining({
+ userId: user.id,
+ orgId: String(fakeOrg._id),
+ }));
+ });
+
+ test('a sendMail call that does not return a promise does not break signup', async () => {
+ setupConfig({ enabled: true });
+ mockSendMail.mockReturnValueOnce(undefined);
+ const user = makeUser('grace@example.com');
+
+ const result = await OrganizationsService.handleSignupOrganization(user);
+
+ expect(result.organization).not.toBeNull();
+ expect(mockSendMail).toHaveBeenCalledTimes(1);
+ expect(mockLoggerWarn).toHaveBeenCalledWith('organizations: welcome email failed', expect.anything());
+ });
+});