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