@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,438 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
- import type { AccountSettingItem } from "@remit/data-ports";
7
- import type { RoleMailboxCandidate } from "@remit/data-ports/folder-role";
8
- import {
9
- CanonicalMailboxRole,
10
- FolderAppointmentSource,
11
- MailboxSpecialUse,
12
- } from "@remit/domain-enums";
13
- import {
14
- CANONICAL_ROLES,
15
- groupFolderAppointmentsByAccount,
16
- loadFolderAppointmentsForAccount,
17
- type PersistedFolderAppointment,
18
- resolveFolderAppointments,
19
- writeFolderRoleAppointment,
20
- } from "./folder-role-appointments.js";
21
-
22
- const setting = (name: string, value: string): AccountSettingItem =>
23
- ({
24
- accountSettingId: `s-${name}`,
25
- accountConfigId: "cfg-1",
26
- name,
27
- value: { kind: "String", value },
28
- createdAt: 0,
29
- updatedAt: 0,
30
- }) as AccountSettingItem;
31
-
32
- const appointed = (
33
- mailboxId: string,
34
- lastKnownPath?: string,
35
- ): Map<string, PersistedFolderAppointment> =>
36
- new Map([[CanonicalMailboxRole.Trash, { mailboxId, lastKnownPath }]]);
37
-
38
- describe("CANONICAL_ROLES", () => {
39
- it("carries every RFC 032 anchor role, Custom excluded", () => {
40
- assert.deepEqual(
41
- [...CANONICAL_ROLES].sort(),
42
- [
43
- CanonicalMailboxRole.Inbox,
44
- CanonicalMailboxRole.Drafts,
45
- CanonicalMailboxRole.Sent,
46
- CanonicalMailboxRole.Archive,
47
- CanonicalMailboxRole.Junk,
48
- CanonicalMailboxRole.Trash,
49
- CanonicalMailboxRole.All,
50
- CanonicalMailboxRole.Flagged,
51
- ].sort(),
52
- );
53
- });
54
- });
55
-
56
- describe("resolveFolderAppointments", () => {
57
- const folders: RoleMailboxCandidate[] = [
58
- { mailboxId: "mb-inbox", fullPath: "INBOX", hierarchyDelimiter: "/" },
59
- {
60
- mailboxId: "mb-concepten",
61
- fullPath: "INBOX/Concepten",
62
- hierarchyDelimiter: "/",
63
- },
64
- {
65
- mailboxId: "mb-spam",
66
- fullPath: "INBOX/Spam",
67
- hierarchyDelimiter: "/",
68
- specialUse: [MailboxSpecialUse.Junk],
69
- },
70
- ];
71
-
72
- const roleIn = (
73
- result: ReturnType<typeof resolveFolderAppointments>,
74
- role: string,
75
- ) => result.find((entry) => entry.role === role);
76
-
77
- it("carries one entry per canonical role, even when unfilled", () => {
78
- const result = resolveFolderAppointments(new Map(), folders);
79
- assert.deepEqual(
80
- result.map((r) => r.role).sort(),
81
- [...CANONICAL_ROLES].sort(),
82
- );
83
- });
84
-
85
- it("gives every role a source, so no surface has to infer one", () => {
86
- const result = resolveFolderAppointments(new Map(), folders);
87
- assert.equal(
88
- result.every((entry) => entry.source !== undefined),
89
- true,
90
- );
91
- });
92
-
93
- it("tells a folder the user chose from one reader guessed by name", () => {
94
- const persisted = new Map<string, PersistedFolderAppointment>([
95
- [CanonicalMailboxRole.Drafts, { mailboxId: "mb-concepten" }],
96
- ]);
97
- const result = resolveFolderAppointments(persisted, folders);
98
- const drafts = roleIn(result, CanonicalMailboxRole.Drafts);
99
- assert.equal(drafts?.mailboxId, "mb-concepten");
100
- assert.equal(drafts?.source, FolderAppointmentSource.Appointed);
101
-
102
- const proposed = resolveFolderAppointments(new Map(), folders);
103
- const guess = roleIn(proposed, CanonicalMailboxRole.Drafts);
104
- assert.equal(guess?.mailboxId, "mb-concepten");
105
- assert.equal(guess?.source, FolderAppointmentSource.Proposed);
106
- });
107
-
108
- it("marks the server's own flag and the reserved INBOX name as such", () => {
109
- const result = resolveFolderAppointments(new Map(), folders);
110
- assert.equal(
111
- roleIn(result, CanonicalMailboxRole.Junk)?.source,
112
- FolderAppointmentSource.Flagged,
113
- );
114
- assert.equal(
115
- roleIn(result, CanonicalMailboxRole.Inbox)?.source,
116
- FolderAppointmentSource.Reserved,
117
- );
118
- });
119
-
120
- it("surfaces a choice whose folder is gone as broken, not as absent", () => {
121
- const persisted = new Map<string, PersistedFolderAppointment>([
122
- [
123
- CanonicalMailboxRole.Junk,
124
- { mailboxId: "mb-deleted-long-ago", lastKnownPath: "INBOX/Rommel" },
125
- ],
126
- ]);
127
- const junk = roleIn(
128
- resolveFolderAppointments(persisted, folders),
129
- CanonicalMailboxRole.Junk,
130
- );
131
- assert.equal(junk?.source, FolderAppointmentSource.Stale);
132
- assert.equal(junk?.staleAppointmentMailboxId, "mb-deleted-long-ago");
133
- assert.equal(junk?.staleAppointmentPath, "INBOX/Rommel");
134
- assert.equal(junk?.mailboxId, "mb-spam");
135
- });
136
-
137
- it("leaves a role unfilled when nothing persisted or proposed matches", () => {
138
- const archive = roleIn(
139
- resolveFolderAppointments(new Map(), folders),
140
- CanonicalMailboxRole.Archive,
141
- );
142
- assert.equal(archive?.mailboxId, undefined);
143
- assert.equal(archive?.source, FolderAppointmentSource.None);
144
- });
145
-
146
- it("never reads the recorded path as evidence for the role", () => {
147
- const trash = roleIn(
148
- resolveFolderAppointments(appointed("mb-inbox", "Prullenbak"), folders),
149
- CanonicalMailboxRole.Trash,
150
- );
151
- assert.equal(trash?.mailboxId, "mb-inbox");
152
- assert.equal(trash?.staleAppointmentPath, undefined);
153
- });
154
- });
155
-
156
- describe("groupFolderAppointmentsByAccount", () => {
157
- it("groups by accountId then role from the composite setting name", () => {
158
- const settings = [
159
- setting(
160
- `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Drafts}`,
161
- "mb-1",
162
- ),
163
- setting(
164
- `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Sent}`,
165
- "mb-2",
166
- ),
167
- setting(
168
- `FolderRoleAppointment#acc-2#${CanonicalMailboxRole.Inbox}`,
169
- "mb-3",
170
- ),
171
- setting("AccountDisplayName#acc-1", "Alice"),
172
- ];
173
- const grouped = groupFolderAppointmentsByAccount(settings);
174
- assert.deepEqual(Object.fromEntries(grouped.get("acc-1") ?? []), {
175
- [CanonicalMailboxRole.Drafts]: { mailboxId: "mb-1" },
176
- [CanonicalMailboxRole.Sent]: { mailboxId: "mb-2" },
177
- });
178
- assert.deepEqual(Object.fromEntries(grouped.get("acc-2") ?? []), {
179
- [CanonicalMailboxRole.Inbox]: { mailboxId: "mb-3" },
180
- });
181
- });
182
-
183
- it("attaches the recorded path to the appointment it belongs to", () => {
184
- const settings = [
185
- setting(
186
- `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Trash}`,
187
- "mb-1",
188
- ),
189
- setting(
190
- `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Trash}`,
191
- "INBOX/Bak",
192
- ),
193
- setting(
194
- `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Sent}`,
195
- "INBOX/Verzonden",
196
- ),
197
- ];
198
- const grouped = groupFolderAppointmentsByAccount(settings);
199
- assert.deepEqual(Object.fromEntries(grouped.get("acc-1") ?? []), {
200
- [CanonicalMailboxRole.Trash]: {
201
- mailboxId: "mb-1",
202
- lastKnownPath: "INBOX/Bak",
203
- },
204
- });
205
- });
206
-
207
- it("ignores a leftover MailboxRole#<mailboxId> row instead of throwing", () => {
208
- // The #963/#964 backfill wrote `MailboxRole#<mailboxId>` rows that are no
209
- // longer written but still persist in production. `FolderRoleAppointment`
210
- // superseded `MailboxRole` (RFC 032 exclusive-folder-appointment, #976);
211
- // a leftover row alongside real appointments must not make GET /config
212
- // throw — it is simply not a folder-role appointment and gets skipped.
213
- const settings = [
214
- setting(
215
- `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Drafts}`,
216
- "mb-1",
217
- ),
218
- setting("MailboxRole#mb-legacy", "custom"),
219
- ];
220
- const grouped = groupFolderAppointmentsByAccount(settings);
221
- assert.deepEqual(Object.fromEntries(grouped.get("acc-1") ?? []), {
222
- [CanonicalMailboxRole.Drafts]: { mailboxId: "mb-1" },
223
- });
224
- });
225
- });
226
-
227
- describe("loadFolderAppointmentsForAccount", () => {
228
- const readerOver = (stored: Map<string, string>) => ({
229
- get: async (_accountConfigId: string, name: string) => {
230
- const value = stored.get(name);
231
- return value ? setting(name, value) : null;
232
- },
233
- });
234
-
235
- it("reads each role's row and collects only the ones that exist", async () => {
236
- const roles = await loadFolderAppointmentsForAccount(
237
- readerOver(
238
- new Map([
239
- [
240
- `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Sent}`,
241
- "mb-sent",
242
- ],
243
- ]),
244
- ),
245
- "cfg-1",
246
- "acc-1",
247
- );
248
- assert.deepEqual(Object.fromEntries(roles), {
249
- [CanonicalMailboxRole.Sent]: { mailboxId: "mb-sent" },
250
- });
251
- });
252
-
253
- it("picks up the recorded path beside the appointment", async () => {
254
- const roles = await loadFolderAppointmentsForAccount(
255
- readerOver(
256
- new Map([
257
- [
258
- `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Trash}`,
259
- "mb-bak",
260
- ],
261
- [
262
- `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Trash}`,
263
- "INBOX/Bak",
264
- ],
265
- ]),
266
- ),
267
- "cfg-1",
268
- "acc-1",
269
- );
270
- assert.deepEqual(roles.get(CanonicalMailboxRole.Trash), {
271
- mailboxId: "mb-bak",
272
- lastKnownPath: "INBOX/Bak",
273
- });
274
- });
275
-
276
- it("drops a label left behind by an appointment that was cleared", async () => {
277
- const roles = await loadFolderAppointmentsForAccount(
278
- readerOver(
279
- new Map([
280
- [
281
- `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Trash}`,
282
- "INBOX/Bak",
283
- ],
284
- ]),
285
- ),
286
- "cfg-1",
287
- "acc-1",
288
- );
289
- assert.equal(roles.get(CanonicalMailboxRole.Trash), undefined);
290
- });
291
- });
292
-
293
- describe("writeFolderRoleAppointment", () => {
294
- const recorder = () => {
295
- const upserts: unknown[] = [];
296
- const deletes: unknown[][] = [];
297
- return {
298
- upserts,
299
- deletes,
300
- accountSetting: {
301
- upsert: async (input: unknown) => {
302
- upserts.push(input);
303
- return input as never;
304
- },
305
- delete: async (accountConfigId: string, name: string) => {
306
- deletes.push([accountConfigId, name]);
307
- },
308
- },
309
- };
310
- };
311
-
312
- it("records the path alongside the appointment", async () => {
313
- const { upserts, deletes, accountSetting } = recorder();
314
- await writeFolderRoleAppointment(
315
- accountSetting,
316
- "cfg-1",
317
- "acc-1",
318
- CanonicalMailboxRole.Archive,
319
- "mb-archive",
320
- "INBOX/Archief",
321
- );
322
- assert.deepEqual(upserts, [
323
- {
324
- accountConfigId: "cfg-1",
325
- name: `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Archive}`,
326
- value: { kind: "String", value: "mb-archive" },
327
- },
328
- {
329
- accountConfigId: "cfg-1",
330
- name: `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Archive}`,
331
- value: { kind: "String", value: "INBOX/Archief" },
332
- },
333
- ]);
334
- assert.deepEqual(deletes, []);
335
- });
336
-
337
- it("clears a path left over from the previous choice", async () => {
338
- const { upserts, deletes, accountSetting } = recorder();
339
- await writeFolderRoleAppointment(
340
- accountSetting,
341
- "cfg-1",
342
- "acc-1",
343
- CanonicalMailboxRole.Archive,
344
- "mb-archive",
345
- );
346
- assert.equal(upserts.length, 1);
347
- assert.deepEqual(deletes, [
348
- [
349
- "cfg-1",
350
- `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Archive}`,
351
- ],
352
- ]);
353
- });
354
-
355
- it("deletes both rows when the appointment is cleared", async () => {
356
- const { upserts, deletes, accountSetting } = recorder();
357
- await writeFolderRoleAppointment(
358
- accountSetting,
359
- "cfg-1",
360
- "acc-1",
361
- CanonicalMailboxRole.Archive,
362
- null,
363
- );
364
- assert.deepEqual(upserts, []);
365
- assert.deepEqual(deletes, [
366
- ["cfg-1", `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Archive}`],
367
- [
368
- "cfg-1",
369
- `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Archive}`,
370
- ],
371
- ]);
372
- });
373
- });
374
-
375
- /**
376
- * D1 of the #887 design: a folder-role appointment row means one thing — a
377
- * person decided. That is what makes an appointment outrank the server's own
378
- * \Trash flag, and what makes an Empty Trash on one defensible. It holds only
379
- * while the row has a single author, so the author is pinned here rather than
380
- * left to review.
381
- */
382
- describe("who may write a folder-role appointment", () => {
383
- const PACKAGES = fileURLToPath(new URL("../../../", import.meta.url));
384
-
385
- const sourceFiles = (): string[] => {
386
- const files: string[] = [];
387
- for (const pkg of readdirSync(PACKAGES, { withFileTypes: true })) {
388
- if (!pkg.isDirectory()) continue;
389
- const src = join(PACKAGES, pkg.name, "src");
390
- if (!existsSync(src)) continue;
391
- for (const entry of readdirSync(src, { recursive: true })) {
392
- const name = String(entry);
393
- if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue;
394
- if (name.endsWith(".test.ts") || name.endsWith(".test.tsx")) continue;
395
- files.push(`${pkg.name}/src/${name}`);
396
- }
397
- }
398
- return files;
399
- };
400
-
401
- it("is composed in three places, and nowhere else in the repo", () => {
402
- const referrers = sourceFiles().filter((file) =>
403
- readFileSync(join(PACKAGES, file), "utf8").includes(
404
- "composeFolderRoleAppointmentName",
405
- ),
406
- );
407
- assert.deepEqual(referrers.sort(), [
408
- "backend/src/handlers/folder-role-appointments.ts",
409
- "data-ports/src/folder-role.ts",
410
- "drizzle-service/src/repos/i4-mailbox-special-use.ts",
411
- ]);
412
- });
413
-
414
- it("is written by writeFolderRoleAppointment alone", () => {
415
- const source = readFileSync(
416
- fileURLToPath(new URL("./folder-role-appointments.ts", import.meta.url)),
417
- "utf8",
418
- );
419
- const writer = source.slice(
420
- source.indexOf("export const writeFolderRoleAppointment"),
421
- );
422
- const body = writer.slice(0, writer.indexOf("\n};"));
423
-
424
- const count = (text: string, needle: string) =>
425
- text.split(needle).length - 1;
426
- // Without these, renaming the receiver satisfies the guard with 0 === 0.
427
- assert.ok(count(body, "accountSetting.upsert(") > 0);
428
- assert.ok(count(body, "accountSetting.delete(") > 0);
429
- assert.equal(
430
- count(source, "accountSetting.upsert("),
431
- count(body, "accountSetting.upsert("),
432
- );
433
- assert.equal(
434
- count(source, "accountSetting.delete("),
435
- count(body, "accountSetting.delete("),
436
- );
437
- });
438
- });
@@ -1,249 +0,0 @@
1
- import type { FolderAppointment } from "@remit/api-openapi-types";
2
- import type {
3
- AccountSettingItem,
4
- IAccountSettingRepository,
5
- IMailboxRepository,
6
- } from "@remit/data-ports";
7
- import {
8
- CANONICAL_ROLES,
9
- type CanonicalMailboxRoleValue,
10
- composeFolderRoleAppointmentLabelName,
11
- composeFolderRoleAppointmentName,
12
- parseFolderRoleAppointmentLabelName,
13
- parseFolderRoleAppointmentName,
14
- type RoleMailboxCandidate,
15
- type RoleResolution,
16
- resolveRoleForAccount,
17
- } from "@remit/data-ports/folder-role";
18
- import { FolderAppointmentSource } from "@remit/domain-enums";
19
-
20
- /**
21
- * RFC 032 exclusive-folder-appointment (#976): a per-account role→mailbox map.
22
- * Each row is a `FolderRoleAppointment#<accountId>#<role>` AccountSetting (RFC
23
- * 032 settings tiers), so a role can never be persisted twice for one account —
24
- * writing it replaces whichever mailbox previously held it. This module owns
25
- * that persistence; the read side is `resolveRoleForAccount`
26
- * (`@remit/data-ports/folder-role`), the same rule every special-folder lookup
27
- * in the backend and the workers goes through.
28
- *
29
- * A second row, `FolderRoleAppointmentLabel#<accountId>#<role>`, records the
30
- * path the appointed mailbox had at the time (#887), so a folder another client
31
- * later deletes can still be named back to the user. It is display only: it
32
- * lives beside the appointment rather than inside it precisely so the
33
- * repository that resolves roles composes one name and cannot reach the other.
34
- */
35
-
36
- export { CANONICAL_ROLES };
37
-
38
- /** One role's persisted appointment: the choice, plus the path it was made on. */
39
- export interface PersistedFolderAppointment {
40
- mailboxId: string;
41
- lastKnownPath?: string;
42
- }
43
-
44
- const stringValueOf = (item: AccountSettingItem): string | undefined => {
45
- const { value } = item;
46
- return value.kind === "String" ? value.value : undefined;
47
- };
48
-
49
- /**
50
- * Group every persisted folder-role appointment in one config-wide settings
51
- * read (e.g. GET /config's already-loaded `listByAccountConfig` result) by
52
- * accountId, then by role. Mirrors `groupAccountOverrides` / `groupMailboxOverrides`.
53
- */
54
- export const groupFolderAppointmentsByAccount = (
55
- settings: AccountSettingItem[],
56
- ): Map<string, Map<string, PersistedFolderAppointment>> => {
57
- const byAccount = new Map<string, Map<string, PersistedFolderAppointment>>();
58
-
59
- for (const setting of settings) {
60
- const target = parseFolderRoleAppointmentName(setting.name);
61
- if (!target) continue;
62
- const mailboxId = stringValueOf(setting);
63
- if (mailboxId === undefined) continue;
64
- const roles =
65
- byAccount.get(target.accountId) ??
66
- new Map<string, PersistedFolderAppointment>();
67
- roles.set(target.role, { mailboxId });
68
- byAccount.set(target.accountId, roles);
69
- }
70
-
71
- for (const setting of settings) {
72
- const target = parseFolderRoleAppointmentLabelName(setting.name);
73
- if (!target) continue;
74
- const lastKnownPath = stringValueOf(setting);
75
- if (lastKnownPath === undefined) continue;
76
- const appointment = byAccount.get(target.accountId)?.get(target.role);
77
- if (!appointment) continue;
78
- appointment.lastKnownPath = lastKnownPath;
79
- }
80
-
81
- return byAccount;
82
- };
83
-
84
- /**
85
- * Load one account's persisted appointments by reading each role's row
86
- * directly (mirrors `loadMailboxOverrides`'s three-get pattern). Used by the
87
- * create/update account handlers, which only ever need a single account.
88
- */
89
- export const loadFolderAppointmentsForAccount = async (
90
- accountSetting: Pick<IAccountSettingRepository, "get">,
91
- accountConfigId: string,
92
- accountId: string,
93
- ): Promise<Map<string, PersistedFolderAppointment>> => {
94
- const entries = await Promise.all(
95
- CANONICAL_ROLES.map(async (role) => {
96
- const appointment = await accountSetting.get(
97
- accountConfigId,
98
- composeFolderRoleAppointmentName(accountId, role),
99
- );
100
- const mailboxId = appointment ? stringValueOf(appointment) : undefined;
101
- if (mailboxId === undefined) return [role, undefined] as const;
102
-
103
- const label = await accountSetting.get(
104
- accountConfigId,
105
- composeFolderRoleAppointmentLabelName(accountId, role),
106
- );
107
- const lastKnownPath = label ? stringValueOf(label) : undefined;
108
- return [
109
- role,
110
- lastKnownPath === undefined
111
- ? { mailboxId }
112
- : { mailboxId, lastKnownPath },
113
- ] as const;
114
- }),
115
- );
116
- const roles = new Map<string, PersistedFolderAppointment>();
117
- for (const [role, appointment] of entries) {
118
- if (appointment) roles.set(role, appointment);
119
- }
120
- return roles;
121
- };
122
-
123
- /**
124
- * Persist (or clear) one role's appointment. `mailboxId: null` deletes the row
125
- * — the role goes back to unfilled (RFC 032 settings tiers: absence is unset).
126
- * A value upserts it, replacing whatever mailbox the role pointed at before;
127
- * there is no second row a duplicate could live in.
128
- *
129
- * The label row moves with it, so a path recorded for a previous choice can
130
- * never be shown against the current one.
131
- */
132
- export const writeFolderRoleAppointment = async (
133
- accountSetting: Pick<IAccountSettingRepository, "upsert" | "delete">,
134
- accountConfigId: string,
135
- accountId: string,
136
- role: CanonicalMailboxRoleValue,
137
- mailboxId: string | null,
138
- lastKnownPath?: string,
139
- ): Promise<void> => {
140
- const name = composeFolderRoleAppointmentName(accountId, role);
141
- const labelName = composeFolderRoleAppointmentLabelName(accountId, role);
142
-
143
- if (mailboxId === null) {
144
- await accountSetting.delete(accountConfigId, name);
145
- await accountSetting.delete(accountConfigId, labelName);
146
- return;
147
- }
148
-
149
- await accountSetting.upsert({
150
- accountConfigId,
151
- name,
152
- value: { kind: "String", value: mailboxId },
153
- });
154
-
155
- if (lastKnownPath === undefined) {
156
- await accountSetting.delete(accountConfigId, labelName);
157
- return;
158
- }
159
- await accountSetting.upsert({
160
- accountConfigId,
161
- name: labelName,
162
- value: { kind: "String", value: lastKnownPath },
163
- });
164
- };
165
-
166
- const SOURCE_BY_KIND: Record<
167
- "appointed" | "flagged" | "reserved" | "proposed",
168
- FolderAppointment["source"]
169
- > = {
170
- appointed: FolderAppointmentSource.Appointed,
171
- flagged: FolderAppointmentSource.Flagged,
172
- reserved: FolderAppointmentSource.Reserved,
173
- proposed: FolderAppointmentSource.Proposed,
174
- };
175
-
176
- const toFolderAppointment = (
177
- role: CanonicalMailboxRoleValue,
178
- resolution: RoleResolution<RoleMailboxCandidate>,
179
- lastKnownPath: string | undefined,
180
- ): FolderAppointment => {
181
- if (resolution.kind === "appointment_stale") {
182
- const { fallback } = resolution;
183
- return {
184
- role,
185
- source: FolderAppointmentSource.Stale,
186
- mailboxId:
187
- fallback.kind === "none" ? undefined : fallback.mailbox.mailboxId,
188
- staleAppointmentMailboxId: resolution.appointedMailboxId,
189
- staleAppointmentPath: lastKnownPath,
190
- };
191
- }
192
- if (resolution.kind === "none") {
193
- return { role, source: FolderAppointmentSource.None };
194
- }
195
- return {
196
- role,
197
- source: SOURCE_BY_KIND[resolution.kind],
198
- mailboxId: resolution.mailbox.mailboxId,
199
- };
200
- };
201
-
202
- /**
203
- * Resolve the full appointment set for one account, each entry saying where it
204
- * came from: the user's persisted choice when set, else the server's
205
- * SPECIAL-USE flag, else the reserved INBOX name, else a name proposal. A
206
- * choice naming a mailbox the account no longer holds resolves to whatever the
207
- * account would have had without it, and says so — the user's decision is
208
- * surfaced as broken rather than quietly discarded. Always one entry per
209
- * `CANONICAL_ROLES` member (RFC 032 settings tiers: total, never a sparse
210
- * array).
211
- */
212
- export const resolveFolderAppointments = (
213
- persisted: ReadonlyMap<string, PersistedFolderAppointment>,
214
- mailboxes: readonly RoleMailboxCandidate[],
215
- ): FolderAppointment[] =>
216
- CANONICAL_ROLES.map((role) => {
217
- const appointment = persisted.get(role);
218
- return toFolderAppointment(
219
- role,
220
- resolveRoleForAccount(role, mailboxes, appointment?.mailboxId),
221
- appointment?.lastKnownPath,
222
- );
223
- });
224
-
225
- /**
226
- * End-to-end resolution for one account: load its persisted appointments and
227
- * its mailboxes, then resolve. The single entry point every handler that
228
- * builds an AccountResponse calls (create/update account, GET /config's
229
- * per-account fan-out mirrors this with a pre-loaded settings batch instead —
230
- * see `groupFolderAppointmentsByAccount` — to avoid an N+1 settings read).
231
- */
232
- export const resolveAccountFolderAppointments = async (
233
- client: {
234
- mailbox: Pick<IMailboxRepository, "listAllByAccount">;
235
- accountSetting: Pick<IAccountSettingRepository, "get">;
236
- },
237
- accountConfigId: string,
238
- accountId: string,
239
- ): Promise<FolderAppointment[]> => {
240
- const [persisted, mailboxes] = await Promise.all([
241
- loadFolderAppointmentsForAccount(
242
- client.accountSetting,
243
- accountConfigId,
244
- accountId,
245
- ),
246
- client.mailbox.listAllByAccount(accountId),
247
- ]);
248
- return resolveFolderAppointments(persisted, mailboxes);
249
- };