@remit/backend 0.0.73 → 0.0.75

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.73",
3
+ "version": "0.0.75",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -1,12 +1,20 @@
1
1
  import assert from "node:assert/strict";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
3
6
  import type { AccountSettingItem } from "@remit/data-ports";
4
7
  import type { RoleMailboxCandidate } from "@remit/data-ports/folder-role";
5
- import { CanonicalMailboxRole, MailboxSpecialUse } from "@remit/domain-enums";
8
+ import {
9
+ CanonicalMailboxRole,
10
+ FolderAppointmentSource,
11
+ MailboxSpecialUse,
12
+ } from "@remit/domain-enums";
6
13
  import {
7
14
  CANONICAL_ROLES,
8
15
  groupFolderAppointmentsByAccount,
9
16
  loadFolderAppointmentsForAccount,
17
+ type PersistedFolderAppointment,
10
18
  resolveFolderAppointments,
11
19
  writeFolderRoleAppointment,
12
20
  } from "./folder-role-appointments.js";
@@ -21,6 +29,12 @@ const setting = (name: string, value: string): AccountSettingItem =>
21
29
  updatedAt: 0,
22
30
  }) as AccountSettingItem;
23
31
 
32
+ const appointed = (
33
+ mailboxId: string,
34
+ lastKnownPath?: string,
35
+ ): Map<string, PersistedFolderAppointment> =>
36
+ new Map([[CanonicalMailboxRole.Trash, { mailboxId, lastKnownPath }]]);
37
+
24
38
  describe("CANONICAL_ROLES", () => {
25
39
  it("carries every RFC 032 anchor role, Custom excluded", () => {
26
40
  assert.deepEqual(
@@ -55,6 +69,11 @@ describe("resolveFolderAppointments", () => {
55
69
  },
56
70
  ];
57
71
 
72
+ const roleIn = (
73
+ result: ReturnType<typeof resolveFolderAppointments>,
74
+ role: string,
75
+ ) => result.find((entry) => entry.role === role);
76
+
58
77
  it("carries one entry per canonical role, even when unfilled", () => {
59
78
  const result = resolveFolderAppointments(new Map(), folders);
60
79
  assert.deepEqual(
@@ -63,26 +82,74 @@ describe("resolveFolderAppointments", () => {
63
82
  );
64
83
  });
65
84
 
66
- it("prefers the persisted appointment over a fresh proposal", () => {
67
- const persisted = new Map([[CanonicalMailboxRole.Drafts, "mb-concepten"]]);
85
+ it("gives every role a source, so no surface has to infer one", () => {
86
+ const result = resolveFolderAppointments(new Map(), folders);
87
+ assert.equal(
88
+ result.every((entry) => entry.source !== undefined),
89
+ true,
90
+ );
91
+ });
92
+
93
+ it("tells a folder the user chose from one reader guessed by name", () => {
94
+ const persisted = new Map<string, PersistedFolderAppointment>([
95
+ [CanonicalMailboxRole.Drafts, { mailboxId: "mb-concepten" }],
96
+ ]);
68
97
  const result = resolveFolderAppointments(persisted, folders);
69
- const drafts = result.find((r) => r.role === CanonicalMailboxRole.Drafts);
98
+ const drafts = roleIn(result, CanonicalMailboxRole.Drafts);
70
99
  assert.equal(drafts?.mailboxId, "mb-concepten");
100
+ assert.equal(drafts?.source, FolderAppointmentSource.Appointed);
101
+
102
+ const proposed = resolveFolderAppointments(new Map(), folders);
103
+ const guess = roleIn(proposed, CanonicalMailboxRole.Drafts);
104
+ assert.equal(guess?.mailboxId, "mb-concepten");
105
+ assert.equal(guess?.source, FolderAppointmentSource.Proposed);
71
106
  });
72
107
 
73
- it("re-proposes when the persisted mailbox no longer exists", () => {
74
- const persisted = new Map([
75
- [CanonicalMailboxRole.Junk, "mb-deleted-long-ago"],
108
+ it("marks the server's own flag and the reserved INBOX name as such", () => {
109
+ const result = resolveFolderAppointments(new Map(), folders);
110
+ assert.equal(
111
+ roleIn(result, CanonicalMailboxRole.Junk)?.source,
112
+ FolderAppointmentSource.Flagged,
113
+ );
114
+ assert.equal(
115
+ roleIn(result, CanonicalMailboxRole.Inbox)?.source,
116
+ FolderAppointmentSource.Reserved,
117
+ );
118
+ });
119
+
120
+ it("surfaces a choice whose folder is gone as broken, not as absent", () => {
121
+ const persisted = new Map<string, PersistedFolderAppointment>([
122
+ [
123
+ CanonicalMailboxRole.Junk,
124
+ { mailboxId: "mb-deleted-long-ago", lastKnownPath: "INBOX/Rommel" },
125
+ ],
76
126
  ]);
77
- const result = resolveFolderAppointments(persisted, folders);
78
- const junk = result.find((r) => r.role === CanonicalMailboxRole.Junk);
127
+ const junk = roleIn(
128
+ resolveFolderAppointments(persisted, folders),
129
+ CanonicalMailboxRole.Junk,
130
+ );
131
+ assert.equal(junk?.source, FolderAppointmentSource.Stale);
132
+ assert.equal(junk?.staleAppointmentMailboxId, "mb-deleted-long-ago");
133
+ assert.equal(junk?.staleAppointmentPath, "INBOX/Rommel");
79
134
  assert.equal(junk?.mailboxId, "mb-spam");
80
135
  });
81
136
 
82
137
  it("leaves a role unfilled when nothing persisted or proposed matches", () => {
83
- const result = resolveFolderAppointments(new Map(), folders);
84
- const archive = result.find((r) => r.role === CanonicalMailboxRole.Archive);
138
+ const archive = roleIn(
139
+ resolveFolderAppointments(new Map(), folders),
140
+ CanonicalMailboxRole.Archive,
141
+ );
85
142
  assert.equal(archive?.mailboxId, undefined);
143
+ assert.equal(archive?.source, FolderAppointmentSource.None);
144
+ });
145
+
146
+ it("never reads the recorded path as evidence for the role", () => {
147
+ const trash = roleIn(
148
+ resolveFolderAppointments(appointed("mb-inbox", "Prullenbak"), folders),
149
+ CanonicalMailboxRole.Trash,
150
+ );
151
+ assert.equal(trash?.mailboxId, "mb-inbox");
152
+ assert.equal(trash?.staleAppointmentPath, undefined);
86
153
  });
87
154
  });
88
155
 
@@ -105,11 +172,35 @@ describe("groupFolderAppointmentsByAccount", () => {
105
172
  ];
106
173
  const grouped = groupFolderAppointmentsByAccount(settings);
107
174
  assert.deepEqual(Object.fromEntries(grouped.get("acc-1") ?? []), {
108
- [CanonicalMailboxRole.Drafts]: "mb-1",
109
- [CanonicalMailboxRole.Sent]: "mb-2",
175
+ [CanonicalMailboxRole.Drafts]: { mailboxId: "mb-1" },
176
+ [CanonicalMailboxRole.Sent]: { mailboxId: "mb-2" },
110
177
  });
111
178
  assert.deepEqual(Object.fromEntries(grouped.get("acc-2") ?? []), {
112
- [CanonicalMailboxRole.Inbox]: "mb-3",
179
+ [CanonicalMailboxRole.Inbox]: { mailboxId: "mb-3" },
180
+ });
181
+ });
182
+
183
+ it("attaches the recorded path to the appointment it belongs to", () => {
184
+ const settings = [
185
+ setting(
186
+ `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Trash}`,
187
+ "mb-1",
188
+ ),
189
+ setting(
190
+ `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Trash}`,
191
+ "INBOX/Bak",
192
+ ),
193
+ setting(
194
+ `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Sent}`,
195
+ "INBOX/Verzonden",
196
+ ),
197
+ ];
198
+ const grouped = groupFolderAppointmentsByAccount(settings);
199
+ assert.deepEqual(Object.fromEntries(grouped.get("acc-1") ?? []), {
200
+ [CanonicalMailboxRole.Trash]: {
201
+ mailboxId: "mb-1",
202
+ lastKnownPath: "INBOX/Bak",
203
+ },
113
204
  });
114
205
  });
115
206
 
@@ -128,71 +219,141 @@ describe("groupFolderAppointmentsByAccount", () => {
128
219
  ];
129
220
  const grouped = groupFolderAppointmentsByAccount(settings);
130
221
  assert.deepEqual(Object.fromEntries(grouped.get("acc-1") ?? []), {
131
- [CanonicalMailboxRole.Drafts]: "mb-1",
222
+ [CanonicalMailboxRole.Drafts]: { mailboxId: "mb-1" },
132
223
  });
133
224
  });
134
225
  });
135
226
 
136
227
  describe("loadFolderAppointmentsForAccount", () => {
228
+ const readerOver = (stored: Map<string, string>) => ({
229
+ get: async (_accountConfigId: string, name: string) => {
230
+ const value = stored.get(name);
231
+ return value ? setting(name, value) : null;
232
+ },
233
+ });
234
+
137
235
  it("reads each role's row and collects only the ones that exist", async () => {
138
- const stored = new Map<string, string>([
139
- [`FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Sent}`, "mb-sent"],
140
- ]);
141
- const accountSetting = {
142
- get: async (_accountConfigId: string, name: string) => {
143
- const value = stored.get(name);
144
- return value ? setting(name, value) : null;
145
- },
146
- };
147
236
  const roles = await loadFolderAppointmentsForAccount(
148
- accountSetting,
237
+ readerOver(
238
+ new Map([
239
+ [
240
+ `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Sent}`,
241
+ "mb-sent",
242
+ ],
243
+ ]),
244
+ ),
149
245
  "cfg-1",
150
246
  "acc-1",
151
247
  );
152
248
  assert.deepEqual(Object.fromEntries(roles), {
153
- [CanonicalMailboxRole.Sent]: "mb-sent",
249
+ [CanonicalMailboxRole.Sent]: { mailboxId: "mb-sent" },
154
250
  });
155
251
  });
252
+
253
+ it("picks up the recorded path beside the appointment", async () => {
254
+ const roles = await loadFolderAppointmentsForAccount(
255
+ readerOver(
256
+ new Map([
257
+ [
258
+ `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Trash}`,
259
+ "mb-bak",
260
+ ],
261
+ [
262
+ `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Trash}`,
263
+ "INBOX/Bak",
264
+ ],
265
+ ]),
266
+ ),
267
+ "cfg-1",
268
+ "acc-1",
269
+ );
270
+ assert.deepEqual(roles.get(CanonicalMailboxRole.Trash), {
271
+ mailboxId: "mb-bak",
272
+ lastKnownPath: "INBOX/Bak",
273
+ });
274
+ });
275
+
276
+ it("drops a label left behind by an appointment that was cleared", async () => {
277
+ const roles = await loadFolderAppointmentsForAccount(
278
+ readerOver(
279
+ new Map([
280
+ [
281
+ `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Trash}`,
282
+ "INBOX/Bak",
283
+ ],
284
+ ]),
285
+ ),
286
+ "cfg-1",
287
+ "acc-1",
288
+ );
289
+ assert.equal(roles.get(CanonicalMailboxRole.Trash), undefined);
290
+ });
156
291
  });
157
292
 
158
293
  describe("writeFolderRoleAppointment", () => {
159
- it("upserts a String-valued row for a value", async () => {
160
- const calls: unknown[] = [];
161
- const accountSetting = {
162
- upsert: async (input: unknown) => {
163
- calls.push(input);
164
- return input as never;
165
- },
166
- delete: async () => {
167
- throw new Error("should not delete");
294
+ const recorder = () => {
295
+ const upserts: unknown[] = [];
296
+ const deletes: unknown[][] = [];
297
+ return {
298
+ upserts,
299
+ deletes,
300
+ accountSetting: {
301
+ upsert: async (input: unknown) => {
302
+ upserts.push(input);
303
+ return input as never;
304
+ },
305
+ delete: async (accountConfigId: string, name: string) => {
306
+ deletes.push([accountConfigId, name]);
307
+ },
168
308
  },
169
309
  };
310
+ };
311
+
312
+ it("records the path alongside the appointment", async () => {
313
+ const { upserts, deletes, accountSetting } = recorder();
170
314
  await writeFolderRoleAppointment(
171
315
  accountSetting,
172
316
  "cfg-1",
173
317
  "acc-1",
174
318
  CanonicalMailboxRole.Archive,
175
319
  "mb-archive",
320
+ "INBOX/Archief",
176
321
  );
177
- assert.deepEqual(calls, [
322
+ assert.deepEqual(upserts, [
178
323
  {
179
324
  accountConfigId: "cfg-1",
180
325
  name: `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Archive}`,
181
326
  value: { kind: "String", value: "mb-archive" },
182
327
  },
328
+ {
329
+ accountConfigId: "cfg-1",
330
+ name: `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Archive}`,
331
+ value: { kind: "String", value: "INBOX/Archief" },
332
+ },
183
333
  ]);
334
+ assert.deepEqual(deletes, []);
184
335
  });
185
336
 
186
- it("deletes the row when mailboxId is null (clear)", async () => {
187
- const calls: unknown[][] = [];
188
- const accountSetting = {
189
- upsert: async () => {
190
- throw new Error("should not upsert");
191
- },
192
- delete: async (accountConfigId: string, name: string) => {
193
- calls.push([accountConfigId, name]);
194
- },
195
- };
337
+ it("clears a path left over from the previous choice", async () => {
338
+ const { upserts, deletes, accountSetting } = recorder();
339
+ await writeFolderRoleAppointment(
340
+ accountSetting,
341
+ "cfg-1",
342
+ "acc-1",
343
+ CanonicalMailboxRole.Archive,
344
+ "mb-archive",
345
+ );
346
+ assert.equal(upserts.length, 1);
347
+ assert.deepEqual(deletes, [
348
+ [
349
+ "cfg-1",
350
+ `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Archive}`,
351
+ ],
352
+ ]);
353
+ });
354
+
355
+ it("deletes both rows when the appointment is cleared", async () => {
356
+ const { upserts, deletes, accountSetting } = recorder();
196
357
  await writeFolderRoleAppointment(
197
358
  accountSetting,
198
359
  "cfg-1",
@@ -200,8 +361,78 @@ describe("writeFolderRoleAppointment", () => {
200
361
  CanonicalMailboxRole.Archive,
201
362
  null,
202
363
  );
203
- assert.deepEqual(calls, [
364
+ assert.deepEqual(upserts, []);
365
+ assert.deepEqual(deletes, [
204
366
  ["cfg-1", `FolderRoleAppointment#acc-1#${CanonicalMailboxRole.Archive}`],
367
+ [
368
+ "cfg-1",
369
+ `FolderRoleAppointmentLabel#acc-1#${CanonicalMailboxRole.Archive}`,
370
+ ],
371
+ ]);
372
+ });
373
+ });
374
+
375
+ /**
376
+ * D1 of the #887 design: a folder-role appointment row means one thing — a
377
+ * person decided. That is what makes an appointment outrank the server's own
378
+ * \Trash flag, and what makes an Empty Trash on one defensible. It holds only
379
+ * while the row has a single author, so the author is pinned here rather than
380
+ * left to review.
381
+ */
382
+ describe("who may write a folder-role appointment", () => {
383
+ const PACKAGES = fileURLToPath(new URL("../../../", import.meta.url));
384
+
385
+ const sourceFiles = (): string[] => {
386
+ const files: string[] = [];
387
+ for (const pkg of readdirSync(PACKAGES, { withFileTypes: true })) {
388
+ if (!pkg.isDirectory()) continue;
389
+ const src = join(PACKAGES, pkg.name, "src");
390
+ if (!existsSync(src)) continue;
391
+ for (const entry of readdirSync(src, { recursive: true })) {
392
+ const name = String(entry);
393
+ if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue;
394
+ if (name.endsWith(".test.ts") || name.endsWith(".test.tsx")) continue;
395
+ files.push(`${pkg.name}/src/${name}`);
396
+ }
397
+ }
398
+ return files;
399
+ };
400
+
401
+ it("is composed in three places, and nowhere else in the repo", () => {
402
+ const referrers = sourceFiles().filter((file) =>
403
+ readFileSync(join(PACKAGES, file), "utf8").includes(
404
+ "composeFolderRoleAppointmentName",
405
+ ),
406
+ );
407
+ assert.deepEqual(referrers.sort(), [
408
+ "backend/src/handlers/folder-role-appointments.ts",
409
+ "data-ports/src/folder-role.ts",
410
+ "drizzle-service/src/repos/i4-mailbox-special-use.ts",
205
411
  ]);
206
412
  });
413
+
414
+ it("is written by writeFolderRoleAppointment alone", () => {
415
+ const source = readFileSync(
416
+ fileURLToPath(new URL("./folder-role-appointments.ts", import.meta.url)),
417
+ "utf8",
418
+ );
419
+ const writer = source.slice(
420
+ source.indexOf("export const writeFolderRoleAppointment"),
421
+ );
422
+ const body = writer.slice(0, writer.indexOf("\n};"));
423
+
424
+ const count = (text: string, needle: string) =>
425
+ text.split(needle).length - 1;
426
+ // Without these, renaming the receiver satisfies the guard with 0 === 0.
427
+ assert.ok(count(body, "accountSetting.upsert(") > 0);
428
+ assert.ok(count(body, "accountSetting.delete(") > 0);
429
+ assert.equal(
430
+ count(source, "accountSetting.upsert("),
431
+ count(body, "accountSetting.upsert("),
432
+ );
433
+ assert.equal(
434
+ count(source, "accountSetting.delete("),
435
+ count(body, "accountSetting.delete("),
436
+ );
437
+ });
207
438
  });
@@ -7,24 +7,40 @@ import type {
7
7
  import {
8
8
  CANONICAL_ROLES,
9
9
  type CanonicalMailboxRoleValue,
10
+ composeFolderRoleAppointmentLabelName,
10
11
  composeFolderRoleAppointmentName,
12
+ parseFolderRoleAppointmentLabelName,
11
13
  parseFolderRoleAppointmentName,
12
14
  type RoleMailboxCandidate,
13
- resolveMailboxForRole,
15
+ type RoleResolution,
16
+ resolveRoleForAccount,
14
17
  } from "@remit/data-ports/folder-role";
18
+ import { FolderAppointmentSource } from "@remit/domain-enums";
15
19
 
16
20
  /**
17
21
  * RFC 032 exclusive-folder-appointment (#976): a per-account role→mailbox map.
18
22
  * Each row is a `FolderRoleAppointment#<accountId>#<role>` AccountSetting (RFC
19
23
  * 032 settings tiers), so a role can never be persisted twice for one account —
20
24
  * writing it replaces whichever mailbox previously held it. This module owns
21
- * that persistence; the read side is `resolveMailboxForRole`
25
+ * that persistence; the read side is `resolveRoleForAccount`
22
26
  * (`@remit/data-ports/folder-role`), the same rule every special-folder lookup
23
27
  * in the backend and the workers goes through.
28
+ *
29
+ * A second row, `FolderRoleAppointmentLabel#<accountId>#<role>`, records the
30
+ * path the appointed mailbox had at the time (#887), so a folder another client
31
+ * later deletes can still be named back to the user. It is display only: it
32
+ * lives beside the appointment rather than inside it precisely so the
33
+ * repository that resolves roles composes one name and cannot reach the other.
24
34
  */
25
35
 
26
36
  export { CANONICAL_ROLES };
27
37
 
38
+ /** One role's persisted appointment: the choice, plus the path it was made on. */
39
+ export interface PersistedFolderAppointment {
40
+ mailboxId: string;
41
+ lastKnownPath?: string;
42
+ }
43
+
28
44
  const stringValueOf = (item: AccountSettingItem): string | undefined => {
29
45
  const { value } = item;
30
46
  return value.kind === "String" ? value.value : undefined;
@@ -37,17 +53,31 @@ const stringValueOf = (item: AccountSettingItem): string | undefined => {
37
53
  */
38
54
  export const groupFolderAppointmentsByAccount = (
39
55
  settings: AccountSettingItem[],
40
- ): Map<string, Map<string, string>> => {
41
- const byAccount = new Map<string, Map<string, string>>();
56
+ ): Map<string, Map<string, PersistedFolderAppointment>> => {
57
+ const byAccount = new Map<string, Map<string, PersistedFolderAppointment>>();
58
+
42
59
  for (const setting of settings) {
43
60
  const target = parseFolderRoleAppointmentName(setting.name);
44
61
  if (!target) continue;
45
62
  const mailboxId = stringValueOf(setting);
46
63
  if (mailboxId === undefined) continue;
47
- const roles = byAccount.get(target.accountId) ?? new Map<string, string>();
48
- roles.set(target.role, mailboxId);
64
+ const roles =
65
+ byAccount.get(target.accountId) ??
66
+ new Map<string, PersistedFolderAppointment>();
67
+ roles.set(target.role, { mailboxId });
49
68
  byAccount.set(target.accountId, roles);
50
69
  }
70
+
71
+ for (const setting of settings) {
72
+ const target = parseFolderRoleAppointmentLabelName(setting.name);
73
+ if (!target) continue;
74
+ const lastKnownPath = stringValueOf(setting);
75
+ if (lastKnownPath === undefined) continue;
76
+ const appointment = byAccount.get(target.accountId)?.get(target.role);
77
+ if (!appointment) continue;
78
+ appointment.lastKnownPath = lastKnownPath;
79
+ }
80
+
51
81
  return byAccount;
52
82
  };
53
83
 
@@ -60,19 +90,32 @@ export const loadFolderAppointmentsForAccount = async (
60
90
  accountSetting: Pick<IAccountSettingRepository, "get">,
61
91
  accountConfigId: string,
62
92
  accountId: string,
63
- ): Promise<Map<string, string>> => {
93
+ ): Promise<Map<string, PersistedFolderAppointment>> => {
64
94
  const entries = await Promise.all(
65
95
  CANONICAL_ROLES.map(async (role) => {
66
- const item = await accountSetting.get(
96
+ const appointment = await accountSetting.get(
67
97
  accountConfigId,
68
98
  composeFolderRoleAppointmentName(accountId, role),
69
99
  );
70
- return [role, item ? stringValueOf(item) : undefined] as const;
100
+ const mailboxId = appointment ? stringValueOf(appointment) : undefined;
101
+ if (mailboxId === undefined) return [role, undefined] as const;
102
+
103
+ const label = await accountSetting.get(
104
+ accountConfigId,
105
+ composeFolderRoleAppointmentLabelName(accountId, role),
106
+ );
107
+ const lastKnownPath = label ? stringValueOf(label) : undefined;
108
+ return [
109
+ role,
110
+ lastKnownPath === undefined
111
+ ? { mailboxId }
112
+ : { mailboxId, lastKnownPath },
113
+ ] as const;
71
114
  }),
72
115
  );
73
- const roles = new Map<string, string>();
74
- for (const [role, mailboxId] of entries) {
75
- if (mailboxId !== undefined) roles.set(role, mailboxId);
116
+ const roles = new Map<string, PersistedFolderAppointment>();
117
+ for (const [role, appointment] of entries) {
118
+ if (appointment) roles.set(role, appointment);
76
119
  }
77
120
  return roles;
78
121
  };
@@ -82,40 +125,101 @@ export const loadFolderAppointmentsForAccount = async (
82
125
  * — the role goes back to unfilled (RFC 032 settings tiers: absence is unset).
83
126
  * A value upserts it, replacing whatever mailbox the role pointed at before;
84
127
  * there is no second row a duplicate could live in.
128
+ *
129
+ * The label row moves with it, so a path recorded for a previous choice can
130
+ * never be shown against the current one.
85
131
  */
86
- export const writeFolderRoleAppointment = (
132
+ export const writeFolderRoleAppointment = async (
87
133
  accountSetting: Pick<IAccountSettingRepository, "upsert" | "delete">,
88
134
  accountConfigId: string,
89
135
  accountId: string,
90
136
  role: CanonicalMailboxRoleValue,
91
137
  mailboxId: string | null,
92
- ): Promise<unknown> => {
138
+ lastKnownPath?: string,
139
+ ): Promise<void> => {
93
140
  const name = composeFolderRoleAppointmentName(accountId, role);
141
+ const labelName = composeFolderRoleAppointmentLabelName(accountId, role);
142
+
94
143
  if (mailboxId === null) {
95
- return accountSetting.delete(accountConfigId, name);
144
+ await accountSetting.delete(accountConfigId, name);
145
+ await accountSetting.delete(accountConfigId, labelName);
146
+ return;
96
147
  }
97
- return accountSetting.upsert({
148
+
149
+ await accountSetting.upsert({
98
150
  accountConfigId,
99
151
  name,
100
152
  value: { kind: "String", value: mailboxId },
101
153
  });
154
+
155
+ if (lastKnownPath === undefined) {
156
+ await accountSetting.delete(accountConfigId, labelName);
157
+ return;
158
+ }
159
+ await accountSetting.upsert({
160
+ accountConfigId,
161
+ name: labelName,
162
+ value: { kind: "String", value: lastKnownPath },
163
+ });
164
+ };
165
+
166
+ const SOURCE_BY_KIND: Record<
167
+ "appointed" | "flagged" | "reserved" | "proposed",
168
+ FolderAppointment["source"]
169
+ > = {
170
+ appointed: FolderAppointmentSource.Appointed,
171
+ flagged: FolderAppointmentSource.Flagged,
172
+ reserved: FolderAppointmentSource.Reserved,
173
+ proposed: FolderAppointmentSource.Proposed,
174
+ };
175
+
176
+ const toFolderAppointment = (
177
+ role: CanonicalMailboxRoleValue,
178
+ resolution: RoleResolution<RoleMailboxCandidate>,
179
+ lastKnownPath: string | undefined,
180
+ ): FolderAppointment => {
181
+ if (resolution.kind === "appointment_stale") {
182
+ const { fallback } = resolution;
183
+ return {
184
+ role,
185
+ source: FolderAppointmentSource.Stale,
186
+ mailboxId:
187
+ fallback.kind === "none" ? undefined : fallback.mailbox.mailboxId,
188
+ staleAppointmentMailboxId: resolution.appointedMailboxId,
189
+ staleAppointmentPath: lastKnownPath,
190
+ };
191
+ }
192
+ if (resolution.kind === "none") {
193
+ return { role, source: FolderAppointmentSource.None };
194
+ }
195
+ return {
196
+ role,
197
+ source: SOURCE_BY_KIND[resolution.kind],
198
+ mailboxId: resolution.mailbox.mailboxId,
199
+ };
102
200
  };
103
201
 
104
202
  /**
105
- * Resolve the full appointment set for one account: the user's persisted
106
- * choice when set (and still a real mailbox a deleted mailbox's stale
107
- * appointment is treated as unfilled and re-proposed), else the server's
108
- * SPECIAL-USE flag, else a name proposal. Always returns one entry per
203
+ * Resolve the full appointment set for one account, each entry saying where it
204
+ * came from: the user's persisted choice when set, else the server's
205
+ * SPECIAL-USE flag, else the reserved INBOX name, else a name proposal. A
206
+ * choice naming a mailbox the account no longer holds resolves to whatever the
207
+ * account would have had without it, and says so — the user's decision is
208
+ * surfaced as broken rather than quietly discarded. Always one entry per
109
209
  * `CANONICAL_ROLES` member (RFC 032 settings tiers: total, never a sparse
110
- * array), so the map is never empty for a normal provider.
210
+ * array).
111
211
  */
112
212
  export const resolveFolderAppointments = (
113
- persisted: ReadonlyMap<string, string>,
213
+ persisted: ReadonlyMap<string, PersistedFolderAppointment>,
114
214
  mailboxes: readonly RoleMailboxCandidate[],
115
215
  ): FolderAppointment[] =>
116
216
  CANONICAL_ROLES.map((role) => {
117
- const found = resolveMailboxForRole(role, mailboxes, persisted.get(role));
118
- return found ? { role, mailboxId: found.mailboxId } : { role };
217
+ const appointment = persisted.get(role);
218
+ return toFolderAppointment(
219
+ role,
220
+ resolveRoleForAccount(role, mailboxes, appointment?.mailboxId),
221
+ appointment?.lastKnownPath,
222
+ );
119
223
  });
120
224
 
121
225
  /**
@@ -43,9 +43,11 @@ export const FolderRoleOperations: Record<
43
43
  const existing = await account.get(accountId);
44
44
  assertAccountOwnership(existing, accountConfigId, "act");
45
45
 
46
+ let lastKnownPath: string | undefined;
46
47
  if (body.mailboxId) {
47
48
  const target = await mailbox.get(accountId, body.mailboxId);
48
49
  assertMailboxInAccount(target, accountId, "act");
50
+ lastKnownPath = target.fullPath;
49
51
  }
50
52
 
51
53
  await writeFolderRoleAppointment(
@@ -54,6 +56,7 @@ export const FolderRoleOperations: Record<
54
56
  accountId,
55
57
  role,
56
58
  body.mailboxId ?? null,
59
+ lastKnownPath,
57
60
  );
58
61
 
59
62
  const [signature, overrides, folderAppointments] = await Promise.all([
@@ -1,8 +1,17 @@
1
1
  import assert from "node:assert/strict";
2
- import { describe, it } from "node:test";
2
+ import { afterEach, describe, it } from "node:test";
3
3
  import type { MailboxItem } from "@remit/data-ports";
4
4
  import { MailboxSyncStatus } from "@remit/domain-enums";
5
- import { excludeDeletingMailboxes } from "./mailbox.js";
5
+ import { UnconfirmedTrashMailboxError } from "@remit/mailbox-service";
6
+ import type { APIGatewayProxyEvent } from "aws-lambda";
7
+ import type { Context } from "openapi-backend";
8
+ import { deriveAccountConfigId } from "../auth.js";
9
+ import {
10
+ _resetForTest,
11
+ type RemitClient,
12
+ setClient,
13
+ } from "../service/data-client.js";
14
+ import { excludeDeletingMailboxes, TrashOperations } from "./mailbox.js";
6
15
 
7
16
  const mailbox = (
8
17
  over: Partial<MailboxItem> & { mailboxId: string },
@@ -32,3 +41,58 @@ describe("excludeDeletingMailboxes", () => {
32
41
  );
33
42
  });
34
43
  });
44
+
45
+ const SUB = "cognito-sub-887";
46
+ const ACCOUNT_ID = "acc-887";
47
+
48
+ const emptyTrash = TrashOperations.TrashOperations_emptyTrash as unknown as (
49
+ context: Context,
50
+ event: APIGatewayProxyEvent,
51
+ ) => Promise<{ deletedCount: number }>;
52
+
53
+ const callEmptyTrash = (
54
+ serviceEmptyTrash: () => Promise<{ deletedCount: number }>,
55
+ ): Promise<{ deletedCount: number }> => {
56
+ setClient({
57
+ account: {
58
+ get: async () => ({
59
+ accountId: ACCOUNT_ID,
60
+ accountConfigId: deriveAccountConfigId(SUB),
61
+ }),
62
+ },
63
+ messageMove: { emptyTrash: serviceEmptyTrash },
64
+ } as unknown as RemitClient);
65
+
66
+ return emptyTrash(
67
+ { request: { params: { accountId: ACCOUNT_ID } } } as unknown as Context,
68
+ {
69
+ requestContext: { authorizer: { claims: { sub: SUB } } },
70
+ } as unknown as APIGatewayProxyEvent,
71
+ );
72
+ };
73
+
74
+ describe("TrashOperations_emptyTrash", () => {
75
+ afterEach(() => {
76
+ _resetForTest();
77
+ });
78
+
79
+ it("reports the service's count rather than one of its own", async () => {
80
+ // The handler resolves no folder and counts no rows: a second read would
81
+ // be free to disagree with the one that actually marked them.
82
+ const response = await callEmptyTrash(async () => ({ deletedCount: 7 }));
83
+
84
+ assert.deepEqual(response, { deletedCount: 7 });
85
+ });
86
+
87
+ it("lets the service's coded refusal through untouched", async () => {
88
+ await assert.rejects(
89
+ callEmptyTrash(async () => {
90
+ throw new UnconfirmedTrashMailboxError(ACCOUNT_ID);
91
+ }),
92
+ (error: unknown) =>
93
+ error instanceof UnconfirmedTrashMailboxError &&
94
+ error.statusCode === 409 &&
95
+ error.publicApiError?.details?.reason === "unconfirmed",
96
+ );
97
+ });
98
+ });
@@ -5,7 +5,6 @@ import type {
5
5
  import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
6
6
  import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
7
7
  import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
8
- import { NoTrashMailboxError } from "@remit/mailbox-service";
9
8
  import type { APIGatewayProxyEvent } from "aws-lambda";
10
9
  import { getAccountConfigIdFromEvent } from "../auth.js";
11
10
  import {
@@ -412,26 +411,10 @@ export const TrashOperations: Record<
412
411
  const account = await client.account.get(accountId);
413
412
  assertAccountOwnership(account, accountConfigId, "act");
414
413
 
415
- // The same confirmed resolution `emptyTrash` expunges through, so the
416
- // count reported is the count of the folder that gets emptied. It
417
- // refuses rather than returning zero when no folder is appointed and
418
- // none is flagged: "0 deleted" reads as success, and the user goes on
419
- // believing their Trash is empty.
420
- const trashMailbox =
421
- await client.mailboxSpecialUse.findConfirmedTrashMailbox(accountId);
422
-
423
- if (!trashMailbox) {
424
- throw new NoTrashMailboxError(accountId);
425
- }
426
-
427
- const messages = await client.message.listAllByMailbox(
428
- trashMailbox.mailboxId,
429
- );
430
- const deletedCount = messages.length;
431
-
432
- // MessageMoveService handles: Message status updates + SQS event
433
- await client.messageMove.emptyTrash(accountConfigId, accountId);
434
-
435
- return { deletedCount };
414
+ // The service resolves Trash, marks the rows and counts them, all off one
415
+ // read, and refuses with a coded 409 when the role is unresolved. Counting
416
+ // here as well would be a second answer free to disagree with the one that
417
+ // acts, and "0 deleted" reads as success to a user whose Trash is untouched.
418
+ return client.messageMove.emptyTrash(accountConfigId, accountId);
436
419
  },
437
420
  };