@remit/backend 0.0.12 → 0.0.14
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.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "Remit Mail Inspector API backend",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"test": "npm run test:typecheck && npm run test:run",
|
|
39
39
|
"bundle": "esbuild 'src/index.ts' --sourcemap --bundle --platform=node --outfile=dist/index.js",
|
|
40
40
|
"dev": "node --env-file .env.test --watch --import tsx dev-server/server.ts",
|
|
41
|
-
"dev:remote": "node --env-file ../../.env.dev --watch --import tsx dev-server/server.ts"
|
|
41
|
+
"dev:remote": "node --env-file ../../.env.dev --watch --import tsx dev-server/server.ts",
|
|
42
|
+
"serve": "node --import tsx dev-server/server.ts"
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@aws-sdk/core": "*",
|
|
@@ -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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
63
|
-
* the unified inbox scope)
|
|
64
|
-
* surface from, see `STARRED_EXCLUDED_SPECIAL_USE`)
|
|
65
|
-
*
|
|
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 {
|
|
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 {
|
|
225
|
-
|
|
365
|
+
const {
|
|
366
|
+
mailboxIdToAccountId,
|
|
367
|
+
inboxMailboxIds,
|
|
368
|
+
starredMailboxIds,
|
|
369
|
+
searchMailboxIds,
|
|
370
|
+
virtualCopyMailboxIds,
|
|
371
|
+
} = await buildInboxMailboxMap(accountConfigId, client);
|
|
226
372
|
|
|
227
|
-
|
|
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 =
|
|
233
|
-
? await client.threadMessage.
|
|
385
|
+
const result = searching
|
|
386
|
+
? await client.threadMessage.searchByDate(
|
|
234
387
|
accountConfigId,
|
|
235
|
-
|
|
388
|
+
{ query: searchText, starred: starredOnly ? true : undefined },
|
|
389
|
+
buildSearchAllThreadsOptions(
|
|
236
390
|
{ continuationToken, order, limit },
|
|
237
|
-
|
|
391
|
+
searchScope,
|
|
238
392
|
),
|
|
239
393
|
)
|
|
240
|
-
:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 {
|