@remit/mailbox-service 0.0.66 → 0.0.68
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 +169 -27
- package/src/message-move-same-mailbox.test.ts +2 -0
- package/src/message-move.ts +126 -21
- 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,58 @@ 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
|
+
schemaVersion?: number;
|
|
53
|
+
trashMailboxId?: string;
|
|
54
|
+
trashUidValidity?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
23
57
|
const buildWorld = (
|
|
24
|
-
|
|
58
|
+
trashResolution: RoleResolution<TrashMailbox>,
|
|
59
|
+
trashContents: TrashMessage[] = [
|
|
60
|
+
{ messageId: "junk-1", syncStatus: "synced" },
|
|
61
|
+
],
|
|
25
62
|
) => {
|
|
26
63
|
const emptied: string[] = [];
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
64
|
+
const markedDeleting: string[] = [];
|
|
65
|
+
const events: EnqueuedEvent[] = [];
|
|
66
|
+
const messagesByMailbox = new Map<string, TrashMessage[]>([
|
|
67
|
+
[DELETED_FOLDER, [{ messageId: "keepsake-1", syncStatus: "synced" }]],
|
|
68
|
+
[REAL_TRASH, trashContents],
|
|
30
69
|
]);
|
|
31
70
|
|
|
32
71
|
const messageService = {
|
|
@@ -34,7 +73,9 @@ const buildWorld = (
|
|
|
34
73
|
emptied.push(mailboxId);
|
|
35
74
|
return messagesByMailbox.get(mailboxId) ?? [];
|
|
36
75
|
},
|
|
37
|
-
update: async () => {
|
|
76
|
+
update: async (messageId: string) => {
|
|
77
|
+
markedDeleting.push(messageId);
|
|
78
|
+
},
|
|
38
79
|
} as unknown as IMessageRepository;
|
|
39
80
|
|
|
40
81
|
const threadMessageService = {
|
|
@@ -48,18 +89,17 @@ const buildWorld = (
|
|
|
48
89
|
|
|
49
90
|
const config: MessageMoveConfig = {
|
|
50
91
|
messageService,
|
|
51
|
-
mailboxService: {
|
|
92
|
+
mailboxService: {
|
|
93
|
+
get: async (_accountId: string, mailboxId: string) => ({
|
|
94
|
+
mailboxId,
|
|
95
|
+
uidValidity: 42,
|
|
96
|
+
}),
|
|
97
|
+
} as unknown as IMailboxRepository,
|
|
52
98
|
addressService: {
|
|
53
99
|
reconcileJunkOnlyForMessage: async () => {},
|
|
54
100
|
} as unknown as IAddressRepository,
|
|
55
101
|
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,
|
|
102
|
+
resolveTrashRole: async () => trashResolution,
|
|
63
103
|
} as unknown as IMailboxSpecialUseRepository,
|
|
64
104
|
threadMessageService,
|
|
65
105
|
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
@@ -67,18 +107,19 @@ const buildWorld = (
|
|
|
67
107
|
|
|
68
108
|
const service = new MessageMoveService(config);
|
|
69
109
|
(
|
|
70
|
-
service as unknown as {
|
|
71
|
-
|
|
110
|
+
service as unknown as {
|
|
111
|
+
enqueueEvent: (event: EnqueuedEvent) => Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
).enqueueEvent = async (event) => {
|
|
114
|
+
events.push(event);
|
|
115
|
+
};
|
|
72
116
|
|
|
73
|
-
return { service, emptied };
|
|
117
|
+
return { service, emptied, markedDeleting, events };
|
|
74
118
|
};
|
|
75
119
|
|
|
76
120
|
describe("MessageMoveService.emptyTrash", () => {
|
|
77
121
|
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
|
-
});
|
|
122
|
+
const { service, emptied } = buildWorld(appointedTrash);
|
|
82
123
|
|
|
83
124
|
await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
84
125
|
|
|
@@ -86,15 +127,116 @@ describe("MessageMoveService.emptyTrash", () => {
|
|
|
86
127
|
});
|
|
87
128
|
|
|
88
129
|
it("refuses and names the remedy when no folder is appointed or flagged", async () => {
|
|
89
|
-
const { service, emptied } = buildWorld(
|
|
130
|
+
const { service, emptied } = buildWorld({ kind: "none" });
|
|
90
131
|
|
|
91
132
|
await assert.rejects(
|
|
92
133
|
service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
|
|
93
|
-
|
|
134
|
+
(error: unknown) =>
|
|
135
|
+
error instanceof NoTrashMailboxError &&
|
|
136
|
+
error.publicApiError?.details?.reason === "none",
|
|
94
137
|
);
|
|
95
138
|
|
|
96
139
|
// Nothing was read, so nothing was marked for deletion and no expunge
|
|
97
140
|
// was enqueued: an unresolved Trash stops the operation dead.
|
|
98
141
|
assert.deepEqual(emptied, []);
|
|
99
142
|
});
|
|
143
|
+
|
|
144
|
+
it("refuses a Trash that only resolves by name, under its own reason", async () => {
|
|
145
|
+
// D18: a plausible folder nobody confirmed is a third answer, distinct
|
|
146
|
+
// from having none. The refusal is what mints the appointment.
|
|
147
|
+
const { service, emptied, markedDeleting } = buildWorld(
|
|
148
|
+
proposedDeletedFolder,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await assert.rejects(
|
|
152
|
+
service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
|
|
153
|
+
(error: unknown) =>
|
|
154
|
+
error instanceof UnconfirmedTrashMailboxError &&
|
|
155
|
+
error.statusCode === 409 &&
|
|
156
|
+
error.publicApiError?.details?.reason === "unconfirmed" &&
|
|
157
|
+
error.publicApiError?.details?.accountId === ACCOUNT,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
assert.deepEqual(emptied, []);
|
|
161
|
+
assert.deepEqual(markedDeleting, []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("refuses a stale appointment rather than emptying its fallback", async () => {
|
|
165
|
+
const { service, emptied, markedDeleting } = buildWorld({
|
|
166
|
+
kind: "appointment_stale",
|
|
167
|
+
appointedMailboxId: "mbx-appointed-and-gone",
|
|
168
|
+
fallback: { kind: "flagged", mailbox: appointedTrash.mailbox },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await assert.rejects(
|
|
172
|
+
service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT),
|
|
173
|
+
(error: unknown) =>
|
|
174
|
+
error instanceof StaleTrashAppointmentError &&
|
|
175
|
+
error.publicApiError?.details?.reason === "stale",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(emptied, []);
|
|
179
|
+
assert.deepEqual(markedDeleting, []);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("reports what it marked, from the one read that decided it", async () => {
|
|
183
|
+
const { service, markedDeleting } = buildWorld(appointedTrash, [
|
|
184
|
+
{ messageId: "junk-1", syncStatus: "synced" },
|
|
185
|
+
{ messageId: "junk-2", syncStatus: "synced" },
|
|
186
|
+
{ messageId: "junk-3", syncStatus: "synced" },
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
190
|
+
|
|
191
|
+
assert.equal(deletedCount, markedDeleting.length);
|
|
192
|
+
assert.equal(deletedCount, 3);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("marks and counts a message whose move to Trash has not settled", async () => {
|
|
196
|
+
// The user saw the message in Trash and asked for the folder to be
|
|
197
|
+
// emptied. Skipping it reports a number the folder contradicts, and the
|
|
198
|
+
// queue is per-account FIFO, so the move has landed on the server before
|
|
199
|
+
// the expunge is even delivered.
|
|
200
|
+
const { service, markedDeleting } = buildWorld(appointedTrash, [
|
|
201
|
+
{ messageId: "settled-1", syncStatus: "synced" },
|
|
202
|
+
{ messageId: "still-moving-1", syncStatus: "pending" },
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
206
|
+
|
|
207
|
+
assert.deepEqual(markedDeleting, ["settled-1", "still-moving-1"]);
|
|
208
|
+
assert.equal(deletedCount, 2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("reports the same count when pressed twice before the worker runs", async () => {
|
|
212
|
+
// The rows it marked are still in that folder, so N stays true and the
|
|
213
|
+
// re-mark is idempotent. Reporting 0 the second time while still
|
|
214
|
+
// enqueuing an expunge would read as success over an untouched Trash.
|
|
215
|
+
const { service, events } = buildWorld(appointedTrash, [
|
|
216
|
+
{ messageId: "junk-1", syncStatus: "synced" },
|
|
217
|
+
{ messageId: "junk-2", syncStatus: "synced" },
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
const first = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
221
|
+
const second = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
222
|
+
|
|
223
|
+
assert.equal(first.deletedCount, 2);
|
|
224
|
+
assert.equal(second.deletedCount, 2);
|
|
225
|
+
assert.deepEqual(
|
|
226
|
+
events.map((event) => event.type),
|
|
227
|
+
["EMPTY_TRASH", "EMPTY_TRASH"],
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("carries the folder's identity as it stood at consent time", async () => {
|
|
232
|
+
// The worker compares this against what its own SELECT serves. Without
|
|
233
|
+
// it the expunge would be authorised by a path, and a path is reusable.
|
|
234
|
+
const { service, events } = buildWorld(appointedTrash);
|
|
235
|
+
|
|
236
|
+
await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
|
|
237
|
+
|
|
238
|
+
assert.equal(events[0]?.schemaVersion, 2);
|
|
239
|
+
assert.equal(events[0]?.trashMailboxId, REAL_TRASH);
|
|
240
|
+
assert.equal(events[0]?.trashUidValidity, 42);
|
|
241
|
+
});
|
|
100
242
|
});
|
|
@@ -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,8 +12,17 @@ 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";
|
|
25
|
+
import { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
|
|
17
26
|
import {
|
|
18
27
|
CanonicalMailboxRole,
|
|
19
28
|
MessageStatus,
|
|
@@ -27,6 +36,7 @@ import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
|
27
36
|
*/
|
|
28
37
|
interface MessageDeleteEvent {
|
|
29
38
|
type: "MESSAGE_DELETE";
|
|
39
|
+
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
30
40
|
eventId: string;
|
|
31
41
|
timestamp: number;
|
|
32
42
|
accountId: string;
|
|
@@ -54,11 +64,14 @@ interface MessageMoveEvent {
|
|
|
54
64
|
|
|
55
65
|
interface EmptyTrashEvent {
|
|
56
66
|
type: "EMPTY_TRASH";
|
|
67
|
+
schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
|
|
57
68
|
eventId: string;
|
|
58
69
|
timestamp: number;
|
|
59
70
|
accountId: string;
|
|
60
71
|
trashMailboxId: string;
|
|
61
72
|
trashMailboxPath: string;
|
|
73
|
+
/** The folder's UIDVALIDITY at consent time; the worker re-checks it. */
|
|
74
|
+
trashUidValidity: number;
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
interface MessageCopyEvent {
|
|
@@ -129,6 +142,74 @@ export class NoTrashMailboxError extends FolderRoleUnresolvedError {
|
|
|
129
142
|
}
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The folder the user appointed as Trash is gone from the server. Distinct from
|
|
147
|
+
* having no Trash at all: there is a decision on file, and the repair is to
|
|
148
|
+
* point it at a folder that exists — not to let reader pick one instead.
|
|
149
|
+
*/
|
|
150
|
+
export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
|
|
151
|
+
name = "StaleTrashAppointmentError";
|
|
152
|
+
|
|
153
|
+
constructor(accountId: string) {
|
|
154
|
+
super(
|
|
155
|
+
STALE_TRASH_FOLDER_REASON,
|
|
156
|
+
CanonicalMailboxRole.Trash,
|
|
157
|
+
"stale",
|
|
158
|
+
accountId,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A folder resolves as Trash by its name alone. Enough to move mail into,
|
|
165
|
+
* never enough to expunge — so only the Empty Trash path raises it.
|
|
166
|
+
*/
|
|
167
|
+
export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
|
|
168
|
+
name = "UnconfirmedTrashMailboxError";
|
|
169
|
+
|
|
170
|
+
constructor(accountId: string) {
|
|
171
|
+
super(
|
|
172
|
+
UNCONFIRMED_TRASH_FOLDER_REASON,
|
|
173
|
+
CanonicalMailboxRole.Trash,
|
|
174
|
+
"unconfirmed",
|
|
175
|
+
accountId,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const REFUSAL_BY_REASON: Record<
|
|
181
|
+
FolderRoleUnresolvedReason,
|
|
182
|
+
new (
|
|
183
|
+
accountId: string,
|
|
184
|
+
) => FolderRoleUnresolvedError
|
|
185
|
+
> = {
|
|
186
|
+
none: NoTrashMailboxError,
|
|
187
|
+
stale: StaleTrashAppointmentError,
|
|
188
|
+
unconfirmed: UnconfirmedTrashMailboxError,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** The coded 409 for one refusal reason. The only place a reason becomes an error. */
|
|
192
|
+
const trashRefusal = (
|
|
193
|
+
reason: FolderRoleUnresolvedReason,
|
|
194
|
+
accountId: string,
|
|
195
|
+
): FolderRoleUnresolvedError => new REFUSAL_BY_REASON[reason](accountId);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The Trash folder a verb may act on at the assurance it demands, or the coded
|
|
199
|
+
* refusal naming which of the three reasons stopped it. Every Trash gate goes
|
|
200
|
+
* through here — the delete and the expunge — so no two of them can tell a
|
|
201
|
+
* user different things about the same account.
|
|
202
|
+
*/
|
|
203
|
+
export const requireTrashMailbox = <T>(
|
|
204
|
+
resolution: RoleResolution<T>,
|
|
205
|
+
level: TrashAssuranceLevel,
|
|
206
|
+
accountId: string,
|
|
207
|
+
): T => {
|
|
208
|
+
const outcome = trashMailboxAt(resolution, level);
|
|
209
|
+
if (outcome.allowed) return outcome.mailbox;
|
|
210
|
+
throw trashRefusal(outcome.reason, accountId);
|
|
211
|
+
};
|
|
212
|
+
|
|
132
213
|
/**
|
|
133
214
|
* Options for delete operations
|
|
134
215
|
*/
|
|
@@ -226,18 +307,23 @@ export class MessageMoveService {
|
|
|
226
307
|
const wantsPermanentDelete =
|
|
227
308
|
options.permanent === true || options.toTrash === false;
|
|
228
309
|
|
|
229
|
-
const
|
|
310
|
+
const trashGate = wantsPermanentDelete
|
|
230
311
|
? null
|
|
231
|
-
:
|
|
312
|
+
: trashMailboxAt(
|
|
313
|
+
await this.mailboxSpecialUseService.resolveTrashRole(accountId),
|
|
314
|
+
"resolved",
|
|
315
|
+
);
|
|
232
316
|
|
|
233
|
-
if (
|
|
317
|
+
if (trashGate && !trashGate.allowed) {
|
|
234
318
|
this.log.error(
|
|
235
|
-
{ accountId, messageCount: messages.length },
|
|
236
|
-
"Refused to delete: account
|
|
319
|
+
{ accountId, messageCount: messages.length, reason: trashGate.reason },
|
|
320
|
+
"Refused to delete: this account's Trash is unresolved",
|
|
237
321
|
);
|
|
238
|
-
throw
|
|
322
|
+
throw trashRefusal(trashGate.reason, accountId);
|
|
239
323
|
}
|
|
240
324
|
|
|
325
|
+
const trashMailbox = trashGate ? trashGate.mailbox : null;
|
|
326
|
+
|
|
241
327
|
// Group messages by operation type
|
|
242
328
|
const moveToTrashMessages: Array<{
|
|
243
329
|
messageId: string;
|
|
@@ -301,6 +387,7 @@ export class MessageMoveService {
|
|
|
301
387
|
|
|
302
388
|
events.push({
|
|
303
389
|
type: "MESSAGE_DELETE",
|
|
390
|
+
schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
|
|
304
391
|
eventId: randomUUID(),
|
|
305
392
|
timestamp: Date.now(),
|
|
306
393
|
accountId,
|
|
@@ -344,6 +431,7 @@ export class MessageMoveService {
|
|
|
344
431
|
|
|
345
432
|
events.push({
|
|
346
433
|
type: "MESSAGE_DELETE",
|
|
434
|
+
schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
|
|
347
435
|
eventId: randomUUID(),
|
|
348
436
|
timestamp: Date.now(),
|
|
349
437
|
accountId,
|
|
@@ -659,26 +747,39 @@ export class MessageMoveService {
|
|
|
659
747
|
/**
|
|
660
748
|
* Empty the Trash mailbox: an EXPUNGE of everything in it, with no undo.
|
|
661
749
|
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
* `Deleted` would otherwise lose its contents
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
750
|
+
* Demands confirmed evidence: the folder the user appointed or the one the
|
|
751
|
+
* server flagged \Trash — never one that merely reads like a trash folder,
|
|
752
|
+
* and never the fallback behind an appointment that went missing. A user
|
|
753
|
+
* with an ordinary folder called `Deleted` would otherwise lose its contents
|
|
754
|
+
* permanently. Each of the three ways that evidence can be absent refuses
|
|
755
|
+
* under its own reason, so the surface can name the remedy instead of
|
|
756
|
+
* offering a silent no-op.
|
|
757
|
+
*
|
|
758
|
+
* Reports the rows it marked, read once here, so the number the caller shows
|
|
759
|
+
* a user is the number this operation acted on. Pressed twice before the
|
|
760
|
+
* worker runs it re-marks the same rows and reports the same number: the
|
|
761
|
+
* folder really does still hold them, and the second expunge finds nothing
|
|
762
|
+
* to do.
|
|
669
763
|
*/
|
|
670
764
|
emptyTrash = async (
|
|
671
765
|
accountConfigId: string,
|
|
672
766
|
accountId: string,
|
|
673
|
-
): Promise<
|
|
674
|
-
const trashMailbox =
|
|
675
|
-
await this.mailboxSpecialUseService.
|
|
767
|
+
): Promise<{ deletedCount: number }> => {
|
|
768
|
+
const trashMailbox = requireTrashMailbox(
|
|
769
|
+
await this.mailboxSpecialUseService.resolveTrashRole(accountId),
|
|
770
|
+
"confirmed",
|
|
771
|
+
accountId,
|
|
772
|
+
);
|
|
676
773
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
774
|
+
// The identity the user consented to, carried on the event so the worker
|
|
775
|
+
// can tell this folder from a different one that has since taken over its
|
|
776
|
+
// path. Read here rather than re-read there: the value at consent time is
|
|
777
|
+
// the whole point, and the stored row is rewritten by every sync.
|
|
778
|
+
const { uidValidity: trashUidValidity } = await this.mailboxService.get(
|
|
779
|
+
accountId,
|
|
780
|
+
trashMailbox.mailboxId,
|
|
781
|
+
);
|
|
680
782
|
|
|
681
|
-
// Get all messages in Trash
|
|
682
783
|
const messages = await this.messageService.listAllByMailbox(
|
|
683
784
|
trashMailbox.mailboxId,
|
|
684
785
|
);
|
|
@@ -708,14 +809,18 @@ export class MessageMoveService {
|
|
|
708
809
|
// Enqueue single event for worker to handle batch
|
|
709
810
|
const event: EmptyTrashEvent = {
|
|
710
811
|
type: "EMPTY_TRASH",
|
|
812
|
+
schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
|
|
711
813
|
eventId: randomUUID(),
|
|
712
814
|
timestamp: Date.now(),
|
|
713
815
|
accountId,
|
|
714
816
|
trashMailboxId: trashMailbox.mailboxId,
|
|
715
817
|
trashMailboxPath: trashMailbox.fullPath,
|
|
818
|
+
trashUidValidity,
|
|
716
819
|
};
|
|
717
820
|
|
|
718
821
|
await this.enqueueEvent(event);
|
|
822
|
+
|
|
823
|
+
return { deletedCount: messages.length };
|
|
719
824
|
};
|
|
720
825
|
|
|
721
826
|
/**
|
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 = {
|