@remit/backend 0.0.132 → 0.0.134

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.132",
3
+ "version": "0.0.134",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -10,6 +10,7 @@ import type {
10
10
  SettleCalendarSuggestionInput,
11
11
  } from "@remit/data-ports";
12
12
  import {
13
+ CalendarInviteAnswer,
13
14
  CalendarInviteMethod,
14
15
  CalendarSuggestionSource,
15
16
  CalendarSuggestionState,
@@ -354,6 +355,7 @@ const INVITATION = [
354
355
  const putSuggestion = (
355
356
  accountConfigId: string,
356
357
  messageId: string,
358
+ method: CalendarSuggestionItem["method"] = CalendarInviteMethod.Request,
357
359
  ): Promise<CalendarSuggestionItem> =>
358
360
  client.calendarSuggestion.put({
359
361
  accountConfigId,
@@ -361,7 +363,7 @@ const putSuggestion = (
361
363
  bodyPartId: "part-1",
362
364
  icalUid: "invite@example.test",
363
365
  sequence: 0,
364
- method: CalendarInviteMethod.Request,
366
+ method,
365
367
  source: CalendarSuggestionSource.IcalendarPart,
366
368
  summary: "Quarterly review",
367
369
  dtStart: "2026-09-01T10:00:00+02:00",
@@ -506,6 +508,45 @@ describe("POST /calendar-suggestions/{suggestionId}/accept", () => {
506
508
  assert.equal(untouched.state, CalendarSuggestionState.Pending);
507
509
  assert.equal(untouched.acceptedCalendarObjectId, "");
508
510
  });
511
+
512
+ test("refuses a maybe to a cancellation, before writing anything", async () => {
513
+ const { accountConfigId, event } = anAccount();
514
+ const calendar = await client.calendarCollection.create({
515
+ accountConfigId,
516
+ urlSegment: "default",
517
+ displayName: "Calendar",
518
+ });
519
+ const card = await putSuggestion(
520
+ accountConfigId,
521
+ "msg-cancel-maybe",
522
+ CalendarInviteMethod.Cancel,
523
+ );
524
+
525
+ await assert.rejects(
526
+ () =>
527
+ acceptSuggestion(
528
+ contextOf({
529
+ params: { suggestionId: card.suggestionId },
530
+ requestBody: {
531
+ calendarId: calendar.calendarId,
532
+ answer: CalendarInviteAnswer.Tentative,
533
+ },
534
+ }),
535
+ event,
536
+ ),
537
+ (error: unknown) => (error as { statusCode?: number }).statusCode === 400,
538
+ );
539
+
540
+ assert.deepEqual(
541
+ await client.calendarObject.listByCalendar(calendar.calendarId),
542
+ [],
543
+ );
544
+ const untouched = await client.calendarSuggestion.get(
545
+ accountConfigId,
546
+ card.suggestionId,
547
+ );
548
+ assert.equal(untouched.state, CalendarSuggestionState.Pending);
549
+ });
509
550
  });
510
551
 
511
552
  describe("POST /calendar-suggestions/{suggestionId}/dismiss", () => {
@@ -1,4 +1,7 @@
1
- import type { CalendarSuggestionResponse } from "@remit/api-openapi-types";
1
+ import type {
2
+ CalendarInviteAnswer as CalendarInviteAnswerValue,
3
+ CalendarSuggestionResponse,
4
+ } from "@remit/api-openapi-types";
2
5
  import { acceptCalendarSuggestion } from "@remit/calendar-service";
3
6
  import {
4
7
  type CalendarSuggestionItem,
@@ -9,6 +12,8 @@ import {
9
12
  } from "@remit/data-ports";
10
13
  import { BadRequestError } from "@remit/data-ports/errors";
11
14
  import {
15
+ CalendarInviteAnswer,
16
+ CalendarInviteMethod,
12
17
  CalendarSource,
13
18
  CalendarSuggestionState,
14
19
  FilterClauseField,
@@ -211,8 +216,10 @@ export const CalendarSuggestionActionOperations: Record<
211
216
  const { suggestionId } = context.request.params as {
212
217
  suggestionId: string;
213
218
  };
214
- const { calendarId } = context.request.requestBody as {
219
+ const { calendarId, answer = CalendarInviteAnswer.Accepted } = context
220
+ .request.requestBody as {
215
221
  calendarId: string;
222
+ answer?: CalendarInviteAnswerValue;
216
223
  };
217
224
 
218
225
  const client = await getClient();
@@ -220,7 +227,20 @@ export const CalendarSuggestionActionOperations: Record<
220
227
  accountConfigId,
221
228
  suggestionId,
222
229
  );
223
- assertSettleable(suggestion, CalendarSuggestionState.Accepted);
230
+ if (
231
+ answer === CalendarInviteAnswer.Tentative &&
232
+ suggestion.method === CalendarInviteMethod.Cancel
233
+ ) {
234
+ throw new BadRequestError(
235
+ "This is a cancellation, so there is nothing to answer maybe to.",
236
+ );
237
+ }
238
+ assertSettleable(
239
+ suggestion,
240
+ answer === CalendarInviteAnswer.Tentative
241
+ ? CalendarSuggestionState.Tentative
242
+ : CalendarSuggestionState.Accepted,
243
+ );
224
244
 
225
245
  // Reads the collection through the caller's own account config, so a
226
246
  // calendarId naming somebody else's collection is a 404 before anything
@@ -240,6 +260,7 @@ export const CalendarSuggestionActionOperations: Record<
240
260
  calendarId,
241
261
  suggestion,
242
262
  attendee: await accountEmailForMessage(client, suggestion.messageId),
263
+ answer,
243
264
  });
244
265
  if (!accepted.ok) {
245
266
  throw new BadRequestError(accepted.error.message);
@@ -10,6 +10,7 @@ import type {
10
10
  FilterItem,
11
11
  } from "@remit/data-ports";
12
12
  import {
13
+ FilterDisabledReason,
13
14
  FilterMatchOperator,
14
15
  FilterScope,
15
16
  FilterState,
@@ -21,6 +22,7 @@ import {
21
22
  pickFilterUpdate,
22
23
  rejectAnchorMutation,
23
24
  resolveFilterScopeExpiry,
25
+ resolveFilterUpdate,
24
26
  } from "./filter.js";
25
27
 
26
28
  describe("pickFilterUpdate", () => {
@@ -45,7 +47,7 @@ describe("pickFilterUpdate", () => {
45
47
  const raw: Record<string, unknown> = {
46
48
  name: "Receipts",
47
49
  ttl: 123,
48
- state: "Expired",
50
+ disabledReason: "None",
49
51
  hasAnchor: true,
50
52
  ruleChangedAt: 999,
51
53
  filterId: "sneaky",
@@ -55,6 +57,204 @@ describe("pickFilterUpdate", () => {
55
57
  });
56
58
  });
57
59
 
60
+ describe("resolveFilterUpdate (#1103)", () => {
61
+ const NOW = Date.parse("2026-09-25T12:00:00Z");
62
+ const standing = {
63
+ scope: FilterScope.Standing,
64
+ expiresAt: undefined,
65
+ state: FilterState.Active,
66
+ disabledReason: FilterDisabledReason.None,
67
+ actionMailboxId: "mbx-invoices",
68
+ };
69
+
70
+ it("carries a state the body names through to the patch", () => {
71
+ assert.equal(
72
+ pickFilterUpdate({ state: FilterState.Disabled }).state,
73
+ FilterState.Disabled,
74
+ );
75
+ });
76
+
77
+ it("records UserDisabled when the user turns a filter off", () => {
78
+ assert.deepEqual(
79
+ resolveFilterUpdate(standing, { state: FilterState.Disabled }, NOW),
80
+ {
81
+ state: FilterState.Disabled,
82
+ disabledReason: FilterDisabledReason.UserDisabled,
83
+ },
84
+ );
85
+ });
86
+
87
+ for (const reason of [
88
+ FilterDisabledReason.UserDisabled,
89
+ FilterDisabledReason.AwaitingFolder,
90
+ FilterDisabledReason.FolderCreateFailed,
91
+ FilterDisabledReason.FolderMissing,
92
+ ]) {
93
+ it(`clears ${reason} when the user turns the filter back on`, () => {
94
+ assert.deepEqual(
95
+ resolveFilterUpdate(
96
+ {
97
+ ...standing,
98
+ state: FilterState.Disabled,
99
+ disabledReason: reason,
100
+ },
101
+ { state: FilterState.Active },
102
+ NOW,
103
+ ),
104
+ {
105
+ state: FilterState.Active,
106
+ disabledReason: FilterDisabledReason.None,
107
+ },
108
+ );
109
+ });
110
+ }
111
+
112
+ it("keeps a disabled filter disabled, with its reason, through an expiry edit", () => {
113
+ const resolved = resolveFilterUpdate(
114
+ { ...standing, state: FilterState.Disabled },
115
+ {
116
+ scope: FilterScope.Temporary,
117
+ expiresAt: "2027-01-01T00:00:00+00:00",
118
+ },
119
+ NOW,
120
+ );
121
+ assert.equal(resolved.state, FilterState.Disabled);
122
+ assert.equal("disabledReason" in resolved, false);
123
+ });
124
+
125
+ for (const reason of [
126
+ FilterDisabledReason.AwaitingFolder,
127
+ FilterDisabledReason.FolderCreateFailed,
128
+ ]) {
129
+ it(`refuses to turn on a ${reason} filter that has no folder`, () => {
130
+ assert.throws(
131
+ () =>
132
+ resolveFilterUpdate(
133
+ {
134
+ ...standing,
135
+ state: FilterState.Disabled,
136
+ disabledReason: reason,
137
+ actionMailboxId: "None",
138
+ },
139
+ { state: FilterState.Active },
140
+ NOW,
141
+ ),
142
+ /pick a folder in the rule/i,
143
+ );
144
+ });
145
+
146
+ it(`turns on a ${reason} filter once the patch names a folder`, () => {
147
+ assert.equal(
148
+ resolveFilterUpdate(
149
+ {
150
+ ...standing,
151
+ state: FilterState.Disabled,
152
+ disabledReason: reason,
153
+ actionMailboxId: "None",
154
+ },
155
+ { state: FilterState.Active, actionMailboxId: "mbx-picked" },
156
+ NOW,
157
+ ).state,
158
+ FilterState.Active,
159
+ );
160
+ });
161
+ }
162
+
163
+ const awaiting = {
164
+ ...standing,
165
+ state: FilterState.Disabled,
166
+ disabledReason: FilterDisabledReason.AwaitingFolder,
167
+ actionMailboxId: "None",
168
+ };
169
+
170
+ it("keeps a folderless reason when the user turns an awaiting filter off again", () => {
171
+ const resolved = resolveFilterUpdate(
172
+ awaiting,
173
+ { state: FilterState.Disabled },
174
+ NOW,
175
+ );
176
+ assert.equal(resolved.state, FilterState.Disabled);
177
+ assert.equal("disabledReason" in resolved, false);
178
+ assert.throws(
179
+ () =>
180
+ resolveFilterUpdate(
181
+ { ...awaiting, state: resolved.state ?? awaiting.state },
182
+ { state: FilterState.Active },
183
+ NOW,
184
+ ),
185
+ /pick a folder in the rule/i,
186
+ );
187
+ });
188
+
189
+ it("keeps a lapsed folderless filter off when its date moves forward", () => {
190
+ const lapsed = {
191
+ ...awaiting,
192
+ scope: FilterScope.Temporary,
193
+ expiresAt: "2026-01-01T00:00:00+00:00",
194
+ disabledReason: FilterDisabledReason.FolderCreateFailed,
195
+ };
196
+ const resolved = resolveFilterUpdate(
197
+ lapsed,
198
+ {
199
+ scope: FilterScope.Temporary,
200
+ expiresAt: "2027-01-01T00:00:00+00:00",
201
+ },
202
+ NOW,
203
+ );
204
+ assert.equal(resolved.state, FilterState.Disabled);
205
+ assert.equal("disabledReason" in resolved, false);
206
+ assert.throws(
207
+ () =>
208
+ resolveFilterUpdate(
209
+ { ...lapsed, expiresAt: "2027-01-01T00:00:00+00:00" },
210
+ { state: FilterState.Active },
211
+ NOW,
212
+ ),
213
+ /pick a folder in the rule/i,
214
+ );
215
+ });
216
+
217
+ it("marks a folderless filter UserDisabled once the user picks a folder", () => {
218
+ assert.deepEqual(
219
+ resolveFilterUpdate(awaiting, { actionMailboxId: "mbx-picked" }, NOW),
220
+ {
221
+ actionMailboxId: "mbx-picked",
222
+ disabledReason: FilterDisabledReason.UserDisabled,
223
+ },
224
+ );
225
+ });
226
+
227
+ it("refuses Expired as a state the user sets", () => {
228
+ assert.throws(
229
+ () => resolveFilterUpdate(standing, { state: FilterState.Expired }, NOW),
230
+ /expires through its date/,
231
+ );
232
+ });
233
+
234
+ it("refuses to turn on a temporary filter whose date has passed", () => {
235
+ assert.throws(
236
+ () =>
237
+ resolveFilterUpdate(
238
+ {
239
+ ...standing,
240
+ scope: FilterScope.Temporary,
241
+ expiresAt: "2026-01-01T00:00:00+00:00",
242
+ state: FilterState.Disabled,
243
+ },
244
+ { state: FilterState.Active },
245
+ NOW,
246
+ ),
247
+ /has expired/,
248
+ );
249
+ });
250
+
251
+ it("leaves a patch that names neither state nor timing untouched", () => {
252
+ assert.deepEqual(resolveFilterUpdate(standing, { name: "Receipts" }, NOW), {
253
+ name: "Receipts",
254
+ });
255
+ });
256
+ });
257
+
58
258
  describe("rejectAnchorMutation", () => {
59
259
  it("throws a 400 when the body carries anchorMessageId", () => {
60
260
  assert.throws(
@@ -197,6 +397,7 @@ describe("createFilterWithAnchor (#351)", () => {
197
397
  name: baseInput.name,
198
398
  scope: FilterScope.Standing,
199
399
  state: FilterState.Active,
400
+ disabledReason: "None",
200
401
  hasAnchor: false,
201
402
  ruleChangedAt: 1_700_000_000,
202
403
  actionChangedAt: 1_700_000_000,
@@ -12,7 +12,11 @@ import {
12
12
  type UpdateFilterInput,
13
13
  } from "@remit/data-ports";
14
14
  import { BadRequestError } from "@remit/data-ports/errors";
15
- import { FilterScope, FilterState } from "@remit/domain-enums";
15
+ import {
16
+ FilterDisabledReason,
17
+ FilterScope,
18
+ FilterState,
19
+ } from "@remit/domain-enums";
16
20
  import type { AnchorPayload } from "@remit/search-service";
17
21
  import type { APIGatewayProxyEvent } from "aws-lambda";
18
22
  import { getAccountConfigIdFromEvent } from "../auth.js";
@@ -76,6 +80,7 @@ export const pickFilterUpdate = (
76
80
  if (Object.hasOwn(body, "actionMailboxId")) {
77
81
  patch.actionMailboxId = body.actionMailboxId;
78
82
  }
83
+ if (Object.hasOwn(body, "state")) patch.state = body.state;
79
84
  return patch;
80
85
  };
81
86
 
@@ -164,6 +169,115 @@ export const resolveFilterScopeExpiry = (
164
169
  return { scope, expiresAt, ttl, state };
165
170
  };
166
171
 
172
+ const hasLapsed = (
173
+ filter: Pick<FilterItem, "scope" | "expiresAt">,
174
+ now: number,
175
+ ): boolean =>
176
+ filter.scope === FilterScope.Temporary &&
177
+ filter.expiresAt !== undefined &&
178
+ new Date(filter.expiresAt).getTime() <= now;
179
+
180
+ const FOLDERLESS_REASONS: ReadonlySet<FilterItem["disabledReason"]> = new Set([
181
+ FilterDisabledReason.AwaitingFolder,
182
+ FilterDisabledReason.FolderCreateFailed,
183
+ ]);
184
+
185
+ const folderlessRefusal = (
186
+ reason: FilterItem["disabledReason"],
187
+ ): BadRequestError =>
188
+ new BadRequestError(
189
+ reason === FilterDisabledReason.AwaitingFolder
190
+ ? "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."
191
+ : "The mail server refused to create this filter's folder. Pick a folder in the rule, then turn it on.",
192
+ );
193
+
194
+ export const resolveFilterUpdate = (
195
+ current: Pick<
196
+ FilterItem,
197
+ "scope" | "expiresAt" | "state" | "disabledReason" | "actionMailboxId"
198
+ >,
199
+ patch: Partial<UpdateFilterInput>,
200
+ now: number = Date.now(),
201
+ ): Partial<UpdateFilterInput> => {
202
+ if (patch.state === FilterState.Expired) {
203
+ throw new BadRequestError(
204
+ "A filter expires through its date, not its state. Set it Active or Disabled, or change its expiry.",
205
+ );
206
+ }
207
+ const folderless =
208
+ current.state === FilterState.Disabled &&
209
+ FOLDERLESS_REASONS.has(current.disabledReason);
210
+ const folder = patch.actionMailboxId ?? current.actionMailboxId;
211
+ const picked = folderless && folder !== FILTER_NO_ACTION;
212
+ const pickedReason = picked
213
+ ? { disabledReason: FilterDisabledReason.UserDisabled }
214
+ : {};
215
+
216
+ const touchesTiming =
217
+ Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
218
+ if (!touchesTiming && patch.state === undefined) {
219
+ return { ...patch, ...pickedReason };
220
+ }
221
+
222
+ const timing = touchesTiming
223
+ ? resolveFilterScopeExpiry(current, patch)
224
+ : undefined;
225
+ const lapsed = timing
226
+ ? timing.state === FilterState.Expired
227
+ : hasLapsed(current, now);
228
+
229
+ if (patch.state === FilterState.Active && lapsed) {
230
+ throw new BadRequestError(
231
+ "This filter has expired. Move its date forward or make it Standing to turn it back on.",
232
+ );
233
+ }
234
+
235
+ const wanted = patch.state ?? current.state;
236
+ const merged = { ...patch, ...timing };
237
+ if (wanted === FilterState.Disabled) {
238
+ return {
239
+ ...merged,
240
+ state: FilterState.Disabled,
241
+ ...(folderless
242
+ ? pickedReason
243
+ : patch.state === FilterState.Disabled
244
+ ? { disabledReason: FilterDisabledReason.UserDisabled }
245
+ : {}),
246
+ };
247
+ }
248
+ if (lapsed) {
249
+ return {
250
+ ...merged,
251
+ state: FilterState.Expired,
252
+ disabledReason: FilterDisabledReason.None,
253
+ };
254
+ }
255
+ if (folderless && folder === FILTER_NO_ACTION) {
256
+ throw folderlessRefusal(current.disabledReason);
257
+ }
258
+ return {
259
+ ...merged,
260
+ state: FilterState.Active,
261
+ disabledReason: FilterDisabledReason.None,
262
+ };
263
+ };
264
+
265
+ const assertEnabledTargetExists = async (
266
+ client: {
267
+ mailbox: Pick<IMailboxRepository, "get" | "resolveAccountId">;
268
+ },
269
+ accountId: string,
270
+ actionMailboxId: string,
271
+ ): Promise<void> => {
272
+ if (actionMailboxId === FILTER_NO_ACTION) return;
273
+ if ((await client.mailbox.resolveAccountId(actionMailboxId)) === null) {
274
+ throw new BadRequestError(
275
+ "The folder this filter moves mail into no longer exists. Pick another folder, then turn the filter on.",
276
+ );
277
+ }
278
+ await assertActionMailboxSettled(client, accountId, actionMailboxId);
279
+ };
280
+
167
281
  const toFilterResponse = (item: FilterItem): FilterResponse => ({
168
282
  filterId: item.filterId,
169
283
  accountConfigId: item.accountConfigId,
@@ -171,6 +285,7 @@ const toFilterResponse = (item: FilterItem): FilterResponse => ({
171
285
  scope: item.scope,
172
286
  expiresAt: item.expiresAt,
173
287
  state: item.state,
288
+ disabledReason: item.disabledReason,
174
289
  hasAnchor: item.hasAnchor,
175
290
  ruleChangedAt: item.ruleChangedAt,
176
291
  actionChangedAt: item.actionChangedAt,
@@ -340,17 +455,18 @@ export const FilterDetailOperations: Record<
340
455
  const { filter } = client;
341
456
  const patch = pickFilterUpdate(body as Partial<UpdateFilterRequestBody>);
342
457
  await assertActionMailboxSettled(client, accountId, patch.actionMailboxId);
343
- const touchesScopeOrExpiry =
344
- Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
345
- const resolvedPatch: Partial<UpdateFilterInput> = touchesScopeOrExpiry
346
- ? {
347
- ...patch,
348
- ...resolveFilterScopeExpiry(
349
- await filter.get(accountConfigId, filterId),
350
- patch,
351
- ),
352
- }
353
- : patch;
458
+ const current = await filter.get(accountConfigId, filterId);
459
+ const resolvedPatch = resolveFilterUpdate(current, patch);
460
+ if (
461
+ patch.state === FilterState.Active &&
462
+ patch.actionMailboxId === undefined
463
+ ) {
464
+ await assertEnabledTargetExists(
465
+ client,
466
+ accountId,
467
+ current.actionMailboxId,
468
+ );
469
+ }
354
470
 
355
471
  const updated = await filter.update(
356
472
  accountConfigId,
@@ -109,6 +109,7 @@ const filterItem = (over: Partial<FilterItem> = {}): FilterItem => ({
109
109
  name: "other filter",
110
110
  scope: "Standing",
111
111
  state: FilterState.Active,
112
+ disabledReason: "None",
112
113
  hasAnchor: false,
113
114
  ruleChangedAt: 0,
114
115
  actionChangedAt: 0,