@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.
Files changed (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +77 -1
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/helpers/integration/crmFixtures.js +3 -0
  5. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  6. package/dist/modules/auth/api/reset.js +7 -1
  7. package/dist/modules/auth/api/reset.js.map +2 -2
  8. package/dist/modules/auth/api/users/resend-invite/route.js +7 -1
  9. package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
  10. package/dist/modules/auth/commands/users.js +7 -1
  11. package/dist/modules/auth/commands/users.js.map +2 -2
  12. package/dist/modules/communication_channels/api/post/test-seed/route.js +101 -26
  13. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +3 -3
  14. package/dist/modules/communication_channels/di.js +8 -0
  15. package/dist/modules/communication_channels/di.js.map +2 -2
  16. package/dist/modules/communication_channels/lib/ensure-system-email-channel.js +57 -0
  17. package/dist/modules/communication_channels/lib/ensure-system-email-channel.js.map +7 -0
  18. package/dist/modules/communication_channels/lib/system-email-provider-config.js +32 -0
  19. package/dist/modules/communication_channels/lib/system-email-provider-config.js.map +7 -0
  20. package/dist/modules/communication_channels/lib/system-email.js +190 -0
  21. package/dist/modules/communication_channels/lib/system-email.js.map +7 -0
  22. package/dist/modules/communication_channels/lib/test-seed.js +130 -1
  23. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  24. package/dist/modules/communication_channels/setup.js +56 -1
  25. package/dist/modules/communication_channels/setup.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/admin/users-invite.js +1 -0
  27. package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/portal/users-invite.js +1 -0
  29. package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/signup.js +8 -4
  31. package/dist/modules/customer_accounts/api/signup.js.map +2 -2
  32. package/dist/modules/customer_accounts/lib/invitationEmail.js +3 -1
  33. package/dist/modules/customer_accounts/lib/invitationEmail.js.map +2 -2
  34. package/dist/modules/customers/api/interactions/route.js +3 -0
  35. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  36. package/dist/modules/messages/lib/email-sender.js +16 -14
  37. package/dist/modules/messages/lib/email-sender.js.map +2 -2
  38. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +3 -1
  39. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +2 -2
  40. package/dist/modules/sales/api/quotes/accept/route.js +3 -1
  41. package/dist/modules/sales/api/quotes/accept/route.js.map +2 -2
  42. package/dist/modules/sales/api/quotes/send/route.js +13 -4
  43. package/dist/modules/sales/api/quotes/send/route.js.map +3 -3
  44. package/package.json +7 -7
  45. package/src/helpers/integration/communicationChannelsFixtures.ts +129 -0
  46. package/src/helpers/integration/crmFixtures.ts +4 -1
  47. package/src/modules/auth/api/reset.ts +7 -1
  48. package/src/modules/auth/api/users/resend-invite/route.ts +7 -1
  49. package/src/modules/auth/commands/users.ts +7 -1
  50. package/src/modules/auth/i18n/de.json +4 -0
  51. package/src/modules/auth/i18n/en.json +4 -0
  52. package/src/modules/auth/i18n/es.json +4 -0
  53. package/src/modules/auth/i18n/ko.json +4 -0
  54. package/src/modules/auth/i18n/pl.json +4 -0
  55. package/src/modules/communication_channels/api/post/test-seed/route.ts +121 -29
  56. package/src/modules/communication_channels/di.ts +8 -0
  57. package/src/modules/communication_channels/lib/ensure-system-email-channel.ts +85 -0
  58. package/src/modules/communication_channels/lib/system-email-provider-config.ts +62 -0
  59. package/src/modules/communication_channels/lib/system-email.ts +316 -0
  60. package/src/modules/communication_channels/lib/test-seed.ts +198 -0
  61. package/src/modules/communication_channels/setup.ts +91 -1
  62. package/src/modules/customer_accounts/api/admin/users-invite.ts +1 -0
  63. package/src/modules/customer_accounts/api/portal/users-invite.ts +1 -0
  64. package/src/modules/customer_accounts/api/signup.ts +6 -2
  65. package/src/modules/customer_accounts/lib/invitationEmail.ts +3 -0
  66. package/src/modules/customers/api/interactions/route.ts +4 -0
  67. package/src/modules/messages/lib/email-sender.ts +18 -16
  68. package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +3 -1
  69. package/src/modules/sales/api/quotes/accept/route.ts +2 -0
  70. package/src/modules/sales/api/quotes/send/route.ts +16 -3
@@ -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 { createLogger } from '@open-mercato/shared/lib/logger'
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
  *
@@ -208,6 +208,7 @@ export async function POST(req: Request) {
208
208
  try {
209
209
  await sendCustomerInvitationEmail({
210
210
  container,
211
+ tenantId: auth.tenantId!,
211
212
  organizationId: auth.orgId!,
212
213
  email: invitation.email,
213
214
  rawToken,
@@ -108,6 +108,7 @@ export async function POST(req: Request) {
108
108
  try {
109
109
  await sendCustomerInvitationEmail({
110
110
  container,
111
+ tenantId: auth.tenantId,
111
112
  organizationId: auth.orgId,
112
113
  email: invitation.email,
113
114
  rawToken,
@@ -140,10 +140,12 @@ export async function POST(req: Request) {
140
140
  ),
141
141
  }
142
142
 
143
- void sendEmail({
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
- void sendEmail({
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 ResendAttachment = {
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<ResendAttachment[]> {
60
- const resendAttachments: ResendAttachment[] = []
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
- resendAttachments.push({
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 resendAttachments
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 resendAttachments = await mapAttachmentsForEmail(message.id, attachments)
182
- logDebug('Sending recipient email via Resend', {
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: resendAttachments.length,
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: resendAttachments,
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 resendAttachments = await mapAttachmentsForEmail(message.id, attachments)
221
- logDebug('Sending external email via Resend', {
221
+ const emailAttachments = await mapAttachmentsForEmail(message.id, attachments)
222
+ logDebug('Sending external email', {
222
223
  messageId: message.id,
223
224
  email,
224
- attachmentsCount: resendAttachments.length,
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: resendAttachments,
243
+ attachments: emailAttachments,
244
+ tenantId: message.tenantId,
245
+ organizationId: message.organizationId ?? null,
244
246
  })
245
- logDebug('External email sent via Resend', {
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 snapshot = quote.customerSnapshot && typeof quote.customerSnapshot === 'object' ? (quote.customerSnapshot as Record<string, unknown>) : null
116
- const metadata = quote.metadata && typeof quote.metadata === 'object' ? (quote.metadata as Record<string, unknown>) : null
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 ? { tenantId: ctx.auth.tenantId } : undefined
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) {