@remit/backend 0.0.143 → 0.0.145

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