@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,518 +0,0 @@
1
- import type {
2
- CreateFilterInput,
3
- CreateFilterResponse,
4
- FilterResponse,
5
- UpdateFilterInput as UpdateFilterRequestBody,
6
- } from "@remit/api-openapi-types";
7
- import {
8
- deriveFilterTtl,
9
- FILTER_NO_ACTION,
10
- type FilterItem,
11
- type IFilterAnchorTransaction,
12
- type IMailboxRepository,
13
- type UpdateFilterInput,
14
- } from "@remit/data-ports";
15
- import { BadRequestError } from "@remit/data-ports/errors";
16
- import {
17
- FilterDisabledReason,
18
- FilterScope,
19
- FilterState,
20
- } from "@remit/domain-enums";
21
- import type { AnchorPayload } from "@remit/search-service";
22
- import type { APIGatewayProxyEvent } from "aws-lambda";
23
- import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
24
- import { getClient } from "../service/data-client.js";
25
- import { buildFilterAnchor } from "../service/filter.js";
26
- import { queueOrganizeJob } from "../service/organize-queue.js";
27
- import type {
28
- FilterDetailOperationIds,
29
- FilterOperationIds,
30
- OperationHandler,
31
- } from "../types.js";
32
- import { assertAccountOwnership } from "./account-ownership.js";
33
- import { assertMailboxInAccount, assertMailboxSettled } from "./mailbox.js";
34
-
35
- /**
36
- * Minimal filter-service surface the create handler needs — declared as a
37
- * `Pick` so tests can stub it without a live table.
38
- */
39
- export interface FilterCrudDeps {
40
- /**
41
- * Creates the `Filter` row and its optional sibling `FilterAnchor` row in
42
- * one transaction (#351) — a failure on the anchor write can never leave a
43
- * `Filter` durably marked `hasAnchor: true` with no matching anchor row,
44
- * and it surfaces as a failed create request rather than a silently
45
- * broken filter.
46
- */
47
- filterAnchorTransaction: IFilterAnchorTransaction;
48
- buildAnchor(
49
- accountConfigId: string,
50
- anchorMessageId: string,
51
- ): Promise<AnchorPayload | null>;
52
- }
53
-
54
- /**
55
- * Reduce a PATCH body to the fields a filter update may set (RFC 034, reader
56
- * #266). Preserves absence: a key not present in the body is not present in
57
- * the patch, so a name-only rename yields `{ name }` and never touches a
58
- * predicate/action/scope/expiresAt field — the service's
59
- * `changesRuleAssertion` guard then leaves `ruleChangedAt` untouched (Decision
60
- * 3.2). Any field outside this set — most notably a server-derived
61
- * `state`/`ttl`/`hasAnchor`/`ruleChangedAt` smuggled into the body — is
62
- * dropped. `scope`/`expiresAt` land here as the caller sent them; a patch that
63
- * touches either still needs `resolveFilterScopeExpiry` to merge them against
64
- * the stored row and derive `ttl`/`state` before this reaches the repo.
65
- */
66
- export const pickFilterUpdate = (
67
- body: Partial<UpdateFilterRequestBody>,
68
- ): Partial<UpdateFilterInput> => {
69
- const patch: Partial<UpdateFilterInput> = {};
70
- if (Object.hasOwn(body, "name")) patch.name = body.name;
71
- if (Object.hasOwn(body, "scope")) patch.scope = body.scope;
72
- if (Object.hasOwn(body, "expiresAt")) patch.expiresAt = body.expiresAt;
73
- if (Object.hasOwn(body, "matchOperator")) {
74
- patch.matchOperator = body.matchOperator;
75
- }
76
- if (Object.hasOwn(body, "literalClauses")) {
77
- patch.literalClauses = body.literalClauses;
78
- }
79
- if (Object.hasOwn(body, "actionLabelId")) {
80
- patch.actionLabelId = body.actionLabelId;
81
- }
82
- if (Object.hasOwn(body, "actionMailboxId")) {
83
- patch.actionMailboxId = body.actionMailboxId;
84
- }
85
- if (Object.hasOwn(body, "state")) patch.state = body.state;
86
- return patch;
87
- };
88
-
89
- /**
90
- * A filter's semantic anchor is set once, from `anchorMessageId`, only on
91
- * `CreateFilterInput` (RFC 034 Decision 2) — the generated `UpdateFilterInput`
92
- * carries no anchor field at all, so a well-behaved client cannot express this.
93
- * This only catches a caller handing the raw PATCH body an anchor field
94
- * anyway, and rejects it loudly rather than silently dropping it (reader
95
- * #266): repointing an anchor would silently change what a saved filter
96
- * matches with nothing visible changing, and that deserves a new filter
97
- * instead — scope and expiry cover the rest of what changed here.
98
- */
99
- const ANCHOR_MUTATION_FIELDS = ["anchorMessageId"] as const;
100
-
101
- export const rejectAnchorMutation = (body: Record<string, unknown>): void => {
102
- const attempted = ANCHOR_MUTATION_FIELDS.find((field) =>
103
- Object.hasOwn(body, field),
104
- );
105
- if (!attempted) return;
106
- throw new BadRequestError(
107
- "A filter's semantic anchor can't change after creation — create a new filter instead.",
108
- );
109
- };
110
-
111
- export interface ResolvedScopeExpiry {
112
- scope: FilterItem["scope"];
113
- expiresAt?: string;
114
- ttl?: number;
115
- state: FilterItem["state"];
116
- }
117
-
118
- /**
119
- * Merge a PATCH body's `scope`/`expiresAt` against the filter's stored values
120
- * and resolve the full set the row must land on (reader #266). Only called
121
- * when the patch touches `scope` or `expiresAt` — one that touches neither
122
- * leaves both, and `ttl`/`state`, untouched exactly as before this ticket.
123
- *
124
- * A `Temporary` result always carries a defined `expiresAt` — moving to
125
- * `Temporary` without one is rejected, matching the Filter model's invariant
126
- * that a Temporary filter always has an expiry (RFC 034 Decision 1.1). Moving
127
- * to `Standing` clears `expiresAt`/`ttl`, matching the model's invariant that
128
- * both stay absent outside `Temporary` (RFC 034 Decision 1.4) — the reserved
129
- * `ttl` attribute must never linger once a filter is no longer temporary.
130
- *
131
- * `state` is recomputed the same comparison `refreshExpiry` runs, so a filter
132
- * extended past a lapsed `expiresAt` (or switched to `Standing`) reads Active
133
- * immediately — the index-time worker's `byAccountAndState` query needs that
134
- * now, not on whatever read happens to touch the row next.
135
- */
136
- export const resolveFilterScopeExpiry = (
137
- current: Pick<FilterItem, "scope" | "expiresAt">,
138
- patch: Partial<UpdateFilterInput>,
139
- ): ResolvedScopeExpiry => {
140
- const scope = patch.scope ?? current.scope;
141
-
142
- if (scope !== FilterScope.Temporary) {
143
- if (Object.hasOwn(patch, "expiresAt") && patch.expiresAt) {
144
- throw new BadRequestError(
145
- "expiresAt only applies to a Temporary filter — switch scope to Temporary to set one.",
146
- );
147
- }
148
- return {
149
- scope,
150
- expiresAt: undefined,
151
- ttl: undefined,
152
- state: FilterState.Active,
153
- };
154
- }
155
-
156
- const expiresAt = Object.hasOwn(patch, "expiresAt")
157
- ? patch.expiresAt
158
- : current.expiresAt;
159
- if (!expiresAt) {
160
- throw new BadRequestError(
161
- "A Temporary filter needs expiresAt — pick a date, or switch scope to Standing.",
162
- );
163
- }
164
-
165
- const ttl = deriveFilterTtl(scope, expiresAt);
166
- const state =
167
- new Date(expiresAt).getTime() > Date.now()
168
- ? FilterState.Active
169
- : FilterState.Expired;
170
-
171
- return { scope, expiresAt, ttl, state };
172
- };
173
-
174
- const hasLapsed = (
175
- filter: Pick<FilterItem, "scope" | "expiresAt">,
176
- now: number,
177
- ): boolean =>
178
- filter.scope === FilterScope.Temporary &&
179
- filter.expiresAt !== undefined &&
180
- new Date(filter.expiresAt).getTime() <= now;
181
-
182
- const FOLDERLESS_REASONS: ReadonlySet<FilterItem["disabledReason"]> = new Set([
183
- FilterDisabledReason.AwaitingFolder,
184
- FilterDisabledReason.FolderCreateFailed,
185
- ]);
186
-
187
- const folderlessRefusal = (
188
- reason: FilterItem["disabledReason"],
189
- ): BadRequestError =>
190
- new BadRequestError(
191
- reason === FilterDisabledReason.AwaitingFolder
192
- ? "This filter has no folder to move mail into yet. Wait for its folder to be created on the mail server, or pick a folder in the rule, then turn it on."
193
- : "The mail server refused to create this filter's folder. Pick a folder in the rule, then turn it on.",
194
- );
195
-
196
- export const resolveFilterUpdate = (
197
- current: Pick<
198
- FilterItem,
199
- "scope" | "expiresAt" | "state" | "disabledReason" | "actionMailboxId"
200
- >,
201
- patch: Partial<UpdateFilterInput>,
202
- now: number = Date.now(),
203
- ): Partial<UpdateFilterInput> => {
204
- if (patch.state === FilterState.Expired) {
205
- throw new BadRequestError(
206
- "A filter expires through its date, not its state. Set it Active or Disabled, or change its expiry.",
207
- );
208
- }
209
- const folderless =
210
- current.state === FilterState.Disabled &&
211
- FOLDERLESS_REASONS.has(current.disabledReason);
212
- const folder = patch.actionMailboxId ?? current.actionMailboxId;
213
- const picked = folderless && folder !== FILTER_NO_ACTION;
214
- const pickedReason = picked
215
- ? { disabledReason: FilterDisabledReason.UserDisabled }
216
- : {};
217
-
218
- const touchesTiming =
219
- Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
220
- if (!touchesTiming && patch.state === undefined) {
221
- return { ...patch, ...pickedReason };
222
- }
223
-
224
- const timing = touchesTiming
225
- ? resolveFilterScopeExpiry(current, patch)
226
- : undefined;
227
- const lapsed = timing
228
- ? timing.state === FilterState.Expired
229
- : hasLapsed(current, now);
230
-
231
- if (patch.state === FilterState.Active && lapsed) {
232
- throw new BadRequestError(
233
- "This filter has expired. Move its date forward or make it Standing to turn it back on.",
234
- );
235
- }
236
-
237
- const wanted = patch.state ?? current.state;
238
- const merged = { ...patch, ...timing };
239
- if (wanted === FilterState.Disabled) {
240
- return {
241
- ...merged,
242
- state: FilterState.Disabled,
243
- ...(folderless
244
- ? pickedReason
245
- : patch.state === FilterState.Disabled
246
- ? { disabledReason: FilterDisabledReason.UserDisabled }
247
- : {}),
248
- };
249
- }
250
- if (lapsed) {
251
- return {
252
- ...merged,
253
- state: FilterState.Expired,
254
- disabledReason: FilterDisabledReason.None,
255
- };
256
- }
257
- if (folderless && folder === FILTER_NO_ACTION) {
258
- throw folderlessRefusal(current.disabledReason);
259
- }
260
- return {
261
- ...merged,
262
- state: FilterState.Active,
263
- disabledReason: FilterDisabledReason.None,
264
- };
265
- };
266
-
267
- const assertEnabledTargetExists = async (
268
- client: {
269
- mailbox: Pick<IMailboxRepository, "get" | "resolveAccountId">;
270
- },
271
- accountId: string,
272
- actionMailboxId: string,
273
- ): Promise<void> => {
274
- if (actionMailboxId === FILTER_NO_ACTION) return;
275
- if ((await client.mailbox.resolveAccountId(actionMailboxId)) === null) {
276
- throw new BadRequestError(
277
- "The folder this filter moves mail into no longer exists. Pick another folder, then turn the filter on.",
278
- );
279
- }
280
- await assertActionMailboxSettled(client, accountId, actionMailboxId);
281
- };
282
-
283
- const toFilterResponse = (item: FilterItem): FilterResponse => ({
284
- filterId: item.filterId,
285
- accountConfigId: item.accountConfigId,
286
- name: item.name,
287
- scope: item.scope,
288
- expiresAt: item.expiresAt,
289
- state: item.state,
290
- disabledReason: item.disabledReason,
291
- hasAnchor: item.hasAnchor,
292
- ruleChangedAt: item.ruleChangedAt,
293
- actionChangedAt: item.actionChangedAt,
294
- matchOperator: item.matchOperator,
295
- literalClauses: item.literalClauses,
296
- actionLabelId: item.actionLabelId,
297
- actionMailboxId: item.actionMailboxId,
298
- createdAt: item.createdAt,
299
- updatedAt: item.updatedAt,
300
- });
301
-
302
- /**
303
- * Create a filter, wiring the semantic anchor when `anchorMessageId` is set
304
- * (RFC 034 Decision 2). The anchor is built first, then the `Filter` row and
305
- * its sibling `FilterAnchor` row are written together in one transaction
306
- * (#351) — never as two independent writes a partial failure could split. A
307
- * message with no indexed chunks yields no anchor: the filter is created as
308
- * purely literal (`hasAnchor: false`) rather than with an empty anchor.
309
- */
310
- export const createFilterWithAnchor = async (
311
- deps: FilterCrudDeps,
312
- accountConfigId: string,
313
- input: CreateFilterInput,
314
- ): Promise<FilterItem> => {
315
- const { anchorMessageId } = input;
316
- const anchor = anchorMessageId
317
- ? await deps.buildAnchor(accountConfigId, anchorMessageId)
318
- : null;
319
-
320
- return deps.filterAnchorTransaction.createWithAnchor(
321
- {
322
- accountConfigId,
323
- name: input.name,
324
- scope: input.scope,
325
- expiresAt: input.expiresAt,
326
- ttl: deriveFilterTtl(input.scope, input.expiresAt),
327
- matchOperator: input.matchOperator,
328
- literalClauses: input.literalClauses,
329
- actionLabelId: input.actionLabelId,
330
- actionMailboxId: input.actionMailboxId,
331
- hasAnchor: anchor !== null,
332
- },
333
- anchor && anchorMessageId
334
- ? {
335
- accountConfigId,
336
- anchorMessageId,
337
- anchorEmbedding: anchor.anchorEmbedding,
338
- anchorEmbeddingId: anchor.anchorEmbeddingId,
339
- anchorSourceText: anchor.anchorSourceText,
340
- }
341
- : null,
342
- );
343
- };
344
-
345
- const requireUserId = (event: APIGatewayProxyEvent): string => {
346
- const userId = getSubFromEvent(event);
347
- if (!userId) {
348
- throw new Error(
349
- "Missing Cognito `sub`: cannot attribute an organize job to a user",
350
- );
351
- }
352
- return userId;
353
- };
354
-
355
- /**
356
- * A filter's `actionMailboxId` is a durable reference, so its target has to be
357
- * a folder the mail server has settled (D12, first row) — 422 otherwise. The
358
- * sentinel means "no move action" and binds to nothing, so it is not gated,
359
- * and neither is a filter that touches the field at all.
360
- */
361
- const assertActionMailboxSettled = async (
362
- client: { mailbox: Pick<IMailboxRepository, "get"> },
363
- accountId: string,
364
- actionMailboxId: string | undefined,
365
- ): Promise<void> => {
366
- if (actionMailboxId === undefined || actionMailboxId === FILTER_NO_ACTION) {
367
- return;
368
- }
369
- const target = await client.mailbox.get(accountId, actionMailboxId);
370
- assertMailboxInAccount(target, accountId, "act");
371
- assertMailboxSettled(target);
372
- };
373
-
374
- export const FilterOperations: Record<
375
- FilterOperationIds,
376
- OperationHandler<FilterOperationIds>
377
- > = {
378
- FilterOperations_listFilters: async (context, ...args: unknown[]) => {
379
- const event = args[0] as APIGatewayProxyEvent;
380
- const accountConfigId = getAccountConfigIdFromEvent(event);
381
- const { accountId } = context.request.params as { accountId: string };
382
- const { continuationToken } = context.request.query as {
383
- continuationToken?: string;
384
- };
385
-
386
- const client = await getClient();
387
- const account = await client.account.get(accountId);
388
- assertAccountOwnership(account, accountConfigId, "read");
389
-
390
- const { filter } = client;
391
- const page = await filter.listPageByAccountConfig(accountConfigId, {
392
- continuationToken,
393
- });
394
-
395
- // Reading the list is one of the touch points that lazily expires a past
396
- // Temporary filter (RFC 034 Decision 1.2) — a no-op write for Standing or
397
- // not-yet-expired filters.
398
- const items = await Promise.all(
399
- page.items.map((item) => filter.refreshExpiry(item)),
400
- );
401
-
402
- return {
403
- items: items.map(toFilterResponse),
404
- continuationToken: page.continuationToken,
405
- };
406
- },
407
-
408
- FilterOperations_createFilter: async (
409
- context,
410
- ...args: unknown[]
411
- ): Promise<CreateFilterResponse> => {
412
- const event = args[0] as APIGatewayProxyEvent;
413
- const accountConfigId = getAccountConfigIdFromEvent(event);
414
- const { accountId } = context.request.params as { accountId: string };
415
- const input = context.request.requestBody as CreateFilterInput;
416
- const userId = requireUserId(event);
417
-
418
- const client = await getClient();
419
- const account = await client.account.get(accountId);
420
- assertAccountOwnership(account, accountConfigId, "act");
421
- await assertActionMailboxSettled(client, accountId, input.actionMailboxId);
422
-
423
- const filter = await createFilterWithAnchor(
424
- {
425
- filterAnchorTransaction: client.filterAnchorTransaction,
426
- buildAnchor: buildFilterAnchor,
427
- },
428
- accountConfigId,
429
- input,
430
- );
431
- // The new filter's first pass over the inbox. The worker reads the filter
432
- // when it runs and refuses one that has nothing to run (#1354).
433
- const job = await queueOrganizeJob(client, {
434
- accountConfigId,
435
- userId,
436
- filterId: filter.filterId,
437
- });
438
- return { ...toFilterResponse(filter), organizeJobId: job.organizeJobId };
439
- },
440
- };
441
-
442
- export const FilterDetailOperations: Record<
443
- FilterDetailOperationIds,
444
- OperationHandler<FilterDetailOperationIds>
445
- > = {
446
- FilterDetailOperations_getFilter: async (context, ...args: unknown[]) => {
447
- const event = args[0] as APIGatewayProxyEvent;
448
- const accountConfigId = getAccountConfigIdFromEvent(event);
449
- const { accountId, filterId } = context.request.params as {
450
- accountId: string;
451
- filterId: string;
452
- };
453
-
454
- const client = await getClient();
455
- const account = await client.account.get(accountId);
456
- assertAccountOwnership(account, accountConfigId, "read");
457
-
458
- const { filter } = client;
459
- const item = await filter.get(accountConfigId, filterId);
460
- const refreshed = await filter.refreshExpiry(item);
461
- return toFilterResponse(refreshed);
462
- },
463
-
464
- FilterDetailOperations_updateFilter: async (context, ...args: unknown[]) => {
465
- const event = args[0] as APIGatewayProxyEvent;
466
- const accountConfigId = getAccountConfigIdFromEvent(event);
467
- const { accountId, filterId } = context.request.params as {
468
- accountId: string;
469
- filterId: string;
470
- };
471
- const body = context.request.requestBody as Record<string, unknown>;
472
- rejectAnchorMutation(body);
473
-
474
- const client = await getClient();
475
- const account = await client.account.get(accountId);
476
- assertAccountOwnership(account, accountConfigId, "act");
477
-
478
- const { filter } = client;
479
- const patch = pickFilterUpdate(body as Partial<UpdateFilterRequestBody>);
480
- await assertActionMailboxSettled(client, accountId, patch.actionMailboxId);
481
- const current = await filter.get(accountConfigId, filterId);
482
- const resolvedPatch = resolveFilterUpdate(current, patch);
483
- if (
484
- patch.state === FilterState.Active &&
485
- patch.actionMailboxId === undefined
486
- ) {
487
- await assertEnabledTargetExists(
488
- client,
489
- accountId,
490
- current.actionMailboxId,
491
- );
492
- }
493
-
494
- const updated = await filter.update(
495
- accountConfigId,
496
- filterId,
497
- resolvedPatch,
498
- );
499
- return toFilterResponse(updated);
500
- },
501
-
502
- FilterDetailOperations_deleteFilter: async (context, ...args: unknown[]) => {
503
- const event = args[0] as APIGatewayProxyEvent;
504
- const accountConfigId = getAccountConfigIdFromEvent(event);
505
- const { accountId, filterId } = context.request.params as {
506
- accountId: string;
507
- filterId: string;
508
- };
509
-
510
- const client = await getClient();
511
- const account = await client.account.get(accountId);
512
- assertAccountOwnership(account, accountConfigId, "act");
513
-
514
- const { filter } = client;
515
- await filter.delete(accountConfigId, filterId);
516
- return { statusCode: 204 };
517
- },
518
- };