@wirecat/cli-core 0.487.0 → 0.489.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/dist/calendar/cli/index.js +48 -13
  3. package/dist/calendar/index.d.ts +1 -1
  4. package/dist/calendar/mcp/index.d.ts +390 -0
  5. package/dist/calendar/mcp/index.js +50 -12
  6. package/dist/calendar/reads.d.ts +9 -2
  7. package/dist/calendar/reads.js +60 -5
  8. package/dist/calendar/recurrence.d.ts +20 -0
  9. package/dist/calendar/recurrence.js +74 -0
  10. package/dist/calendar/sync.d.ts +6 -0
  11. package/dist/calendar/sync.js +123 -41
  12. package/dist/calendar/testing/memory.js +90 -7
  13. package/dist/calendar/testing/sync-contract.js +90 -0
  14. package/dist/calendar/testing/writer-contract.d.ts +2 -6
  15. package/dist/calendar/testing/writer-contract.js +157 -29
  16. package/dist/calendar/testing/writer.d.ts +3 -1
  17. package/dist/calendar/testing/writer.js +11 -0
  18. package/dist/calendar/writer.d.ts +20 -3
  19. package/dist/calendar/writer.js +8 -4
  20. package/dist/calendar/writes.d.ts +21 -12
  21. package/dist/calendar/writes.js +259 -20
  22. package/dist/errors/reasons.d.ts +1 -1
  23. package/dist/errors/reasons.js +5 -0
  24. package/dist/mail/attachments.d.ts +15 -0
  25. package/dist/mail/attachments.js +38 -0
  26. package/dist/mail/locations.d.ts +28 -0
  27. package/dist/mail/locations.js +54 -0
  28. package/dist/mail/quick.d.ts +3 -1
  29. package/dist/mail/quick.js +9 -6
  30. package/dist/mail/resume.d.ts +6 -0
  31. package/dist/mail/retry.d.ts +23 -0
  32. package/dist/mail/retry.js +110 -0
  33. package/dist/mail/sync.d.ts +2 -3
  34. package/dist/mail/sync.js +85 -152
  35. package/dist/mail/testing/fake.d.ts +2 -0
  36. package/dist/mail/testing/fake.js +1 -1
  37. package/dist/meetings/event-store.d.ts +80 -2
  38. package/dist/meetings/event-store.js +65 -1
  39. package/dist/meetings/index.d.ts +2 -1
  40. package/dist/meetings/index.js +1 -0
  41. package/dist/meetings/testing/event-store-contract.d.ts +1 -1
  42. package/dist/meetings/testing/event-store-contract.js +114 -0
  43. package/dist/store/drivers/node-sqlite.d.ts +9 -1
  44. package/dist/store/drivers/node-sqlite.js +30 -8
  45. package/dist/store/index.d.ts +1 -1
  46. package/dist/store/sqlite/accounts.js +1 -0
  47. package/dist/store/sqlite/calendar-events.d.ts +4 -0
  48. package/dist/store/sqlite/calendar-events.js +89 -0
  49. package/dist/store/sqlite/calendar.js +126 -94
  50. package/dist/store/sqlite/email-locations.d.ts +24 -0
  51. package/dist/store/sqlite/email-locations.js +56 -0
  52. package/dist/store/sqlite/email-state.d.ts +2 -0
  53. package/dist/store/sqlite/email-state.js +28 -0
  54. package/dist/store/sqlite/emails.d.ts +13 -0
  55. package/dist/store/sqlite/emails.js +9 -29
  56. package/dist/store/sqlite/manifest.js +1 -0
  57. package/dist/store/sqlite/migrations.generated.js +7 -0
  58. package/dist/store/sqlite/schema.d.ts +72 -0
  59. package/dist/store/sqlite/schema.js +13 -0
  60. package/package.json +1 -1
@@ -0,0 +1,110 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { addressOf, FETCH_CHUNK, readAttachments } from "./attachments.js";
3
+ import { mailFolders } from "./folders.js";
4
+ import { mailHeadersOf } from "./headers.js";
5
+ import { parseMail } from "./parse.js";
6
+ /** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
7
+ export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
8
+ /**
9
+ * Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
10
+ * attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
11
+ * resume point moves; the email keeps its stored mailboxes, flags and rule.
12
+ */
13
+ export const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
14
+ const startedAt = now();
15
+ const key = { provider: "email", account: account.address.toLowerCase() };
16
+ const find = source.find;
17
+ if (find === undefined)
18
+ throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
19
+ const accountId = await store.accounts.saveAccount(key, { name: account.address });
20
+ const wanted = (await store.mail.emailRefs({ accountId, attachmentErrors: RETRYABLE_ATTACHMENTS })).map(({ externalId }) => externalId);
21
+ const chosen = wanted.slice(0, max);
22
+ const found = new Map();
23
+ for (const folder of await mailFolders(account, source)) {
24
+ const left = chosen.filter((id) => !found.has(id));
25
+ if (left.length === 0)
26
+ break;
27
+ for (const item of await find(left, folder))
28
+ if (!found.has(item.messageId))
29
+ found.set(item.messageId, item);
30
+ }
31
+ const items = [...found.values()];
32
+ const attachments = [];
33
+ const unreadable = [];
34
+ let emails = 0;
35
+ let extracted = 0;
36
+ let stillFailed = 0;
37
+ for (let start = 0; start < items.length; start += FETCH_CHUNK) {
38
+ const chunk = items.slice(start, start + FETCH_CHUNK);
39
+ const raws = await source.fetchRaw(chunk);
40
+ for (const [index, item] of chunk.entries()) {
41
+ const raw = raws[index];
42
+ const stored = await store.mail.email(accountId, item.messageId);
43
+ if (stored === null)
44
+ continue;
45
+ let mail;
46
+ try {
47
+ if (raw === undefined || raw instanceof Error)
48
+ throw raw ?? new Error("not read");
49
+ mail = await parseMail(raw, maxBodyChars);
50
+ }
51
+ catch (error) {
52
+ unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
53
+ continue;
54
+ }
55
+ const before = new Map(stored.attachments.map(({ position, extractionError }) => [position, extractionError]));
56
+ const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
57
+ for (const file of files)
58
+ if (RETRYABLE_ATTACHMENTS.includes(before.get(file.position) ?? ""))
59
+ if (file.extraction === "text")
60
+ extracted++;
61
+ else
62
+ stillFailed++;
63
+ const header = mailHeadersOf(raw);
64
+ await store.mail.saveThread({
65
+ accountId,
66
+ externalId: item.threadId,
67
+ now: startedAt,
68
+ emails: [
69
+ {
70
+ externalId: item.messageId,
71
+ subject: mail.subject,
72
+ from: mail.from === null ? null : addressOf(mail.from),
73
+ to: mail.to.map(addressOf),
74
+ cc: mail.cc.map(addressOf),
75
+ bcc: mail.bcc.map(addressOf),
76
+ sentAt: null,
77
+ receivedAt: Date.parse(item.receivedAt),
78
+ bodyText: mail.text || null,
79
+ size: item.size ?? stored.size,
80
+ ...(Object.keys(header).length ? { headers: header } : {}),
81
+ outgoing: mail.from === null ? null : mail.from.email === key.account,
82
+ mailboxes: stored.mailboxes.map(({ externalId, name, kind }) => ({ externalId, name, kind })),
83
+ ...(stored.read === null ? {} : { read: stored.read }),
84
+ ...(stored.flagged === null ? {} : { flagged: stored.flagged }),
85
+ metadata: stored.metadata,
86
+ mailRuleId: stored.mailRuleId,
87
+ attachments: files,
88
+ },
89
+ ],
90
+ });
91
+ emails++;
92
+ }
93
+ }
94
+ const indexed = await index?.(store, key);
95
+ return {
96
+ account: key.account,
97
+ since: since.toISOString(),
98
+ listed: items.length,
99
+ saved: 0,
100
+ alreadyStored: 0,
101
+ skipped: 0,
102
+ envelopes: 0,
103
+ deleted: 0,
104
+ complete: wanted.length <= max && unreadable.length === 0,
105
+ ...(indexed === undefined ? {} : { indexed }),
106
+ retried: { emails, extracted, stillFailed, missing: chosen.length - items.length },
107
+ ...(attachments.length ? { attachments } : {}),
108
+ ...(unreadable.length ? { unreadable } : {}),
109
+ };
110
+ };
@@ -74,7 +74,8 @@ export interface MailSyncResult {
74
74
  missing: number;
75
75
  };
76
76
  }
77
- export declare const MAX_ATTACHMENT_CHARS = 200000;
77
+ export { MAX_ATTACHMENT_CHARS } from "./attachments.js";
78
+ export { RETRYABLE_ATTACHMENTS } from "./retry.js";
78
79
  export interface MailSyncOptions {
79
80
  account: MailAccount;
80
81
  since: Date;
@@ -102,5 +103,3 @@ export interface MailSyncOptions {
102
103
  budget?: RunBudget;
103
104
  }
104
105
  export declare const syncMail: (source: MailSource, store: MessageStore, options: MailSyncOptions) => Promise<MailSyncResult>;
105
- /** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
106
- export declare const RETRYABLE_ATTACHMENTS: string[];
package/dist/mail/sync.js CHANGED
@@ -1,59 +1,27 @@
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";
9
11
  import { FETCH_SLICE, keyOf, readQuick } from "./quick.js";
10
12
  import { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
13
+ import { retryUnread } from "./retry.js";
11
14
  import { ruleTagger, settleRuleTags } from "./rule-tags.js";
12
15
  import { decide, ENVELOPE, envelopeBox, envelopeBoxOf, tagsOf } from "./rules.js";
13
16
  const DAY = 86_400_000;
14
17
  /** IMAP's SINCE counts whole days in the server's zone, so the window's first days are not trusted. */
15
18
  const EDGE = 2 * DAY;
16
- export const MAX_ATTACHMENT_CHARS = 200_000;
17
- /** One chunk of raw mail is held in memory at a time. */
18
- const FETCH_CHUNK = 50;
19
19
  /** How often a long run saves where it is, so a kill costs at most this much listing again. */
20
20
  const SAVE_EVERY_MS = 30_000;
21
21
  /** About a minute of listing; the 30-day window of the timer stays below it. */
22
22
  const RESUME_FROM = 5_000;
23
- const addressOf = ({ email, name }) => ({ address: email, name });
24
- /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
25
- const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
26
- const files = [];
27
- for (const [position, part] of mail.attachments.entries()) {
28
- const extraction = part.bytes === null
29
- ? { status: "unavailable" }
30
- : await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
31
- log.push({ message: messageId, part: part.id, status: extraction.status });
32
- const extracted = extraction.status === "extracted" ? extraction : undefined;
33
- files.push({
34
- position,
35
- kind: "file",
36
- mime: part.mime,
37
- name: part.name,
38
- title: null,
39
- url: null,
40
- size: part.bytes === null ? null : part.bytes.byteLength,
41
- width: null,
42
- height: null,
43
- duration: null,
44
- providerRef: { message: messageId, part: part.id },
45
- localPath: null,
46
- text: extracted ? extracted.text.slice(0, maxAttachmentChars) : null,
47
- normalizedText: null,
48
- extraction: extracted ? "text" : "failed",
49
- extractor: extracted ? extracted.extractor : null,
50
- extractionError: extracted ? null : extraction.status,
51
- contentSha256: null,
52
- extractedAt: at,
53
- });
54
- }
55
- return files;
56
- };
23
+ export { MAX_ATTACHMENT_CHARS } from "./attachments.js";
24
+ export { RETRYABLE_ATTACHMENTS } from "./retry.js";
57
25
  const LIST_SLICE = 200;
58
26
  export const syncMail = async (source, store, options) => {
59
27
  const { budget, index } = options;
@@ -194,6 +162,11 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
194
162
  /** Raw mail read to learn its id, kept for the save so it is not read twice. */
195
163
  const rawOf = new Map();
196
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();
197
170
  let holding;
198
171
  const holdsMail = async (folder) => {
199
172
  holding ??= new Set((await store.mail.emailRefs({ accountId })).flatMap(({ mailboxes }) => mailboxes
@@ -234,10 +207,16 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
234
207
  searched.validity !== undefined &&
235
208
  source.quick !== undefined &&
236
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));
237
216
  const inQuick = quickable &&
238
217
  (plan === undefined
239
- ? sameRules || !(await holdsMail(folder))
240
- : plan.quickFolders.has(folder) || (!plan.modes.has(folder) && (sameRules || !(await holdsMail(folder)))));
218
+ ? await quickReady()
219
+ : plan.quickFolders.has(folder) || (!plan.modes.has(folder) && (await quickReady())));
241
220
  if (inQuick) {
242
221
  plan?.quickFolders.add(folder);
243
222
  plan?.modes.set(folder, "resumed");
@@ -246,7 +225,12 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
246
225
  const done = point !== undefined && sameRules ? point.done : [];
247
226
  if (point !== undefined && sameRules)
248
227
  earlier.set(folder, point);
249
- const round = await readQuick(source, since, folder, searched, done, max, plan);
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 });
250
234
  if (round.capped)
251
235
  capped = true;
252
236
  for (const { item, raw } of round.read) {
@@ -260,12 +244,13 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
260
244
  if (point !== undefined && point.validity === searched.validity && point.rules === ruleSet) {
261
245
  earlier.set(folder, point);
262
246
  const mode = plan?.modes.get(folder);
247
+ const backfill = quickable && !located;
263
248
  const usable = Date.parse(point.full.since) <= since.getTime() && startedAt - Date.parse(point.full.at) < FULL_PASS_MS;
264
249
  // A budgeted run goes on with an unfinished pass however small its window is: listing it whole again would
265
250
  // ask for the same newest messages every run.
266
251
  const unfinished = plan !== undefined && searched.uids.some((uid) => !within(point.done, Number(uid)));
267
252
  if (mode === "resumed" ||
268
- (mode === undefined && usable && (searched.uids.length >= resumeFrom || unfinished))) {
253
+ (mode === undefined && usable && !backfill && (searched.uids.length >= resumeFrom || unfinished))) {
269
254
  resumed.add(folder);
270
255
  wanted = { ...searched, uids: searched.uids.filter((uid) => !within(point.done, Number(uid))) };
271
256
  }
@@ -332,6 +317,22 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
332
317
  current.add(item.folder ?? folders[0]);
333
318
  foundFolders.set(item.messageId, current);
334
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
+ };
335
336
  const mailbox = (folder) => ({ externalId: folder, name: folder, kind: null });
336
337
  const label = (name) => ({ externalId: `label:${name}`, name, kind: "label" });
337
338
  const labelsOf = new Map(collected.flatMap((item) => (item.labels === undefined ? [] : [[item.messageId, item]])));
@@ -346,8 +347,23 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
346
347
  skipped = "an mbox file is a snapshot; mail missing from it is not proven deleted";
347
348
  else if (plan !== undefined && (capped || plan.tried.size > plan.asked || plan.only !== undefined))
348
349
  skipped = "this run read the window in parts; a run that lists all of it at once checks deletions";
349
- else if (quickRan.size > 0)
350
- skipped = "this run listed only mail not read before, by UID; --rescan lists every message and 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
+ }
351
367
  else if (resumed.size > 0)
352
368
  skipped =
353
369
  "this run listed only mail not read before; a full listing, at least daily or with --rescan, checks deletions";
@@ -481,6 +497,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
481
497
  ? undefined
482
498
  : { since: prior.window.since, total: prior.window.total, at: prior.updatedAt });
483
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;
484
502
  const point = {
485
503
  validity: searched.validity,
486
504
  done: merged,
@@ -494,12 +512,22 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
494
512
  }
495
513
  : before.window,
496
514
  widest: widestEver,
515
+ ...(located
516
+ ? { located: Date.parse(locatedFrom) < since.getTime() ? locatedFrom : since.toISOString() }
517
+ : before?.located === undefined
518
+ ? {}
519
+ : { located: before.located }),
497
520
  last: { ...thisRun, done: searched.uids.filter((uid) => within(merged, Number(uid))).length },
498
521
  updatedAt: new Date(now()).toISOString(),
499
522
  };
500
523
  await store.sync.setSyncState(key, resumeKey(folder), JSON.stringify(point));
501
524
  }
502
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
+ }
503
531
  let savedAt = now();
504
532
  const saveNow = async (force = false) => {
505
533
  if (!force && now() - savedAt < SAVE_EVERY_MS)
@@ -534,6 +562,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
534
562
  size: item.size ?? null,
535
563
  outgoing: headers?.from === key.account,
536
564
  mailboxes: [...inMailboxes(item), envelopeBox(rule.match)],
565
+ ...locationOf(item),
537
566
  ...flagsOf(item),
538
567
  mailRuleId: rule.id ?? null,
539
568
  metadata: {
@@ -620,6 +649,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
620
649
  ...(Object.keys(header).length ? { headers: header } : {}),
621
650
  outgoing: mail.from === null ? null : mail.from.email === key.account,
622
651
  mailboxes: inMailboxes(item),
652
+ ...locationOf(item),
623
653
  ...flagsOf(item),
624
654
  ...(upgraded.has(item.messageId) ? { metadata: {} } : {}),
625
655
  mailRuleId: decide(rules, factsFor(item))?.id ?? null,
@@ -647,9 +677,17 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
647
677
  }
648
678
  // Read and starred change at the source after import; mail saved in this run already holds them.
649
679
  if (!snapshot)
650
- await store.mail.setFlags(accountId, new Map([...labelsOf].flatMap(([id, { seen, flagged }]) => seen === undefined && flagged === undefined
651
- ? []
652
- : [[id, { ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) }]])), startedAt);
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);
653
691
  const incomplete = capped || (plan !== undefined && (plan.stopped !== null || plan.failed.size > 0));
654
692
  const complete = !full && !incomplete && unreadable.length === 0 && (plan?.unreadable.size ?? 0) === 0;
655
693
  const bulk = new Map(listed.filter((item) => item.headers?.bulk).map((item) => [item.messageId, [bulkBox.externalId]]));
@@ -690,108 +728,3 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
690
728
  ...(tagging.skipped.length ? { tagsSkipped: tagging.skipped } : {}),
691
729
  };
692
730
  };
693
- /** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
694
- export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
695
- /**
696
- * Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
697
- * attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
698
- * resume point moves; the email keeps its stored mailboxes, flags and rule.
699
- */
700
- const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
701
- const startedAt = now();
702
- const key = { provider: "email", account: account.address.toLowerCase() };
703
- const find = source.find;
704
- if (find === undefined)
705
- throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
706
- const accountId = await store.accounts.saveAccount(key, { name: account.address });
707
- const wanted = (await store.mail.emailRefs({ accountId, attachmentErrors: RETRYABLE_ATTACHMENTS })).map(({ externalId }) => externalId);
708
- const chosen = wanted.slice(0, max);
709
- const found = new Map();
710
- for (const folder of await mailFolders(account, source)) {
711
- const left = chosen.filter((id) => !found.has(id));
712
- if (left.length === 0)
713
- break;
714
- for (const item of await find(left, folder))
715
- if (!found.has(item.messageId))
716
- found.set(item.messageId, item);
717
- }
718
- const items = [...found.values()];
719
- const attachments = [];
720
- const unreadable = [];
721
- let emails = 0;
722
- let extracted = 0;
723
- let stillFailed = 0;
724
- for (let start = 0; start < items.length; start += FETCH_CHUNK) {
725
- const chunk = items.slice(start, start + FETCH_CHUNK);
726
- const raws = await source.fetchRaw(chunk);
727
- for (const [index, item] of chunk.entries()) {
728
- const raw = raws[index];
729
- const stored = await store.mail.email(accountId, item.messageId);
730
- if (stored === null)
731
- continue;
732
- let mail;
733
- try {
734
- if (raw === undefined || raw instanceof Error)
735
- throw raw ?? new Error("not read");
736
- mail = await parseMail(raw, maxBodyChars);
737
- }
738
- catch (error) {
739
- unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
740
- continue;
741
- }
742
- const before = new Map(stored.attachments.map(({ position, extractionError }) => [position, extractionError]));
743
- const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
744
- for (const file of files)
745
- if (RETRYABLE_ATTACHMENTS.includes(before.get(file.position) ?? ""))
746
- if (file.extraction === "text")
747
- extracted++;
748
- else
749
- stillFailed++;
750
- const header = mailHeadersOf(raw);
751
- await store.mail.saveThread({
752
- accountId,
753
- externalId: item.threadId,
754
- now: startedAt,
755
- emails: [
756
- {
757
- externalId: item.messageId,
758
- subject: mail.subject,
759
- from: mail.from === null ? null : addressOf(mail.from),
760
- to: mail.to.map(addressOf),
761
- cc: mail.cc.map(addressOf),
762
- bcc: mail.bcc.map(addressOf),
763
- sentAt: null,
764
- receivedAt: Date.parse(item.receivedAt),
765
- bodyText: mail.text || null,
766
- size: item.size ?? stored.size,
767
- ...(Object.keys(header).length ? { headers: header } : {}),
768
- outgoing: mail.from === null ? null : mail.from.email === key.account,
769
- mailboxes: stored.mailboxes.map(({ externalId, name, kind }) => ({ externalId, name, kind })),
770
- ...(stored.read === null ? {} : { read: stored.read }),
771
- ...(stored.flagged === null ? {} : { flagged: stored.flagged }),
772
- metadata: stored.metadata,
773
- mailRuleId: stored.mailRuleId,
774
- attachments: files,
775
- },
776
- ],
777
- });
778
- emails++;
779
- }
780
- }
781
- const indexed = await index?.(store, key);
782
- return {
783
- account: key.account,
784
- since: since.toISOString(),
785
- listed: items.length,
786
- saved: 0,
787
- alreadyStored: 0,
788
- skipped: 0,
789
- envelopes: 0,
790
- deleted: 0,
791
- complete: wanted.length <= max && unreadable.length === 0,
792
- ...(indexed === undefined ? {} : { indexed }),
793
- retried: { emails, extracted, stillFailed, missing: chosen.length - items.length },
794
- ...(attachments.length ? { attachments } : {}),
795
- ...(unreadable.length ? { unreadable } : {}),
796
- };
797
- };
@@ -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 {
@@ -54,7 +54,7 @@ export const fakeMailSource = (mailbox) => {
54
54
  const inFolder = (folder) => {
55
55
  if (![all(), trash(), "INBOX"].includes(folder))
56
56
  throw failure(`Gmail has no such mailbox: ${folder}`);
57
- 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));
58
58
  };
59
59
  const search = async (since, folder) => {
60
60
  if (mailbox.searchFails)
@@ -144,6 +144,10 @@ export interface StoredEntry {
144
144
  etag: string | null;
145
145
  seenAt: number | null;
146
146
  deletedAt: number | null;
147
+ /** When this copy was first stored. */
148
+ createdAt: number;
149
+ /** `transparent`: the copy does not take the owner's time. */
150
+ transparency: EntryTransparency | null;
147
151
  }
148
152
  export interface StoredParticipant {
149
153
  entryId: number;
@@ -183,6 +187,13 @@ export interface EventFilter {
183
187
  to?: number;
184
188
  /** All-day events, which have dates and no instant, ending after this local day (`YYYY-MM-DD`); `from` without it. */
185
189
  fromDate?: string;
190
+ /** Events `eventChange` finds new, moved or cancelled at or after this instant. */
191
+ changedSince?: number;
192
+ /**
193
+ * Events whose time overlaps another's. Only timed events that take the owner's time count: not cancelled, and with
194
+ * a live copy the owner did not decline or mark free.
195
+ */
196
+ overlapping?: boolean;
186
197
  /** All-day events starting before this local day (exclusive); `to` without it. */
187
198
  toDate?: string;
188
199
  accountId?: number;
@@ -195,10 +206,17 @@ export interface EventFilter {
195
206
  offset?: number;
196
207
  }
197
208
  /** Pairs the rules left unlinked because two or more entities fit equally well (requirements 3.6). */
209
+ /** One series' slots for `EventStore.saveSlots`: what the provider gave, or `null` to keep what is stored. */
210
+ export interface SeriesSlots {
211
+ series: string;
212
+ slots: EntryInput[] | null;
213
+ }
198
214
  export interface LinkReview {
199
215
  id: number;
200
216
  linkableType: string;
201
217
  linkableId: number;
218
+ /** The calendar of a `calendar_entry` review's copy, so a choice is made only from that calendar's account. */
219
+ calendarId: number | null;
202
220
  entityType: string;
203
221
  candidates: number[];
204
222
  reason: string;
@@ -231,13 +249,22 @@ export interface EventStore {
231
249
  */
232
250
  removeEntries(calendarId: number, externalIds: string[], now: number): Promise<number>;
233
251
  /**
234
- * After a full list: entries not seen since `seenBefore` get `deleted_at`. Plain occurrences come only from the
235
- * window fetched, so only those inside it count. Events, series and links stay.
252
+ * After a full list: entries not seen since `seenBefore` get `deleted_at`. A full list holds only what falls in the
253
+ * window, so only entries `entryInWindow` puts there count. Events, series and links stay.
236
254
  */
237
255
  markUnseen(calendarId: number, seenBefore: number, window: {
238
256
  from: number;
239
257
  to: number;
240
258
  }, now: number): Promise<number>;
259
+ /**
260
+ * Takes `deleted_at` off the entries a full list marked although they were outside the window it listed:
261
+ * `windowAt(deletedAt)` is the window of the list that marked one. Their events take their fields again. Returns how
262
+ * many came back.
263
+ */
264
+ restoreUnseen(calendarId: number, windowAt: (deletedAt: number) => {
265
+ from: number;
266
+ to: number;
267
+ }, now: number): Promise<number>;
241
268
  /**
242
269
  * Where the calendar's last sync stopped, as the sync engine wrote it (`{"v":1,"token":…,"window":…,"fullAt":…}`),
243
270
  * or `null` before the first one.
@@ -247,8 +274,34 @@ export interface EventStore {
247
274
  setCursor(calendarId: number, value: string | null, now: number): Promise<void>;
248
275
  /** One entry by the provider's id; a series, which no event holds, is found only this way. */
249
276
  entry(calendarId: number, externalId: string): Promise<StoredEntry | null>;
277
+ /**
278
+ * Several series' slots in the window and the sync cursor, in one transaction: a sync commits once for many series
279
+ * instead of twice for each. A series' slots replace its stored plain slots in the window (one it no longer has is
280
+ * cancelled, as `removeEntries` does); `slots: null` keeps them and counts them as seen now.
281
+ */
282
+ saveSlots(calendarId: number, groups: SeriesSlots[], window: {
283
+ from: number;
284
+ to: number;
285
+ }, cursor: string, now: number): Promise<{
286
+ saved: number;
287
+ removed: number;
288
+ }>;
289
+ /** The series' stored slots, moved ones too, whose original start falls in the window; deleted ones left out. */
290
+ slotsOf(calendarId: number, seriesExternalId: string, window: {
291
+ from: number;
292
+ to: number;
293
+ }): Promise<StoredEntry[]>;
250
294
  events(filter?: EventFilter): Promise<Event[]>;
251
295
  event(id: number): Promise<EventDetails | null>;
296
+ /** These events' copies on these calendars, deleted ones too, and their revisions since the instant, for `eventChange`. */
297
+ historyOf(eventIds: number[], calendarIds: number[], since: number): Promise<{
298
+ entries: StoredEntry[];
299
+ revisions: EntryRevision[];
300
+ }>;
301
+ /** The participants of these events' copies on these calendars, deleted copies too, as `event` gives them. */
302
+ participantsOf(eventIds: number[], calendarIds: number[]): Promise<(StoredParticipant & {
303
+ eventId: number;
304
+ })[]>;
252
305
  /** The owner's link of an entry to an event; no rule replaces it. */
253
306
  linkEntry(entryId: number, eventId: number, now: number): Promise<void>;
254
307
  /** The owner's unlink: the rules leave the entry alone after it. */
@@ -258,3 +311,28 @@ export interface EventStore {
258
311
  }): Promise<LinkReview[]>;
259
312
  resolveReview(id: number, decision: ReviewDecision, now: number): Promise<LinkReview>;
260
313
  }
314
+ /**
315
+ * Whether a full list of the window would return the entry: a single or a moved slot by its own time, a plain slot by
316
+ * the time its series gave it, a series while it repeats into the window. An entry with no time is never counted.
317
+ */
318
+ export declare const entryInWindow: (entry: Pick<StoredEntry, "kind" | "exception" | "startsAt" | "endsAt" | "originalStartsAt" | "recurrence" | "recurrenceEndsAt">, window: {
319
+ from: number;
320
+ to: number;
321
+ }) => boolean;
322
+ /** What happened to an event at or after an instant, from its copies: first stored, a new time, or cancelled. */
323
+ export interface EventChange {
324
+ change: "new" | "moved" | "cancelled";
325
+ /** When the store saw it. */
326
+ at: number;
327
+ /** The time a moved event had at the instant. */
328
+ was?: {
329
+ startsAt: number | null;
330
+ endsAt: number | null;
331
+ };
332
+ }
333
+ /**
334
+ * The event's change since `since`, from its copies and their revisions (each revision holds the fields before one
335
+ * change): cancelled when the event is gone and a copy was cancelled or removed since; new when its first copy was
336
+ * stored since; moved when a live copy's time now differs from its time at `since`. `null` when none of these.
337
+ */
338
+ export declare const eventChange: (event: Pick<Event, "status" | "sourceDeletedAt">, entries: readonly StoredEntry[], revisions: readonly EntryRevision[], since: number) => EventChange | null;