@remit/backend 0.0.142 → 0.0.144

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