@remit/backend 0.0.74 → 0.0.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { AccountSettingItem, MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { isPublicApiError } from "@remit/data-ports/errors";
|
|
5
|
+
import { composeFolderRoleAppointmentLabelName } from "@remit/data-ports/folder-role";
|
|
6
|
+
import { CanonicalMailboxRole, MailboxSyncStatus } from "@remit/domain-enums";
|
|
7
|
+
import { assertMailboxSettled } from "./folder-role.js";
|
|
8
|
+
import { applyMailboxPatch, type MailboxPatchClient } from "./mailbox.js";
|
|
9
|
+
|
|
10
|
+
const mailbox = (over: Partial<MailboxItem>): MailboxItem =>
|
|
11
|
+
({
|
|
12
|
+
mailboxId: "mb-1",
|
|
13
|
+
fullPath: "INBOX/Prullenbak",
|
|
14
|
+
hierarchyDelimiter: "/",
|
|
15
|
+
...over,
|
|
16
|
+
}) as unknown as MailboxItem;
|
|
17
|
+
|
|
18
|
+
const caught = (run: () => void): unknown => {
|
|
19
|
+
let thrown: unknown;
|
|
20
|
+
assert.throws(run, (error: unknown) => {
|
|
21
|
+
thrown = error;
|
|
22
|
+
return true;
|
|
23
|
+
});
|
|
24
|
+
return thrown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const publicErrorOf = (error: unknown) => {
|
|
28
|
+
if (typeof error !== "object" || error === null) return undefined;
|
|
29
|
+
const { publicApiError } = error as { publicApiError?: unknown };
|
|
30
|
+
return isPublicApiError(publicApiError) ? publicApiError : undefined;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("assertMailboxSettled", () => {
|
|
34
|
+
it("refuses a folder the mail server has not created yet", () => {
|
|
35
|
+
const error = caught(() =>
|
|
36
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.pending })),
|
|
37
|
+
);
|
|
38
|
+
assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("refuses a folder on its way out", () => {
|
|
42
|
+
const error = caught(() =>
|
|
43
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.deleting })),
|
|
44
|
+
);
|
|
45
|
+
assert.equal(publicErrorOf(error)?.code, "mailbox_not_settled");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("carries the mailbox and its state, so the client words a wait", () => {
|
|
49
|
+
const error = caught(() =>
|
|
50
|
+
assertMailboxSettled(
|
|
51
|
+
mailbox({ mailboxId: "mb-9", syncStatus: MailboxSyncStatus.pending }),
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
assert.deepEqual(publicErrorOf(error)?.details, {
|
|
55
|
+
mailboxId: "mb-9",
|
|
56
|
+
syncStatus: "pending",
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("allows a settled folder, and one whose delete failed", () => {
|
|
61
|
+
assert.doesNotThrow(() =>
|
|
62
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.synced })),
|
|
63
|
+
);
|
|
64
|
+
assert.doesNotThrow(() =>
|
|
65
|
+
assertMailboxSettled(mailbox({ syncStatus: MailboxSyncStatus.failed })),
|
|
66
|
+
);
|
|
67
|
+
assert.doesNotThrow(() => assertMailboxSettled(mailbox({})));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("applyMailboxPatch — the appointment label follows a rename", () => {
|
|
72
|
+
const labelName = composeFolderRoleAppointmentLabelName(
|
|
73
|
+
"acc-1",
|
|
74
|
+
CanonicalMailboxRole.Trash,
|
|
75
|
+
);
|
|
76
|
+
const sentLabelName = composeFolderRoleAppointmentLabelName(
|
|
77
|
+
"acc-1",
|
|
78
|
+
CanonicalMailboxRole.Sent,
|
|
79
|
+
);
|
|
80
|
+
const appointmentName = (role: string) =>
|
|
81
|
+
`FolderRoleAppointment#acc-1#${role}`;
|
|
82
|
+
|
|
83
|
+
const settingsFor = (
|
|
84
|
+
rows: Record<string, string>,
|
|
85
|
+
renamed = "INBOX",
|
|
86
|
+
): { store: Record<string, string>; client: MailboxPatchClient } => {
|
|
87
|
+
const store: Record<string, string> = { ...rows };
|
|
88
|
+
const client = {
|
|
89
|
+
mailbox: {
|
|
90
|
+
get: async () => mailbox({ fullPath: renamed }),
|
|
91
|
+
},
|
|
92
|
+
mailboxQueue: {
|
|
93
|
+
renameMailbox: async (mailboxId: string, newPath: string) =>
|
|
94
|
+
mailbox({ mailboxId, fullPath: newPath }),
|
|
95
|
+
},
|
|
96
|
+
accountSetting: {
|
|
97
|
+
get: async (_configId: string, name: string) =>
|
|
98
|
+
store[name] === undefined
|
|
99
|
+
? undefined
|
|
100
|
+
: ({
|
|
101
|
+
name,
|
|
102
|
+
value: { kind: "String", value: store[name] },
|
|
103
|
+
} as AccountSettingItem),
|
|
104
|
+
upsert: async (item: AccountSettingItem) => {
|
|
105
|
+
if (item.value.kind === "String") store[item.name] = item.value.value;
|
|
106
|
+
return item;
|
|
107
|
+
},
|
|
108
|
+
delete: async (_configId: string, name: string) => {
|
|
109
|
+
delete store[name];
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
} as unknown as MailboxPatchClient;
|
|
113
|
+
return { store, client };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it("rewrites the recorded path for the folder that was renamed", async () => {
|
|
117
|
+
const { store, client } = settingsFor(
|
|
118
|
+
{
|
|
119
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-1",
|
|
120
|
+
[labelName]: "INBOX/Prullenbak",
|
|
121
|
+
},
|
|
122
|
+
"INBOX/Prullenbak",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
|
|
126
|
+
fullPath: "INBOX/Verwijderd",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.equal(store[labelName], "INBOX/Verwijderd");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// IMAP RENAME moves the subtree in one command and `renameChildPaths`
|
|
133
|
+
// rewrites every descendant row, so every label under the branch moves too.
|
|
134
|
+
it("carries every appointed folder under the renamed branch with it", async () => {
|
|
135
|
+
const { store, client } = settingsFor(
|
|
136
|
+
{
|
|
137
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
138
|
+
[labelName]: "INBOX/Prullenbak",
|
|
139
|
+
[appointmentName(CanonicalMailboxRole.Sent)]: "mb-sent",
|
|
140
|
+
[sentLabelName]: "INBOX/Verzonden",
|
|
141
|
+
},
|
|
142
|
+
"INBOX",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
|
|
146
|
+
fullPath: "Mail",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.equal(store[labelName], "Mail/Prullenbak");
|
|
150
|
+
assert.equal(store[sentLabelName], "Mail/Verzonden");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("leaves a folder outside the renamed branch alone", async () => {
|
|
154
|
+
const { store, client } = settingsFor(
|
|
155
|
+
{
|
|
156
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
157
|
+
[labelName]: "Archief/Prullenbak",
|
|
158
|
+
},
|
|
159
|
+
"INBOX",
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
|
|
163
|
+
fullPath: "Mail",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
assert.equal(store[labelName], "Archief/Prullenbak");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("never rebases a sibling that merely shares the prefix", async () => {
|
|
170
|
+
const { store, client } = settingsFor(
|
|
171
|
+
{
|
|
172
|
+
[appointmentName(CanonicalMailboxRole.Trash)]: "mb-trash",
|
|
173
|
+
[labelName]: "INBOXES/Prullenbak",
|
|
174
|
+
},
|
|
175
|
+
"INBOX",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
await applyMailboxPatch(client, "cfg-1", "mb-1", "acc-1", {
|
|
179
|
+
fullPath: "Mail",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
assert.equal(store[labelName], "INBOXES/Prullenbak");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -2,6 +2,9 @@ import type {
|
|
|
2
2
|
AppointFolderRoleInput,
|
|
3
3
|
CanonicalMailboxRole,
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
|
+
import type { MailboxItem } from "@remit/data-ports";
|
|
6
|
+
import { MailboxNotSettledError } from "@remit/data-ports/errors";
|
|
7
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
8
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
6
9
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
7
10
|
import { getClient } from "../service/data-client.js";
|
|
@@ -16,6 +19,30 @@ import {
|
|
|
16
19
|
} from "./folder-role-appointments.js";
|
|
17
20
|
import { assertMailboxInAccount } from "./mailbox.js";
|
|
18
21
|
|
|
22
|
+
/** Mailbox states in which the mail server has not settled the folder yet. */
|
|
23
|
+
const UNSETTLED: ReadonlySet<string> = new Set([
|
|
24
|
+
MailboxSyncStatus.pending,
|
|
25
|
+
MailboxSyncStatus.deleting,
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Appointing a role to a folder the mail server is still creating or deleting
|
|
30
|
+
* would bind the account's Trash to a folder that may never exist (D16 item 3,
|
|
31
|
+
* imap-mutations R2: wait). A `\Noselect` container is refused by construction
|
|
32
|
+
* — mailbox-sync keeps no row for one — so there is nothing to appoint.
|
|
33
|
+
*/
|
|
34
|
+
export const assertMailboxSettled = (
|
|
35
|
+
target: Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
|
|
36
|
+
): void => {
|
|
37
|
+
const syncStatus = target.syncStatus;
|
|
38
|
+
if (!syncStatus || !UNSETTLED.has(syncStatus)) return;
|
|
39
|
+
throw new MailboxNotSettledError(
|
|
40
|
+
`Mailbox ${target.fullPath} is not settled on the mail server yet`,
|
|
41
|
+
target.mailboxId,
|
|
42
|
+
syncStatus,
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
|
|
19
46
|
/**
|
|
20
47
|
* RFC 032 exclusive-folder-appointment (#976): the single write operation for
|
|
21
48
|
* the per-account role map. `appoint(role, mailboxId)` sets `map[role]` —
|
|
@@ -47,6 +74,7 @@ export const FolderRoleOperations: Record<
|
|
|
47
74
|
if (body.mailboxId) {
|
|
48
75
|
const target = await mailbox.get(accountId, body.mailboxId);
|
|
49
76
|
assertMailboxInAccount(target, accountId, "act");
|
|
77
|
+
assertMailboxSettled(target);
|
|
50
78
|
lastKnownPath = target.fullPath;
|
|
51
79
|
}
|
|
52
80
|
|
|
@@ -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 {
|
|
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
|
+
});
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -4,8 +4,11 @@ import type {
|
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
5
|
import type { IAccountSettingRepository, MailboxItem } from "@remit/data-ports";
|
|
6
6
|
import { ForbiddenError, NotFoundError } from "@remit/data-ports/errors";
|
|
7
|
+
import {
|
|
8
|
+
type CanonicalMailboxRoleValue,
|
|
9
|
+
composeFolderRoleAppointmentLabelName,
|
|
10
|
+
} from "@remit/data-ports/folder-role";
|
|
7
11
|
import { MailboxSyncStatus, MessageSystemFlag } from "@remit/domain-enums";
|
|
8
|
-
import { NoTrashMailboxError } from "@remit/mailbox-service";
|
|
9
12
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
10
13
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
11
14
|
import {
|
|
@@ -26,6 +29,7 @@ import {
|
|
|
26
29
|
type MailboxOverrides,
|
|
27
30
|
} from "./account-overrides.js";
|
|
28
31
|
import { assertAccountOwnership } from "./account-ownership.js";
|
|
32
|
+
import { loadFolderAppointmentsForAccount } from "./folder-role-appointments.js";
|
|
29
33
|
|
|
30
34
|
/**
|
|
31
35
|
* The mute flag and the display-name override are user preferences that live
|
|
@@ -69,9 +73,73 @@ export interface MailboxPatchClient {
|
|
|
69
73
|
accountId: string,
|
|
70
74
|
): Promise<MailboxItem>;
|
|
71
75
|
};
|
|
72
|
-
accountSetting: Pick<IAccountSettingRepository, "upsert" | "delete">;
|
|
76
|
+
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert" | "delete">;
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Where a recorded path lands after the rename, or `undefined` when the rename
|
|
81
|
+
* did not move it. IMAP RENAME moves the whole subtree in one command and
|
|
82
|
+
* `renameChildPaths` rewrites every descendant row with it, so a label under
|
|
83
|
+
* the renamed branch moves exactly as far as its prefix does.
|
|
84
|
+
*/
|
|
85
|
+
const rebasePath = (
|
|
86
|
+
recorded: string,
|
|
87
|
+
oldPath: string,
|
|
88
|
+
newPath: string,
|
|
89
|
+
delimiter: string,
|
|
90
|
+
): string | undefined => {
|
|
91
|
+
if (recorded === oldPath) return newPath;
|
|
92
|
+
const branch = `${oldPath}${delimiter}`;
|
|
93
|
+
if (!recorded.startsWith(branch)) return undefined;
|
|
94
|
+
return `${newPath}${recorded.slice(oldPath.length)}`;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A reader-side rename keeps every mailboxId, so the appointments survive it —
|
|
99
|
+
* but the paths recorded beside them (#887) would still name where the folders
|
|
100
|
+
* were before. Move the labels with the branch, or a later third-party delete
|
|
101
|
+
* names a path the user has not seen since the rename.
|
|
102
|
+
*
|
|
103
|
+
* The renamed folder is matched by id; its descendants are matched by the path
|
|
104
|
+
* each label already holds, which is the path their rows carried until this
|
|
105
|
+
* rename rewrote them.
|
|
106
|
+
*/
|
|
107
|
+
const refreshAppointmentLabels = async (
|
|
108
|
+
accountSetting: Pick<IAccountSettingRepository, "get" | "upsert">,
|
|
109
|
+
accountConfigId: string,
|
|
110
|
+
accountId: string,
|
|
111
|
+
renamed: { mailboxId: string; oldPath: string; newPath: string },
|
|
112
|
+
delimiter: string,
|
|
113
|
+
): Promise<void> => {
|
|
114
|
+
const persisted = await loadFolderAppointmentsForAccount(
|
|
115
|
+
accountSetting,
|
|
116
|
+
accountConfigId,
|
|
117
|
+
accountId,
|
|
118
|
+
);
|
|
119
|
+
for (const [role, appointment] of persisted) {
|
|
120
|
+
const moved =
|
|
121
|
+
appointment.mailboxId === renamed.mailboxId
|
|
122
|
+
? renamed.newPath
|
|
123
|
+
: appointment.lastKnownPath === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: rebasePath(
|
|
126
|
+
appointment.lastKnownPath,
|
|
127
|
+
renamed.oldPath,
|
|
128
|
+
renamed.newPath,
|
|
129
|
+
delimiter,
|
|
130
|
+
);
|
|
131
|
+
if (moved === undefined || moved === appointment.lastKnownPath) continue;
|
|
132
|
+
await accountSetting.upsert({
|
|
133
|
+
accountConfigId,
|
|
134
|
+
name: composeFolderRoleAppointmentLabelName(
|
|
135
|
+
accountId,
|
|
136
|
+
role as CanonicalMailboxRoleValue,
|
|
137
|
+
),
|
|
138
|
+
value: { kind: "String", value: moved },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
75
143
|
/**
|
|
76
144
|
* Apply a mailbox PATCH body: override changes first (mute flag + display-name/
|
|
77
145
|
* role overrides — written to per-mailbox AccountSetting rows, no IMAP
|
|
@@ -110,7 +178,26 @@ export const applyMailboxPatch = async (
|
|
|
110
178
|
return client.mailbox.get(accountId, mailboxId);
|
|
111
179
|
}
|
|
112
180
|
|
|
113
|
-
|
|
181
|
+
// Read before the rename: the labels of the folders under this one are
|
|
182
|
+
// rebased off the path it is leaving, which the row no longer carries after.
|
|
183
|
+
const before = await client.mailbox.get(accountId, mailboxId);
|
|
184
|
+
const renamed = await client.mailboxQueue.renameMailbox(
|
|
185
|
+
mailboxId,
|
|
186
|
+
fullPath,
|
|
187
|
+
accountId,
|
|
188
|
+
);
|
|
189
|
+
await refreshAppointmentLabels(
|
|
190
|
+
client.accountSetting,
|
|
191
|
+
accountConfigId,
|
|
192
|
+
accountId,
|
|
193
|
+
{
|
|
194
|
+
mailboxId,
|
|
195
|
+
oldPath: before.fullPath,
|
|
196
|
+
newPath: renamed.fullPath,
|
|
197
|
+
},
|
|
198
|
+
before.hierarchyDelimiter,
|
|
199
|
+
);
|
|
200
|
+
return renamed;
|
|
114
201
|
};
|
|
115
202
|
|
|
116
203
|
/**
|
|
@@ -412,26 +499,10 @@ export const TrashOperations: Record<
|
|
|
412
499
|
const account = await client.account.get(accountId);
|
|
413
500
|
assertAccountOwnership(account, accountConfigId, "act");
|
|
414
501
|
|
|
415
|
-
// The
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
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 };
|
|
502
|
+
// The service resolves Trash, marks the rows and counts them, all off one
|
|
503
|
+
// read, and refuses with a coded 409 when the role is unresolved. Counting
|
|
504
|
+
// here as well would be a second answer free to disagree with the one that
|
|
505
|
+
// acts, and "0 deleted" reads as success to a user whose Trash is untouched.
|
|
506
|
+
return client.messageMove.emptyTrash(accountConfigId, accountId);
|
|
436
507
|
},
|
|
437
508
|
};
|