@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
|
@@ -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
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
3
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
4
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
2
5
|
import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
|
|
6
|
+
import { CommunicationChannel } from './data/entities'
|
|
3
7
|
import { COMMUNICATION_CHANNELS_QUEUES } from './lib/queue'
|
|
4
|
-
import {
|
|
8
|
+
import { isTestChannelSeedingEnabled, TEST_SEED_PROVIDER_KEY } from './lib/test-seed'
|
|
5
9
|
|
|
6
10
|
const logger = createLogger('communication_channels')
|
|
7
11
|
|
|
@@ -25,6 +29,24 @@ type SchedulerServiceLike = {
|
|
|
25
29
|
}) => Promise<void>
|
|
26
30
|
}
|
|
27
31
|
|
|
32
|
+
type CredentialsServiceLike = {
|
|
33
|
+
resolve: (
|
|
34
|
+
integrationId: string,
|
|
35
|
+
scope: { organizationId: string; tenantId: string; userId?: string | null },
|
|
36
|
+
) => Promise<Record<string, unknown> | null>
|
|
37
|
+
save: (
|
|
38
|
+
integrationId: string,
|
|
39
|
+
credentials: Record<string, unknown>,
|
|
40
|
+
scope: { organizationId: string; tenantId: string; userId?: string | null },
|
|
41
|
+
) => Promise<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Sender the test-seed channel presents. Shared by the channel row's
|
|
46
|
+
* `externalIdentifier` and its seeded credentials so both describe one address.
|
|
47
|
+
*/
|
|
48
|
+
const TEST_SEED_FROM_ADDRESS = 'system@test-seed.local'
|
|
49
|
+
|
|
28
50
|
/**
|
|
29
51
|
* Tick interval in seconds. Default 60s per email integration spec
|
|
30
52
|
* § Hub Deltas → Delta 6 (scheduler mechanism).
|
|
@@ -88,6 +110,74 @@ export const setup: ModuleSetupConfig = {
|
|
|
88
110
|
},
|
|
89
111
|
|
|
90
112
|
async seedDefaults({ container, organizationId, tenantId }) {
|
|
113
|
+
if (isTestChannelSeedingEnabled() && process.env.SYSTEM_EMAIL_PROVIDER === TEST_SEED_PROVIDER_KEY) {
|
|
114
|
+
const em = (container.resolve('em') as EntityManager).fork()
|
|
115
|
+
const existing = await findOneWithDecryption(
|
|
116
|
+
em,
|
|
117
|
+
CommunicationChannel,
|
|
118
|
+
{
|
|
119
|
+
providerKey: TEST_SEED_PROVIDER_KEY,
|
|
120
|
+
channelType: 'email',
|
|
121
|
+
tenantId,
|
|
122
|
+
organizationId,
|
|
123
|
+
userId: null,
|
|
124
|
+
deletedAt: null,
|
|
125
|
+
},
|
|
126
|
+
undefined,
|
|
127
|
+
{ tenantId, organizationId },
|
|
128
|
+
)
|
|
129
|
+
if (!existing) {
|
|
130
|
+
em.persist(em.create(CommunicationChannel, {
|
|
131
|
+
providerKey: TEST_SEED_PROVIDER_KEY,
|
|
132
|
+
channelType: 'email',
|
|
133
|
+
displayName: 'Test Seed System Email',
|
|
134
|
+
externalIdentifier: TEST_SEED_FROM_ADDRESS,
|
|
135
|
+
userId: null,
|
|
136
|
+
isPrimary: false,
|
|
137
|
+
isActive: true,
|
|
138
|
+
status: 'connected',
|
|
139
|
+
tenantId,
|
|
140
|
+
organizationId,
|
|
141
|
+
}))
|
|
142
|
+
await em.flush()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Seeding the channel row without credentials is not a neutral state — it is a
|
|
147
|
+
* fail-closed one. Once a tenant owns a Hub channel, `resolveTenantCredentials`
|
|
148
|
+
* deliberately refuses to fall back to instance-wide env credentials, so every
|
|
149
|
+
* tenant-scoped send throws `SYSTEM_EMAIL_CREDENTIALS_NOT_CONFIGURED`. Callers that
|
|
150
|
+
* treat a failed send as a failed write (customer invitations roll back and return
|
|
151
|
+
* 502) then fail outright rather than merely losing the email.
|
|
152
|
+
*
|
|
153
|
+
* Seeding credentials alongside the row makes the harness model a tenant that
|
|
154
|
+
* finished configuring its provider, which is also the only one of the three
|
|
155
|
+
* credential cases the integration suite would otherwise never exercise.
|
|
156
|
+
*/
|
|
157
|
+
// Best-effort, like the scheduler registration below: credential storage needs the
|
|
158
|
+
// integrations module and a usable encryption key, and neither is guaranteed in every
|
|
159
|
+
// harness. A failure here must not abort tenant initialization for every other module.
|
|
160
|
+
try {
|
|
161
|
+
const credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike
|
|
162
|
+
const testSeedIntegrationId = `channel_${TEST_SEED_PROVIDER_KEY}`
|
|
163
|
+
const credentialScope = { tenantId, organizationId, userId: null }
|
|
164
|
+
const existingCredentials = await credentialsService.resolve(testSeedIntegrationId, credentialScope)
|
|
165
|
+
if (!existingCredentials) {
|
|
166
|
+
await credentialsService.save(
|
|
167
|
+
testSeedIntegrationId,
|
|
168
|
+
{ testSeed: true, fromAddress: TEST_SEED_FROM_ADDRESS },
|
|
169
|
+
credentialScope,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.warn('Test-seed email credentials could not be stored; tenant-scoped sends will fail closed', {
|
|
174
|
+
err: error,
|
|
175
|
+
tenantId,
|
|
176
|
+
organizationId,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
91
181
|
/**
|
|
92
182
|
* Register the per-channel polling tick with `@open-mercato/scheduler`.
|
|
93
183
|
*
|
|
@@ -140,10 +140,12 @@ export async function POST(req: Request) {
|
|
|
140
140
|
),
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
await sendEmail({
|
|
144
144
|
to: existing.email,
|
|
145
145
|
subject,
|
|
146
146
|
react: CustomerExistingAccountEmail({ loginUrl, copy }),
|
|
147
|
+
tenantId,
|
|
148
|
+
organizationId,
|
|
147
149
|
}).catch((error) => {
|
|
148
150
|
logger.error('Existing-account email failed', { err: error })
|
|
149
151
|
})
|
|
@@ -193,10 +195,12 @@ export async function POST(req: Request) {
|
|
|
193
195
|
),
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
|
|
198
|
+
await sendEmail({
|
|
197
199
|
to: user.email,
|
|
198
200
|
subject,
|
|
199
201
|
react: CustomerSignupVerificationEmail({ verifyUrl, copy }),
|
|
202
|
+
tenantId,
|
|
203
|
+
organizationId,
|
|
200
204
|
}).catch((error) => {
|
|
201
205
|
logger.error('Verification email failed', { err: error })
|
|
202
206
|
})
|
|
@@ -6,6 +6,7 @@ import { urlForCustomerOrg } from '@open-mercato/core/modules/customer_accounts/
|
|
|
6
6
|
|
|
7
7
|
export type CustomerInvitationEmailInput = {
|
|
8
8
|
container: AppContainer
|
|
9
|
+
tenantId?: string
|
|
9
10
|
organizationId: string
|
|
10
11
|
email: string
|
|
11
12
|
rawToken: string
|
|
@@ -38,5 +39,7 @@ export async function sendCustomerInvitationEmail(input: CustomerInvitationEmail
|
|
|
38
39
|
to: input.email,
|
|
39
40
|
subject,
|
|
40
41
|
react: CustomerInvitationEmail({ inviteUrl, copy }),
|
|
42
|
+
tenantId: input.tenantId,
|
|
43
|
+
organizationId: input.organizationId,
|
|
41
44
|
})
|
|
42
45
|
}
|
|
@@ -172,6 +172,7 @@ type InteractionListRow = {
|
|
|
172
172
|
priority: number | null
|
|
173
173
|
author_user_id: string | null
|
|
174
174
|
owner_user_id: string | null
|
|
175
|
+
external_message_id: string | null
|
|
175
176
|
appearance_icon: string | null
|
|
176
177
|
appearance_color: string | null
|
|
177
178
|
source: string | null
|
|
@@ -304,6 +305,7 @@ const INTERACTION_LIST_COLUMNS = [
|
|
|
304
305
|
'priority',
|
|
305
306
|
'author_user_id',
|
|
306
307
|
'owner_user_id',
|
|
308
|
+
'external_message_id',
|
|
307
309
|
'appearance_icon',
|
|
308
310
|
'appearance_color',
|
|
309
311
|
'source',
|
|
@@ -654,6 +656,7 @@ export async function GET(req: Request) {
|
|
|
654
656
|
priority: row.priority ?? null,
|
|
655
657
|
authorUserId: row.author_user_id ?? null,
|
|
656
658
|
ownerUserId: row.owner_user_id ?? null,
|
|
659
|
+
externalMessageId: row.external_message_id ?? null,
|
|
657
660
|
appearanceIcon: row.appearance_icon ?? null,
|
|
658
661
|
appearanceColor: row.appearance_color ?? null,
|
|
659
662
|
source: row.source ?? null,
|
|
@@ -733,6 +736,7 @@ const interactionListItemSchema = z
|
|
|
733
736
|
priority: z.number().nullable(),
|
|
734
737
|
authorUserId: z.string().uuid().nullable(),
|
|
735
738
|
ownerUserId: z.string().uuid().nullable(),
|
|
739
|
+
externalMessageId: z.string().uuid().nullable().optional(),
|
|
736
740
|
appearanceIcon: z.string().nullable().optional(),
|
|
737
741
|
appearanceColor: z.string().nullable().optional(),
|
|
738
742
|
source: z.string().nullable().optional(),
|
|
@@ -47,7 +47,7 @@ function resolveObjectLabels(objects: MessageObject[]): string[] {
|
|
|
47
47
|
return objects.map((item) => `${item.entityModule}.${item.entityType} (${item.entityId})`)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
type
|
|
50
|
+
type EmailAttachment = {
|
|
51
51
|
filename: string
|
|
52
52
|
content: string
|
|
53
53
|
contentType?: string
|
|
@@ -56,8 +56,8 @@ type ResendAttachment = {
|
|
|
56
56
|
async function mapAttachmentsForEmail(
|
|
57
57
|
messageId: string,
|
|
58
58
|
attachments: MessageEmailAttachment[],
|
|
59
|
-
): Promise<
|
|
60
|
-
const
|
|
59
|
+
): Promise<EmailAttachment[]> {
|
|
60
|
+
const emailAttachments: EmailAttachment[] = []
|
|
61
61
|
let totalBytes = 0
|
|
62
62
|
|
|
63
63
|
for (const attachment of attachments.slice(0, MAX_EMAIL_ATTACHMENTS)) {
|
|
@@ -89,14 +89,14 @@ async function mapAttachmentsForEmail(
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
totalBytes += buffer.length
|
|
92
|
-
|
|
92
|
+
emailAttachments.push({
|
|
93
93
|
filename: attachment.fileName,
|
|
94
94
|
content: buffer.toString('base64'),
|
|
95
95
|
contentType: attachment.mimeType || undefined,
|
|
96
96
|
})
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
return
|
|
99
|
+
return emailAttachments
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
async function renderMarkdownEmailBody(body: string) {
|
|
@@ -178,14 +178,13 @@ export async function sendMessageEmailToRecipient(params: {
|
|
|
178
178
|
}
|
|
179
179
|
const copy = await buildEmailCopy(message.sentAt ?? new Date())
|
|
180
180
|
const bodyHtml = await buildEmailBodyHtml(message)
|
|
181
|
-
const
|
|
182
|
-
logDebug('Sending recipient email
|
|
181
|
+
const emailAttachments = await mapAttachmentsForEmail(message.id, attachments)
|
|
182
|
+
logDebug('Sending recipient email', {
|
|
183
183
|
messageId: message.id,
|
|
184
184
|
recipientUserId,
|
|
185
185
|
recipientEmail,
|
|
186
186
|
hasViewUrl: Boolean(viewUrl),
|
|
187
|
-
attachmentsCount:
|
|
188
|
-
hasApiKey: Boolean(process.env.RESEND_API_KEY),
|
|
187
|
+
attachmentsCount: emailAttachments.length,
|
|
189
188
|
from: resolveDefaultEmailFromAddress() ?? null,
|
|
190
189
|
})
|
|
191
190
|
|
|
@@ -203,7 +202,9 @@ export async function sendMessageEmailToRecipient(params: {
|
|
|
203
202
|
attachmentNames: attachments.map((item) => item.fileName),
|
|
204
203
|
objectLabels: resolveObjectLabels(objects),
|
|
205
204
|
}),
|
|
206
|
-
attachments:
|
|
205
|
+
attachments: emailAttachments,
|
|
206
|
+
tenantId: message.tenantId,
|
|
207
|
+
organizationId: message.organizationId ?? null,
|
|
207
208
|
})
|
|
208
209
|
}
|
|
209
210
|
|
|
@@ -217,12 +218,11 @@ export async function sendMessageEmailToExternal(params: {
|
|
|
217
218
|
const { message, email, sender, objects, attachments } = params
|
|
218
219
|
const copy = await buildEmailCopy(message.sentAt ?? new Date())
|
|
219
220
|
const bodyHtml = await buildEmailBodyHtml(message)
|
|
220
|
-
const
|
|
221
|
-
logDebug('Sending external email
|
|
221
|
+
const emailAttachments = await mapAttachmentsForEmail(message.id, attachments)
|
|
222
|
+
logDebug('Sending external email', {
|
|
222
223
|
messageId: message.id,
|
|
223
224
|
email,
|
|
224
|
-
attachmentsCount:
|
|
225
|
-
hasApiKey: Boolean(process.env.RESEND_API_KEY),
|
|
225
|
+
attachmentsCount: emailAttachments.length,
|
|
226
226
|
from: resolveDefaultEmailFromAddress() ?? null,
|
|
227
227
|
})
|
|
228
228
|
|
|
@@ -240,9 +240,11 @@ export async function sendMessageEmailToExternal(params: {
|
|
|
240
240
|
attachmentNames: attachments.map((item) => item.fileName),
|
|
241
241
|
objectLabels: resolveObjectLabels(objects),
|
|
242
242
|
}),
|
|
243
|
-
attachments:
|
|
243
|
+
attachments: emailAttachments,
|
|
244
|
+
tenantId: message.tenantId,
|
|
245
|
+
organizationId: message.organizationId ?? null,
|
|
244
246
|
})
|
|
245
|
-
logDebug('External email sent
|
|
247
|
+
logDebug('External email sent', {
|
|
246
248
|
messageId: message.id,
|
|
247
249
|
email,
|
|
248
250
|
})
|
|
@@ -24,7 +24,7 @@ export const emailDeliveryStrategy: NotificationDeliveryStrategy = {
|
|
|
24
24
|
defaultEnabled: true,
|
|
25
25
|
isConfigured: (ctx: NotificationDeliveryContext) => ctx.deliveryConfig.strategies.email.enabled === true,
|
|
26
26
|
async deliver(ctx: NotificationDeliveryContext) {
|
|
27
|
-
const { recipient, panelLink, title, body, actionLinks, deliveryConfig, t } = ctx
|
|
27
|
+
const { notification, recipient, panelLink, title, body, actionLinks, deliveryConfig, t } = ctx
|
|
28
28
|
if (!recipient?.email || !panelLink) {
|
|
29
29
|
debug('email skipped: missing recipient email or panelLink')
|
|
30
30
|
return
|
|
@@ -48,6 +48,8 @@ export const emailDeliveryStrategy: NotificationDeliveryStrategy = {
|
|
|
48
48
|
subject,
|
|
49
49
|
from: deliveryConfig.strategies.email.from,
|
|
50
50
|
replyTo: deliveryConfig.strategies.email.replyTo,
|
|
51
|
+
tenantId: notification.tenantId,
|
|
52
|
+
organizationId: notification.organizationId ?? null,
|
|
51
53
|
react: NotificationEmail({
|
|
52
54
|
title,
|
|
53
55
|
body,
|
|
@@ -169,6 +169,8 @@ export async function POST(req: Request) {
|
|
|
169
169
|
orderNumber,
|
|
170
170
|
}),
|
|
171
171
|
react: QuoteAcceptedAdminEmail({ orderUrl, copy }),
|
|
172
|
+
tenantId: quote.tenantId,
|
|
173
|
+
organizationId: quote.organizationId,
|
|
172
174
|
})
|
|
173
175
|
} catch (err) {
|
|
174
176
|
logger.error('sales.quotes.accept.adminEmail failed', { err })
|
|
@@ -18,6 +18,7 @@ import crypto from 'node:crypto'
|
|
|
18
18
|
import { withScopedPayload } from '../../utils'
|
|
19
19
|
import { hashAuthToken } from '../../../../auth/lib/tokenHash'
|
|
20
20
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
21
|
+
import { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
|
|
21
22
|
import { SalesQuote } from '../../../data/entities'
|
|
22
23
|
import { quoteSendSchema } from '../../../data/validators'
|
|
23
24
|
import { sendEmail } from '@open-mercato/shared/lib/email/send'
|
|
@@ -112,8 +113,16 @@ async function resolveRequestContext(req: Request): Promise<RequestContext> {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
function resolveQuoteEmail(quote: SalesQuote): string | null {
|
|
115
|
-
const
|
|
116
|
-
|
|
116
|
+
const normalizeRecord = (value: unknown): Record<string, unknown> | null => {
|
|
117
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
|
|
118
|
+
if (typeof value !== 'string') return null
|
|
119
|
+
const parsed = parseDecryptedFieldValue(value)
|
|
120
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
121
|
+
? parsed as Record<string, unknown>
|
|
122
|
+
: null
|
|
123
|
+
}
|
|
124
|
+
const snapshot = normalizeRecord(quote.customerSnapshot)
|
|
125
|
+
const metadata = normalizeRecord(quote.metadata)
|
|
117
126
|
const contact = snapshot?.contact as Record<string, unknown> | undefined
|
|
118
127
|
const customer = snapshot?.customer as Record<string, unknown> | undefined
|
|
119
128
|
const candidate =
|
|
@@ -148,7 +157,9 @@ export async function POST(req: Request) {
|
|
|
148
157
|
}
|
|
149
158
|
|
|
150
159
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
151
|
-
const tenantScope = ctx.auth?.tenantId
|
|
160
|
+
const tenantScope = ctx.auth?.tenantId
|
|
161
|
+
? { tenantId: ctx.auth.tenantId, organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null }
|
|
162
|
+
: undefined
|
|
152
163
|
const quote = await findOneWithDecryption(em, SalesQuote, { id: input.quoteId, deletedAt: null }, {}, tenantScope)
|
|
153
164
|
if (!quote) {
|
|
154
165
|
throw notFound(translate('sales.documents.detail.error', 'Document not found or inaccessible.'))
|
|
@@ -216,6 +227,8 @@ export async function POST(req: Request) {
|
|
|
216
227
|
to: email,
|
|
217
228
|
subject: translate('sales.quotes.email.subject', 'Quote {quoteNumber}', { quoteNumber: quote.quoteNumber }),
|
|
218
229
|
react: QuoteSentEmail({ url, copy }),
|
|
230
|
+
tenantId: quote.tenantId,
|
|
231
|
+
organizationId: quote.organizationId,
|
|
219
232
|
})
|
|
220
233
|
|
|
221
234
|
if (guardResult.afterSuccessCallbacks.length) {
|