@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.59",
3
+ "version": "0.0.60",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -185,6 +185,7 @@ export {
185
185
  type MessageMoveConfig,
186
186
  type MessageMoveLogger,
187
187
  MessageMoveService,
188
+ NoTrashMailboxError,
188
189
  } from "./message-move.js";
189
190
  export {
190
191
  type ParsedMessageContent,
@@ -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 service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
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
+ });
@@ -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
- // Find trash mailbox once
195
- const trashMailbox =
196
- await this.mailboxSpecialUseService.findTrashMailbox(accountId);
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 && trashMailbox) {
262
+ if (shouldMoveToTrash) {
232
263
  moveToTrashMessages.push(entry);
233
264
  } else {
234
265
  permanentDeleteMessages.push(entry);