@remit/backend 0.0.119 → 0.0.121

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.119",
3
+ "version": "0.0.121",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -23,6 +23,10 @@
23
23
  "types": "./src/handlers/folder-role-appointments.ts",
24
24
  "default": "./src/handlers/folder-role-appointments.ts"
25
25
  },
26
+ "./folder-role-labels": {
27
+ "types": "./src/handlers/folder-role-labels.ts",
28
+ "default": "./src/handlers/folder-role-labels.ts"
29
+ },
26
30
  "./organize": {
27
31
  "types": "./src/service/organize.ts",
28
32
  "default": "./src/service/organize.ts"
@@ -5,8 +5,10 @@ import type {
5
5
  } from "@remit/api-openapi-types";
6
6
  import {
7
7
  deriveFilterTtl,
8
+ FILTER_NO_ACTION,
8
9
  type FilterItem,
9
10
  type IFilterAnchorTransaction,
11
+ type IMailboxRepository,
10
12
  type UpdateFilterInput,
11
13
  } from "@remit/data-ports";
12
14
  import { BadRequestError } from "@remit/data-ports/errors";
@@ -22,6 +24,7 @@ import type {
22
24
  OperationHandler,
23
25
  } from "../types.js";
24
26
  import { assertAccountOwnership } from "./account-ownership.js";
27
+ import { assertMailboxInAccount, assertMailboxSettled } from "./mailbox.js";
25
28
 
26
29
  /**
27
30
  * Minimal filter-service surface the create handler needs — declared as a
@@ -222,6 +225,25 @@ export const createFilterWithAnchor = async (
222
225
  );
223
226
  };
224
227
 
228
+ /**
229
+ * A filter's `actionMailboxId` is a durable reference, so its target has to be
230
+ * a folder the mail server has settled (D12, first row) — 422 otherwise. The
231
+ * sentinel means "no move action" and binds to nothing, so it is not gated,
232
+ * and neither is a filter that touches the field at all.
233
+ */
234
+ const assertActionMailboxSettled = async (
235
+ client: { mailbox: Pick<IMailboxRepository, "get"> },
236
+ accountId: string,
237
+ actionMailboxId: string | undefined,
238
+ ): Promise<void> => {
239
+ if (actionMailboxId === undefined || actionMailboxId === FILTER_NO_ACTION) {
240
+ return;
241
+ }
242
+ const target = await client.mailbox.get(accountId, actionMailboxId);
243
+ assertMailboxInAccount(target, accountId, "act");
244
+ assertMailboxSettled(target);
245
+ };
246
+
225
247
  export const FilterOperations: Record<
226
248
  FilterOperationIds,
227
249
  OperationHandler<FilterOperationIds>
@@ -265,6 +287,7 @@ export const FilterOperations: Record<
265
287
  const client = await getClient();
266
288
  const account = await client.account.get(accountId);
267
289
  assertAccountOwnership(account, accountConfigId, "act");
290
+ await assertActionMailboxSettled(client, accountId, input.actionMailboxId);
268
291
 
269
292
  const filter = await createFilterWithAnchor(
270
293
  {
@@ -316,6 +339,7 @@ export const FilterDetailOperations: Record<
316
339
 
317
340
  const { filter } = client;
318
341
  const patch = pickFilterUpdate(body as Partial<UpdateFilterRequestBody>);
342
+ await assertActionMailboxSettled(client, accountId, patch.actionMailboxId);
319
343
  const touchesScopeOrExpiry =
320
344
  Object.hasOwn(patch, "scope") || Object.hasOwn(patch, "expiresAt");
321
345
  const resolvedPatch: Partial<UpdateFilterInput> = touchesScopeOrExpiry
@@ -0,0 +1,63 @@
1
+ import type { IAccountSettingRepository } from "@remit/data-ports";
2
+ import {
3
+ type CanonicalMailboxRoleValue,
4
+ composeFolderRoleAppointmentLabelName,
5
+ } from "@remit/data-ports/folder-role";
6
+ import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
7
+ import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
8
+
9
+ /**
10
+ * The label row beside each role appointment records the path its folder had at
11
+ * the time (#887), so a folder another client later deletes can still be named
12
+ * back to the user. It is display only, and it lives here rather than beside
13
+ * the appointment writer because that writer is guarded as the only writer of
14
+ * the appointment row itself.
15
+ *
16
+ * A rename keeps every mailboxId, so an appointment survives it — but the path
17
+ * recorded beside it still names where the folder was. Move the labels with the
18
+ * branch, or a later third-party delete names a path the user has not seen
19
+ * since the rename.
20
+ *
21
+ * Driven by the settle, not by the intent: under D2 the row keeps the path the
22
+ * mail server holds until the rename lands, so a label moved when the intent
23
+ * was recorded would name a path that does not exist — permanently, if the
24
+ * rename then fails.
25
+ *
26
+ * The renamed folder is matched by id; its descendants are matched by the path
27
+ * each label already holds.
28
+ */
29
+ export const refreshFolderAppointmentLabels = async (
30
+ accountSetting: Pick<IAccountSettingRepository, "get" | "upsert">,
31
+ accountConfigId: string,
32
+ accountId: string,
33
+ renamed: { mailboxId: string; oldPath: string; newPath: string },
34
+ delimiter: string,
35
+ ): Promise<void> => {
36
+ const persisted = await loadFolderAppointmentsForAccount(
37
+ accountSetting,
38
+ accountConfigId,
39
+ accountId,
40
+ );
41
+ for (const [role, appointment] of persisted) {
42
+ const moved =
43
+ appointment.mailboxId === renamed.mailboxId
44
+ ? renamed.newPath
45
+ : appointment.lastKnownPath === undefined
46
+ ? undefined
47
+ : rebaseMailboxPath(
48
+ appointment.lastKnownPath,
49
+ renamed.oldPath,
50
+ renamed.newPath,
51
+ delimiter,
52
+ );
53
+ if (moved === undefined || moved === appointment.lastKnownPath) continue;
54
+ await accountSetting.upsert({
55
+ accountConfigId,
56
+ name: composeFolderRoleAppointmentLabelName(
57
+ accountId,
58
+ role as CanonicalMailboxRoleValue,
59
+ ),
60
+ value: { kind: "String", value: moved },
61
+ });
62
+ }
63
+ };
@@ -4,8 +4,8 @@ import type { AccountSettingItem, MailboxItem } from "@remit/data-ports";
4
4
  import { isPublicApiError } from "@remit/data-ports/errors";
5
5
  import { composeFolderRoleAppointmentLabelName } from "@remit/data-ports/folder-role";
6
6
  import { CanonicalMailboxRole, MailboxSyncStatus } from "@remit/domain-enums";
7
- import { assertMailboxSettled } from "./folder-role.js";
8
- import { applyMailboxPatch, type MailboxPatchClient } from "./mailbox.js";
7
+ import { refreshFolderAppointmentLabels } from "./folder-role-labels.js";
8
+ import { assertMailboxSettled } from "./mailbox.js";
9
9
 
10
10
  const mailbox = (over: Partial<MailboxItem>): MailboxItem =>
11
11
  ({
@@ -57,18 +57,45 @@ describe("assertMailboxSettled", () => {
57
57
  });
58
58
  });
59
59
 
60
- it("allows a settled folder, and one whose delete failed", () => {
61
- assert.doesNotThrow(() =>
62
- assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
60
+ it("refuses a folder whose last change failed", () => {
61
+ // `failed` means the folder exists at `fullPath` and the last rename or
62
+ // delete did not land, so it is exactly as unready to be bound to as a
63
+ // folder mid-mutation — and the user has a retry or a dismissal to make
64
+ // before anything should point at it.
65
+ const error = caught(() =>
66
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.failed })),
63
67
  );
68
+ assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
69
+ });
70
+
71
+ it("answers 422, not 409", () => {
72
+ // 409 already means "this folder is being changed right now, refresh".
73
+ // This means "the folder you are pointing at has not settled yet", which
74
+ // is different copy and a different remedy (D4).
75
+ const error = caught(() =>
76
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.pending })),
77
+ );
78
+ assert.equal((error as { statusCode?: number }).statusCode, 422);
79
+ });
80
+
81
+ it("words each state differently, because each has its own remedy", () => {
82
+ const messageFor = (syncStatus: MailboxItem["syncStatus"]) =>
83
+ (caught(() => assertMailboxSettled(mailbox({ syncStatus }))) as Error)
84
+ .message;
85
+
86
+ assert.match(messageFor(MailboxSyncStatus.pending), /hasn.t confirmed it/);
87
+ assert.match(messageFor(MailboxSyncStatus.deleting), /being deleted/);
88
+ assert.match(messageFor(MailboxSyncStatus.failed), /Retry or dismiss/);
89
+ });
90
+
91
+ it("allows a settled folder", () => {
64
92
  assert.doesNotThrow(() =>
65
- assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.failed })),
93
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
66
94
  );
67
- assert.doesNotThrow(() => assertMailboxSettled(mailbox({})));
68
95
  });
69
96
  });
70
97
 
71
- describe("applyMailboxPatch — the appointment label follows a rename", () => {
98
+ describe("refreshFolderAppointmentLabels — the label follows a settled rename", () => {
72
99
  const labelName = composeFolderRoleAppointmentLabelName(
73
100
  "acc-1",
74
101
  CanonicalMailboxRole.Trash,
@@ -82,68 +109,68 @@ describe("applyMailboxPatch — the appointment label follows a rename", () => {
82
109
 
83
110
  const settingsFor = (
84
111
  rows: Record<string, string>,
85
- renamed = "INBOX",
86
- ): { store: Record<string, string>; client: MailboxPatchClient } => {
112
+ ): {
113
+ store: Record<string, string>;
114
+ accountSetting: Parameters<typeof refreshFolderAppointmentLabels>[0];
115
+ } => {
87
116
  const store: Record<string, string> = { ...rows };
88
- const client = {
89
- mailbox: {
90
- get: async () => mailbox({ fullPath: renamed }),
117
+ const accountSetting = {
118
+ get: async (_configId: string, name: string) =>
119
+ store[name] === undefined
120
+ ? undefined
121
+ : ({
122
+ name,
123
+ value: { kind: "String", value: store[name] },
124
+ } as AccountSettingItem),
125
+ upsert: async (item: AccountSettingItem) => {
126
+ if (item.value.kind === "String") store[item.name] = item.value.value;
127
+ return item;
91
128
  },
92
- mailboxQueue: {
93
- renameMailbox: async (mailboxId: string, newPath: string) =>
94
- mailbox({ mailboxId, fullPath: newPath }),
95
- },
96
- accountSetting: {
97
- get: async (_configId: string, name: string) =>
98
- store[name] === undefined
99
- ? undefined
100
- : ({
101
- name,
102
- value: { kind: "String", value: store[name] },
103
- } as AccountSettingItem),
104
- upsert: async (item: AccountSettingItem) => {
105
- if (item.value.kind === "String") store[item.name] = item.value.value;
106
- return item;
107
- },
108
- delete: async (_configId: string, name: string) => {
109
- delete store[name];
110
- },
111
- },
112
- } as unknown as MailboxPatchClient;
113
- return { store, client };
129
+ } as unknown as Parameters<typeof refreshFolderAppointmentLabels>[0];
130
+ return { store, accountSetting };
114
131
  };
115
132
 
116
- it("rewrites the recorded path for the folder that was renamed", async () => {
117
- const { store, client } = settingsFor(
118
- {
119
- [appointmentName(CanonicalMailboxRole.Trash)]: "mb-1",
120
- [labelName]: "INBOX/Prullenbak",
121
- },
122
- "INBOX/Prullenbak",
133
+ const settle = (
134
+ accountSetting: Parameters<typeof refreshFolderAppointmentLabels>[0],
135
+ renamed: { mailboxId: string; oldPath: string; newPath: string },
136
+ ) =>
137
+ refreshFolderAppointmentLabels(
138
+ accountSetting,
139
+ "cfg-1",
140
+ "acc-1",
141
+ renamed,
142
+ "/",
123
143
  );
124
144
 
125
- await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
126
- fullPath: "INBOX/Verwijderd",
145
+ it("rewrites the recorded path for the folder that was renamed", async () => {
146
+ const { store, accountSetting } = settingsFor({
147
+ [appointmentName(CanonicalMailboxRole.Trash)]: "mb-1",
148
+ [labelName]: "INBOX/Prullenbak",
149
+ });
150
+
151
+ await settle(accountSetting, {
152
+ mailboxId: "mb-1",
153
+ oldPath: "INBOX/Prullenbak",
154
+ newPath: "INBOX/Verwijderd",
127
155
  });
128
156
 
129
157
  assert.equal(store[labelName], "INBOX/Verwijderd");
130
158
  });
131
159
 
132
- // IMAP RENAME moves the subtree in one command and `renameChildPaths`
133
- // rewrites every descendant row, so every label under the branch moves too.
160
+ // IMAP RENAME moves the subtree in one command, so every label under the
161
+ // branch moves with it.
134
162
  it("carries every appointed folder under the renamed branch with it", async () => {
135
- const { store, client } = settingsFor(
136
- {
137
- [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
138
- [labelName]: "INBOX/Prullenbak",
139
- [appointmentName(CanonicalMailboxRole.Sent)]: "mb-sent",
140
- [sentLabelName]: "INBOX/Verzonden",
141
- },
142
- "INBOX",
143
- );
163
+ const { store, accountSetting } = settingsFor({
164
+ [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
165
+ [labelName]: "INBOX/Prullenbak",
166
+ [appointmentName(CanonicalMailboxRole.Sent)]: "mb-sent",
167
+ [sentLabelName]: "INBOX/Verzonden",
168
+ });
144
169
 
145
- await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
146
- fullPath: "Mail",
170
+ await settle(accountSetting, {
171
+ mailboxId: "mb-1",
172
+ oldPath: "INBOX",
173
+ newPath: "Mail",
147
174
  });
148
175
 
149
176
  assert.equal(store[labelName], "Mail/Prullenbak");
@@ -151,32 +178,30 @@ describe("applyMailboxPatch — the appointment label follows a rename", () => {
151
178
  });
152
179
 
153
180
  it("leaves a folder outside the renamed branch alone", async () => {
154
- const { store, client } = settingsFor(
155
- {
156
- [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
157
- [labelName]: "Archief/Prullenbak",
158
- },
159
- "INBOX",
160
- );
181
+ const { store, accountSetting } = settingsFor({
182
+ [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
183
+ [labelName]: "Archief/Prullenbak",
184
+ });
161
185
 
162
- await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
163
- fullPath: "Mail",
186
+ await settle(accountSetting, {
187
+ mailboxId: "mb-1",
188
+ oldPath: "INBOX",
189
+ newPath: "Mail",
164
190
  });
165
191
 
166
192
  assert.equal(store[labelName], "Archief/Prullenbak");
167
193
  });
168
194
 
169
195
  it("never rebases a sibling that merely shares the prefix", async () => {
170
- const { store, client } = settingsFor(
171
- {
172
- [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
173
- [labelName]: "INBOXES/Prullenbak",
174
- },
175
- "INBOX",
176
- );
196
+ const { store, accountSetting } = settingsFor({
197
+ [appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
198
+ [labelName]: "INBOXES/Prullenbak",
199
+ });
177
200
 
178
- await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
179
- fullPath: "Mail",
201
+ await settle(accountSetting, {
202
+ mailboxId: "mb-1",
203
+ oldPath: "INBOX",
204
+ newPath: "Mail",
180
205
  });
181
206
 
182
207
  assert.equal(store[labelName], "INBOXES/Prullenbak");
@@ -2,9 +2,6 @@ import type {
2
2
  AppointFolderRoleInput,
3
3
  CanonicalMailboxRole,
4
4
  } from "@remit/api-openapi-types";
5
- import type { MailboxItem } from "@remit/data-ports";
6
- import { MailboxNotSettledError } from "@remit/data-ports/errors";
7
- import { MailboxSyncStatus } from "@remit/domain-enums";
8
5
  import type { APIGatewayProxyEvent } from "aws-lambda";
9
6
  import { getAccountConfigIdFromEvent } from "../auth.js";
10
7
  import { getClient } from "../service/data-client.js";
@@ -17,31 +14,7 @@ import {
17
14
  resolveAccountFolderAppointments,
18
15
  writeFolderRoleAppointment,
19
16
  } from "./folder-role-appointments.js";
20
- import { assertMailboxInAccount } from "./mailbox.js";
21
-
22
- /** Mailbox states in which the mail server has not settled the folder yet. */
23
- const UNSETTLED: ReadonlySet<string> = new Set([
24
- MailboxSyncStatus.pending,
25
- MailboxSyncStatus.deleting,
26
- ]);
27
-
28
- /**
29
- * Appointing a role to a folder the mail server is still creating or deleting
30
- * would bind the account's Trash to a folder that may never exist (D16 item 3,
31
- * imap-mutations R2: wait). A `\Noselect` container is refused by construction
32
- * — mailbox-sync keeps no row for one — so there is nothing to appoint.
33
- */
34
- export const assertMailboxSettled = (
35
- target: Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
36
- ): void => {
37
- const syncStatus = target.syncStatus;
38
- if (!syncStatus || !UNSETTLED.has(syncStatus)) return;
39
- throw new MailboxNotSettledError(
40
- `Mailbox ${target.fullPath} is not settled on the mail server yet`,
41
- target.mailboxId,
42
- syncStatus,
43
- );
44
- };
17
+ import { assertMailboxInAccount, assertMailboxSettled } from "./mailbox.js";
45
18
 
46
19
  /**
47
20
  * RFC 032 exclusive-folder-appointment (#976): the single write operation for
@@ -0,0 +1,111 @@
1
+ import type {
2
+ FilterItem,
3
+ IAccountSettingRepository,
4
+ IFilterRepository,
5
+ MailboxItem,
6
+ } from "@remit/data-ports";
7
+ import { FILTER_NO_ACTION } from "@remit/data-ports";
8
+ import { BadRequestError } from "@remit/data-ports/errors";
9
+ import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
10
+ import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
11
+
12
+ /**
13
+ * What a folder is bound to: the filters that move mail into it, and the
14
+ * canonical roles it has been appointed to.
15
+ */
16
+ export interface MailboxBindings {
17
+ filterNames: string[];
18
+ roles: CanonicalMailboxRoleValue[];
19
+ }
20
+
21
+ /**
22
+ * `filter.actionMailboxId` has no reverse index, so the filters are listed for
23
+ * the account config and matched — they are few and per-config, so this is not
24
+ * a mailbox scan. Role appointments are `account_setting` rows whose *value* is
25
+ * a mailboxId, which a `mailbox_id` column scan cannot find, so the existing
26
+ * per-account appointment map answers for them.
27
+ */
28
+ export const loadMailboxBindings = async (
29
+ client: {
30
+ filter: Pick<IFilterRepository, "listByAccountConfig">;
31
+ accountSetting: Pick<IAccountSettingRepository, "get">;
32
+ },
33
+ accountConfigId: string,
34
+ accountId: string,
35
+ mailboxId: string,
36
+ ): Promise<MailboxBindings> => {
37
+ const [filters, appointments] = await Promise.all([
38
+ client.filter.listByAccountConfig(accountConfigId),
39
+ loadFolderAppointmentsForAccount(
40
+ client.accountSetting,
41
+ accountConfigId,
42
+ accountId,
43
+ ),
44
+ ]);
45
+
46
+ const filterNames = filters
47
+ .filter(
48
+ (filter: FilterItem) =>
49
+ filter.actionMailboxId !== FILTER_NO_ACTION &&
50
+ filter.actionMailboxId === mailboxId,
51
+ )
52
+ .map((filter: FilterItem) => filter.name);
53
+
54
+ const roles: CanonicalMailboxRoleValue[] = [];
55
+ for (const [role, appointment] of appointments) {
56
+ if (appointment.mailboxId === mailboxId) {
57
+ roles.push(role as CanonicalMailboxRoleValue);
58
+ }
59
+ }
60
+
61
+ return { filterNames, roles };
62
+ };
63
+
64
+ const asList = (items: readonly string[]): string =>
65
+ items.length === 1
66
+ ? items[0]
67
+ : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
68
+
69
+ /**
70
+ * Refuse a delete while any durable reference is bound to the folder (D16).
71
+ *
72
+ * This is what makes the settled-target rule total: a binding is only created
73
+ * against a settled folder and only removed by the user, so a `mailboxId` that
74
+ * names nothing never exists and no reader of a filter's `actionMailboxId` or a
75
+ * role appointment needs a missing-target branch. It removes code rather than
76
+ * adding a check.
77
+ *
78
+ * The alternatives both cost more than they look. `Filter` has no enabled flag
79
+ * and its `actionMailboxId` sentinel means "no move action", so clearing the
80
+ * target silently turns a move-filter into one that does something else;
81
+ * deleting the user's filters outright is not a decision a folder delete gets
82
+ * to make.
83
+ *
84
+ * The message **names** each binding rather than counting them, because a count
85
+ * is not actionable — and this fires on ordinary configurations, since pointing
86
+ * a filter at a folder is what filters are for.
87
+ */
88
+ export const assertNoBindings = (
89
+ mailbox: Pick<MailboxItem, "fullPath">,
90
+ bindings: MailboxBindings,
91
+ ): void => {
92
+ const clauses: string[] = [];
93
+ if (bindings.filterNames.length > 0) {
94
+ clauses.push(
95
+ `the ${bindings.filterNames.length === 1 ? "filter" : "filters"} ${asList(
96
+ bindings.filterNames.map((name) => `“${name}”`),
97
+ )}`,
98
+ );
99
+ }
100
+ if (bindings.roles.length > 0) {
101
+ clauses.push(
102
+ `the ${asList(bindings.roles)} ${
103
+ bindings.roles.length === 1 ? "role" : "roles"
104
+ }`,
105
+ );
106
+ }
107
+ if (clauses.length === 0) return;
108
+ throw new BadRequestError(
109
+ `“${mailbox.fullPath}” is used by ${asList(clauses)}.`,
110
+ );
111
+ };