@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.
- 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/customers/commands/tags.js +8 -2
- package/dist/modules/customers/commands/tags.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/dist/modules/staff/commands/timesheets-entries.js +4 -2
- package/dist/modules/staff/commands/timesheets-entries.js.map +2 -2
- package/dist/modules/staff/commands/timesheets-projects.js +4 -2
- package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
- 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/customers/commands/tags.ts +12 -1
- 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
- package/src/modules/staff/commands/timesheets-entries.ts +2 -0
- 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
|
}
|