@remit/backend 0.0.98 → 0.0.100

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.98",
3
+ "version": "0.0.100",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,9 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
+ import type { MessageCategory } from "@remit/api-openapi-types";
3
4
  import type {
4
5
  AccountItem,
5
6
  AccountSettingItem,
6
7
  MailboxItem,
8
+ SearchOptions,
7
9
  } from "@remit/data-ports";
8
10
  import {
9
11
  buildInboxMailboxMap,
@@ -11,7 +13,10 @@ import {
11
13
  buildListStarredThreadsOptions,
12
14
  buildSearchAllThreadsOptions,
13
15
  dedupeByMessageId,
16
+ executeUnifiedThreadListing,
14
17
  type InboxMapClient,
18
+ type UnifiedThreadClient,
19
+ type UnifiedThreadParams,
15
20
  } from "./unified-threads.js";
16
21
 
17
22
  // #44: Flagged filtered client-side over the newest 50 unified-inbox rows, so a
@@ -432,3 +437,162 @@ describe("buildSearchAllThreadsOptions", () => {
432
437
  assert.equal(search.limit, unified.limit);
433
438
  });
434
439
  });
440
+
441
+ // #308: the cross-account collections filtered and counted in the browser, over
442
+ // the pages a client happened to have loaded. Both are the query's job now.
443
+ describe("executeUnifiedThreadListing", () => {
444
+ interface Call {
445
+ mode: "listByDate" | "listByStarred" | "searchByDate";
446
+ search?: SearchOptions;
447
+ limit?: number;
448
+ continuationToken?: string;
449
+ }
450
+
451
+ const emptyPage = { items: [], continuationToken: undefined };
452
+
453
+ const buildListingClient = (calls: Call[], count = 0) =>
454
+ ({
455
+ account: { listAllByAccountConfig: async () => [account("a1")] },
456
+ mailbox: {
457
+ listAllByAccount: async () => [
458
+ mailbox("m-inbox", "a1", "INBOX"),
459
+ mailbox("m-archive", "a1", "Archive"),
460
+ ],
461
+ },
462
+ accountSetting: { listByAccountConfig: async () => [] },
463
+ message: { get: async () => [] },
464
+ address: { getAddress: async () => [] },
465
+ messageLabel: { listByMessageIds: async () => [] },
466
+ label: { listByAccountConfig: async () => [] },
467
+ threadMessage: {
468
+ listByDate: async (
469
+ _config: string,
470
+ options?: { search?: SearchOptions; limit?: number },
471
+ ) => {
472
+ calls.push({ mode: "listByDate", ...options });
473
+ return emptyPage;
474
+ },
475
+ listByStarred: async (
476
+ _config: string,
477
+ options?: { search?: SearchOptions; limit?: number },
478
+ ) => {
479
+ calls.push({ mode: "listByStarred", ...options });
480
+ return emptyPage;
481
+ },
482
+ searchByDate: async (
483
+ _config: string,
484
+ search: SearchOptions,
485
+ options?: { limit?: number },
486
+ ) => {
487
+ calls.push({ mode: "searchByDate", search, ...options });
488
+ return emptyPage;
489
+ },
490
+ countThreadsInScope: async () => count,
491
+ },
492
+ }) as unknown as UnifiedThreadClient;
493
+
494
+ const params = (
495
+ overrides: Partial<UnifiedThreadParams> = {},
496
+ ): UnifiedThreadParams => ({
497
+ starredOnly: false,
498
+ count: false,
499
+ results: true,
500
+ ...overrides,
501
+ });
502
+
503
+ test("sends the row filters into the starred query", async () => {
504
+ const calls: Call[] = [];
505
+ await executeUnifiedThreadListing(buildListingClient(calls), CONFIG_ID, {
506
+ ...params({ starredOnly: true }),
507
+ category: ["social", "personal"] as MessageCategory[],
508
+ unread: true,
509
+ attachments: true,
510
+ });
511
+
512
+ assert.equal(calls.length, 1);
513
+ assert.equal(calls[0].mode, "listByStarred");
514
+ assert.deepEqual(calls[0].search, {
515
+ starred: true,
516
+ category: ["social", "personal"],
517
+ unread: true,
518
+ attachments: true,
519
+ });
520
+ });
521
+
522
+ test("sends the row filters into the INBOX query", async () => {
523
+ const calls: Call[] = [];
524
+ await executeUnifiedThreadListing(buildListingClient(calls), CONFIG_ID, {
525
+ ...params(),
526
+ category: ["uncategorized"] as MessageCategory[],
527
+ });
528
+
529
+ assert.equal(calls[0].mode, "listByDate");
530
+ assert.deepEqual(calls[0].search, { category: ["uncategorized"] });
531
+ });
532
+
533
+ test("sends the row filters into the search query", async () => {
534
+ const calls: Call[] = [];
535
+ await executeUnifiedThreadListing(buildListingClient(calls), CONFIG_ID, {
536
+ ...params({ starredOnly: true }),
537
+ searchText: "invoice",
538
+ unread: true,
539
+ });
540
+
541
+ assert.equal(calls[0].mode, "searchByDate");
542
+ assert.deepEqual(calls[0].search, {
543
+ query: "invoice",
544
+ starred: true,
545
+ unread: true,
546
+ });
547
+ });
548
+
549
+ // The defect the count replaces: a page-length figure grew with every press
550
+ // of "load more" while the header presented it as a total.
551
+ test("counts the same however far the caller has paged", async () => {
552
+ const calls: Call[] = [];
553
+ const first = await executeUnifiedThreadListing(
554
+ buildListingClient(calls, 42),
555
+ CONFIG_ID,
556
+ params({ starredOnly: true, count: true, unread: true }),
557
+ );
558
+ const later = await executeUnifiedThreadListing(
559
+ buildListingClient(calls, 42),
560
+ CONFIG_ID,
561
+ params({
562
+ starredOnly: true,
563
+ count: true,
564
+ unread: true,
565
+ continuationToken: "page-3",
566
+ limit: 10,
567
+ }),
568
+ );
569
+
570
+ assert.equal(first.count, 42);
571
+ assert.equal(later.count, 42);
572
+ });
573
+
574
+ test("reads the count alone when results are not wanted", async () => {
575
+ const calls: Call[] = [];
576
+ const response = await executeUnifiedThreadListing(
577
+ buildListingClient(calls, 7),
578
+ CONFIG_ID,
579
+ params({ starredOnly: true, count: true, results: false }),
580
+ );
581
+
582
+ assert.deepEqual(calls, []);
583
+ assert.equal(response.items, undefined);
584
+ assert.equal(response.count, 7);
585
+ });
586
+
587
+ test("omits the count when it was not asked for", async () => {
588
+ const calls: Call[] = [];
589
+ const response = await executeUnifiedThreadListing(
590
+ buildListingClient(calls, 7),
591
+ CONFIG_ID,
592
+ params({ starredOnly: true }),
593
+ );
594
+
595
+ assert.equal(response.count, undefined);
596
+ assert.deepEqual(response.items, []);
597
+ });
598
+ });
@@ -1,7 +1,14 @@
1
+ import type {
2
+ MessageCategory,
3
+ ThreadSearchResponse,
4
+ } from "@remit/api-openapi-types";
1
5
  import type {
2
6
  AccountItem,
3
7
  IAccountSettingRepository,
4
8
  MailboxItem,
9
+ ResultList,
10
+ SearchOptions,
11
+ ThreadMessageItem,
5
12
  } from "@remit/data-ports";
6
13
  import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
7
14
  import { MailboxSpecialUse } from "@remit/domain-enums";
@@ -9,7 +16,10 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
9
16
  import type { Context } from "openapi-backend";
10
17
  import pMap from "p-map";
11
18
  import { getAccountConfigIdFromEvent } from "../auth.js";
12
- import { enrichThreadRows } from "../derive/enrichThreadRows.js";
19
+ import {
20
+ type EnrichClient,
21
+ enrichThreadRows,
22
+ } from "../derive/enrichThreadRows.js";
13
23
  import { getClient } from "../service/data-client.js";
14
24
  import type { OperationHandler, UnifiedThreadOperationIds } from "../types.js";
15
25
  import {
@@ -209,12 +219,14 @@ export const buildListAllThreadsOptions = (
209
219
  limit?: number;
210
220
  },
211
221
  inboxMailboxIds: Set<string>,
222
+ search?: SearchOptions,
212
223
  ) => ({
213
224
  order: query.order ?? ("desc" as const),
214
225
  continuationToken: query.continuationToken,
215
226
  limit: query.limit ?? DEFAULT_UNIFIED_THREADS_PAGE_SIZE,
216
227
  inboxMailboxIds,
217
228
  excludeDeleted: true,
229
+ search,
218
230
  });
219
231
 
220
232
  /**
@@ -231,12 +243,14 @@ export const buildListStarredThreadsOptions = (
231
243
  limit?: number;
232
244
  },
233
245
  starredMailboxIds: Set<string>,
246
+ search?: SearchOptions,
234
247
  ) => ({
235
248
  order: query.order ?? ("desc" as const),
236
249
  continuationToken: query.continuationToken,
237
250
  limit: query.limit ?? DEFAULT_UNIFIED_THREADS_PAGE_SIZE,
238
251
  mailboxIds: starredMailboxIds,
239
252
  excludeDeleted: true,
253
+ search,
240
254
  });
241
255
 
242
256
  /**
@@ -311,75 +325,140 @@ export const attachAccountIds = (
311
325
  accountId: mailboxIdToAccountId.get(row.mailboxId),
312
326
  }));
313
327
 
314
- export const UnifiedThreadOperations: Record<
315
- UnifiedThreadOperationIds,
316
- OperationHandler<UnifiedThreadOperationIds>
317
- > = {
318
- UnifiedThreadOperations_listAllThreads: async (
319
- context: Context,
320
- ...args: unknown[]
321
- ) => {
322
- const event = args[0] as APIGatewayProxyEvent;
323
- const accountConfigId = getAccountConfigIdFromEvent(event);
324
- const { continuationToken, order, limit, starred, query } = context.request
325
- .query as {
326
- continuationToken?: string;
327
- order?: "asc" | "desc";
328
- limit?: number;
329
- starred?: boolean | string;
330
- query?: string;
331
- };
332
- const starredOnly = starred === true || starred === "true";
333
- // Whitespace-only text is not a search: it would widen the scope to every
334
- // folder while matching nothing in particular.
335
- const searchText = query?.trim();
336
- const searching = searchText !== undefined && searchText.length > 0;
328
+ /**
329
+ * The row criteria the listing carries, whichever mode answers it.
330
+ *
331
+ * `category`, `unread` and `attachments` are columns on the ThreadMessage row,
332
+ * so each is a predicate inside the query. Filtering the rows a page returned
333
+ * instead is the defect this replaces: a category whose mail sits below the
334
+ * newest page rendered an empty list however much of it the collection held
335
+ * (#308). `starred` and `query` are in here too so one `SearchOptions` says
336
+ * what the whole request narrows by, which is what lets the count run the same
337
+ * predicate as the listing.
338
+ */
339
+ export const buildUnifiedThreadSearch = (params: {
340
+ starredOnly: boolean;
341
+ searchText?: string;
342
+ category?: MessageCategory[];
343
+ unread?: boolean;
344
+ attachments?: boolean;
345
+ }): SearchOptions => ({
346
+ ...(params.searchText ? { query: params.searchText } : {}),
347
+ ...(params.starredOnly ? { starred: true } : {}),
348
+ ...(params.category?.length ? { category: params.category } : {}),
349
+ ...(params.unread !== undefined ? { unread: params.unread } : {}),
350
+ ...(params.attachments !== undefined
351
+ ? { attachments: params.attachments }
352
+ : {}),
353
+ });
337
354
 
338
- const client = await getClient();
355
+ /**
356
+ * Minimal client surface `executeUnifiedThreadListing` needs, declared
357
+ * structurally (like `ThreadSearchClient`) so the mode selection, the row
358
+ * filters and the count are testable with an in-memory fake.
359
+ */
360
+ export interface UnifiedThreadClient extends EnrichClient, InboxMapClient {
361
+ threadMessage: {
362
+ listByDate(
363
+ accountConfigId: string,
364
+ options?: ReturnType<typeof buildListAllThreadsOptions>,
365
+ ): Promise<ResultList<ThreadMessageItem>>;
366
+ listByStarred(
367
+ accountConfigId: string,
368
+ options?: ReturnType<typeof buildListStarredThreadsOptions>,
369
+ ): Promise<ResultList<ThreadMessageItem>>;
370
+ searchByDate(
371
+ accountConfigId: string,
372
+ search: SearchOptions,
373
+ options?: ReturnType<typeof buildSearchAllThreadsOptions>,
374
+ ): Promise<ResultList<ThreadMessageItem>>;
375
+ countThreadsInScope(
376
+ accountConfigId: string,
377
+ search: SearchOptions,
378
+ options?: { mailboxIds?: Set<string>; excludeDeleted?: boolean },
379
+ ): Promise<number>;
380
+ };
381
+ }
339
382
 
340
- const {
341
- mailboxIdToAccountId,
342
- inboxMailboxIds,
343
- starredMailboxIds,
344
- searchMailboxIds,
345
- virtualCopyMailboxIds,
346
- } = await buildInboxMailboxMap(accountConfigId, client);
347
-
348
- // Search widens past INBOX to every folder it may reach; `starred=true`
349
- // still narrows it to the starred scope, so the two compose.
350
- const searchScope = starredOnly ? starredMailboxIds : searchMailboxIds;
351
- const scope = searching
352
- ? searchScope
353
- : starredOnly
354
- ? starredMailboxIds
355
- : inboxMailboxIds;
356
- if (scope.size === 0) {
357
- return { items: [], continuationToken: undefined };
358
- }
383
+ export type UnifiedThreadParams = {
384
+ continuationToken?: string;
385
+ order?: "asc" | "desc";
386
+ limit?: number;
387
+ starredOnly: boolean;
388
+ searchText?: string;
389
+ category?: MessageCategory[];
390
+ unread?: boolean;
391
+ attachments?: boolean;
392
+ count: boolean;
393
+ results: boolean;
394
+ };
395
+
396
+ /**
397
+ * Run the cross-account listing: pick the mode, apply the row criteria inside
398
+ * the query, and optionally count the matches.
399
+ *
400
+ * `count` names the whole match, never the page: a page size bounds the rows a
401
+ * response carries and has no bearing on how much matches, so pressing "load
402
+ * more" cannot move it. It counts conversations, the unit the listing renders
403
+ * once its per-mailbox rows are collapsed by `threadId`. `results: false` reads
404
+ * the count alone, which is how a header total is fetched without also paying
405
+ * for a page of mail.
406
+ */
407
+ export const executeUnifiedThreadListing = async (
408
+ client: UnifiedThreadClient,
409
+ accountConfigId: string,
410
+ params: UnifiedThreadParams,
411
+ ): Promise<ThreadSearchResponse> => {
412
+ const searching =
413
+ params.searchText !== undefined && params.searchText.length > 0;
414
+ const {
415
+ mailboxIdToAccountId,
416
+ inboxMailboxIds,
417
+ starredMailboxIds,
418
+ searchMailboxIds,
419
+ virtualCopyMailboxIds,
420
+ } = await buildInboxMailboxMap(accountConfigId, client);
359
421
 
422
+ // Search widens past INBOX to every folder it may reach; `starred=true`
423
+ // still narrows it to the starred scope, so the two compose.
424
+ const searchScope = params.starredOnly ? starredMailboxIds : searchMailboxIds;
425
+ const scope = searching
426
+ ? searchScope
427
+ : params.starredOnly
428
+ ? starredMailboxIds
429
+ : inboxMailboxIds;
430
+
431
+ const search = buildUnifiedThreadSearch(params);
432
+ const page = {
433
+ continuationToken: params.continuationToken,
434
+ order: params.order,
435
+ limit: params.limit,
436
+ };
437
+
438
+ if (scope.size === 0) {
439
+ return {
440
+ ...(params.results ? { items: [] } : {}),
441
+ ...(params.count ? { count: 0 } : {}),
442
+ };
443
+ }
444
+
445
+ const response: ThreadSearchResponse = {};
446
+
447
+ if (params.results) {
360
448
  const result = searching
361
449
  ? await client.threadMessage.searchByDate(
362
450
  accountConfigId,
363
- { query: searchText, starred: starredOnly ? true : undefined },
364
- buildSearchAllThreadsOptions(
365
- { continuationToken, order, limit },
366
- searchScope,
367
- ),
451
+ search,
452
+ buildSearchAllThreadsOptions(page, searchScope),
368
453
  )
369
- : starredOnly
454
+ : params.starredOnly
370
455
  ? await client.threadMessage.listByStarred(
371
456
  accountConfigId,
372
- buildListStarredThreadsOptions(
373
- { continuationToken, order, limit },
374
- starredMailboxIds,
375
- ),
457
+ buildListStarredThreadsOptions(page, starredMailboxIds, search),
376
458
  )
377
459
  : await client.threadMessage.listByDate(
378
460
  accountConfigId,
379
- buildListAllThreadsOptions(
380
- { continuationToken, order, limit },
381
- inboxMailboxIds,
382
- ),
461
+ buildListAllThreadsOptions(page, inboxMailboxIds, search),
383
462
  );
384
463
 
385
464
  // Search spans folders, so it is the one mode that can see the same mail
@@ -393,11 +472,97 @@ export const UnifiedThreadOperations: Record<
393
472
  : result.items;
394
473
 
395
474
  const enriched = await enrichThreadRows(rows, client, accountConfigId);
396
- const items = attachAccountIds(enriched, mailboxIdToAccountId);
475
+ response.items = attachAccountIds(enriched, mailboxIdToAccountId);
476
+ response.continuationToken = result.continuationToken;
477
+ }
397
478
 
398
- return {
399
- items,
400
- continuationToken: result.continuationToken,
479
+ if (params.count) {
480
+ response.count = await client.threadMessage.countThreadsInScope(
481
+ accountConfigId,
482
+ search,
483
+ { mailboxIds: scope, excludeDeleted: true },
484
+ );
485
+ }
486
+
487
+ return response;
488
+ };
489
+
490
+ const toArray = <T>(value: T | T[] | undefined): T[] | undefined => {
491
+ if (value === undefined) return undefined;
492
+ return Array.isArray(value) ? value : [value];
493
+ };
494
+
495
+ /**
496
+ * A query-string boolean, as three states.
497
+ *
498
+ * Query strings carry text; openapi-backend coerces where the schema says
499
+ * boolean and leaves the raw string where it cannot, so both forms are read.
500
+ * Anything else — absent, or a value that is neither — is `undefined`, and
501
+ * every caller below decides what that means for its own parameter. Folding
502
+ * "not stated" into `false` would turn an absent `unread` into "only read
503
+ * mail": a filter nobody asked for, and the opposite of the one they might
504
+ * have meant.
505
+ */
506
+ const toBoolean = (
507
+ value: boolean | string | undefined,
508
+ ): boolean | undefined => {
509
+ if (value === true || value === "true") return true;
510
+ if (value === false || value === "false") return false;
511
+ return undefined;
512
+ };
513
+
514
+ export const UnifiedThreadOperations: Record<
515
+ UnifiedThreadOperationIds,
516
+ OperationHandler<UnifiedThreadOperationIds>
517
+ > = {
518
+ UnifiedThreadOperations_listAllThreads: async (
519
+ context: Context,
520
+ ...args: unknown[]
521
+ ) => {
522
+ const event = args[0] as APIGatewayProxyEvent;
523
+ const accountConfigId = getAccountConfigIdFromEvent(event);
524
+ const {
525
+ continuationToken,
526
+ order,
527
+ limit,
528
+ starred,
529
+ query,
530
+ category,
531
+ unread,
532
+ attachments,
533
+ count,
534
+ results,
535
+ } = context.request.query as {
536
+ continuationToken?: string;
537
+ order?: "asc" | "desc";
538
+ limit?: number;
539
+ starred?: boolean | string;
540
+ query?: string;
541
+ category?: MessageCategory | MessageCategory[];
542
+ unread?: boolean | string;
543
+ attachments?: boolean | string;
544
+ count?: boolean | string;
545
+ results?: boolean | string;
401
546
  };
547
+
548
+ // Whitespace-only text is not a search: it would widen the scope to every
549
+ // folder while matching nothing in particular.
550
+ const searchText = query?.trim();
551
+
552
+ return executeUnifiedThreadListing(await getClient(), accountConfigId, {
553
+ continuationToken,
554
+ order,
555
+ limit,
556
+ // Absent means unstated for all five, and each says what it does with
557
+ // that: the three filters drop out of the predicate, `count` is off
558
+ // unless it is asked for, and `results` is on unless it is refused.
559
+ starredOnly: toBoolean(starred) === true,
560
+ searchText: searchText || undefined,
561
+ category: toArray(category),
562
+ unread: toBoolean(unread),
563
+ attachments: toBoolean(attachments),
564
+ count: toBoolean(count) === true,
565
+ results: toBoolean(results) !== false,
566
+ });
402
567
  },
403
568
  };
@@ -33,9 +33,7 @@ process.stdout.write = ((
33
33
 
34
34
  process.env.LOG_LEVEL = "trace";
35
35
 
36
- const { calendarFeedPath, mintCalendarFeedToken } = await import(
37
- "@remit/calendar-service"
38
- );
36
+ const { mintCalendarFeedToken } = await import("@remit/calendar-service");
39
37
  const { _resetForTest, setClient } = await import("./service/data-client.js");
40
38
  const { createCalendarSqliteClient } = await import(
41
39
  "./handlers/calendar-sqlite-fixture.js"
@@ -96,7 +94,7 @@ describe("what a request leaves in the log", () => {
96
94
  const { token } = mintCalendarFeedToken();
97
95
 
98
96
  const { response, logged } = await capture(() =>
99
- send({ httpMethod: "GET", path: calendarFeedPath(token) }),
97
+ send({ httpMethod: "GET", path: `/feeds/calendar/${token}.ics` }),
100
98
  );
101
99
 
102
100
  assert.equal(