@remit/imap-worker 0.0.85 → 0.0.87
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/README.md +3 -1
- package/package.json +10 -2
- package/src/account-check.ts +1 -1
- package/src/body-sync-gate.test.ts +1 -1
- package/src/body-sync-gate.ts +1 -1
- package/src/e2e-processor-shim.ts +6 -21
- package/src/emit.test.ts +32 -4
- package/src/emit.ts +58 -23
- package/src/failure-kind.ts +1 -1
- package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
- package/src/handlers/append-sent-message.ts +3 -24
- package/src/handlers/delete-account-objects.test.ts +1 -1
- package/src/handlers/delete-account-objects.ts +1 -1
- package/src/handlers/empty-trash.sqlite.test.ts +209 -0
- package/src/handlers/empty-trash.ts +3 -24
- package/src/handlers/flag-push.ts +11 -18
- package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
- package/src/handlers/mailbox-management.ts +12 -48
- package/src/handlers/message-copy.sqlite.test.ts +298 -0
- package/src/handlers/message-copy.ts +31 -49
- package/src/handlers/message-delete-terminal.ts +12 -10
- package/src/handlers/message-delete.sqlite.test.ts +291 -0
- package/src/handlers/message-delete.ts +26 -32
- package/src/handlers/message-move-terminal.ts +12 -10
- package/src/handlers/message-move.sqlite.test.ts +271 -0
- package/src/handlers/message-move.test.ts +76 -863
- package/src/handlers/message-move.ts +49 -39
- package/src/handlers/placement-move-push.test.ts +96 -329
- package/src/handlers/placement-move-push.ts +75 -29
- package/src/handlers/placement-push.sqlite.test.ts +298 -0
- package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
- package/src/handlers/probe-message-id.ts +15 -0
- package/src/handlers/sync-mailboxes.test.ts +0 -1
- package/src/handlers/sync-mailboxes.ts +1 -1
- package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
- package/src/handlers/sync-message-body.test.ts +3 -299
- package/src/handlers/sync-message-body.ts +21 -11
- package/src/handlers/sync-messages.sqlite.test.ts +183 -0
- package/src/handlers/sync-messages.ts +35 -34
- package/src/index.ts +5 -3
- package/src/mail-sync-selection.sqlite.test.ts +20 -18
- package/src/pending-sync.sqlite.test.ts +167 -0
- package/src/poller.ts +5 -4
- package/src/processor.test.ts +1 -1
- package/src/processor.ts +1 -1
- package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
- package/src/scheduler/attachment-sweep.ts +1 -1
- package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
- package/src/scheduler/calendar-subscriptions.ts +6 -6
- package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
- package/src/scheduler/handler.ts +12 -1
- package/src/scheduler/loop.test.ts +2 -0
- package/src/scheduler/run-tick.sqlite.test.ts +192 -0
- package/src/scheduler/run-tick.ts +39 -1
- package/src/scheduler/runner.ts +21 -1
- package/src/scheduler/stranded-placement-redrive.ts +265 -0
- package/src/stranded-placement.sqlite.test.ts +795 -0
- package/src/test-helpers/test-world.ts +398 -0
- package/src/thread-join.sqlite.test.ts +115 -0
- package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
- package/src/with-oauth-lifecycle.ts +1 -1
- package/src/handlers/append-sent-message.test.ts +0 -529
- package/src/handlers/empty-trash.test.ts +0 -728
- package/src/handlers/flag-push-placement-race.test.ts +0 -385
- package/src/handlers/flag-push.test.ts +0 -325
- package/src/handlers/mailbox-management.test.ts +0 -850
- package/src/handlers/message-copy.test.ts +0 -654
- package/src/handlers/message-delete.test.ts +0 -1869
- package/src/handlers/message-move-terminal.test.ts +0 -412
- package/src/handlers/sync-messages.test.ts +0 -567
- package/src/scheduler/attachment-sweep.test.ts +0 -169
- package/src/scheduler/run-tick.test.ts +0 -478
- package/src/with-oauth-lifecycle.test.ts +0 -318
|
@@ -1,1869 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { beforeEach, describe, it, mock } from "node:test";
|
|
3
|
-
import type { ThreadMessageItem } from "@remit/data-ports";
|
|
4
|
-
import type { Logger } from "@remit/logger-lambda";
|
|
5
|
-
import { renderMetrics, resetMetrics } from "@remit/logger-lambda";
|
|
6
|
-
import { noopLogger } from "@remit/logger-lambda/noop-logger";
|
|
7
|
-
import {
|
|
8
|
-
buildThreadMessageTrashUpdate,
|
|
9
|
-
buildThreadMessageUndelete,
|
|
10
|
-
} from "@remit/mailbox-service";
|
|
11
|
-
import type { MessageDeleteEvent } from "../events.js";
|
|
12
|
-
import {
|
|
13
|
-
deleteAllThreadMessagesForMessage,
|
|
14
|
-
getMessageDeleteMaxAttempts,
|
|
15
|
-
handleMessageDelete,
|
|
16
|
-
MESSAGE_DELETE_MAX_ATTEMPTS,
|
|
17
|
-
type MessageDeleteDeps,
|
|
18
|
-
} from "./message-delete.js";
|
|
19
|
-
|
|
20
|
-
describe("getMessageDeleteMaxAttempts — env-derived threshold (#980)", () => {
|
|
21
|
-
it("parses the injected env var", () => {
|
|
22
|
-
assert.equal(
|
|
23
|
-
getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "3" }),
|
|
24
|
-
3,
|
|
25
|
-
);
|
|
26
|
-
assert.equal(
|
|
27
|
-
getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "5" }),
|
|
28
|
-
5,
|
|
29
|
-
);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("defaults to the mailbox queue's own maxReceiveCount when unset", () => {
|
|
33
|
-
assert.equal(getMessageDeleteMaxAttempts({}), 3);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("defaults on a non-numeric or non-positive value", () => {
|
|
37
|
-
assert.equal(
|
|
38
|
-
getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "nope" }),
|
|
39
|
-
3,
|
|
40
|
-
);
|
|
41
|
-
assert.equal(
|
|
42
|
-
getMessageDeleteMaxAttempts({ MESSAGE_DELETE_MAX_ATTEMPTS: "0" }),
|
|
43
|
-
3,
|
|
44
|
-
);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("MESSAGE_DELETE_MAX_ATTEMPTS is a concrete, positive number at module load", () => {
|
|
48
|
-
assert.ok(MESSAGE_DELETE_MAX_ATTEMPTS > 0);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const sourceMailboxId = "source-mailbox-id-aaaaaaaaa";
|
|
53
|
-
const trashMailboxId = "trash-mailbox-id-aaaaaaaaa";
|
|
54
|
-
|
|
55
|
-
const baseThreadMessage = {
|
|
56
|
-
sentDate: 1700000000000,
|
|
57
|
-
mailboxId: sourceMailboxId,
|
|
58
|
-
isRead: true,
|
|
59
|
-
isDeleted: false,
|
|
60
|
-
hasStars: true,
|
|
61
|
-
hasAttachment: false,
|
|
62
|
-
} satisfies Pick<
|
|
63
|
-
ThreadMessageItem,
|
|
64
|
-
| "sentDate"
|
|
65
|
-
| "mailboxId"
|
|
66
|
-
| "isRead"
|
|
67
|
-
| "isDeleted"
|
|
68
|
-
| "hasStars"
|
|
69
|
-
| "hasAttachment"
|
|
70
|
-
>;
|
|
71
|
-
|
|
72
|
-
describe("buildThreadMessageTrashUpdate", () => {
|
|
73
|
-
// Regression for the same composites-direction landmine PR #186 fixed in
|
|
74
|
-
// `flag-queue.ts`. The CURRENT row state must go in `composites`; the NEW
|
|
75
|
-
// values must go in `set`. Flipping any of these silently drops the
|
|
76
|
-
// move-to-trash update on the ThreadMessage row — IMAP shows the message in
|
|
77
|
-
// Trash but the local thread-list still shows it in the source mailbox.
|
|
78
|
-
|
|
79
|
-
it("set carries the NEW uid, destination mailboxId, and isDeleted=true", () => {
|
|
80
|
-
const args = buildThreadMessageTrashUpdate(
|
|
81
|
-
baseThreadMessage,
|
|
82
|
-
42,
|
|
83
|
-
trashMailboxId,
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
assert.strictEqual(args.set.uid, 42);
|
|
87
|
-
assert.strictEqual(
|
|
88
|
-
args.set.mailboxId,
|
|
89
|
-
trashMailboxId,
|
|
90
|
-
"set.mailboxId must be the NEW trash mailbox",
|
|
91
|
-
);
|
|
92
|
-
assert.strictEqual(
|
|
93
|
-
args.set.isDeleted,
|
|
94
|
-
true,
|
|
95
|
-
"set.isDeleted must be true (move-to-trash marks the row deleted)",
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("composites.mailboxId is the CURRENT (source) mailboxId, not Trash", () => {
|
|
100
|
-
const args = buildThreadMessageTrashUpdate(
|
|
101
|
-
baseThreadMessage,
|
|
102
|
-
42,
|
|
103
|
-
trashMailboxId,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
assert.strictEqual(
|
|
107
|
-
args.composites.mailboxId,
|
|
108
|
-
sourceMailboxId,
|
|
109
|
-
"composites.mailboxId must be the CURRENT source mailbox; passing Trash breaks the conditional check",
|
|
110
|
-
);
|
|
111
|
-
assert.notStrictEqual(
|
|
112
|
-
args.composites.mailboxId,
|
|
113
|
-
trashMailboxId,
|
|
114
|
-
"composites.mailboxId must NOT match the new trash mailbox id",
|
|
115
|
-
);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("composites.isDeleted is the CURRENT value, not the new true", () => {
|
|
119
|
-
const args = buildThreadMessageTrashUpdate(
|
|
120
|
-
baseThreadMessage,
|
|
121
|
-
42,
|
|
122
|
-
trashMailboxId,
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
assert.strictEqual(
|
|
126
|
-
args.composites.isDeleted,
|
|
127
|
-
false,
|
|
128
|
-
"composites.isDeleted must be the CURRENT value (false), not the new value (true)",
|
|
129
|
-
);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("composites mirrors every CURRENT indexed attribute on the threadMessage", () => {
|
|
133
|
-
const tm = {
|
|
134
|
-
sentDate: 1700000000123,
|
|
135
|
-
mailboxId: sourceMailboxId,
|
|
136
|
-
isRead: false,
|
|
137
|
-
isDeleted: false,
|
|
138
|
-
hasStars: true,
|
|
139
|
-
hasAttachment: true,
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const args = buildThreadMessageTrashUpdate(tm, 99, trashMailboxId);
|
|
143
|
-
|
|
144
|
-
assert.deepStrictEqual(args.composites, {
|
|
145
|
-
sentDate: tm.sentDate,
|
|
146
|
-
mailboxId: tm.mailboxId,
|
|
147
|
-
isRead: tm.isRead,
|
|
148
|
-
isDeleted: tm.isDeleted,
|
|
149
|
-
hasStars: tm.hasStars,
|
|
150
|
-
hasAttachment: tm.hasAttachment,
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
describe("buildThreadMessageUndelete", () => {
|
|
156
|
-
// Every `set` payload here is hand-written, so the three builders can drift
|
|
157
|
-
// apart silently. Undelete is the exact inverse of the trash update's
|
|
158
|
-
// deletion mark, and it must move nothing else: the mail is still in Trash
|
|
159
|
-
// at the uid the row already carries.
|
|
160
|
-
|
|
161
|
-
it("clears only the deletion mark that buildThreadMessageTrashUpdate set", () => {
|
|
162
|
-
const trashed = buildThreadMessageTrashUpdate(
|
|
163
|
-
baseThreadMessage,
|
|
164
|
-
42,
|
|
165
|
-
trashMailboxId,
|
|
166
|
-
);
|
|
167
|
-
const undelete = buildThreadMessageUndelete(baseThreadMessage);
|
|
168
|
-
|
|
169
|
-
assert.strictEqual(trashed.set.isDeleted, true);
|
|
170
|
-
assert.strictEqual(undelete.set.isDeleted, false);
|
|
171
|
-
assert.deepStrictEqual(
|
|
172
|
-
Object.keys(undelete.set),
|
|
173
|
-
["isDeleted"],
|
|
174
|
-
"undelete must not move the row's uid or mailbox",
|
|
175
|
-
);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("carries the CURRENT row state in composites, like every other builder", () => {
|
|
179
|
-
const undelete = buildThreadMessageUndelete(baseThreadMessage);
|
|
180
|
-
|
|
181
|
-
assert.deepStrictEqual(
|
|
182
|
-
undelete.composites,
|
|
183
|
-
buildThreadMessageTrashUpdate(baseThreadMessage, 42, trashMailboxId)
|
|
184
|
-
.composites,
|
|
185
|
-
);
|
|
186
|
-
assert.strictEqual(
|
|
187
|
-
undelete.composites.isDeleted,
|
|
188
|
-
baseThreadMessage.isDeleted,
|
|
189
|
-
"composites hold the state to check against, never the new value",
|
|
190
|
-
);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe("deleteAllThreadMessagesForMessage (#212)", () => {
|
|
195
|
-
// Regression for the multi-mailbox cleanup gap in #212. A single Message
|
|
196
|
-
// can have ThreadMessage rows in multiple mailboxes (e.g. INBOX + a label
|
|
197
|
-
// folder copy). The pre-fix code used `findByMessageId` (single row) and
|
|
198
|
-
// left orphan rows behind that then leaked into other mailbox listings.
|
|
199
|
-
|
|
200
|
-
const baseRow = (
|
|
201
|
-
threadMessageId: string,
|
|
202
|
-
mailboxId: string,
|
|
203
|
-
): Pick<
|
|
204
|
-
ThreadMessageItem,
|
|
205
|
-
"accountConfigId" | "threadMessageId" | "mailboxId"
|
|
206
|
-
> => ({
|
|
207
|
-
accountConfigId: "alice-config-aaaaaaaaaa",
|
|
208
|
-
threadMessageId,
|
|
209
|
-
mailboxId,
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("deletes every ThreadMessage row returned by findAllByMessageId", async () => {
|
|
213
|
-
const rows = [
|
|
214
|
-
baseRow("alice-tm-1-aaaaaaaaaa", "alice-inbox-aaaaaaaaa"),
|
|
215
|
-
baseRow("alice-tm-2-aaaaaaaaaa", "alice-label-aaaaaaaaa"),
|
|
216
|
-
];
|
|
217
|
-
|
|
218
|
-
const findAllByMessageId = mock.fn(async () => rows);
|
|
219
|
-
const deleteRow = mock.fn(async () => undefined);
|
|
220
|
-
|
|
221
|
-
const count = await deleteAllThreadMessagesForMessage(
|
|
222
|
-
{
|
|
223
|
-
findAllByMessageId,
|
|
224
|
-
delete: deleteRow,
|
|
225
|
-
} as unknown as Parameters<typeof deleteAllThreadMessagesForMessage>[0],
|
|
226
|
-
"alice-config-aaaaaaaaaa",
|
|
227
|
-
"alice-msg-multi-aaaaaaaa",
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
assert.equal(count, 2);
|
|
231
|
-
assert.equal(deleteRow.mock.calls.length, 2);
|
|
232
|
-
assert.deepEqual(deleteRow.mock.calls[0].arguments, [
|
|
233
|
-
"alice-config-aaaaaaaaaa",
|
|
234
|
-
"alice-tm-1-aaaaaaaaaa",
|
|
235
|
-
]);
|
|
236
|
-
assert.deepEqual(deleteRow.mock.calls[1].arguments, [
|
|
237
|
-
"alice-config-aaaaaaaaaa",
|
|
238
|
-
"alice-tm-2-aaaaaaaaaa",
|
|
239
|
-
]);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it("returns zero when no ThreadMessage rows exist", async () => {
|
|
243
|
-
const findAllByMessageId = mock.fn(async () => []);
|
|
244
|
-
const deleteRow = mock.fn(async () => undefined);
|
|
245
|
-
|
|
246
|
-
const count = await deleteAllThreadMessagesForMessage(
|
|
247
|
-
{
|
|
248
|
-
findAllByMessageId,
|
|
249
|
-
delete: deleteRow,
|
|
250
|
-
} as unknown as Parameters<typeof deleteAllThreadMessagesForMessage>[0],
|
|
251
|
-
"alice-config-aaaaaaaaaa",
|
|
252
|
-
"alice-msg-missing-aaaaaa",
|
|
253
|
-
);
|
|
254
|
-
|
|
255
|
-
assert.equal(count, 0);
|
|
256
|
-
assert.equal(deleteRow.mock.calls.length, 0);
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
interface Call {
|
|
261
|
-
method: string;
|
|
262
|
-
args: unknown[];
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
interface LogLine {
|
|
266
|
-
level: string;
|
|
267
|
-
fields: Record<string, unknown>;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
interface Connection {
|
|
271
|
-
openBox: (
|
|
272
|
-
path: string,
|
|
273
|
-
readOnly?: boolean,
|
|
274
|
-
) => Promise<{ uidvalidity: number }>;
|
|
275
|
-
moveMessages: (
|
|
276
|
-
uids: number[],
|
|
277
|
-
dest: string,
|
|
278
|
-
) => Promise<{ uidMap: Map<number, number> }>;
|
|
279
|
-
deleteMessages: (uids: number[]) => Promise<void>;
|
|
280
|
-
createMailbox: (path: string) => Promise<void>;
|
|
281
|
-
fetchMessages: (uids: number[]) => Promise<{ uid: number }[]>;
|
|
282
|
-
search: (criteria: unknown[]) => Promise<number[]>;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
interface Harness {
|
|
286
|
-
calls: Call[];
|
|
287
|
-
account: {
|
|
288
|
-
accountId: string;
|
|
289
|
-
accountConfigId: string;
|
|
290
|
-
deletedAt?: number;
|
|
291
|
-
} | null;
|
|
292
|
-
mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
|
|
293
|
-
mailboxError?: Error;
|
|
294
|
-
folderPaths: Record<string, string>;
|
|
295
|
-
goneMailboxIds: string[];
|
|
296
|
-
connection: Connection;
|
|
297
|
-
threadMessageUpdateError?: Error;
|
|
298
|
-
messageRow: { messageIdHeader?: string; status?: string } | undefined;
|
|
299
|
-
/** The row was settled by another lane, so every transition loses. */
|
|
300
|
-
transitionLost?: boolean;
|
|
301
|
-
destinationSearchUids: number[];
|
|
302
|
-
// A Message-ID SEARCH is answered by whichever folder was opened last, so a
|
|
303
|
-
// case can put the same header in both. Default: the source still holds it,
|
|
304
|
-
// the destination does not.
|
|
305
|
-
sourceSearchUids: number[];
|
|
306
|
-
openBoxPath: string;
|
|
307
|
-
threadMessage: Record<string, unknown> | null;
|
|
308
|
-
allThreadMessages: Record<string, unknown>[];
|
|
309
|
-
getConnectionCount: number;
|
|
310
|
-
disconnectCount: number;
|
|
311
|
-
// Rejections handed to `getConnection`, one per call, oldest first. A
|
|
312
|
-
// dropped connection is how the guarded openBox gets skipped on a delivery
|
|
313
|
-
// that still goes on to settle the row.
|
|
314
|
-
connectionErrors: Error[];
|
|
315
|
-
logs: LogLine[];
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// The level a give-up is logged at is behaviour: a routine pause must not page
|
|
319
|
-
// anyone, and the local rows going while the server copy survives must stay
|
|
320
|
-
// readable whichever level it lands on.
|
|
321
|
-
const capturingLog = (): Logger => {
|
|
322
|
-
const at =
|
|
323
|
-
(level: string) =>
|
|
324
|
-
(fields: Record<string, unknown>): void => {
|
|
325
|
-
h.logs.push({ level, fields });
|
|
326
|
-
};
|
|
327
|
-
const log = {
|
|
328
|
-
info: at("info"),
|
|
329
|
-
warn: at("warn"),
|
|
330
|
-
error: at("error"),
|
|
331
|
-
debug: at("debug"),
|
|
332
|
-
fatal: at("fatal"),
|
|
333
|
-
trace: at("trace"),
|
|
334
|
-
child: () => log,
|
|
335
|
-
} as unknown as Logger;
|
|
336
|
-
return log;
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
const loggedWith = (key: string, value: string): LogLine[] =>
|
|
340
|
-
h.logs.filter((line) => line.fields[key] === value);
|
|
341
|
-
|
|
342
|
-
let h: Harness;
|
|
343
|
-
|
|
344
|
-
const record =
|
|
345
|
-
(method: string) =>
|
|
346
|
-
async (...args: unknown[]) => {
|
|
347
|
-
h.calls.push({ method, args });
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
const MESSAGE_ID_HEADER = "<trashed-message@example.com>";
|
|
351
|
-
|
|
352
|
-
// The source-presence probe (`isMessageGoneFromOpenMailbox`) and the
|
|
353
|
-
// destination probe both go through `connection.search`; only the criterion
|
|
354
|
-
// form tells them apart.
|
|
355
|
-
const isMessageIdSearch = (criteria: unknown): boolean =>
|
|
356
|
-
Array.isArray(criteria) &&
|
|
357
|
-
Array.isArray(criteria[0]) &&
|
|
358
|
-
criteria[0][0] === "HEADER";
|
|
359
|
-
|
|
360
|
-
const buildConnection = (): Connection => ({
|
|
361
|
-
openBox: async (path: string) => {
|
|
362
|
-
h.openBoxPath = path;
|
|
363
|
-
return { uidvalidity: 1 };
|
|
364
|
-
},
|
|
365
|
-
moveMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
|
|
366
|
-
deleteMessages: record(
|
|
367
|
-
"connection.deleteMessages",
|
|
368
|
-
) as Connection["deleteMessages"],
|
|
369
|
-
createMailbox: record(
|
|
370
|
-
"connection.createMailbox",
|
|
371
|
-
) as Connection["createMailbox"],
|
|
372
|
-
// The source box still holds the uid unless a case says otherwise, so the
|
|
373
|
-
// presence probe only reports "gone" where a test made it true.
|
|
374
|
-
fetchMessages: async (uids: number[]) => {
|
|
375
|
-
h.calls.push({ method: "connection.fetchMessages", args: [uids] });
|
|
376
|
-
return uids.map((uid) => ({ uid }));
|
|
377
|
-
},
|
|
378
|
-
search: async (...args: unknown[]) => {
|
|
379
|
-
h.calls.push({ method: "connection.search", args });
|
|
380
|
-
if (!isMessageIdSearch(args[0])) return [10];
|
|
381
|
-
return h.openBoxPath === "INBOX"
|
|
382
|
-
? h.sourceSearchUids
|
|
383
|
-
: h.destinationSearchUids;
|
|
384
|
-
},
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
const sourceNoLongerHoldsTheUid = (): void => {
|
|
388
|
-
h.sourceSearchUids = [];
|
|
389
|
-
h.connection.fetchMessages = async () => [];
|
|
390
|
-
h.connection.search = async (...args: unknown[]) => {
|
|
391
|
-
h.calls.push({ method: "connection.search", args });
|
|
392
|
-
if (!isMessageIdSearch(args[0])) return [];
|
|
393
|
-
return h.openBoxPath === "INBOX"
|
|
394
|
-
? h.sourceSearchUids
|
|
395
|
-
: h.destinationSearchUids;
|
|
396
|
-
};
|
|
397
|
-
};
|
|
398
|
-
|
|
399
|
-
const fresh = (): Harness => ({
|
|
400
|
-
calls: [],
|
|
401
|
-
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
402
|
-
mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
|
|
403
|
-
folderPaths: { "src-mbx": "INBOX", "trash-mbx": "Trash" },
|
|
404
|
-
goneMailboxIds: [],
|
|
405
|
-
// What the API writes before it enqueues: a move to Trash records `moving`,
|
|
406
|
-
// a permanent delete records `deleting`, and the handler's guard asks one
|
|
407
|
-
// question of both — is a mutation still outstanding on this row.
|
|
408
|
-
messageRow: { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" },
|
|
409
|
-
destinationSearchUids: [],
|
|
410
|
-
sourceSearchUids: [10],
|
|
411
|
-
openBoxPath: "INBOX",
|
|
412
|
-
connection: buildConnection(),
|
|
413
|
-
threadMessage: {
|
|
414
|
-
...baseThreadMessage,
|
|
415
|
-
accountConfigId: "cfg-1",
|
|
416
|
-
threadMessageId: "tm-1",
|
|
417
|
-
},
|
|
418
|
-
allThreadMessages: [
|
|
419
|
-
{ ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-1" },
|
|
420
|
-
{ ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-2" },
|
|
421
|
-
],
|
|
422
|
-
getConnectionCount: 0,
|
|
423
|
-
disconnectCount: 0,
|
|
424
|
-
connectionErrors: [],
|
|
425
|
-
logs: [],
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
const deps = (): MessageDeleteDeps =>
|
|
429
|
-
({
|
|
430
|
-
getClient: async () => ({
|
|
431
|
-
account: {
|
|
432
|
-
get: async (accountId: string) => {
|
|
433
|
-
h.calls.push({ method: "account.get", args: [accountId] });
|
|
434
|
-
return h.account;
|
|
435
|
-
},
|
|
436
|
-
},
|
|
437
|
-
message: {
|
|
438
|
-
get: async (messageIds: string[]) => {
|
|
439
|
-
h.calls.push({ method: "message.get", args: [messageIds] });
|
|
440
|
-
return h.messageRow ? [h.messageRow] : [];
|
|
441
|
-
},
|
|
442
|
-
updateUid: record("message.updateUid"),
|
|
443
|
-
transitionPlacement: async (...args: unknown[]) => {
|
|
444
|
-
h.calls.push({ method: "message.transitionPlacement", args });
|
|
445
|
-
return h.transitionLost ? undefined : { messageId: "msg-1" };
|
|
446
|
-
},
|
|
447
|
-
update: record("message.update"),
|
|
448
|
-
delete: record("message.delete"),
|
|
449
|
-
},
|
|
450
|
-
threadMessage: {
|
|
451
|
-
findByMessageId: async () => h.threadMessage,
|
|
452
|
-
findAllByMessageId: async (...args: unknown[]) => {
|
|
453
|
-
h.calls.push({ method: "threadMessage.findAllByMessageId", args });
|
|
454
|
-
return h.allThreadMessages;
|
|
455
|
-
},
|
|
456
|
-
update: async (...args: unknown[]) => {
|
|
457
|
-
h.calls.push({ method: "threadMessage.update", args });
|
|
458
|
-
if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
|
|
459
|
-
},
|
|
460
|
-
delete: record("threadMessage.delete"),
|
|
461
|
-
deleteMany: record("threadMessage.deleteMany"),
|
|
462
|
-
},
|
|
463
|
-
mailbox: {
|
|
464
|
-
get: async (_accountId: string, mailboxId: string) => {
|
|
465
|
-
if (h.mailboxError) throw h.mailboxError;
|
|
466
|
-
if (h.goneMailboxIds.includes(mailboxId)) {
|
|
467
|
-
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
468
|
-
name: "NotFoundError",
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
const row =
|
|
472
|
-
mailboxId === h.mailbox.mailboxId
|
|
473
|
-
? h.mailbox
|
|
474
|
-
: { mailboxId, uidValidity: 1, cursorState: "normal" };
|
|
475
|
-
return { ...row, fullPath: h.folderPaths[mailboxId] };
|
|
476
|
-
},
|
|
477
|
-
update: record("mailbox.update"),
|
|
478
|
-
},
|
|
479
|
-
secrets: {},
|
|
480
|
-
}),
|
|
481
|
-
buildLifecycleDeps: () => ({}),
|
|
482
|
-
withOAuthLifecycle: async (
|
|
483
|
-
_deps: unknown,
|
|
484
|
-
_account: unknown,
|
|
485
|
-
_log: unknown,
|
|
486
|
-
cb: (credentials: unknown) => Promise<void>,
|
|
487
|
-
) => cb({}),
|
|
488
|
-
emitEvent: record("emitEvent"),
|
|
489
|
-
createConnectionScope: () => ({
|
|
490
|
-
getConnection: async () => {
|
|
491
|
-
h.getConnectionCount += 1;
|
|
492
|
-
const failure = h.connectionErrors.shift();
|
|
493
|
-
if (failure) throw failure;
|
|
494
|
-
return h.connection;
|
|
495
|
-
},
|
|
496
|
-
disconnect: async () => {
|
|
497
|
-
h.disconnectCount += 1;
|
|
498
|
-
},
|
|
499
|
-
}),
|
|
500
|
-
}) as unknown as MessageDeleteDeps;
|
|
501
|
-
|
|
502
|
-
const moveEvent: MessageDeleteEvent = {
|
|
503
|
-
type: "MESSAGE_DELETE",
|
|
504
|
-
schemaVersion: 2,
|
|
505
|
-
accountId: "acc-1",
|
|
506
|
-
messageId: "msg-1",
|
|
507
|
-
mailboxId: "src-mbx",
|
|
508
|
-
mailboxPath: "INBOX",
|
|
509
|
-
uid: 10,
|
|
510
|
-
operation: "move_to_trash",
|
|
511
|
-
destinationMailboxId: "trash-mbx",
|
|
512
|
-
destinationMailboxPath: "Trash",
|
|
513
|
-
} as MessageDeleteEvent;
|
|
514
|
-
|
|
515
|
-
const permanentEvent: MessageDeleteEvent = {
|
|
516
|
-
type: "MESSAGE_DELETE",
|
|
517
|
-
schemaVersion: 2,
|
|
518
|
-
accountId: "acc-1",
|
|
519
|
-
messageId: "msg-1",
|
|
520
|
-
mailboxId: "src-mbx",
|
|
521
|
-
mailboxPath: "INBOX",
|
|
522
|
-
uid: 10,
|
|
523
|
-
operation: "permanent_delete",
|
|
524
|
-
} as MessageDeleteEvent;
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* A permanent delete's row carries `deleting`, which is the state its own guard
|
|
528
|
-
* asks for — the move to Trash's is `moving`, and the default fixture is that.
|
|
529
|
-
* Called at the point of the handler call so the row is right for the event.
|
|
530
|
-
*/
|
|
531
|
-
const permanent = (): MessageDeleteEvent => {
|
|
532
|
-
if (h.messageRow) h.messageRow.status = "deleting";
|
|
533
|
-
return permanentEvent;
|
|
534
|
-
};
|
|
535
|
-
|
|
536
|
-
const called = (method: string): Call[] =>
|
|
537
|
-
h.calls.filter((c) => c.method === method);
|
|
538
|
-
|
|
539
|
-
/**
|
|
540
|
-
* The transient attempt marker: the one transition that writes nothing but the
|
|
541
|
-
* attempt's own sync status, predicated on this delete still being outstanding.
|
|
542
|
-
* Told apart from the hand-back transitions, which write a whole placement.
|
|
543
|
-
*/
|
|
544
|
-
/**
|
|
545
|
-
* The hand-back transitions: the ones that write a whole placement back onto
|
|
546
|
-
* the row. Told apart from the attempt marker, which writes only a sync status,
|
|
547
|
-
* and from the claim that precedes a removal, which writes only a status.
|
|
548
|
-
*/
|
|
549
|
-
const restoreCalls = (): Call[] =>
|
|
550
|
-
called("message.transitionPlacement").filter(
|
|
551
|
-
(candidate) =>
|
|
552
|
-
(candidate.args[2] as { mailboxId?: string }).mailboxId !== undefined,
|
|
553
|
-
);
|
|
554
|
-
|
|
555
|
-
const attemptMarkerSyncStatus = (): string | undefined => {
|
|
556
|
-
const call = called("message.transitionPlacement").find(
|
|
557
|
-
(candidate) => Object.keys(candidate.args[2] as object).length === 1,
|
|
558
|
-
);
|
|
559
|
-
return (call?.args[2] as { syncStatus?: string })?.syncStatus;
|
|
560
|
-
};
|
|
561
|
-
|
|
562
|
-
// The one write that puts a given-up delete back on its source pair. It clears
|
|
563
|
-
// `moving` in the same write as the pair, which is what keeps a later delete
|
|
564
|
-
// from waiting on a mutation that has terminated (#1005), and it is a
|
|
565
|
-
// transition rather than a plain update so a row another lane already settled
|
|
566
|
-
// keeps its own placement (imap-mutations R3).
|
|
567
|
-
const restoredToSource = (syncStatus: string): unknown[] => [
|
|
568
|
-
"msg-1",
|
|
569
|
-
{ status: ["moving", "deleting"] },
|
|
570
|
-
{
|
|
571
|
-
mailboxId: "src-mbx",
|
|
572
|
-
uid: 10,
|
|
573
|
-
status: "active",
|
|
574
|
-
syncStatus,
|
|
575
|
-
// A give-up names the mutation it gave up on, so the reading pane can
|
|
576
|
-
// offer the way out for THAT one (#1229). `none` rides along with every
|
|
577
|
-
// other sync status, so the field never outlives its gate.
|
|
578
|
-
abandonedMutation: syncStatus === "abandoned" ? "delete" : "none",
|
|
579
|
-
},
|
|
580
|
-
];
|
|
581
|
-
|
|
582
|
-
// Label order in the rendered text is prom-client's, not ours.
|
|
583
|
-
const imapFailures = async (operation: string): Promise<number> => {
|
|
584
|
-
const line = (await renderMetrics())
|
|
585
|
-
.split("\n")
|
|
586
|
-
.find(
|
|
587
|
-
(candidate) =>
|
|
588
|
-
candidate.startsWith("remit_imap_failures_total{") &&
|
|
589
|
-
candidate.includes(`operation="${operation}"`),
|
|
590
|
-
);
|
|
591
|
-
return line ? Number(line.slice(line.lastIndexOf(" ") + 1)) : 0;
|
|
592
|
-
};
|
|
593
|
-
|
|
594
|
-
describe("handleMessageDelete", () => {
|
|
595
|
-
beforeEach(() => {
|
|
596
|
-
h = fresh();
|
|
597
|
-
resetMetrics();
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
it("moves to trash, rewrites the uid, and flips the thread row to deleted", async () => {
|
|
601
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
602
|
-
|
|
603
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
604
|
-
"msg-1",
|
|
605
|
-
20,
|
|
606
|
-
"trash-mbx",
|
|
607
|
-
]);
|
|
608
|
-
const update = called("threadMessage.update")[0];
|
|
609
|
-
assert.deepEqual(update?.args[2], {
|
|
610
|
-
uid: 20,
|
|
611
|
-
mailboxId: "trash-mbx",
|
|
612
|
-
isDeleted: true,
|
|
613
|
-
});
|
|
614
|
-
assert.equal(h.disconnectCount, 1);
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
// UIDPLUS is an extension: a server without it answers a perfectly
|
|
618
|
-
// successful MOVE with no COPYUID entry. An empty uidMap is therefore
|
|
619
|
-
// UNCONFIRMED, and the destination is asked by Message-ID before any
|
|
620
|
-
// verdict — the rule every sibling handler already carries. Issue #979.
|
|
621
|
-
describe("no COPYUID entry on the move to trash", () => {
|
|
622
|
-
it("settles the row on the probed uid when the message left the source and is at the destination (non-UIDPLUS server, genuine success)", async () => {
|
|
623
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
624
|
-
sourceNoLongerHoldsTheUid();
|
|
625
|
-
h.destinationSearchUids = [77];
|
|
626
|
-
|
|
627
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
628
|
-
|
|
629
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
630
|
-
"msg-1",
|
|
631
|
-
77,
|
|
632
|
-
"trash-mbx",
|
|
633
|
-
]);
|
|
634
|
-
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
635
|
-
uid: 77,
|
|
636
|
-
mailboxId: "trash-mbx",
|
|
637
|
-
isDeleted: true,
|
|
638
|
-
});
|
|
639
|
-
assert.equal(
|
|
640
|
-
called("message.update").length,
|
|
641
|
-
0,
|
|
642
|
-
"a settled move never marks the row failed",
|
|
643
|
-
);
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
// Issue #1122. Trash already holds an older copy of this Message-ID (a
|
|
647
|
-
// resend, a sieve `fileinto` + `keep`, an earlier delete of another
|
|
648
|
-
// folder's copy). Settling on uid 12 points the row at that copy and
|
|
649
|
-
// orphans the one this MOVE just delivered: Empty Trash then expunges by
|
|
650
|
-
// the wrong uid and the fresh copy survives, unreachable.
|
|
651
|
-
it("settles on the copy this move delivered, not on an older copy already in Trash", async () => {
|
|
652
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
653
|
-
sourceNoLongerHoldsTheUid();
|
|
654
|
-
h.destinationSearchUids = [12, 77];
|
|
655
|
-
|
|
656
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
657
|
-
|
|
658
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
659
|
-
"msg-1",
|
|
660
|
-
77,
|
|
661
|
-
"trash-mbx",
|
|
662
|
-
]);
|
|
663
|
-
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
664
|
-
uid: 77,
|
|
665
|
-
mailboxId: "trash-mbx",
|
|
666
|
-
isDeleted: true,
|
|
667
|
-
});
|
|
668
|
-
});
|
|
669
|
-
|
|
670
|
-
it("probes the DESTINATION mailbox, read-only, by Message-ID", async () => {
|
|
671
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
672
|
-
sourceNoLongerHoldsTheUid();
|
|
673
|
-
h.destinationSearchUids = [77];
|
|
674
|
-
const opened: unknown[][] = [];
|
|
675
|
-
h.connection.openBox = (async (...args: unknown[]) => {
|
|
676
|
-
opened.push(args);
|
|
677
|
-
h.openBoxPath = args[0] as string;
|
|
678
|
-
return { uidvalidity: 1 };
|
|
679
|
-
}) as Connection["openBox"];
|
|
680
|
-
|
|
681
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
682
|
-
|
|
683
|
-
assert.deepEqual(
|
|
684
|
-
opened,
|
|
685
|
-
[
|
|
686
|
-
["INBOX", false],
|
|
687
|
-
["INBOX", true],
|
|
688
|
-
["Trash", true],
|
|
689
|
-
],
|
|
690
|
-
"the source is re-asked read-only, then the destination is EXAMINEd — neither probe may SELECT a box writable",
|
|
691
|
-
);
|
|
692
|
-
assert.deepEqual(called("connection.search").at(-1)?.args[0], [
|
|
693
|
-
["HEADER", "Message-ID", MESSAGE_ID_HEADER],
|
|
694
|
-
]);
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
// Issue #980. Within the budget the row is marked unsettled and the
|
|
698
|
-
// event is re-thrown so the queue redelivers it, exactly as
|
|
699
|
-
// `handleMessageMove` does; nothing is reverted and nothing is deleted,
|
|
700
|
-
// because a MOVE that ran server-side but dropped before the tagged OK
|
|
701
|
-
// is indistinguishable from one that never ran (#655, PR #652).
|
|
702
|
-
it("throws to redeliver while the budget still has attempts left", async () => {
|
|
703
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
704
|
-
sourceNoLongerHoldsTheUid();
|
|
705
|
-
h.destinationSearchUids = [];
|
|
706
|
-
|
|
707
|
-
await assert.rejects(
|
|
708
|
-
handleMessageDelete(moveEvent, noopLogger, 1, deps()),
|
|
709
|
-
/unconfirmed/,
|
|
710
|
-
);
|
|
711
|
-
|
|
712
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
713
|
-
assert.equal(
|
|
714
|
-
called("message.delete").length,
|
|
715
|
-
0,
|
|
716
|
-
"an unconfirmed move must never delete the local row inside the budget",
|
|
717
|
-
);
|
|
718
|
-
assert.equal(
|
|
719
|
-
called("threadMessage.deleteMany").length,
|
|
720
|
-
0,
|
|
721
|
-
"an unconfirmed move must never delete the listing rows inside the budget",
|
|
722
|
-
);
|
|
723
|
-
assert.equal(
|
|
724
|
-
called("threadMessage.update").length,
|
|
725
|
-
0,
|
|
726
|
-
"an unconfirmed move must never revert the listing row to the source",
|
|
727
|
-
);
|
|
728
|
-
assert.equal(attemptMarkerSyncStatus(), "failed");
|
|
729
|
-
assert.equal(h.disconnectCount, 1);
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
it("settles once at the ceiling: reconciles the stale rows and resyncs both folders", async () => {
|
|
733
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
734
|
-
sourceNoLongerHoldsTheUid();
|
|
735
|
-
h.destinationSearchUids = [];
|
|
736
|
-
|
|
737
|
-
await handleMessageDelete(moveEvent, noopLogger, 3, deps());
|
|
738
|
-
|
|
739
|
-
assert.equal(
|
|
740
|
-
called("threadMessage.deleteMany").length,
|
|
741
|
-
1,
|
|
742
|
-
"the ceiling settles the row rather than leaving it claiming a uid the server does not have",
|
|
743
|
-
);
|
|
744
|
-
assert.equal(called("message.delete").length, 1);
|
|
745
|
-
assert.deepEqual(
|
|
746
|
-
called("emitEvent").map((c) => c.args[0]),
|
|
747
|
-
[
|
|
748
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
|
|
749
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "trash-mbx" },
|
|
750
|
-
],
|
|
751
|
-
"both folders re-project from IMAP so the message reappears where it actually is",
|
|
752
|
-
);
|
|
753
|
-
assert.equal(h.disconnectCount, 1);
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
// A message with no Message-ID header on a non-UIDPLUS server can never
|
|
757
|
-
// be confirmed at the destination, so redelivering it only burns the
|
|
758
|
-
// budget on an answer that cannot change.
|
|
759
|
-
it("settles on the first attempt when the row carries no Message-ID header", async () => {
|
|
760
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
761
|
-
sourceNoLongerHoldsTheUid();
|
|
762
|
-
h.messageRow = { status: "moving" };
|
|
763
|
-
|
|
764
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
765
|
-
|
|
766
|
-
assert.equal(
|
|
767
|
-
called("connection.search").filter((c) =>
|
|
768
|
-
JSON.stringify(c.args).includes("HEADER"),
|
|
769
|
-
).length,
|
|
770
|
-
0,
|
|
771
|
-
);
|
|
772
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
773
|
-
assert.equal(
|
|
774
|
-
called("message.delete").length,
|
|
775
|
-
1,
|
|
776
|
-
"an unprobeable row settles instead of spending the whole budget",
|
|
777
|
-
);
|
|
778
|
-
assert.equal(called("emitEvent").length, 2);
|
|
779
|
-
assert.equal(
|
|
780
|
-
await imapFailures("MESSAGE_DELETE_TRASH_MOVE_UNCONFIRMED"),
|
|
781
|
-
1,
|
|
782
|
-
);
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
// The row this event names was already deleted, so there is nothing to
|
|
786
|
-
// settle and nothing to show. Re-throwing would loop a NotFoundError on
|
|
787
|
-
// the account's per-group FIFO and block every later mailbox event.
|
|
788
|
-
it("acks without touching the database when the local row is already gone", async () => {
|
|
789
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
790
|
-
sourceNoLongerHoldsTheUid();
|
|
791
|
-
h.messageRow = undefined;
|
|
792
|
-
|
|
793
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
794
|
-
|
|
795
|
-
assert.equal(called("message.update").length, 0);
|
|
796
|
-
assert.equal(called("message.delete").length, 0);
|
|
797
|
-
assert.equal(called("threadMessage.deleteMany").length, 0);
|
|
798
|
-
assert.equal(called("emitEvent").length, 0);
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
// A lost SQS acknowledgement is the ordinary way this happens: the delete
|
|
802
|
-
// ran, `settleTrashMoveConfirmed` wrote the Trash uid, and the record came
|
|
803
|
-
// back anyway. Without the guard its three siblings carry (MESSAGE_MOVE,
|
|
804
|
-
// MESSAGE_COPY, FLAG_PUSH) the redelivery MOVEs a uid the source no longer
|
|
805
|
-
// holds, exhausts, and the terminal resolver reads the source's honest
|
|
806
|
-
// "gone" as grounds to delete rows that are correct — taking spamReport,
|
|
807
|
-
// classificationState, category and the Undo target's `originalMailboxId`
|
|
808
|
-
// with them, none of which the resync re-projection can rebuild.
|
|
809
|
-
it("skips a redelivery of a delete that already settled, rather than re-running it", async () => {
|
|
810
|
-
h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
|
|
811
|
-
|
|
812
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
813
|
-
|
|
814
|
-
assert.equal(h.getConnectionCount, 0, "no IMAP command is issued");
|
|
815
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
816
|
-
assert.equal(called("message.delete").length, 0);
|
|
817
|
-
assert.equal(called("threadMessage.delete").length, 0);
|
|
818
|
-
assert.equal(called("threadMessage.deleteMany").length, 0);
|
|
819
|
-
assert.equal(called("message.transitionPlacement").length, 0);
|
|
820
|
-
assert.equal(called("emitEvent").length, 0);
|
|
821
|
-
});
|
|
822
|
-
|
|
823
|
-
// The guard asks for the state THIS delete wrote, not for "is anything
|
|
824
|
-
// outstanding". A move to Trash records `moving`, a permanent delete
|
|
825
|
-
// records `deleting`, and a row in the other one belongs to a different
|
|
826
|
-
// mutation — reading it as unsettled is what would let a redelivery
|
|
827
|
-
// finish somebody else's work against a uid it never owned.
|
|
828
|
-
it("proceeds on a move-to-trash row still marked moving, which is its own work", async () => {
|
|
829
|
-
h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" };
|
|
830
|
-
|
|
831
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
832
|
-
|
|
833
|
-
assert.equal(h.getConnectionCount, 1, "the delete runs");
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
it("skips a move-to-trash whose row a permanent delete has claimed", async () => {
|
|
837
|
-
h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
|
|
838
|
-
|
|
839
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
840
|
-
|
|
841
|
-
assert.equal(h.getConnectionCount, 0, "no IMAP command is issued");
|
|
842
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
843
|
-
assert.equal(called("emitEvent").length, 0);
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
it("proceeds on a permanent delete's own deleting row", async () => {
|
|
847
|
-
h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
|
|
848
|
-
|
|
849
|
-
await handleMessageDelete(permanent(), noopLogger, 1, deps());
|
|
850
|
-
|
|
851
|
-
assert.equal(h.getConnectionCount, 1, "the expunge runs");
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
it("skips a permanent delete whose row a move has claimed", async () => {
|
|
855
|
-
h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "moving" };
|
|
856
|
-
|
|
857
|
-
await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
|
|
858
|
-
|
|
859
|
-
assert.equal(h.getConnectionCount, 0, "no expunge is issued");
|
|
860
|
-
assert.equal(called("message.delete").length, 0);
|
|
861
|
-
assert.equal(called("emitEvent").length, 0);
|
|
862
|
-
});
|
|
863
|
-
|
|
864
|
-
it("skips a redelivered permanent delete the same way", async () => {
|
|
865
|
-
h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
|
|
866
|
-
|
|
867
|
-
await handleMessageDelete(permanentEvent, noopLogger, 1, deps());
|
|
868
|
-
|
|
869
|
-
assert.equal(h.getConnectionCount, 0);
|
|
870
|
-
assert.equal(called("message.delete").length, 0);
|
|
871
|
-
assert.equal(called("emitEvent").length, 0);
|
|
872
|
-
});
|
|
873
|
-
|
|
874
|
-
// One Message-ID can have several server copies in one account while
|
|
875
|
-
// `deriveMessageId` gives them one local row. A source that still holds
|
|
876
|
-
// the uid proves the MOVE did not happen, so any hit at the destination
|
|
877
|
-
// is a different copy — including the highest one #1122 now takes, since
|
|
878
|
-
// with no fresh copy at all the highest match is still an older copy.
|
|
879
|
-
it("never takes a destination hit while the source still holds the uid (duplicate Message-ID)", async () => {
|
|
880
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
881
|
-
h.destinationSearchUids = [100];
|
|
882
|
-
|
|
883
|
-
await assert.rejects(
|
|
884
|
-
handleMessageDelete(moveEvent, noopLogger, 1, deps()),
|
|
885
|
-
/unconfirmed/,
|
|
886
|
-
);
|
|
887
|
-
|
|
888
|
-
assert.equal(
|
|
889
|
-
called("message.updateUid").length,
|
|
890
|
-
0,
|
|
891
|
-
"an earlier copy's uid must never settle this row",
|
|
892
|
-
);
|
|
893
|
-
assert.equal(called("threadMessage.update").length, 0);
|
|
894
|
-
assert.equal(called("message.delete").length, 0);
|
|
895
|
-
assert.equal(attemptMarkerSyncStatus(), "failed");
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
// Issue #1098. A source that still holds the uid at the ceiling means the
|
|
899
|
-
// MOVE never took effect, so the row goes back to the source folder: the
|
|
900
|
-
// optimistic transition left `mailboxId` on Trash while `uid` still
|
|
901
|
-
// named the source's message, and settling `status` to `active` on top of
|
|
902
|
-
// that pair switched off every guard that reads `status === "moving"`.
|
|
903
|
-
// The UI then resolved a Trash `mailboxId` to "Delete permanently", an
|
|
904
|
-
// expunge of Trash by a uid belonging to a different message. This is not
|
|
905
|
-
// a revert on ambiguity (#652, #655): the presence probe has just
|
|
906
|
-
// answered, so the pair written here is the placement the server holds.
|
|
907
|
-
it("moves a message still at its source back to the source folder at the ceiling", async () => {
|
|
908
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
909
|
-
h.destinationSearchUids = [100];
|
|
910
|
-
h.allThreadMessages = [
|
|
911
|
-
{
|
|
912
|
-
...baseThreadMessage,
|
|
913
|
-
accountConfigId: "cfg-1",
|
|
914
|
-
threadMessageId: "tm-1",
|
|
915
|
-
mailboxId: "trash-mbx",
|
|
916
|
-
isDeleted: true,
|
|
917
|
-
},
|
|
918
|
-
];
|
|
919
|
-
|
|
920
|
-
await handleMessageDelete(moveEvent, noopLogger, 3, deps());
|
|
921
|
-
|
|
922
|
-
assert.equal(called("message.delete").length, 0);
|
|
923
|
-
assert.equal(called("threadMessage.deleteMany").length, 0);
|
|
924
|
-
|
|
925
|
-
// `mailboxId` and `uid` name the same message again, and the same write
|
|
926
|
-
// settles `status` out of `moving`, so no later delete of this message
|
|
927
|
-
// waits on a mutation that has terminated. It also carries the give-up
|
|
928
|
-
// (#1153) and names it a delete (#1229): the user asked for one, it did
|
|
929
|
-
// not happen, and nothing is coming to try again.
|
|
930
|
-
assert.deepEqual(
|
|
931
|
-
called("message.transitionPlacement").at(-1)?.args,
|
|
932
|
-
restoredToSource("abandoned"),
|
|
933
|
-
);
|
|
934
|
-
assert.equal(
|
|
935
|
-
called("message.update").length,
|
|
936
|
-
0,
|
|
937
|
-
"settling `status` without repairing the pair is what left the row addressable as a Trash expunge",
|
|
938
|
-
);
|
|
939
|
-
|
|
940
|
-
// The listing row follows the Message row out of Trash, which is what
|
|
941
|
-
// makes "Delete permanently" unreachable: the client resolves that
|
|
942
|
-
// outcome from a target whose `mailboxId` is the Trash mailbox.
|
|
943
|
-
assert.deepEqual(called("threadMessage.update").at(-1)?.args, [
|
|
944
|
-
"cfg-1",
|
|
945
|
-
"tm-1",
|
|
946
|
-
{ uid: 10, mailboxId: "src-mbx", isDeleted: false },
|
|
947
|
-
{
|
|
948
|
-
composites: {
|
|
949
|
-
sentDate: baseThreadMessage.sentDate,
|
|
950
|
-
mailboxId: "trash-mbx",
|
|
951
|
-
isRead: true,
|
|
952
|
-
isDeleted: true,
|
|
953
|
-
hasStars: true,
|
|
954
|
-
hasAttachment: false,
|
|
955
|
-
},
|
|
956
|
-
},
|
|
957
|
-
]);
|
|
958
|
-
assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
|
|
959
|
-
});
|
|
960
|
-
|
|
961
|
-
// The optimistic transition already pointed the row at Trash. The
|
|
962
|
-
// server has now confirmed the message never left the source, so the row
|
|
963
|
-
// and the server disagree about where the mail is, and only a resync of
|
|
964
|
-
// both folders settles that. Without it the user is shown the message in
|
|
965
|
-
// Trash indefinitely while it sits in the source folder.
|
|
966
|
-
it("resyncs both folders when the server confirms the message never left the source", async () => {
|
|
967
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
968
|
-
h.destinationSearchUids = [100];
|
|
969
|
-
|
|
970
|
-
await handleMessageDelete(moveEvent, noopLogger, 3, deps());
|
|
971
|
-
|
|
972
|
-
assert.deepEqual(
|
|
973
|
-
called("emitEvent").map((c) => c.args[0]),
|
|
974
|
-
[
|
|
975
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
|
|
976
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "trash-mbx" },
|
|
977
|
-
],
|
|
978
|
-
"a confirmed divergence resyncs on the broken verdict too, not only on the reconciled one",
|
|
979
|
-
);
|
|
980
|
-
});
|
|
981
|
-
|
|
982
|
-
// Issue #980, the failure the budget exists for: every redelivery
|
|
983
|
-
// re-MOVEs a uid the source no longer holds and throws identically. The
|
|
984
|
-
// ceiling lives in the error catch, so a throwing `moveMessages` settles
|
|
985
|
-
// there rather than running past the budget into the dead-letter queue.
|
|
986
|
-
describe("a re-MOVE that throws", () => {
|
|
987
|
-
const moveThrows = (): void => {
|
|
988
|
-
h.connection.moveMessages = async () => {
|
|
989
|
-
throw new Error(
|
|
990
|
-
"NO [TRYAGAIN] UID MOVE failed: no matching messages",
|
|
991
|
-
);
|
|
992
|
-
};
|
|
993
|
-
};
|
|
994
|
-
|
|
995
|
-
it("re-throws inside the budget so the queue redelivers", async () => {
|
|
996
|
-
moveThrows();
|
|
997
|
-
|
|
998
|
-
await assert.rejects(
|
|
999
|
-
handleMessageDelete(moveEvent, noopLogger, 1, deps()),
|
|
1000
|
-
/UID MOVE failed/,
|
|
1001
|
-
);
|
|
1002
|
-
|
|
1003
|
-
assert.equal(called("message.delete").length, 0);
|
|
1004
|
-
assert.equal(called("threadMessage.deleteMany").length, 0);
|
|
1005
|
-
assert.equal(attemptMarkerSyncStatus(), "failed");
|
|
1006
|
-
});
|
|
1007
|
-
|
|
1008
|
-
it("settles at the ceiling instead of dead-lettering undiagnosed", async () => {
|
|
1009
|
-
moveThrows();
|
|
1010
|
-
sourceNoLongerHoldsTheUid();
|
|
1011
|
-
|
|
1012
|
-
await handleMessageDelete(moveEvent, noopLogger, 3, deps());
|
|
1013
|
-
|
|
1014
|
-
assert.equal(
|
|
1015
|
-
called("threadMessage.deleteMany").length,
|
|
1016
|
-
1,
|
|
1017
|
-
"the ceiling must diagnose the failure, not hand it to the DLQ",
|
|
1018
|
-
);
|
|
1019
|
-
assert.equal(called("message.delete").length, 1);
|
|
1020
|
-
assert.equal(called("emitEvent").length, 2);
|
|
1021
|
-
});
|
|
1022
|
-
|
|
1023
|
-
it("settles a still-present message at the ceiling back onto its source", async () => {
|
|
1024
|
-
moveThrows();
|
|
1025
|
-
|
|
1026
|
-
await handleMessageDelete(moveEvent, noopLogger, 3, deps());
|
|
1027
|
-
|
|
1028
|
-
assert.deepEqual(
|
|
1029
|
-
called("message.transitionPlacement").at(-1)?.args,
|
|
1030
|
-
restoredToSource("abandoned"),
|
|
1031
|
-
);
|
|
1032
|
-
assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
|
|
1033
|
-
});
|
|
1034
|
-
});
|
|
1035
|
-
|
|
1036
|
-
// A probe the server refused says nothing either way, so it counts as
|
|
1037
|
-
// unconfirmed and spends an attempt. The budget is what makes that safe:
|
|
1038
|
-
// without a ceiling the redeliveries would re-MOVE a uid the source no
|
|
1039
|
-
// longer holds forever and head-of-line block the account's deletes.
|
|
1040
|
-
it("treats an unanswerable probe as unconfirmed and spends an attempt", async () => {
|
|
1041
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
1042
|
-
h.connection.openBox = (async (_path: string, readOnly?: boolean) => {
|
|
1043
|
-
if (readOnly) throw new Error("NO [SERVERBUG] EXAMINE failed");
|
|
1044
|
-
return { uidvalidity: 1 };
|
|
1045
|
-
}) as Connection["openBox"];
|
|
1046
|
-
|
|
1047
|
-
await assert.rejects(
|
|
1048
|
-
handleMessageDelete(moveEvent, noopLogger, 1, deps()),
|
|
1049
|
-
/unconfirmed/,
|
|
1050
|
-
);
|
|
1051
|
-
|
|
1052
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
1053
|
-
assert.equal(called("message.delete").length, 0);
|
|
1054
|
-
assert.equal(called("threadMessage.deleteMany").length, 0);
|
|
1055
|
-
assert.equal(called("threadMessage.update").length, 0);
|
|
1056
|
-
assert.equal(attemptMarkerSyncStatus(), "failed");
|
|
1057
|
-
assert.equal(h.disconnectCount, 1);
|
|
1058
|
-
});
|
|
1059
|
-
});
|
|
1060
|
-
|
|
1061
|
-
// The queue handler `JSON.parse`s the body and casts it to WorkerEvent with
|
|
1062
|
-
// no validation, so an event whose `operation` is missing, misspelled or
|
|
1063
|
-
// from a newer producer reaches here. It must never be read as an expunge.
|
|
1064
|
-
for (const [name, operation] of [
|
|
1065
|
-
["missing", undefined],
|
|
1066
|
-
["unknown", "trash"],
|
|
1067
|
-
["empty", ""],
|
|
1068
|
-
] as const) {
|
|
1069
|
-
it(`abandons the delete and hands the row back when operation is ${name}`, async () => {
|
|
1070
|
-
const malformed = {
|
|
1071
|
-
...moveEvent,
|
|
1072
|
-
operation,
|
|
1073
|
-
} as unknown as MessageDeleteEvent;
|
|
1074
|
-
|
|
1075
|
-
await handleMessageDelete(malformed, noopLogger, 1, deps());
|
|
1076
|
-
|
|
1077
|
-
assert.equal(
|
|
1078
|
-
called("connection.deleteMessages").length,
|
|
1079
|
-
0,
|
|
1080
|
-
"nothing may be expunged for an operation nobody wrote",
|
|
1081
|
-
);
|
|
1082
|
-
assert.equal(called("message.delete").length, 0);
|
|
1083
|
-
assert.equal(called("threadMessage.delete").length, 0);
|
|
1084
|
-
|
|
1085
|
-
// The row goes back to the mailbox the server still holds it in, so
|
|
1086
|
-
// the failure is visible as the message reappearing rather than as an
|
|
1087
|
-
// invisible syncStatus on a row that claims Trash. A refusal keeps
|
|
1088
|
-
// `failed`: the product turned down something the user asked for.
|
|
1089
|
-
assert.deepEqual(
|
|
1090
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1091
|
-
restoredToSource("abandoned"),
|
|
1092
|
-
);
|
|
1093
|
-
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
1094
|
-
uid: 10,
|
|
1095
|
-
mailboxId: "src-mbx",
|
|
1096
|
-
isDeleted: false,
|
|
1097
|
-
});
|
|
1098
|
-
});
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
it("refuses to expunge a move-to-trash that names no destination", async () => {
|
|
1102
|
-
const destinationless = {
|
|
1103
|
-
...moveEvent,
|
|
1104
|
-
destinationMailboxId: undefined,
|
|
1105
|
-
destinationMailboxPath: undefined,
|
|
1106
|
-
} as MessageDeleteEvent;
|
|
1107
|
-
|
|
1108
|
-
await handleMessageDelete(destinationless, noopLogger, 1, deps());
|
|
1109
|
-
|
|
1110
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
1111
|
-
assert.equal(called("message.delete").length, 0);
|
|
1112
|
-
assert.deepEqual(
|
|
1113
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1114
|
-
restoredToSource("abandoned"),
|
|
1115
|
-
);
|
|
1116
|
-
});
|
|
1117
|
-
|
|
1118
|
-
it("expunges on the server and removes every thread row before the message row", async () => {
|
|
1119
|
-
await handleMessageDelete(permanent(), noopLogger, 1, deps());
|
|
1120
|
-
|
|
1121
|
-
assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10]]);
|
|
1122
|
-
assert.equal(called("threadMessage.delete").length, 2);
|
|
1123
|
-
assert.ok(
|
|
1124
|
-
h.calls.findIndex((c) => c.method === "threadMessage.delete") <
|
|
1125
|
-
h.calls.findIndex((c) => c.method === "message.delete"),
|
|
1126
|
-
"thread rows go first so no row outlives its message",
|
|
1127
|
-
);
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
// Regression on #212: a permanent delete the server has already applied must
|
|
1131
|
-
// still reconcile the local rows, now that the text alone no longer decides.
|
|
1132
|
-
it("cleans up locally and swallows the error when the message is confirmed gone on IMAP", async () => {
|
|
1133
|
-
h.connection.deleteMessages = async () => {
|
|
1134
|
-
throw new Error("NONEXISTENT uid");
|
|
1135
|
-
};
|
|
1136
|
-
sourceNoLongerHoldsTheUid();
|
|
1137
|
-
|
|
1138
|
-
await handleMessageDelete(permanent(), noopLogger, 1, deps());
|
|
1139
|
-
|
|
1140
|
-
assert.equal(called("message.delete").length, 1);
|
|
1141
|
-
assert.equal(called("threadMessage.delete").length, 2);
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
// Issue #845. The three cases below are the same bug from three directions:
|
|
1145
|
-
// "not found" in an error string is never on its own a reason to destroy the
|
|
1146
|
-
// only local record of live mail.
|
|
1147
|
-
|
|
1148
|
-
it("keeps the rows when a move-to-trash fails after the MOVE landed", async () => {
|
|
1149
|
-
// The MOVE succeeded; the ThreadMessage write behind it did not. The
|
|
1150
|
-
// message is sitting in Trash, and the NotFoundError names a local row,
|
|
1151
|
-
// not a mail-server one.
|
|
1152
|
-
h.threadMessageUpdateError = Object.assign(
|
|
1153
|
-
new Error("Thread message not found"),
|
|
1154
|
-
{ name: "NotFoundError" },
|
|
1155
|
-
);
|
|
1156
|
-
sourceNoLongerHoldsTheUid();
|
|
1157
|
-
|
|
1158
|
-
await assert.rejects(
|
|
1159
|
-
handleMessageDelete(moveEvent, noopLogger, 1, deps()),
|
|
1160
|
-
/not found/,
|
|
1161
|
-
);
|
|
1162
|
-
|
|
1163
|
-
assert.equal(
|
|
1164
|
-
called("message.delete").length,
|
|
1165
|
-
0,
|
|
1166
|
-
"the message is in Trash; deleting its row loses the only handle on it",
|
|
1167
|
-
);
|
|
1168
|
-
assert.equal(called("threadMessage.delete").length, 0);
|
|
1169
|
-
assert.equal(
|
|
1170
|
-
called("connection.search").length,
|
|
1171
|
-
0,
|
|
1172
|
-
"a move-to-trash never probes: absence from the source is its success signature",
|
|
1173
|
-
);
|
|
1174
|
-
assert.equal(attemptMarkerSyncStatus(), "failed");
|
|
1175
|
-
});
|
|
1176
|
-
|
|
1177
|
-
it("keeps the rows on a NONEXISTENT move-to-trash whose source still holds the uid", async () => {
|
|
1178
|
-
h.connection.moveMessages = async () => {
|
|
1179
|
-
throw new Error("NONEXISTENT: mailbox does not exist");
|
|
1180
|
-
};
|
|
1181
|
-
|
|
1182
|
-
await assert.rejects(
|
|
1183
|
-
handleMessageDelete(moveEvent, noopLogger, 1, deps()),
|
|
1184
|
-
/NONEXISTENT/,
|
|
1185
|
-
);
|
|
1186
|
-
|
|
1187
|
-
assert.equal(called("message.delete").length, 0);
|
|
1188
|
-
assert.equal(called("threadMessage.delete").length, 0);
|
|
1189
|
-
assert.equal(attemptMarkerSyncStatus(), "failed");
|
|
1190
|
-
});
|
|
1191
|
-
|
|
1192
|
-
it("keeps the rows when a permanent delete's source still holds the uid", async () => {
|
|
1193
|
-
h.connection.deleteMessages = async () => {
|
|
1194
|
-
throw new Error("NONEXISTENT uid");
|
|
1195
|
-
};
|
|
1196
|
-
// imapflow drops rows on back-to-back FETCHes (#408); the SEARCH is what
|
|
1197
|
-
// the verdict rests on, and it still lists the uid.
|
|
1198
|
-
h.connection.fetchMessages = async () => [];
|
|
1199
|
-
|
|
1200
|
-
await assert.rejects(
|
|
1201
|
-
handleMessageDelete(permanent(), noopLogger, 1, deps()),
|
|
1202
|
-
/NONEXISTENT/,
|
|
1203
|
-
);
|
|
1204
|
-
|
|
1205
|
-
assert.equal(
|
|
1206
|
-
called("connection.search").length,
|
|
1207
|
-
1,
|
|
1208
|
-
"the error text only selects a candidate; the source box decides",
|
|
1209
|
-
);
|
|
1210
|
-
assert.equal(called("message.delete").length, 0);
|
|
1211
|
-
assert.equal(called("threadMessage.delete").length, 0);
|
|
1212
|
-
});
|
|
1213
|
-
|
|
1214
|
-
it("keeps the rows and rethrows the original error when the probe cannot answer", async () => {
|
|
1215
|
-
// The SELECT is what failed, so there is no open box left for the probe
|
|
1216
|
-
// to ask. The IMAP error must still be the one that rejects, and the row
|
|
1217
|
-
// must not be stranded mid-delete for the record to carry to the DLQ.
|
|
1218
|
-
h.connection.openBox = async () => {
|
|
1219
|
-
throw new Error("NONEXISTENT mailbox does not exist");
|
|
1220
|
-
};
|
|
1221
|
-
h.connection.fetchMessages = async () => {
|
|
1222
|
-
throw new Error("No mailbox selected");
|
|
1223
|
-
};
|
|
1224
|
-
|
|
1225
|
-
await assert.rejects(
|
|
1226
|
-
handleMessageDelete(permanent(), noopLogger, 1, deps()),
|
|
1227
|
-
/NONEXISTENT mailbox does not exist/,
|
|
1228
|
-
);
|
|
1229
|
-
|
|
1230
|
-
assert.equal(called("message.delete").length, 0);
|
|
1231
|
-
assert.equal(called("threadMessage.delete").length, 0);
|
|
1232
|
-
assert.equal(attemptMarkerSyncStatus(), "failed");
|
|
1233
|
-
});
|
|
1234
|
-
|
|
1235
|
-
it("abandons rather than creating the destination on TRYCREATE", async () => {
|
|
1236
|
-
// Creating it resurrects an empty `Trash` beside the real one, and the
|
|
1237
|
-
// name-hint rule then has two folders to choose between. The picker
|
|
1238
|
-
// offers the ones the account actually has.
|
|
1239
|
-
h.connection.moveMessages = async () => {
|
|
1240
|
-
throw new Error("TRYCREATE: no such mailbox");
|
|
1241
|
-
};
|
|
1242
|
-
|
|
1243
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1244
|
-
|
|
1245
|
-
assert.equal(called("connection.createMailbox").length, 0);
|
|
1246
|
-
assert.deepEqual(
|
|
1247
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1248
|
-
restoredToSource("abandoned"),
|
|
1249
|
-
);
|
|
1250
|
-
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
1251
|
-
uid: 10,
|
|
1252
|
-
mailboxId: "src-mbx",
|
|
1253
|
-
isDeleted: false,
|
|
1254
|
-
});
|
|
1255
|
-
});
|
|
1256
|
-
|
|
1257
|
-
// Removing the Message row is destructive, and the refusal paths reach it on
|
|
1258
|
-
// whatever the row happens to be — an unrecognised operation is answered on
|
|
1259
|
-
// an `active` row as readily as on a `deleting` one. So the removal is
|
|
1260
|
-
// claimed first, and a row with no delete outstanding is left alone.
|
|
1261
|
-
it("never removes a row no delete is outstanding on", async () => {
|
|
1262
|
-
h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "active" };
|
|
1263
|
-
h.allThreadMessages = [];
|
|
1264
|
-
h.transitionLost = true;
|
|
1265
|
-
const malformed = {
|
|
1266
|
-
...moveEvent,
|
|
1267
|
-
operation: undefined,
|
|
1268
|
-
} as unknown as MessageDeleteEvent;
|
|
1269
|
-
|
|
1270
|
-
await handleMessageDelete(malformed, noopLogger, 1, deps());
|
|
1271
|
-
|
|
1272
|
-
assert.deepEqual(
|
|
1273
|
-
called("message.transitionPlacement")[0]?.args[1],
|
|
1274
|
-
{ status: ["moving", "deleting"] },
|
|
1275
|
-
"the removal is claimed off a placement this delete owns",
|
|
1276
|
-
);
|
|
1277
|
-
assert.equal(
|
|
1278
|
-
called("message.delete").length,
|
|
1279
|
-
0,
|
|
1280
|
-
"and a lost claim removes nothing",
|
|
1281
|
-
);
|
|
1282
|
-
});
|
|
1283
|
-
|
|
1284
|
-
it("removes the row once the claim on it wins", async () => {
|
|
1285
|
-
h.messageRow = { messageIdHeader: MESSAGE_ID_HEADER, status: "deleting" };
|
|
1286
|
-
h.allThreadMessages = [];
|
|
1287
|
-
const malformed = {
|
|
1288
|
-
...moveEvent,
|
|
1289
|
-
operation: undefined,
|
|
1290
|
-
} as unknown as MessageDeleteEvent;
|
|
1291
|
-
|
|
1292
|
-
await handleMessageDelete(malformed, noopLogger, 1, deps());
|
|
1293
|
-
|
|
1294
|
-
assert.deepEqual(
|
|
1295
|
-
called("message.delete").map((c) => c.args[0]),
|
|
1296
|
-
["msg-1"],
|
|
1297
|
-
);
|
|
1298
|
-
});
|
|
1299
|
-
|
|
1300
|
-
it("abandons an event minted under an unknown contract, before connecting", async () => {
|
|
1301
|
-
const unversioned = {
|
|
1302
|
-
...moveEvent,
|
|
1303
|
-
schemaVersion: undefined,
|
|
1304
|
-
} as unknown as MessageDeleteEvent;
|
|
1305
|
-
|
|
1306
|
-
await handleMessageDelete(unversioned, noopLogger, 1, deps());
|
|
1307
|
-
|
|
1308
|
-
assert.equal(h.getConnectionCount, 0);
|
|
1309
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
1310
|
-
assert.deepEqual(
|
|
1311
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1312
|
-
restoredToSource("abandoned"),
|
|
1313
|
-
);
|
|
1314
|
-
assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
|
|
1315
|
-
uid: 10,
|
|
1316
|
-
mailboxId: "src-mbx",
|
|
1317
|
-
isDeleted: false,
|
|
1318
|
-
});
|
|
1319
|
-
});
|
|
1320
|
-
|
|
1321
|
-
it("hands back every listing row a message has, not just the first", async () => {
|
|
1322
|
-
const unversioned = {
|
|
1323
|
-
...moveEvent,
|
|
1324
|
-
schemaVersion: undefined,
|
|
1325
|
-
} as unknown as MessageDeleteEvent;
|
|
1326
|
-
|
|
1327
|
-
await handleMessageDelete(unversioned, noopLogger, 1, deps());
|
|
1328
|
-
|
|
1329
|
-
assert.deepEqual(
|
|
1330
|
-
called("threadMessage.update").map((c) => c.args[1]),
|
|
1331
|
-
["tm-1", "tm-2"],
|
|
1332
|
-
);
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
it("finishes the removal when an abandoned delete has no listing rows left", async () => {
|
|
1336
|
-
// A permanent delete removes them before it enqueues, and they cannot be
|
|
1337
|
-
// rebuilt here. Restoring the Message alone leaves mail nothing can list,
|
|
1338
|
-
// which is worse than either consistent state; the server copy survives
|
|
1339
|
-
// and a full sync brings it back.
|
|
1340
|
-
h.allThreadMessages = [];
|
|
1341
|
-
const unversioned = {
|
|
1342
|
-
...permanent(),
|
|
1343
|
-
schemaVersion: undefined,
|
|
1344
|
-
} as unknown as MessageDeleteEvent;
|
|
1345
|
-
|
|
1346
|
-
await handleMessageDelete(unversioned, noopLogger, 1, deps());
|
|
1347
|
-
|
|
1348
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
1349
|
-
assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
|
|
1350
|
-
assert.equal(
|
|
1351
|
-
called("message.update").length,
|
|
1352
|
-
0,
|
|
1353
|
-
"never leave a Message row no listing can reach",
|
|
1354
|
-
);
|
|
1355
|
-
});
|
|
1356
|
-
|
|
1357
|
-
it("marks failed and rethrows on an unclassified IMAP error", async () => {
|
|
1358
|
-
h.connection.moveMessages = async () => {
|
|
1359
|
-
throw new Error("server exploded");
|
|
1360
|
-
};
|
|
1361
|
-
|
|
1362
|
-
await assert.rejects(
|
|
1363
|
-
handleMessageDelete(moveEvent, noopLogger, 1, deps()),
|
|
1364
|
-
/server exploded/,
|
|
1365
|
-
);
|
|
1366
|
-
|
|
1367
|
-
assert.equal(attemptMarkerSyncStatus(), "failed");
|
|
1368
|
-
});
|
|
1369
|
-
|
|
1370
|
-
// Issue #1203. The pause is thrown by the openBox guard before the MOVE is
|
|
1371
|
-
// issued, so acking left the row `moving` on Trash with the source's uid.
|
|
1372
|
-
// Nothing re-enqueues a MESSAGE_DELETE, and the cursor rebuild matches rows
|
|
1373
|
-
// by `(accountConfigId, mailboxId)`, so a row naming Trash is in neither
|
|
1374
|
-
// folder's set — the message stayed undeletable and unmovable for good.
|
|
1375
|
-
it("hands the optimistic trash move back when openBox trips a UIDVALIDITY mismatch", async () => {
|
|
1376
|
-
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
1377
|
-
|
|
1378
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1379
|
-
|
|
1380
|
-
assert.equal(
|
|
1381
|
-
(called("mailbox.update")[0]?.args[2] as { cursorState?: string })
|
|
1382
|
-
?.cursorState,
|
|
1383
|
-
"cursor_invalid",
|
|
1384
|
-
);
|
|
1385
|
-
assert.deepEqual(
|
|
1386
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1387
|
-
restoredToSource("synced"),
|
|
1388
|
-
"the row goes back to the source pair, which is the set the rebuild adjudicates",
|
|
1389
|
-
);
|
|
1390
|
-
assert.equal(
|
|
1391
|
-
(called("threadMessage.update")[0]?.args[2] as { isDeleted?: boolean })
|
|
1392
|
-
?.isDeleted,
|
|
1393
|
-
false,
|
|
1394
|
-
"the listing rows lose the deletion mark the optimistic write put on them",
|
|
1395
|
-
);
|
|
1396
|
-
});
|
|
1397
|
-
|
|
1398
|
-
// The terminal resolver opens the source itself, and since #1005 its answer
|
|
1399
|
-
// WRITES a placement. A folder deleted and recreated renumbers every uid, so
|
|
1400
|
-
// a uid-keyed probe finds whatever now sits at this one, reads it as "still
|
|
1401
|
-
// at source", and binds the row to a stranger the next permanent delete
|
|
1402
|
-
// expunges. The dropped connection is what makes it reachable: it skips the
|
|
1403
|
-
// guarded openBox on the very delivery that goes on to settle.
|
|
1404
|
-
it("never settles an exhausted delete off a uid-keyed probe that skipped the cursor guard", async () => {
|
|
1405
|
-
h.connectionErrors = [new Error("ECONNRESET")];
|
|
1406
|
-
h.connection.openBox = async (path: string) => {
|
|
1407
|
-
h.openBoxPath = path;
|
|
1408
|
-
return { uidvalidity: 999 };
|
|
1409
|
-
};
|
|
1410
|
-
|
|
1411
|
-
await handleMessageDelete(
|
|
1412
|
-
moveEvent,
|
|
1413
|
-
noopLogger,
|
|
1414
|
-
MESSAGE_DELETE_MAX_ATTEMPTS,
|
|
1415
|
-
deps(),
|
|
1416
|
-
);
|
|
1417
|
-
|
|
1418
|
-
assert.equal(
|
|
1419
|
-
called("connection.fetchMessages").length,
|
|
1420
|
-
0,
|
|
1421
|
-
"the guard refused the SELECT, so nothing was settled off a uid on the dead axis",
|
|
1422
|
-
);
|
|
1423
|
-
assert.equal(
|
|
1424
|
-
called("message.delete").length,
|
|
1425
|
-
0,
|
|
1426
|
-
"a renumbered folder answers for a different message; nothing is reconciled away off it",
|
|
1427
|
-
);
|
|
1428
|
-
assert.equal(
|
|
1429
|
-
(called("mailbox.update")[0]?.args[2] as { cursorState?: string })
|
|
1430
|
-
?.cursorState,
|
|
1431
|
-
"cursor_invalid",
|
|
1432
|
-
"the probe trips the mailbox into a rebuild instead",
|
|
1433
|
-
);
|
|
1434
|
-
// Issue #1203: "leaving it for the cursor rebuild" was never an option —
|
|
1435
|
-
// the rebuild matches rows by `(accountConfigId, mailboxId)`, so it only
|
|
1436
|
-
// sees this row once the row names the source again. The source still
|
|
1437
|
-
// answers to the Message-ID, so the pair the row goes back on is the one
|
|
1438
|
-
// the server has just confirmed on the axis a UIDVALIDITY change leaves
|
|
1439
|
-
// intact.
|
|
1440
|
-
assert.deepEqual(
|
|
1441
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1442
|
-
restoredToSource("synced"),
|
|
1443
|
-
);
|
|
1444
|
-
});
|
|
1445
|
-
|
|
1446
|
-
// Issue #1203, the redelivery half, and the mail-loss edge in it. INBOX was
|
|
1447
|
-
// recreated (UIDVALIDITY bumped, which is why the cursor is paused), the
|
|
1448
|
-
// MOVE never ran, and INBOX honestly answers "no" for the Message-ID because
|
|
1449
|
-
// it no longer holds anything it used to. Trash holds an older copy of the
|
|
1450
|
-
// same Message-ID at uid 77 — a sieve `fileinto` + `keep`, a resend — which
|
|
1451
|
-
// `deriveMessageId` folds into this one local row. Settling `moved` on 77
|
|
1452
|
-
// binds the row to that copy, and the next permanent delete expunges it:
|
|
1453
|
-
// Trash is not paused, so no rebuild is ever coming to adjudicate there.
|
|
1454
|
-
it("never settles a redelivered paused trash move as moved off a destination sighting", async () => {
|
|
1455
|
-
h.mailbox = {
|
|
1456
|
-
mailboxId: "src-mbx",
|
|
1457
|
-
uidValidity: 1,
|
|
1458
|
-
cursorState: "cursor_invalid",
|
|
1459
|
-
};
|
|
1460
|
-
h.sourceSearchUids = [];
|
|
1461
|
-
h.destinationSearchUids = [77];
|
|
1462
|
-
|
|
1463
|
-
await handleMessageDelete(moveEvent, capturingLog(), 2, deps());
|
|
1464
|
-
|
|
1465
|
-
assert.equal(
|
|
1466
|
-
called("message.updateUid").length,
|
|
1467
|
-
0,
|
|
1468
|
-
"a sighting off a source whose uid axis has moved is not proof this delete ran",
|
|
1469
|
-
);
|
|
1470
|
-
// The source pair is the set its own cursor rebuild walks and adjudicates
|
|
1471
|
-
// by Message-ID, so handing the row back there is the reconcile path.
|
|
1472
|
-
assert.deepEqual(
|
|
1473
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1474
|
-
restoredToSource("abandoned"),
|
|
1475
|
-
);
|
|
1476
|
-
assert.equal(
|
|
1477
|
-
loggedWith("alert", "message_delete_paused_placement_unproven").length,
|
|
1478
|
-
1,
|
|
1479
|
-
"a placement the server never settled is an operator's to adjudicate",
|
|
1480
|
-
);
|
|
1481
|
-
});
|
|
1482
|
-
|
|
1483
|
-
// Issue #1203 and the taxonomy `message-copy.ts` already applies: an
|
|
1484
|
-
// evidence-free redelivery is broken, not settled. With no Message-ID header
|
|
1485
|
-
// neither folder was asked, and silence is not an answer to write `synced`
|
|
1486
|
-
// on. Route in: attempt 1 issues the MOVE, the confirmation comes back
|
|
1487
|
-
// unprobeable, and the exhausted settle's guarded openBox trips the pause.
|
|
1488
|
-
// `receiveCount === 1` there is not proof the command never left.
|
|
1489
|
-
it("settles a paused delete broken when the MOVE was issued and neither folder can be asked", async () => {
|
|
1490
|
-
h.connection.moveMessages = async () => ({ uidMap: new Map() });
|
|
1491
|
-
sourceNoLongerHoldsTheUid();
|
|
1492
|
-
h.messageRow = { status: "moving" };
|
|
1493
|
-
let opened = 0;
|
|
1494
|
-
h.connection.openBox = async (path: string) => {
|
|
1495
|
-
h.openBoxPath = path;
|
|
1496
|
-
opened += 1;
|
|
1497
|
-
// The folder was recreated between the confirmation probe and the
|
|
1498
|
-
// settle, which is what the terminal resolver's guarded openBox
|
|
1499
|
-
// refuses to settle off.
|
|
1500
|
-
return { uidvalidity: opened > 2 ? 999 : 1 };
|
|
1501
|
-
};
|
|
1502
|
-
|
|
1503
|
-
await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
|
|
1504
|
-
|
|
1505
|
-
assert.deepEqual(
|
|
1506
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1507
|
-
restoredToSource("abandoned"),
|
|
1508
|
-
"the first attempt reaches this settle with the MOVE already issued, so `synced` claims what nothing confirmed",
|
|
1509
|
-
);
|
|
1510
|
-
assert.equal(
|
|
1511
|
-
loggedWith("alert", "message_delete_paused_placement_unproven").length,
|
|
1512
|
-
1,
|
|
1513
|
-
);
|
|
1514
|
-
});
|
|
1515
|
-
|
|
1516
|
-
// The user's delete was silently dropped. Nothing throws, nothing alerts and
|
|
1517
|
-
// the row reads settled, so the only trace it ever leaves is this metric —
|
|
1518
|
-
// the counterpart of copy's `message_copy_not_landed`.
|
|
1519
|
-
it("counts a paused delete the server was never told about", async () => {
|
|
1520
|
-
h.mailbox = {
|
|
1521
|
-
mailboxId: "src-mbx",
|
|
1522
|
-
uidValidity: 1,
|
|
1523
|
-
cursorState: "rebuilding",
|
|
1524
|
-
};
|
|
1525
|
-
|
|
1526
|
-
await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
|
|
1527
|
-
|
|
1528
|
-
const dropped = loggedWith("metric", "message_delete_dropped_on_pause");
|
|
1529
|
-
assert.equal(dropped.length, 1);
|
|
1530
|
-
assert.equal(dropped[0]?.level, "warn");
|
|
1531
|
-
assert.deepEqual(
|
|
1532
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1533
|
-
restoredToSource("synced"),
|
|
1534
|
-
);
|
|
1535
|
-
});
|
|
1536
|
-
|
|
1537
|
-
// The listing rows decide the outcome, so the caller has already read them.
|
|
1538
|
-
// Reading them a second time inside `restoreSourcePlacement` is a query per
|
|
1539
|
-
// hand-back for an answer already in hand.
|
|
1540
|
-
it("reads the listing rows once per hand-back", async () => {
|
|
1541
|
-
h.mailbox = {
|
|
1542
|
-
mailboxId: "src-mbx",
|
|
1543
|
-
uidValidity: 1,
|
|
1544
|
-
cursorState: "rebuilding",
|
|
1545
|
-
};
|
|
1546
|
-
|
|
1547
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1548
|
-
|
|
1549
|
-
assert.equal(called("threadMessage.findAllByMessageId").length, 1);
|
|
1550
|
-
});
|
|
1551
|
-
|
|
1552
|
-
// A permanent delete has no destination, so the resync was gated off
|
|
1553
|
-
// entirely: `handBackDelete` removed the local row, nothing enqueued a
|
|
1554
|
-
// SYNC_MESSAGES, and the mail was invisible until the next scheduled sync
|
|
1555
|
-
// while the server still held it. One folder is all this event touches, and
|
|
1556
|
-
// SYNC_MESSAGES on a paused mailbox routes to the cursor rebuild.
|
|
1557
|
-
it("resyncs the source folder after a paused permanent delete removes the local rows", async () => {
|
|
1558
|
-
h.mailbox = {
|
|
1559
|
-
mailboxId: "src-mbx",
|
|
1560
|
-
uidValidity: 1,
|
|
1561
|
-
cursorState: "rebuilding",
|
|
1562
|
-
};
|
|
1563
|
-
h.allThreadMessages = [];
|
|
1564
|
-
|
|
1565
|
-
await handleMessageDelete(permanent(), capturingLog(), 1, deps());
|
|
1566
|
-
|
|
1567
|
-
assert.deepEqual(
|
|
1568
|
-
called("emitEvent").map((c) => c.args[0]),
|
|
1569
|
-
[{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" }],
|
|
1570
|
-
"the folder whose rows just went must be told to re-project them",
|
|
1571
|
-
);
|
|
1572
|
-
});
|
|
1573
|
-
|
|
1574
|
-
// The pause is the expected skip the guard doc calls never-a-fault, so it
|
|
1575
|
-
// alerted on every routine paused permanent delete. The fact that local mail
|
|
1576
|
-
// went while the server copy survived still has to be readable.
|
|
1577
|
-
it("does not alert on a routine paused permanent delete", async () => {
|
|
1578
|
-
h.mailbox = {
|
|
1579
|
-
mailboxId: "src-mbx",
|
|
1580
|
-
uidValidity: 1,
|
|
1581
|
-
cursorState: "rebuilding",
|
|
1582
|
-
};
|
|
1583
|
-
h.allThreadMessages = [];
|
|
1584
|
-
|
|
1585
|
-
await handleMessageDelete(permanent(), capturingLog(), 1, deps());
|
|
1586
|
-
|
|
1587
|
-
assert.deepEqual(
|
|
1588
|
-
h.logs.filter((line) => line.level === "error"),
|
|
1589
|
-
[],
|
|
1590
|
-
"a pause the guard expects never pages anyone",
|
|
1591
|
-
);
|
|
1592
|
-
const dropped = loggedWith("metric", "message_delete_dropped_on_pause");
|
|
1593
|
-
assert.equal(dropped.length, 1);
|
|
1594
|
-
assert.equal(
|
|
1595
|
-
dropped[0]?.fields.localRowsRemoved,
|
|
1596
|
-
true,
|
|
1597
|
-
"local mail disappearing while the server still holds it stays visible",
|
|
1598
|
-
);
|
|
1599
|
-
});
|
|
1600
|
-
|
|
1601
|
-
// A refusal is not a pause: the product declined something the user asked
|
|
1602
|
-
// for, and that still wakes someone.
|
|
1603
|
-
it("keeps alerting when a refused delete removed the last listing rows", async () => {
|
|
1604
|
-
h.allThreadMessages = [];
|
|
1605
|
-
const unversioned = {
|
|
1606
|
-
...permanent(),
|
|
1607
|
-
schemaVersion: undefined,
|
|
1608
|
-
} as unknown as MessageDeleteEvent;
|
|
1609
|
-
|
|
1610
|
-
await handleMessageDelete(unversioned, capturingLog(), 1, deps());
|
|
1611
|
-
|
|
1612
|
-
assert.equal(
|
|
1613
|
-
loggedWith("alert", "message_delete_unknown_schema_version").length,
|
|
1614
|
-
1,
|
|
1615
|
-
);
|
|
1616
|
-
});
|
|
1617
|
-
|
|
1618
|
-
// Issue #1122 on the same path. Trash already held an older copy of this
|
|
1619
|
-
// Message-ID and the MOVE never ran, so the destination hit is not this
|
|
1620
|
-
// message. The source is asked first for exactly this reason, and its answer
|
|
1621
|
-
// ends it — settling on uid 12 would hand Empty Trash a stranger's uid.
|
|
1622
|
-
it("never binds a redelivered paused trash move to an older copy while the source still answers", async () => {
|
|
1623
|
-
h.mailbox = {
|
|
1624
|
-
mailboxId: "src-mbx",
|
|
1625
|
-
uidValidity: 1,
|
|
1626
|
-
cursorState: "cursor_invalid",
|
|
1627
|
-
};
|
|
1628
|
-
h.sourceSearchUids = [4];
|
|
1629
|
-
h.destinationSearchUids = [12];
|
|
1630
|
-
|
|
1631
|
-
await handleMessageDelete(moveEvent, noopLogger, 2, deps());
|
|
1632
|
-
|
|
1633
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
1634
|
-
assert.deepEqual(
|
|
1635
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1636
|
-
restoredToSource("synced"),
|
|
1637
|
-
);
|
|
1638
|
-
});
|
|
1639
|
-
|
|
1640
|
-
// A paused settle is a settle, so both folders re-read their counts from
|
|
1641
|
-
// IMAP — the same resync every other terminal verdict in this handler runs.
|
|
1642
|
-
// Without it the repair depended entirely on someone else arming the
|
|
1643
|
-
// mailbox's rebuild.
|
|
1644
|
-
it("resyncs both folders after a paused delete settles", async () => {
|
|
1645
|
-
h.mailbox = {
|
|
1646
|
-
mailboxId: "src-mbx",
|
|
1647
|
-
uidValidity: 1,
|
|
1648
|
-
cursorState: "rebuilding",
|
|
1649
|
-
};
|
|
1650
|
-
|
|
1651
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1652
|
-
|
|
1653
|
-
assert.deepEqual(
|
|
1654
|
-
called("emitEvent").map((c) => c.args[0]),
|
|
1655
|
-
[
|
|
1656
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
|
|
1657
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "trash-mbx" },
|
|
1658
|
-
],
|
|
1659
|
-
);
|
|
1660
|
-
});
|
|
1661
|
-
|
|
1662
|
-
it("skips without opening a connection when the cursor is rebuilding", async () => {
|
|
1663
|
-
h.mailbox = {
|
|
1664
|
-
mailboxId: "src-mbx",
|
|
1665
|
-
uidValidity: 1,
|
|
1666
|
-
cursorState: "rebuilding",
|
|
1667
|
-
};
|
|
1668
|
-
|
|
1669
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1670
|
-
|
|
1671
|
-
assert.equal(h.getConnectionCount, 0);
|
|
1672
|
-
assert.deepEqual(
|
|
1673
|
-
called("message.transitionPlacement")[0]?.args,
|
|
1674
|
-
restoredToSource("synced"),
|
|
1675
|
-
);
|
|
1676
|
-
});
|
|
1677
|
-
|
|
1678
|
-
// A permanent delete drops its listing rows at enqueue and only the sync
|
|
1679
|
-
// path can shape them, so restoring the Message alone leaves mail nothing
|
|
1680
|
-
// can list. The row is removed instead and the mailbox's own resync
|
|
1681
|
-
// re-projects it — leaving it `deleting` made the message invisible locally
|
|
1682
|
-
// while the server still held it (issue #1203). The rule is the listing
|
|
1683
|
-
// rows, the same one `abandonDelete` resolves this state by, not the
|
|
1684
|
-
// operation on the event.
|
|
1685
|
-
it("removes a paused delete that has no listing rows left to restore", async () => {
|
|
1686
|
-
h.mailbox = {
|
|
1687
|
-
mailboxId: "src-mbx",
|
|
1688
|
-
uidValidity: 1,
|
|
1689
|
-
cursorState: "rebuilding",
|
|
1690
|
-
};
|
|
1691
|
-
h.allThreadMessages = [];
|
|
1692
|
-
|
|
1693
|
-
await handleMessageDelete(permanent(), noopLogger, 1, deps());
|
|
1694
|
-
|
|
1695
|
-
assert.equal(h.getConnectionCount, 0);
|
|
1696
|
-
assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
|
|
1697
|
-
assert.equal(
|
|
1698
|
-
restoreCalls().length,
|
|
1699
|
-
0,
|
|
1700
|
-
"a row no listing can reach is never restored",
|
|
1701
|
-
);
|
|
1702
|
-
});
|
|
1703
|
-
|
|
1704
|
-
it("removes a paused move to trash whose listing rows are already gone", async () => {
|
|
1705
|
-
h.mailbox = {
|
|
1706
|
-
mailboxId: "src-mbx",
|
|
1707
|
-
uidValidity: 1,
|
|
1708
|
-
cursorState: "rebuilding",
|
|
1709
|
-
};
|
|
1710
|
-
h.allThreadMessages = [];
|
|
1711
|
-
|
|
1712
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1713
|
-
|
|
1714
|
-
assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
|
|
1715
|
-
assert.equal(
|
|
1716
|
-
restoreCalls().length,
|
|
1717
|
-
0,
|
|
1718
|
-
"restoring a Message no listing can reach is the silent vanish, not a hand-back",
|
|
1719
|
-
);
|
|
1720
|
-
});
|
|
1721
|
-
|
|
1722
|
-
it("acks terminally without connecting when the mailbox was deleted", async () => {
|
|
1723
|
-
h.mailboxError = Object.assign(new Error("Mailbox not found: src-mbx"), {
|
|
1724
|
-
name: "NotFoundError",
|
|
1725
|
-
});
|
|
1726
|
-
|
|
1727
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1728
|
-
|
|
1729
|
-
assert.equal(h.getConnectionCount, 0);
|
|
1730
|
-
assert.equal(called("message.updateUid").length, 0);
|
|
1731
|
-
assert.equal(called("message.delete").length, 0);
|
|
1732
|
-
});
|
|
1733
|
-
|
|
1734
|
-
it("returns early without connecting when the account is soft-deleted", async () => {
|
|
1735
|
-
h.account = {
|
|
1736
|
-
accountId: "acc-1",
|
|
1737
|
-
accountConfigId: "cfg-1",
|
|
1738
|
-
deletedAt: Date.now(),
|
|
1739
|
-
};
|
|
1740
|
-
|
|
1741
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1742
|
-
|
|
1743
|
-
assert.equal(h.getConnectionCount, 0);
|
|
1744
|
-
});
|
|
1745
|
-
|
|
1746
|
-
it("throws when the account no longer exists", async () => {
|
|
1747
|
-
h.account = null;
|
|
1748
|
-
|
|
1749
|
-
await assert.rejects(
|
|
1750
|
-
handleMessageDelete(moveEvent, noopLogger, 1, deps()),
|
|
1751
|
-
/not found/,
|
|
1752
|
-
);
|
|
1753
|
-
});
|
|
1754
|
-
});
|
|
1755
|
-
|
|
1756
|
-
describe("handleMessageDelete — folder paths come from the mailbox rows", () => {
|
|
1757
|
-
beforeEach(() => {
|
|
1758
|
-
h = fresh();
|
|
1759
|
-
});
|
|
1760
|
-
|
|
1761
|
-
const movedTo = (): string[] => {
|
|
1762
|
-
const destinations: string[] = [];
|
|
1763
|
-
h.connection.moveMessages = async (
|
|
1764
|
-
_uids: number[],
|
|
1765
|
-
destination: string,
|
|
1766
|
-
) => {
|
|
1767
|
-
destinations.push(destination);
|
|
1768
|
-
return { uidMap: new Map([[10, 20]]) };
|
|
1769
|
-
};
|
|
1770
|
-
return destinations;
|
|
1771
|
-
};
|
|
1772
|
-
|
|
1773
|
-
it("runs a delete enqueued with the version 2 payload that carries folder paths", async () => {
|
|
1774
|
-
const destinations = movedTo();
|
|
1775
|
-
|
|
1776
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1777
|
-
|
|
1778
|
-
assert.deepEqual(destinations, ["Trash"]);
|
|
1779
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
1780
|
-
"msg-1",
|
|
1781
|
-
20,
|
|
1782
|
-
"trash-mbx",
|
|
1783
|
-
]);
|
|
1784
|
-
});
|
|
1785
|
-
|
|
1786
|
-
it("runs a delete enqueued with the pathless payload", async () => {
|
|
1787
|
-
const destinations = movedTo();
|
|
1788
|
-
const pathless: MessageDeleteEvent = {
|
|
1789
|
-
type: "MESSAGE_DELETE",
|
|
1790
|
-
schemaVersion: 3,
|
|
1791
|
-
eventId: "evt-1",
|
|
1792
|
-
timestamp: 1,
|
|
1793
|
-
accountId: "acc-1",
|
|
1794
|
-
messageId: "msg-1",
|
|
1795
|
-
mailboxId: "src-mbx",
|
|
1796
|
-
uid: 10,
|
|
1797
|
-
operation: "move_to_trash",
|
|
1798
|
-
destinationMailboxId: "trash-mbx",
|
|
1799
|
-
};
|
|
1800
|
-
|
|
1801
|
-
await handleMessageDelete(pathless, noopLogger, 1, deps());
|
|
1802
|
-
|
|
1803
|
-
assert.deepEqual(destinations, ["Trash"]);
|
|
1804
|
-
assert.deepEqual(called("message.updateUid")[0]?.args, [
|
|
1805
|
-
"msg-1",
|
|
1806
|
-
20,
|
|
1807
|
-
"trash-mbx",
|
|
1808
|
-
]);
|
|
1809
|
-
});
|
|
1810
|
-
|
|
1811
|
-
it("moves to Trash under its current path when the folder was renamed after enqueue", async () => {
|
|
1812
|
-
const destinations = movedTo();
|
|
1813
|
-
const opened: string[] = [];
|
|
1814
|
-
const openBox = h.connection.openBox;
|
|
1815
|
-
h.connection.openBox = async (path: string) => {
|
|
1816
|
-
opened.push(path);
|
|
1817
|
-
return openBox(path);
|
|
1818
|
-
};
|
|
1819
|
-
h.folderPaths = { "src-mbx": "Mail/INBOX", "trash-mbx": "Deleted Items" };
|
|
1820
|
-
|
|
1821
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1822
|
-
|
|
1823
|
-
assert.equal(opened[0], "Mail/INBOX");
|
|
1824
|
-
assert.deepEqual(destinations, ["Deleted Items"]);
|
|
1825
|
-
});
|
|
1826
|
-
|
|
1827
|
-
it("opens the source under its current path when it was renamed after enqueue", async () => {
|
|
1828
|
-
const destinations = movedTo();
|
|
1829
|
-
const opened: string[] = [];
|
|
1830
|
-
const openBox = h.connection.openBox;
|
|
1831
|
-
h.connection.openBox = async (path: string) => {
|
|
1832
|
-
opened.push(path);
|
|
1833
|
-
return openBox(path);
|
|
1834
|
-
};
|
|
1835
|
-
h.folderPaths = { "src-mbx": "Old/Inbox-renamed", "trash-mbx": "Trash" };
|
|
1836
|
-
|
|
1837
|
-
await handleMessageDelete(moveEvent, noopLogger, 1, deps());
|
|
1838
|
-
|
|
1839
|
-
assert.equal(opened[0], "Old/Inbox-renamed");
|
|
1840
|
-
assert.deepEqual(destinations, ["Trash"]);
|
|
1841
|
-
});
|
|
1842
|
-
|
|
1843
|
-
it("resyncs the source when a redelivered delete finds the Trash mailbox deleted", async () => {
|
|
1844
|
-
h.goneMailboxIds = ["trash-mbx"];
|
|
1845
|
-
|
|
1846
|
-
await handleMessageDelete(moveEvent, noopLogger, 2, deps());
|
|
1847
|
-
|
|
1848
|
-
assert.deepEqual(called("emitEvent")[0]?.args, [
|
|
1849
|
-
{ type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
|
|
1850
|
-
]);
|
|
1851
|
-
});
|
|
1852
|
-
|
|
1853
|
-
it("hands the row back without connecting when the Trash mailbox was deleted", async () => {
|
|
1854
|
-
h.goneMailboxIds = ["trash-mbx"];
|
|
1855
|
-
const destinations = movedTo();
|
|
1856
|
-
|
|
1857
|
-
await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
|
|
1858
|
-
|
|
1859
|
-
assert.equal(h.getConnectionCount, 0);
|
|
1860
|
-
assert.deepEqual(destinations, []);
|
|
1861
|
-
assert.equal(called("connection.deleteMessages").length, 0);
|
|
1862
|
-
assert.equal(called("message.delete").length, 0);
|
|
1863
|
-
assert.deepEqual(restoreCalls()[0]?.args, restoredToSource("abandoned"));
|
|
1864
|
-
assert.equal(
|
|
1865
|
-
loggedWith("alert", "message_delete_destination_gone").length,
|
|
1866
|
-
1,
|
|
1867
|
-
);
|
|
1868
|
-
});
|
|
1869
|
-
});
|