@remit/backend 0.0.142 → 0.0.144
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/dist/dev-server/content-handler.d.ts +38 -0
- package/dist/dev-server/content-handler.js +46 -0
- package/dist/src/api.d.ts +7 -0
- package/dist/src/api.js +144 -0
- package/dist/src/auth.d.ts +30 -0
- package/dist/src/auth.js +73 -0
- package/dist/src/config/msoauth.d.ts +30 -0
- package/dist/src/config/msoauth.js +30 -0
- package/{src/data-backend.ts → dist/src/data-backend.d.ts} +2 -4
- package/dist/src/data-backend.js +12 -0
- package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
- package/dist/src/derive/autoMoved.js +49 -0
- package/dist/src/derive/contentSignature.d.ts +41 -0
- package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +27 -72
- package/dist/src/derive/contentUrl.d.ts +46 -0
- package/dist/src/derive/contentUrl.js +46 -0
- package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
- package/dist/src/derive/deriveMuted.js +7 -0
- package/dist/src/derive/enrichThreadRows.d.ts +118 -0
- package/dist/src/derive/enrichThreadRows.js +151 -0
- package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
- package/dist/src/derive/filterThreadCriteria.js +24 -0
- package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
- package/dist/src/derive/pendingMoveCounts.js +53 -0
- package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
- package/dist/src/derive/senderTrust.js +19 -0
- package/dist/src/error.d.ts +21 -0
- package/dist/src/error.js +85 -0
- package/dist/src/handlers/account-guards.d.ts +36 -0
- package/dist/src/handlers/account-guards.js +107 -0
- package/dist/src/handlers/account-oauth.d.ts +14 -0
- package/dist/src/handlers/account-oauth.js +387 -0
- package/dist/src/handlers/account-overrides.d.ts +53 -0
- package/dist/src/handlers/account-overrides.js +233 -0
- package/dist/src/handlers/account-signature.d.ts +28 -0
- package/dist/src/handlers/account-signature.js +85 -0
- package/dist/src/handlers/account.d.ts +6 -0
- package/dist/src/handlers/account.js +350 -0
- package/dist/src/handlers/address.d.ts +35 -0
- package/dist/src/handlers/address.js +141 -0
- package/dist/src/handlers/calendar-event.d.ts +70 -0
- package/dist/src/handlers/calendar-event.js +425 -0
- package/dist/src/handlers/calendar-feed.d.ts +47 -0
- package/dist/src/handlers/calendar-feed.js +192 -0
- package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
- package/dist/src/handlers/calendar-suggestion.js +212 -0
- package/dist/src/handlers/calendar.d.ts +116 -0
- package/dist/src/handlers/calendar.js +330 -0
- package/dist/src/handlers/config.d.ts +43 -0
- package/dist/src/handlers/config.js +176 -0
- package/dist/src/handlers/ensure-account-config.d.ts +9 -0
- package/dist/src/handlers/ensure-account-config.js +27 -0
- package/dist/src/handlers/filter.d.ts +3 -0
- package/dist/src/handlers/filter.js +103 -0
- package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
- package/dist/src/handlers/folder-role-appointments.js +168 -0
- package/dist/src/handlers/folder-role-labels.d.ts +26 -0
- package/dist/src/handlers/folder-role-labels.js +40 -0
- package/dist/src/handlers/folder-role.d.ts +9 -0
- package/dist/src/handlers/folder-role.js +40 -0
- package/dist/src/handlers/index.d.ts +2 -0
- package/dist/src/handlers/index.js +60 -0
- package/dist/src/handlers/label.d.ts +43 -0
- package/dist/src/handlers/label.js +114 -0
- package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
- package/dist/src/handlers/mailbox-bindings.js +61 -0
- package/dist/src/handlers/mailbox.d.ts +23 -0
- package/dist/src/handlers/mailbox.js +219 -0
- package/dist/src/handlers/me.d.ts +2 -0
- package/dist/src/handlers/me.js +138 -0
- package/dist/src/handlers/message.d.ts +84 -0
- package/dist/src/handlers/message.js +607 -0
- package/dist/src/handlers/organize.d.ts +10 -0
- package/dist/src/handlers/organize.js +112 -0
- package/dist/src/handlers/outbox-attachment.d.ts +23 -0
- package/dist/src/handlers/outbox-attachment.js +81 -0
- package/dist/src/handlers/outbox.d.ts +4 -0
- package/dist/src/handlers/outbox.js +144 -0
- package/dist/src/handlers/sync-progress.d.ts +46 -0
- package/dist/src/handlers/sync-progress.js +63 -0
- package/dist/src/handlers/sync.d.ts +28 -0
- package/dist/src/handlers/sync.js +82 -0
- package/dist/src/handlers/system-update.d.ts +2 -0
- package/dist/src/handlers/system-update.js +172 -0
- package/dist/src/handlers/thread.d.ts +148 -0
- package/dist/src/handlers/thread.js +233 -0
- package/dist/src/handlers/unified-threads.d.ts +247 -0
- package/dist/src/handlers/unified-threads.js +409 -0
- package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
- package/dist/src/handlers/vip-suggestions.js +16 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +37 -0
- package/{src/json.ts → dist/src/json.d.ts} +1 -4
- package/dist/src/json.js +10 -0
- package/dist/src/jwt-auth.d.ts +18 -0
- package/dist/src/jwt-auth.js +97 -0
- package/dist/src/request-context.d.ts +9 -0
- package/dist/src/request-context.js +22 -0
- package/dist/src/request.d.ts +3 -0
- package/dist/src/request.js +7 -0
- package/dist/src/response.d.ts +27 -0
- package/dist/src/response.js +87 -0
- package/dist/src/service/account-deletion.d.ts +22 -0
- package/dist/src/service/account-deletion.js +39 -0
- package/dist/src/service/compose-sqlite.d.ts +2 -0
- package/dist/src/service/compose-sqlite.js +11 -0
- package/dist/src/service/create-remit-client.d.ts +95 -0
- package/dist/src/service/create-remit-client.js +207 -0
- package/dist/src/service/data-client.d.ts +12 -0
- package/dist/src/service/data-client.js +44 -0
- package/dist/src/service/export-identity.d.ts +3 -0
- package/dist/src/service/export-identity.js +35 -0
- package/dist/src/service/fire-and-forget.d.ts +54 -0
- package/dist/src/service/fire-and-forget.js +38 -0
- package/dist/src/service/organize.d.ts +184 -0
- package/dist/src/service/organize.js +384 -0
- package/dist/src/service/refusal.d.ts +4 -0
- package/dist/src/service/refusal.js +20 -0
- package/dist/src/service/sender-category.d.ts +51 -0
- package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
- package/dist/src/service/sqs.d.ts +1 -0
- package/{src/service/sqs.ts → dist/src/service/sqs.js} +0 -1
- package/dist/src/service/trigger-sync.d.ts +51 -0
- package/dist/src/service/trigger-sync.js +39 -0
- package/dist/src/types.d.ts +49 -0
- package/dist/src/types.js +1 -0
- package/package.json +113 -98
- package/.env.test +0 -4
- package/dev-server/content-auth.test.ts +0 -113
- package/dev-server/content-auth.ts +0 -54
- package/dev-server/content-handler.test.ts +0 -105
- package/dev-server/content-handler.ts +0 -79
- package/dev-server/content-path.test.ts +0 -37
- package/dev-server/content-path.ts +0 -27
- package/dev-server/cors.test.ts +0 -48
- package/dev-server/cors.ts +0 -25
- package/dev-server/keep-alive.test.ts +0 -24
- package/dev-server/lambda-helpers.ts +0 -69
- package/dev-server/log-output.test.ts +0 -173
- package/dev-server/oauth-callback-route.test.ts +0 -66
- package/dev-server/relational-health.test.ts +0 -83
- package/dev-server/relational-health.ts +0 -39
- package/dev-server/server.ts +0 -384
- package/dev-server/sync-age.test.ts +0 -91
- package/dev-server/sync-age.ts +0 -55
- package/dev-server/upload-handler.ts +0 -132
- package/dev-server/upload-route.test.ts +0 -196
- package/scripts/backfill-classification.ts +0 -91
- package/scripts/backfill-list-id.ts +0 -83
- package/scripts/config-save.ts +0 -128
- package/src/auth.test.ts +0 -12
- package/src/auth.ts +0 -92
- package/src/config/msoauth.test.ts +0 -48
- package/src/config/msoauth.ts +0 -48
- package/src/data-backend.test.ts +0 -23
- package/src/derive/autoMoved.test.ts +0 -104
- package/src/derive/contentSignature.test.ts +0 -161
- package/src/derive/contentUrl.test.ts +0 -172
- package/src/derive/contentUrl.ts +0 -86
- package/src/derive/deriveMuted.test.ts +0 -34
- package/src/derive/enrichThreadRows.test.ts +0 -360
- package/src/derive/enrichThreadRows.ts +0 -234
- package/src/derive/filterThreadCriteria.test.ts +0 -126
- package/src/derive/filterThreadCriteria.ts +0 -51
- package/src/derive/senderTrust.test.ts +0 -67
- package/src/error.test.ts +0 -201
- package/src/error.ts +0 -123
- package/src/handlers/account-guards.test.ts +0 -67
- package/src/handlers/account-guards.ts +0 -169
- package/src/handlers/account-oauth-callback.test.ts +0 -155
- package/src/handlers/account-oauth-consent.test.ts +0 -336
- package/src/handlers/account-oauth-start.test.ts +0 -191
- package/src/handlers/account-oauth.test.ts +0 -439
- package/src/handlers/account-oauth.ts +0 -635
- package/src/handlers/account-overrides.test.ts +0 -88
- package/src/handlers/account-overrides.ts +0 -323
- package/src/handlers/account-ownership.ts +0 -25
- package/src/handlers/account-signature.ts +0 -117
- package/src/handlers/account-synced-services.test.ts +0 -245
- package/src/handlers/account.ts +0 -552
- package/src/handlers/address.test.ts +0 -381
- package/src/handlers/address.ts +0 -200
- package/src/handlers/calendar-event.test.ts +0 -685
- package/src/handlers/calendar-event.ts +0 -738
- package/src/handlers/calendar-feed-validators.test.ts +0 -228
- package/src/handlers/calendar-feed.test.ts +0 -439
- package/src/handlers/calendar-feed.ts +0 -291
- package/src/handlers/calendar-sqlite-fixture.ts +0 -36
- package/src/handlers/calendar-suggestion.test.ts +0 -1269
- package/src/handlers/calendar-suggestion.ts +0 -425
- package/src/handlers/calendar.test.ts +0 -1080
- package/src/handlers/calendar.ts +0 -517
- package/src/handlers/config.test.ts +0 -245
- package/src/handlers/config.ts +0 -332
- package/src/handlers/ensure-account-config.ts +0 -30
- package/src/handlers/filter.test.ts +0 -644
- package/src/handlers/filter.ts +0 -518
- package/src/handlers/folder-role-appointments.test.ts +0 -438
- package/src/handlers/folder-role-appointments.ts +0 -249
- package/src/handlers/folder-role-labels.ts +0 -63
- package/src/handlers/folder-role.test.ts +0 -209
- package/src/handlers/folder-role.ts +0 -80
- package/src/handlers/index.ts +0 -83
- package/src/handlers/label.test.ts +0 -112
- package/src/handlers/label.ts +0 -210
- package/src/handlers/mailbox-bindings.ts +0 -111
- package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
- package/src/handlers/mailbox-settled-target.test.ts +0 -251
- package/src/handlers/mailbox.test.ts +0 -280
- package/src/handlers/mailbox.ts +0 -581
- package/src/handlers/me.test.ts +0 -81
- package/src/handlers/me.ts +0 -203
- package/src/handlers/message-read-backfill.test.ts +0 -103
- package/src/handlers/message.test.ts +0 -131
- package/src/handlers/message.ts +0 -1131
- package/src/handlers/organize.test.ts +0 -277
- package/src/handlers/organize.ts +0 -197
- package/src/handlers/outbox-attachment.test.ts +0 -765
- package/src/handlers/outbox-attachment.ts +0 -126
- package/src/handlers/outbox-list.test.ts +0 -179
- package/src/handlers/outbox.test.ts +0 -772
- package/src/handlers/outbox.ts +0 -258
- package/src/handlers/search.test.ts +0 -161
- package/src/handlers/search.ts +0 -105
- package/src/handlers/sync-progress.test.ts +0 -205
- package/src/handlers/sync-progress.ts +0 -99
- package/src/handlers/sync.test.ts +0 -168
- package/src/handlers/sync.ts +0 -118
- package/src/handlers/system-update.test.ts +0 -497
- package/src/handlers/system-update.ts +0 -230
- package/src/handlers/thread.test.ts +0 -396
- package/src/handlers/thread.ts +0 -427
- package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
- package/src/handlers/unified-threads.test.ts +0 -785
- package/src/handlers/unified-threads.ts +0 -669
- package/src/index.test.ts +0 -83
- package/src/index.ts +0 -228
- package/src/jwt-auth.test.ts +0 -152
- package/src/jwt-auth.ts +0 -132
- package/src/log-redaction.test.ts +0 -144
- package/src/openapi-spec.test.ts +0 -118
- package/src/request-context.test.ts +0 -61
- package/src/request-context.ts +0 -33
- package/src/request-pipeline.test.ts +0 -88
- package/src/request.ts +0 -10
- package/src/response.test.ts +0 -130
- package/src/response.ts +0 -144
- package/src/service/compose-sqlite.ts +0 -98
- package/src/service/config-import.ts +0 -39
- package/src/service/create-remit-client.ts +0 -521
- package/src/service/data-client.test.ts +0 -97
- package/src/service/data-client.ts +0 -58
- package/src/service/export-identity.ts +0 -39
- package/src/service/filter.ts +0 -40
- package/src/service/fire-and-forget.test.ts +0 -126
- package/src/service/fire-and-forget.ts +0 -79
- package/src/service/localhost-env-config.test.ts +0 -36
- package/src/service/organize-queue.ts +0 -98
- package/src/service/organize.test.ts +0 -1451
- package/src/service/organize.ts +0 -1025
- package/src/service/read-path-filter-config.test.ts +0 -243
- package/src/service/semantic-capability.test.ts +0 -78
- package/src/service/semantic-capability.ts +0 -95
- package/src/service/sender-category.test.ts +0 -235
- package/src/service/trigger-sync.test.ts +0 -205
- package/src/service/trigger-sync.ts +0 -100
- package/src/stdout-capture-fixture.ts +0 -40
- package/src/types.ts +0 -271
- package/tsconfig.json +0 -7
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { MessageCategory, ThreadSearchResponse } from "@remit/api-openapi-types";
|
|
2
|
+
import type { AccountItem, IAccountSettingRepository, MailboxItem, ResultList, SearchOptions, ThreadMessageItem } from "@remit/data-ports";
|
|
3
|
+
import { type EnrichClient, enrichThreadRows } from "../derive/enrichThreadRows.js";
|
|
4
|
+
import type { OperationHandler, UnifiedThreadOperationIds } from "../types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Minimal client surface for inbox discovery. Structurally satisfied by
|
|
7
|
+
* RemitClient; narrowed so unit tests can pass an in-memory fake without
|
|
8
|
+
* standing up DynamoDB.
|
|
9
|
+
*/
|
|
10
|
+
export interface InboxMapClient {
|
|
11
|
+
account: {
|
|
12
|
+
listMailSourcesByAccountConfig(accountConfigId: string): Promise<AccountItem[]>;
|
|
13
|
+
};
|
|
14
|
+
mailbox: {
|
|
15
|
+
listAllByAccount(accountId: string): Promise<MailboxItem[]>;
|
|
16
|
+
};
|
|
17
|
+
accountSetting: Pick<IAccountSettingRepository, "listByAccountConfig">;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the read scope for the unified listing: a mailboxId→accountId map over
|
|
21
|
+
* every non-muted mailbox of every non-muted account in a given
|
|
22
|
+
* accountConfigId, plus three id sets — `inboxMailboxIds` (top-level INBOX
|
|
23
|
+
* only, the unified inbox scope), `starredMailboxIds` (every folder a star may
|
|
24
|
+
* surface from, see `STARRED_EXCLUDED_SPECIAL_USE`) and `searchMailboxIds`
|
|
25
|
+
* (every folder the unscoped search reaches, see
|
|
26
|
+
* `SEARCH_EXCLUDED_SPECIAL_USE`). The map covers all mailboxes, excluded ones
|
|
27
|
+
* included, so any row still resolves its accountId.
|
|
28
|
+
*
|
|
29
|
+
* INBOX is identified by exact match `fullPath.toUpperCase() === "INBOX"` —
|
|
30
|
+
* MailboxSpecialUse has no Inbox value per RFC 6154. This is the same rule
|
|
31
|
+
* sync-mailboxes.ts uses for INBOX-first sync ordering. By design this matches
|
|
32
|
+
* only the top-level INBOX per account: namespaced sub-paths (`INBOX/Receipts`)
|
|
33
|
+
* and non-English server primaries are excluded from the unified view, so the
|
|
34
|
+
* product contract is "unified inbox = each account's primary INBOX, not
|
|
35
|
+
* sub-folders" — consistent with sync-mailboxes.
|
|
36
|
+
*
|
|
37
|
+
* Muted accounts and muted mailboxes are excluded so the unified thread
|
|
38
|
+
* listing respects the mute flags from #437 (#433). The mute flags now live in
|
|
39
|
+
* per-target AccountSetting rows (RFC 032), loaded once for the whole config and
|
|
40
|
+
* keyed by accountId/mailboxId; a target counts as muted only when its MutedFlag
|
|
41
|
+
* exists AND `value === true`.
|
|
42
|
+
*
|
|
43
|
+
* NOTE (read cost / mute drift): this fan-out (one listMailSourcesByAccountConfig + N
|
|
44
|
+
* per-account mailbox listings) runs on *every* page request and is not cached
|
|
45
|
+
* across a pagination session. Acceptable for v1 — mailbox lists are small —
|
|
46
|
+
* but it means the inbox/mute filter set is rebuilt per page from live state,
|
|
47
|
+
* so muting/unmuting a mailbox mid-pagination changes which rows match on
|
|
48
|
+
* subsequent pages (the cursor is an opaque byDate position; mute changes take
|
|
49
|
+
* effect on the next page, never retroactively). Memoizing the map within a
|
|
50
|
+
* session (or threading it through the continuationToken) is a natural
|
|
51
|
+
* follow-up — tracked alongside the stored-isInbox index work in issue #443.
|
|
52
|
+
*/
|
|
53
|
+
export declare const buildInboxMailboxMap: (accountConfigId: string, client: InboxMapClient) => Promise<{
|
|
54
|
+
mailboxIdToAccountId: Map<string, string>;
|
|
55
|
+
inboxMailboxIds: Set<string>;
|
|
56
|
+
starredMailboxIds: Set<string>;
|
|
57
|
+
searchMailboxIds: Set<string>;
|
|
58
|
+
virtualCopyMailboxIds: Set<string>;
|
|
59
|
+
}>;
|
|
60
|
+
/**
|
|
61
|
+
* Build listByDate options for the unified thread listing.
|
|
62
|
+
*
|
|
63
|
+
* Exposed as a pure helper (same pattern as `buildListThreadsOptions` in
|
|
64
|
+
* thread.ts) so the defaults — `order: "desc"`, default page size, and the
|
|
65
|
+
* non-negotiable `excludeDeleted: true` (#212) — are testable without
|
|
66
|
+
* standing up DynamoDB.
|
|
67
|
+
*/
|
|
68
|
+
export declare const buildListAllThreadsOptions: (query: {
|
|
69
|
+
continuationToken?: string;
|
|
70
|
+
order?: "asc" | "desc";
|
|
71
|
+
limit?: number;
|
|
72
|
+
}, inboxMailboxIds: Set<string>, search?: SearchOptions) => {
|
|
73
|
+
order: "asc" | "desc";
|
|
74
|
+
continuationToken: string | undefined;
|
|
75
|
+
limit: number;
|
|
76
|
+
inboxMailboxIds: Set<string>;
|
|
77
|
+
excludeDeleted: boolean;
|
|
78
|
+
search: SearchOptions | undefined;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Build listByStarred options for the starred (`starred=true`) listing.
|
|
82
|
+
*
|
|
83
|
+
* A star marks the mail, not its placement, so the INBOX narrowing does not
|
|
84
|
+
* apply: the scope is every non-muted mailbox except the ones a star never
|
|
85
|
+
* surfaces from. Same defaults as the unified listing otherwise.
|
|
86
|
+
*/
|
|
87
|
+
export declare const buildListStarredThreadsOptions: (query: {
|
|
88
|
+
continuationToken?: string;
|
|
89
|
+
order?: "asc" | "desc";
|
|
90
|
+
limit?: number;
|
|
91
|
+
}, starredMailboxIds: Set<string>, search?: SearchOptions) => {
|
|
92
|
+
order: "asc" | "desc";
|
|
93
|
+
continuationToken: string | undefined;
|
|
94
|
+
limit: number;
|
|
95
|
+
mailboxIds: Set<string>;
|
|
96
|
+
excludeDeleted: boolean;
|
|
97
|
+
search: SearchOptions | undefined;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Build searchByDate options for the search mode (`query=<text>`).
|
|
101
|
+
*
|
|
102
|
+
* The scope is the caller-built set: every non-muted mailbox minus the folders
|
|
103
|
+
* a search never reaches, or the starred scope when `starred=true` narrows it
|
|
104
|
+
* further. Same defaults as the unified listing; `limit` is a page size over
|
|
105
|
+
* matches and is clamped by the repository.
|
|
106
|
+
*/
|
|
107
|
+
export declare const buildSearchAllThreadsOptions: (query: {
|
|
108
|
+
continuationToken?: string;
|
|
109
|
+
order?: "asc" | "desc";
|
|
110
|
+
limit?: number;
|
|
111
|
+
}, mailboxIds: Set<string>) => {
|
|
112
|
+
order: "asc" | "desc";
|
|
113
|
+
continuationToken: string | undefined;
|
|
114
|
+
limit: number;
|
|
115
|
+
mailboxIds: Set<string>;
|
|
116
|
+
excludeDeleted: boolean;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Collapse rows that are the same piece of mail seen through more than one
|
|
120
|
+
* folder, keeping the copy in a real folder over the one in a virtual folder.
|
|
121
|
+
*
|
|
122
|
+
* A backend that keys a row by its mailbox returns one row per copy, so a
|
|
123
|
+
* starred, Important Gmail inbox message arrives four times and crowds genuine
|
|
124
|
+
* matches off the page. Dropping the extras here rather than barring the
|
|
125
|
+
* virtual folders from the scope is what keeps the message reachable when the
|
|
126
|
+
* ONLY row it has sits in one of them.
|
|
127
|
+
*
|
|
128
|
+
* Preference matters: a real folder is where the user filed the mail, so its
|
|
129
|
+
* row is the one whose `mailboxId` should open. Ties keep the first row seen,
|
|
130
|
+
* which is the newest given the caller's ordering.
|
|
131
|
+
*
|
|
132
|
+
* Scope: within a page. A duplicate split across a page boundary survives, the
|
|
133
|
+
* same caveat the endpoint already documents for collapsing by `threadId`.
|
|
134
|
+
*/
|
|
135
|
+
export declare const dedupeByMessageId: <T extends {
|
|
136
|
+
messageId: string;
|
|
137
|
+
}>(rows: readonly T[], isVirtualCopy: (row: T) => boolean) => T[];
|
|
138
|
+
/**
|
|
139
|
+
* Attach accountId to each enriched ThreadMessageResponse row using the
|
|
140
|
+
* mailboxId→accountId map built from inbox discovery. Same read-time-attach
|
|
141
|
+
* pattern as senderTrust in enrichThreadRows.
|
|
142
|
+
*/
|
|
143
|
+
export declare const attachAccountIds: (rows: Awaited<ReturnType<typeof enrichThreadRows>>, mailboxIdToAccountId: Map<string, string>) => typeof rows;
|
|
144
|
+
/**
|
|
145
|
+
* The row criteria the listing carries, whichever mode answers it.
|
|
146
|
+
*
|
|
147
|
+
* `category`, `unread` and `attachments` are columns on the ThreadMessage row,
|
|
148
|
+
* so each is a predicate inside the query. Filtering the rows a page returned
|
|
149
|
+
* instead is the defect this replaces: a category whose mail sits below the
|
|
150
|
+
* newest page rendered an empty list however much of it the collection held
|
|
151
|
+
* (#308). `starred` and `query` are in here too so one `SearchOptions` says
|
|
152
|
+
* what the whole request narrows by, which is what lets the count run the same
|
|
153
|
+
* predicate as the listing.
|
|
154
|
+
*
|
|
155
|
+
* `from` and `subject` name one field each, which `query` cannot — it matches
|
|
156
|
+
* both at once, so neither token could be asked for on its own and both were
|
|
157
|
+
* applied over the loaded pages instead (#1128). `muted` is the one criterion
|
|
158
|
+
* that is not a column at all: it is read from the sender's address, and it is
|
|
159
|
+
* here because a caller that hides muted mail has to be able to count what it
|
|
160
|
+
* renders (#1137).
|
|
161
|
+
*/
|
|
162
|
+
export declare const buildUnifiedThreadSearch: (params: {
|
|
163
|
+
starredOnly: boolean;
|
|
164
|
+
searchText?: string;
|
|
165
|
+
from?: string;
|
|
166
|
+
subject?: string;
|
|
167
|
+
category?: MessageCategory[];
|
|
168
|
+
unread?: boolean;
|
|
169
|
+
attachments?: boolean;
|
|
170
|
+
muted?: boolean;
|
|
171
|
+
}) => SearchOptions;
|
|
172
|
+
/**
|
|
173
|
+
* Narrow a mailbox scope to one account's mailboxes.
|
|
174
|
+
*
|
|
175
|
+
* The account pill used to narrow the rows a page returned, which took the
|
|
176
|
+
* number off every section header: a count over every account is not the size
|
|
177
|
+
* of a list showing one, so the brief withheld it rather than overstate (#1136).
|
|
178
|
+
* The scope is where the answer belongs — the listing, the search and the count
|
|
179
|
+
* all read it, so all three agree.
|
|
180
|
+
*
|
|
181
|
+
* An account with no mailboxes in the map — one that is muted, or one belonging
|
|
182
|
+
* to another config — narrows to the empty set, which matches nothing. That is
|
|
183
|
+
* the honest answer: the caller asked for an account this config does not read.
|
|
184
|
+
*/
|
|
185
|
+
export declare const scopeToAccount: (mailboxIds: Set<string>, mailboxIdToAccountId: Map<string, string>, accountId: string | undefined) => Set<string>;
|
|
186
|
+
/**
|
|
187
|
+
* Narrow a mailbox scope to one folder.
|
|
188
|
+
*
|
|
189
|
+
* Intersects rather than replaces: a folder the mode's own scope does not hold —
|
|
190
|
+
* Trash, a muted folder, one of a muted account, one of another account when
|
|
191
|
+
* `accountId` narrowed first — leaves the empty set and matches nothing. A
|
|
192
|
+
* caller asking what one folder holds of a search must not be handed a folder
|
|
193
|
+
* the search itself does not reach.
|
|
194
|
+
*
|
|
195
|
+
* This is what makes a per-folder count of a cross-account search exact. The
|
|
196
|
+
* spam offer states how much of the search sits in a junk folder; before the
|
|
197
|
+
* parameter existed the only number available was the junk share of the page
|
|
198
|
+
* the client had loaded, which is a page length presented as a folder total
|
|
199
|
+
* (#313).
|
|
200
|
+
*/
|
|
201
|
+
export declare const scopeToMailbox: (mailboxIds: Set<string>, mailboxId: string | undefined) => Set<string>;
|
|
202
|
+
/**
|
|
203
|
+
* Minimal client surface `executeUnifiedThreadListing` needs, declared
|
|
204
|
+
* structurally (like `ThreadSearchClient`) so the mode selection, the row
|
|
205
|
+
* filters and the count are testable with an in-memory fake.
|
|
206
|
+
*/
|
|
207
|
+
export interface UnifiedThreadClient extends EnrichClient, InboxMapClient {
|
|
208
|
+
threadMessage: {
|
|
209
|
+
listByDate(accountConfigId: string, options?: ReturnType<typeof buildListAllThreadsOptions>): Promise<ResultList<ThreadMessageItem>>;
|
|
210
|
+
listByStarred(accountConfigId: string, options?: ReturnType<typeof buildListStarredThreadsOptions>): Promise<ResultList<ThreadMessageItem>>;
|
|
211
|
+
searchByDate(accountConfigId: string, search: SearchOptions, options?: ReturnType<typeof buildSearchAllThreadsOptions>): Promise<ResultList<ThreadMessageItem>>;
|
|
212
|
+
countThreadsInScope(accountConfigId: string, search: SearchOptions, options?: {
|
|
213
|
+
mailboxIds?: Set<string>;
|
|
214
|
+
excludeDeleted?: boolean;
|
|
215
|
+
}): Promise<number>;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export type UnifiedThreadParams = {
|
|
219
|
+
continuationToken?: string;
|
|
220
|
+
order?: "asc" | "desc";
|
|
221
|
+
limit?: number;
|
|
222
|
+
starredOnly: boolean;
|
|
223
|
+
searchText?: string;
|
|
224
|
+
from?: string;
|
|
225
|
+
subject?: string;
|
|
226
|
+
accountId?: string;
|
|
227
|
+
mailboxId?: string;
|
|
228
|
+
category?: MessageCategory[];
|
|
229
|
+
unread?: boolean;
|
|
230
|
+
attachments?: boolean;
|
|
231
|
+
muted?: boolean;
|
|
232
|
+
count: boolean;
|
|
233
|
+
results: boolean;
|
|
234
|
+
};
|
|
235
|
+
/**
|
|
236
|
+
* Run the cross-account listing: pick the mode, apply the row criteria inside
|
|
237
|
+
* the query, and optionally count the matches.
|
|
238
|
+
*
|
|
239
|
+
* `count` names the whole match, never the page: a page size bounds the rows a
|
|
240
|
+
* response carries and has no bearing on how much matches, so pressing "load
|
|
241
|
+
* more" cannot move it. It counts conversations, the unit the listing renders
|
|
242
|
+
* once its per-mailbox rows are collapsed by `threadId`. `results: false` reads
|
|
243
|
+
* the count alone, which is how a header total is fetched without also paying
|
|
244
|
+
* for a page of mail.
|
|
245
|
+
*/
|
|
246
|
+
export declare const executeUnifiedThreadListing: (client: UnifiedThreadClient, accountConfigId: string, params: UnifiedThreadParams) => Promise<ThreadSearchResponse>;
|
|
247
|
+
export declare const UnifiedThreadOperations: Record<UnifiedThreadOperationIds, OperationHandler<UnifiedThreadOperationIds>>;
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
|
|
2
|
+
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
3
|
+
import pMap from "p-map";
|
|
4
|
+
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
5
|
+
import { enrichThreadRows, } from "../derive/enrichThreadRows.js";
|
|
6
|
+
import { getClient } from "../service/data-client.js";
|
|
7
|
+
import { groupAccountOverrides, groupMailboxOverrides, } from "./account-overrides.js";
|
|
8
|
+
const DEFAULT_UNIFIED_THREADS_PAGE_SIZE = 50;
|
|
9
|
+
const MAILBOX_LIST_CONCURRENCY = 5;
|
|
10
|
+
/**
|
|
11
|
+
* Special-use folders a star never surfaces from.
|
|
12
|
+
*
|
|
13
|
+
* `Junk` and `Trash` match what Gmail and Fastmail do — a star on mail the user
|
|
14
|
+
* already threw away or that was classed as spam is not something the starred
|
|
15
|
+
* view should resurface. `All` is Gmail's All Mail: a second copy of everything
|
|
16
|
+
* already reachable through its own folder, and on a backend that keys a row by
|
|
17
|
+
* its mailbox the copy is a distinct row carrying the same server-side
|
|
18
|
+
* \Flagged, so including it would render every starred Gmail message twice.
|
|
19
|
+
*/
|
|
20
|
+
const STARRED_EXCLUDED_SPECIAL_USE = [
|
|
21
|
+
MailboxSpecialUse.All,
|
|
22
|
+
MailboxSpecialUse.Junk,
|
|
23
|
+
MailboxSpecialUse.Trash,
|
|
24
|
+
];
|
|
25
|
+
const isExcludedFromStarred = (mailbox) => mailbox.specialUse?.some((use) => STARRED_EXCLUDED_SPECIAL_USE.includes(use)) === true;
|
|
26
|
+
/**
|
|
27
|
+
* The only folder the unscoped search never reaches: `Trash` holds mail the
|
|
28
|
+
* user discarded, the same judgement `excludeDeleted` already applies to
|
|
29
|
+
* soft-deleted rows.
|
|
30
|
+
*
|
|
31
|
+
* Everything else is in scope, and the rule is stated as a complement on
|
|
32
|
+
* purpose — enumerating the folders a search DOES reach is what left Drafts out
|
|
33
|
+
* of every description of it while it was in scope all along. `Junk` is in:
|
|
34
|
+
* an unscoped search exists to reach the folders the user did not think to look
|
|
35
|
+
* in, and misfiled mail in Spam is the case that matters most.
|
|
36
|
+
*
|
|
37
|
+
* Gmail's virtual folders (All Mail, Starred, Important) are in scope too, and
|
|
38
|
+
* deliberately so. They hold a second copy of mail that also lives in a real
|
|
39
|
+
* folder, which on a per-mailbox-row backend multiplies results — but a row
|
|
40
|
+
* here is keyed by `(threadId, messageId)`, both mailbox-independent, so the
|
|
41
|
+
* copies collapse into ONE row whose `mailboxId` is whichever mailbox synced it
|
|
42
|
+
* first (message-sync calls this a "residual cross-mailbox collision"). Barring
|
|
43
|
+
* those mailboxes therefore does not remove a duplicate, it removes the only
|
|
44
|
+
* row a message has whenever a virtual folder won that race — mail that exists
|
|
45
|
+
* and cannot be found. Duplicates are handled where they are safe to handle,
|
|
46
|
+
* by `dedupeByMessageId` below.
|
|
47
|
+
*/
|
|
48
|
+
const SEARCH_EXCLUDED_SPECIAL_USE = [
|
|
49
|
+
MailboxSpecialUse.Trash,
|
|
50
|
+
];
|
|
51
|
+
const isExcludedFromSearch = (mailbox) => mailbox.specialUse?.some((use) => SEARCH_EXCLUDED_SPECIAL_USE.includes(use)) === true ||
|
|
52
|
+
// `specialUse` is absent on servers that do not advertise it and on accounts
|
|
53
|
+
// synced before it was recorded, so the well-known path is matched too.
|
|
54
|
+
// Whole path, never a prefix — a user's own `Trash talk` folder is real mail.
|
|
55
|
+
mailbox.fullPath.toLowerCase() === "[gmail]/trash";
|
|
56
|
+
/**
|
|
57
|
+
* Build the read scope for the unified listing: a mailboxId→accountId map over
|
|
58
|
+
* every non-muted mailbox of every non-muted account in a given
|
|
59
|
+
* accountConfigId, plus three id sets — `inboxMailboxIds` (top-level INBOX
|
|
60
|
+
* only, the unified inbox scope), `starredMailboxIds` (every folder a star may
|
|
61
|
+
* surface from, see `STARRED_EXCLUDED_SPECIAL_USE`) and `searchMailboxIds`
|
|
62
|
+
* (every folder the unscoped search reaches, see
|
|
63
|
+
* `SEARCH_EXCLUDED_SPECIAL_USE`). The map covers all mailboxes, excluded ones
|
|
64
|
+
* included, so any row still resolves its accountId.
|
|
65
|
+
*
|
|
66
|
+
* INBOX is identified by exact match `fullPath.toUpperCase() === "INBOX"` —
|
|
67
|
+
* MailboxSpecialUse has no Inbox value per RFC 6154. This is the same rule
|
|
68
|
+
* sync-mailboxes.ts uses for INBOX-first sync ordering. By design this matches
|
|
69
|
+
* only the top-level INBOX per account: namespaced sub-paths (`INBOX/Receipts`)
|
|
70
|
+
* and non-English server primaries are excluded from the unified view, so the
|
|
71
|
+
* product contract is "unified inbox = each account's primary INBOX, not
|
|
72
|
+
* sub-folders" — consistent with sync-mailboxes.
|
|
73
|
+
*
|
|
74
|
+
* Muted accounts and muted mailboxes are excluded so the unified thread
|
|
75
|
+
* listing respects the mute flags from #437 (#433). The mute flags now live in
|
|
76
|
+
* per-target AccountSetting rows (RFC 032), loaded once for the whole config and
|
|
77
|
+
* keyed by accountId/mailboxId; a target counts as muted only when its MutedFlag
|
|
78
|
+
* exists AND `value === true`.
|
|
79
|
+
*
|
|
80
|
+
* NOTE (read cost / mute drift): this fan-out (one listMailSourcesByAccountConfig + N
|
|
81
|
+
* per-account mailbox listings) runs on *every* page request and is not cached
|
|
82
|
+
* across a pagination session. Acceptable for v1 — mailbox lists are small —
|
|
83
|
+
* but it means the inbox/mute filter set is rebuilt per page from live state,
|
|
84
|
+
* so muting/unmuting a mailbox mid-pagination changes which rows match on
|
|
85
|
+
* subsequent pages (the cursor is an opaque byDate position; mute changes take
|
|
86
|
+
* effect on the next page, never retroactively). Memoizing the map within a
|
|
87
|
+
* session (or threading it through the continuationToken) is a natural
|
|
88
|
+
* follow-up — tracked alongside the stored-isInbox index work in issue #443.
|
|
89
|
+
*/
|
|
90
|
+
export const buildInboxMailboxMap = async (accountConfigId, client) => {
|
|
91
|
+
const [accounts, settings] = await Promise.all([
|
|
92
|
+
client.account.listMailSourcesByAccountConfig(accountConfigId),
|
|
93
|
+
client.accountSetting.listByAccountConfig(accountConfigId),
|
|
94
|
+
]);
|
|
95
|
+
// Mute flags live in per-target AccountSetting rows (RFC 032). A target is
|
|
96
|
+
// muted only when its MutedFlag exists and `value === true`.
|
|
97
|
+
const accountOverrides = groupAccountOverrides(settings);
|
|
98
|
+
const mailboxOverrides = groupMailboxOverrides(settings);
|
|
99
|
+
const isAccountMuted = (accountId) => accountOverrides.get(accountId)?.muted?.value === true;
|
|
100
|
+
const isMailboxMuted = (mailboxId) => mailboxOverrides.get(mailboxId)?.muted?.value === true;
|
|
101
|
+
const activeAccounts = accounts.filter((account) => !isAccountMuted(account.accountId));
|
|
102
|
+
const mailboxLists = await pMap(activeAccounts, (account) => client.mailbox.listAllByAccount(account.accountId), { concurrency: MAILBOX_LIST_CONCURRENCY });
|
|
103
|
+
const mailboxIdToAccountId = new Map();
|
|
104
|
+
const inboxMailboxIds = new Set();
|
|
105
|
+
const starredMailboxIds = new Set();
|
|
106
|
+
const searchMailboxIds = new Set();
|
|
107
|
+
const virtualCopyMailboxIds = new Set();
|
|
108
|
+
const activeMailboxes = mailboxLists
|
|
109
|
+
.flat()
|
|
110
|
+
.filter((mailbox) => !isMailboxMuted(mailbox.mailboxId));
|
|
111
|
+
for (const mailbox of activeMailboxes) {
|
|
112
|
+
mailboxIdToAccountId.set(mailbox.mailboxId, mailbox.accountId);
|
|
113
|
+
if (!isExcludedFromStarred(mailbox)) {
|
|
114
|
+
starredMailboxIds.add(mailbox.mailboxId);
|
|
115
|
+
}
|
|
116
|
+
if (!isExcludedFromSearch(mailbox)) {
|
|
117
|
+
searchMailboxIds.add(mailbox.mailboxId);
|
|
118
|
+
}
|
|
119
|
+
if (isVirtualCopyMailbox(mailbox)) {
|
|
120
|
+
virtualCopyMailboxIds.add(mailbox.mailboxId);
|
|
121
|
+
}
|
|
122
|
+
if (mailbox.fullPath.toUpperCase() === "INBOX") {
|
|
123
|
+
inboxMailboxIds.add(mailbox.mailboxId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
mailboxIdToAccountId,
|
|
128
|
+
inboxMailboxIds,
|
|
129
|
+
starredMailboxIds,
|
|
130
|
+
searchMailboxIds,
|
|
131
|
+
virtualCopyMailboxIds,
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Build listByDate options for the unified thread listing.
|
|
136
|
+
*
|
|
137
|
+
* Exposed as a pure helper (same pattern as `buildListThreadsOptions` in
|
|
138
|
+
* thread.ts) so the defaults — `order: "desc"`, default page size, and the
|
|
139
|
+
* non-negotiable `excludeDeleted: true` (#212) — are testable without
|
|
140
|
+
* standing up DynamoDB.
|
|
141
|
+
*/
|
|
142
|
+
export const buildListAllThreadsOptions = (query, inboxMailboxIds, search) => ({
|
|
143
|
+
order: query.order ?? "desc",
|
|
144
|
+
continuationToken: query.continuationToken,
|
|
145
|
+
limit: query.limit ?? DEFAULT_UNIFIED_THREADS_PAGE_SIZE,
|
|
146
|
+
inboxMailboxIds,
|
|
147
|
+
excludeDeleted: true,
|
|
148
|
+
search,
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* Build listByStarred options for the starred (`starred=true`) listing.
|
|
152
|
+
*
|
|
153
|
+
* A star marks the mail, not its placement, so the INBOX narrowing does not
|
|
154
|
+
* apply: the scope is every non-muted mailbox except the ones a star never
|
|
155
|
+
* surfaces from. Same defaults as the unified listing otherwise.
|
|
156
|
+
*/
|
|
157
|
+
export const buildListStarredThreadsOptions = (query, starredMailboxIds, search) => ({
|
|
158
|
+
order: query.order ?? "desc",
|
|
159
|
+
continuationToken: query.continuationToken,
|
|
160
|
+
limit: query.limit ?? DEFAULT_UNIFIED_THREADS_PAGE_SIZE,
|
|
161
|
+
mailboxIds: starredMailboxIds,
|
|
162
|
+
excludeDeleted: true,
|
|
163
|
+
search,
|
|
164
|
+
});
|
|
165
|
+
/**
|
|
166
|
+
* Build searchByDate options for the search mode (`query=<text>`).
|
|
167
|
+
*
|
|
168
|
+
* The scope is the caller-built set: every non-muted mailbox minus the folders
|
|
169
|
+
* a search never reaches, or the starred scope when `starred=true` narrows it
|
|
170
|
+
* further. Same defaults as the unified listing; `limit` is a page size over
|
|
171
|
+
* matches and is clamped by the repository.
|
|
172
|
+
*/
|
|
173
|
+
export const buildSearchAllThreadsOptions = (query, mailboxIds) => ({
|
|
174
|
+
order: query.order ?? "desc",
|
|
175
|
+
continuationToken: query.continuationToken,
|
|
176
|
+
limit: query.limit ?? DEFAULT_UNIFIED_THREADS_PAGE_SIZE,
|
|
177
|
+
mailboxIds,
|
|
178
|
+
excludeDeleted: true,
|
|
179
|
+
});
|
|
180
|
+
/**
|
|
181
|
+
* Collapse rows that are the same piece of mail seen through more than one
|
|
182
|
+
* folder, keeping the copy in a real folder over the one in a virtual folder.
|
|
183
|
+
*
|
|
184
|
+
* A backend that keys a row by its mailbox returns one row per copy, so a
|
|
185
|
+
* starred, Important Gmail inbox message arrives four times and crowds genuine
|
|
186
|
+
* matches off the page. Dropping the extras here rather than barring the
|
|
187
|
+
* virtual folders from the scope is what keeps the message reachable when the
|
|
188
|
+
* ONLY row it has sits in one of them.
|
|
189
|
+
*
|
|
190
|
+
* Preference matters: a real folder is where the user filed the mail, so its
|
|
191
|
+
* row is the one whose `mailboxId` should open. Ties keep the first row seen,
|
|
192
|
+
* which is the newest given the caller's ordering.
|
|
193
|
+
*
|
|
194
|
+
* Scope: within a page. A duplicate split across a page boundary survives, the
|
|
195
|
+
* same caveat the endpoint already documents for collapsing by `threadId`.
|
|
196
|
+
*/
|
|
197
|
+
export const dedupeByMessageId = (rows, isVirtualCopy) => {
|
|
198
|
+
const chosen = new Map();
|
|
199
|
+
for (const row of rows) {
|
|
200
|
+
const existing = chosen.get(row.messageId);
|
|
201
|
+
if (!existing) {
|
|
202
|
+
chosen.set(row.messageId, row);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (isVirtualCopy(existing) && !isVirtualCopy(row)) {
|
|
206
|
+
chosen.set(row.messageId, row);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return [...chosen.values()];
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* Attach accountId to each enriched ThreadMessageResponse row using the
|
|
213
|
+
* mailboxId→accountId map built from inbox discovery. Same read-time-attach
|
|
214
|
+
* pattern as senderTrust in enrichThreadRows.
|
|
215
|
+
*/
|
|
216
|
+
export const attachAccountIds = (rows, mailboxIdToAccountId) => rows.map((row) => ({
|
|
217
|
+
...row,
|
|
218
|
+
accountId: mailboxIdToAccountId.get(row.mailboxId),
|
|
219
|
+
}));
|
|
220
|
+
/**
|
|
221
|
+
* The row criteria the listing carries, whichever mode answers it.
|
|
222
|
+
*
|
|
223
|
+
* `category`, `unread` and `attachments` are columns on the ThreadMessage row,
|
|
224
|
+
* so each is a predicate inside the query. Filtering the rows a page returned
|
|
225
|
+
* instead is the defect this replaces: a category whose mail sits below the
|
|
226
|
+
* newest page rendered an empty list however much of it the collection held
|
|
227
|
+
* (#308). `starred` and `query` are in here too so one `SearchOptions` says
|
|
228
|
+
* what the whole request narrows by, which is what lets the count run the same
|
|
229
|
+
* predicate as the listing.
|
|
230
|
+
*
|
|
231
|
+
* `from` and `subject` name one field each, which `query` cannot — it matches
|
|
232
|
+
* both at once, so neither token could be asked for on its own and both were
|
|
233
|
+
* applied over the loaded pages instead (#1128). `muted` is the one criterion
|
|
234
|
+
* that is not a column at all: it is read from the sender's address, and it is
|
|
235
|
+
* here because a caller that hides muted mail has to be able to count what it
|
|
236
|
+
* renders (#1137).
|
|
237
|
+
*/
|
|
238
|
+
export const buildUnifiedThreadSearch = (params) => ({
|
|
239
|
+
...(params.searchText ? { query: params.searchText } : {}),
|
|
240
|
+
...(params.from ? { from: params.from } : {}),
|
|
241
|
+
...(params.subject ? { subject: params.subject } : {}),
|
|
242
|
+
...(params.starredOnly ? { starred: true } : {}),
|
|
243
|
+
...(params.category?.length ? { category: params.category } : {}),
|
|
244
|
+
...(params.unread !== undefined ? { unread: params.unread } : {}),
|
|
245
|
+
...(params.attachments !== undefined
|
|
246
|
+
? { attachments: params.attachments }
|
|
247
|
+
: {}),
|
|
248
|
+
...(params.muted !== undefined ? { muted: params.muted } : {}),
|
|
249
|
+
});
|
|
250
|
+
/**
|
|
251
|
+
* Narrow a mailbox scope to one account's mailboxes.
|
|
252
|
+
*
|
|
253
|
+
* The account pill used to narrow the rows a page returned, which took the
|
|
254
|
+
* number off every section header: a count over every account is not the size
|
|
255
|
+
* of a list showing one, so the brief withheld it rather than overstate (#1136).
|
|
256
|
+
* The scope is where the answer belongs — the listing, the search and the count
|
|
257
|
+
* all read it, so all three agree.
|
|
258
|
+
*
|
|
259
|
+
* An account with no mailboxes in the map — one that is muted, or one belonging
|
|
260
|
+
* to another config — narrows to the empty set, which matches nothing. That is
|
|
261
|
+
* the honest answer: the caller asked for an account this config does not read.
|
|
262
|
+
*/
|
|
263
|
+
export const scopeToAccount = (mailboxIds, mailboxIdToAccountId, accountId) => {
|
|
264
|
+
if (accountId === undefined)
|
|
265
|
+
return mailboxIds;
|
|
266
|
+
return new Set([...mailboxIds].filter((mailboxId) => mailboxIdToAccountId.get(mailboxId) === accountId));
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Narrow a mailbox scope to one folder.
|
|
270
|
+
*
|
|
271
|
+
* Intersects rather than replaces: a folder the mode's own scope does not hold —
|
|
272
|
+
* Trash, a muted folder, one of a muted account, one of another account when
|
|
273
|
+
* `accountId` narrowed first — leaves the empty set and matches nothing. A
|
|
274
|
+
* caller asking what one folder holds of a search must not be handed a folder
|
|
275
|
+
* the search itself does not reach.
|
|
276
|
+
*
|
|
277
|
+
* This is what makes a per-folder count of a cross-account search exact. The
|
|
278
|
+
* spam offer states how much of the search sits in a junk folder; before the
|
|
279
|
+
* parameter existed the only number available was the junk share of the page
|
|
280
|
+
* the client had loaded, which is a page length presented as a folder total
|
|
281
|
+
* (#313).
|
|
282
|
+
*/
|
|
283
|
+
export const scopeToMailbox = (mailboxIds, mailboxId) => {
|
|
284
|
+
if (mailboxId === undefined)
|
|
285
|
+
return mailboxIds;
|
|
286
|
+
return new Set(mailboxIds.has(mailboxId) ? [mailboxId] : []);
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Run the cross-account listing: pick the mode, apply the row criteria inside
|
|
290
|
+
* the query, and optionally count the matches.
|
|
291
|
+
*
|
|
292
|
+
* `count` names the whole match, never the page: a page size bounds the rows a
|
|
293
|
+
* response carries and has no bearing on how much matches, so pressing "load
|
|
294
|
+
* more" cannot move it. It counts conversations, the unit the listing renders
|
|
295
|
+
* once its per-mailbox rows are collapsed by `threadId`. `results: false` reads
|
|
296
|
+
* the count alone, which is how a header total is fetched without also paying
|
|
297
|
+
* for a page of mail.
|
|
298
|
+
*/
|
|
299
|
+
export const executeUnifiedThreadListing = async (client, accountConfigId, params) => {
|
|
300
|
+
const searching = params.searchText !== undefined && params.searchText.length > 0;
|
|
301
|
+
const { mailboxIdToAccountId, inboxMailboxIds: everyInboxMailboxId, starredMailboxIds: everyStarredMailboxId, searchMailboxIds: everySearchMailboxId, virtualCopyMailboxIds, } = await buildInboxMailboxMap(accountConfigId, client);
|
|
302
|
+
// The account and folder scopes apply before the mode picks a set, so the
|
|
303
|
+
// listing, the search and the count all narrow by the same rule. Account
|
|
304
|
+
// first, folder second: the folder intersects what the account left, so a
|
|
305
|
+
// folder of another account narrows to nothing rather than reinstating one.
|
|
306
|
+
const narrow = (mailboxIds) => scopeToMailbox(scopeToAccount(mailboxIds, mailboxIdToAccountId, params.accountId), params.mailboxId);
|
|
307
|
+
const inboxMailboxIds = narrow(everyInboxMailboxId);
|
|
308
|
+
const starredMailboxIds = narrow(everyStarredMailboxId);
|
|
309
|
+
const searchMailboxIds = narrow(everySearchMailboxId);
|
|
310
|
+
// Search widens past INBOX to every folder it may reach; `starred=true`
|
|
311
|
+
// still narrows it to the starred scope, so the two compose.
|
|
312
|
+
const searchScope = params.starredOnly ? starredMailboxIds : searchMailboxIds;
|
|
313
|
+
const scope = searching
|
|
314
|
+
? searchScope
|
|
315
|
+
: params.starredOnly
|
|
316
|
+
? starredMailboxIds
|
|
317
|
+
: inboxMailboxIds;
|
|
318
|
+
const search = buildUnifiedThreadSearch(params);
|
|
319
|
+
const page = {
|
|
320
|
+
continuationToken: params.continuationToken,
|
|
321
|
+
order: params.order,
|
|
322
|
+
limit: params.limit,
|
|
323
|
+
};
|
|
324
|
+
if (scope.size === 0) {
|
|
325
|
+
return {
|
|
326
|
+
...(params.results ? { items: [] } : {}),
|
|
327
|
+
...(params.count ? { count: 0 } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const response = {};
|
|
331
|
+
if (params.results) {
|
|
332
|
+
const result = searching
|
|
333
|
+
? await client.threadMessage.searchByDate(accountConfigId, search, buildSearchAllThreadsOptions(page, searchScope))
|
|
334
|
+
: params.starredOnly
|
|
335
|
+
? await client.threadMessage.listByStarred(accountConfigId, buildListStarredThreadsOptions(page, starredMailboxIds, search))
|
|
336
|
+
: await client.threadMessage.listByDate(accountConfigId, buildListAllThreadsOptions(page, inboxMailboxIds, search));
|
|
337
|
+
// Search spans folders, so it is the one mode that can see the same mail
|
|
338
|
+
// twice — through its real folder and through a virtual copy of it. The
|
|
339
|
+
// INBOX and starred listings each read a scope that already holds one row
|
|
340
|
+
// per message, so they are left exactly as they were.
|
|
341
|
+
const rows = searching
|
|
342
|
+
? dedupeByMessageId(result.items, (row) => virtualCopyMailboxIds.has(row.mailboxId))
|
|
343
|
+
: result.items;
|
|
344
|
+
const enriched = await enrichThreadRows(rows, client, accountConfigId);
|
|
345
|
+
response.items = attachAccountIds(enriched, mailboxIdToAccountId);
|
|
346
|
+
response.continuationToken = result.continuationToken;
|
|
347
|
+
}
|
|
348
|
+
if (params.count) {
|
|
349
|
+
response.count = await client.threadMessage.countThreadsInScope(accountConfigId, search, { mailboxIds: scope, excludeDeleted: true });
|
|
350
|
+
}
|
|
351
|
+
return response;
|
|
352
|
+
};
|
|
353
|
+
const toArray = (value) => {
|
|
354
|
+
if (value === undefined)
|
|
355
|
+
return undefined;
|
|
356
|
+
return Array.isArray(value) ? value : [value];
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* A query-string boolean, as three states.
|
|
360
|
+
*
|
|
361
|
+
* Query strings carry text; openapi-backend coerces where the schema says
|
|
362
|
+
* boolean and leaves the raw string where it cannot, so both forms are read.
|
|
363
|
+
* Anything else — absent, or a value that is neither — is `undefined`, and
|
|
364
|
+
* every caller below decides what that means for its own parameter. Folding
|
|
365
|
+
* "not stated" into `false` would turn an absent `unread` into "only read
|
|
366
|
+
* mail": a filter nobody asked for, and the opposite of the one they might
|
|
367
|
+
* have meant.
|
|
368
|
+
*/
|
|
369
|
+
const toBoolean = (value) => {
|
|
370
|
+
if (value === true || value === "true")
|
|
371
|
+
return true;
|
|
372
|
+
if (value === false || value === "false")
|
|
373
|
+
return false;
|
|
374
|
+
return undefined;
|
|
375
|
+
};
|
|
376
|
+
export const UnifiedThreadOperations = {
|
|
377
|
+
UnifiedThreadOperations_listAllThreads: async (context, ...args) => {
|
|
378
|
+
const event = args[0];
|
|
379
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
380
|
+
const { continuationToken, order, limit, starred, query, from, subject, accountId, mailboxId, category, unread, attachments, muted, count, results, } = context.request.query;
|
|
381
|
+
// Whitespace-only text is not a search: it would widen the scope to every
|
|
382
|
+
// folder while matching nothing in particular. The two field parameters
|
|
383
|
+
// are trimmed on the same rule, and neither widens the scope at all.
|
|
384
|
+
const searchText = query?.trim();
|
|
385
|
+
const fromText = from?.trim();
|
|
386
|
+
const subjectText = subject?.trim();
|
|
387
|
+
return executeUnifiedThreadListing(await getClient(), accountConfigId, {
|
|
388
|
+
continuationToken,
|
|
389
|
+
order,
|
|
390
|
+
limit,
|
|
391
|
+
// Absent means unstated for all of them, and each says what it does with
|
|
392
|
+
// that: the row filters drop out of the predicate, the account scope
|
|
393
|
+
// stays the cross-account aggregate, `count` is off unless it is asked
|
|
394
|
+
// for, and `results` is on unless it is refused.
|
|
395
|
+
starredOnly: toBoolean(starred) === true,
|
|
396
|
+
searchText: searchText || undefined,
|
|
397
|
+
from: fromText || undefined,
|
|
398
|
+
subject: subjectText || undefined,
|
|
399
|
+
accountId: accountId || undefined,
|
|
400
|
+
mailboxId: mailboxId || undefined,
|
|
401
|
+
category: toArray(category),
|
|
402
|
+
unread: toBoolean(unread),
|
|
403
|
+
attachments: toBoolean(attachments),
|
|
404
|
+
muted: toBoolean(muted),
|
|
405
|
+
count: toBoolean(count) === true,
|
|
406
|
+
results: toBoolean(results) !== false,
|
|
407
|
+
});
|
|
408
|
+
},
|
|
409
|
+
};
|
|
@@ -1,21 +1,9 @@
|
|
|
1
1
|
import type { VipSuggestionEntry } from "@remit/api-openapi-types";
|
|
2
2
|
import type { AddressItem } from "@remit/data-ports";
|
|
3
|
-
|
|
4
3
|
/**
|
|
5
4
|
* Map an Address row to the wire-format `VipSuggestionEntry` returned by
|
|
6
5
|
* `GET /me/vip-suggestions` (issue #234). Absent counters are normalised to
|
|
7
6
|
* zero so the UI can render `12 received · 4 sent · 2 replies` without a
|
|
8
7
|
* `?? 0` fallback at every render site.
|
|
9
8
|
*/
|
|
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
|
-
});
|
|
9
|
+
export declare const toVipSuggestionEntry: (item: AddressItem) => VipSuggestionEntry;
|