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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/communicationChannelsFixtures.js +77 -1
  3. package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
  4. package/dist/helpers/integration/crmFixtures.js +3 -0
  5. package/dist/helpers/integration/crmFixtures.js.map +2 -2
  6. package/dist/modules/auth/api/reset.js +7 -1
  7. package/dist/modules/auth/api/reset.js.map +2 -2
  8. package/dist/modules/auth/api/users/resend-invite/route.js +7 -1
  9. package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
  10. package/dist/modules/auth/commands/users.js +7 -1
  11. package/dist/modules/auth/commands/users.js.map +2 -2
  12. package/dist/modules/communication_channels/api/post/test-seed/route.js +101 -26
  13. package/dist/modules/communication_channels/api/post/test-seed/route.js.map +3 -3
  14. package/dist/modules/communication_channels/di.js +8 -0
  15. package/dist/modules/communication_channels/di.js.map +2 -2
  16. package/dist/modules/communication_channels/lib/ensure-system-email-channel.js +57 -0
  17. package/dist/modules/communication_channels/lib/ensure-system-email-channel.js.map +7 -0
  18. package/dist/modules/communication_channels/lib/system-email-provider-config.js +32 -0
  19. package/dist/modules/communication_channels/lib/system-email-provider-config.js.map +7 -0
  20. package/dist/modules/communication_channels/lib/system-email.js +190 -0
  21. package/dist/modules/communication_channels/lib/system-email.js.map +7 -0
  22. package/dist/modules/communication_channels/lib/test-seed.js +130 -1
  23. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  24. package/dist/modules/communication_channels/setup.js +56 -1
  25. package/dist/modules/communication_channels/setup.js.map +2 -2
  26. package/dist/modules/customer_accounts/api/admin/users-invite.js +1 -0
  27. package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
  28. package/dist/modules/customer_accounts/api/portal/users-invite.js +1 -0
  29. package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
  30. package/dist/modules/customer_accounts/api/signup.js +8 -4
  31. package/dist/modules/customer_accounts/api/signup.js.map +2 -2
  32. package/dist/modules/customer_accounts/lib/invitationEmail.js +3 -1
  33. package/dist/modules/customer_accounts/lib/invitationEmail.js.map +2 -2
  34. package/dist/modules/customers/api/interactions/route.js +3 -0
  35. package/dist/modules/customers/api/interactions/route.js.map +2 -2
  36. package/dist/modules/customers/commands/tags.js +8 -2
  37. package/dist/modules/customers/commands/tags.js.map +2 -2
  38. package/dist/modules/messages/lib/email-sender.js +16 -14
  39. package/dist/modules/messages/lib/email-sender.js.map +2 -2
  40. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +3 -1
  41. package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +2 -2
  42. package/dist/modules/sales/api/quotes/accept/route.js +3 -1
  43. package/dist/modules/sales/api/quotes/accept/route.js.map +2 -2
  44. package/dist/modules/sales/api/quotes/send/route.js +13 -4
  45. package/dist/modules/sales/api/quotes/send/route.js.map +3 -3
  46. package/dist/modules/staff/commands/timesheets-entries.js +4 -2
  47. package/dist/modules/staff/commands/timesheets-entries.js.map +2 -2
  48. package/dist/modules/staff/commands/timesheets-projects.js +4 -2
  49. package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
  50. package/package.json +7 -7
  51. package/src/helpers/integration/communicationChannelsFixtures.ts +129 -0
  52. package/src/helpers/integration/crmFixtures.ts +4 -1
  53. package/src/modules/auth/api/reset.ts +7 -1
  54. package/src/modules/auth/api/users/resend-invite/route.ts +7 -1
  55. package/src/modules/auth/commands/users.ts +7 -1
  56. package/src/modules/auth/i18n/de.json +4 -0
  57. package/src/modules/auth/i18n/en.json +4 -0
  58. package/src/modules/auth/i18n/es.json +4 -0
  59. package/src/modules/auth/i18n/ko.json +4 -0
  60. package/src/modules/auth/i18n/pl.json +4 -0
  61. package/src/modules/communication_channels/api/post/test-seed/route.ts +121 -29
  62. package/src/modules/communication_channels/di.ts +8 -0
  63. package/src/modules/communication_channels/lib/ensure-system-email-channel.ts +85 -0
  64. package/src/modules/communication_channels/lib/system-email-provider-config.ts +62 -0
  65. package/src/modules/communication_channels/lib/system-email.ts +316 -0
  66. package/src/modules/communication_channels/lib/test-seed.ts +198 -0
  67. package/src/modules/communication_channels/setup.ts +91 -1
  68. package/src/modules/customer_accounts/api/admin/users-invite.ts +1 -0
  69. package/src/modules/customer_accounts/api/portal/users-invite.ts +1 -0
  70. package/src/modules/customer_accounts/api/signup.ts +6 -2
  71. package/src/modules/customer_accounts/lib/invitationEmail.ts +3 -0
  72. package/src/modules/customers/api/interactions/route.ts +4 -0
  73. package/src/modules/customers/commands/tags.ts +12 -1
  74. package/src/modules/messages/lib/email-sender.ts +18 -16
  75. package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +3 -1
  76. package/src/modules/sales/api/quotes/accept/route.ts +2 -0
  77. package/src/modules/sales/api/quotes/send/route.ts +16 -3
  78. package/src/modules/staff/commands/timesheets-entries.ts +2 -0
  79. package/src/modules/staff/commands/timesheets-projects.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.7.1-develop.7149.1.7efa6e1612",
3
+ "version": "0.7.1-develop.7151.1.00d0391847",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -252,16 +252,16 @@
252
252
  "zod": "^4.4.3"
253
253
  },
254
254
  "peerDependencies": {
255
- "@open-mercato/ai-assistant": "0.7.1-develop.7149.1.7efa6e1612",
256
- "@open-mercato/shared": "0.7.1-develop.7149.1.7efa6e1612",
257
- "@open-mercato/ui": "0.7.1-develop.7149.1.7efa6e1612",
255
+ "@open-mercato/ai-assistant": "0.7.1-develop.7151.1.00d0391847",
256
+ "@open-mercato/shared": "0.7.1-develop.7151.1.00d0391847",
257
+ "@open-mercato/ui": "0.7.1-develop.7151.1.00d0391847",
258
258
  "react": "^19.0.0",
259
259
  "react-dom": "^19.0.0"
260
260
  },
261
261
  "devDependencies": {
262
- "@open-mercato/ai-assistant": "0.7.1-develop.7149.1.7efa6e1612",
263
- "@open-mercato/shared": "0.7.1-develop.7149.1.7efa6e1612",
264
- "@open-mercato/ui": "0.7.1-develop.7149.1.7efa6e1612",
262
+ "@open-mercato/ai-assistant": "0.7.1-develop.7151.1.00d0391847",
263
+ "@open-mercato/shared": "0.7.1-develop.7151.1.00d0391847",
264
+ "@open-mercato/ui": "0.7.1-develop.7151.1.00d0391847",
265
265
  "@testing-library/dom": "^10.4.1",
266
266
  "@testing-library/jest-dom": "^7.0.0",
267
267
  "@testing-library/react": "^16.3.1",
@@ -16,8 +16,39 @@ export type SeedAddressField =
16
16
  | { address: string; name?: string }
17
17
  | Array<string | { address: string; name?: string }>;
18
18
 
19
+ export type CapturedTestSeedEmail = {
20
+ capturedAt: string;
21
+ externalMessageId: string;
22
+ conversationId?: string;
23
+ content: {
24
+ text?: string;
25
+ html?: string;
26
+ bodyFormat?: 'text' | 'markdown' | 'html';
27
+ attachments?: Array<{ url: string; mimeType: string; fileName: string; fileSize?: number; inline?: boolean }>;
28
+ raw?: Record<string, unknown>;
29
+ };
30
+ credentials: Record<string, unknown>;
31
+ scope: {
32
+ tenantId: string;
33
+ organizationId: string;
34
+ };
35
+ metadata?: Record<string, unknown>;
36
+ };
37
+
19
38
  const TEST_SEED_PATH = '/api/communication_channels/test-seed';
20
39
 
40
+ function resolveSystemCaptureCredentials(): {
41
+ accessToken: string;
42
+ captureCorrelationToken: string;
43
+ } {
44
+ const accessToken = process.env.OM_TEST_EMAIL_CAPTURE_ACCESS_TOKEN?.trim();
45
+ const captureCorrelationToken = process.env.OM_TEST_EMAIL_CAPTURE_CORRELATION_TOKEN?.trim();
46
+ if (!accessToken || !captureCorrelationToken) {
47
+ throw new Error('[internal] System email capture credentials are not configured by the integration harness');
48
+ }
49
+ return { accessToken, captureCorrelationToken };
50
+ }
51
+
21
52
  /**
22
53
  * Probe whether the env-gated test-seed endpoint is enabled in the target app.
23
54
  * Returns false when the route answers 404 (flag off) so callers can `test.skip`.
@@ -80,6 +111,104 @@ export async function seedConnectedChannel(
80
111
  return expectId(body?.channelId, 'connect-channel response should include channelId');
81
112
  }
82
113
 
114
+ export async function seedSystemEmailChannel(
115
+ request: APIRequestContext,
116
+ token: string,
117
+ input: { displayName?: string; externalIdentifier?: string } = {},
118
+ ): Promise<string> {
119
+ const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {
120
+ token,
121
+ data: { action: 'seed-system-channel', ...input },
122
+ });
123
+ expect(
124
+ response.status(),
125
+ 'POST /api/communication_channels/test-seed (seed-system-channel) should return 201',
126
+ ).toBe(201);
127
+ const body = await readJsonSafe<{ channelId?: string }>(response);
128
+ return expectId(body?.channelId, 'seed-system-channel response should include channelId');
129
+ }
130
+
131
+ export async function clearCapturedSystemEmails(
132
+ request: APIRequestContext,
133
+ token: string,
134
+ options: { systemRecipient?: string } = {},
135
+ ): Promise<void> {
136
+ const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;
137
+ const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {
138
+ token,
139
+ data: {
140
+ action: 'clear-capture',
141
+ ...options,
142
+ ...(captureCredentials
143
+ ? { captureCorrelationToken: captureCredentials.captureCorrelationToken }
144
+ : {}),
145
+ },
146
+ headers: captureCredentials
147
+ ? { 'x-om-test-email-capture-access-token': captureCredentials.accessToken }
148
+ : undefined,
149
+ });
150
+ expect(response.ok(), 'POST /api/communication_channels/test-seed (clear-capture) should succeed').toBeTruthy();
151
+ }
152
+
153
+ export async function listCapturedSystemEmails(
154
+ request: APIRequestContext,
155
+ token: string,
156
+ options: { systemRecipient?: string } = {},
157
+ ): Promise<CapturedTestSeedEmail[]> {
158
+ const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;
159
+ const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {
160
+ token,
161
+ data: {
162
+ action: 'list-capture',
163
+ ...options,
164
+ ...(captureCredentials
165
+ ? { captureCorrelationToken: captureCredentials.captureCorrelationToken }
166
+ : {}),
167
+ },
168
+ headers: captureCredentials
169
+ ? { 'x-om-test-email-capture-access-token': captureCredentials.accessToken }
170
+ : undefined,
171
+ });
172
+ expect(response.ok(), 'POST /api/communication_channels/test-seed (list-capture) should succeed').toBeTruthy();
173
+ const body = await readJsonSafe<{ items?: CapturedTestSeedEmail[] }>(response);
174
+ return Array.isArray(body?.items) ? body.items : [];
175
+ }
176
+
177
+ export async function waitForCapturedSystemEmail(
178
+ request: APIRequestContext,
179
+ token: string,
180
+ predicate: (email: CapturedTestSeedEmail) => boolean,
181
+ options: {
182
+ timeoutMs?: number;
183
+ intervalMs?: number;
184
+ description?: string;
185
+ systemRecipient?: string;
186
+ } = {},
187
+ ): Promise<CapturedTestSeedEmail> {
188
+ const timeoutMs = options.timeoutMs ?? 20_000;
189
+ const intervalMs = options.intervalMs ?? 1_000;
190
+ const deadline = Date.now() + timeoutMs;
191
+ let lastItems: CapturedTestSeedEmail[] = [];
192
+ while (Date.now() < deadline) {
193
+ lastItems = await listCapturedSystemEmails(request, token, {
194
+ systemRecipient: options.systemRecipient,
195
+ });
196
+ const found = lastItems.find(predicate);
197
+ if (found) return found;
198
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
199
+ }
200
+ const summary = lastItems.map((item) => ({
201
+ to: item.metadata?.to,
202
+ from: item.metadata?.from,
203
+ subject: item.metadata?.subject,
204
+ scope: item.scope,
205
+ bodyFormat: item.content?.bodyFormat,
206
+ }));
207
+ throw new Error(
208
+ `Timed out waiting for captured system email${options.description ? `: ${options.description}` : ''}. Captured ${lastItems.length} message(s): ${JSON.stringify(summary)}`,
209
+ );
210
+ }
211
+
83
212
  /**
84
213
  * Seed an inbound `MessageChannelLink` for `channelId` and emit
85
214
  * `communication_channels.message.received` through the real event bus. The
@@ -55,7 +55,7 @@ export async function createCompanyFixture(
55
55
  export async function createPersonFixture(
56
56
  request: APIRequestContext,
57
57
  token: string,
58
- input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string },
58
+ input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string; primaryEmail?: string },
59
59
  ): Promise<string> {
60
60
  const data: Record<string, unknown> = {
61
61
  firstName: input.firstName,
@@ -65,6 +65,9 @@ export async function createPersonFixture(
65
65
  if (input.companyEntityId) {
66
66
  data.companyEntityId = input.companyEntityId;
67
67
  }
68
+ if (input.primaryEmail) {
69
+ data.primaryEmail = input.primaryEmail;
70
+ }
68
71
  return createEntity(request, token, '/api/customers/people', data, ['id', 'entityId', 'personId']);
69
72
  }
70
73
 
@@ -76,7 +76,13 @@ export async function POST(req: Request) {
76
76
  }
77
77
 
78
78
  try {
79
- await sendEmail({ to: user.email, subject, react: ResetPasswordEmail({ resetUrl, copy }) })
79
+ await sendEmail({
80
+ to: user.email,
81
+ subject,
82
+ react: ResetPasswordEmail({ resetUrl, copy }),
83
+ tenantId: user.tenantId ? String(user.tenantId) : undefined,
84
+ organizationId: user.organizationId ? String(user.organizationId) : null,
85
+ })
80
86
  } catch (err) {
81
87
  logger.error('Failed to send reset email', { err })
82
88
  }
@@ -175,7 +175,13 @@ export async function POST(req: Request) {
175
175
 
176
176
  let emailSent = true
177
177
  try {
178
- await sendEmail({ to: user.email, subject, react: InviteUserEmail({ inviteUrl, copy }) })
178
+ await sendEmail({
179
+ to: user.email,
180
+ subject,
181
+ react: InviteUserEmail({ inviteUrl, copy }),
182
+ tenantId: user.tenantId ? String(user.tenantId) : auth.tenantId ?? undefined,
183
+ organizationId: user.organizationId ? String(user.organizationId) : null,
184
+ })
179
185
  } catch (err) {
180
186
  logger.error('Failed to send invitation email', { err })
181
187
  emailSent = false
@@ -504,7 +504,13 @@ async function sendInviteToUser(
504
504
 
505
505
  let emailSent = true
506
506
  try {
507
- await sendEmail({ to: user.email, subject, react: InviteUserEmail({ inviteUrl, copy }) })
507
+ await sendEmail({
508
+ to: user.email,
509
+ subject,
510
+ react: InviteUserEmail({ inviteUrl, copy }),
511
+ tenantId: user.tenantId ? String(user.tenantId) : undefined,
512
+ organizationId: user.organizationId ? String(user.organizationId) : null,
513
+ })
508
514
  } catch (err) {
509
515
  logger.error('Failed to send invitation email', { err })
510
516
  emailSent = false
@@ -66,6 +66,10 @@
66
66
  "auth.acl.features.channel_gmail.view": "View Gmail channel configuration",
67
67
  "auth.acl.features.channel_imap.configure": "Configure IMAP channel defaults",
68
68
  "auth.acl.features.channel_imap.view": "View IMAP channel configuration",
69
+ "auth.acl.features.channel_resend.configure": "Configure Resend channel defaults",
70
+ "auth.acl.features.channel_resend.view": "View Resend channel configuration",
71
+ "auth.acl.features.channel_ses.configure": "Configure Amazon SES channel defaults",
72
+ "auth.acl.features.channel_ses.view": "View Amazon SES channel configuration",
69
73
  "auth.acl.features.checkout.create": "Create checkout links and templates",
70
74
  "auth.acl.features.checkout.delete": "Delete checkout links and templates",
71
75
  "auth.acl.features.checkout.edit": "Edit checkout links and templates",
@@ -66,6 +66,10 @@
66
66
  "auth.acl.features.channel_gmail.view": "View Gmail channel configuration",
67
67
  "auth.acl.features.channel_imap.configure": "Configure IMAP channel defaults",
68
68
  "auth.acl.features.channel_imap.view": "View IMAP channel configuration",
69
+ "auth.acl.features.channel_resend.configure": "Configure Resend channel defaults",
70
+ "auth.acl.features.channel_resend.view": "View Resend channel configuration",
71
+ "auth.acl.features.channel_ses.configure": "Configure Amazon SES channel defaults",
72
+ "auth.acl.features.channel_ses.view": "View Amazon SES channel configuration",
69
73
  "auth.acl.features.checkout.create": "Create checkout links and templates",
70
74
  "auth.acl.features.checkout.delete": "Delete checkout links and templates",
71
75
  "auth.acl.features.checkout.edit": "Edit checkout links and templates",
@@ -66,6 +66,10 @@
66
66
  "auth.acl.features.channel_gmail.view": "View Gmail channel configuration",
67
67
  "auth.acl.features.channel_imap.configure": "Configure IMAP channel defaults",
68
68
  "auth.acl.features.channel_imap.view": "View IMAP channel configuration",
69
+ "auth.acl.features.channel_resend.configure": "Configure Resend channel defaults",
70
+ "auth.acl.features.channel_resend.view": "View Resend channel configuration",
71
+ "auth.acl.features.channel_ses.configure": "Configure Amazon SES channel defaults",
72
+ "auth.acl.features.channel_ses.view": "View Amazon SES channel configuration",
69
73
  "auth.acl.features.checkout.create": "Create checkout links and templates",
70
74
  "auth.acl.features.checkout.delete": "Delete checkout links and templates",
71
75
  "auth.acl.features.checkout.edit": "Edit checkout links and templates",
@@ -66,6 +66,10 @@
66
66
  "auth.acl.features.channel_gmail.view": "View Gmail channel configuration",
67
67
  "auth.acl.features.channel_imap.configure": "Configure IMAP channel defaults",
68
68
  "auth.acl.features.channel_imap.view": "View IMAP channel configuration",
69
+ "auth.acl.features.channel_resend.configure": "Configure Resend channel defaults",
70
+ "auth.acl.features.channel_resend.view": "View Resend channel configuration",
71
+ "auth.acl.features.channel_ses.configure": "Configure Amazon SES channel defaults",
72
+ "auth.acl.features.channel_ses.view": "View Amazon SES channel configuration",
69
73
  "auth.acl.features.checkout.create": "Create checkout links and templates",
70
74
  "auth.acl.features.checkout.delete": "Delete checkout links and templates",
71
75
  "auth.acl.features.checkout.edit": "Edit checkout links and templates",
@@ -66,6 +66,10 @@
66
66
  "auth.acl.features.channel_gmail.view": "Wyświetl konfigurację kanału Gmail",
67
67
  "auth.acl.features.channel_imap.configure": "Konfiguruj domyślne ustawienia kanału IMAP",
68
68
  "auth.acl.features.channel_imap.view": "Wyświetl konfigurację kanału IMAP",
69
+ "auth.acl.features.channel_resend.configure": "Konfiguruj domyślne ustawienia kanału Resend",
70
+ "auth.acl.features.channel_resend.view": "Wyświetl konfigurację kanału Resend",
71
+ "auth.acl.features.channel_ses.configure": "Konfiguruj domyślne ustawienia kanału Amazon SES",
72
+ "auth.acl.features.channel_ses.view": "Wyświetl konfigurację kanału Amazon SES",
69
73
  "auth.acl.features.checkout.create": "Twórz linki i szablony płatności",
70
74
  "auth.acl.features.checkout.delete": "Usuwaj linki i szablony płatności",
71
75
  "auth.acl.features.checkout.edit": "Edytuj linki i szablony płatności",
@@ -22,8 +22,12 @@ import { emitCommunicationChannelsEvent } from '../../../events'
22
22
  import {
23
23
  TEST_SEED_CHAT_PROVIDER_KEY,
24
24
  TEST_SEED_PROVIDER_KEY,
25
+ clearTestSeedCapturedMessages,
26
+ createTestSeedPlatformMessage,
25
27
  ensureTestSeedAdapterRegistered,
28
+ isTestEmailCaptureAccessAuthorized,
26
29
  isTestChannelSeedingEnabled,
30
+ listTestSeedCapturedMessages,
27
31
  } from '../../../lib/test-seed'
28
32
  import {
29
33
  COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,
@@ -135,6 +139,24 @@ const ingestInboundSchema = z.object({
135
139
  externalConversationId: z.string().min(1).max(255),
136
140
  })
137
141
 
142
+ const seedSystemChannelSchema = z.object({
143
+ action: z.literal('seed-system-channel'),
144
+ displayName: z.string().min(1).max(255).optional(),
145
+ externalIdentifier: z.string().min(1).max(255).optional(),
146
+ })
147
+
148
+ const clearCaptureSchema = z.object({
149
+ action: z.literal('clear-capture'),
150
+ systemRecipient: z.string().email().max(320).optional(),
151
+ captureCorrelationToken: z.string().min(32).max(512).optional(),
152
+ })
153
+
154
+ const listCaptureSchema = z.object({
155
+ action: z.literal('list-capture'),
156
+ systemRecipient: z.string().email().max(320).optional(),
157
+ captureCorrelationToken: z.string().min(32).max(512).optional(),
158
+ })
159
+
138
160
  const emitInboundSchema = z.object({
139
161
  action: z.literal('emit-inbound'),
140
162
  /** Channel that owns the inbound message; controls authorUserId + default visibility. */
@@ -172,6 +194,9 @@ const bodySchema = z.discriminatedUnion('action', [
172
194
  connectChannelSchema,
173
195
  ingestInboundSchema,
174
196
  emitInboundSchema,
197
+ seedSystemChannelSchema,
198
+ clearCaptureSchema,
199
+ listCaptureSchema,
175
200
  ])
176
201
 
177
202
  export async function POST(req: Request): Promise<Response> {
@@ -196,14 +221,97 @@ export async function POST(req: Request): Promise<Response> {
196
221
  )
197
222
  }
198
223
 
224
+ const tenantId = auth.tenantId as string
225
+ const organizationId = (auth as { orgId?: string | null }).orgId ?? null
226
+ const userId = auth.sub as string
227
+ const captureScope = { tenantId, organizationId }
228
+
229
+ if (
230
+ (body.action === 'clear-capture' || body.action === 'list-capture')
231
+ && body.systemRecipient
232
+ && (
233
+ !body.captureCorrelationToken
234
+ || !isTestEmailCaptureAccessAuthorized(req.headers.get('x-om-test-email-capture-access-token'))
235
+ )
236
+ ) {
237
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
238
+ }
239
+
240
+ if (body.action === 'clear-capture') {
241
+ await clearTestSeedCapturedMessages(captureScope, {
242
+ systemRecipient: body.systemRecipient,
243
+ captureCorrelationToken: body.captureCorrelationToken,
244
+ })
245
+ return NextResponse.json({ ok: true })
246
+ }
247
+
248
+ if (body.action === 'list-capture') {
249
+ return NextResponse.json({
250
+ items: (await listTestSeedCapturedMessages(captureScope, {
251
+ systemRecipient: body.systemRecipient,
252
+ captureCorrelationToken: body.captureCorrelationToken,
253
+ })).map(({ captureCorrelationToken: _captureCorrelationToken, ...message }) => message),
254
+ })
255
+ }
256
+
199
257
  const container = await createRequestContainer()
200
258
  // Defensive: make sure the stub adapter is registered for this process even if
201
259
  // a worker-only node skipped module di registration.
202
260
  ensureTestSeedAdapterRegistered()
203
261
 
204
- const tenantId = auth.tenantId as string
205
- const organizationId = (auth as { orgId?: string | null }).orgId ?? null
206
- const userId = auth.sub as string
262
+ if (body.action === 'seed-system-channel') {
263
+ const em = (container.resolve('em') as EntityManager).fork()
264
+ const credentialsService = container.resolve('integrationCredentialsService') as {
265
+ save: (
266
+ integrationId: string,
267
+ credentials: Record<string, unknown>,
268
+ scope: { organizationId: string; tenantId: string; userId?: string | null },
269
+ ) => Promise<void>
270
+ }
271
+ await credentialsService.save(
272
+ `channel_${TEST_SEED_PROVIDER_KEY}`,
273
+ { testSeed: true },
274
+ { tenantId, organizationId: organizationId ?? tenantId, userId: null },
275
+ )
276
+ let channel = await findOneWithDecryption(
277
+ em,
278
+ CommunicationChannel,
279
+ {
280
+ providerKey: TEST_SEED_PROVIDER_KEY,
281
+ channelType: 'email',
282
+ tenantId,
283
+ organizationId,
284
+ userId: null,
285
+ deletedAt: null,
286
+ },
287
+ undefined,
288
+ { tenantId, organizationId },
289
+ )
290
+ if (!channel) {
291
+ const stamp = Date.now()
292
+ channel = em.create(CommunicationChannel, {
293
+ providerKey: TEST_SEED_PROVIDER_KEY,
294
+ channelType: 'email',
295
+ displayName: body.displayName ?? `Test Seed System Email ${stamp}`,
296
+ externalIdentifier: body.externalIdentifier ?? `system-${stamp}@test-seed.local`,
297
+ userId: null,
298
+ isPrimary: false,
299
+ isActive: true,
300
+ status: 'connected',
301
+ tenantId,
302
+ organizationId,
303
+ })
304
+ em.persist(channel)
305
+ await em.flush()
306
+ } else if (!channel.isActive || channel.status !== 'connected') {
307
+ channel.isActive = true
308
+ channel.status = 'connected'
309
+ channel.lastError = null
310
+ await em.flush()
311
+ }
312
+
313
+ return NextResponse.json({ channelId: channel.id }, { status: 201 })
314
+ }
207
315
 
208
316
  if (body.action === 'connect-channel') {
209
317
  const stamp = Date.now()
@@ -424,32 +532,16 @@ export async function POST(req: Request): Promise<Response> {
424
532
  em.persist(conversation)
425
533
  await em.flush()
426
534
 
427
- // Insert the platform `messages.message` row via raw SQL rather than importing
428
- // the messages module's entity class (cross-module ORM coupling rule). Only
429
- // `thread_id` matters for the hub-thread inheritance join (TC-CRM-EMAIL-005);
430
- // the rest satisfy NOT NULL constraints.
431
- const messageRows = (await em.getConnection().execute(
432
- `INSERT INTO messages
433
- (type, thread_id, sender_user_id, subject, body, body_format, priority, status,
434
- is_draft, sent_at, visibility, source_entity_type, source_entity_id,
435
- tenant_id, organization_id, created_at, updated_at)
436
- VALUES
437
- (?, ?, ?, ?, ?, 'text', 'normal', 'sent',
438
- false, now(), 'public', 'communication_channels.test_seed_inbound', ?,
439
- ?, ?, now(), now())
440
- RETURNING id`,
441
- [
442
- `channel.${providerKey}`,
443
- body.messageThreadId ?? null,
444
- userId,
445
- body.subject ?? '(no subject)',
446
- body.bodyText ?? '',
447
- body.channelId,
448
- tenantId,
449
- organizationId,
450
- ],
451
- )) as Array<{ id: string }>
452
- const messageId = messageRows[0]?.id
535
+ const messageId = await createTestSeedPlatformMessage(em, {
536
+ providerKey,
537
+ threadId: body.messageThreadId,
538
+ senderUserId: userId,
539
+ subject: body.subject,
540
+ bodyText: body.bodyText,
541
+ channelId: body.channelId,
542
+ tenantId,
543
+ organizationId,
544
+ })
453
545
  if (!messageId) {
454
546
  return NextResponse.json({ error: '[internal] failed to seed message row' }, { status: 500 })
455
547
  }
@@ -1,5 +1,6 @@
1
1
  import { asValue } from 'awilix'
2
2
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { registerEmailTransport } from '@open-mercato/shared/lib/email/transport'
3
4
  import {
4
5
  CommunicationChannel,
5
6
  ExternalConversation,
@@ -11,6 +12,7 @@ import {
11
12
  import { getChannelAdapterRegistry } from './lib/adapter-registry-singleton'
12
13
  import { ensureTestSeedAdapterRegistered } from './lib/test-seed'
13
14
  import { sendAsUser } from './lib/send-as-user'
15
+ import { isSystemEmailTransportConfigured, sendSystemEmail } from './lib/system-email'
14
16
  import { resolveChannelTypeSafely } from './lib/resolve-channel-type'
15
17
 
16
18
  export function register(container: AppContainer) {
@@ -19,6 +21,11 @@ export function register(container: AppContainer) {
19
21
  // integration harness connect a channel + complete the outbound send chain.
20
22
  // See lib/test-seed.ts.
21
23
  ensureTestSeedAdapterRegistered()
24
+ registerEmailTransport({
25
+ id: 'communication_channels',
26
+ send: (payload) => sendSystemEmail(container, payload),
27
+ isConfigured: isSystemEmailTransportConfigured,
28
+ })
22
29
 
23
30
  container.register({
24
31
  // Entity class registrations (for EntityManager lookups by string)
@@ -37,6 +44,7 @@ export function register(container: AppContainer) {
37
44
  // In-process send-as-user facade. Cross-module callers (e.g. the customers
38
45
  // compose route) resolve this instead of making an HTTP self-call.
39
46
  communicationChannelsSendAsUser: asValue(sendAsUser),
47
+ communicationChannelsSendSystemEmail: asValue(sendSystemEmail),
40
48
 
41
49
  // Cross-module channel-type lookup. The messages compose route resolves this
42
50
  // to decide whether an external correspondent must carry an email address
@@ -0,0 +1,85 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
4
+ import { CommunicationChannel } from '../data/entities'
5
+
6
+ const logger = createLogger('communication_channels')
7
+
8
+ export type EnsureSystemEmailChannelInput = {
9
+ tenantId: string
10
+ organizationId: string
11
+ providerKey: string
12
+ externalIdentifier: string
13
+ displayName?: string
14
+ capabilities?: Record<string, unknown>
15
+ }
16
+
17
+ export type EnsureSystemEmailChannelResult = 'created' | 'updated' | 'failed'
18
+
19
+ /**
20
+ * Idempotently create or refresh a tenant's system email channel row.
21
+ *
22
+ * Shared by the two places that need the same row: the provider env preset, which runs at tenant setup
23
+ * and again on every `yarn mercato seed:defaults --module channel_<provider>` used to migrate tenants
24
+ * that predate the Communications Hub, and the lazy repair in `sendSystemEmail` for a tenant whose
25
+ * credentials were saved through the integrations admin UI (which stores credentials but has no hook
26
+ * to create the channel).
27
+ *
28
+ * Never throws: a channel row is a convenience for the Hub UI and for later lookups, so a failure to
29
+ * write one must not take down the caller — an email send in particular still has usable credentials.
30
+ */
31
+ export async function ensureSystemEmailChannel(
32
+ em: EntityManager,
33
+ input: EnsureSystemEmailChannelInput,
34
+ ): Promise<EnsureSystemEmailChannelResult> {
35
+ const { tenantId, organizationId, providerKey, externalIdentifier } = input
36
+ const displayName = input.displayName ?? `${providerKey} system email`
37
+ const dscope = { tenantId, organizationId }
38
+
39
+ try {
40
+ const existing = await findOneWithDecryption(
41
+ em,
42
+ CommunicationChannel,
43
+ {
44
+ providerKey,
45
+ channelType: 'email',
46
+ tenantId,
47
+ organizationId,
48
+ userId: null,
49
+ deletedAt: null,
50
+ },
51
+ undefined,
52
+ dscope,
53
+ )
54
+
55
+ if (existing) {
56
+ existing.displayName = displayName
57
+ existing.externalIdentifier = externalIdentifier
58
+ if (input.capabilities) existing.capabilities = { ...input.capabilities }
59
+ existing.isActive = true
60
+ existing.status = 'connected'
61
+ existing.lastError = null
62
+ await em.flush()
63
+ return 'updated'
64
+ }
65
+
66
+ const channel = em.create(CommunicationChannel, {
67
+ providerKey,
68
+ channelType: 'email',
69
+ displayName,
70
+ externalIdentifier,
71
+ capabilities: { ...(input.capabilities ?? {}) },
72
+ isActive: true,
73
+ status: 'connected',
74
+ userId: null,
75
+ pollIntervalSeconds: null,
76
+ tenantId,
77
+ organizationId,
78
+ })
79
+ await em.persist(channel).flush()
80
+ return 'created'
81
+ } catch (err) {
82
+ logger.warn('Could not ensure system email channel', { err, tenantId, providerKey })
83
+ return 'failed'
84
+ }
85
+ }