@open-mercato/core 0.7.1-develop.7150.1.c1941e0c22 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js +77 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
- package/dist/helpers/integration/crmFixtures.js +3 -0
- package/dist/helpers/integration/crmFixtures.js.map +2 -2
- package/dist/modules/auth/api/reset.js +7 -1
- package/dist/modules/auth/api/reset.js.map +2 -2
- package/dist/modules/auth/api/users/resend-invite/route.js +7 -1
- package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
- package/dist/modules/auth/commands/users.js +7 -1
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +101 -26
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +3 -3
- package/dist/modules/communication_channels/di.js +8 -0
- package/dist/modules/communication_channels/di.js.map +2 -2
- package/dist/modules/communication_channels/lib/ensure-system-email-channel.js +57 -0
- package/dist/modules/communication_channels/lib/ensure-system-email-channel.js.map +7 -0
- package/dist/modules/communication_channels/lib/system-email-provider-config.js +32 -0
- package/dist/modules/communication_channels/lib/system-email-provider-config.js.map +7 -0
- package/dist/modules/communication_channels/lib/system-email.js +190 -0
- package/dist/modules/communication_channels/lib/system-email.js.map +7 -0
- package/dist/modules/communication_channels/lib/test-seed.js +130 -1
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/communication_channels/setup.js +56 -1
- package/dist/modules/communication_channels/setup.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users-invite.js +1 -0
- package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/users-invite.js +1 -0
- package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/api/signup.js +8 -4
- package/dist/modules/customer_accounts/api/signup.js.map +2 -2
- package/dist/modules/customer_accounts/lib/invitationEmail.js +3 -1
- package/dist/modules/customer_accounts/lib/invitationEmail.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +3 -0
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/messages/lib/email-sender.js +16 -14
- package/dist/modules/messages/lib/email-sender.js.map +2 -2
- package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +3 -1
- package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +2 -2
- package/dist/modules/sales/api/quotes/accept/route.js +3 -1
- package/dist/modules/sales/api/quotes/accept/route.js.map +2 -2
- package/dist/modules/sales/api/quotes/send/route.js +13 -4
- package/dist/modules/sales/api/quotes/send/route.js.map +3 -3
- package/package.json +7 -7
- package/src/helpers/integration/communicationChannelsFixtures.ts +129 -0
- package/src/helpers/integration/crmFixtures.ts +4 -1
- package/src/modules/auth/api/reset.ts +7 -1
- package/src/modules/auth/api/users/resend-invite/route.ts +7 -1
- package/src/modules/auth/commands/users.ts +7 -1
- package/src/modules/auth/i18n/de.json +4 -0
- package/src/modules/auth/i18n/en.json +4 -0
- package/src/modules/auth/i18n/es.json +4 -0
- package/src/modules/auth/i18n/ko.json +4 -0
- package/src/modules/auth/i18n/pl.json +4 -0
- package/src/modules/communication_channels/api/post/test-seed/route.ts +121 -29
- package/src/modules/communication_channels/di.ts +8 -0
- package/src/modules/communication_channels/lib/ensure-system-email-channel.ts +85 -0
- package/src/modules/communication_channels/lib/system-email-provider-config.ts +62 -0
- package/src/modules/communication_channels/lib/system-email.ts +316 -0
- package/src/modules/communication_channels/lib/test-seed.ts +198 -0
- package/src/modules/communication_channels/setup.ts +91 -1
- package/src/modules/customer_accounts/api/admin/users-invite.ts +1 -0
- package/src/modules/customer_accounts/api/portal/users-invite.ts +1 -0
- package/src/modules/customer_accounts/api/signup.ts +6 -2
- package/src/modules/customer_accounts/lib/invitationEmail.ts +3 -0
- package/src/modules/customers/api/interactions/route.ts +4 -0
- package/src/modules/messages/lib/email-sender.ts +18 -16
- package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +3 -1
- package/src/modules/sales/api/quotes/accept/route.ts +2 -0
- 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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
+
}
|