@remit/mailbox-service 0.0.110 → 0.0.112

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.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +226 -200
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +19 -15
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/types.ts +1 -1
  74. package/src/adversarial-mime.e2e.test.ts +0 -307
  75. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  76. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  77. package/src/body-sync-category-denormalize.test.ts +0 -352
  78. package/src/body-sync-category-override.test.ts +0 -501
  79. package/src/body-sync-category-writeonce.test.ts +0 -234
  80. package/src/body-sync-filter.test.ts +0 -237
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -1,562 +0,0 @@
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 {
19
- NO_JUNK_ROLES,
20
- noFolderRoles,
21
- trashRole,
22
- } from "./test-helpers/folder-roles.js";
23
- import type { IImapConnection, ImapMessage } from "./types.js";
24
-
25
- const stubAddressService = (): IAddressRepository =>
26
- ({
27
- reconcileJunkOnlyForMessage: async () => {},
28
- }) as unknown as IAddressRepository;
29
-
30
- // A copy is a per-folder placement of the same mail. These tests pin the three
31
- // properties issue #75 turns on: the copy row is deterministic (idempotent), a
32
- // delete removes exactly the placement it targets, and neither the copy nor the
33
- // original leaves an unreachable row behind.
34
-
35
- const ACCOUNT = "acc-1";
36
- const ACCOUNT_CONFIG = "cfg-1";
37
- const SOURCE_MAILBOX = "mbx-inbox";
38
- const DEST_MAILBOX = "mbx-archive";
39
- const HEADER = "<abc@example.com>";
40
- const SOURCE_ID = deriveMessageId(ACCOUNT, HEADER);
41
- const THREAD_ID = "thread-1";
42
-
43
- interface ThreadRow {
44
- accountConfigId: string;
45
- threadMessageId: string;
46
- threadId: string;
47
- messageId: string;
48
- mailboxId: string;
49
- messageIdHeader: string;
50
- isRead?: boolean;
51
- hasStars?: boolean;
52
- listId?: string;
53
- }
54
-
55
- const buildWorld = () => {
56
- const messages = new Map<string, Record<string, unknown>>([
57
- [
58
- SOURCE_ID,
59
- {
60
- messageId: SOURCE_ID,
61
- mailboxId: SOURCE_MAILBOX,
62
- uid: 42,
63
- rfc822Size: 100,
64
- internalDate: 1_700_000_000_000,
65
- messageIdHeader: HEADER,
66
- envelopeId: "env-1",
67
- rootBodyPartId: "body-1",
68
- bodyStorageKey: "s3://body-1",
69
- category: "primary",
70
- classificationState: "Examined",
71
- hasListUnsubscribe: false,
72
- // Settled, which is what a delete's predicate asks for: a row every
73
- // repository read carries a status, and leaving it off made the
74
- // fixture answer for a state no row is ever in.
75
- status: "active",
76
- syncStatus: "synced",
77
- },
78
- ],
79
- ]);
80
-
81
- const threadRows: ThreadRow[] = [
82
- {
83
- accountConfigId: ACCOUNT_CONFIG,
84
- threadMessageId: `tm:${THREAD_ID}::${SOURCE_ID}`,
85
- threadId: THREAD_ID,
86
- messageId: SOURCE_ID,
87
- mailboxId: SOURCE_MAILBOX,
88
- messageIdHeader: HEADER,
89
- isRead: false,
90
- hasStars: false,
91
- listId: "invitations.linkedin.com",
92
- },
93
- ];
94
-
95
- const mailboxes = new Map<string, Record<string, unknown>>([
96
- [
97
- SOURCE_MAILBOX,
98
- { mailboxId: SOURCE_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
99
- ],
100
- [
101
- DEST_MAILBOX,
102
- { mailboxId: DEST_MAILBOX, fullPath: "Archive", accountId: ACCOUNT },
103
- ],
104
- ]);
105
-
106
- const messageService = {
107
- get: async (id: string | string[]) => {
108
- if (Array.isArray(id)) {
109
- return id.map((i) => messages.get(i)).filter(Boolean);
110
- }
111
- const m = messages.get(id);
112
- if (!m) throw new Error(`no message ${id}`);
113
- return m;
114
- },
115
- // Faithful to the production repo: a duplicate messageId throws a
116
- // conflict. create is NOT idempotent — only upsert is — so a copy path
117
- // that relied on create swallowing the duplicate would strand the second
118
- // attempt here (issue #75). The copy path uses upsert for exactly this
119
- // reason.
120
- create: async (input: Record<string, unknown>) => {
121
- const id = input.messageId as string;
122
- if (messages.has(id)) {
123
- throw new CreateFailedConflictError("Message", input);
124
- }
125
- messages.set(id, { ...input });
126
- return messages.get(id);
127
- },
128
- upsert: async (input: Record<string, unknown>) => {
129
- const id = input.messageId as string;
130
- if (!messages.has(id)) messages.set(id, { ...input });
131
- return messages.get(id);
132
- },
133
- update: async (id: string, patch: Record<string, unknown>) => {
134
- const m = messages.get(id);
135
- if (m) Object.assign(m, patch);
136
- return m;
137
- },
138
- transitionPlacement: async (
139
- id: string,
140
- expected: Record<string, unknown>,
141
- patch: Record<string, unknown>,
142
- ) => {
143
- const m = messages.get(id) as Record<string, unknown> | undefined;
144
- if (!m) return undefined;
145
- for (const [field, want] of Object.entries(expected)) {
146
- if (want !== undefined && m[field] !== want) return undefined;
147
- }
148
- Object.assign(m, patch);
149
- return m;
150
- },
151
- } as unknown as IMessageRepository;
152
-
153
- const threadMessageService = {
154
- create: async (input: Record<string, unknown>) => {
155
- const threadId = input.threadId as string;
156
- const messageId = input.messageId as string;
157
- const existing = threadRows.find(
158
- (r) => r.threadId === threadId && r.messageId === messageId,
159
- );
160
- if (existing) return existing;
161
- const row: ThreadRow = {
162
- accountConfigId: input.accountConfigId as string,
163
- threadMessageId: `tm:${threadId}::${messageId}`,
164
- threadId,
165
- messageId,
166
- mailboxId: input.mailboxId as string,
167
- messageIdHeader: input.messageIdHeader as string,
168
- listId: input.listId as string | undefined,
169
- };
170
- threadRows.push(row);
171
- return row;
172
- },
173
- getByMessageId: async (_cfg: string, messageId: string) => {
174
- const row = threadRows.find((r) => r.messageId === messageId);
175
- if (!row) throw new Error(`no thread message ${messageId}`);
176
- return row;
177
- },
178
- findByMessageId: async (_cfg: string, messageId: string) =>
179
- threadRows.find((r) => r.messageId === messageId) ?? null,
180
- findAllByMessageId: async (_cfg: string, messageId: string) =>
181
- threadRows.filter((r) => r.messageId === messageId),
182
- delete: async (_cfg: string, threadMessageId: string) => {
183
- const idx = threadRows.findIndex(
184
- (r) => r.threadMessageId === threadMessageId,
185
- );
186
- if (idx >= 0) threadRows.splice(idx, 1);
187
- },
188
- } as unknown as IThreadMessageRepository;
189
-
190
- const mailboxService = {
191
- get: async (_acc: string, id: string | string[]) => {
192
- if (Array.isArray(id)) {
193
- return id.map((i) => mailboxes.get(i)).filter(Boolean);
194
- }
195
- return mailboxes.get(id);
196
- },
197
- } as unknown as IMailboxRepository;
198
-
199
- // No Trash configured, so deleteMessages takes the permanent-delete path —
200
- // the branch that eagerly removes ThreadMessage rows (issue #212), which is
201
- // where a copy's placement row must be reachable.
202
- const mailboxSpecialUseService = {
203
- findTrashMailbox: async () => null,
204
- resolveTrashRole: async () => trashRole(null),
205
- resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
206
- } as unknown as IMailboxSpecialUseRepository;
207
-
208
- const config: MessageMoveConfig = {
209
- messageService,
210
- mailboxService,
211
- mailboxSpecialUseService,
212
- threadMessageService,
213
- addressService: stubAddressService(),
214
- sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
215
- };
216
-
217
- const service = new MessageMoveService(config);
218
- // The service builds a real SQS producer in its constructor; a unit test has
219
- // no queue, so swap in a recorder.
220
- const sent: unknown[] = [];
221
- (
222
- service as unknown as { sqs: { send: (c: unknown) => Promise<unknown> } }
223
- ).sqs = {
224
- send: async (command: unknown) => {
225
- sent.push(command);
226
- return {};
227
- },
228
- };
229
-
230
- return {
231
- service,
232
- threadRows,
233
- messages,
234
- sent,
235
- messageService,
236
- threadMessageService,
237
- };
238
- };
239
-
240
- // A message copied into the destination folder, as IMAP later enumerates it:
241
- // same valid Message-ID header as the source, a real server UID. Sync derives
242
- // its identity from that header (folder-independent), so the id it computes is
243
- // the ORIGINAL's — not the copy's folder-scoped id.
244
- const destinationServerMessage = (uid: number): ImapMessage =>
245
- ({
246
- uid,
247
- seq: uid,
248
- flags: [],
249
- internalDate: new Date("2023-11-14T22:13:20Z"),
250
- size: 100,
251
- modseq: "510",
252
- envelope: {
253
- date: "Tue, 14 Nov 2023 22:13:20 +0000",
254
- subject: "Hello",
255
- from: [{ mailbox: "sender", host: "example.com" }],
256
- sender: [],
257
- replyTo: [],
258
- to: [{ mailbox: "me", host: "example.com" }],
259
- cc: [],
260
- bcc: [],
261
- inReplyTo: "",
262
- messageId: HEADER,
263
- },
264
- }) as ImapMessage;
265
-
266
- // A MessageSyncService that reads and writes the SAME rows the copy produced,
267
- // so "copy then sync" is exercised against one shared world. A stored envelope
268
- // with a valid Message-ID takes the CHANGEDSINCE path; anything the round tries
269
- // to create lands in the shared maps, so a duplicate would be visible to the
270
- // caller's row-count assertions.
271
- const buildSyncOverDestination = (
272
- world: ReturnType<typeof buildWorld>,
273
- changed: ImapMessage[],
274
- ) => {
275
- const destMailbox = {
276
- mailboxId: DEST_MAILBOX,
277
- accountId: ACCOUNT,
278
- fullPath: "Archive",
279
- uidValidity: 100,
280
- lastSyncUid: 1,
281
- highWaterMarkUid: 20,
282
- highestModseq: "500",
283
- cursorState: MailboxCursorState.normal,
284
- };
285
-
286
- const connection = {
287
- openBox: async () => ({ uidvalidity: 100, uidnext: 99 }),
288
- getMailboxStatus: async () => ({
289
- messages: 10,
290
- recent: 0,
291
- unseen: 1,
292
- uidNext: 99,
293
- uidValidity: 100,
294
- highestModseq: "600",
295
- deletedCount: 0,
296
- }),
297
- supportsCondstore: () => true,
298
- fetchMessagesChangedSince: async () => changed,
299
- search: async () => [],
300
- fetchMessages: async () => [],
301
- fetchEnvelopeSnapshots: async () => [],
302
- } as unknown as IImapConnection;
303
-
304
- const connectionFactory = {
305
- getConnection: () => connection,
306
- close: async () => {},
307
- } as ManagedConnectionFactory;
308
-
309
- const mailboxService = {
310
- get: async () => destMailbox,
311
- update: async () => destMailbox,
312
- } as unknown as IMailboxRepository;
313
-
314
- // A create that reaches DynamoDB would append to the shared maps, so the
315
- // caller's "nothing accumulated" assertions catch a regression that mints a
316
- // third row.
317
- const unitOfWork: IUnitOfWork = {
318
- transaction: (fn) =>
319
- fn({
320
- message: world.messageService,
321
- envelope: {
322
- upsertEnvelope: async () => undefined,
323
- upsertBodyParts: async () => undefined,
324
- } as unknown as IEnvelopeRepository,
325
- address: {
326
- upsertCorrespondentAddress: async () => undefined,
327
- upsertEnvelopeAddress: async () => undefined,
328
- } as unknown as IAddressRepository,
329
- threadMessage: world.threadMessageService,
330
- }),
331
- };
332
-
333
- return new MessageSyncService(
334
- connectionFactory,
335
- mailboxService,
336
- noFolderRoles,
337
- world.messageService,
338
- {} as IEnvelopeRepository,
339
- {} as IAddressRepository,
340
- world.threadMessageService,
341
- undefined,
342
- unitOfWork,
343
- );
344
- };
345
-
346
- describe("MessageMoveService.copyMessage — deterministic per-folder identity (#75)", () => {
347
- it("derives the copy id from source + destination, not at random", async () => {
348
- const { service, threadRows } = buildWorld();
349
-
350
- await service.copyMessages(
351
- ACCOUNT_CONFIG,
352
- [SOURCE_ID],
353
- DEST_MAILBOX,
354
- ACCOUNT,
355
- );
356
-
357
- const expected = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
358
- const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
359
- assert.ok(copyRow, "a placement row exists in the destination folder");
360
- assert.equal(copyRow.messageId, expected);
361
- });
362
-
363
- it("carries the source row's list id onto the copy", async () => {
364
- // The copy inherits its source's stored body, so no classifying pass ever
365
- // runs over it to derive a list id of its own (issue #331).
366
- const { service, threadRows } = buildWorld();
367
-
368
- await service.copyMessages(
369
- ACCOUNT_CONFIG,
370
- [SOURCE_ID],
371
- DEST_MAILBOX,
372
- ACCOUNT,
373
- );
374
-
375
- const copyRow = threadRows.find((r) => r.mailboxId === DEST_MAILBOX);
376
- assert.equal(copyRow?.listId, "invitations.linkedin.com");
377
- });
378
-
379
- it("carries the source's classification state onto the copy", async () => {
380
- // Defaulting the copy to `NotExamined` would put a row no classifier will
381
- // ever reach — its body is already stored — into the cohort a backfill
382
- // selects as never examined (issue #331).
383
- const { service, messages } = buildWorld();
384
-
385
- await service.copyMessages(
386
- ACCOUNT_CONFIG,
387
- [SOURCE_ID],
388
- DEST_MAILBOX,
389
- ACCOUNT,
390
- );
391
-
392
- const copy = [...messages.values()].find(
393
- (m) => m.mailboxId === DEST_MAILBOX,
394
- );
395
- assert.equal(copy?.classificationState, "Examined");
396
- });
397
-
398
- it("a replayed copy converges on one row, no duplicate", async () => {
399
- const { service, threadRows, messages } = buildWorld();
400
-
401
- await service.copyMessages(
402
- ACCOUNT_CONFIG,
403
- [SOURCE_ID],
404
- DEST_MAILBOX,
405
- ACCOUNT,
406
- );
407
- // A replay (retried COPY event, or the user copying the same mail again)
408
- // re-derives the same id. The copy path upserts, so the second write is a
409
- // no-op on the existing row rather than the CreateFailedConflictError a
410
- // plain create would throw — the operation is idempotent.
411
- await service.copyMessages(
412
- ACCOUNT_CONFIG,
413
- [SOURCE_ID],
414
- DEST_MAILBOX,
415
- ACCOUNT,
416
- );
417
-
418
- const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
419
- assert.equal(
420
- threadRows.filter((r) => r.messageId === copyId).length,
421
- 1,
422
- "exactly one copy row after a replay",
423
- );
424
- assert.equal(
425
- threadRows.length,
426
- 2,
427
- "the original and one copy — nothing accumulates",
428
- );
429
- assert.equal(messages.has(copyId), true);
430
- });
431
-
432
- it("copy then a destination-folder sync creates no third row", async () => {
433
- const world = buildWorld();
434
-
435
- await world.service.copyMessages(
436
- ACCOUNT_CONFIG,
437
- [SOURCE_ID],
438
- DEST_MAILBOX,
439
- ACCOUNT,
440
- );
441
-
442
- const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
443
- assert.equal(world.threadRows.length, 2, "original plus one copy");
444
- assert.equal(world.messages.size, 2, "original plus one copy");
445
-
446
- // IMAP now enumerates the copied message in the destination folder. Sync
447
- // derives its id from the valid Message-ID header — the folder-independent
448
- // original id, not the copy's — finds the existing original row, and takes
449
- // the flag-only branch. The crux of #75: no third or duplicate row.
450
- const sync = buildSyncOverDestination(world, [
451
- destinationServerMessage(77),
452
- ]);
453
- const result = await sync.syncMessages(
454
- DEST_MAILBOX,
455
- ACCOUNT,
456
- ACCOUNT_CONFIG,
457
- );
458
-
459
- assert.equal(result.syncedCount, 0, "sync created no new message");
460
- assert.equal(
461
- world.threadRows.length,
462
- 2,
463
- "still original plus the copy — sync added nothing",
464
- );
465
- assert.equal(
466
- world.messages.size,
467
- 2,
468
- "still original plus the copy — sync added nothing",
469
- );
470
- assert.equal(
471
- world.threadRows.filter((r) => r.messageId === copyId).length,
472
- 1,
473
- "the copy row is untouched",
474
- );
475
- });
476
-
477
- it("copy then delete of the copy removes the copy row and leaves the original", async () => {
478
- const { service, threadRows, messages } = buildWorld();
479
-
480
- await service.copyMessages(
481
- ACCOUNT_CONFIG,
482
- [SOURCE_ID],
483
- DEST_MAILBOX,
484
- ACCOUNT,
485
- );
486
- const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
487
-
488
- // A fresh copy row is `moving` until the server hands back its COPYUID, and
489
- // a delete against it would name uid 0. Settle it the way `updateUid`
490
- // does, which is also the only state in which a user can see it to delete.
491
- Object.assign(messages.get(copyId) ?? {}, {
492
- uid: 77,
493
- status: "active",
494
- syncStatus: "synced",
495
- });
496
-
497
- await service.deleteMessages(ACCOUNT_CONFIG, [copyId], ACCOUNT, {
498
- permanent: true,
499
- });
500
-
501
- assert.equal(
502
- threadRows.some((r) => r.messageId === copyId),
503
- false,
504
- "copy row removed",
505
- );
506
- assert.equal(
507
- threadRows.some((r) => r.messageId === SOURCE_ID),
508
- true,
509
- "original row survives",
510
- );
511
- });
512
-
513
- it("copy then delete of the original removes the original and leaves the copy — no orphan of the wrong row", async () => {
514
- const { service, threadRows } = buildWorld();
515
-
516
- await service.copyMessages(
517
- ACCOUNT_CONFIG,
518
- [SOURCE_ID],
519
- DEST_MAILBOX,
520
- ACCOUNT,
521
- );
522
- const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
523
-
524
- await service.deleteMessages(ACCOUNT_CONFIG, [SOURCE_ID], ACCOUNT, {
525
- permanent: true,
526
- });
527
-
528
- assert.equal(
529
- threadRows.some((r) => r.messageId === SOURCE_ID),
530
- false,
531
- "original row removed",
532
- );
533
- assert.equal(
534
- threadRows.some((r) => r.messageId === copyId),
535
- true,
536
- "copy row survives its own delete boundary",
537
- );
538
- });
539
-
540
- it("a copy into two folders is two distinct, reachable rows", async () => {
541
- const { service, threadRows } = buildWorld();
542
-
543
- await service.copyMessages(
544
- ACCOUNT_CONFIG,
545
- [SOURCE_ID],
546
- DEST_MAILBOX,
547
- ACCOUNT,
548
- );
549
- await service.copyMessages(
550
- ACCOUNT_CONFIG,
551
- [SOURCE_ID],
552
- SOURCE_MAILBOX,
553
- ACCOUNT,
554
- );
555
-
556
- const idArchive = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
557
- const idInbox = deriveCopyMessageId(SOURCE_ID, SOURCE_MAILBOX);
558
- assert.notEqual(idArchive, idInbox);
559
- assert.equal(threadRows.filter((r) => r.messageId === idArchive).length, 1);
560
- assert.equal(threadRows.filter((r) => r.messageId === idInbox).length, 1);
561
- });
562
- });