@remit/backend 0.0.119 → 0.0.120
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.
|
|
3
|
+
"version": "0.0.120",
|
|
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"
|
|
@@ -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
|
+
};
|
|
@@ -5,7 +5,7 @@ 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
7
|
import { assertMailboxSettled } from "./folder-role.js";
|
|
8
|
-
import {
|
|
8
|
+
import { refreshFolderAppointmentLabels } from "./folder-role-labels.js";
|
|
9
9
|
|
|
10
10
|
const mailbox = (over: Partial<MailboxItem>): MailboxItem =>
|
|
11
11
|
({
|
|
@@ -68,7 +68,7 @@ describe("assertMailboxSettled", () => {
|
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
describe("
|
|
71
|
+
describe("refreshFolderAppointmentLabels — the label follows a settled rename", () => {
|
|
72
72
|
const labelName = composeFolderRoleAppointmentLabelName(
|
|
73
73
|
"acc-1",
|
|
74
74
|
CanonicalMailboxRole.Trash,
|
|
@@ -82,68 +82,68 @@ describe("applyMailboxPatch — the appointment label follows a rename", () => {
|
|
|
82
82
|
|
|
83
83
|
const settingsFor = (
|
|
84
84
|
rows: Record<string, string>,
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
): {
|
|
86
|
+
store: Record<string, string>;
|
|
87
|
+
accountSetting: Parameters<typeof refreshFolderAppointmentLabels>[0];
|
|
88
|
+
} => {
|
|
87
89
|
const store: Record<string, string> = { ...rows };
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
const accountSetting = {
|
|
91
|
+
get: async (_configId: string, name: string) =>
|
|
92
|
+
store[name] === undefined
|
|
93
|
+
? undefined
|
|
94
|
+
: ({
|
|
95
|
+
name,
|
|
96
|
+
value: { kind: "String", value: store[name] },
|
|
97
|
+
} as AccountSettingItem),
|
|
98
|
+
upsert: async (item: AccountSettingItem) => {
|
|
99
|
+
if (item.value.kind === "String") store[item.name] = item.value.value;
|
|
100
|
+
return item;
|
|
91
101
|
},
|
|
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 };
|
|
102
|
+
} as unknown as Parameters<typeof refreshFolderAppointmentLabels>[0];
|
|
103
|
+
return { store, accountSetting };
|
|
114
104
|
};
|
|
115
105
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"
|
|
106
|
+
const settle = (
|
|
107
|
+
accountSetting: Parameters<typeof refreshFolderAppointmentLabels>[0],
|
|
108
|
+
renamed: { mailboxId: string; oldPath: string; newPath: string },
|
|
109
|
+
) =>
|
|
110
|
+
refreshFolderAppointmentLabels(
|
|
111
|
+
accountSetting,
|
|
112
|
+
"cfg-1",
|
|
113
|
+
"acc-1",
|
|
114
|
+
renamed,
|
|
115
|
+
"/",
|
|
123
116
|
);
|
|
124
117
|
|
|
125
|
-
|
|
126
|
-
|
|
118
|
+
it("rewrites the recorded path for the folder that was renamed", async () => {
|
|
119
|
+
const { store, accountSetting } = settingsFor({
|
|
120
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-1",
|
|
121
|
+
[labelName]: "INBOX/Prullenbak",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await settle(accountSetting, {
|
|
125
|
+
mailboxId: "mb-1",
|
|
126
|
+
oldPath: "INBOX/Prullenbak",
|
|
127
|
+
newPath: "INBOX/Verwijderd",
|
|
127
128
|
});
|
|
128
129
|
|
|
129
130
|
assert.equal(store[labelName], "INBOX/Verwijderd");
|
|
130
131
|
});
|
|
131
132
|
|
|
132
|
-
// IMAP RENAME moves the subtree in one command
|
|
133
|
-
//
|
|
133
|
+
// IMAP RENAME moves the subtree in one command, so every label under the
|
|
134
|
+
// branch moves with it.
|
|
134
135
|
it("carries every appointed folder under the renamed branch with it", async () => {
|
|
135
|
-
const { store,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
},
|
|
142
|
-
"INBOX",
|
|
143
|
-
);
|
|
136
|
+
const { store, accountSetting } = settingsFor({
|
|
137
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
138
|
+
[labelName]: "INBOX/Prullenbak",
|
|
139
|
+
[appointmentName(CanonicalMailboxRole.Sent)]: "mb-sent",
|
|
140
|
+
[sentLabelName]: "INBOX/Verzonden",
|
|
141
|
+
});
|
|
144
142
|
|
|
145
|
-
await
|
|
146
|
-
|
|
143
|
+
await settle(accountSetting, {
|
|
144
|
+
mailboxId: "mb-1",
|
|
145
|
+
oldPath: "INBOX",
|
|
146
|
+
newPath: "Mail",
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
assert.equal(store[labelName], "Mail/Prullenbak");
|
|
@@ -151,32 +151,30 @@ describe("applyMailboxPatch — the appointment label follows a rename", () => {
|
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
it("leaves a folder outside the renamed branch alone", async () => {
|
|
154
|
-
const { store,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
},
|
|
159
|
-
"INBOX",
|
|
160
|
-
);
|
|
154
|
+
const { store, accountSetting } = settingsFor({
|
|
155
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
156
|
+
[labelName]: "Archief/Prullenbak",
|
|
157
|
+
});
|
|
161
158
|
|
|
162
|
-
await
|
|
163
|
-
|
|
159
|
+
await settle(accountSetting, {
|
|
160
|
+
mailboxId: "mb-1",
|
|
161
|
+
oldPath: "INBOX",
|
|
162
|
+
newPath: "Mail",
|
|
164
163
|
});
|
|
165
164
|
|
|
166
165
|
assert.equal(store[labelName], "Archief/Prullenbak");
|
|
167
166
|
});
|
|
168
167
|
|
|
169
168
|
it("never rebases a sibling that merely shares the prefix", async () => {
|
|
170
|
-
const { store,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
},
|
|
175
|
-
"INBOX",
|
|
176
|
-
);
|
|
169
|
+
const { store, accountSetting } = settingsFor({
|
|
170
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
171
|
+
[labelName]: "INBOXES/Prullenbak",
|
|
172
|
+
});
|
|
177
173
|
|
|
178
|
-
await
|
|
179
|
-
|
|
174
|
+
await settle(accountSetting, {
|
|
175
|
+
mailboxId: "mb-1",
|
|
176
|
+
oldPath: "INBOX",
|
|
177
|
+
newPath: "Mail",
|
|
180
178
|
});
|
|
181
179
|
|
|
182
180
|
assert.equal(store[labelName], "INBOXES/Prullenbak");
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
|
+
import { applyMailboxPatch, type MailboxPatchClient } from "./mailbox.js";
|
|
6
|
+
|
|
7
|
+
const ACCOUNT_ID = "acc-1";
|
|
8
|
+
const CONFIG_ID = "cfg-1";
|
|
9
|
+
|
|
10
|
+
const mailbox = (over: Partial<MailboxItem> = {}): MailboxItem =>
|
|
11
|
+
({
|
|
12
|
+
mailboxId: "mbx-1",
|
|
13
|
+
accountId: ACCOUNT_ID,
|
|
14
|
+
fullPath: "Work",
|
|
15
|
+
hierarchyDelimiter: "/",
|
|
16
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
17
|
+
...over,
|
|
18
|
+
}) as MailboxItem;
|
|
19
|
+
|
|
20
|
+
const clientOver = (
|
|
21
|
+
row: MailboxItem,
|
|
22
|
+
others: MailboxItem[] = [],
|
|
23
|
+
): {
|
|
24
|
+
client: MailboxPatchClient;
|
|
25
|
+
renames: { mailboxId: string; newPath: string }[];
|
|
26
|
+
dismissals: string[];
|
|
27
|
+
settings: string[];
|
|
28
|
+
} => {
|
|
29
|
+
const renames: { mailboxId: string; newPath: string }[] = [];
|
|
30
|
+
const dismissals: string[] = [];
|
|
31
|
+
const settings: string[] = [];
|
|
32
|
+
const client: MailboxPatchClient = {
|
|
33
|
+
mailbox: {
|
|
34
|
+
get: async () => row,
|
|
35
|
+
listAllByAccount: async () => [row, ...others],
|
|
36
|
+
},
|
|
37
|
+
mailboxQueue: {
|
|
38
|
+
renameMailbox: async (mailboxId, newPath) => {
|
|
39
|
+
renames.push({ mailboxId, newPath });
|
|
40
|
+
return mailbox({ pendingPath: newPath });
|
|
41
|
+
},
|
|
42
|
+
dismissMailboxIntent: async (mailboxId) => {
|
|
43
|
+
dismissals.push(mailboxId);
|
|
44
|
+
return mailbox();
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
accountSetting: {
|
|
48
|
+
get: async () => null,
|
|
49
|
+
upsert: async (item) => {
|
|
50
|
+
settings.push(item.name);
|
|
51
|
+
return item as never;
|
|
52
|
+
},
|
|
53
|
+
delete: async (_configId, name) => {
|
|
54
|
+
settings.push(name);
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return { client, renames, dismissals, settings };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const patch = (
|
|
62
|
+
client: MailboxPatchClient,
|
|
63
|
+
body: Record<string, unknown>,
|
|
64
|
+
): Promise<MailboxItem> =>
|
|
65
|
+
applyMailboxPatch(client, CONFIG_ID, "mbx-1", ACCOUNT_ID, body);
|
|
66
|
+
|
|
67
|
+
const refusedWith = (message: string) => (error: unknown) =>
|
|
68
|
+
(error as { statusCode?: number }).statusCode === 400 &&
|
|
69
|
+
(error as Error).message === message;
|
|
70
|
+
|
|
71
|
+
describe("applyMailboxPatch — renames that can never succeed (D4, D5)", () => {
|
|
72
|
+
it("refuses to rename the inbox", async () => {
|
|
73
|
+
const { client, renames } = clientOver(mailbox({ fullPath: "INBOX" }));
|
|
74
|
+
|
|
75
|
+
await assert.rejects(
|
|
76
|
+
patch(client, { fullPath: "Postvak" }),
|
|
77
|
+
refusedWith("The inbox can't be renamed."),
|
|
78
|
+
);
|
|
79
|
+
assert.equal(renames.length, 0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("refuses a target that canonicalizes to the inbox", async () => {
|
|
83
|
+
const { client, renames } = clientOver(mailbox());
|
|
84
|
+
|
|
85
|
+
await assert.rejects(
|
|
86
|
+
patch(client, { fullPath: "inbox" }),
|
|
87
|
+
refusedWith("“INBOX” is reserved for the inbox."),
|
|
88
|
+
);
|
|
89
|
+
assert.equal(renames.length, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("refuses an empty target", async () => {
|
|
93
|
+
const { client, renames } = clientOver(mailbox());
|
|
94
|
+
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
patch(client, { fullPath: " " }),
|
|
97
|
+
refusedWith("A folder needs a name."),
|
|
98
|
+
);
|
|
99
|
+
assert.equal(renames.length, 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("refuses a target another folder already holds", async () => {
|
|
103
|
+
const { client, renames } = clientOver(mailbox(), [
|
|
104
|
+
mailbox({ mailboxId: "mbx-2", fullPath: "Projects" }),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
await assert.rejects(
|
|
108
|
+
patch(client, { fullPath: "Projects" }),
|
|
109
|
+
refusedWith("A folder named “Projects” is already there."),
|
|
110
|
+
);
|
|
111
|
+
assert.equal(renames.length, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The sweep reads a folder whose leaf name is a role's conventional name but
|
|
116
|
+
* which lacks the server's flag as a duplicate, and deletes its row — under
|
|
117
|
+
* D8, with the folder's mail — as soon as another folder holds the flag. A
|
|
118
|
+
* rename to one of those settles `synced` and is then reaped.
|
|
119
|
+
*/
|
|
120
|
+
for (const [name, leaf] of [
|
|
121
|
+
["Archive", "Archive"],
|
|
122
|
+
["Trash", "Trash"],
|
|
123
|
+
["Drafts", "Drafts"],
|
|
124
|
+
["Sent", "Sent"],
|
|
125
|
+
["Junk", "Spam"],
|
|
126
|
+
] as const) {
|
|
127
|
+
it(`refuses a rename to the reserved ${name} name`, async () => {
|
|
128
|
+
const { client, renames } = clientOver(mailbox());
|
|
129
|
+
|
|
130
|
+
await assert.rejects(
|
|
131
|
+
patch(client, { fullPath: `Work/${leaf}` }),
|
|
132
|
+
refusedWith(
|
|
133
|
+
`“${leaf}” is reserved for a system folder. Pick another name.`,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
assert.equal(renames.length, 0);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
it("refuses a target another rename is already on its way to", async () => {
|
|
141
|
+
// A path a rename is aiming at is as taken as one a folder sits at (D2).
|
|
142
|
+
// Two renames onto one target both settle, both write it, and the sweep
|
|
143
|
+
// then reaps one row — with its mail — and inserts a duplicate.
|
|
144
|
+
const { client, renames } = clientOver(mailbox(), [
|
|
145
|
+
mailbox({
|
|
146
|
+
mailboxId: "mbx-2",
|
|
147
|
+
fullPath: "Admin",
|
|
148
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
149
|
+
pendingPath: "Projects",
|
|
150
|
+
}),
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
patch(client, { fullPath: "Projects" }),
|
|
155
|
+
refusedWith("“Admin” is already being renamed to “Projects”."),
|
|
156
|
+
);
|
|
157
|
+
assert.equal(renames.length, 0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("lets a failed rename's stale target go to somebody else", async () => {
|
|
161
|
+
// `failed` keeps its target so the client can name what the rename was
|
|
162
|
+
// aiming at and offer a retry (T6), and nothing clears it until the user
|
|
163
|
+
// retries or dismisses. Honouring that claim would tell everyone else the
|
|
164
|
+
// path is taken by a rename that is not happening, with nothing to wait
|
|
165
|
+
// for and no way to find out.
|
|
166
|
+
const { client, renames } = clientOver(mailbox(), [
|
|
167
|
+
mailbox({
|
|
168
|
+
mailboxId: "mbx-2",
|
|
169
|
+
fullPath: "Admin",
|
|
170
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
171
|
+
pendingPath: "Projects",
|
|
172
|
+
}),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
await patch(client, { fullPath: "Projects" });
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(renames, [{ mailboxId: "mbx-1", newPath: "Projects" }]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("lets a retry aim at the target its own failed rename recorded", async () => {
|
|
181
|
+
const { client, renames } = clientOver(
|
|
182
|
+
mailbox({
|
|
183
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
184
|
+
pendingPath: "Projects",
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await patch(client, { fullPath: "Projects" });
|
|
189
|
+
|
|
190
|
+
assert.deepEqual(renames, [{ mailboxId: "mbx-1", newPath: "Projects" }]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("records the intent for an ordinary target", async () => {
|
|
194
|
+
const { client, renames } = clientOver(mailbox());
|
|
195
|
+
|
|
196
|
+
const renamed = await patch(client, { fullPath: "Projects" });
|
|
197
|
+
|
|
198
|
+
assert.deepEqual(renames, [{ mailboxId: "mbx-1", newPath: "Projects" }]);
|
|
199
|
+
assert.equal(renamed.pendingPath, "Projects");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("applyMailboxPatch — dismissing a failed intent (T10, T11)", () => {
|
|
204
|
+
it("reads a target equal to the confirmed path as a dismissal, not a rename", async () => {
|
|
205
|
+
const { client, renames, dismissals } = clientOver(
|
|
206
|
+
mailbox({
|
|
207
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
208
|
+
pendingPath: "Projects",
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const dismissed = await patch(client, { fullPath: "Work" });
|
|
213
|
+
|
|
214
|
+
assert.deepEqual(dismissals, ["mbx-1"]);
|
|
215
|
+
assert.equal(renames.length, 0, "nothing is enqueued for a dismissal");
|
|
216
|
+
assert.equal(dismissed.syncStatus, MailboxSyncStatus.synced);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("dismisses even where the folder's own name is reserved", async () => {
|
|
220
|
+
// The refusals gate a rename's target. Keeping the name a folder already
|
|
221
|
+
// has is not one, and a folder called `Archive` must still be able to
|
|
222
|
+
// dismiss a rename that failed.
|
|
223
|
+
const { client, dismissals } = clientOver(
|
|
224
|
+
mailbox({ fullPath: "Archive", syncStatus: MailboxSyncStatus.failed }),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
await patch(client, { fullPath: "Archive" });
|
|
228
|
+
|
|
229
|
+
assert.deepEqual(dismissals, ["mbx-1"]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe("applyMailboxPatch — an override-only PATCH", () => {
|
|
234
|
+
it("touches no mutation state", async () => {
|
|
235
|
+
const { client, renames, dismissals, settings } = clientOver(mailbox());
|
|
236
|
+
|
|
237
|
+
await patch(client, { muted: true });
|
|
238
|
+
|
|
239
|
+
assert.equal(renames.length, 0);
|
|
240
|
+
assert.equal(dismissals.length, 0);
|
|
241
|
+
assert.equal(
|
|
242
|
+
settings.length > 0,
|
|
243
|
+
true,
|
|
244
|
+
"the override row is still written",
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -3,12 +3,13 @@ import type {
|
|
|
3
3
|
RenameMailboxInput,
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
5
|
import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
|
|
6
|
-
import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
|
|
7
6
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ForbiddenError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
} from "@remit/data-ports/errors";
|
|
11
|
+
import { isReservedFolderName } from "@remit/data-ports/folder-role";
|
|
12
|
+
import { mailboxLeafName } from "@remit/data-ports/mailbox-name";
|
|
12
13
|
import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
|
|
13
14
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
14
15
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
@@ -30,7 +31,6 @@ import {
|
|
|
30
31
|
type MailboxOverrides,
|
|
31
32
|
} from "./account-overrides.js";
|
|
32
33
|
import { assertAccountOwnership } from "./account-ownership.js";
|
|
33
|
-
import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* The mute flag and the display-name override are user preferences that live
|
|
@@ -66,6 +66,7 @@ export const pickMailboxOverrideChanges = (
|
|
|
66
66
|
export interface MailboxPatchClient {
|
|
67
67
|
mailbox: {
|
|
68
68
|
get(accountId: string, mailboxId: string): Promise<MailboxItem>;
|
|
69
|
+
listAllByAccount(accountId: string): Promise<MailboxItem[]>;
|
|
69
70
|
};
|
|
70
71
|
mailboxQueue: {
|
|
71
72
|
renameMailbox(
|
|
@@ -73,53 +74,80 @@ export interface MailboxPatchClient {
|
|
|
73
74
|
newPath: string,
|
|
74
75
|
accountId: string,
|
|
75
76
|
): Promise<MailboxItem>;
|
|
77
|
+
dismissMailboxIntent(
|
|
78
|
+
mailboxId: string,
|
|
79
|
+
accountId: string,
|
|
80
|
+
): Promise<MailboxItem>;
|
|
76
81
|
};
|
|
77
82
|
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
/**
|
|
81
|
-
*
|
|
82
|
-
* but the paths recorded beside them (#887) would still name where the folders
|
|
83
|
-
* were before. Move the labels with the branch, or a later third-party delete
|
|
84
|
-
* names a path the user has not seen since the rename.
|
|
86
|
+
* Refuse a rename that no retry of the same request could ever satisfy (D4).
|
|
85
87
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
88
|
+
* `INBOX` is refused at both ends: renaming it moves its mail to the new name
|
|
89
|
+
* and leaves an empty INBOX behind, and renaming anything *to* it collides with
|
|
90
|
+
* the one name RFC 3501 reserves (D5).
|
|
91
|
+
*
|
|
92
|
+
* A reserved leaf name is refused for a different reason. The mailbox sweep
|
|
93
|
+
* reads a folder whose leaf name is a role's conventional name but which lacks
|
|
94
|
+
* the server's flag as a duplicate, and deletes its row — under D8, with the
|
|
95
|
+
* folder's mail — as soon as another folder holds the flag. A rename to such a
|
|
96
|
+
* name settles `synced` and is then reaped. Refusing at the API is the same
|
|
97
|
+
* class of check as the rest of D4 and does not require teaching that heuristic
|
|
98
|
+
* about renames.
|
|
89
99
|
*/
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
accountConfigId: string,
|
|
100
|
+
const assertRenameTargetAllowed = async (
|
|
101
|
+
client: Pick<MailboxPatchClient, "mailbox">,
|
|
93
102
|
accountId: string,
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
before: MailboxItem,
|
|
104
|
+
target: string,
|
|
96
105
|
): Promise<void> => {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
106
|
+
if (before.fullPath.toUpperCase() === "INBOX") {
|
|
107
|
+
throw new BadRequestError("The inbox can't be renamed.");
|
|
108
|
+
}
|
|
109
|
+
if (target.trim().length === 0) {
|
|
110
|
+
throw new BadRequestError("A folder needs a name.");
|
|
111
|
+
}
|
|
112
|
+
if (target.toUpperCase() === "INBOX") {
|
|
113
|
+
throw new BadRequestError("“INBOX” is reserved for the inbox.");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const leaf = mailboxLeafName({
|
|
117
|
+
fullPath: target,
|
|
118
|
+
hierarchyDelimiter: before.hierarchyDelimiter,
|
|
119
|
+
});
|
|
120
|
+
if (isReservedFolderName(leaf)) {
|
|
121
|
+
throw new BadRequestError(
|
|
122
|
+
`“${leaf}” is reserved for a system folder. Pick another name.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A path a rename is on its way to is as taken as one a folder already sits
|
|
127
|
+
// at (D2). Reading `fullPath` alone accepts a second rename onto a target
|
|
128
|
+
// another is already recorded for: both settle, both write the same path,
|
|
129
|
+
// and the sweep then reaps one row — with its mail — and inserts a duplicate
|
|
130
|
+
// for the survivor's path. The account's folders are few and the sweep
|
|
131
|
+
// already reads them all, so one pass answers both questions.
|
|
132
|
+
const folders = await client.mailbox.listAllByAccount(accountId);
|
|
133
|
+
for (const folder of folders) {
|
|
134
|
+
if (folder.mailboxId === before.mailboxId) continue;
|
|
135
|
+
if (folder.fullPath === target) {
|
|
136
|
+
throw new BadRequestError(`A folder named “${target}” is already there.`);
|
|
137
|
+
}
|
|
138
|
+
// `pending` only. A failed rename keeps its target so the client can name
|
|
139
|
+
// what it was aiming at and offer a retry (T6), and nothing clears it
|
|
140
|
+
// until the user retries or dismisses — so honouring a `failed` row's
|
|
141
|
+
// claim would tell everyone else the path is taken by a rename that is
|
|
142
|
+
// not happening, with no way to find that out and nothing to wait for.
|
|
143
|
+
if (
|
|
144
|
+
folder.syncStatus === MailboxSyncStatus.pending &&
|
|
145
|
+
folder.pendingPath === target
|
|
146
|
+
) {
|
|
147
|
+
throw new BadRequestError(
|
|
148
|
+
`“${folder.fullPath}” is already being renamed to “${target}”.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
123
151
|
}
|
|
124
152
|
};
|
|
125
153
|
|
|
@@ -130,6 +158,11 @@ const refreshAppointmentLabels = async (
|
|
|
130
158
|
* (the recorded subtree intent + MAILBOX_RENAME event) — only when `fullPath`
|
|
131
159
|
* is present. An override-only PATCH therefore never calls
|
|
132
160
|
* `mailboxQueue.renameMailbox`.
|
|
161
|
+
*
|
|
162
|
+
* A `fullPath` equal to the row's confirmed one is not a rename: it is the
|
|
163
|
+
* dismissal of a failed intent (T10, T11), which is how a user keeps the name
|
|
164
|
+
* the folder already has without new API surface. It is checked first, because
|
|
165
|
+
* from `failed` it is the one route out that enqueues nothing.
|
|
133
166
|
*/
|
|
134
167
|
export const applyMailboxPatch = async (
|
|
135
168
|
client: MailboxPatchClient,
|
|
@@ -161,26 +194,17 @@ export const applyMailboxPatch = async (
|
|
|
161
194
|
return client.mailbox.get(accountId, mailboxId);
|
|
162
195
|
}
|
|
163
196
|
|
|
164
|
-
// Read before the rename: the labels of the folders under this one are
|
|
165
|
-
// rebased off the path it is leaving, which the row no longer carries after.
|
|
166
197
|
const before = await client.mailbox.get(accountId, mailboxId);
|
|
167
|
-
|
|
168
|
-
mailboxId,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
mailboxId,
|
|
178
|
-
oldPath: before.fullPath,
|
|
179
|
-
newPath: renamed.fullPath,
|
|
180
|
-
},
|
|
181
|
-
before.hierarchyDelimiter,
|
|
182
|
-
);
|
|
183
|
-
return renamed;
|
|
198
|
+
if (fullPath === before.fullPath) {
|
|
199
|
+
return client.mailboxQueue.dismissMailboxIntent(mailboxId, accountId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await assertRenameTargetAllowed(client, accountId, before, fullPath);
|
|
203
|
+
|
|
204
|
+
// The row keeps the path the server holds until the rename settles (D2), so
|
|
205
|
+
// nothing that names a folder by path moves here — the appointment labels
|
|
206
|
+
// recorded beside each role (#887) included. They move with the settle.
|
|
207
|
+
return client.mailboxQueue.renameMailbox(mailboxId, fullPath, accountId);
|
|
184
208
|
};
|
|
185
209
|
|
|
186
210
|
/**
|