@remit/backend 0.0.1
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/.env.test +7 -0
- package/dev-server/content-auth.test.ts +113 -0
- package/dev-server/content-auth.ts +54 -0
- package/dev-server/content-handler.test.ts +105 -0
- package/dev-server/content-handler.ts +79 -0
- package/dev-server/content-path.test.ts +37 -0
- package/dev-server/content-path.ts +27 -0
- package/dev-server/cors.test.ts +48 -0
- package/dev-server/cors.ts +25 -0
- package/dev-server/lambda-helpers.ts +69 -0
- package/dev-server/server.ts +289 -0
- package/package.json +82 -0
- package/src/auth.ts +92 -0
- package/src/config/msoauth.ts +60 -0
- package/src/data-backend.test.ts +25 -0
- package/src/data-backend.ts +20 -0
- package/src/derive/autoMoved.ts +28 -0
- package/src/derive/contentSignature.test.ts +153 -0
- package/src/derive/contentSignature.ts +139 -0
- package/src/derive/contentUrl.test.ts +156 -0
- package/src/derive/contentUrl.ts +70 -0
- package/src/derive/enrichThreadRows.ts +155 -0
- package/src/derive/filterThreadCriteria.test.ts +119 -0
- package/src/derive/filterThreadCriteria.ts +60 -0
- package/src/derive/pendingMoveCounts.ts +90 -0
- package/src/derive/senderTrust.test.ts +67 -0
- package/src/derive/senderTrust.ts +23 -0
- package/src/error.ts +55 -0
- package/src/handlers/account-guards.ts +137 -0
- package/src/handlers/account-oauth.test.ts +383 -0
- package/src/handlers/account-oauth.ts +452 -0
- package/src/handlers/account-overrides.test.ts +69 -0
- package/src/handlers/account-overrides.ts +286 -0
- package/src/handlers/account-ownership.ts +25 -0
- package/src/handlers/account-signature.ts +129 -0
- package/src/handlers/account.ts +524 -0
- package/src/handlers/address.ts +136 -0
- package/src/handlers/config.test.ts +184 -0
- package/src/handlers/config.ts +219 -0
- package/src/handlers/ensure-account-config.ts +30 -0
- package/src/handlers/filter.ts +294 -0
- package/src/handlers/folder-role-appointments.test.ts +250 -0
- package/src/handlers/folder-role-appointments.ts +272 -0
- package/src/handlers/folder-role.ts +76 -0
- package/src/handlers/index.ts +48 -0
- package/src/handlers/mailbox.ts +411 -0
- package/src/handlers/me.ts +190 -0
- package/src/handlers/message.ts +886 -0
- package/src/handlers/organize.test.ts +43 -0
- package/src/handlers/organize.ts +196 -0
- package/src/handlers/outbox.ts +218 -0
- package/src/handlers/search.test.ts +182 -0
- package/src/handlers/search.ts +105 -0
- package/src/handlers/sync-progress.test.ts +158 -0
- package/src/handlers/sync-progress.ts +64 -0
- package/src/handlers/sync.test.ts +146 -0
- package/src/handlers/sync.ts +119 -0
- package/src/handlers/thread.ts +361 -0
- package/src/handlers/unified-threads.ts +196 -0
- package/src/handlers/vip-suggestions.ts +21 -0
- package/src/index.ts +170 -0
- package/src/json.ts +11 -0
- package/src/jwt-auth.test.ts +89 -0
- package/src/jwt-auth.ts +95 -0
- package/src/request-context.test.ts +61 -0
- package/src/request-context.ts +29 -0
- package/src/request.ts +10 -0
- package/src/response.test.ts +107 -0
- package/src/response.ts +80 -0
- package/src/service/compose-postgres.ts +72 -0
- package/src/service/compose-sqlite.ts +80 -0
- package/src/service/create-remit-client.ts +358 -0
- package/src/service/dynamodb.test.ts +100 -0
- package/src/service/dynamodb.ts +58 -0
- package/src/service/filter.ts +55 -0
- package/src/service/fire-and-forget.test.ts +126 -0
- package/src/service/fire-and-forget.ts +79 -0
- package/src/service/localhost-env-config.test.ts +40 -0
- package/src/service/organize.test.ts +384 -0
- package/src/service/organize.ts +354 -0
- package/src/service/semantic-capability.test.ts +64 -0
- package/src/service/semantic-capability.ts +62 -0
- package/src/service/sqs.ts +4 -0
- package/src/service/trigger-sync.test.ts +211 -0
- package/src/service/trigger-sync.ts +102 -0
- package/src/types.ts +161 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MessageCategory,
|
|
3
|
+
SenderTrust,
|
|
4
|
+
ThreadSearchResponse,
|
|
5
|
+
} from "@remit/api-openapi-types";
|
|
6
|
+
import type { ResultList, ThreadMessageItem } from "@remit/data-ports";
|
|
7
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
8
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
9
|
+
import type { Context } from "openapi-backend";
|
|
10
|
+
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
11
|
+
import {
|
|
12
|
+
type EnrichClient,
|
|
13
|
+
enrichThreadRows,
|
|
14
|
+
} from "../derive/enrichThreadRows.js";
|
|
15
|
+
import {
|
|
16
|
+
filterByOffRowCriteria,
|
|
17
|
+
hasOffRowCriteria,
|
|
18
|
+
} from "../derive/filterThreadCriteria.js";
|
|
19
|
+
import { getClient } from "../service/dynamodb.js";
|
|
20
|
+
import type {
|
|
21
|
+
OperationHandler,
|
|
22
|
+
ThreadDetailOperationIds,
|
|
23
|
+
ThreadOperationIds,
|
|
24
|
+
} from "../types.js";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_THREADS_PAGE_SIZE = 50;
|
|
27
|
+
|
|
28
|
+
// Project to fields used by ListThreadsResponse — keep in sync with handler mapping.
|
|
29
|
+
// Includes ThreadMessage table key fields (accountConfigId, threadMessageId) and
|
|
30
|
+
// the lsi2 index components (mailboxId, sentDate) so ElectroDB can materialize
|
|
31
|
+
// entities and pagination cursors from projected reads. accountConfigId +
|
|
32
|
+
// fromEmail are required at read time to derive the From Address row for
|
|
33
|
+
// senderTrust enrichment (see enrichThreadRows).
|
|
34
|
+
const THREAD_LIST_ATTRIBUTES: ReadonlyArray<keyof ThreadMessageItem> = [
|
|
35
|
+
"threadMessageId",
|
|
36
|
+
"threadId",
|
|
37
|
+
"messageId",
|
|
38
|
+
"accountConfigId",
|
|
39
|
+
"mailboxId",
|
|
40
|
+
"fromEmail",
|
|
41
|
+
"fromName",
|
|
42
|
+
"subject",
|
|
43
|
+
"sentDate",
|
|
44
|
+
"isRead",
|
|
45
|
+
"hasAttachment",
|
|
46
|
+
"star",
|
|
47
|
+
"hasStars",
|
|
48
|
+
"isDeleted",
|
|
49
|
+
"snippet",
|
|
50
|
+
"createdAt",
|
|
51
|
+
"updatedAt",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build the `listByMailbox` options for the thread list handler.
|
|
56
|
+
*
|
|
57
|
+
* Exposed as a pure helper so the `excludeDeleted: true` default — which is
|
|
58
|
+
* the entire point of the #212 fix on the handler side — is testable without
|
|
59
|
+
* standing up DynamoDB. The defaults must stay opt-out from the user (a
|
|
60
|
+
* future Trash / All-Mail view can flip the flag) and opt-in at the
|
|
61
|
+
* service layer.
|
|
62
|
+
*/
|
|
63
|
+
export const buildListThreadsOptions = (query: {
|
|
64
|
+
continuationToken?: string;
|
|
65
|
+
order?: "asc" | "desc";
|
|
66
|
+
}) => ({
|
|
67
|
+
order: query.order ?? ("desc" as const),
|
|
68
|
+
continuationToken: query.continuationToken,
|
|
69
|
+
limit: DEFAULT_THREADS_PAGE_SIZE,
|
|
70
|
+
attributes: [...THREAD_LIST_ATTRIBUTES],
|
|
71
|
+
excludeDeleted: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the `searchByMailboxWindow` options for the thread search handler.
|
|
76
|
+
* Same #212 `excludeDeleted` default as `buildListThreadsOptions`. The caller's
|
|
77
|
+
* `limit` is forwarded raw and clamped server-side (THREAD_SEARCH_MAX_LIMIT).
|
|
78
|
+
*/
|
|
79
|
+
export const buildSearchThreadsOptions = (query: {
|
|
80
|
+
continuationToken?: string;
|
|
81
|
+
order?: "asc" | "desc";
|
|
82
|
+
limit?: number;
|
|
83
|
+
}) => ({
|
|
84
|
+
order: query.order ?? ("desc" as const),
|
|
85
|
+
continuationToken: query.continuationToken,
|
|
86
|
+
limit: query.limit,
|
|
87
|
+
attributes: [...THREAD_LIST_ATTRIBUTES],
|
|
88
|
+
excludeDeleted: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const toArray = <T>(value: T | T[] | undefined): T[] | undefined => {
|
|
92
|
+
if (value === undefined) return undefined;
|
|
93
|
+
return Array.isArray(value) ? value : [value];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type WindowOptions = {
|
|
97
|
+
order?: "asc" | "desc";
|
|
98
|
+
limit?: number;
|
|
99
|
+
continuationToken?: string;
|
|
100
|
+
attributes?: Array<keyof ThreadMessageItem>;
|
|
101
|
+
excludeDeleted?: boolean;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type ThreadSearch = {
|
|
105
|
+
query?: string;
|
|
106
|
+
subject?: string;
|
|
107
|
+
from?: string;
|
|
108
|
+
unread?: boolean;
|
|
109
|
+
starred?: boolean;
|
|
110
|
+
attachments?: boolean;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Minimal client surface `executeThreadSearch` needs. Declared structurally
|
|
115
|
+
* (like `EnrichClient`) so the orchestration — index/window read, count, and
|
|
116
|
+
* the off-row enrich/filter — is testable with an in-memory fake.
|
|
117
|
+
*/
|
|
118
|
+
export interface ThreadSearchClient extends EnrichClient {
|
|
119
|
+
threadMessage: {
|
|
120
|
+
searchByMailboxWindow(
|
|
121
|
+
accountConfigId: string,
|
|
122
|
+
mailboxId: string,
|
|
123
|
+
search: ThreadSearch,
|
|
124
|
+
options?: WindowOptions,
|
|
125
|
+
): Promise<ResultList<ThreadMessageItem>>;
|
|
126
|
+
countByMailbox(
|
|
127
|
+
accountConfigId: string,
|
|
128
|
+
mailboxId: string,
|
|
129
|
+
search: ThreadSearch,
|
|
130
|
+
options?: {
|
|
131
|
+
limit?: number;
|
|
132
|
+
excludeDeleted?: boolean;
|
|
133
|
+
order?: "asc" | "desc";
|
|
134
|
+
},
|
|
135
|
+
): Promise<number>;
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type ThreadSearchParams = {
|
|
140
|
+
continuationToken?: string;
|
|
141
|
+
order?: "asc" | "desc";
|
|
142
|
+
query?: string;
|
|
143
|
+
subject?: string;
|
|
144
|
+
from?: string;
|
|
145
|
+
unread?: boolean;
|
|
146
|
+
starred?: boolean;
|
|
147
|
+
attachments?: boolean;
|
|
148
|
+
senderTrust?: SenderTrust[];
|
|
149
|
+
category?: MessageCategory[];
|
|
150
|
+
dkimMismatch?: boolean;
|
|
151
|
+
count?: boolean;
|
|
152
|
+
results?: boolean;
|
|
153
|
+
limit?: number;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run a per-mailbox thread search: select the index/window read, optionally
|
|
158
|
+
* count, and resolve off-row criteria by enriching+filtering the window. The
|
|
159
|
+
* `results` toggle omits `items` (count-only) and `count` adds the match count.
|
|
160
|
+
* Off-row criteria force a window read+enrich even in count-only mode, since no
|
|
161
|
+
* Select:COUNT can see fields that aren't on the row.
|
|
162
|
+
*/
|
|
163
|
+
export const executeThreadSearch = async (
|
|
164
|
+
client: ThreadSearchClient,
|
|
165
|
+
accountConfigId: string,
|
|
166
|
+
mailboxId: string,
|
|
167
|
+
params: ThreadSearchParams,
|
|
168
|
+
): Promise<ThreadSearchResponse> => {
|
|
169
|
+
const search = {
|
|
170
|
+
query: params.query,
|
|
171
|
+
subject: params.subject,
|
|
172
|
+
from: params.from,
|
|
173
|
+
unread: params.unread,
|
|
174
|
+
starred: params.starred,
|
|
175
|
+
attachments: params.attachments,
|
|
176
|
+
};
|
|
177
|
+
const offRow = {
|
|
178
|
+
senderTrust: params.senderTrust,
|
|
179
|
+
category: params.category,
|
|
180
|
+
dkimMismatch: params.dkimMismatch,
|
|
181
|
+
};
|
|
182
|
+
const wantCount = params.count === true;
|
|
183
|
+
const wantResults = params.results !== false;
|
|
184
|
+
const options = buildSearchThreadsOptions({
|
|
185
|
+
continuationToken: params.continuationToken,
|
|
186
|
+
order: params.order,
|
|
187
|
+
limit: params.limit,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
if (hasOffRowCriteria(offRow)) {
|
|
191
|
+
const window = await client.threadMessage.searchByMailboxWindow(
|
|
192
|
+
accountConfigId,
|
|
193
|
+
mailboxId,
|
|
194
|
+
search,
|
|
195
|
+
options,
|
|
196
|
+
);
|
|
197
|
+
const enriched = await enrichThreadRows(
|
|
198
|
+
window.items,
|
|
199
|
+
client,
|
|
200
|
+
accountConfigId,
|
|
201
|
+
);
|
|
202
|
+
const filtered = filterByOffRowCriteria(enriched, offRow);
|
|
203
|
+
return {
|
|
204
|
+
...(wantResults
|
|
205
|
+
? { items: filtered, continuationToken: window.continuationToken }
|
|
206
|
+
: {}),
|
|
207
|
+
...(wantCount ? { count: filtered.length } : {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const response: ThreadSearchResponse = {};
|
|
212
|
+
|
|
213
|
+
if (wantResults) {
|
|
214
|
+
const window = await client.threadMessage.searchByMailboxWindow(
|
|
215
|
+
accountConfigId,
|
|
216
|
+
mailboxId,
|
|
217
|
+
search,
|
|
218
|
+
options,
|
|
219
|
+
);
|
|
220
|
+
response.items = await enrichThreadRows(
|
|
221
|
+
window.items,
|
|
222
|
+
client,
|
|
223
|
+
accountConfigId,
|
|
224
|
+
);
|
|
225
|
+
response.continuationToken = window.continuationToken;
|
|
226
|
+
// The window read already yielded the exact match set, so the count is
|
|
227
|
+
// its length — no separate Select:COUNT, and count == items.length by
|
|
228
|
+
// construction.
|
|
229
|
+
if (wantCount) response.count = response.items.length;
|
|
230
|
+
return response;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (wantCount) {
|
|
234
|
+
response.count = await client.threadMessage.countByMailbox(
|
|
235
|
+
accountConfigId,
|
|
236
|
+
mailboxId,
|
|
237
|
+
search,
|
|
238
|
+
{ limit: params.limit, excludeDeleted: true, order: params.order },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return response;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build the `listByThread` options for the thread-messages handler. Same
|
|
247
|
+
* #212 default as the listing handlers — a soft-deleted message inside a
|
|
248
|
+
* conversation should not surface in the conversation pane either.
|
|
249
|
+
*/
|
|
250
|
+
export const buildListThreadMessagesOptions = (query: {
|
|
251
|
+
order?: "asc" | "desc";
|
|
252
|
+
mailboxId?: string;
|
|
253
|
+
}) => ({
|
|
254
|
+
order: query.order ?? ("desc" as const),
|
|
255
|
+
mailboxId: query.mailboxId,
|
|
256
|
+
excludeDeleted: true,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
export const ThreadOperations: Record<
|
|
260
|
+
ThreadOperationIds,
|
|
261
|
+
OperationHandler<ThreadOperationIds>
|
|
262
|
+
> = {
|
|
263
|
+
ThreadOperations_listThreads: async (
|
|
264
|
+
context: Context,
|
|
265
|
+
...args: unknown[]
|
|
266
|
+
) => {
|
|
267
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
268
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
269
|
+
const { mailboxId } = context.request.params as { mailboxId: string };
|
|
270
|
+
const { continuationToken, order } = context.request.query as {
|
|
271
|
+
continuationToken?: string;
|
|
272
|
+
order?: "asc" | "desc";
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const client = await getClient();
|
|
276
|
+
const result = await client.threadMessage.listByMailbox(
|
|
277
|
+
accountConfigId,
|
|
278
|
+
mailboxId,
|
|
279
|
+
buildListThreadsOptions({ continuationToken, order }),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const items = await enrichThreadRows(result.items, client, accountConfigId);
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
items,
|
|
286
|
+
continuationToken: result.continuationToken,
|
|
287
|
+
};
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
ThreadOperations_searchThreads: async (
|
|
291
|
+
context: Context,
|
|
292
|
+
...args: unknown[]
|
|
293
|
+
) => {
|
|
294
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
295
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
296
|
+
const { mailboxId } = context.request.params as { mailboxId: string };
|
|
297
|
+
const raw = context.request.query as {
|
|
298
|
+
continuationToken?: string;
|
|
299
|
+
order?: "asc" | "desc";
|
|
300
|
+
query?: string;
|
|
301
|
+
subject?: string;
|
|
302
|
+
from?: string;
|
|
303
|
+
unread?: boolean;
|
|
304
|
+
starred?: boolean;
|
|
305
|
+
attachments?: boolean;
|
|
306
|
+
senderTrust?: SenderTrust | SenderTrust[];
|
|
307
|
+
category?: MessageCategory | MessageCategory[];
|
|
308
|
+
dkimMismatch?: boolean;
|
|
309
|
+
count?: boolean;
|
|
310
|
+
results?: boolean;
|
|
311
|
+
limit?: number;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
return executeThreadSearch(await getClient(), accountConfigId, mailboxId, {
|
|
315
|
+
...raw,
|
|
316
|
+
senderTrust: toArray(raw.senderTrust),
|
|
317
|
+
category: toArray(raw.category),
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
export const ThreadDetailOperations: Record<
|
|
323
|
+
ThreadDetailOperationIds,
|
|
324
|
+
OperationHandler<ThreadDetailOperationIds>
|
|
325
|
+
> = {
|
|
326
|
+
ThreadDetailOperations_listThreadMessages: async (
|
|
327
|
+
context,
|
|
328
|
+
...args: unknown[]
|
|
329
|
+
) => {
|
|
330
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
331
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
332
|
+
const { threadId } = context.request.params as { threadId: string };
|
|
333
|
+
const { order, mailboxId } = context.request.query as {
|
|
334
|
+
order?: "asc" | "desc";
|
|
335
|
+
mailboxId?: string;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const client = await getClient();
|
|
339
|
+
const result = await client.threadMessage.listByThread(
|
|
340
|
+
threadId,
|
|
341
|
+
accountConfigId,
|
|
342
|
+
buildListThreadMessagesOptions({ order, mailboxId }),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
// Defense-in-depth: the query is already scoped to accountConfigId; assert
|
|
346
|
+
// no foreign row slips into the response. `read`-class → 404, no existence
|
|
347
|
+
// leak, consistent with the mailbox/message guards.
|
|
348
|
+
for (const row of result.items) {
|
|
349
|
+
if (row.accountConfigId !== accountConfigId) {
|
|
350
|
+
throw new NotFoundError(`Thread not found: ${threadId}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const items = await enrichThreadRows(result.items, client, accountConfigId);
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
items,
|
|
358
|
+
continuationToken: result.continuationToken,
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountItem,
|
|
3
|
+
IAccountSettingRepository,
|
|
4
|
+
MailboxItem,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
7
|
+
import type { Context } from "openapi-backend";
|
|
8
|
+
import pMap from "p-map";
|
|
9
|
+
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
10
|
+
import { enrichThreadRows } from "../derive/enrichThreadRows.js";
|
|
11
|
+
import { getClient } from "../service/dynamodb.js";
|
|
12
|
+
import type { OperationHandler, UnifiedThreadOperationIds } from "../types.js";
|
|
13
|
+
import {
|
|
14
|
+
groupAccountOverrides,
|
|
15
|
+
groupMailboxOverrides,
|
|
16
|
+
} from "./account-overrides.js";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_UNIFIED_THREADS_PAGE_SIZE = 50;
|
|
19
|
+
const MAILBOX_LIST_CONCURRENCY = 5;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal client surface for inbox discovery. Structurally satisfied by
|
|
23
|
+
* RemitClient; narrowed so unit tests can pass an in-memory fake without
|
|
24
|
+
* standing up DynamoDB.
|
|
25
|
+
*/
|
|
26
|
+
export interface InboxMapClient {
|
|
27
|
+
account: {
|
|
28
|
+
listAllByAccountConfig(accountConfigId: string): Promise<AccountItem[]>;
|
|
29
|
+
};
|
|
30
|
+
mailbox: {
|
|
31
|
+
listAllByAccount(accountId: string): Promise<MailboxItem[]>;
|
|
32
|
+
};
|
|
33
|
+
accountSetting: Pick<IAccountSettingRepository, "listByAccountConfig">;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a mailboxId→accountId map for all non-muted INBOX mailboxes across
|
|
38
|
+
* all non-muted accounts of a given accountConfigId.
|
|
39
|
+
*
|
|
40
|
+
* INBOX is identified by exact match `fullPath.toUpperCase() === "INBOX"` —
|
|
41
|
+
* MailboxSpecialUse has no Inbox value per RFC 6154. This is the same rule
|
|
42
|
+
* sync-mailboxes.ts uses for INBOX-first sync ordering. By design this matches
|
|
43
|
+
* only the top-level INBOX per account: namespaced sub-paths (`INBOX/Receipts`)
|
|
44
|
+
* and non-English server primaries are excluded from the unified view, so the
|
|
45
|
+
* product contract is "unified inbox = each account's primary INBOX, not
|
|
46
|
+
* sub-folders" — consistent with sync-mailboxes.
|
|
47
|
+
*
|
|
48
|
+
* Muted accounts and muted mailboxes are excluded so the unified thread
|
|
49
|
+
* listing respects the mute flags from #437 (#433). The mute flags now live in
|
|
50
|
+
* per-target AccountSetting rows (RFC 032), loaded once for the whole config and
|
|
51
|
+
* keyed by accountId/mailboxId; a target counts as muted only when its MutedFlag
|
|
52
|
+
* exists AND `value === true`.
|
|
53
|
+
*
|
|
54
|
+
* NOTE (read cost / mute drift): this fan-out (one listAllByAccountConfig + N
|
|
55
|
+
* per-account mailbox listings) runs on *every* page request and is not cached
|
|
56
|
+
* across a pagination session. Acceptable for v1 — mailbox lists are small —
|
|
57
|
+
* but it means the inbox/mute filter set is rebuilt per page from live state,
|
|
58
|
+
* so muting/unmuting a mailbox mid-pagination changes which rows match on
|
|
59
|
+
* subsequent pages (the cursor is an opaque byDate position; mute changes take
|
|
60
|
+
* effect on the next page, never retroactively). Memoizing the map within a
|
|
61
|
+
* session (or threading it through the continuationToken) is a natural
|
|
62
|
+
* follow-up — tracked alongside the stored-isInbox index work in issue #443.
|
|
63
|
+
*/
|
|
64
|
+
export const buildInboxMailboxMap = async (
|
|
65
|
+
accountConfigId: string,
|
|
66
|
+
client: InboxMapClient,
|
|
67
|
+
): Promise<{
|
|
68
|
+
mailboxIdToAccountId: Map<string, string>;
|
|
69
|
+
inboxMailboxIds: Set<string>;
|
|
70
|
+
}> => {
|
|
71
|
+
const [accounts, settings] = await Promise.all([
|
|
72
|
+
client.account.listAllByAccountConfig(accountConfigId),
|
|
73
|
+
client.accountSetting.listByAccountConfig(accountConfigId),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// Mute flags live in per-target AccountSetting rows (RFC 032). A target is
|
|
77
|
+
// muted only when its MutedFlag exists and `value === true`.
|
|
78
|
+
const accountOverrides = groupAccountOverrides(settings);
|
|
79
|
+
const mailboxOverrides = groupMailboxOverrides(settings);
|
|
80
|
+
const isAccountMuted = (accountId: string): boolean =>
|
|
81
|
+
accountOverrides.get(accountId)?.muted?.value === true;
|
|
82
|
+
const isMailboxMuted = (mailboxId: string): boolean =>
|
|
83
|
+
mailboxOverrides.get(mailboxId)?.muted?.value === true;
|
|
84
|
+
|
|
85
|
+
const activeAccounts = accounts.filter(
|
|
86
|
+
(account) => !isAccountMuted(account.accountId),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const mailboxLists = await pMap(
|
|
90
|
+
activeAccounts,
|
|
91
|
+
(account) => client.mailbox.listAllByAccount(account.accountId),
|
|
92
|
+
{ concurrency: MAILBOX_LIST_CONCURRENCY },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const mailboxIdToAccountId = new Map<string, string>();
|
|
96
|
+
const inboxMailboxIds = new Set<string>();
|
|
97
|
+
|
|
98
|
+
const inboxes = mailboxLists
|
|
99
|
+
.flat()
|
|
100
|
+
.filter(
|
|
101
|
+
(mailbox) =>
|
|
102
|
+
!isMailboxMuted(mailbox.mailboxId) &&
|
|
103
|
+
mailbox.fullPath.toUpperCase() === "INBOX",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
for (const mailbox of inboxes) {
|
|
107
|
+
mailboxIdToAccountId.set(mailbox.mailboxId, mailbox.accountId);
|
|
108
|
+
inboxMailboxIds.add(mailbox.mailboxId);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { mailboxIdToAccountId, inboxMailboxIds };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build listByDate options for the unified thread listing.
|
|
116
|
+
*
|
|
117
|
+
* Exposed as a pure helper (same pattern as `buildListThreadsOptions` in
|
|
118
|
+
* thread.ts) so the defaults — `order: "desc"`, default page size, and the
|
|
119
|
+
* non-negotiable `excludeDeleted: true` (#212) — are testable without
|
|
120
|
+
* standing up DynamoDB.
|
|
121
|
+
*/
|
|
122
|
+
export const buildListAllThreadsOptions = (
|
|
123
|
+
query: {
|
|
124
|
+
continuationToken?: string;
|
|
125
|
+
order?: "asc" | "desc";
|
|
126
|
+
limit?: number;
|
|
127
|
+
},
|
|
128
|
+
inboxMailboxIds: Set<string>,
|
|
129
|
+
) => ({
|
|
130
|
+
order: query.order ?? ("desc" as const),
|
|
131
|
+
continuationToken: query.continuationToken,
|
|
132
|
+
limit: query.limit ?? DEFAULT_UNIFIED_THREADS_PAGE_SIZE,
|
|
133
|
+
inboxMailboxIds,
|
|
134
|
+
excludeDeleted: true,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Attach accountId to each enriched ThreadMessageResponse row using the
|
|
139
|
+
* mailboxId→accountId map built from inbox discovery. Same read-time-attach
|
|
140
|
+
* pattern as senderTrust/category in enrichThreadRows.
|
|
141
|
+
*/
|
|
142
|
+
export const attachAccountIds = (
|
|
143
|
+
rows: Awaited<ReturnType<typeof enrichThreadRows>>,
|
|
144
|
+
mailboxIdToAccountId: Map<string, string>,
|
|
145
|
+
): typeof rows =>
|
|
146
|
+
rows.map((row) => ({
|
|
147
|
+
...row,
|
|
148
|
+
accountId: mailboxIdToAccountId.get(row.mailboxId),
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
export const UnifiedThreadOperations: Record<
|
|
152
|
+
UnifiedThreadOperationIds,
|
|
153
|
+
OperationHandler<UnifiedThreadOperationIds>
|
|
154
|
+
> = {
|
|
155
|
+
UnifiedThreadOperations_listAllThreads: async (
|
|
156
|
+
context: Context,
|
|
157
|
+
...args: unknown[]
|
|
158
|
+
) => {
|
|
159
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
160
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
161
|
+
const { continuationToken, order, limit } = context.request.query as {
|
|
162
|
+
continuationToken?: string;
|
|
163
|
+
order?: "asc" | "desc";
|
|
164
|
+
limit?: number;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const client = await getClient();
|
|
168
|
+
|
|
169
|
+
const { mailboxIdToAccountId, inboxMailboxIds } =
|
|
170
|
+
await buildInboxMailboxMap(accountConfigId, client);
|
|
171
|
+
|
|
172
|
+
if (inboxMailboxIds.size === 0) {
|
|
173
|
+
return { items: [], continuationToken: undefined };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = await client.threadMessage.listByDate(
|
|
177
|
+
accountConfigId,
|
|
178
|
+
buildListAllThreadsOptions(
|
|
179
|
+
{ continuationToken, order, limit },
|
|
180
|
+
inboxMailboxIds,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const enriched = await enrichThreadRows(
|
|
185
|
+
result.items,
|
|
186
|
+
client,
|
|
187
|
+
accountConfigId,
|
|
188
|
+
);
|
|
189
|
+
const items = attachAccountIds(enriched, mailboxIdToAccountId);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
items,
|
|
193
|
+
continuationToken: result.continuationToken,
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { VipSuggestionEntry } from "@remit/api-openapi-types";
|
|
2
|
+
import type { AddressItem } from "@remit/data-ports";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Map an Address row to the wire-format `VipSuggestionEntry` returned by
|
|
6
|
+
* `GET /me/vip-suggestions` (issue #234). Absent counters are normalised to
|
|
7
|
+
* zero so the UI can render `12 received · 4 sent · 2 replies` without a
|
|
8
|
+
* `?? 0` fallback at every render site.
|
|
9
|
+
*/
|
|
10
|
+
export const toVipSuggestionEntry = (
|
|
11
|
+
item: AddressItem,
|
|
12
|
+
): VipSuggestionEntry => ({
|
|
13
|
+
addressId: item.addressId,
|
|
14
|
+
displayName: item.displayName,
|
|
15
|
+
normalizedEmail: item.normalizedEmail,
|
|
16
|
+
inboundCount: item.inboundCount ?? 0,
|
|
17
|
+
outboundCount: item.outboundCount ?? 0,
|
|
18
|
+
replyCount: item.replyCount ?? 0,
|
|
19
|
+
lastInboundAt: item.lastInboundAt,
|
|
20
|
+
lastReplyAt: item.lastReplyAt,
|
|
21
|
+
});
|