@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
@@ -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
- });