@remit/backend 0.0.73 → 0.0.74
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,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 {
|
|
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("
|
|
67
|
-
const
|
|
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
|
|
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("
|
|
74
|
-
const
|
|
75
|
-
|
|
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
|
|
78
|
-
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
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("
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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(
|
|
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
|
-
|
|
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 `
|
|
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,
|
|
41
|
-
const byAccount = new Map<string, Map<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 =
|
|
48
|
-
|
|
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,
|
|
93
|
+
): Promise<Map<string, PersistedFolderAppointment>> => {
|
|
64
94
|
const entries = await Promise.all(
|
|
65
95
|
CANONICAL_ROLES.map(async (role) => {
|
|
66
|
-
const
|
|
96
|
+
const appointment = await accountSetting.get(
|
|
67
97
|
accountConfigId,
|
|
68
98
|
composeFolderRoleAppointmentName(accountId, role),
|
|
69
99
|
);
|
|
70
|
-
|
|
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,
|
|
74
|
-
for (const [role,
|
|
75
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
await accountSetting.delete(accountConfigId, name);
|
|
145
|
+
await accountSetting.delete(accountConfigId, labelName);
|
|
146
|
+
return;
|
|
96
147
|
}
|
|
97
|
-
|
|
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
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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)
|
|
210
|
+
* array).
|
|
111
211
|
*/
|
|
112
212
|
export const resolveFolderAppointments = (
|
|
113
|
-
persisted: ReadonlyMap<string,
|
|
213
|
+
persisted: ReadonlyMap<string, PersistedFolderAppointment>,
|
|
114
214
|
mailboxes: readonly RoleMailboxCandidate[],
|
|
115
215
|
): FolderAppointment[] =>
|
|
116
216
|
CANONICAL_ROLES.map((role) => {
|
|
117
|
-
const
|
|
118
|
-
return
|
|
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([
|