@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/dist/mail/sync.js CHANGED
@@ -1,61 +1,28 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { extractText, importEngine } from "../attachments/extract.js";
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
- const addressOf = ({ email, name }) => ({ address: email, name });
23
- /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
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
- if (plan !== undefined) {
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
- raws = await source.fetchRaw(chunk);
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([...labelsOf].flatMap(([id, { seen, flagged }]) => seen === undefined && flagged === undefined
602
- ? []
603
- : [[id, { ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) }]])), startedAt);
604
- const incomplete = plan !== undefined && (capped || plan.stopped !== null || plan.failed.size > 0);
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 listingOf = (m, headers, folder) => ({
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] += 1;
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))
@@ -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
+ };