@remit/backend 0.0.143 → 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 -522
  251. package/src/service/data-client.test.ts +0 -97
  252. package/src/service/data-client.ts +0 -58
  253. package/src/service/export-identity.ts +0 -39
  254. package/src/service/filter.ts +0 -40
  255. package/src/service/fire-and-forget.test.ts +0 -126
  256. package/src/service/fire-and-forget.ts +0 -79
  257. package/src/service/localhost-env-config.test.ts +0 -36
  258. package/src/service/organize-queue.ts +0 -98
  259. package/src/service/organize.test.ts +0 -1451
  260. package/src/service/organize.ts +0 -1025
  261. package/src/service/read-path-filter-config.test.ts +0 -243
  262. package/src/service/semantic-capability.test.ts +0 -78
  263. package/src/service/semantic-capability.ts +0 -95
  264. package/src/service/sender-category.test.ts +0 -235
  265. package/src/service/trigger-sync.test.ts +0 -205
  266. package/src/service/trigger-sync.ts +0 -100
  267. package/src/stdout-capture-fixture.ts +0 -40
  268. package/src/types.ts +0 -271
  269. package/tsconfig.json +0 -7
@@ -0,0 +1,114 @@
1
+ import { ownedAccount } from "@remit/mailbox-service";
2
+ import { getAccountConfigIdFromEvent } from "../auth.js";
3
+ import { getClient } from "../service/data-client.js";
4
+ import { unwrap } from "../service/refusal.js";
5
+ /**
6
+ * Every filter in the account whose action applies this label (issue #26). The
7
+ * count backs `LabelResponse.filterCount`; the list backs the cascade delete.
8
+ */
9
+ export const findFiltersForLabel = async (deps, accountConfigId, labelId) => {
10
+ const filters = await deps.filter.listByAccountConfig(accountConfigId);
11
+ return filters.filter((filter) => filter.actionLabelId === labelId);
12
+ };
13
+ /**
14
+ * `filterCount` is computed here, not stored (issue #26) — every other field
15
+ * comes straight off the `LabelItem` row.
16
+ */
17
+ const toLabelResponse = (item, filterCount) => ({
18
+ labelId: item.labelId,
19
+ accountConfigId: item.accountConfigId,
20
+ name: item.name,
21
+ color: item.color,
22
+ filterCount,
23
+ createdAt: item.createdAt,
24
+ updatedAt: item.updatedAt,
25
+ });
26
+ /**
27
+ * Delete a label and cascade in both directions (issue #26): every filter
28
+ * whose `actionLabelId` is this label is deleted outright — never left
29
+ * dangling, never blocking the delete — and every `MessageLabel` row for it is
30
+ * removed. The label row itself is deleted last so a failure mid-cascade never
31
+ * leaves the label gone while its filters or applied-to-messages still exist.
32
+ */
33
+ export const deleteLabelWithCascade = async (deps, accountConfigId, labelId) => {
34
+ const filters = await findFiltersForLabel(deps, accountConfigId, labelId);
35
+ for (const filter of filters) {
36
+ await deps.filter.delete(accountConfigId, filter.filterId);
37
+ }
38
+ await deps.messageLabel.removeAllByLabelId(accountConfigId, labelId);
39
+ await deps.label.delete(accountConfigId, labelId);
40
+ };
41
+ export const LabelOperations = {
42
+ LabelOperations_listLabels: async (context, ...args) => {
43
+ const event = args[0];
44
+ const accountConfigId = getAccountConfigIdFromEvent(event);
45
+ const { accountId } = context.request.params;
46
+ const { continuationToken } = context.request.query;
47
+ const client = await getClient();
48
+ const account = await client.account.get(accountId);
49
+ unwrap(ownedAccount(account, accountConfigId, "read"));
50
+ const page = await client.label.listPageByAccountConfig(accountConfigId, {
51
+ continuationToken,
52
+ });
53
+ const filters = await client.filter.listByAccountConfig(accountConfigId);
54
+ const filterCountByLabelId = new Map();
55
+ for (const filter of filters) {
56
+ const labelId = filter.actionLabelId;
57
+ filterCountByLabelId.set(labelId, (filterCountByLabelId.get(labelId) ?? 0) + 1);
58
+ }
59
+ return {
60
+ items: page.items.map((item) => toLabelResponse(item, filterCountByLabelId.get(item.labelId) ?? 0)),
61
+ continuationToken: page.continuationToken,
62
+ };
63
+ },
64
+ LabelOperations_createLabel: async (context, ...args) => {
65
+ const event = args[0];
66
+ const accountConfigId = getAccountConfigIdFromEvent(event);
67
+ const { accountId } = context.request.params;
68
+ const input = context.request.requestBody;
69
+ const client = await getClient();
70
+ const account = await client.account.get(accountId);
71
+ unwrap(ownedAccount(account, accountConfigId, "act"));
72
+ const label = await client.label.create({
73
+ accountConfigId,
74
+ name: input.name,
75
+ color: input.color,
76
+ });
77
+ return toLabelResponse(label, 0);
78
+ },
79
+ };
80
+ export const LabelDetailOperations = {
81
+ LabelDetailOperations_getLabel: async (context, ...args) => {
82
+ const event = args[0];
83
+ const accountConfigId = getAccountConfigIdFromEvent(event);
84
+ const { accountId, labelId } = context.request.params;
85
+ const client = await getClient();
86
+ const account = await client.account.get(accountId);
87
+ unwrap(ownedAccount(account, accountConfigId, "read"));
88
+ const label = await client.label.get(accountConfigId, labelId);
89
+ const filters = await findFiltersForLabel(client, accountConfigId, labelId);
90
+ return toLabelResponse(label, filters.length);
91
+ },
92
+ LabelDetailOperations_updateLabel: async (context, ...args) => {
93
+ const event = args[0];
94
+ const accountConfigId = getAccountConfigIdFromEvent(event);
95
+ const { accountId, labelId } = context.request.params;
96
+ const body = context.request.requestBody;
97
+ const client = await getClient();
98
+ const account = await client.account.get(accountId);
99
+ unwrap(ownedAccount(account, accountConfigId, "act"));
100
+ const updated = await client.label.update(accountConfigId, labelId, body);
101
+ const filters = await findFiltersForLabel(client, accountConfigId, labelId);
102
+ return toLabelResponse(updated, filters.length);
103
+ },
104
+ LabelDetailOperations_deleteLabel: async (context, ...args) => {
105
+ const event = args[0];
106
+ const accountConfigId = getAccountConfigIdFromEvent(event);
107
+ const { accountId, labelId } = context.request.params;
108
+ const client = await getClient();
109
+ const account = await client.account.get(accountId);
110
+ unwrap(ownedAccount(account, accountConfigId, "act"));
111
+ await deleteLabelWithCascade(client, accountConfigId, labelId);
112
+ return { statusCode: 204 };
113
+ },
114
+ };
@@ -0,0 +1,41 @@
1
+ import type { IAccountSettingRepository, IFilterRepository, MailboxItem } from "@remit/data-ports";
2
+ import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
3
+ /**
4
+ * What a folder is bound to: the filters that move mail into it, and the
5
+ * canonical roles it has been appointed to.
6
+ */
7
+ export interface MailboxBindings {
8
+ filterNames: string[];
9
+ roles: CanonicalMailboxRoleValue[];
10
+ }
11
+ /**
12
+ * `filter.actionMailboxId` has no reverse index, so the filters are listed for
13
+ * the account config and matched — they are few and per-config, so this is not
14
+ * a mailbox scan. Role appointments are `account_setting` rows whose *value* is
15
+ * a mailboxId, which a `mailbox_id` column scan cannot find, so the existing
16
+ * per-account appointment map answers for them.
17
+ */
18
+ export declare const loadMailboxBindings: (client: {
19
+ filter: Pick<IFilterRepository, "listByAccountConfig">;
20
+ accountSetting: Pick<IAccountSettingRepository, "get">;
21
+ }, accountConfigId: string, accountId: string, mailboxId: string) => Promise<MailboxBindings>;
22
+ /**
23
+ * Refuse a delete while any durable reference is bound to the folder (D16).
24
+ *
25
+ * This is what makes the settled-target rule total: a binding is only created
26
+ * against a settled folder and only removed by the user, so a `mailboxId` that
27
+ * names nothing never exists and no reader of a filter's `actionMailboxId` or a
28
+ * role appointment needs a missing-target branch. It removes code rather than
29
+ * adding a check.
30
+ *
31
+ * The alternatives both cost more than they look. `Filter` has no enabled flag
32
+ * and its `actionMailboxId` sentinel means "no move action", so clearing the
33
+ * target silently turns a move-filter into one that does something else;
34
+ * deleting the user's filters outright is not a decision a folder delete gets
35
+ * to make.
36
+ *
37
+ * The message **names** each binding rather than counting them, because a count
38
+ * is not actionable — and this fires on ordinary configurations, since pointing
39
+ * a filter at a folder is what filters are for.
40
+ */
41
+ export declare const assertNoBindings: (mailbox: Pick<MailboxItem, "fullPath">, bindings: MailboxBindings) => void;
@@ -0,0 +1,61 @@
1
+ import { FILTER_NO_ACTION } from "@remit/data-ports";
2
+ import { BadRequestError } from "@remit/data-ports/errors";
3
+ import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
4
+ /**
5
+ * `filter.actionMailboxId` has no reverse index, so the filters are listed for
6
+ * the account config and matched — they are few and per-config, so this is not
7
+ * a mailbox scan. Role appointments are `account_setting` rows whose *value* is
8
+ * a mailboxId, which a `mailbox_id` column scan cannot find, so the existing
9
+ * per-account appointment map answers for them.
10
+ */
11
+ export const loadMailboxBindings = async (client, accountConfigId, accountId, mailboxId) => {
12
+ const [filters, appointments] = await Promise.all([
13
+ client.filter.listByAccountConfig(accountConfigId),
14
+ loadFolderAppointmentsForAccount(client.accountSetting, accountConfigId, accountId),
15
+ ]);
16
+ const filterNames = filters
17
+ .filter((filter) => filter.actionMailboxId !== FILTER_NO_ACTION &&
18
+ filter.actionMailboxId === mailboxId)
19
+ .map((filter) => filter.name);
20
+ const roles = [];
21
+ for (const [role, appointment] of appointments) {
22
+ if (appointment.mailboxId === mailboxId) {
23
+ roles.push(role);
24
+ }
25
+ }
26
+ return { filterNames, roles };
27
+ };
28
+ const asList = (items) => items.length === 1
29
+ ? items[0]
30
+ : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
31
+ /**
32
+ * Refuse a delete while any durable reference is bound to the folder (D16).
33
+ *
34
+ * This is what makes the settled-target rule total: a binding is only created
35
+ * against a settled folder and only removed by the user, so a `mailboxId` that
36
+ * names nothing never exists and no reader of a filter's `actionMailboxId` or a
37
+ * role appointment needs a missing-target branch. It removes code rather than
38
+ * adding a check.
39
+ *
40
+ * The alternatives both cost more than they look. `Filter` has no enabled flag
41
+ * and its `actionMailboxId` sentinel means "no move action", so clearing the
42
+ * target silently turns a move-filter into one that does something else;
43
+ * deleting the user's filters outright is not a decision a folder delete gets
44
+ * to make.
45
+ *
46
+ * The message **names** each binding rather than counting them, because a count
47
+ * is not actionable — and this fires on ordinary configurations, since pointing
48
+ * a filter at a folder is what filters are for.
49
+ */
50
+ export const assertNoBindings = (mailbox, bindings) => {
51
+ const clauses = [];
52
+ if (bindings.filterNames.length > 0) {
53
+ clauses.push(`the ${bindings.filterNames.length === 1 ? "filter" : "filters"} ${asList(bindings.filterNames.map((name) => `“${name}”`))}`);
54
+ }
55
+ if (bindings.roles.length > 0) {
56
+ clauses.push(`the ${asList(bindings.roles)} ${bindings.roles.length === 1 ? "role" : "roles"}`);
57
+ }
58
+ if (clauses.length === 0)
59
+ return;
60
+ throw new BadRequestError(`“${mailbox.fullPath}” is used by ${asList(clauses)}.`);
61
+ };
@@ -0,0 +1,23 @@
1
+ import type { RenameMailboxInput } from "@remit/api-openapi-types";
2
+ import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
3
+ import { type FolderPathDeps } from "@remit/mailbox-service";
4
+ import type { MailboxDetailOperationIds, MailboxOperationIds, OperationHandler, TrashOperationIds } from "../types.js";
5
+ /**
6
+ * The mute flag and the display-name override are user preferences that live
7
+ * in per-mailbox AccountSetting rows (RFC 032), not on the Mailbox entity.
8
+ * Pick only those keys from a PATCH body: each follows the same `null` → remove,
9
+ * value → set, absent/undefined → no-op semantics as UpdateAddressFlagsInput.
10
+ * The canonical role a folder fills is appointed separately — see
11
+ * FolderRoleOperations.appointFolderRole (RFC 032 exclusive-folder-appointment).
12
+ */
13
+ export declare const pickMailboxOverrideChanges: (body: RenameMailboxInput) => {
14
+ displayNameOverride?: string | null;
15
+ muted?: RenameMailboxInput["muted"];
16
+ };
17
+ export type MailboxPatchClient = FolderPathDeps & {
18
+ accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
19
+ };
20
+ export declare const applyMailboxPatch: (client: MailboxPatchClient, accountConfigId: string, mailboxId: string, accountId: string, body: RenameMailboxInput) => Promise<MailboxItem>;
21
+ export declare const MailboxOperations: Record<MailboxOperationIds, OperationHandler<MailboxOperationIds>>;
22
+ export declare const MailboxDetailOperations: Record<MailboxDetailOperationIds, OperationHandler<MailboxDetailOperationIds>>;
23
+ export declare const TrashOperations: Record<TrashOperationIds, OperationHandler<TrashOperationIds>>;
@@ -0,0 +1,219 @@
1
+ import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
2
+ import { MessageSystemFlag } from "@remit/domain-enums";
3
+ import { changeFolderPath, createFolder, folderInAccount, folderInFlight, ownedAccount, ownedFolder, } from "@remit/mailbox-service";
4
+ import { getAccountConfigIdFromEvent } from "../auth.js";
5
+ import { applyPendingMoveCountPrediction, } from "../derive/pendingMoveCounts.js";
6
+ import { getClient } from "../service/data-client.js";
7
+ import { unwrap } from "../service/refusal.js";
8
+ import { applyMailboxOverrideChanges, loadMailboxOverrides, loadMailboxOverridesForConfig, } from "./account-overrides.js";
9
+ import { assertNoBindings, loadMailboxBindings } from "./mailbox-bindings.js";
10
+ /**
11
+ * The mute flag and the display-name override are user preferences that live
12
+ * in per-mailbox AccountSetting rows (RFC 032), not on the Mailbox entity.
13
+ * Pick only those keys from a PATCH body: each follows the same `null` → remove,
14
+ * value → set, absent/undefined → no-op semantics as UpdateAddressFlagsInput.
15
+ * The canonical role a folder fills is appointed separately — see
16
+ * FolderRoleOperations.appointFolderRole (RFC 032 exclusive-folder-appointment).
17
+ */
18
+ export const pickMailboxOverrideChanges = (body) => {
19
+ const changes = {};
20
+ if (Object.hasOwn(body, "displayNameOverride")) {
21
+ changes.displayNameOverride = body.displayNameOverride;
22
+ }
23
+ if (Object.hasOwn(body, "muted")) {
24
+ changes.muted = body.muted;
25
+ }
26
+ return changes;
27
+ };
28
+ // Overrides land first and independently, so an override-only PATCH never
29
+ // touches the folder's mutation state.
30
+ export const applyMailboxPatch = async (client, accountConfigId, mailboxId, accountId, body) => {
31
+ const { fullPath } = body;
32
+ const applyOverrides = async () => {
33
+ const changes = pickMailboxOverrideChanges(body);
34
+ if (Object.keys(changes).length === 0)
35
+ return;
36
+ await applyMailboxOverrideChanges(client.accountSetting, accountConfigId, mailboxId, changes);
37
+ };
38
+ if (!fullPath) {
39
+ const folder = unwrap(await ownedFolder(client, accountConfigId, accountId, mailboxId));
40
+ await applyOverrides();
41
+ return folder;
42
+ }
43
+ const { outcome, mailbox } = unwrap(await changeFolderPath(client, accountConfigId, {
44
+ accountId,
45
+ mailboxId,
46
+ fullPath,
47
+ }));
48
+ await applyOverrides();
49
+ if (outcome === "AlreadyRecorded") {
50
+ throw new ConflictError(folderInFlight(mailbox));
51
+ }
52
+ return mailbox;
53
+ };
54
+ /**
55
+ * Every pending placement move (issue #1271) for an account, on whichever
56
+ * backend is active (`RemitClient.placementMove` is present on both — see
57
+ * `create-remit-client.ts`). Read-only; feeds
58
+ * `applyPendingMoveCountPrediction`'s read-time adjustment only, never mutates
59
+ * stored counts (epic #1281 invariant 4). Markers are written by the
60
+ * imap-worker bulk sync path through `RemitClient.placementMove` on every
61
+ * backend, so this is a real signal on the self-host stack too.
62
+ */
63
+ const loadPendingMoves = (client, accountId) => client.placementMove.listByAccountId(accountId);
64
+ /**
65
+ * Every pending `\Seen` flag-push marker (issue #1273) for an account —
66
+ * `\Flagged` (star) markers are excluded, since only read/unread state feeds
67
+ * `unseenCount`'s prediction. `RemitClient.flagPush` is present and WRITTEN
68
+ * on both backends, so this is a real signal on the self-host stack too.
69
+ */
70
+ const loadPendingUnseenFlagPushes = async (client, accountId) => {
71
+ const pushes = await client.flagPush.listByAccountId(accountId);
72
+ return pushes
73
+ .filter((push) => push.flagName === MessageSystemFlag.Seen)
74
+ .map((push) => ({ mailboxId: push.mailboxId, operation: push.operation }));
75
+ };
76
+ const toMailboxResponse = (mailbox, overrides = {}) => ({
77
+ mailboxId: mailbox.mailboxId,
78
+ accountId: mailbox.accountId,
79
+ namespaceType: mailbox.namespaceType,
80
+ namespacePrefix: mailbox.namespacePrefix,
81
+ hierarchyDelimiter: mailbox.hierarchyDelimiter,
82
+ fullPath: mailbox.fullPath,
83
+ messageCount: mailbox.messageCount,
84
+ unseenCount: mailbox.unseenCount,
85
+ deletedCount: mailbox.deletedCount,
86
+ specialUse: mailbox.specialUse ? Array.from(mailbox.specialUse) : undefined,
87
+ lastSyncUid: mailbox.lastSyncUid,
88
+ highWaterMarkUid: mailbox.highWaterMarkUid,
89
+ lastMessageSyncAt: mailbox.lastMessageSyncAt,
90
+ syncStatus: mailbox.syncStatus,
91
+ // The row is the only thing that knows what a rename is aiming at, and
92
+ // `fullPath` stays the confirmed one throughout (D2), so without this a
93
+ // client cannot tell a create in flight from a rename in flight, name the
94
+ // target of a failed rename, or offer the right retry.
95
+ pendingPath: mailbox.pendingPath,
96
+ syncFailureReason: mailbox.syncFailureReason,
97
+ muted: overrides.muted,
98
+ displayNameOverride: overrides.displayNameOverride,
99
+ createdAt: mailbox.createdAt,
100
+ updatedAt: mailbox.updatedAt,
101
+ });
102
+ export const MailboxOperations = {
103
+ MailboxOperations_listMailboxes: async (context, ...args) => {
104
+ const event = args[0];
105
+ const accountConfigId = getAccountConfigIdFromEvent(event);
106
+ const { accountId } = context.request.params;
107
+ const { continuationToken } = context.request.query;
108
+ const client = await getClient();
109
+ const account = await client.account.get(accountId);
110
+ unwrap(ownedAccount(account, accountConfigId, "read"));
111
+ const result = await client.mailbox.listByAccount(accountId, {
112
+ continuationToken,
113
+ });
114
+ // Overrides (mute / display-name / role) live in per-mailbox AccountSetting
115
+ // rows (RFC 032). Load the whole config's set in one query and key it by
116
+ // mailboxId so each mailbox surfaces its overrides without an N+1.
117
+ const overridesByMailbox = await loadMailboxOverridesForConfig(client.accountSetting, accountConfigId);
118
+ // Read-time prediction for any pending placement move (issue #1271) and
119
+ // pending \Seen flag push (issue #1273) — adjusts messageCount /
120
+ // unseenCount only, never the stored row (epic #1281 invariant 4).
121
+ const pendingMoves = await loadPendingMoves(client, accountId);
122
+ const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(client, accountId);
123
+ // A folder mid-mutation is listed, labelled (D11). Hiding a `deleting` one
124
+ // made a delete that does not settle look like a folder that silently
125
+ // vanished, while the server still held it.
126
+ const items = applyPendingMoveCountPrediction(result.items, pendingMoves, pendingUnseenFlagPushes);
127
+ return {
128
+ items: items.map((mailbox) => toMailboxResponse(mailbox, overridesByMailbox.get(mailbox.mailboxId) ?? {})),
129
+ continuationToken: result.continuationToken,
130
+ };
131
+ },
132
+ MailboxOperations_createMailbox: async (context, ...args) => {
133
+ const event = args[0];
134
+ const accountConfigId = getAccountConfigIdFromEvent(event);
135
+ const { accountId } = context.request.params;
136
+ const { namespaceType, fullPath } = context.request
137
+ .requestBody;
138
+ const created = await createFolder(await getClient(), accountConfigId, {
139
+ accountId,
140
+ namespaceType,
141
+ fullPath,
142
+ });
143
+ const { outcome, mailbox } = unwrap(created);
144
+ if (outcome === "Existing") {
145
+ throw new BadRequestError(`A folder named “${fullPath}” is already there.`);
146
+ }
147
+ return toMailboxResponse(mailbox);
148
+ },
149
+ };
150
+ export const MailboxDetailOperations = {
151
+ MailboxDetailOperations_getMailbox: async (context, ...args) => {
152
+ const event = args[0];
153
+ const accountConfigId = getAccountConfigIdFromEvent(event);
154
+ const { accountId, mailboxId } = context.request.params;
155
+ const client = await getClient();
156
+ const account = await client.account.get(accountId);
157
+ unwrap(ownedAccount(account, accountConfigId, "read"));
158
+ const mailbox = await client.mailbox.get(accountId, mailboxId);
159
+ unwrap(folderInAccount(mailbox, accountId, "read"));
160
+ const overrides = await loadMailboxOverrides(client.accountSetting, accountConfigId, mailboxId);
161
+ // Read-time prediction for any pending placement move (issue #1271) and
162
+ // pending \Seen flag push (issue #1273) — adjusts messageCount /
163
+ // unseenCount only, never the stored row (epic #1281 invariant 4).
164
+ const pendingMoves = await loadPendingMoves(client, accountId);
165
+ const pendingUnseenFlagPushes = await loadPendingUnseenFlagPushes(client, accountId);
166
+ const adjusted = applyPendingMoveCountPrediction([mailbox], pendingMoves, pendingUnseenFlagPushes)[0] ?? mailbox;
167
+ return toMailboxResponse(adjusted, overrides);
168
+ },
169
+ MailboxDetailOperations_renameMailbox: async (context, ...args) => {
170
+ const event = args[0];
171
+ const accountConfigId = getAccountConfigIdFromEvent(event);
172
+ const { accountId, mailboxId } = context.request.params;
173
+ const body = context.request.requestBody;
174
+ const client = await getClient();
175
+ const mailbox = await applyMailboxPatch(client, accountConfigId, mailboxId, accountId, body);
176
+ const overrides = await loadMailboxOverrides(client.accountSetting, accountConfigId, mailboxId);
177
+ return toMailboxResponse(mailbox, overrides);
178
+ },
179
+ MailboxDetailOperations_deleteMailbox: async (context, ...args) => {
180
+ const event = args[0];
181
+ const accountConfigId = getAccountConfigIdFromEvent(event);
182
+ const { accountId, mailboxId } = context.request.params;
183
+ const client = await getClient();
184
+ const account = await client.account.get(accountId);
185
+ unwrap(ownedAccount(account, accountConfigId, "act"));
186
+ const mailbox = await client.mailbox.get(accountId, mailboxId);
187
+ unwrap(folderInAccount(mailbox, accountId, "act"));
188
+ // Cheapest first (D4, D13). None of the three becomes possible by
189
+ // retrying the same request, so all three are 400 and all three run
190
+ // before anything is recorded.
191
+ if (mailbox.fullPath.toUpperCase() === "INBOX") {
192
+ throw new BadRequestError("The inbox can't be deleted.");
193
+ }
194
+ const children = await client.mailbox.findByPathPrefix(accountId, mailbox.fullPath, mailbox.hierarchyDelimiter);
195
+ if (children.length > 0) {
196
+ // The server would keep the parent's name as a placeholder and leave the
197
+ // children untouched — an outcome no local row describes (D9).
198
+ throw new BadRequestError(`“${mailbox.fullPath}” has folders inside it. Delete those first.`);
199
+ }
200
+ assertNoBindings(mailbox, await loadMailboxBindings(client, accountConfigId, accountId, mailboxId));
201
+ await client.mailboxQueue.deleteMailbox(mailboxId, accountId);
202
+ return { statusCode: 204 };
203
+ },
204
+ };
205
+ export const TrashOperations = {
206
+ TrashOperations_emptyTrash: async (context, ...args) => {
207
+ const event = args[0];
208
+ const accountConfigId = getAccountConfigIdFromEvent(event);
209
+ const { accountId } = context.request.params;
210
+ const client = await getClient();
211
+ const account = await client.account.get(accountId);
212
+ unwrap(ownedAccount(account, accountConfigId, "act"));
213
+ // The service resolves Trash, marks the rows and counts them, all off one
214
+ // read, and refuses with a coded 409 when the role is unresolved. Counting
215
+ // here as well would be a second answer free to disagree with the one that
216
+ // acts, and "0 deleted" reads as success to a user whose Trash is untouched.
217
+ return client.messageMove.emptyTrash(accountConfigId, accountId);
218
+ },
219
+ };
@@ -0,0 +1,2 @@
1
+ import type { MeOperationIds, OperationHandler } from "../types.js";
2
+ export declare const MeOperations: Record<MeOperationIds, OperationHandler<MeOperationIds>>;
@@ -0,0 +1,138 @@
1
+ import { SendMessageCommand } from "@aws-sdk/client-sqs";
2
+ import { BadRequestError, NotFoundError } from "@remit/data-ports/errors";
3
+ import { SYNC_WARNING_SURFACE_ATTEMPTS } from "@remit/data-ports/sync-warning-state";
4
+ import { logger } from "@remit/logger";
5
+ import { env } from "expect-env";
6
+ import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
7
+ import { getClient } from "../service/data-client.js";
8
+ import { sqsClient } from "../service/sqs.js";
9
+ import { toVipSuggestionEntry } from "./vip-suggestions.js";
10
+ const DOWNLOAD_URL_TTL_SECONDS = 60 * 60;
11
+ const SURFACED_WARNING_CAP = 200;
12
+ export const MeOperations = {
13
+ MeOperations_listVipSuggestions: async (_context, ...args) => {
14
+ const event = args[0];
15
+ const accountConfigId = getAccountConfigIdFromEvent(event);
16
+ const { address } = await getClient();
17
+ const items = await address.listSuggestedVips({ accountConfigId });
18
+ return { suggestions: items.map(toVipSuggestionEntry) };
19
+ },
20
+ MeOperations_listQuarantine: async (_context, ...args) => {
21
+ const event = args[0];
22
+ const accountConfigId = getAccountConfigIdFromEvent(event);
23
+ const { quarantine, syncWarning } = await getClient();
24
+ const [entries, warnings] = await Promise.all([
25
+ quarantine.listByAccountConfigId(accountConfigId),
26
+ syncWarning.listSurfacedByAccountConfigId(accountConfigId, SYNC_WARNING_SURFACE_ATTEMPTS, SURFACED_WARNING_CAP),
27
+ ]);
28
+ return {
29
+ entries,
30
+ warnings: warnings.items,
31
+ warningsTotal: warnings.total,
32
+ };
33
+ },
34
+ MeOperations_deleteMe: async (_context, ...args) => {
35
+ const event = args[0];
36
+ const accountConfigId = getAccountConfigIdFromEvent(event);
37
+ const { confirmEmail } = JSON.parse(event.body ?? "{}");
38
+ const callerEmail = event.requestContext?.authorizer?.claims?.email;
39
+ if (typeof confirmEmail !== "string" || confirmEmail.trim() === "") {
40
+ throw new BadRequestError("confirmEmail is required");
41
+ }
42
+ if (!callerEmail) {
43
+ throw new BadRequestError("Account email claim missing from token");
44
+ }
45
+ if (confirmEmail.toLowerCase() !== callerEmail.toLowerCase()) {
46
+ throw new BadRequestError("confirmEmail does not match your account email");
47
+ }
48
+ const { accountConfig } = await getClient();
49
+ try {
50
+ await accountConfig.update(accountConfigId, {
51
+ state: "deleting",
52
+ deletedAt: Date.now(),
53
+ });
54
+ }
55
+ catch (err) {
56
+ // If already in "deleting" state, treat as idempotent
57
+ if (err instanceof Error &&
58
+ (err.name === "ConditionalCheckFailedException" ||
59
+ err.message.includes("ConditionalCheckFailed"))) {
60
+ return {
61
+ statusCode: 202,
62
+ message: "Account deletion already in progress",
63
+ };
64
+ }
65
+ throw err;
66
+ }
67
+ const queueUrl = env.SQS_QUEUE_URL_ACCOUNT_FANOUT;
68
+ await sqsClient.send(new SendMessageCommand({
69
+ QueueUrl: queueUrl,
70
+ MessageBody: JSON.stringify({
71
+ type: "AccountDelete",
72
+ accountConfigId,
73
+ }),
74
+ }));
75
+ // biome-ignore lint/plugin/no-logger-info: account deletion is an audit-grade signal
76
+ logger.info({ accountConfigId }, "Account deletion initiated");
77
+ return {
78
+ statusCode: 202,
79
+ message: "Account deletion initiated",
80
+ };
81
+ },
82
+ MeOperations_createExport: async (_context, ...args) => {
83
+ const event = args[0];
84
+ const accountConfigId = getAccountConfigIdFromEvent(event);
85
+ const userId = getSubFromEvent(event);
86
+ if (!userId) {
87
+ throw new Error("Missing Cognito `sub`: cannot attribute an export request to a user");
88
+ }
89
+ const { accountExportRequest } = await getClient();
90
+ const exportRequest = await accountExportRequest.create({
91
+ accountConfigId,
92
+ userId,
93
+ state: "Pending",
94
+ });
95
+ await sqsClient.send(new SendMessageCommand({
96
+ QueueUrl: env.SQS_QUEUE_URL_ACCOUNT_FANOUT,
97
+ MessageBody: JSON.stringify({
98
+ type: "AccountExport",
99
+ accountConfigId,
100
+ accountExportRequestId: exportRequest.accountExportRequestId,
101
+ }),
102
+ }));
103
+ // biome-ignore lint/plugin/no-logger-info: account export is an audit-grade signal
104
+ logger.info({
105
+ accountConfigId,
106
+ accountExportRequestId: exportRequest.accountExportRequestId,
107
+ }, "Account export initiated");
108
+ return {
109
+ statusCode: 202,
110
+ exportId: exportRequest.accountExportRequestId,
111
+ };
112
+ },
113
+ MeOperations_getExport: async (context, ...args) => {
114
+ const event = args[0];
115
+ const accountConfigId = getAccountConfigIdFromEvent(event);
116
+ const exportId = context.request.params.exportId;
117
+ const { accountExportRequest, storage } = await getClient();
118
+ const exportRequest = await accountExportRequest.get(exportId);
119
+ if (exportRequest.accountConfigId !== accountConfigId) {
120
+ throw new NotFoundError(`Export not found: ${exportId}`);
121
+ }
122
+ const downloadUrl = exportRequest.state === "Ready" && exportRequest.objectKey
123
+ ? await storage.getPresignedDownloadUrl(exportRequest.objectKey, DOWNLOAD_URL_TTL_SECONDS)
124
+ : undefined;
125
+ return {
126
+ accountExportRequestId: exportRequest.accountExportRequestId,
127
+ accountConfigId: exportRequest.accountConfigId,
128
+ userId: exportRequest.userId,
129
+ state: exportRequest.state,
130
+ createdAt: exportRequest.createdAt,
131
+ updatedAt: exportRequest.updatedAt,
132
+ objectKey: exportRequest.objectKey,
133
+ downloadUrl,
134
+ expiresAt: exportRequest.expiresAt,
135
+ errorMessage: exportRequest.errorMessage,
136
+ };
137
+ },
138
+ };