@remit/mailbox-service 0.0.17 → 0.0.19
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 +2 -2
- package/src/message-move-copy.test.ts +482 -0
- package/src/message-move.ts +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/mailbox-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"test:typecheck": "tsgo --noEmit",
|
|
23
|
-
"test:run": "node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.test.ts'",
|
|
23
|
+
"test:run": "node --env-file=../../localhost-test-unit.env --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=50 --test 'src/**/*.test.ts'",
|
|
24
24
|
"test:integ": "RUN_INTEG_TESTS=1 node --env-file=../../localhost-test-unit.env --import tsx --test 'src/**/*.integ.test.ts'",
|
|
25
25
|
"test:e2e": "RUN_E2E_TESTS=1 node --env-file=../../localhost-test-e2e.env --import tsx --test --test-concurrency=1 'src/**/*.e2e.test.ts'",
|
|
26
26
|
"test": "npm run test:typecheck && npm run test:run"
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IAddressRepository,
|
|
5
|
+
IEnvelopeRepository,
|
|
6
|
+
IMailboxRepository,
|
|
7
|
+
IMailboxSpecialUseRepository,
|
|
8
|
+
IMessageRepository,
|
|
9
|
+
IThreadMessageRepository,
|
|
10
|
+
IUnitOfWork,
|
|
11
|
+
} from "@remit/data-ports";
|
|
12
|
+
import { CreateFailedConflictError } from "@remit/data-ports/errors";
|
|
13
|
+
import { deriveCopyMessageId, deriveMessageId } from "@remit/data-ports/id";
|
|
14
|
+
import { MailboxCursorState } from "@remit/domain-enums";
|
|
15
|
+
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
16
|
+
import { type MessageMoveConfig, MessageMoveService } from "./message-move.js";
|
|
17
|
+
import { MessageSyncService } from "./message-sync.js";
|
|
18
|
+
import type { IImapConnection, ImapMessage } from "./types.js";
|
|
19
|
+
|
|
20
|
+
// A copy is a per-folder placement of the same mail. These tests pin the three
|
|
21
|
+
// properties issue #75 turns on: the copy row is deterministic (idempotent), a
|
|
22
|
+
// delete removes exactly the placement it targets, and neither the copy nor the
|
|
23
|
+
// original leaves an unreachable row behind.
|
|
24
|
+
|
|
25
|
+
const ACCOUNT = "acc-1";
|
|
26
|
+
const ACCOUNT_CONFIG = "cfg-1";
|
|
27
|
+
const SOURCE_MAILBOX = "mbx-inbox";
|
|
28
|
+
const DEST_MAILBOX = "mbx-archive";
|
|
29
|
+
const HEADER = "<abc@example.com>";
|
|
30
|
+
const SOURCE_ID = deriveMessageId(ACCOUNT, HEADER);
|
|
31
|
+
const THREAD_ID = "thread-1";
|
|
32
|
+
|
|
33
|
+
interface ThreadRow {
|
|
34
|
+
accountConfigId: string;
|
|
35
|
+
threadMessageId: string;
|
|
36
|
+
threadId: string;
|
|
37
|
+
messageId: string;
|
|
38
|
+
mailboxId: string;
|
|
39
|
+
messageIdHeader: string;
|
|
40
|
+
isRead?: boolean;
|
|
41
|
+
hasStars?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const buildWorld = () => {
|
|
45
|
+
const messages = new Map<string, Record<string, unknown>>([
|
|
46
|
+
[
|
|
47
|
+
SOURCE_ID,
|
|
48
|
+
{
|
|
49
|
+
messageId: SOURCE_ID,
|
|
50
|
+
mailboxId: SOURCE_MAILBOX,
|
|
51
|
+
uid: 42,
|
|
52
|
+
rfc822Size: 100,
|
|
53
|
+
internalDate: 1_700_000_000_000,
|
|
54
|
+
messageIdHeader: HEADER,
|
|
55
|
+
envelopeId: "env-1",
|
|
56
|
+
rootBodyPartId: "body-1",
|
|
57
|
+
bodyStorageKey: "s3://body-1",
|
|
58
|
+
category: "primary",
|
|
59
|
+
hasListUnsubscribe: false,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const threadRows: ThreadRow[] = [
|
|
65
|
+
{
|
|
66
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
67
|
+
threadMessageId: `tm:${THREAD_ID}::${SOURCE_ID}`,
|
|
68
|
+
threadId: THREAD_ID,
|
|
69
|
+
messageId: SOURCE_ID,
|
|
70
|
+
mailboxId: SOURCE_MAILBOX,
|
|
71
|
+
messageIdHeader: HEADER,
|
|
72
|
+
isRead: false,
|
|
73
|
+
hasStars: false,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const mailboxes = new Map<string, Record<string, unknown>>([
|
|
78
|
+
[
|
|
79
|
+
SOURCE_MAILBOX,
|
|
80
|
+
{ mailboxId: SOURCE_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
DEST_MAILBOX,
|
|
84
|
+
{ mailboxId: DEST_MAILBOX, fullPath: "Archive", accountId: ACCOUNT },
|
|
85
|
+
],
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const messageService = {
|
|
89
|
+
get: async (id: string | string[]) => {
|
|
90
|
+
if (Array.isArray(id)) {
|
|
91
|
+
return id.map((i) => messages.get(i)).filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
const m = messages.get(id);
|
|
94
|
+
if (!m) throw new Error(`no message ${id}`);
|
|
95
|
+
return m;
|
|
96
|
+
},
|
|
97
|
+
// Faithful to the production repo: a duplicate messageId throws a
|
|
98
|
+
// conflict. create is NOT idempotent — only upsert is — so a copy path
|
|
99
|
+
// that relied on create swallowing the duplicate would strand the second
|
|
100
|
+
// attempt here (issue #75). The copy path uses upsert for exactly this
|
|
101
|
+
// reason.
|
|
102
|
+
create: async (input: Record<string, unknown>) => {
|
|
103
|
+
const id = input.messageId as string;
|
|
104
|
+
if (messages.has(id)) {
|
|
105
|
+
throw new CreateFailedConflictError("Message", input);
|
|
106
|
+
}
|
|
107
|
+
messages.set(id, { ...input });
|
|
108
|
+
return messages.get(id);
|
|
109
|
+
},
|
|
110
|
+
upsert: async (input: Record<string, unknown>) => {
|
|
111
|
+
const id = input.messageId as string;
|
|
112
|
+
if (!messages.has(id)) messages.set(id, { ...input });
|
|
113
|
+
return messages.get(id);
|
|
114
|
+
},
|
|
115
|
+
update: async (id: string, patch: Record<string, unknown>) => {
|
|
116
|
+
const m = messages.get(id);
|
|
117
|
+
if (m) Object.assign(m, patch);
|
|
118
|
+
return m;
|
|
119
|
+
},
|
|
120
|
+
} as unknown as IMessageRepository;
|
|
121
|
+
|
|
122
|
+
const threadMessageService = {
|
|
123
|
+
create: async (input: Record<string, unknown>) => {
|
|
124
|
+
const threadId = input.threadId as string;
|
|
125
|
+
const messageId = input.messageId as string;
|
|
126
|
+
const existing = threadRows.find(
|
|
127
|
+
(r) => r.threadId === threadId && r.messageId === messageId,
|
|
128
|
+
);
|
|
129
|
+
if (existing) return existing;
|
|
130
|
+
const row: ThreadRow = {
|
|
131
|
+
accountConfigId: input.accountConfigId as string,
|
|
132
|
+
threadMessageId: `tm:${threadId}::${messageId}`,
|
|
133
|
+
threadId,
|
|
134
|
+
messageId,
|
|
135
|
+
mailboxId: input.mailboxId as string,
|
|
136
|
+
messageIdHeader: input.messageIdHeader as string,
|
|
137
|
+
};
|
|
138
|
+
threadRows.push(row);
|
|
139
|
+
return row;
|
|
140
|
+
},
|
|
141
|
+
getByMessageId: async (_cfg: string, messageId: string) => {
|
|
142
|
+
const row = threadRows.find((r) => r.messageId === messageId);
|
|
143
|
+
if (!row) throw new Error(`no thread message ${messageId}`);
|
|
144
|
+
return row;
|
|
145
|
+
},
|
|
146
|
+
findByMessageId: async (_cfg: string, messageId: string) =>
|
|
147
|
+
threadRows.find((r) => r.messageId === messageId) ?? null,
|
|
148
|
+
findAllByMessageId: async (_cfg: string, messageId: string) =>
|
|
149
|
+
threadRows.filter((r) => r.messageId === messageId),
|
|
150
|
+
delete: async (_cfg: string, threadMessageId: string) => {
|
|
151
|
+
const idx = threadRows.findIndex(
|
|
152
|
+
(r) => r.threadMessageId === threadMessageId,
|
|
153
|
+
);
|
|
154
|
+
if (idx >= 0) threadRows.splice(idx, 1);
|
|
155
|
+
},
|
|
156
|
+
} as unknown as IThreadMessageRepository;
|
|
157
|
+
|
|
158
|
+
const mailboxService = {
|
|
159
|
+
get: async (_acc: string, id: string | string[]) => {
|
|
160
|
+
if (Array.isArray(id)) {
|
|
161
|
+
return id.map((i) => mailboxes.get(i)).filter(Boolean);
|
|
162
|
+
}
|
|
163
|
+
return mailboxes.get(id);
|
|
164
|
+
},
|
|
165
|
+
} as unknown as IMailboxRepository;
|
|
166
|
+
|
|
167
|
+
// No Trash configured, so deleteMessages takes the permanent-delete path —
|
|
168
|
+
// the branch that eagerly removes ThreadMessage rows (issue #212), which is
|
|
169
|
+
// where a copy's placement row must be reachable.
|
|
170
|
+
const mailboxSpecialUseService = {
|
|
171
|
+
findTrashMailbox: async () => null,
|
|
172
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
173
|
+
|
|
174
|
+
const config: MessageMoveConfig = {
|
|
175
|
+
messageService,
|
|
176
|
+
mailboxService,
|
|
177
|
+
mailboxSpecialUseService,
|
|
178
|
+
threadMessageService,
|
|
179
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const service = new MessageMoveService(config);
|
|
183
|
+
// The service builds a real SQS producer in its constructor; a unit test has
|
|
184
|
+
// no queue, so swap in a recorder.
|
|
185
|
+
const sent: unknown[] = [];
|
|
186
|
+
(
|
|
187
|
+
service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
|
|
188
|
+
).sqs = {
|
|
189
|
+
send: async (command: unknown) => {
|
|
190
|
+
sent.push(command);
|
|
191
|
+
return {};
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
service,
|
|
197
|
+
threadRows,
|
|
198
|
+
messages,
|
|
199
|
+
sent,
|
|
200
|
+
messageService,
|
|
201
|
+
threadMessageService,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// A message copied into the destination folder, as IMAP later enumerates it:
|
|
206
|
+
// same valid Message-ID header as the source, a real server UID. Sync derives
|
|
207
|
+
// its identity from that header (folder-independent), so the id it computes is
|
|
208
|
+
// the ORIGINAL's — not the copy's folder-scoped id.
|
|
209
|
+
const destinationServerMessage = (uid: number): ImapMessage =>
|
|
210
|
+
({
|
|
211
|
+
uid,
|
|
212
|
+
seq: uid,
|
|
213
|
+
flags: [],
|
|
214
|
+
internalDate: new Date("2023-11-14T22:13:20Z"),
|
|
215
|
+
size: 100,
|
|
216
|
+
modseq: "510",
|
|
217
|
+
envelope: {
|
|
218
|
+
date: "Tue, 14 Nov 2023 22:13:20 +0000",
|
|
219
|
+
subject: "Hello",
|
|
220
|
+
from: [{ mailbox: "sender", host: "example.com" }],
|
|
221
|
+
sender: [],
|
|
222
|
+
replyTo: [],
|
|
223
|
+
to: [{ mailbox: "me", host: "example.com" }],
|
|
224
|
+
cc: [],
|
|
225
|
+
bcc: [],
|
|
226
|
+
inReplyTo: "",
|
|
227
|
+
messageId: HEADER,
|
|
228
|
+
},
|
|
229
|
+
}) as ImapMessage;
|
|
230
|
+
|
|
231
|
+
// A MessageSyncService that reads and writes the SAME rows the copy produced,
|
|
232
|
+
// so "copy then sync" is exercised against one shared world. A stored envelope
|
|
233
|
+
// with a valid Message-ID takes the CHANGEDSINCE path; anything the round tries
|
|
234
|
+
// to create lands in the shared maps, so a duplicate would be visible to the
|
|
235
|
+
// caller's row-count assertions.
|
|
236
|
+
const buildSyncOverDestination = (
|
|
237
|
+
world: ReturnType<typeof buildWorld>,
|
|
238
|
+
changed: ImapMessage[],
|
|
239
|
+
) => {
|
|
240
|
+
const destMailbox = {
|
|
241
|
+
mailboxId: DEST_MAILBOX,
|
|
242
|
+
accountId: ACCOUNT,
|
|
243
|
+
fullPath: "Archive",
|
|
244
|
+
uidValidity: 100,
|
|
245
|
+
lastSyncUid: 1,
|
|
246
|
+
highWaterMarkUid: 20,
|
|
247
|
+
highestModseq: "500",
|
|
248
|
+
cursorState: MailboxCursorState.normal,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const connection = {
|
|
252
|
+
openBox: async () => ({ uidvalidity: 100, uidnext: 99 }),
|
|
253
|
+
getMailboxStatus: async () => ({
|
|
254
|
+
messages: 10,
|
|
255
|
+
recent: 0,
|
|
256
|
+
unseen: 1,
|
|
257
|
+
uidNext: 99,
|
|
258
|
+
uidValidity: 100,
|
|
259
|
+
highestModseq: "600",
|
|
260
|
+
deletedCount: 0,
|
|
261
|
+
}),
|
|
262
|
+
supportsCondstore: () => true,
|
|
263
|
+
fetchMessagesChangedSince: async () => changed,
|
|
264
|
+
search: async () => [],
|
|
265
|
+
fetchMessages: async () => [],
|
|
266
|
+
fetchEnvelopeSnapshots: async () => [],
|
|
267
|
+
} as unknown as IImapConnection;
|
|
268
|
+
|
|
269
|
+
const connectionFactory = {
|
|
270
|
+
getConnection: () => connection,
|
|
271
|
+
close: async () => {},
|
|
272
|
+
} as ManagedConnectionFactory;
|
|
273
|
+
|
|
274
|
+
const mailboxService = {
|
|
275
|
+
get: async () => destMailbox,
|
|
276
|
+
update: async () => destMailbox,
|
|
277
|
+
} as unknown as IMailboxRepository;
|
|
278
|
+
|
|
279
|
+
// A create that reaches DynamoDB would append to the shared maps, so the
|
|
280
|
+
// caller's "nothing accumulated" assertions catch a regression that mints a
|
|
281
|
+
// third row.
|
|
282
|
+
const unitOfWork: IUnitOfWork = {
|
|
283
|
+
transaction: (fn) =>
|
|
284
|
+
fn({
|
|
285
|
+
message: world.messageService,
|
|
286
|
+
envelope: {
|
|
287
|
+
upsertEnvelope: async () => undefined,
|
|
288
|
+
upsertBodyParts: async () => undefined,
|
|
289
|
+
} as unknown as IEnvelopeRepository,
|
|
290
|
+
address: {
|
|
291
|
+
upsertAddress: async () => undefined,
|
|
292
|
+
upsertEnvelopeAddress: async () => undefined,
|
|
293
|
+
} as unknown as IAddressRepository,
|
|
294
|
+
threadMessage: world.threadMessageService,
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return new MessageSyncService(
|
|
299
|
+
connectionFactory,
|
|
300
|
+
mailboxService,
|
|
301
|
+
world.messageService,
|
|
302
|
+
{} as IEnvelopeRepository,
|
|
303
|
+
{} as IAddressRepository,
|
|
304
|
+
world.threadMessageService,
|
|
305
|
+
undefined,
|
|
306
|
+
unitOfWork,
|
|
307
|
+
);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
describe("MessageMoveService.copyMessage — deterministic per-folder identity (#75)", () => {
|
|
311
|
+
it("derives the copy id from source + destination, not at random", async () => {
|
|
312
|
+
const { service, threadRows } = buildWorld();
|
|
313
|
+
|
|
314
|
+
await service.copyMessages(
|
|
315
|
+
ACCOUNT_CONFIG,
|
|
316
|
+
[SOURCE_ID],
|
|
317
|
+
DEST_MAILBOX,
|
|
318
|
+
ACCOUNT,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const expected = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
322
|
+
const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
|
|
323
|
+
assert.ok(copyRow, "a placement row exists in the destination folder");
|
|
324
|
+
assert.equal(copyRow.messageId, expected);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("a replayed copy converges on one row, no duplicate", async () => {
|
|
328
|
+
const { service, threadRows, messages } = buildWorld();
|
|
329
|
+
|
|
330
|
+
await service.copyMessages(
|
|
331
|
+
ACCOUNT_CONFIG,
|
|
332
|
+
[SOURCE_ID],
|
|
333
|
+
DEST_MAILBOX,
|
|
334
|
+
ACCOUNT,
|
|
335
|
+
);
|
|
336
|
+
// A replay (retried COPY event, or the user copying the same mail again)
|
|
337
|
+
// re-derives the same id. The copy path upserts, so the second write is a
|
|
338
|
+
// no-op on the existing row rather than the CreateFailedConflictError a
|
|
339
|
+
// plain create would throw — the operation is idempotent.
|
|
340
|
+
await service.copyMessages(
|
|
341
|
+
ACCOUNT_CONFIG,
|
|
342
|
+
[SOURCE_ID],
|
|
343
|
+
DEST_MAILBOX,
|
|
344
|
+
ACCOUNT,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
348
|
+
assert.equal(
|
|
349
|
+
threadRows.filter((r) => r.messageId === copyId).length,
|
|
350
|
+
1,
|
|
351
|
+
"exactly one copy row after a replay",
|
|
352
|
+
);
|
|
353
|
+
assert.equal(
|
|
354
|
+
threadRows.length,
|
|
355
|
+
2,
|
|
356
|
+
"the original and one copy — nothing accumulates",
|
|
357
|
+
);
|
|
358
|
+
assert.equal(messages.has(copyId), true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("copy then a destination-folder sync creates no third row", async () => {
|
|
362
|
+
const world = buildWorld();
|
|
363
|
+
|
|
364
|
+
await world.service.copyMessages(
|
|
365
|
+
ACCOUNT_CONFIG,
|
|
366
|
+
[SOURCE_ID],
|
|
367
|
+
DEST_MAILBOX,
|
|
368
|
+
ACCOUNT,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
372
|
+
assert.equal(world.threadRows.length, 2, "original plus one copy");
|
|
373
|
+
assert.equal(world.messages.size, 2, "original plus one copy");
|
|
374
|
+
|
|
375
|
+
// IMAP now enumerates the copied message in the destination folder. Sync
|
|
376
|
+
// derives its id from the valid Message-ID header — the folder-independent
|
|
377
|
+
// original id, not the copy's — finds the existing original row, and takes
|
|
378
|
+
// the flag-only branch. The crux of #75: no third or duplicate row.
|
|
379
|
+
const sync = buildSyncOverDestination(world, [
|
|
380
|
+
destinationServerMessage(77),
|
|
381
|
+
]);
|
|
382
|
+
const result = await sync.syncMessages(
|
|
383
|
+
DEST_MAILBOX,
|
|
384
|
+
ACCOUNT,
|
|
385
|
+
ACCOUNT_CONFIG,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
assert.equal(result.syncedCount, 0, "sync created no new message");
|
|
389
|
+
assert.equal(
|
|
390
|
+
world.threadRows.length,
|
|
391
|
+
2,
|
|
392
|
+
"still original plus the copy — sync added nothing",
|
|
393
|
+
);
|
|
394
|
+
assert.equal(
|
|
395
|
+
world.messages.size,
|
|
396
|
+
2,
|
|
397
|
+
"still original plus the copy — sync added nothing",
|
|
398
|
+
);
|
|
399
|
+
assert.equal(
|
|
400
|
+
world.threadRows.filter((r) => r.messageId === copyId).length,
|
|
401
|
+
1,
|
|
402
|
+
"the copy row is untouched",
|
|
403
|
+
);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("copy then delete of the copy removes the copy row and leaves the original", async () => {
|
|
407
|
+
const { service, threadRows } = buildWorld();
|
|
408
|
+
|
|
409
|
+
await service.copyMessages(
|
|
410
|
+
ACCOUNT_CONFIG,
|
|
411
|
+
[SOURCE_ID],
|
|
412
|
+
DEST_MAILBOX,
|
|
413
|
+
ACCOUNT,
|
|
414
|
+
);
|
|
415
|
+
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
416
|
+
|
|
417
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [copyId], ACCOUNT, {
|
|
418
|
+
permanent: true,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
assert.equal(
|
|
422
|
+
threadRows.some((r) => r.messageId === copyId),
|
|
423
|
+
false,
|
|
424
|
+
"copy row removed",
|
|
425
|
+
);
|
|
426
|
+
assert.equal(
|
|
427
|
+
threadRows.some((r) => r.messageId === SOURCE_ID),
|
|
428
|
+
true,
|
|
429
|
+
"original row survives",
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("copy then delete of the original removes the original and leaves the copy — no orphan of the wrong row", async () => {
|
|
434
|
+
const { service, threadRows } = buildWorld();
|
|
435
|
+
|
|
436
|
+
await service.copyMessages(
|
|
437
|
+
ACCOUNT_CONFIG,
|
|
438
|
+
[SOURCE_ID],
|
|
439
|
+
DEST_MAILBOX,
|
|
440
|
+
ACCOUNT,
|
|
441
|
+
);
|
|
442
|
+
const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
443
|
+
|
|
444
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [SOURCE_ID], ACCOUNT, {
|
|
445
|
+
permanent: true,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
assert.equal(
|
|
449
|
+
threadRows.some((r) => r.messageId === SOURCE_ID),
|
|
450
|
+
false,
|
|
451
|
+
"original row removed",
|
|
452
|
+
);
|
|
453
|
+
assert.equal(
|
|
454
|
+
threadRows.some((r) => r.messageId === copyId),
|
|
455
|
+
true,
|
|
456
|
+
"copy row survives its own delete boundary",
|
|
457
|
+
);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("a copy into two folders is two distinct, reachable rows", async () => {
|
|
461
|
+
const { service, threadRows } = buildWorld();
|
|
462
|
+
|
|
463
|
+
await service.copyMessages(
|
|
464
|
+
ACCOUNT_CONFIG,
|
|
465
|
+
[SOURCE_ID],
|
|
466
|
+
DEST_MAILBOX,
|
|
467
|
+
ACCOUNT,
|
|
468
|
+
);
|
|
469
|
+
await service.copyMessages(
|
|
470
|
+
ACCOUNT_CONFIG,
|
|
471
|
+
[SOURCE_ID],
|
|
472
|
+
SOURCE_MAILBOX,
|
|
473
|
+
ACCOUNT,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const idArchive = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
|
|
477
|
+
const idInbox = deriveCopyMessageId(SOURCE_ID, SOURCE_MAILBOX);
|
|
478
|
+
assert.notEqual(idArchive, idInbox);
|
|
479
|
+
assert.equal(threadRows.filter((r) => r.messageId === idArchive).length, 1);
|
|
480
|
+
assert.equal(threadRows.filter((r) => r.messageId === idInbox).length, 1);
|
|
481
|
+
});
|
|
482
|
+
});
|
package/src/message-move.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
IMessageRepository,
|
|
11
11
|
IThreadMessageRepository,
|
|
12
12
|
} from "@remit/data-ports";
|
|
13
|
-
import {
|
|
13
|
+
import { deriveCopyMessageId } from "@remit/data-ports/id";
|
|
14
14
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
15
15
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
16
16
|
|
|
@@ -450,11 +450,20 @@ export class MessageMoveService {
|
|
|
450
450
|
destinationMailboxId,
|
|
451
451
|
);
|
|
452
452
|
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
//
|
|
457
|
-
|
|
453
|
+
// Deterministic identity for the copy: derived from the source message and
|
|
454
|
+
// the destination mailbox, so a replayed COPY event or a repeated copy
|
|
455
|
+
// resolves to the same row instead of a fresh unreachable duplicate. A
|
|
456
|
+
// random id (the prior behaviour) left rows no sync or delete could reach
|
|
457
|
+
// (issue #75).
|
|
458
|
+
const newMessageId = deriveCopyMessageId(messageId, destinationMailboxId);
|
|
459
|
+
|
|
460
|
+
// Upsert, not create: the id is deterministic, so a repeated copy or a
|
|
461
|
+
// retry after a partial failure (row written, event never delivered)
|
|
462
|
+
// re-derives the same id. create throws on the existing row and strands
|
|
463
|
+
// the copy at uid=0 with an undelivered event; upsert no-ops on the row
|
|
464
|
+
// and lets the event be re-enqueued below, keeping the operation
|
|
465
|
+
// idempotent and the copy re-drivable (issue #75).
|
|
466
|
+
await this.messageService.upsert({
|
|
458
467
|
messageId: newMessageId,
|
|
459
468
|
mailboxId: destinationMailboxId,
|
|
460
469
|
uid: 0, // Will be updated by worker after IMAP COPY
|