@remit/backend 0.0.8 → 0.0.9

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.8",
3
+ "version": "0.0.9",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,172 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type {
4
+ AccountItem,
5
+ AccountSettingItem,
6
+ MailboxItem,
7
+ } from "@remit/data-ports";
8
+ import {
9
+ buildInboxMailboxMap,
10
+ buildListAllThreadsOptions,
11
+ buildListStarredThreadsOptions,
12
+ type InboxMapClient,
13
+ } from "./unified-threads.js";
14
+
15
+ // #44: Flagged filtered client-side over the newest 50 unified-inbox rows, so a
16
+ // star outside that window — or in any folder that is not INBOX — never showed.
17
+ // The starred scope is every non-muted mailbox, served by its own access
18
+ // pattern.
19
+
20
+ const CONFIG_ID = "cfg-1";
21
+
22
+ const account = (accountId: string): AccountItem =>
23
+ ({ accountId }) as unknown as AccountItem;
24
+
25
+ const mailbox = (
26
+ mailboxId: string,
27
+ accountId: string,
28
+ fullPath: string,
29
+ specialUse?: string[],
30
+ ): MailboxItem =>
31
+ ({ mailboxId, accountId, fullPath, specialUse }) as unknown as MailboxItem;
32
+
33
+ const mutedSetting = (
34
+ name: "AccountMuted" | "MailboxMuted",
35
+ targetId: string,
36
+ ): AccountSettingItem =>
37
+ ({
38
+ name: `${name}#${targetId}`,
39
+ value: { kind: "MutedFlag", value: { value: true, setAt: 0 } },
40
+ }) as unknown as AccountSettingItem;
41
+
42
+ const buildClient = (
43
+ accounts: AccountItem[],
44
+ mailboxesByAccount: Record<string, MailboxItem[]>,
45
+ settings: AccountSettingItem[] = [],
46
+ ): InboxMapClient =>
47
+ ({
48
+ account: { listAllByAccountConfig: async () => accounts },
49
+ mailbox: {
50
+ listAllByAccount: async (accountId: string) =>
51
+ mailboxesByAccount[accountId] ?? [],
52
+ },
53
+ accountSetting: { listByAccountConfig: async () => settings },
54
+ }) as unknown as InboxMapClient;
55
+
56
+ describe("buildInboxMailboxMap", () => {
57
+ test("separates the INBOX scope from the wider starred scope", async () => {
58
+ const client = buildClient([account("a1")], {
59
+ a1: [
60
+ mailbox("m-inbox", "a1", "INBOX"),
61
+ mailbox("m-archive", "a1", "Archive", ["Archive"]),
62
+ mailbox("m-sub", "a1", "INBOX/Receipts"),
63
+ ],
64
+ });
65
+
66
+ const { inboxMailboxIds, starredMailboxIds, mailboxIdToAccountId } =
67
+ await buildInboxMailboxMap(CONFIG_ID, client);
68
+
69
+ assert.deepEqual([...inboxMailboxIds], ["m-inbox"]);
70
+ assert.deepEqual([...starredMailboxIds].sort(), [
71
+ "m-archive",
72
+ "m-inbox",
73
+ "m-sub",
74
+ ]);
75
+ // The map must cover every mailbox, or a starred row from Archive could
76
+ // not resolve its accountId.
77
+ assert.equal(mailboxIdToAccountId.get("m-archive"), "a1");
78
+ });
79
+
80
+ // A star on mail in Spam or Trash is not something the starred view should
81
+ // resurface; Gmail's All Mail is a second copy of everything, so including it
82
+ // renders every starred Gmail message twice.
83
+ test("the starred scope excludes All Mail, Junk and Trash", async () => {
84
+ const client = buildClient([account("a1")], {
85
+ a1: [
86
+ mailbox("m-inbox", "a1", "INBOX"),
87
+ mailbox("m-all", "a1", "[Gmail]/All Mail", ["All"]),
88
+ mailbox("m-junk", "a1", "[Gmail]/Spam", ["Junk"]),
89
+ mailbox("m-trash", "a1", "[Gmail]/Trash", ["Trash"]),
90
+ mailbox("m-sent", "a1", "[Gmail]/Sent Mail", ["Sent"]),
91
+ ],
92
+ });
93
+
94
+ const { starredMailboxIds, mailboxIdToAccountId } =
95
+ await buildInboxMailboxMap(CONFIG_ID, client);
96
+
97
+ assert.deepEqual([...starredMailboxIds].sort(), ["m-inbox", "m-sent"]);
98
+ // Excluded mailboxes still resolve an accountId — the map is the wider set.
99
+ assert.equal(mailboxIdToAccountId.get("m-trash"), "a1");
100
+ });
101
+
102
+ test("a mailbox carrying several special uses is excluded on any one of them", async () => {
103
+ const client = buildClient([account("a1")], {
104
+ a1: [mailbox("m-archive-all", "a1", "Archive", ["Archive", "All"])],
105
+ });
106
+
107
+ const { starredMailboxIds } = await buildInboxMailboxMap(CONFIG_ID, client);
108
+ assert.equal(starredMailboxIds.size, 0);
109
+ });
110
+
111
+ test("muted mailboxes are excluded from both scopes", async () => {
112
+ const client = buildClient(
113
+ [account("a1")],
114
+ {
115
+ a1: [
116
+ mailbox("m-inbox", "a1", "INBOX"),
117
+ mailbox("m-muted", "a1", "Archive"),
118
+ ],
119
+ },
120
+ [mutedSetting("MailboxMuted", "m-muted")],
121
+ );
122
+
123
+ const { inboxMailboxIds, starredMailboxIds } = await buildInboxMailboxMap(
124
+ CONFIG_ID,
125
+ client,
126
+ );
127
+
128
+ assert.deepEqual([...inboxMailboxIds], ["m-inbox"]);
129
+ assert.deepEqual([...starredMailboxIds], ["m-inbox"]);
130
+ });
131
+
132
+ test("a muted account contributes no mailboxes", async () => {
133
+ const client = buildClient(
134
+ [account("a1")],
135
+ { a1: [mailbox("m-inbox", "a1", "INBOX")] },
136
+ [mutedSetting("AccountMuted", "a1")],
137
+ );
138
+
139
+ const { starredMailboxIds } = await buildInboxMailboxMap(CONFIG_ID, client);
140
+ assert.equal(starredMailboxIds.size, 0);
141
+ });
142
+ });
143
+
144
+ describe("buildListStarredThreadsOptions", () => {
145
+ test("scopes to every supplied mailbox, not just the inbox", () => {
146
+ const options = buildListStarredThreadsOptions(
147
+ {},
148
+ new Set(["m-inbox", "m-archive"]),
149
+ );
150
+
151
+ assert.deepEqual([...options.mailboxIds].sort(), ["m-archive", "m-inbox"]);
152
+ assert.equal(options.order, "desc");
153
+ assert.equal(options.excludeDeleted, true);
154
+ });
155
+
156
+ test("carries the caller's paging through", () => {
157
+ const options = buildListStarredThreadsOptions(
158
+ { continuationToken: "tok", order: "asc", limit: 10 },
159
+ new Set(["m-inbox"]),
160
+ );
161
+
162
+ assert.equal(options.continuationToken, "tok");
163
+ assert.equal(options.order, "asc");
164
+ assert.equal(options.limit, 10);
165
+ });
166
+
167
+ test("defaults the page size to the unified default", () => {
168
+ const starred = buildListStarredThreadsOptions({}, new Set(["m-inbox"]));
169
+ const unified = buildListAllThreadsOptions({}, new Set(["m-inbox"]));
170
+ assert.equal(starred.limit, unified.limit);
171
+ });
172
+ });
@@ -3,6 +3,7 @@ import type {
3
3
  IAccountSettingRepository,
4
4
  MailboxItem,
5
5
  } from "@remit/data-ports";
6
+ import { MailboxSpecialUse } from "@remit/domain-enums";
6
7
  import type { APIGatewayProxyEvent } from "aws-lambda";
7
8
  import type { Context } from "openapi-backend";
8
9
  import pMap from "p-map";
@@ -34,8 +35,34 @@ export interface InboxMapClient {
34
35
  }
35
36
 
36
37
  /**
37
- * Build a mailboxId→accountId map for all non-muted INBOX mailboxes across
38
- * all non-muted accounts of a given accountConfigId.
38
+ * Special-use folders a star never surfaces from.
39
+ *
40
+ * `Junk` and `Trash` match what Gmail and Fastmail do — a star on mail the user
41
+ * already threw away or that was classed as spam is not something the starred
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.
47
+ */
48
+ const STARRED_EXCLUDED_SPECIAL_USE: readonly string[] = [
49
+ MailboxSpecialUse.All,
50
+ MailboxSpecialUse.Junk,
51
+ MailboxSpecialUse.Trash,
52
+ ];
53
+
54
+ const isExcludedFromStarred = (mailbox: MailboxItem): boolean =>
55
+ mailbox.specialUse?.some((use) =>
56
+ STARRED_EXCLUDED_SPECIAL_USE.includes(use),
57
+ ) === true;
58
+
59
+ /**
60
+ * Build the read scope for the unified listing: a mailboxId→accountId map over
61
+ * 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.
39
66
  *
40
67
  * INBOX is identified by exact match `fullPath.toUpperCase() === "INBOX"` —
41
68
  * MailboxSpecialUse has no Inbox value per RFC 6154. This is the same rule
@@ -67,6 +94,7 @@ export const buildInboxMailboxMap = async (
67
94
  ): Promise<{
68
95
  mailboxIdToAccountId: Map<string, string>;
69
96
  inboxMailboxIds: Set<string>;
97
+ starredMailboxIds: Set<string>;
70
98
  }> => {
71
99
  const [accounts, settings] = await Promise.all([
72
100
  client.account.listAllByAccountConfig(accountConfigId),
@@ -94,21 +122,23 @@ export const buildInboxMailboxMap = async (
94
122
 
95
123
  const mailboxIdToAccountId = new Map<string, string>();
96
124
  const inboxMailboxIds = new Set<string>();
125
+ const starredMailboxIds = new Set<string>();
97
126
 
98
- const inboxes = mailboxLists
127
+ const activeMailboxes = mailboxLists
99
128
  .flat()
100
- .filter(
101
- (mailbox) =>
102
- !isMailboxMuted(mailbox.mailboxId) &&
103
- mailbox.fullPath.toUpperCase() === "INBOX",
104
- );
129
+ .filter((mailbox) => !isMailboxMuted(mailbox.mailboxId));
105
130
 
106
- for (const mailbox of inboxes) {
131
+ for (const mailbox of activeMailboxes) {
107
132
  mailboxIdToAccountId.set(mailbox.mailboxId, mailbox.accountId);
108
- inboxMailboxIds.add(mailbox.mailboxId);
133
+ if (!isExcludedFromStarred(mailbox)) {
134
+ starredMailboxIds.add(mailbox.mailboxId);
135
+ }
136
+ if (mailbox.fullPath.toUpperCase() === "INBOX") {
137
+ inboxMailboxIds.add(mailbox.mailboxId);
138
+ }
109
139
  }
110
140
 
111
- return { mailboxIdToAccountId, inboxMailboxIds };
141
+ return { mailboxIdToAccountId, inboxMailboxIds, starredMailboxIds };
112
142
  };
113
143
 
114
144
  /**
@@ -134,6 +164,28 @@ export const buildListAllThreadsOptions = (
134
164
  excludeDeleted: true,
135
165
  });
136
166
 
167
+ /**
168
+ * Build listByStarred options for the starred (`starred=true`) listing.
169
+ *
170
+ * A star marks the mail, not its placement, so the INBOX narrowing does not
171
+ * apply: the scope is every non-muted mailbox except the ones a star never
172
+ * surfaces from. Same defaults as the unified listing otherwise.
173
+ */
174
+ export const buildListStarredThreadsOptions = (
175
+ query: {
176
+ continuationToken?: string;
177
+ order?: "asc" | "desc";
178
+ limit?: number;
179
+ },
180
+ starredMailboxIds: Set<string>,
181
+ ) => ({
182
+ order: query.order ?? ("desc" as const),
183
+ continuationToken: query.continuationToken,
184
+ limit: query.limit ?? DEFAULT_UNIFIED_THREADS_PAGE_SIZE,
185
+ mailboxIds: starredMailboxIds,
186
+ excludeDeleted: true,
187
+ });
188
+
137
189
  /**
138
190
  * Attach accountId to each enriched ThreadMessageResponse row using the
139
191
  * mailboxId→accountId map built from inbox discovery. Same read-time-attach
@@ -158,28 +210,40 @@ export const UnifiedThreadOperations: Record<
158
210
  ) => {
159
211
  const event = args[0] as APIGatewayProxyEvent;
160
212
  const accountConfigId = getAccountConfigIdFromEvent(event);
161
- const { continuationToken, order, limit } = context.request.query as {
213
+ const { continuationToken, order, limit, starred } = context.request
214
+ .query as {
162
215
  continuationToken?: string;
163
216
  order?: "asc" | "desc";
164
217
  limit?: number;
218
+ starred?: boolean | string;
165
219
  };
220
+ const starredOnly = starred === true || starred === "true";
166
221
 
167
222
  const client = await getClient();
168
223
 
169
- const { mailboxIdToAccountId, inboxMailboxIds } =
224
+ const { mailboxIdToAccountId, inboxMailboxIds, starredMailboxIds } =
170
225
  await buildInboxMailboxMap(accountConfigId, client);
171
226
 
172
- if (inboxMailboxIds.size === 0) {
227
+ const scope = starredOnly ? starredMailboxIds : inboxMailboxIds;
228
+ if (scope.size === 0) {
173
229
  return { items: [], continuationToken: undefined };
174
230
  }
175
231
 
176
- const result = await client.threadMessage.listByDate(
177
- accountConfigId,
178
- buildListAllThreadsOptions(
179
- { continuationToken, order, limit },
180
- inboxMailboxIds,
181
- ),
182
- );
232
+ const result = starredOnly
233
+ ? await client.threadMessage.listByStarred(
234
+ accountConfigId,
235
+ buildListStarredThreadsOptions(
236
+ { continuationToken, order, limit },
237
+ starredMailboxIds,
238
+ ),
239
+ )
240
+ : await client.threadMessage.listByDate(
241
+ accountConfigId,
242
+ buildListAllThreadsOptions(
243
+ { continuationToken, order, limit },
244
+ inboxMailboxIds,
245
+ ),
246
+ );
183
247
 
184
248
  const enriched = await enrichThreadRows(
185
249
  result.items,