@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.
- package/CHANGELOG.md +108 -1
- package/dist/calendar/cli/index.js +48 -13
- package/dist/calendar/index.d.ts +1 -1
- package/dist/calendar/mcp/index.d.ts +390 -0
- package/dist/calendar/mcp/index.js +50 -12
- package/dist/calendar/reads.d.ts +9 -2
- package/dist/calendar/reads.js +60 -5
- package/dist/calendar/recurrence.d.ts +20 -0
- package/dist/calendar/recurrence.js +74 -0
- package/dist/calendar/sync.d.ts +6 -0
- package/dist/calendar/sync.js +123 -41
- package/dist/calendar/testing/memory.js +90 -7
- package/dist/calendar/testing/sync-contract.js +90 -0
- package/dist/calendar/testing/writer-contract.d.ts +2 -6
- package/dist/calendar/testing/writer-contract.js +157 -29
- package/dist/calendar/testing/writer.d.ts +3 -1
- package/dist/calendar/testing/writer.js +11 -0
- package/dist/calendar/writer.d.ts +20 -3
- package/dist/calendar/writer.js +8 -4
- package/dist/calendar/writes.d.ts +21 -12
- package/dist/calendar/writes.js +259 -20
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +5 -0
- package/dist/mail/attachments.d.ts +15 -0
- package/dist/mail/attachments.js +38 -0
- package/dist/mail/locations.d.ts +28 -0
- package/dist/mail/locations.js +54 -0
- package/dist/mail/quick.d.ts +3 -1
- package/dist/mail/quick.js +9 -6
- 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/sync.d.ts +2 -3
- package/dist/mail/sync.js +85 -152
- package/dist/mail/testing/fake.d.ts +2 -0
- package/dist/mail/testing/fake.js +1 -1
- package/dist/meetings/event-store.d.ts +80 -2
- package/dist/meetings/event-store.js +65 -1
- package/dist/meetings/index.d.ts +2 -1
- package/dist/meetings/index.js +1 -0
- package/dist/meetings/testing/event-store-contract.d.ts +1 -1
- package/dist/meetings/testing/event-store-contract.js +114 -0
- package/dist/store/drivers/node-sqlite.d.ts +9 -1
- package/dist/store/drivers/node-sqlite.js +30 -8
- package/dist/store/index.d.ts +1 -1
- package/dist/store/sqlite/accounts.js +1 -0
- package/dist/store/sqlite/calendar-events.d.ts +4 -0
- package/dist/store/sqlite/calendar-events.js +89 -0
- package/dist/store/sqlite/calendar.js +126 -94
- 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
|
@@ -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
|
+
};
|
package/dist/mail/sync.d.ts
CHANGED
|
@@ -74,7 +74,8 @@ export interface MailSyncResult {
|
|
|
74
74
|
missing: number;
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
-
export
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
?
|
|
240
|
-
: plan.quickFolders.has(folder) || (!plan.modes.has(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
|
|
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
|
-
|
|
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([
|
|
651
|
-
|
|
652
|
-
|
|
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`.
|
|
235
|
-
* window
|
|
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;
|