@open-mercato/core 0.7.1-develop.7149.1.7efa6e1612 → 0.7.1-develop.7151.1.00d0391847

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 (79) 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/customers/commands/tags.js +8 -2
  37. package/dist/modules/customers/commands/tags.js.map +2 -2
  38. package/dist/modules/messages/lib/email-sender.js +16 -14
  39. package/dist/modules/messages/lib/email-sender.js.map +2 -2
  40. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +3 -1
  41. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +2 -2
  42. package/dist/modules/sales/api/quotes/accept/route.js +3 -1
  43. package/dist/modules/sales/api/quotes/accept/route.js.map +2 -2
  44. package/dist/modules/sales/api/quotes/send/route.js +13 -4
  45. package/dist/modules/sales/api/quotes/send/route.js.map +3 -3
  46. package/dist/modules/staff/commands/timesheets-entries.js +4 -2
  47. package/dist/modules/staff/commands/timesheets-entries.js.map +2 -2
  48. package/dist/modules/staff/commands/timesheets-projects.js +4 -2
  49. package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
  50. package/package.json +7 -7
  51. package/src/helpers/integration/communicationChannelsFixtures.ts +129 -0
  52. package/src/helpers/integration/crmFixtures.ts +4 -1
  53. package/src/modules/auth/api/reset.ts +7 -1
  54. package/src/modules/auth/api/users/resend-invite/route.ts +7 -1
  55. package/src/modules/auth/commands/users.ts +7 -1
  56. package/src/modules/auth/i18n/de.json +4 -0
  57. package/src/modules/auth/i18n/en.json +4 -0
  58. package/src/modules/auth/i18n/es.json +4 -0
  59. package/src/modules/auth/i18n/ko.json +4 -0
  60. package/src/modules/auth/i18n/pl.json +4 -0
  61. package/src/modules/communication_channels/api/post/test-seed/route.ts +121 -29
  62. package/src/modules/communication_channels/di.ts +8 -0
  63. package/src/modules/communication_channels/lib/ensure-system-email-channel.ts +85 -0
  64. package/src/modules/communication_channels/lib/system-email-provider-config.ts +62 -0
  65. package/src/modules/communication_channels/lib/system-email.ts +316 -0
  66. package/src/modules/communication_channels/lib/test-seed.ts +198 -0
  67. package/src/modules/communication_channels/setup.ts +91 -1
  68. package/src/modules/customer_accounts/api/admin/users-invite.ts +1 -0
  69. package/src/modules/customer_accounts/api/portal/users-invite.ts +1 -0
  70. package/src/modules/customer_accounts/api/signup.ts +6 -2
  71. package/src/modules/customer_accounts/lib/invitationEmail.ts +3 -0
  72. package/src/modules/customers/api/interactions/route.ts +4 -0
  73. package/src/modules/customers/commands/tags.ts +12 -1
  74. package/src/modules/messages/lib/email-sender.ts +18 -16
  75. package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +3 -1
  76. package/src/modules/sales/api/quotes/accept/route.ts +2 -0
  77. package/src/modules/sales/api/quotes/send/route.ts +16 -3
  78. package/src/modules/staff/commands/timesheets-entries.ts +2 -0
  79. package/src/modules/staff/commands/timesheets-projects.ts +2 -0
@@ -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
+ }
@@ -1,3 +1,7 @@
1
+ import { appendFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { timingSafeEqual } from 'node:crypto'
3
+ import path from 'node:path'
4
+ import type { EntityManager } from '@mikro-orm/postgresql'
1
5
  import type {
2
6
  ChannelAdapter,
3
7
  ChannelCapabilities,
@@ -15,6 +19,7 @@ import type {
15
19
  } from './adapter'
16
20
  import { baseEmailCapabilities } from './email-capabilities'
17
21
  import { hasChannelAdapter, registerChannelAdapter } from './adapter-registry-singleton'
22
+ import { registerSystemEmailProviderConfigResolver } from './system-email-provider-config'
18
23
 
19
24
  /**
20
25
  * Test-only channel seeding support.
@@ -56,6 +61,18 @@ export const TEST_SEED_CHAT_PROVIDER_KEY = '__test_seed_chat__'
56
61
 
57
62
  /** Env flag that unlocks test-only channel seeding. Off in production. */
58
63
  export const TEST_CHANNEL_SEEDING_ENV = 'OM_ENABLE_TEST_CHANNEL_SEEDING'
64
+ export const TEST_EMAIL_CAPTURE_ACCESS_TOKEN_ENV = 'OM_TEST_EMAIL_CAPTURE_ACCESS_TOKEN'
65
+ export const TEST_EMAIL_CAPTURE_CORRELATION_TOKEN_ENV = 'OM_TEST_EMAIL_CAPTURE_CORRELATION_TOKEN'
66
+ /**
67
+ * Where the Hub's tenant-scoped capture is written.
68
+ *
69
+ * Deliberately not `OM_TEST_EMAIL_CAPTURE_PATH`: that one belongs to the unscoped capture in
70
+ * `@open-mercato/shared/lib/email/send`, which writes a different record shape to a path the repo
71
+ * also ships a committed fixture for. Pointing both mechanisms at one file made this module parse
72
+ * foreign records and let `clear-capture` rewrite the other mechanism's fixtures. Two mechanisms,
73
+ * two files.
74
+ */
75
+ export const TEST_SYSTEM_EMAIL_CAPTURE_PATH_ENV = 'OM_TEST_SYSTEM_EMAIL_CAPTURE_PATH'
59
76
 
60
77
  /**
61
78
  * True only when the test-seeding env flag is explicitly enabled. Accepts the
@@ -68,6 +85,173 @@ export function isTestChannelSeedingEnabled(): boolean {
68
85
  return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())
69
86
  }
70
87
 
88
+ export type TestSeedCapturedMessage = {
89
+ capturedAt: string
90
+ externalMessageId: string
91
+ conversationId?: string
92
+ content: SendMessageInput['content']
93
+ scope: SendMessageInput['scope']
94
+ metadata?: SendMessageInput['metadata']
95
+ captureCorrelationToken?: string
96
+ }
97
+
98
+ type TestSeedCaptureScope = {
99
+ tenantId: string
100
+ organizationId: string | null
101
+ }
102
+
103
+ export type TestSeedCaptureOptions = {
104
+ systemRecipient?: string
105
+ captureCorrelationToken?: string
106
+ }
107
+
108
+ export async function createTestSeedPlatformMessage(
109
+ em: EntityManager,
110
+ input: {
111
+ providerKey: string
112
+ threadId?: string
113
+ senderUserId: string
114
+ subject?: string
115
+ bodyText?: string
116
+ channelId: string
117
+ tenantId: string
118
+ organizationId: string | null
119
+ },
120
+ ): Promise<string | null> {
121
+ const rows = (await em.getConnection().execute(
122
+ `INSERT INTO messages
123
+ (type, thread_id, sender_user_id, subject, body, body_format, priority, status,
124
+ is_draft, sent_at, visibility, source_entity_type, source_entity_id,
125
+ tenant_id, organization_id, created_at, updated_at)
126
+ VALUES
127
+ (?, ?, ?, ?, ?, 'text', 'normal', 'sent',
128
+ false, now(), 'public', 'communication_channels.test_seed_inbound', ?,
129
+ ?, ?, now(), now())
130
+ RETURNING id`,
131
+ [
132
+ `channel.${input.providerKey}`,
133
+ input.threadId ?? null,
134
+ input.senderUserId,
135
+ input.subject ?? '(no subject)',
136
+ input.bodyText ?? '',
137
+ input.channelId,
138
+ input.tenantId,
139
+ input.organizationId,
140
+ ],
141
+ )) as Array<{ id: string }>
142
+ return rows[0]?.id ?? null
143
+ }
144
+
145
+ function normalizeToken(value: string | null | undefined): string | null {
146
+ if (typeof value !== 'string') return null
147
+ const normalized = value.trim()
148
+ return normalized.length >= 32 ? normalized : null
149
+ }
150
+
151
+ export function isTestEmailCaptureAccessAuthorized(providedToken: string | null): boolean {
152
+ const expectedToken = normalizeToken(process.env[TEST_EMAIL_CAPTURE_ACCESS_TOKEN_ENV])
153
+ const normalizedProvidedToken = normalizeToken(providedToken)
154
+ if (!expectedToken || !normalizedProvidedToken) return false
155
+ const expected = Buffer.from(expectedToken)
156
+ const provided = Buffer.from(normalizedProvidedToken)
157
+ return expected.length === provided.length && timingSafeEqual(expected, provided)
158
+ }
159
+
160
+ export function resolveTestEmailCaptureCorrelationToken(): string | null {
161
+ return normalizeToken(process.env[TEST_EMAIL_CAPTURE_CORRELATION_TOKEN_ENV])
162
+ }
163
+
164
+ function resolveCapturePath(): string {
165
+ const explicit = process.env[TEST_SYSTEM_EMAIL_CAPTURE_PATH_ENV]?.trim()
166
+ if (explicit) return path.resolve(explicit)
167
+ const queueBaseDir = process.env.QUEUE_BASE_DIR?.trim()
168
+ if (queueBaseDir) return path.resolve(queueBaseDir, '..', 'test-email-capture.jsonl')
169
+ return path.resolve(process.cwd(), '.mercato', 'test-email-capture.jsonl')
170
+ }
171
+
172
+ function matchesCaptureScope(
173
+ message: TestSeedCapturedMessage,
174
+ scope: TestSeedCaptureScope,
175
+ options: TestSeedCaptureOptions = {},
176
+ ): boolean {
177
+ if (message.scope.tenantId === scope.tenantId) {
178
+ const messageOrganizationId = message.scope.organizationId ?? null
179
+ return messageOrganizationId === scope.organizationId || messageOrganizationId === scope.tenantId
180
+ }
181
+
182
+ if (
183
+ message.scope.tenantId !== 'system' ||
184
+ message.scope.organizationId !== 'system' ||
185
+ !options.systemRecipient ||
186
+ !options.captureCorrelationToken ||
187
+ message.captureCorrelationToken !== options.captureCorrelationToken
188
+ ) {
189
+ return false
190
+ }
191
+
192
+ const expectedRecipient = options.systemRecipient.trim().toLowerCase()
193
+ const matchesRecipient = (value: unknown): boolean => {
194
+ if (typeof value === 'string') return value.trim().toLowerCase() === expectedRecipient
195
+ if (Array.isArray(value)) return value.some(matchesRecipient)
196
+ if (!value || typeof value !== 'object') return false
197
+ return matchesRecipient((value as Record<string, unknown>).address)
198
+ }
199
+
200
+ return matchesRecipient(message.metadata?.to)
201
+ }
202
+
203
+ async function readTestSeedCapturedMessages(): Promise<TestSeedCapturedMessage[]> {
204
+ const capturePath = resolveCapturePath()
205
+ const text = await readFile(capturePath, 'utf8').catch(() => '')
206
+ return text
207
+ .split(/\r?\n/)
208
+ .map((line) => line.trim())
209
+ .filter((line) => line.length > 0)
210
+ .map((line) => {
211
+ try {
212
+ return JSON.parse(line) as TestSeedCapturedMessage
213
+ } catch {
214
+ return null
215
+ }
216
+ })
217
+ // A record without a scope is not ours. Skipping instead of throwing keeps a stray or
218
+ // foreign-shaped line from turning every capture read into a 500.
219
+ .filter((message): message is TestSeedCapturedMessage => Boolean(message?.scope))
220
+ }
221
+
222
+ export async function clearTestSeedCapturedMessages(
223
+ scope: TestSeedCaptureScope,
224
+ options: TestSeedCaptureOptions = {},
225
+ ): Promise<void> {
226
+ const capturePath = resolveCapturePath()
227
+ const retained = (await readTestSeedCapturedMessages())
228
+ .filter((message) => !matchesCaptureScope(message, scope, options))
229
+ if (retained.length === 0) {
230
+ await rm(capturePath, { force: true })
231
+ return
232
+ }
233
+ await writeFile(
234
+ capturePath,
235
+ `${retained.map((message) => JSON.stringify(message)).join('\n')}\n`,
236
+ 'utf8',
237
+ )
238
+ }
239
+
240
+ export async function listTestSeedCapturedMessages(
241
+ scope: TestSeedCaptureScope,
242
+ options: TestSeedCaptureOptions = {},
243
+ ): Promise<TestSeedCapturedMessage[]> {
244
+ return (await readTestSeedCapturedMessages()).filter((message) =>
245
+ matchesCaptureScope(message, scope, options),
246
+ )
247
+ }
248
+
249
+ async function captureTestSeedMessage(record: TestSeedCapturedMessage): Promise<void> {
250
+ const capturePath = resolveCapturePath()
251
+ await mkdir(path.dirname(capturePath), { recursive: true })
252
+ await appendFile(capturePath, `${JSON.stringify(record)}\n`, 'utf8')
253
+ }
254
+
71
255
  /**
72
256
  * Capabilities for the stub: an email channel that supports neither reactions,
73
257
  * edit/delete, nor conversation history — so the strict registry validator
@@ -95,6 +279,15 @@ class TestSeedChannelAdapter implements ChannelAdapter {
95
279
  // Synthesize a deterministic-looking RFC2822-style message id; never touches
96
280
  // the network. The delivery worker persists this as the external message id.
97
281
  const externalMessageId = `test-seed-${Date.now()}-${Math.random().toString(16).slice(2, 10)}@test-seed.local`
282
+ await captureTestSeedMessage({
283
+ capturedAt: new Date().toISOString(),
284
+ externalMessageId,
285
+ conversationId: input.conversationId,
286
+ content: input.content,
287
+ scope: input.scope,
288
+ metadata: input.metadata,
289
+ captureCorrelationToken: resolveTestEmailCaptureCorrelationToken() ?? undefined,
290
+ })
98
291
  return {
99
292
  externalMessageId,
100
293
  conversationId: input.conversationId,
@@ -210,6 +403,11 @@ function getTestSeedChatChannelAdapter(): TestSeedChatChannelAdapter {
210
403
  */
211
404
  export function ensureTestSeedAdapterRegistered(): void {
212
405
  if (!isTestChannelSeedingEnabled()) return
406
+ registerSystemEmailProviderConfigResolver({
407
+ providerKey: TEST_SEED_PROVIDER_KEY,
408
+ isConfigured: isTestChannelSeedingEnabled,
409
+ resolveCredentials: ({ fromAddress }) => ({ testSeed: true, fromAddress }),
410
+ })
213
411
  if (!hasChannelAdapter(TEST_SEED_PROVIDER_KEY)) {
214
412
  registerChannelAdapter(getTestSeedChannelAdapter())
215
413
  }