@open-mercato/core 0.8.1-develop.7451.1.83d4762b57 → 0.8.1-develop.7454.1.010095490f
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.
|
@@ -36,6 +36,10 @@ const deliverInputSchema = z.object({
|
|
|
36
36
|
forceCredentialRefresh: z.boolean().optional()
|
|
37
37
|
});
|
|
38
38
|
const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID = "communication_channels.message.deliver_outbound";
|
|
39
|
+
function omitRawMessage(metadata) {
|
|
40
|
+
if (!metadata) return {};
|
|
41
|
+
return Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "rawMessage"));
|
|
42
|
+
}
|
|
39
43
|
const deliverOutboundMessageCommand = {
|
|
40
44
|
id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,
|
|
41
45
|
async execute(rawInput, ctx) {
|
|
@@ -311,7 +315,7 @@ const deliverOutboundMessageCommand = {
|
|
|
311
315
|
link.externalMessageId = externalMessageRowId;
|
|
312
316
|
link.channelMetadata = {
|
|
313
317
|
...link.channelMetadata ?? {},
|
|
314
|
-
...converted.metadata
|
|
318
|
+
...omitRawMessage(converted.metadata),
|
|
315
319
|
externalMessageId: sendResult.externalMessageId,
|
|
316
320
|
// Always persist the RFC2822 Message-ID so inbound reply matching (JWZ
|
|
317
321
|
// strategy in lib/thread-matcher) and sent-folder dedup can resolve this
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/communication_channels/commands/deliver-outbound-message.ts"],
|
|
4
|
-
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (4c) Chat-provider reply threading \u2014 resolve the provider-native id of the\n // message this one answers so a threading adapter can attach it (Discord's\n // `message_reference`). Email adapters ignore this and keep threading on the\n // `inReplyTo` / `references` headers built above. Best-effort like the thread\n // token: an unresolvable parent sends an unthreaded reply rather than failing\n // a delivery.\n let replyToExternalId: string | null = null\n try {\n replyToExternalId = await resolveOutboundReplyExternalId(em, {\n parentMessageId: message.parentMessageId ?? null,\n externalConversationId: mapping.externalConversationId,\n capabilities: adapter.capabilities,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n })\n } catch (replyRefErr) {\n moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {\n err: replyRefErr,\n })\n }\n if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {\n // The provider threads and the caller asked for a reply, yet nothing\n // resolved \u2014 the parent never reached this channel, or it belongs to\n // another conversation. Delivery is unaffected, but say so: an unthreaded\n // reply is otherwise indistinguishable from a non-reply, which is exactly\n // the silent-unreachability shape #5541 was filed about.\n moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {\n messageId: message.id,\n parentMessageId: message.parentMessageId,\n conversationId: mapping.externalConversationId,\n })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n // Reply targeting is hub-resolved only. `send-as-user` merges\n // caller-supplied metadata onto the link, so every reply-targeting key\n // is stripped off the stored metadata here rather than merely\n // out-ranked below: merge order settles the contest only when the hub\n // resolved a parent, and the uncontested case \u2014 no `parentMessageId`,\n // or a parent that does not resolve \u2014 is exactly the one a caller\n // controls.\n ...stripCallerReplyTargeting(baseMetadata),\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n ...(replyToExternalId ? { replyToExternalId } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...(converted.metadata ?? {}),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n // #6095: same contract as the inbound path \u2014 the send time travels on\n // the event so consumers (the customers timeline) date their rows\n // from it instead of reading the ExternalMessage row across the\n // module boundary. ISO string because this event is persistent.\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: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kBAAkB;AAC3B,SAAS,SAAS;AAGlB,SAAS,uBAAuB;AAChC,SAAS,6BAA6B;AACtC,SAAS,sCAAsC;AAC/C,SAAS,kCAAkC;AAC3C,SAAS,uBAAuB,qBAAqB;AACrD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,mBAAmB,qBAAqB;AACjD,SAAS,gCAAgC,iCAAiC;AAE1E,SAAS,yBAAyB;AAClC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,oBAAoB;AAE7B,MAAM,eAAe,aAAa,wBAAwB,EAAE,MAAM,EAAE,WAAW,2BAA2B,CAAC;AAM3G,MAAM,2BAA2B,EAAE,QAAQ,kBAA2B;AAEtE,MAAM,qBAAqB,EAAE,OAAO;AAAA,EAClC,WAAW,EAAE,OAAO,EAAE,KAAK;AAAA,EAC3B,OAAO,EAAE,OAAO;AAAA,IACd,UAAU,EAAE,OAAO,EAAE,KAAK;AAAA,IAC1B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC7C,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,EAKD,wBAAwB,EAAE,QAAQ,EAAE,SAAS;AAC/C,CAAC;AA6BM,MAAM,qDACX;
|
|
4
|
+
"sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { emitCommunicationChannelsEvent } from '../events'\nimport { refreshCredentialsIfNeeded } from '../lib/credential-refresh'\nimport { classifyOutboundError, isReauthError } from '../lib/error-classification'\nimport {\n buildBodyFooter,\n buildReferencesId,\n getOrCreateThreadToken,\n} from '../lib/thread-token'\nimport { stringOrUndefined, stripBrackets } from '../lib/email-mime'\nimport { resolveOutboundReplyExternalId, stripCallerReplyTargeting } from '../lib/outbound-reply-ref'\nimport type { ChannelAdapterRegistry } from '../lib/registry'\nimport { isUniqueViolation } from '../lib/pg-errors'\nimport { Message } from '../../messages/data/entities'\nimport {\n ChannelThreadMapping,\n CommunicationChannel,\n ExternalMessage,\n MessageChannelLink,\n} from '../data/entities'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst moduleLogger = createLogger('communication_channels').child({ component: 'deliver-outbound-message' })\n\n/**\n * Sentinel \u2014 `Message.threadId` of an internal-only (no channel link) message\n * has no matching `ChannelThreadMapping`. In that case outbound delivery is a no-op.\n */\nconst NO_THREAD_MAPPING_RESULT = { status: 'no_channel_link' as const }\n\nconst deliverInputSchema = z.object({\n messageId: z.string().uuid(),\n scope: z.object({\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n }),\n /**\n * If true, force a credential refresh before sending \u2014 used by retry attempts\n * after a 401 from the provider.\n */\n forceCredentialRefresh: z.boolean().optional(),\n})\n\nexport type DeliverOutboundMessageInput = z.infer<typeof deliverInputSchema>\n\nexport type DeliverOutboundMessageResult =\n | { status: 'no_channel_link' }\n | { status: 'already_delivered'; messageId: string; channelLinkId: string }\n | {\n status: 'delivered'\n messageId: string\n channelLinkId: string\n externalMessageId: string\n providerKey: string\n }\n | {\n status: 'failed'\n messageId: string\n channelLinkId: string\n providerKey: string\n error: string\n transient: boolean\n /**\n * True when the failure was a 401 / invalid_grant \u2014 the channel was\n * flipped to `requires_reauth`. The worker uses this to attempt one\n * forced credential refresh before giving up.\n */\n requiresReauth: boolean\n }\n\nexport const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =\n 'communication_channels.message.deliver_outbound'\n\nfunction omitRawMessage(metadata: Record<string, unknown> | undefined): Record<string, unknown> {\n if (!metadata) return {}\n return Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== 'rawMessage'))\n}\n\ntype CredentialsServiceLike = {\n resolve: (\n integrationId: string,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<Record<string, unknown> | null>\n save?: (\n integrationId: string,\n credentials: Record<string, unknown>,\n scope: { organizationId: string; tenantId: string; userId?: string | null },\n ) => Promise<void>\n}\n\ntype IntegrationLogLike = {\n log?: (entry: Record<string, unknown>) => Promise<void> | void\n warn?: (entry: Record<string, unknown>) => Promise<void> | void\n error?: (entry: Record<string, unknown>) => Promise<void> | void\n}\n\n/**\n * Outbound delivery command. Called from the outbound worker.\n *\n * Steps (SPEC-045d \u00A77):\n * 1. Re-fetch the Message by ID. Bail if internal-only (no ChannelThreadMapping).\n * 2. Resolve channel + adapter + credentials.\n * 3. Idempotently upsert a 'pending' MessageChannelLink (unique on `messageId`).\n * Skip if a 'sent'/'delivered' link already exists.\n * 4. Refresh credentials when OAuth + near expiry (or when caller forces it).\n * 5. Call `adapter.convertOutbound(...)` \u2192 channel-native content.\n * 6. Call `adapter.sendMessage(...)`.\n * 7. On success: persist ExternalMessage + flip link to 'sent', emit `.message.sent`.\n * 8. On failure: flip link to 'failed' + classify error, log to integrationLog,\n * emit `.delivery_failed`. The worker decides whether to retry based on\n * `result.transient`.\n *\n * Idempotency: the unique constraint on `message_channel_links.message_id`\n * prevents the same Message being sent twice through the channel even if the\n * subscriber fires repeatedly. Combined with the link's lifecycle state\n * (pending \u2192 sent | failed), we get safe retries.\n */\nconst deliverOutboundMessageCommand: CommandHandler<\n DeliverOutboundMessageInput,\n DeliverOutboundMessageResult\n> = {\n id: COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID,\n async execute(rawInput, ctx) {\n const input = deliverInputSchema.parse(rawInput) as DeliverOutboundMessageInput\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const dscope = {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n }\n\n // (1) Re-fetch Message by ID \u2014 never trust the event payload shape.\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!message) {\n // Message was deleted before we got to deliver. Treat as no-op.\n return NO_THREAD_MAPPING_RESULT\n }\n if (!message.threadId) {\n // Message has no thread \u2192 no channel routing.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (1 cont.) Look up the channel link via ChannelThreadMapping.threadId.\n const mapping = await findOneWithDecryption(\n em,\n ChannelThreadMapping,\n {\n messageThreadId: message.threadId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (!mapping) {\n // Internal-only message \u2014 no channel delivery needed.\n return NO_THREAD_MAPPING_RESULT\n }\n\n // (2) Channel + adapter.\n const channel = await findOneWithDecryption(\n em,\n CommunicationChannel,\n {\n id: mapping.channelId,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n deletedAt: null,\n },\n undefined,\n dscope,\n )\n if (!channel) {\n throw new Error(\n `[internal] Channel ${mapping.channelId} not found for tenant ${input.scope.tenantId} (or has been deleted)`,\n )\n }\n if (!channel.isActive) {\n throw new Error(`[internal] Channel ${mapping.channelId} is inactive; refusing to deliver outbound`)\n }\n\n const adapterRegistry = ctx.container.resolve('channelAdapterRegistry') as ChannelAdapterRegistry\n const adapter = adapterRegistry.get(channel.providerKey)\n if (!adapter) {\n throw new Error(\n `[internal] No ChannelAdapter registered for providerKey '${channel.providerKey}'. ` +\n 'Check that the provider package is enabled in modules.ts.',\n )\n }\n\n // (3) Idempotently upsert a 'pending' MessageChannelLink.\n let link = await findOneWithDecryption(\n em,\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (\n link &&\n (link.deliveryStatus === 'queued' ||\n link.deliveryStatus === 'sent' ||\n link.deliveryStatus === 'delivered' ||\n link.deliveryStatus === 'read')\n ) {\n // Already sent (or accepted by the provider as 'queued') \u2014 short-circuit\n // so a retried job does not re-invoke the adapter and double-send.\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: link.id,\n }\n }\n if (!link) {\n link = em.create(MessageChannelLink, {\n messageId: message.id,\n externalConversationId: mapping.externalConversationId,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n deliveryStatus: 'pending',\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(link)\n try {\n await em.flush()\n } catch (flushErr) {\n // Concurrency guard: the link lookup above is not atomic with this\n // insert, so two deliveries of the same message (a replayed\n // `messages.message.sent`, or an overlapping worker retry) can both\n // reach here. The `message_channel_links_message_uq` index rejects the\n // loser with a 23505. Defer to the winning job \u2014 re-read its link on a\n // fresh fork and report `already_delivered` \u2014 instead of re-invoking the\n // adapter (double send) or letting the raw error dead-letter the job.\n if (isUniqueViolation(flushErr)) {\n const winner = await findOneWithDecryption(\n em.fork(),\n MessageChannelLink,\n {\n messageId: message.id,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n undefined,\n dscope,\n )\n if (winner) {\n return {\n status: 'already_delivered',\n messageId: message.id,\n channelLinkId: winner.id,\n }\n }\n }\n throw flushErr\n }\n }\n\n // (2 cont.) Decrypted credentials via the integrations module (if available).\n let credentialsService: CredentialsServiceLike | null = null\n try {\n credentialsService = ctx.container.resolve(\n 'integrationCredentialsService',\n ) as CredentialsServiceLike\n } catch {\n credentialsService = null\n }\n // Per-user credentials scope: pass `channel.userId` so the credentials\n // service returns this user's row, not whoever connected last. See\n // review R2-C1 / N1 (2026-05-26).\n //\n // Key the org on the CHANNEL's own org, not the message's org. Tenant-wide\n // channels store `organization_id = NULL` and their credentials live at\n // `organization_id = tenantId` (see connect-credential-channel.ts), so\n // `channel.organizationId ?? tenantId` matches the write key for both\n // org-scoped and tenant-wide channels. Keying on the message org would\n // resolve `{}` the moment a tenant-wide provider delivers from a message\n // that carries a non-null org.\n const credentialsScope = {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n userId: channel.userId ?? null,\n }\n let credentials: Record<string, unknown> = {}\n if (channel.credentialsRef && credentialsService) {\n try {\n credentials =\n (await credentialsService.resolve(`channel_${channel.providerKey}`, credentialsScope)) ?? {}\n } catch {\n credentials = {}\n }\n }\n\n // (4) Credential refresh if OAuth + near expiry, or forced by retry.\n let integrationLog: IntegrationLogLike | null = null\n try {\n integrationLog = ctx.container.resolve('integrationLogService') as IntegrationLogLike\n } catch {\n integrationLog = null\n }\n const refreshResult = await refreshCredentialsIfNeeded(\n {\n adapter,\n channelId: channel.id,\n credentials,\n scope: credentialsScope,\n force: Boolean(input.forceCredentialRefresh),\n },\n {\n credentialsService,\n logger: (...args) => moduleLogger.warn('credential refresh diagnostic', { details: args }),\n },\n )\n credentials = refreshResult.credentials\n\n // (4b) Spec B \u2014 get-or-create the per-thread crypto token and inject it\n // into the outbound payload BEFORE the adapter converts it. The token\n // travels as both a `References` header (via channelMetadata.references)\n // and a hidden body marker so inbound replies can be threaded back to\n // this conversation even when the recipient's MUA strips RFC 5322\n // headers. Idempotent on retry \u2014 same token reused per thread.\n let threadToken: string | null = null\n try {\n const { token } = await getOrCreateThreadToken(em, {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n messageThreadId: mapping.messageThreadId,\n })\n threadToken = token\n } catch (tokenErr) {\n // Token creation should never block a send \u2014 if it fails, the message\n // still goes out, just without our thread-token attachment point.\n // Threading falls back to the JWZ strategy via Message-Id headers.\n moduleLogger.warn('thread token unavailable, proceeding without it', { err: tokenErr })\n }\n\n // (4c) Chat-provider reply threading \u2014 resolve the provider-native id of the\n // message this one answers so a threading adapter can attach it (Discord's\n // `message_reference`). Email adapters ignore this and keep threading on the\n // `inReplyTo` / `references` headers built above. Best-effort like the thread\n // token: an unresolvable parent sends an unthreaded reply rather than failing\n // a delivery.\n let replyToExternalId: string | null = null\n try {\n replyToExternalId = await resolveOutboundReplyExternalId(em, {\n parentMessageId: message.parentMessageId ?? null,\n externalConversationId: mapping.externalConversationId,\n capabilities: adapter.capabilities,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n })\n } catch (replyRefErr) {\n moduleLogger.warn('outbound reply reference unavailable, sending unthreaded', {\n err: replyRefErr,\n })\n }\n if (!replyToExternalId && message.parentMessageId && adapter.capabilities?.threading === true) {\n // The provider threads and the caller asked for a reply, yet nothing\n // resolved \u2014 the parent never reached this channel, or it belongs to\n // another conversation. Delivery is unaffected, but say so: an unthreaded\n // reply is otherwise indistinguishable from a non-reply, which is exactly\n // the silent-unreachability shape #5541 was filed about.\n moduleLogger.debug('reply parent has no external id on this channel, sending unthreaded', {\n messageId: message.id,\n parentMessageId: message.parentMessageId,\n conversationId: mapping.externalConversationId,\n })\n }\n\n // (5) + (6) Convert + send.\n try {\n const outboundPayload = (link.channelPayload as Record<string, unknown> | null) ?? {}\n const outboundHtml = typeof outboundPayload.html === 'string' ? outboundPayload.html : null\n const outboundText = typeof outboundPayload.text === 'string' ? outboundPayload.text : null\n let outboundBody = outboundHtml ?? outboundText ?? message.body ?? ''\n const outboundBodyFormat = outboundHtml\n ? 'html'\n : ((message.bodyFormat as 'text' | 'markdown' | 'html') ?? 'text')\n\n // Pre-existing channelMetadata.references (string[]) so we can extend\n // it with the synthetic thread-token id without disturbing other refs\n // (e.g. the recipient's own reply chain).\n const baseMetadata = (link.channelMetadata as Record<string, unknown> | undefined) ?? {}\n const existingRefs = Array.isArray(baseMetadata.references)\n ? (baseMetadata.references as unknown[]).filter(\n (value): value is string => typeof value === 'string',\n )\n : []\n let mergedReferences = existingRefs\n if (threadToken && !outboundBody.includes(`[OM:${threadToken}]`)) {\n // Append the body footer for the corresponding format. The hidden\n // HTML span is `display:none`; the plain-text trailer is a small\n // bracketed marker on its own line. Both survive most reply clients.\n const footer = buildBodyFooter(threadToken)\n if (outboundBodyFormat === 'html') {\n const closingBody = outboundBody.lastIndexOf('</body>')\n outboundBody =\n closingBody >= 0\n ? `${outboundBody.slice(0, closingBody)}${footer.html}${outboundBody.slice(closingBody)}`\n : `${outboundBody}${footer.html}`\n } else {\n outboundBody = `${outboundBody}${footer.plain}`\n }\n const refId = buildReferencesId(threadToken)\n if (!mergedReferences.includes(refId)) {\n mergedReferences = [...mergedReferences, refId]\n }\n }\n const converted = await adapter.convertOutbound({\n body: outboundBody,\n bodyFormat: outboundBodyFormat,\n channelMetadata: {\n thread_id: mapping.externalThreadRef,\n // Reply targeting is hub-resolved only. `send-as-user` merges\n // caller-supplied metadata onto the link, so every reply-targeting key\n // is stripped off the stored metadata here rather than merely\n // out-ranked below: merge order settles the contest only when the hub\n // resolved a parent, and the uncontested case \u2014 no `parentMessageId`,\n // or a parent that does not resolve \u2014 is exactly the one a caller\n // controls.\n ...stripCallerReplyTargeting(baseMetadata),\n references: mergedReferences,\n ...(threadToken ? { omThreadToken: threadToken } : {}),\n ...(replyToExternalId ? { replyToExternalId } : {}),\n },\n })\n\n // NOTE \u2014 at-least-once delivery (accepted v1 semantics). This provider\n // send is a non-transactional external side effect. The terminal-status\n // short-circuit above and the `message_channel_links_message_uq` index\n // prevent duplicate link records and re-sends after a *completed*\n // delivery, but if the process crashes in the narrow window between this\n // call returning and the success flush below, the link stays `pending`\n // and a worker retry re-invokes the adapter \u2014 the recipient may receive a\n // duplicate. This is deliberate: email providers (Gmail/SMTP) expose no\n // idempotent-send key nor a reliable \"did message X send?\" query, so\n // re-sending is preferred over risking a dropped message.\n const sendResult = await adapter.sendMessage({\n conversationId: mapping.externalThreadRef,\n content: converted.content,\n credentials,\n scope: {\n tenantId: input.scope.tenantId,\n organizationId: channel.organizationId ?? input.scope.tenantId,\n },\n metadata: converted.metadata,\n })\n\n if (sendResult.status === 'failed') {\n throw new Error(sendResult.error ?? `Adapter '${adapter.providerKey}' reported send failure`)\n }\n\n // (7) Persist success records.\n //\n // Pre-generate the ExternalMessage PK client-side. The PK uses\n // `defaultRaw: 'gen_random_uuid()'`, so `externalMessage.id` is undefined\n // until after the INSERT returns \u2014 writing `link.externalMessageId =\n // externalMessage.id` before the flush would silently persist NULL on the\n // link's FK. Mirrors the inbound ingest path (ingest-inbound-message.ts).\n const externalMessageRowId = randomUUID()\n const externalMessage = em.create(ExternalMessage, {\n id: externalMessageRowId,\n channelId: channel.id,\n conversationId: mapping.externalConversationId,\n externalMessageId: sendResult.externalMessageId,\n direction: 'outbound',\n senderIdentifier: null,\n senderDisplayName: null,\n providerTimestamp: new Date(),\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n })\n em.persist(externalMessage)\n\n // A successful send proves the credentials are valid \u2014 clear any prior\n // `requires_reauth` / `error` state so a recovered channel doesn't keep\n // showing a stale reconnect banner (e.g. after a forced-refresh retry).\n if (channel.status === 'requires_reauth' || channel.status === 'error') {\n channel.status = 'connected'\n }\n\n link.deliveryStatus = sendResult.status === 'sent' ? 'sent' : 'queued'\n link.externalMessageId = externalMessageRowId\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n ...omitRawMessage(converted.metadata),\n externalMessageId: sendResult.externalMessageId,\n // Always persist the RFC2822 Message-ID so inbound reply matching (JWZ\n // strategy in lib/thread-matcher) and sent-folder dedup can resolve this\n // outbound message. Adapters that build the message themselves (Gmail)\n // return it in `converted.metadata.messageId`; IMAP/SMTP lets\n // the transport mint it, surfacing it only as `sendResult.externalMessageId`\n // (the RFC2822 id the recipient replies to) \u2014 fall back to that.\n // Store it bracket-stripped to match the inbound convention\n // (`normalizeMimeInbound` strips), so the JWZ matcher and sent-folder dedup \u2014\n // which compare against stripped ids \u2014 resolve it. `assembleRfc2822`\n // re-applies brackets when this id is later used to build reply headers.\n messageId: stripBrackets(\n stringOrUndefined((converted.metadata as Record<string, unknown> | undefined)?.messageId) ??\n sendResult.externalMessageId,\n ),\n }\n await em.flush()\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.sent',\n {\n messageId: message.id,\n externalMessageId: externalMessage.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n direction: 'outbound',\n // #6095: same contract as the inbound path \u2014 the send time travels on\n // the event so consumers (the customers timeline) date their rows\n // from it instead of reading the ExternalMessage row across the\n // module boundary. ISO string because this event is persistent.\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: 'delivered',\n messageId: message.id,\n channelLinkId: link.id,\n externalMessageId: externalMessage.id,\n providerKey: channel.providerKey,\n }\n } catch (sendErr) {\n // (8) Failure path \u2014 classify, persist, emit, return.\n const classification = classifyOutboundError(sendErr)\n const requiresReauth = isReauthError(classification)\n link.deliveryStatus = 'failed'\n link.channelMetadata = {\n ...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),\n lastError: classification.message,\n lastErrorAt: new Date().toISOString(),\n transient: classification.transient,\n requiresReauth,\n }\n // A 401 / invalid_grant means the stored credentials are dead and no\n // retry will help \u2014 flip the channel to `requires_reauth` (mirrors the\n // inbound poll path) so the operator gets a reconnect signal instead of\n // silently-failing sends. A later successful send self-heals back to\n // `connected` (see the success path above).\n if (requiresReauth) {\n channel.status = 'requires_reauth'\n }\n await em.flush()\n\n try {\n await integrationLog?.error?.({\n integrationId: `channel_${channel.providerKey}`,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n channelId: channel.id,\n messageId: message.id,\n status: classification.status ?? null,\n transient: classification.transient,\n message: classification.message,\n })\n } catch {\n // best-effort logging\n }\n\n if (requiresReauth) {\n await emitCommunicationChannelsEvent(\n 'communication_channels.channel.requires_reauth',\n {\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n reason: classification.message,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n }\n\n await emitCommunicationChannelsEvent(\n 'communication_channels.message.delivery_failed',\n {\n messageId: message.id,\n channelLinkId: link.id,\n conversationId: mapping.externalConversationId,\n channelId: channel.id,\n providerKey: channel.providerKey,\n channelType: channel.channelType,\n transient: classification.transient,\n error: classification.message,\n status: classification.status ?? null,\n tenantId: input.scope.tenantId,\n organizationId: input.scope.organizationId ?? null,\n },\n { persistent: true },\n )\n\n return {\n status: 'failed',\n messageId: message.id,\n channelLinkId: link.id,\n providerKey: channel.providerKey,\n error: classification.message,\n transient: classification.transient,\n requiresReauth,\n }\n }\n },\n}\n\nregisterCommand(deliverOutboundMessageCommand)\n\nexport default deliverOutboundMessageCommand\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.8.1-develop.
|
|
3
|
+
"version": "0.8.1-develop.7454.1.010095490f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -264,16 +264,16 @@
|
|
|
264
264
|
"zod": "^4.4.3"
|
|
265
265
|
},
|
|
266
266
|
"peerDependencies": {
|
|
267
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
268
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
269
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
267
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7454.1.010095490f",
|
|
268
|
+
"@open-mercato/shared": "0.8.1-develop.7454.1.010095490f",
|
|
269
|
+
"@open-mercato/ui": "0.8.1-develop.7454.1.010095490f",
|
|
270
270
|
"react": "^19.0.0",
|
|
271
271
|
"react-dom": "^19.0.0"
|
|
272
272
|
},
|
|
273
273
|
"devDependencies": {
|
|
274
|
-
"@open-mercato/ai-assistant": "0.8.1-develop.
|
|
275
|
-
"@open-mercato/shared": "0.8.1-develop.
|
|
276
|
-
"@open-mercato/ui": "0.8.1-develop.
|
|
274
|
+
"@open-mercato/ai-assistant": "0.8.1-develop.7454.1.010095490f",
|
|
275
|
+
"@open-mercato/shared": "0.8.1-develop.7454.1.010095490f",
|
|
276
|
+
"@open-mercato/ui": "0.8.1-develop.7454.1.010095490f",
|
|
277
277
|
"@testing-library/dom": "^10.4.1",
|
|
278
278
|
"@testing-library/jest-dom": "^7.0.1",
|
|
279
279
|
"@testing-library/react": "^16.3.3",
|
|
@@ -76,6 +76,11 @@ export type DeliverOutboundMessageResult =
|
|
|
76
76
|
export const COMMUNICATION_CHANNELS_DELIVER_OUTBOUND_COMMAND_ID =
|
|
77
77
|
'communication_channels.message.deliver_outbound'
|
|
78
78
|
|
|
79
|
+
function omitRawMessage(metadata: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
80
|
+
if (!metadata) return {}
|
|
81
|
+
return Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== 'rawMessage'))
|
|
82
|
+
}
|
|
83
|
+
|
|
79
84
|
type CredentialsServiceLike = {
|
|
80
85
|
resolve: (
|
|
81
86
|
integrationId: string,
|
|
@@ -500,7 +505,7 @@ const deliverOutboundMessageCommand: CommandHandler<
|
|
|
500
505
|
link.externalMessageId = externalMessageRowId
|
|
501
506
|
link.channelMetadata = {
|
|
502
507
|
...((link.channelMetadata as Record<string, unknown> | undefined) ?? {}),
|
|
503
|
-
...(converted.metadata
|
|
508
|
+
...omitRawMessage(converted.metadata),
|
|
504
509
|
externalMessageId: sendResult.externalMessageId,
|
|
505
510
|
// Always persist the RFC2822 Message-ID so inbound reply matching (JWZ
|
|
506
511
|
// strategy in lib/thread-matcher) and sent-folder dedup can resolve this
|