@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 +1 -1
- package/src/message-move-same-mailbox.test.ts +125 -0
- package/src/message-move.ts +14 -0
- package/src/outbox-queue.ts +14 -10
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/message-move.ts
CHANGED
|
@@ -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,
|
package/src/outbox-queue.ts
CHANGED
|
@@ -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 =
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
141
|
-
`
|
|
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
|
|
188
|
-
`
|
|
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
|
|
256
|
-
`
|
|
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
|
|