@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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { MessageItem } from "@remit/data-ports";
|
|
2
|
-
import { SenderTrust } from "@remit/domain-enums";
|
|
2
|
+
import { SenderOverride, SenderTrust } from "@remit/domain-enums";
|
|
3
3
|
|
|
4
4
|
type SenderTrustValue = (typeof SenderTrust)[keyof typeof SenderTrust];
|
|
5
5
|
|
|
6
|
+
type SenderOverrideValue = (typeof SenderOverride)[keyof typeof SenderOverride];
|
|
7
|
+
|
|
6
8
|
export type FolderPlacement = "inbox" | "junk" | "other";
|
|
7
9
|
|
|
8
10
|
export type PlacementAction = "move-to-inbox" | "move-to-junk" | "leave";
|
|
@@ -39,7 +41,7 @@ export interface SenderBlockedSignal {
|
|
|
39
41
|
* fixed, arbitrary order: `blocked` before `vip` before `wellknown` — a
|
|
40
42
|
* determinism backstop, not a meaningful signal.
|
|
41
43
|
*/
|
|
42
|
-
|
|
44
|
+
const resolveBlockedVsTrust = (
|
|
43
45
|
trust: SenderTrustSignal,
|
|
44
46
|
blocked: SenderBlockedSignal,
|
|
45
47
|
): { senderTrust: SenderTrustValue; senderBlocked: boolean } => {
|
|
@@ -60,6 +62,32 @@ export const resolveBlockedVsTrust = (
|
|
|
60
62
|
return { senderTrust: trust.trust, senderBlocked: false };
|
|
61
63
|
};
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the two axes {@link classifyPlacement} takes: the trust signal, and
|
|
67
|
+
* the user's standing instruction about placement.
|
|
68
|
+
*
|
|
69
|
+
* `blocked` and `neverSpam` are two directly contradictory instructions and
|
|
70
|
+
* cannot both stand — `AddressRepository.mergeFlags` drops the loser at write
|
|
71
|
+
* time. Reading a row that carries both anyway (written before that invariant)
|
|
72
|
+
* resolves to `Blocked`, the same direction the `blocked`/`vip` same-second tie
|
|
73
|
+
* already breaks in: the demote is the recoverable mistake.
|
|
74
|
+
*/
|
|
75
|
+
export const resolveSenderPlacement = (
|
|
76
|
+
trust: SenderTrustSignal,
|
|
77
|
+
blocked: SenderBlockedSignal,
|
|
78
|
+
neverSpam: boolean,
|
|
79
|
+
): {
|
|
80
|
+
senderTrust: SenderTrustValue;
|
|
81
|
+
senderOverride: SenderOverrideValue;
|
|
82
|
+
} => {
|
|
83
|
+
const { senderTrust, senderBlocked } = resolveBlockedVsTrust(trust, blocked);
|
|
84
|
+
if (senderBlocked)
|
|
85
|
+
return { senderTrust, senderOverride: SenderOverride.Blocked };
|
|
86
|
+
if (neverSpam)
|
|
87
|
+
return { senderTrust, senderOverride: SenderOverride.NeverSpam };
|
|
88
|
+
return { senderTrust, senderOverride: SenderOverride.None };
|
|
89
|
+
};
|
|
90
|
+
|
|
63
91
|
/**
|
|
64
92
|
* Tier 0 deterministic placement verdict (RFC 031, "Confident moves").
|
|
65
93
|
*
|
|
@@ -69,17 +97,32 @@ export const resolveBlockedVsTrust = (
|
|
|
69
97
|
* recall-biased — a confident move only fires when cheap, deterministic signals
|
|
70
98
|
* agree; everything else is left in place for a later LLM tier.
|
|
71
99
|
*
|
|
72
|
-
* `
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
100
|
+
* `senderOverride` (RFC 039 Decision 3, issue #605) is the user's standing
|
|
101
|
+
* instruction about this sender's placement, and it outranks every
|
|
102
|
+
* DKIM/DMARC/provider signal below — an explicit block or never-spam grant is
|
|
103
|
+
* not a heuristic. Already-tie-broken against `vip`/`wellknown` by
|
|
104
|
+
* {@link resolveSenderPlacement} in the caller; this function itself does not
|
|
76
105
|
* compare `setAt`.
|
|
106
|
+
*
|
|
107
|
+
* `NeverSpam` is checked above the `missing-signals` return and above the
|
|
108
|
+
* `providerSpam.classified` gate on purpose: the senders who need the grant are
|
|
109
|
+
* the ones the provider rated clean and whose domain publishes no DMARC record,
|
|
110
|
+
* so a check inside the rescue branch below would never fire for them. It
|
|
111
|
+
* rescues only from `junk` — a message a standing filter put in a user folder
|
|
112
|
+
* is not a spam question.
|
|
113
|
+
*
|
|
114
|
+
* It refuses outright on a `dmarc === "Fail"` or a DKIM mismatch. The demote
|
|
115
|
+
* branch below is reached only from `inbox`, so a spoof already sitting in Junk
|
|
116
|
+
* would never meet it: without this guard the grant would launder mail out of
|
|
117
|
+
* Junk on exactly the two signals that identify someone impersonating the
|
|
118
|
+
* address the user granted. A domain that publishes no DMARC record never
|
|
119
|
+
* scores `Fail`, so the case the grant exists for is untouched.
|
|
77
120
|
*/
|
|
78
121
|
export const classifyPlacement = (
|
|
79
122
|
message: MessageItem,
|
|
80
123
|
placement: FolderPlacement,
|
|
81
124
|
senderTrust: SenderTrustValue,
|
|
82
|
-
|
|
125
|
+
senderOverride: SenderOverrideValue,
|
|
83
126
|
): PlacementVerdict => {
|
|
84
127
|
if (message.movedByRemit === true) {
|
|
85
128
|
return {
|
|
@@ -89,7 +132,7 @@ export const classifyPlacement = (
|
|
|
89
132
|
};
|
|
90
133
|
}
|
|
91
134
|
|
|
92
|
-
if (
|
|
135
|
+
if (senderOverride === SenderOverride.Blocked && placement !== "junk") {
|
|
93
136
|
return {
|
|
94
137
|
action: "move-to-junk",
|
|
95
138
|
confidence: "confident",
|
|
@@ -97,6 +140,19 @@ export const classifyPlacement = (
|
|
|
97
140
|
};
|
|
98
141
|
}
|
|
99
142
|
|
|
143
|
+
if (
|
|
144
|
+
senderOverride === SenderOverride.NeverSpam &&
|
|
145
|
+
placement === "junk" &&
|
|
146
|
+
message.authResult?.dmarc !== "Fail" &&
|
|
147
|
+
message.authenticity?.dkimMismatch !== true
|
|
148
|
+
) {
|
|
149
|
+
return {
|
|
150
|
+
action: "move-to-inbox",
|
|
151
|
+
confidence: "confident",
|
|
152
|
+
reasons: ["sender=never-spam"],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
100
156
|
if (!message.providerSpam || !message.authResult) {
|
|
101
157
|
return {
|
|
102
158
|
action: "leave",
|
|
@@ -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
|
-
});
|