@remit/mailbox-service 0.0.71 → 0.0.73
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
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
MessageMoveService,
|
|
14
14
|
NoTrashMailboxError,
|
|
15
15
|
StaleTrashAppointmentError,
|
|
16
|
+
UnconfirmedTrashMailboxError,
|
|
16
17
|
} from "./message-move.js";
|
|
17
18
|
|
|
18
19
|
const ACCOUNT = "acc-1";
|
|
@@ -200,14 +201,34 @@ describe("delete only expunges when it was asked to", () => {
|
|
|
200
201
|
);
|
|
201
202
|
});
|
|
202
203
|
|
|
203
|
-
it("
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
204
|
+
it("refuses to expunge a message already inside a Trash that only resolves by name", async () => {
|
|
205
|
+
// #876: a message already sitting in the guessed folder used to fall
|
|
206
|
+
// straight through to an expunge, on the same name guess Empty Trash
|
|
207
|
+
// refuses to act on. Deleting it is exactly as unrecoverable, so it
|
|
208
|
+
// demands the same confirmed evidence — nobody, neither the user nor the
|
|
209
|
+
// server, ever said this folder is Trash.
|
|
210
|
+
const { service, patches, events, message } = buildWorld(
|
|
207
211
|
{ kind: "proposed", mailbox: { mailboxId: TRASH, fullPath: "Trash" } },
|
|
208
212
|
TRASH,
|
|
209
213
|
);
|
|
210
214
|
|
|
215
|
+
await assert.rejects(
|
|
216
|
+
() => service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT),
|
|
217
|
+
(error: unknown) =>
|
|
218
|
+
error instanceof UnconfirmedTrashMailboxError &&
|
|
219
|
+
error.statusCode === 409 &&
|
|
220
|
+
error.publicApiError?.details?.reason === "unconfirmed" &&
|
|
221
|
+
error.publicApiError?.details?.accountId === ACCOUNT,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
assert.deepEqual(events, []);
|
|
225
|
+
assert.deepEqual(patches, []);
|
|
226
|
+
assert.equal(message.mailboxId, TRASH);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("expunges a message already inside a confirmed Trash", async () => {
|
|
230
|
+
const { service, events } = buildWorld(flaggedTrash, TRASH);
|
|
231
|
+
|
|
211
232
|
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
212
233
|
|
|
213
234
|
assert.deepEqual(
|
package/src/message-move.ts
CHANGED
|
@@ -162,7 +162,8 @@ export class StaleTrashAppointmentError extends FolderRoleUnresolvedError {
|
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* A folder resolves as Trash by its name alone. Enough to move mail into,
|
|
165
|
-
* never enough to expunge —
|
|
165
|
+
* never enough to expunge — raised by Empty Trash and by a delete that would
|
|
166
|
+
* expunge a message already sitting in that folder.
|
|
166
167
|
*/
|
|
167
168
|
export class UnconfirmedTrashMailboxError extends FolderRoleUnresolvedError {
|
|
168
169
|
name = "UnconfirmedTrashMailboxError";
|
|
@@ -307,12 +308,13 @@ export class MessageMoveService {
|
|
|
307
308
|
const wantsPermanentDelete =
|
|
308
309
|
options.permanent === true || options.toTrash === false;
|
|
309
310
|
|
|
310
|
-
const
|
|
311
|
+
const trashResolution = wantsPermanentDelete
|
|
311
312
|
? null
|
|
312
|
-
:
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
313
|
+
: await this.mailboxSpecialUseService.resolveTrashRole(accountId);
|
|
314
|
+
|
|
315
|
+
const trashGate = trashResolution
|
|
316
|
+
? trashMailboxAt(trashResolution, "resolved")
|
|
317
|
+
: null;
|
|
316
318
|
|
|
317
319
|
if (trashGate && !trashGate.allowed) {
|
|
318
320
|
this.log.error(
|
|
@@ -324,6 +326,34 @@ export class MessageMoveService {
|
|
|
324
326
|
|
|
325
327
|
const trashMailbox = trashGate ? trashGate.mailbox : null;
|
|
326
328
|
|
|
329
|
+
// A message already sitting in the folder resolved as Trash deletes by
|
|
330
|
+
// expunging it, not by moving it — the same unrecoverable act Empty
|
|
331
|
+
// Trash performs, so it demands the same confirmed evidence. The name
|
|
332
|
+
// guess ("resolved") is enough to file mail into; it is never enough to
|
|
333
|
+
// destroy mail that is already there. Checked once, before any local
|
|
334
|
+
// write, so a batch that touches such a message is refused whole rather
|
|
335
|
+
// than partially expunged.
|
|
336
|
+
if (!wantsPermanentDelete && trashResolution && trashMailbox) {
|
|
337
|
+
const deletesInPlace = messages.some(
|
|
338
|
+
(message) => message.mailboxId === trashMailbox.mailboxId,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (deletesInPlace) {
|
|
342
|
+
const confirmedTrashGate = trashMailboxAt(trashResolution, "confirmed");
|
|
343
|
+
if (!confirmedTrashGate.allowed) {
|
|
344
|
+
this.log.error(
|
|
345
|
+
{
|
|
346
|
+
accountId,
|
|
347
|
+
messageCount: messages.length,
|
|
348
|
+
reason: confirmedTrashGate.reason,
|
|
349
|
+
},
|
|
350
|
+
"Refused to delete: this account's Trash is not confirmed",
|
|
351
|
+
);
|
|
352
|
+
throw trashRefusal(confirmedTrashGate.reason, accountId);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
327
357
|
// Group messages by operation type
|
|
328
358
|
const moveToTrashMessages: Array<{
|
|
329
359
|
messageId: string;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A row the SMTP worker settled has to be a row the user can still act on.
|
|
3
|
+
*
|
|
4
|
+
* `sending` is not: `send` takes draft, failed and blocked, and `deleteDraft`
|
|
5
|
+
* those three plus `unfiled`, so a send that never reached the server left a
|
|
6
|
+
* row that 409s on both buttons forever (#951). The worker now settles that
|
|
7
|
+
* row instead of dead-lettering it, and these are the two moves the settled
|
|
8
|
+
* status has to accept for the settle to be worth anything.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "node:test";
|
|
13
|
+
import type {
|
|
14
|
+
IAccountRepository,
|
|
15
|
+
IOutboxMessageRepository,
|
|
16
|
+
OutboxMessageItem,
|
|
17
|
+
} from "@remit/data-ports";
|
|
18
|
+
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
19
|
+
import type { OutboxAttachmentService } from "./outbox-attachment.js";
|
|
20
|
+
import { OutboxQueueService } from "./outbox-queue.js";
|
|
21
|
+
|
|
22
|
+
const ACCOUNT_CONFIG_ID = "cfg-1";
|
|
23
|
+
const ACCOUNT_ID = "acc-1";
|
|
24
|
+
const OUTBOX_MESSAGE_ID = "ob-1";
|
|
25
|
+
|
|
26
|
+
const row = (status: OutboxMessageItem["status"]): OutboxMessageItem =>
|
|
27
|
+
({
|
|
28
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
29
|
+
accountId: ACCOUNT_ID,
|
|
30
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
31
|
+
fromAddress: "me@example.com",
|
|
32
|
+
toAddresses: ["them@example.com"],
|
|
33
|
+
ccAddresses: [],
|
|
34
|
+
bccAddresses: [],
|
|
35
|
+
references: [],
|
|
36
|
+
messageIdValue: "<m1@example.com>",
|
|
37
|
+
lastError: "SMTP connection failed: ECONNREFUSED",
|
|
38
|
+
status,
|
|
39
|
+
createdAt: 0,
|
|
40
|
+
updatedAt: 0,
|
|
41
|
+
}) as unknown as OutboxMessageItem;
|
|
42
|
+
|
|
43
|
+
interface Harness {
|
|
44
|
+
service: OutboxQueueService;
|
|
45
|
+
enqueued: string[];
|
|
46
|
+
statusWrites: string[];
|
|
47
|
+
deleted: string[];
|
|
48
|
+
discarded: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const createHarness = (stored: OutboxMessageItem): Harness => {
|
|
52
|
+
const harness: Harness = {
|
|
53
|
+
service: undefined as unknown as OutboxQueueService,
|
|
54
|
+
enqueued: [],
|
|
55
|
+
statusWrites: [],
|
|
56
|
+
deleted: [],
|
|
57
|
+
discarded: [],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const outboxMessageService = {
|
|
61
|
+
get: async () => stored,
|
|
62
|
+
updateStatus: async (
|
|
63
|
+
_configId: string,
|
|
64
|
+
_id: string,
|
|
65
|
+
status: OutboxMessageItem["status"],
|
|
66
|
+
) => {
|
|
67
|
+
harness.statusWrites.push(status);
|
|
68
|
+
return { ...stored, status };
|
|
69
|
+
},
|
|
70
|
+
delete: async (_configId: string, id: string) => {
|
|
71
|
+
harness.deleted.push(id);
|
|
72
|
+
},
|
|
73
|
+
} as unknown as IOutboxMessageRepository;
|
|
74
|
+
|
|
75
|
+
harness.service = new OutboxQueueService({
|
|
76
|
+
outboxMessageService,
|
|
77
|
+
outboxAttachmentService: {
|
|
78
|
+
discardAll: async (_configId: string, _accountId: string, id: string) => {
|
|
79
|
+
harness.discarded.push(id);
|
|
80
|
+
},
|
|
81
|
+
} as unknown as OutboxAttachmentService,
|
|
82
|
+
accountService: {} as unknown as IAccountRepository,
|
|
83
|
+
sqsSmtpQueueUrl: "http://localhost/queue",
|
|
84
|
+
sqsClient: {
|
|
85
|
+
send: async (command: { input: { MessageBody: string } }) => {
|
|
86
|
+
harness.enqueued.push(command.input.MessageBody);
|
|
87
|
+
return {};
|
|
88
|
+
},
|
|
89
|
+
} as never,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return harness;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
describe("a row the worker settled at `failed`", () => {
|
|
96
|
+
it("is sendable again — Retry is the whole point of settling there", async () => {
|
|
97
|
+
const harness = createHarness(row(OutboxMessageStatus.failed));
|
|
98
|
+
|
|
99
|
+
await harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(harness.statusWrites, [OutboxMessageStatus.queued]);
|
|
102
|
+
assert.equal(harness.enqueued.length, 1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("is discardable — the other way out of the Outbox", async () => {
|
|
106
|
+
const harness = createHarness(row(OutboxMessageStatus.failed));
|
|
107
|
+
|
|
108
|
+
await harness.service.deleteDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
109
|
+
|
|
110
|
+
assert.deepEqual(harness.deleted, [OUTBOX_MESSAGE_ID]);
|
|
111
|
+
assert.deepEqual(harness.discarded, [OUTBOX_MESSAGE_ID]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("a row the worker settled at `unfiled`", () => {
|
|
116
|
+
it("is discardable but never sendable — the server may already hold it", async () => {
|
|
117
|
+
const harness = createHarness(row(OutboxMessageStatus.unfiled));
|
|
118
|
+
|
|
119
|
+
await assert.rejects(
|
|
120
|
+
() => harness.service.send(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID),
|
|
121
|
+
/cannot be sent again/,
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(harness.enqueued, [], "nothing reached the SMTP queue");
|
|
124
|
+
|
|
125
|
+
await harness.service.deleteDraft(ACCOUNT_CONFIG_ID, OUTBOX_MESSAGE_ID);
|
|
126
|
+
assert.deepEqual(harness.deleted, [OUTBOX_MESSAGE_ID]);
|
|
127
|
+
});
|
|
128
|
+
});
|