@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 +5 -1
- package/src/handlers/filter.ts +24 -0
- package/src/handlers/folder-role-labels.ts +63 -0
- package/src/handlers/folder-role.test.ts +100 -75
- package/src/handlers/folder-role.ts +1 -28
- package/src/handlers/mailbox-bindings.ts +111 -0
- package/src/handlers/mailbox-rename-refusal.test.ts +247 -0
- package/src/handlers/mailbox-settled-target.test.ts +239 -0
- package/src/handlers/mailbox.test.ts +164 -30
- package/src/handlers/mailbox.ts +163 -84
- package/src/handlers/message.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/backend",
|
|
3
|
-
"version": "0.0.
|
|
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"
|
package/src/handlers/filter.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
import {
|
|
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("
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
93
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
|
|
66
94
|
);
|
|
67
|
-
assert.doesNotThrow(() => assertMailboxSettled(mailbox({})));
|
|
68
95
|
});
|
|
69
96
|
});
|
|
70
97
|
|
|
71
|
-
describe("
|
|
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
|
-
|
|
86
|
-
|
|
112
|
+
): {
|
|
113
|
+
store: Record<string, string>;
|
|
114
|
+
accountSetting: Parameters<typeof refreshFolderAppointmentLabels>[0];
|
|
115
|
+
} => {
|
|
87
116
|
const store: Record<string, string> = { ...rows };
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
133
|
-
//
|
|
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,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
146
|
-
|
|
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,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
},
|
|
159
|
-
"INBOX",
|
|
160
|
-
);
|
|
181
|
+
const { store, accountSetting } = settingsFor({
|
|
182
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
183
|
+
[labelName]: "Archief/Prullenbak",
|
|
184
|
+
});
|
|
161
185
|
|
|
162
|
-
await
|
|
163
|
-
|
|
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,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
},
|
|
175
|
-
"INBOX",
|
|
176
|
-
);
|
|
196
|
+
const { store, accountSetting } = settingsFor({
|
|
197
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
198
|
+
[labelName]: "INBOXES/Prullenbak",
|
|
199
|
+
});
|
|
177
200
|
|
|
178
|
-
await
|
|
179
|
-
|
|
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
|
+
};
|