@remit/backend 0.0.143 → 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.
Files changed (269) 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 +7 -0
  4. package/dist/src/api.js +144 -0
  5. package/dist/src/auth.d.ts +30 -0
  6. package/dist/src/auth.js +73 -0
  7. package/dist/src/config/msoauth.d.ts +30 -0
  8. package/dist/src/config/msoauth.js +30 -0
  9. package/{src/data-backend.ts → dist/src/data-backend.d.ts} +2 -4
  10. package/dist/src/data-backend.js +12 -0
  11. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  12. package/dist/src/derive/autoMoved.js +49 -0
  13. package/dist/src/derive/contentSignature.d.ts +41 -0
  14. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +27 -72
  15. package/dist/src/derive/contentUrl.d.ts +46 -0
  16. package/dist/src/derive/contentUrl.js +46 -0
  17. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  18. package/dist/src/derive/deriveMuted.js +7 -0
  19. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  20. package/dist/src/derive/enrichThreadRows.js +151 -0
  21. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  22. package/dist/src/derive/filterThreadCriteria.js +24 -0
  23. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  24. package/dist/src/derive/pendingMoveCounts.js +53 -0
  25. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  26. package/dist/src/derive/senderTrust.js +19 -0
  27. package/dist/src/error.d.ts +21 -0
  28. package/dist/src/error.js +85 -0
  29. package/dist/src/handlers/account-guards.d.ts +36 -0
  30. package/dist/src/handlers/account-guards.js +107 -0
  31. package/dist/src/handlers/account-oauth.d.ts +14 -0
  32. package/dist/src/handlers/account-oauth.js +387 -0
  33. package/dist/src/handlers/account-overrides.d.ts +53 -0
  34. package/dist/src/handlers/account-overrides.js +233 -0
  35. package/dist/src/handlers/account-signature.d.ts +28 -0
  36. package/dist/src/handlers/account-signature.js +85 -0
  37. package/dist/src/handlers/account.d.ts +6 -0
  38. package/dist/src/handlers/account.js +350 -0
  39. package/dist/src/handlers/address.d.ts +35 -0
  40. package/dist/src/handlers/address.js +141 -0
  41. package/dist/src/handlers/calendar-event.d.ts +70 -0
  42. package/dist/src/handlers/calendar-event.js +425 -0
  43. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  44. package/dist/src/handlers/calendar-feed.js +192 -0
  45. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  46. package/dist/src/handlers/calendar-suggestion.js +212 -0
  47. package/dist/src/handlers/calendar.d.ts +116 -0
  48. package/dist/src/handlers/calendar.js +330 -0
  49. package/dist/src/handlers/config.d.ts +43 -0
  50. package/dist/src/handlers/config.js +176 -0
  51. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  52. package/dist/src/handlers/ensure-account-config.js +27 -0
  53. package/dist/src/handlers/filter.d.ts +3 -0
  54. package/dist/src/handlers/filter.js +103 -0
  55. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  56. package/dist/src/handlers/folder-role-appointments.js +168 -0
  57. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  58. package/dist/src/handlers/folder-role-labels.js +40 -0
  59. package/dist/src/handlers/folder-role.d.ts +9 -0
  60. package/dist/src/handlers/folder-role.js +40 -0
  61. package/dist/src/handlers/index.d.ts +2 -0
  62. package/dist/src/handlers/index.js +60 -0
  63. package/dist/src/handlers/label.d.ts +43 -0
  64. package/dist/src/handlers/label.js +114 -0
  65. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  66. package/dist/src/handlers/mailbox-bindings.js +61 -0
  67. package/dist/src/handlers/mailbox.d.ts +23 -0
  68. package/dist/src/handlers/mailbox.js +219 -0
  69. package/dist/src/handlers/me.d.ts +2 -0
  70. package/dist/src/handlers/me.js +138 -0
  71. package/dist/src/handlers/message.d.ts +84 -0
  72. package/dist/src/handlers/message.js +607 -0
  73. package/dist/src/handlers/organize.d.ts +10 -0
  74. package/dist/src/handlers/organize.js +112 -0
  75. package/dist/src/handlers/outbox-attachment.d.ts +23 -0
  76. package/dist/src/handlers/outbox-attachment.js +81 -0
  77. package/dist/src/handlers/outbox.d.ts +4 -0
  78. package/dist/src/handlers/outbox.js +144 -0
  79. package/dist/src/handlers/sync-progress.d.ts +46 -0
  80. package/dist/src/handlers/sync-progress.js +63 -0
  81. package/dist/src/handlers/sync.d.ts +28 -0
  82. package/dist/src/handlers/sync.js +82 -0
  83. package/dist/src/handlers/system-update.d.ts +2 -0
  84. package/dist/src/handlers/system-update.js +172 -0
  85. package/dist/src/handlers/thread.d.ts +148 -0
  86. package/dist/src/handlers/thread.js +233 -0
  87. package/dist/src/handlers/unified-threads.d.ts +247 -0
  88. package/dist/src/handlers/unified-threads.js +409 -0
  89. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  90. package/dist/src/handlers/vip-suggestions.js +16 -0
  91. package/dist/src/index.d.ts +2 -0
  92. package/dist/src/index.js +37 -0
  93. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  94. package/dist/src/json.js +10 -0
  95. package/dist/src/jwt-auth.d.ts +18 -0
  96. package/dist/src/jwt-auth.js +97 -0
  97. package/dist/src/request-context.d.ts +9 -0
  98. package/dist/src/request-context.js +22 -0
  99. package/dist/src/request.d.ts +3 -0
  100. package/dist/src/request.js +7 -0
  101. package/dist/src/response.d.ts +27 -0
  102. package/dist/src/response.js +87 -0
  103. package/dist/src/service/account-deletion.d.ts +22 -0
  104. package/dist/src/service/account-deletion.js +39 -0
  105. package/dist/src/service/compose-sqlite.d.ts +2 -0
  106. package/dist/src/service/compose-sqlite.js +11 -0
  107. package/dist/src/service/create-remit-client.d.ts +95 -0
  108. package/dist/src/service/create-remit-client.js +207 -0
  109. package/dist/src/service/data-client.d.ts +12 -0
  110. package/dist/src/service/data-client.js +44 -0
  111. package/dist/src/service/export-identity.d.ts +3 -0
  112. package/dist/src/service/export-identity.js +35 -0
  113. package/dist/src/service/fire-and-forget.d.ts +54 -0
  114. package/dist/src/service/fire-and-forget.js +38 -0
  115. package/dist/src/service/organize.d.ts +184 -0
  116. package/dist/src/service/organize.js +384 -0
  117. package/dist/src/service/refusal.d.ts +4 -0
  118. package/dist/src/service/refusal.js +20 -0
  119. package/dist/src/service/sender-category.d.ts +51 -0
  120. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  121. package/dist/src/service/sqs.d.ts +1 -0
  122. package/{src/service/sqs.ts → dist/src/service/sqs.js} +0 -1
  123. package/dist/src/service/trigger-sync.d.ts +51 -0
  124. package/dist/src/service/trigger-sync.js +39 -0
  125. package/dist/src/types.d.ts +49 -0
  126. package/dist/src/types.js +1 -0
  127. package/package.json +113 -98
  128. package/.env.test +0 -4
  129. package/dev-server/content-auth.test.ts +0 -113
  130. package/dev-server/content-auth.ts +0 -54
  131. package/dev-server/content-handler.test.ts +0 -105
  132. package/dev-server/content-handler.ts +0 -79
  133. package/dev-server/content-path.test.ts +0 -37
  134. package/dev-server/content-path.ts +0 -27
  135. package/dev-server/cors.test.ts +0 -48
  136. package/dev-server/cors.ts +0 -25
  137. package/dev-server/keep-alive.test.ts +0 -24
  138. package/dev-server/lambda-helpers.ts +0 -69
  139. package/dev-server/log-output.test.ts +0 -173
  140. package/dev-server/oauth-callback-route.test.ts +0 -66
  141. package/dev-server/relational-health.test.ts +0 -83
  142. package/dev-server/relational-health.ts +0 -39
  143. package/dev-server/server.ts +0 -384
  144. package/dev-server/sync-age.test.ts +0 -91
  145. package/dev-server/sync-age.ts +0 -55
  146. package/dev-server/upload-handler.ts +0 -132
  147. package/dev-server/upload-route.test.ts +0 -196
  148. package/scripts/backfill-classification.ts +0 -91
  149. package/scripts/backfill-list-id.ts +0 -83
  150. package/scripts/config-save.ts +0 -128
  151. package/src/auth.test.ts +0 -12
  152. package/src/auth.ts +0 -92
  153. package/src/config/msoauth.test.ts +0 -48
  154. package/src/config/msoauth.ts +0 -48
  155. package/src/data-backend.test.ts +0 -23
  156. package/src/derive/autoMoved.test.ts +0 -104
  157. package/src/derive/contentSignature.test.ts +0 -161
  158. package/src/derive/contentUrl.test.ts +0 -172
  159. package/src/derive/contentUrl.ts +0 -86
  160. package/src/derive/deriveMuted.test.ts +0 -34
  161. package/src/derive/enrichThreadRows.test.ts +0 -360
  162. package/src/derive/enrichThreadRows.ts +0 -234
  163. package/src/derive/filterThreadCriteria.test.ts +0 -126
  164. package/src/derive/filterThreadCriteria.ts +0 -51
  165. package/src/derive/senderTrust.test.ts +0 -67
  166. package/src/error.test.ts +0 -201
  167. package/src/error.ts +0 -123
  168. package/src/handlers/account-guards.test.ts +0 -67
  169. package/src/handlers/account-guards.ts +0 -169
  170. package/src/handlers/account-oauth-callback.test.ts +0 -155
  171. package/src/handlers/account-oauth-consent.test.ts +0 -336
  172. package/src/handlers/account-oauth-start.test.ts +0 -191
  173. package/src/handlers/account-oauth.test.ts +0 -439
  174. package/src/handlers/account-oauth.ts +0 -635
  175. package/src/handlers/account-overrides.test.ts +0 -88
  176. package/src/handlers/account-overrides.ts +0 -323
  177. package/src/handlers/account-ownership.ts +0 -25
  178. package/src/handlers/account-signature.ts +0 -117
  179. package/src/handlers/account-synced-services.test.ts +0 -245
  180. package/src/handlers/account.ts +0 -552
  181. package/src/handlers/address.test.ts +0 -381
  182. package/src/handlers/address.ts +0 -200
  183. package/src/handlers/calendar-event.test.ts +0 -685
  184. package/src/handlers/calendar-event.ts +0 -738
  185. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  186. package/src/handlers/calendar-feed.test.ts +0 -439
  187. package/src/handlers/calendar-feed.ts +0 -291
  188. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  189. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  190. package/src/handlers/calendar-suggestion.ts +0 -425
  191. package/src/handlers/calendar.test.ts +0 -1080
  192. package/src/handlers/calendar.ts +0 -517
  193. package/src/handlers/config.test.ts +0 -245
  194. package/src/handlers/config.ts +0 -332
  195. package/src/handlers/ensure-account-config.ts +0 -30
  196. package/src/handlers/filter.test.ts +0 -644
  197. package/src/handlers/filter.ts +0 -518
  198. package/src/handlers/folder-role-appointments.test.ts +0 -438
  199. package/src/handlers/folder-role-appointments.ts +0 -249
  200. package/src/handlers/folder-role-labels.ts +0 -63
  201. package/src/handlers/folder-role.test.ts +0 -209
  202. package/src/handlers/folder-role.ts +0 -80
  203. package/src/handlers/index.ts +0 -83
  204. package/src/handlers/label.test.ts +0 -112
  205. package/src/handlers/label.ts +0 -210
  206. package/src/handlers/mailbox-bindings.ts +0 -111
  207. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  208. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  209. package/src/handlers/mailbox.test.ts +0 -280
  210. package/src/handlers/mailbox.ts +0 -581
  211. package/src/handlers/me.test.ts +0 -81
  212. package/src/handlers/me.ts +0 -203
  213. package/src/handlers/message-read-backfill.test.ts +0 -103
  214. package/src/handlers/message.test.ts +0 -131
  215. package/src/handlers/message.ts +0 -1131
  216. package/src/handlers/organize.test.ts +0 -277
  217. package/src/handlers/organize.ts +0 -197
  218. package/src/handlers/outbox-attachment.test.ts +0 -765
  219. package/src/handlers/outbox-attachment.ts +0 -126
  220. package/src/handlers/outbox-list.test.ts +0 -179
  221. package/src/handlers/outbox.test.ts +0 -772
  222. package/src/handlers/outbox.ts +0 -258
  223. package/src/handlers/search.test.ts +0 -161
  224. package/src/handlers/search.ts +0 -105
  225. package/src/handlers/sync-progress.test.ts +0 -205
  226. package/src/handlers/sync-progress.ts +0 -99
  227. package/src/handlers/sync.test.ts +0 -168
  228. package/src/handlers/sync.ts +0 -118
  229. package/src/handlers/system-update.test.ts +0 -497
  230. package/src/handlers/system-update.ts +0 -230
  231. package/src/handlers/thread.test.ts +0 -396
  232. package/src/handlers/thread.ts +0 -427
  233. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  234. package/src/handlers/unified-threads.test.ts +0 -785
  235. package/src/handlers/unified-threads.ts +0 -669
  236. package/src/index.test.ts +0 -83
  237. package/src/index.ts +0 -228
  238. package/src/jwt-auth.test.ts +0 -152
  239. package/src/jwt-auth.ts +0 -132
  240. package/src/log-redaction.test.ts +0 -144
  241. package/src/openapi-spec.test.ts +0 -118
  242. package/src/request-context.test.ts +0 -61
  243. package/src/request-context.ts +0 -33
  244. package/src/request-pipeline.test.ts +0 -88
  245. package/src/request.ts +0 -10
  246. package/src/response.test.ts +0 -130
  247. package/src/response.ts +0 -144
  248. package/src/service/compose-sqlite.ts +0 -98
  249. package/src/service/config-import.ts +0 -39
  250. package/src/service/create-remit-client.ts +0 -522
  251. package/src/service/data-client.test.ts +0 -97
  252. package/src/service/data-client.ts +0 -58
  253. package/src/service/export-identity.ts +0 -39
  254. package/src/service/filter.ts +0 -40
  255. package/src/service/fire-and-forget.test.ts +0 -126
  256. package/src/service/fire-and-forget.ts +0 -79
  257. package/src/service/localhost-env-config.test.ts +0 -36
  258. package/src/service/organize-queue.ts +0 -98
  259. package/src/service/organize.test.ts +0 -1451
  260. package/src/service/organize.ts +0 -1025
  261. package/src/service/read-path-filter-config.test.ts +0 -243
  262. package/src/service/semantic-capability.test.ts +0 -78
  263. package/src/service/semantic-capability.ts +0 -95
  264. package/src/service/sender-category.test.ts +0 -235
  265. package/src/service/trigger-sync.test.ts +0 -205
  266. package/src/service/trigger-sync.ts +0 -100
  267. package/src/stdout-capture-fixture.ts +0 -40
  268. package/src/types.ts +0 -271
  269. 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
- };