@remit/backend 0.0.8 → 0.0.10
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
|
@@ -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
|
+
});
|
package/src/handlers/thread.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
406
|
+
const items = await enrichThreadRows(
|
|
407
|
+
dedupeThreadMessages(result.items),
|
|
408
|
+
client,
|
|
409
|
+
accountConfigId,
|
|
410
|
+
);
|
|
355
411
|
|
|
356
412
|
return {
|
|
357
413
|
items,
|
|
@@ -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
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
|
131
|
+
for (const mailbox of activeMailboxes) {
|
|
107
132
|
mailboxIdToAccountId.set(mailbox.mailboxId, mailbox.accountId);
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
227
|
+
const scope = starredOnly ? starredMailboxIds : inboxMailboxIds;
|
|
228
|
+
if (scope.size === 0) {
|
|
173
229
|
return { items: [], continuationToken: undefined };
|
|
174
230
|
}
|
|
175
231
|
|
|
176
|
-
const result =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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,
|