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:

+ + + + +
+ Get started +
+

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()); + }); +});