@open-mercato/core 0.7.1-develop.7112.1.d486e6f1b2 → 0.7.1-develop.7120.1.a0154ac412

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/crmFixtures.js.map +1 -1
  3. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
  4. package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
  5. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +34 -2
  6. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  7. package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
  8. package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
  9. package/dist/modules/communication_channels/lib/outbound-reply-ref.js +49 -0
  10. package/dist/modules/communication_channels/lib/outbound-reply-ref.js.map +7 -0
  11. package/dist/modules/communication_channels/lib/test-seed.js +5 -0
  12. package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
  13. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
  14. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
  15. package/dist/modules/customers/backend/customers/deals/page.js +4 -3
  16. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  17. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
  18. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
  19. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
  20. package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
  21. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
  22. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  23. package/dist/modules/customers/cli.js +5 -5
  24. package/dist/modules/customers/cli.js.map +2 -2
  25. package/dist/modules/customers/commands/deals.js.map +2 -2
  26. package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
  27. package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
  28. package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
  29. package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
  30. package/dist/modules/customers/lib/closureStage.js.map +2 -2
  31. package/dist/modules/customers/lib/dealStatus.js +6 -4
  32. package/dist/modules/customers/lib/dealStatus.js.map +2 -2
  33. package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
  34. package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
  35. package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
  36. package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
  37. package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
  38. package/dist/modules/eudr/notifications.js +5 -0
  39. package/dist/modules/eudr/notifications.js.map +2 -2
  40. package/dist/modules/query_index/lib/search-tokens.js +59 -28
  41. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  42. package/dist/modules/sales/commands/documents.js +120 -37
  43. package/dist/modules/sales/commands/documents.js.map +2 -2
  44. package/dist/modules/warranty_claims/notifications.js +5 -0
  45. package/dist/modules/warranty_claims/notifications.js.map +2 -2
  46. package/dist/modules/wms/notifications.js +2 -0
  47. package/dist/modules/wms/notifications.js.map +2 -2
  48. package/package.json +7 -7
  49. package/src/helpers/integration/crmFixtures.ts +1 -1
  50. package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
  51. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +45 -1
  52. package/src/modules/communication_channels/lib/adapter.ts +11 -0
  53. package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
  54. package/src/modules/communication_channels/lib/outbound-reply-ref.ts +138 -0
  55. package/src/modules/communication_channels/lib/test-seed.ts +19 -0
  56. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
  57. package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
  58. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
  59. package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
  60. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
  61. package/src/modules/customers/cli.ts +5 -5
  62. package/src/modules/customers/commands/deals.ts +4 -3
  63. package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
  64. package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
  65. package/src/modules/customers/i18n/de.json +1 -1
  66. package/src/modules/customers/i18n/en.json +1 -1
  67. package/src/modules/customers/i18n/es.json +1 -1
  68. package/src/modules/customers/i18n/ko.json +1 -1
  69. package/src/modules/customers/i18n/pl.json +1 -1
  70. package/src/modules/customers/lib/closureStage.ts +2 -1
  71. package/src/modules/customers/lib/dealStatus.ts +23 -12
  72. package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
  73. package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
  74. package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
  75. package/src/modules/eudr/notifications.ts +5 -0
  76. package/src/modules/query_index/lib/search-tokens.ts +100 -43
  77. package/src/modules/sales/commands/documents.ts +153 -39
  78. package/src/modules/warranty_claims/notifications.ts +5 -0
  79. package/src/modules/wms/notifications.ts +2 -0
@@ -1,4 +1,4 @@
1
- [build:core] found 4364 entry points
1
+ [build:core] found 4366 entry points
2
2
  [build:core] built successfully
3
3
  [build:core:generated] found 218 entry points
4
4
  [build:core:generated] built successfully
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/helpers/integration/crmFixtures.ts"],
4
- "sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { readJsonSafe } from './generalFixtures';\n\ntype JsonRecord = Record<string, unknown>;\n\nexport { readJsonSafe } from './generalFixtures';\n\nfunction isRecord(value: unknown): value is JsonRecord {\n return typeof value === 'object' && value !== null;\n}\n\nfunction findStringByKeys(value: unknown, keys: readonly string[]): string | null {\n if (!isRecord(value)) return null;\n\n for (const key of keys) {\n const candidate = value[key];\n if (typeof candidate === 'string' && candidate.trim().length > 0) {\n return candidate.trim();\n }\n }\n\n for (const nested of Object.values(value)) {\n if (Array.isArray(nested)) continue;\n const found = findStringByKeys(nested, keys);\n if (found) return found;\n }\n\n return null;\n}\n\nasync function createEntity(\n request: APIRequestContext,\n token: string,\n path: string,\n data: Record<string, unknown>,\n idKeys: readonly string[],\n): Promise<string> {\n const response = await apiRequest(request, 'POST', path, { token, data });\n const payload = await readJsonSafe(response);\n expect(response.ok(), `Failed POST ${path}: ${response.status()}`).toBeTruthy();\n const id = findStringByKeys(payload, idKeys);\n expect(id, `No id in ${path} response`).toBeTruthy();\n return id as string;\n}\n\nexport async function createCompanyFixture(\n request: APIRequestContext,\n token: string,\n displayName: string,\n): Promise<string> {\n return createEntity(request, token, '/api/customers/companies', { displayName }, ['id', 'entityId', 'companyId']);\n}\n\nexport async function createPersonFixture(\n request: APIRequestContext,\n token: string,\n input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string },\n): Promise<string> {\n const data: Record<string, unknown> = {\n firstName: input.firstName,\n lastName: input.lastName,\n displayName: input.displayName,\n };\n if (input.companyEntityId) {\n data.companyEntityId = input.companyEntityId;\n }\n return createEntity(request, token, '/api/customers/people', data, ['id', 'entityId', 'personId']);\n}\n\nexport async function createDealFixture(\n request: APIRequestContext,\n token: string,\n input: {\n title: string;\n companyIds?: string[];\n personIds?: string[];\n pipelineId?: string;\n pipelineStageId?: string;\n valueAmount?: number;\n valueCurrency?: string;\n // Optional deal lifecycle fields \u2014 all valid on `dealCreateSchema`. Forwarded so KPI/summary\n // tests can seed won/lost/overdue/owned deals across quarters (see TC-CRM-082). `status` is a\n // free-form dictionary value (e.g. 'open', 'in_progress', 'win', 'loose'); `expectedCloseAt`\n // accepts an ISO string (the schema coerces it to a Date); `closureOutcome` is 'won' | 'lost'.\n status?: string;\n expectedCloseAt?: string;\n ownerUserId?: string;\n closureOutcome?: 'won' | 'lost';\n },\n): Promise<string> {\n const data: Record<string, unknown> = { title: input.title };\n if (input.companyIds?.length) data.companyIds = input.companyIds;\n if (input.personIds?.length) data.personIds = input.personIds;\n if (input.pipelineId) data.pipelineId = input.pipelineId;\n if (input.pipelineStageId) data.pipelineStageId = input.pipelineStageId;\n if (input.valueAmount !== undefined) data.valueAmount = input.valueAmount;\n if (input.valueCurrency) data.valueCurrency = input.valueCurrency;\n if (input.status) data.status = input.status;\n if (input.expectedCloseAt) data.expectedCloseAt = input.expectedCloseAt;\n if (input.ownerUserId) data.ownerUserId = input.ownerUserId;\n if (input.closureOutcome) data.closureOutcome = input.closureOutcome;\n return createEntity(request, token, '/api/customers/deals', data, ['dealId', 'id', 'entityId']);\n}\n\nexport async function createPipelineFixture(\n request: APIRequestContext,\n token: string,\n input: { name: string; isDefault?: boolean },\n): Promise<string> {\n const data: Record<string, unknown> = { name: input.name };\n if (input.isDefault !== undefined) data.isDefault = input.isDefault;\n return createEntity(request, token, '/api/customers/pipelines', data, ['id', 'pipelineId']);\n}\n\nexport async function createPipelineStageFixture(\n request: APIRequestContext,\n token: string,\n input: { pipelineId: string; label: string; order?: number },\n): Promise<string> {\n const data: Record<string, unknown> = { pipelineId: input.pipelineId, label: input.label };\n if (input.order !== undefined) data.order = input.order;\n return createEntity(request, token, '/api/customers/pipeline-stages', data, ['id', 'stageId']);\n}\n\nexport async function deleteEntityByBody(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', path, { token, data: { id } });\n } catch {\n return;\n }\n}\n\nexport async function deleteEntityIfExists(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', `${path}?id=${encodeURIComponent(id)}`, { token });\n } catch {\n return;\n }\n}\n"],
4
+ "sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { readJsonSafe } from './generalFixtures';\n\ntype JsonRecord = Record<string, unknown>;\n\nexport { readJsonSafe } from './generalFixtures';\n\nfunction isRecord(value: unknown): value is JsonRecord {\n return typeof value === 'object' && value !== null;\n}\n\nfunction findStringByKeys(value: unknown, keys: readonly string[]): string | null {\n if (!isRecord(value)) return null;\n\n for (const key of keys) {\n const candidate = value[key];\n if (typeof candidate === 'string' && candidate.trim().length > 0) {\n return candidate.trim();\n }\n }\n\n for (const nested of Object.values(value)) {\n if (Array.isArray(nested)) continue;\n const found = findStringByKeys(nested, keys);\n if (found) return found;\n }\n\n return null;\n}\n\nasync function createEntity(\n request: APIRequestContext,\n token: string,\n path: string,\n data: Record<string, unknown>,\n idKeys: readonly string[],\n): Promise<string> {\n const response = await apiRequest(request, 'POST', path, { token, data });\n const payload = await readJsonSafe(response);\n expect(response.ok(), `Failed POST ${path}: ${response.status()}`).toBeTruthy();\n const id = findStringByKeys(payload, idKeys);\n expect(id, `No id in ${path} response`).toBeTruthy();\n return id as string;\n}\n\nexport async function createCompanyFixture(\n request: APIRequestContext,\n token: string,\n displayName: string,\n): Promise<string> {\n return createEntity(request, token, '/api/customers/companies', { displayName }, ['id', 'entityId', 'companyId']);\n}\n\nexport async function createPersonFixture(\n request: APIRequestContext,\n token: string,\n input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string },\n): Promise<string> {\n const data: Record<string, unknown> = {\n firstName: input.firstName,\n lastName: input.lastName,\n displayName: input.displayName,\n };\n if (input.companyEntityId) {\n data.companyEntityId = input.companyEntityId;\n }\n return createEntity(request, token, '/api/customers/people', data, ['id', 'entityId', 'personId']);\n}\n\nexport async function createDealFixture(\n request: APIRequestContext,\n token: string,\n input: {\n title: string;\n companyIds?: string[];\n personIds?: string[];\n pipelineId?: string;\n pipelineStageId?: string;\n valueAmount?: number;\n valueCurrency?: string;\n // Optional deal lifecycle fields \u2014 all valid on `dealCreateSchema`. Forwarded so KPI/summary\n // tests can seed won/lost/overdue/owned deals across quarters (see TC-CRM-082). `status` is a\n // free-form dictionary value (e.g. 'open', 'in_progress', 'win', 'lost'); `expectedCloseAt`\n // accepts an ISO string (the schema coerces it to a Date); `closureOutcome` is 'won' | 'lost'.\n status?: string;\n expectedCloseAt?: string;\n ownerUserId?: string;\n closureOutcome?: 'won' | 'lost';\n },\n): Promise<string> {\n const data: Record<string, unknown> = { title: input.title };\n if (input.companyIds?.length) data.companyIds = input.companyIds;\n if (input.personIds?.length) data.personIds = input.personIds;\n if (input.pipelineId) data.pipelineId = input.pipelineId;\n if (input.pipelineStageId) data.pipelineStageId = input.pipelineStageId;\n if (input.valueAmount !== undefined) data.valueAmount = input.valueAmount;\n if (input.valueCurrency) data.valueCurrency = input.valueCurrency;\n if (input.status) data.status = input.status;\n if (input.expectedCloseAt) data.expectedCloseAt = input.expectedCloseAt;\n if (input.ownerUserId) data.ownerUserId = input.ownerUserId;\n if (input.closureOutcome) data.closureOutcome = input.closureOutcome;\n return createEntity(request, token, '/api/customers/deals', data, ['dealId', 'id', 'entityId']);\n}\n\nexport async function createPipelineFixture(\n request: APIRequestContext,\n token: string,\n input: { name: string; isDefault?: boolean },\n): Promise<string> {\n const data: Record<string, unknown> = { name: input.name };\n if (input.isDefault !== undefined) data.isDefault = input.isDefault;\n return createEntity(request, token, '/api/customers/pipelines', data, ['id', 'pipelineId']);\n}\n\nexport async function createPipelineStageFixture(\n request: APIRequestContext,\n token: string,\n input: { pipelineId: string; label: string; order?: number },\n): Promise<string> {\n const data: Record<string, unknown> = { pipelineId: input.pipelineId, label: input.label };\n if (input.order !== undefined) data.order = input.order;\n return createEntity(request, token, '/api/customers/pipeline-stages', data, ['id', 'stageId']);\n}\n\nexport async function deleteEntityByBody(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', path, { token, data: { id } });\n } catch {\n return;\n }\n}\n\nexport async function deleteEntityIfExists(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', `${path}?id=${encodeURIComponent(id)}`, { token });\n } catch {\n return;\n }\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": ["readJsonSafe"]
7
7
  }
@@ -28,7 +28,7 @@ const metadata = {
28
28
  }
29
29
  };
30
30
  const bodySchema = z.object({
31
- to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\r\n]*$/),
31
+ to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\r\n]*$/).optional(),
32
32
  subject: z.string().min(1).max(500).optional(),
33
33
  body: z.string().max(5e4).optional()
34
34
  });
@@ -168,7 +168,14 @@ async function POST(req, context) {
168
168
  tenantId: auth.tenantId,
169
169
  organizationId: channelCredentialsOrg
170
170
  },
171
- metadata: { to: body.to, subject: body.subject ?? "Test send", testSend: true }
171
+ // `to` is omitted from the metadata rather than passed as `undefined` when
172
+ // the caller left it out, so an adapter reading `metadata.to` sees no key
173
+ // at all and falls through to its own configured target.
174
+ metadata: {
175
+ ...body.to !== void 0 ? { to: body.to } : {},
176
+ subject: body.subject ?? "Test send",
177
+ testSend: true
178
+ }
172
179
  });
173
180
  await guard.afterSuccess();
174
181
  return NextResponse.json({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/communication_channels/api/post/channels/%5Bid%5D/test-send/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../../../../../data/entities'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport {\n ChannelAccessDeniedError,\n assertCanManageChannel,\n channelOrgScopeWhere,\n} from '../../../../../lib/access-control'\nimport { refreshCredentialsIfNeeded } from '../../../../../lib/credential-refresh'\nimport {\n MAX_OUTBOUND_RECIPIENT_LENGTH,\n validateOutboundRecipient,\n} from '../../../../../lib/outbound-recipient'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nexport const metadata = {\n path: '/communication_channels/channels/[id]/test-send',\n POST: {\n // Owner self-service: a user may send a test from their OWN mailbox (gated\n // by `connect_user_channel`). Test-sending from a shared/tenant-wide channel\n // still requires `manage` \u2014 enforced per channel type by `assertCanManageChannel`.\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\n// `to` is deliberately NOT typed as an email here. The recipient shape depends\n// on the provider (an email address for Gmail/IMAP, a channel snowflake for\n// Discord), and the adapter is only known once the channel is loaded \u2014 so the\n// schema accepts any non-empty single-line string and `validateOutboundRecipient`\n// applies the provider-appropriate rules below, defaulting to email. The CR/LF\n// rejection stays at the schema level so a header-injection attempt is refused\n// before the request reaches the channel lookup or the mutation guard.\nconst bodySchema = z.object({\n to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\\r\\n]*$/),\n subject: z.string().min(1).max(500).optional(),\n body: z.string().max(50_000).optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\n/**\n * Admin diagnostic \u2014 send a test message via the adapter without creating a\n * platform `Message`. Useful for verifying credentials + outbound connectivity\n * after a channel is configured.\n *\n * The result is NOT persisted to `ExternalMessage` / `MessageChannelLink` \u2014 it\n * is a one-shot probe.\n */\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof bodySchema>\n try {\n body = bodySchema.parse(await readJsonSafe(req, null))\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager).fork()\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const dscope = { tenantId: auth.tenantId as string, organizationId }\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id,\n tenantId: auth.tenantId as string,\n ...channelOrgScopeWhere(organizationId),\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n // Resolve credentials at the channel's OWN org, not the caller's selected org.\n // Tenant-wide channels store `organization_id = NULL` and their credentials\n // live at `organization_id = tenantId` (see connect-credential-channel.ts), so\n // the read key must be `channel.organizationId ?? tenantId` \u2014 keying on the\n // session org would resolve `{}` for a tenant-wide channel viewed from a\n // non-null org.\n const channelCredentialsOrg = channel.organizationId ?? (auth.tenantId as string)\n // Load features via RBAC service so admin bypass (`communication_channels.admin`,\n // wildcards, super-admin) is honoured. The `auth` object from\n // `getAuthFromRequest` carries identity only \u2014 feature ACLs live in the RBAC\n // service and must be loaded per request.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(auth.sub as string, {\n tenantId: auth.tenantId as string,\n organizationId,\n })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: channel.userId },\n auth.sub as string,\n userFeatures,\n 'communication_channels.manage',\n )\n } catch (err) {\n if (err instanceof ChannelAccessDeniedError) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n const status = (err as { statusCode?: number }).statusCode ?? 403\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Access denied' },\n { status },\n )\n }\n if (!channel.isActive || channel.status !== 'connected') {\n return NextResponse.json(\n { error: `Channel is in status '${channel.status}' (not connected)` },\n { status: 409 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n operation: 'custom',\n mutationPayload: body as unknown as Record<string, unknown>,\n },\n })\n if ('response' in guard) return guard.response\n\n const adapter = getChannelAdapter(channel.providerKey)\n if (!adapter) {\n return NextResponse.json(\n { error: `No adapter registered for provider '${channel.providerKey}'` },\n { status: 404 },\n )\n }\n\n const recipientCheck = validateOutboundRecipient(body.to, adapter.capabilities)\n if (!recipientCheck.ok) {\n return NextResponse.json({ error: recipientCheck.error }, { status: 422 })\n }\n\n // Resolve credentials + optionally refresh.\n let credentials: Record<string, unknown> = {}\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n if (channel.credentialsRef && credentialsService) {\n credentials =\n (await credentialsService\n .resolve(`channel_${channel.providerKey}`, {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n })\n .catch(() => null)) ?? {}\n }\n const credentialScope = {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n }\n const refreshed = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialScope,\n },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n try {\n const converted = await adapter.convertOutbound({\n body: body.body ?? 'Test message from Open Mercato',\n bodyFormat: 'text',\n })\n const result = await adapter.sendMessage({\n content: converted.content,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n },\n metadata: { to: body.to, subject: body.subject ?? 'Test send', testSend: true },\n })\n await guard.afterSuccess()\n return NextResponse.json({\n status: result.status,\n externalMessageId: result.externalMessageId,\n providerError: result.error ?? null,\n })\n } catch (err) {\n const message = err instanceof Error ? err.message : 'send failed'\n return NextResponse.json({ status: 'failed', error: message }, { status: 502 })\n }\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Diagnostic \u2014 send a test message through the channel without persisting',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Test send result' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Not allowed to manage this channel' },\n { status: 404, description: 'Channel or adapter not found' },\n { status: 409, description: 'Channel not connected' },\n { status: 422, description: 'Invalid request body' },\n { status: 502, description: 'Provider error' },\n ],\n },\n },\n}\nexport default POST\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,oBAAoB;AAE7B,SAAS,6BAA6B;AACtC,SAAS,4BAA4B;AACrC,SAAS,yBAAyB;AAClC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,kCAAkC;AAC3C;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,kCAAkC;AASpC,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,MAAM;AAAA;AAAA;AAAA;AAAA,IAIJ,aAAa;AAAA,IACb,iBAAiB,CAAC,6CAA6C;AAAA,EACjE;AACF;AASA,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,6BAA6B,EAAE,MAAM,YAAY;AAAA,EAC3E,SAAS,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,SAAS;AAAA,EAC7C,MAAM,EAAE,OAAO,EAAE,IAAI,GAAM,EAAE,SAAS;AACxC,CAAC;AA0BD,eAAsB,KAAK,KAAc,SAA0C;AACjF,QAAM,EAAE,GAAG,IAAI,MAAM,QAAQ;AAC7B,MAAI,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,EAAE,SAAS;AAC5C,WAAO,aAAa,KAAK,EAAE,OAAO,qBAAqB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3E;AAEA,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,MAAM,OAAO,CAAC,MAAM,UAAU;AACjC,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACrE;AAEA,MAAI;AACJ,MAAI;AACF,WAAO,WAAW,MAAM,MAAM,aAAa,KAAK,IAAI,CAAC;AAAA,EACvD,SAAS,KAAK;AACZ,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,eAAe,QAAQ,IAAI,UAAU,uBAAuB;AAAA,MACrE,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAM,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC3D,QAAM,iBAAkB,KAAmC,SAAS;AACpE,QAAM,SAAS,EAAE,UAAU,KAAK,UAAoB,eAAe;AACnE,QAAM,UAAU,MAAM;AAAA,IACpB;AAAA,IACA;AAAA,IACA;AAAA,MACE;AAAA,MACA,UAAU,KAAK;AAAA,MACf,GAAG,qBAAqB,cAAc;AAAA,MACtC,WAAW;AAAA,IACb;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACA,MAAI,CAAC,SAAS;AACZ,WAAO,aAAa,KAAK,EAAE,OAAO,oBAAoB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC1E;AAOA,QAAM,wBAAwB,QAAQ,kBAAmB,KAAK;AAK9D,MAAI,eAAyB,CAAC;AAC9B,MAAI;AACF,UAAM,OAAO,UAAU,QAAQ,aAAa;AAC5C,UAAM,MAAM,MAAM,KAAK,QAAQ,KAAK,KAAe;AAAA,MACjD,UAAU,KAAK;AAAA,MACf;AAAA,IACF,CAAC;AACD,mBAAe,KAAK,eAAe,CAAC,GAAG,IAAI,MAAM,QAAQ,KAAK,QAAQ,IAAI,IAAI,WAAW,CAAC;AAAA,EAC5F,QAAQ;AACN,mBAAe,CAAC;AAAA,EAClB;AACA,MAAI;AACF;AAAA,MACE,EAAE,QAAQ,QAAQ,OAAO;AAAA,MACzB,KAAK;AAAA,MACL;AAAA,MACA;AAAA,IACF;AAAA,EACF,SAAS,KAAK;AACZ,QAAI,eAAe,0BAA0B;AAC3C,aAAO,aAAa,KAAK,EAAE,OAAO,oBAAoB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC1E;AACA,UAAM,SAAU,IAAgC,cAAc;AAC9D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,eAAe,QAAQ,IAAI,UAAU,gBAAgB;AAAA,MAC9D,EAAE,OAAO;AAAA,IACX;AAAA,EACF;AACA,MAAI,CAAC,QAAQ,YAAY,QAAQ,WAAW,aAAa;AACvD,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,yBAAyB,QAAQ,MAAM,oBAAoB;AAAA,MACpE,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,QAAQ,MAAM,2BAA2B;AAAA,IAC7C;AAAA,IACA;AAAA,IACA;AAAA,IACA,OAAO;AAAA,MACL,cAAc;AAAA,MACd,YAAY,QAAQ;AAAA,MACpB,WAAW;AAAA,MACX,iBAAiB;AAAA,IACnB;AAAA,EACF,CAAC;AACD,MAAI,cAAc,MAAO,QAAO,MAAM;AAEtC,QAAM,UAAU,kBAAkB,QAAQ,WAAW;AACrD,MAAI,CAAC,SAAS;AACZ,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,uCAAuC,QAAQ,WAAW,IAAI;AAAA,MACvE,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,iBAAiB,0BAA0B,KAAK,IAAI,QAAQ,YAAY;AAC9E,MAAI,CAAC,eAAe,IAAI;AACtB,WAAO,aAAa,KAAK,EAAE,OAAO,eAAe,MAAM,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAC3E;AAGA,MAAI,cAAuC,CAAC;AAC5C,MAAI,qBAAoD;AACxD,MAAI;AACF,yBAAqB,UAAU,QAAQ,+BAA+B;AAAA,EACxE,QAAQ;AACN,yBAAqB;AAAA,EACvB;AACA,MAAI,QAAQ,kBAAkB,oBAAoB;AAChD,kBACG,MAAM,mBACJ,QAAQ,WAAW,QAAQ,WAAW,IAAI;AAAA,MACzC,UAAU,KAAK;AAAA,MACf,gBAAgB;AAAA,MAChB,QAAQ,QAAQ,UAAU;AAAA,IAC5B,CAAC,EACA,MAAM,MAAM,IAAI,KAAM,CAAC;AAAA,EAC9B;AACA,QAAM,kBAAkB;AAAA,IACtB,UAAU,KAAK;AAAA,IACf,gBAAgB;AAAA,IAChB,QAAQ,QAAQ,UAAU;AAAA,EAC5B;AACA,QAAM,YAAY,MAAM;AAAA,IACtB;AAAA,MACE;AAAA,MACA,WAAW,QAAQ;AAAA,MACnB;AAAA,MACA,OAAO;AAAA,IACT;AAAA,IACA,EAAE,mBAAmB;AAAA,EACvB;AACA,gBAAc,UAAU;AAExB,MAAI;AACF,UAAM,YAAY,MAAM,QAAQ,gBAAgB;AAAA,MAC9C,MAAM,KAAK,QAAQ;AAAA,MACnB,YAAY;AAAA,IACd,CAAC;AACD,UAAM,SAAS,MAAM,QAAQ,YAAY;AAAA,MACvC,SAAS,UAAU;AAAA,MACnB;AAAA,MACA,OAAO;AAAA,QACL,UAAU,KAAK;AAAA,QACf,gBAAgB;AAAA,MAClB;AAAA,MACA,UAAU,EAAE,IAAI,KAAK,IAAI,SAAS,KAAK,WAAW,aAAa,UAAU,KAAK;AAAA,IAChF,CAAC;AACD,UAAM,MAAM,aAAa;AACzB,WAAO,aAAa,KAAK;AAAA,MACvB,QAAQ,OAAO;AAAA,MACf,mBAAmB,OAAO;AAAA,MAC1B,eAAe,OAAO,SAAS;AAAA,IACjC,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,UAAM,UAAU,eAAe,QAAQ,IAAI,UAAU;AACrD,WAAO,aAAa,KAAK,EAAE,QAAQ,UAAU,OAAO,QAAQ,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EAChF;AACF;AAEO,MAAM,UAAU;AAAA,EACrB,MAAM,CAAC,uBAAuB;AAAA,EAC9B,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,MAAM,CAAC,uBAAuB;AAAA,MAC9B,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,mBAAmB;AAAA,QAC/C,EAAE,QAAQ,KAAK,aAAa,qBAAqB;AAAA,QACjD,EAAE,QAAQ,KAAK,aAAa,eAAe;AAAA,QAC3C,EAAE,QAAQ,KAAK,aAAa,qCAAqC;AAAA,QACjE,EAAE,QAAQ,KAAK,aAAa,+BAA+B;AAAA,QAC3D,EAAE,QAAQ,KAAK,aAAa,wBAAwB;AAAA,QACpD,EAAE,QAAQ,KAAK,aAAa,uBAAuB;AAAA,QACnD,EAAE,QAAQ,KAAK,aAAa,iBAAiB;AAAA,MAC/C;AAAA,IACF;AAAA,EACF;AACF;AACA,IAAO,gBAAQ;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CommunicationChannel } from '../../../../../data/entities'\nimport { getChannelAdapter } from '../../../../../lib/adapter-registry-singleton'\nimport {\n ChannelAccessDeniedError,\n assertCanManageChannel,\n channelOrgScopeWhere,\n} from '../../../../../lib/access-control'\nimport { refreshCredentialsIfNeeded } from '../../../../../lib/credential-refresh'\nimport {\n MAX_OUTBOUND_RECIPIENT_LENGTH,\n validateOutboundRecipient,\n} from '../../../../../lib/outbound-recipient'\nimport { validateRouteMutationGuard } from '../../../../../lib/route-mutation-guard'\n\ntype RbacServiceLike = {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<{ isSuperAdmin: boolean; features: string[]; organizations: string[] | null }>\n}\n\nexport const metadata = {\n path: '/communication_channels/channels/[id]/test-send',\n POST: {\n // Owner self-service: a user may send a test from their OWN mailbox (gated\n // by `connect_user_channel`). Test-sending from a shared/tenant-wide channel\n // still requires `manage` \u2014 enforced per channel type by `assertCanManageChannel`.\n requireAuth: true,\n requireFeatures: ['communication_channels.connect_user_channel'],\n },\n}\n\n// `to` is deliberately NOT typed as an email here, and deliberately optional.\n// The recipient shape depends on the provider (an email address for Gmail/IMAP,\n// a channel snowflake for Discord), and the adapter is only known once the\n// channel is loaded \u2014 so the schema accepts any non-empty single-line string and\n// `validateOutboundRecipient` applies the provider-appropriate rules below,\n// defaulting to email. Whether the recipient may be left out is provider-derived\n// too: an adapter that carries its own outbound target (Discord's\n// `defaultChannelId`) accepts a body with no `to` and posts there, which is the\n// smoke test the Discord spec documents; an email provider has no such default\n// and `validateOutboundRecipient` still refuses the request. The CR/LF rejection\n// stays at the schema level so a header-injection attempt is refused before the\n// request reaches the channel lookup or the mutation guard.\nconst bodySchema = z.object({\n to: z.string().min(1).max(MAX_OUTBOUND_RECIPIENT_LENGTH).regex(/^[^\\r\\n]*$/).optional(),\n subject: z.string().min(1).max(500).optional(),\n body: z.string().max(50_000).optional(),\n})\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\n/**\n * Admin diagnostic \u2014 send a test message via the adapter without creating a\n * platform `Message`. Useful for verifying credentials + outbound connectivity\n * after a channel is configured.\n *\n * The result is NOT persisted to `ExternalMessage` / `MessageChannelLink` \u2014 it\n * is a one-shot probe.\n */\nexport async function POST(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n let body: z.infer<typeof bodySchema>\n try {\n body = bodySchema.parse(await readJsonSafe(req, null))\n } catch (err) {\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Invalid request body' },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const em = (container.resolve('em') as EntityManager).fork()\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n const dscope = { tenantId: auth.tenantId as string, organizationId }\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id,\n tenantId: auth.tenantId as string,\n ...channelOrgScopeWhere(organizationId),\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n // Resolve credentials at the channel's OWN org, not the caller's selected org.\n // Tenant-wide channels store `organization_id = NULL` and their credentials\n // live at `organization_id = tenantId` (see connect-credential-channel.ts), so\n // the read key must be `channel.organizationId ?? tenantId` \u2014 keying on the\n // session org would resolve `{}` for a tenant-wide channel viewed from a\n // non-null org.\n const channelCredentialsOrg = channel.organizationId ?? (auth.tenantId as string)\n // Load features via RBAC service so admin bypass (`communication_channels.admin`,\n // wildcards, super-admin) is honoured. The `auth` object from\n // `getAuthFromRequest` carries identity only \u2014 feature ACLs live in the RBAC\n // service and must be loaded per request.\n let userFeatures: string[] = []\n try {\n const rbac = container.resolve('rbacService') as RbacServiceLike\n const acl = await rbac.loadAcl(auth.sub as string, {\n tenantId: auth.tenantId as string,\n organizationId,\n })\n userFeatures = acl?.isSuperAdmin ? ['*'] : Array.isArray(acl?.features) ? acl.features : []\n } catch {\n userFeatures = []\n }\n try {\n assertCanManageChannel(\n { userId: channel.userId },\n auth.sub as string,\n userFeatures,\n 'communication_channels.manage',\n )\n } catch (err) {\n if (err instanceof ChannelAccessDeniedError) {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n const status = (err as { statusCode?: number }).statusCode ?? 403\n return NextResponse.json(\n { error: err instanceof Error ? err.message : 'Access denied' },\n { status },\n )\n }\n if (!channel.isActive || channel.status !== 'connected') {\n return NextResponse.json(\n { error: `Channel is in status '${channel.status}' (not connected)` },\n { status: 409 },\n )\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: 'communication_channels.channel',\n resourceId: channel.id,\n operation: 'custom',\n mutationPayload: body as unknown as Record<string, unknown>,\n },\n })\n if ('response' in guard) return guard.response\n\n const adapter = getChannelAdapter(channel.providerKey)\n if (!adapter) {\n return NextResponse.json(\n { error: `No adapter registered for provider '${channel.providerKey}'` },\n { status: 404 },\n )\n }\n\n const recipientCheck = validateOutboundRecipient(body.to, adapter.capabilities)\n if (!recipientCheck.ok) {\n return NextResponse.json({ error: recipientCheck.error }, { status: 422 })\n }\n\n // Resolve credentials + optionally refresh.\n let credentials: Record<string, unknown> = {}\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = container.resolve('integrationCredentialsService') as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n if (channel.credentialsRef && credentialsService) {\n credentials =\n (await credentialsService\n .resolve(`channel_${channel.providerKey}`, {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n })\n .catch(() => null)) ?? {}\n }\n const credentialScope = {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n userId: channel.userId ?? null,\n }\n const refreshed = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialScope,\n },\n { credentialsService },\n )\n credentials = refreshed.credentials\n\n try {\n const converted = await adapter.convertOutbound({\n body: body.body ?? 'Test message from Open Mercato',\n bodyFormat: 'text',\n })\n const result = await adapter.sendMessage({\n content: converted.content,\n credentials,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: channelCredentialsOrg,\n },\n // `to` is omitted from the metadata rather than passed as `undefined` when\n // the caller left it out, so an adapter reading `metadata.to` sees no key\n // at all and falls through to its own configured target.\n metadata: {\n ...(body.to !== undefined ? { to: body.to } : {}),\n subject: body.subject ?? 'Test send',\n testSend: true,\n },\n })\n await guard.afterSuccess()\n return NextResponse.json({\n status: result.status,\n externalMessageId: result.externalMessageId,\n providerError: result.error ?? null,\n })\n } catch (err) {\n const message = err instanceof Error ? err.message : 'send failed'\n return NextResponse.json({ status: 'failed', error: message }, { status: 502 })\n }\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n POST: {\n summary: 'Diagnostic \u2014 send a test message through the channel without persisting',\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Test send result' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 403, description: 'Not allowed to manage this channel' },\n { status: 404, description: 'Channel or adapter not found' },\n { status: 409, description: 'Channel not connected' },\n { status: 422, description: 'Invalid request body' },\n { status: 502, description: 'Provider error' },\n ],\n },\n },\n}\nexport default POST\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -11,6 +11,7 @@ import {
11
11
  getOrCreateThreadToken
12
12
  } from "../lib/thread-token.js";
13
13
  import { stringOrUndefined, stripBrackets } from "../lib/email-mime.js";
14
+ import { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from "../lib/outbound-reply-ref.js";
14
15
  import { isUniqueViolation } from "../lib/pg-errors.js";
15
16
  import { Message } from "../../messages/data/entities.js";
16
17
  import {
@@ -211,6 +212,29 @@ const deliverOutboundMessageCommand = {
211
212
  } catch (tokenErr) {
212
213
  moduleLogger.warn("thread token unavailable, proceeding without it", { err: tokenErr });
213
214
  }
215
+ let replyToExternalId = null;
216
+ try {
217
+ replyToExternalId = await resolveOutboundReplyExternalId(em, {
218
+ parentMessageId: message.parentMessageId ?? null,
219
+ externalConversationId: mapping.externalConversationId,
220
+ capabilities: adapter.capabilities,
221
+ scope: {
222
+ tenantId: input.scope.tenantId,
223
+ organizationId: input.scope.organizationId ?? null
224
+ }
225
+ });
226
+ } catch (replyRefErr) {
227
+ moduleLogger.warn("outbound reply reference unavailable, sending unthreaded", {
228
+ err: replyRefErr
229
+ });
230
+ }
231
+ if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {
232
+ moduleLogger.debug("reply parent has no external id on this channel, sending unthreaded", {
233
+ messageId: message.id,
234
+ parentMessageId: message.parentMessageId,
235
+ conversationId: mapping.externalConversationId
236
+ });
237
+ }
214
238
  try {
215
239
  const outboundPayload = link.channelPayload ?? {};
216
240
  const outboundHtml = typeof outboundPayload.html === "string" ? outboundPayload.html : null;
@@ -240,9 +264,17 @@ const deliverOutboundMessageCommand = {
240
264
  bodyFormat: outboundBodyFormat,
241
265
  channelMetadata: {
242
266
  thread_id: mapping.externalThreadRef,
243
- ...baseMetadata,
267
+ // Reply targeting is hub-resolved only. `send-as-user` merges
268
+ // caller-supplied metadata onto the link, so every reply-targeting key
269
+ // is stripped off the stored metadata here rather than merely
270
+ // out-ranked below: merge order settles the contest only when the hub
271
+ // resolved a parent, and the uncontested case — no `parentMessageId`,
272
+ // or a parent that does not resolve — is exactly the one a caller
273
+ // controls.
274
+ ...stripCallerReplyTargeting(baseMetadata),
244
275
  references: mergedReferences,
245
- ...threadToken ? { omThreadToken: threadToken } : {}
276
+ ...threadToken ? { omThreadToken: threadToken } : {},
277
+ ...replyToExternalId ? { replyToExternalId } : {}
246
278
  }
247
279
  });
248
280
  const sendResult = await adapter.sendMessage({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/commands/deliver-outbound-message.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n ...baseMetadata,\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...(converted.metadata ?? {}),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (4c) Chat-provider reply threading \u2014 resolve the provider-native id of the\n // message this one answers so a threading adapter can attach it (Discord's\n // `message_reference`). Email adapters ignore this and keep threading on the\n // `inReplyTo` / `references` headers built above. Best-effort like the thread\n // token: an unresolvable parent sends an unthreaded reply rather than failing\n // a delivery.\n let replyToExternalId: string | null = null\n try {\n replyToExternalId = await resolveOutboundReplyExternalId(em, {\n parentMessageId: message.parentMessageId ?? null,\n externalConversationId: mapping.externalConversationId,\n capabilities: adapter.capabilities,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n })\n } catch (replyRefErr) {\n moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {\n err: replyRefErr,\n })\n }\n if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {\n // The provider threads and the caller asked for a reply, yet nothing\n // resolved \u2014 the parent never reached this channel, or it belongs to\n // another conversation. Delivery is unaffected, but say so: an unthreaded\n // reply is otherwise indistinguishable from a non-reply, which is exactly\n // the silent-unreachability shape #5541 was filed about.\n moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {\n messageId: message.id,\n parentMessageId: message.parentMessageId,\n conversationId: mapping.externalConversationId,\n })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n // Reply targeting is hub-resolved only. `send-as-user` merges\n // caller-supplied metadata onto the link, so every reply-targeting key\n // is stripped off the stored metadata here rather than merely\n // out-ranked below: merge order settles the contest only when the hub\n // resolved a parent, and the uncontested case \u2014 no `parentMessageId`,\n // or a parent that does not resolve \u2014 is exactly the one a caller\n // controls.\n ...stripCallerReplyTargeting(baseMetadata),\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n ...(replyToExternalId ? { replyToExternalId } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...(converted.metadata ?? {}),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,SAAS;AAGlB,SAAS,uBAAuB;AAChC,SAAS,6BAA6B;AACtC,SAAS,sCAAsC;AAC/C,SAAS,kCAAkC;AAC3C,SAAS,uBAAuB,qBAAqB;AACrD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mBAAmB,qBAAqB;AACjD,SAAS,gCAAgC,iCAAiC;AAE1E,SAAS,yBAAyB;AAClC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAE7B,MAAM,eAAe,aAAa,wBAAwB,EAAE,MAAM,EAAE,WAAW,2BAA2B,CAAC;AAM3G,MAAM,2BAA2B,EAAE,QAAQ,kBAA2B;AAEtE,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,WAAW,EAAE,OAAO,EAAE,KAAK;AAAA,EAC3B,OAAO,EAAE,OAAO;AAAA,IACd,UAAU,EAAE,OAAO,EAAE,KAAK;AAAA,IAC1B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC7C,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,EAKD,wBAAwB,EAAE,QAAQ,EAAE,SAAS;AAC/C,CAAC;AA6BM,MAAM,qDACX;AAyCF,MAAM,gCAGF;AAAA,EACF,IAAI;AAAA,EACJ,MAAM,QAAQ,UAAU,KAAK;AAC3B,UAAM,QAAQ,mBAAmB,MAAM,QAAQ;AAC/C,UAAM,KAAM,IAAI,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC/D,UAAM,SAAS;AAAA,MACb,UAAU,MAAM,MAAM;AAAA,MACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,IAChD;AAGA,UAAM,UAAU,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA;AAAA,QACE,IAAI,MAAM;AAAA,QACV,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,QAC9C,WAAW;AAAA,MACb;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,SAAS;AAEZ,aAAO;AAAA,IACT;AACA,QAAI,CAAC,QAAQ,UAAU;AAErB,aAAO;AAAA,IACT;AAGA,UAAM,UAAU,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA;AAAA,QACE,iBAAiB,QAAQ;AAAA,QACzB,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,MAChD;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,SAAS;AAEZ,aAAO;AAAA,IACT;AAGA,UAAM,UAAU,MAAM;AAAA,MACpB;AAAA,MACA;AAAA,MACA;AAAA,QACE,IAAI,QAAQ;AAAA,QACZ,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,QAC9C,WAAW;AAAA,MACb;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI;AAAA,QACR,sBAAsB,QAAQ,SAAS,yBAAyB,MAAM,MAAM,QAAQ;AAAA,MACtF;AAAA,IACF;AACA,QAAI,CAAC,QAAQ,UAAU;AACrB,YAAM,IAAI,MAAM,sBAAsB,QAAQ,SAAS,4CAA4C;AAAA,IACrG;AAEA,UAAM,kBAAkB,IAAI,UAAU,QAAQ,wBAAwB;AACtE,UAAM,UAAU,gBAAgB,IAAI,QAAQ,WAAW;AACvD,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI;AAAA,QACR,4DAA4D,QAAQ,WAAW;AAAA,MAEjF;AAAA,IACF;AAGA,QAAI,OAAO,MAAM;AAAA,MACf;AAAA,MACA;AAAA,MACA;AAAA,QACE,WAAW,QAAQ;AAAA,QACnB,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,MAChD;AAAA,MACA;AAAA,MACA;AAAA,IACF;AACA,QACE,SACC,KAAK,mBAAmB,YACvB,KAAK,mBAAmB,UACxB,KAAK,mBAAmB,eACxB,KAAK,mBAAmB,SAC1B;AAGA,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,WAAW,QAAQ;AAAA,QACnB,eAAe,KAAK;AAAA,MACtB;AAAA,IACF;AACA,QAAI,CAAC,MAAM;AACT,aAAO,GAAG,OAAO,oBAAoB;AAAA,QACnC,WAAW,QAAQ;AAAA,QACnB,wBAAwB,QAAQ;AAAA,QAChC,aAAa,QAAQ;AAAA,QACrB,aAAa,QAAQ;AAAA,QACrB,WAAW;AAAA,QACX,gBAAgB;AAAA,QAChB,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,MAChD,CAAC;AACD,SAAG,QAAQ,IAAI;AACf,UAAI;AACF,cAAM,GAAG,MAAM;AAAA,MACjB,SAAS,UAAU;AAQjB,YAAI,kBAAkB,QAAQ,GAAG;AAC/B,gBAAM,SAAS,MAAM;AAAA,YACnB,GAAG,KAAK;AAAA,YACR;AAAA,YACA;AAAA,cACE,WAAW,QAAQ;AAAA,cACnB,UAAU,MAAM,MAAM;AAAA,cACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,YAChD;AAAA,YACA;AAAA,YACA;AAAA,UACF;AACA,cAAI,QAAQ;AACV,mBAAO;AAAA,cACL,QAAQ;AAAA,cACR,WAAW,QAAQ;AAAA,cACnB,eAAe,OAAO;AAAA,YACxB;AAAA,UACF;AAAA,QACF;AACA,cAAM;AAAA,MACR;AAAA,IACF;AAGA,QAAI,qBAAoD;AACxD,QAAI;AACF,2BAAqB,IAAI,UAAU;AAAA,QACjC;AAAA,MACF;AAAA,IACF,QAAQ;AACN,2BAAqB;AAAA,IACvB;AAYA,UAAM,mBAAmB;AAAA,MACvB,UAAU,MAAM,MAAM;AAAA,MACtB,gBAAgB,QAAQ,kBAAkB,MAAM,MAAM;AAAA,MACtD,QAAQ,QAAQ,UAAU;AAAA,IAC5B;AACA,QAAI,cAAuC,CAAC;AAC5C,QAAI,QAAQ,kBAAkB,oBAAoB;AAChD,UAAI;AACF,sBACG,MAAM,mBAAmB,QAAQ,WAAW,QAAQ,WAAW,IAAI,gBAAgB,KAAM,CAAC;AAAA,MAC/F,QAAQ;AACN,sBAAc,CAAC;AAAA,MACjB;AAAA,IACF;AAGA,QAAI,iBAA4C;AAChD,QAAI;AACF,uBAAiB,IAAI,UAAU,QAAQ,uBAAuB;AAAA,IAChE,QAAQ;AACN,uBAAiB;AAAA,IACnB;AACA,UAAM,gBAAgB,MAAM;AAAA,MAC1B;AAAA,QACE;AAAA,QACA,WAAW,QAAQ;AAAA,QACnB;AAAA,QACA,OAAO;AAAA,QACP,OAAO,QAAQ,MAAM,sBAAsB;AAAA,MAC7C;AAAA,MACA;AAAA,QACE;AAAA,QACA,QAAQ,IAAI,SAAS,aAAa,KAAK,iCAAiC,EAAE,SAAS,KAAK,CAAC;AAAA,MAC3F;AAAA,IACF;AACA,kBAAc,cAAc;AAQ5B,QAAI,cAA6B;AACjC,QAAI;AACF,YAAM,EAAE,MAAM,IAAI,MAAM,uBAAuB,IAAI;AAAA,QACjD,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,QAC9C,iBAAiB,QAAQ;AAAA,MAC3B,CAAC;AACD,oBAAc;AAAA,IAChB,SAAS,UAAU;AAIjB,mBAAa,KAAK,mDAAmD,EAAE,KAAK,SAAS,CAAC;AAAA,IACxF;AAQA,QAAI,oBAAmC;AACvC,QAAI;AACF,0BAAoB,MAAM,+BAA+B,IAAI;AAAA,QAC3D,iBAAiB,QAAQ,mBAAmB;AAAA,QAC5C,wBAAwB,QAAQ;AAAA,QAChC,cAAc,QAAQ;AAAA,QACtB,OAAO;AAAA,UACL,UAAU,MAAM,MAAM;AAAA,UACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,QAChD;AAAA,MACF,CAAC;AAAA,IACH,SAAS,aAAa;AACpB,mBAAa,KAAK,4DAA4D;AAAA,QAC5E,KAAK;AAAA,MACP,CAAC;AAAA,IACH;AACA,QAAI,CAAC,qBAAqB,QAAQ,mBAAmB,QAAQ,cAAc,cAAc,MAAM;AAM7F,mBAAa,MAAM,uEAAuE;AAAA,QACxF,WAAW,QAAQ;AAAA,QACnB,iBAAiB,QAAQ;AAAA,QACzB,gBAAgB,QAAQ;AAAA,MAC1B,CAAC;AAAA,IACH;AAGA,QAAI;AACF,YAAM,kBAAmB,KAAK,kBAAqD,CAAC;AACpF,YAAM,eAAe,OAAO,gBAAgB,SAAS,WAAW,gBAAgB,OAAO;AACvF,YAAM,eAAe,OAAO,gBAAgB,SAAS,WAAW,gBAAgB,OAAO;AACvF,UAAI,eAAe,gBAAgB,gBAAgB,QAAQ,QAAQ;AACnE,YAAM,qBAAqB,eACvB,SACE,QAAQ,cAA+C;AAK7D,YAAM,eAAgB,KAAK,mBAA2D,CAAC;AACvF,YAAM,eAAe,MAAM,QAAQ,aAAa,UAAU,IACrD,aAAa,WAAyB;AAAA,QACrC,CAAC,UAA2B,OAAO,UAAU;AAAA,MAC/C,IACA,CAAC;AACL,UAAI,mBAAmB;AACvB,UAAI,eAAe,CAAC,aAAa,SAAS,OAAO,WAAW,GAAG,GAAG;AAIhE,cAAM,SAAS,gBAAgB,WAAW;AAC1C,YAAI,uBAAuB,QAAQ;AACjC,gBAAM,cAAc,aAAa,YAAY,SAAS;AACtD,yBACE,eAAe,IACX,GAAG,aAAa,MAAM,GAAG,WAAW,CAAC,GAAG,OAAO,IAAI,GAAG,aAAa,MAAM,WAAW,CAAC,KACrF,GAAG,YAAY,GAAG,OAAO,IAAI;AAAA,QACrC,OAAO;AACL,yBAAe,GAAG,YAAY,GAAG,OAAO,KAAK;AAAA,QAC/C;AACA,cAAM,QAAQ,kBAAkB,WAAW;AAC3C,YAAI,CAAC,iBAAiB,SAAS,KAAK,GAAG;AACrC,6BAAmB,CAAC,GAAG,kBAAkB,KAAK;AAAA,QAChD;AAAA,MACF;AACA,YAAM,YAAY,MAAM,QAAQ,gBAAgB;AAAA,QAC9C,MAAM;AAAA,QACN,YAAY;AAAA,QACZ,iBAAiB;AAAA,UACf,WAAW,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAQnB,GAAG,0BAA0B,YAAY;AAAA,UACzC,YAAY;AAAA,UACZ,GAAI,cAAc,EAAE,eAAe,YAAY,IAAI,CAAC;AAAA,UACpD,GAAI,oBAAoB,EAAE,kBAAkB,IAAI,CAAC;AAAA,QACnD;AAAA,MACF,CAAC;AAYD,YAAM,aAAa,MAAM,QAAQ,YAAY;AAAA,QAC3C,gBAAgB,QAAQ;AAAA,QACxB,SAAS,UAAU;AAAA,QACnB;AAAA,QACA,OAAO;AAAA,UACL,UAAU,MAAM,MAAM;AAAA,UACtB,gBAAgB,QAAQ,kBAAkB,MAAM,MAAM;AAAA,QACxD;AAAA,QACA,UAAU,UAAU;AAAA,MACtB,CAAC;AAED,UAAI,WAAW,WAAW,UAAU;AAClC,cAAM,IAAI,MAAM,WAAW,SAAS,YAAY,QAAQ,WAAW,yBAAyB;AAAA,MAC9F;AASA,YAAM,uBAAuB,WAAW;AACxC,YAAM,kBAAkB,GAAG,OAAO,iBAAiB;AAAA,QACjD,IAAI;AAAA,QACJ,WAAW,QAAQ;AAAA,QACnB,gBAAgB,QAAQ;AAAA,QACxB,mBAAmB,WAAW;AAAA,QAC9B,WAAW;AAAA,QACX,kBAAkB;AAAA,QAClB,mBAAmB;AAAA,QACnB,mBAAmB,oBAAI,KAAK;AAAA,QAC5B,UAAU,MAAM,MAAM;AAAA,QACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,MAChD,CAAC;AACD,SAAG,QAAQ,eAAe;AAK1B,UAAI,QAAQ,WAAW,qBAAqB,QAAQ,WAAW,SAAS;AACtE,gBAAQ,SAAS;AAAA,MACnB;AAEA,WAAK,iBAAiB,WAAW,WAAW,SAAS,SAAS;AAC9D,WAAK,oBAAoB;AACzB,WAAK,kBAAkB;AAAA,QACrB,GAAK,KAAK,mBAA2D,CAAC;AAAA,QACtE,GAAI,UAAU,YAAY,CAAC;AAAA,QAC3B,mBAAmB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAW9B,WAAW;AAAA,UACT,kBAAmB,UAAU,UAAkD,SAAS,KACtF,WAAW;AAAA,QACf;AAAA,MACF;AACA,YAAM,GAAG,MAAM;AAEf,YAAM;AAAA,QACJ;AAAA,QACA;AAAA,UACE,WAAW,QAAQ;AAAA,UACnB,mBAAmB,gBAAgB;AAAA,UACnC,eAAe,KAAK;AAAA,UACpB,gBAAgB,QAAQ;AAAA,UACxB,WAAW,QAAQ;AAAA,UACnB,aAAa,QAAQ;AAAA,UACrB,aAAa,QAAQ;AAAA,UACrB,WAAW;AAAA,UACX,UAAU,MAAM,MAAM;AAAA,UACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,QAChD;AAAA,QACA,EAAE,YAAY,KAAK;AAAA,MACrB;AAEA,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,WAAW,QAAQ;AAAA,QACnB,eAAe,KAAK;AAAA,QACpB,mBAAmB,gBAAgB;AAAA,QACnC,aAAa,QAAQ;AAAA,MACvB;AAAA,IACF,SAAS,SAAS;AAEhB,YAAM,iBAAiB,sBAAsB,OAAO;AACpD,YAAM,iBAAiB,cAAc,cAAc;AACnD,WAAK,iBAAiB;AACtB,WAAK,kBAAkB;AAAA,QACrB,GAAK,KAAK,mBAA2D,CAAC;AAAA,QACtE,WAAW,eAAe;AAAA,QAC1B,cAAa,oBAAI,KAAK,GAAE,YAAY;AAAA,QACpC,WAAW,eAAe;AAAA,QAC1B;AAAA,MACF;AAMA,UAAI,gBAAgB;AAClB,gBAAQ,SAAS;AAAA,MACnB;AACA,YAAM,GAAG,MAAM;AAEf,UAAI;AACF,cAAM,gBAAgB,QAAQ;AAAA,UAC5B,eAAe,WAAW,QAAQ,WAAW;AAAA,UAC7C,UAAU,MAAM,MAAM;AAAA,UACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,UAC9C,WAAW,QAAQ;AAAA,UACnB,WAAW,QAAQ;AAAA,UACnB,QAAQ,eAAe,UAAU;AAAA,UACjC,WAAW,eAAe;AAAA,UAC1B,SAAS,eAAe;AAAA,QAC1B,CAAC;AAAA,MACH,QAAQ;AAAA,MAER;AAEA,UAAI,gBAAgB;AAClB,cAAM;AAAA,UACJ;AAAA,UACA;AAAA,YACE,WAAW,QAAQ;AAAA,YACnB,aAAa,QAAQ;AAAA,YACrB,aAAa,QAAQ;AAAA,YACrB,QAAQ,eAAe;AAAA,YACvB,UAAU,MAAM,MAAM;AAAA,YACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,UAChD;AAAA,UACA,EAAE,YAAY,KAAK;AAAA,QACrB;AAAA,MACF;AAEA,YAAM;AAAA,QACJ;AAAA,QACA;AAAA,UACE,WAAW,QAAQ;AAAA,UACnB,eAAe,KAAK;AAAA,UACpB,gBAAgB,QAAQ;AAAA,UACxB,WAAW,QAAQ;AAAA,UACnB,aAAa,QAAQ;AAAA,UACrB,aAAa,QAAQ;AAAA,UACrB,WAAW,eAAe;AAAA,UAC1B,OAAO,eAAe;AAAA,UACtB,QAAQ,eAAe,UAAU;AAAA,UACjC,UAAU,MAAM,MAAM;AAAA,UACtB,gBAAgB,MAAM,MAAM,kBAAkB;AAAA,QAChD;AAAA,QACA,EAAE,YAAY,KAAK;AAAA,MACrB;AAEA,aAAO;AAAA,QACL,QAAQ;AAAA,QACR,WAAW,QAAQ;AAAA,QACnB,eAAe,KAAK;AAAA,QACpB,aAAa,QAAQ;AAAA,QACrB,OAAO,eAAe;AAAA,QACtB,WAAW,eAAe;AAAA,QAC1B;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAEA,gBAAgB,6BAA6B;AAE7C,IAAO,mCAAQ;",
6
6
  "names": []
7
7
  }
@@ -3,6 +3,10 @@ const MAX_OUTBOUND_RECIPIENT_LENGTH = 320;
3
3
  const emailRecipientSchema = z.string().email();
4
4
  const PROVIDER_NATIVE_ALLOWED = /^[A-Za-z0-9._:@+-]+$/;
5
5
  function validateOutboundRecipient(recipient, capabilities) {
6
+ const isProviderNative = capabilities?.recipientFormat === "provider-native";
7
+ if (recipient === void 0 && isProviderNative) {
8
+ return { ok: true };
9
+ }
6
10
  if (typeof recipient !== "string" || recipient.length === 0) {
7
11
  return { ok: false, error: "Recipient is required" };
8
12
  }
@@ -12,7 +16,7 @@ function validateOutboundRecipient(recipient, capabilities) {
12
16
  error: `Recipient must be at most ${MAX_OUTBOUND_RECIPIENT_LENGTH} characters`
13
17
  };
14
18
  }
15
- if (capabilities?.recipientFormat !== "provider-native") {
19
+ if (!isProviderNative) {
16
20
  return emailRecipientSchema.safeParse(recipient).success ? { ok: true } : { ok: false, error: "Recipient must be a valid email address" };
17
21
  }
18
22
  if (!PROVIDER_NATIVE_ALLOWED.test(recipient)) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/outbound-recipient.ts"],
4
- "sourcesContent": ["import { z } from 'zod'\nimport type { ChannelCapabilities } from './adapter'\n\n/**\n * Upper bound shared by both recipient shapes. 320 is the RFC 5321 maximum for\n * an email address (64 local + `@` + 255 domain); provider-native identifiers\n * are far shorter, so one ceiling covers both.\n */\nexport const MAX_OUTBOUND_RECIPIENT_LENGTH = 320\n\nconst emailRecipientSchema = z.string().email()\n\n/**\n * Characters a provider-native recipient may contain. This is an allowlist, not a\n * denylist, because the recipient reaches adapters that interpolate it into a REST\n * path (Discord posts to `/channels/{recipient}/messages`), and a denylist fails\n * open on every character nobody thought of \u2014 percent-encoded separators\n * (`%2F`, `%2e%2e%2f`) and control bytes (NUL, DEL) all walked through the\n * previous `[\\r\\n\\s/\\\\?#]` class. An allowlist fails closed instead: the worst\n * case is a provider whose identifier alphabet needs widening, which is a\n * reviewable one-line change rather than a silent path-steering primitive.\n *\n * The set covers every provider-native identifier shape the hub has to carry\n * today \u2014 Discord snowflakes (digits), Slack-style ids (alphanumerics), and email\n * addresses, since `'provider-native'` must never narrow what `'email'` accepts.\n */\nconst PROVIDER_NATIVE_ALLOWED = /^[A-Za-z0-9._:@+-]+$/\n\nexport type OutboundRecipientCheck = { ok: true } | { ok: false; error: string }\n\n/**\n * Validate an outbound recipient against the provider's declared recipient\n * shape.\n *\n * The hub used to hard-wire `z.string().email()` on every outbound endpoint,\n * which left providers whose recipients are not email addresses with no product\n * path to send at all (#4976). Validation now follows the adapter's\n * `capabilities.recipientFormat`, defaulting to `'email'` so every existing\n * provider keeps byte-identical behavior.\n */\nexport function validateOutboundRecipient(\n recipient: unknown,\n capabilities: Pick<ChannelCapabilities, 'recipientFormat'> | null | undefined,\n): OutboundRecipientCheck {\n if (typeof recipient !== 'string' || recipient.length === 0) {\n return { ok: false, error: 'Recipient is required' }\n }\n if (recipient.length > MAX_OUTBOUND_RECIPIENT_LENGTH) {\n return {\n ok: false,\n error: `Recipient must be at most ${MAX_OUTBOUND_RECIPIENT_LENGTH} characters`,\n }\n }\n if (capabilities?.recipientFormat !== 'provider-native') {\n return emailRecipientSchema.safeParse(recipient).success\n ? { ok: true }\n : { ok: false, error: 'Recipient must be a valid email address' }\n }\n if (!PROVIDER_NATIVE_ALLOWED.test(recipient)) {\n return {\n ok: false,\n error: 'Recipient may only contain letters, digits, and the characters . _ : @ + -',\n }\n }\n // `.` is allowed (email addresses need it), so traversal still has to be\n // rejected explicitly even though the separators it would need are not.\n if (recipient.includes('..')) {\n return { ok: false, error: 'Recipient must not contain \"..\"' }\n }\n return { ok: true }\n}\n"],
5
- "mappings": "AAAA,SAAS,SAAS;AAQX,MAAM,gCAAgC;AAE7C,MAAM,uBAAuB,EAAE,OAAO,EAAE,MAAM;AAgB9C,MAAM,0BAA0B;AAczB,SAAS,0BACd,WACA,cACwB;AACxB,MAAI,OAAO,cAAc,YAAY,UAAU,WAAW,GAAG;AAC3D,WAAO,EAAE,IAAI,OAAO,OAAO,wBAAwB;AAAA,EACrD;AACA,MAAI,UAAU,SAAS,+BAA+B;AACpD,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO,6BAA6B,6BAA6B;AAAA,IACnE;AAAA,EACF;AACA,MAAI,cAAc,oBAAoB,mBAAmB;AACvD,WAAO,qBAAqB,UAAU,SAAS,EAAE,UAC7C,EAAE,IAAI,KAAK,IACX,EAAE,IAAI,OAAO,OAAO,0CAA0C;AAAA,EACpE;AACA,MAAI,CAAC,wBAAwB,KAAK,SAAS,GAAG;AAC5C,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO;AAAA,IACT;AAAA,EACF;AAGA,MAAI,UAAU,SAAS,IAAI,GAAG;AAC5B,WAAO,EAAE,IAAI,OAAO,OAAO,kCAAkC;AAAA,EAC/D;AACA,SAAO,EAAE,IAAI,KAAK;AACpB;",
4
+ "sourcesContent": ["import { z } from 'zod'\nimport type { ChannelCapabilities } from './adapter'\n\n/**\n * Upper bound shared by both recipient shapes. 320 is the RFC 5321 maximum for\n * an email address (64 local + `@` + 255 domain); provider-native identifiers\n * are far shorter, so one ceiling covers both.\n */\nexport const MAX_OUTBOUND_RECIPIENT_LENGTH = 320\n\nconst emailRecipientSchema = z.string().email()\n\n/**\n * Characters a provider-native recipient may contain. This is an allowlist, not a\n * denylist, because the recipient reaches adapters that interpolate it into a REST\n * path (Discord posts to `/channels/{recipient}/messages`), and a denylist fails\n * open on every character nobody thought of \u2014 percent-encoded separators\n * (`%2F`, `%2e%2e%2f`) and control bytes (NUL, DEL) all walked through the\n * previous `[\\r\\n\\s/\\\\?#]` class. An allowlist fails closed instead: the worst\n * case is a provider whose identifier alphabet needs widening, which is a\n * reviewable one-line change rather than a silent path-steering primitive.\n *\n * The set covers every provider-native identifier shape the hub has to carry\n * today \u2014 Discord snowflakes (digits), Slack-style ids (alphanumerics), and email\n * addresses, since `'provider-native'` must never narrow what `'email'` accepts.\n */\nconst PROVIDER_NATIVE_ALLOWED = /^[A-Za-z0-9._:@+-]+$/\n\nexport type OutboundRecipientCheck = { ok: true } | { ok: false; error: string }\n\n/**\n * Validate an outbound recipient against the provider's declared recipient\n * shape.\n *\n * The hub used to hard-wire `z.string().email()` on every outbound endpoint,\n * which left providers whose recipients are not email addresses with no product\n * path to send at all (#4976). Validation now follows the adapter's\n * `capabilities.recipientFormat`, defaulting to `'email'` so every existing\n * provider keeps byte-identical behavior.\n *\n * A `'provider-native'` provider may also be addressed by **omitting** the\n * recipient entirely: those adapters are configured with their own outbound\n * target (Discord's `defaultChannelId`) and fall back to it when the message\n * names no channel, which is the smoke test the Discord spec documents in\n * \u00A7 6 \"Test it\". An email provider has no such default, so `'email'` \u2014 the\n * format every existing provider resolves to \u2014 still requires a recipient.\n * Omission means `undefined` only: `null` or `''` is a caller that meant to\n * supply an address and got it wrong, and stays a 422 on every provider.\n */\nexport function validateOutboundRecipient(\n recipient: unknown,\n capabilities: Pick<ChannelCapabilities, 'recipientFormat'> | null | undefined,\n): OutboundRecipientCheck {\n const isProviderNative = capabilities?.recipientFormat === 'provider-native'\n if (recipient === undefined && isProviderNative) {\n return { ok: true }\n }\n if (typeof recipient !== 'string' || recipient.length === 0) {\n return { ok: false, error: 'Recipient is required' }\n }\n if (recipient.length > MAX_OUTBOUND_RECIPIENT_LENGTH) {\n return {\n ok: false,\n error: `Recipient must be at most ${MAX_OUTBOUND_RECIPIENT_LENGTH} characters`,\n }\n }\n if (!isProviderNative) {\n return emailRecipientSchema.safeParse(recipient).success\n ? { ok: true }\n : { ok: false, error: 'Recipient must be a valid email address' }\n }\n if (!PROVIDER_NATIVE_ALLOWED.test(recipient)) {\n return {\n ok: false,\n error: 'Recipient may only contain letters, digits, and the characters . _ : @ + -',\n }\n }\n // `.` is allowed (email addresses need it), so traversal still has to be\n // rejected explicitly even though the separators it would need are not.\n if (recipient.includes('..')) {\n return { ok: false, error: 'Recipient must not contain \"..\"' }\n }\n return { ok: true }\n}\n"],
5
+ "mappings": "AAAA,SAAS,SAAS;AAQX,MAAM,gCAAgC;AAE7C,MAAM,uBAAuB,EAAE,OAAO,EAAE,MAAM;AAgB9C,MAAM,0BAA0B;AAuBzB,SAAS,0BACd,WACA,cACwB;AACxB,QAAM,mBAAmB,cAAc,oBAAoB;AAC3D,MAAI,cAAc,UAAa,kBAAkB;AAC/C,WAAO,EAAE,IAAI,KAAK;AAAA,EACpB;AACA,MAAI,OAAO,cAAc,YAAY,UAAU,WAAW,GAAG;AAC3D,WAAO,EAAE,IAAI,OAAO,OAAO,wBAAwB;AAAA,EACrD;AACA,MAAI,UAAU,SAAS,+BAA+B;AACpD,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO,6BAA6B,6BAA6B;AAAA,IACnE;AAAA,EACF;AACA,MAAI,CAAC,kBAAkB;AACrB,WAAO,qBAAqB,UAAU,SAAS,EAAE,UAC7C,EAAE,IAAI,KAAK,IACX,EAAE,IAAI,OAAO,OAAO,0CAA0C;AAAA,EACpE;AACA,MAAI,CAAC,wBAAwB,KAAK,SAAS,GAAG;AAC5C,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,OAAO;AAAA,IACT;AAAA,EACF;AAGA,MAAI,UAAU,SAAS,IAAI,GAAG;AAC5B,WAAO,EAAE,IAAI,OAAO,OAAO,kCAAkC;AAAA,EAC/D;AACA,SAAO,EAAE,IAAI,KAAK;AACpB;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,49 @@
1
+ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
2
+ import { ExternalMessage, MessageChannelLink } from "../data/entities.js";
3
+ async function resolveOutboundReplyExternalId(em, input) {
4
+ if (!input.parentMessageId) return null;
5
+ if (input.capabilities?.threading !== true) return null;
6
+ const dscope = {
7
+ tenantId: input.scope.tenantId,
8
+ organizationId: input.scope.organizationId
9
+ };
10
+ const parentLink = await findOneWithDecryption(
11
+ em,
12
+ MessageChannelLink,
13
+ {
14
+ messageId: input.parentMessageId,
15
+ tenantId: input.scope.tenantId,
16
+ organizationId: input.scope.organizationId
17
+ },
18
+ void 0,
19
+ dscope
20
+ );
21
+ if (!parentLink?.externalMessageId) return null;
22
+ if (parentLink.externalConversationId !== input.externalConversationId) return null;
23
+ const parentExternal = await findOneWithDecryption(
24
+ em,
25
+ ExternalMessage,
26
+ {
27
+ id: parentLink.externalMessageId,
28
+ tenantId: input.scope.tenantId,
29
+ organizationId: input.scope.organizationId
30
+ },
31
+ void 0,
32
+ dscope
33
+ );
34
+ if (!parentExternal) return null;
35
+ if (parentExternal.conversationId !== input.externalConversationId) return null;
36
+ const externalId = parentExternal.externalMessageId;
37
+ return typeof externalId === "string" && externalId.length > 0 ? externalId : null;
38
+ }
39
+ const REPLY_TARGETING_METADATA_KEYS = ["replyToExternalId", "messageReferenceId"];
40
+ function stripCallerReplyTargeting(metadata) {
41
+ const cleaned = { ...metadata };
42
+ for (const key of REPLY_TARGETING_METADATA_KEYS) delete cleaned[key];
43
+ return cleaned;
44
+ }
45
+ export {
46
+ resolveOutboundReplyExternalId,
47
+ stripCallerReplyTargeting
48
+ };
49
+ //# sourceMappingURL=outbound-reply-ref.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/communication_channels/lib/outbound-reply-ref.ts"],
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { ChannelCapabilities } from './adapter'\nimport { ExternalMessage, MessageChannelLink } from '../data/entities'\n\nexport interface OutboundReplyRefInput {\n /** `Message.parentMessageId` of the message being delivered. */\n parentMessageId: string | null | undefined\n /** `ChannelThreadMapping.externalConversationId` the message is being sent to. */\n externalConversationId: string\n capabilities: Pick<ChannelCapabilities, 'threading'> | null | undefined\n scope: { tenantId: string; organizationId: string | null }\n}\n\n/**\n * Resolve the provider-native id of the message an outbound reply answers, so\n * chat adapters can attach it (Discord `message_reference`, and any provider\n * that threads by message id rather than by RFC 5322 headers).\n *\n * Email providers thread through `inReplyTo` / `references`, which the hub has\n * always produced. Chat providers had no equivalent: `capabilities.threading`\n * described a reply-attachment the hub could never ask for, because nothing on\n * the outbound path wrote the parent's external id into channel metadata\n * (#5541 \u2014 the flag had to be declared `false` to stay honest). This is that\n * missing producer.\n *\n * The hub stores everything needed already: the parent hub message owns a\n * `MessageChannelLink` (unique per `message_id`) whose `external_message_id`\n * points at the `ExternalMessage` row carrying the provider's own id \u2014 written\n * by both the inbound ingest and outbound delivery paths, so a reply can answer\n * an inbound message or one of our own sends.\n *\n * Returns `null` \u2014 never throws a routing decision at the caller \u2014 when the\n * provider does not thread, the message is not a reply, the parent never\n * reached this channel, or the parent belongs to a different conversation.\n * Threading is an enhancement to a delivery, never a precondition for one.\n */\nexport async function resolveOutboundReplyExternalId(\n em: EntityManager,\n input: OutboundReplyRefInput,\n): Promise<string | null> {\n // A non-reply can never produce a reference, so check that before anything\n // that costs a query.\n if (!input.parentMessageId) return null\n // Gate on the adapter's own declaration: handing a non-threading provider a key\n // it silently drops is exactly the mismatch `ChannelCapabilities` exists to\n // prevent. Note this gate does NOT spare header-threading providers \u2014 the\n // shared email profile declares `threading: true` (`email-capabilities.ts`), so\n // an email reply pays for both lookups below and its converter then ignores the\n // result in favor of `inReplyTo` / `references`. Correct but wasteful;\n // distinguishing id-threading from header-threading needs a capability the\n // contract does not have yet (#5691).\n if (input.capabilities?.threading !== true) return null\n\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n }\n\n const parentLink = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: input.parentMessageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n },\n undefined,\n dscope,\n )\n if (!parentLink?.externalMessageId) return null\n\n // A parent that lives in another conversation would make the provider reject\n // the send (Discord answers `400 Unknown message` for a cross-channel\n // reference). Dropping the reference degrades to an unthreaded reply, which\n // is strictly better than a failed delivery.\n if (parentLink.externalConversationId !== input.externalConversationId) return null\n\n const parentExternal = await findOneWithDecryption(\n em,\n ExternalMessage,\n {\n id: parentLink.externalMessageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n },\n undefined,\n dscope,\n )\n\n if (!parentExternal) return null\n // Re-assert the conversation invariant on the row that actually carries the\n // provider id. Both producers write the link and the external message together,\n // so these cannot disagree today \u2014 checking here keeps the guard correct even if\n // that coupling is ever relaxed, rather than trusting a second table's field.\n if (parentExternal.conversationId !== input.externalConversationId) return null\n\n const externalId = parentExternal.externalMessageId\n return typeof externalId === 'string' && externalId.length > 0 ? externalId : null\n}\n\n/**\n * Outbound `channelMetadata` keys that decide which provider message a reply\n * attaches to.\n *\n * - `replyToExternalId` \u2014 the hub's own key, produced by\n * `resolveOutboundReplyExternalId` above.\n * - `messageReferenceId` \u2014 Discord's already-converted equivalent. Its converter\n * has to accept that key so the value survives the hub's convert\u2192send double\n * conversion (#5541), and the converter cannot tell the two passes apart, so\n * the key is equally accepted on the first, caller-controlled pass.\n */\nconst REPLY_TARGETING_METADATA_KEYS = ['replyToExternalId', 'messageReferenceId'] as const\n\n/**\n * Drop every reply-targeting key from stored link metadata, so reply targeting\n * on the outbound path is hub-resolved only.\n *\n * `send-as-user` merges caller-supplied `channelMetadata` onto the\n * `MessageChannelLink`, and `deliver-outbound-message` reads that row back as\n * the converter's base metadata. Merge order alone only settles the contest when\n * the hub actually resolved a parent; the uncontested case \u2014 a message with no\n * `parentMessageId`, or one whose parent legitimately fails to resolve \u2014 is\n * exactly the one a caller controls, and there nothing would overwrite a\n * smuggled key. Without this, a `communication_channels.manage` caller could aim\n * a reply at an arbitrary provider message id and have the bot's message render\n * as a reply to a message the hub never resolved and never validated.\n *\n * Stripping is safe on a retry: the reference is re-resolved from\n * `parentMessageId` on every delivery attempt, never read back from the link.\n */\nexport function stripCallerReplyTargeting(\n metadata: Record<string, unknown>,\n): Record<string, unknown> {\n const cleaned = { ...metadata }\n for (const key of REPLY_TARGETING_METADATA_KEYS) delete cleaned[key]\n return cleaned\n}\n"],
5
+ "mappings": "AACA,SAAS,6BAA6B;AAEtC,SAAS,iBAAiB,0BAA0B;AAkCpD,eAAsB,+BACpB,IACA,OACwB;AAGxB,MAAI,CAAC,MAAM,gBAAiB,QAAO;AASnC,MAAI,MAAM,cAAc,cAAc,KAAM,QAAO;AAEnD,QAAM,SAAS;AAAA,IACb,UAAU,MAAM,MAAM;AAAA,IACtB,gBAAgB,MAAM,MAAM;AAAA,EAC9B;AAEA,QAAM,aAAa,MAAM;AAAA,IACvB;AAAA,IACA;AAAA,IACA;AAAA,MACE,WAAW,MAAM;AAAA,MACjB,UAAU,MAAM,MAAM;AAAA,MACtB,gBAAgB,MAAM,MAAM;AAAA,IAC9B;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACA,MAAI,CAAC,YAAY,kBAAmB,QAAO;AAM3C,MAAI,WAAW,2BAA2B,MAAM,uBAAwB,QAAO;AAE/E,QAAM,iBAAiB,MAAM;AAAA,IAC3B;AAAA,IACA;AAAA,IACA;AAAA,MACE,IAAI,WAAW;AAAA,MACf,UAAU,MAAM,MAAM;AAAA,MACtB,gBAAgB,MAAM,MAAM;AAAA,IAC9B;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,MAAI,CAAC,eAAgB,QAAO;AAK5B,MAAI,eAAe,mBAAmB,MAAM,uBAAwB,QAAO;AAE3E,QAAM,aAAa,eAAe;AAClC,SAAO,OAAO,eAAe,YAAY,WAAW,SAAS,IAAI,aAAa;AAChF;AAaA,MAAM,gCAAgC,CAAC,qBAAqB,oBAAoB;AAmBzE,SAAS,0BACd,UACyB;AACzB,QAAM,UAAU,EAAE,GAAG,SAAS;AAC9B,aAAW,OAAO,8BAA+B,QAAO,QAAQ,GAAG;AACnE,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -52,11 +52,16 @@ class TestSeedChannelAdapter {
52
52
  return { ok: true };
53
53
  }
54
54
  }
55
+ const testSeedChatCapabilities = {
56
+ ...testSeedCapabilities,
57
+ recipientFormat: "provider-native"
58
+ };
55
59
  class TestSeedChatChannelAdapter extends TestSeedChannelAdapter {
56
60
  constructor() {
57
61
  super(...arguments);
58
62
  this.providerKey = TEST_SEED_CHAT_PROVIDER_KEY;
59
63
  this.channelType = "discord";
64
+ this.capabilities = testSeedChatCapabilities;
60
65
  }
61
66
  async normalizeInbound(raw) {
62
67
  const frame = raw.raw ?? {};
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/test-seed.ts"],
4
- "sourcesContent": ["import type {\n ChannelAdapter,\n ChannelCapabilities,\n ChannelNativeContent,\n ConvertOutboundInput,\n GetMessageStatusInput,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n SendMessageInput,\n SendMessageResult,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from './adapter'\nimport { baseEmailCapabilities } from './email-capabilities'\nimport { hasChannelAdapter, registerChannelAdapter } from './adapter-registry-singleton'\n\n/**\n * Test-only channel seeding support.\n *\n * The ephemeral integration harness cannot connect a REAL email channel:\n * - IMAP/SMTP `validateCredentials` performs a live LOGIN against a mail server\n * (none exists in CI), so `POST /channels/connect/credentials` returns 422.\n * - Even with a connected channel, the outbound delivery worker calls the real\n * SMTP adapter, which fails with no server \u2014 so `communication_channels.message.sent`\n * never fires and the customers link subscriber never runs.\n *\n * To make the compose \u2192 deliver \u2192 `.sent` \u2192 CRM-link \u2192 cross-user-visibility chain\n * (TC-CRM-EMAIL-001) and the inbound auto-link chain (TC-CRM-EMAIL-002..005) runnable\n * end-to-end against real Postgres, this module provides a network-free stub adapter\n * that is registered ONLY when `OM_ENABLE_TEST_CHANNEL_SEEDING` is set.\n *\n * Production safety: the registration is gated by {@link isTestChannelSeedingEnabled};\n * when the env flag is unset (the production default) the adapter is never registered\n * and the `__test_seed__` provider key resolves to no adapter \u2014 so the connect route\n * returns 404 `no_adapter` exactly as it would for any unknown provider. The dedicated\n * test-seed API route enforces the same gate independently (fail-closed 404 in prod).\n */\n\n/** Provider key for the network-free test stub adapter. */\nexport const TEST_SEED_PROVIDER_KEY = '__test_seed__'\n\n/**\n * Provider key for the network-free stub adapter that stands in for a CHAT\n * provider \u2014 one whose senders are identified by an opaque handle and have no\n * email address at all (Discord, Slack, Telegram\u2026).\n *\n * It exists because the email-shaped stub above can only ever prove the hub\n * accepts email-shaped data. That is precisely how CI stayed green while every\n * real inbound Discord message was rejected (#4975): the fixture invented an\n * address the provider can never produce. Tests that need to prove the hub's\n * non-email identity contract MUST drive this provider instead.\n */\nexport const TEST_SEED_CHAT_PROVIDER_KEY = '__test_seed_chat__'\n\n/** Env flag that unlocks test-only channel seeding. Off in production. */\nexport const TEST_CHANNEL_SEEDING_ENV = 'OM_ENABLE_TEST_CHANNEL_SEEDING'\n\n/**\n * True only when the test-seeding env flag is explicitly enabled. Accepts the\n * usual truthy tokens (`1`, `true`, `yes`, `on`) so the harness can opt in via a\n * plain `=true`. Any other value (including unset) is treated as disabled.\n */\nexport function isTestChannelSeedingEnabled(): boolean {\n const raw = process.env[TEST_CHANNEL_SEEDING_ENV]\n if (typeof raw !== 'string') return false\n return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())\n}\n\n/**\n * Capabilities for the stub: an email channel that supports neither reactions,\n * edit/delete, nor conversation history \u2014 so the strict registry validator\n * (`validateAdapterCapabilities`) requires only the core method surface.\n */\nconst testSeedCapabilities: ChannelCapabilities = {\n ...baseEmailCapabilities,\n conversationHistory: false,\n realtimePush: false,\n}\n\n/**\n * A `ChannelAdapter` whose `sendMessage` reports a successful send WITHOUT any\n * network I/O. Used exclusively by the integration harness to let the outbound\n * delivery worker reach its success path and emit `communication_channels.message.sent`.\n */\nclass TestSeedChannelAdapter implements ChannelAdapter {\n // Widened to `string` rather than inferred as a literal so the chat-flavoured\n // subclass below can override both with its own provider key / channel type.\n readonly providerKey: string = TEST_SEED_PROVIDER_KEY\n readonly channelType: string = 'email'\n readonly capabilities = testSeedCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n // Synthesize a deterministic-looking RFC2822-style message id; never touches\n // the network. The delivery worker persists this as the external message id.\n const externalMessageId = `test-seed-${Date.now()}-${Math.random().toString(16).slice(2, 10)}@test-seed.local`\n return {\n externalMessageId,\n conversationId: input.conversationId,\n status: 'sent',\n metadata: { testSeed: true },\n }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n // No real webhook \u2014 return the inert event so the generic webhook route 202s\n // without enqueuing tenant-scoped work (mirrors the IMAP adapter contract).\n return { raw: {}, eventType: 'other', metadata: { reason: 'test-seed-no-webhook' } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return {\n content: {\n text: input.body,\n bodyFormat: input.bodyFormat,\n },\n metadata: input.channelMetadata ?? {},\n }\n }\n\n async normalizeInbound(_raw: InboundMessage): Promise<NormalizedInboundMessage> {\n // The test-seed inbound path seeds MessageChannelLink rows directly and emits\n // the hub event, so this adapter never normalizes a raw inbound payload.\n throw new Error('[internal] TestSeedChannelAdapter.normalizeInbound is not used by the seed harness')\n }\n\n async validateCredentials(_input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n // No real server to authenticate against \u2014 accept any credentials so the\n // connect command persists a connected channel.\n return { ok: true }\n }\n}\n\n/**\n * Chat-flavoured twin of {@link TestSeedChannelAdapter}: same network-free\n * behaviour, but it declares a non-email `channelType`, so a channel connected\n * through it is shaped like a real chat channel \u2014 including an\n * `externalIdentifier` of NULL when no email-ish credential key is supplied.\n */\nclass TestSeedChatChannelAdapter extends TestSeedChannelAdapter {\n readonly providerKey: string = TEST_SEED_CHAT_PROVIDER_KEY\n readonly channelType: string = 'discord'\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n // Unlike the email stub, this one is reachable: the test-seed ingest action\n // feeds it a chat-shaped frame so the message travels the real ingest path\n // (and therefore the real compose validation) rather than a SQL shortcut.\n const frame = (raw.raw ?? {}) as Record<string, unknown>\n const senderIdentifier = String(frame.senderIdentifier ?? '')\n if (!senderIdentifier) {\n throw new Error('[internal] TestSeedChatChannelAdapter requires a senderIdentifier')\n }\n return {\n externalMessageId: String(frame.externalMessageId ?? ''),\n externalConversationId: String(frame.externalConversationId ?? ''),\n senderIdentifier,\n senderDisplayName:\n typeof frame.senderDisplayName === 'string' ? frame.senderDisplayName : undefined,\n body: typeof frame.body === 'string' ? frame.body : '',\n bodyFormat: 'text',\n timestamp: new Date(),\n channelPayload: {},\n channelContentType: 'text/plain',\n channelMetadata: {},\n }\n }\n}\n\nlet cachedTestSeedAdapter: TestSeedChannelAdapter | null = null\nlet cachedTestSeedChatAdapter: TestSeedChatChannelAdapter | null = null\n\nfunction getTestSeedChannelAdapter(): TestSeedChannelAdapter {\n if (!cachedTestSeedAdapter) cachedTestSeedAdapter = new TestSeedChannelAdapter()\n return cachedTestSeedAdapter\n}\n\nfunction getTestSeedChatChannelAdapter(): TestSeedChatChannelAdapter {\n if (!cachedTestSeedChatAdapter) cachedTestSeedChatAdapter = new TestSeedChatChannelAdapter()\n return cachedTestSeedChatAdapter\n}\n\n/**\n * Register the test-seed adapter exactly once, but ONLY when the env flag is set.\n * Idempotent and safe to call from every container creation (`di.register`) \u2014 a\n * no-op when seeding is disabled or the adapter is already registered.\n */\nexport function ensureTestSeedAdapterRegistered(): void {\n if (!isTestChannelSeedingEnabled()) return\n if (!hasChannelAdapter(TEST_SEED_PROVIDER_KEY)) {\n registerChannelAdapter(getTestSeedChannelAdapter())\n }\n if (!hasChannelAdapter(TEST_SEED_CHAT_PROVIDER_KEY)) {\n registerChannelAdapter(getTestSeedChatChannelAdapter())\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type {\n ChannelAdapter,\n ChannelCapabilities,\n ChannelNativeContent,\n ConvertOutboundInput,\n GetMessageStatusInput,\n InboundMessage,\n MessageStatus,\n NormalizedInboundMessage,\n SendMessageInput,\n SendMessageResult,\n ValidateCredentialsInput,\n ValidateCredentialsResult,\n VerifyWebhookInput,\n} from './adapter'\nimport { baseEmailCapabilities } from './email-capabilities'\nimport { hasChannelAdapter, registerChannelAdapter } from './adapter-registry-singleton'\n\n/**\n * Test-only channel seeding support.\n *\n * The ephemeral integration harness cannot connect a REAL email channel:\n * - IMAP/SMTP `validateCredentials` performs a live LOGIN against a mail server\n * (none exists in CI), so `POST /channels/connect/credentials` returns 422.\n * - Even with a connected channel, the outbound delivery worker calls the real\n * SMTP adapter, which fails with no server \u2014 so `communication_channels.message.sent`\n * never fires and the customers link subscriber never runs.\n *\n * To make the compose \u2192 deliver \u2192 `.sent` \u2192 CRM-link \u2192 cross-user-visibility chain\n * (TC-CRM-EMAIL-001) and the inbound auto-link chain (TC-CRM-EMAIL-002..005) runnable\n * end-to-end against real Postgres, this module provides a network-free stub adapter\n * that is registered ONLY when `OM_ENABLE_TEST_CHANNEL_SEEDING` is set.\n *\n * Production safety: the registration is gated by {@link isTestChannelSeedingEnabled};\n * when the env flag is unset (the production default) the adapter is never registered\n * and the `__test_seed__` provider key resolves to no adapter \u2014 so the connect route\n * returns 404 `no_adapter` exactly as it would for any unknown provider. The dedicated\n * test-seed API route enforces the same gate independently (fail-closed 404 in prod).\n */\n\n/** Provider key for the network-free test stub adapter. */\nexport const TEST_SEED_PROVIDER_KEY = '__test_seed__'\n\n/**\n * Provider key for the network-free stub adapter that stands in for a CHAT\n * provider \u2014 one whose senders are identified by an opaque handle and have no\n * email address at all (Discord, Slack, Telegram\u2026).\n *\n * It exists because the email-shaped stub above can only ever prove the hub\n * accepts email-shaped data. That is precisely how CI stayed green while every\n * real inbound Discord message was rejected (#4975): the fixture invented an\n * address the provider can never produce. Tests that need to prove the hub's\n * non-email identity contract MUST drive this provider instead.\n */\nexport const TEST_SEED_CHAT_PROVIDER_KEY = '__test_seed_chat__'\n\n/** Env flag that unlocks test-only channel seeding. Off in production. */\nexport const TEST_CHANNEL_SEEDING_ENV = 'OM_ENABLE_TEST_CHANNEL_SEEDING'\n\n/**\n * True only when the test-seeding env flag is explicitly enabled. Accepts the\n * usual truthy tokens (`1`, `true`, `yes`, `on`) so the harness can opt in via a\n * plain `=true`. Any other value (including unset) is treated as disabled.\n */\nexport function isTestChannelSeedingEnabled(): boolean {\n const raw = process.env[TEST_CHANNEL_SEEDING_ENV]\n if (typeof raw !== 'string') return false\n return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase())\n}\n\n/**\n * Capabilities for the stub: an email channel that supports neither reactions,\n * edit/delete, nor conversation history \u2014 so the strict registry validator\n * (`validateAdapterCapabilities`) requires only the core method surface.\n */\nconst testSeedCapabilities: ChannelCapabilities = {\n ...baseEmailCapabilities,\n conversationHistory: false,\n realtimePush: false,\n}\n\n/**\n * A `ChannelAdapter` whose `sendMessage` reports a successful send WITHOUT any\n * network I/O. Used exclusively by the integration harness to let the outbound\n * delivery worker reach its success path and emit `communication_channels.message.sent`.\n */\nclass TestSeedChannelAdapter implements ChannelAdapter {\n // Widened to `string` rather than inferred as a literal so the chat-flavoured\n // subclass below can override both with its own provider key / channel type.\n readonly providerKey: string = TEST_SEED_PROVIDER_KEY\n readonly channelType: string = 'email'\n readonly capabilities = testSeedCapabilities\n\n async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {\n // Synthesize a deterministic-looking RFC2822-style message id; never touches\n // the network. The delivery worker persists this as the external message id.\n const externalMessageId = `test-seed-${Date.now()}-${Math.random().toString(16).slice(2, 10)}@test-seed.local`\n return {\n externalMessageId,\n conversationId: input.conversationId,\n status: 'sent',\n metadata: { testSeed: true },\n }\n }\n\n async verifyWebhook(_input: VerifyWebhookInput): Promise<InboundMessage> {\n // No real webhook \u2014 return the inert event so the generic webhook route 202s\n // without enqueuing tenant-scoped work (mirrors the IMAP adapter contract).\n return { raw: {}, eventType: 'other', metadata: { reason: 'test-seed-no-webhook' } }\n }\n\n async getStatus(_input: GetMessageStatusInput): Promise<MessageStatus> {\n return { status: 'sent' }\n }\n\n async convertOutbound(input: ConvertOutboundInput): Promise<ChannelNativeContent> {\n return {\n content: {\n text: input.body,\n bodyFormat: input.bodyFormat,\n },\n metadata: input.channelMetadata ?? {},\n }\n }\n\n async normalizeInbound(_raw: InboundMessage): Promise<NormalizedInboundMessage> {\n // The test-seed inbound path seeds MessageChannelLink rows directly and emits\n // the hub event, so this adapter never normalizes a raw inbound payload.\n throw new Error('[internal] TestSeedChannelAdapter.normalizeInbound is not used by the seed harness')\n }\n\n async validateCredentials(_input: ValidateCredentialsInput): Promise<ValidateCredentialsResult> {\n // No real server to authenticate against \u2014 accept any credentials so the\n // connect command persists a connected channel.\n return { ok: true }\n }\n}\n\n/**\n * Capabilities for the chat stub. Identical to the email stub's except for the\n * recipient shape: a chat channel is addressed by a provider-issued identifier\n * (a Discord snowflake), not an email address.\n *\n * Without this override the chat stub inherited `baseEmailCapabilities`, so it\n * claimed `channelType: 'discord'` while validating recipients as email\n * addresses \u2014 a channel that could not be addressed the way the provider it\n * imitates actually is. That left the hub's `'provider-native'` branch\n * (#4976: per-provider recipient validation, and the omitted recipient that\n * falls back to the adapter's own target) reachable only with a live Discord\n * bot, so no integration test could cover it and CI never exercised it.\n */\nconst testSeedChatCapabilities: ChannelCapabilities = {\n ...testSeedCapabilities,\n recipientFormat: 'provider-native',\n}\n\n/**\n * Chat-flavoured twin of {@link TestSeedChannelAdapter}: same network-free\n * behaviour, but it declares a non-email `channelType`, so a channel connected\n * through it is shaped like a real chat channel \u2014 including an\n * `externalIdentifier` of NULL when no email-ish credential key is supplied.\n */\nclass TestSeedChatChannelAdapter extends TestSeedChannelAdapter {\n readonly providerKey: string = TEST_SEED_CHAT_PROVIDER_KEY\n readonly channelType: string = 'discord'\n readonly capabilities = testSeedChatCapabilities\n\n async normalizeInbound(raw: InboundMessage): Promise<NormalizedInboundMessage> {\n // Unlike the email stub, this one is reachable: the test-seed ingest action\n // feeds it a chat-shaped frame so the message travels the real ingest path\n // (and therefore the real compose validation) rather than a SQL shortcut.\n const frame = (raw.raw ?? {}) as Record<string, unknown>\n const senderIdentifier = String(frame.senderIdentifier ?? '')\n if (!senderIdentifier) {\n throw new Error('[internal] TestSeedChatChannelAdapter requires a senderIdentifier')\n }\n return {\n externalMessageId: String(frame.externalMessageId ?? ''),\n externalConversationId: String(frame.externalConversationId ?? ''),\n senderIdentifier,\n senderDisplayName:\n typeof frame.senderDisplayName === 'string' ? frame.senderDisplayName : undefined,\n body: typeof frame.body === 'string' ? frame.body : '',\n bodyFormat: 'text',\n timestamp: new Date(),\n channelPayload: {},\n channelContentType: 'text/plain',\n channelMetadata: {},\n }\n }\n}\n\nlet cachedTestSeedAdapter: TestSeedChannelAdapter | null = null\nlet cachedTestSeedChatAdapter: TestSeedChatChannelAdapter | null = null\n\nfunction getTestSeedChannelAdapter(): TestSeedChannelAdapter {\n if (!cachedTestSeedAdapter) cachedTestSeedAdapter = new TestSeedChannelAdapter()\n return cachedTestSeedAdapter\n}\n\nfunction getTestSeedChatChannelAdapter(): TestSeedChatChannelAdapter {\n if (!cachedTestSeedChatAdapter) cachedTestSeedChatAdapter = new TestSeedChatChannelAdapter()\n return cachedTestSeedChatAdapter\n}\n\n/**\n * Register the test-seed adapter exactly once, but ONLY when the env flag is set.\n * Idempotent and safe to call from every container creation (`di.register`) \u2014 a\n * no-op when seeding is disabled or the adapter is already registered.\n */\nexport function ensureTestSeedAdapterRegistered(): void {\n if (!isTestChannelSeedingEnabled()) return\n if (!hasChannelAdapter(TEST_SEED_PROVIDER_KEY)) {\n registerChannelAdapter(getTestSeedChannelAdapter())\n }\n if (!hasChannelAdapter(TEST_SEED_CHAT_PROVIDER_KEY)) {\n registerChannelAdapter(getTestSeedChatChannelAdapter())\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -62,7 +62,7 @@ function useDealClosure({
62
62
  const lossPayload = {
63
63
  id: currentDealId,
64
64
  closureOutcome: "lost",
65
- status: "loose",
65
+ status: "lost",
66
66
  lossReasonId: input.lossReasonId,
67
67
  ...input.lossNotes ? { lossNotes: input.lossNotes } : {}
68
68
  };