@wirecat/cli-core 0.486.0 → 0.488.0
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/CHANGELOG.md +26 -0
- package/dist/cli/messenger/context.d.ts +2 -0
- package/dist/cli/messenger/reactions-command.js +4 -0
- package/dist/cli/messenger/serve-relay.d.ts +1 -0
- package/dist/mail/attachments.d.ts +15 -0
- package/dist/mail/attachments.js +38 -0
- package/dist/mail/headers.d.ts +2 -0
- package/dist/mail/headers.js +8 -4
- package/dist/mail/index.d.ts +1 -1
- package/dist/mail/locations.d.ts +28 -0
- package/dist/mail/locations.js +54 -0
- package/dist/mail/quick.d.ts +41 -0
- package/dist/mail/quick.js +76 -0
- package/dist/mail/resume.d.ts +6 -0
- package/dist/mail/retry.d.ts +23 -0
- package/dist/mail/retry.js +110 -0
- package/dist/mail/source.d.ts +20 -1
- package/dist/mail/sync.d.ts +2 -3
- package/dist/mail/sync.js +135 -153
- package/dist/mail/testing/contract.js +22 -0
- package/dist/mail/testing/fake.d.ts +7 -0
- package/dist/mail/testing/fake.js +29 -6
- package/dist/store/index.d.ts +1 -1
- package/dist/store/sqlite/accounts.js +1 -0
- package/dist/store/sqlite/email-locations.d.ts +24 -0
- package/dist/store/sqlite/email-locations.js +56 -0
- package/dist/store/sqlite/email-state.d.ts +2 -0
- package/dist/store/sqlite/email-state.js +28 -0
- package/dist/store/sqlite/emails.d.ts +13 -0
- package/dist/store/sqlite/emails.js +9 -29
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +7 -0
- package/dist/store/sqlite/schema.d.ts +72 -0
- package/dist/store/sqlite/schema.js +13 -0
- package/package.json +1 -1
package/dist/mail/sync.js
CHANGED
|
@@ -1,61 +1,28 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
|
-
import {
|
|
2
|
+
import { importEngine } from "../attachments/extract.js";
|
|
3
|
+
import { addressOf, FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
|
|
3
4
|
import { BULK, bulkBox } from "./bulk.js";
|
|
4
5
|
import { factsOf } from "./facts.js";
|
|
5
6
|
import { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
|
|
6
7
|
import { dropMissing, tooManyGone } from "./gone.js";
|
|
7
8
|
import { mailHeadersOf } from "./headers.js";
|
|
9
|
+
import { flagsToRefresh, proveByLocation } from "./locations.js";
|
|
8
10
|
import { MAX_BODY_CHARS, parseMail } from "./parse.js";
|
|
11
|
+
import { FETCH_SLICE, keyOf, readQuick } from "./quick.js";
|
|
9
12
|
import { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
|
|
13
|
+
import { retryUnread } from "./retry.js";
|
|
10
14
|
import { ruleTagger, settleRuleTags } from "./rule-tags.js";
|
|
11
15
|
import { decide, ENVELOPE, envelopeBox, envelopeBoxOf, tagsOf } from "./rules.js";
|
|
12
16
|
const DAY = 86_400_000;
|
|
13
17
|
/** IMAP's SINCE counts whole days in the server's zone, so the window's first days are not trusted. */
|
|
14
18
|
const EDGE = 2 * DAY;
|
|
15
|
-
export const MAX_ATTACHMENT_CHARS = 200_000;
|
|
16
|
-
/** One chunk of raw mail is held in memory at a time. */
|
|
17
|
-
const FETCH_CHUNK = 50;
|
|
18
19
|
/** How often a long run saves where it is, so a kill costs at most this much listing again. */
|
|
19
20
|
const SAVE_EVERY_MS = 30_000;
|
|
20
21
|
/** About a minute of listing; the 30-day window of the timer stays below it. */
|
|
21
22
|
const RESUME_FROM = 5_000;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
|
|
25
|
-
const files = [];
|
|
26
|
-
for (const [position, part] of mail.attachments.entries()) {
|
|
27
|
-
const extraction = part.bytes === null
|
|
28
|
-
? { status: "unavailable" }
|
|
29
|
-
: await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
|
|
30
|
-
log.push({ message: messageId, part: part.id, status: extraction.status });
|
|
31
|
-
const extracted = extraction.status === "extracted" ? extraction : undefined;
|
|
32
|
-
files.push({
|
|
33
|
-
position,
|
|
34
|
-
kind: "file",
|
|
35
|
-
mime: part.mime,
|
|
36
|
-
name: part.name,
|
|
37
|
-
title: null,
|
|
38
|
-
url: null,
|
|
39
|
-
size: part.bytes === null ? null : part.bytes.byteLength,
|
|
40
|
-
width: null,
|
|
41
|
-
height: null,
|
|
42
|
-
duration: null,
|
|
43
|
-
providerRef: { message: messageId, part: part.id },
|
|
44
|
-
localPath: null,
|
|
45
|
-
text: extracted ? extracted.text.slice(0, maxAttachmentChars) : null,
|
|
46
|
-
normalizedText: null,
|
|
47
|
-
extraction: extracted ? "text" : "failed",
|
|
48
|
-
extractor: extracted ? extracted.extractor : null,
|
|
49
|
-
extractionError: extracted ? null : extraction.status,
|
|
50
|
-
contentSha256: null,
|
|
51
|
-
extractedAt: at,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
return files;
|
|
55
|
-
};
|
|
23
|
+
export { MAX_ATTACHMENT_CHARS } from "./attachments.js";
|
|
24
|
+
export { RETRYABLE_ATTACHMENTS } from "./retry.js";
|
|
56
25
|
const LIST_SLICE = 200;
|
|
57
|
-
const FETCH_SLICE = 50;
|
|
58
|
-
const keyOf = (folder, uid) => `${folder}\0${uid}`;
|
|
59
26
|
export const syncMail = async (source, store, options) => {
|
|
60
27
|
const { budget, index } = options;
|
|
61
28
|
if (budget === undefined || options.retryAttachments || options.account.mode === "mbox")
|
|
@@ -68,6 +35,8 @@ export const syncMail = async (source, store, options) => {
|
|
|
68
35
|
modes: new Map(),
|
|
69
36
|
unreadable: new Map(),
|
|
70
37
|
unlisted: new Set(),
|
|
38
|
+
quick: new Map(),
|
|
39
|
+
quickFolders: new Set(),
|
|
71
40
|
stopped: null,
|
|
72
41
|
remaining: 0,
|
|
73
42
|
asked: 0,
|
|
@@ -100,6 +69,7 @@ export const syncMail = async (source, store, options) => {
|
|
|
100
69
|
// A message the server failed on once is often fine a minute later; only one more try, then the next run.
|
|
101
70
|
if (plan.stopped === null && plan.failed.size > 0) {
|
|
102
71
|
plan.only = new Set(plan.failed);
|
|
72
|
+
plan.quick.clear();
|
|
103
73
|
plan.failed.clear();
|
|
104
74
|
plan.tried.clear();
|
|
105
75
|
await goOn();
|
|
@@ -188,6 +158,22 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
188
158
|
mboxPoint = point;
|
|
189
159
|
}
|
|
190
160
|
let capped = false;
|
|
161
|
+
const unreadable = [];
|
|
162
|
+
/** Raw mail read to learn its id, kept for the save so it is not read twice. */
|
|
163
|
+
const rawOf = new Map();
|
|
164
|
+
const quickRan = new Set();
|
|
165
|
+
/** Folders whose server gives no cheap id: their mail's UIDs are kept, so a UID listing can stand in for headers. */
|
|
166
|
+
const locatable = new Set();
|
|
167
|
+
/** A quick folder's stored mail as it was before this run changed anything, with the UID of each. */
|
|
168
|
+
const folderMail = new Map();
|
|
169
|
+
const refreshed = new Map();
|
|
170
|
+
let holding;
|
|
171
|
+
const holdsMail = async (folder) => {
|
|
172
|
+
holding ??= new Set((await store.mail.emailRefs({ accountId })).flatMap(({ mailboxes }) => mailboxes
|
|
173
|
+
.filter(({ kind }) => kind !== "label" && kind !== ENVELOPE && kind !== BULK)
|
|
174
|
+
.map((box) => box.externalId)));
|
|
175
|
+
return holding.has(folder);
|
|
176
|
+
};
|
|
191
177
|
for (const folder of folders) {
|
|
192
178
|
if (mboxPoint !== undefined)
|
|
193
179
|
break;
|
|
@@ -212,15 +198,59 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
212
198
|
if (kept !== undefined)
|
|
213
199
|
storedPoints.set(folder, kept);
|
|
214
200
|
const point = fresh ? undefined : kept;
|
|
201
|
+
const sameRules = point !== undefined && point.validity === searched.validity && point.rules === ruleSet;
|
|
202
|
+
// Without a cheap id the mail's identity is in its bytes, so a folder is listed quickly only when no stored mail
|
|
203
|
+
// can be read twice: its done UIDs are known, or the store holds nothing from it. Otherwise (a new UIDVALIDITY,
|
|
204
|
+
// other rules, --rescan) the full header listing recognises what is stored without downloading it.
|
|
205
|
+
const quickable = !snapshot &&
|
|
206
|
+
searched.quick === true &&
|
|
207
|
+
searched.validity !== undefined &&
|
|
208
|
+
source.quick !== undefined &&
|
|
209
|
+
source.identify !== undefined;
|
|
210
|
+
if (quickable)
|
|
211
|
+
locatable.add(folder);
|
|
212
|
+
// The UID listing proves deletions only for mail whose UID is stored; a folder stored before locations existed
|
|
213
|
+
// gets them from one header listing.
|
|
214
|
+
const located = point?.located !== undefined && Date.parse(point.located) <= since.getTime();
|
|
215
|
+
const quickReady = async () => (sameRules && located) || !(await holdsMail(folder));
|
|
216
|
+
const inQuick = quickable &&
|
|
217
|
+
(plan === undefined
|
|
218
|
+
? await quickReady()
|
|
219
|
+
: plan.quickFolders.has(folder) || (!plan.modes.has(folder) && (await quickReady())));
|
|
220
|
+
if (inQuick) {
|
|
221
|
+
plan?.quickFolders.add(folder);
|
|
222
|
+
plan?.modes.set(folder, "resumed");
|
|
223
|
+
resumed.add(folder);
|
|
224
|
+
quickRan.add(folder);
|
|
225
|
+
const done = point !== undefined && sameRules ? point.done : [];
|
|
226
|
+
if (point !== undefined && sameRules)
|
|
227
|
+
earlier.set(folder, point);
|
|
228
|
+
const rows = await store.mail.folderEmails(accountId, folder, searched.validity);
|
|
229
|
+
folderMail.set(folder, rows);
|
|
230
|
+
const byUid = new Map(rows.map((row) => [String(row.uid), row]));
|
|
231
|
+
const round = await readQuick(source, since, folder, searched, done, max, plan, new Set(byUid.keys()));
|
|
232
|
+
for (const [id, flags] of flagsToRefresh(byUid, round.listing))
|
|
233
|
+
refreshed.set(id, { ...refreshed.get(id), ...flags });
|
|
234
|
+
if (round.capped)
|
|
235
|
+
capped = true;
|
|
236
|
+
for (const { item, raw } of round.read) {
|
|
237
|
+
collected.push(item);
|
|
238
|
+
rawOf.set(item, raw);
|
|
239
|
+
}
|
|
240
|
+
for (const bad of round.unreadable)
|
|
241
|
+
unreadable.push(bad);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
215
244
|
if (point !== undefined && point.validity === searched.validity && point.rules === ruleSet) {
|
|
216
245
|
earlier.set(folder, point);
|
|
217
246
|
const mode = plan?.modes.get(folder);
|
|
247
|
+
const backfill = quickable && !located;
|
|
218
248
|
const usable = Date.parse(point.full.since) <= since.getTime() && startedAt - Date.parse(point.full.at) < FULL_PASS_MS;
|
|
219
249
|
// A budgeted run goes on with an unfinished pass however small its window is: listing it whole again would
|
|
220
250
|
// ask for the same newest messages every run.
|
|
221
251
|
const unfinished = plan !== undefined && searched.uids.some((uid) => !within(point.done, Number(uid)));
|
|
222
252
|
if (mode === "resumed" ||
|
|
223
|
-
(mode === undefined && usable && (searched.uids.length >= resumeFrom || unfinished))) {
|
|
253
|
+
(mode === undefined && usable && !backfill && (searched.uids.length >= resumeFrom || unfinished))) {
|
|
224
254
|
resumed.add(folder);
|
|
225
255
|
wanted = { ...searched, uids: searched.uids.filter((uid) => !within(point.done, Number(uid))) };
|
|
226
256
|
}
|
|
@@ -287,6 +317,22 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
287
317
|
current.add(item.folder ?? folders[0]);
|
|
288
318
|
foundFolders.set(item.messageId, current);
|
|
289
319
|
}
|
|
320
|
+
const listingWhole = !capped && (plan === undefined || (plan.stopped === null && plan.failed.size === 0 && plan.only === undefined));
|
|
321
|
+
/** Where each message was seen this run, saved with it so a message saved is a message located. */
|
|
322
|
+
const locationsOf = new Map();
|
|
323
|
+
for (const item of collected) {
|
|
324
|
+
const folder = item.folder ?? folders[0];
|
|
325
|
+
const validity = searches.get(folder)?.validity;
|
|
326
|
+
if (!locatable.has(folder) || validity === undefined)
|
|
327
|
+
continue;
|
|
328
|
+
const where = locationsOf.get(item.messageId) ?? [];
|
|
329
|
+
where.push({ folder, uidvalidity: validity, uid: Number(item.uid) });
|
|
330
|
+
locationsOf.set(item.messageId, where);
|
|
331
|
+
}
|
|
332
|
+
const locationOf = (item) => {
|
|
333
|
+
const locations = locationsOf.get(item.messageId);
|
|
334
|
+
return locations === undefined ? {} : { locations };
|
|
335
|
+
};
|
|
290
336
|
const mailbox = (folder) => ({ externalId: folder, name: folder, kind: null });
|
|
291
337
|
const label = (name) => ({ externalId: `label:${name}`, name, kind: "label" });
|
|
292
338
|
const labelsOf = new Map(collected.flatMap((item) => (item.labels === undefined ? [] : [[item.messageId, item]])));
|
|
@@ -301,6 +347,23 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
301
347
|
skipped = "an mbox file is a snapshot; mail missing from it is not proven deleted";
|
|
302
348
|
else if (plan !== undefined && (capped || plan.tried.size > plan.asked || plan.only !== undefined))
|
|
303
349
|
skipped = "this run read the window in parts; a run that lists all of it at once checks deletions";
|
|
350
|
+
else if (quickRan.size > 0) {
|
|
351
|
+
const proof = await proveByLocation(store, accountId, {
|
|
352
|
+
folders: new Map([...quickRan].map((folder) => [
|
|
353
|
+
folder,
|
|
354
|
+
{ searched: searches.get(folder), rows: folderMail.get(folder) ?? [] },
|
|
355
|
+
])),
|
|
356
|
+
windowFrom: windowStart.getTime(),
|
|
357
|
+
present,
|
|
358
|
+
now: startedAt,
|
|
359
|
+
}, tooManyGone);
|
|
360
|
+
deleted = proof.deleted;
|
|
361
|
+
skipped =
|
|
362
|
+
proof.skipped ??
|
|
363
|
+
(quickRan.size < folders.length
|
|
364
|
+
? "folders this run did not list by UID are not checked for deletions; --rescan lists every message and checks them"
|
|
365
|
+
: undefined);
|
|
366
|
+
}
|
|
304
367
|
else if (resumed.size > 0)
|
|
305
368
|
skipped =
|
|
306
369
|
"this run listed only mail not read before; a full listing, at least daily or with --rescan, checks deletions";
|
|
@@ -339,7 +402,6 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
339
402
|
await store.mail.setMailboxes(accountId, [...scanned.values()], new Map([...labelsOf].map(([id, { labels }]) => [id, (labels ?? []).map((name) => label(name).externalId)])), startedAt);
|
|
340
403
|
}
|
|
341
404
|
const attachments = [];
|
|
342
|
-
const unreadable = [];
|
|
343
405
|
let saved = 0;
|
|
344
406
|
let alreadyStored = 0;
|
|
345
407
|
let ruledOut = 0;
|
|
@@ -435,6 +497,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
435
497
|
? undefined
|
|
436
498
|
: { since: prior.window.since, total: prior.window.total, at: prior.updatedAt });
|
|
437
499
|
const widestEver = priorWidest === undefined || since.getTime() <= Date.parse(priorWidest.since) ? thisRun : priorWidest;
|
|
500
|
+
const located = locatable.has(folder) && (quickRan.has(folder) || (!resumed.has(folder) && listingWhole));
|
|
501
|
+
const locatedFrom = before?.located === undefined ? since.toISOString() : before.located;
|
|
438
502
|
const point = {
|
|
439
503
|
validity: searched.validity,
|
|
440
504
|
done: merged,
|
|
@@ -448,12 +512,22 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
448
512
|
}
|
|
449
513
|
: before.window,
|
|
450
514
|
widest: widestEver,
|
|
515
|
+
...(located
|
|
516
|
+
? { located: Date.parse(locatedFrom) < since.getTime() ? locatedFrom : since.toISOString() }
|
|
517
|
+
: before?.located === undefined
|
|
518
|
+
? {}
|
|
519
|
+
: { located: before.located }),
|
|
451
520
|
last: { ...thisRun, done: searched.uids.filter((uid) => within(merged, Number(uid))).length },
|
|
452
521
|
updatedAt: new Date(now()).toISOString(),
|
|
453
522
|
};
|
|
454
523
|
await store.sync.setSyncState(key, resumeKey(folder), JSON.stringify(point));
|
|
455
524
|
}
|
|
456
525
|
};
|
|
526
|
+
// Mail already stored is located before its UID is marked done, so a done UID always has its place.
|
|
527
|
+
for (const folder of locatable) {
|
|
528
|
+
const searched = searches.get(folder);
|
|
529
|
+
await store.mail.setLocations(accountId, folder, searched.validity, uidsOf.get(folder) ?? new Map());
|
|
530
|
+
}
|
|
457
531
|
let savedAt = now();
|
|
458
532
|
const saveNow = async (force = false) => {
|
|
459
533
|
if (!force && now() - savedAt < SAVE_EVERY_MS)
|
|
@@ -488,6 +562,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
488
562
|
size: item.size ?? null,
|
|
489
563
|
outgoing: headers?.from === key.account,
|
|
490
564
|
mailboxes: [...inMailboxes(item), envelopeBox(rule.match)],
|
|
565
|
+
...locationOf(item),
|
|
491
566
|
...flagsOf(item),
|
|
492
567
|
mailRuleId: rule.id ?? null,
|
|
493
568
|
metadata: {
|
|
@@ -509,7 +584,9 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
509
584
|
for (let start = 0; start < pending.length; start += fetchChunk) {
|
|
510
585
|
await stopIfAborted();
|
|
511
586
|
const chunk = pending.slice(start, start + fetchChunk);
|
|
512
|
-
|
|
587
|
+
// Mail read to learn its id is already in hand: a chunk of it costs no request.
|
|
588
|
+
const unread = chunk.filter((item) => !rawOf.has(item));
|
|
589
|
+
if (plan !== undefined && unread.length > 0) {
|
|
513
590
|
const stop = plan.budget.take();
|
|
514
591
|
if (stop) {
|
|
515
592
|
plan.stopped = stop;
|
|
@@ -519,7 +596,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
519
596
|
}
|
|
520
597
|
let raws;
|
|
521
598
|
try {
|
|
522
|
-
|
|
599
|
+
const got = unread.length > 0 ? await source.fetchRaw(unread) : [];
|
|
600
|
+
raws = chunk.map((item) => rawOf.get(item) ?? got[unread.indexOf(item)]);
|
|
523
601
|
}
|
|
524
602
|
catch (error) {
|
|
525
603
|
// What this run saved stays saved: the next one goes on from it.
|
|
@@ -571,6 +649,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
571
649
|
...(Object.keys(header).length ? { headers: header } : {}),
|
|
572
650
|
outgoing: mail.from === null ? null : mail.from.email === key.account,
|
|
573
651
|
mailboxes: inMailboxes(item),
|
|
652
|
+
...locationOf(item),
|
|
574
653
|
...flagsOf(item),
|
|
575
654
|
...(upgraded.has(item.messageId) ? { metadata: {} } : {}),
|
|
576
655
|
mailRuleId: decide(rules, factsFor(item))?.id ?? null,
|
|
@@ -598,10 +677,18 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
598
677
|
}
|
|
599
678
|
// Read and starred change at the source after import; mail saved in this run already holds them.
|
|
600
679
|
if (!snapshot)
|
|
601
|
-
await store.mail.setFlags(accountId, new Map([
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
680
|
+
await store.mail.setFlags(accountId, new Map([
|
|
681
|
+
...refreshed,
|
|
682
|
+
...[...labelsOf].flatMap(([id, { seen, flagged }]) => seen === undefined && flagged === undefined
|
|
683
|
+
? []
|
|
684
|
+
: [
|
|
685
|
+
[
|
|
686
|
+
id,
|
|
687
|
+
{ ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) },
|
|
688
|
+
],
|
|
689
|
+
]),
|
|
690
|
+
]), startedAt);
|
|
691
|
+
const incomplete = capped || (plan !== undefined && (plan.stopped !== null || plan.failed.size > 0));
|
|
605
692
|
const complete = !full && !incomplete && unreadable.length === 0 && (plan?.unreadable.size ?? 0) === 0;
|
|
606
693
|
const bulk = new Map(listed.filter((item) => item.headers?.bulk).map((item) => [item.messageId, [bulkBox.externalId]]));
|
|
607
694
|
if (bulk.size)
|
|
@@ -641,108 +728,3 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
|
|
|
641
728
|
...(tagging.skipped.length ? { tagsSkipped: tagging.skipped } : {}),
|
|
642
729
|
};
|
|
643
730
|
};
|
|
644
|
-
/** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
|
|
645
|
-
export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
|
|
646
|
-
/**
|
|
647
|
-
* Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
|
|
648
|
-
* attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
|
|
649
|
-
* resume point moves; the email keeps its stored mailboxes, flags and rule.
|
|
650
|
-
*/
|
|
651
|
-
const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
|
|
652
|
-
const startedAt = now();
|
|
653
|
-
const key = { provider: "email", account: account.address.toLowerCase() };
|
|
654
|
-
const find = source.find;
|
|
655
|
-
if (find === undefined)
|
|
656
|
-
throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
|
|
657
|
-
const accountId = await store.accounts.saveAccount(key, { name: account.address });
|
|
658
|
-
const wanted = (await store.mail.emailRefs({ accountId, attachmentErrors: RETRYABLE_ATTACHMENTS })).map(({ externalId }) => externalId);
|
|
659
|
-
const chosen = wanted.slice(0, max);
|
|
660
|
-
const found = new Map();
|
|
661
|
-
for (const folder of await mailFolders(account, source)) {
|
|
662
|
-
const left = chosen.filter((id) => !found.has(id));
|
|
663
|
-
if (left.length === 0)
|
|
664
|
-
break;
|
|
665
|
-
for (const item of await find(left, folder))
|
|
666
|
-
if (!found.has(item.messageId))
|
|
667
|
-
found.set(item.messageId, item);
|
|
668
|
-
}
|
|
669
|
-
const items = [...found.values()];
|
|
670
|
-
const attachments = [];
|
|
671
|
-
const unreadable = [];
|
|
672
|
-
let emails = 0;
|
|
673
|
-
let extracted = 0;
|
|
674
|
-
let stillFailed = 0;
|
|
675
|
-
for (let start = 0; start < items.length; start += FETCH_CHUNK) {
|
|
676
|
-
const chunk = items.slice(start, start + FETCH_CHUNK);
|
|
677
|
-
const raws = await source.fetchRaw(chunk);
|
|
678
|
-
for (const [index, item] of chunk.entries()) {
|
|
679
|
-
const raw = raws[index];
|
|
680
|
-
const stored = await store.mail.email(accountId, item.messageId);
|
|
681
|
-
if (stored === null)
|
|
682
|
-
continue;
|
|
683
|
-
let mail;
|
|
684
|
-
try {
|
|
685
|
-
if (raw === undefined || raw instanceof Error)
|
|
686
|
-
throw raw ?? new Error("not read");
|
|
687
|
-
mail = await parseMail(raw, maxBodyChars);
|
|
688
|
-
}
|
|
689
|
-
catch (error) {
|
|
690
|
-
unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
|
|
691
|
-
continue;
|
|
692
|
-
}
|
|
693
|
-
const before = new Map(stored.attachments.map(({ position, extractionError }) => [position, extractionError]));
|
|
694
|
-
const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
|
|
695
|
-
for (const file of files)
|
|
696
|
-
if (RETRYABLE_ATTACHMENTS.includes(before.get(file.position) ?? ""))
|
|
697
|
-
if (file.extraction === "text")
|
|
698
|
-
extracted++;
|
|
699
|
-
else
|
|
700
|
-
stillFailed++;
|
|
701
|
-
const header = mailHeadersOf(raw);
|
|
702
|
-
await store.mail.saveThread({
|
|
703
|
-
accountId,
|
|
704
|
-
externalId: item.threadId,
|
|
705
|
-
now: startedAt,
|
|
706
|
-
emails: [
|
|
707
|
-
{
|
|
708
|
-
externalId: item.messageId,
|
|
709
|
-
subject: mail.subject,
|
|
710
|
-
from: mail.from === null ? null : addressOf(mail.from),
|
|
711
|
-
to: mail.to.map(addressOf),
|
|
712
|
-
cc: mail.cc.map(addressOf),
|
|
713
|
-
bcc: mail.bcc.map(addressOf),
|
|
714
|
-
sentAt: null,
|
|
715
|
-
receivedAt: Date.parse(item.receivedAt),
|
|
716
|
-
bodyText: mail.text || null,
|
|
717
|
-
size: item.size ?? stored.size,
|
|
718
|
-
...(Object.keys(header).length ? { headers: header } : {}),
|
|
719
|
-
outgoing: mail.from === null ? null : mail.from.email === key.account,
|
|
720
|
-
mailboxes: stored.mailboxes.map(({ externalId, name, kind }) => ({ externalId, name, kind })),
|
|
721
|
-
...(stored.read === null ? {} : { read: stored.read }),
|
|
722
|
-
...(stored.flagged === null ? {} : { flagged: stored.flagged }),
|
|
723
|
-
metadata: stored.metadata,
|
|
724
|
-
mailRuleId: stored.mailRuleId,
|
|
725
|
-
attachments: files,
|
|
726
|
-
},
|
|
727
|
-
],
|
|
728
|
-
});
|
|
729
|
-
emails++;
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
const indexed = await index?.(store, key);
|
|
733
|
-
return {
|
|
734
|
-
account: key.account,
|
|
735
|
-
since: since.toISOString(),
|
|
736
|
-
listed: items.length,
|
|
737
|
-
saved: 0,
|
|
738
|
-
alreadyStored: 0,
|
|
739
|
-
skipped: 0,
|
|
740
|
-
envelopes: 0,
|
|
741
|
-
deleted: 0,
|
|
742
|
-
complete: wanted.length <= max && unreadable.length === 0,
|
|
743
|
-
...(indexed === undefined ? {} : { indexed }),
|
|
744
|
-
retried: { emails, extracted, stillFailed, missing: chosen.length - items.length },
|
|
745
|
-
...(attachments.length ? { attachments } : {}),
|
|
746
|
-
...(unreadable.length ? { unreadable } : {}),
|
|
747
|
-
};
|
|
748
|
-
};
|
|
@@ -53,6 +53,28 @@ export const mailSourceContract = (make) => {
|
|
|
53
53
|
});
|
|
54
54
|
assert.deepEqual(listed.map(({ uid }) => uid), first === undefined ? [] : [first]);
|
|
55
55
|
}),
|
|
56
|
+
test("lists quickly the same UIDs, flags and sizes, and gives from the bytes the ids `list` gives", async (source) => {
|
|
57
|
+
if (source.search === undefined || source.quick === undefined || source.identify === undefined)
|
|
58
|
+
return;
|
|
59
|
+
const searched = await source.search(SINCE, folder);
|
|
60
|
+
if (!searched.quick)
|
|
61
|
+
return;
|
|
62
|
+
const quick = await source.quick(SINCE, folder, { searched });
|
|
63
|
+
const full = await source.list(SINCE, folder);
|
|
64
|
+
assert.deepEqual(quick.map(({ uid }) => uid).sort(), full.map(({ uid }) => uid).sort());
|
|
65
|
+
const raws = await source.fetchRaw(quick);
|
|
66
|
+
for (const [index, item] of quick.entries()) {
|
|
67
|
+
const raw = raws[index];
|
|
68
|
+
assert.ok(raw instanceof Uint8Array, "bytes");
|
|
69
|
+
const same = full.find(({ uid }) => uid === item.uid);
|
|
70
|
+
assert.deepEqual(source.identify(raw, item), {
|
|
71
|
+
messageId: same?.messageId,
|
|
72
|
+
threadId: same?.threadId,
|
|
73
|
+
});
|
|
74
|
+
assert.equal(item.seen, same?.seen);
|
|
75
|
+
assert.equal(item.receivedAt, same?.receivedAt);
|
|
76
|
+
}
|
|
77
|
+
}),
|
|
56
78
|
test("returns each message's bytes in order, and one that cannot be read as an Error, not a failed call", async (source) => {
|
|
57
79
|
const listed = await source.list(SINCE, folder);
|
|
58
80
|
const raws = await source.fetchRaw(listed);
|
|
@@ -26,6 +26,8 @@ export interface FakeMail {
|
|
|
26
26
|
flagged?: boolean;
|
|
27
27
|
/** `trash` puts it in the trash folder; otherwise it is in All Mail, which INBOX also shows. */
|
|
28
28
|
folder?: "trash";
|
|
29
|
+
/** Folders that do not hold it although its `folder` says they do, as a server's delete from one folder leaves it. */
|
|
30
|
+
absentFrom?: string[];
|
|
29
31
|
}
|
|
30
32
|
/** The mailbox a fake source reads live, so a test changes it between syncs; the switches make the source fail. */
|
|
31
33
|
export interface FakeMailbox {
|
|
@@ -48,7 +50,12 @@ export interface FakeMailbox {
|
|
|
48
50
|
search: number;
|
|
49
51
|
list: number;
|
|
50
52
|
fetchRaw: number;
|
|
53
|
+
quick?: number;
|
|
51
54
|
};
|
|
55
|
+
/** Answers as a server with no cheap message id does: `search` says `quick`, and `quick` and `identify` work. */
|
|
56
|
+
quick?: boolean;
|
|
57
|
+
/** The UIDs `fetchRaw` was asked for, in order, so a test proves nothing is read twice. */
|
|
58
|
+
read?: string[];
|
|
52
59
|
/** What the source tells a budgeted sync about its batch sizes. */
|
|
53
60
|
listBatch?: number;
|
|
54
61
|
fetchBatch?: number;
|
|
@@ -29,18 +29,21 @@ export const rawOf = (m) => {
|
|
|
29
29
|
};
|
|
30
30
|
const failure = (message) => new CliError("provider_error", message, { reason: "mail.source-failed" });
|
|
31
31
|
const labelsOf = (m) => [...new Set([...(m.labels ?? []), ...(m.flagged ? ["Starred"] : [])])].sort();
|
|
32
|
-
const
|
|
32
|
+
const quickOf = (m, folder) => ({
|
|
33
33
|
uid: m.uid,
|
|
34
|
-
messageId: m.messageId,
|
|
35
|
-
threadId: m.threadId,
|
|
36
34
|
receivedAt: new Date(m.receivedAt).toISOString(),
|
|
37
35
|
size: rawOf(m).length,
|
|
38
36
|
labels: labelsOf(m),
|
|
39
37
|
seen: m.seen ?? false,
|
|
40
38
|
flagged: m.flagged ?? false,
|
|
41
|
-
...(headers ? { headers: headersOf(Buffer.from(rawOf(m)).toString().split("\r\n\r\n")[0] ?? "") } : {}),
|
|
42
39
|
folder,
|
|
43
40
|
});
|
|
41
|
+
const listingOf = (m, headers, folder) => ({
|
|
42
|
+
...quickOf(m, folder),
|
|
43
|
+
messageId: m.messageId,
|
|
44
|
+
threadId: m.threadId,
|
|
45
|
+
...(headers ? { headers: headersOf(Buffer.from(rawOf(m)).toString().split("\r\n\r\n")[0] ?? "") } : {}),
|
|
46
|
+
});
|
|
44
47
|
/**
|
|
45
48
|
* An in-memory mail source with Gmail's shape: ids and threads that outlive a folder, labels and flags, UIDVALIDITY,
|
|
46
49
|
* a trash a move reaches, and All Mail under the account's language. The sync tests and the adapter contract run on it.
|
|
@@ -51,7 +54,7 @@ export const fakeMailSource = (mailbox) => {
|
|
|
51
54
|
const inFolder = (folder) => {
|
|
52
55
|
if (![all(), trash(), "INBOX"].includes(folder))
|
|
53
56
|
throw failure(`Gmail has no such mailbox: ${folder}`);
|
|
54
|
-
return mailbox.messages.filter((m) => (folder === trash() ? m.folder === "trash" : m.folder !== "trash"));
|
|
57
|
+
return mailbox.messages.filter((m) => (folder === trash() ? m.folder === "trash" : m.folder !== "trash") && !m.absentFrom?.includes(folder));
|
|
55
58
|
};
|
|
56
59
|
const search = async (since, folder) => {
|
|
57
60
|
if (mailbox.searchFails)
|
|
@@ -59,6 +62,7 @@ export const fakeMailSource = (mailbox) => {
|
|
|
59
62
|
const from = Date.UTC(since.getUTCFullYear(), since.getUTCMonth(), since.getUTCDate());
|
|
60
63
|
return {
|
|
61
64
|
validity: String(mailbox.validity ?? 12),
|
|
65
|
+
...(mailbox.quick ? { quick: true } : {}),
|
|
62
66
|
uids: inFolder(folder)
|
|
63
67
|
.filter((m) => Date.parse(m.receivedAt) >= from)
|
|
64
68
|
.map((m) => m.uid),
|
|
@@ -73,7 +77,7 @@ export const fakeMailSource = (mailbox) => {
|
|
|
73
77
|
};
|
|
74
78
|
const count = (kind) => {
|
|
75
79
|
if (mailbox.calls)
|
|
76
|
-
mailbox.calls[kind]
|
|
80
|
+
mailbox.calls[kind] = (mailbox.calls[kind] ?? 0) + 1;
|
|
77
81
|
};
|
|
78
82
|
return {
|
|
79
83
|
...(mailbox.listBatch === undefined ? {} : { listBatch: mailbox.listBatch }),
|
|
@@ -94,6 +98,24 @@ export const fakeMailSource = (mailbox) => {
|
|
|
94
98
|
.filter((m) => wanted.has(m.uid) && !failsNow(m.uid))
|
|
95
99
|
.map((m) => listingOf(m, options?.headers ?? false, folder));
|
|
96
100
|
},
|
|
101
|
+
...(mailbox.quick
|
|
102
|
+
? {
|
|
103
|
+
quick: async (_since, folder, options) => {
|
|
104
|
+
count("quick");
|
|
105
|
+
if (mailbox.searchFails)
|
|
106
|
+
throw failure(mailbox.searchFails);
|
|
107
|
+
const wanted = new Set(options.searched.uids);
|
|
108
|
+
return inFolder(folder)
|
|
109
|
+
.filter((m) => wanted.has(m.uid) && !failsNow(m.uid))
|
|
110
|
+
.map((m) => quickOf(m, folder));
|
|
111
|
+
},
|
|
112
|
+
identify: (raw) => {
|
|
113
|
+
const id = /^Message-ID:\s*<(.+?)@example\.test>/im.exec(Buffer.from(raw).toString())?.[1];
|
|
114
|
+
const m = mailbox.messages.find((each) => each.messageId === id);
|
|
115
|
+
return { messageId: m.messageId, threadId: m.threadId };
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
97
119
|
find: async (messageIds, folder, options) => {
|
|
98
120
|
for (const id of messageIds)
|
|
99
121
|
mailbox.asked?.push(`msgid:${id}`);
|
|
@@ -131,6 +153,7 @@ export const fakeMailSource = (mailbox) => {
|
|
|
131
153
|
},
|
|
132
154
|
fetchRaw: async (items) => {
|
|
133
155
|
count("fetchRaw");
|
|
156
|
+
mailbox.read?.push(...items.map(({ uid }) => uid));
|
|
134
157
|
return items.map((item) => {
|
|
135
158
|
const m = mailbox.messages.find((each) => each.uid === item.uid);
|
|
136
159
|
if (m === undefined || mailbox.unreadable?.includes(item.uid) || failsNow(item.uid))
|
package/dist/store/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type { BotUpdate, BotUpdateStore } from "./sqlite/bot-updates.js";
|
|
|
29
29
|
export type { CatalogActor, CatalogEntry, CatalogInput, CatalogQuery, CatalogStore } from "./sqlite/catalog.js";
|
|
30
30
|
export { CATALOG_QUERY } from "./sqlite/catalog.js";
|
|
31
31
|
export type { ChatMetadata } from "./sqlite/chats.js";
|
|
32
|
-
export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
|
|
32
|
+
export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailLocationInput, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, FolderEmail, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
|
|
33
33
|
export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
|
|
34
34
|
export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
|
|
35
35
|
export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
|
|
@@ -92,6 +92,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
|
|
|
92
92
|
orm.delete(messages).where(eq(messages.accountId, accountKey)).run();
|
|
93
93
|
orm.delete(chats).where(eq(chats.accountId, accountKey)).run();
|
|
94
94
|
orm.delete(syncCursors).where(eq(syncCursors.accountId, accountKey)).run();
|
|
95
|
+
orm.run(sql `DELETE FROM email_locations WHERE mailbox_id IN (SELECT id FROM mailboxes WHERE account_id = ${accountKey})`);
|
|
95
96
|
// Deleting an email clears its recipients, mailboxes, words, attachments and tags (trigger email_bd).
|
|
96
97
|
orm.run(sql `DELETE FROM emails WHERE account_id = ${accountKey}`);
|
|
97
98
|
orm.run(sql `DELETE FROM taggings WHERE taggable_type = 'email_thread' AND taggable_id IN (SELECT id FROM email_threads WHERE account_id = ${accountKey})`);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CacheDatabase } from "../driver.js";
|
|
2
|
+
/** Where an IMAP server holds an email: `folder` is the mailbox's external id. */
|
|
3
|
+
export interface EmailLocationInput {
|
|
4
|
+
folder: string;
|
|
5
|
+
uidvalidity: string;
|
|
6
|
+
uid: number;
|
|
7
|
+
}
|
|
8
|
+
/** One email of a folder as a sync sees it there. */
|
|
9
|
+
export interface FolderEmail {
|
|
10
|
+
uid: number;
|
|
11
|
+
externalId: string;
|
|
12
|
+
receivedAt: number | null;
|
|
13
|
+
read: boolean | null;
|
|
14
|
+
flagged: boolean | null;
|
|
15
|
+
/** Its locations in every folder, and the folders it is a member of: an email is gone only when all are lost. */
|
|
16
|
+
locations: number;
|
|
17
|
+
folders: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const locate: (database: CacheDatabase, accountId: number, emailId: number, where: EmailLocationInput) => {
|
|
20
|
+
changes: number;
|
|
21
|
+
};
|
|
22
|
+
export declare const folderEmails: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string) => FolderEmail[];
|
|
23
|
+
export declare const setLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>) => number;
|
|
24
|
+
export declare const forgetLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, uids: number[]) => void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const locate = (database, accountId, emailId, where) => database
|
|
2
|
+
.prepare(`INSERT OR REPLACE INTO email_locations (mailbox_id, uidvalidity, uid, email_id)
|
|
3
|
+
SELECT id, ?, ?, ? FROM mailboxes WHERE account_id = ? AND external_id = ?`)
|
|
4
|
+
.run(where.uidvalidity, where.uid, emailId, accountId, where.folder);
|
|
5
|
+
export const folderEmails = (database, accountId, folder, uidvalidity) => database
|
|
6
|
+
.prepare(`SELECT l.uid, e.external_id, coalesce(e.received_at, e.sent_at) AS at, e.read, e.flagged,
|
|
7
|
+
(SELECT count(*) FROM email_locations x WHERE x.email_id = e.id) AS locations,
|
|
8
|
+
(SELECT count(*) FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
|
|
9
|
+
WHERE m.email_id = e.id AND b.kind IS NULL) AS folders
|
|
10
|
+
FROM email_locations l JOIN emails e ON e.id = l.email_id
|
|
11
|
+
WHERE l.mailbox_id = (SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?)
|
|
12
|
+
AND l.uidvalidity = ? AND e.deleted_at IS NULL
|
|
13
|
+
ORDER BY l.uid`)
|
|
14
|
+
.all(accountId, folder, uidvalidity)
|
|
15
|
+
.map((row) => ({
|
|
16
|
+
uid: Number(row.uid),
|
|
17
|
+
externalId: String(row.external_id),
|
|
18
|
+
receivedAt: row.at === null ? null : Number(row.at),
|
|
19
|
+
read: row.read === null ? null : Boolean(row.read),
|
|
20
|
+
flagged: row.flagged === null ? null : Boolean(row.flagged),
|
|
21
|
+
locations: Number(row.locations),
|
|
22
|
+
folders: Number(row.folders),
|
|
23
|
+
}));
|
|
24
|
+
export const setLocations = (database, accountId, folder, uidvalidity, found) => {
|
|
25
|
+
database
|
|
26
|
+
.prepare(`DELETE FROM email_locations WHERE uidvalidity <> ?
|
|
27
|
+
AND mailbox_id = (SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?)`)
|
|
28
|
+
.run(uidvalidity, accountId, folder);
|
|
29
|
+
const lookup = database.prepare("SELECT id FROM emails WHERE account_id = ? AND external_id = ?");
|
|
30
|
+
let count = 0;
|
|
31
|
+
for (const [externalId, uids] of found) {
|
|
32
|
+
const row = lookup.get(accountId, externalId);
|
|
33
|
+
if (!row)
|
|
34
|
+
continue;
|
|
35
|
+
for (const uid of uids)
|
|
36
|
+
locate(database, accountId, Number(row.id), { folder, uidvalidity, uid });
|
|
37
|
+
count++;
|
|
38
|
+
}
|
|
39
|
+
return count;
|
|
40
|
+
};
|
|
41
|
+
export const forgetLocations = (database, accountId, folder, uidvalidity, uids) => {
|
|
42
|
+
const box = database
|
|
43
|
+
.prepare("SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?")
|
|
44
|
+
.get(accountId, folder);
|
|
45
|
+
if (!box)
|
|
46
|
+
return;
|
|
47
|
+
const emails = database
|
|
48
|
+
.prepare(`DELETE FROM email_locations WHERE mailbox_id = ? AND uidvalidity = ?
|
|
49
|
+
AND uid IN (SELECT value FROM json_each(?)) RETURNING email_id`)
|
|
50
|
+
.all(Number(box.id), uidvalidity, JSON.stringify(uids))
|
|
51
|
+
.map((row) => Number(row.email_id));
|
|
52
|
+
const leave = database.prepare(`DELETE FROM email_mailboxes WHERE email_id = ? AND mailbox_id = ?
|
|
53
|
+
AND NOT EXISTS (SELECT 1 FROM email_locations WHERE email_id = ? AND mailbox_id = ?)`);
|
|
54
|
+
for (const emailId of new Set(emails))
|
|
55
|
+
leave.run(emailId, Number(box.id), emailId, Number(box.id));
|
|
56
|
+
};
|