@wirecat/cli-core 0.486.0 → 0.488.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.488.0 — 30.10.2026
11
+
12
+ ### Added
13
+
14
+ - **A quick mail sync proves deletions and refreshes flags again (store version 39).** The store keeps each email's
15
+ place on an IMAP server (`email_locations`: folder, UIDVALIDITY, UID), saved with the email, so a run that lists only
16
+ UIDs and flags can tell which stored mail the server dropped and which flags changed, without reading the mail. The
17
+ first run on mail stored before this lists the folder once with headers to record the places; after that the folder
18
+ is quick. A new UIDVALIDITY drops the folder's places. New `store.mail.folderEmails`, `setLocations` and
19
+ `forgetLocations`, and `locations` on `EmailInput`.
20
+
21
+ ## 0.487.0 — 30.10.2026
22
+
23
+ ### Added
24
+
25
+ - **A messenger can say what `reactions list` covers.** The new `reactionsListScope` option on the messenger (for
26
+ WhatsApp, "reactions received while this computer was syncing") adds `missing: [{ field: "reactions", why }]` to the
27
+ JSON answer and one line on stderr for a person: "shows only …". A messenger that sets none answers as before.
28
+ - **Mail from a server with no cheap message id (Yandex) syncs from a listing of UID, flags, date and size.** A header
29
+ FETCH of 200 messages took 5 to 40 s there and often failed, so six months took hours. A `MailSource` can now answer
30
+ `quick: true` from `search` and give `quick` and `identify`; `syncMail` then lists only the UIDs not done, reads the
31
+ raw bytes of the new ones and takes the id from the Message-ID, so stored mail keeps its id and nothing stored is read
32
+ again. Rules and the bulk class run on the downloaded headers. A new UIDVALIDITY, other rules or `rescan` list the
33
+ folder with headers as before; a quick run proves no deletion and refreshes no flags of the mail it skips.
34
+ `mailSourceContract` checks that `quick` and `identify` agree with `list`; `fakeMailSource` takes `quick: true`.
35
+
10
36
  ## 0.486.0 — 30.10.2026
11
37
 
12
38
  ### Fixed
@@ -84,6 +84,8 @@ export interface Messenger extends ServiceMessenger {
84
84
  pollCloseSeconds?: readonly [min: number, max: number];
85
85
  /** Whether `reactions list` shows who reacted to a message; no when unset. */
86
86
  reactionsList?: boolean;
87
+ /** What `reactions list` covers when it is less than everyone who reacted, as the end of "shows only …", such as "reactions received while this computer was syncing"; none when unset. */
88
+ reactionsListScope?: string;
87
89
  /** Whether `polls voters` lists who voted for what; no when unset. */
88
90
  pollVoters?: boolean;
89
91
  /** Whether a poll can be closed — `polls close`; yes when unset. WhatsApp's stay open. */
@@ -33,6 +33,7 @@ export const reactionsCommand = (messenger) => {
33
33
  ...(cursor === undefined ? {} : { cursor }),
34
34
  });
35
35
  });
36
+ const why = messenger.reactionsListScope === undefined ? undefined : `shows only ${messenger.reactionsListScope}`;
36
37
  renderAnswer(this, context, listOf("reactions list", {
37
38
  items: found.items.map(reactorAnswer),
38
39
  limit,
@@ -40,7 +41,10 @@ export const reactionsCommand = (messenger) => {
40
41
  paging: "cursor",
41
42
  ...(found.cursor === undefined ? {} : { cursor: found.cursor }),
42
43
  args: { chat, message, ...(words.length > 0 ? { query: words.join(" ") } : {}) },
44
+ ...(why === undefined ? {} : { missing: [{ field: "reactions", why }] }),
43
45
  }), lines(({ reacted_at, person, reaction: name }) => [reacted_at, person.name ?? person.id, name].join(" ")));
46
+ if (why !== undefined && context.format !== "json")
47
+ context.renderer.note(why);
44
48
  }));
45
49
  annotate(reactions.command("add"), {
46
50
  effects: REMOTE_WRITE,
@@ -45,6 +45,7 @@ export declare const serveRelay: (command: Command, messenger: Messenger) => {
45
45
  pollQuiz?: boolean;
46
46
  pollCloseSeconds?: readonly [min: number, max: number];
47
47
  reactionsList?: boolean;
48
+ reactionsListScope?: string;
48
49
  pollVoters?: boolean;
49
50
  pollClose?: boolean;
50
51
  pollRevote?: boolean;
@@ -0,0 +1,15 @@
1
+ import type { LoadEngine } from "../readers/types.js";
2
+ import type { AttachmentInput, EmailAddress } from "../store/index.js";
3
+ import type { Address, ParsedMail } from "./parse.js";
4
+ import type { MailSyncResult } from "./sync.js";
5
+ export declare const MAX_ATTACHMENT_CHARS = 200000;
6
+ /** One chunk of raw mail is held in memory at a time. */
7
+ export declare const FETCH_CHUNK = 50;
8
+ export declare const addressOf: ({ email, name }: Address) => EmailAddress;
9
+ export type AttachmentLog = NonNullable<MailSyncResult["attachments"]>;
10
+ /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
11
+ export declare const readAttachments: (mail: ParsedMail, messageId: string, { loadEngine, maxAttachmentChars, at }: {
12
+ loadEngine: LoadEngine;
13
+ maxAttachmentChars: number;
14
+ at: number;
15
+ }, log: AttachmentLog) => Promise<AttachmentInput[]>;
@@ -0,0 +1,38 @@
1
+ import { extractText } from "../attachments/extract.js";
2
+ export const MAX_ATTACHMENT_CHARS = 200_000;
3
+ /** One chunk of raw mail is held in memory at a time. */
4
+ export const FETCH_CHUNK = 50;
5
+ export const addressOf = ({ email, name }) => ({ address: email, name });
6
+ /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
7
+ export const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
8
+ const files = [];
9
+ for (const [position, part] of mail.attachments.entries()) {
10
+ const extraction = part.bytes === null
11
+ ? { status: "unavailable" }
12
+ : await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
13
+ log.push({ message: messageId, part: part.id, status: extraction.status });
14
+ const extracted = extraction.status === "extracted" ? extraction : undefined;
15
+ files.push({
16
+ position,
17
+ kind: "file",
18
+ mime: part.mime,
19
+ name: part.name,
20
+ title: null,
21
+ url: null,
22
+ size: part.bytes === null ? null : part.bytes.byteLength,
23
+ width: null,
24
+ height: null,
25
+ duration: null,
26
+ providerRef: { message: messageId, part: part.id },
27
+ localPath: null,
28
+ text: extracted ? extracted.text.slice(0, maxAttachmentChars) : null,
29
+ normalizedText: null,
30
+ extraction: extracted ? "text" : "failed",
31
+ extractor: extracted ? extracted.extractor : null,
32
+ extractionError: extracted ? null : extraction.status,
33
+ contentSha256: null,
34
+ extractedAt: at,
35
+ });
36
+ }
37
+ return files;
38
+ };
@@ -36,6 +36,8 @@ export declare const authOf: (unfolded: string) => AuthVerdicts | undefined;
36
36
  export declare const addressesOf: (value: string | undefined) => string[];
37
37
  /** The header fields of one email, from its header text. */
38
38
  export declare const headersOf: (text: string) => MailHeaders;
39
+ /** The header fields of a whole email, as `headersOf` reads them from a listing. */
40
+ export declare const headersOfRaw: (raw: Uint8Array) => MailHeaders;
39
41
  /**
40
42
  * List-Id, the bulk-mail fields, Reply-To and Gmail's SPF, DKIM and DMARC verdicts of a whole email, kept on mail
41
43
  * stored in full so a rule or a sender score can be worked out from the store later. Names are lower case.
@@ -66,15 +66,19 @@ export const headersOf = (text) => {
66
66
  ...(/^\s*multipart\/mixed\b/i.test(field(unfolded, "Content-Type") ?? "") ? { attachments: true } : {}),
67
67
  };
68
68
  };
69
+ const headTextOf = (raw) => {
70
+ const bytes = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
71
+ const end = [bytes.indexOf("\r\n\r\n"), bytes.indexOf("\n\n")].filter((at) => at !== -1);
72
+ return bytes.subarray(0, end.length ? Math.min(...end) : Math.min(bytes.length, 256 * 1024)).toString("utf8");
73
+ };
74
+ /** The header fields of a whole email, as `headersOf` reads them from a listing. */
75
+ export const headersOfRaw = (raw) => headersOf(headTextOf(raw));
69
76
  /**
70
77
  * List-Id, the bulk-mail fields, Reply-To and Gmail's SPF, DKIM and DMARC verdicts of a whole email, kept on mail
71
78
  * stored in full so a rule or a sender score can be worked out from the store later. Names are lower case.
72
79
  */
73
80
  export const mailHeadersOf = (raw) => {
74
- const bytes = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
75
- const end = [bytes.indexOf("\r\n\r\n"), bytes.indexOf("\n\n")].filter((at) => at !== -1);
76
- const head = bytes.subarray(0, end.length ? Math.min(...end) : Math.min(bytes.length, 256 * 1024));
77
- const unfolded = unfold(head.toString("utf8"));
81
+ const unfolded = unfold(headTextOf(raw));
78
82
  const listId = listIdOf(field(unfolded, "List-Id"));
79
83
  const precedence = field(unfolded, "Precedence")?.trim().toLowerCase();
80
84
  const [replyTo] = addressesOf(field(unfolded, "Reply-To"));
@@ -12,6 +12,6 @@ export { mapProposedTags, ruleTagger, settleRuleTags, type TagOutcome } from "./
12
12
  export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, type MailAction, type MailFacts, type MailRule, type MailRuleMatch, matches, sameMatch, tagsOf, } from "./rules.js";
13
13
  export { type DomainAge, FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, type Scored, type Signal, scoreGroup, WEIGHTS, } from "./score.js";
14
14
  export { DEMOTE_AT, type DomainAges, demotedSenders, type LookupResult, SCORE_VERSION, scoreSenders, } from "./sender-scores.js";
15
- export type { Listed, MailSource, Searched } from "./source.js";
15
+ export type { Listed, MailSource, Quick, Searched } from "./source.js";
16
16
  export { listedOf, type StoredMail } from "./stored.js";
17
17
  export { MAX_ATTACHMENT_CHARS, type MailAccount, type MailIndexed, type MailSyncOptions, type MailSyncResult, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.js";
@@ -0,0 +1,28 @@
1
+ import type { FolderEmail, MessageStore } from "../store/index.js";
2
+ import type { Quick, Searched } from "./source.js";
3
+ /** The flags a listing shows that differ from the stored ones, by Message-ID, for mail whose UID is stored. */
4
+ export declare const flagsToRefresh: (rows: ReadonlyMap<string, FolderEmail>, listing: readonly Quick[]) => Map<string, {
5
+ read?: boolean;
6
+ flagged?: boolean;
7
+ }>;
8
+ export interface LocationProof {
9
+ /** Each folder listed by UID: what the server searched, and the stored mail with a UID there, before this run saved. */
10
+ folders: ReadonlyMap<string, {
11
+ searched: Searched;
12
+ rows: readonly FolderEmail[];
13
+ }>;
14
+ /** Mail received before this is outside the window IMAP's SINCE can vouch for. */
15
+ windowFrom: number;
16
+ /** Messages this run saw in any folder: one moved to another folder is not gone. */
17
+ present: ReadonlySet<string>;
18
+ now: number;
19
+ }
20
+ /**
21
+ * A folder listed by UID proves a deletion from the UIDs it stores: mail in the window whose UID the server no
22
+ * longer lists is gone from that folder, and gone altogether when no other location of it is left. Nothing is
23
+ * changed when too many would go, as for a header listing.
24
+ */
25
+ export declare const proveByLocation: (store: MessageStore, accountId: number, { folders, windowFrom, present, now }: LocationProof, refuse: (gone: number, candidates: number) => string | undefined) => Promise<{
26
+ deleted: number;
27
+ skipped?: string;
28
+ }>;
@@ -0,0 +1,54 @@
1
+ /** The flags a listing shows that differ from the stored ones, by Message-ID, for mail whose UID is stored. */
2
+ export const flagsToRefresh = (rows, listing) => {
3
+ const changes = new Map();
4
+ for (const { uid, seen, flagged } of listing) {
5
+ const row = rows.get(uid);
6
+ if (row === undefined)
7
+ continue;
8
+ const changed = {
9
+ ...(seen === undefined || seen === row.read ? {} : { read: seen }),
10
+ ...(flagged === undefined || flagged === row.flagged ? {} : { flagged }),
11
+ };
12
+ if (Object.keys(changed).length)
13
+ changes.set(row.externalId, changed);
14
+ }
15
+ return changes;
16
+ };
17
+ /**
18
+ * A folder listed by UID proves a deletion from the UIDs it stores: mail in the window whose UID the server no
19
+ * longer lists is gone from that folder, and gone altogether when no other location of it is left. Nothing is
20
+ * changed when too many would go, as for a header listing.
21
+ */
22
+ export const proveByLocation = async (store, accountId, { folders, windowFrom, present, now }, refuse) => {
23
+ const candidates = new Set();
24
+ const lost = new Map();
25
+ const lostUids = new Map();
26
+ for (const [folder, { searched, rows }] of folders) {
27
+ const inWindow = new Set(searched.uids.map(Number));
28
+ for (const row of rows) {
29
+ if (row.receivedAt === null || row.receivedAt < windowFrom)
30
+ continue;
31
+ candidates.add(row.externalId);
32
+ if (inWindow.has(row.uid))
33
+ continue;
34
+ const entry = lost.get(row.externalId) ?? { row, locations: 0, folders: new Set() };
35
+ entry.locations++;
36
+ entry.folders.add(folder);
37
+ lost.set(row.externalId, entry);
38
+ const uids = lostUids.get(folder) ?? [];
39
+ uids.push(row.uid);
40
+ lostUids.set(folder, uids);
41
+ }
42
+ }
43
+ const gone = [...lost]
44
+ .filter(([id, entry]) => !present.has(id) && entry.locations === entry.row.locations && entry.folders.size >= entry.row.folders)
45
+ .map(([id]) => id);
46
+ const refused = refuse(gone.length, candidates.size);
47
+ if (refused)
48
+ return { deleted: 0, skipped: refused };
49
+ // Marked first: an interruption leaves locations that prove it again, never an email with no way to be found.
50
+ const deleted = gone.length ? await store.mail.markDeleted(accountId, gone, now) : 0;
51
+ for (const [folder, uids] of lostUids)
52
+ await store.mail.forgetLocations(accountId, folder, folders.get(folder).searched.validity, uids);
53
+ return { deleted };
54
+ };
@@ -0,0 +1,41 @@
1
+ import type { BudgetStop, RunBudget } from "../background/budget.js";
2
+ import { type Ranges } from "./resume.js";
3
+ import type { Listed, MailSource, Quick, Searched } from "./source.js";
4
+ export declare const FETCH_SLICE = 50;
5
+ export declare const keyOf: (folder: string, uid: string) => string;
6
+ /** What a budgeted run carries from round to round that a quick folder also reads and writes. */
7
+ export interface QuickPlan {
8
+ budget: RunBudget;
9
+ /** UIDs a round already asked for, `folder\0uid`. */
10
+ tried: Set<string>;
11
+ /** Messages the source could not give; asked for once more when the pass is over. */
12
+ failed: Set<string>;
13
+ unlisted: Set<string>;
14
+ /** Only these are asked for: the retry pass. */
15
+ only?: Set<string>;
16
+ /** Each quick folder's listing of what was not done, made once; a round takes the next newest of it. */
17
+ quick: Map<string, Quick[]>;
18
+ stopped: BudgetStop | null;
19
+ remaining: number;
20
+ asked: number;
21
+ }
22
+ export interface QuickRead {
23
+ /** Every message of the window the server listed, done or not: its flags refresh the stored mail. */
24
+ listing: Quick[];
25
+ /** Messages read and identified, with their raw bytes so the save does not read them again. */
26
+ read: {
27
+ item: Listed;
28
+ raw: Uint8Array;
29
+ }[];
30
+ unreadable: {
31
+ message: string;
32
+ reason: string;
33
+ }[];
34
+ /** Mail of the folder is left for a later round or run. */
35
+ capped: boolean;
36
+ }
37
+ /**
38
+ * One round of a folder whose server gives no cheap message id: UID, flags, date and size of what is not done, then
39
+ * the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
40
+ */
41
+ export declare const readQuick: (source: MailSource & Required<Pick<MailSource, "quick" | "identify">>, since: Date, folder: string, searched: Searched, done: Ranges, max: number, plan: QuickPlan | undefined, located: ReadonlySet<string>) => Promise<QuickRead>;
@@ -0,0 +1,76 @@
1
+ import { headersOfRaw } from "./headers.js";
2
+ import { within } from "./resume.js";
3
+ export const FETCH_SLICE = 50;
4
+ export const keyOf = (folder, uid) => `${folder}\0${uid}`;
5
+ /**
6
+ * One round of a folder whose server gives no cheap message id: UID, flags, date and size of what is not done, then
7
+ * the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
8
+ */
9
+ export const readQuick = async (source, since, folder, searched, done, max, plan, located) => {
10
+ const answer = { listing: [], read: [], unreadable: [], capped: false };
11
+ let items = plan?.quick.get(folder);
12
+ if (items === undefined) {
13
+ // Mail whose location is known is listed for its flags only; it is never read again.
14
+ const asked = searched.uids.filter((uid) => plan?.only === undefined || plan.only.has(keyOf(folder, uid)));
15
+ const wanted = asked.filter((uid) => !within(done, Number(uid)) && !located.has(uid));
16
+ if (asked.length > 0 && plan) {
17
+ const stop = plan.budget.take();
18
+ if (stop) {
19
+ plan.stopped = stop;
20
+ plan.remaining += wanted.length;
21
+ return { ...answer, capped: true };
22
+ }
23
+ }
24
+ items = asked.length === 0 ? [] : await source.quick(since, folder, { searched: { ...searched, uids: asked } });
25
+ if (plan !== undefined) {
26
+ plan.quick.set(folder, items);
27
+ const got = new Set(items.map(({ uid }) => uid));
28
+ for (const uid of wanted)
29
+ if (!got.has(uid)) {
30
+ plan.failed.add(keyOf(folder, uid));
31
+ plan.unlisted.add(keyOf(folder, uid));
32
+ }
33
+ }
34
+ }
35
+ answer.listing = items;
36
+ const open = items
37
+ .filter(({ uid }) => !within(done, Number(uid)) && !located.has(uid) && !plan?.tried.has(keyOf(folder, uid)))
38
+ .sort((a, b) => Number(b.uid) - Number(a.uid));
39
+ const slice = open.slice(0, plan === undefined ? max : (source.fetchBatch ?? FETCH_SLICE));
40
+ if (plan !== undefined)
41
+ plan.remaining += open.length - slice.length;
42
+ answer.capped = open.length > slice.length;
43
+ if (slice.length === 0)
44
+ return answer;
45
+ if (plan !== undefined) {
46
+ const stop = plan.budget.take();
47
+ if (stop) {
48
+ plan.stopped = stop;
49
+ plan.remaining += slice.length;
50
+ return { ...answer, capped: true };
51
+ }
52
+ for (const { uid } of slice)
53
+ plan.tried.add(keyOf(folder, uid));
54
+ plan.asked += slice.length;
55
+ }
56
+ const raws = await source.fetchRaw(slice);
57
+ // Every email failing is the connection or the login, not the mail: stop instead of trying the rest.
58
+ if (slice.length > 1 && raws.every((raw) => raw instanceof Error))
59
+ throw raws[0];
60
+ for (const [index, item] of slice.entries()) {
61
+ const raw = raws[index];
62
+ const key = keyOf(folder, item.uid);
63
+ if (raw === undefined || raw instanceof Error) {
64
+ if (plan === undefined)
65
+ answer.unreadable.push({ message: `uid:${folder}:${item.uid}`, reason: raw?.message ?? "not read" });
66
+ else {
67
+ plan.failed.add(key);
68
+ plan.unlisted.add(key);
69
+ }
70
+ continue;
71
+ }
72
+ plan?.unlisted.delete(key);
73
+ answer.read.push({ item: { ...item, ...source.identify(raw, item), headers: headersOfRaw(raw) }, raw });
74
+ }
75
+ return answer;
76
+ };
@@ -30,6 +30,12 @@ export interface ResumePoint {
30
30
  total: number;
31
31
  at: string;
32
32
  };
33
+ /**
34
+ * From this date on, every stored email of the folder has its UID in `email_locations`: a listing that gives UIDs
35
+ * and flags is enough to refresh flags and prove deletions. Absent after an engine that did not know locations
36
+ * wrote the point, and then one header listing records them.
37
+ */
38
+ located?: string;
33
39
  /** What the latest run listed, whatever its window. */
34
40
  last?: {
35
41
  since: string;
@@ -0,0 +1,23 @@
1
+ import type { LoadEngine } from "../readers/types.js";
2
+ import type { MessageStore } from "../store/index.js";
3
+ import type { MailSource } from "./source.js";
4
+ import type { MailAccount, MailSyncOptions, MailSyncResult } from "./sync.js";
5
+ /** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
6
+ export declare const RETRYABLE_ATTACHMENTS: string[];
7
+ /**
8
+ * Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
9
+ * attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
10
+ * resume point moves; the email keeps its stored mailboxes, flags and rule.
11
+ */
12
+ export declare const retryUnread: ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }: {
13
+ store: MessageStore;
14
+ source: MailSource;
15
+ account: MailAccount;
16
+ since: Date;
17
+ max: number;
18
+ maxBodyChars: number;
19
+ maxAttachmentChars: number;
20
+ now: () => number;
21
+ loadEngine: LoadEngine;
22
+ index: MailSyncOptions["index"];
23
+ }) => Promise<MailSyncResult>;
@@ -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
+ };
@@ -16,10 +16,17 @@ export interface Listed {
16
16
  /** Read only when asked for; absent when not asked or unreadable, so no rule applies to it. */
17
17
  headers?: MailHeaders;
18
18
  }
19
+ /** A message as `quick` lists it: everything but the ids, which a source without a cheap one reads from the raw bytes. */
20
+ export type Quick = Omit<Listed, "messageId" | "threadId">;
19
21
  export interface Searched {
20
22
  /** Absent when the server does not say; then no UID is trusted across runs. */
21
23
  validity?: string;
22
24
  uids: string[];
25
+ /**
26
+ * The server gives no message id without reading the message, so `list` is slow. The sync then lists with `quick`
27
+ * and takes the id from the raw bytes with `identify`.
28
+ */
29
+ quick?: boolean;
23
30
  }
24
31
  /** Everything the import asks of a mail client, so a transport can be replaced without touching the sync. */
25
32
  export interface MailSource {
@@ -43,6 +50,18 @@ export interface MailSource {
43
50
  * those. Absent for a file, which has no UIDs.
44
51
  */
45
52
  search?(since: Date, folder: string): Promise<Searched>;
53
+ /**
54
+ * For a folder whose `search` said `quick`: UID, flags, date and size of the given UIDs, with no envelope and no
55
+ * header. A UID the server does not return is left out.
56
+ */
57
+ quick?(since: Date, folder: string, options: {
58
+ searched: Searched;
59
+ }): Promise<Quick[]>;
60
+ /** The ids of a message read by `fetchRaw`, worked out as `list` works them out; needed wherever `quick` is. */
61
+ identify?(raw: Uint8Array, item: Quick): {
62
+ messageId: string;
63
+ threadId: string;
64
+ };
46
65
  /** A file's size and modification time, so an unchanged file read to the end is not read again. */
47
66
  stamp?(): Promise<FileStamp>;
48
67
  /**
@@ -70,5 +89,5 @@ export interface MailSource {
70
89
  /** Puts labels back on messages in `folder`, by Gmail id. */
71
90
  label?(messageIds: readonly string[], folder: string, labels: readonly string[]): Promise<void>;
72
91
  /** Each item's raw RFC 5322 bytes, in order; one that could not be read is an Error, not a failed call. */
73
- fetchRaw(items: Listed[]): Promise<(Uint8Array | Error)[]>;
92
+ fetchRaw(items: Quick[]): Promise<(Uint8Array | Error)[]>;
74
93
  }
@@ -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[];