@remit/mailbox-service 0.0.44 → 0.0.46

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.44",
3
+ "version": "0.0.46",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ IMailboxRepository,
5
+ IMailboxSpecialUseRepository,
6
+ IMessageRepository,
7
+ IThreadMessageRepository,
8
+ } from "@remit/data-ports";
9
+ import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
10
+
11
+ const ACCOUNT = "acc-1";
12
+ const ACCOUNT_CONFIG = "cfg-1";
13
+ const INBOX = "mbx-inbox";
14
+ const ARCHIVE = "mbx-archive";
15
+ const MESSAGE_ID = "msg-1";
16
+
17
+ const buildWorld = () => {
18
+ const message = {
19
+ messageId: MESSAGE_ID,
20
+ mailboxId: INBOX,
21
+ uid: 337,
22
+ status: "active",
23
+ syncStatus: "synced",
24
+ };
25
+
26
+ const threadRow = {
27
+ accountConfigId: ACCOUNT_CONFIG,
28
+ threadMessageId: "tm-1",
29
+ messageId: MESSAGE_ID,
30
+ mailboxId: INBOX,
31
+ sentDate: 1_700_000_000_000,
32
+ isRead: false,
33
+ isDeleted: false,
34
+ hasStars: false,
35
+ hasAttachment: false,
36
+ };
37
+
38
+ const mailboxes = new Map<string, Record<string, unknown>>([
39
+ [INBOX, { mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT }],
40
+ [ARCHIVE, { mailboxId: ARCHIVE, fullPath: "Archive", accountId: ACCOUNT }],
41
+ ]);
42
+
43
+ const messageService = {
44
+ get: async () => message,
45
+ update: async (_id: string, patch: Record<string, unknown>) =>
46
+ Object.assign(message, patch),
47
+ updateForMove: async (_id: string, patch: Record<string, unknown>) =>
48
+ Object.assign(message, patch),
49
+ } as unknown as IMessageRepository;
50
+
51
+ const threadMessageService = {
52
+ getByMessageId: async () => threadRow,
53
+ update: async (_cfg: string, _id: string, patch: Record<string, unknown>) =>
54
+ Object.assign(threadRow, patch),
55
+ } as unknown as IThreadMessageRepository;
56
+
57
+ const mailboxService = {
58
+ get: async (_acc: string, id: string) => mailboxes.get(id),
59
+ } as unknown as IMailboxRepository;
60
+
61
+ const mailboxSpecialUseService = {
62
+ findTrashMailbox: async () => null,
63
+ } as unknown as IMailboxSpecialUseRepository;
64
+
65
+ const config: MessageMoveConfig = {
66
+ messageService,
67
+ mailboxService,
68
+ mailboxSpecialUseService,
69
+ threadMessageService,
70
+ sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
71
+ };
72
+
73
+ const service = new MessageMoveService(config);
74
+ const sent: unknown[] = [];
75
+ (
76
+ service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
77
+ ).sqs = {
78
+ send: async (command: unknown) => {
79
+ sent.push(command);
80
+ return {};
81
+ },
82
+ };
83
+
84
+ return { service, message, threadRow, sent };
85
+ };
86
+
87
+ // IMAP has no in-place MOVE: a same-mailbox move expunges the message and
88
+ // re-appends it under a new UID, so a request naming the folder the message is
89
+ // already in destroys its identity for nothing. It reached the mail server
90
+ // because the client can fire the same move twice — the second one after the
91
+ // first has already landed.
92
+ describe("MessageMoveService.moveMessage — same-mailbox move (#594)", () => {
93
+ it("enqueues nothing when the destination is the mailbox the message is in", async () => {
94
+ const { service, sent } = buildWorld();
95
+
96
+ await service.moveMessages(ACCOUNT_CONFIG, [MESSAGE_ID], INBOX, ACCOUNT);
97
+
98
+ assert.equal(sent.length, 0, "no IMAP move event was enqueued");
99
+ });
100
+
101
+ it("leaves the message row exactly as it was", async () => {
102
+ const { service, message, threadRow } = buildWorld();
103
+
104
+ await service.moveMessages(ACCOUNT_CONFIG, [MESSAGE_ID], INBOX, ACCOUNT);
105
+
106
+ assert.deepEqual(message, {
107
+ messageId: MESSAGE_ID,
108
+ mailboxId: INBOX,
109
+ uid: 337,
110
+ status: "active",
111
+ syncStatus: "synced",
112
+ });
113
+ assert.equal(threadRow.mailboxId, INBOX);
114
+ });
115
+
116
+ it("still moves a message whose destination is a different mailbox", async () => {
117
+ const { service, message, sent } = buildWorld();
118
+
119
+ await service.moveMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ARCHIVE, ACCOUNT);
120
+
121
+ assert.equal(sent.length, 1, "the real move is enqueued");
122
+ assert.equal(message.mailboxId, ARCHIVE);
123
+ assert.equal(message.syncStatus, "pending");
124
+ });
125
+ });
@@ -335,6 +335,20 @@ export class MessageMoveService {
335
335
  accountId: string,
336
336
  ): Promise<void> => {
337
337
  const message = await this.messageService.get(messageId);
338
+
339
+ // IMAP has no same-mailbox MOVE: the server copies the message, expunges
340
+ // the original and hands back a fresh UID, so a request that asks for the
341
+ // mailbox the message is already in destroys its identity to reach the
342
+ // state it was already in. The requested end state holds, so there is
343
+ // nothing to record as pending and nothing to enqueue.
344
+ if (message.mailboxId === destinationMailboxId) {
345
+ this.log.info(
346
+ { messageId, mailboxId: destinationMailboxId },
347
+ "Skipping move: message is already in the destination mailbox",
348
+ );
349
+ return;
350
+ }
351
+
338
352
  const sourceMailbox = await this.mailboxService.get(
339
353
  accountId,
340
354
  message.mailboxId,
@@ -5,6 +5,7 @@ import type {
5
5
  IOutboxMessageRepository,
6
6
  OutboxMessageItem,
7
7
  } from "@remit/data-ports";
8
+ import { ConflictError } from "@remit/data-ports/errors";
8
9
  import { OutboxMessageStatus } from "@remit/domain-enums";
9
10
  import { createQueueProducer } from "@remit/sqs-client/producer";
10
11
 
@@ -31,6 +32,7 @@ export interface OutboxQueueConfig {
31
32
  accountService: IAccountRepository;
32
33
  sqsSmtpQueueUrl: string;
33
34
  sqsEndpoint?: string;
35
+ sqsClient?: SQSClient;
34
36
  logger?: OutboxQueueLogger;
35
37
  }
36
38
 
@@ -91,10 +93,12 @@ export class OutboxQueueService {
91
93
  this.queueUrl = sqsSmtpQueueUrl;
92
94
  this.log = config.logger ?? noopLogger;
93
95
 
94
- this.sqs = createQueueProducer({
95
- queueUrl: sqsSmtpQueueUrl,
96
- endpoint: sqsEndpoint,
97
- });
96
+ this.sqs =
97
+ config.sqsClient ??
98
+ createQueueProducer({
99
+ queueUrl: sqsSmtpQueueUrl,
100
+ endpoint: sqsEndpoint,
101
+ });
98
102
  }
99
103
 
100
104
  createDraft = async (input: CreateDraftInput): Promise<OutboxMessageItem> => {
@@ -137,8 +141,8 @@ export class OutboxQueueService {
137
141
  "act",
138
142
  );
139
143
  if (existing.status !== OutboxMessageStatus.draft) {
140
- throw new Error(
141
- `Cannot update outbox message with status: ${existing.status}`,
144
+ throw new ConflictError(
145
+ `This message is already ${existing.status} and can no longer be edited as a draft. Start a new message to change it.`,
142
146
  );
143
147
  }
144
148
 
@@ -184,8 +188,8 @@ export class OutboxQueueService {
184
188
  existing.status !== OutboxMessageStatus.failed &&
185
189
  existing.status !== OutboxMessageStatus.blocked
186
190
  ) {
187
- throw new Error(
188
- `Cannot send outbox message with status: ${existing.status}`,
191
+ throw new ConflictError(
192
+ `This message is already ${existing.status} and cannot be sent again. Open the Outbox to see where it stands.`,
189
193
  );
190
194
  }
191
195
 
@@ -252,8 +256,8 @@ export class OutboxQueueService {
252
256
  existing.status !== OutboxMessageStatus.failed &&
253
257
  existing.status !== OutboxMessageStatus.blocked
254
258
  ) {
255
- throw new Error(
256
- `Cannot delete outbox message with status: ${existing.status}`,
259
+ throw new ConflictError(
260
+ `This message is already ${existing.status} and can no longer be discarded. Open the Outbox to see where it stands.`,
257
261
  );
258
262
  }
259
263