@remit/mailbox-service 0.0.95 → 0.0.97
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 +1 -1
- package/src/external-move.test.ts +369 -0
- package/src/external-move.ts +272 -0
- package/src/imapflow-connection.ts +9 -0
- package/src/index.ts +6 -0
- package/src/mailbox-intent.ts +109 -0
- package/src/mailbox-management.test.ts +28 -2
- package/src/mailbox-management.ts +453 -69
- package/src/mailbox-presence.ts +6 -5
- package/src/mailbox-queue.ts +133 -13
- package/src/mailbox-rename-intent.test.ts +271 -25
- package/src/mailbox-rename-subtree.test.ts +494 -106
- package/src/mailbox-sync.ts +1 -27
- package/src/mailbox-upstream.ts +83 -0
- package/src/message-sync-changedsince.test.ts +8 -1
- package/src/message-sync-quarantine.test.ts +8 -1
- package/src/message-sync-repoint.test.ts +128 -103
- package/src/message-sync.ts +209 -77
package/src/mailbox-sync.ts
CHANGED
|
@@ -11,10 +11,8 @@ import type {
|
|
|
11
11
|
MailboxItem,
|
|
12
12
|
} from "@remit/data-ports";
|
|
13
13
|
import {
|
|
14
|
-
CANONICAL_ROLES,
|
|
15
|
-
type CanonicalMailboxRoleValue,
|
|
16
|
-
ROLE_NAME_HINTS,
|
|
17
14
|
ROLE_SPECIAL_USE,
|
|
15
|
+
roleForFolderName,
|
|
18
16
|
} from "@remit/data-ports/folder-role";
|
|
19
17
|
import {
|
|
20
18
|
MailboxCursorState,
|
|
@@ -59,30 +57,6 @@ const areSpecialUseSetsEqual = (
|
|
|
59
57
|
return true;
|
|
60
58
|
};
|
|
61
59
|
|
|
62
|
-
/**
|
|
63
|
-
* The role a folder's own leaf name is most conventionally for, read from the
|
|
64
|
-
* one hint table every special-folder lookup shares (`@remit/data-ports`,
|
|
65
|
-
* #837). A name that several roles list goes to the role that ranks it highest,
|
|
66
|
-
* so `All Mail` is an All folder rather than a lookalike of Archive.
|
|
67
|
-
*
|
|
68
|
-
* A second copy of these names lived here, and it is the one that could destroy
|
|
69
|
-
* something: it still called `Deleted` and `Bin` Trash names, which #843 dropped
|
|
70
|
-
* precisely because they are ordinary folders a user keeps mail in — and this
|
|
71
|
-
* lookup does not merely skip a folder, it deletes the row and everything the
|
|
72
|
-
* client can see in it.
|
|
73
|
-
*/
|
|
74
|
-
const roleForFolderName = (
|
|
75
|
-
name: string,
|
|
76
|
-
): CanonicalMailboxRoleValue | undefined => {
|
|
77
|
-
let best: { role: CanonicalMailboxRoleValue; rank: number } | undefined;
|
|
78
|
-
for (const role of CANONICAL_ROLES) {
|
|
79
|
-
const rank = ROLE_NAME_HINTS[role]?.indexOf(name) ?? -1;
|
|
80
|
-
if (rank < 0) continue;
|
|
81
|
-
if (!best || rank < best.rank) best = { role, rank };
|
|
82
|
-
}
|
|
83
|
-
return best?.role;
|
|
84
|
-
};
|
|
85
|
-
|
|
86
60
|
/**
|
|
87
61
|
* Account info needed for mailbox sync
|
|
88
62
|
*/
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const stringField = (value: unknown, key: string): string => {
|
|
2
|
+
if (!(value instanceof Object)) return "";
|
|
3
|
+
const field = Reflect.get(value, key);
|
|
4
|
+
return typeof field === "string" ? field : "";
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read a tagged-NO outcome out of an IMAP failure.
|
|
9
|
+
*
|
|
10
|
+
* The two outcomes below are each a folder operation finding the server already
|
|
11
|
+
* in the state it was asked for — the operation having happened, not failing.
|
|
12
|
+
* Reading them as failures marks the row `failed` and rethrows, and since folder
|
|
13
|
+
* management shares the account's per-account FIFO group with mailbox sync, that
|
|
14
|
+
* un-acked rethrow holds back every later sync for the account.
|
|
15
|
+
*
|
|
16
|
+
* Both places the server can say so are read. `message` carries it when the
|
|
17
|
+
* client raises the error itself; RFC 5530's response code and its text carry it
|
|
18
|
+
* when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
|
|
19
|
+
* with the code on the error, which is why matching the message alone never
|
|
20
|
+
* caught a real Dovecot answer.
|
|
21
|
+
*/
|
|
22
|
+
const saidByServer = (error: Error): string =>
|
|
23
|
+
`${error.message} ${stringField(error, "responseText")}`;
|
|
24
|
+
|
|
25
|
+
/** The folder is not on the server: a delete has nothing left to do. */
|
|
26
|
+
export const isMailboxAbsentUpstream = (error: unknown): boolean => {
|
|
27
|
+
if (!(error instanceof Error)) return false;
|
|
28
|
+
if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
|
|
29
|
+
return /not found|does ?n.?t exist/i.test(saidByServer(error));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The folder is already on the server: a create has nothing left to do. */
|
|
33
|
+
export const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
34
|
+
if (!(error instanceof Error)) return false;
|
|
35
|
+
if (stringField(error, "serverResponseCode") === "ALREADYEXISTS") return true;
|
|
36
|
+
return /already exists/i.test(saidByServer(error));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Something after the server RENAME failed — the local settle, not the
|
|
41
|
+
* mutation. The rename landed, so this is never a refused rename: marking the
|
|
42
|
+
* rows `failed` would offer a retry of work the server has already done, and
|
|
43
|
+
* `fullPath` would go on naming a path the server no longer holds.
|
|
44
|
+
*
|
|
45
|
+
* It is rethrown rather than swallowed. The rows are still `pending` with their
|
|
46
|
+
* targets recorded, so the redelivery's guard passes and the settle runs again;
|
|
47
|
+
* swallowing it would ack the message and leave the subtree `pending` forever,
|
|
48
|
+
* skipped by message sync with no route out.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The folder a rename was to move is confirmed gone: the server refused the
|
|
52
|
+
* RENAME as non-existent, and its own listing holds neither the path being left
|
|
53
|
+
* nor the path being aimed at.
|
|
54
|
+
*
|
|
55
|
+
* It is a distinct kind because it is the only answer that may remove a folder
|
|
56
|
+
* and its mail. Re-reading the server's response code at the call site would
|
|
57
|
+
* make every unclassifiable `NONEXISTENT` — a listing that could not be read, a
|
|
58
|
+
* path the comparison normalized wrongly — destroy the user's mail; here the
|
|
59
|
+
* decision is made once, where the evidence is.
|
|
60
|
+
*/
|
|
61
|
+
export class FolderGoneUpstreamError extends Error {
|
|
62
|
+
name = "FolderGoneUpstreamError";
|
|
63
|
+
readonly cause: unknown;
|
|
64
|
+
|
|
65
|
+
constructor(mailboxId: string, oldPath: string, cause: unknown) {
|
|
66
|
+
super(
|
|
67
|
+
`Folder ${mailboxId} is gone from the mail server: neither "${oldPath}" nor its rename target is listed`,
|
|
68
|
+
);
|
|
69
|
+
this.cause = cause;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class FolderRenameSettleError extends Error {
|
|
74
|
+
name = "FolderRenameSettleError";
|
|
75
|
+
readonly cause: unknown;
|
|
76
|
+
|
|
77
|
+
constructor(mailboxId: string, confirmedPath: string, cause: unknown) {
|
|
78
|
+
super(
|
|
79
|
+
`Folder ${mailboxId} was renamed to "${confirmedPath}" but the local settle did not finish`,
|
|
80
|
+
);
|
|
81
|
+
this.cause = cause;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -265,11 +265,18 @@ const buildHarness = (options: HarnessOptions): Harness => {
|
|
|
265
265
|
},
|
|
266
266
|
) as IAddressRepository;
|
|
267
267
|
|
|
268
|
+
// The rows sync reads back before it saves, to ask whether a message sighted
|
|
269
|
+
// here has left the folder its row points at (#1146). This harness stores
|
|
270
|
+
// none, so nothing is contested and the probe issues no IMAP.
|
|
271
|
+
const messageRepository = {
|
|
272
|
+
get: async () => [],
|
|
273
|
+
} as unknown as IMessageRepository;
|
|
274
|
+
|
|
268
275
|
const service = new MessageSyncService(
|
|
269
276
|
connectionFactory,
|
|
270
277
|
mailboxService,
|
|
271
278
|
noFolderRoles,
|
|
272
|
-
|
|
279
|
+
messageRepository,
|
|
273
280
|
{} as IEnvelopeRepository,
|
|
274
281
|
addressRepository,
|
|
275
282
|
threadMessageService,
|
|
@@ -174,6 +174,13 @@ const buildHarness = (options: {
|
|
|
174
174
|
},
|
|
175
175
|
} satisfies IQuarantineRepository;
|
|
176
176
|
|
|
177
|
+
// The rows sync reads back before it saves, to ask whether a message sighted
|
|
178
|
+
// here has left the folder its row points at (#1146). This harness stores
|
|
179
|
+
// none, so nothing is contested and the probe issues no IMAP.
|
|
180
|
+
const messageRepository = {
|
|
181
|
+
get: async () => [],
|
|
182
|
+
} as unknown as IMessageRepository;
|
|
183
|
+
|
|
177
184
|
const service = new MessageSyncService(
|
|
178
185
|
{
|
|
179
186
|
getConnection: () => connection,
|
|
@@ -181,7 +188,7 @@ const buildHarness = (options: {
|
|
|
181
188
|
} as ManagedConnectionFactory,
|
|
182
189
|
mailboxService,
|
|
183
190
|
noFolderRoles,
|
|
184
|
-
|
|
191
|
+
messageRepository,
|
|
185
192
|
{} as IEnvelopeRepository,
|
|
186
193
|
{} as IAddressRepository,
|
|
187
194
|
threadMessageService,
|
|
@@ -11,13 +11,11 @@ import type {
|
|
|
11
11
|
ThreadMessageItem,
|
|
12
12
|
UpdateThreadMessageInput,
|
|
13
13
|
} from "@remit/data-ports";
|
|
14
|
+
import { deriveMessageId } from "@remit/data-ports/id";
|
|
14
15
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
15
16
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
MessageSyncService,
|
|
19
|
-
repointsOnSighting,
|
|
20
|
-
} from "./message-sync.js";
|
|
17
|
+
import { type DepartureVerdicts, placementKey } from "./external-move.js";
|
|
18
|
+
import { type AccountFolderRoles, MessageSyncService } from "./message-sync.js";
|
|
21
19
|
import { folderRoles, NO_JUNK_ROLES } from "./test-helpers/folder-roles.js";
|
|
22
20
|
import type { ImapEnvelope, ImapMessage } from "./types.js";
|
|
23
21
|
|
|
@@ -85,8 +83,22 @@ interface Observed {
|
|
|
85
83
|
threadUpdates: UpdateThreadMessageInput[];
|
|
86
84
|
reconciled: string[];
|
|
87
85
|
owned: boolean;
|
|
86
|
+
unresolved: boolean;
|
|
88
87
|
}
|
|
89
88
|
|
|
89
|
+
/**
|
|
90
|
+
* What `confirmDepartures` had answered about the stored placement by the time
|
|
91
|
+
* the batch was saved (#1146).
|
|
92
|
+
*
|
|
93
|
+
* - `departed` — the source folder no longer holds it: a move another client made.
|
|
94
|
+
* - `held` — the source folder still holds it: the labelled message, in two
|
|
95
|
+
* folders at once.
|
|
96
|
+
* - `none` — no verdict at all: the source could not be asked this round.
|
|
97
|
+
* - `stale` — a verdict, but for a placement this row no longer has, because it
|
|
98
|
+
* moved between the probe and the save.
|
|
99
|
+
*/
|
|
100
|
+
type Verdict = "departed" | "held" | "none" | "stale";
|
|
101
|
+
|
|
90
102
|
/**
|
|
91
103
|
* Sync one message out of `sighting`, against a database that already holds it
|
|
92
104
|
* under `stored`. The account's Junk folder is `JUNK` and its Trash is `TRASH`,
|
|
@@ -97,12 +109,14 @@ const sync = async (
|
|
|
97
109
|
stored: MessageItem,
|
|
98
110
|
threadRow: ThreadMessageItem | null = threadRowIn(INBOX),
|
|
99
111
|
copiesHere: MessageItem[] = [],
|
|
112
|
+
verdict: Verdict = "departed",
|
|
100
113
|
): Promise<Observed> => {
|
|
101
114
|
const observed: Observed = {
|
|
102
115
|
repointedTo: [],
|
|
103
116
|
threadUpdates: [],
|
|
104
117
|
reconciled: [],
|
|
105
118
|
owned: false,
|
|
119
|
+
unresolved: false,
|
|
106
120
|
};
|
|
107
121
|
|
|
108
122
|
const messageService = {
|
|
@@ -164,6 +178,19 @@ const sync = async (
|
|
|
164
178
|
envelope,
|
|
165
179
|
} as unknown as ImapMessage;
|
|
166
180
|
|
|
181
|
+
const answeredFor = placementKey(
|
|
182
|
+
deriveMessageId("acct-1", envelope.messageId),
|
|
183
|
+
stored.mailboxId,
|
|
184
|
+
verdict === "stale" ? stored.uid + 1 : stored.uid,
|
|
185
|
+
);
|
|
186
|
+
const departures: DepartureVerdicts = {
|
|
187
|
+
departed:
|
|
188
|
+
verdict === "held" || verdict === "none"
|
|
189
|
+
? new Set()
|
|
190
|
+
: new Set([answeredFor]),
|
|
191
|
+
settled: verdict === "none" ? new Set() : new Set([answeredFor]),
|
|
192
|
+
};
|
|
193
|
+
|
|
167
194
|
const result = (await (
|
|
168
195
|
service as unknown as {
|
|
169
196
|
saveMessage: (
|
|
@@ -172,15 +199,24 @@ const sync = async (
|
|
|
172
199
|
accountConfigId: string,
|
|
173
200
|
msg: ImapMessage,
|
|
174
201
|
roles: AccountFolderRoles,
|
|
175
|
-
|
|
202
|
+
departures: DepartureVerdicts,
|
|
203
|
+
) => Promise<{ owned: boolean; unresolvedSighting: boolean }>;
|
|
176
204
|
}
|
|
177
|
-
).saveMessage(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
205
|
+
).saveMessage(
|
|
206
|
+
sighting,
|
|
207
|
+
"acct-1",
|
|
208
|
+
"cfg-1",
|
|
209
|
+
msg,
|
|
210
|
+
{
|
|
211
|
+
junkMailboxId: JUNK.mailboxId,
|
|
212
|
+
trashMailboxId: TRASH.mailboxId,
|
|
213
|
+
configJunkRoles: NO_JUNK_ROLES,
|
|
214
|
+
},
|
|
215
|
+
departures,
|
|
216
|
+
)) as { owned: boolean; unresolvedSighting: boolean };
|
|
182
217
|
|
|
183
218
|
observed.owned = result.owned;
|
|
219
|
+
observed.unresolved = result.unresolvedSighting;
|
|
184
220
|
return observed;
|
|
185
221
|
};
|
|
186
222
|
|
|
@@ -227,6 +263,87 @@ describe("which folder a message the database already holds lives in", () => {
|
|
|
227
263
|
assert.equal(observed.owned, false);
|
|
228
264
|
});
|
|
229
265
|
|
|
266
|
+
/**
|
|
267
|
+
* The reported bug (#1146): a message that legitimately sits in two real
|
|
268
|
+
* folders — a Gmail user label, a Sieve `fileinto` beside a `keep`, a Sent
|
|
269
|
+
* copy a list echoes back — was re-pointed out of the Inbox by whichever
|
|
270
|
+
* folder the round enumerated last.
|
|
271
|
+
*/
|
|
272
|
+
it("leaves a labelled message in the folder that still holds it", async () => {
|
|
273
|
+
const observed = await sync(
|
|
274
|
+
mailboxAt("Receipts"),
|
|
275
|
+
storedIn(INBOX),
|
|
276
|
+
threadRowIn(INBOX),
|
|
277
|
+
[],
|
|
278
|
+
"held",
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
282
|
+
assert.deepEqual(observed.threadUpdates, []);
|
|
283
|
+
assert.equal(observed.owned, false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A decided sighting is decided for good: the message is in two folders and
|
|
288
|
+
* asking again next round would get the same answer. Only an UNDECIDED one
|
|
289
|
+
* comes back, so a settled decline must not hold the watermark.
|
|
290
|
+
*/
|
|
291
|
+
it("finishes with a labelled message rather than holding the watermark for it", async () => {
|
|
292
|
+
const observed = await sync(
|
|
293
|
+
mailboxAt("Receipts"),
|
|
294
|
+
storedIn(INBOX),
|
|
295
|
+
threadRowIn(INBOX),
|
|
296
|
+
[],
|
|
297
|
+
"held",
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
assert.equal(observed.unresolved, false);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("holds the watermark when the source folder could not be asked", async () => {
|
|
304
|
+
const observed = await sync(
|
|
305
|
+
JUNK,
|
|
306
|
+
storedIn(INBOX),
|
|
307
|
+
threadRowIn(INBOX),
|
|
308
|
+
[],
|
|
309
|
+
"none",
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
313
|
+
assert.equal(observed.unresolved, true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The probe reads the row outside the transaction that acts on it, so a user
|
|
318
|
+
* move landing in between leaves a verdict about a placement this row no
|
|
319
|
+
* longer has. Spending it would re-point the row against an answer reached
|
|
320
|
+
* for somewhere else.
|
|
321
|
+
*/
|
|
322
|
+
it("spends no verdict reached for a placement the row has since left", async () => {
|
|
323
|
+
const observed = await sync(
|
|
324
|
+
JUNK,
|
|
325
|
+
storedIn(INBOX),
|
|
326
|
+
threadRowIn(INBOX),
|
|
327
|
+
[],
|
|
328
|
+
"stale",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
assert.deepEqual(observed.repointedTo, []);
|
|
332
|
+
assert.equal(observed.unresolved, true);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("holds the watermark for nothing when the row is already ours", async () => {
|
|
336
|
+
const observed = await sync(
|
|
337
|
+
INBOX,
|
|
338
|
+
storedIn(INBOX),
|
|
339
|
+
threadRowIn(INBOX),
|
|
340
|
+
[],
|
|
341
|
+
"none",
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
assert.equal(observed.unresolved, false);
|
|
345
|
+
});
|
|
346
|
+
|
|
230
347
|
it("declines a sighting in a folder that copies every message", async () => {
|
|
231
348
|
const observed = await sync(ALL_MAIL, storedIn(INBOX));
|
|
232
349
|
|
|
@@ -300,95 +417,3 @@ describe("what a re-pointed message does to its sender's standing", () => {
|
|
|
300
417
|
assert.deepEqual(observed.reconciled, []);
|
|
301
418
|
});
|
|
302
419
|
});
|
|
303
|
-
|
|
304
|
-
describe("repointsOnSighting", () => {
|
|
305
|
-
it("refuses a Gmail virtual folder the server never flagged", () => {
|
|
306
|
-
assert.equal(
|
|
307
|
-
repointsOnSighting(mailboxAt("[Gmail]/All Mail"), storedIn(INBOX)),
|
|
308
|
-
false,
|
|
309
|
-
);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it("accepts a folder the user named after a virtual one", () => {
|
|
313
|
-
assert.equal(
|
|
314
|
-
repointsOnSighting(mailboxAt("Starred ideas"), storedIn(INBOX)),
|
|
315
|
-
true,
|
|
316
|
-
);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("accepts an ordinary inbound row the sync path left pending", () => {
|
|
320
|
-
assert.equal(
|
|
321
|
-
repointsOnSighting(
|
|
322
|
-
JUNK,
|
|
323
|
-
storedIn(INBOX, { syncStatus: MessageSyncStatus.pending }),
|
|
324
|
-
),
|
|
325
|
-
true,
|
|
326
|
-
);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
it("accepts a row a settled mutation marked synced", () => {
|
|
330
|
-
assert.equal(
|
|
331
|
-
repointsOnSighting(
|
|
332
|
-
JUNK,
|
|
333
|
-
storedIn(INBOX, { syncStatus: MessageSyncStatus.synced }),
|
|
334
|
-
),
|
|
335
|
-
true,
|
|
336
|
-
);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* The row `abandonDelete` hands back: reader refused the delete, put the
|
|
341
|
-
* message back where the server still has it, and nothing else is coming
|
|
342
|
-
* for the row. Reader shares its mailboxes, so the user moving that same
|
|
343
|
-
* message in another client is ordinary — and before R3 the sighting was
|
|
344
|
-
* refused on `syncStatus` alone and the row never followed the move.
|
|
345
|
-
*/
|
|
346
|
-
it("accepts a row whose delete was abandoned, so a move made elsewhere still lands", () => {
|
|
347
|
-
assert.equal(
|
|
348
|
-
repointsOnSighting(
|
|
349
|
-
JUNK,
|
|
350
|
-
storedIn(INBOX, {
|
|
351
|
-
status: MessageStatus.active,
|
|
352
|
-
syncStatus: MessageSyncStatus.abandoned,
|
|
353
|
-
}),
|
|
354
|
-
),
|
|
355
|
-
true,
|
|
356
|
-
);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
it("accepts a row left `failed` by a transient attempt that has since settled", () => {
|
|
360
|
-
assert.equal(
|
|
361
|
-
repointsOnSighting(
|
|
362
|
-
JUNK,
|
|
363
|
-
storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
|
|
364
|
-
),
|
|
365
|
-
true,
|
|
366
|
-
);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it("refuses a row whose own move is still in flight", () => {
|
|
370
|
-
assert.equal(
|
|
371
|
-
repointsOnSighting(
|
|
372
|
-
JUNK,
|
|
373
|
-
storedIn(INBOX, {
|
|
374
|
-
status: MessageStatus.moving,
|
|
375
|
-
syncStatus: MessageSyncStatus.pending,
|
|
376
|
-
}),
|
|
377
|
-
),
|
|
378
|
-
false,
|
|
379
|
-
);
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
it("refuses a row whose own delete is still in flight", () => {
|
|
383
|
-
assert.equal(
|
|
384
|
-
repointsOnSighting(
|
|
385
|
-
JUNK,
|
|
386
|
-
storedIn(INBOX, {
|
|
387
|
-
status: MessageStatus.deleting,
|
|
388
|
-
syncStatus: MessageSyncStatus.pending,
|
|
389
|
-
}),
|
|
390
|
-
),
|
|
391
|
-
false,
|
|
392
|
-
);
|
|
393
|
-
});
|
|
394
|
-
});
|