@remit/backend 0.0.75 → 0.0.76

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.75",
3
+ "version": "0.0.76",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -0,0 +1,184 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { AccountSettingItem, MailboxItem } from "@remit/data-ports";
4
+ import { isPublicApiError } from "@remit/data-ports/errors";
5
+ import { composeFolderRoleAppointmentLabelName } from "@remit/data-ports/folder-role";
6
+ import { CanonicalMailboxRole, MailboxSyncStatus } from "@remit/domain-enums";
7
+ import { assertMailboxSettled } from "./folder-role.js";
8
+ import { applyMailboxPatch, type MailboxPatchClient } from "./mailbox.js";
9
+
10
+ const mailbox = (over: Partial<MailboxItem>): MailboxItem =>
11
+ ({
12
+ mailboxId: "mb-1",
13
+ fullPath: "INBOX/Prullenbak",
14
+ hierarchyDelimiter: "/",
15
+ ...over,
16
+ }) as unknown as MailboxItem;
17
+
18
+ const caught = (run: () => void): unknown => {
19
+ let thrown: unknown;
20
+ assert.throws(run, (error: unknown) => {
21
+ thrown = error;
22
+ return true;
23
+ });
24
+ return thrown;
25
+ };
26
+
27
+ const publicErrorOf = (error: unknown) => {
28
+ if (typeof error !== "object" || error === null) return undefined;
29
+ const { publicApiError } = error as { publicApiError?: unknown };
30
+ return isPublicApiError(publicApiError) ? publicApiError : undefined;
31
+ };
32
+
33
+ describe("assertMailboxSettled", () => {
34
+ it("refuses a folder the mail server has not created yet", () => {
35
+ const error = caught(() =>
36
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.pending })),
37
+ );
38
+ assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
39
+ });
40
+
41
+ it("refuses a folder on its way out", () => {
42
+ const error = caught(() =>
43
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.deleting })),
44
+ );
45
+ assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
46
+ });
47
+
48
+ it("carries the mailbox and its state, so the client words a wait", () => {
49
+ const error = caught(() =>
50
+ assertMailboxSettled(
51
+ mailbox({ mailboxId: "mb-9", syncStatus: MailboxSyncStatus.pending }),
52
+ ),
53
+ );
54
+ assert.deepEqual(publicErrorOf(error)?.details, {
55
+ mailboxId: "mb-9",
56
+ syncStatus: "pending",
57
+ });
58
+ });
59
+
60
+ it("allows a settled folder, and one whose delete failed", () => {
61
+ assert.doesNotThrow(() =>
62
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
63
+ );
64
+ assert.doesNotThrow(() =>
65
+ assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.failed })),
66
+ );
67
+ assert.doesNotThrow(() => assertMailboxSettled(mailbox({})));
68
+ });
69
+ });
70
+
71
+ describe("applyMailboxPatch — the appointment label follows a rename", () => {
72
+ const labelName = composeFolderRoleAppointmentLabelName(
73
+ "acc-1",
74
+ CanonicalMailboxRole.Trash,
75
+ );
76
+ const sentLabelName = composeFolderRoleAppointmentLabelName(
77
+ "acc-1",
78
+ CanonicalMailboxRole.Sent,
79
+ );
80
+ const appointmentName = (role: string) =>
81
+ `FolderRoleAppointment#acc-1#${role}`;
82
+
83
+ const settingsFor = (
84
+ rows: Record<string, string>,
85
+ renamed = "INBOX",
86
+ ): { store: Record<string, string>; client: MailboxPatchClient } => {
87
+ const store: Record<string, string> = { ...rows };
88
+ const client = {
89
+ mailbox: {
90
+ get: async () => mailbox({ fullPath: renamed }),
91
+ },
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 };
114
+ };
115
+
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",
123
+ );
124
+
125
+ await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
126
+ fullPath: "INBOX/Verwijderd",
127
+ });
128
+
129
+ assert.equal(store[labelName], "INBOX/Verwijderd");
130
+ });
131
+
132
+ // IMAP RENAME moves the subtree in one command and `renameChildPaths`
133
+ // rewrites every descendant row, so every label under the branch moves too.
134
+ 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
+ );
144
+
145
+ await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
146
+ fullPath: "Mail",
147
+ });
148
+
149
+ assert.equal(store[labelName], "Mail/Prullenbak");
150
+ assert.equal(store[sentLabelName], "Mail/Verzonden");
151
+ });
152
+
153
+ 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
+ );
161
+
162
+ await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
163
+ fullPath: "Mail",
164
+ });
165
+
166
+ assert.equal(store[labelName], "Archief/Prullenbak");
167
+ });
168
+
169
+ 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
+ );
177
+
178
+ await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
179
+ fullPath: "Mail",
180
+ });
181
+
182
+ assert.equal(store[labelName], "INBOXES/Prullenbak");
183
+ });
184
+ });
@@ -2,6 +2,9 @@ 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";
5
8
  import type { APIGatewayProxyEvent } from "aws-lambda";
6
9
  import { getAccountConfigIdFromEvent } from "../auth.js";
7
10
  import { getClient } from "../service/data-client.js";
@@ -16,6 +19,30 @@ import {
16
19
  } from "./folder-role-appointments.js";
17
20
  import { assertMailboxInAccount } from "./mailbox.js";
18
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
+ };
45
+
19
46
  /**
20
47
  * RFC 032 exclusive-folder-appointment (#976): the single write operation for
21
48
  * the per-account role map. `appoint(role, mailboxId)` sets `map[role]` —
@@ -47,6 +74,7 @@ export const FolderRoleOperations: Record<
47
74
  if (body.mailboxId) {
48
75
  const target = await mailbox.get(accountId, body.mailboxId);
49
76
  assertMailboxInAccount(target, accountId, "act");
77
+ assertMailboxSettled(target);
50
78
  lastKnownPath = target.fullPath;
51
79
  }
52
80
 
@@ -4,6 +4,10 @@ import type {
4
4
  } from "@remit/api-openapi-types";
5
5
  import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
6
6
  import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
7
+ import {
8
+ type CanonicalMailboxRoleValue,
9
+ composeFolderRoleAppointmentLabelName,
10
+ } from "@remit/data-ports/folder-role";
7
11
  import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
8
12
  import type { APIGatewayProxyEvent } from "aws-lambda";
9
13
  import { getAccountConfigIdFromEvent } from "../auth.js";
@@ -25,6 +29,7 @@ import {
25
29
  type MailboxOverrides,
26
30
  } from "./account-overrides.js";
27
31
  import { assertAccountOwnership } from "./account-ownership.js";
32
+ import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
28
33
 
29
34
  /**
30
35
  * The mute flag and the display-name override are user preferences that live
@@ -68,9 +73,73 @@ export interface MailboxPatchClient {
68
73
  accountId: string,
69
74
  ): Promise<MailboxItem>;
70
75
  };
71
- accountSetting: Pick<IAccountSettingRepository, "upsert" | "delete">;
76
+ accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
72
77
  }
73
78
 
79
+ /**
80
+ * Where a recorded path lands after the rename, or `undefined` when the rename
81
+ * did not move it. IMAP RENAME moves the whole subtree in one command and
82
+ * `renameChildPaths` rewrites every descendant row with it, so a label under
83
+ * the renamed branch moves exactly as far as its prefix does.
84
+ */
85
+ const rebasePath = (
86
+ recorded: string,
87
+ oldPath: string,
88
+ newPath: string,
89
+ delimiter: string,
90
+ ): string | undefined => {
91
+ if (recorded === oldPath) return newPath;
92
+ const branch = `${oldPath}${delimiter}`;
93
+ if (!recorded.startsWith(branch)) return undefined;
94
+ return `${newPath}${recorded.slice(oldPath.length)}`;
95
+ };
96
+
97
+ /**
98
+ * A reader-side rename keeps every mailboxId, so the appointments survive it —
99
+ * but the paths recorded beside them (#887) would still name where the folders
100
+ * were before. Move the labels with the branch, or a later third-party delete
101
+ * names a path the user has not seen since the rename.
102
+ *
103
+ * The renamed folder is matched by id; its descendants are matched by the path
104
+ * each label already holds, which is the path their rows carried until this
105
+ * rename rewrote them.
106
+ */
107
+ const refreshAppointmentLabels = async (
108
+ accountSetting: Pick<IAccountSettingRepository, "get" | "upsert">,
109
+ accountConfigId: string,
110
+ accountId: string,
111
+ renamed: { mailboxId: string; oldPath: string; newPath: string },
112
+ delimiter: string,
113
+ ): Promise<void> => {
114
+ const persisted = await loadFolderAppointmentsForAccount(
115
+ accountSetting,
116
+ accountConfigId,
117
+ accountId,
118
+ );
119
+ for (const [role, appointment] of persisted) {
120
+ const moved =
121
+ appointment.mailboxId === renamed.mailboxId
122
+ ? renamed.newPath
123
+ : appointment.lastKnownPath === undefined
124
+ ? undefined
125
+ : rebasePath(
126
+ appointment.lastKnownPath,
127
+ renamed.oldPath,
128
+ renamed.newPath,
129
+ delimiter,
130
+ );
131
+ if (moved === undefined || moved === appointment.lastKnownPath) continue;
132
+ await accountSetting.upsert({
133
+ accountConfigId,
134
+ name: composeFolderRoleAppointmentLabelName(
135
+ accountId,
136
+ role as CanonicalMailboxRoleValue,
137
+ ),
138
+ value: { kind: "String", value: moved },
139
+ });
140
+ }
141
+ };
142
+
74
143
  /**
75
144
  * Apply a mailbox PATCH body: override changes first (mute flag + display-name/
76
145
  * role overrides — written to per-mailbox AccountSetting rows, no IMAP
@@ -109,7 +178,26 @@ export const applyMailboxPatch = async (
109
178
  return client.mailbox.get(accountId, mailboxId);
110
179
  }
111
180
 
112
- return client.mailboxQueue.renameMailbox(mailboxId, fullPath, accountId);
181
+ // Read before the rename: the labels of the folders under this one are
182
+ // rebased off the path it is leaving, which the row no longer carries after.
183
+ const before = await client.mailbox.get(accountId, mailboxId);
184
+ const renamed = await client.mailboxQueue.renameMailbox(
185
+ mailboxId,
186
+ fullPath,
187
+ accountId,
188
+ );
189
+ await refreshAppointmentLabels(
190
+ client.accountSetting,
191
+ accountConfigId,
192
+ accountId,
193
+ {
194
+ mailboxId,
195
+ oldPath: before.fullPath,
196
+ newPath: renamed.fullPath,
197
+ },
198
+ before.hierarchyDelimiter,
199
+ );
200
+ return renamed;
113
201
  };
114
202
 
115
203
  /**