@open-mercato/core 0.7.1-develop.7150.1.c1941e0c22 → 0.7.1-develop.7152.1.a69e92f9c9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +77 -1
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/helpers/integration/crmFixtures.js +3 -0
  5. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  6. package/dist/modules/auth/api/reset.js +7 -1
  7. package/dist/modules/auth/api/reset.js.map +2 -2
  8. package/dist/modules/auth/api/users/resend-invite/route.js +7 -1
  9. package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
  10. package/dist/modules/auth/commands/users.js +7 -1
  11. package/dist/modules/auth/commands/users.js.map +2 -2
  12. package/dist/modules/communication_channels/api/post/test-seed/route.js +101 -26
  13. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +3 -3
  14. package/dist/modules/communication_channels/di.js +8 -0
  15. package/dist/modules/communication_channels/di.js.map +2 -2
  16. package/dist/modules/communication_channels/lib/ensure-system-email-channel.js +57 -0
  17. package/dist/modules/communication_channels/lib/ensure-system-email-channel.js.map +7 -0
  18. package/dist/modules/communication_channels/lib/system-email-provider-config.js +32 -0
  19. package/dist/modules/communication_channels/lib/system-email-provider-config.js.map +7 -0
  20. package/dist/modules/communication_channels/lib/system-email.js +190 -0
  21. package/dist/modules/communication_channels/lib/system-email.js.map +7 -0
  22. package/dist/modules/communication_channels/lib/test-seed.js +130 -1
  23. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  24. package/dist/modules/communication_channels/setup.js +56 -1
  25. package/dist/modules/communication_channels/setup.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/admin/users-invite.js +1 -0
  27. package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/portal/users-invite.js +1 -0
  29. package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/signup.js +8 -4
  31. package/dist/modules/customer_accounts/api/signup.js.map +2 -2
  32. package/dist/modules/customer_accounts/lib/invitationEmail.js +3 -1
  33. package/dist/modules/customer_accounts/lib/invitationEmail.js.map +2 -2
  34. package/dist/modules/customers/api/interactions/route.js +3 -0
  35. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  36. package/dist/modules/messages/lib/email-sender.js +16 -14
  37. package/dist/modules/messages/lib/email-sender.js.map +2 -2
  38. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +3 -1
  39. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +2 -2
  40. package/dist/modules/sales/api/quotes/accept/route.js +3 -1
  41. package/dist/modules/sales/api/quotes/accept/route.js.map +2 -2
  42. package/dist/modules/sales/api/quotes/send/route.js +13 -4
  43. package/dist/modules/sales/api/quotes/send/route.js.map +3 -3
  44. package/package.json +7 -7
  45. package/src/helpers/integration/communicationChannelsFixtures.ts +129 -0
  46. package/src/helpers/integration/crmFixtures.ts +4 -1
  47. package/src/modules/auth/api/reset.ts +7 -1
  48. package/src/modules/auth/api/users/resend-invite/route.ts +7 -1
  49. package/src/modules/auth/commands/users.ts +7 -1
  50. package/src/modules/auth/i18n/de.json +4 -0
  51. package/src/modules/auth/i18n/en.json +4 -0
  52. package/src/modules/auth/i18n/es.json +4 -0
  53. package/src/modules/auth/i18n/ko.json +4 -0
  54. package/src/modules/auth/i18n/pl.json +4 -0
  55. package/src/modules/communication_channels/api/post/test-seed/route.ts +121 -29
  56. package/src/modules/communication_channels/di.ts +8 -0
  57. package/src/modules/communication_channels/lib/ensure-system-email-channel.ts +85 -0
  58. package/src/modules/communication_channels/lib/system-email-provider-config.ts +62 -0
  59. package/src/modules/communication_channels/lib/system-email.ts +316 -0
  60. package/src/modules/communication_channels/lib/test-seed.ts +198 -0
  61. package/src/modules/communication_channels/setup.ts +91 -1
  62. package/src/modules/customer_accounts/api/admin/users-invite.ts +1 -0
  63. package/src/modules/customer_accounts/api/portal/users-invite.ts +1 -0
  64. package/src/modules/customer_accounts/api/signup.ts +6 -2
  65. package/src/modules/customer_accounts/lib/invitationEmail.ts +3 -0
  66. package/src/modules/customers/api/interactions/route.ts +4 -0
  67. package/src/modules/messages/lib/email-sender.ts +18 -16
  68. package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +3 -1
  69. package/src/modules/sales/api/quotes/accept/route.ts +2 -0
  70. package/src/modules/sales/api/quotes/send/route.ts +16 -3
@@ -22,8 +22,12 @@ import { emitCommunicationChannelsEvent } from '../../../events'
22
22
  import {
23
23
  TEST_SEED_CHAT_PROVIDER_KEY,
24
24
  TEST_SEED_PROVIDER_KEY,
25
+ clearTestSeedCapturedMessages,
26
+ createTestSeedPlatformMessage,
25
27
  ensureTestSeedAdapterRegistered,
28
+ isTestEmailCaptureAccessAuthorized,
26
29
  isTestChannelSeedingEnabled,
30
+ listTestSeedCapturedMessages,
27
31
  } from '../../../lib/test-seed'
28
32
  import {
29
33
  COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,
@@ -135,6 +139,24 @@ const ingestInboundSchema = z.object({
135
139
  externalConversationId: z.string().min(1).max(255),
136
140
  })
137
141
 
142
+ const seedSystemChannelSchema = z.object({
143
+ action: z.literal('seed-system-channel'),
144
+ displayName: z.string().min(1).max(255).optional(),
145
+ externalIdentifier: z.string().min(1).max(255).optional(),
146
+ })
147
+
148
+ const clearCaptureSchema = z.object({
149
+ action: z.literal('clear-capture'),
150
+ systemRecipient: z.string().email().max(320).optional(),
151
+ captureCorrelationToken: z.string().min(32).max(512).optional(),
152
+ })
153
+
154
+ const listCaptureSchema = z.object({
155
+ action: z.literal('list-capture'),
156
+ systemRecipient: z.string().email().max(320).optional(),
157
+ captureCorrelationToken: z.string().min(32).max(512).optional(),
158
+ })
159
+
138
160
  const emitInboundSchema = z.object({
139
161
  action: z.literal('emit-inbound'),
140
162
  /** Channel that owns the inbound message; controls authorUserId + default visibility. */
@@ -172,6 +194,9 @@ const bodySchema = z.discriminatedUnion('action', [
172
194
  connectChannelSchema,
173
195
  ingestInboundSchema,
174
196
  emitInboundSchema,
197
+ seedSystemChannelSchema,
198
+ clearCaptureSchema,
199
+ listCaptureSchema,
175
200
  ])
176
201
 
177
202
  export async function POST(req: Request): Promise<Response> {
@@ -196,14 +221,97 @@ export async function POST(req: Request): Promise<Response> {
196
221
  )
197
222
  }
198
223
 
224
+ const tenantId = auth.tenantId as string
225
+ const organizationId = (auth as { orgId?: string | null }).orgId ?? null
226
+ const userId = auth.sub as string
227
+ const captureScope = { tenantId, organizationId }
228
+
229
+ if (
230
+ (body.action === 'clear-capture' || body.action === 'list-capture')
231
+ && body.systemRecipient
232
+ && (
233
+ !body.captureCorrelationToken
234
+ || !isTestEmailCaptureAccessAuthorized(req.headers.get('x-om-test-email-capture-access-token'))
235
+ )
236
+ ) {
237
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
238
+ }
239
+
240
+ if (body.action === 'clear-capture') {
241
+ await clearTestSeedCapturedMessages(captureScope, {
242
+ systemRecipient: body.systemRecipient,
243
+ captureCorrelationToken: body.captureCorrelationToken,
244
+ })
245
+ return NextResponse.json({ ok: true })
246
+ }
247
+
248
+ if (body.action === 'list-capture') {
249
+ return NextResponse.json({
250
+ items: (await listTestSeedCapturedMessages(captureScope, {
251
+ systemRecipient: body.systemRecipient,
252
+ captureCorrelationToken: body.captureCorrelationToken,
253
+ })).map(({ captureCorrelationToken: _captureCorrelationToken, ...message }) => message),
254
+ })
255
+ }
256
+
199
257
  const container = await createRequestContainer()
200
258
  // Defensive: make sure the stub adapter is registered for this process even if
201
259
  // a worker-only node skipped module di registration.
202
260
  ensureTestSeedAdapterRegistered()
203
261
 
204
- const tenantId = auth.tenantId as string
205
- const organizationId = (auth as { orgId?: string | null }).orgId ?? null
206
- const userId = auth.sub as string
262
+ if (body.action === 'seed-system-channel') {
263
+ const em = (container.resolve('em') as EntityManager).fork()
264
+ const credentialsService = container.resolve('integrationCredentialsService') as {
265
+ save: (
266
+ integrationId: string,
267
+ credentials: Record<string, unknown>,
268
+ scope: { organizationId: string; tenantId: string; userId?: string | null },
269
+ ) => Promise<void>
270
+ }
271
+ await credentialsService.save(
272
+ `channel_${TEST_SEED_PROVIDER_KEY}`,
273
+ { testSeed: true },
274
+ { tenantId, organizationId: organizationId ?? tenantId, userId: null },
275
+ )
276
+ let channel = await findOneWithDecryption(
277
+ em,
278
+ CommunicationChannel,
279
+ {
280
+ providerKey: TEST_SEED_PROVIDER_KEY,
281
+ channelType: 'email',
282
+ tenantId,
283
+ organizationId,
284
+ userId: null,
285
+ deletedAt: null,
286
+ },
287
+ undefined,
288
+ { tenantId, organizationId },
289
+ )
290
+ if (!channel) {
291
+ const stamp = Date.now()
292
+ channel = em.create(CommunicationChannel, {
293
+ providerKey: TEST_SEED_PROVIDER_KEY,
294
+ channelType: 'email',
295
+ displayName: body.displayName ?? `Test Seed System Email ${stamp}`,
296
+ externalIdentifier: body.externalIdentifier ?? `system-${stamp}@test-seed.local`,
297
+ userId: null,
298
+ isPrimary: false,
299
+ isActive: true,
300
+ status: 'connected',
301
+ tenantId,
302
+ organizationId,
303
+ })
304
+ em.persist(channel)
305
+ await em.flush()
306
+ } else if (!channel.isActive || channel.status !== 'connected') {
307
+ channel.isActive = true
308
+ channel.status = 'connected'
309
+ channel.lastError = null
310
+ await em.flush()
311
+ }
312
+
313
+ return NextResponse.json({ channelId: channel.id }, { status: 201 })
314
+ }
207
315
 
208
316
  if (body.action === 'connect-channel') {
209
317
  const stamp = Date.now()
@@ -424,32 +532,16 @@ export async function POST(req: Request): Promise<Response> {
424
532
  em.persist(conversation)
425
533
  await em.flush()
426
534
 
427
- // Insert the platform `messages.message` row via raw SQL rather than importing
428
- // the messages module's entity class (cross-module ORM coupling rule). Only
429
- // `thread_id` matters for the hub-thread inheritance join (TC-CRM-EMAIL-005);
430
- // the rest satisfy NOT NULL constraints.
431
- const messageRows = (await em.getConnection().execute(
432
- `INSERT INTO messages
433
- (type, thread_id, sender_user_id, subject, body, body_format, priority, status,
434
- is_draft, sent_at, visibility, source_entity_type, source_entity_id,
435
- tenant_id, organization_id, created_at, updated_at)
436
- VALUES
437
- (?, ?, ?, ?, ?, 'text', 'normal', 'sent',
438
- false, now(), 'public', 'communication_channels.test_seed_inbound', ?,
439
- ?, ?, now(), now())
440
- RETURNING id`,
441
- [
442
- `channel.${providerKey}`,
443
- body.messageThreadId ?? null,
444
- userId,
445
- body.subject ?? '(no subject)',
446
- body.bodyText ?? '',
447
- body.channelId,
448
- tenantId,
449
- organizationId,
450
- ],
451
- )) as Array<{ id: string }>
452
- const messageId = messageRows[0]?.id
535
+ const messageId = await createTestSeedPlatformMessage(em, {
536
+ providerKey,
537
+ threadId: body.messageThreadId,
538
+ senderUserId: userId,
539
+ subject: body.subject,
540
+ bodyText: body.bodyText,
541
+ channelId: body.channelId,
542
+ tenantId,
543
+ organizationId,
544
+ })
453
545
  if (!messageId) {
454
546
  return NextResponse.json({ error: '[internal] failed to seed message row' }, { status: 500 })
455
547
  }
@@ -1,5 +1,6 @@
1
1
  import { asValue } from 'awilix'
2
2
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { registerEmailTransport } from '@open-mercato/shared/lib/email/transport'
3
4
  import {
4
5
  CommunicationChannel,
5
6
  ExternalConversation,
@@ -11,6 +12,7 @@ import {
11
12
  import { getChannelAdapterRegistry } from './lib/adapter-registry-singleton'
12
13
  import { ensureTestSeedAdapterRegistered } from './lib/test-seed'
13
14
  import { sendAsUser } from './lib/send-as-user'
15
+ import { isSystemEmailTransportConfigured, sendSystemEmail } from './lib/system-email'
14
16
  import { resolveChannelTypeSafely } from './lib/resolve-channel-type'
15
17
 
16
18
  export function register(container: AppContainer) {
@@ -19,6 +21,11 @@ export function register(container: AppContainer) {
19
21
  // integration harness connect a channel + complete the outbound send chain.
20
22
  // See lib/test-seed.ts.
21
23
  ensureTestSeedAdapterRegistered()
24
+ registerEmailTransport({
25
+ id: 'communication_channels',
26
+ send: (payload) => sendSystemEmail(container, payload),
27
+ isConfigured: isSystemEmailTransportConfigured,
28
+ })
22
29
 
23
30
  container.register({
24
31
  // Entity class registrations (for EntityManager lookups by string)
@@ -37,6 +44,7 @@ export function register(container: AppContainer) {
37
44
  // In-process send-as-user facade. Cross-module callers (e.g. the customers
38
45
  // compose route) resolve this instead of making an HTTP self-call.
39
46
  communicationChannelsSendAsUser: asValue(sendAsUser),
47
+ communicationChannelsSendSystemEmail: asValue(sendSystemEmail),
40
48
 
41
49
  // Cross-module channel-type lookup. The messages compose route resolves this
42
50
  // to decide whether an external correspondent must carry an email address
@@ -0,0 +1,85 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { CommunicationChannel } from '../data/entities'
5
+
6
+ const logger = createLogger('communication_channels')
7
+
8
+ export type EnsureSystemEmailChannelInput = {
9
+ tenantId: string
10
+ organizationId: string
11
+ providerKey: string
12
+ externalIdentifier: string
13
+ displayName?: string
14
+ capabilities?: Record<string, unknown>
15
+ }
16
+
17
+ export type EnsureSystemEmailChannelResult = 'created' | 'updated' | 'failed'
18
+
19
+ /**
20
+ * Idempotently create or refresh a tenant's system email channel row.
21
+ *
22
+ * Shared by the two places that need the same row: the provider env preset, which runs at tenant setup
23
+ * and again on every `yarn mercato seed:defaults --module channel_<provider>` used to migrate tenants
24
+ * that predate the Communications Hub, and the lazy repair in `sendSystemEmail` for a tenant whose
25
+ * credentials were saved through the integrations admin UI (which stores credentials but has no hook
26
+ * to create the channel).
27
+ *
28
+ * Never throws: a channel row is a convenience for the Hub UI and for later lookups, so a failure to
29
+ * write one must not take down the caller — an email send in particular still has usable credentials.
30
+ */
31
+ export async function ensureSystemEmailChannel(
32
+ em: EntityManager,
33
+ input: EnsureSystemEmailChannelInput,
34
+ ): Promise<EnsureSystemEmailChannelResult> {
35
+ const { tenantId, organizationId, providerKey, externalIdentifier } = input
36
+ const displayName = input.displayName ?? `${providerKey} system email`
37
+ const dscope = { tenantId, organizationId }
38
+
39
+ try {
40
+ const existing = await findOneWithDecryption(
41
+ em,
42
+ CommunicationChannel,
43
+ {
44
+ providerKey,
45
+ channelType: 'email',
46
+ tenantId,
47
+ organizationId,
48
+ userId: null,
49
+ deletedAt: null,
50
+ },
51
+ undefined,
52
+ dscope,
53
+ )
54
+
55
+ if (existing) {
56
+ existing.displayName = displayName
57
+ existing.externalIdentifier = externalIdentifier
58
+ if (input.capabilities) existing.capabilities = { ...input.capabilities }
59
+ existing.isActive = true
60
+ existing.status = 'connected'
61
+ existing.lastError = null
62
+ await em.flush()
63
+ return 'updated'
64
+ }
65
+
66
+ const channel = em.create(CommunicationChannel, {
67
+ providerKey,
68
+ channelType: 'email',
69
+ displayName,
70
+ externalIdentifier,
71
+ capabilities: { ...(input.capabilities ?? {}) },
72
+ isActive: true,
73
+ status: 'connected',
74
+ userId: null,
75
+ pollIntervalSeconds: null,
76
+ tenantId,
77
+ organizationId,
78
+ })
79
+ await em.persist(channel).flush()
80
+ return 'created'
81
+ } catch (err) {
82
+ logger.warn('Could not ensure system email channel', { err, tenantId, providerKey })
83
+ return 'failed'
84
+ }
85
+ }
@@ -0,0 +1,62 @@
1
+ import { normalizeEnvString } from '@open-mercato/shared/lib/email/config'
2
+
3
+ type SystemEmailProviderConfigContext = {
4
+ fromAddress: string
5
+ }
6
+
7
+ export const DEFAULT_SYSTEM_EMAIL_PROVIDER = 'resend'
8
+
9
+ /**
10
+ * The provider key `sendSystemEmail` resolves channels and credentials against.
11
+ *
12
+ * Lives here rather than in `system-email.ts` so a provider package's `setup.ts` can read it without
13
+ * importing the send path — that module pulls the ORM and the React renderer in behind it, and a
14
+ * preset that runs at tenant seeding has no business loading either.
15
+ */
16
+ export function resolveSystemEmailProvider(): string {
17
+ return normalizeEnvString(process.env.SYSTEM_EMAIL_PROVIDER) ?? DEFAULT_SYSTEM_EMAIL_PROVIDER
18
+ }
19
+
20
+ /**
21
+ * Whether a provider package's env preset should seed anything for this instance.
22
+ *
23
+ * Only the selected provider seeds. Without this gate a preset fires off whatever env variables
24
+ * happen to be present, and `AWS_REGION` in particular is a general-purpose variable this repo
25
+ * already ships uncommented for vector search — so an instance sending through Resend would get an
26
+ * `isEnabled: true` SES integration and a `status: 'connected'` SES channel it never configured.
27
+ */
28
+ export function isSelectedSystemEmailProvider(providerKey: string): boolean {
29
+ return resolveSystemEmailProvider() === providerKey
30
+ }
31
+
32
+ export type SystemEmailProviderConfigResolver = {
33
+ providerKey: string
34
+ isConfigured: () => boolean
35
+ resolveCredentials: (ctx: SystemEmailProviderConfigContext) => Record<string, unknown>
36
+ }
37
+
38
+ const SYSTEM_EMAIL_PROVIDER_CONFIG_REGISTRY = Symbol.for(
39
+ 'open-mercato.communication-channels.system-email-provider-config',
40
+ )
41
+
42
+ type RegistryGlobal = typeof globalThis & {
43
+ [SYSTEM_EMAIL_PROVIDER_CONFIG_REGISTRY]?: Map<string, SystemEmailProviderConfigResolver>
44
+ }
45
+
46
+ function getRegistry(): Map<string, SystemEmailProviderConfigResolver> {
47
+ const root = globalThis as RegistryGlobal
48
+ if (!root[SYSTEM_EMAIL_PROVIDER_CONFIG_REGISTRY]) {
49
+ root[SYSTEM_EMAIL_PROVIDER_CONFIG_REGISTRY] = new Map()
50
+ }
51
+ return root[SYSTEM_EMAIL_PROVIDER_CONFIG_REGISTRY]
52
+ }
53
+
54
+ export function registerSystemEmailProviderConfigResolver(resolver: SystemEmailProviderConfigResolver): void {
55
+ getRegistry().set(resolver.providerKey, resolver)
56
+ }
57
+
58
+ export function getSystemEmailProviderConfigResolver(
59
+ providerKey: string,
60
+ ): SystemEmailProviderConfigResolver | undefined {
61
+ return getRegistry().get(providerKey)
62
+ }
@@ -0,0 +1,316 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import type { ResolvedEmailPayload } from '@open-mercato/shared/lib/email/send'
4
+ import { normalizeEnvString } from '@open-mercato/shared/lib/email/config'
5
+ import { createLogger } from '@open-mercato/shared/lib/logger'
6
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
+ import type { ChannelAdapterRegistry } from './registry'
8
+ import { htmlToText } from './email-mime'
9
+ import {
10
+ DEFAULT_SYSTEM_EMAIL_PROVIDER,
11
+ getSystemEmailProviderConfigResolver,
12
+ resolveSystemEmailProvider,
13
+ } from './system-email-provider-config'
14
+ import { isTestChannelSeedingEnabled, TEST_SEED_PROVIDER_KEY } from './test-seed'
15
+ import { ensureSystemEmailChannel } from './ensure-system-email-channel'
16
+ import { CommunicationChannel } from '../data/entities'
17
+
18
+ export { DEFAULT_SYSTEM_EMAIL_PROVIDER }
19
+
20
+ const logger = createLogger('communication_channels')
21
+
22
+ type CredentialsServiceLike = {
23
+ resolve: (
24
+ integrationId: string,
25
+ scope: { organizationId: string; tenantId: string; userId?: string | null },
26
+ ) => Promise<Record<string, unknown> | null>
27
+ }
28
+
29
+ type ResolvedSystemEmailChannel = Pick<CommunicationChannel, 'providerKey' | 'channelType' | 'organizationId'>
30
+
31
+ /**
32
+ * Outcome of resolving which channel a system email should go out through.
33
+ *
34
+ * `hasTenantChannel` drives the credential posture and is the whole point of the split: a tenant that
35
+ * has an explicitly configured Hub channel MUST fail closed when its credentials are missing (never
36
+ * silently borrow the instance-wide env key), while a tenant with no channel at all is an instance
37
+ * that simply has not been migrated onto the Communications Hub yet and MUST keep working off env.
38
+ */
39
+ type ResolvedSystemEmailTarget = {
40
+ channel: ResolvedSystemEmailChannel
41
+ hasTenantChannel: boolean
42
+ }
43
+
44
+ export function isSystemEmailTransportConfigured(): boolean {
45
+ return getSystemEmailProviderConfigResolver(resolveSystemEmailProvider())?.isConfigured() ?? false
46
+ }
47
+
48
+ async function renderReactEmail(react: ResolvedEmailPayload['react']): Promise<string | undefined> {
49
+ if (!react) return undefined
50
+ const { renderToStaticMarkup } = await import('react-dom/server')
51
+ const markup = renderToStaticMarkup(react)
52
+ return markup.startsWith('<!doctype html>') || markup.startsWith('<!DOCTYPE html>')
53
+ ? markup
54
+ : `<!doctype html>${markup}`
55
+ }
56
+
57
+ async function resolveEmailBody(payload: ResolvedEmailPayload): Promise<{
58
+ html?: string
59
+ text?: string
60
+ body: string
61
+ bodyFormat: 'text' | 'html'
62
+ }> {
63
+ const html = payload.html ?? (await renderReactEmail(payload.react))
64
+ const text = payload.text ?? (html ? htmlToText(html) : undefined)
65
+ if (html) return { html, text, body: html, bodyFormat: 'html' }
66
+ if (text) return { text, body: text, bodyFormat: 'text' }
67
+ throw new Error('EMAIL_BODY_NOT_CONFIGURED: provide react, html, or text')
68
+ }
69
+
70
+ /**
71
+ * Locate the tenant's system email channel.
72
+ *
73
+ * Two probes, both inside the caller's own scope: the caller's organization first, then the
74
+ * organization-agnostic (`organization_id IS NULL`) row that represents a genuinely tenant-wide
75
+ * channel. A channel belonging to a *different* organization is deliberately never used — organization
76
+ * is a scoping boundary, and borrowing another organization's channel would send through credentials
77
+ * that organization owns. A caller whose organization has no channel falls through to the
78
+ * instance-wide environment credentials instead, which cross no boundary at all.
79
+ */
80
+ async function findTenantSystemEmailChannel(
81
+ em: EntityManager,
82
+ payload: ResolvedEmailPayload,
83
+ providerKey: string,
84
+ ): Promise<CommunicationChannel | null> {
85
+ const tenantId = payload.tenantId as string
86
+ const dscope = { tenantId, organizationId: payload.organizationId ?? null }
87
+ const base = {
88
+ providerKey,
89
+ channelType: 'email',
90
+ tenantId,
91
+ userId: null,
92
+ deletedAt: null,
93
+ }
94
+
95
+ const organizationScopes: Array<string | null> = [payload.organizationId ?? null]
96
+ if (payload.organizationId) organizationScopes.push(null)
97
+
98
+ for (const organizationId of organizationScopes) {
99
+ const found = await findOneWithDecryption(
100
+ em,
101
+ CommunicationChannel,
102
+ { ...base, organizationId },
103
+ undefined,
104
+ dscope,
105
+ )
106
+ if (found) return found
107
+ }
108
+ return null
109
+ }
110
+
111
+ async function resolveSystemEmailChannel(
112
+ em: EntityManager,
113
+ payload: ResolvedEmailPayload,
114
+ ): Promise<ResolvedSystemEmailTarget> {
115
+ const providerKey = resolveSystemEmailProvider()
116
+ const envTarget: ResolvedSystemEmailTarget = {
117
+ channel: {
118
+ providerKey,
119
+ channelType: 'email',
120
+ organizationId: payload.organizationId ?? null,
121
+ },
122
+ hasTenantChannel: false,
123
+ }
124
+
125
+ if (!payload.tenantId) {
126
+ return { ...envTarget, channel: { ...envTarget.channel, organizationId: null } }
127
+ }
128
+
129
+ const dscope = {
130
+ tenantId: payload.tenantId,
131
+ organizationId: payload.organizationId ?? null,
132
+ }
133
+ const explicitChannelId = normalizeEnvString(process.env.SYSTEM_EMAIL_CHANNEL_ID)
134
+
135
+ // An operator who pinned SYSTEM_EMAIL_CHANNEL_ID asked for exactly one channel. Honour that
136
+ // literally and never widen the search or fall back to env — a silent substitution would send
137
+ // through a provider the operator deliberately did not choose.
138
+ //
139
+ // The pin narrows the search; it does not lift the organization boundary. A pinned id is accepted
140
+ // only when the row is the caller's own organization or the tenant-wide (`organization_id IS NULL`)
141
+ // one the setting is documented to name. Matching on the id alone would let a pin aimed at one
142
+ // organization's channel send every other organization's mail through credentials that
143
+ // organization owns — the same borrowing `findTenantSystemEmailChannel` refuses.
144
+ const channel = explicitChannelId
145
+ ? await findOneWithDecryption(
146
+ em,
147
+ CommunicationChannel,
148
+ {
149
+ id: explicitChannelId,
150
+ channelType: 'email',
151
+ tenantId: payload.tenantId,
152
+ userId: null,
153
+ deletedAt: null,
154
+ $or: [{ organizationId: payload.organizationId ?? null }, { organizationId: null }],
155
+ },
156
+ undefined,
157
+ dscope,
158
+ )
159
+ : await findTenantSystemEmailChannel(em, payload, providerKey)
160
+
161
+ if (channel) {
162
+ if (!channel.isActive || channel.status !== 'connected') {
163
+ throw new Error(`SYSTEM_EMAIL_CHANNEL_UNAVAILABLE: channel is ${channel.status}`)
164
+ }
165
+ return { channel, hasTenantChannel: true }
166
+ }
167
+
168
+ if (explicitChannelId) {
169
+ throw new Error(
170
+ `SYSTEM_EMAIL_CHANNEL_NOT_CONFIGURED: SYSTEM_EMAIL_CHANNEL_ID '${explicitChannelId}' matches no active channel this organization may use`,
171
+ )
172
+ }
173
+
174
+ if (isTestChannelSeedingEnabled() && providerKey === TEST_SEED_PROVIDER_KEY) {
175
+ return {
176
+ channel: { providerKey: TEST_SEED_PROVIDER_KEY, channelType: 'email', organizationId: payload.organizationId ?? null },
177
+ hasTenantChannel: false,
178
+ }
179
+ }
180
+
181
+ // No channel row for this tenant. This is the ordinary state of an instance that upgraded into the
182
+ // Communications Hub: tenants predate the feature, so nothing ever seeded a channel for them.
183
+ // Do not decide here — `sendSystemEmail` still has to check whether the tenant configured the
184
+ // provider through the integrations UI before it considers the instance-wide env credentials.
185
+ return envTarget
186
+ }
187
+
188
+ function resolveEnvCredentials(providerKey: string, fromAddress: string): Record<string, unknown> {
189
+ return getSystemEmailProviderConfigResolver(providerKey)?.resolveCredentials({ fromAddress }) ?? { fromAddress }
190
+ }
191
+
192
+ /**
193
+ * Decide which credentials a tenant-scoped send uses.
194
+ *
195
+ * Three cases, in priority order:
196
+ *
197
+ * 1. The tenant has credentials stored for the provider (seeded, or saved through
198
+ * `/backend/integrations/channel_<provider>`) — always win. When no Hub channel row exists yet we
199
+ * also create one, so configuring the integration in the admin UI produces a usable channel
200
+ * instead of credentials nothing reads.
201
+ * 2. The tenant has a configured channel but no credentials — **fail closed**. Borrowing the
202
+ * instance key here would send a tenant's mail through a provider account it did not choose.
203
+ * 3. The tenant has neither — fall back to the instance-wide env credentials. This is the pre-Hub
204
+ * behaviour and the upgrade path: env credentials are instance-wide by definition, so this leaks
205
+ * nothing across tenants, and without it every tenant that predates the Hub loses all mail.
206
+ */
207
+ async function resolveTenantCredentials(
208
+ container: AppContainer,
209
+ em: EntityManager,
210
+ payload: ResolvedEmailPayload,
211
+ channel: ResolvedSystemEmailChannel,
212
+ hasTenantChannel: boolean,
213
+ ): Promise<Record<string, unknown>> {
214
+ const tenantId = payload.tenantId as string
215
+ const organizationId = channel.organizationId ?? payload.organizationId ?? tenantId
216
+ const credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike
217
+ const resolvedCredentials = await credentialsService.resolve(`channel_${channel.providerKey}`, {
218
+ tenantId,
219
+ organizationId,
220
+ userId: null,
221
+ })
222
+
223
+ if (resolvedCredentials) {
224
+ if (!hasTenantChannel) {
225
+ await ensureSystemEmailChannel(em, {
226
+ tenantId,
227
+ organizationId,
228
+ providerKey: channel.providerKey,
229
+ externalIdentifier: typeof resolvedCredentials.fromAddress === 'string' ? resolvedCredentials.fromAddress : payload.from,
230
+ })
231
+ }
232
+ return resolvedCredentials
233
+ }
234
+
235
+ if (hasTenantChannel) {
236
+ throw new Error(
237
+ `SYSTEM_EMAIL_CREDENTIALS_NOT_CONFIGURED: configure tenant credentials for '${channel.providerKey}'`,
238
+ )
239
+ }
240
+
241
+ if (!isSystemEmailTransportConfigured()) {
242
+ throw new Error('SYSTEM_EMAIL_CHANNEL_NOT_CONFIGURED: configure a tenant-wide email channel')
243
+ }
244
+
245
+ logger.warn('Sending system email with instance-wide environment credentials', {
246
+ tenantId,
247
+ providerKey: channel.providerKey,
248
+ reason: `no tenant email channel or credentials configured for '${channel.providerKey}'`,
249
+ remedy: `run 'yarn mercato seed:defaults --module channel_${channel.providerKey}' to move this tenant onto the Communications Hub`,
250
+ })
251
+ return resolveEnvCredentials(channel.providerKey, payload.from)
252
+ }
253
+
254
+ /**
255
+ * Choose the address a system email actually leaves from.
256
+ *
257
+ * The tenant's configured sender wins whenever the caller did not name one itself. `sendEmail` always
258
+ * fills `from` in — from `NOTIFICATIONS_EMAIL_FROM` / `EMAIL_FROM` / `ADMIN_EMAIL` — so an adapter that
259
+ * merely falls back to its credentials when `from` is missing would never reach the tenant's value, and
260
+ * the "From address" saved in the integrations UI would be inert. A caller that passed an explicit
261
+ * `from` meant it, and keeps it.
262
+ */
263
+ function resolveOutboundFromAddress(
264
+ payload: ResolvedEmailPayload,
265
+ credentials: Record<string, unknown>,
266
+ ): string {
267
+ if (!payload.fromIsInstanceDefault) return payload.from
268
+ const configured = credentials.fromAddress
269
+ return typeof configured === 'string' && configured.trim().length > 0 ? configured : payload.from
270
+ }
271
+
272
+ export async function sendSystemEmail(
273
+ container: AppContainer,
274
+ payload: ResolvedEmailPayload,
275
+ ): Promise<void> {
276
+ const em = (container.resolve('em') as EntityManager).fork()
277
+ const { channel, hasTenantChannel } = await resolveSystemEmailChannel(em, payload)
278
+ const registry = container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry
279
+ const adapter = registry.get(channel.providerKey)
280
+ if (!adapter) {
281
+ throw new Error(
282
+ `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. Enable the provider module.`,
283
+ )
284
+ }
285
+
286
+ const credentials = payload.tenantId
287
+ ? await resolveTenantCredentials(container, em, payload, channel, hasTenantChannel)
288
+ : resolveEnvCredentials(channel.providerKey, payload.from)
289
+
290
+ const body = await resolveEmailBody(payload)
291
+ const converted = await adapter.convertOutbound({
292
+ body: body.body,
293
+ bodyFormat: body.bodyFormat,
294
+ channelMetadata: {
295
+ to: payload.to,
296
+ subject: payload.subject,
297
+ from: resolveOutboundFromAddress(payload, credentials),
298
+ replyTo: payload.replyTo,
299
+ attachments: payload.attachments,
300
+ },
301
+ })
302
+
303
+ const sendResult = await adapter.sendMessage({
304
+ content: converted.content,
305
+ credentials,
306
+ scope: {
307
+ tenantId: payload.tenantId ?? 'system',
308
+ organizationId: payload.organizationId ?? payload.tenantId ?? 'system',
309
+ },
310
+ metadata: converted.metadata,
311
+ })
312
+
313
+ if (sendResult.status === 'failed') {
314
+ throw new Error(sendResult.error ?? `SYSTEM_EMAIL_SEND_FAILED: ${channel.providerKey}`)
315
+ }
316
+ }