@open-mercato/core 0.8.1-develop.7261.1.5d42353f61 → 0.8.1-develop.7266.1.8e520bbe03

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -0
  2. package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
  3. package/dist/modules/communication_channels/commands/ingest-inbound-message.js +13 -0
  4. package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
  5. package/dist/modules/communication_channels/lib/email-mime.js +19 -1
  6. package/dist/modules/communication_channels/lib/email-mime.js.map +2 -2
  7. package/dist/modules/customers/lib/link-channel-message-handler.js +17 -7
  8. package/dist/modules/customers/lib/link-channel-message-handler.js.map +3 -3
  9. package/dist/modules/messages/api/route.js +1 -0
  10. package/dist/modules/messages/api/route.js.map +2 -2
  11. package/dist/modules/messages/commands/messages.js +3 -1
  12. package/dist/modules/messages/commands/messages.js.map +2 -2
  13. package/dist/modules/messages/data/validators.js +10 -2
  14. package/dist/modules/messages/data/validators.js.map +2 -2
  15. package/package.json +7 -7
  16. package/src/modules/communication_channels/commands/deliver-outbound-message.ts +8 -0
  17. package/src/modules/communication_channels/commands/ingest-inbound-message.ts +16 -0
  18. package/src/modules/communication_channels/lib/email-mime.ts +52 -2
  19. package/src/modules/customers/lib/link-channel-message-handler.ts +48 -5
  20. package/src/modules/messages/api/route.ts +6 -3
  21. package/src/modules/messages/commands/messages.ts +3 -1
  22. package/src/modules/messages/data/validators.ts +21 -12
@@ -414,7 +414,19 @@ export interface NormalizeMimeInboundOptions {
414
414
  fallbackMessageId: string
415
415
  /** Compute the conversation grouping id from the resolved message id + references. */
416
416
  resolveConversationId: (context: { messageId: string; references: string[] }) => string
417
- /** Fallback timestamp when the parsed message has no Date header. */
417
+ /**
418
+ * When the provider received the message (Gmail `internalDate`, IMAP
419
+ * `INTERNALDATE`). Preferred over the MIME `Date` header for the message
420
+ * timestamp: the header is written by the sender's client and can be forged
421
+ * or skewed, and since #6095 the timestamp also dates the platform message
422
+ * and the CRM interaction, so a bad header would pin a message in the future
423
+ * or hide it in the past.
424
+ */
425
+ receivedAt?: Date
426
+ /**
427
+ * Fallback timestamp when neither `receivedAt` nor a usable Date header is
428
+ * available.
429
+ */
418
430
  fallbackDate?: Date
419
431
  /** Provider-specific fields merged into `channelMetadata`. */
420
432
  channelMetadata?: (parsed: ParsedMail) => Record<string, unknown>
@@ -422,6 +434,40 @@ export interface NormalizeMimeInboundOptions {
422
434
  channelPayload?: (parsed: ParsedMail) => Record<string, unknown>
423
435
  }
424
436
 
437
+ /**
438
+ * A MIME `Date` header this far ahead of the clock is treated as bogus. Clock
439
+ * skew between real mail servers is seconds to minutes; a day of tolerance
440
+ * keeps legitimate mail while stopping a forged header from pinning a message
441
+ * to the top of a `sent_at`-sorted inbox for weeks.
442
+ */
443
+ const MAX_MIME_DATE_FUTURE_SKEW_MS = 24 * 60 * 60 * 1000
444
+
445
+ function isValidDate(value: unknown): value is Date {
446
+ return value instanceof Date && !Number.isNaN(value.getTime())
447
+ }
448
+
449
+ /**
450
+ * Pick the timestamp for an inbound message.
451
+ *
452
+ * Order: the provider's receipt time; then the sender's MIME `Date` header,
453
+ * unless it is unparsable or more than a day in the future; then the caller's
454
+ * fallback; then now. Exported for tests.
455
+ */
456
+ export function resolveInboundTimestamp(input: {
457
+ receivedAt?: Date
458
+ mimeDate?: Date
459
+ fallbackDate?: Date
460
+ now?: Date
461
+ }): Date {
462
+ if (isValidDate(input.receivedAt)) return input.receivedAt
463
+ const now = input.now ?? new Date()
464
+ if (isValidDate(input.mimeDate) && input.mimeDate.getTime() <= now.getTime() + MAX_MIME_DATE_FUTURE_SKEW_MS) {
465
+ return input.mimeDate
466
+ }
467
+ if (isValidDate(input.fallbackDate)) return input.fallbackDate
468
+ return now
469
+ }
470
+
425
471
  /**
426
472
  * Build the hub's canonical `NormalizedInboundMessage` from a parsed MIME
427
473
  * message. Providers supply the bits that genuinely differ (message-id
@@ -474,7 +520,11 @@ export function normalizeMimeInbound(options: NormalizeMimeInboundOptions): Norm
474
520
  body,
475
521
  bodyFormat,
476
522
  attachments,
477
- timestamp: parsed.date ? new Date(parsed.date) : options.fallbackDate ?? new Date(),
523
+ timestamp: resolveInboundTimestamp({
524
+ receivedAt: options.receivedAt,
525
+ mimeDate: parsed.date ? new Date(parsed.date) : undefined,
526
+ fallbackDate: options.fallbackDate,
527
+ }),
478
528
  replyToExternalId: inReplyTo ?? undefined,
479
529
  channelPayload,
480
530
  channelContentType: 'email/mime',
@@ -34,6 +34,13 @@ type LinkChannelMessagePayload = {
34
34
  organizationId?: string | null
35
35
  providerKey?: string | null
36
36
  direction?: 'inbound' | 'outbound' | null
37
+ /**
38
+ * The provider's own receive/send time, carried by the hub on the event
39
+ * (#6095). An ISO string when the event rode the queue, a Date for an
40
+ * in-process emit. Absent on events enqueued before the field existed and on
41
+ * adapters that supply no timestamp.
42
+ */
43
+ providerTimestamp?: string | Date | null
37
44
  }
38
45
 
39
46
  type SubscriberContext = {
@@ -88,6 +95,17 @@ export default async function handler(
88
95
  const metaJson = (link.channelMetadata ?? null) as Record<string, unknown> | null
89
96
  const payloadJson = (link.channelPayload ?? null) as Record<string, unknown> | null
90
97
 
98
+ // ── (1b) Resolve when the email actually happened ─────────────────────
99
+ //
100
+ // `link.createdAt` is when the hub ingested the message, which for a history
101
+ // import is the import minute, not the day the mail arrived (#6095). The hub
102
+ // carries the provider's own timestamp on the event payload, so take it from
103
+ // there: reading the communication_channels ExternalMessage row here would
104
+ // cross the storage boundary (Cross-Module Coupling in AGENTS.md) and add a
105
+ // peer-table query per event. Resolved once and threaded through every branch
106
+ // below so the address-match and threading-inheritance paths agree.
107
+ const occurredAt = resolveOccurredAt(payload, link)
108
+
91
109
  // ── (2) Resolve the channel to get its owner userId ───────────────────
92
110
  //
93
111
  // The channel.userId is needed for two purposes:
@@ -172,7 +190,7 @@ export default async function handler(
172
190
  // Early exit: no addresses AND no hint → nothing to link.
173
191
  if (normalized.length === 0 && !crmPersonId) {
174
192
  // Before giving up, try threading-inheritance (TC-CRM-EMAIL-005).
175
- await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson)
193
+ await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson, occurredAt)
176
194
  return
177
195
  }
178
196
 
@@ -183,7 +201,7 @@ export default async function handler(
183
201
 
184
202
  if (personIdSet.size === 0) {
185
203
  // Try threading-inheritance before giving up.
186
- await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson)
204
+ await handleThreadingInheritance(em, link, linkId, tenantId, organizationId, channelUserId, metaJson, payloadJson, occurredAt)
187
205
  return
188
206
  }
189
207
 
@@ -211,7 +229,6 @@ export default async function handler(
211
229
  ? (payloadJson!.text as string)
212
230
  : null
213
231
 
214
- const occurredAt = link.createdAt instanceof Date ? link.createdAt : new Date()
215
232
  const providerKey =
216
233
  typeof link.providerKey === 'string' ? (link.providerKey as string) : null
217
234
 
@@ -257,6 +274,7 @@ async function handleThreadingInheritance(
257
274
  channelUserId: string | null,
258
275
  metaJson: Record<string, unknown> | null,
259
276
  payloadJson: Record<string, unknown> | null,
277
+ occurredAt: Date,
260
278
  ): Promise<void> {
261
279
  // ── Primary: inherit Person(s) from the hub's authoritative thread ──────
262
280
  //
@@ -325,7 +343,6 @@ async function handleThreadingInheritance(
325
343
  : typeof metaJson?.bodyText === 'string'
326
344
  ? (metaJson.bodyText as string)
327
345
  : null
328
- const occurredAt = _link.createdAt instanceof Date ? (_link.createdAt as Date) : new Date()
329
346
  const providerKey = typeof _link.providerKey === 'string' ? (_link.providerKey as string) : null
330
347
  await persistInteractions(em, threadPersonIds, {
331
348
  linkId,
@@ -420,7 +437,6 @@ async function handleThreadingInheritance(
420
437
  const inheritedMeta = (link.channelMetadata ?? null) as Record<string, unknown> | null
421
438
  const subject =
422
439
  typeof inheritedMeta?.subject === 'string' ? (inheritedMeta.subject as string) : null
423
- const occurredAt = link.createdAt instanceof Date ? (link.createdAt as Date) : new Date()
424
440
  const providerKey =
425
441
  typeof link.providerKey === 'string' ? (link.providerKey as string) : null
426
442
 
@@ -444,6 +460,33 @@ async function handleThreadingInheritance(
444
460
 
445
461
  // ── Helpers ───────────────────────────────────────────────────────────────
446
462
 
463
+ /**
464
+ * When the linked email happened, for `CustomerInteraction.occurredAt` (#6095).
465
+ *
466
+ * Preference order:
467
+ * 1. `payload.providerTimestamp` — the provider's own receive/send time, put
468
+ * on the hub event by ingest / delivery;
469
+ * 2. `MessageChannelLink.createdAt` — the ingest time, the only date this
470
+ * handler knew before #6095, and the compatibility path for events that
471
+ * were enqueued before the field existed;
472
+ * 3. now, for a link row with no usable `createdAt` (test stubs).
473
+ */
474
+ function resolveOccurredAt(
475
+ payload: LinkChannelMessagePayload,
476
+ link: Record<string, unknown>,
477
+ ): Date {
478
+ const ingestedAt = link.createdAt instanceof Date ? link.createdAt : new Date()
479
+ const carried = payload.providerTimestamp
480
+ if (carried instanceof Date) {
481
+ return Number.isNaN(carried.getTime()) ? ingestedAt : carried
482
+ }
483
+ if (typeof carried === 'string' && carried) {
484
+ const parsed = new Date(carried)
485
+ if (!Number.isNaN(parsed.getTime())) return parsed
486
+ }
487
+ return ingestedAt
488
+ }
489
+
447
490
  interface InteractionData {
448
491
  linkId: string
449
492
  tenantId: string
@@ -470,12 +470,15 @@ export async function POST(req: Request) {
470
470
  sourceHint.data,
471
471
  )
472
472
  : undefined
473
- // `inboundFromChannel` is likewise server-only (#6093): channel ingest sets
474
- // it so an inbound message can be addressed to the conversation's assignee,
475
- // and a request body must not be able to waive the recipients rule with it.
473
+ // `inboundFromChannel` and `sentAt` are likewise server-only (#6093, #6095):
474
+ // channel ingest sets them so an inbound message can be addressed to the
475
+ // conversation's assignee and stamped with the provider's receive time, but
476
+ // a request body must not be able to waive the recipients rule or backdate
477
+ // a message with them.
476
478
  const {
477
479
  sourceChannelType: _clientSuppliedChannelType,
478
480
  inboundFromChannel: _clientSuppliedInboundFlag,
481
+ sentAt: _clientSuppliedSentAt,
479
482
  ...clientBody
480
483
  } = (body ?? {}) as Record<string, unknown>
481
484
  const input = composeMessageSchema.parse({
@@ -333,7 +333,9 @@ const composeMessageCommand: CommandHandler<unknown, { id: string; threadId: str
333
333
  priority: input.priority,
334
334
  status: input.isDraft ? 'draft' : 'sent',
335
335
  isDraft: input.isDraft ?? false,
336
- sentAt: input.isDraft ? null : new Date(),
336
+ // #6095: channel ingest passes the provider's timestamp so an imported
337
+ // mailbox sorts by when the mail was received, not when it was imported.
338
+ sentAt: input.isDraft ? null : input.sentAt ?? new Date(),
337
339
  actionData: input.actionData as MessageActionData | undefined,
338
340
  sendViaEmail,
339
341
  idempotencyKey: input.idempotencyKey ?? null,
@@ -138,12 +138,20 @@ const composeMessageBaseSchema = z.object({
138
138
  sendViaEmail: z.boolean().optional().default(false),
139
139
  parentMessageId: z.string().uuid().optional(),
140
140
  isDraft: z.boolean().optional().default(false),
141
+ /**
142
+ * When the message was actually sent, if the caller knows better than "now"
143
+ * (#6095). Channel ingest sets it to the provider's timestamp so an imported
144
+ * mailbox keeps its real chronology instead of collapsing onto the import
145
+ * minute. Server-only: the HTTP route strips any client-sent value, so a
146
+ * browser caller cannot backdate a message. Ignored for drafts.
147
+ */
148
+ sentAt: z.coerce.date().optional(),
141
149
  })
142
150
 
143
151
  type ComposeMessageRefinementValue = Omit<
144
152
  z.infer<typeof composeMessageBaseSchema>,
145
- 'sourceChannelType'
146
- > & { sourceChannelType?: string }
153
+ 'sourceChannelType' | 'sentAt'
154
+ > & { sourceChannelType?: string; sentAt?: Date }
147
155
 
148
156
  function refineComposeMessage(value: ComposeMessageRefinementValue, ctx: z.RefinementCtx): void {
149
157
  const isDraft = value.isDraft ?? false
@@ -213,23 +221,24 @@ function refineComposeMessage(value: ComposeMessageRefinementValue, ctx: z.Refin
213
221
  }
214
222
 
215
223
  /**
216
- * Full compose contract, including the server-resolved `sourceChannelType` and
217
- * the ingest-only `inboundFromChannel`. Used by the `messages.messages.compose`
218
- * command and by the HTTP route AFTER it has resolved the channel type itself.
224
+ * Full compose contract, including the server-resolved `sourceChannelType`,
225
+ * the ingest-only `inboundFromChannel`, and the server-supplied `sentAt`. Used
226
+ * by the `messages.messages.compose` command and by the HTTP route AFTER it
227
+ * has resolved the channel type itself.
219
228
  */
220
229
  export const composeMessageSchema = composeMessageBaseSchema.superRefine(refineComposeMessage)
221
230
 
222
231
  /**
223
232
  * Client-facing compose contract — the same rules minus the server-only fields:
224
- * `sourceChannelType` is never accepted from a request body (#4975) and neither
225
- * is `inboundFromChannel` (#6093). Published in OpenAPI so the documented
226
- * request shape matches what `POST /api/messages` actually reads: the route
227
- * discards any client-sent value for these, resolves the real channel type from
228
- * the referenced conversation or parent message, and only then validates
229
- * against {@link composeMessageSchema}.
233
+ * `sourceChannelType` is never accepted from a request body (#4975), neither is
234
+ * `inboundFromChannel` (#6093) nor `sentAt` (#6095). Published in OpenAPI so the
235
+ * documented request shape matches what `POST /api/messages` actually reads:
236
+ * the route discards any client-sent value for these, resolves the real
237
+ * channel type from the referenced conversation or parent message, and only
238
+ * then validates against {@link composeMessageSchema}.
230
239
  */
231
240
  export const composeMessageRequestSchema = composeMessageBaseSchema
232
- .omit({ sourceChannelType: true, inboundFromChannel: true })
241
+ .omit({ sourceChannelType: true, inboundFromChannel: true, sentAt: true })
233
242
  .superRefine(refineComposeMessage)
234
243
 
235
244
  export const updateDraftSchema = z.object({