@remit/backend 0.0.9 → 0.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -86,7 +86,7 @@ export const AddressOperations: Record<
86
86
 
87
87
  const result = await client.address.listByAccountConfig({
88
88
  accountConfigId,
89
- normalizedCompound: q.toLowerCase(),
89
+ search: q.toLowerCase(),
90
90
  limit: limit ?? 10,
91
91
  });
92
92
 
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ buildListThreadMessagesOptions,
5
+ dedupeThreadMessages,
6
+ } from "./thread.js";
7
+
8
+ type Row = {
9
+ threadMessageId: string;
10
+ messageIdHeader?: string;
11
+ createdAt: number;
12
+ };
13
+
14
+ const row = (
15
+ threadMessageId: string,
16
+ messageIdHeader: string | undefined,
17
+ createdAt: number,
18
+ ): Row => ({ threadMessageId, messageIdHeader, createdAt });
19
+
20
+ describe("buildListThreadMessagesOptions", () => {
21
+ it("defaults to newest-first and hides soft-deleted messages", () => {
22
+ assert.deepEqual(buildListThreadMessagesOptions({}), {
23
+ order: "desc",
24
+ excludeDeleted: true,
25
+ });
26
+ });
27
+
28
+ it("honours an explicit order", () => {
29
+ assert.equal(buildListThreadMessagesOptions({ order: "asc" }).order, "asc");
30
+ });
31
+
32
+ it("carries no mailbox filter, so sent messages stay in the conversation", () => {
33
+ assert.ok(!("mailboxId" in buildListThreadMessagesOptions({})));
34
+ });
35
+ });
36
+
37
+ describe("dedupeThreadMessages", () => {
38
+ it("keeps a thread whose messages are all distinct", () => {
39
+ const rows = [
40
+ row("tm-1", "<a@example.test>", 10),
41
+ row("tm-2", "<b@example.test>", 20),
42
+ ];
43
+ assert.deepEqual(dedupeThreadMessages(rows), rows);
44
+ });
45
+
46
+ it("collapses a copied message to the original", () => {
47
+ const original = row("tm-1", "<a@example.test>", 10);
48
+ const copy = row("tm-2", "<a@example.test>", 50);
49
+ assert.deepEqual(dedupeThreadMessages([original, copy]), [original]);
50
+ });
51
+
52
+ it("keeps the same row whichever order the rows arrive in", () => {
53
+ const original = row("tm-1", "<a@example.test>", 10);
54
+ const copy = row("tm-2", "<a@example.test>", 50);
55
+ assert.deepEqual(dedupeThreadMessages([copy, original]), [original]);
56
+ });
57
+
58
+ it("breaks a createdAt tie on threadMessageId", () => {
59
+ const first = row("tm-a", "<a@example.test>", 10);
60
+ const second = row("tm-b", "<a@example.test>", 10);
61
+ assert.deepEqual(dedupeThreadMessages([second, first]), [first]);
62
+ });
63
+
64
+ it("treats headers that differ only by surrounding space as one message", () => {
65
+ const original = row("tm-1", "<a@example.test>", 10);
66
+ const copy = row("tm-2", " <a@example.test> ", 50);
67
+ assert.deepEqual(dedupeThreadMessages([original, copy]), [original]);
68
+ });
69
+
70
+ it("never merges rows without a usable header", () => {
71
+ const rows = [
72
+ row("tm-1", undefined, 10),
73
+ row("tm-2", undefined, 20),
74
+ row("tm-3", "<>", 30),
75
+ row("tm-4", "", 40),
76
+ ];
77
+ assert.deepEqual(dedupeThreadMessages(rows), rows);
78
+ });
79
+
80
+ it("leaves an empty thread empty", () => {
81
+ assert.deepEqual(dedupeThreadMessages([]), []);
82
+ });
83
+ });
@@ -5,6 +5,7 @@ import type {
5
5
  } from "@remit/api-openapi-types";
6
6
  import type { ResultList, ThreadMessageItem } from "@remit/data-ports";
7
7
  import { NotFoundError } from "@remit/data-ports/errors";
8
+ import { isValidMessageId } from "@remit/data-ports/id";
8
9
  import type { APIGatewayProxyEvent } from "aws-lambda";
9
10
  import type { Context } from "openapi-backend";
10
11
  import { getAccountConfigIdFromEvent } from "../auth.js";
@@ -246,16 +247,68 @@ export const executeThreadSearch = async (
246
247
  * Build the `listByThread` options for the thread-messages handler. Same
247
248
  * #212 default as the listing handlers — a soft-deleted message inside a
248
249
  * conversation should not surface in the conversation pane either.
250
+ *
251
+ * A conversation is never scoped to one mailbox: the user's own replies live
252
+ * in Sent, filed messages live in their folder, and all of them belong to the
253
+ * same thread (#46).
249
254
  */
250
255
  export const buildListThreadMessagesOptions = (query: {
251
256
  order?: "asc" | "desc";
252
- mailboxId?: string;
253
257
  }) => ({
254
258
  order: query.order ?? ("desc" as const),
255
- mailboxId: query.mailboxId,
256
259
  excludeDeleted: true,
257
260
  });
258
261
 
262
+ /**
263
+ * Collapse rows that are the same piece of mail to one entry.
264
+ *
265
+ * A ThreadMessage row is keyed by (threadId, messageId), and a messageId is
266
+ * derived from the account plus the RFC 5322 Message-ID, so the same mail
267
+ * synced from two folders is one row — the mailbox a thread spans is not what
268
+ * duplicates it. `MessageMoveService.copyMessage` is the exception: it mints a
269
+ * random id for the copy, so a copied message becomes a second row in the same
270
+ * thread carrying the same `messageIdHeader`. Reading the whole thread rather
271
+ * than one mailbox of it (#46) is what makes both rows visible at once, and two
272
+ * entries for one message is never what a conversation should show.
273
+ *
274
+ * The oldest row wins, so the original outlives the copy, with `threadMessageId`
275
+ * breaking ties. Both are independent of sort order, so `asc` and `desc` keep
276
+ * the same message rather than each keeping a different copy of it.
277
+ *
278
+ * A row without a usable `messageIdHeader` is never merged: the fallback
279
+ * identity for headerless mail is per-row by construction, so two such rows are
280
+ * two distinct messages.
281
+ */
282
+ export const dedupeThreadMessages = <
283
+ T extends Pick<
284
+ ThreadMessageItem,
285
+ "threadMessageId" | "messageIdHeader" | "createdAt"
286
+ >,
287
+ >(
288
+ rows: T[],
289
+ ): T[] => {
290
+ const winners = new Map<string, T>();
291
+
292
+ for (const row of rows) {
293
+ if (!isValidMessageId(row.messageIdHeader)) continue;
294
+ const header = (row.messageIdHeader as string).trim();
295
+ const held = winners.get(header);
296
+ if (
297
+ !held ||
298
+ row.createdAt < held.createdAt ||
299
+ (row.createdAt === held.createdAt &&
300
+ row.threadMessageId < held.threadMessageId)
301
+ ) {
302
+ winners.set(header, row);
303
+ }
304
+ }
305
+
306
+ return rows.filter((row) => {
307
+ if (!isValidMessageId(row.messageIdHeader)) return true;
308
+ return winners.get((row.messageIdHeader as string).trim()) === row;
309
+ });
310
+ };
311
+
259
312
  export const ThreadOperations: Record<
260
313
  ThreadOperationIds,
261
314
  OperationHandler<ThreadOperationIds>
@@ -330,16 +383,15 @@ export const ThreadDetailOperations: Record<
330
383
  const event = args[0] as APIGatewayProxyEvent;
331
384
  const accountConfigId = getAccountConfigIdFromEvent(event);
332
385
  const { threadId } = context.request.params as { threadId: string };
333
- const { order, mailboxId } = context.request.query as {
386
+ const { order } = context.request.query as {
334
387
  order?: "asc" | "desc";
335
- mailboxId?: string;
336
388
  };
337
389
 
338
390
  const client = await getClient();
339
391
  const result = await client.threadMessage.listByThread(
340
392
  threadId,
341
393
  accountConfigId,
342
- buildListThreadMessagesOptions({ order, mailboxId }),
394
+ buildListThreadMessagesOptions({ order }),
343
395
  );
344
396
 
345
397
  // Defense-in-depth: the query is already scoped to accountConfigId; assert
@@ -351,7 +403,11 @@ export const ThreadDetailOperations: Record<
351
403
  }
352
404
  }
353
405
 
354
- const items = await enrichThreadRows(result.items, client, accountConfigId);
406
+ const items = await enrichThreadRows(
407
+ dedupeThreadMessages(result.items),
408
+ client,
409
+ accountConfigId,
410
+ );
355
411
 
356
412
  return {
357
413
  items,