@remit/mailbox-service 0.0.59 → 0.0.60
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
package/src/index.ts
CHANGED
|
@@ -98,10 +98,12 @@ describe("deleting a message re-asks what its senders stand on", () => {
|
|
|
98
98
|
assert.deepEqual(reconciled, []);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
it("asks nothing when the account has no Trash folder", async () => {
|
|
101
|
+
it("asks nothing when the account has no Trash folder, because the delete is refused", async () => {
|
|
102
102
|
const { service, reconciled } = buildWorld(false);
|
|
103
103
|
|
|
104
|
-
await
|
|
104
|
+
await assert.rejects(() =>
|
|
105
|
+
service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
|
|
106
|
+
);
|
|
105
107
|
|
|
106
108
|
assert.deepEqual(reconciled, []);
|
|
107
109
|
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IAddressRepository,
|
|
5
|
+
IMailboxRepository,
|
|
6
|
+
IMailboxSpecialUseRepository,
|
|
7
|
+
IMessageRepository,
|
|
8
|
+
IThreadMessageRepository,
|
|
9
|
+
} from "@remit/data-ports";
|
|
10
|
+
import {
|
|
11
|
+
type MessageMoveConfig,
|
|
12
|
+
MessageMoveService,
|
|
13
|
+
NoTrashMailboxError,
|
|
14
|
+
} from "./message-move.js";
|
|
15
|
+
|
|
16
|
+
const ACCOUNT = "acc-1";
|
|
17
|
+
const ACCOUNT_CONFIG = "cfg-1";
|
|
18
|
+
const INBOX = "mbx-inbox";
|
|
19
|
+
const TRASH = "mbx-trash";
|
|
20
|
+
const MESSAGE_ID = "msg-1";
|
|
21
|
+
|
|
22
|
+
interface EnqueuedEvent {
|
|
23
|
+
operation: string;
|
|
24
|
+
destinationMailboxPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const buildWorld = (
|
|
28
|
+
trash: { mailboxId: string; fullPath: string } | null,
|
|
29
|
+
startingMailboxId = INBOX,
|
|
30
|
+
) => {
|
|
31
|
+
const patches: Array<Record<string, unknown>> = [];
|
|
32
|
+
const events: EnqueuedEvent[] = [];
|
|
33
|
+
const threadMessageDeletes: string[] = [];
|
|
34
|
+
|
|
35
|
+
const message = {
|
|
36
|
+
messageId: MESSAGE_ID,
|
|
37
|
+
mailboxId: startingMailboxId,
|
|
38
|
+
uid: 337,
|
|
39
|
+
status: "active",
|
|
40
|
+
syncStatus: "synced",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const messageService = {
|
|
44
|
+
get: async () => [message],
|
|
45
|
+
update: async (_id: string, patch: Record<string, unknown>) => {
|
|
46
|
+
patches.push(patch);
|
|
47
|
+
return Object.assign(message, patch);
|
|
48
|
+
},
|
|
49
|
+
updateForMove: async (_id: string, patch: Record<string, unknown>) => {
|
|
50
|
+
patches.push(patch);
|
|
51
|
+
return Object.assign(message, patch);
|
|
52
|
+
},
|
|
53
|
+
} as unknown as IMessageRepository;
|
|
54
|
+
|
|
55
|
+
const threadMessageService = {
|
|
56
|
+
findAllByMessageId: async () => [],
|
|
57
|
+
getByMessageId: async () => ({
|
|
58
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
59
|
+
threadMessageId: "tm-1",
|
|
60
|
+
messageId: MESSAGE_ID,
|
|
61
|
+
mailboxId: startingMailboxId,
|
|
62
|
+
}),
|
|
63
|
+
update: async () => {},
|
|
64
|
+
delete: async (_cfg: string, id: string) => {
|
|
65
|
+
threadMessageDeletes.push(id);
|
|
66
|
+
},
|
|
67
|
+
} as unknown as IThreadMessageRepository;
|
|
68
|
+
|
|
69
|
+
const mailboxService = {
|
|
70
|
+
get: async () => [
|
|
71
|
+
{ mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
72
|
+
{ mailboxId: TRASH, fullPath: "INBOX/Trash", accountId: ACCOUNT },
|
|
73
|
+
],
|
|
74
|
+
} as unknown as IMailboxRepository;
|
|
75
|
+
|
|
76
|
+
const mailboxSpecialUseService = {
|
|
77
|
+
findTrashMailbox: async () => trash,
|
|
78
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
79
|
+
|
|
80
|
+
const addressService = {
|
|
81
|
+
reconcileJunkOnlyForMessage: async () => {},
|
|
82
|
+
} as unknown as IAddressRepository;
|
|
83
|
+
|
|
84
|
+
const config: MessageMoveConfig = {
|
|
85
|
+
messageService,
|
|
86
|
+
mailboxService,
|
|
87
|
+
mailboxSpecialUseService,
|
|
88
|
+
threadMessageService,
|
|
89
|
+
addressService,
|
|
90
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const service = new MessageMoveService(config);
|
|
94
|
+
(
|
|
95
|
+
service as unknown as {
|
|
96
|
+
enqueueEventsBatch: (batch: EnqueuedEvent[]) => Promise<void>;
|
|
97
|
+
}
|
|
98
|
+
).enqueueEventsBatch = async (batch) => {
|
|
99
|
+
events.push(...batch);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return { service, patches, events, message };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
describe("delete only expunges when it was asked to", () => {
|
|
106
|
+
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
|
+
});
|
|
111
|
+
|
|
112
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
113
|
+
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
events.map((event) => event.operation),
|
|
116
|
+
["move_to_trash"],
|
|
117
|
+
);
|
|
118
|
+
assert.equal(events[0].destinationMailboxPath, "INBOX/Trash");
|
|
119
|
+
assert.equal(message.mailboxId, TRASH);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("refuses, and touches nothing, when no Trash resolves", async () => {
|
|
123
|
+
const { service, patches, events, message } = buildWorld(null);
|
|
124
|
+
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
() => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
|
|
127
|
+
(error: unknown) =>
|
|
128
|
+
error instanceof NoTrashMailboxError &&
|
|
129
|
+
error.statusCode === 409 &&
|
|
130
|
+
error.message.includes("no Trash folder"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
assert.deepEqual(events, []);
|
|
134
|
+
assert.deepEqual(patches, []);
|
|
135
|
+
assert.equal(message.mailboxId, INBOX);
|
|
136
|
+
assert.equal(message.status, "active");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("expunges only when the caller asked for a permanent delete", async () => {
|
|
140
|
+
const { service, events } = buildWorld({
|
|
141
|
+
mailboxId: TRASH,
|
|
142
|
+
fullPath: "INBOX/Trash",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT, {
|
|
146
|
+
permanent: true,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
events.map((event) => event.operation),
|
|
151
|
+
["permanent_delete"],
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("expunges a message already in Trash, which the dialog asks as such", async () => {
|
|
156
|
+
const { service, events } = buildWorld(
|
|
157
|
+
{ mailboxId: TRASH, fullPath: "INBOX/Trash" },
|
|
158
|
+
TRASH,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(
|
|
164
|
+
events.map((event) => event.operation),
|
|
165
|
+
["permanent_delete"],
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/message-move.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
IMessageRepository,
|
|
12
12
|
IThreadMessageRepository,
|
|
13
13
|
} from "@remit/data-ports";
|
|
14
|
+
import { HTTPError } from "@remit/data-ports/errors";
|
|
14
15
|
import { deriveCopyMessageId } from "@remit/data-ports/id";
|
|
15
16
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
16
17
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
@@ -103,6 +104,23 @@ export interface MessageMoveConfig {
|
|
|
103
104
|
logger?: MessageMoveLogger;
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The account resolves no Trash folder, so a move-to-Trash delete has nowhere
|
|
109
|
+
* to put the mail. A designed, user-facing outcome carrying its own text and
|
|
110
|
+
* a 409 so the backend returns it verbatim: the absence of a Trash folder is
|
|
111
|
+
* a blocker the user resolves by appointing one, never a licence to expunge.
|
|
112
|
+
*/
|
|
113
|
+
export class NoTrashMailboxError extends HTTPError {
|
|
114
|
+
name = "NoTrashMailboxError";
|
|
115
|
+
statusCode = 409;
|
|
116
|
+
|
|
117
|
+
constructor() {
|
|
118
|
+
super(
|
|
119
|
+
"This account has no Trash folder, so nothing was deleted. Appoint one under Settings → Folder roles, then delete again.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
/**
|
|
107
125
|
* Options for delete operations
|
|
108
126
|
*/
|
|
@@ -191,9 +209,26 @@ export class MessageMoveService {
|
|
|
191
209
|
);
|
|
192
210
|
const mailboxMap = new Map(mailboxes.map((m) => [m.mailboxId, m]));
|
|
193
211
|
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
// A permanent delete is only ever what the caller explicitly asked for.
|
|
213
|
+
// Everything else is a move to Trash, and a Trash folder that does not
|
|
214
|
+
// resolve blocks it — the user was asked "move to Trash?" and expunging
|
|
215
|
+
// their mail instead is unrecoverable and answers a question nobody put
|
|
216
|
+
// to them. Thrown before any local write or event, so the server and the
|
|
217
|
+
// projection are both left untouched.
|
|
218
|
+
const wantsPermanentDelete =
|
|
219
|
+
options.permanent === true || options.toTrash === false;
|
|
220
|
+
|
|
221
|
+
const trashMailbox = wantsPermanentDelete
|
|
222
|
+
? null
|
|
223
|
+
: await this.mailboxSpecialUseService.findTrashMailbox(accountId);
|
|
224
|
+
|
|
225
|
+
if (!wantsPermanentDelete && !trashMailbox) {
|
|
226
|
+
this.log.error(
|
|
227
|
+
{ accountId, messageCount: messages.length },
|
|
228
|
+
"Refused to delete: account resolves no Trash mailbox",
|
|
229
|
+
);
|
|
230
|
+
throw new NoTrashMailboxError();
|
|
231
|
+
}
|
|
197
232
|
|
|
198
233
|
// Group messages by operation type
|
|
199
234
|
const moveToTrashMessages: Array<{
|
|
@@ -212,12 +247,8 @@ export class MessageMoveService {
|
|
|
212
247
|
if (!sourceMailbox) continue;
|
|
213
248
|
|
|
214
249
|
const isInTrash =
|
|
215
|
-
trashMailbox && message.mailboxId === trashMailbox.mailboxId;
|
|
216
|
-
const shouldMoveToTrash =
|
|
217
|
-
options.toTrash !== false &&
|
|
218
|
-
!options.permanent &&
|
|
219
|
-
!isInTrash &&
|
|
220
|
-
trashMailbox;
|
|
250
|
+
trashMailbox !== null && message.mailboxId === trashMailbox.mailboxId;
|
|
251
|
+
const shouldMoveToTrash = trashMailbox !== null && !isInTrash;
|
|
221
252
|
|
|
222
253
|
const entry = {
|
|
223
254
|
messageId: message.messageId,
|
|
@@ -228,7 +259,7 @@ export class MessageMoveService {
|
|
|
228
259
|
},
|
|
229
260
|
};
|
|
230
261
|
|
|
231
|
-
if (shouldMoveToTrash
|
|
262
|
+
if (shouldMoveToTrash) {
|
|
232
263
|
moveToTrashMessages.push(entry);
|
|
233
264
|
} else {
|
|
234
265
|
permanentDeleteMessages.push(entry);
|