@open-mercato/core 0.10.1-develop.7771.1.615a6c9ba9 → 0.10.1-develop.7773.1.a41b0f6c03
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +6 -7
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/data/validators.js +2 -1
- package/dist/modules/communication_channels/data/validators.js.map +2 -2
- package/dist/modules/communication_channels/lib/email-mime.js +13 -3
- package/dist/modules/communication_channels/lib/email-mime.js.map +2 -2
- package/dist/modules/communication_channels/lib/thread-matcher.js.map +2 -2
- package/dist/modules/customers/api/activities/route.js +16 -6
- package/dist/modules/customers/api/activities/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +1 -1
- package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +1 -1
- package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js +3 -2
- package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/DealDetailTabs.js +3 -2
- package/dist/modules/customers/components/detail/DealDetailTabs.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js +3 -2
- package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
- package/dist/modules/customers/lib/interactionOrderBy.js +12 -0
- package/dist/modules/customers/lib/interactionOrderBy.js.map +7 -0
- package/dist/modules/customers/lib/link-channel-message-handler.js +12 -11
- package/dist/modules/customers/lib/link-channel-message-handler.js.map +2 -2
- package/dist/modules/customers/lib/personEmailThreads.js +43 -12
- package/dist/modules/customers/lib/personEmailThreads.js.map +3 -3
- package/dist/modules/messages/api/route.js +2 -1
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +2 -1
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/components/MessagesInboxPageClient.js +1 -0
- package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
- package/dist/modules/messages/components/defaults/DefaultMessageListItem.js +10 -8
- package/dist/modules/messages/components/defaults/DefaultMessageListItem.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/MessageHeader.js +1 -1
- package/dist/modules/messages/components/message-detail/panels/MessageHeader.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/MessageListComponent.js +3 -3
- package/dist/modules/messages/components/message-detail/panels/MessageListComponent.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/thread-panel.js +1 -1
- package/dist/modules/messages/components/message-detail/panels/thread-panel.js.map +2 -2
- package/dist/modules/messages/components/message-detail/utils.js +3 -3
- package/dist/modules/messages/components/message-detail/utils.js.map +2 -2
- package/dist/modules/messages/data/validators.js +3 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/messages/frontend/messages/view/[token]/page.js +6 -5
- package/dist/modules/messages/frontend/messages/view/[token]/page.js.map +2 -2
- package/dist/modules/messages/lib/forwarding.js +3 -1
- package/dist/modules/messages/lib/forwarding.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +5 -7
- package/src/modules/communication_channels/data/validators.ts +2 -1
- package/src/modules/communication_channels/lib/adapter.ts +4 -1
- package/src/modules/communication_channels/lib/email-mime.ts +24 -7
- package/src/modules/communication_channels/lib/thread-matcher.ts +2 -1
- package/src/modules/customers/api/activities/route.ts +17 -7
- package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +1 -1
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -1
- package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +1 -1
- package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +5 -1
- package/src/modules/customers/components/detail/DealDetailTabs.tsx +6 -2
- package/src/modules/customers/components/detail/PersonDetailTabs.tsx +6 -2
- package/src/modules/customers/lib/interactionOrderBy.ts +9 -0
- package/src/modules/customers/lib/link-channel-message-handler.ts +27 -38
- package/src/modules/customers/lib/personEmailThreads.ts +63 -21
- package/src/modules/messages/api/route.ts +3 -1
- package/src/modules/messages/commands/messages.ts +2 -1
- package/src/modules/messages/components/MessagesInboxPageClient.tsx +2 -0
- package/src/modules/messages/components/defaults/DefaultMessageListItem.tsx +10 -8
- package/src/modules/messages/components/message-detail/panels/MessageHeader.tsx +3 -1
- package/src/modules/messages/components/message-detail/panels/MessageListComponent.tsx +5 -3
- package/src/modules/messages/components/message-detail/panels/thread-panel.tsx +1 -1
- package/src/modules/messages/components/message-detail/utils.ts +3 -3
- package/src/modules/messages/data/validators.ts +4 -3
- package/src/modules/messages/frontend/messages/view/[token]/page.tsx +5 -4
- package/src/modules/messages/i18n/de.json +1 -0
- package/src/modules/messages/i18n/en.json +1 -0
- package/src/modules/messages/i18n/es.json +1 -0
- package/src/modules/messages/i18n/ko.json +1 -0
- package/src/modules/messages/i18n/pl.json +2 -1
- package/src/modules/messages/lib/forwarding.ts +3 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -147,7 +147,7 @@ const ingestInboundMessageCommand = {
|
|
|
147
147
|
subject: m.subject ?? null,
|
|
148
148
|
tenantId: input.scope.tenantId,
|
|
149
149
|
organizationId: input.scope.organizationId ?? null,
|
|
150
|
-
lastMessageAt: m.timestamp
|
|
150
|
+
lastMessageAt: m.timestamp,
|
|
151
151
|
assignedUserId: channel.userId ?? null
|
|
152
152
|
});
|
|
153
153
|
em.persist(conversation);
|
|
@@ -184,8 +184,7 @@ const ingestInboundMessageCommand = {
|
|
|
184
184
|
toAddresses: extractStringArrayFromMeta(metaForMatcher, "to"),
|
|
185
185
|
ccAddresses: extractStringArrayFromMeta(metaForMatcher, "cc"),
|
|
186
186
|
bodyPlain: m.bodyFormat === "html" ? null : m.body ?? null,
|
|
187
|
-
bodyHtml: m.bodyFormat === "html" ? m.body ?? null : null
|
|
188
|
-
receivedAt: m.timestamp ?? /* @__PURE__ */ new Date()
|
|
187
|
+
bodyHtml: m.bodyFormat === "html" ? m.body ?? null : null
|
|
189
188
|
},
|
|
190
189
|
{ em }
|
|
191
190
|
);
|
|
@@ -264,8 +263,8 @@ const ingestInboundMessageCommand = {
|
|
|
264
263
|
// mail, not when this worker ran. Otherwise a history import lands every
|
|
265
264
|
// message on the import minute and the inbox (sorted on `sent_at`) shows
|
|
266
265
|
// a 90-day mailbox as one block in import order. Adapters without a
|
|
267
|
-
// timestamp
|
|
268
|
-
sentAt: m.timestamp
|
|
266
|
+
// timestamp retain an explicitly unknown occurrence time.
|
|
267
|
+
sentAt: m.timestamp,
|
|
269
268
|
// Stable dedup key so a retried ingest (after a transient failure between
|
|
270
269
|
// compose and the ExternalMessage anchor insert) reuses the message
|
|
271
270
|
// composed by the first attempt instead of duplicating it. Mirrors the
|
|
@@ -314,7 +313,7 @@ const ingestInboundMessageCommand = {
|
|
|
314
313
|
direction: "inbound",
|
|
315
314
|
senderIdentifier: m.senderIdentifier,
|
|
316
315
|
senderDisplayName: m.senderDisplayName ?? null,
|
|
317
|
-
providerTimestamp: m.
|
|
316
|
+
providerTimestamp: m.providerTimestamp ?? null,
|
|
318
317
|
tenantId: input.scope.tenantId,
|
|
319
318
|
organizationId: input.scope.organizationId ?? null
|
|
320
319
|
});
|
|
@@ -396,7 +395,7 @@ const ingestInboundMessageCommand = {
|
|
|
396
395
|
// this worker ran. Carried here so no consumer has to read the
|
|
397
396
|
// ExternalMessage row across the module boundary. ISO string because a
|
|
398
397
|
// persistent event is serialized onto the queue; null when the adapter
|
|
399
|
-
// supplied no timestamp
|
|
398
|
+
// supplied no provider-native timestamp.
|
|
400
399
|
providerTimestamp: externalMessage.providerTimestamp instanceof Date ? externalMessage.providerTimestamp.toISOString() : null,
|
|
401
400
|
tenantId: input.scope.tenantId,
|
|
402
401
|
organizationId: input.scope.organizationId ?? null
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/commands/ingest-inbound-message.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandBus, CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { resolveContact } from '../lib/contact-resolver'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport type { NormalizedInboundMessage } from '../lib/adapter'\nimport { matchThread, type ThreadMatch } from '../lib/thread-matcher'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { normalizedInboundMessageSchema } from '../data/validators'\nimport { buildInboundIdempotencyKey } from '../lib/inbound-message-origin'\nimport { resolveCommunicationChannelsSystemUserId } from '../lib/system-user'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'ingest-inbound-message' })\n\nconst ingestInputSchema = z.object({\n channelId: z.string().uuid(),\n providerKey: z.string().min(1),\n channelType: z.string().min(1),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n message: normalizedInboundMessageSchema,\n})\n\nexport type IngestInboundMessageInput = z.infer<typeof ingestInputSchema>\n\nexport type IngestInboundMessageResult = {\n status: 'created' | 'duplicate'\n messageId?: string\n externalConversationId?: string\n externalMessageId?: string\n channelLinkId?: string\n threadMappingId?: string\n contactPersonId?: string | null\n}\n\nexport const COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID = 'communication_channels.message.ingest_inbound'\n\n/**\n * Idempotently ingest a normalized inbound channel message.\n *\n * Steps (per SPEC-045d \u00A76):\n * 1. Dedup by `(channel_id, external_message_id)` \u2014 if a MessageChannelLink already\n * exists for that pair, return `{ status: 'duplicate' }` without side effects.\n * 2. Create or load `ExternalConversation` by `(channel_id, external_conversation_id)`.\n * 3. Create or load `ChannelThreadMapping` (1:1 with ExternalConversation).\n * 4. Resolve CRM contact via adapter + QueryEngine (best-effort).\n * 5. Compose the platform `Message` via `messages.messages.compose` (separate transaction).\n * 6. Create `ExternalMessage` + `MessageChannelLink`.\n * 7. Emit `communication_channels.message.received` (and `.conversation.created` / `.contact.resolved` when applicable).\n *\n * The two-transaction model (compose-message-then-record-link) is acceptable for v1;\n * the link's unique-on-message-id constraint is the safety net against orphans. See\n * the pre-implementation analysis for a discussion of single-transaction alternatives.\n */\nconst ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, IngestInboundMessageResult> = {\n id: COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = ingestInputSchema.parse(rawInput) as IngestInboundMessageInput\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Dedup: short-circuit if we've already processed this provider message.\n // The unique constraint is on `messageId`, not (channel, externalMessageId).\n // We must dedup by joining against ExternalMessage which IS uniquely indexed by\n // (channel_id, external_message_id). Hub-side dedup is the authoritative gate.\n const existingExternal = await findOneWithDecryption(\n em,\n ExternalMessage,\n {\n channelId: input.channelId,\n externalMessageId: input.message.externalMessageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (existingExternal) {\n return {\n status: 'duplicate',\n externalConversationId: existingExternal.conversationId,\n externalMessageId: existingExternal.id,\n }\n }\n\n // (1b) Spec B \u00A7 Sent-folder dedup.\n //\n // When an outbound message lands in the user's IMAP Sent folder (or\n // when Gmail's \"send and archive\" deposits it in All Mail), the next\n // poll will re-fetch it from INBOX as if it were inbound. Skip it\n // here using the RFC 5322 `Message-ID` header \u2014 we recorded it on the\n // outbound `MessageChannelLink.channelMetadata.messageId` at send time.\n //\n // We dedup ONLY on outbound links (direction='outbound') for the same\n // channel \u2014 that way an inbound copy of someone ELSE's email that\n // happens to share a Message-ID is still ingested normally.\n const incomingMessageId = (() => {\n const fromMeta = (input.message.channelMetadata as Record<string, unknown> | undefined)?.messageId\n if (typeof fromMeta === 'string' && fromMeta.length > 0) return fromMeta\n return null\n })()\n if (incomingMessageId) {\n // MikroORM v7 dropped the Knex builder in favour of Kysely/raw SQL.\n // We use a positional-placeholder raw query for the JSONB\n // `channel_metadata->>messageId` comparison.\n try {\n const sentFolderHit = await em.getConnection().execute<Array<{ id: string }>>(\n `SELECT link.id FROM message_channel_links AS link\n INNER JOIN external_conversations AS conv\n ON conv.id = link.external_conversation_id\n WHERE link.tenant_id = ?\n AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)\n AND conv.tenant_id = ?\n AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)\n AND conv.channel_id = ?\n AND link.direction = 'outbound'\n AND link.channel_metadata->>'messageId' = ?\n LIMIT 1`,\n [\n input.scope.tenantId,\n input.scope.organizationId ?? null,\n input.scope.organizationId ?? null,\n input.scope.tenantId,\n input.scope.organizationId ?? null,\n input.scope.organizationId ?? null,\n input.channelId,\n incomingMessageId,\n ],\n )\n if (Array.isArray(sentFolderHit) && sentFolderHit.length > 0) {\n return {\n status: 'duplicate',\n externalConversationId: input.message.externalConversationId,\n externalMessageId: input.message.externalMessageId,\n }\n }\n } catch (dedupErr) {\n // Sent-folder dedup is best-effort \u2014 a failure here must not abort\n // ingest (better a possible duplicate than a lost inbound message).\n logger.warn('sent-folder dedup query failed, continuing', { err: dedupErr })\n }\n }\n\n // Channel + adapter lookup (the channel must exist + be active).\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${input.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${input.channelId} is inactive; refusing to ingest`)\n }\n\n const adapter = adapterRegistry.get(input.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${input.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (2) ExternalConversation upsert by (channel_id, externalConversationId).\n const m = input.message\n let conversation = await findOneWithDecryption(\n em,\n ExternalConversation,\n {\n channelId: input.channelId,\n externalConversationId: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n let conversationCreated = false\n if (!conversation) {\n conversation = em.create(ExternalConversation, {\n channelId: input.channelId,\n externalConversationId: m.externalConversationId,\n subject: m.subject ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n lastMessageAt: m.timestamp ?? new Date(),\n assignedUserId: channel.userId ?? null,\n })\n em.persist(conversation)\n conversationCreated = true\n }\n\n // (3) ChannelThreadMapping upsert (1:1 with ExternalConversation per tenant).\n let mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n externalConversationId: conversation.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n\n // Last-activity bump on an existing conversation. Applied AFTER the mapping\n // lookup, immediately before the flush, so the scalar mutation and its flush\n // stay adjacent with no query in between (core flush-ordering rule \u2014 a query\n // between a scalar mutation and `em.flush()` can drop the change under some\n // flush modes / subscriber configurations).\n if (\n !conversationCreated &&\n m.timestamp &&\n (!conversation.lastMessageAt || m.timestamp > conversation.lastMessageAt)\n ) {\n conversation.lastMessageAt = m.timestamp\n }\n // We'll fill `messageThreadId` after composing the platform Message (since the\n // first inbound message becomes the thread root in the messages module).\n await em.flush()\n\n // (3b) Spec B \u2014 layered thread match.\n //\n // Resolve the inbound message to an existing platform thread using\n // (in priority order):\n // 1. Crypto token in References / In-Reply-To header (high confidence)\n // 2. Crypto token in body hidden span or plain-text marker (high)\n // 3. JWZ on Message-Id \u2194 stored `MessageChannelLink.channelMetadata.messageId` (medium)\n // 4. Subject + participants in last 30 days, same channel (low)\n //\n // The matcher returns `null` when nothing hits \u2014 in that case we fall\n // back to the existing `ChannelThreadMapping`-by-conversation-id lookup\n // (which also returns null on first-ever inbound, in which case the\n // compose command opens a new thread).\n const metaForMatcher = (m.channelMetadata ?? {}) as Record<string, unknown>\n let threadMatch: ThreadMatch | null = null\n try {\n threadMatch = await matchThread(\n {\n channelId: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageId: extractStringFromMeta(metaForMatcher, 'messageId') ?? m.externalMessageId,\n inReplyTo:\n m.replyToExternalId ?? extractStringFromMeta(metaForMatcher, 'inReplyTo'),\n references: extractStringArrayFromMeta(metaForMatcher, 'references'),\n subject: m.subject ?? '',\n fromAddress:\n extractStringFromMeta(metaForMatcher, 'from') ?? m.senderIdentifier,\n toAddresses: extractStringArrayFromMeta(metaForMatcher, 'to'),\n ccAddresses: extractStringArrayFromMeta(metaForMatcher, 'cc'),\n bodyPlain: m.bodyFormat === 'html' ? null : m.body ?? null,\n bodyHtml: m.bodyFormat === 'html' ? m.body ?? null : null,\n receivedAt: m.timestamp ?? new Date(),\n },\n { em },\n )\n } catch (matcherErr) {\n // Matcher failure must not block ingest \u2014 fall back to the existing\n // conversation-based thread mapping so the message still lands.\n logger.warn('thread matcher failed, falling back to conversation mapping', { err: matcherErr })\n }\n\n // (4) Contact resolution (best-effort, advisory).\n let contactHint: {\n matchedPersonId?: string | null\n email?: string\n displayName?: string\n } | null = null\n try {\n contactHint = await resolveContact(\n {\n adapter,\n senderIdentifier: m.senderIdentifier,\n senderDisplayName: m.senderDisplayName,\n channelMetadata: m.channelMetadata,\n credentials: {}, // credentials decrypted at the webhook route; resolver doesn't re-fetch\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? input.scope.tenantId,\n },\n },\n { container: ctx.container },\n )\n } catch (contactErr) {\n // Best-effort: contact resolution is advisory and must not abort ingest.\n // Log like the sibling dedup/matcher catches so a misbehaving resolver is\n // visible in operator logs instead of failing silently.\n logger.warn('contact resolution failed, continuing without a CRM match', { err: contactErr })\n contactHint = null\n }\n const matchedPersonId = contactHint?.matchedPersonId ?? null\n if (matchedPersonId && conversation.contactPersonId !== matchedPersonId) {\n conversation.contactPersonId = matchedPersonId\n // Flush this scalar mutation before the system-user lookup below queries the\n // same EntityManager. SPEC-018: a query between a scalar mutation and its\n // flush can silently discard the pending UPDATE (mirrors the lastMessageAt\n // bump above).\n await em.flush()\n }\n\n // (5) Compose the platform Message via the messages module command.\n //\n // Sanitize against the `messages` module's validators (max 50_000 char body\n // + non-empty subject) so real-world emails don't get rejected mid-ingest:\n // - HTML emails routinely exceed 50k (Gmail signatures, marketing\n // templates, RFC 5322 multipart). Truncate with a marker rather than\n // drop the whole message \u2014 the full raw body is still in\n // ExternalMessage.rawPayload if needed for forensic / forward use.\n // - Some legitimate messages have no subject (notifications, bounce\n // digests). Substitute a placeholder instead of failing ingest.\n // - HTML bodies land in the platform body as plain text rather than being\n // merely relabelled as `text`: `MarkdownContent` renders a `text` body\n // verbatim, so unconverted markup shows up as literal doctype, <style>\n // blocks and tags in the inbox preview and the detail body. The source\n // markup is untouched in `channelPayload.html`, which `data/enrichers.ts`\n // sanitizes into `_channelPayload.sanitizedHtml` for the channel-payload\n // renderer widget mounted at `detail:messages:message:body:after`.\n // - The plain-text alternative wins over conversion when the sender's\n // client supplied one (`multipart/alternative`), because that part is\n // what a human actually wrote; `html-to-text` output \u2014 inlined link\n // URLs, flattened tables, synthesized list markers \u2014 is the fallback for\n // HTML-only senders. `lib/email-mime.ts` preferred `bodyHtml` when\n // picking `body`, but preserved the text part at `channelPayload.text`.\n const MAX_COMPOSE_BODY = 50_000\n // Bound the synchronous parse: inbound mail is untrusted and the 5MB body\n // ceiling from `lib/email-capabilities.ts` would otherwise block the ingest\n // worker on a single oversized document. This cap counts markup bytes while\n // MAX_COMPOSE_BODY counts text characters, so it can bite first \u2014 an\n // ordinary marketing template or a long quoted thread is easily over 512KB\n // of markup and still well under 50k characters of text. It therefore\n // carries the same truncation marker, rather than ending mid-sentence with\n // nothing to tell the reader the rest exists.\n const MAX_HTML_PARSE_INPUT = 512 * 1024\n const TRUNCATE_MARKER =\n '\\n\\n[\u2026message truncated by Open Mercato \u2014 full body preserved in ExternalMessage.rawPayload]'\n const sourceBody = m.body ?? ''\n const plainAlternative =\n typeof m.channelPayload?.text === 'string' ? m.channelPayload.text.trim() : ''\n const convertHtmlBody = () => {\n const converted = htmlToPlainText(sourceBody.slice(0, MAX_HTML_PARSE_INPUT))\n return sourceBody.length > MAX_HTML_PARSE_INPUT ? converted + TRUNCATE_MARKER : converted\n }\n const rawBody = m.bodyFormat === 'html'\n ? (plainAlternative || convertHtmlBody())\n : sourceBody\n const truncatedBody =\n rawBody.length > MAX_COMPOSE_BODY\n ? rawBody.slice(0, MAX_COMPOSE_BODY - TRUNCATE_MARKER.length) + TRUNCATE_MARKER\n : rawBody\n const safeSubject = (m.subject ?? '').trim() || '(no subject)'\n\n // #6106: a per-user channel is its owner's inbox, so the owner is the\n // default assignee. Without this fallback the first message of a thread\n // (no mapping yet) and every message on an unassigned mapping had no\n // recipient, and the participant-scoped inbox showed it to nobody. A\n // manual assignment stays authoritative; tenant-wide channels have no owner.\n const routedAssigneeId = mapping?.assignedUserId ?? channel.userId ?? null\n\n const composeInput = {\n type: `channel.${input.providerKey}`,\n visibility: 'public' as const,\n sourceEntityType: 'communication_channels.external_conversation',\n sourceEntityId: conversation.id,\n externalEmail: contactHint?.email ?? undefined,\n externalName: contactHint?.displayName ?? m.senderDisplayName,\n // #4975: tells the hub which identity contract applies to this sender.\n // Email-typed channels keep the mandatory `externalEmail`; providers whose\n // senders have no address (Discord, Slack, SMS\u2026) are validated without it.\n // The messages validator fails closed on any type it does not recognize.\n sourceChannelType: input.channelType,\n // #6093: this message came in from the channel, so the recipient below\n // is internal routing (the conversation's assignee), not an external\n // addressee. Without the flag the hub's \"no recipients on a public\n // message\" rule rejected every message in an assigned conversation and\n // the worker dropped it as a permanent failure.\n inboundFromChannel: true,\n recipients: routedAssigneeId\n ? [{ userId: routedAssigneeId, type: 'to' as const }]\n : [],\n subject: safeSubject,\n body: truncatedBody,\n bodyFormat: (m.bodyFormat === 'html' ? 'text' : m.bodyFormat) as 'text' | 'markdown',\n priority: 'normal' as const,\n sendViaEmail: false,\n // Spec B: matcher-resolved thread id takes priority over the existing\n // conversation-based mapping. Falls through to `mapping?.messageThreadId`\n // when the matcher returned null (no token / JWZ / subject hit).\n parentMessageId: threadMatch?.messageThreadId ?? mapping?.messageThreadId,\n isDraft: false,\n // #6095: the platform message is dated when the provider received the\n // mail, not when this worker ran. Otherwise a history import lands every\n // message on the import minute and the inbox (sorted on `sent_at`) shows\n // a 90-day mailbox as one block in import order. Adapters without a\n // timestamp fall through to compose's own `new Date()`.\n sentAt: m.timestamp ?? undefined,\n // Stable dedup key so a retried ingest (after a transient failure between\n // compose and the ExternalMessage anchor insert) reuses the message\n // composed by the first attempt instead of duplicating it. Mirrors the\n // (channel, externalMessageId) ExternalMessage anchor's natural key.\n idempotencyKey: m.externalMessageId\n ? buildInboundIdempotencyKey(input.channelId, m.externalMessageId)\n : undefined,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n userId: await resolveCommunicationChannelsSystemUserId(\n em,\n input.scope.tenantId,\n mapping?.assignedUserId ?? null,\n ),\n }\n\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const composeResult = await commandBus.execute<typeof composeInput, { id: string; threadId: string | null }>(\n 'messages.messages.compose',\n {\n input: composeInput,\n ctx: passthroughCommandCtx(ctx, input.scope),\n },\n )\n const message = composeResult.result\n if (!message?.id) {\n throw new Error('messages.messages.compose did not return a message id')\n }\n\n // (3 continued) Create or update ChannelThreadMapping now that we have a threadId.\n if (!mapping) {\n mapping = em.create(ChannelThreadMapping, {\n externalConversationId: conversation.id,\n messageThreadId: message.threadId ?? message.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n externalThreadRef: m.externalConversationId,\n assignedUserId: channel.userId ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(mapping)\n }\n\n // (6) Create ExternalMessage + MessageChannelLink (hub-side records).\n //\n // The PrimaryKey for both uses `defaultRaw: 'gen_random_uuid()'` \u2014 a\n // Postgres-side default that doesn't populate the entity's `id` field\n // until after the INSERT returns. If we let MikroORM generate both, then\n // `em.create(MessageChannelLink, { externalMessageId: externalMessage.id })`\n // reads `undefined` for `externalMessage.id` (it hasn't been flushed yet)\n // and writes NULL to `message_channel_links.external_message_id`,\n // breaking the FK and causing downstream joins to silently return 0 rows.\n //\n // Pre-generating both UUIDs client-side fixes the cross-row reference\n // problem and keeps the single-transaction flush semantics intact.\n const externalMessageRowId = randomUUID()\n const channelLinkRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: input.channelId,\n conversationId: conversation.id,\n externalMessageId: m.externalMessageId,\n direction: 'inbound',\n senderIdentifier: m.senderIdentifier,\n senderDisplayName: m.senderDisplayName ?? null,\n providerTimestamp: m.timestamp,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // Spec B: annotate the link with which thread-matcher strategy resolved\n // this message (or `'new-thread'` when matcher returned null and we\n // opened a fresh thread). Surfaced to observability + future UI (\"this\n // thread match is low-confidence \u2014 confirm or move\").\n const matcherAnnotatedMetadata: Record<string, unknown> = {\n ...((m.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n threadMatchStrategy: threadMatch?.matchedBy ?? 'new-thread',\n threadMatchConfidence: threadMatch?.confidence ?? 'low',\n }\n\n const channelLink = em.create(MessageChannelLink, {\n id: channelLinkRowId,\n messageId: message.id,\n externalConversationId: conversation.id,\n externalMessageId: externalMessageRowId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n direction: 'inbound',\n deliveryStatus: 'received',\n channelPayload: m.channelPayload,\n channelContentType: m.channelContentType,\n channelMetadata: matcherAnnotatedMetadata,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(channelLink)\n\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the pre-check at (1) is not atomic with this insert,\n // so a poll re-fetch racing a push notification (or two push deliveries)\n // can both pass the check and reach here. The `(channel_id,\n // external_message_id)` unique index rejects the loser with a 23505. Treat\n // that as a duplicate \u2014 returning here (instead of throwing) prevents the\n // message from being dead-lettered and retried forever. The winning job\n // already recorded the message + link.\n if (isUniqueViolation(flushErr)) {\n return {\n status: 'duplicate',\n externalConversationId: conversation.id,\n externalMessageId: m.externalMessageId,\n }\n }\n throw flushErr\n }\n\n // (7) Emit events \u2014 order matters for downstream subscribers.\n if (conversationCreated) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.conversation.created',\n {\n conversationId: conversation.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n externalConversationId: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n if (matchedPersonId) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.contact.resolved',\n {\n conversationId: conversation.id,\n contactPersonId: matchedPersonId,\n providerKey: input.providerKey,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.received',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: channelLink.id,\n conversationId: conversation.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n direction: 'inbound',\n // #6095: subscribers that date their own rows from this message (the\n // customers timeline) need the provider's receive time, not the moment\n // this worker ran. Carried here so no consumer has to read the\n // ExternalMessage row across the module boundary. ISO string because a\n // persistent event is serialized onto the queue; null when the adapter\n // supplied no timestamp, which leaves the consumer's own fallback.\n providerTimestamp:\n externalMessage.providerTimestamp instanceof Date\n ? externalMessage.providerTimestamp.toISOString()\n : null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'created',\n messageId: message.id,\n externalConversationId: conversation.id,\n externalMessageId: externalMessage.id,\n channelLinkId: channelLink.id,\n threadMappingId: mapping.id,\n contactPersonId: matchedPersonId,\n }\n },\n}\n\n\n/**\n * Build a runtime context for the nested `messages.messages.compose` call.\n *\n * The compose command expects a `CommandRuntimeContext`. For inbound webhook\n * processing there is no platform user; we pass `auth: null` and use the tenant\n * scope from our input.\n */\nfunction passthroughCommandCtx(\n parent: CommandRuntimeContext,\n scope: IngestInboundMessageInput['scope'],\n): CommandRuntimeContext {\n return {\n container: parent.container,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId ?? null,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n }\n}\n\n/**\n * Pull a string value from the provider's `channelMetadata` map. Returns\n * `null` (not `undefined`) when the key is absent or the value isn't a\n * string \u2014 keeps the matcher's input shape predictable.\n */\nfunction extractStringFromMeta(\n meta: Record<string, unknown>,\n key: string,\n): string | null {\n const value = meta[key]\n if (typeof value === 'string' && value.length > 0) return value\n return null\n}\n\n/**\n * Pull a string[] value from the provider's `channelMetadata` map.\n * Filters out non-string entries defensively. Returns an empty array\n * when the key is absent or the value isn't an array.\n */\nfunction extractStringArrayFromMeta(\n meta: Record<string, unknown>,\n key: string,\n): string[] {\n const value = meta[key]\n if (!Array.isArray(value)) return []\n return value.filter((item): item is string => typeof item === 'string')\n}\n\nregisterCommand(ingestInboundMessageCommand)\n\nexport default ingestInboundMessageCommand\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,SAAS;AAGlB,SAAS,uBAAuB;AAChC,SAAS,6BAA6B;AACtC,SAAS,uBAAuB;AAChC,SAAS,sCAAsC;AAC/C,SAAS,sBAAsB;
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandBus, CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { resolveContact } from '../lib/contact-resolver'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { matchThread, type ThreadMatch } from '../lib/thread-matcher'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalConversation,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { normalizedInboundMessageSchema } from '../data/validators'\nimport { buildInboundIdempotencyKey } from '../lib/inbound-message-origin'\nimport { resolveCommunicationChannelsSystemUserId } from '../lib/system-user'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('communication_channels').child({ component: 'ingest-inbound-message' })\n\nconst ingestInputSchema = z.object({\n channelId: z.string().uuid(),\n providerKey: z.string().min(1),\n channelType: z.string().min(1),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n message: normalizedInboundMessageSchema,\n})\n\nexport type IngestInboundMessageInput = z.infer<typeof ingestInputSchema>\n\nexport type IngestInboundMessageResult = {\n status: 'created' | 'duplicate'\n messageId?: string\n externalConversationId?: string\n externalMessageId?: string\n channelLinkId?: string\n threadMappingId?: string\n contactPersonId?: string | null\n}\n\nexport const COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID = 'communication_channels.message.ingest_inbound'\n\n/**\n * Idempotently ingest a normalized inbound channel message.\n *\n * Steps (per SPEC-045d \u00A76):\n * 1. Dedup by `(channel_id, external_message_id)` \u2014 if a MessageChannelLink already\n * exists for that pair, return `{ status: 'duplicate' }` without side effects.\n * 2. Create or load `ExternalConversation` by `(channel_id, external_conversation_id)`.\n * 3. Create or load `ChannelThreadMapping` (1:1 with ExternalConversation).\n * 4. Resolve CRM contact via adapter + QueryEngine (best-effort).\n * 5. Compose the platform `Message` via `messages.messages.compose` (separate transaction).\n * 6. Create `ExternalMessage` + `MessageChannelLink`.\n * 7. Emit `communication_channels.message.received` (and `.conversation.created` / `.contact.resolved` when applicable).\n *\n * The two-transaction model (compose-message-then-record-link) is acceptable for v1;\n * the link's unique-on-message-id constraint is the safety net against orphans. See\n * the pre-implementation analysis for a discussion of single-transaction alternatives.\n */\nconst ingestInboundMessageCommand: CommandHandler<IngestInboundMessageInput, IngestInboundMessageResult> = {\n id: COMMUNICATION_CHANNELS_INGEST_INBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = ingestInputSchema.parse(rawInput) as IngestInboundMessageInput\n\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Dedup: short-circuit if we've already processed this provider message.\n // The unique constraint is on `messageId`, not (channel, externalMessageId).\n // We must dedup by joining against ExternalMessage which IS uniquely indexed by\n // (channel_id, external_message_id). Hub-side dedup is the authoritative gate.\n const existingExternal = await findOneWithDecryption(\n em,\n ExternalMessage,\n {\n channelId: input.channelId,\n externalMessageId: input.message.externalMessageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (existingExternal) {\n return {\n status: 'duplicate',\n externalConversationId: existingExternal.conversationId,\n externalMessageId: existingExternal.id,\n }\n }\n\n // (1b) Spec B \u00A7 Sent-folder dedup.\n //\n // When an outbound message lands in the user's IMAP Sent folder (or\n // when Gmail's \"send and archive\" deposits it in All Mail), the next\n // poll will re-fetch it from INBOX as if it were inbound. Skip it\n // here using the RFC 5322 `Message-ID` header \u2014 we recorded it on the\n // outbound `MessageChannelLink.channelMetadata.messageId` at send time.\n //\n // We dedup ONLY on outbound links (direction='outbound') for the same\n // channel \u2014 that way an inbound copy of someone ELSE's email that\n // happens to share a Message-ID is still ingested normally.\n const incomingMessageId = (() => {\n const fromMeta = (input.message.channelMetadata as Record<string, unknown> | undefined)?.messageId\n if (typeof fromMeta === 'string' && fromMeta.length > 0) return fromMeta\n return null\n })()\n if (incomingMessageId) {\n // MikroORM v7 dropped the Knex builder in favour of Kysely/raw SQL.\n // We use a positional-placeholder raw query for the JSONB\n // `channel_metadata->>messageId` comparison.\n try {\n const sentFolderHit = await em.getConnection().execute<Array<{ id: string }>>(\n `SELECT link.id FROM message_channel_links AS link\n INNER JOIN external_conversations AS conv\n ON conv.id = link.external_conversation_id\n WHERE link.tenant_id = ?\n AND ((?::uuid IS NULL AND link.organization_id IS NULL) OR link.organization_id = ?::uuid)\n AND conv.tenant_id = ?\n AND ((?::uuid IS NULL AND conv.organization_id IS NULL) OR conv.organization_id = ?::uuid)\n AND conv.channel_id = ?\n AND link.direction = 'outbound'\n AND link.channel_metadata->>'messageId' = ?\n LIMIT 1`,\n [\n input.scope.tenantId,\n input.scope.organizationId ?? null,\n input.scope.organizationId ?? null,\n input.scope.tenantId,\n input.scope.organizationId ?? null,\n input.scope.organizationId ?? null,\n input.channelId,\n incomingMessageId,\n ],\n )\n if (Array.isArray(sentFolderHit) && sentFolderHit.length > 0) {\n return {\n status: 'duplicate',\n externalConversationId: input.message.externalConversationId,\n externalMessageId: input.message.externalMessageId,\n }\n }\n } catch (dedupErr) {\n // Sent-folder dedup is best-effort \u2014 a failure here must not abort\n // ingest (better a possible duplicate than a lost inbound message).\n logger.warn('sent-folder dedup query failed, continuing', { err: dedupErr })\n }\n }\n\n // Channel + adapter lookup (the channel must exist + be active).\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${input.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${input.channelId} is inactive; refusing to ingest`)\n }\n\n const adapter = adapterRegistry.get(input.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${input.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (2) ExternalConversation upsert by (channel_id, externalConversationId).\n const m = input.message\n let conversation = await findOneWithDecryption(\n em,\n ExternalConversation,\n {\n channelId: input.channelId,\n externalConversationId: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n let conversationCreated = false\n if (!conversation) {\n conversation = em.create(ExternalConversation, {\n channelId: input.channelId,\n externalConversationId: m.externalConversationId,\n subject: m.subject ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n lastMessageAt: m.timestamp,\n assignedUserId: channel.userId ?? null,\n })\n em.persist(conversation)\n conversationCreated = true\n }\n\n // (3) ChannelThreadMapping upsert (1:1 with ExternalConversation per tenant).\n let mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n externalConversationId: conversation.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n\n // Last-activity bump on an existing conversation. Applied AFTER the mapping\n // lookup, immediately before the flush, so the scalar mutation and its flush\n // stay adjacent with no query in between (core flush-ordering rule \u2014 a query\n // between a scalar mutation and `em.flush()` can drop the change under some\n // flush modes / subscriber configurations).\n if (\n !conversationCreated &&\n m.timestamp &&\n (!conversation.lastMessageAt || m.timestamp > conversation.lastMessageAt)\n ) {\n conversation.lastMessageAt = m.timestamp\n }\n // We'll fill `messageThreadId` after composing the platform Message (since the\n // first inbound message becomes the thread root in the messages module).\n await em.flush()\n\n // (3b) Spec B \u2014 layered thread match.\n //\n // Resolve the inbound message to an existing platform thread using\n // (in priority order):\n // 1. Crypto token in References / In-Reply-To header (high confidence)\n // 2. Crypto token in body hidden span or plain-text marker (high)\n // 3. JWZ on Message-Id \u2194 stored `MessageChannelLink.channelMetadata.messageId` (medium)\n // 4. Subject + participants in last 30 days, same channel (low)\n //\n // The matcher returns `null` when nothing hits \u2014 in that case we fall\n // back to the existing `ChannelThreadMapping`-by-conversation-id lookup\n // (which also returns null on first-ever inbound, in which case the\n // compose command opens a new thread).\n const metaForMatcher = (m.channelMetadata ?? {}) as Record<string, unknown>\n let threadMatch: ThreadMatch | null = null\n try {\n threadMatch = await matchThread(\n {\n channelId: input.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageId: extractStringFromMeta(metaForMatcher, 'messageId') ?? m.externalMessageId,\n inReplyTo:\n m.replyToExternalId ?? extractStringFromMeta(metaForMatcher, 'inReplyTo'),\n references: extractStringArrayFromMeta(metaForMatcher, 'references'),\n subject: m.subject ?? '',\n fromAddress:\n extractStringFromMeta(metaForMatcher, 'from') ?? m.senderIdentifier,\n toAddresses: extractStringArrayFromMeta(metaForMatcher, 'to'),\n ccAddresses: extractStringArrayFromMeta(metaForMatcher, 'cc'),\n bodyPlain: m.bodyFormat === 'html' ? null : m.body ?? null,\n bodyHtml: m.bodyFormat === 'html' ? m.body ?? null : null,\n },\n { em },\n )\n } catch (matcherErr) {\n // Matcher failure must not block ingest \u2014 fall back to the existing\n // conversation-based thread mapping so the message still lands.\n logger.warn('thread matcher failed, falling back to conversation mapping', { err: matcherErr })\n }\n\n // (4) Contact resolution (best-effort, advisory).\n let contactHint: {\n matchedPersonId?: string | null\n email?: string\n displayName?: string\n } | null = null\n try {\n contactHint = await resolveContact(\n {\n adapter,\n senderIdentifier: m.senderIdentifier,\n senderDisplayName: m.senderDisplayName,\n channelMetadata: m.channelMetadata,\n credentials: {}, // credentials decrypted at the webhook route; resolver doesn't re-fetch\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? input.scope.tenantId,\n },\n },\n { container: ctx.container },\n )\n } catch (contactErr) {\n // Best-effort: contact resolution is advisory and must not abort ingest.\n // Log like the sibling dedup/matcher catches so a misbehaving resolver is\n // visible in operator logs instead of failing silently.\n logger.warn('contact resolution failed, continuing without a CRM match', { err: contactErr })\n contactHint = null\n }\n const matchedPersonId = contactHint?.matchedPersonId ?? null\n if (matchedPersonId && conversation.contactPersonId !== matchedPersonId) {\n conversation.contactPersonId = matchedPersonId\n // Flush this scalar mutation before the system-user lookup below queries the\n // same EntityManager. SPEC-018: a query between a scalar mutation and its\n // flush can silently discard the pending UPDATE (mirrors the lastMessageAt\n // bump above).\n await em.flush()\n }\n\n // (5) Compose the platform Message via the messages module command.\n //\n // Sanitize against the `messages` module's validators (max 50_000 char body\n // + non-empty subject) so real-world emails don't get rejected mid-ingest:\n // - HTML emails routinely exceed 50k (Gmail signatures, marketing\n // templates, RFC 5322 multipart). Truncate with a marker rather than\n // drop the whole message \u2014 the full raw body is still in\n // ExternalMessage.rawPayload if needed for forensic / forward use.\n // - Some legitimate messages have no subject (notifications, bounce\n // digests). Substitute a placeholder instead of failing ingest.\n // - HTML bodies land in the platform body as plain text rather than being\n // merely relabelled as `text`: `MarkdownContent` renders a `text` body\n // verbatim, so unconverted markup shows up as literal doctype, <style>\n // blocks and tags in the inbox preview and the detail body. The source\n // markup is untouched in `channelPayload.html`, which `data/enrichers.ts`\n // sanitizes into `_channelPayload.sanitizedHtml` for the channel-payload\n // renderer widget mounted at `detail:messages:message:body:after`.\n // - The plain-text alternative wins over conversion when the sender's\n // client supplied one (`multipart/alternative`), because that part is\n // what a human actually wrote; `html-to-text` output \u2014 inlined link\n // URLs, flattened tables, synthesized list markers \u2014 is the fallback for\n // HTML-only senders. `lib/email-mime.ts` preferred `bodyHtml` when\n // picking `body`, but preserved the text part at `channelPayload.text`.\n const MAX_COMPOSE_BODY = 50_000\n // Bound the synchronous parse: inbound mail is untrusted and the 5MB body\n // ceiling from `lib/email-capabilities.ts` would otherwise block the ingest\n // worker on a single oversized document. This cap counts markup bytes while\n // MAX_COMPOSE_BODY counts text characters, so it can bite first \u2014 an\n // ordinary marketing template or a long quoted thread is easily over 512KB\n // of markup and still well under 50k characters of text. It therefore\n // carries the same truncation marker, rather than ending mid-sentence with\n // nothing to tell the reader the rest exists.\n const MAX_HTML_PARSE_INPUT = 512 * 1024\n const TRUNCATE_MARKER =\n '\\n\\n[\u2026message truncated by Open Mercato \u2014 full body preserved in ExternalMessage.rawPayload]'\n const sourceBody = m.body ?? ''\n const plainAlternative =\n typeof m.channelPayload?.text === 'string' ? m.channelPayload.text.trim() : ''\n const convertHtmlBody = () => {\n const converted = htmlToPlainText(sourceBody.slice(0, MAX_HTML_PARSE_INPUT))\n return sourceBody.length > MAX_HTML_PARSE_INPUT ? converted + TRUNCATE_MARKER : converted\n }\n const rawBody = m.bodyFormat === 'html'\n ? (plainAlternative || convertHtmlBody())\n : sourceBody\n const truncatedBody =\n rawBody.length > MAX_COMPOSE_BODY\n ? rawBody.slice(0, MAX_COMPOSE_BODY - TRUNCATE_MARKER.length) + TRUNCATE_MARKER\n : rawBody\n const safeSubject = (m.subject ?? '').trim() || '(no subject)'\n\n // #6106: a per-user channel is its owner's inbox, so the owner is the\n // default assignee. Without this fallback the first message of a thread\n // (no mapping yet) and every message on an unassigned mapping had no\n // recipient, and the participant-scoped inbox showed it to nobody. A\n // manual assignment stays authoritative; tenant-wide channels have no owner.\n const routedAssigneeId = mapping?.assignedUserId ?? channel.userId ?? null\n\n const composeInput = {\n type: `channel.${input.providerKey}`,\n visibility: 'public' as const,\n sourceEntityType: 'communication_channels.external_conversation',\n sourceEntityId: conversation.id,\n externalEmail: contactHint?.email ?? undefined,\n externalName: contactHint?.displayName ?? m.senderDisplayName,\n // #4975: tells the hub which identity contract applies to this sender.\n // Email-typed channels keep the mandatory `externalEmail`; providers whose\n // senders have no address (Discord, Slack, SMS\u2026) are validated without it.\n // The messages validator fails closed on any type it does not recognize.\n sourceChannelType: input.channelType,\n // #6093: this message came in from the channel, so the recipient below\n // is internal routing (the conversation's assignee), not an external\n // addressee. Without the flag the hub's \"no recipients on a public\n // message\" rule rejected every message in an assigned conversation and\n // the worker dropped it as a permanent failure.\n inboundFromChannel: true,\n recipients: routedAssigneeId\n ? [{ userId: routedAssigneeId, type: 'to' as const }]\n : [],\n subject: safeSubject,\n body: truncatedBody,\n bodyFormat: (m.bodyFormat === 'html' ? 'text' : m.bodyFormat) as 'text' | 'markdown',\n priority: 'normal' as const,\n sendViaEmail: false,\n // Spec B: matcher-resolved thread id takes priority over the existing\n // conversation-based mapping. Falls through to `mapping?.messageThreadId`\n // when the matcher returned null (no token / JWZ / subject hit).\n parentMessageId: threadMatch?.messageThreadId ?? mapping?.messageThreadId,\n isDraft: false,\n // #6095: the platform message is dated when the provider received the\n // mail, not when this worker ran. Otherwise a history import lands every\n // message on the import minute and the inbox (sorted on `sent_at`) shows\n // a 90-day mailbox as one block in import order. Adapters without a\n // timestamp retain an explicitly unknown occurrence time.\n sentAt: m.timestamp,\n // Stable dedup key so a retried ingest (after a transient failure between\n // compose and the ExternalMessage anchor insert) reuses the message\n // composed by the first attempt instead of duplicating it. Mirrors the\n // (channel, externalMessageId) ExternalMessage anchor's natural key.\n idempotencyKey: m.externalMessageId\n ? buildInboundIdempotencyKey(input.channelId, m.externalMessageId)\n : undefined,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId,\n userId: await resolveCommunicationChannelsSystemUserId(\n em,\n input.scope.tenantId,\n mapping?.assignedUserId ?? null,\n ),\n }\n\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const composeResult = await commandBus.execute<typeof composeInput, { id: string; threadId: string | null }>(\n 'messages.messages.compose',\n {\n input: composeInput,\n ctx: passthroughCommandCtx(ctx, input.scope),\n },\n )\n const message = composeResult.result\n if (!message?.id) {\n throw new Error('messages.messages.compose did not return a message id')\n }\n\n // (3 continued) Create or update ChannelThreadMapping now that we have a threadId.\n if (!mapping) {\n mapping = em.create(ChannelThreadMapping, {\n externalConversationId: conversation.id,\n messageThreadId: message.threadId ?? message.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n externalThreadRef: m.externalConversationId,\n assignedUserId: channel.userId ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(mapping)\n }\n\n // (6) Create ExternalMessage + MessageChannelLink (hub-side records).\n //\n // The PrimaryKey for both uses `defaultRaw: 'gen_random_uuid()'` \u2014 a\n // Postgres-side default that doesn't populate the entity's `id` field\n // until after the INSERT returns. If we let MikroORM generate both, then\n // `em.create(MessageChannelLink, { externalMessageId: externalMessage.id })`\n // reads `undefined` for `externalMessage.id` (it hasn't been flushed yet)\n // and writes NULL to `message_channel_links.external_message_id`,\n // breaking the FK and causing downstream joins to silently return 0 rows.\n //\n // Pre-generating both UUIDs client-side fixes the cross-row reference\n // problem and keeps the single-transaction flush semantics intact.\n const externalMessageRowId = randomUUID()\n const channelLinkRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: input.channelId,\n conversationId: conversation.id,\n externalMessageId: m.externalMessageId,\n direction: 'inbound',\n senderIdentifier: m.senderIdentifier,\n senderDisplayName: m.senderDisplayName ?? null,\n providerTimestamp: m.providerTimestamp ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // Spec B: annotate the link with which thread-matcher strategy resolved\n // this message (or `'new-thread'` when matcher returned null and we\n // opened a fresh thread). Surfaced to observability + future UI (\"this\n // thread match is low-confidence \u2014 confirm or move\").\n const matcherAnnotatedMetadata: Record<string, unknown> = {\n ...((m.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n threadMatchStrategy: threadMatch?.matchedBy ?? 'new-thread',\n threadMatchConfidence: threadMatch?.confidence ?? 'low',\n }\n\n const channelLink = em.create(MessageChannelLink, {\n id: channelLinkRowId,\n messageId: message.id,\n externalConversationId: conversation.id,\n externalMessageId: externalMessageRowId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n direction: 'inbound',\n deliveryStatus: 'received',\n channelPayload: m.channelPayload,\n channelContentType: m.channelContentType,\n channelMetadata: matcherAnnotatedMetadata,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(channelLink)\n\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the pre-check at (1) is not atomic with this insert,\n // so a poll re-fetch racing a push notification (or two push deliveries)\n // can both pass the check and reach here. The `(channel_id,\n // external_message_id)` unique index rejects the loser with a 23505. Treat\n // that as a duplicate \u2014 returning here (instead of throwing) prevents the\n // message from being dead-lettered and retried forever. The winning job\n // already recorded the message + link.\n if (isUniqueViolation(flushErr)) {\n return {\n status: 'duplicate',\n externalConversationId: conversation.id,\n externalMessageId: m.externalMessageId,\n }\n }\n throw flushErr\n }\n\n // (7) Emit events \u2014 order matters for downstream subscribers.\n if (conversationCreated) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.conversation.created',\n {\n conversationId: conversation.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n externalConversationId: m.externalConversationId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n if (matchedPersonId) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.contact.resolved',\n {\n conversationId: conversation.id,\n contactPersonId: matchedPersonId,\n providerKey: input.providerKey,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.received',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: channelLink.id,\n conversationId: conversation.id,\n channelId: input.channelId,\n providerKey: input.providerKey,\n channelType: input.channelType,\n direction: 'inbound',\n // #6095: subscribers that date their own rows from this message (the\n // customers timeline) need the provider's receive time, not the moment\n // this worker ran. Carried here so no consumer has to read the\n // ExternalMessage row across the module boundary. ISO string because a\n // persistent event is serialized onto the queue; null when the adapter\n // supplied no provider-native timestamp.\n providerTimestamp:\n externalMessage.providerTimestamp instanceof Date\n ? externalMessage.providerTimestamp.toISOString()\n : null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'created',\n messageId: message.id,\n externalConversationId: conversation.id,\n externalMessageId: externalMessage.id,\n channelLinkId: channelLink.id,\n threadMappingId: mapping.id,\n contactPersonId: matchedPersonId,\n }\n },\n}\n\n\n/**\n * Build a runtime context for the nested `messages.messages.compose` call.\n *\n * The compose command expects a `CommandRuntimeContext`. For inbound webhook\n * processing there is no platform user; we pass `auth: null` and use the tenant\n * scope from our input.\n */\nfunction passthroughCommandCtx(\n parent: CommandRuntimeContext,\n scope: IngestInboundMessageInput['scope'],\n): CommandRuntimeContext {\n return {\n container: parent.container,\n auth: null,\n organizationScope: null,\n selectedOrganizationId: scope.organizationId ?? null,\n organizationIds: scope.organizationId ? [scope.organizationId] : null,\n }\n}\n\n/**\n * Pull a string value from the provider's `channelMetadata` map. Returns\n * `null` (not `undefined`) when the key is absent or the value isn't a\n * string \u2014 keeps the matcher's input shape predictable.\n */\nfunction extractStringFromMeta(\n meta: Record<string, unknown>,\n key: string,\n): string | null {\n const value = meta[key]\n if (typeof value === 'string' && value.length > 0) return value\n return null\n}\n\n/**\n * Pull a string[] value from the provider's `channelMetadata` map.\n * Filters out non-string entries defensively. Returns an empty array\n * when the key is absent or the value isn't an array.\n */\nfunction extractStringArrayFromMeta(\n meta: Record<string, unknown>,\n key: string,\n): string[] {\n const value = meta[key]\n if (!Array.isArray(value)) return []\n return value.filter((item): item is string => typeof item === 'string')\n}\n\nregisterCommand(ingestInboundMessageCommand)\n\nexport default ingestInboundMessageCommand\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -96,7 +96,8 @@ const normalizedInboundMessageSchema = z.object({
|
|
|
96
96
|
body: z.string(),
|
|
97
97
|
bodyFormat: z.enum(["text", "markdown", "html"]),
|
|
98
98
|
attachments: z.array(normalizedAttachmentSchema).optional(),
|
|
99
|
-
timestamp: z.date(),
|
|
99
|
+
timestamp: z.date().nullable(),
|
|
100
|
+
providerTimestamp: z.date().nullable().optional(),
|
|
100
101
|
replyToExternalId: z.string().optional(),
|
|
101
102
|
channelPayload: z.record(z.string(), z.unknown()),
|
|
102
103
|
channelContentType: z.string().min(1),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/data/validators.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\n\n/**\n * Zod schemas for adapter inputs and API request/response bodies.\n *\n * These mirror the TypeScript interfaces in `lib/adapter.ts`. They are used by:\n * - API route handlers to validate incoming requests\n * - Adapter implementations that prefer runtime validation over TypeScript-only types\n * - `validateCredentials?(...)` flow on credential-based providers\n */\n\nexport const tenantScopeSchema = z.object({\n organizationId: z.string().uuid().nullable().optional(),\n tenantId: z.string().uuid(),\n})\n\nexport const channelCapabilitiesSchema = z.object({\n threading: z.boolean(),\n richText: z.boolean(),\n fileSharing: z.boolean(),\n maxFileSize: z.number().int().nonnegative().optional(),\n supportedMimeTypes: z.array(z.string()).optional(),\n readReceipts: z.boolean(),\n deliveryReceipts: z.boolean(),\n typingIndicators: z.boolean(),\n reactions: z.boolean(),\n multiReactionPerUser: z.boolean(),\n editMessage: z.boolean(),\n deleteMessage: z.boolean(),\n presence: z.boolean(),\n richBlocks: z.boolean(),\n interactiveComponents: z.boolean(),\n inlineImages: z.boolean(),\n conversationHistory: z.boolean(),\n contactCards: z.boolean(),\n locationSharing: z.boolean(),\n voiceNotes: z.boolean(),\n stickers: z.boolean(),\n supportedBodyFormats: z.array(z.enum(['text', 'markdown', 'html'])),\n maxBodyLength: z.number().int().positive().optional(),\n realtimePush: z.boolean().optional(),\n})\n\nexport const normalizedAttachmentSchema = z.object({\n url: z.string().url(),\n mimeType: z.string().min(1),\n fileName: z.string().min(1),\n fileSize: z.number().int().nonnegative().optional(),\n inline: z.boolean().optional(),\n})\n\nexport const messageContentSchema = z.object({\n text: z.string().optional(),\n html: z.string().optional(),\n bodyFormat: z.enum(['text', 'markdown', 'html']).optional(),\n attachments: z.array(normalizedAttachmentSchema).optional(),\n raw: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const sendMessageInputSchema = z.object({\n conversationId: z.string().optional(),\n content: messageContentSchema,\n credentials: z.record(z.string(), z.unknown()),\n scope: tenantScopeSchema,\n metadata: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const validateCredentialsInputSchema = z.object({\n providerKey: z.string().min(1),\n credentials: z.record(z.string(), z.unknown()),\n scope: tenantScopeSchema,\n})\n\nexport const validateCredentialsResultSchema = z.object({\n ok: z.boolean(),\n errors: z.record(z.string(), z.string()).optional(),\n errorCodes: z.record(z.string(), z.string()).optional(),\n})\n\nexport const refreshCredentialsInputSchema = z.object({\n channelId: z.string().uuid(),\n credentials: z.record(z.string(), z.unknown()),\n scope: tenantScopeSchema,\n})\n\nexport const refreshedCredentialsSchema = z.object({\n credentials: z.record(z.string(), z.unknown()),\n expiresAt: z.date().optional(),\n})\n\nexport const inboundReactionSchema = z.object({\n emoji: z.string().min(1),\n userIdentifier: z.string().min(1),\n userDisplayName: z.string().optional(),\n timestamp: z.date().optional(),\n})\n\n/**\n * Canonical normalized inbound reaction event \u2014 matches `InboundReactionEvent`\n * in `lib/adapter.ts`. Used by the reactions queue for type-safe payloads.\n */\nexport const inboundReactionEventSchema = z.object({\n externalMessageId: z.string().min(1),\n externalConversationId: z.string().min(1).optional(),\n externalReactionId: z.string().min(1).optional(),\n emoji: z.string().min(1).max(64),\n userIdentifier: z.string().min(1),\n userDisplayName: z.string().optional(),\n action: z.enum(['added', 'removed']),\n timestamp: z.date().optional(),\n raw: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const normalizedInboundMessageSchema = z.object({\n externalMessageId: z.string().min(1),\n externalConversationId: z.string().min(1),\n senderIdentifier: z.string().min(1),\n senderDisplayName: z.string().optional(),\n senderAvatarUrl: z.string().url().optional(),\n subject: z.string().optional(),\n body: z.string(),\n bodyFormat: z.enum(['text', 'markdown', 'html']),\n attachments: z.array(normalizedAttachmentSchema).optional(),\n timestamp: z.date(),\n replyToExternalId: z.string().optional(),\n channelPayload: z.record(z.string(), z.unknown()),\n channelContentType: z.string().min(1),\n channelMetadata: z.record(z.string(), z.unknown()),\n reactions: z.array(inboundReactionSchema).optional(),\n})\n\nexport const reactionApiBodySchema = z.object({\n emoji: z.string().min(1).max(64),\n})\n\nexport type SendMessageInputDto = z.infer<typeof sendMessageInputSchema>\nexport type NormalizedInboundMessageDto = z.infer<typeof normalizedInboundMessageSchema>\nexport type ChannelCapabilitiesDto = z.infer<typeof channelCapabilitiesSchema>\nexport type ReactionApiBodyDto = z.infer<typeof reactionApiBodySchema>\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\n\n/**\n * Zod schemas for adapter inputs and API request/response bodies.\n *\n * These mirror the TypeScript interfaces in `lib/adapter.ts`. They are used by:\n * - API route handlers to validate incoming requests\n * - Adapter implementations that prefer runtime validation over TypeScript-only types\n * - `validateCredentials?(...)` flow on credential-based providers\n */\n\nexport const tenantScopeSchema = z.object({\n organizationId: z.string().uuid().nullable().optional(),\n tenantId: z.string().uuid(),\n})\n\nexport const channelCapabilitiesSchema = z.object({\n threading: z.boolean(),\n richText: z.boolean(),\n fileSharing: z.boolean(),\n maxFileSize: z.number().int().nonnegative().optional(),\n supportedMimeTypes: z.array(z.string()).optional(),\n readReceipts: z.boolean(),\n deliveryReceipts: z.boolean(),\n typingIndicators: z.boolean(),\n reactions: z.boolean(),\n multiReactionPerUser: z.boolean(),\n editMessage: z.boolean(),\n deleteMessage: z.boolean(),\n presence: z.boolean(),\n richBlocks: z.boolean(),\n interactiveComponents: z.boolean(),\n inlineImages: z.boolean(),\n conversationHistory: z.boolean(),\n contactCards: z.boolean(),\n locationSharing: z.boolean(),\n voiceNotes: z.boolean(),\n stickers: z.boolean(),\n supportedBodyFormats: z.array(z.enum(['text', 'markdown', 'html'])),\n maxBodyLength: z.number().int().positive().optional(),\n realtimePush: z.boolean().optional(),\n})\n\nexport const normalizedAttachmentSchema = z.object({\n url: z.string().url(),\n mimeType: z.string().min(1),\n fileName: z.string().min(1),\n fileSize: z.number().int().nonnegative().optional(),\n inline: z.boolean().optional(),\n})\n\nexport const messageContentSchema = z.object({\n text: z.string().optional(),\n html: z.string().optional(),\n bodyFormat: z.enum(['text', 'markdown', 'html']).optional(),\n attachments: z.array(normalizedAttachmentSchema).optional(),\n raw: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const sendMessageInputSchema = z.object({\n conversationId: z.string().optional(),\n content: messageContentSchema,\n credentials: z.record(z.string(), z.unknown()),\n scope: tenantScopeSchema,\n metadata: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const validateCredentialsInputSchema = z.object({\n providerKey: z.string().min(1),\n credentials: z.record(z.string(), z.unknown()),\n scope: tenantScopeSchema,\n})\n\nexport const validateCredentialsResultSchema = z.object({\n ok: z.boolean(),\n errors: z.record(z.string(), z.string()).optional(),\n errorCodes: z.record(z.string(), z.string()).optional(),\n})\n\nexport const refreshCredentialsInputSchema = z.object({\n channelId: z.string().uuid(),\n credentials: z.record(z.string(), z.unknown()),\n scope: tenantScopeSchema,\n})\n\nexport const refreshedCredentialsSchema = z.object({\n credentials: z.record(z.string(), z.unknown()),\n expiresAt: z.date().optional(),\n})\n\nexport const inboundReactionSchema = z.object({\n emoji: z.string().min(1),\n userIdentifier: z.string().min(1),\n userDisplayName: z.string().optional(),\n timestamp: z.date().optional(),\n})\n\n/**\n * Canonical normalized inbound reaction event \u2014 matches `InboundReactionEvent`\n * in `lib/adapter.ts`. Used by the reactions queue for type-safe payloads.\n */\nexport const inboundReactionEventSchema = z.object({\n externalMessageId: z.string().min(1),\n externalConversationId: z.string().min(1).optional(),\n externalReactionId: z.string().min(1).optional(),\n emoji: z.string().min(1).max(64),\n userIdentifier: z.string().min(1),\n userDisplayName: z.string().optional(),\n action: z.enum(['added', 'removed']),\n timestamp: z.date().optional(),\n raw: z.record(z.string(), z.unknown()).optional(),\n})\n\nexport const normalizedInboundMessageSchema = z.object({\n externalMessageId: z.string().min(1),\n externalConversationId: z.string().min(1),\n senderIdentifier: z.string().min(1),\n senderDisplayName: z.string().optional(),\n senderAvatarUrl: z.string().url().optional(),\n subject: z.string().optional(),\n body: z.string(),\n bodyFormat: z.enum(['text', 'markdown', 'html']),\n attachments: z.array(normalizedAttachmentSchema).optional(),\n timestamp: z.date().nullable(),\n providerTimestamp: z.date().nullable().optional(),\n replyToExternalId: z.string().optional(),\n channelPayload: z.record(z.string(), z.unknown()),\n channelContentType: z.string().min(1),\n channelMetadata: z.record(z.string(), z.unknown()),\n reactions: z.array(inboundReactionSchema).optional(),\n})\n\nexport const reactionApiBodySchema = z.object({\n emoji: z.string().min(1).max(64),\n})\n\nexport type SendMessageInputDto = z.infer<typeof sendMessageInputSchema>\nexport type NormalizedInboundMessageDto = z.infer<typeof normalizedInboundMessageSchema>\nexport type ChannelCapabilitiesDto = z.infer<typeof channelCapabilitiesSchema>\nexport type ReactionApiBodyDto = z.infer<typeof reactionApiBodySchema>\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -215,21 +215,30 @@ function extractHeaders(headers) {
|
|
|
215
215
|
}
|
|
216
216
|
return out;
|
|
217
217
|
}
|
|
218
|
+
function validDate(value) {
|
|
219
|
+
if (value == null) return null;
|
|
220
|
+
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
|
221
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
222
|
+
}
|
|
218
223
|
const MAX_MIME_DATE_FUTURE_SKEW_MS = 24 * 60 * 60 * 1e3;
|
|
219
224
|
function isValidDate(value) {
|
|
220
225
|
return value instanceof Date && !Number.isNaN(value.getTime());
|
|
221
226
|
}
|
|
222
|
-
function
|
|
227
|
+
function resolveKnownInboundTimestamp(input) {
|
|
223
228
|
if (isValidDate(input.receivedAt)) return input.receivedAt;
|
|
224
229
|
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
225
230
|
if (isValidDate(input.mimeDate) && input.mimeDate.getTime() <= now.getTime() + MAX_MIME_DATE_FUTURE_SKEW_MS) {
|
|
226
231
|
return input.mimeDate;
|
|
227
232
|
}
|
|
228
233
|
if (isValidDate(input.fallbackDate)) return input.fallbackDate;
|
|
229
|
-
return
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
function resolveInboundTimestamp(input) {
|
|
237
|
+
return resolveKnownInboundTimestamp(input) ?? input.now ?? /* @__PURE__ */ new Date();
|
|
230
238
|
}
|
|
231
239
|
function normalizeMimeInbound(options) {
|
|
232
240
|
const { parsed } = options;
|
|
241
|
+
const providerTimestamp = validDate(options.receivedAt) ?? validDate(options.fallbackDate);
|
|
233
242
|
const messageId = stripBrackets(parsed.messageId) ?? options.fallbackMessageId;
|
|
234
243
|
const inReplyTo = stripBrackets(parsed.inReplyTo);
|
|
235
244
|
const references = parseReferences(parsed.references);
|
|
@@ -268,11 +277,12 @@ function normalizeMimeInbound(options) {
|
|
|
268
277
|
body,
|
|
269
278
|
bodyFormat,
|
|
270
279
|
attachments,
|
|
271
|
-
timestamp:
|
|
280
|
+
timestamp: resolveKnownInboundTimestamp({
|
|
272
281
|
receivedAt: options.receivedAt,
|
|
273
282
|
mimeDate: parsed.date ? new Date(parsed.date) : void 0,
|
|
274
283
|
fallbackDate: options.fallbackDate
|
|
275
284
|
}),
|
|
285
|
+
providerTimestamp,
|
|
276
286
|
replyToExternalId: inReplyTo ?? void 0,
|
|
277
287
|
channelPayload,
|
|
278
288
|
channelContentType: "email/mime",
|
|
@@ -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 ' ': ' ',\n '<': '<',\n '>': '>',\n '"': '\"',\n '&': '&',\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 `&`) are never re-scanned, so\n * `&lt;` decodes to the literal `<` 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. `<script>`) 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 `--${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') + '\\r\\n\\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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
|
|
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 ' ': ' ',\n '<': '<',\n '>': '>',\n '"': '\"',\n '&': '&',\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 `&`) are never re-scanned, so\n * `&lt;` decodes to the literal `<` 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. `<script>`) 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 `--${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') + '\\r\\n\\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 | null\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\nfunction validDate(value: Date | string | null | undefined): Date | null {\n if (value == null) return null\n const date = value instanceof Date ? new Date(value.getTime()) : new Date(value)\n return Number.isNaN(date.getTime()) ? null : date\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; unknown remains null.\n */\nfunction resolveKnownInboundTimestamp(input: {\n receivedAt?: Date\n mimeDate?: Date\n fallbackDate?: Date | null\n now?: Date\n}): Date | null {\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 null\n}\n\nexport function resolveInboundTimestamp(input: {\n receivedAt?: Date\n mimeDate?: Date\n fallbackDate?: Date | null\n now?: Date\n}): Date {\n return resolveKnownInboundTimestamp(input) ?? input.now ?? new Date()\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 const providerTimestamp = validDate(options.receivedAt) ?? validDate(options.fallbackDate)\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: resolveKnownInboundTimestamp({\n receivedAt: options.receivedAt,\n mimeDate: parsed.date ? new Date(parsed.date) : undefined,\n fallbackDate: options.fallbackDate,\n }),\n providerTimestamp,\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
|
}
|