@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.
- package/package.json +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +226 -200
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +19 -15
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/types.ts +1 -1
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
|
@@ -1,482 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The three optimistic writers, against a row that moves under them
|
|
3
|
-
* (docs/architecture/imap-mutations.md R3).
|
|
4
|
-
*
|
|
5
|
-
* Each reads a message, does several lookups, then writes where it decided to
|
|
6
|
-
* put it. Between the read and the write, PLACEMENT_MOVE_PUSH can settle the
|
|
7
|
-
* same row: that lane rides a standard queue, which the account's FIFO group
|
|
8
|
-
* does not order against. A blind write then stamps `moving` and the OLD uid
|
|
9
|
-
* over a folder and uid the mail server has just confirmed — a pair
|
|
10
|
-
* `carriesForeignUid` reads as consistent, so nothing downstream catches it —
|
|
11
|
-
* and the event that follows sends the worker after whatever now holds the old
|
|
12
|
-
* number.
|
|
13
|
-
*
|
|
14
|
-
* The repository honours the predicate here the way the real one does, so a
|
|
15
|
-
* losing write returns `undefined` and lands nothing at all.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import assert from "node:assert/strict";
|
|
19
|
-
import { describe, it } from "node:test";
|
|
20
|
-
import type {
|
|
21
|
-
IAddressRepository,
|
|
22
|
-
IMailboxRepository,
|
|
23
|
-
IMailboxSpecialUseRepository,
|
|
24
|
-
IMessagePlacementMoveRepository,
|
|
25
|
-
IMessageRepository,
|
|
26
|
-
IThreadMessageRepository,
|
|
27
|
-
PlacementPredicate,
|
|
28
|
-
PlacementTransitionInput,
|
|
29
|
-
} from "@remit/data-ports";
|
|
30
|
-
import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
|
|
31
|
-
import { MessageMoveService } from "./message-move.js";
|
|
32
|
-
import { PlacementMoveService } from "./placement-move.js";
|
|
33
|
-
import { NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
34
|
-
|
|
35
|
-
const ACCOUNT = "acc-1";
|
|
36
|
-
const ACCOUNT_CONFIG = "cfg-1";
|
|
37
|
-
const INBOX = "mbx-inbox";
|
|
38
|
-
const ARCHIVE = "mbx-archive";
|
|
39
|
-
const TRASH = "mbx-trash";
|
|
40
|
-
const JUNK = "mbx-junk";
|
|
41
|
-
const MESSAGE_ID = "msg-1";
|
|
42
|
-
|
|
43
|
-
/** What the row is when each writer reads it. */
|
|
44
|
-
const READ_UID = 42;
|
|
45
|
-
|
|
46
|
-
/** What the placement push settles it to while the writer is still deciding. */
|
|
47
|
-
const SETTLED_UID = 907;
|
|
48
|
-
|
|
49
|
-
const mailboxes = [
|
|
50
|
-
{ mailboxId: INBOX, fullPath: "INBOX", accountId: ACCOUNT, uidValidity: 1 },
|
|
51
|
-
{
|
|
52
|
-
mailboxId: ARCHIVE,
|
|
53
|
-
fullPath: "Archive",
|
|
54
|
-
accountId: ACCOUNT,
|
|
55
|
-
uidValidity: 1,
|
|
56
|
-
},
|
|
57
|
-
{ mailboxId: TRASH, fullPath: "Trash", accountId: ACCOUNT, uidValidity: 1 },
|
|
58
|
-
{ mailboxId: JUNK, fullPath: "Junk", accountId: ACCOUNT, uidValidity: 1 },
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
interface CapturedEvent {
|
|
62
|
-
type: string;
|
|
63
|
-
messageId?: string;
|
|
64
|
-
uid?: number;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface Row {
|
|
68
|
-
messageId: string;
|
|
69
|
-
accountConfigId: string;
|
|
70
|
-
mailboxId: string;
|
|
71
|
-
uid: number;
|
|
72
|
-
status: string;
|
|
73
|
-
syncStatus: string;
|
|
74
|
-
abandonedMutation: string;
|
|
75
|
-
originalMailboxId?: string;
|
|
76
|
-
originalUid?: number;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const freshRow = (): Row => ({
|
|
80
|
-
messageId: MESSAGE_ID,
|
|
81
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
82
|
-
mailboxId: INBOX,
|
|
83
|
-
uid: READ_UID,
|
|
84
|
-
status: "active",
|
|
85
|
-
syncStatus: "synced",
|
|
86
|
-
abandonedMutation: "none",
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
interface World {
|
|
90
|
-
row: Row;
|
|
91
|
-
events: CapturedEvent[];
|
|
92
|
-
writes: PlacementTransitionInput[];
|
|
93
|
-
/** Destination folders the listing row was actually moved to. */
|
|
94
|
-
threadMoves: string[];
|
|
95
|
-
/** Stand in for the push settling the row onto Archive with Archive's uid. */
|
|
96
|
-
settleElsewhere: () => void;
|
|
97
|
-
messageService: IMessageRepository;
|
|
98
|
-
threadMessageService: IThreadMessageRepository;
|
|
99
|
-
mailboxService: IMailboxRepository;
|
|
100
|
-
mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
101
|
-
addressService: IAddressRepository;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const buildWorld = (onLookup?: (world: World) => void): World => {
|
|
105
|
-
const row = freshRow();
|
|
106
|
-
const events: CapturedEvent[] = [];
|
|
107
|
-
const writes: PlacementTransitionInput[] = [];
|
|
108
|
-
|
|
109
|
-
const threadMoves: string[] = [];
|
|
110
|
-
const world = { row, events, writes, threadMoves } as World;
|
|
111
|
-
|
|
112
|
-
world.settleElsewhere = () => {
|
|
113
|
-
row.mailboxId = ARCHIVE;
|
|
114
|
-
row.uid = SETTLED_UID;
|
|
115
|
-
row.status = "active";
|
|
116
|
-
row.syncStatus = "synced";
|
|
117
|
-
row.originalUid = undefined;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
world.messageService = {
|
|
121
|
-
// A snapshot, the way a repository read is one. Handing back the live
|
|
122
|
-
// object would let the caller's `message` follow the concurrent settle,
|
|
123
|
-
// and the predicate it builds from it would match by accident.
|
|
124
|
-
get: async (ids: string | string[]) =>
|
|
125
|
-
Array.isArray(ids) ? [{ ...row }] : ({ ...row } as unknown),
|
|
126
|
-
update: async () => row,
|
|
127
|
-
transitionPlacement: async (
|
|
128
|
-
_messageId: string,
|
|
129
|
-
expected: PlacementPredicate,
|
|
130
|
-
next: PlacementTransitionInput,
|
|
131
|
-
) => {
|
|
132
|
-
if (expected.status !== undefined && expected.status !== row.status) {
|
|
133
|
-
return undefined;
|
|
134
|
-
}
|
|
135
|
-
if (
|
|
136
|
-
expected.mailboxId !== undefined &&
|
|
137
|
-
expected.mailboxId !== row.mailboxId
|
|
138
|
-
) {
|
|
139
|
-
return undefined;
|
|
140
|
-
}
|
|
141
|
-
if (expected.uid !== undefined && expected.uid !== row.uid) {
|
|
142
|
-
return undefined;
|
|
143
|
-
}
|
|
144
|
-
writes.push(next);
|
|
145
|
-
Object.assign(row, next);
|
|
146
|
-
return row;
|
|
147
|
-
},
|
|
148
|
-
} as unknown as IMessageRepository;
|
|
149
|
-
|
|
150
|
-
world.threadMessageService = {
|
|
151
|
-
getByMessageId: async () => ({
|
|
152
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
153
|
-
threadMessageId: "tm-1",
|
|
154
|
-
messageId: MESSAGE_ID,
|
|
155
|
-
mailboxId: row.mailboxId,
|
|
156
|
-
sentDate: 1,
|
|
157
|
-
isRead: false,
|
|
158
|
-
isDeleted: false,
|
|
159
|
-
hasStars: false,
|
|
160
|
-
hasAttachment: false,
|
|
161
|
-
}),
|
|
162
|
-
findAllByMessageId: async () => [],
|
|
163
|
-
update: async (
|
|
164
|
-
_accountConfigId: string,
|
|
165
|
-
_threadMessageId: string,
|
|
166
|
-
patch: { mailboxId?: string },
|
|
167
|
-
) => {
|
|
168
|
-
if (patch.mailboxId) threadMoves.push(patch.mailboxId);
|
|
169
|
-
},
|
|
170
|
-
} as unknown as IThreadMessageRepository;
|
|
171
|
-
|
|
172
|
-
// Every writer resolves folders between reading the row and writing it, so
|
|
173
|
-
// this is where the concurrent settle is made to land.
|
|
174
|
-
world.mailboxService = {
|
|
175
|
-
get: async (_accountId: string, ids: string | string[]) => {
|
|
176
|
-
onLookup?.(world);
|
|
177
|
-
return Array.isArray(ids)
|
|
178
|
-
? mailboxes.filter((mailbox) => ids.includes(mailbox.mailboxId))
|
|
179
|
-
: mailboxes.find((mailbox) => mailbox.mailboxId === ids);
|
|
180
|
-
},
|
|
181
|
-
} as unknown as IMailboxRepository;
|
|
182
|
-
|
|
183
|
-
world.mailboxSpecialUseService = {
|
|
184
|
-
findTrashMailbox: async () => mailboxes[2],
|
|
185
|
-
resolveTrashRole: async () => ({ kind: "flagged", mailbox: mailboxes[2] }),
|
|
186
|
-
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
187
|
-
} as unknown as IMailboxSpecialUseRepository;
|
|
188
|
-
|
|
189
|
-
world.addressService = {
|
|
190
|
-
reconcileJunkOnlyForMessage: async () => {},
|
|
191
|
-
} as unknown as IAddressRepository;
|
|
192
|
-
|
|
193
|
-
return world;
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
const buildMoveService = (world: World): MessageMoveService => {
|
|
197
|
-
const service = new MessageMoveService({
|
|
198
|
-
messageService: world.messageService,
|
|
199
|
-
mailboxService: world.mailboxService,
|
|
200
|
-
mailboxSpecialUseService: world.mailboxSpecialUseService,
|
|
201
|
-
threadMessageService: world.threadMessageService,
|
|
202
|
-
addressService: world.addressService,
|
|
203
|
-
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
204
|
-
moveSettleTimeoutMs: 100,
|
|
205
|
-
moveSettlePollMs: 10,
|
|
206
|
-
});
|
|
207
|
-
(
|
|
208
|
-
service as unknown as {
|
|
209
|
-
enqueueEvent: (e: CapturedEvent) => Promise<void>;
|
|
210
|
-
enqueueEventsBatch: (e: CapturedEvent[]) => Promise<void>;
|
|
211
|
-
}
|
|
212
|
-
).enqueueEvent = async (event) => {
|
|
213
|
-
world.events.push(event);
|
|
214
|
-
};
|
|
215
|
-
(
|
|
216
|
-
service as unknown as {
|
|
217
|
-
enqueueEventsBatch: (e: CapturedEvent[]) => Promise<void>;
|
|
218
|
-
}
|
|
219
|
-
).enqueueEventsBatch = async (batch) => {
|
|
220
|
-
world.events.push(...batch);
|
|
221
|
-
};
|
|
222
|
-
return service;
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
describe("a user move writes only against the placement it read (R3)", () => {
|
|
226
|
-
it("moves the message when nothing else touched the row", async () => {
|
|
227
|
-
const world = buildWorld();
|
|
228
|
-
const service = buildMoveService(world);
|
|
229
|
-
|
|
230
|
-
await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, ARCHIVE, ACCOUNT);
|
|
231
|
-
|
|
232
|
-
assert.equal(world.events.length, 1);
|
|
233
|
-
assert.equal(world.row.status, "moving");
|
|
234
|
-
assert.equal(world.row.mailboxId, ARCHIVE);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("refuses, and writes nothing, when the row settles elsewhere mid-decision", async () => {
|
|
238
|
-
let settled = false;
|
|
239
|
-
const world = buildWorld((w) => {
|
|
240
|
-
if (settled) return;
|
|
241
|
-
settled = true;
|
|
242
|
-
w.settleElsewhere();
|
|
243
|
-
});
|
|
244
|
-
const service = buildMoveService(world);
|
|
245
|
-
|
|
246
|
-
await assert.rejects(
|
|
247
|
-
() => service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT),
|
|
248
|
-
(error: unknown) =>
|
|
249
|
-
error instanceof MessagePlacementUnsettledError &&
|
|
250
|
-
error.statusCode === 409,
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
assert.deepEqual(world.writes, [], "no placement was written");
|
|
254
|
-
assert.deepEqual(world.events, [], "and nothing was enqueued");
|
|
255
|
-
assert.equal(
|
|
256
|
-
world.row.mailboxId,
|
|
257
|
-
ARCHIVE,
|
|
258
|
-
"the winner's placement stands exactly as it left it",
|
|
259
|
-
);
|
|
260
|
-
assert.equal(world.row.uid, SETTLED_UID);
|
|
261
|
-
assert.equal(world.row.status, "active");
|
|
262
|
-
});
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
describe("a delete writes only against the placement it read (R3)", () => {
|
|
266
|
-
it("marks the message when nothing else touched the row", async () => {
|
|
267
|
-
const world = buildWorld();
|
|
268
|
-
const service = buildMoveService(world);
|
|
269
|
-
|
|
270
|
-
await service.deleteMessages(ACCOUNT_CONFIG, [MESSAGE_ID], ACCOUNT);
|
|
271
|
-
|
|
272
|
-
assert.equal(world.events.length, 1);
|
|
273
|
-
assert.equal(world.row.status, "moving");
|
|
274
|
-
assert.equal(world.row.mailboxId, TRASH);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
it("skips the message, and enqueues nothing for it, when the row settles elsewhere", async () => {
|
|
278
|
-
let settled = false;
|
|
279
|
-
const world = buildWorld((w) => {
|
|
280
|
-
if (settled) return;
|
|
281
|
-
settled = true;
|
|
282
|
-
w.settleElsewhere();
|
|
283
|
-
});
|
|
284
|
-
const service = buildMoveService(world);
|
|
285
|
-
|
|
286
|
-
const { refusedMessageIds } = await service.deleteMessages(
|
|
287
|
-
ACCOUNT_CONFIG,
|
|
288
|
-
[MESSAGE_ID],
|
|
289
|
-
ACCOUNT,
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
assert.deepEqual(
|
|
293
|
-
refusedMessageIds,
|
|
294
|
-
[MESSAGE_ID],
|
|
295
|
-
"the caller is told which rows this delete never claimed",
|
|
296
|
-
);
|
|
297
|
-
assert.deepEqual(world.writes, [], "no placement was written");
|
|
298
|
-
assert.deepEqual(
|
|
299
|
-
world.events,
|
|
300
|
-
[],
|
|
301
|
-
"and no delete was enqueued against a uid the source no longer holds",
|
|
302
|
-
);
|
|
303
|
-
assert.equal(world.row.mailboxId, ARCHIVE);
|
|
304
|
-
assert.equal(world.row.uid, SETTLED_UID);
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// The folder lookup is scoped to one account. The daily brief lists every
|
|
309
|
-
// account's mail, so a selection made there carries rows this lookup cannot
|
|
310
|
-
// resolve — and dropping them silently answered "deleted" over messages still
|
|
311
|
-
// sitting where they were.
|
|
312
|
-
describe("a delete reports a row whose folder it could not resolve", () => {
|
|
313
|
-
it("refuses it rather than counting it as deleted", async () => {
|
|
314
|
-
const world = buildWorld();
|
|
315
|
-
world.row.mailboxId = "mbx-another-account";
|
|
316
|
-
const service = buildMoveService(world);
|
|
317
|
-
|
|
318
|
-
const { refusedMessageIds } = await service.deleteMessages(
|
|
319
|
-
ACCOUNT_CONFIG,
|
|
320
|
-
[MESSAGE_ID],
|
|
321
|
-
ACCOUNT,
|
|
322
|
-
);
|
|
323
|
-
|
|
324
|
-
assert.deepEqual(refusedMessageIds, [MESSAGE_ID]);
|
|
325
|
-
assert.deepEqual(world.writes, [], "nothing was written");
|
|
326
|
-
assert.deepEqual(world.events, [], "and nothing was enqueued");
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
describe("a permanent delete writes only against the placement it read (R3)", () => {
|
|
331
|
-
// The row is already in Trash, so the batch takes the expunge path rather
|
|
332
|
-
// than the move-to-Trash one — a different writer, and the fourth of the
|
|
333
|
-
// four. Its event names one uid in one folder, so a row that has moved since
|
|
334
|
-
// the read must not be marked for it: a restore out of Trash settling in
|
|
335
|
-
// between was overwritten with `deleting`, and the expunge went after the
|
|
336
|
-
// uid Trash no longer holds.
|
|
337
|
-
const inTrash = (world: World): void => {
|
|
338
|
-
world.row.mailboxId = TRASH;
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
it("marks the message when nothing else touched the row", async () => {
|
|
342
|
-
const world = buildWorld();
|
|
343
|
-
inTrash(world);
|
|
344
|
-
const service = buildMoveService(world);
|
|
345
|
-
|
|
346
|
-
const { refusedMessageIds } = await service.deleteMessages(
|
|
347
|
-
ACCOUNT_CONFIG,
|
|
348
|
-
[MESSAGE_ID],
|
|
349
|
-
ACCOUNT,
|
|
350
|
-
{ permanent: true },
|
|
351
|
-
);
|
|
352
|
-
|
|
353
|
-
assert.deepEqual(refusedMessageIds, []);
|
|
354
|
-
assert.equal(world.row.status, "deleting");
|
|
355
|
-
assert.equal(world.events.length, 1);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it("reports the message, and enqueues nothing, when the row settles elsewhere", async () => {
|
|
359
|
-
let settled = false;
|
|
360
|
-
const world = buildWorld((w) => {
|
|
361
|
-
if (settled) return;
|
|
362
|
-
settled = true;
|
|
363
|
-
w.settleElsewhere();
|
|
364
|
-
});
|
|
365
|
-
inTrash(world);
|
|
366
|
-
const service = buildMoveService(world);
|
|
367
|
-
|
|
368
|
-
const { refusedMessageIds } = await service.deleteMessages(
|
|
369
|
-
ACCOUNT_CONFIG,
|
|
370
|
-
[MESSAGE_ID],
|
|
371
|
-
ACCOUNT,
|
|
372
|
-
{ permanent: true },
|
|
373
|
-
);
|
|
374
|
-
|
|
375
|
-
// Reported, never silent: the client removed the row optimistically, and
|
|
376
|
-
// one that reappears with nothing said is a failure the user cannot act
|
|
377
|
-
// on — the same class as a button that does nothing (#1229).
|
|
378
|
-
assert.deepEqual(refusedMessageIds, [MESSAGE_ID]);
|
|
379
|
-
assert.deepEqual(world.writes, [], "no placement was written");
|
|
380
|
-
assert.deepEqual(world.events, [], "and no expunge was enqueued");
|
|
381
|
-
assert.equal(world.row.mailboxId, ARCHIVE);
|
|
382
|
-
assert.equal(world.row.status, "active");
|
|
383
|
-
});
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
describe("a classification filing writes only against the placement it read (R3)", () => {
|
|
387
|
-
const buildPlacementService = (world: World, onPut?: () => void) => {
|
|
388
|
-
const markers = new Map<string, unknown>();
|
|
389
|
-
const pushed: string[] = [];
|
|
390
|
-
const markerService = {
|
|
391
|
-
find: async () => markers.get(MESSAGE_ID) ?? null,
|
|
392
|
-
// The last step between reading the row and writing it, so this is
|
|
393
|
-
// where a concurrent settle is staged.
|
|
394
|
-
put: async (input: Record<string, unknown>) => {
|
|
395
|
-
onPut?.();
|
|
396
|
-
const marker = { ...input, state: "pending", createdAt: Date.now() };
|
|
397
|
-
markers.set(MESSAGE_ID, marker);
|
|
398
|
-
return marker;
|
|
399
|
-
},
|
|
400
|
-
updateState: async () => markers.get(MESSAGE_ID),
|
|
401
|
-
delete: async () => {
|
|
402
|
-
markers.delete(MESSAGE_ID);
|
|
403
|
-
},
|
|
404
|
-
} as unknown as IMessagePlacementMoveRepository;
|
|
405
|
-
|
|
406
|
-
const service = new PlacementMoveService({
|
|
407
|
-
messageService: world.messageService,
|
|
408
|
-
threadMessageService: world.threadMessageService,
|
|
409
|
-
markerService,
|
|
410
|
-
addressService: world.addressService,
|
|
411
|
-
mailboxSpecialUseService: world.mailboxSpecialUseService,
|
|
412
|
-
sqsQueueUrl: "http://localhost:9324/000000000000/remit-message-mgmt",
|
|
413
|
-
moveSettleTimeoutMs: 100,
|
|
414
|
-
moveSettlePollMs: 10,
|
|
415
|
-
});
|
|
416
|
-
(service as unknown as { enqueuePush: () => Promise<void> }).enqueuePush =
|
|
417
|
-
async () => {
|
|
418
|
-
pushed.push(MESSAGE_ID);
|
|
419
|
-
};
|
|
420
|
-
return { service, markers, pushed };
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
it("files the message when nothing else touched the row", async () => {
|
|
424
|
-
const world = buildWorld();
|
|
425
|
-
const { service, markers, pushed } = buildPlacementService(world);
|
|
426
|
-
|
|
427
|
-
await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
|
|
428
|
-
|
|
429
|
-
assert.equal(world.row.mailboxId, JUNK);
|
|
430
|
-
assert.deepEqual(
|
|
431
|
-
world.threadMoves,
|
|
432
|
-
[JUNK],
|
|
433
|
-
"and the listing row follows the message it describes",
|
|
434
|
-
);
|
|
435
|
-
assert.deepEqual(pushed, [MESSAGE_ID]);
|
|
436
|
-
assert.ok(markers.get(MESSAGE_ID));
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
// The Message row is written first, so a loser leaves nothing behind. Moving
|
|
440
|
-
// the listing row ahead of the transition made the loser diverge instead: the
|
|
441
|
-
// listing showed the classification folder while the Message row sat where
|
|
442
|
-
// the winner put it, and nothing routine reconciles that.
|
|
443
|
-
it("leaves the listing row alone too when the row settles elsewhere", async () => {
|
|
444
|
-
const world = buildWorld();
|
|
445
|
-
const { service, markers, pushed } = buildPlacementService(world, () =>
|
|
446
|
-
world.settleElsewhere(),
|
|
447
|
-
);
|
|
448
|
-
|
|
449
|
-
await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
|
|
450
|
-
|
|
451
|
-
assert.deepEqual(world.writes, [], "no placement was written");
|
|
452
|
-
assert.deepEqual(
|
|
453
|
-
world.threadMoves,
|
|
454
|
-
[],
|
|
455
|
-
"and the listing row never claimed a folder the message is not in",
|
|
456
|
-
);
|
|
457
|
-
assert.deepEqual(pushed, [], "and no push was enqueued");
|
|
458
|
-
assert.equal(
|
|
459
|
-
markers.get(MESSAGE_ID),
|
|
460
|
-
undefined,
|
|
461
|
-
"the marker goes with it: nothing owes IMAP this move",
|
|
462
|
-
);
|
|
463
|
-
assert.equal(world.row.mailboxId, ARCHIVE);
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
// A permanent delete has already claimed the row. Waiting the ceiling out and
|
|
467
|
-
// then throwing drove an ordinary sequence toward the DLQ; there is nothing
|
|
468
|
-
// left to file, so this skips.
|
|
469
|
-
it("skips a message a delete has claimed, rather than burning the ceiling", async () => {
|
|
470
|
-
const world = buildWorld();
|
|
471
|
-
world.row.status = "deleting";
|
|
472
|
-
const { service, markers, pushed } = buildPlacementService(world);
|
|
473
|
-
|
|
474
|
-
const startedAt = Date.now();
|
|
475
|
-
await service.moveMessage(ACCOUNT_CONFIG, MESSAGE_ID, JUNK, ACCOUNT);
|
|
476
|
-
|
|
477
|
-
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
478
|
-
assert.deepEqual(world.writes, []);
|
|
479
|
-
assert.deepEqual(pushed, []);
|
|
480
|
-
assert.equal(markers.get(MESSAGE_ID), undefined);
|
|
481
|
-
});
|
|
482
|
-
});
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
4
|
-
IMessageRepository,
|
|
5
|
-
IThreadMessageRepository,
|
|
6
|
-
} from "@remit/data-ports";
|
|
7
|
-
import {
|
|
8
|
-
reconcileStaleMessage,
|
|
9
|
-
type StaleMessageReconcileDeps,
|
|
10
|
-
} from "./stale-message-reconcile.js";
|
|
11
|
-
|
|
12
|
-
describe("reconcileStaleMessage", () => {
|
|
13
|
-
it("deletes the Message row and every ThreadMessage copy that points at it", async () => {
|
|
14
|
-
const deletedMessages: string[] = [];
|
|
15
|
-
const deletedThreadMessageKeys: Array<{
|
|
16
|
-
accountConfigId: string;
|
|
17
|
-
threadMessageId: string;
|
|
18
|
-
}> = [];
|
|
19
|
-
|
|
20
|
-
const deps: StaleMessageReconcileDeps = {
|
|
21
|
-
messageService: {
|
|
22
|
-
delete: async (messageId: string) => {
|
|
23
|
-
deletedMessages.push(messageId);
|
|
24
|
-
},
|
|
25
|
-
} as unknown as Pick<IMessageRepository, "delete">,
|
|
26
|
-
threadMessageService: {
|
|
27
|
-
findAllByMessageId: async (
|
|
28
|
-
accountConfigId: string,
|
|
29
|
-
messageId: string,
|
|
30
|
-
) => [
|
|
31
|
-
{ accountConfigId, threadMessageId: `tm-inbox-${messageId}` },
|
|
32
|
-
{ accountConfigId, threadMessageId: `tm-label-${messageId}` },
|
|
33
|
-
],
|
|
34
|
-
deleteMany: async (
|
|
35
|
-
keys: Array<{ accountConfigId: string; threadMessageId: string }>,
|
|
36
|
-
) => {
|
|
37
|
-
deletedThreadMessageKeys.push(...keys);
|
|
38
|
-
},
|
|
39
|
-
} as unknown as Pick<
|
|
40
|
-
IThreadMessageRepository,
|
|
41
|
-
"findAllByMessageId" | "deleteMany"
|
|
42
|
-
>,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const result = await reconcileStaleMessage(deps, "cfg-1", "msg-1");
|
|
46
|
-
|
|
47
|
-
assert.equal(result.threadMessagesDeleted, 2);
|
|
48
|
-
assert.deepEqual(deletedMessages, ["msg-1"]);
|
|
49
|
-
assert.deepEqual(deletedThreadMessageKeys, [
|
|
50
|
-
{ accountConfigId: "cfg-1", threadMessageId: "tm-inbox-msg-1" },
|
|
51
|
-
{ accountConfigId: "cfg-1", threadMessageId: "tm-label-msg-1" },
|
|
52
|
-
]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("skips the batch delete (never calls deleteMany with an empty array) when no copies exist", async () => {
|
|
56
|
-
const deletedMessages: string[] = [];
|
|
57
|
-
let deleteManyCalls = 0;
|
|
58
|
-
|
|
59
|
-
const deps: StaleMessageReconcileDeps = {
|
|
60
|
-
messageService: {
|
|
61
|
-
delete: async (messageId: string) => {
|
|
62
|
-
deletedMessages.push(messageId);
|
|
63
|
-
},
|
|
64
|
-
} as unknown as Pick<IMessageRepository, "delete">,
|
|
65
|
-
threadMessageService: {
|
|
66
|
-
findAllByMessageId: async () => [],
|
|
67
|
-
deleteMany: async () => {
|
|
68
|
-
deleteManyCalls++;
|
|
69
|
-
},
|
|
70
|
-
} as unknown as Pick<
|
|
71
|
-
IThreadMessageRepository,
|
|
72
|
-
"findAllByMessageId" | "deleteMany"
|
|
73
|
-
>,
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const result = await reconcileStaleMessage(deps, "cfg-1", "msg-solo");
|
|
77
|
-
|
|
78
|
-
assert.equal(result.threadMessagesDeleted, 0);
|
|
79
|
-
assert.deepEqual(deletedMessages, ["msg-solo"]);
|
|
80
|
-
assert.equal(deleteManyCalls, 0);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
IMessageRepository,
|
|
3
|
-
IThreadMessageRepository,
|
|
4
|
-
} from "@remit/data-ports";
|
|
5
|
-
|
|
6
|
-
export interface StaleMessageReconcileDeps {
|
|
7
|
-
messageService: Pick<IMessageRepository, "delete">;
|
|
8
|
-
threadMessageService: Pick<
|
|
9
|
-
IThreadMessageRepository,
|
|
10
|
-
"findAllByMessageId" | "deleteMany"
|
|
11
|
-
>;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface StaleMessageReconcileResult {
|
|
15
|
-
threadMessagesDeleted: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Delete a Message row (and every ThreadMessage copy that points at it) once
|
|
20
|
-
* the caller has confirmed the message no longer exists on the IMAP server.
|
|
21
|
-
*
|
|
22
|
-
* A row outliving an upstream expunge (or a UIDVALIDITY change — #1272) is
|
|
23
|
-
* the EXPECTED terminal outcome for a body that can never be fetched (issue
|
|
24
|
-
* #1270 / epic #1281 invariant 3): expunges are routine, so cleaning up here
|
|
25
|
-
* is normal operation, not an incident. Deleting the row makes the existing
|
|
26
|
-
* missing-row 404 path (`MessageService.get` / `describe`) authoritative
|
|
27
|
-
* again — nothing renders the row, nothing re-arms a sync for it.
|
|
28
|
-
*
|
|
29
|
-
* Exported standalone, not inlined in the body-sync retry path, so #1272's
|
|
30
|
-
* UIDVALIDITY cursor rebuild can call the same reconciliation when it finds
|
|
31
|
-
* rows whose UIDs no longer resolve after a cursor rebuild.
|
|
32
|
-
*/
|
|
33
|
-
export const reconcileStaleMessage = async (
|
|
34
|
-
deps: StaleMessageReconcileDeps,
|
|
35
|
-
accountConfigId: string,
|
|
36
|
-
messageId: string,
|
|
37
|
-
): Promise<StaleMessageReconcileResult> => {
|
|
38
|
-
const threadMessages = await deps.threadMessageService.findAllByMessageId(
|
|
39
|
-
accountConfigId,
|
|
40
|
-
messageId,
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
if (threadMessages.length > 0) {
|
|
44
|
-
await deps.threadMessageService.deleteMany(
|
|
45
|
-
threadMessages.map((row) => ({
|
|
46
|
-
accountConfigId: row.accountConfigId,
|
|
47
|
-
threadMessageId: row.threadMessageId,
|
|
48
|
-
})),
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
await deps.messageService.delete(messageId);
|
|
53
|
-
|
|
54
|
-
return { threadMessagesDeleted: threadMessages.length };
|
|
55
|
-
};
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type { ImapFlowConnection } from "../imapflow-connection.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Per-file mailbox isolation for the mailfuzz e2e suite (#508).
|
|
6
|
-
*
|
|
7
|
-
* The e2e files share one Dovecot account. INBOX is the seeded corpus and is
|
|
8
|
-
* treated as read-only: any test that APPENDs or mutates flags does so in its
|
|
9
|
-
* own uniquely-named mailbox instead, so two files can run in parallel without
|
|
10
|
-
* contending on the same message set. This is what lets `test:e2e` drop
|
|
11
|
-
* `--test-concurrency=1`.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export const uniqueMailboxName = (prefix: string): string =>
|
|
15
|
-
`${prefix}_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
16
|
-
|
|
17
|
-
const seedMessage = (index: number): string =>
|
|
18
|
-
[
|
|
19
|
-
"From: seed@example.com",
|
|
20
|
-
"To: vmail@localhost",
|
|
21
|
-
`Subject: Isolated seed message ${index}`,
|
|
22
|
-
`Date: ${new Date().toUTCString()}`,
|
|
23
|
-
`Message-ID: <seed-${index}-${randomUUID()}@test.example.com>`,
|
|
24
|
-
"",
|
|
25
|
-
`Isolated seed body ${index}`,
|
|
26
|
-
].join("\r\n");
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Create `mailbox` and APPEND `count` deterministic messages into it. Returns
|
|
30
|
-
* the appended UIDs in insertion order.
|
|
31
|
-
*/
|
|
32
|
-
export const seedMailbox = async (
|
|
33
|
-
connection: ImapFlowConnection,
|
|
34
|
-
mailbox: string,
|
|
35
|
-
count: number,
|
|
36
|
-
): Promise<number[]> => {
|
|
37
|
-
await connection.createMailbox(mailbox);
|
|
38
|
-
const uids: number[] = [];
|
|
39
|
-
for (let index = 0; index < count; index++) {
|
|
40
|
-
const { uid } = await connection.append(mailbox, seedMessage(index));
|
|
41
|
-
uids.push(uid);
|
|
42
|
-
}
|
|
43
|
-
return uids;
|
|
44
|
-
};
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { ImapFlowConnection } from "../imapflow-connection.js";
|
|
2
|
-
|
|
3
|
-
export const MAILFUZZ_CONFIG = {
|
|
4
|
-
host: process.env.MAILFUZZ_HOST ?? "localhost",
|
|
5
|
-
port: Number(process.env.MAILFUZZ_PORT ?? 1143),
|
|
6
|
-
user: process.env.MAILFUZZ_USER ?? "vmail",
|
|
7
|
-
credentials: {
|
|
8
|
-
kind: "password" as const,
|
|
9
|
-
password: process.env.MAILFUZZ_PASSWORD ?? "testpass123",
|
|
10
|
-
},
|
|
11
|
-
tls: false,
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export const createMailfuzzConnection = (): ImapFlowConnection =>
|
|
15
|
-
new ImapFlowConnection(MAILFUZZ_CONFIG);
|
|
16
|
-
|
|
17
|
-
export const withMailfuzzConnection = async (
|
|
18
|
-
fn: (connection: ImapFlowConnection) => Promise<void>,
|
|
19
|
-
): Promise<void> => {
|
|
20
|
-
const connection = createMailfuzzConnection();
|
|
21
|
-
await connection.connect();
|
|
22
|
-
await fn(connection).finally(() => {
|
|
23
|
-
if (connection.isConnected) {
|
|
24
|
-
return connection.disconnect();
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
};
|