@remit/backend 0.0.143 → 0.0.145

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/dist/dev-server/content-handler.d.ts +38 -0
  2. package/dist/dev-server/content-handler.js +46 -0
  3. package/dist/src/api.d.ts +5 -0
  4. package/dist/src/api.js +73 -0
  5. package/dist/src/auth.d.ts +24 -0
  6. package/dist/src/auth.js +81 -0
  7. package/dist/src/config/api-config.d.ts +20 -0
  8. package/dist/src/config/api-config.js +1 -0
  9. package/dist/src/config/from-env.d.ts +2 -0
  10. package/dist/src/config/from-env.js +65 -0
  11. package/dist/src/config/msoauth-secrets-manager.d.ts +2 -0
  12. package/dist/src/config/msoauth-secrets-manager.js +28 -0
  13. package/dist/src/config/msoauth.d.ts +19 -0
  14. package/dist/src/config/msoauth.js +6 -0
  15. package/{src/derive/autoMoved.ts → dist/src/derive/autoMoved.d.ts} +1 -29
  16. package/dist/src/derive/autoMoved.js +49 -0
  17. package/dist/src/derive/contentSignature.d.ts +40 -0
  18. package/{src/derive/contentSignature.ts → dist/src/derive/contentSignature.js} +23 -77
  19. package/dist/src/derive/contentUrl.d.ts +33 -0
  20. package/dist/src/derive/contentUrl.js +27 -0
  21. package/{src/derive/deriveMuted.ts → dist/src/derive/deriveMuted.d.ts} +1 -3
  22. package/dist/src/derive/deriveMuted.js +7 -0
  23. package/dist/src/derive/enrichThreadRows.d.ts +118 -0
  24. package/dist/src/derive/enrichThreadRows.js +151 -0
  25. package/dist/src/derive/filterThreadCriteria.d.ts +23 -0
  26. package/dist/src/derive/filterThreadCriteria.js +24 -0
  27. package/{src/derive/pendingMoveCounts.ts → dist/src/derive/pendingMoveCounts.d.ts} +5 -53
  28. package/dist/src/derive/pendingMoveCounts.js +53 -0
  29. package/{src/derive/senderTrust.ts → dist/src/derive/senderTrust.d.ts} +1 -9
  30. package/dist/src/derive/senderTrust.js +19 -0
  31. package/dist/src/error.d.ts +21 -0
  32. package/dist/src/error.js +85 -0
  33. package/dist/src/handlers/account-guards.d.ts +36 -0
  34. package/dist/src/handlers/account-guards.js +107 -0
  35. package/dist/src/handlers/account-oauth.d.ts +15 -0
  36. package/dist/src/handlers/account-oauth.js +334 -0
  37. package/dist/src/handlers/account-overrides.d.ts +53 -0
  38. package/dist/src/handlers/account-overrides.js +233 -0
  39. package/dist/src/handlers/account-signature.d.ts +28 -0
  40. package/dist/src/handlers/account-signature.js +85 -0
  41. package/dist/src/handlers/account.d.ts +7 -0
  42. package/dist/src/handlers/account.js +337 -0
  43. package/dist/src/handlers/address.d.ts +35 -0
  44. package/dist/src/handlers/address.js +133 -0
  45. package/dist/src/handlers/calendar-event.d.ts +70 -0
  46. package/dist/src/handlers/calendar-event.js +417 -0
  47. package/dist/src/handlers/calendar-feed.d.ts +47 -0
  48. package/dist/src/handlers/calendar-feed.js +184 -0
  49. package/dist/src/handlers/calendar-suggestion.d.ts +47 -0
  50. package/dist/src/handlers/calendar-suggestion.js +204 -0
  51. package/dist/src/handlers/calendar.d.ts +116 -0
  52. package/dist/src/handlers/calendar.js +323 -0
  53. package/dist/src/handlers/config.d.ts +43 -0
  54. package/dist/src/handlers/config.js +164 -0
  55. package/dist/src/handlers/ensure-account-config.d.ts +9 -0
  56. package/dist/src/handlers/ensure-account-config.js +27 -0
  57. package/dist/src/handlers/filter.d.ts +3 -0
  58. package/dist/src/handlers/filter.js +88 -0
  59. package/dist/src/handlers/folder-role-appointments.d.ts +68 -0
  60. package/dist/src/handlers/folder-role-appointments.js +168 -0
  61. package/dist/src/handlers/folder-role-labels.d.ts +26 -0
  62. package/dist/src/handlers/folder-role-labels.js +40 -0
  63. package/dist/src/handlers/folder-role.d.ts +9 -0
  64. package/dist/src/handlers/folder-role.js +37 -0
  65. package/dist/src/handlers/index.d.ts +88 -0
  66. package/dist/src/handlers/index.js +62 -0
  67. package/dist/src/handlers/label.d.ts +43 -0
  68. package/dist/src/handlers/label.js +107 -0
  69. package/dist/src/handlers/mailbox-bindings.d.ts +41 -0
  70. package/dist/src/handlers/mailbox-bindings.js +61 -0
  71. package/dist/src/handlers/mailbox.d.ts +23 -0
  72. package/dist/src/handlers/mailbox.js +211 -0
  73. package/dist/src/handlers/me.d.ts +2 -0
  74. package/dist/src/handlers/me.js +117 -0
  75. package/dist/src/handlers/message.d.ts +82 -0
  76. package/dist/src/handlers/message.js +595 -0
  77. package/dist/src/handlers/organize.d.ts +10 -0
  78. package/dist/src/handlers/organize.js +103 -0
  79. package/dist/src/handlers/outbox-attachment.d.ts +25 -0
  80. package/dist/src/handlers/outbox-attachment.js +77 -0
  81. package/dist/src/handlers/outbox.d.ts +4 -0
  82. package/dist/src/handlers/outbox.js +136 -0
  83. package/dist/src/handlers/sync-progress.d.ts +46 -0
  84. package/dist/src/handlers/sync-progress.js +63 -0
  85. package/dist/src/handlers/sync.d.ts +28 -0
  86. package/dist/src/handlers/sync.js +73 -0
  87. package/dist/src/handlers/system-update.d.ts +2 -0
  88. package/dist/src/handlers/system-update.js +159 -0
  89. package/dist/src/handlers/thread.d.ts +148 -0
  90. package/dist/src/handlers/thread.js +228 -0
  91. package/dist/src/handlers/unified-threads.d.ts +247 -0
  92. package/dist/src/handlers/unified-threads.js +406 -0
  93. package/{src/handlers/vip-suggestions.ts → dist/src/handlers/vip-suggestions.d.ts} +1 -13
  94. package/dist/src/handlers/vip-suggestions.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +53 -0
  97. package/{src/json.ts → dist/src/json.d.ts} +1 -4
  98. package/dist/src/json.js +10 -0
  99. package/dist/src/jwt-auth.d.ts +15 -0
  100. package/dist/src/jwt-auth.js +45 -0
  101. package/dist/src/lambda.d.ts +13 -0
  102. package/dist/src/lambda.js +33 -0
  103. package/dist/src/request.d.ts +9 -0
  104. package/dist/src/request.js +17 -0
  105. package/dist/src/response.d.ts +44 -0
  106. package/dist/src/response.js +80 -0
  107. package/dist/src/serve.d.ts +31 -0
  108. package/dist/src/serve.js +117 -0
  109. package/dist/src/service/account-deletion.d.ts +20 -0
  110. package/dist/src/service/account-deletion.js +33 -0
  111. package/dist/src/service/compose-sqlite.d.ts +3 -0
  112. package/dist/src/service/compose-sqlite.js +13 -0
  113. package/dist/src/service/create-remit-client.d.ts +109 -0
  114. package/dist/src/service/create-remit-client.js +188 -0
  115. package/dist/src/service/data-client.d.ts +12 -0
  116. package/dist/src/service/data-client.js +42 -0
  117. package/dist/src/service/export-identity.d.ts +4 -0
  118. package/dist/src/service/export-identity.js +33 -0
  119. package/dist/src/service/fire-and-forget.d.ts +54 -0
  120. package/dist/src/service/fire-and-forget.js +37 -0
  121. package/dist/src/service/organize.d.ts +181 -0
  122. package/dist/src/service/organize.js +367 -0
  123. package/dist/src/service/queues.d.ts +12 -0
  124. package/dist/src/service/queues.js +1 -0
  125. package/dist/src/service/refusal.d.ts +4 -0
  126. package/dist/src/service/refusal.js +20 -0
  127. package/dist/src/service/sender-category.d.ts +51 -0
  128. package/{src/service/sender-category.ts → dist/src/service/sender-category.js} +40 -81
  129. package/dist/src/service/shared-deps.d.ts +6 -0
  130. package/dist/src/service/shared-deps.js +24 -0
  131. package/dist/src/service/trigger-sync.d.ts +57 -0
  132. package/dist/src/service/trigger-sync.js +31 -0
  133. package/dist/src/types.d.ts +72 -0
  134. package/dist/src/types.js +1 -0
  135. package/package.json +125 -98
  136. package/.env.test +0 -4
  137. package/dev-server/content-auth.test.ts +0 -113
  138. package/dev-server/content-auth.ts +0 -54
  139. package/dev-server/content-handler.test.ts +0 -105
  140. package/dev-server/content-handler.ts +0 -79
  141. package/dev-server/content-path.test.ts +0 -37
  142. package/dev-server/content-path.ts +0 -27
  143. package/dev-server/cors.test.ts +0 -48
  144. package/dev-server/cors.ts +0 -25
  145. package/dev-server/keep-alive.test.ts +0 -24
  146. package/dev-server/lambda-helpers.ts +0 -69
  147. package/dev-server/log-output.test.ts +0 -173
  148. package/dev-server/oauth-callback-route.test.ts +0 -66
  149. package/dev-server/relational-health.test.ts +0 -83
  150. package/dev-server/relational-health.ts +0 -39
  151. package/dev-server/server.ts +0 -384
  152. package/dev-server/sync-age.test.ts +0 -91
  153. package/dev-server/sync-age.ts +0 -55
  154. package/dev-server/upload-handler.ts +0 -132
  155. package/dev-server/upload-route.test.ts +0 -196
  156. package/scripts/backfill-classification.ts +0 -91
  157. package/scripts/backfill-list-id.ts +0 -83
  158. package/scripts/config-save.ts +0 -128
  159. package/src/auth.test.ts +0 -12
  160. package/src/auth.ts +0 -92
  161. package/src/config/msoauth.test.ts +0 -48
  162. package/src/config/msoauth.ts +0 -48
  163. package/src/data-backend.test.ts +0 -23
  164. package/src/data-backend.ts +0 -14
  165. package/src/derive/autoMoved.test.ts +0 -104
  166. package/src/derive/contentSignature.test.ts +0 -161
  167. package/src/derive/contentUrl.test.ts +0 -172
  168. package/src/derive/contentUrl.ts +0 -86
  169. package/src/derive/deriveMuted.test.ts +0 -34
  170. package/src/derive/enrichThreadRows.test.ts +0 -360
  171. package/src/derive/enrichThreadRows.ts +0 -234
  172. package/src/derive/filterThreadCriteria.test.ts +0 -126
  173. package/src/derive/filterThreadCriteria.ts +0 -51
  174. package/src/derive/senderTrust.test.ts +0 -67
  175. package/src/error.test.ts +0 -201
  176. package/src/error.ts +0 -123
  177. package/src/handlers/account-guards.test.ts +0 -67
  178. package/src/handlers/account-guards.ts +0 -169
  179. package/src/handlers/account-oauth-callback.test.ts +0 -155
  180. package/src/handlers/account-oauth-consent.test.ts +0 -336
  181. package/src/handlers/account-oauth-start.test.ts +0 -191
  182. package/src/handlers/account-oauth.test.ts +0 -439
  183. package/src/handlers/account-oauth.ts +0 -635
  184. package/src/handlers/account-overrides.test.ts +0 -88
  185. package/src/handlers/account-overrides.ts +0 -323
  186. package/src/handlers/account-ownership.ts +0 -25
  187. package/src/handlers/account-signature.ts +0 -117
  188. package/src/handlers/account-synced-services.test.ts +0 -245
  189. package/src/handlers/account.ts +0 -552
  190. package/src/handlers/address.test.ts +0 -381
  191. package/src/handlers/address.ts +0 -200
  192. package/src/handlers/calendar-event.test.ts +0 -685
  193. package/src/handlers/calendar-event.ts +0 -738
  194. package/src/handlers/calendar-feed-validators.test.ts +0 -228
  195. package/src/handlers/calendar-feed.test.ts +0 -439
  196. package/src/handlers/calendar-feed.ts +0 -291
  197. package/src/handlers/calendar-sqlite-fixture.ts +0 -36
  198. package/src/handlers/calendar-suggestion.test.ts +0 -1269
  199. package/src/handlers/calendar-suggestion.ts +0 -425
  200. package/src/handlers/calendar.test.ts +0 -1080
  201. package/src/handlers/calendar.ts +0 -517
  202. package/src/handlers/config.test.ts +0 -245
  203. package/src/handlers/config.ts +0 -332
  204. package/src/handlers/ensure-account-config.ts +0 -30
  205. package/src/handlers/filter.test.ts +0 -644
  206. package/src/handlers/filter.ts +0 -518
  207. package/src/handlers/folder-role-appointments.test.ts +0 -438
  208. package/src/handlers/folder-role-appointments.ts +0 -249
  209. package/src/handlers/folder-role-labels.ts +0 -63
  210. package/src/handlers/folder-role.test.ts +0 -209
  211. package/src/handlers/folder-role.ts +0 -80
  212. package/src/handlers/index.ts +0 -83
  213. package/src/handlers/label.test.ts +0 -112
  214. package/src/handlers/label.ts +0 -210
  215. package/src/handlers/mailbox-bindings.ts +0 -111
  216. package/src/handlers/mailbox-rename-refusal.test.ts +0 -247
  217. package/src/handlers/mailbox-settled-target.test.ts +0 -251
  218. package/src/handlers/mailbox.test.ts +0 -280
  219. package/src/handlers/mailbox.ts +0 -581
  220. package/src/handlers/me.test.ts +0 -81
  221. package/src/handlers/me.ts +0 -203
  222. package/src/handlers/message-read-backfill.test.ts +0 -103
  223. package/src/handlers/message.test.ts +0 -131
  224. package/src/handlers/message.ts +0 -1131
  225. package/src/handlers/organize.test.ts +0 -277
  226. package/src/handlers/organize.ts +0 -197
  227. package/src/handlers/outbox-attachment.test.ts +0 -765
  228. package/src/handlers/outbox-attachment.ts +0 -126
  229. package/src/handlers/outbox-list.test.ts +0 -179
  230. package/src/handlers/outbox.test.ts +0 -772
  231. package/src/handlers/outbox.ts +0 -258
  232. package/src/handlers/search.test.ts +0 -161
  233. package/src/handlers/search.ts +0 -105
  234. package/src/handlers/sync-progress.test.ts +0 -205
  235. package/src/handlers/sync-progress.ts +0 -99
  236. package/src/handlers/sync.test.ts +0 -168
  237. package/src/handlers/sync.ts +0 -118
  238. package/src/handlers/system-update.test.ts +0 -497
  239. package/src/handlers/system-update.ts +0 -230
  240. package/src/handlers/thread.test.ts +0 -396
  241. package/src/handlers/thread.ts +0 -427
  242. package/src/handlers/unified-threads-mail-sources.sqlite.test.ts +0 -197
  243. package/src/handlers/unified-threads.test.ts +0 -785
  244. package/src/handlers/unified-threads.ts +0 -669
  245. package/src/index.test.ts +0 -83
  246. package/src/index.ts +0 -228
  247. package/src/jwt-auth.test.ts +0 -152
  248. package/src/jwt-auth.ts +0 -132
  249. package/src/log-redaction.test.ts +0 -144
  250. package/src/openapi-spec.test.ts +0 -118
  251. package/src/request-context.test.ts +0 -61
  252. package/src/request-context.ts +0 -33
  253. package/src/request-pipeline.test.ts +0 -88
  254. package/src/request.ts +0 -10
  255. package/src/response.test.ts +0 -130
  256. package/src/response.ts +0 -144
  257. package/src/service/compose-sqlite.ts +0 -98
  258. package/src/service/config-import.ts +0 -39
  259. package/src/service/create-remit-client.ts +0 -522
  260. package/src/service/data-client.test.ts +0 -97
  261. package/src/service/data-client.ts +0 -58
  262. package/src/service/export-identity.ts +0 -39
  263. package/src/service/filter.ts +0 -40
  264. package/src/service/fire-and-forget.test.ts +0 -126
  265. package/src/service/fire-and-forget.ts +0 -79
  266. package/src/service/localhost-env-config.test.ts +0 -36
  267. package/src/service/organize-queue.ts +0 -98
  268. package/src/service/organize.test.ts +0 -1451
  269. package/src/service/organize.ts +0 -1025
  270. package/src/service/read-path-filter-config.test.ts +0 -243
  271. package/src/service/semantic-capability.test.ts +0 -78
  272. package/src/service/semantic-capability.ts +0 -95
  273. package/src/service/sender-category.test.ts +0 -235
  274. package/src/service/sqs.ts +0 -4
  275. package/src/service/trigger-sync.test.ts +0 -205
  276. package/src/service/trigger-sync.ts +0 -100
  277. package/src/stdout-capture-fixture.ts +0 -40
  278. package/src/types.ts +0 -271
  279. package/tsconfig.json +0 -7
@@ -1,581 +0,0 @@
1
- import type {
2
- MailboxResponse,
3
- RenameMailboxInput,
4
- } from "@remit/api-openapi-types";
5
- import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
6
- import {
7
- BadRequestError,
8
- ForbiddenError,
9
- MailboxNotSettledError,
10
- NotFoundError,
11
- } from "@remit/data-ports/errors";
12
- import { isReservedFolderName } from "@remit/data-ports/folder-role";
13
- import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
14
- import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
15
- import type { APIGatewayProxyEvent } from "aws-lambda";
16
- import { getAccountConfigIdFromEvent } from "../auth.js";
17
- import {
18
- applyPendingMoveCountPrediction,
19
- type PendingUnseenFlagPush,
20
- } from "../derive/pendingMoveCounts.js";
21
- import { getClient } from "../service/data-client.js";
22
- import type {
23
- MailboxDetailOperationIds,
24
- MailboxOperationIds,
25
- OperationHandler,
26
- TrashOperationIds,
27
- } from "../types.js";
28
- import {
29
- applyMailboxOverrideChanges,
30
- loadMailboxOverrides,
31
- loadMailboxOverridesForConfig,
32
- type MailboxOverrides,
33
- } from "./account-overrides.js";
34
- import { assertAccountOwnership } from "./account-ownership.js";
35
- import { assertNoBindings, loadMailboxBindings } from "./mailbox-bindings.js";
36
-
37
- /**
38
- * The mute flag and the display-name override are user preferences that live
39
- * in per-mailbox AccountSetting rows (RFC 032), not on the Mailbox entity.
40
- * Pick only those keys from a PATCH body: each follows the same `null` → remove,
41
- * value → set, absent/undefined → no-op semantics as UpdateAddressFlagsInput.
42
- * The canonical role a folder fills is appointed separately — see
43
- * FolderRoleOperations.appointFolderRole (RFC 032 exclusive-folder-appointment).
44
- */
45
- export const pickMailboxOverrideChanges = (
46
- body: RenameMailboxInput,
47
- ): {
48
- displayNameOverride?: string | null;
49
- muted?: RenameMailboxInput["muted"];
50
- } => {
51
- const changes: {
52
- displayNameOverride?: string | null;
53
- muted?: RenameMailboxInput["muted"];
54
- } = {};
55
- if (Object.hasOwn(body, "displayNameOverride")) {
56
- changes.displayNameOverride = body.displayNameOverride;
57
- }
58
- if (Object.hasOwn(body, "muted")) {
59
- changes.muted = body.muted;
60
- }
61
- return changes;
62
- };
63
-
64
- /**
65
- * Minimal client surface needed to apply a mailbox PATCH. Structurally
66
- * satisfied by RemitClient; narrowed so tests can stub it.
67
- */
68
- export interface MailboxPatchClient {
69
- mailbox: {
70
- get(accountId: string, mailboxId: string): Promise<MailboxItem>;
71
- listAllByAccount(accountId: string): Promise<MailboxItem[]>;
72
- };
73
- mailboxQueue: {
74
- renameMailbox(
75
- mailboxId: string,
76
- newPath: string,
77
- accountId: string,
78
- ): Promise<MailboxItem>;
79
- dismissMailboxIntent(
80
- mailboxId: string,
81
- accountId: string,
82
- ): Promise<MailboxItem>;
83
- };
84
- accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
85
- }
86
-
87
- /**
88
- * Refuse a rename that no retry of the same request could ever satisfy (D4).
89
- *
90
- * `INBOX` is refused at both ends: renaming it moves its mail to the new name
91
- * and leaves an empty INBOX behind, and renaming anything *to* it collides with
92
- * the one name RFC 3501 reserves (D5).
93
- *
94
- * A reserved leaf name is refused for a different reason. The mailbox sweep
95
- * reads a folder whose leaf name is a role's conventional name but which lacks
96
- * the server's flag as a duplicate, and deletes its row — under D8, with the
97
- * folder's mail — as soon as another folder holds the flag. A rename to such a
98
- * name settles `synced` and is then reaped. Refusing at the API is the same
99
- * class of check as the rest of D4 and does not require teaching that heuristic
100
- * about renames.
101
- */
102
- const assertRenameTargetAllowed = async (
103
- client: Pick<MailboxPatchClient, "mailbox">,
104
- accountId: string,
105
- before: MailboxItem,
106
- target: string,
107
- ): Promise<void> => {
108
- if (before.fullPath.toUpperCase() === "INBOX") {
109
- throw new BadRequestError("The inbox can't be renamed.");
110
- }
111
- if (target.trim().length === 0) {
112
- throw new BadRequestError("A folder needs a name.");
113
- }
114
- if (target.toUpperCase() === "INBOX") {
115
- throw new BadRequestError("“INBOX” is reserved for the inbox.");
116
- }
117
-
118
- const leaf = mailboxLeafName({
119
- fullPath: target,
120
- hierarchyDelimiter: before.hierarchyDelimiter,
121
- });
122
- if (isReservedFolderName(leaf)) {
123
- throw new BadRequestError(
124
- `“${leaf}” is reserved for a system folder. Pick another name.`,
125
- );
126
- }
127
-
128
- // A path a rename is on its way to is as taken as one a folder already sits
129
- // at (D2). Reading `fullPath` alone accepts a second rename onto a target
130
- // another is already recorded for: both settle, both write the same path,
131
- // and the sweep then reaps one row — with its mail — and inserts a duplicate
132
- // for the survivor's path. The account's folders are few and the sweep
133
- // already reads them all, so one pass answers both questions.
134
- const folders = await client.mailbox.listAllByAccount(accountId);
135
- for (const folder of folders) {
136
- if (folder.mailboxId === before.mailboxId) continue;
137
- if (folder.fullPath === target) {
138
- throw new BadRequestError(`A folder named “${target}” is already there.`);
139
- }
140
- // `pending` only. A failed rename keeps its target so the client can name
141
- // what it was aiming at and offer a retry (T6), and nothing clears it
142
- // until the user retries or dismisses — so honouring a `failed` row's
143
- // claim would tell everyone else the path is taken by a rename that is
144
- // not happening, with no way to find that out and nothing to wait for.
145
- if (
146
- folder.syncStatus === MailboxSyncStatus.pending &&
147
- folder.pendingPath === target
148
- ) {
149
- throw new BadRequestError(
150
- `“${folder.fullPath}” is already being renamed to “${target}”.`,
151
- );
152
- }
153
- }
154
- };
155
-
156
- /**
157
- * Apply a mailbox PATCH body: override changes first (mute flag + display-name/
158
- * role overrides — written to per-mailbox AccountSetting rows, no IMAP
159
- * machinery), then rename — which triggers the IMAP rename machinery
160
- * (the recorded subtree intent + MAILBOX_RENAME event) — only when `fullPath`
161
- * is present. An override-only PATCH therefore never calls
162
- * `mailboxQueue.renameMailbox`.
163
- *
164
- * A `fullPath` equal to the row's confirmed one is not a rename: it is the
165
- * dismissal of a failed intent (T10, T11), which is how a user keeps the name
166
- * the folder already has without new API surface. It is checked first, because
167
- * from `failed` it is the one route out that enqueues nothing.
168
- */
169
- export const applyMailboxPatch = async (
170
- client: MailboxPatchClient,
171
- accountConfigId: string,
172
- mailboxId: string,
173
- accountId: string,
174
- body: RenameMailboxInput,
175
- ): Promise<MailboxItem> => {
176
- const { fullPath } = body;
177
-
178
- // --- Override settings (mute flag + display-name/role overrides) ---
179
- // Written to per-mailbox AccountSetting rows (RFC 032) with the same
180
- // null→remove semantics as UpdateAddressFlagsInput. Applied before (and
181
- // independent of) any rename so an override-only PATCH never touches the
182
- // row's mutation state.
183
- const changes = pickMailboxOverrideChanges(body);
184
- if (Object.keys(changes).length > 0) {
185
- await applyMailboxOverrideChanges(
186
- client.accountSetting,
187
- accountConfigId,
188
- mailboxId,
189
- changes,
190
- );
191
- }
192
-
193
- // --- Rename (IMAP machinery) ---
194
- // Only triggered when fullPath is present.
195
- if (!fullPath) {
196
- return client.mailbox.get(accountId, mailboxId);
197
- }
198
-
199
- const before = await client.mailbox.get(accountId, mailboxId);
200
- if (fullPath === before.fullPath) {
201
- return client.mailboxQueue.dismissMailboxIntent(mailboxId, accountId);
202
- }
203
-
204
- await assertRenameTargetAllowed(client, accountId, before, fullPath);
205
-
206
- // The row keeps the path the server holds until the rename settles (D2), so
207
- // nothing that names a folder by path moves here — the appointment labels
208
- // recorded beside each role (#887) included. They move with the settle.
209
- return client.mailboxQueue.renameMailbox(mailboxId, fullPath, accountId);
210
- };
211
-
212
- /**
213
- * Cross-account guard for a mailbox reached by id under an `accountId` path.
214
- * `assertAccountOwnership` only proves the caller owns the account in the path;
215
- * the `mailboxId` in that same path can belong to a different account, so without
216
- * this check a caller could read or mutate another account's mailbox. Mirrors the
217
- * read/act split of `assertAccountOwnership`: 404 on a read (no existence leak),
218
- * 403 on an action.
219
- */
220
- export const assertMailboxInAccount = (
221
- mailbox: Pick<MailboxItem, "mailboxId" | "accountId">,
222
- accountId: string,
223
- mode: "read" | "act",
224
- ): void => {
225
- if (mailbox.accountId === accountId) return;
226
- if (mode === "read") {
227
- throw new NotFoundError(`Mailbox not found: ${mailbox.mailboxId}`);
228
- }
229
- throw new ForbiddenError(`Mailbox ${mailbox.mailboxId} not in account`);
230
- };
231
-
232
- /**
233
- * Why a folder cannot be bound to yet, per state — one wording in one place,
234
- * because the remedy differs and the client has only the sentence to show.
235
- */
236
- const UNSETTLED_REASON: Record<string, (path: string) => string> = {
237
- [MailboxSyncStatus.pending]: (path) =>
238
- `“${path}” isn’t ready yet — the mail server hasn’t confirmed it.`,
239
- [MailboxSyncStatus.deleting]: (path) => `“${path}” is being deleted.`,
240
- [MailboxSyncStatus.failed]: (path) =>
241
- `The last change to “${path}” failed. Retry or dismiss it first.`,
242
- };
243
-
244
- /**
245
- * Every API write that binds a durable reference to a mailbox requires that
246
- * mailbox to be `synced` (D12, first row; imap-mutations R2: wait). A dangling
247
- * reference is permanent; blocking costs seconds.
248
- *
249
- * This is the server-side floor under the client-side wait, and it is not
250
- * redundant with it: there is more than one client, and a script hitting the
251
- * API directly gets no wait at all.
252
- *
253
- * It is the other half of D16. A binding is only ever created against a settled
254
- * folder (here) and only ever removed by the user (the delete refusal below),
255
- * so a `mailboxId` that names nothing never exists — which is what lets every
256
- * reader of a filter's `actionMailboxId` or a role appointment treat the
257
- * reference as resolvable, with no missing-target branch.
258
- *
259
- * A `\Noselect` container is refused by construction — mailbox-sync keeps no
260
- * row for one — so there is nothing to bind to. Clearing a reference is not a
261
- * bind and is never gated, and neither is a move *out of* a folder: a
262
- * `deleting` folder still holds its mail until the DELETE lands, which is the
263
- * delete-with-move flow (D13).
264
- */
265
- export const assertMailboxSettled = (
266
- target: Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
267
- ): void => {
268
- const reason = UNSETTLED_REASON[target.syncStatus];
269
- if (!reason) return;
270
- throw new MailboxNotSettledError(
271
- reason(target.fullPath),
272
- target.mailboxId,
273
- target.syncStatus,
274
- );
275
- };
276
-
277
- /**
278
- * Every pending placement move (issue #1271) for an account, on whichever
279
- * backend is active (`RemitClient.placementMove` is present on both — see
280
- * `create-remit-client.ts`). Read-only; feeds
281
- * `applyPendingMoveCountPrediction`'s read-time adjustment only, never mutates
282
- * stored counts (epic #1281 invariant 4). Markers are written by the
283
- * imap-worker bulk sync path through `RemitClient.placementMove` on every
284
- * backend, so this is a real signal on the self-host stack too.
285
- */
286
- const loadPendingMoves = (
287
- client: Awaited<ReturnType<typeof getClient>>,
288
- accountId: string,
289
- ) => client.placementMove.listByAccountId(accountId);
290
-
291
- /**
292
- * Every pending `\Seen` flag-push marker (issue #1273) for an account —
293
- * `\Flagged` (star) markers are excluded, since only read/unread state feeds
294
- * `unseenCount`'s prediction. `RemitClient.flagPush` is present and WRITTEN
295
- * on both backends, so this is a real signal on the self-host stack too.
296
- */
297
- const loadPendingUnseenFlagPushes = async (
298
- client: Awaited<ReturnType<typeof getClient>>,
299
- accountId: string,
300
- ): Promise<PendingUnseenFlagPush[]> => {
301
- const pushes = await client.flagPush.listByAccountId(accountId);
302
- return pushes
303
- .filter((push) => push.flagName === MessageSystemFlag.Seen)
304
- .map((push) => ({ mailboxId: push.mailboxId, operation: push.operation }));
305
- };
306
-
307
- const toMailboxResponse = (
308
- mailbox: MailboxItem,
309
- overrides: MailboxOverrides = {},
310
- ): MailboxResponse => ({
311
- mailboxId: mailbox.mailboxId,
312
- accountId: mailbox.accountId,
313
- namespaceType: mailbox.namespaceType,
314
- namespacePrefix: mailbox.namespacePrefix,
315
- hierarchyDelimiter: mailbox.hierarchyDelimiter,
316
- fullPath: mailbox.fullPath,
317
- messageCount: mailbox.messageCount,
318
- unseenCount: mailbox.unseenCount,
319
- deletedCount: mailbox.deletedCount,
320
- specialUse: mailbox.specialUse ? Array.from(mailbox.specialUse) : undefined,
321
- lastSyncUid: mailbox.lastSyncUid,
322
- highWaterMarkUid: mailbox.highWaterMarkUid,
323
- lastMessageSyncAt: mailbox.lastMessageSyncAt,
324
- syncStatus: mailbox.syncStatus,
325
- // The row is the only thing that knows what a rename is aiming at, and
326
- // `fullPath` stays the confirmed one throughout (D2), so without this a
327
- // client cannot tell a create in flight from a rename in flight, name the
328
- // target of a failed rename, or offer the right retry.
329
- pendingPath: mailbox.pendingPath,
330
- syncFailureReason: mailbox.syncFailureReason,
331
- muted: overrides.muted,
332
- displayNameOverride: overrides.displayNameOverride,
333
- createdAt: mailbox.createdAt,
334
- updatedAt: mailbox.updatedAt,
335
- });
336
-
337
- export const MailboxOperations: Record<
338
- MailboxOperationIds,
339
- OperationHandler<MailboxOperationIds>
340
- > = {
341
- MailboxOperations_listMailboxes: async (context, ...args: unknown[]) => {
342
- const event = args[0] as APIGatewayProxyEvent;
343
- const accountConfigId = getAccountConfigIdFromEvent(event);
344
- const { accountId } = context.request.params as { accountId: string };
345
- const { continuationToken } = context.request.query as {
346
- continuationToken?: string;
347
- };
348
-
349
- const client = await getClient();
350
- const account = await client.account.get(accountId);
351
- assertAccountOwnership(account, accountConfigId, "read");
352
-
353
- const result = await client.mailbox.listByAccount(accountId, {
354
- continuationToken,
355
- });
356
-
357
- // Overrides (mute / display-name / role) live in per-mailbox AccountSetting
358
- // rows (RFC 032). Load the whole config's set in one query and key it by
359
- // mailboxId so each mailbox surfaces its overrides without an N+1.
360
- const overridesByMailbox = await loadMailboxOverridesForConfig(
361
- client.accountSetting,
362
- accountConfigId,
363
- );
364
-
365
- // Read-time prediction for any pending placement move (issue #1271) and
366
- // pending \Seen flag push (issue #1273) — adjusts messageCount /
367
- // unseenCount only, never the stored row (epic #1281 invariant 4).
368
- const pendingMoves = await loadPendingMoves(client, accountId);
369
- const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(
370
- client,
371
- accountId,
372
- );
373
- // A folder mid-mutation is listed, labelled (D11). Hiding a `deleting` one
374
- // made a delete that does not settle look like a folder that silently
375
- // vanished, while the server still held it.
376
- const items = applyPendingMoveCountPrediction(
377
- result.items,
378
- pendingMoves,
379
- pendingUnseenFlagPushes,
380
- );
381
-
382
- return {
383
- items: items.map((mailbox) =>
384
- toMailboxResponse(
385
- mailbox,
386
- overridesByMailbox.get(mailbox.mailboxId) ?? {},
387
- ),
388
- ),
389
- continuationToken: result.continuationToken,
390
- };
391
- },
392
-
393
- MailboxOperations_createMailbox: async (context, ...args: unknown[]) => {
394
- const event = args[0] as APIGatewayProxyEvent;
395
- const accountConfigId = getAccountConfigIdFromEvent(event);
396
- const { accountId } = context.request.params as { accountId: string };
397
- const { namespaceType, fullPath } = context.request.requestBody as {
398
- namespaceType: string;
399
- fullPath: string;
400
- };
401
-
402
- const client = await getClient();
403
- const account = await client.account.get(accountId);
404
- assertAccountOwnership(account, accountConfigId, "act");
405
-
406
- const created = await client.mailboxQueue.createMailbox(
407
- {
408
- accountId,
409
- namespaceType: namespaceType as "personal" | "other_users" | "shared",
410
- namespacePrefix: "",
411
- hierarchyDelimiter: "/",
412
- fullPath,
413
- uidValidity: 0,
414
- uidNext: 1,
415
- highestModseq: "0",
416
- messageCount: 0,
417
- unseenCount: 0,
418
- deletedCount: 0,
419
- totalSize: 0,
420
- lastSyncUid: 0,
421
- highWaterMarkUid: 0,
422
- lastMessageSyncAt: 0,
423
- },
424
- accountId,
425
- true,
426
- );
427
- if (created.outcome === "PathTaken") {
428
- throw new BadRequestError(
429
- `A folder named “${fullPath}” is already there.`,
430
- );
431
- }
432
-
433
- return toMailboxResponse(created.mailbox);
434
- },
435
- };
436
-
437
- export const MailboxDetailOperations: Record<
438
- MailboxDetailOperationIds,
439
- OperationHandler<MailboxDetailOperationIds>
440
- > = {
441
- MailboxDetailOperations_getMailbox: async (context, ...args: unknown[]) => {
442
- const event = args[0] as APIGatewayProxyEvent;
443
- const accountConfigId = getAccountConfigIdFromEvent(event);
444
- const { accountId, mailboxId } = context.request.params as {
445
- accountId: string;
446
- mailboxId: string;
447
- };
448
-
449
- const client = await getClient();
450
- const account = await client.account.get(accountId);
451
- assertAccountOwnership(account, accountConfigId, "read");
452
-
453
- const mailbox = await client.mailbox.get(accountId, mailboxId);
454
- assertMailboxInAccount(mailbox, accountId, "read");
455
- const overrides = await loadMailboxOverrides(
456
- client.accountSetting,
457
- accountConfigId,
458
- mailboxId,
459
- );
460
-
461
- // Read-time prediction for any pending placement move (issue #1271) and
462
- // pending \Seen flag push (issue #1273) — adjusts messageCount /
463
- // unseenCount only, never the stored row (epic #1281 invariant 4).
464
- const pendingMoves = await loadPendingMoves(client, accountId);
465
- const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(
466
- client,
467
- accountId,
468
- );
469
- const adjusted =
470
- applyPendingMoveCountPrediction(
471
- [mailbox],
472
- pendingMoves,
473
- pendingUnseenFlagPushes,
474
- )[0] ?? mailbox;
475
-
476
- return toMailboxResponse(adjusted, overrides);
477
- },
478
-
479
- MailboxDetailOperations_renameMailbox: async (
480
- context,
481
- ...args: unknown[]
482
- ) => {
483
- const event = args[0] as APIGatewayProxyEvent;
484
- const accountConfigId = getAccountConfigIdFromEvent(event);
485
- const { accountId, mailboxId } = context.request.params as {
486
- accountId: string;
487
- mailboxId: string;
488
- };
489
- const body = context.request.requestBody as RenameMailboxInput;
490
-
491
- const client = await getClient();
492
- const account = await client.account.get(accountId);
493
- assertAccountOwnership(account, accountConfigId, "act");
494
-
495
- const existing = await client.mailbox.get(accountId, mailboxId);
496
- assertMailboxInAccount(existing, accountId, "act");
497
-
498
- const mailbox = await applyMailboxPatch(
499
- client,
500
- accountConfigId,
501
- mailboxId,
502
- accountId,
503
- body,
504
- );
505
- const overrides = await loadMailboxOverrides(
506
- client.accountSetting,
507
- accountConfigId,
508
- mailboxId,
509
- );
510
- return toMailboxResponse(mailbox, overrides);
511
- },
512
-
513
- MailboxDetailOperations_deleteMailbox: async (
514
- context,
515
- ...args: unknown[]
516
- ) => {
517
- const event = args[0] as APIGatewayProxyEvent;
518
- const accountConfigId = getAccountConfigIdFromEvent(event);
519
- const { accountId, mailboxId } = context.request.params as {
520
- accountId: string;
521
- mailboxId: string;
522
- };
523
-
524
- const client = await getClient();
525
- const account = await client.account.get(accountId);
526
- assertAccountOwnership(account, accountConfigId, "act");
527
-
528
- const mailbox = await client.mailbox.get(accountId, mailboxId);
529
- assertMailboxInAccount(mailbox, accountId, "act");
530
-
531
- // Cheapest first (D4, D13). None of the three becomes possible by
532
- // retrying the same request, so all three are 400 and all three run
533
- // before anything is recorded.
534
- if (mailbox.fullPath.toUpperCase() === "INBOX") {
535
- throw new BadRequestError("The inbox can't be deleted.");
536
- }
537
-
538
- const children = await client.mailbox.findByPathPrefix(
539
- accountId,
540
- mailbox.fullPath,
541
- mailbox.hierarchyDelimiter,
542
- );
543
- if (children.length > 0) {
544
- // The server would keep the parent's name as a placeholder and leave the
545
- // children untouched — an outcome no local row describes (D9).
546
- throw new BadRequestError(
547
- `“${mailbox.fullPath}” has folders inside it. Delete those first.`,
548
- );
549
- }
550
-
551
- assertNoBindings(
552
- mailbox,
553
- await loadMailboxBindings(client, accountConfigId, accountId, mailboxId),
554
- );
555
-
556
- await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
557
- return { statusCode: 204 };
558
- },
559
- };
560
-
561
- export const TrashOperations: Record<
562
- TrashOperationIds,
563
- OperationHandler<TrashOperationIds>
564
- > = {
565
- TrashOperations_emptyTrash: async (context, ...args: unknown[]) => {
566
- const event = args[0] as APIGatewayProxyEvent;
567
- const accountConfigId = getAccountConfigIdFromEvent(event);
568
- const { accountId } = context.request.params as { accountId: string };
569
-
570
- const client = await getClient();
571
-
572
- const account = await client.account.get(accountId);
573
- assertAccountOwnership(account, accountConfigId, "act");
574
-
575
- // The service resolves Trash, marks the rows and counts them, all off one
576
- // read, and refuses with a coded 409 when the role is unresolved. Counting
577
- // here as well would be a second answer free to disagree with the one that
578
- // acts, and "0 deleted" reads as success to a user whose Trash is untouched.
579
- return client.messageMove.emptyTrash(accountConfigId, accountId);
580
- },
581
- };
@@ -1,81 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, describe, it } from "node:test";
3
- import type { QuarantineItem } from "@remit/data-ports";
4
- import type { APIGatewayProxyEvent } from "aws-lambda";
5
- import type { Context } from "openapi-backend";
6
- import { deriveAccountConfigId } from "../auth.js";
7
- import {
8
- _resetForTest,
9
- type RemitClient,
10
- setClient,
11
- } from "../service/data-client.js";
12
- import { MeOperations } from "./me.js";
13
-
14
- const listQuarantine = MeOperations.MeOperations_listQuarantine as unknown as (
15
- context: Context,
16
- event: APIGatewayProxyEvent,
17
- ) => Promise<{ entries: QuarantineItem[] }>;
18
-
19
- const SUB = "cognito-sub-1";
20
-
21
- const eventFor = (sub: string): APIGatewayProxyEvent =>
22
- ({
23
- requestContext: { authorizer: { claims: { sub } } },
24
- }) as unknown as APIGatewayProxyEvent;
25
-
26
- const entry = (over: Partial<QuarantineItem> = {}): QuarantineItem =>
27
- ({
28
- quarantineId: "q-1",
29
- accountConfigId: deriveAccountConfigId(SUB),
30
- accountId: "acct-1",
31
- mailboxId: "mbx-1",
32
- uidValidity: 1_712_000_000,
33
- uid: 40217,
34
- mailboxPath: "INBOX",
35
- quarantinedAt: 1_000,
36
- attempts: 3,
37
- failureStage: "BodyParse",
38
- failureCode: "UnreadableBody",
39
- failureMessage: "multipart boundary was never closed",
40
- workerVersion: "worker 1.0.0",
41
- structure: [{ depth: 0, contentType: "multipart/mixed" }],
42
- createdAt: 1_000,
43
- updatedAt: 1_000,
44
- ...over,
45
- }) as QuarantineItem;
46
-
47
- const clientListing = (items: QuarantineItem[], seen: string[]): RemitClient =>
48
- ({
49
- quarantine: {
50
- listByAccountConfigId: async (
51
- accountConfigId: string,
52
- ): Promise<QuarantineItem[]> => {
53
- seen.push(accountConfigId);
54
- return items;
55
- },
56
- },
57
- }) as unknown as RemitClient;
58
-
59
- afterEach(() => {
60
- _resetForTest();
61
- });
62
-
63
- describe("MeOperations_listQuarantine", () => {
64
- it("lists only what belongs to the caller", async () => {
65
- const seen: string[] = [];
66
- setClient(clientListing([entry()], seen));
67
-
68
- const response = await listQuarantine({} as Context, eventFor(SUB));
69
-
70
- assert.deepEqual(seen, [deriveAccountConfigId(SUB)]);
71
- assert.deepEqual(response, { entries: [entry()] });
72
- });
73
-
74
- it("returns an empty list rather than failing when nothing is quarantined", async () => {
75
- setClient(clientListing([], []));
76
-
77
- const response = await listQuarantine({} as Context, eventFor(SUB));
78
-
79
- assert.deepEqual(response, { entries: [] });
80
- });
81
- });