@remit/imap-worker 0.0.74 → 0.0.76
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
CHANGED
|
@@ -29,10 +29,24 @@ interface Connection {
|
|
|
29
29
|
messages: { total: number };
|
|
30
30
|
}>;
|
|
31
31
|
closeBox: () => Promise<void>;
|
|
32
|
-
renameMailbox: (
|
|
32
|
+
renameMailbox: (
|
|
33
|
+
oldPath: string,
|
|
34
|
+
newPath: string,
|
|
35
|
+
) => Promise<{ path: string; newPath: string }>;
|
|
33
36
|
deleteMailbox: (path: string) => Promise<void>;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/** The folder rows the handler reads its guards and its settle row-set from. */
|
|
40
|
+
interface Row {
|
|
41
|
+
mailboxId: string;
|
|
42
|
+
accountId: string;
|
|
43
|
+
fullPath: string;
|
|
44
|
+
hierarchyDelimiter: string;
|
|
45
|
+
namespacePrefix: string;
|
|
46
|
+
syncStatus: string;
|
|
47
|
+
pendingPath?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
interface Harness {
|
|
37
51
|
calls: Call[];
|
|
38
52
|
account: {
|
|
@@ -41,6 +55,7 @@ interface Harness {
|
|
|
41
55
|
deletedAt?: number;
|
|
42
56
|
} | null;
|
|
43
57
|
connection: Connection;
|
|
58
|
+
rows: Map<string, Row>;
|
|
44
59
|
mailboxUpdateError?: Error;
|
|
45
60
|
mailboxRowGone?: boolean;
|
|
46
61
|
disconnectCount: number;
|
|
@@ -48,11 +63,6 @@ interface Harness {
|
|
|
48
63
|
|
|
49
64
|
let h: Harness;
|
|
50
65
|
|
|
51
|
-
const notFoundError = (): Error =>
|
|
52
|
-
Object.assign(new Error("Mailbox not found: mbx-1"), {
|
|
53
|
-
name: "NotFoundError",
|
|
54
|
-
});
|
|
55
|
-
|
|
56
66
|
const record =
|
|
57
67
|
(method: string) =>
|
|
58
68
|
async (...args: unknown[]) => {
|
|
@@ -72,14 +82,37 @@ const buildConnection = (): Connection => ({
|
|
|
72
82
|
messages: { total: 3 },
|
|
73
83
|
}),
|
|
74
84
|
closeBox: record("connection.closeBox"),
|
|
75
|
-
renameMailbox:
|
|
85
|
+
renameMailbox: async (oldPath: string, newPath: string) => {
|
|
86
|
+
h.calls.push({
|
|
87
|
+
method: "connection.renameMailbox",
|
|
88
|
+
args: [oldPath, newPath],
|
|
89
|
+
});
|
|
90
|
+
return { path: oldPath, newPath };
|
|
91
|
+
},
|
|
76
92
|
deleteMailbox: record("connection.deleteMailbox"),
|
|
77
93
|
});
|
|
78
94
|
|
|
95
|
+
const row = (over: Partial<Row> & { mailboxId: string }): Row => ({
|
|
96
|
+
accountId: "acc-1",
|
|
97
|
+
fullPath: "Archive",
|
|
98
|
+
hierarchyDelimiter: "/",
|
|
99
|
+
namespacePrefix: "",
|
|
100
|
+
syncStatus: "pending",
|
|
101
|
+
...over,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** The server holding neither path is what confirms the folder is gone. */
|
|
105
|
+
const listsNeitherPath = (): void => {
|
|
106
|
+
h.connection.listMailboxes = async () => [{ fullPath: "Iets anders" }];
|
|
107
|
+
};
|
|
108
|
+
|
|
79
109
|
const fresh = (): Harness => ({
|
|
80
110
|
calls: [],
|
|
81
111
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
82
112
|
connection: buildConnection(),
|
|
113
|
+
// A create in flight: `pending` with no recorded rename target, which is
|
|
114
|
+
// what tells it apart from a rename in flight (D3).
|
|
115
|
+
rows: new Map([["mbx-1", row({ mailboxId: "mbx-1" })]]),
|
|
83
116
|
disconnectCount: 0,
|
|
84
117
|
});
|
|
85
118
|
|
|
@@ -92,23 +125,80 @@ const deps = (): MailboxManagementDeps =>
|
|
|
92
125
|
return h.account;
|
|
93
126
|
},
|
|
94
127
|
},
|
|
128
|
+
accountSetting: {
|
|
129
|
+
get: async () => undefined,
|
|
130
|
+
upsert: record("accountSetting.upsert"),
|
|
131
|
+
delete: record("accountSetting.delete"),
|
|
132
|
+
},
|
|
95
133
|
mailbox: {
|
|
134
|
+
get: async (accountId: string, mailboxId: string) => {
|
|
135
|
+
h.calls.push({ method: "mailbox.get", args: [accountId, mailboxId] });
|
|
136
|
+
const found = h.mailboxRowGone
|
|
137
|
+
? undefined
|
|
138
|
+
: h.rows.get(mailboxId as string);
|
|
139
|
+
if (!found) {
|
|
140
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
141
|
+
name: "NotFoundError",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return found;
|
|
145
|
+
},
|
|
96
146
|
update: async (...args: unknown[]) => {
|
|
97
147
|
h.calls.push({ method: "mailbox.update", args });
|
|
98
148
|
if (h.mailboxUpdateError) throw h.mailboxUpdateError;
|
|
99
149
|
},
|
|
100
|
-
// The conditional write the folder state now goes through
|
|
101
|
-
//
|
|
102
|
-
//
|
|
150
|
+
// The conditional write the folder state now goes through, with the
|
|
151
|
+
// predicate honoured: a row that is gone or that somebody else moved
|
|
152
|
+
// matches nothing, so the loser gets a null rather than a throw and
|
|
153
|
+
// the handler raises the NotFoundError itself.
|
|
103
154
|
transition: async (...args: unknown[]) => {
|
|
104
155
|
h.calls.push({ method: "mailbox.transition", args });
|
|
105
|
-
|
|
156
|
+
if (h.mailboxRowGone) return null;
|
|
157
|
+
const mailboxId = args[1] as string;
|
|
158
|
+
const intent = args[2] as {
|
|
159
|
+
from: readonly string[];
|
|
160
|
+
wherePendingPath?: string | null;
|
|
161
|
+
to: string;
|
|
162
|
+
set?: { fullPath?: string };
|
|
163
|
+
};
|
|
164
|
+
const current = h.rows.get(mailboxId);
|
|
165
|
+
if (!current) return null;
|
|
166
|
+
if (!intent.from.includes(current.syncStatus)) return null;
|
|
167
|
+
if (
|
|
168
|
+
intent.wherePendingPath !== undefined &&
|
|
169
|
+
(intent.wherePendingPath ?? undefined) !== current.pendingPath
|
|
170
|
+
) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const next: Row = {
|
|
174
|
+
...current,
|
|
175
|
+
...(intent.set?.fullPath !== undefined
|
|
176
|
+
? { fullPath: intent.set.fullPath }
|
|
177
|
+
: {}),
|
|
178
|
+
syncStatus: intent.to,
|
|
179
|
+
};
|
|
180
|
+
if (intent.to !== "pending" && intent.to !== "failed") {
|
|
181
|
+
next.pendingPath = undefined;
|
|
182
|
+
}
|
|
183
|
+
h.rows.set(mailboxId, next);
|
|
184
|
+
return next;
|
|
106
185
|
},
|
|
107
186
|
findByPathPrefix: async (...args: unknown[]) => {
|
|
108
187
|
h.calls.push({ method: "mailbox.findByPathPrefix", args });
|
|
109
188
|
return [];
|
|
110
189
|
},
|
|
190
|
+
findBySyncStatus: async (accountId: string, syncStatus: string) => {
|
|
191
|
+
h.calls.push({
|
|
192
|
+
method: "mailbox.findBySyncStatus",
|
|
193
|
+
args: [accountId, syncStatus],
|
|
194
|
+
});
|
|
195
|
+
if (h.mailboxRowGone) return [];
|
|
196
|
+
return [...h.rows.values()].filter(
|
|
197
|
+
(r) => r.syncStatus === syncStatus,
|
|
198
|
+
);
|
|
199
|
+
},
|
|
111
200
|
delete: record("mailbox.delete"),
|
|
201
|
+
deleteMailboxWithMail: record("mailbox.deleteMailboxWithMail"),
|
|
112
202
|
},
|
|
113
203
|
secrets: {},
|
|
114
204
|
}),
|
|
@@ -155,10 +245,15 @@ const called = (method: string): Call[] =>
|
|
|
155
245
|
const lastUpdate = (): Record<string, unknown> =>
|
|
156
246
|
(called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
|
|
157
247
|
|
|
158
|
-
const lastSettle = (): {
|
|
248
|
+
const lastSettle = (): {
|
|
249
|
+
to?: string;
|
|
250
|
+
set?: Record<string, unknown>;
|
|
251
|
+
wherePendingPath?: string | null;
|
|
252
|
+
} =>
|
|
159
253
|
(called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
|
|
160
254
|
to?: string;
|
|
161
255
|
set?: Record<string, unknown>;
|
|
256
|
+
wherePendingPath?: string | null;
|
|
162
257
|
};
|
|
163
258
|
|
|
164
259
|
describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
@@ -257,12 +352,24 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
257
352
|
});
|
|
258
353
|
});
|
|
259
354
|
|
|
355
|
+
/**
|
|
356
|
+
* The rename intent a MAILBOX_RENAME is settled against: the folder keeps the
|
|
357
|
+
* path the server holds, and carries the target the intent recorded (D2).
|
|
358
|
+
*/
|
|
359
|
+
const recordRenameIntent = (pendingPath = "Archive 2024"): void => {
|
|
360
|
+
h.rows.set(
|
|
361
|
+
"mbx-1",
|
|
362
|
+
row({ mailboxId: "mbx-1", fullPath: "Archive", pendingPath }),
|
|
363
|
+
);
|
|
364
|
+
};
|
|
365
|
+
|
|
260
366
|
describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
261
367
|
beforeEach(() => {
|
|
262
368
|
h = fresh();
|
|
369
|
+
recordRenameIntent();
|
|
263
370
|
});
|
|
264
371
|
|
|
265
|
-
it("renames on the server and settles the row", async () => {
|
|
372
|
+
it("renames on the server and settles the row onto the confirmed path", async () => {
|
|
266
373
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
267
374
|
|
|
268
375
|
assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
|
|
@@ -270,31 +377,92 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
270
377
|
"Archive 2024",
|
|
271
378
|
]);
|
|
272
379
|
assert.equal(lastSettle().to, "synced");
|
|
380
|
+
assert.equal(lastSettle().set?.fullPath, "Archive 2024");
|
|
273
381
|
});
|
|
274
382
|
|
|
275
|
-
it("
|
|
383
|
+
it("settles onto the path ImapFlow resolved, not the one that was asked for", async () => {
|
|
384
|
+
h.connection.renameMailbox = async (oldPath: string) => {
|
|
385
|
+
h.calls.push({ method: "connection.renameMailbox", args: [oldPath] });
|
|
386
|
+
return { path: oldPath, newPath: "INBOX/Archive 2024" };
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
390
|
+
|
|
391
|
+
assert.equal(lastSettle().set?.fullPath, "INBOX/Archive 2024");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("takes the folder's mail with it when the source is gone on the server", async () => {
|
|
395
|
+
// The folder was deleted under us. Dropping the row on its own is the
|
|
396
|
+
// orphaning bug: its mail stays keyed to a dead mailboxId, out of every
|
|
397
|
+
// reader and still in the search index (D8).
|
|
398
|
+
listsNeitherPath();
|
|
276
399
|
h.connection.renameMailbox = async () => {
|
|
277
400
|
throw new Error("Mailbox not found");
|
|
278
401
|
};
|
|
279
402
|
|
|
280
403
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
281
404
|
|
|
282
|
-
assert.deepEqual(called("mailbox.
|
|
283
|
-
|
|
405
|
+
assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
|
|
406
|
+
"acc-1",
|
|
407
|
+
"mbx-1",
|
|
408
|
+
]);
|
|
409
|
+
assert.equal(called("mailbox.delete").length, 0);
|
|
284
410
|
});
|
|
285
411
|
|
|
286
|
-
it("
|
|
412
|
+
it("takes every descendant's mail with it too, not just the renamed folder's", async () => {
|
|
413
|
+
// The whole subtree recorded this rename's intent (D6); dropping the
|
|
414
|
+
// root alone strands each descendant `pending` at a path the server
|
|
415
|
+
// never held, with nothing left to revisit it.
|
|
416
|
+
h.rows.set(
|
|
417
|
+
"mbx-2",
|
|
418
|
+
row({
|
|
419
|
+
mailboxId: "mbx-2",
|
|
420
|
+
fullPath: "Archive/Sub",
|
|
421
|
+
pendingPath: "Archive 2024/Sub",
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
listsNeitherPath();
|
|
287
425
|
h.connection.renameMailbox = async () => {
|
|
288
|
-
throw new Error("
|
|
426
|
+
throw new Error("Mailbox not found");
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
430
|
+
|
|
431
|
+
const deleted = called("mailbox.deleteMailboxWithMail").map(
|
|
432
|
+
(c) => c.args[1],
|
|
433
|
+
);
|
|
434
|
+
assert.deepEqual(new Set(deleted), new Set(["mbx-1", "mbx-2"]));
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("refuses the rename rather than deleting when the source is still on the server", async () => {
|
|
438
|
+
// The server said NONEXISTENT and its own listing still holds the folder,
|
|
439
|
+
// so it was talking about something else. Reading that as an upstream
|
|
440
|
+
// delete would destroy the folder's mail over an answer nothing confirms.
|
|
441
|
+
h.connection.renameMailbox = async () => {
|
|
442
|
+
throw new Error("Mailbox not found");
|
|
289
443
|
};
|
|
444
|
+
|
|
445
|
+
await assert.rejects(
|
|
446
|
+
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
447
|
+
/Mailbox not found/,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
|
|
451
|
+
assert.equal(lastSettle().to, "failed");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("acks terminally without connecting when the folder row is gone", async () => {
|
|
290
455
|
h.mailboxRowGone = true;
|
|
291
456
|
|
|
292
457
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
293
458
|
|
|
459
|
+
assert.equal(called("connection.renameMailbox").length, 0);
|
|
294
460
|
assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
|
|
295
461
|
});
|
|
296
462
|
|
|
297
|
-
it("
|
|
463
|
+
it("keeps the confirmed path and the target on a refused rename", async () => {
|
|
464
|
+
// T6. `fullPath` was never written, so there is nothing to restore; the
|
|
465
|
+
// target is kept so the client can name what the rename was aiming at.
|
|
298
466
|
h.connection.renameMailbox = async () => {
|
|
299
467
|
throw new Error("server exploded");
|
|
300
468
|
};
|
|
@@ -305,41 +473,180 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
305
473
|
);
|
|
306
474
|
|
|
307
475
|
assert.equal(lastSettle().to, "failed");
|
|
308
|
-
assert.equal(
|
|
476
|
+
assert.equal(
|
|
477
|
+
lastSettle().set?.fullPath,
|
|
478
|
+
undefined,
|
|
479
|
+
"nothing is restored: the row never left the path the server holds",
|
|
480
|
+
);
|
|
481
|
+
assert.equal(lastSettle().wherePendingPath, "Archive 2024");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("settles instead of deleting when NONEXISTENT means the rename already landed", async () => {
|
|
485
|
+
// A redelivery after a lost settle re-issues RENAME from a path that has
|
|
486
|
+
// moved. Reading that as an upstream delete drops the folder and its mail
|
|
487
|
+
// while the folder is alive at the target.
|
|
488
|
+
h.connection.renameMailbox = async () => {
|
|
489
|
+
throw Object.assign(new Error("Command failed"), {
|
|
490
|
+
serverResponseCode: "NONEXISTENT",
|
|
491
|
+
responseText: "Mailbox doesn't exist: Archive",
|
|
492
|
+
});
|
|
493
|
+
};
|
|
494
|
+
h.connection.listMailboxes = async () => [{ fullPath: "Archive 2024" }];
|
|
495
|
+
|
|
496
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
497
|
+
|
|
498
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
|
|
499
|
+
assert.equal(lastSettle().to, "synced");
|
|
500
|
+
assert.equal(lastSettle().set?.fullPath, "Archive 2024");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("rethrows a settle failure without marking the rename refused", async () => {
|
|
504
|
+
// The server executed the rename. Marking the rows failed would offer a
|
|
505
|
+
// retry of work already done, on a path the server no longer holds.
|
|
506
|
+
let settles = 0;
|
|
507
|
+
const broken = deps();
|
|
508
|
+
const inner = broken.getClient as unknown as () => Promise<
|
|
509
|
+
Record<string, unknown>
|
|
510
|
+
>;
|
|
511
|
+
broken.getClient = (async () => {
|
|
512
|
+
const client = await inner();
|
|
513
|
+
return {
|
|
514
|
+
...client,
|
|
515
|
+
mailbox: {
|
|
516
|
+
...(client.mailbox as Record<string, unknown>),
|
|
517
|
+
transition: async () => {
|
|
518
|
+
settles += 1;
|
|
519
|
+
throw new Error("database is locked");
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}) as unknown as MailboxManagementDeps["getClient"];
|
|
524
|
+
|
|
525
|
+
await assert.rejects(
|
|
526
|
+
processMailboxManagement(renameEvent, noopLogger, broken),
|
|
527
|
+
/local settle did not finish/,
|
|
528
|
+
);
|
|
529
|
+
assert.equal(settles, 1, "the refusal path never ran a second write");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("issues no RENAME when the row has already settled", async () => {
|
|
533
|
+
h.rows.set(
|
|
534
|
+
"mbx-1",
|
|
535
|
+
row({
|
|
536
|
+
mailboxId: "mbx-1",
|
|
537
|
+
fullPath: "Archive 2024",
|
|
538
|
+
syncStatus: "synced",
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
543
|
+
|
|
544
|
+
assert.equal(called("connection.renameMailbox").length, 0);
|
|
545
|
+
assert.equal(called("mailbox.transition").length, 0);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("issues no RENAME when the row is pending for a different target", async () => {
|
|
549
|
+
h.rows.set("mbx-1", row({ mailboxId: "mbx-1", pendingPath: "Archief" }));
|
|
550
|
+
|
|
551
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
552
|
+
|
|
553
|
+
assert.equal(called("connection.renameMailbox").length, 0);
|
|
554
|
+
assert.equal(called("mailbox.transition").length, 0);
|
|
309
555
|
});
|
|
310
556
|
});
|
|
311
557
|
|
|
558
|
+
describe("processMailboxManagement — the seventh state (#363, D3)", () => {
|
|
559
|
+
beforeEach(() => {
|
|
560
|
+
h = fresh();
|
|
561
|
+
recordRenameIntent();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* A settled create whose acknowledgement was lost, redelivered against a row
|
|
566
|
+
* this rename has since claimed. A `pending`-only guard passes it; CREATE
|
|
567
|
+
* collides and ImapFlow reports `{created: false}`, which #346 correctly
|
|
568
|
+
* reads as success; the settle then writes `synced` with the rename target
|
|
569
|
+
* still on the row — and the rename's own settle no longer matches, so the
|
|
570
|
+
* RENAME never runs and nothing is ever marked failed.
|
|
571
|
+
*/
|
|
572
|
+
it("lets a redelivered create settle nothing on a row a rename has claimed", async () => {
|
|
573
|
+
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
574
|
+
|
|
575
|
+
const settle = called("mailbox.transition").at(-1)?.args[2] as {
|
|
576
|
+
wherePendingPath?: string | null;
|
|
577
|
+
to?: string;
|
|
578
|
+
};
|
|
579
|
+
assert.equal(
|
|
580
|
+
settle?.wherePendingPath,
|
|
581
|
+
null,
|
|
582
|
+
"the create settle requires the row to carry no rename target",
|
|
583
|
+
);
|
|
584
|
+
assert.equal(h.rows.get("mbx-1")?.pendingPath, "Archive 2024");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("then settles the rename normally", async () => {
|
|
588
|
+
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
589
|
+
h.calls = [];
|
|
590
|
+
|
|
591
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
592
|
+
|
|
593
|
+
assert.equal(called("connection.renameMailbox").length, 1);
|
|
594
|
+
assert.equal(lastSettle().to, "synced");
|
|
595
|
+
assert.equal(lastSettle().set?.fullPath, "Archive 2024");
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
/** The delete intent a MAILBOX_DELETE is settled against. */
|
|
600
|
+
const recordDeleteIntent = (): void => {
|
|
601
|
+
h.rows.set(
|
|
602
|
+
"mbx-1",
|
|
603
|
+
row({ mailboxId: "mbx-1", fullPath: "Archive", syncStatus: "deleting" }),
|
|
604
|
+
);
|
|
605
|
+
};
|
|
606
|
+
|
|
312
607
|
describe("processMailboxManagement — MAILBOX_DELETE", () => {
|
|
313
608
|
beforeEach(() => {
|
|
314
609
|
h = fresh();
|
|
610
|
+
recordDeleteIntent();
|
|
315
611
|
});
|
|
316
612
|
|
|
317
|
-
it("deletes on the server and
|
|
613
|
+
it("deletes on the server and takes the folder's mail with it", async () => {
|
|
318
614
|
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
319
615
|
|
|
320
616
|
assert.equal(called("connection.deleteMailbox")[0]?.args[0], "Archive");
|
|
321
|
-
assert.deepEqual(called("mailbox.
|
|
617
|
+
assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
|
|
618
|
+
"acc-1",
|
|
619
|
+
"mbx-1",
|
|
620
|
+
]);
|
|
621
|
+
assert.equal(
|
|
622
|
+
called("mailbox.delete").length,
|
|
623
|
+
0,
|
|
624
|
+
"the bare row delete is what orphaned the folder's mail",
|
|
625
|
+
);
|
|
322
626
|
});
|
|
323
627
|
|
|
324
|
-
it("
|
|
628
|
+
it("removes the folder's mail when the folder is already gone on the server", async () => {
|
|
325
629
|
h.connection.deleteMailbox = async () => {
|
|
326
630
|
throw new Error("Mailbox not found");
|
|
327
631
|
};
|
|
328
632
|
|
|
329
633
|
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
330
634
|
|
|
331
|
-
assert.equal(called("mailbox.
|
|
635
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 1);
|
|
332
636
|
});
|
|
333
637
|
|
|
334
|
-
it("
|
|
638
|
+
it("marks the delete failed rather than synced when the server refuses INBOX", async () => {
|
|
639
|
+
// The API refuses a delete of INBOX, so this backstop is unreachable from
|
|
640
|
+
// it. Reached another way, the honest outcome is `failed`: the folder was
|
|
641
|
+
// never deleted, and nothing was undone.
|
|
335
642
|
h.connection.deleteMailbox = async () => {
|
|
336
643
|
throw new Error("Cannot delete INBOX");
|
|
337
644
|
};
|
|
338
645
|
|
|
339
646
|
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
340
647
|
|
|
341
|
-
assert.equal(lastSettle().to, "
|
|
342
|
-
assert.equal(called("mailbox.
|
|
648
|
+
assert.equal(lastSettle().to, "failed");
|
|
649
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
|
|
343
650
|
});
|
|
344
651
|
|
|
345
652
|
it("marks the mailbox failed and rethrows on any other delete error", async () => {
|
|
@@ -355,14 +662,21 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
|
|
|
355
662
|
assert.equal(lastSettle().to, "failed");
|
|
356
663
|
});
|
|
357
664
|
|
|
358
|
-
it("
|
|
359
|
-
h.
|
|
360
|
-
|
|
361
|
-
|
|
665
|
+
it("issues no DELETE when the row is not deleting", async () => {
|
|
666
|
+
h.rows.set("mbx-1", row({ mailboxId: "mbx-1", syncStatus: "synced" }));
|
|
667
|
+
|
|
668
|
+
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
669
|
+
|
|
670
|
+
assert.equal(called("connection.deleteMailbox").length, 0);
|
|
671
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("acks terminally without connecting when the folder row is gone", async () => {
|
|
362
675
|
h.mailboxRowGone = true;
|
|
363
676
|
|
|
364
677
|
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
365
678
|
|
|
679
|
+
assert.equal(called("connection.deleteMailbox").length, 0);
|
|
366
680
|
assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
|
|
367
681
|
});
|
|
368
682
|
});
|
|
@@ -378,7 +692,8 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
378
692
|
* only the message treated an already-absent folder as a failure: the row was
|
|
379
693
|
* marked failed and the event left poisoning the account's queue.
|
|
380
694
|
*/
|
|
381
|
-
it("treats it as the delete already having happened, and
|
|
695
|
+
it("treats it as the delete already having happened, mail and all", async () => {
|
|
696
|
+
recordDeleteIntent();
|
|
382
697
|
h.connection.deleteMailbox = async () => {
|
|
383
698
|
throw Object.assign(new Error("Command failed"), {
|
|
384
699
|
serverResponseCode: "NONEXISTENT",
|
|
@@ -389,11 +704,15 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
389
704
|
await assert.doesNotReject(
|
|
390
705
|
processMailboxManagement(deleteEvent, noopLogger, deps()),
|
|
391
706
|
);
|
|
392
|
-
assert.deepEqual(called("mailbox.
|
|
707
|
+
assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
|
|
708
|
+
"acc-1",
|
|
709
|
+
"mbx-1",
|
|
710
|
+
]);
|
|
393
711
|
assert.equal(called("mailbox.transition").length, 0);
|
|
394
712
|
});
|
|
395
713
|
|
|
396
714
|
it("still marks failed and rethrows when the server fails for any other reason", async () => {
|
|
715
|
+
recordDeleteIntent();
|
|
397
716
|
h.connection.deleteMailbox = async () => {
|
|
398
717
|
throw Object.assign(new Error("Command failed"), {
|
|
399
718
|
serverResponseCode: "SERVERBUG",
|
|
@@ -443,7 +762,9 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
443
762
|
assert.equal(lastSettle().to, "failed");
|
|
444
763
|
});
|
|
445
764
|
|
|
446
|
-
it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
|
|
765
|
+
it("reads NONEXISTENT on a RENAME as the source folder being gone, mail and all", async () => {
|
|
766
|
+
recordRenameIntent();
|
|
767
|
+
listsNeitherPath();
|
|
447
768
|
h.connection.renameMailbox = async () => {
|
|
448
769
|
throw Object.assign(new Error("Command failed"), {
|
|
449
770
|
serverResponseCode: "NONEXISTENT",
|
|
@@ -454,10 +775,14 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
454
775
|
await assert.doesNotReject(
|
|
455
776
|
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
456
777
|
);
|
|
457
|
-
assert.deepEqual(called("mailbox.
|
|
778
|
+
assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
|
|
779
|
+
"acc-1",
|
|
780
|
+
"mbx-1",
|
|
781
|
+
]);
|
|
458
782
|
});
|
|
459
783
|
|
|
460
|
-
it("still
|
|
784
|
+
it("still refuses the intent and rethrows when a RENAME fails for any other reason", async () => {
|
|
785
|
+
recordRenameIntent();
|
|
461
786
|
h.connection.renameMailbox = async () => {
|
|
462
787
|
throw Object.assign(new Error("Command failed"), {
|
|
463
788
|
serverResponseCode: "SERVERBUG",
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
|
|
2
3
|
import type { IMailboxRepository } from "@remit/data-ports";
|
|
3
4
|
import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
|
|
4
5
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
6
|
import type { Logger } from "@remit/logger-lambda";
|
|
6
7
|
import {
|
|
7
|
-
|
|
8
|
+
FolderGoneUpstreamError,
|
|
9
|
+
FolderRenameSettleError,
|
|
10
|
+
isMailboxAbsentUpstream,
|
|
11
|
+
isMailboxPresentUpstream,
|
|
8
12
|
MailboxManagementService,
|
|
9
13
|
} from "@remit/mailbox-service";
|
|
10
14
|
import { isAccountDeleted } from "../account-check.js";
|
|
@@ -32,74 +36,27 @@ const defaultDeps: MailboxManagementDeps = {
|
|
|
32
36
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
33
37
|
};
|
|
34
38
|
|
|
35
|
-
const stringField = (value: unknown, key: string): string => {
|
|
36
|
-
if (!(value instanceof Object)) return "";
|
|
37
|
-
const field = Reflect.get(value, key);
|
|
38
|
-
return typeof field === "string" ? field : "";
|
|
39
|
-
};
|
|
40
|
-
|
|
41
39
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* Reading them as failures marks the row `failed` and rethrows, and since folder
|
|
47
|
-
* management shares the account's per-account FIFO group with mailbox sync, that
|
|
48
|
-
* un-acked rethrow holds back every later sync for the account.
|
|
49
|
-
*
|
|
50
|
-
* Both places the server can say so are read. `message` carries it when the
|
|
51
|
-
* client raises the error itself; RFC 5530's response code and its text carry it
|
|
52
|
-
* when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
|
|
53
|
-
* with the code on the error, which is why matching the message alone never
|
|
54
|
-
* caught a real Dovecot answer.
|
|
40
|
+
* The outcome of a create, written against the state a create in flight is:
|
|
41
|
+
* `pending` with no recorded rename target (D3, D10). Without the absence
|
|
42
|
+
* check, a create redelivered after a lost acknowledgement settles a row a
|
|
43
|
+
* rename has since claimed and kills the rename silently.
|
|
55
44
|
*/
|
|
56
|
-
const
|
|
57
|
-
`${error.message} ${stringField(error, "responseText")}`;
|
|
58
|
-
|
|
59
|
-
/** The folder is not on the server: a delete has nothing left to do. */
|
|
60
|
-
const isMailboxAbsentUpstream = (error: unknown): boolean => {
|
|
61
|
-
if (!(error instanceof Error)) return false;
|
|
62
|
-
if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
|
|
63
|
-
return /not found|does ?n.?t exist/i.test(saidByServer(error));
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Write the outcome of a folder operation back, as the conditional write that
|
|
68
|
-
* is the only way to write a folder's state
|
|
69
|
-
* (docs/architecture/folder-rename-and-delete.md D3).
|
|
70
|
-
*
|
|
71
|
-
* The from-set is every state because that is what these write-backs decide
|
|
72
|
-
* against today — they are the unconditional writes they replace, moved onto
|
|
73
|
-
* the door. #363 narrows each to the state its intent recorded, and gives a
|
|
74
|
-
* lost predicate its `superseded` outcome. A null means the row is gone, which
|
|
75
|
-
* is the NotFoundError the whole-chain guards below classify as the user having
|
|
76
|
-
* deleted the folder mid-sync.
|
|
77
|
-
*/
|
|
78
|
-
const recordOutcome = async (
|
|
45
|
+
const recordCreateOutcome = async (
|
|
79
46
|
mailboxService: Pick<IMailboxRepository, "transition">,
|
|
80
47
|
accountId: string,
|
|
81
48
|
mailboxId: string,
|
|
82
49
|
to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
|
|
83
|
-
confirmedPath?: string,
|
|
84
50
|
): Promise<void> => {
|
|
85
51
|
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
86
|
-
from:
|
|
52
|
+
from: [MailboxSyncStatus.pending],
|
|
53
|
+
wherePendingPath: null,
|
|
87
54
|
to,
|
|
88
|
-
|
|
89
|
-
// name what the rename was aiming at (T6); every other outcome drops it,
|
|
90
|
-
// which the transition does on its own.
|
|
91
|
-
set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
|
|
55
|
+
set: {},
|
|
92
56
|
});
|
|
93
57
|
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
94
58
|
};
|
|
95
59
|
|
|
96
|
-
/** The folder is already on the server: a create has nothing left to do. */
|
|
97
|
-
const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
98
|
-
if (!(error instanceof Error)) return false;
|
|
99
|
-
if (stringField(error, "serverResponseCode") === "ALREADYEXISTS") return true;
|
|
100
|
-
return /already exists/i.test(saidByServer(error));
|
|
101
|
-
};
|
|
102
|
-
|
|
103
60
|
/**
|
|
104
61
|
* Pinned invariant for the whole-chain terminal guards below.
|
|
105
62
|
*
|
|
@@ -110,12 +67,13 @@ const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
|
110
67
|
* never an unrelated missing entity that should have been retried.
|
|
111
68
|
*
|
|
112
69
|
* `syncCreate` and `syncDelete` hold to that by touching nothing but the target
|
|
113
|
-
* row. `syncRename` also writes
|
|
114
|
-
* the invariant by
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
70
|
+
* row. `syncRename` also writes every other row that recorded the same intent,
|
|
71
|
+
* and keeps the invariant by treating a lost predicate as an outcome rather than
|
|
72
|
+
* an error: a descendant deleted mid-settle is skipped, never raised. The
|
|
73
|
+
* appointment-label refresh writes `account_setting` rows, whose reads answer
|
|
74
|
+
* absent rather than throwing. Any sync method that reads or writes a second
|
|
75
|
+
* entity owes the same, or these catches must be narrowed (match the mailboxId /
|
|
76
|
+
* re-check existence) before a NotFoundError from elsewhere is silently acked.
|
|
119
77
|
*/
|
|
120
78
|
|
|
121
79
|
/**
|
|
@@ -192,14 +150,14 @@ const handleCreate = async (
|
|
|
192
150
|
{ accountId, mailboxId, path },
|
|
193
151
|
"Mailbox already exists, marking as synced",
|
|
194
152
|
);
|
|
195
|
-
await
|
|
153
|
+
await recordCreateOutcome(
|
|
196
154
|
mailboxService,
|
|
197
155
|
accountId,
|
|
198
156
|
mailboxId,
|
|
199
157
|
MailboxSyncStatus.synced,
|
|
200
158
|
);
|
|
201
159
|
} else {
|
|
202
|
-
await
|
|
160
|
+
await recordCreateOutcome(
|
|
203
161
|
mailboxService,
|
|
204
162
|
accountId,
|
|
205
163
|
mailboxId,
|
|
@@ -246,6 +204,7 @@ const handleRename = async (
|
|
|
246
204
|
|
|
247
205
|
const {
|
|
248
206
|
account: accountService,
|
|
207
|
+
accountSetting: accountSettingService,
|
|
249
208
|
mailbox: mailboxService,
|
|
250
209
|
secrets,
|
|
251
210
|
} = await getClient();
|
|
@@ -286,38 +245,91 @@ const handleRename = async (
|
|
|
286
245
|
newPath,
|
|
287
246
|
scope.getConnection,
|
|
288
247
|
)
|
|
289
|
-
.then((result) => {
|
|
290
|
-
if (result.success) {
|
|
291
|
-
log.info(
|
|
292
|
-
{ accountId, mailboxId, oldPath, newPath },
|
|
293
|
-
"Mailbox renamed on IMAP",
|
|
294
|
-
);
|
|
295
|
-
} else {
|
|
248
|
+
.then(async (result) => {
|
|
249
|
+
if (!result.success) {
|
|
296
250
|
log.error(
|
|
297
251
|
{ accountId, mailboxId, oldPath, newPath, error: result.error },
|
|
298
252
|
"Failed to rename mailbox on IMAP",
|
|
299
253
|
);
|
|
254
|
+
return;
|
|
300
255
|
}
|
|
256
|
+
log.info(
|
|
257
|
+
{ accountId, mailboxId, oldPath, newPath },
|
|
258
|
+
"Mailbox renamed on IMAP",
|
|
259
|
+
);
|
|
260
|
+
if (!result.renamed) return;
|
|
261
|
+
// The path recorded beside each role appointment (#887) moves with
|
|
262
|
+
// the settle, because under D2 that is the first moment the new
|
|
263
|
+
// path is one the server holds.
|
|
264
|
+
//
|
|
265
|
+
// A label is display-only and this runs after the server rename
|
|
266
|
+
// landed, so letting it fail would take the job down the failure
|
|
267
|
+
// path and mark a completed rename refused. Logged and dropped;
|
|
268
|
+
// the next appointment write records the folder's current path
|
|
269
|
+
// anyway.
|
|
270
|
+
await refreshFolderAppointmentLabels(
|
|
271
|
+
accountSettingService,
|
|
272
|
+
account.accountConfigId,
|
|
273
|
+
accountId,
|
|
274
|
+
{
|
|
275
|
+
mailboxId,
|
|
276
|
+
oldPath: result.renamed.oldPath,
|
|
277
|
+
newPath: result.renamed.newPath,
|
|
278
|
+
},
|
|
279
|
+
result.renamed.delimiter,
|
|
280
|
+
).catch((error: unknown) => {
|
|
281
|
+
log.error(
|
|
282
|
+
{ accountId, mailboxId, oldPath, newPath, error },
|
|
283
|
+
"Renamed folder settled, but its role-appointment labels did not move",
|
|
284
|
+
);
|
|
285
|
+
});
|
|
301
286
|
})
|
|
302
287
|
.catch(async (error) => {
|
|
303
|
-
//
|
|
304
|
-
|
|
288
|
+
// The server executed the rename and only the local settle did not
|
|
289
|
+
// finish. That is not a refused rename: marking the rows `failed`
|
|
290
|
+
// would offer a retry of work already done and leave `fullPath`
|
|
291
|
+
// naming a path the server no longer holds. Rethrow, so the
|
|
292
|
+
// redelivery finishes the settle against an intent still standing.
|
|
293
|
+
if (error instanceof FolderRenameSettleError) {
|
|
294
|
+
log.error(
|
|
295
|
+
{ accountId, mailboxId, oldPath, newPath, error },
|
|
296
|
+
"Rename landed on the server; the settle will finish on redelivery",
|
|
297
|
+
);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
// The folder the rename was to move is confirmed gone: the
|
|
301
|
+
// server refused the RENAME as non-existent and its own listing
|
|
302
|
+
// held neither path. The rename decides that, once, where the
|
|
303
|
+
// evidence is — re-reading the response code here would make
|
|
304
|
+
// every `NONEXISTENT` this code cannot classify destroy the
|
|
305
|
+
// folder's mail. Removing the root row alone is the orphaning
|
|
306
|
+
// bug: every descendant recorded the same intent (D6) and would
|
|
307
|
+
// be left `pending` at a path that does not exist, with its mail
|
|
308
|
+
// stranded out of every reader and still in the search index
|
|
309
|
+
// (D8).
|
|
310
|
+
if (error instanceof FolderGoneUpstreamError) {
|
|
305
311
|
log.info(
|
|
306
|
-
{ accountId, mailboxId, oldPath },
|
|
307
|
-
"Source mailbox not found, deleting local",
|
|
312
|
+
{ accountId, mailboxId, oldPath, intent: "rename" },
|
|
313
|
+
"Source mailbox not found, deleting local folder and its mail",
|
|
308
314
|
);
|
|
309
|
-
await
|
|
310
|
-
} else {
|
|
311
|
-
// Rollback local rename by restoring old path
|
|
312
|
-
await recordOutcome(
|
|
313
|
-
mailboxService,
|
|
315
|
+
await managementService.abandonRenameSubtree(
|
|
314
316
|
accountId,
|
|
315
317
|
mailboxId,
|
|
316
|
-
MailboxSyncStatus.failed,
|
|
317
318
|
oldPath,
|
|
319
|
+
newPath,
|
|
318
320
|
);
|
|
319
|
-
|
|
321
|
+
return;
|
|
320
322
|
}
|
|
323
|
+
// T6. Nothing is restored — `fullPath` was never written, and
|
|
324
|
+
// each intent-carrying row keeps its target so the client can
|
|
325
|
+
// name what the rename was aiming at and offer a retry.
|
|
326
|
+
await managementService.failRename(
|
|
327
|
+
accountId,
|
|
328
|
+
mailboxId,
|
|
329
|
+
oldPath,
|
|
330
|
+
newPath,
|
|
331
|
+
);
|
|
332
|
+
throw error;
|
|
321
333
|
})
|
|
322
334
|
.finally(() => scope.disconnect());
|
|
323
335
|
} catch (error) {
|
|
@@ -400,37 +412,32 @@ const handleDelete = async (
|
|
|
400
412
|
}
|
|
401
413
|
})
|
|
402
414
|
.catch(async (error) => {
|
|
403
|
-
//
|
|
415
|
+
// The folder is already gone from the server, so the delete has
|
|
416
|
+
// happened. Its mail still goes with it (D8), which is the whole
|
|
417
|
+
// point of the settle.
|
|
404
418
|
if (isMailboxAbsentUpstream(error)) {
|
|
405
419
|
log.info(
|
|
406
|
-
{ accountId, mailboxId, path },
|
|
407
|
-
"Mailbox not found on IMAP,
|
|
420
|
+
{ accountId, mailboxId, path, intent: "delete" },
|
|
421
|
+
"Mailbox not found on IMAP, removing the local folder and its mail",
|
|
408
422
|
);
|
|
409
|
-
await
|
|
410
|
-
} else if (
|
|
411
|
-
error instanceof Error &&
|
|
412
|
-
error.message.includes("Cannot delete INBOX")
|
|
413
|
-
) {
|
|
414
|
-
// Restore the mailbox
|
|
415
|
-
await recordOutcome(
|
|
416
|
-
mailboxService,
|
|
417
|
-
accountId,
|
|
418
|
-
mailboxId,
|
|
419
|
-
MailboxSyncStatus.synced,
|
|
420
|
-
);
|
|
421
|
-
log.error(
|
|
422
|
-
{ accountId, mailboxId, path },
|
|
423
|
-
"Cannot delete INBOX, restoring mailbox",
|
|
424
|
-
);
|
|
425
|
-
// Don't rethrow - this is an expected error
|
|
423
|
+
await managementService.settleDelete(accountId, mailboxId);
|
|
426
424
|
} else {
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
425
|
+
// T9, INBOX included. The API refuses a delete of INBOX, so the
|
|
426
|
+
// worker's backstop is unreachable from it; if something else
|
|
427
|
+
// reaches it, the honest outcome is `failed`, not `synced` —
|
|
428
|
+
// the folder was never deleted and nothing was undone.
|
|
429
|
+
await managementService.failDelete(accountId, mailboxId);
|
|
430
|
+
if (
|
|
431
|
+
error instanceof Error &&
|
|
432
|
+
error.message.includes("Cannot delete INBOX")
|
|
433
|
+
) {
|
|
434
|
+
log.error(
|
|
435
|
+
{ accountId, mailboxId, path, intent: "delete" },
|
|
436
|
+
"Cannot delete INBOX",
|
|
437
|
+
);
|
|
438
|
+
// Don't rethrow — no retry can make this succeed.
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
434
441
|
throw error;
|
|
435
442
|
}
|
|
436
443
|
})
|