@remit/mailbox-service 0.0.94 → 0.0.96
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/body-sync.ts +11 -4
- package/src/external-move.test.ts +369 -0
- package/src/external-move.ts +272 -0
- package/src/heuristics/classifyPlacement.test.ts +183 -31
- package/src/heuristics/classifyPlacement.ts +64 -8
- 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/package.json
CHANGED
package/src/body-sync.ts
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
classifyPlacement,
|
|
60
60
|
type FolderPlacement,
|
|
61
61
|
type PlacementVerdict,
|
|
62
|
-
|
|
62
|
+
resolveSenderPlacement,
|
|
63
63
|
} from "./heuristics/classifyPlacement.js";
|
|
64
64
|
import { extractSenderMismatch } from "./heuristics/senderMismatch.js";
|
|
65
65
|
import { denormalizeMessageCategory } from "./message-category.js";
|
|
@@ -1176,7 +1176,7 @@ export class BodySyncService {
|
|
|
1176
1176
|
* `deriveSenderTrust` always did — `vip → wellknown → unknown`, untouched by
|
|
1177
1177
|
* `blocked` — plus `blocked`/`autoArchive` off the same row. `trustSetAt` is
|
|
1178
1178
|
* the `setAt` of whichever flag produced the trust value, needed by
|
|
1179
|
-
* {@link
|
|
1179
|
+
* {@link resolveSenderPlacement}'s tie-break; it stays local to placement and
|
|
1180
1180
|
* never reaches `deriveSenderTrust`'s own contract (the trust badge).
|
|
1181
1181
|
*/
|
|
1182
1182
|
private async deriveSenderPlacementSignals(
|
|
@@ -1187,11 +1187,13 @@ export class BodySyncService {
|
|
|
1187
1187
|
trustSetAt?: number;
|
|
1188
1188
|
blocked: boolean;
|
|
1189
1189
|
blockedSetAt?: number;
|
|
1190
|
+
neverSpam: boolean;
|
|
1190
1191
|
autoArchive: boolean;
|
|
1191
1192
|
}> {
|
|
1192
1193
|
const unknown = {
|
|
1193
1194
|
trust: SenderTrust.Unknown,
|
|
1194
1195
|
blocked: false,
|
|
1196
|
+
neverSpam: false,
|
|
1195
1197
|
autoArchive: false,
|
|
1196
1198
|
} as const;
|
|
1197
1199
|
try {
|
|
@@ -1207,6 +1209,7 @@ export class BodySyncService {
|
|
|
1207
1209
|
trustSetAt: flags.vip.setAt,
|
|
1208
1210
|
blocked: flags.blocked?.value === true,
|
|
1209
1211
|
blockedSetAt: flags.blocked?.setAt,
|
|
1212
|
+
neverSpam: flags.neverSpam?.value === true,
|
|
1210
1213
|
autoArchive: flags.autoArchive?.value === true,
|
|
1211
1214
|
};
|
|
1212
1215
|
}
|
|
@@ -1216,6 +1219,7 @@ export class BodySyncService {
|
|
|
1216
1219
|
trustSetAt: flags.wellknown.setAt,
|
|
1217
1220
|
blocked: flags.blocked?.value === true,
|
|
1218
1221
|
blockedSetAt: flags.blocked?.setAt,
|
|
1222
|
+
neverSpam: flags.neverSpam?.value === true,
|
|
1219
1223
|
autoArchive: flags.autoArchive?.value === true,
|
|
1220
1224
|
};
|
|
1221
1225
|
}
|
|
@@ -1223,6 +1227,7 @@ export class BodySyncService {
|
|
|
1223
1227
|
...unknown,
|
|
1224
1228
|
blocked: flags?.blocked?.value === true,
|
|
1225
1229
|
blockedSetAt: flags?.blocked?.setAt,
|
|
1230
|
+
neverSpam: flags?.neverSpam?.value === true,
|
|
1226
1231
|
autoArchive: flags?.autoArchive?.value === true,
|
|
1227
1232
|
};
|
|
1228
1233
|
} catch (err) {
|
|
@@ -1514,12 +1519,14 @@ export class BodySyncService {
|
|
|
1514
1519
|
: {
|
|
1515
1520
|
trust: SenderTrust.Unknown,
|
|
1516
1521
|
blocked: false,
|
|
1522
|
+
neverSpam: false,
|
|
1517
1523
|
autoArchive: false,
|
|
1518
1524
|
};
|
|
1519
1525
|
|
|
1520
|
-
const { senderTrust,
|
|
1526
|
+
const { senderTrust, senderOverride } = resolveSenderPlacement(
|
|
1521
1527
|
{ trust: signals.trust, setAt: signals.trustSetAt },
|
|
1522
1528
|
{ blocked: signals.blocked, setAt: signals.blockedSetAt },
|
|
1529
|
+
signals.neverSpam,
|
|
1523
1530
|
);
|
|
1524
1531
|
|
|
1525
1532
|
// The verdict needs the classification signals (providerSpam,
|
|
@@ -1530,7 +1537,7 @@ export class BodySyncService {
|
|
|
1530
1537
|
candidate,
|
|
1531
1538
|
placement,
|
|
1532
1539
|
senderTrust,
|
|
1533
|
-
|
|
1540
|
+
senderOverride,
|
|
1534
1541
|
);
|
|
1535
1542
|
|
|
1536
1543
|
// A `leave` verdict — including "nothing confident to say" — carries no
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
IMailboxRepository,
|
|
5
|
+
IMessageRepository,
|
|
6
|
+
MailboxItem,
|
|
7
|
+
MessageItem,
|
|
8
|
+
} from "@remit/data-ports";
|
|
9
|
+
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
10
|
+
import {
|
|
11
|
+
confirmDepartures,
|
|
12
|
+
placementKey,
|
|
13
|
+
sightingContestsPlacement,
|
|
14
|
+
} from "./external-move.js";
|
|
15
|
+
import type { IImapConnection } from "./types.js";
|
|
16
|
+
|
|
17
|
+
const mailboxAt = (
|
|
18
|
+
fullPath: string,
|
|
19
|
+
specialUse?: string[],
|
|
20
|
+
uidValidity = 1,
|
|
21
|
+
): MailboxItem =>
|
|
22
|
+
({
|
|
23
|
+
mailboxId: `mbx-${fullPath}`,
|
|
24
|
+
fullPath,
|
|
25
|
+
uidValidity,
|
|
26
|
+
hierarchyDelimiter: "/",
|
|
27
|
+
...(specialUse ? { specialUse } : {}),
|
|
28
|
+
}) as MailboxItem;
|
|
29
|
+
|
|
30
|
+
const INBOX = mailboxAt("INBOX");
|
|
31
|
+
const LABEL = mailboxAt("Receipts");
|
|
32
|
+
const JUNK = mailboxAt("INBOX/Rubbish");
|
|
33
|
+
const ALL_MAIL = mailboxAt("[Gmail]/All Mail", ["All"]);
|
|
34
|
+
|
|
35
|
+
const storedIn = (
|
|
36
|
+
mailbox: MailboxItem,
|
|
37
|
+
overrides: Partial<MessageItem> = {},
|
|
38
|
+
): MessageItem =>
|
|
39
|
+
({
|
|
40
|
+
messageId: "msg-1",
|
|
41
|
+
mailboxId: mailbox.mailboxId,
|
|
42
|
+
uid: 7,
|
|
43
|
+
status: MessageStatus.active,
|
|
44
|
+
syncStatus: MessageSyncStatus.pending,
|
|
45
|
+
...overrides,
|
|
46
|
+
}) as MessageItem;
|
|
47
|
+
|
|
48
|
+
interface Probe {
|
|
49
|
+
opened: string[];
|
|
50
|
+
searched: string[];
|
|
51
|
+
departed: Set<string>;
|
|
52
|
+
settled: Set<string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ask whether the messages this batch sighted in `sightedIn` have left the
|
|
57
|
+
* folders their rows point at, against a server where `held` names the uids each
|
|
58
|
+
* folder still holds and `served` the UIDVALIDITY it answers on SELECT.
|
|
59
|
+
*/
|
|
60
|
+
const probe = async (
|
|
61
|
+
sightedIn: MailboxItem,
|
|
62
|
+
rows: MessageItem[],
|
|
63
|
+
held: Record<string, number[]>,
|
|
64
|
+
served: Record<string, number> = {},
|
|
65
|
+
sources: MailboxItem[] = [INBOX],
|
|
66
|
+
unreachable: string[] = [],
|
|
67
|
+
): Promise<Probe> => {
|
|
68
|
+
const opened: string[] = [];
|
|
69
|
+
const searched: string[] = [];
|
|
70
|
+
|
|
71
|
+
const connection = {
|
|
72
|
+
openBox: async (fullPath: string) => {
|
|
73
|
+
if (unreachable.includes(fullPath)) {
|
|
74
|
+
throw new Error(`no such mailbox on the server: ${fullPath}`);
|
|
75
|
+
}
|
|
76
|
+
opened.push(fullPath);
|
|
77
|
+
const source = sources.find((box) => box.fullPath === fullPath);
|
|
78
|
+
return { uidvalidity: served[fullPath] ?? source?.uidValidity ?? 1 };
|
|
79
|
+
},
|
|
80
|
+
search: async (criteria: unknown[]) => {
|
|
81
|
+
const [[, uids]] = criteria as Array<[string, string]>;
|
|
82
|
+
searched.push(uids);
|
|
83
|
+
const current = opened[opened.length - 1];
|
|
84
|
+
const asked = uids.split(",").map(Number);
|
|
85
|
+
return (held[current] ?? []).filter((uid) => asked.includes(uid));
|
|
86
|
+
},
|
|
87
|
+
} as unknown as Pick<IImapConnection, "openBox" | "search">;
|
|
88
|
+
|
|
89
|
+
const verdicts = await confirmDepartures(
|
|
90
|
+
{
|
|
91
|
+
connection,
|
|
92
|
+
mailboxService: {
|
|
93
|
+
get: async (_accountId: string, mailboxId: string) => {
|
|
94
|
+
const source = sources.find((box) => box.mailboxId === mailboxId);
|
|
95
|
+
if (!source) throw new Error(`no such mailbox: ${mailboxId}`);
|
|
96
|
+
return source;
|
|
97
|
+
},
|
|
98
|
+
} as unknown as Pick<IMailboxRepository, "get">,
|
|
99
|
+
messageService: {
|
|
100
|
+
get: async () => rows,
|
|
101
|
+
} as unknown as Pick<IMessageRepository, "get">,
|
|
102
|
+
log: { info: () => {}, warn: () => {} },
|
|
103
|
+
},
|
|
104
|
+
"acct-1",
|
|
105
|
+
sightedIn,
|
|
106
|
+
rows.map((row) => row.messageId),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return { opened, searched, ...verdicts };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
describe("whether a sighting in a second folder is a move", () => {
|
|
113
|
+
/**
|
|
114
|
+
* The reported bug (#1146). A Gmail user label, a Sieve `fileinto` beside a
|
|
115
|
+
* `keep` and a Sent copy a list echoes back all put one message in two real
|
|
116
|
+
* folders. Reading the second folder's sighting as a move took the message
|
|
117
|
+
* out of the Inbox on the round that enumerated the label.
|
|
118
|
+
*/
|
|
119
|
+
it("is not a move while the folder the row points at still holds it", async () => {
|
|
120
|
+
const observed = await probe(LABEL, [storedIn(INBOX)], {
|
|
121
|
+
INBOX: [7],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.deepEqual([...observed.departed], []);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("is a move once the folder the row points at has let it go", async () => {
|
|
128
|
+
const observed = await probe(LABEL, [storedIn(INBOX)], {
|
|
129
|
+
INBOX: [3, 11],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
assert.deepEqual(
|
|
133
|
+
[...observed.departed],
|
|
134
|
+
[placementKey("msg-1", INBOX.mailboxId, 7)],
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("names the placement it was answered for, so a row that moved since is left alone", async () => {
|
|
139
|
+
const observed = await probe(JUNK, [storedIn(INBOX, { uid: 42 })], {
|
|
140
|
+
INBOX: [],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.equal(
|
|
144
|
+
observed.departed.has(placementKey("msg-1", INBOX.mailboxId, 7)),
|
|
145
|
+
false,
|
|
146
|
+
);
|
|
147
|
+
assert.equal(
|
|
148
|
+
observed.departed.has(placementKey("msg-1", INBOX.mailboxId, 42)),
|
|
149
|
+
true,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("asks each source folder once for the whole batch", async () => {
|
|
154
|
+
const observed = await probe(
|
|
155
|
+
LABEL,
|
|
156
|
+
[
|
|
157
|
+
storedIn(INBOX, { messageId: "msg-1", uid: 7 }),
|
|
158
|
+
storedIn(INBOX, { messageId: "msg-2", uid: 8 }),
|
|
159
|
+
storedIn(INBOX, { messageId: "msg-3", uid: 9 }),
|
|
160
|
+
],
|
|
161
|
+
{ INBOX: [7] },
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(observed.opened, ["INBOX"]);
|
|
165
|
+
assert.deepEqual(observed.searched, ["7,8,9"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The answer needed is about the contested uids. A SEARCH ALL would pull a
|
|
170
|
+
* page of uids per batch out of every source folder an account has, which on
|
|
171
|
+
* a large Inbox is traffic proportional to the mailbox rather than to the
|
|
172
|
+
* question.
|
|
173
|
+
*/
|
|
174
|
+
it("asks about the contested uids, not the whole source folder", async () => {
|
|
175
|
+
const observed = await probe(LABEL, [storedIn(INBOX, { uid: 42 })], {
|
|
176
|
+
INBOX: [1, 2, 42],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.deepEqual(observed.searched, ["42"]);
|
|
180
|
+
assert.deepEqual([...observed.departed], []);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("asks nothing at all when no row is contested", async () => {
|
|
184
|
+
const observed = await probe(INBOX, [storedIn(INBOX)], { INBOX: [7] });
|
|
185
|
+
|
|
186
|
+
assert.deepEqual(observed.opened, []);
|
|
187
|
+
assert.deepEqual([...observed.departed], []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("asks nothing for a folder that holds a copy of every message", async () => {
|
|
191
|
+
const observed = await probe(ALL_MAIL, [storedIn(INBOX)], { INBOX: [] });
|
|
192
|
+
|
|
193
|
+
assert.deepEqual(observed.opened, []);
|
|
194
|
+
assert.deepEqual([...observed.departed], []);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("asks nothing for a row whose own mutation has not settled", async () => {
|
|
198
|
+
const observed = await probe(
|
|
199
|
+
LABEL,
|
|
200
|
+
[storedIn(INBOX, { status: MessageStatus.moving })],
|
|
201
|
+
{ INBOX: [] },
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
assert.deepEqual(observed.opened, []);
|
|
205
|
+
assert.deepEqual([...observed.departed], []);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* All Mail, Starred and Important hold a copy of every message and release
|
|
210
|
+
* nothing, so a row that landed on one — its sync ran before the real
|
|
211
|
+
* folder's — would answer "still here" forever and freeze there. Reachable on
|
|
212
|
+
* any Gmail account whose mail takes more than one batch.
|
|
213
|
+
*/
|
|
214
|
+
it("frees a row whose source is a folder that copies every message", async () => {
|
|
215
|
+
const observed = await probe(
|
|
216
|
+
LABEL,
|
|
217
|
+
[storedIn(ALL_MAIL)],
|
|
218
|
+
{ "[Gmail]/All Mail": [7] },
|
|
219
|
+
{},
|
|
220
|
+
[ALL_MAIL],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
assert.deepEqual(observed.opened, []);
|
|
224
|
+
assert.deepEqual(
|
|
225
|
+
[...observed.departed],
|
|
226
|
+
[placementKey("msg-1", ALL_MAIL.mailboxId, 7)],
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Reader shares its mailboxes, so another client renaming or deleting the
|
|
232
|
+
* source folder between the row being written and this round is ordinary. It
|
|
233
|
+
* decides nothing about these rows, and it may not fail the sync of the
|
|
234
|
+
* unrelated folder that happened to sight them.
|
|
235
|
+
*/
|
|
236
|
+
it("leaves a source folder it cannot reach undecided rather than failing the round", async () => {
|
|
237
|
+
const observed = await probe(
|
|
238
|
+
LABEL,
|
|
239
|
+
[storedIn(INBOX)],
|
|
240
|
+
{ INBOX: [] },
|
|
241
|
+
{},
|
|
242
|
+
[INBOX],
|
|
243
|
+
["INBOX"],
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
assert.deepEqual([...observed.departed], []);
|
|
247
|
+
assert.deepEqual([...observed.settled], []);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("settles a decided sighting, so nothing holds the watermark for it", async () => {
|
|
251
|
+
const observed = await probe(LABEL, [storedIn(INBOX)], { INBOX: [7] });
|
|
252
|
+
|
|
253
|
+
assert.deepEqual(
|
|
254
|
+
[...observed.settled],
|
|
255
|
+
[placementKey("msg-1", INBOX.mailboxId, 7)],
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A uid means nothing without the axis it was issued on (RFC 9051 2.3.1.1),
|
|
261
|
+
* so a source that answers a different UIDVALIDITY is evidence of nothing.
|
|
262
|
+
* The cursor rebuild re-keys that folder; until it has, the sighting waits.
|
|
263
|
+
*/
|
|
264
|
+
it("refuses a source folder that has been re-keyed under it", async () => {
|
|
265
|
+
const observed = await probe(
|
|
266
|
+
LABEL,
|
|
267
|
+
[storedIn(INBOX)],
|
|
268
|
+
{ INBOX: [] },
|
|
269
|
+
{
|
|
270
|
+
INBOX: 99,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
assert.deepEqual([...observed.departed], []);
|
|
275
|
+
assert.deepEqual([...observed.settled], []);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("sightingContestsPlacement", () => {
|
|
280
|
+
it("refuses a Gmail virtual folder the server never flagged", () => {
|
|
281
|
+
assert.equal(
|
|
282
|
+
sightingContestsPlacement(mailboxAt("[Gmail]/All Mail"), storedIn(INBOX)),
|
|
283
|
+
false,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("accepts a folder the user named after a virtual one", () => {
|
|
288
|
+
assert.equal(
|
|
289
|
+
sightingContestsPlacement(mailboxAt("Starred ideas"), storedIn(INBOX)),
|
|
290
|
+
true,
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("accepts an ordinary inbound row the sync path left pending", () => {
|
|
295
|
+
assert.equal(
|
|
296
|
+
sightingContestsPlacement(
|
|
297
|
+
JUNK,
|
|
298
|
+
storedIn(INBOX, { syncStatus: MessageSyncStatus.pending }),
|
|
299
|
+
),
|
|
300
|
+
true,
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("accepts a row a settled mutation marked synced", () => {
|
|
305
|
+
assert.equal(
|
|
306
|
+
sightingContestsPlacement(
|
|
307
|
+
JUNK,
|
|
308
|
+
storedIn(INBOX, { syncStatus: MessageSyncStatus.synced }),
|
|
309
|
+
),
|
|
310
|
+
true,
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The row `abandonDelete` hands back: reader refused the delete, put the
|
|
316
|
+
* message back where the server still has it, and nothing else is coming
|
|
317
|
+
* for the row. Reader shares its mailboxes, so the user moving that same
|
|
318
|
+
* message in another client is ordinary — and before R3 the sighting was
|
|
319
|
+
* refused on `syncStatus` alone and the row never followed the move.
|
|
320
|
+
*/
|
|
321
|
+
it("accepts a row whose delete was abandoned, so a move made elsewhere still lands", () => {
|
|
322
|
+
assert.equal(
|
|
323
|
+
sightingContestsPlacement(
|
|
324
|
+
JUNK,
|
|
325
|
+
storedIn(INBOX, {
|
|
326
|
+
status: MessageStatus.active,
|
|
327
|
+
syncStatus: MessageSyncStatus.abandoned,
|
|
328
|
+
}),
|
|
329
|
+
),
|
|
330
|
+
true,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("accepts a row left `failed` by a transient attempt that has since settled", () => {
|
|
335
|
+
assert.equal(
|
|
336
|
+
sightingContestsPlacement(
|
|
337
|
+
JUNK,
|
|
338
|
+
storedIn(INBOX, { syncStatus: MessageSyncStatus.failed }),
|
|
339
|
+
),
|
|
340
|
+
true,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("refuses a row whose own move is still in flight", () => {
|
|
345
|
+
assert.equal(
|
|
346
|
+
sightingContestsPlacement(
|
|
347
|
+
JUNK,
|
|
348
|
+
storedIn(INBOX, {
|
|
349
|
+
status: MessageStatus.moving,
|
|
350
|
+
syncStatus: MessageSyncStatus.pending,
|
|
351
|
+
}),
|
|
352
|
+
),
|
|
353
|
+
false,
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("refuses a row whose own delete is still in flight", () => {
|
|
358
|
+
assert.equal(
|
|
359
|
+
sightingContestsPlacement(
|
|
360
|
+
JUNK,
|
|
361
|
+
storedIn(INBOX, {
|
|
362
|
+
status: MessageStatus.deleting,
|
|
363
|
+
syncStatus: MessageSyncStatus.pending,
|
|
364
|
+
}),
|
|
365
|
+
),
|
|
366
|
+
false,
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
});
|