@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.71",
3
+ "version": "0.0.73",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -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("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.
206
- const { service, events } = buildWorld(
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(
@@ -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 — so only the Empty Trash path raises it.
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 trashGate = wantsPermanentDelete
311
+ const trashResolution = wantsPermanentDelete
311
312
  ? null
312
- : trashMailboxAt(
313
- await this.mailboxSpecialUseService.resolveTrashRole(accountId),
314
- "resolved",
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
+ });