@open-mercato/core 0.8.1-develop.7263.1.bbc6db3437 → 0.8.1-develop.7267.1.2e95af80a6

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 (145) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities.ids.generated.js +0 -4
  3. package/dist/generated/entities.ids.generated.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +0 -21
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -0
  7. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  8. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +13 -0
  9. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  10. package/dist/modules/communication_channels/lib/email-mime.js +19 -1
  11. package/dist/modules/communication_channels/lib/email-mime.js.map +2 -2
  12. package/dist/modules/customers/lib/link-channel-message-handler.js +17 -7
  13. package/dist/modules/customers/lib/link-channel-message-handler.js.map +3 -3
  14. package/dist/modules/messages/api/route.js +1 -0
  15. package/dist/modules/messages/api/route.js.map +2 -2
  16. package/dist/modules/messages/commands/messages.js +3 -1
  17. package/dist/modules/messages/commands/messages.js.map +2 -2
  18. package/dist/modules/messages/data/validators.js +10 -2
  19. package/dist/modules/messages/data/validators.js.map +2 -2
  20. package/dist/modules/wms/di.js +0 -3
  21. package/dist/modules/wms/di.js.map +2 -2
  22. package/generated/entities.ids.generated.ts +0 -4
  23. package/generated/entity-fields-registry.ts +0 -21
  24. package/package.json +7 -7
  25. package/src/modules/auth/i18n/de.json +0 -3
  26. package/src/modules/auth/i18n/en.json +0 -3
  27. package/src/modules/auth/i18n/es.json +0 -3
  28. package/src/modules/auth/i18n/ko.json +0 -3
  29. package/src/modules/auth/i18n/pl.json +0 -3
  30. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +8 -0
  31. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +16 -0
  32. package/src/modules/communication_channels/lib/email-mime.ts +52 -2
  33. package/src/modules/customers/lib/link-channel-message-handler.ts +48 -5
  34. package/src/modules/messages/api/route.ts +6 -3
  35. package/src/modules/messages/commands/messages.ts +3 -1
  36. package/src/modules/messages/data/validators.ts +21 -12
  37. package/src/modules/wms/di.ts +0 -6
  38. package/dist/generated/entities/availability_policy/index.js +0 -41
  39. package/dist/generated/entities/availability_policy/index.js.map +0 -7
  40. package/dist/modules/availability/acl.js +0 -16
  41. package/dist/modules/availability/acl.js.map +0 -7
  42. package/dist/modules/availability/api/check/route.js +0 -95
  43. package/dist/modules/availability/api/check/route.js.map +0 -7
  44. package/dist/modules/availability/api/openapi.js +0 -27
  45. package/dist/modules/availability/api/openapi.js.map +0 -7
  46. package/dist/modules/availability/api/policies/resolve-preview/route.js +0 -58
  47. package/dist/modules/availability/api/policies/resolve-preview/route.js.map +0 -7
  48. package/dist/modules/availability/api/policies/route.js +0 -188
  49. package/dist/modules/availability/api/policies/route.js.map +0 -7
  50. package/dist/modules/availability/backend/availability/check/page.js +0 -185
  51. package/dist/modules/availability/backend/availability/check/page.js.map +0 -7
  52. package/dist/modules/availability/backend/availability/check/page.meta.js +0 -16
  53. package/dist/modules/availability/backend/availability/check/page.meta.js.map +0 -7
  54. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js +0 -79
  55. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js.map +0 -7
  56. package/dist/modules/availability/backend/availability/policies/ScopeFields.js +0 -53
  57. package/dist/modules/availability/backend/availability/policies/ScopeFields.js.map +0 -7
  58. package/dist/modules/availability/backend/availability/policies/[id]/page.js +0 -152
  59. package/dist/modules/availability/backend/availability/policies/[id]/page.js.map +0 -7
  60. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js +0 -12
  61. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js.map +0 -7
  62. package/dist/modules/availability/backend/availability/policies/create/page.js +0 -94
  63. package/dist/modules/availability/backend/availability/policies/create/page.js.map +0 -7
  64. package/dist/modules/availability/backend/availability/policies/create/page.meta.js +0 -12
  65. package/dist/modules/availability/backend/availability/policies/create/page.meta.js.map +0 -7
  66. package/dist/modules/availability/backend/availability/policies/formGroups.js +0 -75
  67. package/dist/modules/availability/backend/availability/policies/formGroups.js.map +0 -7
  68. package/dist/modules/availability/backend/availability/policies/page.js +0 -176
  69. package/dist/modules/availability/backend/availability/policies/page.js.map +0 -7
  70. package/dist/modules/availability/backend/availability/policies/page.meta.js +0 -16
  71. package/dist/modules/availability/backend/availability/policies/page.meta.js.map +0 -7
  72. package/dist/modules/availability/commands/policies.js +0 -312
  73. package/dist/modules/availability/commands/policies.js.map +0 -7
  74. package/dist/modules/availability/commands/scope.js +0 -19
  75. package/dist/modules/availability/commands/scope.js.map +0 -7
  76. package/dist/modules/availability/data/entities.js +0 -95
  77. package/dist/modules/availability/data/entities.js.map +0 -7
  78. package/dist/modules/availability/data/validators.js +0 -53
  79. package/dist/modules/availability/data/validators.js.map +0 -7
  80. package/dist/modules/availability/di.js +0 -41
  81. package/dist/modules/availability/di.js.map +0 -7
  82. package/dist/modules/availability/events.js +0 -18
  83. package/dist/modules/availability/events.js.map +0 -7
  84. package/dist/modules/availability/index.js +0 -15
  85. package/dist/modules/availability/index.js.map +0 -7
  86. package/dist/modules/availability/lib/policyResolution.js +0 -155
  87. package/dist/modules/availability/lib/policyResolution.js.map +0 -7
  88. package/dist/modules/availability/lib/tryResolve.js +0 -11
  89. package/dist/modules/availability/lib/tryResolve.js.map +0 -7
  90. package/dist/modules/availability/migrations/Migration20260922095844_availability.js +0 -16
  91. package/dist/modules/availability/migrations/Migration20260922095844_availability.js.map +0 -7
  92. package/dist/modules/availability/setup.js +0 -12
  93. package/dist/modules/availability/setup.js.map +0 -7
  94. package/dist/modules/wms/lib/availabilityCache.js +0 -85
  95. package/dist/modules/wms/lib/availabilityCache.js.map +0 -7
  96. package/dist/modules/wms/lib/availabilityCalculation.js +0 -178
  97. package/dist/modules/wms/lib/availabilityCalculation.js.map +0 -7
  98. package/dist/modules/wms/lib/availabilityProvider.js +0 -14
  99. package/dist/modules/wms/lib/availabilityProvider.js.map +0 -7
  100. package/dist/modules/wms/lib/sqlInClause.js +0 -9
  101. package/dist/modules/wms/lib/sqlInClause.js.map +0 -7
  102. package/dist/modules/wms/lib/tryResolve.js +0 -11
  103. package/dist/modules/wms/lib/tryResolve.js.map +0 -7
  104. package/dist/modules/wms/subscribers/invalidate-availability-cache.js +0 -16
  105. package/dist/modules/wms/subscribers/invalidate-availability-cache.js.map +0 -7
  106. package/generated/entities/availability_policy/index.ts +0 -19
  107. package/src/modules/availability/acl.ts +0 -12
  108. package/src/modules/availability/api/check/route.ts +0 -114
  109. package/src/modules/availability/api/openapi.ts +0 -28
  110. package/src/modules/availability/api/policies/resolve-preview/route.ts +0 -67
  111. package/src/modules/availability/api/policies/route.ts +0 -224
  112. package/src/modules/availability/backend/availability/check/page.meta.ts +0 -12
  113. package/src/modules/availability/backend/availability/check/page.tsx +0 -223
  114. package/src/modules/availability/backend/availability/policies/ResolutionPreviewPanel.tsx +0 -106
  115. package/src/modules/availability/backend/availability/policies/ScopeFields.tsx +0 -55
  116. package/src/modules/availability/backend/availability/policies/[id]/page.meta.ts +0 -8
  117. package/src/modules/availability/backend/availability/policies/[id]/page.tsx +0 -182
  118. package/src/modules/availability/backend/availability/policies/create/page.meta.ts +0 -8
  119. package/src/modules/availability/backend/availability/policies/create/page.tsx +0 -105
  120. package/src/modules/availability/backend/availability/policies/formGroups.ts +0 -77
  121. package/src/modules/availability/backend/availability/policies/page.meta.ts +0 -12
  122. package/src/modules/availability/backend/availability/policies/page.tsx +0 -196
  123. package/src/modules/availability/commands/policies.ts +0 -358
  124. package/src/modules/availability/commands/scope.ts +0 -26
  125. package/src/modules/availability/data/entities.ts +0 -92
  126. package/src/modules/availability/data/validators.ts +0 -70
  127. package/src/modules/availability/di.ts +0 -54
  128. package/src/modules/availability/events.ts +0 -17
  129. package/src/modules/availability/i18n/de.json +0 -81
  130. package/src/modules/availability/i18n/en.json +0 -81
  131. package/src/modules/availability/i18n/es.json +0 -81
  132. package/src/modules/availability/i18n/ko.json +0 -81
  133. package/src/modules/availability/i18n/pl.json +0 -81
  134. package/src/modules/availability/index.ts +0 -13
  135. package/src/modules/availability/lib/policyResolution.ts +0 -264
  136. package/src/modules/availability/lib/tryResolve.ts +0 -8
  137. package/src/modules/availability/migrations/.snapshot-open-mercato.json +0 -375
  138. package/src/modules/availability/migrations/Migration20260922095844_availability.ts +0 -13
  139. package/src/modules/availability/setup.ts +0 -10
  140. package/src/modules/wms/lib/availabilityCache.ts +0 -136
  141. package/src/modules/wms/lib/availabilityCalculation.ts +0 -289
  142. package/src/modules/wms/lib/availabilityProvider.ts +0 -29
  143. package/src/modules/wms/lib/sqlInClause.ts +0 -21
  144. package/src/modules/wms/lib/tryResolve.ts +0 -8
  145. package/src/modules/wms/subscribers/invalidate-availability-cache.ts +0 -30
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/communication_channels/lib/email-mime.ts"],
4
- "sourcesContent": ["import crypto from 'node:crypto'\nimport type { NormalizedInboundMessage, NormalizedAttachment } from './adapter'\nimport { EMAIL_MAX_ATTACHMENT_BYTES } from './email-capabilities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'email-mime' })\n\n/**\n * Aggregate ceiling for all attachments on a single inbound message. Inbound\n * mail is untrusted, so without a cap a malicious/large message would be fully\n * base64-buffered in memory (~1.33x raw bytes) and persisted. Allow a small\n * multiple of the per-attachment limit for legitimate multi-file emails.\n */\nconst TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES = EMAIL_MAX_ATTACHMENT_BYTES * 2\n\n/**\n * Shared email MIME helpers for the email channel providers (Gmail, IMAP).\n * Outbound assembly, inbound parsing, header/address normalization,\n * and threading-id extraction all live here so every provider shares one\n * correct implementation instead of copy-pasting (which previously let Gmail's\n * `extractHeaders` drift into a Map-handling bug that IMAP had already fixed).\n *\n * Provider-specific transport (Gmail History API, IMAP UID sync)\n * stays in each package \u2014 this module only owns the format-level plumbing.\n */\n\n// \u2500\u2500 Outbound MIME helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport function stringOrUndefined(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport function toAddressList(value: unknown): string[] {\n if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter((v) => v.length > 0)\n if (typeof value === 'string') {\n return value\n .split(/[,;]\\s*/)\n .map((v) => v.trim())\n .filter((v) => v.length > 0)\n }\n return []\n}\n\nexport function referencesFromMeta(value: unknown): string[] | undefined {\n if (!Array.isArray(value)) return undefined\n return value.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Strip every `<tag>\u2026</tag>` block (and its contents) for a single tag name.\n * Inbound HTML is untrusted, so the matcher must resist evasion: the close tag\n * allows trailing attributes/whitespace (`</script >`, `</style foo>`), the `i`\n * flag covers mixed case, and the replacement loops until the string is stable\n * so a payload split across nested or reconstructed tags cannot survive a\n * single pass (`<scr<script>ipt>` collapsing back into `<script>`).\n *\n * The opening tag is also matched when it is truncated or never closed \u2014 a bare\n * `<script` or an unterminated `<script>\u2026` running to end-of-input is removed\n * outright \u2014 so no prefix of the element name can leak into the output.\n */\nfunction stripTagBlocks(html: string, tag: string): string {\n const blockPattern = new RegExp(\n `<${tag}\\\\b[^>]*(?:>[\\\\s\\\\S]*?(?:<\\\\/${tag}[^>]*>|$)|$)`,\n 'gi',\n )\n let previous: string\n let current = html\n do {\n previous = current\n current = current.replace(blockPattern, ' ')\n } while (current !== previous)\n return current\n}\n\n/**\n * Drop HTML comments (`<!-- \u2026 -->`), including an unterminated comment running\n * to end-of-input. Comments are stripped first because they can wrap content\n * that would otherwise survive tag removal (`<!--<script-->`), and a naive\n * filter that ignores them leaves a tag fragment behind.\n */\nfunction stripHtmlComments(html: string): string {\n return html.replace(/<!--[\\s\\S]*?(?:-->|$)/g, ' ')\n}\n\nconst BASIC_HTML_ENTITIES: Record<string, string> = {\n '&nbsp;': ' ',\n '&lt;': '<',\n '&gt;': '>',\n '&quot;': '\"',\n '&amp;': '&',\n}\n\n/**\n * Decode the handful of HTML entities we surface in plaintext in ONE\n * left-to-right pass. A single pass cannot double-unescape: characters produced\n * by a replacement (e.g. the `&` from `&amp;`) are never re-scanned, so\n * `&amp;lt;` decodes to the literal `&lt;` rather than collapsing into `<`.\n */\nfunction decodeBasicEntities(input: string): string {\n return input.replace(\n /&(?:nbsp|lt|gt|quot|amp);/gi,\n (match) => BASIC_HTML_ENTITIES[match.toLowerCase()] ?? match,\n )\n}\n\n/**\n * Remove every remaining `<\u2026>` tag, looping until the string is stable so a tag\n * reconstructed by a single pass (`<<div>div>`) cannot survive \u2014 the same\n * loop-until-stable shape `stripTagBlocks` uses. A raw `<` that is not part of a\n * tag (`a < b`) has no closing `>`, never matches, and is intentionally kept.\n */\nfunction stripRemainingTags(html: string): string {\n let previous: string\n let current = html\n do {\n previous = current\n current = current.replace(/<[^>]*>/g, '')\n } while (current !== previous)\n return current\n}\n\n/**\n * Convert untrusted inbound HTML to plaintext. Entities are decoded FIRST so any\n * entity-encoded markup (e.g. `&lt;script&gt;`) is normalized into real tag\n * syntax before the strippers run \u2014 otherwise decoding last would reintroduce a\n * `<script` fragment into the final output after sanitization had finished\n * (CodeQL `js/incomplete-multi-character-sanitization`). After decoding, all\n * tag removal (script/style blocks, then remaining tags) happens via\n * loop-until-stable passes, so no `<tag` prefix can leak into the plaintext.\n */\nexport function htmlToText(html: string): string {\n const decoded = decodeBasicEntities(html)\n const stripped = stripTagBlocks(stripTagBlocks(stripHtmlComments(decoded), 'style'), 'script')\n .replace(/<br\\s*\\/?>(?=\\s*)/gi, '\\n')\n .replace(/<\\/p\\s*>/gi, '\\n\\n')\n return stripRemainingTags(stripped)\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\nexport function escapeQuotes(value: string): string {\n return value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')\n}\n\n/**\n * Collapse CR/LF/TAB in an email header value to a single space to prevent\n * RFC 5322 header injection \u2014 e.g. a Subject smuggling an extra\n * `Bcc:`/`Content-Type:` header or splitting the message into a body.\n * Collapsing (rather than folding) is safe for the short structured headers\n * we emit (Subject, addresses, Message-ID, References).\n */\nexport function sanitizeHeaderValue(value: string): string {\n return value.replace(/[\\r\\n\\t]+/g, ' ').trim()\n}\n\nexport function ensureBrackets(value: string): string {\n const trimmed = sanitizeHeaderValue(value)\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed\n return `<${trimmed}>`\n}\n\nfunction isPureAscii(value: string): boolean {\n // eslint-disable-next-line no-control-regex\n return /^[\\x00-\\x7F]*$/.test(value)\n}\n\n/**\n * Encode a single header value as an RFC 2047 \"B\" (base64) encoded-word when it\n * contains non-ASCII characters, so 8-bit text like \"Caf\u00E9\" survives strict MTAs\n * that treat header bytes as 7-bit ASCII. Pure-ASCII values are returned\n * unchanged. Apply only AFTER `sanitizeHeaderValue` so the CR/LF injection guard\n * still runs against the raw value.\n */\nexport function encodeHeaderWord(value: string): string {\n if (isPureAscii(value)) return value\n return `=?utf-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`\n}\n\n/**\n * Encode the display-name part of a single address header value, leaving the\n * `<addr@domain>` untouched (per RFC 2047, encoded-words are not permitted\n * inside the addr-spec). Inputs without a bracketed address are treated as a\n * bare display name / address and encoded only when non-ASCII.\n */\nexport function encodeAddressHeaderWord(value: string): string {\n if (isPureAscii(value)) return value\n const match = value.match(/^(.*?)(\\s*<[^>]*>)\\s*$/)\n if (match) {\n const [, displayPart, addrPart] = match\n const displayName = displayPart.replace(/^\"|\"$/g, '').trim()\n if (!displayName) return value\n return `${encodeHeaderWord(displayName)}${addrPart}`\n }\n return encodeHeaderWord(value)\n}\n\n/**\n * Generate an RFC 5322 Message-ID rooted in the sender's domain. Used as a\n * downstream idempotency key, so entropy comes from `crypto.randomUUID()`\n * rather than `Math.random()`.\n */\nexport function generateMessageId(fromAddress: string, fallbackDomain = 'localhost'): string {\n const domain = fromAddress.split('@')[1] ?? fallbackDomain\n return `<${crypto.randomUUID()}@${domain}>`\n}\n\nexport interface AssembleRfc2822Input {\n from: string\n to: string[]\n cc: string[]\n bcc: string[]\n subject: string | undefined\n text: string | undefined\n html: string | undefined\n inReplyTo: string | undefined\n references: string[] | undefined\n messageId: string\n}\n\n/**\n * Render one MIME body part's CTE header + body content. Non-ASCII bodies are\n * base64-encoded (CRLF-wrapped at 76 cols) and labelled\n * `Content-Transfer-Encoding: base64` so 8-bit text survives strict MTAs;\n * pure-ASCII bodies stay `7bit` and verbatim.\n */\nfunction encodeBodyPart(content: string): { cte: string; body: string } {\n if (isPureAscii(content)) return { cte: '7bit', body: content }\n const base64 = Buffer.from(content, 'utf-8').toString('base64')\n const wrapped = base64.match(/.{1,76}/g)?.join('\\r\\n') ?? base64\n return { cte: 'base64', body: wrapped }\n}\n\n/**\n * Assemble a raw RFC2822 message (used by transports that send the encoded\n * message directly, e.g. Gmail `users.messages.send`). Emits a\n * `multipart/alternative` body when both html and text are present, otherwise a\n * single-part text or html body.\n */\nexport function assembleRfc2822(input: AssembleRfc2822Input): Buffer {\n const boundary = `omc_${crypto.randomUUID()}`\n const headers: string[] = []\n headers.push(`From: ${encodeAddressHeaderWord(sanitizeHeaderValue(input.from))}`)\n headers.push(`To: ${input.to.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.cc.length) headers.push(`Cc: ${input.cc.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.bcc.length) headers.push(`Bcc: ${input.bcc.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.subject) headers.push(`Subject: ${encodeHeaderWord(sanitizeHeaderValue(input.subject))}`)\n headers.push(`Message-ID: ${ensureBrackets(input.messageId)}`)\n if (input.inReplyTo) headers.push(`In-Reply-To: ${ensureBrackets(input.inReplyTo)}`)\n if (input.references && input.references.length) {\n headers.push(`References: ${input.references.map(ensureBrackets).join(' ')}`)\n }\n headers.push('MIME-Version: 1.0')\n headers.push(`Date: ${new Date().toUTCString()}`)\n\n if (input.html && input.text) {\n headers.push(`Content-Type: multipart/alternative; boundary=\"${boundary}\"`)\n const textPart = encodeBodyPart(input.text)\n const htmlPart = encodeBodyPart(input.html)\n const body = [\n '',\n `--${boundary}`,\n 'Content-Type: text/plain; charset=utf-8',\n `Content-Transfer-Encoding: ${textPart.cte}`,\n '',\n textPart.body,\n `--${boundary}`,\n 'Content-Type: text/html; charset=utf-8',\n `Content-Transfer-Encoding: ${htmlPart.cte}`,\n '',\n htmlPart.body,\n `--${boundary}--`,\n '',\n ].join('\\r\\n')\n return Buffer.from(headers.join('\\r\\n') + body, 'utf-8')\n }\n\n if (input.html) {\n const htmlPart = encodeBodyPart(input.html)\n headers.push('Content-Type: text/html; charset=utf-8')\n headers.push(`Content-Transfer-Encoding: ${htmlPart.cte}`)\n return Buffer.from(headers.join('\\r\\n') + '\\r\\n\\r\\n' + htmlPart.body, 'utf-8')\n }\n\n const textPart = encodeBodyPart(input.text ?? '')\n headers.push('Content-Type: text/plain; charset=utf-8')\n headers.push(`Content-Transfer-Encoding: ${textPart.cte}`)\n return Buffer.from(headers.join('\\r\\n') + '\\r\\n\\r\\n' + textPart.body, 'utf-8')\n}\n\n// \u2500\u2500 Inbound MIME parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface ParsedMail {\n messageId?: string | null\n inReplyTo?: string | null\n references?: string | string[] | null\n from?: { value?: Array<{ address?: string; name?: string }> }\n to?: { value?: Array<{ address?: string; name?: string }> }\n cc?: { value?: Array<{ address?: string; name?: string }> }\n bcc?: { value?: Array<{ address?: string; name?: string }> }\n subject?: string | null\n html?: string | false\n text?: string\n date?: string | Date | null\n attachments?: ParsedAttachment[]\n headers?: Map<string, unknown> | Record<string, unknown>\n}\n\nexport interface ParsedAttachment {\n content?: Buffer | Uint8Array\n contentType?: string\n filename?: string\n size?: number\n contentDisposition?: string\n cid?: string\n}\n\nexport function stripBrackets(value: string | undefined | null): string | undefined {\n if (!value) return undefined\n const trimmed = value.trim()\n if (!trimmed) return undefined\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1)\n return trimmed\n}\n\nexport function parseReferences(value: string | string[] | undefined | null): string[] {\n if (!value) return []\n if (Array.isArray(value)) return value.map((v) => stripBrackets(v)).filter((v): v is string => Boolean(v))\n return value\n .split(/\\s+/)\n .map((segment) => stripBrackets(segment))\n .filter((segment): segment is string => Boolean(segment))\n}\n\nexport function normalizeAttachments(attachments: ParsedAttachment[]): NormalizedAttachment[] {\n const out: NormalizedAttachment[] = []\n let totalBytes = 0\n for (const att of attachments) {\n if (!att.content) continue\n const byteLength = att.content.byteLength\n if (byteLength > EMAIL_MAX_ATTACHMENT_BYTES) {\n logger.warn('dropping oversized inbound attachment', { fileName: att.filename ?? 'attachment', bytes: byteLength, maxBytes: EMAIL_MAX_ATTACHMENT_BYTES })\n continue\n }\n if (totalBytes + byteLength > TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES) {\n logger.warn('aggregate inbound attachment size exceeded; dropping remaining attachments', { maxBytes: TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES })\n break\n }\n totalBytes += byteLength\n const base64 = Buffer.isBuffer(att.content)\n ? att.content.toString('base64')\n : Buffer.from(att.content).toString('base64')\n out.push({\n url: `data:${att.contentType ?? 'application/octet-stream'};base64,${base64}`,\n mimeType: att.contentType ?? 'application/octet-stream',\n fileName: att.filename ?? 'attachment',\n fileSize: att.size,\n inline: Boolean(att.contentDisposition && /inline/i.test(att.contentDisposition)) || Boolean(att.cid),\n })\n }\n return out\n}\n\nfunction stringifyHeaderValue(value: unknown): string | undefined {\n if (typeof value === 'string') return value\n if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')\n if (value instanceof Date) return value.toISOString()\n if (value && typeof value === 'object') {\n try {\n return JSON.stringify(value)\n } catch {\n return undefined\n }\n }\n if (value === undefined || value === null) return undefined\n return String(value)\n}\n\n/**\n * Flatten parsed MIME headers to a `Record<string, string>`.\n *\n * mailparser returns `headers` as a `Map<string, unknown>`. `Object.entries` on\n * a Map yields an empty array (Maps have no enumerable own properties), so we\n * iterate Map entries directly, with a Record fallback for test fakes.\n */\nexport function extractHeaders(headers: ParsedMail['headers']): Record<string, string> {\n if (!headers) return {}\n const out: Record<string, string> = {}\n if (headers instanceof Map) {\n for (const [key, value] of headers.entries()) {\n const stringified = stringifyHeaderValue(value)\n if (stringified !== undefined) out[String(key)] = stringified\n }\n return out\n }\n for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {\n const stringified = stringifyHeaderValue(value)\n if (stringified !== undefined) out[key] = stringified\n }\n return out\n}\n\nexport interface NormalizeMimeInboundOptions {\n /**\n * The parsed MIME message. Providers parse with their own `mailparser`\n * dependency (Gmail/IMAP) and pass the result here, so the hub stays free of a\n * MIME-parser dependency while still owning the normalization logic.\n */\n parsed: ParsedMail\n /** External identifier of the receiving channel (typically the account's email). */\n accountIdentifier: string\n /** Deterministic id used when the MIME message carries no `Message-ID` header. */\n fallbackMessageId: string\n /** Compute the conversation grouping id from the resolved message id + references. */\n resolveConversationId: (context: { messageId: string; references: string[] }) => string\n /** Fallback timestamp when the parsed message has no Date header. */\n fallbackDate?: Date\n /** Provider-specific fields merged into `channelMetadata`. */\n channelMetadata?: (parsed: ParsedMail) => Record<string, unknown>\n /** Provider-specific fields merged into `channelPayload`. */\n channelPayload?: (parsed: ParsedMail) => Record<string, unknown>\n}\n\n/**\n * Build the hub's canonical `NormalizedInboundMessage` from a parsed MIME\n * message. Providers supply the bits that genuinely differ (message-id\n * fallback, conversation grouping, extra metadata) and inherit the shared\n * threading / attachment / header logic.\n */\nexport function normalizeMimeInbound(options: NormalizeMimeInboundOptions): NormalizedInboundMessage {\n const { parsed } = options\n\n const messageId = stripBrackets(parsed.messageId) ?? options.fallbackMessageId\n const inReplyTo = stripBrackets(parsed.inReplyTo)\n const references = parseReferences(parsed.references)\n const conversationId = options.resolveConversationId({ messageId, references })\n\n const from = parsed.from?.value?.[0]\n const subject = parsed.subject?.trim() || undefined\n const bodyHtml = parsed.html && typeof parsed.html === 'string' ? parsed.html : undefined\n const bodyText = typeof parsed.text === 'string' ? parsed.text : undefined\n const body = bodyHtml ?? bodyText ?? ''\n const bodyFormat: 'text' | 'html' = bodyHtml ? 'html' : 'text'\n\n const attachments = normalizeAttachments(parsed.attachments ?? [])\n\n const channelMetadata: Record<string, unknown> = {\n ...(options.channelMetadata?.(parsed) ?? {}),\n messageId,\n inReplyTo: inReplyTo ?? null,\n references,\n headers: extractHeaders(parsed.headers),\n }\n\n const channelPayload: Record<string, unknown> = {\n subject,\n from: from ? { address: from.address, name: from.name } : null,\n to: parsed.to?.value ?? [],\n cc: parsed.cc?.value ?? [],\n bcc: parsed.bcc?.value ?? [],\n html: bodyHtml ?? null,\n text: bodyText ?? null,\n messageId,\n ...(options.channelPayload?.(parsed) ?? {}),\n }\n\n return {\n externalMessageId: messageId,\n externalConversationId: conversationId,\n senderIdentifier: from?.address ?? options.accountIdentifier,\n senderDisplayName: from?.name?.trim() || undefined,\n subject,\n body,\n bodyFormat,\n attachments,\n timestamp: parsed.date ? new Date(parsed.date) : options.fallbackDate ?? new Date(),\n replyToExternalId: inReplyTo ?? undefined,\n channelPayload,\n channelContentType: 'email/mime',\n channelMetadata,\n }\n}\n\n// \u2500\u2500 Provider sync cursor helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Encode a provider channel-state object into an opaque base64 sync cursor. */\nexport function encodeCursor(state: unknown): string {\n return Buffer.from(JSON.stringify(state)).toString('base64')\n}\n\n/** Decode a base64 sync cursor back into its object form, or `null` if malformed. */\nexport function decodeCursor(value: string | null | undefined): unknown {\n if (!value) return null\n try {\n return JSON.parse(Buffer.from(value, 'base64').toString('utf-8'))\n } catch {\n return null\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import crypto from 'node:crypto'\nimport type { NormalizedInboundMessage, NormalizedAttachment } from './adapter'\nimport { EMAIL_MAX_ATTACHMENT_BYTES } from './email-capabilities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'email-mime' })\n\n/**\n * Aggregate ceiling for all attachments on a single inbound message. Inbound\n * mail is untrusted, so without a cap a malicious/large message would be fully\n * base64-buffered in memory (~1.33x raw bytes) and persisted. Allow a small\n * multiple of the per-attachment limit for legitimate multi-file emails.\n */\nconst TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES = EMAIL_MAX_ATTACHMENT_BYTES * 2\n\n/**\n * Shared email MIME helpers for the email channel providers (Gmail, IMAP).\n * Outbound assembly, inbound parsing, header/address normalization,\n * and threading-id extraction all live here so every provider shares one\n * correct implementation instead of copy-pasting (which previously let Gmail's\n * `extractHeaders` drift into a Map-handling bug that IMAP had already fixed).\n *\n * Provider-specific transport (Gmail History API, IMAP UID sync)\n * stays in each package \u2014 this module only owns the format-level plumbing.\n */\n\n// \u2500\u2500 Outbound MIME helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport function stringOrUndefined(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport function toAddressList(value: unknown): string[] {\n if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter((v) => v.length > 0)\n if (typeof value === 'string') {\n return value\n .split(/[,;]\\s*/)\n .map((v) => v.trim())\n .filter((v) => v.length > 0)\n }\n return []\n}\n\nexport function referencesFromMeta(value: unknown): string[] | undefined {\n if (!Array.isArray(value)) return undefined\n return value.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Strip every `<tag>\u2026</tag>` block (and its contents) for a single tag name.\n * Inbound HTML is untrusted, so the matcher must resist evasion: the close tag\n * allows trailing attributes/whitespace (`</script >`, `</style foo>`), the `i`\n * flag covers mixed case, and the replacement loops until the string is stable\n * so a payload split across nested or reconstructed tags cannot survive a\n * single pass (`<scr<script>ipt>` collapsing back into `<script>`).\n *\n * The opening tag is also matched when it is truncated or never closed \u2014 a bare\n * `<script` or an unterminated `<script>\u2026` running to end-of-input is removed\n * outright \u2014 so no prefix of the element name can leak into the output.\n */\nfunction stripTagBlocks(html: string, tag: string): string {\n const blockPattern = new RegExp(\n `<${tag}\\\\b[^>]*(?:>[\\\\s\\\\S]*?(?:<\\\\/${tag}[^>]*>|$)|$)`,\n 'gi',\n )\n let previous: string\n let current = html\n do {\n previous = current\n current = current.replace(blockPattern, ' ')\n } while (current !== previous)\n return current\n}\n\n/**\n * Drop HTML comments (`<!-- \u2026 -->`), including an unterminated comment running\n * to end-of-input. Comments are stripped first because they can wrap content\n * that would otherwise survive tag removal (`<!--<script-->`), and a naive\n * filter that ignores them leaves a tag fragment behind.\n */\nfunction stripHtmlComments(html: string): string {\n return html.replace(/<!--[\\s\\S]*?(?:-->|$)/g, ' ')\n}\n\nconst BASIC_HTML_ENTITIES: Record<string, string> = {\n '&nbsp;': ' ',\n '&lt;': '<',\n '&gt;': '>',\n '&quot;': '\"',\n '&amp;': '&',\n}\n\n/**\n * Decode the handful of HTML entities we surface in plaintext in ONE\n * left-to-right pass. A single pass cannot double-unescape: characters produced\n * by a replacement (e.g. the `&` from `&amp;`) are never re-scanned, so\n * `&amp;lt;` decodes to the literal `&lt;` rather than collapsing into `<`.\n */\nfunction decodeBasicEntities(input: string): string {\n return input.replace(\n /&(?:nbsp|lt|gt|quot|amp);/gi,\n (match) => BASIC_HTML_ENTITIES[match.toLowerCase()] ?? match,\n )\n}\n\n/**\n * Remove every remaining `<\u2026>` tag, looping until the string is stable so a tag\n * reconstructed by a single pass (`<<div>div>`) cannot survive \u2014 the same\n * loop-until-stable shape `stripTagBlocks` uses. A raw `<` that is not part of a\n * tag (`a < b`) has no closing `>`, never matches, and is intentionally kept.\n */\nfunction stripRemainingTags(html: string): string {\n let previous: string\n let current = html\n do {\n previous = current\n current = current.replace(/<[^>]*>/g, '')\n } while (current !== previous)\n return current\n}\n\n/**\n * Convert untrusted inbound HTML to plaintext. Entities are decoded FIRST so any\n * entity-encoded markup (e.g. `&lt;script&gt;`) is normalized into real tag\n * syntax before the strippers run \u2014 otherwise decoding last would reintroduce a\n * `<script` fragment into the final output after sanitization had finished\n * (CodeQL `js/incomplete-multi-character-sanitization`). After decoding, all\n * tag removal (script/style blocks, then remaining tags) happens via\n * loop-until-stable passes, so no `<tag` prefix can leak into the plaintext.\n */\nexport function htmlToText(html: string): string {\n const decoded = decodeBasicEntities(html)\n const stripped = stripTagBlocks(stripTagBlocks(stripHtmlComments(decoded), 'style'), 'script')\n .replace(/<br\\s*\\/?>(?=\\s*)/gi, '\\n')\n .replace(/<\\/p\\s*>/gi, '\\n\\n')\n return stripRemainingTags(stripped)\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim()\n}\n\nexport function escapeQuotes(value: string): string {\n return value.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')\n}\n\n/**\n * Collapse CR/LF/TAB in an email header value to a single space to prevent\n * RFC 5322 header injection \u2014 e.g. a Subject smuggling an extra\n * `Bcc:`/`Content-Type:` header or splitting the message into a body.\n * Collapsing (rather than folding) is safe for the short structured headers\n * we emit (Subject, addresses, Message-ID, References).\n */\nexport function sanitizeHeaderValue(value: string): string {\n return value.replace(/[\\r\\n\\t]+/g, ' ').trim()\n}\n\nexport function ensureBrackets(value: string): string {\n const trimmed = sanitizeHeaderValue(value)\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed\n return `<${trimmed}>`\n}\n\nfunction isPureAscii(value: string): boolean {\n // eslint-disable-next-line no-control-regex\n return /^[\\x00-\\x7F]*$/.test(value)\n}\n\n/**\n * Encode a single header value as an RFC 2047 \"B\" (base64) encoded-word when it\n * contains non-ASCII characters, so 8-bit text like \"Caf\u00E9\" survives strict MTAs\n * that treat header bytes as 7-bit ASCII. Pure-ASCII values are returned\n * unchanged. Apply only AFTER `sanitizeHeaderValue` so the CR/LF injection guard\n * still runs against the raw value.\n */\nexport function encodeHeaderWord(value: string): string {\n if (isPureAscii(value)) return value\n return `=?utf-8?B?${Buffer.from(value, 'utf-8').toString('base64')}?=`\n}\n\n/**\n * Encode the display-name part of a single address header value, leaving the\n * `<addr@domain>` untouched (per RFC 2047, encoded-words are not permitted\n * inside the addr-spec). Inputs without a bracketed address are treated as a\n * bare display name / address and encoded only when non-ASCII.\n */\nexport function encodeAddressHeaderWord(value: string): string {\n if (isPureAscii(value)) return value\n const match = value.match(/^(.*?)(\\s*<[^>]*>)\\s*$/)\n if (match) {\n const [, displayPart, addrPart] = match\n const displayName = displayPart.replace(/^\"|\"$/g, '').trim()\n if (!displayName) return value\n return `${encodeHeaderWord(displayName)}${addrPart}`\n }\n return encodeHeaderWord(value)\n}\n\n/**\n * Generate an RFC 5322 Message-ID rooted in the sender's domain. Used as a\n * downstream idempotency key, so entropy comes from `crypto.randomUUID()`\n * rather than `Math.random()`.\n */\nexport function generateMessageId(fromAddress: string, fallbackDomain = 'localhost'): string {\n const domain = fromAddress.split('@')[1] ?? fallbackDomain\n return `<${crypto.randomUUID()}@${domain}>`\n}\n\nexport interface AssembleRfc2822Input {\n from: string\n to: string[]\n cc: string[]\n bcc: string[]\n subject: string | undefined\n text: string | undefined\n html: string | undefined\n inReplyTo: string | undefined\n references: string[] | undefined\n messageId: string\n}\n\n/**\n * Render one MIME body part's CTE header + body content. Non-ASCII bodies are\n * base64-encoded (CRLF-wrapped at 76 cols) and labelled\n * `Content-Transfer-Encoding: base64` so 8-bit text survives strict MTAs;\n * pure-ASCII bodies stay `7bit` and verbatim.\n */\nfunction encodeBodyPart(content: string): { cte: string; body: string } {\n if (isPureAscii(content)) return { cte: '7bit', body: content }\n const base64 = Buffer.from(content, 'utf-8').toString('base64')\n const wrapped = base64.match(/.{1,76}/g)?.join('\\r\\n') ?? base64\n return { cte: 'base64', body: wrapped }\n}\n\n/**\n * Assemble a raw RFC2822 message (used by transports that send the encoded\n * message directly, e.g. Gmail `users.messages.send`). Emits a\n * `multipart/alternative` body when both html and text are present, otherwise a\n * single-part text or html body.\n */\nexport function assembleRfc2822(input: AssembleRfc2822Input): Buffer {\n const boundary = `omc_${crypto.randomUUID()}`\n const headers: string[] = []\n headers.push(`From: ${encodeAddressHeaderWord(sanitizeHeaderValue(input.from))}`)\n headers.push(`To: ${input.to.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.cc.length) headers.push(`Cc: ${input.cc.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.bcc.length) headers.push(`Bcc: ${input.bcc.map((value) => encodeAddressHeaderWord(sanitizeHeaderValue(value))).join(', ')}`)\n if (input.subject) headers.push(`Subject: ${encodeHeaderWord(sanitizeHeaderValue(input.subject))}`)\n headers.push(`Message-ID: ${ensureBrackets(input.messageId)}`)\n if (input.inReplyTo) headers.push(`In-Reply-To: ${ensureBrackets(input.inReplyTo)}`)\n if (input.references && input.references.length) {\n headers.push(`References: ${input.references.map(ensureBrackets).join(' ')}`)\n }\n headers.push('MIME-Version: 1.0')\n headers.push(`Date: ${new Date().toUTCString()}`)\n\n if (input.html && input.text) {\n headers.push(`Content-Type: multipart/alternative; boundary=\"${boundary}\"`)\n const textPart = encodeBodyPart(input.text)\n const htmlPart = encodeBodyPart(input.html)\n const body = [\n '',\n `--${boundary}`,\n 'Content-Type: text/plain; charset=utf-8',\n `Content-Transfer-Encoding: ${textPart.cte}`,\n '',\n textPart.body,\n `--${boundary}`,\n 'Content-Type: text/html; charset=utf-8',\n `Content-Transfer-Encoding: ${htmlPart.cte}`,\n '',\n htmlPart.body,\n `--${boundary}--`,\n '',\n ].join('\\r\\n')\n return Buffer.from(headers.join('\\r\\n') + body, 'utf-8')\n }\n\n if (input.html) {\n const htmlPart = encodeBodyPart(input.html)\n headers.push('Content-Type: text/html; charset=utf-8')\n headers.push(`Content-Transfer-Encoding: ${htmlPart.cte}`)\n return Buffer.from(headers.join('\\r\\n') + '\\r\\n\\r\\n' + htmlPart.body, 'utf-8')\n }\n\n const textPart = encodeBodyPart(input.text ?? '')\n headers.push('Content-Type: text/plain; charset=utf-8')\n headers.push(`Content-Transfer-Encoding: ${textPart.cte}`)\n return Buffer.from(headers.join('\\r\\n') + '\\r\\n\\r\\n' + textPart.body, 'utf-8')\n}\n\n// \u2500\u2500 Inbound MIME parsing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface ParsedMail {\n messageId?: string | null\n inReplyTo?: string | null\n references?: string | string[] | null\n from?: { value?: Array<{ address?: string; name?: string }> }\n to?: { value?: Array<{ address?: string; name?: string }> }\n cc?: { value?: Array<{ address?: string; name?: string }> }\n bcc?: { value?: Array<{ address?: string; name?: string }> }\n subject?: string | null\n html?: string | false\n text?: string\n date?: string | Date | null\n attachments?: ParsedAttachment[]\n headers?: Map<string, unknown> | Record<string, unknown>\n}\n\nexport interface ParsedAttachment {\n content?: Buffer | Uint8Array\n contentType?: string\n filename?: string\n size?: number\n contentDisposition?: string\n cid?: string\n}\n\nexport function stripBrackets(value: string | undefined | null): string | undefined {\n if (!value) return undefined\n const trimmed = value.trim()\n if (!trimmed) return undefined\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1)\n return trimmed\n}\n\nexport function parseReferences(value: string | string[] | undefined | null): string[] {\n if (!value) return []\n if (Array.isArray(value)) return value.map((v) => stripBrackets(v)).filter((v): v is string => Boolean(v))\n return value\n .split(/\\s+/)\n .map((segment) => stripBrackets(segment))\n .filter((segment): segment is string => Boolean(segment))\n}\n\nexport function normalizeAttachments(attachments: ParsedAttachment[]): NormalizedAttachment[] {\n const out: NormalizedAttachment[] = []\n let totalBytes = 0\n for (const att of attachments) {\n if (!att.content) continue\n const byteLength = att.content.byteLength\n if (byteLength > EMAIL_MAX_ATTACHMENT_BYTES) {\n logger.warn('dropping oversized inbound attachment', { fileName: att.filename ?? 'attachment', bytes: byteLength, maxBytes: EMAIL_MAX_ATTACHMENT_BYTES })\n continue\n }\n if (totalBytes + byteLength > TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES) {\n logger.warn('aggregate inbound attachment size exceeded; dropping remaining attachments', { maxBytes: TOTAL_INBOUND_ATTACHMENTS_MAX_BYTES })\n break\n }\n totalBytes += byteLength\n const base64 = Buffer.isBuffer(att.content)\n ? att.content.toString('base64')\n : Buffer.from(att.content).toString('base64')\n out.push({\n url: `data:${att.contentType ?? 'application/octet-stream'};base64,${base64}`,\n mimeType: att.contentType ?? 'application/octet-stream',\n fileName: att.filename ?? 'attachment',\n fileSize: att.size,\n inline: Boolean(att.contentDisposition && /inline/i.test(att.contentDisposition)) || Boolean(att.cid),\n })\n }\n return out\n}\n\nfunction stringifyHeaderValue(value: unknown): string | undefined {\n if (typeof value === 'string') return value\n if (Array.isArray(value)) return value.map((v) => String(v)).join(', ')\n if (value instanceof Date) return value.toISOString()\n if (value && typeof value === 'object') {\n try {\n return JSON.stringify(value)\n } catch {\n return undefined\n }\n }\n if (value === undefined || value === null) return undefined\n return String(value)\n}\n\n/**\n * Flatten parsed MIME headers to a `Record<string, string>`.\n *\n * mailparser returns `headers` as a `Map<string, unknown>`. `Object.entries` on\n * a Map yields an empty array (Maps have no enumerable own properties), so we\n * iterate Map entries directly, with a Record fallback for test fakes.\n */\nexport function extractHeaders(headers: ParsedMail['headers']): Record<string, string> {\n if (!headers) return {}\n const out: Record<string, string> = {}\n if (headers instanceof Map) {\n for (const [key, value] of headers.entries()) {\n const stringified = stringifyHeaderValue(value)\n if (stringified !== undefined) out[String(key)] = stringified\n }\n return out\n }\n for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {\n const stringified = stringifyHeaderValue(value)\n if (stringified !== undefined) out[key] = stringified\n }\n return out\n}\n\nexport interface NormalizeMimeInboundOptions {\n /**\n * The parsed MIME message. Providers parse with their own `mailparser`\n * dependency (Gmail/IMAP) and pass the result here, so the hub stays free of a\n * MIME-parser dependency while still owning the normalization logic.\n */\n parsed: ParsedMail\n /** External identifier of the receiving channel (typically the account's email). */\n accountIdentifier: string\n /** Deterministic id used when the MIME message carries no `Message-ID` header. */\n fallbackMessageId: string\n /** Compute the conversation grouping id from the resolved message id + references. */\n resolveConversationId: (context: { messageId: string; references: string[] }) => string\n /**\n * When the provider received the message (Gmail `internalDate`, IMAP\n * `INTERNALDATE`). Preferred over the MIME `Date` header for the message\n * timestamp: the header is written by the sender's client and can be forged\n * or skewed, and since #6095 the timestamp also dates the platform message\n * and the CRM interaction, so a bad header would pin a message in the future\n * or hide it in the past.\n */\n receivedAt?: Date\n /**\n * Fallback timestamp when neither `receivedAt` nor a usable Date header is\n * available.\n */\n fallbackDate?: Date\n /** Provider-specific fields merged into `channelMetadata`. */\n channelMetadata?: (parsed: ParsedMail) => Record<string, unknown>\n /** Provider-specific fields merged into `channelPayload`. */\n channelPayload?: (parsed: ParsedMail) => Record<string, unknown>\n}\n\n/**\n * A MIME `Date` header this far ahead of the clock is treated as bogus. Clock\n * skew between real mail servers is seconds to minutes; a day of tolerance\n * keeps legitimate mail while stopping a forged header from pinning a message\n * to the top of a `sent_at`-sorted inbox for weeks.\n */\nconst MAX_MIME_DATE_FUTURE_SKEW_MS = 24 * 60 * 60 * 1000\n\nfunction isValidDate(value: unknown): value is Date {\n return value instanceof Date && !Number.isNaN(value.getTime())\n}\n\n/**\n * Pick the timestamp for an inbound message.\n *\n * Order: the provider's receipt time; then the sender's MIME `Date` header,\n * unless it is unparsable or more than a day in the future; then the caller's\n * fallback; then now. Exported for tests.\n */\nexport function resolveInboundTimestamp(input: {\n receivedAt?: Date\n mimeDate?: Date\n fallbackDate?: Date\n now?: Date\n}): Date {\n if (isValidDate(input.receivedAt)) return input.receivedAt\n const now = input.now ?? new Date()\n if (isValidDate(input.mimeDate) && input.mimeDate.getTime() <= now.getTime() + MAX_MIME_DATE_FUTURE_SKEW_MS) {\n return input.mimeDate\n }\n if (isValidDate(input.fallbackDate)) return input.fallbackDate\n return now\n}\n\n/**\n * Build the hub's canonical `NormalizedInboundMessage` from a parsed MIME\n * message. Providers supply the bits that genuinely differ (message-id\n * fallback, conversation grouping, extra metadata) and inherit the shared\n * threading / attachment / header logic.\n */\nexport function normalizeMimeInbound(options: NormalizeMimeInboundOptions): NormalizedInboundMessage {\n const { parsed } = options\n\n const messageId = stripBrackets(parsed.messageId) ?? options.fallbackMessageId\n const inReplyTo = stripBrackets(parsed.inReplyTo)\n const references = parseReferences(parsed.references)\n const conversationId = options.resolveConversationId({ messageId, references })\n\n const from = parsed.from?.value?.[0]\n const subject = parsed.subject?.trim() || undefined\n const bodyHtml = parsed.html && typeof parsed.html === 'string' ? parsed.html : undefined\n const bodyText = typeof parsed.text === 'string' ? parsed.text : undefined\n const body = bodyHtml ?? bodyText ?? ''\n const bodyFormat: 'text' | 'html' = bodyHtml ? 'html' : 'text'\n\n const attachments = normalizeAttachments(parsed.attachments ?? [])\n\n const channelMetadata: Record<string, unknown> = {\n ...(options.channelMetadata?.(parsed) ?? {}),\n messageId,\n inReplyTo: inReplyTo ?? null,\n references,\n headers: extractHeaders(parsed.headers),\n }\n\n const channelPayload: Record<string, unknown> = {\n subject,\n from: from ? { address: from.address, name: from.name } : null,\n to: parsed.to?.value ?? [],\n cc: parsed.cc?.value ?? [],\n bcc: parsed.bcc?.value ?? [],\n html: bodyHtml ?? null,\n text: bodyText ?? null,\n messageId,\n ...(options.channelPayload?.(parsed) ?? {}),\n }\n\n return {\n externalMessageId: messageId,\n externalConversationId: conversationId,\n senderIdentifier: from?.address ?? options.accountIdentifier,\n senderDisplayName: from?.name?.trim() || undefined,\n subject,\n body,\n bodyFormat,\n attachments,\n timestamp: resolveInboundTimestamp({\n receivedAt: options.receivedAt,\n mimeDate: parsed.date ? new Date(parsed.date) : undefined,\n fallbackDate: options.fallbackDate,\n }),\n replyToExternalId: inReplyTo ?? undefined,\n channelPayload,\n channelContentType: 'email/mime',\n channelMetadata,\n }\n}\n\n// \u2500\u2500 Provider sync cursor helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Encode a provider channel-state object into an opaque base64 sync cursor. */\nexport function encodeCursor(state: unknown): string {\n return Buffer.from(JSON.stringify(state)).toString('base64')\n}\n\n/** Decode a base64 sync cursor back into its object form, or `null` if malformed. */\nexport function decodeCursor(value: string | null | undefined): unknown {\n if (!value) return null\n try {\n return JSON.parse(Buffer.from(value, 'base64').toString('utf-8'))\n } catch {\n return null\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["textPart"]
7
7
  }
@@ -23,6 +23,7 @@ async function handler(payload, ctx) {
23
23
  if (!link) return;
24
24
  const metaJson = link.channelMetadata ?? null;
25
25
  const payloadJson = link.channelPayload ?? null;
26
+ const occurredAt = resolveOccurredAt(payload, link);
26
27
  let channelUserId = null;
27
28
  if (typeof payload.channelId === "string" && payload.channelId) {
28
29
  const channel = await findOneWithDecryption(
@@ -61,21 +62,20 @@ async function handler(payload, ctx) {
61
62
  if (hintedPerson) crmPersonId = crmPersonIdHint;
62
63
  }
63
64
  if (normalized.length === 0 && !crmPersonId) {
64
- await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson);
65
+ await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson, occurredAt);
65
66
  return;
66
67
  }
67
68
  const matched = await findPeopleByAddresses(em, normalized, tenantId, organizationId);
68
69
  const personIdSet = new Set(matched.map((m) => m.id));
69
70
  if (crmPersonId) personIdSet.add(crmPersonId);
70
71
  if (personIdSet.size === 0) {
71
- await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson);
72
+ await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson, occurredAt);
72
73
  return;
73
74
  }
74
75
  const linkDirection = link.direction === "inbound" || link.direction === "outbound" ? link.direction : null;
75
76
  const visibility = resolveVisibility(metaJson, channelUserId, linkDirection);
76
77
  const subject = typeof metaJson?.subject === "string" ? metaJson.subject : typeof payloadJson?.subject === "string" ? payloadJson.subject : null;
77
78
  const bodyText = typeof metaJson?.bodyText === "string" ? metaJson.bodyText : typeof payloadJson?.text === "string" ? payloadJson.text : null;
78
- const occurredAt = link.createdAt instanceof Date ? link.createdAt : /* @__PURE__ */ new Date();
79
79
  const providerKey = typeof link.providerKey === "string" ? link.providerKey : null;
80
80
  await persistInteractions(
81
81
  em,
@@ -94,7 +94,7 @@ async function handler(payload, ctx) {
94
94
  }
95
95
  );
96
96
  }
97
- async function handleThreadingInheritance(em, _link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson) {
97
+ async function handleThreadingInheritance(em, _link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson, occurredAt) {
98
98
  const inboundMessageId = typeof _link.messageId === "string" ? _link.messageId : null;
99
99
  if (inboundMessageId && organizationId) {
100
100
  const threadPersonRows = await em.getConnection().execute(
@@ -137,7 +137,6 @@ async function handleThreadingInheritance(em, _link, linkId, tenantId, organizat
137
137
  if (threadPersonIds.size > 0) {
138
138
  const subject2 = typeof metaJson?.subject === "string" ? metaJson.subject : typeof payloadJson?.subject === "string" ? payloadJson.subject : null;
139
139
  const bodyText = typeof payloadJson?.text === "string" ? payloadJson.text : typeof metaJson?.bodyText === "string" ? metaJson.bodyText : null;
140
- const occurredAt2 = _link.createdAt instanceof Date ? _link.createdAt : /* @__PURE__ */ new Date();
141
140
  const providerKey2 = typeof _link.providerKey === "string" ? _link.providerKey : null;
142
141
  await persistInteractions(em, threadPersonIds, {
143
142
  linkId,
@@ -147,7 +146,7 @@ async function handleThreadingInheritance(em, _link, linkId, tenantId, organizat
147
146
  title: subject2,
148
147
  body: bodyText,
149
148
  authorUserId: channelUserId,
150
- occurredAt: occurredAt2,
149
+ occurredAt,
151
150
  visibility: channelUserId ? "private" : "shared",
152
151
  channelProviderKey: providerKey2
153
152
  });
@@ -201,7 +200,6 @@ async function handleThreadingInheritance(em, _link, linkId, tenantId, organizat
201
200
  const link = _link;
202
201
  const inheritedMeta = link.channelMetadata ?? null;
203
202
  const subject = typeof inheritedMeta?.subject === "string" ? inheritedMeta.subject : null;
204
- const occurredAt = link.createdAt instanceof Date ? link.createdAt : /* @__PURE__ */ new Date();
205
203
  const providerKey = typeof link.providerKey === "string" ? link.providerKey : null;
206
204
  await persistInteractions(
207
205
  em,
@@ -220,6 +218,18 @@ async function handleThreadingInheritance(em, _link, linkId, tenantId, organizat
220
218
  }
221
219
  );
222
220
  }
221
+ function resolveOccurredAt(payload, link) {
222
+ const ingestedAt = link.createdAt instanceof Date ? link.createdAt : /* @__PURE__ */ new Date();
223
+ const carried = payload.providerTimestamp;
224
+ if (carried instanceof Date) {
225
+ return Number.isNaN(carried.getTime()) ? ingestedAt : carried;
226
+ }
227
+ if (typeof carried === "string" && carried) {
228
+ const parsed = new Date(carried);
229
+ if (!Number.isNaN(parsed.getTime())) return parsed;
230
+ }
231
+ return ingestedAt;
232
+ }
223
233
  async function persistInteractions(em, personIdSet, data) {
224
234
  for (const personId of personIdSet) {
225
235
  const rowEm = em.fork();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/customers/lib/link-channel-message-handler.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CustomerEntity, CustomerInteraction } from '../data/entities'\nimport { findPeopleByAddresses, normalizeAddresses } from './findPeopleByAddresses'\nimport { emitCustomersEvent } from '../events'\nimport { INTERACTION_STATUS_COMPLETED } from './interactionStatus'\n\n/**\n * Shared implementation for the link-channel-message subscribers.\n *\n * The auto-discovery scanner requires a single string `event` value on each\n * subscriber file. Because we must handle both `communication_channels.message.received`\n * AND `.sent`, we use TWO thin subscriber files (link-channel-message-received.ts\n * and link-channel-message-sent.ts) that both delegate here.\n */\n\n// \u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Payload emitted by `communication_channels.message.received` and\n * `communication_channels.message.sent`.\n *\n * The canonical field is `channelLinkId` (as emitted by the hub). The alias\n * `messageChannelLinkId` is kept for test stubs and legacy compatibility.\n */\ntype LinkChannelMessagePayload = {\n eventType?: string\n /** UUID of the MessageChannelLink row (canonical hub field name). */\n channelLinkId?: string\n /** Alias used in some older stubs / test payloads. Prefer channelLinkId. */\n messageChannelLinkId?: string\n channelId?: string | null\n tenantId?: string\n organizationId?: string | null\n providerKey?: string | null\n direction?: 'inbound' | 'outbound' | null\n}\n\ntype SubscriberContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\n// \u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst POSTGRES_UNIQUE_VIOLATION = '23505'\n\n// \u2500\u2500 Main handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport default async function handler(\n payload: LinkChannelMessagePayload,\n ctx: SubscriberContext,\n): Promise<void> {\n // Resolve the link ID from either field name.\n const linkId = payload?.channelLinkId ?? payload?.messageChannelLinkId\n if (typeof linkId !== 'string' || !linkId) return\n\n // Fail-closed when tenantId is missing \u2014 unscoped queries are unsafe.\n if (typeof payload.tenantId !== 'string' || !payload.tenantId) return\n\n const tenantId = payload.tenantId\n const organizationId = payload.organizationId ?? null\n // CustomerEntity and CustomerInteraction are organization-scoped. Without an\n // organization id, fail closed instead of linking tenant-wide by email/thread.\n if (!organizationId) return\n const dscope = { tenantId, organizationId }\n\n // em.fork() gives us an isolated identity map for this event.\n const em = (ctx.resolve('em') as EntityManager).fork()\n\n // \u2500\u2500 (1) Load the MessageChannelLink row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The customers module MUST NOT import MessageChannelLink from the\n // communication_channels module (cross-module ORM boundary rule in AGENTS.md).\n // We use the entity class name as a string so MikroORM's identity map resolves\n // it at runtime \u2014 the generated entity registry includes the hub's entities.\n // Read through findOneWithDecryption so any encrypted columns on the hub\n // entity are transparently decrypted (per the Encryption section in AGENTS.md).\n const link = (await findOneWithDecryption(\n em,\n 'MessageChannelLink' as any,\n { id: linkId, tenantId, organizationId } as any,\n undefined,\n dscope,\n )) as Record<string, unknown> | null\n\n if (!link) return\n\n const metaJson = (link.channelMetadata ?? null) as Record<string, unknown> | null\n const payloadJson = (link.channelPayload ?? null) as Record<string, unknown> | null\n\n // \u2500\u2500 (2) Resolve the channel to get its owner userId \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The channel.userId is needed for two purposes:\n // - authorUserId on the CustomerInteraction row\n // - default visibility ('private' for user-scoped, 'shared' for tenant-scoped)\n //\n // We look up the channel only when channelId is provided in the event payload.\n let channelUserId: string | null = null\n if (typeof payload.channelId === 'string' && payload.channelId) {\n const channel = (await findOneWithDecryption(\n em,\n 'CommunicationChannel' as any,\n { id: payload.channelId, tenantId, organizationId } as any,\n undefined,\n dscope,\n )) as { userId?: string | null } | null\n channelUserId = channel?.userId ?? null\n }\n\n // \u2500\u2500 (3) Collect recipient addresses \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The channel metadata shape differs by provider and direction:\n // - Outbound (Gmail): subject/to/cc/bcc/from are in channelMetadata\n // (merged from GmailEmailNativeMetadata by deliver-outbound-message.ts)\n // - Inbound (Gmail): from/to/cc/bcc/subject are in channelPayload\n // (from NormalizedInboundMessage.channelPayload)\n //\n // We check channelMetadata first (works for outbound + any provider that\n // writes addresses there), then fall back to channelPayload for inbound.\n const rawAddresses: unknown[] = []\n\n // For inbound IMAP messages we ALWAYS read addresses from `channelPayload`,\n // not `channelMetadata`. The IMAP adapter stores raw provider headers in\n // `channelMetadata` (where `from` is a JSON-stringified string that's\n // useless for address matching), and the structured normalized addresses\n // in `channelPayload.from` / `.to` / `.cc` / `.bcc`. Reading metadata\n // first poisoned `rawAddresses` with stringified-JSON garbage so the\n // payload-fallback never ran.\n //\n // Priority: payloadJson (canonical normalized shape) \u2192 metaJson fallback\n // (for legacy/outbound providers that still write addresses there).\n if (payloadJson) {\n collectAddressField(rawAddresses, payloadJson.from)\n collectAddressField(rawAddresses, payloadJson.to)\n collectAddressField(rawAddresses, payloadJson.cc)\n collectAddressField(rawAddresses, payloadJson.bcc)\n }\n\n if (rawAddresses.length === 0 && metaJson) {\n collectAddressField(rawAddresses, metaJson.from)\n collectAddressField(rawAddresses, metaJson.to)\n collectAddressField(rawAddresses, metaJson.cc)\n collectAddressField(rawAddresses, metaJson.bcc)\n }\n\n const normalized = normalizeAddresses(rawAddresses as string[])\n\n // \u2500\u2500 (4) Optional explicit crmPersonId hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The outbound compose route stores `crmPersonId` in channelMetadata so the\n // subscriber can link to the intended Person even if their address isn't in\n // the recipient list (e.g. typo in To: field).\n const crmPersonIdHint =\n typeof metaJson?.crmPersonId === 'string' ? (metaJson!.crmPersonId as string) : null\n\n // Defense-in-depth: the crmPersonId hint is written by the compose route\n // (which already verifies tenant ownership), but the subscriber MUST re-verify\n // the hinted Person belongs to THIS tenant before linking. Otherwise a stale or\n // forged hint could attach an interaction to a Person in another tenant.\n let crmPersonId: string | null = null\n if (crmPersonIdHint) {\n const hintedPerson = await findOneWithDecryption(\n em,\n CustomerEntity,\n { id: crmPersonIdHint, kind: 'person', tenantId, organizationId, deletedAt: null } as any,\n undefined,\n dscope,\n )\n if (hintedPerson) crmPersonId = crmPersonIdHint\n }\n\n // Early exit: no addresses AND no hint \u2192 nothing to link.\n if (normalized.length === 0 && !crmPersonId) {\n // Before giving up, try threading-inheritance (TC-CRM-EMAIL-005).\n await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson)\n return\n }\n\n // \u2500\u2500 (5) Resolve People by address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const matched = await findPeopleByAddresses(em, normalized, tenantId, organizationId)\n const personIdSet = new Set<string>(matched.map((m) => m.id))\n if (crmPersonId) personIdSet.add(crmPersonId)\n\n if (personIdSet.size === 0) {\n // Try threading-inheritance before giving up.\n await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson)\n return\n }\n\n // \u2500\u2500 (6) Determine visibility \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // Priority order:\n // 1. Explicit `crmVisibility` in channelMetadata (set by compose route)\n // 2. Channel owner: 'private' if user-scoped, 'shared' if tenant-scoped\n const linkDirection =\n link.direction === 'inbound' || link.direction === 'outbound' ? link.direction : null\n const visibility: 'private' | 'shared' = resolveVisibility(metaJson, channelUserId, linkDirection)\n\n // \u2500\u2500 (7) Extract subject / body / timestamps \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const subject =\n typeof metaJson?.subject === 'string'\n ? (metaJson!.subject as string)\n : typeof payloadJson?.subject === 'string'\n ? (payloadJson!.subject as string)\n : null\n\n const bodyText =\n typeof metaJson?.bodyText === 'string'\n ? (metaJson!.bodyText as string)\n : typeof payloadJson?.text === 'string'\n ? (payloadJson!.text as string)\n : null\n\n const occurredAt = link.createdAt instanceof Date ? link.createdAt : new Date()\n const providerKey =\n typeof link.providerKey === 'string' ? (link.providerKey as string) : null\n\n // \u2500\u2500 (8) INSERT one CustomerInteraction per matched Person \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await persistInteractions(\n em,\n personIdSet,\n {\n linkId,\n tenantId,\n organizationId,\n interactionType: 'email',\n title: subject,\n body: bodyText,\n authorUserId: channelUserId,\n occurredAt,\n visibility,\n channelProviderKey: providerKey,\n },\n )\n}\n\n// \u2500\u2500 Threading-inheritance fallback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * When direct address matching finds 0 people AND crmPersonId is absent, look\n * up parent message references from channelMetadata (`inReplyTo` + `references`).\n *\n * For each reference (an RFC2822 Message-ID), find a prior MessageChannelLink\n * in this tenant whose `channelMetadata.messageId` matches. For each such\n * parent link, find any existing email CustomerInteraction rows where\n * `externalMessageId = parent.id` \u2014 and link THIS message to the same Persons.\n *\n * This enables TC-CRM-EMAIL-005: a reply from an unknown address is still\n * attached to alice's timeline because the original thread was.\n */\nasync function handleThreadingInheritance(\n em: EntityManager,\n _link: Record<string, unknown>,\n linkId: string,\n tenantId: string,\n organizationId: string | null,\n channelUserId: string | null,\n metaJson: Record<string, unknown> | null,\n payloadJson: Record<string, unknown> | null,\n): Promise<void> {\n // \u2500\u2500 Primary: inherit Person(s) from the hub's authoritative thread \u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The hub threads an inbound reply into the same `messages.message.thread_id`\n // as the outbound that started the conversation (via the thread token /\n // subject+participants matcher). That outbound is already linked to the CRM\n // Person (through the `crmPersonId` hint set by the compose route). So a reply\n // inherits the Person of any existing email interaction in the same thread.\n //\n // This is the dependable join where the alternatives are not:\n // - Address matching (`findPeopleByAddresses`) filters the *encrypted*\n // `primary_email` column by a plaintext value, which never matches when\n // tenant data encryption is on (ciphertext != plaintext).\n // - RFC Message-IDs are rewritten by some providers (e.g. Gmail) on send,\n // so the legacy In-Reply-To/References inheritance below also misses.\n // The hub thread id survives both, so we resolve by it first.\n const inboundMessageId = typeof _link.messageId === 'string' ? _link.messageId : null\n if (inboundMessageId && organizationId) {\n const threadPersonRows = (await em.getConnection().execute(\n `SELECT DISTINCT ci.entity_id AS entity_id\n FROM messages inbound_m\n JOIN messages thread_m ON thread_m.thread_id = inbound_m.thread_id\n JOIN message_channel_links mcl ON mcl.message_id = thread_m.id\n JOIN customer_interactions ci ON ci.external_message_id = mcl.id\n WHERE inbound_m.id = ?\n AND inbound_m.tenant_id = ?\n AND inbound_m.organization_id = ?\n AND inbound_m.deleted_at IS NULL\n AND inbound_m.thread_id IS NOT NULL\n AND thread_m.tenant_id = ?\n AND thread_m.organization_id = ?\n AND thread_m.deleted_at IS NULL\n AND mcl.tenant_id = ?\n AND mcl.organization_id = ?\n AND ci.tenant_id = ?\n AND ci.organization_id = ?\n AND ci.interaction_type = 'email'\n AND ci.deleted_at IS NULL\n AND ci.entity_id IS NOT NULL\n LIMIT 200`,\n [\n inboundMessageId,\n tenantId,\n organizationId,\n tenantId,\n organizationId,\n tenantId,\n organizationId,\n tenantId,\n organizationId,\n ],\n )) as Array<{ entity_id: string }>\n const threadPersonIds = new Set<string>(\n threadPersonRows.map((row) => row.entity_id).filter((id): id is string => !!id),\n )\n if (threadPersonIds.size > 0) {\n const subject =\n typeof metaJson?.subject === 'string'\n ? (metaJson.subject as string)\n : typeof payloadJson?.subject === 'string'\n ? (payloadJson.subject as string)\n : null\n const bodyText =\n typeof payloadJson?.text === 'string'\n ? (payloadJson.text as string)\n : typeof metaJson?.bodyText === 'string'\n ? (metaJson.bodyText as string)\n : null\n const occurredAt = _link.createdAt instanceof Date ? (_link.createdAt as Date) : new Date()\n const providerKey = typeof _link.providerKey === 'string' ? (_link.providerKey as string) : null\n await persistInteractions(em, threadPersonIds, {\n linkId,\n tenantId,\n organizationId,\n interactionType: 'email',\n title: subject,\n body: bodyText,\n authorUserId: channelUserId,\n occurredAt,\n visibility: channelUserId ? 'private' : 'shared',\n channelProviderKey: providerKey,\n })\n return\n }\n }\n\n // \u2500\u2500 Fallback: legacy In-Reply-To / References Message-ID inheritance \u2500\u2500\u2500\u2500\u2500\n // Collect reference message-ids from inReplyTo + references\n const refIds: string[] = []\n const inReplyTo =\n typeof metaJson?.inReplyTo === 'string'\n ? stripBrackets(metaJson!.inReplyTo as string)\n : typeof payloadJson?.inReplyTo === 'string'\n ? stripBrackets(payloadJson!.inReplyTo as string)\n : null\n if (inReplyTo) refIds.push(inReplyTo)\n\n const refs =\n Array.isArray(metaJson?.references)\n ? (metaJson!.references as unknown[])\n : Array.isArray(payloadJson?.references)\n ? (payloadJson!.references as unknown[])\n : []\n for (const r of refs) {\n if (typeof r === 'string') {\n const stripped = stripBrackets(r)\n if (stripped && !refIds.includes(stripped)) refIds.push(stripped)\n }\n }\n\n if (refIds.length === 0) return\n\n // Find parent MessageChannelLinks whose channelMetadata.messageId is in refIds.\n // Bounded lookup: narrow to the candidate Message-IDs directly in SQL instead\n // of loading every link in the tenant and filtering in JS (which does not scale\n // on a busy mailbox \u2014 this path runs for every inbound reply that didn't match a\n // Person by address). channel_metadata is NOT an encrypted column, so a\n // parameterized raw query is safe; we match both bracketed (`<id>`) and\n // unbracketed (`id`) Message-ID storage forms, capped to a sane page.\n const dscope = { tenantId, organizationId }\n const messageIdCandidates = Array.from(\n new Set(refIds.flatMap((ref) => [ref, `<${ref}>`])),\n )\n const placeholders = messageIdCandidates.map(() => '?').join(', ')\n const parentLinkRows = (await em.getConnection().execute(\n `SELECT id FROM message_channel_links\n WHERE tenant_id = ?\n AND organization_id = ?\n AND channel_metadata->>'messageId' IN (${placeholders})\n LIMIT 200`,\n [tenantId, organizationId, ...messageIdCandidates],\n )) as Array<{ id: string }>\n\n const matchedParentIds = parentLinkRows.map((parentLink) => parentLink.id)\n\n if (matchedParentIds.length === 0) return\n\n // Find existing email CustomerInteraction rows for those parent links.\n const parentInteractions = (await findWithDecryption(\n em,\n CustomerInteraction,\n {\n externalMessageId: { $in: matchedParentIds },\n tenantId,\n organizationId,\n interactionType: 'email',\n deletedAt: null,\n } as any,\n undefined,\n dscope,\n )) as Array<{ entity: { id: string } }>\n\n const inheritedPersonIdSet = new Set<string>(\n parentInteractions.map((pi) => pi.entity?.id).filter(Boolean) as string[],\n )\n\n if (inheritedPersonIdSet.size === 0) return\n\n const visibility: 'private' | 'shared' = channelUserId ? 'private' : 'shared'\n const link = _link\n const inheritedMeta = (link.channelMetadata ?? null) as Record<string, unknown> | null\n const subject =\n typeof inheritedMeta?.subject === 'string' ? (inheritedMeta.subject as string) : null\n const occurredAt = link.createdAt instanceof Date ? (link.createdAt as Date) : new Date()\n const providerKey =\n typeof link.providerKey === 'string' ? (link.providerKey as string) : null\n\n await persistInteractions(\n em,\n inheritedPersonIdSet,\n {\n linkId,\n tenantId,\n organizationId,\n interactionType: 'email',\n title: subject,\n body: null,\n authorUserId: channelUserId,\n occurredAt,\n visibility,\n channelProviderKey: providerKey,\n },\n )\n}\n\n// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ninterface InteractionData {\n linkId: string\n tenantId: string\n organizationId: string | null\n interactionType: string\n title: string | null\n body: string | null\n authorUserId: string | null\n occurredAt: Date\n visibility: 'private' | 'shared'\n channelProviderKey: string | null\n}\n\nasync function persistInteractions(\n em: EntityManager,\n personIdSet: Set<string>,\n data: InteractionData,\n): Promise<void> {\n for (const personId of personIdSet) {\n // Fork per row so a unique-violation on one row doesn't poison the identity\n // map for subsequent rows. The parent em's connection pool is reused.\n const rowEm = em.fork()\n // Use em.getReference so we satisfy the ManyToOne relation without a\n // redundant SELECT \u2014 MikroORM will flush the FK column directly.\n const entityRef = rowEm.getReference(CustomerEntity, personId)\n const interaction = rowEm.create(CustomerInteraction, {\n tenantId: data.tenantId,\n organizationId: data.organizationId,\n entity: entityRef,\n interactionType: data.interactionType,\n title: data.title,\n body: data.body,\n authorUserId: data.authorUserId,\n occurredAt: data.occurredAt,\n // Emails are logged AFTER they're sent/received \u2014 they are not\n // scheduled work. Without an explicit status the entity default\n // ('planned') combined with a past `occurredAt` makes the activity\n // timeline render the email as \"overdue\", which is the wrong UX.\n // The canonical terminal-success value is `INTERACTION_STATUS_COMPLETED`\n // ('done'); see `lib/interactionStatus.ts` for the open/terminal semantics.\n status: INTERACTION_STATUS_COMPLETED,\n externalMessageId: data.linkId,\n visibility: data.visibility,\n channelProviderKey: data.channelProviderKey,\n } as any)\n try {\n await rowEm.flush()\n } catch (err) {\n // Idempotency: swallow unique-violation on (entity_id, external_message_id).\n // The partial unique index `customer_interactions_email_dedupe_uq` guarantees\n // at-most-once semantics across retries.\n const code = (err as { code?: string }).code\n if (code !== POSTGRES_UNIQUE_VIOLATION) throw err\n // Duplicate (retried delivery) \u2014 already linked, skip the refresh signal.\n continue\n }\n // Live-refresh signal for the CRM Person page (clientBroadcast \u2192 SSE). The\n // interaction is already persisted, so a failed emit must not abort linking\n // or fail this persistent (retried) subscriber.\n try {\n await emitCustomersEvent('customers.email.linked', {\n personId,\n interactionId: interaction.id,\n tenantId: data.tenantId,\n organizationId: data.organizationId,\n })\n } catch {\n /* swallow \u2014 UI refresh signal is non-critical */\n }\n }\n}\n\nfunction resolveVisibility(\n metaJson: Record<string, unknown> | null,\n channelUserId: string | null,\n direction: 'inbound' | 'outbound' | null,\n): 'private' | 'shared' {\n // The explicit `crmVisibility` override is written ONLY by the outbound compose\n // route. Inbound `channelMetadata` is provider-derived (and therefore attacker-\n // influenceable), so it MUST NOT be able to downgrade a user-owned channel's\n // inbound mail from private \u2192 shared. Honor the override on outbound only.\n if (direction === 'outbound') {\n if (metaJson?.crmVisibility === 'shared') return 'shared'\n if (metaJson?.crmVisibility === 'private') return 'private'\n }\n // Tenant-scoped channels (no userId) \u2192 shared; user-scoped \u2192 private.\n return channelUserId ? 'private' : 'shared'\n}\n\n/**\n * Collect email address strings from a field that may be:\n * - a plain string: 'alice@example.com'\n * - an array of strings: ['alice@example.com', 'bob@example.com']\n * - an object with an `address` property: { address: 'alice@example.com', name: 'Alice' }\n * - an array of such objects\n */\nfunction collectAddressField(out: unknown[], value: unknown): void {\n if (!value) return\n if (typeof value === 'string') {\n out.push(value)\n return\n }\n if (Array.isArray(value)) {\n for (const item of value) {\n if (typeof item === 'string') {\n out.push(item)\n } else if (item && typeof item === 'object' && typeof (item as Record<string, unknown>).address === 'string') {\n out.push((item as Record<string, unknown>).address as string)\n }\n }\n return\n }\n if (typeof value === 'object' && typeof (value as Record<string, unknown>).address === 'string') {\n out.push((value as Record<string, unknown>).address as string)\n }\n}\n\nfunction stripBrackets(value: string): string {\n const trimmed = value.trim()\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) {\n return trimmed.slice(1, -1)\n }\n return trimmed\n}\n"],
5
- "mappings": "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",
6
- "names": ["subject", "occurredAt", "providerKey"]
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CustomerEntity, CustomerInteraction } from '../data/entities'\nimport { findPeopleByAddresses, normalizeAddresses } from './findPeopleByAddresses'\nimport { emitCustomersEvent } from '../events'\nimport { INTERACTION_STATUS_COMPLETED } from './interactionStatus'\n\n/**\n * Shared implementation for the link-channel-message subscribers.\n *\n * The auto-discovery scanner requires a single string `event` value on each\n * subscriber file. Because we must handle both `communication_channels.message.received`\n * AND `.sent`, we use TWO thin subscriber files (link-channel-message-received.ts\n * and link-channel-message-sent.ts) that both delegate here.\n */\n\n// \u2500\u2500 Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Payload emitted by `communication_channels.message.received` and\n * `communication_channels.message.sent`.\n *\n * The canonical field is `channelLinkId` (as emitted by the hub). The alias\n * `messageChannelLinkId` is kept for test stubs and legacy compatibility.\n */\ntype LinkChannelMessagePayload = {\n eventType?: string\n /** UUID of the MessageChannelLink row (canonical hub field name). */\n channelLinkId?: string\n /** Alias used in some older stubs / test payloads. Prefer channelLinkId. */\n messageChannelLinkId?: string\n channelId?: string | null\n tenantId?: string\n organizationId?: string | null\n providerKey?: string | null\n direction?: 'inbound' | 'outbound' | null\n /**\n * The provider's own receive/send time, carried by the hub on the event\n * (#6095). An ISO string when the event rode the queue, a Date for an\n * in-process emit. Absent on events enqueued before the field existed and on\n * adapters that supply no timestamp.\n */\n providerTimestamp?: string | Date | null\n}\n\ntype SubscriberContext = {\n resolve: <T = unknown>(name: string) => T\n}\n\n// \u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst POSTGRES_UNIQUE_VIOLATION = '23505'\n\n// \u2500\u2500 Main handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport default async function handler(\n payload: LinkChannelMessagePayload,\n ctx: SubscriberContext,\n): Promise<void> {\n // Resolve the link ID from either field name.\n const linkId = payload?.channelLinkId ?? payload?.messageChannelLinkId\n if (typeof linkId !== 'string' || !linkId) return\n\n // Fail-closed when tenantId is missing \u2014 unscoped queries are unsafe.\n if (typeof payload.tenantId !== 'string' || !payload.tenantId) return\n\n const tenantId = payload.tenantId\n const organizationId = payload.organizationId ?? null\n // CustomerEntity and CustomerInteraction are organization-scoped. Without an\n // organization id, fail closed instead of linking tenant-wide by email/thread.\n if (!organizationId) return\n const dscope = { tenantId, organizationId }\n\n // em.fork() gives us an isolated identity map for this event.\n const em = (ctx.resolve('em') as EntityManager).fork()\n\n // \u2500\u2500 (1) Load the MessageChannelLink row \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The customers module MUST NOT import MessageChannelLink from the\n // communication_channels module (cross-module ORM boundary rule in AGENTS.md).\n // We use the entity class name as a string so MikroORM's identity map resolves\n // it at runtime \u2014 the generated entity registry includes the hub's entities.\n // Read through findOneWithDecryption so any encrypted columns on the hub\n // entity are transparently decrypted (per the Encryption section in AGENTS.md).\n const link = (await findOneWithDecryption(\n em,\n 'MessageChannelLink' as any,\n { id: linkId, tenantId, organizationId } as any,\n undefined,\n dscope,\n )) as Record<string, unknown> | null\n\n if (!link) return\n\n const metaJson = (link.channelMetadata ?? null) as Record<string, unknown> | null\n const payloadJson = (link.channelPayload ?? null) as Record<string, unknown> | null\n\n // \u2500\u2500 (1b) Resolve when the email actually happened \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // `link.createdAt` is when the hub ingested the message, which for a history\n // import is the import minute, not the day the mail arrived (#6095). The hub\n // carries the provider's own timestamp on the event payload, so take it from\n // there: reading the communication_channels ExternalMessage row here would\n // cross the storage boundary (Cross-Module Coupling in AGENTS.md) and add a\n // peer-table query per event. Resolved once and threaded through every branch\n // below so the address-match and threading-inheritance paths agree.\n const occurredAt = resolveOccurredAt(payload, link)\n\n // \u2500\u2500 (2) Resolve the channel to get its owner userId \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The channel.userId is needed for two purposes:\n // - authorUserId on the CustomerInteraction row\n // - default visibility ('private' for user-scoped, 'shared' for tenant-scoped)\n //\n // We look up the channel only when channelId is provided in the event payload.\n let channelUserId: string | null = null\n if (typeof payload.channelId === 'string' && payload.channelId) {\n const channel = (await findOneWithDecryption(\n em,\n 'CommunicationChannel' as any,\n { id: payload.channelId, tenantId, organizationId } as any,\n undefined,\n dscope,\n )) as { userId?: string | null } | null\n channelUserId = channel?.userId ?? null\n }\n\n // \u2500\u2500 (3) Collect recipient addresses \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The channel metadata shape differs by provider and direction:\n // - Outbound (Gmail): subject/to/cc/bcc/from are in channelMetadata\n // (merged from GmailEmailNativeMetadata by deliver-outbound-message.ts)\n // - Inbound (Gmail): from/to/cc/bcc/subject are in channelPayload\n // (from NormalizedInboundMessage.channelPayload)\n //\n // We check channelMetadata first (works for outbound + any provider that\n // writes addresses there), then fall back to channelPayload for inbound.\n const rawAddresses: unknown[] = []\n\n // For inbound IMAP messages we ALWAYS read addresses from `channelPayload`,\n // not `channelMetadata`. The IMAP adapter stores raw provider headers in\n // `channelMetadata` (where `from` is a JSON-stringified string that's\n // useless for address matching), and the structured normalized addresses\n // in `channelPayload.from` / `.to` / `.cc` / `.bcc`. Reading metadata\n // first poisoned `rawAddresses` with stringified-JSON garbage so the\n // payload-fallback never ran.\n //\n // Priority: payloadJson (canonical normalized shape) \u2192 metaJson fallback\n // (for legacy/outbound providers that still write addresses there).\n if (payloadJson) {\n collectAddressField(rawAddresses, payloadJson.from)\n collectAddressField(rawAddresses, payloadJson.to)\n collectAddressField(rawAddresses, payloadJson.cc)\n collectAddressField(rawAddresses, payloadJson.bcc)\n }\n\n if (rawAddresses.length === 0 && metaJson) {\n collectAddressField(rawAddresses, metaJson.from)\n collectAddressField(rawAddresses, metaJson.to)\n collectAddressField(rawAddresses, metaJson.cc)\n collectAddressField(rawAddresses, metaJson.bcc)\n }\n\n const normalized = normalizeAddresses(rawAddresses as string[])\n\n // \u2500\u2500 (4) Optional explicit crmPersonId hint \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The outbound compose route stores `crmPersonId` in channelMetadata so the\n // subscriber can link to the intended Person even if their address isn't in\n // the recipient list (e.g. typo in To: field).\n const crmPersonIdHint =\n typeof metaJson?.crmPersonId === 'string' ? (metaJson!.crmPersonId as string) : null\n\n // Defense-in-depth: the crmPersonId hint is written by the compose route\n // (which already verifies tenant ownership), but the subscriber MUST re-verify\n // the hinted Person belongs to THIS tenant before linking. Otherwise a stale or\n // forged hint could attach an interaction to a Person in another tenant.\n let crmPersonId: string | null = null\n if (crmPersonIdHint) {\n const hintedPerson = await findOneWithDecryption(\n em,\n CustomerEntity,\n { id: crmPersonIdHint, kind: 'person', tenantId, organizationId, deletedAt: null } as any,\n undefined,\n dscope,\n )\n if (hintedPerson) crmPersonId = crmPersonIdHint\n }\n\n // Early exit: no addresses AND no hint \u2192 nothing to link.\n if (normalized.length === 0 && !crmPersonId) {\n // Before giving up, try threading-inheritance (TC-CRM-EMAIL-005).\n await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson, occurredAt)\n return\n }\n\n // \u2500\u2500 (5) Resolve People by address \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const matched = await findPeopleByAddresses(em, normalized, tenantId, organizationId)\n const personIdSet = new Set<string>(matched.map((m) => m.id))\n if (crmPersonId) personIdSet.add(crmPersonId)\n\n if (personIdSet.size === 0) {\n // Try threading-inheritance before giving up.\n await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson, occurredAt)\n return\n }\n\n // \u2500\u2500 (6) Determine visibility \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n //\n // Priority order:\n // 1. Explicit `crmVisibility` in channelMetadata (set by compose route)\n // 2. Channel owner: 'private' if user-scoped, 'shared' if tenant-scoped\n const linkDirection =\n link.direction === 'inbound' || link.direction === 'outbound' ? link.direction : null\n const visibility: 'private' | 'shared' = resolveVisibility(metaJson, channelUserId, linkDirection)\n\n // \u2500\u2500 (7) Extract subject / body / timestamps \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const subject =\n typeof metaJson?.subject === 'string'\n ? (metaJson!.subject as string)\n : typeof payloadJson?.subject === 'string'\n ? (payloadJson!.subject as string)\n : null\n\n const bodyText =\n typeof metaJson?.bodyText === 'string'\n ? (metaJson!.bodyText as string)\n : typeof payloadJson?.text === 'string'\n ? (payloadJson!.text as string)\n : null\n\n const providerKey =\n typeof link.providerKey === 'string' ? (link.providerKey as string) : null\n\n // \u2500\u2500 (8) INSERT one CustomerInteraction per matched Person \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n await persistInteractions(\n em,\n personIdSet,\n {\n linkId,\n tenantId,\n organizationId,\n interactionType: 'email',\n title: subject,\n body: bodyText,\n authorUserId: channelUserId,\n occurredAt,\n visibility,\n channelProviderKey: providerKey,\n },\n )\n}\n\n// \u2500\u2500 Threading-inheritance fallback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * When direct address matching finds 0 people AND crmPersonId is absent, look\n * up parent message references from channelMetadata (`inReplyTo` + `references`).\n *\n * For each reference (an RFC2822 Message-ID), find a prior MessageChannelLink\n * in this tenant whose `channelMetadata.messageId` matches. For each such\n * parent link, find any existing email CustomerInteraction rows where\n * `externalMessageId = parent.id` \u2014 and link THIS message to the same Persons.\n *\n * This enables TC-CRM-EMAIL-005: a reply from an unknown address is still\n * attached to alice's timeline because the original thread was.\n */\nasync function handleThreadingInheritance(\n em: EntityManager,\n _link: Record<string, unknown>,\n linkId: string,\n tenantId: string,\n organizationId: string | null,\n channelUserId: string | null,\n metaJson: Record<string, unknown> | null,\n payloadJson: Record<string, unknown> | null,\n occurredAt: Date,\n): Promise<void> {\n // \u2500\u2500 Primary: inherit Person(s) from the hub's authoritative thread \u2500\u2500\u2500\u2500\u2500\u2500\n //\n // The hub threads an inbound reply into the same `messages.message.thread_id`\n // as the outbound that started the conversation (via the thread token /\n // subject+participants matcher). That outbound is already linked to the CRM\n // Person (through the `crmPersonId` hint set by the compose route). So a reply\n // inherits the Person of any existing email interaction in the same thread.\n //\n // This is the dependable join where the alternatives are not:\n // - Address matching (`findPeopleByAddresses`) filters the *encrypted*\n // `primary_email` column by a plaintext value, which never matches when\n // tenant data encryption is on (ciphertext != plaintext).\n // - RFC Message-IDs are rewritten by some providers (e.g. Gmail) on send,\n // so the legacy In-Reply-To/References inheritance below also misses.\n // The hub thread id survives both, so we resolve by it first.\n const inboundMessageId = typeof _link.messageId === 'string' ? _link.messageId : null\n if (inboundMessageId && organizationId) {\n const threadPersonRows = (await em.getConnection().execute(\n `SELECT DISTINCT ci.entity_id AS entity_id\n FROM messages inbound_m\n JOIN messages thread_m ON thread_m.thread_id = inbound_m.thread_id\n JOIN message_channel_links mcl ON mcl.message_id = thread_m.id\n JOIN customer_interactions ci ON ci.external_message_id = mcl.id\n WHERE inbound_m.id = ?\n AND inbound_m.tenant_id = ?\n AND inbound_m.organization_id = ?\n AND inbound_m.deleted_at IS NULL\n AND inbound_m.thread_id IS NOT NULL\n AND thread_m.tenant_id = ?\n AND thread_m.organization_id = ?\n AND thread_m.deleted_at IS NULL\n AND mcl.tenant_id = ?\n AND mcl.organization_id = ?\n AND ci.tenant_id = ?\n AND ci.organization_id = ?\n AND ci.interaction_type = 'email'\n AND ci.deleted_at IS NULL\n AND ci.entity_id IS NOT NULL\n LIMIT 200`,\n [\n inboundMessageId,\n tenantId,\n organizationId,\n tenantId,\n organizationId,\n tenantId,\n organizationId,\n tenantId,\n organizationId,\n ],\n )) as Array<{ entity_id: string }>\n const threadPersonIds = new Set<string>(\n threadPersonRows.map((row) => row.entity_id).filter((id): id is string => !!id),\n )\n if (threadPersonIds.size > 0) {\n const subject =\n typeof metaJson?.subject === 'string'\n ? (metaJson.subject as string)\n : typeof payloadJson?.subject === 'string'\n ? (payloadJson.subject as string)\n : null\n const bodyText =\n typeof payloadJson?.text === 'string'\n ? (payloadJson.text as string)\n : typeof metaJson?.bodyText === 'string'\n ? (metaJson.bodyText as string)\n : null\n const providerKey = typeof _link.providerKey === 'string' ? (_link.providerKey as string) : null\n await persistInteractions(em, threadPersonIds, {\n linkId,\n tenantId,\n organizationId,\n interactionType: 'email',\n title: subject,\n body: bodyText,\n authorUserId: channelUserId,\n occurredAt,\n visibility: channelUserId ? 'private' : 'shared',\n channelProviderKey: providerKey,\n })\n return\n }\n }\n\n // \u2500\u2500 Fallback: legacy In-Reply-To / References Message-ID inheritance \u2500\u2500\u2500\u2500\u2500\n // Collect reference message-ids from inReplyTo + references\n const refIds: string[] = []\n const inReplyTo =\n typeof metaJson?.inReplyTo === 'string'\n ? stripBrackets(metaJson!.inReplyTo as string)\n : typeof payloadJson?.inReplyTo === 'string'\n ? stripBrackets(payloadJson!.inReplyTo as string)\n : null\n if (inReplyTo) refIds.push(inReplyTo)\n\n const refs =\n Array.isArray(metaJson?.references)\n ? (metaJson!.references as unknown[])\n : Array.isArray(payloadJson?.references)\n ? (payloadJson!.references as unknown[])\n : []\n for (const r of refs) {\n if (typeof r === 'string') {\n const stripped = stripBrackets(r)\n if (stripped && !refIds.includes(stripped)) refIds.push(stripped)\n }\n }\n\n if (refIds.length === 0) return\n\n // Find parent MessageChannelLinks whose channelMetadata.messageId is in refIds.\n // Bounded lookup: narrow to the candidate Message-IDs directly in SQL instead\n // of loading every link in the tenant and filtering in JS (which does not scale\n // on a busy mailbox \u2014 this path runs for every inbound reply that didn't match a\n // Person by address). channel_metadata is NOT an encrypted column, so a\n // parameterized raw query is safe; we match both bracketed (`<id>`) and\n // unbracketed (`id`) Message-ID storage forms, capped to a sane page.\n const dscope = { tenantId, organizationId }\n const messageIdCandidates = Array.from(\n new Set(refIds.flatMap((ref) => [ref, `<${ref}>`])),\n )\n const placeholders = messageIdCandidates.map(() => '?').join(', ')\n const parentLinkRows = (await em.getConnection().execute(\n `SELECT id FROM message_channel_links\n WHERE tenant_id = ?\n AND organization_id = ?\n AND channel_metadata->>'messageId' IN (${placeholders})\n LIMIT 200`,\n [tenantId, organizationId, ...messageIdCandidates],\n )) as Array<{ id: string }>\n\n const matchedParentIds = parentLinkRows.map((parentLink) => parentLink.id)\n\n if (matchedParentIds.length === 0) return\n\n // Find existing email CustomerInteraction rows for those parent links.\n const parentInteractions = (await findWithDecryption(\n em,\n CustomerInteraction,\n {\n externalMessageId: { $in: matchedParentIds },\n tenantId,\n organizationId,\n interactionType: 'email',\n deletedAt: null,\n } as any,\n undefined,\n dscope,\n )) as Array<{ entity: { id: string } }>\n\n const inheritedPersonIdSet = new Set<string>(\n parentInteractions.map((pi) => pi.entity?.id).filter(Boolean) as string[],\n )\n\n if (inheritedPersonIdSet.size === 0) return\n\n const visibility: 'private' | 'shared' = channelUserId ? 'private' : 'shared'\n const link = _link\n const inheritedMeta = (link.channelMetadata ?? null) as Record<string, unknown> | null\n const subject =\n typeof inheritedMeta?.subject === 'string' ? (inheritedMeta.subject as string) : null\n const providerKey =\n typeof link.providerKey === 'string' ? (link.providerKey as string) : null\n\n await persistInteractions(\n em,\n inheritedPersonIdSet,\n {\n linkId,\n tenantId,\n organizationId,\n interactionType: 'email',\n title: subject,\n body: null,\n authorUserId: channelUserId,\n occurredAt,\n visibility,\n channelProviderKey: providerKey,\n },\n )\n}\n\n// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * When the linked email happened, for `CustomerInteraction.occurredAt` (#6095).\n *\n * Preference order:\n * 1. `payload.providerTimestamp` \u2014 the provider's own receive/send time, put\n * on the hub event by ingest / delivery;\n * 2. `MessageChannelLink.createdAt` \u2014 the ingest time, the only date this\n * handler knew before #6095, and the compatibility path for events that\n * were enqueued before the field existed;\n * 3. now, for a link row with no usable `createdAt` (test stubs).\n */\nfunction resolveOccurredAt(\n payload: LinkChannelMessagePayload,\n link: Record<string, unknown>,\n): Date {\n const ingestedAt = link.createdAt instanceof Date ? link.createdAt : new Date()\n const carried = payload.providerTimestamp\n if (carried instanceof Date) {\n return Number.isNaN(carried.getTime()) ? ingestedAt : carried\n }\n if (typeof carried === 'string' && carried) {\n const parsed = new Date(carried)\n if (!Number.isNaN(parsed.getTime())) return parsed\n }\n return ingestedAt\n}\n\ninterface InteractionData {\n linkId: string\n tenantId: string\n organizationId: string | null\n interactionType: string\n title: string | null\n body: string | null\n authorUserId: string | null\n occurredAt: Date\n visibility: 'private' | 'shared'\n channelProviderKey: string | null\n}\n\nasync function persistInteractions(\n em: EntityManager,\n personIdSet: Set<string>,\n data: InteractionData,\n): Promise<void> {\n for (const personId of personIdSet) {\n // Fork per row so a unique-violation on one row doesn't poison the identity\n // map for subsequent rows. The parent em's connection pool is reused.\n const rowEm = em.fork()\n // Use em.getReference so we satisfy the ManyToOne relation without a\n // redundant SELECT \u2014 MikroORM will flush the FK column directly.\n const entityRef = rowEm.getReference(CustomerEntity, personId)\n const interaction = rowEm.create(CustomerInteraction, {\n tenantId: data.tenantId,\n organizationId: data.organizationId,\n entity: entityRef,\n interactionType: data.interactionType,\n title: data.title,\n body: data.body,\n authorUserId: data.authorUserId,\n occurredAt: data.occurredAt,\n // Emails are logged AFTER they're sent/received \u2014 they are not\n // scheduled work. Without an explicit status the entity default\n // ('planned') combined with a past `occurredAt` makes the activity\n // timeline render the email as \"overdue\", which is the wrong UX.\n // The canonical terminal-success value is `INTERACTION_STATUS_COMPLETED`\n // ('done'); see `lib/interactionStatus.ts` for the open/terminal semantics.\n status: INTERACTION_STATUS_COMPLETED,\n externalMessageId: data.linkId,\n visibility: data.visibility,\n channelProviderKey: data.channelProviderKey,\n } as any)\n try {\n await rowEm.flush()\n } catch (err) {\n // Idempotency: swallow unique-violation on (entity_id, external_message_id).\n // The partial unique index `customer_interactions_email_dedupe_uq` guarantees\n // at-most-once semantics across retries.\n const code = (err as { code?: string }).code\n if (code !== POSTGRES_UNIQUE_VIOLATION) throw err\n // Duplicate (retried delivery) \u2014 already linked, skip the refresh signal.\n continue\n }\n // Live-refresh signal for the CRM Person page (clientBroadcast \u2192 SSE). The\n // interaction is already persisted, so a failed emit must not abort linking\n // or fail this persistent (retried) subscriber.\n try {\n await emitCustomersEvent('customers.email.linked', {\n personId,\n interactionId: interaction.id,\n tenantId: data.tenantId,\n organizationId: data.organizationId,\n })\n } catch {\n /* swallow \u2014 UI refresh signal is non-critical */\n }\n }\n}\n\nfunction resolveVisibility(\n metaJson: Record<string, unknown> | null,\n channelUserId: string | null,\n direction: 'inbound' | 'outbound' | null,\n): 'private' | 'shared' {\n // The explicit `crmVisibility` override is written ONLY by the outbound compose\n // route. Inbound `channelMetadata` is provider-derived (and therefore attacker-\n // influenceable), so it MUST NOT be able to downgrade a user-owned channel's\n // inbound mail from private \u2192 shared. Honor the override on outbound only.\n if (direction === 'outbound') {\n if (metaJson?.crmVisibility === 'shared') return 'shared'\n if (metaJson?.crmVisibility === 'private') return 'private'\n }\n // Tenant-scoped channels (no userId) \u2192 shared; user-scoped \u2192 private.\n return channelUserId ? 'private' : 'shared'\n}\n\n/**\n * Collect email address strings from a field that may be:\n * - a plain string: 'alice@example.com'\n * - an array of strings: ['alice@example.com', 'bob@example.com']\n * - an object with an `address` property: { address: 'alice@example.com', name: 'Alice' }\n * - an array of such objects\n */\nfunction collectAddressField(out: unknown[], value: unknown): void {\n if (!value) return\n if (typeof value === 'string') {\n out.push(value)\n return\n }\n if (Array.isArray(value)) {\n for (const item of value) {\n if (typeof item === 'string') {\n out.push(item)\n } else if (item && typeof item === 'object' && typeof (item as Record<string, unknown>).address === 'string') {\n out.push((item as Record<string, unknown>).address as string)\n }\n }\n return\n }\n if (typeof value === 'object' && typeof (value as Record<string, unknown>).address === 'string') {\n out.push((value as Record<string, unknown>).address as string)\n }\n}\n\nfunction stripBrackets(value: string): string {\n const trimmed = value.trim()\n if (trimmed.startsWith('<') && trimmed.endsWith('>')) {\n return trimmed.slice(1, -1)\n }\n return trimmed\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["subject", "providerKey"]
7
7
  }
@@ -298,6 +298,7 @@ async function POST(req) {
298
298
  const {
299
299
  sourceChannelType: _clientSuppliedChannelType,
300
300
  inboundFromChannel: _clientSuppliedInboundFlag,
301
+ sentAt: _clientSuppliedSentAt,
301
302
  ...clientBody
302
303
  } = body ?? {};
303
304
  const input = composeMessageSchema.parse({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/messages/api/route.ts"],
4
- "sourcesContent": ["import { createHash } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n normalizeTagSegment,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { User } from '../../auth/data/entities'\nimport { Message, MessageObject } from '../data/entities'\nimport { composeMessageSchema, listMessagesSchema, type ListMessagesInput } from '../data/validators'\nimport {\n composeRequiresChannelTypeResolution,\n composeSourceHintSchema,\n resolveComposeSourceChannelType,\n} from '../lib/composeSourceChannelType'\nimport { resolveMessageActionData } from '../lib/actions'\nimport { MESSAGE_ATTACHMENT_ENTITY_ID } from '../lib/constants'\nimport { getMessageType } from '../lib/message-types-registry'\nimport { validateMessageObjectsForType } from '../lib/object-validation'\nimport { attachOperationMetadataHeader } from '../lib/operationMetadata'\nimport { canUseMessageEmailFeature, resolveMessageContext } from '../lib/routeHelpers'\nimport { applyMessageParticipantScope } from '../lib/participantScope'\nimport { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from './guards'\nimport { findMessageIdsBySearchTokens } from '../lib/searchLookup'\nimport { MessageCommandExecuteResult } from '../commands/shared'\nimport {\n composeMessageRequestSchema as composeSchema,\n composeResponseSchema,\n listMessagesSchema as listSchema,\n messageListItemSchema,\n} from './openapi'\n\ntype MessageCommandExecuteResultWithThreadId = MessageCommandExecuteResult & {\n threadId: string\n}\n\nconst NO_MATCH_ID = '00000000-0000-0000-0000-000000000000'\nconst MESSAGE_LIST_CACHE_TTL_MS = 30_000\nconst MESSAGE_LIST_RESOURCE = 'messages.message'\n\nfunction getDb(em: EntityManager): Kysely<any> {\n return em.getKysely<any>()\n}\n\ntype MessageListScopeRow = {\n id: string\n sender_user_id: string\n is_draft: boolean\n recipient_status: string | null\n read_at: string | null\n}\n\ntype AttachmentCountRow = {\n record_id: string\n count: string | number\n}\n\ntype RecipientCountRow = {\n message_id: string\n count: string | number\n}\n\ntype MessageListPayload = {\n items: Array<Record<string, unknown>>\n page: number\n pageSize: number\n total: number\n totalPages: number\n}\n\ntype MessageListScope = Awaited<ReturnType<typeof resolveMessageContext>>['scope']\n\nfunction normalizeCacheFilterValue(value: unknown): unknown {\n if (value === undefined) return null\n if (value instanceof Date) return value.toISOString()\n if (Array.isArray(value)) return value.map(normalizeCacheFilterValue)\n if (value && typeof value === 'object') {\n return Object.entries(value as Record<string, unknown>)\n .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))\n .map(([key, entryValue]) => [key, normalizeCacheFilterValue(entryValue)])\n }\n return value\n}\n\nfunction buildMessageListFilterSignature(input: ListMessagesInput): string {\n const canonicalInput = Object.entries(input)\n .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))\n .map(([key, value]) => [key, normalizeCacheFilterValue(value)])\n\n return createHash('sha256')\n .update(JSON.stringify(canonicalInput))\n .digest('hex')\n .slice(0, 32)\n}\n\nfunction buildMessageListCacheKey(scope: MessageListScope, input: ListMessagesInput): string {\n return [\n 'messages:list:v1',\n `tenant:${normalizeTagSegment(scope.tenantId)}`,\n `org:${normalizeTagSegment(scope.organizationId)}`,\n `user:${normalizeTagSegment(scope.userId)}`,\n `filters:${buildMessageListFilterSignature(input)}`,\n ].join('|')\n}\n\nfunction isMessageListPayload(value: unknown): value is MessageListPayload {\n if (!value || typeof value !== 'object') return false\n const payload = value as Partial<MessageListPayload>\n return Array.isArray(payload.items)\n && typeof payload.page === 'number'\n && typeof payload.pageSize === 'number'\n && typeof payload.total === 'number'\n && typeof payload.totalPages === 'number'\n}\n\nexport const metadata = {\n GET: { requireAuth: true },\n POST: { requireAuth: true, requireFeatures: ['messages.compose'] },\n}\n\nexport async function GET(req: Request) {\n const { ctx, scope } = await resolveMessageContext(req)\n const url = new URL(req.url)\n const params = Object.fromEntries(url.searchParams)\n const input = listMessagesSchema.parse(params)\n\n const cache = isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null\n const cacheKey = cache ? buildMessageListCacheKey(scope, input) : null\n if (cache && cacheKey) {\n try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (isMessageListPayload(cached)) {\n return Response.json(cached)\n }\n } catch (error) {\n debugCrudCache('messages-list-cache-read-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n const em = ctx.container.resolve('em') as EntityManager\n const db = getDb(em) as any\n\n const searchIds = input.search\n ? await findMessageIdsBySearchTokens({\n em,\n query: input.search,\n tenantId: scope.tenantId ?? null,\n organizationId: scope.organizationId,\n })\n : undefined\n\n const buildBaseQuery = () => {\n let q: any = db\n .selectFrom('messages as m')\n .where('m.tenant_id', '=', scope.tenantId)\n .where('m.deleted_at', 'is', null)\n\n if (scope.organizationId) {\n q = q.where('m.organization_id', '=', scope.organizationId)\n } else {\n q = q.where('m.organization_id', 'is', null)\n }\n\n const joinRecipient = () => {\n q = q.leftJoin('message_recipients as r', (jb: any) => jb\n .onRef('m.id', '=', 'r.message_id')\n .on('r.recipient_user_id', '=', scope.userId))\n }\n\n switch (input.folder) {\n case 'inbox':\n joinRecipient()\n q = q\n .where('r.message_id', 'is not', null)\n .where('r.deleted_at', 'is', null)\n .where('r.archived_at', 'is', null)\n .where('m.is_draft', '=', false)\n break\n case 'archived':\n joinRecipient()\n q = q\n .where('r.message_id', 'is not', null)\n .where('r.deleted_at', 'is', null)\n .where('r.archived_at', 'is not', null)\n break\n case 'sent':\n q = q\n .where('m.sender_user_id', '=', scope.userId)\n .where('m.is_draft', '=', false)\n joinRecipient()\n break\n case 'drafts':\n q = q\n .where('m.sender_user_id', '=', scope.userId)\n .where('m.is_draft', '=', true)\n joinRecipient()\n break\n case 'all':\n // Sender-OR-recipient participant scope shared with the\n // communication_channels message enricher \u2014 see participantScope.ts (#4133).\n q = applyMessageParticipantScope(q, scope.userId)\n break\n default: {\n const unsupportedFolder: never = input.folder\n throw new Error(`Unsupported folder: ${String(unsupportedFolder)}`)\n }\n }\n\n if (input.status) q = q.where('r.status', '=', input.status)\n if (input.type) q = q.where('m.type', '=', input.type)\n if (input.visibility) q = q.where('m.visibility', '=', input.visibility)\n if (input.sourceEntityType) q = q.where('m.source_entity_type', '=', input.sourceEntityType)\n if (input.sourceEntityId) q = q.where('m.source_entity_id', '=', input.sourceEntityId)\n if (input.externalEmail) q = q.where('m.external_email_hash', 'in', lookupHashCandidates(input.externalEmail))\n if (input.senderId) q = q.where('m.sender_user_id', '=', input.senderId)\n\n if (input.search) {\n if (!searchIds || searchIds.length === 0) {\n q = q.where('m.id', '=', NO_MATCH_ID)\n } else {\n q = q.where('m.id', 'in', searchIds)\n }\n }\n\n if (input.since) q = q.where('m.sent_at', '>', new Date(input.since))\n\n if (input.hasObjects !== undefined) {\n const existsFn = (eb: any) => eb.exists(\n eb.selectFrom('message_objects')\n .select(sql<number>`1`.as('one'))\n .whereRef('message_objects.message_id', '=', 'm.id')\n )\n const notExistsFn = (eb: any) => eb.not(eb.exists(\n eb.selectFrom('message_objects')\n .select(sql<number>`1`.as('one'))\n .whereRef('message_objects.message_id', '=', 'm.id')\n ))\n q = input.hasObjects ? q.where(existsFn) : q.where(notExistsFn)\n }\n\n if (input.hasAttachments !== undefined) {\n const existsFn = (eb: any) => eb.exists(\n eb.selectFrom('attachments')\n .select(sql<number>`1`.as('one'))\n .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where(sql<boolean>`attachments.record_id = m.id::text`)\n )\n const notExistsFn = (eb: any) => eb.not(eb.exists(\n eb.selectFrom('attachments')\n .select(sql<number>`1`.as('one'))\n .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where(sql<boolean>`attachments.record_id = m.id::text`)\n ))\n q = input.hasAttachments ? q.where(existsFn) : q.where(notExistsFn)\n }\n\n if (input.hasActions !== undefined) {\n q = input.hasActions\n ? q.where('m.action_data', 'is not', null)\n : q.where('m.action_data', 'is', null)\n }\n\n return q\n }\n\n // Audited for #3386 rollout (P3): sort is on m.sent_at (a plain timestamp \u2014\n // not in the messages:message encryption map whose encrypted fields are:\n // subject, body, external_email, external_name, action_data, action_result).\n // The handler already uses the correct two-phase shape: Kysely SQL\n // ORDER BY + LIMIT/OFFSET produces a bounded page of IDs, then\n // findWithDecryption is called only for those IDs \u2014 never for the full\n // result set. The #3278 unbounded-decrypt hazard does not apply here.\n // Covered by __tests__/list.test.ts.\n const countResult = await buildBaseQuery()\n .select(sql<number>`count(*)`.as('count'))\n .executeTakeFirst() as { count: string | number } | undefined\n const total = Number(countResult?.count ?? 0)\n\n const offset = (input.page - 1) * input.pageSize\n const scopeRows = await buildBaseQuery()\n .select([\n 'm.id',\n 'm.sender_user_id',\n 'm.is_draft',\n 'r.status as recipient_status',\n 'r.read_at',\n ])\n .orderBy('m.sent_at', 'desc')\n .offset(offset)\n .limit(input.pageSize)\n .execute()\n\n const typedRows = scopeRows as MessageListScopeRow[]\n const messageIds = typedRows.map((row) => row.id)\n\n const messageEntities = messageIds.length > 0\n ? await findWithDecryption(\n em,\n Message,\n { id: { $in: messageIds } },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId }\n )\n : []\n\n const messagesById = new Map<string, Message>()\n for (const message of messageEntities) {\n messagesById.set(message.id, message)\n }\n\n const objects = messageIds.length > 0\n ? await em.find(MessageObject, { messageId: { $in: messageIds } })\n : []\n\n const objectsByMessage = objects.reduce((acc, obj) => {\n if (!acc[obj.messageId]) acc[obj.messageId] = []\n acc[obj.messageId].push(obj)\n return acc\n }, {} as Record<string, MessageObject[]>)\n\n const attachmentCounts: AttachmentCountRow[] = messageIds.length > 0\n ? await (getDb(em) as any)\n .selectFrom('attachments')\n .select(['record_id', sql<string>`count(*)`.as('count')])\n .where('entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where('record_id', 'in', messageIds)\n .groupBy('record_id')\n .execute()\n : []\n\n const attachmentCountByMessage = attachmentCounts.reduce((acc: Record<string, number>, row) => {\n acc[row.record_id] = Number(row.count)\n return acc\n }, {})\n\n const recipientCounts: RecipientCountRow[] = messageIds.length > 0\n ? await (getDb(em) as any)\n .selectFrom('message_recipients')\n .select(['message_id', sql<string>`count(*)`.as('count')])\n .where('message_id', 'in', messageIds)\n .where('deleted_at', 'is', null)\n .groupBy('message_id')\n .execute()\n : []\n\n const recipientCountByMessage = recipientCounts.reduce((acc: Record<string, number>, row) => {\n acc[row.message_id] = Number(row.count)\n return acc\n }, {})\n\n const senderUserIds = Array.from(new Set(typedRows.map((row) => row.sender_user_id).filter(Boolean)))\n const senderUsers = senderUserIds.length > 0\n ? await findWithDecryption(\n em,\n User,\n { id: { $in: senderUserIds } },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId }\n )\n : []\n\n const senderMetaById = new Map<string, { name: string | null; email: string | null }>()\n senderUsers.forEach((user) => {\n const name = typeof user.name === 'string' && user.name.trim().length ? user.name.trim() : null\n senderMetaById.set(user.id, { name, email: user.email ?? null })\n })\n\n const payload: MessageListPayload = {\n items: typedRows\n .map((row) => {\n const message = messagesById.get(row.id)\n if (!message) return null\n const body = typeof message.body === 'string' ? message.body : ''\n const bodyPreview = body.substring(0, 150) + (body.length > 150 ? '...' : '')\n const actionData = resolveMessageActionData(message)\n return {\n ...(senderMetaById.get(row.sender_user_id)\n ? {\n senderName: senderMetaById.get(row.sender_user_id)?.name ?? null,\n senderEmail: senderMetaById.get(row.sender_user_id)?.email ?? null,\n }\n : { senderName: null, senderEmail: null }),\n id: message.id,\n type: message.type,\n visibility: message.visibility ?? null,\n sourceEntityType: message.sourceEntityType ?? null,\n sourceEntityId: message.sourceEntityId ?? null,\n externalEmail: message.externalEmail ?? null,\n externalName: message.externalName ?? null,\n subject: message.subject,\n bodyPreview,\n senderUserId: message.senderUserId,\n priority: message.priority,\n status: row.recipient_status ?? (row.is_draft ? 'draft' : 'sent'),\n hasObjects: (objectsByMessage[message.id] || []).length > 0,\n objectCount: (objectsByMessage[message.id] || []).length,\n hasAttachments: (attachmentCountByMessage[message.id] || 0) > 0,\n attachmentCount: attachmentCountByMessage[message.id] || 0,\n recipientCount: recipientCountByMessage[message.id] || 0,\n hasActions:\n Boolean(actionData?.actions?.length)\n || Boolean(getMessageType(message.type)?.defaultActions?.length)\n || (objectsByMessage[message.id] || []).some((item) => item.actionRequired && Boolean(item.actionType)),\n actionTaken: message.actionTaken ?? null,\n sentAt: message.sentAt ? message.sentAt.toISOString() : null,\n readAt: row.read_at,\n threadId: message.threadId ?? null,\n }\n })\n .filter((item): item is NonNullable<typeof item> => item !== null),\n page: input.page,\n pageSize: input.pageSize,\n total,\n totalPages: Math.ceil(total / input.pageSize),\n }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: MESSAGE_LIST_CACHE_TTL_MS,\n tags: buildCollectionTags(\n MESSAGE_LIST_RESOURCE,\n scope.tenantId,\n [scope.organizationId],\n ),\n }),\n )\n } catch (error) {\n debugCrudCache('messages-list-cache-write-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n return Response.json(payload)\n}\n\nexport async function POST(req: Request) {\n const { ctx, scope } = await resolveMessageContext(req)\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const body = await req.json().catch(() => ({}))\n\n // #4975: whether `externalEmail` is required depends on the channel this\n // message originates from, so the channel type has to be established BEFORE\n // validation. It is resolved server-side from the referenced conversation or\n // parent message and any client-sent `sourceChannelType` is dropped \u2014\n // otherwise a caller could waive the requirement by asserting its own type.\n // An unresolvable source stays `undefined`, which the validator fails closed on.\n // Only the \"public, non-draft, no address\" branch of the validator reads the\n // answer, so every other compose skips the lookup instead of discarding it.\n const sourceHint = composeRequiresChannelTypeResolution(body)\n ? composeSourceHintSchema.safeParse(body)\n : null\n const sourceChannelType = sourceHint?.success\n ? await resolveComposeSourceChannelType(\n ctx.container,\n { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null },\n sourceHint.data,\n )\n : undefined\n // `inboundFromChannel` is likewise server-only (#6093): channel ingest sets\n // it so an inbound message can be addressed to the conversation's assignee,\n // and a request body must not be able to waive the recipients rule with it.\n const {\n sourceChannelType: _clientSuppliedChannelType,\n inboundFromChannel: _clientSuppliedInboundFlag,\n ...clientBody\n } = (body ?? {}) as Record<string, unknown>\n const input = composeMessageSchema.parse({\n ...clientBody,\n ...(sourceChannelType ? { sourceChannelType } : {}),\n })\n\n const isPublicVisibility = input.visibility === 'public'\n const sendViaEmail = isPublicVisibility ? true : input.sendViaEmail\n if (sendViaEmail && !(await canUseMessageEmailFeature(ctx, scope))) {\n return Response.json({ error: 'Missing feature: messages.email' }, { status: 403 })\n }\n\n if (input.objects?.length) {\n const objectValidationError = validateMessageObjectsForType(input.type, input.objects)\n if (objectValidationError) {\n return Response.json({ error: objectValidationError }, { status: 400 })\n }\n }\n\n const guardResult = await runMessageMutationGuards(\n ctx.container,\n {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n resourceKind: 'messages.message',\n resourceId: null,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: input as Record<string, unknown>,\n },\n resolveUserFeatures(ctx.auth),\n )\n if (!guardResult.ok) {\n return Response.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n const { result, logEntry } = await commandBus.execute('messages.messages.compose', {\n input: {\n ...input,\n sendViaEmail,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n },\n ctx: {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n request: req,\n },\n })\n const { id: messageId, threadId: responseThreadId } = result as unknown as MessageCommandExecuteResultWithThreadId\n\n const response = Response.json({ id: messageId, threadId: responseThreadId }, { status: 201 })\n attachOperationMetadataHeader(response, logEntry, {\n resourceKind: 'messages.message',\n resourceId: messageId,\n })\n await runMessageMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n resourceKind: 'messages.message',\n resourceId: messageId,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n return response\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Messages',\n methods: {\n GET: {\n summary: 'List messages',\n query: listSchema,\n responses: [\n {\n status: 200,\n description: 'Message list',\n schema: z.object({\n items: z.array(messageListItemSchema),\n page: z.number(),\n pageSize: z.number(),\n total: z.number(),\n totalPages: z.number(),\n }),\n },\n ],\n },\n POST: {\n summary: 'Compose a message',\n requestBody: {\n schema: composeSchema,\n },\n responses: [\n {\n status: 201,\n description: 'Message created',\n schema: composeResponseSchema,\n },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createHash } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands/command-bus'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n normalizeTagSegment,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi/types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { lookupHashCandidates } from '@open-mercato/shared/lib/encryption/aes'\nimport { User } from '../../auth/data/entities'\nimport { Message, MessageObject } from '../data/entities'\nimport { composeMessageSchema, listMessagesSchema, type ListMessagesInput } from '../data/validators'\nimport {\n composeRequiresChannelTypeResolution,\n composeSourceHintSchema,\n resolveComposeSourceChannelType,\n} from '../lib/composeSourceChannelType'\nimport { resolveMessageActionData } from '../lib/actions'\nimport { MESSAGE_ATTACHMENT_ENTITY_ID } from '../lib/constants'\nimport { getMessageType } from '../lib/message-types-registry'\nimport { validateMessageObjectsForType } from '../lib/object-validation'\nimport { attachOperationMetadataHeader } from '../lib/operationMetadata'\nimport { canUseMessageEmailFeature, resolveMessageContext } from '../lib/routeHelpers'\nimport { applyMessageParticipantScope } from '../lib/participantScope'\nimport { resolveUserFeatures, runMessageMutationGuardAfterSuccess, runMessageMutationGuards } from './guards'\nimport { findMessageIdsBySearchTokens } from '../lib/searchLookup'\nimport { MessageCommandExecuteResult } from '../commands/shared'\nimport {\n composeMessageRequestSchema as composeSchema,\n composeResponseSchema,\n listMessagesSchema as listSchema,\n messageListItemSchema,\n} from './openapi'\n\ntype MessageCommandExecuteResultWithThreadId = MessageCommandExecuteResult & {\n threadId: string\n}\n\nconst NO_MATCH_ID = '00000000-0000-0000-0000-000000000000'\nconst MESSAGE_LIST_CACHE_TTL_MS = 30_000\nconst MESSAGE_LIST_RESOURCE = 'messages.message'\n\nfunction getDb(em: EntityManager): Kysely<any> {\n return em.getKysely<any>()\n}\n\ntype MessageListScopeRow = {\n id: string\n sender_user_id: string\n is_draft: boolean\n recipient_status: string | null\n read_at: string | null\n}\n\ntype AttachmentCountRow = {\n record_id: string\n count: string | number\n}\n\ntype RecipientCountRow = {\n message_id: string\n count: string | number\n}\n\ntype MessageListPayload = {\n items: Array<Record<string, unknown>>\n page: number\n pageSize: number\n total: number\n totalPages: number\n}\n\ntype MessageListScope = Awaited<ReturnType<typeof resolveMessageContext>>['scope']\n\nfunction normalizeCacheFilterValue(value: unknown): unknown {\n if (value === undefined) return null\n if (value instanceof Date) return value.toISOString()\n if (Array.isArray(value)) return value.map(normalizeCacheFilterValue)\n if (value && typeof value === 'object') {\n return Object.entries(value as Record<string, unknown>)\n .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))\n .map(([key, entryValue]) => [key, normalizeCacheFilterValue(entryValue)])\n }\n return value\n}\n\nfunction buildMessageListFilterSignature(input: ListMessagesInput): string {\n const canonicalInput = Object.entries(input)\n .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))\n .map(([key, value]) => [key, normalizeCacheFilterValue(value)])\n\n return createHash('sha256')\n .update(JSON.stringify(canonicalInput))\n .digest('hex')\n .slice(0, 32)\n}\n\nfunction buildMessageListCacheKey(scope: MessageListScope, input: ListMessagesInput): string {\n return [\n 'messages:list:v1',\n `tenant:${normalizeTagSegment(scope.tenantId)}`,\n `org:${normalizeTagSegment(scope.organizationId)}`,\n `user:${normalizeTagSegment(scope.userId)}`,\n `filters:${buildMessageListFilterSignature(input)}`,\n ].join('|')\n}\n\nfunction isMessageListPayload(value: unknown): value is MessageListPayload {\n if (!value || typeof value !== 'object') return false\n const payload = value as Partial<MessageListPayload>\n return Array.isArray(payload.items)\n && typeof payload.page === 'number'\n && typeof payload.pageSize === 'number'\n && typeof payload.total === 'number'\n && typeof payload.totalPages === 'number'\n}\n\nexport const metadata = {\n GET: { requireAuth: true },\n POST: { requireAuth: true, requireFeatures: ['messages.compose'] },\n}\n\nexport async function GET(req: Request) {\n const { ctx, scope } = await resolveMessageContext(req)\n const url = new URL(req.url)\n const params = Object.fromEntries(url.searchParams)\n const input = listMessagesSchema.parse(params)\n\n const cache = isCrudCacheEnabled() ? resolveCrudCache(ctx.container) : null\n const cacheKey = cache ? buildMessageListCacheKey(scope, input) : null\n if (cache && cacheKey) {\n try {\n const cached = await runWithCacheTenant(scope.tenantId, () => cache.get(cacheKey))\n if (isMessageListPayload(cached)) {\n return Response.json(cached)\n }\n } catch (error) {\n debugCrudCache('messages-list-cache-read-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n const em = ctx.container.resolve('em') as EntityManager\n const db = getDb(em) as any\n\n const searchIds = input.search\n ? await findMessageIdsBySearchTokens({\n em,\n query: input.search,\n tenantId: scope.tenantId ?? null,\n organizationId: scope.organizationId,\n })\n : undefined\n\n const buildBaseQuery = () => {\n let q: any = db\n .selectFrom('messages as m')\n .where('m.tenant_id', '=', scope.tenantId)\n .where('m.deleted_at', 'is', null)\n\n if (scope.organizationId) {\n q = q.where('m.organization_id', '=', scope.organizationId)\n } else {\n q = q.where('m.organization_id', 'is', null)\n }\n\n const joinRecipient = () => {\n q = q.leftJoin('message_recipients as r', (jb: any) => jb\n .onRef('m.id', '=', 'r.message_id')\n .on('r.recipient_user_id', '=', scope.userId))\n }\n\n switch (input.folder) {\n case 'inbox':\n joinRecipient()\n q = q\n .where('r.message_id', 'is not', null)\n .where('r.deleted_at', 'is', null)\n .where('r.archived_at', 'is', null)\n .where('m.is_draft', '=', false)\n break\n case 'archived':\n joinRecipient()\n q = q\n .where('r.message_id', 'is not', null)\n .where('r.deleted_at', 'is', null)\n .where('r.archived_at', 'is not', null)\n break\n case 'sent':\n q = q\n .where('m.sender_user_id', '=', scope.userId)\n .where('m.is_draft', '=', false)\n joinRecipient()\n break\n case 'drafts':\n q = q\n .where('m.sender_user_id', '=', scope.userId)\n .where('m.is_draft', '=', true)\n joinRecipient()\n break\n case 'all':\n // Sender-OR-recipient participant scope shared with the\n // communication_channels message enricher \u2014 see participantScope.ts (#4133).\n q = applyMessageParticipantScope(q, scope.userId)\n break\n default: {\n const unsupportedFolder: never = input.folder\n throw new Error(`Unsupported folder: ${String(unsupportedFolder)}`)\n }\n }\n\n if (input.status) q = q.where('r.status', '=', input.status)\n if (input.type) q = q.where('m.type', '=', input.type)\n if (input.visibility) q = q.where('m.visibility', '=', input.visibility)\n if (input.sourceEntityType) q = q.where('m.source_entity_type', '=', input.sourceEntityType)\n if (input.sourceEntityId) q = q.where('m.source_entity_id', '=', input.sourceEntityId)\n if (input.externalEmail) q = q.where('m.external_email_hash', 'in', lookupHashCandidates(input.externalEmail))\n if (input.senderId) q = q.where('m.sender_user_id', '=', input.senderId)\n\n if (input.search) {\n if (!searchIds || searchIds.length === 0) {\n q = q.where('m.id', '=', NO_MATCH_ID)\n } else {\n q = q.where('m.id', 'in', searchIds)\n }\n }\n\n if (input.since) q = q.where('m.sent_at', '>', new Date(input.since))\n\n if (input.hasObjects !== undefined) {\n const existsFn = (eb: any) => eb.exists(\n eb.selectFrom('message_objects')\n .select(sql<number>`1`.as('one'))\n .whereRef('message_objects.message_id', '=', 'm.id')\n )\n const notExistsFn = (eb: any) => eb.not(eb.exists(\n eb.selectFrom('message_objects')\n .select(sql<number>`1`.as('one'))\n .whereRef('message_objects.message_id', '=', 'm.id')\n ))\n q = input.hasObjects ? q.where(existsFn) : q.where(notExistsFn)\n }\n\n if (input.hasAttachments !== undefined) {\n const existsFn = (eb: any) => eb.exists(\n eb.selectFrom('attachments')\n .select(sql<number>`1`.as('one'))\n .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where(sql<boolean>`attachments.record_id = m.id::text`)\n )\n const notExistsFn = (eb: any) => eb.not(eb.exists(\n eb.selectFrom('attachments')\n .select(sql<number>`1`.as('one'))\n .where('attachments.entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where(sql<boolean>`attachments.record_id = m.id::text`)\n ))\n q = input.hasAttachments ? q.where(existsFn) : q.where(notExistsFn)\n }\n\n if (input.hasActions !== undefined) {\n q = input.hasActions\n ? q.where('m.action_data', 'is not', null)\n : q.where('m.action_data', 'is', null)\n }\n\n return q\n }\n\n // Audited for #3386 rollout (P3): sort is on m.sent_at (a plain timestamp \u2014\n // not in the messages:message encryption map whose encrypted fields are:\n // subject, body, external_email, external_name, action_data, action_result).\n // The handler already uses the correct two-phase shape: Kysely SQL\n // ORDER BY + LIMIT/OFFSET produces a bounded page of IDs, then\n // findWithDecryption is called only for those IDs \u2014 never for the full\n // result set. The #3278 unbounded-decrypt hazard does not apply here.\n // Covered by __tests__/list.test.ts.\n const countResult = await buildBaseQuery()\n .select(sql<number>`count(*)`.as('count'))\n .executeTakeFirst() as { count: string | number } | undefined\n const total = Number(countResult?.count ?? 0)\n\n const offset = (input.page - 1) * input.pageSize\n const scopeRows = await buildBaseQuery()\n .select([\n 'm.id',\n 'm.sender_user_id',\n 'm.is_draft',\n 'r.status as recipient_status',\n 'r.read_at',\n ])\n .orderBy('m.sent_at', 'desc')\n .offset(offset)\n .limit(input.pageSize)\n .execute()\n\n const typedRows = scopeRows as MessageListScopeRow[]\n const messageIds = typedRows.map((row) => row.id)\n\n const messageEntities = messageIds.length > 0\n ? await findWithDecryption(\n em,\n Message,\n { id: { $in: messageIds } },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId }\n )\n : []\n\n const messagesById = new Map<string, Message>()\n for (const message of messageEntities) {\n messagesById.set(message.id, message)\n }\n\n const objects = messageIds.length > 0\n ? await em.find(MessageObject, { messageId: { $in: messageIds } })\n : []\n\n const objectsByMessage = objects.reduce((acc, obj) => {\n if (!acc[obj.messageId]) acc[obj.messageId] = []\n acc[obj.messageId].push(obj)\n return acc\n }, {} as Record<string, MessageObject[]>)\n\n const attachmentCounts: AttachmentCountRow[] = messageIds.length > 0\n ? await (getDb(em) as any)\n .selectFrom('attachments')\n .select(['record_id', sql<string>`count(*)`.as('count')])\n .where('entity_id', '=', MESSAGE_ATTACHMENT_ENTITY_ID)\n .where('record_id', 'in', messageIds)\n .groupBy('record_id')\n .execute()\n : []\n\n const attachmentCountByMessage = attachmentCounts.reduce((acc: Record<string, number>, row) => {\n acc[row.record_id] = Number(row.count)\n return acc\n }, {})\n\n const recipientCounts: RecipientCountRow[] = messageIds.length > 0\n ? await (getDb(em) as any)\n .selectFrom('message_recipients')\n .select(['message_id', sql<string>`count(*)`.as('count')])\n .where('message_id', 'in', messageIds)\n .where('deleted_at', 'is', null)\n .groupBy('message_id')\n .execute()\n : []\n\n const recipientCountByMessage = recipientCounts.reduce((acc: Record<string, number>, row) => {\n acc[row.message_id] = Number(row.count)\n return acc\n }, {})\n\n const senderUserIds = Array.from(new Set(typedRows.map((row) => row.sender_user_id).filter(Boolean)))\n const senderUsers = senderUserIds.length > 0\n ? await findWithDecryption(\n em,\n User,\n { id: { $in: senderUserIds } },\n undefined,\n { tenantId: scope.tenantId, organizationId: scope.organizationId }\n )\n : []\n\n const senderMetaById = new Map<string, { name: string | null; email: string | null }>()\n senderUsers.forEach((user) => {\n const name = typeof user.name === 'string' && user.name.trim().length ? user.name.trim() : null\n senderMetaById.set(user.id, { name, email: user.email ?? null })\n })\n\n const payload: MessageListPayload = {\n items: typedRows\n .map((row) => {\n const message = messagesById.get(row.id)\n if (!message) return null\n const body = typeof message.body === 'string' ? message.body : ''\n const bodyPreview = body.substring(0, 150) + (body.length > 150 ? '...' : '')\n const actionData = resolveMessageActionData(message)\n return {\n ...(senderMetaById.get(row.sender_user_id)\n ? {\n senderName: senderMetaById.get(row.sender_user_id)?.name ?? null,\n senderEmail: senderMetaById.get(row.sender_user_id)?.email ?? null,\n }\n : { senderName: null, senderEmail: null }),\n id: message.id,\n type: message.type,\n visibility: message.visibility ?? null,\n sourceEntityType: message.sourceEntityType ?? null,\n sourceEntityId: message.sourceEntityId ?? null,\n externalEmail: message.externalEmail ?? null,\n externalName: message.externalName ?? null,\n subject: message.subject,\n bodyPreview,\n senderUserId: message.senderUserId,\n priority: message.priority,\n status: row.recipient_status ?? (row.is_draft ? 'draft' : 'sent'),\n hasObjects: (objectsByMessage[message.id] || []).length > 0,\n objectCount: (objectsByMessage[message.id] || []).length,\n hasAttachments: (attachmentCountByMessage[message.id] || 0) > 0,\n attachmentCount: attachmentCountByMessage[message.id] || 0,\n recipientCount: recipientCountByMessage[message.id] || 0,\n hasActions:\n Boolean(actionData?.actions?.length)\n || Boolean(getMessageType(message.type)?.defaultActions?.length)\n || (objectsByMessage[message.id] || []).some((item) => item.actionRequired && Boolean(item.actionType)),\n actionTaken: message.actionTaken ?? null,\n sentAt: message.sentAt ? message.sentAt.toISOString() : null,\n readAt: row.read_at,\n threadId: message.threadId ?? null,\n }\n })\n .filter((item): item is NonNullable<typeof item> => item !== null),\n page: input.page,\n pageSize: input.pageSize,\n total,\n totalPages: Math.ceil(total / input.pageSize),\n }\n\n if (cache && cacheKey) {\n try {\n await runWithCacheTenant(scope.tenantId, () =>\n cache.set(cacheKey, payload, {\n ttl: MESSAGE_LIST_CACHE_TTL_MS,\n tags: buildCollectionTags(\n MESSAGE_LIST_RESOURCE,\n scope.tenantId,\n [scope.organizationId],\n ),\n }),\n )\n } catch (error) {\n debugCrudCache('messages-list-cache-write-failed', {\n error: error instanceof Error ? error.message : String(error),\n })\n }\n }\n\n return Response.json(payload)\n}\n\nexport async function POST(req: Request) {\n const { ctx, scope } = await resolveMessageContext(req)\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const body = await req.json().catch(() => ({}))\n\n // #4975: whether `externalEmail` is required depends on the channel this\n // message originates from, so the channel type has to be established BEFORE\n // validation. It is resolved server-side from the referenced conversation or\n // parent message and any client-sent `sourceChannelType` is dropped \u2014\n // otherwise a caller could waive the requirement by asserting its own type.\n // An unresolvable source stays `undefined`, which the validator fails closed on.\n // Only the \"public, non-draft, no address\" branch of the validator reads the\n // answer, so every other compose skips the lookup instead of discarding it.\n const sourceHint = composeRequiresChannelTypeResolution(body)\n ? composeSourceHintSchema.safeParse(body)\n : null\n const sourceChannelType = sourceHint?.success\n ? await resolveComposeSourceChannelType(\n ctx.container,\n { tenantId: scope.tenantId, organizationId: scope.organizationId ?? null },\n sourceHint.data,\n )\n : undefined\n // `inboundFromChannel` and `sentAt` are likewise server-only (#6093, #6095):\n // channel ingest sets them so an inbound message can be addressed to the\n // conversation's assignee and stamped with the provider's receive time, but\n // a request body must not be able to waive the recipients rule or backdate\n // a message with them.\n const {\n sourceChannelType: _clientSuppliedChannelType,\n inboundFromChannel: _clientSuppliedInboundFlag,\n sentAt: _clientSuppliedSentAt,\n ...clientBody\n } = (body ?? {}) as Record<string, unknown>\n const input = composeMessageSchema.parse({\n ...clientBody,\n ...(sourceChannelType ? { sourceChannelType } : {}),\n })\n\n const isPublicVisibility = input.visibility === 'public'\n const sendViaEmail = isPublicVisibility ? true : input.sendViaEmail\n if (sendViaEmail && !(await canUseMessageEmailFeature(ctx, scope))) {\n return Response.json({ error: 'Missing feature: messages.email' }, { status: 403 })\n }\n\n if (input.objects?.length) {\n const objectValidationError = validateMessageObjectsForType(input.type, input.objects)\n if (objectValidationError) {\n return Response.json({ error: objectValidationError }, { status: 400 })\n }\n }\n\n const guardResult = await runMessageMutationGuards(\n ctx.container,\n {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n resourceKind: 'messages.message',\n resourceId: null,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: input as Record<string, unknown>,\n },\n resolveUserFeatures(ctx.auth),\n )\n if (!guardResult.ok) {\n return Response.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n const { result, logEntry } = await commandBus.execute('messages.messages.compose', {\n input: {\n ...input,\n sendViaEmail,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n },\n ctx: {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n request: req,\n },\n })\n const { id: messageId, threadId: responseThreadId } = result as unknown as MessageCommandExecuteResultWithThreadId\n\n const response = Response.json({ id: messageId, threadId: responseThreadId }, { status: 201 })\n attachOperationMetadataHeader(response, logEntry, {\n resourceKind: 'messages.message',\n resourceId: messageId,\n })\n await runMessageMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n userId: scope.userId,\n resourceKind: 'messages.message',\n resourceId: messageId,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n return response\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Messages',\n methods: {\n GET: {\n summary: 'List messages',\n query: listSchema,\n responses: [\n {\n status: 200,\n description: 'Message list',\n schema: z.object({\n items: z.array(messageListItemSchema),\n page: z.number(),\n pageSize: z.number(),\n total: z.number(),\n totalPages: z.number(),\n }),\n },\n ],\n },\n POST: {\n summary: 'Compose a message',\n requestBody: {\n schema: composeSchema,\n },\n responses: [\n {\n status: 201,\n description: 'Message created',\n schema: composeResponseSchema,\n },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }