@remit/backend 0.0.13 → 0.0.15

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.13",
3
+ "version": "0.0.15",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import type { QuarantineItem } from "@remit/data-ports";
4
+ import type { APIGatewayProxyEvent } from "aws-lambda";
5
+ import type { Context } from "openapi-backend";
6
+ import { deriveAccountConfigId } from "../auth.js";
7
+ import {
8
+ _resetForTest,
9
+ type RemitClient,
10
+ setClient,
11
+ } from "../service/dynamodb.js";
12
+ import { MeOperations } from "./me.js";
13
+
14
+ const listQuarantine = MeOperations.MeOperations_listQuarantine as unknown as (
15
+ context: Context,
16
+ event: APIGatewayProxyEvent,
17
+ ) => Promise<{ entries: QuarantineItem[] }>;
18
+
19
+ const SUB = "cognito-sub-1";
20
+
21
+ const eventFor = (sub: string): APIGatewayProxyEvent =>
22
+ ({
23
+ requestContext: { authorizer: { claims: { sub } } },
24
+ }) as unknown as APIGatewayProxyEvent;
25
+
26
+ const entry = (over: Partial<QuarantineItem> = {}): QuarantineItem =>
27
+ ({
28
+ quarantineId: "q-1",
29
+ accountConfigId: deriveAccountConfigId(SUB),
30
+ accountId: "acct-1",
31
+ mailboxId: "mbx-1",
32
+ uidValidity: 1_712_000_000,
33
+ uid: 40217,
34
+ mailboxPath: "INBOX",
35
+ quarantinedAt: 1_000,
36
+ attempts: 3,
37
+ failureStage: "BodyParse",
38
+ failureCode: "UnterminatedMultipartBoundary",
39
+ failureMessage: "multipart boundary was never closed",
40
+ workerVersion: "worker 1.0.0",
41
+ structure: [{ depth: 0, contentType: "multipart/mixed" }],
42
+ createdAt: 1_000,
43
+ updatedAt: 1_000,
44
+ ...over,
45
+ }) as QuarantineItem;
46
+
47
+ const clientListing = (items: QuarantineItem[], seen: string[]): RemitClient =>
48
+ ({
49
+ quarantine: {
50
+ listByAccountConfigId: async (
51
+ accountConfigId: string,
52
+ ): Promise<QuarantineItem[]> => {
53
+ seen.push(accountConfigId);
54
+ return items;
55
+ },
56
+ },
57
+ }) as unknown as RemitClient;
58
+
59
+ afterEach(() => {
60
+ _resetForTest();
61
+ });
62
+
63
+ describe("MeOperations_listQuarantine", () => {
64
+ it("lists only what belongs to the caller", async () => {
65
+ const seen: string[] = [];
66
+ setClient(clientListing([entry()], seen));
67
+
68
+ const response = await listQuarantine({} as Context, eventFor(SUB));
69
+
70
+ assert.deepEqual(seen, [deriveAccountConfigId(SUB)]);
71
+ assert.deepEqual(response, { entries: [entry()] });
72
+ });
73
+
74
+ it("returns an empty list rather than failing when nothing is quarantined", async () => {
75
+ setClient(clientListing([], []));
76
+
77
+ const response = await listQuarantine({} as Context, eventFor(SUB));
78
+
79
+ assert.deepEqual(response, { entries: [] });
80
+ });
81
+ });
@@ -4,6 +4,7 @@ import type {
4
4
  CreateExportResponse,
5
5
  DeleteAccountConfigResponse,
6
6
  DeleteMeInput,
7
+ QuarantineListResponse,
7
8
  VipSuggestionsResponse,
8
9
  } from "@remit/api-openapi-types";
9
10
  import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
@@ -35,6 +36,18 @@ export const MeOperations: Record<
35
36
 
36
37
  return { suggestions: items.map(toVipSuggestionEntry) };
37
38
  },
39
+ MeOperations_listQuarantine: async (
40
+ _context: Context,
41
+ ...args: unknown[]
42
+ ): Promise<QuarantineListResponse> => {
43
+ const event = args[0] as APIGatewayProxyEvent;
44
+ const accountConfigId = getAccountConfigIdFromEvent(event);
45
+
46
+ const { quarantine } = await getClient();
47
+ const entries = await quarantine.listByAccountConfigId(accountConfigId);
48
+
49
+ return { entries };
50
+ },
38
51
  MeOperations_deleteMe: async (
39
52
  _context: Context,
40
53
  ...args: unknown[]
@@ -9,6 +9,8 @@ import {
9
9
  buildInboxMailboxMap,
10
10
  buildListAllThreadsOptions,
11
11
  buildListStarredThreadsOptions,
12
+ buildSearchAllThreadsOptions,
13
+ dedupeByMessageId,
12
14
  type InboxMapClient,
13
15
  } from "./unified-threads.js";
14
16
 
@@ -108,6 +110,148 @@ describe("buildInboxMailboxMap", () => {
108
110
  assert.equal(starredMailboxIds.size, 0);
109
111
  });
110
112
 
113
+ // #49: the unified list backing the unscoped search was INBOX-only, so a
114
+ // search from the daily brief could not see Archive, Sent, Spam or any
115
+ // custom folder. The scope is defined by what it excludes — anything not on
116
+ // that list is searched, Drafts included.
117
+ test("the search scope reaches every folder but Trash", async () => {
118
+ const client = buildClient([account("a1")], {
119
+ a1: [
120
+ mailbox("m-inbox", "a1", "INBOX"),
121
+ mailbox("m-sub", "a1", "INBOX/Receipts"),
122
+ mailbox("m-archive", "a1", "Archive", ["Archive"]),
123
+ mailbox("m-sent", "a1", "[Gmail]/Sent Mail", ["Sent"]),
124
+ mailbox("m-junk", "a1", "[Gmail]/Spam", ["Junk"]),
125
+ mailbox("m-drafts", "a1", "[Gmail]/Drafts", ["Drafts"]),
126
+ mailbox("m-custom", "a1", "Projects/Remit"),
127
+ mailbox("m-trash", "a1", "[Gmail]/Trash", ["Trash"]),
128
+ ],
129
+ });
130
+
131
+ const { searchMailboxIds } = await buildInboxMailboxMap(CONFIG_ID, client);
132
+
133
+ assert.deepEqual(
134
+ [...searchMailboxIds].sort(),
135
+ [
136
+ "m-archive",
137
+ "m-custom",
138
+ "m-drafts",
139
+ "m-inbox",
140
+ "m-junk",
141
+ "m-sent",
142
+ "m-sub",
143
+ ],
144
+ "Spam and Drafts are in scope; Trash is not",
145
+ );
146
+ });
147
+
148
+ // Trash is matched by path too, for servers that do not advertise the
149
+ // special-use attribute.
150
+ test("Trash is excluded without a special-use attribute", async () => {
151
+ const client = buildClient([account("a1")], {
152
+ a1: [
153
+ mailbox("m-inbox", "a1", "INBOX"),
154
+ mailbox("m-trash", "a1", "[Gmail]/Trash"),
155
+ // Whole path, never a prefix — a user's own folder is real mail.
156
+ mailbox("m-trash-talk", "a1", "Trash talk"),
157
+ ],
158
+ });
159
+
160
+ const { searchMailboxIds } = await buildInboxMailboxMap(CONFIG_ID, client);
161
+ assert.deepEqual([...searchMailboxIds].sort(), ["m-inbox", "m-trash-talk"]);
162
+ });
163
+
164
+ // A row is keyed by (threadId, messageId), both mailbox-independent, so
165
+ // Gmail's copies collapse into ONE row owned by whichever mailbox synced it
166
+ // first. Barring the virtual folders from the scope would therefore delete
167
+ // the only row such a message has — mail that exists and cannot be found.
168
+ test("the virtual copies stay IN the search scope, so mail they own is findable", async () => {
169
+ const client = buildClient([account("a1")], {
170
+ a1: [
171
+ mailbox("m-inbox", "a1", "INBOX"),
172
+ mailbox("m-all", "a1", "[Gmail]/All Mail", ["All"]),
173
+ mailbox("m-starred", "a1", "[Gmail]/Starred", ["Flagged"]),
174
+ mailbox("m-important", "a1", "[Gmail]/Important", ["Important"]),
175
+ ],
176
+ });
177
+
178
+ const { searchMailboxIds, virtualCopyMailboxIds } =
179
+ await buildInboxMailboxMap(CONFIG_ID, client);
180
+
181
+ assert.deepEqual(
182
+ [...searchMailboxIds].sort(),
183
+ ["m-all", "m-important", "m-inbox", "m-starred"],
184
+ "nothing is barred — duplicates are dropped after the read instead",
185
+ );
186
+ assert.deepEqual(
187
+ [...virtualCopyMailboxIds].sort(),
188
+ ["m-all", "m-important", "m-starred"],
189
+ "they are still identified, to decide which duplicate to drop",
190
+ );
191
+ });
192
+
193
+ test("a user folder named like a virtual copy is not treated as one", async () => {
194
+ const client = buildClient([account("a1")], {
195
+ a1: [
196
+ mailbox("m-starred-ideas", "a1", "Starred ideas"),
197
+ mailbox("m-important-clients", "a1", "Important clients"),
198
+ ],
199
+ });
200
+
201
+ const { virtualCopyMailboxIds } = await buildInboxMailboxMap(
202
+ CONFIG_ID,
203
+ client,
204
+ );
205
+ assert.equal(virtualCopyMailboxIds.size, 0);
206
+ });
207
+
208
+ // The starred scope drops Junk; the search scope keeps it. Two sets, two
209
+ // judgements — a star in Spam is noise, a search for mail that landed in
210
+ // Spam is the whole point.
211
+ test("the search scope and the starred scope differ on Junk", async () => {
212
+ const client = buildClient([account("a1")], {
213
+ a1: [
214
+ mailbox("m-inbox", "a1", "INBOX"),
215
+ mailbox("m-junk", "a1", "[Gmail]/Spam", ["Junk"]),
216
+ ],
217
+ });
218
+
219
+ const { searchMailboxIds, starredMailboxIds } = await buildInboxMailboxMap(
220
+ CONFIG_ID,
221
+ client,
222
+ );
223
+
224
+ assert.equal(searchMailboxIds.has("m-junk"), true);
225
+ assert.equal(starredMailboxIds.has("m-junk"), false);
226
+ });
227
+
228
+ test("the search scope spans every non-muted account", async () => {
229
+ const client = buildClient([account("a1"), account("a2")], {
230
+ a1: [mailbox("m1-archive", "a1", "Archive", ["Archive"])],
231
+ a2: [mailbox("m2-sent", "a2", "Sent", ["Sent"])],
232
+ });
233
+
234
+ const { searchMailboxIds } = await buildInboxMailboxMap(CONFIG_ID, client);
235
+
236
+ assert.deepEqual([...searchMailboxIds].sort(), ["m1-archive", "m2-sent"]);
237
+ });
238
+
239
+ test("muted mailboxes are excluded from the search scope too", async () => {
240
+ const client = buildClient(
241
+ [account("a1")],
242
+ {
243
+ a1: [
244
+ mailbox("m-inbox", "a1", "INBOX"),
245
+ mailbox("m-muted", "a1", "Archive"),
246
+ ],
247
+ },
248
+ [mutedSetting("MailboxMuted", "m-muted")],
249
+ );
250
+
251
+ const { searchMailboxIds } = await buildInboxMailboxMap(CONFIG_ID, client);
252
+ assert.deepEqual([...searchMailboxIds], ["m-inbox"]);
253
+ });
254
+
111
255
  test("muted mailboxes are excluded from both scopes", async () => {
112
256
  const client = buildClient(
113
257
  [account("a1")],
@@ -170,3 +314,121 @@ describe("buildListStarredThreadsOptions", () => {
170
314
  assert.equal(starred.limit, unified.limit);
171
315
  });
172
316
  });
317
+
318
+ // A backend that keys a row by its mailbox returns one row per Gmail copy, so
319
+ // a starred, Important inbox message arrives four times and crowds genuine
320
+ // matches off the page. Dropping the extras here — rather than barring the
321
+ // virtual folders from the scope — keeps the message reachable when the only
322
+ // row it has sits in one of them.
323
+ describe("dedupeByMessageId", () => {
324
+ const row = (messageId: string, mailboxId: string) => ({
325
+ messageId,
326
+ mailboxId,
327
+ });
328
+ const virtual = new Set(["m-all", "m-starred", "m-important"]);
329
+ const isVirtual = (r: { mailboxId: string }) => virtual.has(r.mailboxId);
330
+
331
+ test("a starred, Important Gmail inbox message collapses to one row", () => {
332
+ const deduped = dedupeByMessageId(
333
+ [
334
+ row("msg-1", "m-inbox"),
335
+ row("msg-1", "m-all"),
336
+ row("msg-1", "m-starred"),
337
+ row("msg-1", "m-important"),
338
+ ],
339
+ isVirtual,
340
+ );
341
+
342
+ assert.equal(deduped.length, 1);
343
+ assert.equal(deduped[0].mailboxId, "m-inbox");
344
+ });
345
+
346
+ test("the real folder wins even when a virtual copy came first", () => {
347
+ const deduped = dedupeByMessageId(
348
+ [row("msg-1", "m-all"), row("msg-1", "m-archive")],
349
+ isVirtual,
350
+ );
351
+
352
+ assert.deepEqual(
353
+ deduped.map((r) => r.mailboxId),
354
+ ["m-archive"],
355
+ );
356
+ });
357
+
358
+ // The case the scope exclusion would have broken: nothing but a virtual
359
+ // copy holds this message, so that row must survive.
360
+ test("a message that exists only in a virtual folder is kept", () => {
361
+ const deduped = dedupeByMessageId([row("msg-1", "m-starred")], isVirtual);
362
+
363
+ assert.deepEqual(
364
+ deduped.map((r) => r.mailboxId),
365
+ ["m-starred"],
366
+ );
367
+ });
368
+
369
+ test("the same mail in two real folders keeps the first, newest by order", () => {
370
+ const deduped = dedupeByMessageId(
371
+ [row("msg-1", "m-archive"), row("msg-1", "m-custom")],
372
+ isVirtual,
373
+ );
374
+
375
+ assert.deepEqual(
376
+ deduped.map((r) => r.mailboxId),
377
+ ["m-archive"],
378
+ );
379
+ });
380
+
381
+ test("distinct messages are all kept, in order", () => {
382
+ const deduped = dedupeByMessageId(
383
+ [
384
+ row("msg-1", "m-inbox"),
385
+ row("msg-2", "m-archive"),
386
+ row("msg-3", "m-junk"),
387
+ ],
388
+ isVirtual,
389
+ );
390
+
391
+ assert.deepEqual(
392
+ deduped.map((r) => r.messageId),
393
+ ["msg-1", "msg-2", "msg-3"],
394
+ );
395
+ });
396
+
397
+ test("an empty page stays empty", () => {
398
+ assert.deepEqual(dedupeByMessageId([], isVirtual), []);
399
+ });
400
+ });
401
+
402
+ describe("buildSearchAllThreadsOptions", () => {
403
+ test("carries the whole search scope, not just the inbox", () => {
404
+ const options = buildSearchAllThreadsOptions(
405
+ {},
406
+ new Set(["m-inbox", "m-archive", "m-junk"]),
407
+ );
408
+
409
+ assert.deepEqual([...options.mailboxIds].sort(), [
410
+ "m-archive",
411
+ "m-inbox",
412
+ "m-junk",
413
+ ]);
414
+ assert.equal(options.order, "desc");
415
+ assert.equal(options.excludeDeleted, true);
416
+ });
417
+
418
+ test("carries the caller's paging through", () => {
419
+ const options = buildSearchAllThreadsOptions(
420
+ { continuationToken: "tok", order: "asc", limit: 10 },
421
+ new Set(["m-inbox"]),
422
+ );
423
+
424
+ assert.equal(options.continuationToken, "tok");
425
+ assert.equal(options.order, "asc");
426
+ assert.equal(options.limit, 10);
427
+ });
428
+
429
+ test("defaults the page size to the unified default", () => {
430
+ const search = buildSearchAllThreadsOptions({}, new Set(["m-inbox"]));
431
+ const unified = buildListAllThreadsOptions({}, new Set(["m-inbox"]));
432
+ assert.equal(search.limit, unified.limit);
433
+ });
434
+ });
@@ -40,10 +40,9 @@ export interface InboxMapClient {
40
40
  * `Junk` and `Trash` match what Gmail and Fastmail do — a star on mail the user
41
41
  * already threw away or that was classed as spam is not something the starred
42
42
  * view should resurface. `All` is Gmail's All Mail: a second copy of everything
43
- * already reachable through its own folder, and because a message's identity
44
- * includes its mailbox, the copy is a distinct row carrying the same
45
- * server-side \Flagged. Including it would render every starred Gmail message
46
- * twice.
43
+ * already reachable through its own folder, and on a backend that keys a row by
44
+ * its mailbox the copy is a distinct row carrying the same server-side
45
+ * \Flagged, so including it would render every starred Gmail message twice.
47
46
  */
48
47
  const STARRED_EXCLUDED_SPECIAL_USE: readonly string[] = [
49
48
  MailboxSpecialUse.All,
@@ -56,13 +55,76 @@ const isExcludedFromStarred = (mailbox: MailboxItem): boolean =>
56
55
  STARRED_EXCLUDED_SPECIAL_USE.includes(use),
57
56
  ) === true;
58
57
 
58
+ /**
59
+ * The only folder the unscoped search never reaches: `Trash` holds mail the
60
+ * user discarded, the same judgement `excludeDeleted` already applies to
61
+ * soft-deleted rows.
62
+ *
63
+ * Everything else is in scope, and the rule is stated as a complement on
64
+ * purpose — enumerating the folders a search DOES reach is what left Drafts out
65
+ * of every description of it while it was in scope all along. `Junk` is in:
66
+ * an unscoped search exists to reach the folders the user did not think to look
67
+ * in, and misfiled mail in Spam is the case that matters most.
68
+ *
69
+ * Gmail's virtual folders (All Mail, Starred, Important) are in scope too, and
70
+ * deliberately so. They hold a second copy of mail that also lives in a real
71
+ * folder, which on a per-mailbox-row backend multiplies results — but a row
72
+ * here is keyed by `(threadId, messageId)`, both mailbox-independent, so the
73
+ * copies collapse into ONE row whose `mailboxId` is whichever mailbox synced it
74
+ * first (message-sync calls this a "residual cross-mailbox collision"). Barring
75
+ * those mailboxes therefore does not remove a duplicate, it removes the only
76
+ * row a message has whenever a virtual folder won that race — mail that exists
77
+ * and cannot be found. Duplicates are handled where they are safe to handle,
78
+ * by `dedupeByMessageId` below.
79
+ */
80
+ const SEARCH_EXCLUDED_SPECIAL_USE: readonly string[] = [
81
+ MailboxSpecialUse.Trash,
82
+ ];
83
+
84
+ /**
85
+ * Special-use folders that hold a second copy of mail already reachable through
86
+ * the folder it actually lives in — Gmail's All Mail, Starred and Important.
87
+ * Used to pick which duplicate to drop, never to decide what is searched.
88
+ */
89
+ const VIRTUAL_COPY_SPECIAL_USE: readonly string[] = [
90
+ MailboxSpecialUse.All,
91
+ MailboxSpecialUse.Flagged,
92
+ MailboxSpecialUse.Important,
93
+ ];
94
+
95
+ /**
96
+ * Well-known Gmail virtual paths, for servers that do not advertise the
97
+ * special-use attribute. Whole path, never a prefix — a user's own
98
+ * `Starred ideas` folder is real mail.
99
+ */
100
+ const VIRTUAL_COPY_FULL_PATHS: readonly string[] = [
101
+ "[gmail]/all mail",
102
+ "[gmail]/starred",
103
+ "[gmail]/important",
104
+ ];
105
+
106
+ const isVirtualCopy = (mailbox: MailboxItem): boolean =>
107
+ mailbox.specialUse?.some((use) => VIRTUAL_COPY_SPECIAL_USE.includes(use)) ===
108
+ true || VIRTUAL_COPY_FULL_PATHS.includes(mailbox.fullPath.toLowerCase());
109
+
110
+ const isExcludedFromSearch = (mailbox: MailboxItem): boolean =>
111
+ mailbox.specialUse?.some((use) =>
112
+ SEARCH_EXCLUDED_SPECIAL_USE.includes(use),
113
+ ) === true ||
114
+ // `specialUse` is absent on servers that do not advertise it and on accounts
115
+ // synced before it was recorded, so the well-known path is matched too.
116
+ // Whole path, never a prefix — a user's own `Trash talk` folder is real mail.
117
+ mailbox.fullPath.toLowerCase() === "[gmail]/trash";
118
+
59
119
  /**
60
120
  * Build the read scope for the unified listing: a mailboxId→accountId map over
61
121
  * every non-muted mailbox of every non-muted account in a given
62
- * accountConfigId, plus two id sets — `inboxMailboxIds` (top-level INBOX only,
63
- * the unified inbox scope) and `starredMailboxIds` (every folder a star may
64
- * surface from, see `STARRED_EXCLUDED_SPECIAL_USE`). The map covers all
65
- * mailboxes, excluded ones included, so any row still resolves its accountId.
122
+ * accountConfigId, plus three id sets — `inboxMailboxIds` (top-level INBOX
123
+ * only, the unified inbox scope), `starredMailboxIds` (every folder a star may
124
+ * surface from, see `STARRED_EXCLUDED_SPECIAL_USE`) and `searchMailboxIds`
125
+ * (every folder the unscoped search reaches, see
126
+ * `SEARCH_EXCLUDED_SPECIAL_USE`). The map covers all mailboxes, excluded ones
127
+ * included, so any row still resolves its accountId.
66
128
  *
67
129
  * INBOX is identified by exact match `fullPath.toUpperCase() === "INBOX"` —
68
130
  * MailboxSpecialUse has no Inbox value per RFC 6154. This is the same rule
@@ -95,6 +157,8 @@ export const buildInboxMailboxMap = async (
95
157
  mailboxIdToAccountId: Map<string, string>;
96
158
  inboxMailboxIds: Set<string>;
97
159
  starredMailboxIds: Set<string>;
160
+ searchMailboxIds: Set<string>;
161
+ virtualCopyMailboxIds: Set<string>;
98
162
  }> => {
99
163
  const [accounts, settings] = await Promise.all([
100
164
  client.account.listAllByAccountConfig(accountConfigId),
@@ -123,6 +187,8 @@ export const buildInboxMailboxMap = async (
123
187
  const mailboxIdToAccountId = new Map<string, string>();
124
188
  const inboxMailboxIds = new Set<string>();
125
189
  const starredMailboxIds = new Set<string>();
190
+ const searchMailboxIds = new Set<string>();
191
+ const virtualCopyMailboxIds = new Set<string>();
126
192
 
127
193
  const activeMailboxes = mailboxLists
128
194
  .flat()
@@ -133,12 +199,24 @@ export const buildInboxMailboxMap = async (
133
199
  if (!isExcludedFromStarred(mailbox)) {
134
200
  starredMailboxIds.add(mailbox.mailboxId);
135
201
  }
202
+ if (!isExcludedFromSearch(mailbox)) {
203
+ searchMailboxIds.add(mailbox.mailboxId);
204
+ }
205
+ if (isVirtualCopy(mailbox)) {
206
+ virtualCopyMailboxIds.add(mailbox.mailboxId);
207
+ }
136
208
  if (mailbox.fullPath.toUpperCase() === "INBOX") {
137
209
  inboxMailboxIds.add(mailbox.mailboxId);
138
210
  }
139
211
  }
140
212
 
141
- return { mailboxIdToAccountId, inboxMailboxIds, starredMailboxIds };
213
+ return {
214
+ mailboxIdToAccountId,
215
+ inboxMailboxIds,
216
+ starredMailboxIds,
217
+ searchMailboxIds,
218
+ virtualCopyMailboxIds,
219
+ };
142
220
  };
143
221
 
144
222
  /**
@@ -186,6 +264,64 @@ export const buildListStarredThreadsOptions = (
186
264
  excludeDeleted: true,
187
265
  });
188
266
 
267
+ /**
268
+ * Build searchByDate options for the search mode (`query=<text>`).
269
+ *
270
+ * The scope is the caller-built set: every non-muted mailbox minus the folders
271
+ * a search never reaches, or the starred scope when `starred=true` narrows it
272
+ * further. Same defaults as the unified listing; `limit` is a page size over
273
+ * matches and is clamped by the repository.
274
+ */
275
+ export const buildSearchAllThreadsOptions = (
276
+ query: {
277
+ continuationToken?: string;
278
+ order?: "asc" | "desc";
279
+ limit?: number;
280
+ },
281
+ mailboxIds: Set<string>,
282
+ ) => ({
283
+ order: query.order ?? ("desc" as const),
284
+ continuationToken: query.continuationToken,
285
+ limit: query.limit ?? DEFAULT_UNIFIED_THREADS_PAGE_SIZE,
286
+ mailboxIds,
287
+ excludeDeleted: true,
288
+ });
289
+
290
+ /**
291
+ * Collapse rows that are the same piece of mail seen through more than one
292
+ * folder, keeping the copy in a real folder over the one in a virtual folder.
293
+ *
294
+ * A backend that keys a row by its mailbox returns one row per copy, so a
295
+ * starred, Important Gmail inbox message arrives four times and crowds genuine
296
+ * matches off the page. Dropping the extras here rather than barring the
297
+ * virtual folders from the scope is what keeps the message reachable when the
298
+ * ONLY row it has sits in one of them.
299
+ *
300
+ * Preference matters: a real folder is where the user filed the mail, so its
301
+ * row is the one whose `mailboxId` should open. Ties keep the first row seen,
302
+ * which is the newest given the caller's ordering.
303
+ *
304
+ * Scope: within a page. A duplicate split across a page boundary survives, the
305
+ * same caveat the endpoint already documents for collapsing by `threadId`.
306
+ */
307
+ export const dedupeByMessageId = <T extends { messageId: string }>(
308
+ rows: readonly T[],
309
+ isVirtualCopy: (row: T) => boolean,
310
+ ): T[] => {
311
+ const chosen = new Map<string, T>();
312
+ for (const row of rows) {
313
+ const existing = chosen.get(row.messageId);
314
+ if (!existing) {
315
+ chosen.set(row.messageId, row);
316
+ continue;
317
+ }
318
+ if (isVirtualCopy(existing) && !isVirtualCopy(row)) {
319
+ chosen.set(row.messageId, row);
320
+ }
321
+ }
322
+ return [...chosen.values()];
323
+ };
324
+
189
325
  /**
190
326
  * Attach accountId to each enriched ThreadMessageResponse row using the
191
327
  * mailboxId→accountId map built from inbox discovery. Same read-time-attach
@@ -210,46 +346,78 @@ export const UnifiedThreadOperations: Record<
210
346
  ) => {
211
347
  const event = args[0] as APIGatewayProxyEvent;
212
348
  const accountConfigId = getAccountConfigIdFromEvent(event);
213
- const { continuationToken, order, limit, starred } = context.request
349
+ const { continuationToken, order, limit, starred, query } = context.request
214
350
  .query as {
215
351
  continuationToken?: string;
216
352
  order?: "asc" | "desc";
217
353
  limit?: number;
218
354
  starred?: boolean | string;
355
+ query?: string;
219
356
  };
220
357
  const starredOnly = starred === true || starred === "true";
358
+ // Whitespace-only text is not a search: it would widen the scope to every
359
+ // folder while matching nothing in particular.
360
+ const searchText = query?.trim();
361
+ const searching = searchText !== undefined && searchText.length > 0;
221
362
 
222
363
  const client = await getClient();
223
364
 
224
- const { mailboxIdToAccountId, inboxMailboxIds, starredMailboxIds } =
225
- await buildInboxMailboxMap(accountConfigId, client);
365
+ const {
366
+ mailboxIdToAccountId,
367
+ inboxMailboxIds,
368
+ starredMailboxIds,
369
+ searchMailboxIds,
370
+ virtualCopyMailboxIds,
371
+ } = await buildInboxMailboxMap(accountConfigId, client);
226
372
 
227
- const scope = starredOnly ? starredMailboxIds : inboxMailboxIds;
373
+ // Search widens past INBOX to every folder it may reach; `starred=true`
374
+ // still narrows it to the starred scope, so the two compose.
375
+ const searchScope = starredOnly ? starredMailboxIds : searchMailboxIds;
376
+ const scope = searching
377
+ ? searchScope
378
+ : starredOnly
379
+ ? starredMailboxIds
380
+ : inboxMailboxIds;
228
381
  if (scope.size === 0) {
229
382
  return { items: [], continuationToken: undefined };
230
383
  }
231
384
 
232
- const result = starredOnly
233
- ? await client.threadMessage.listByStarred(
385
+ const result = searching
386
+ ? await client.threadMessage.searchByDate(
234
387
  accountConfigId,
235
- buildListStarredThreadsOptions(
388
+ { query: searchText, starred: starredOnly ? true : undefined },
389
+ buildSearchAllThreadsOptions(
236
390
  { continuationToken, order, limit },
237
- starredMailboxIds,
391
+ searchScope,
238
392
  ),
239
393
  )
240
- : await client.threadMessage.listByDate(
241
- accountConfigId,
242
- buildListAllThreadsOptions(
243
- { continuationToken, order, limit },
244
- inboxMailboxIds,
245
- ),
246
- );
394
+ : starredOnly
395
+ ? await client.threadMessage.listByStarred(
396
+ accountConfigId,
397
+ buildListStarredThreadsOptions(
398
+ { continuationToken, order, limit },
399
+ starredMailboxIds,
400
+ ),
401
+ )
402
+ : await client.threadMessage.listByDate(
403
+ accountConfigId,
404
+ buildListAllThreadsOptions(
405
+ { continuationToken, order, limit },
406
+ inboxMailboxIds,
407
+ ),
408
+ );
409
+
410
+ // Search spans folders, so it is the one mode that can see the same mail
411
+ // twice — through its real folder and through a virtual copy of it. The
412
+ // INBOX and starred listings each read a scope that already holds one row
413
+ // per message, so they are left exactly as they were.
414
+ const rows = searching
415
+ ? dedupeByMessageId(result.items, (row) =>
416
+ virtualCopyMailboxIds.has(row.mailboxId),
417
+ )
418
+ : result.items;
247
419
 
248
- const enriched = await enrichThreadRows(
249
- result.items,
250
- client,
251
- accountConfigId,
252
- );
420
+ const enriched = await enrichThreadRows(rows, client, accountConfigId);
253
421
  const items = attachAccountIds(enriched, mailboxIdToAccountId);
254
422
 
255
423
  return {
@@ -21,6 +21,7 @@ import {
21
21
  messageDataSchema,
22
22
  OrganizeJobRequestRepo,
23
23
  OutboxMessageRepo,
24
+ QuarantineRepo,
24
25
  } from "@remit/drizzle-service";
25
26
  import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
26
27
  import { env } from "expect-env";
@@ -58,6 +59,7 @@ export const buildPostgresClient = (): RemitClient => {
58
59
  threadMessage: new DrizzleThreadMessageRepository(pgConnectionUrl),
59
60
  envelope: new DrizzleEnvelopeRepository(messageDataDb),
60
61
  accountExportRequest: new AccountExportRequestRepo(genericDb),
62
+ quarantine: new QuarantineRepo(genericDb),
61
63
  organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
62
64
  placementMove: new MessagePlacementMoveRepo(genericDb),
63
65
  flagPush: new MessageFlagPushRepo(genericDb),
@@ -22,6 +22,7 @@ import {
22
22
  messageDataSchema,
23
23
  OrganizeJobRequestRepo,
24
24
  OutboxMessageRepo,
25
+ QuarantineRepo,
25
26
  } from "@remit/drizzle-service";
26
27
  import type { NodePgDatabase } from "drizzle-orm/node-postgres";
27
28
  import { env } from "expect-env";
@@ -66,6 +67,7 @@ export const buildSqliteClient = async (): Promise<RemitClient> => {
66
67
  threadMessage: new DrizzleThreadMessageRepository(genericDb),
67
68
  envelope: new DrizzleEnvelopeRepository(messageDataDb),
68
69
  accountExportRequest: new AccountExportRequestRepo(genericDb),
70
+ quarantine: new QuarantineRepo(genericDb),
69
71
  organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
70
72
  placementMove: new MessagePlacementMoveRepo(genericDb),
71
73
  flagPush: new MessageFlagPushRepo(genericDb),
@@ -18,6 +18,7 @@ import type {
18
18
  IMessageRepository,
19
19
  IOrganizeJobRequestRepository,
20
20
  IOutboxMessageRepository,
21
+ IQuarantineRepository,
21
22
  IThreadMessageRepository,
22
23
  IUnitOfWork,
23
24
  } from "@remit/data-ports";
@@ -71,6 +72,10 @@ export interface RemitClient {
71
72
  envelope: IEnvelopeRepository;
72
73
  accountExportRequest: IAccountExportRequestRepository;
73
74
 
75
+ // Messages the sync path could not read (issue #72). Read-only from the API
76
+ // process: the sync worker writes the rows, settings lists them.
77
+ quarantine: IQuarantineRepository;
78
+
74
79
  // Smart Organize back-apply job (RFC 034, #1278). Present on both backends,
75
80
  // modeled on accountExportRequest — a Pending row the account-fanout worker
76
81
  // picks up and drives to Complete/Failed. Never a Filter.
@@ -151,6 +156,7 @@ export interface RemitClientRepositories {
151
156
  threadMessage: IThreadMessageRepository;
152
157
  envelope: IEnvelopeRepository;
153
158
  accountExportRequest: IAccountExportRequestRepository;
159
+ quarantine: IQuarantineRepository;
154
160
  organizeJobRequest: IOrganizeJobRequestRepository;
155
161
  placementMove: IMessagePlacementMoveRepository;
156
162
  flagPush: IMessageFlagPushRepository;
@@ -310,6 +316,7 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
310
316
  threadMessage: repositories.threadMessage,
311
317
  envelope: repositories.envelope,
312
318
  accountExportRequest: repositories.accountExportRequest,
319
+ quarantine: repositories.quarantine,
313
320
  organizeJobRequest: repositories.organizeJobRequest,
314
321
  filter: repositories.filter,
315
322
  filterAnchor: repositories.filterAnchor,
@@ -20,6 +20,7 @@ const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
20
20
  "threadMessage",
21
21
  "envelope",
22
22
  "accountExportRequest",
23
+ "quarantine",
23
24
  "storage",
24
25
  "search",
25
26
  "secrets",
package/src/types.ts CHANGED
@@ -10,6 +10,7 @@ export type OperationIds =
10
10
  | "MeOperations_listVipSuggestions"
11
11
  | "MeOperations_createExport"
12
12
  | "MeOperations_getExport"
13
+ | "MeOperations_listQuarantine"
13
14
  | "ConfigOperations_getConfig"
14
15
  | "AccountOperations_createAccount"
15
16
  | "AccountOperations_testConnection"