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