@open-mercato/core 0.8.1-develop.7263.1.bbc6db3437 → 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.
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -0
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +13 -0
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/lib/email-mime.js +19 -1
- package/dist/modules/communication_channels/lib/email-mime.js.map +2 -2
- package/dist/modules/customers/lib/link-channel-message-handler.js +17 -7
- package/dist/modules/customers/lib/link-channel-message-handler.js.map +3 -3
- package/dist/modules/messages/api/route.js +1 -0
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +3 -1
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/data/validators.js +10 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/communication_channels/commands/deliver-outbound-message.ts +8 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +16 -0
- package/src/modules/communication_channels/lib/email-mime.ts +52 -2
- package/src/modules/customers/lib/link-channel-message-handler.ts +48 -5
- package/src/modules/messages/api/route.ts +6 -3
- package/src/modules/messages/commands/messages.ts +3 -1
- 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
|
-
/**
|
|
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:
|
|
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`
|
|
474
|
-
//
|
|
475
|
-
//
|
|
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
|
-
|
|
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
|
|
217
|
-
* the ingest-only `inboundFromChannel
|
|
218
|
-
* command and by the HTTP route AFTER it
|
|
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)
|
|
225
|
-
*
|
|
226
|
-
* request shape matches what `POST /api/messages` actually reads:
|
|
227
|
-
* discards any client-sent value for these, resolves the real
|
|
228
|
-
* the referenced conversation or parent message, and only
|
|
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({
|