@remit/mailbox-service 0.0.66 → 0.0.67
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 +1 -1
- package/src/index.ts +3 -0
- package/src/message-delete-junk-reconcile.test.ts +5 -2
- package/src/message-delete-no-trash.test.ts +63 -13
- package/src/message-move-copy.test.ts +2 -1
- package/src/message-move-empty-trash.test.ts +149 -26
- package/src/message-move-same-mailbox.test.ts +2 -0
- package/src/message-move.ts +109 -22
- package/src/spam-report.test.ts +2 -0
- package/src/test-helpers/folder-roles.ts +24 -7
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -191,6 +191,9 @@ export {
|
|
|
191
191
|
type MessageMoveLogger,
|
|
192
192
|
MessageMoveService,
|
|
193
193
|
NoTrashMailboxError,
|
|
194
|
+
requireTrashMailbox,
|
|
195
|
+
StaleTrashAppointmentError,
|
|
196
|
+
UnconfirmedTrashMailboxError,
|
|
194
197
|
} from "./message-move.js";
|
|
195
198
|
export {
|
|
196
199
|
type ParsedMessageContent,
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
IThreadMessageRepository,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
10
|
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
11
|
+
import { trashRole } from "./test-helpers/folder-roles.js";
|
|
11
12
|
|
|
12
13
|
const ACCOUNT = "acc-1";
|
|
13
14
|
const ACCOUNT_CONFIG = "cfg-1";
|
|
@@ -51,9 +52,11 @@ const buildWorld = (trashExists: boolean) => {
|
|
|
51
52
|
],
|
|
52
53
|
} as unknown as IMailboxRepository;
|
|
53
54
|
|
|
55
|
+
const trash = trashExists ? { mailboxId: TRASH, fullPath: "Trash" } : null;
|
|
56
|
+
|
|
54
57
|
const mailboxSpecialUseService = {
|
|
55
|
-
findTrashMailbox: async () =>
|
|
56
|
-
|
|
58
|
+
findTrashMailbox: async () => trash,
|
|
59
|
+
resolveTrashRole: async () => trashRole(trash),
|
|
57
60
|
} as unknown as IMailboxSpecialUseRepository;
|
|
58
61
|
|
|
59
62
|
const addressService = {
|
|
@@ -7,10 +7,12 @@ import type {
|
|
|
7
7
|
IMessageRepository,
|
|
8
8
|
IThreadMessageRepository,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
|
+
import type { RoleResolution } from "@remit/data-ports/folder-role";
|
|
10
11
|
import {
|
|
11
12
|
type MessageMoveConfig,
|
|
12
13
|
MessageMoveService,
|
|
13
14
|
NoTrashMailboxError,
|
|
15
|
+
StaleTrashAppointmentError,
|
|
14
16
|
} from "./message-move.js";
|
|
15
17
|
|
|
16
18
|
const ACCOUNT = "acc-1";
|
|
@@ -19,13 +21,15 @@ const INBOX = "mbx-inbox";
|
|
|
19
21
|
const TRASH = "mbx-trash";
|
|
20
22
|
const MESSAGE_ID = "msg-1";
|
|
21
23
|
|
|
24
|
+
type TrashMailbox = { mailboxId: string; fullPath: string };
|
|
25
|
+
|
|
22
26
|
interface EnqueuedEvent {
|
|
23
27
|
operation: string;
|
|
24
28
|
destinationMailboxPath?: string;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
const buildWorld = (
|
|
28
|
-
|
|
32
|
+
trashResolution: RoleResolution<TrashMailbox>,
|
|
29
33
|
startingMailboxId = INBOX,
|
|
30
34
|
) => {
|
|
31
35
|
const patches: Array<Record<string, unknown>> = [];
|
|
@@ -74,7 +78,7 @@ const buildWorld = (
|
|
|
74
78
|
} as unknown as IMailboxRepository;
|
|
75
79
|
|
|
76
80
|
const mailboxSpecialUseService = {
|
|
77
|
-
|
|
81
|
+
resolveTrashRole: async () => trashResolution,
|
|
78
82
|
} as unknown as IMailboxSpecialUseRepository;
|
|
79
83
|
|
|
80
84
|
const addressService = {
|
|
@@ -102,12 +106,14 @@ const buildWorld = (
|
|
|
102
106
|
return { service, patches, events, message };
|
|
103
107
|
};
|
|
104
108
|
|
|
109
|
+
const flaggedTrash: RoleResolution<TrashMailbox> = {
|
|
110
|
+
kind: "flagged",
|
|
111
|
+
mailbox: { mailboxId: TRASH, fullPath: "INBOX/Trash" },
|
|
112
|
+
};
|
|
113
|
+
|
|
105
114
|
describe("delete only expunges when it was asked to", () => {
|
|
106
115
|
it("moves to a Trash the account resolves under a nested path", async () => {
|
|
107
|
-
const { service, events, message } = buildWorld(
|
|
108
|
-
mailboxId: TRASH,
|
|
109
|
-
fullPath: "INBOX/Trash",
|
|
110
|
-
});
|
|
116
|
+
const { service, events, message } = buildWorld(flaggedTrash);
|
|
111
117
|
|
|
112
118
|
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
113
119
|
|
|
@@ -120,13 +126,14 @@ describe("delete only expunges when it was asked to", () => {
|
|
|
120
126
|
});
|
|
121
127
|
|
|
122
128
|
it("refuses, and touches nothing, when no Trash resolves", async () => {
|
|
123
|
-
const { service, patches, events, message } = buildWorld(
|
|
129
|
+
const { service, patches, events, message } = buildWorld({ kind: "none" });
|
|
124
130
|
|
|
125
131
|
await assert.rejects(
|
|
126
132
|
() => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
|
|
127
133
|
(error: unknown) =>
|
|
128
134
|
error instanceof NoTrashMailboxError &&
|
|
129
135
|
error.statusCode === 409 &&
|
|
136
|
+
error.publicApiError?.details?.reason === "none" &&
|
|
130
137
|
error.message.includes("no Trash folder"),
|
|
131
138
|
);
|
|
132
139
|
|
|
@@ -136,12 +143,53 @@ describe("delete only expunges when it was asked to", () => {
|
|
|
136
143
|
assert.equal(message.status, "active");
|
|
137
144
|
});
|
|
138
145
|
|
|
139
|
-
it("
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
it("refuses a stale appointment rather than filing into the fallback", async () => {
|
|
147
|
+
// #887 Done item 2: the user appointed a folder another client has since
|
|
148
|
+
// deleted. Silently filing 200 messages into the flagged folder overrides
|
|
149
|
+
// a choice they made and cannot see was lost.
|
|
150
|
+
const { service, patches, events, message } = buildWorld({
|
|
151
|
+
kind: "appointment_stale",
|
|
152
|
+
appointedMailboxId: "mbx-appointed-and-gone",
|
|
153
|
+
fallback: flaggedTrash,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await assert.rejects(
|
|
157
|
+
() => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
|
|
158
|
+
(error: unknown) =>
|
|
159
|
+
error instanceof StaleTrashAppointmentError &&
|
|
160
|
+
error.statusCode === 409 &&
|
|
161
|
+
error.publicApiError?.details?.reason === "stale" &&
|
|
162
|
+
error.publicApiError?.details?.accountId === ACCOUNT,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
assert.deepEqual(events, []);
|
|
166
|
+
assert.deepEqual(patches, []);
|
|
167
|
+
assert.equal(message.mailboxId, INBOX);
|
|
168
|
+
assert.equal(message.status, "active");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("files into a Trash that resolves by name alone", async () => {
|
|
172
|
+
// A name guess is enough to move mail somewhere retrievable; only the
|
|
173
|
+
// expunge demands more (D4).
|
|
174
|
+
const { service, events, message } = buildWorld({
|
|
175
|
+
kind: "proposed",
|
|
176
|
+
mailbox: { mailboxId: TRASH, fullPath: "Trash" },
|
|
143
177
|
});
|
|
144
178
|
|
|
179
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
180
|
+
|
|
181
|
+
assert.deepEqual(
|
|
182
|
+
events.map((event) => event.operation),
|
|
183
|
+
["move_to_trash"],
|
|
184
|
+
);
|
|
185
|
+
assert.equal(message.mailboxId, TRASH);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("expunges only when the caller asked for a permanent delete", async () => {
|
|
189
|
+
// fc685509: an explicit permanent delete never resolves Trash at all, so
|
|
190
|
+
// an account with none can still empty a message it selected.
|
|
191
|
+
const { service, events } = buildWorld({ kind: "none" });
|
|
192
|
+
|
|
145
193
|
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT, {
|
|
146
194
|
permanent: true,
|
|
147
195
|
});
|
|
@@ -152,9 +200,11 @@ describe("delete only expunges when it was asked to", () => {
|
|
|
152
200
|
);
|
|
153
201
|
});
|
|
154
202
|
|
|
155
|
-
it("expunges a message already
|
|
203
|
+
it("expunges a message already inside a Trash that resolves by name", async () => {
|
|
204
|
+
// D4a: the rows were selected and the dialog named the consequence, so
|
|
205
|
+
// consent is per message. Empty Trash has no such consent and refuses.
|
|
156
206
|
const { service, events } = buildWorld(
|
|
157
|
-
{ mailboxId: TRASH, fullPath: "
|
|
207
|
+
{ kind: "proposed", mailbox: { mailboxId: TRASH, fullPath: "Trash" } },
|
|
158
208
|
TRASH,
|
|
159
209
|
);
|
|
160
210
|
|
|
@@ -15,7 +15,7 @@ import { MailboxCursorState } from "@remit/domain-enums";
|
|
|
15
15
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
16
16
|
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
17
17
|
import { MessageSyncService } from "./message-sync.js";
|
|
18
|
-
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
18
|
+
import { noFolderRoles, trashRole } from "./test-helpers/folder-roles.js";
|
|
19
19
|
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
20
20
|
|
|
21
21
|
const stubAddressService = (): IAddressRepository =>
|
|
@@ -175,6 +175,7 @@ const buildWorld = () => {
|
|
|
175
175
|
// where a copy's placement row must be reachable.
|
|
176
176
|
const mailboxSpecialUseService = {
|
|
177
177
|
findTrashMailbox: async () => null,
|
|
178
|
+
resolveTrashRole: async () => trashRole(null),
|
|
178
179
|
} as unknown as IMailboxSpecialUseRepository;
|
|
179
180
|
|
|
180
181
|
const config: MessageMoveConfig = {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Empty Trash is an EXPUNGE with no undo, so it
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Empty Trash is an EXPUNGE with no undo, so it demands confirmed evidence:
|
|
3
|
+
* what the user appointed, or what the server flagged `\Trash`. The name
|
|
4
|
+
* proposal that serves every other lookup is a guess, and a wrong guess here
|
|
5
|
+
* destroys mail (#837, audit #841). Each way that evidence can be missing
|
|
6
|
+
* refuses under its own reason, so the surface can name the repair (#887).
|
|
6
7
|
*/
|
|
7
8
|
import assert from "node:assert/strict";
|
|
8
9
|
import { describe, it } from "node:test";
|
|
@@ -13,20 +14,56 @@ import type {
|
|
|
13
14
|
IMessageRepository,
|
|
14
15
|
IThreadMessageRepository,
|
|
15
16
|
} from "@remit/data-ports";
|
|
16
|
-
import
|
|
17
|
+
import type { RoleResolution } from "@remit/data-ports/folder-role";
|
|
18
|
+
import {
|
|
19
|
+
type MessageMoveConfig,
|
|
20
|
+
MessageMoveService,
|
|
21
|
+
NoTrashMailboxError,
|
|
22
|
+
StaleTrashAppointmentError,
|
|
23
|
+
UnconfirmedTrashMailboxError,
|
|
24
|
+
} from "./message-move.js";
|
|
17
25
|
|
|
18
26
|
const ACCOUNT = "acc-1";
|
|
19
27
|
const ACCOUNT_CONFIG = "cfg-1";
|
|
20
28
|
const DELETED_FOLDER = "mbx-deleted";
|
|
21
29
|
const REAL_TRASH = "mbx-trash";
|
|
22
30
|
|
|
31
|
+
type TrashMailbox = { mailboxId: string; fullPath: string };
|
|
32
|
+
|
|
33
|
+
const appointedTrash: RoleResolution<TrashMailbox> = {
|
|
34
|
+
kind: "appointed",
|
|
35
|
+
mailbox: { mailboxId: REAL_TRASH, fullPath: "[Gmail]/Trash" },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// A folder merely named `Deleted` is what the name proposal returns, and the
|
|
39
|
+
// confirmed gate is what this path asks for.
|
|
40
|
+
const proposedDeletedFolder: RoleResolution<TrashMailbox> = {
|
|
41
|
+
kind: "proposed",
|
|
42
|
+
mailbox: { mailboxId: DELETED_FOLDER, fullPath: "Deleted" },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface TrashMessage {
|
|
46
|
+
messageId: string;
|
|
47
|
+
syncStatus: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface EnqueuedEvent {
|
|
51
|
+
type: string;
|
|
52
|
+
trashMailboxId?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
const buildWorld = (
|
|
24
|
-
|
|
56
|
+
trashResolution: RoleResolution<TrashMailbox>,
|
|
57
|
+
trashContents: TrashMessage[] = [
|
|
58
|
+
{ messageId: "junk-1", syncStatus: "synced" },
|
|
59
|
+
],
|
|
25
60
|
) => {
|
|
26
61
|
const emptied: string[] = [];
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
62
|
+
const markedDeleting: string[] = [];
|
|
63
|
+
const events: EnqueuedEvent[] = [];
|
|
64
|
+
const messagesByMailbox = new Map<string, TrashMessage[]>([
|
|
65
|
+
[DELETED_FOLDER, [{ messageId: "keepsake-1", syncStatus: "synced" }]],
|
|
66
|
+
[REAL_TRASH, trashContents],
|
|
30
67
|
]);
|
|
31
68
|
|
|
32
69
|
const messageService = {
|
|
@@ -34,7 +71,9 @@ const buildWorld = (
|
|
|
34
71
|
emptied.push(mailboxId);
|
|
35
72
|
return messagesByMailbox.get(mailboxId) ?? [];
|
|
36
73
|
},
|
|
37
|
-
update: async () => {
|
|
74
|
+
update: async (messageId: string) => {
|
|
75
|
+
markedDeleting.push(messageId);
|
|
76
|
+
},
|
|
38
77
|
} as unknown as IMessageRepository;
|
|
39
78
|
|
|
40
79
|
const threadMessageService = {
|
|
@@ -53,13 +92,7 @@ const buildWorld = (
|
|
|
53
92
|
reconcileJunkOnlyForMessage: async () => {},
|
|
54
93
|
} as unknown as IAddressRepository,
|
|
55
94
|
mailboxSpecialUseService: {
|
|
56
|
-
|
|
57
|
-
// return; the confirmed lookup is what this path asks for.
|
|
58
|
-
findTrashMailbox: async () => ({
|
|
59
|
-
mailboxId: DELETED_FOLDER,
|
|
60
|
-
fullPath: "Deleted",
|
|
61
|
-
}),
|
|
62
|
-
findConfirmedTrashMailbox: async () => confirmedTrash,
|
|
95
|
+
resolveTrashRole: async () => trashResolution,
|
|
63
96
|
} as unknown as IMailboxSpecialUseRepository,
|
|
64
97
|
threadMessageService,
|
|
65
98
|
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
@@ -67,18 +100,19 @@ const buildWorld = (
|
|
|
67
100
|
|
|
68
101
|
const service = new MessageMoveService(config);
|
|
69
102
|
(
|
|
70
|
-
service as unknown as {
|
|
71
|
-
|
|
103
|
+
service as unknown as {
|
|
104
|
+
enqueueEvent: (event: EnqueuedEvent) => Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
).enqueueEvent = async (event) => {
|
|
107
|
+
events.push(event);
|
|
108
|
+
};
|
|
72
109
|
|
|
73
|
-
return { service, emptied };
|
|
110
|
+
return { service, emptied, markedDeleting, events };
|
|
74
111
|
};
|
|
75
112
|
|
|
76
113
|
describe("MessageMoveService.emptyTrash", () => {
|
|
77
114
|
it("empties the appointed Trash, never the user folder called Deleted", async () => {
|
|
78
|
-
const { service, emptied } = buildWorld(
|
|
79
|
-
mailboxId: REAL_TRASH,
|
|
80
|
-
fullPath: "[Gmail]/Trash",
|
|
81
|
-
});
|
|
115
|
+
const { service, emptied } = buildWorld(appointedTrash);
|
|
82
116
|
|
|
83
117
|
await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
84
118
|
|
|
@@ -86,15 +120,104 @@ describe("MessageMoveService.emptyTrash", () => {
|
|
|
86
120
|
});
|
|
87
121
|
|
|
88
122
|
it("refuses and names the remedy when no folder is appointed or flagged", async () => {
|
|
89
|
-
const { service, emptied } = buildWorld(
|
|
123
|
+
const { service, emptied } = buildWorld({ kind: "none" });
|
|
90
124
|
|
|
91
125
|
await assert.rejects(
|
|
92
126
|
service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
|
|
93
|
-
|
|
127
|
+
(error: unknown) =>
|
|
128
|
+
error instanceof NoTrashMailboxError &&
|
|
129
|
+
error.publicApiError?.details?.reason === "none",
|
|
94
130
|
);
|
|
95
131
|
|
|
96
132
|
// Nothing was read, so nothing was marked for deletion and no expunge
|
|
97
133
|
// was enqueued: an unresolved Trash stops the operation dead.
|
|
98
134
|
assert.deepEqual(emptied, []);
|
|
99
135
|
});
|
|
136
|
+
|
|
137
|
+
it("refuses a Trash that only resolves by name, under its own reason", async () => {
|
|
138
|
+
// D18: a plausible folder nobody confirmed is a third answer, distinct
|
|
139
|
+
// from having none. The refusal is what mints the appointment.
|
|
140
|
+
const { service, emptied, markedDeleting } = buildWorld(
|
|
141
|
+
proposedDeletedFolder,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
await assert.rejects(
|
|
145
|
+
service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
|
|
146
|
+
(error: unknown) =>
|
|
147
|
+
error instanceof UnconfirmedTrashMailboxError &&
|
|
148
|
+
error.statusCode === 409 &&
|
|
149
|
+
error.publicApiError?.details?.reason === "unconfirmed" &&
|
|
150
|
+
error.publicApiError?.details?.accountId === ACCOUNT,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
assert.deepEqual(emptied, []);
|
|
154
|
+
assert.deepEqual(markedDeleting, []);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("refuses a stale appointment rather than emptying its fallback", async () => {
|
|
158
|
+
const { service, emptied, markedDeleting } = buildWorld({
|
|
159
|
+
kind: "appointment_stale",
|
|
160
|
+
appointedMailboxId: "mbx-appointed-and-gone",
|
|
161
|
+
fallback: { kind: "flagged", mailbox: appointedTrash.mailbox },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await assert.rejects(
|
|
165
|
+
service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
|
|
166
|
+
(error: unknown) =>
|
|
167
|
+
error instanceof StaleTrashAppointmentError &&
|
|
168
|
+
error.publicApiError?.details?.reason === "stale",
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
assert.deepEqual(emptied, []);
|
|
172
|
+
assert.deepEqual(markedDeleting, []);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("reports what it marked, from the one read that decided it", async () => {
|
|
176
|
+
const { service, markedDeleting } = buildWorld(appointedTrash, [
|
|
177
|
+
{ messageId: "junk-1", syncStatus: "synced" },
|
|
178
|
+
{ messageId: "junk-2", syncStatus: "synced" },
|
|
179
|
+
{ messageId: "junk-3", syncStatus: "synced" },
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
183
|
+
|
|
184
|
+
assert.equal(deletedCount, markedDeleting.length);
|
|
185
|
+
assert.equal(deletedCount, 3);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("marks and counts a message whose move to Trash has not settled", async () => {
|
|
189
|
+
// The user saw the message in Trash and asked for the folder to be
|
|
190
|
+
// emptied. Skipping it reports a number the folder contradicts, and the
|
|
191
|
+
// queue is per-account FIFO, so the move has landed on the server before
|
|
192
|
+
// the expunge is even delivered.
|
|
193
|
+
const { service, markedDeleting } = buildWorld(appointedTrash, [
|
|
194
|
+
{ messageId: "settled-1", syncStatus: "synced" },
|
|
195
|
+
{ messageId: "still-moving-1", syncStatus: "pending" },
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
199
|
+
|
|
200
|
+
assert.deepEqual(markedDeleting, ["settled-1", "still-moving-1"]);
|
|
201
|
+
assert.equal(deletedCount, 2);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("reports the same count when pressed twice before the worker runs", async () => {
|
|
205
|
+
// The rows it marked are still in that folder, so N stays true and the
|
|
206
|
+
// re-mark is idempotent. Reporting 0 the second time while still
|
|
207
|
+
// enqueuing an expunge would read as success over an untouched Trash.
|
|
208
|
+
const { service, events } = buildWorld(appointedTrash, [
|
|
209
|
+
{ messageId: "junk-1", syncStatus: "synced" },
|
|
210
|
+
{ messageId: "junk-2", syncStatus: "synced" },
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
const first = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
214
|
+
const second = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
215
|
+
|
|
216
|
+
assert.equal(first.deletedCount, 2);
|
|
217
|
+
assert.equal(second.deletedCount, 2);
|
|
218
|
+
assert.deepEqual(
|
|
219
|
+
events.map((event) => event.type),
|
|
220
|
+
["EMPTY_TRASH", "EMPTY_TRASH"],
|
|
221
|
+
);
|
|
222
|
+
});
|
|
100
223
|
});
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
IThreadMessageRepository,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
10
|
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
11
|
+
import { trashRole } from "./test-helpers/folder-roles.js";
|
|
11
12
|
|
|
12
13
|
const stubAddressService = (): IAddressRepository =>
|
|
13
14
|
({
|
|
@@ -66,6 +67,7 @@ const buildWorld = () => {
|
|
|
66
67
|
|
|
67
68
|
const mailboxSpecialUseService = {
|
|
68
69
|
findTrashMailbox: async () => null,
|
|
70
|
+
resolveTrashRole: async () => trashRole(null),
|
|
69
71
|
} as unknown as IMailboxSpecialUseRepository;
|
|
70
72
|
|
|
71
73
|
const config: MessageMoveConfig = {
|
package/src/message-move.ts
CHANGED
|
@@ -12,7 +12,15 @@ import type {
|
|
|
12
12
|
IThreadMessageRepository,
|
|
13
13
|
} from "@remit/data-ports";
|
|
14
14
|
import { FolderRoleUnresolvedError } from "@remit/data-ports/errors";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
type FolderRoleUnresolvedReason,
|
|
17
|
+
NO_TRASH_FOLDER_REASON,
|
|
18
|
+
type RoleResolution,
|
|
19
|
+
STALE_TRASH_FOLDER_REASON,
|
|
20
|
+
type TrashAssuranceLevel,
|
|
21
|
+
trashMailboxAt,
|
|
22
|
+
UNCONFIRMED_TRASH_FOLDER_REASON,
|
|
23
|
+
} from "@remit/data-ports/folder-role";
|
|
16
24
|
import { deriveCopyMessageId } from "@remit/data-ports/id";
|
|
17
25
|
import {
|
|
18
26
|
CanonicalMailboxRole,
|
|
@@ -129,6 +137,74 @@ export class NoTrashMailboxError extends FolderRoleUnresolvedError {
|
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
|
|
140
|
+
/**
|
|
141
|
+
* The folder the user appointed as Trash is gone from the server. Distinct from
|
|
142
|
+
* having no Trash at all: there is a decision on file, and the repair is to
|
|
143
|
+
* point it at a folder that exists — not to let reader pick one instead.
|
|
144
|
+
*/
|
|
145
|
+
export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
|
|
146
|
+
name = "StaleTrashAppointmentError";
|
|
147
|
+
|
|
148
|
+
constructor(accountId: string) {
|
|
149
|
+
super(
|
|
150
|
+
STALE_TRASH_FOLDER_REASON,
|
|
151
|
+
CanonicalMailboxRole.Trash,
|
|
152
|
+
"stale",
|
|
153
|
+
accountId,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A folder resolves as Trash by its name alone. Enough to move mail into,
|
|
160
|
+
* never enough to expunge — so only the Empty Trash path raises it.
|
|
161
|
+
*/
|
|
162
|
+
export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
|
|
163
|
+
name = "UnconfirmedTrashMailboxError";
|
|
164
|
+
|
|
165
|
+
constructor(accountId: string) {
|
|
166
|
+
super(
|
|
167
|
+
UNCONFIRMED_TRASH_FOLDER_REASON,
|
|
168
|
+
CanonicalMailboxRole.Trash,
|
|
169
|
+
"unconfirmed",
|
|
170
|
+
accountId,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const REFUSAL_BY_REASON: Record<
|
|
176
|
+
FolderRoleUnresolvedReason,
|
|
177
|
+
new (
|
|
178
|
+
accountId: string,
|
|
179
|
+
) => FolderRoleUnresolvedError
|
|
180
|
+
> = {
|
|
181
|
+
none: NoTrashMailboxError,
|
|
182
|
+
stale: StaleTrashAppointmentError,
|
|
183
|
+
unconfirmed: UnconfirmedTrashMailboxError,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** The coded 409 for one refusal reason. The only place a reason becomes an error. */
|
|
187
|
+
const trashRefusal = (
|
|
188
|
+
reason: FolderRoleUnresolvedReason,
|
|
189
|
+
accountId: string,
|
|
190
|
+
): FolderRoleUnresolvedError => new REFUSAL_BY_REASON[reason](accountId);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The Trash folder a verb may act on at the assurance it demands, or the coded
|
|
194
|
+
* refusal naming which of the three reasons stopped it. Every Trash gate goes
|
|
195
|
+
* through here — the delete and the expunge — so no two of them can tell a
|
|
196
|
+
* user different things about the same account.
|
|
197
|
+
*/
|
|
198
|
+
export const requireTrashMailbox = <T>(
|
|
199
|
+
resolution: RoleResolution<T>,
|
|
200
|
+
level: TrashAssuranceLevel,
|
|
201
|
+
accountId: string,
|
|
202
|
+
): T => {
|
|
203
|
+
const outcome = trashMailboxAt(resolution, level);
|
|
204
|
+
if (outcome.allowed) return outcome.mailbox;
|
|
205
|
+
throw trashRefusal(outcome.reason, accountId);
|
|
206
|
+
};
|
|
207
|
+
|
|
132
208
|
/**
|
|
133
209
|
* Options for delete operations
|
|
134
210
|
*/
|
|
@@ -226,18 +302,23 @@ export class MessageMoveService {
|
|
|
226
302
|
const wantsPermanentDelete =
|
|
227
303
|
options.permanent === true || options.toTrash === false;
|
|
228
304
|
|
|
229
|
-
const
|
|
305
|
+
const trashGate = wantsPermanentDelete
|
|
230
306
|
? null
|
|
231
|
-
:
|
|
307
|
+
: trashMailboxAt(
|
|
308
|
+
await this.mailboxSpecialUseService.resolveTrashRole(accountId),
|
|
309
|
+
"resolved",
|
|
310
|
+
);
|
|
232
311
|
|
|
233
|
-
if (
|
|
312
|
+
if (trashGate && !trashGate.allowed) {
|
|
234
313
|
this.log.error(
|
|
235
|
-
{ accountId, messageCount: messages.length },
|
|
236
|
-
"Refused to delete: account
|
|
314
|
+
{ accountId, messageCount: messages.length, reason: trashGate.reason },
|
|
315
|
+
"Refused to delete: this account's Trash is unresolved",
|
|
237
316
|
);
|
|
238
|
-
throw
|
|
317
|
+
throw trashRefusal(trashGate.reason, accountId);
|
|
239
318
|
}
|
|
240
319
|
|
|
320
|
+
const trashMailbox = trashGate ? trashGate.mailbox : null;
|
|
321
|
+
|
|
241
322
|
// Group messages by operation type
|
|
242
323
|
const moveToTrashMessages: Array<{
|
|
243
324
|
messageId: string;
|
|
@@ -659,26 +740,30 @@ export class MessageMoveService {
|
|
|
659
740
|
/**
|
|
660
741
|
* Empty the Trash mailbox: an EXPUNGE of everything in it, with no undo.
|
|
661
742
|
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
* `Deleted` would otherwise lose its contents
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
743
|
+
* Demands confirmed evidence: the folder the user appointed or the one the
|
|
744
|
+
* server flagged \Trash — never one that merely reads like a trash folder,
|
|
745
|
+
* and never the fallback behind an appointment that went missing. A user
|
|
746
|
+
* with an ordinary folder called `Deleted` would otherwise lose its contents
|
|
747
|
+
* permanently. Each of the three ways that evidence can be absent refuses
|
|
748
|
+
* under its own reason, so the surface can name the remedy instead of
|
|
749
|
+
* offering a silent no-op.
|
|
750
|
+
*
|
|
751
|
+
* Reports the rows it marked, read once here, so the number the caller shows
|
|
752
|
+
* a user is the number this operation acted on. Pressed twice before the
|
|
753
|
+
* worker runs it re-marks the same rows and reports the same number: the
|
|
754
|
+
* folder really does still hold them, and the second expunge finds nothing
|
|
755
|
+
* to do.
|
|
669
756
|
*/
|
|
670
757
|
emptyTrash = async (
|
|
671
758
|
accountConfigId: string,
|
|
672
759
|
accountId: string,
|
|
673
|
-
): Promise<
|
|
674
|
-
const trashMailbox =
|
|
675
|
-
await this.mailboxSpecialUseService.
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
760
|
+
): Promise<{ deletedCount: number }> => {
|
|
761
|
+
const trashMailbox = requireTrashMailbox(
|
|
762
|
+
await this.mailboxSpecialUseService.resolveTrashRole(accountId),
|
|
763
|
+
"confirmed",
|
|
764
|
+
accountId,
|
|
765
|
+
);
|
|
680
766
|
|
|
681
|
-
// Get all messages in Trash
|
|
682
767
|
const messages = await this.messageService.listAllByMailbox(
|
|
683
768
|
trashMailbox.mailboxId,
|
|
684
769
|
);
|
|
@@ -716,6 +801,8 @@ export class MessageMoveService {
|
|
|
716
801
|
};
|
|
717
802
|
|
|
718
803
|
await this.enqueueEvent(event);
|
|
804
|
+
|
|
805
|
+
return { deletedCount: messages.length };
|
|
719
806
|
};
|
|
720
807
|
|
|
721
808
|
/**
|
package/src/spam-report.test.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
NoJunkMailboxError,
|
|
20
20
|
SpamReportService,
|
|
21
21
|
} from "./spam-report.js";
|
|
22
|
+
import { trashRole } from "./test-helpers/folder-roles.js";
|
|
22
23
|
|
|
23
24
|
const ACCOUNT = "acc-1";
|
|
24
25
|
const ACCOUNT_CONFIG = "cfg-1";
|
|
@@ -233,6 +234,7 @@ const buildWorld = (
|
|
|
233
234
|
const mailboxSpecialUseService = {
|
|
234
235
|
findJunkMailbox: async () => junkMailbox,
|
|
235
236
|
findTrashMailbox: async () => null,
|
|
237
|
+
resolveTrashRole: async () => trashRole(null),
|
|
236
238
|
} as unknown as IMailboxSpecialUseRepository;
|
|
237
239
|
|
|
238
240
|
const mailboxService = {
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
|
|
2
|
+
import type { RoleResolution } from "@remit/data-ports/folder-role";
|
|
3
|
+
|
|
4
|
+
type RoleMailbox = { mailboxId: string; fullPath: string };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The evidence-carrying answer behind `findTrashMailbox`, so a stub that names
|
|
8
|
+
* a Trash folder answers both reads consistently. A named folder stands in as
|
|
9
|
+
* server-flagged: the suites using this are testing something other than how
|
|
10
|
+
* the role was decided.
|
|
11
|
+
*/
|
|
12
|
+
export const trashRole = (
|
|
13
|
+
trash: RoleMailbox | null,
|
|
14
|
+
): RoleResolution<RoleMailbox> =>
|
|
15
|
+
trash ? { kind: "flagged", mailbox: trash } : { kind: "none" };
|
|
2
16
|
|
|
3
17
|
/**
|
|
4
18
|
* A folder-role map for an account that has appointed nothing and whose server
|
|
@@ -9,23 +23,26 @@ import type { IMailboxSpecialUseRepository } from "@remit/data-ports";
|
|
|
9
23
|
export const noFolderRoles = {
|
|
10
24
|
findJunkMailbox: async () => null,
|
|
11
25
|
findTrashMailbox: async () => null,
|
|
26
|
+
resolveTrashRole: async () => trashRole(null),
|
|
12
27
|
} as unknown as IMailboxSpecialUseRepository;
|
|
13
28
|
|
|
14
29
|
/** A folder-role map naming the mailboxes that hold Junk and Trash. */
|
|
15
30
|
export const folderRoles = (roles: {
|
|
16
31
|
junkMailboxId?: string;
|
|
17
32
|
trashMailboxId?: string;
|
|
18
|
-
}): IMailboxSpecialUseRepository =>
|
|
19
|
-
|
|
33
|
+
}): IMailboxSpecialUseRepository => {
|
|
34
|
+
const trash = roles.trashMailboxId
|
|
35
|
+
? { mailboxId: roles.trashMailboxId, fullPath: roles.trashMailboxId }
|
|
36
|
+
: null;
|
|
37
|
+
return {
|
|
20
38
|
findJunkMailbox: async () =>
|
|
21
39
|
roles.junkMailboxId
|
|
22
40
|
? { mailboxId: roles.junkMailboxId, fullPath: roles.junkMailboxId }
|
|
23
41
|
: null,
|
|
24
|
-
findTrashMailbox: async () =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}) as unknown as IMailboxSpecialUseRepository;
|
|
42
|
+
findTrashMailbox: async () => trash,
|
|
43
|
+
resolveTrashRole: async () => trashRole(trash),
|
|
44
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
45
|
+
};
|
|
29
46
|
|
|
30
47
|
/** The same account, as the resolved map `saveMessage` is handed. */
|
|
31
48
|
export const NO_FOLDER_ROLES = {
|