@wirecat/cli-core 0.487.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,17 @@ 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
+
10
21
  ## 0.487.0 — 30.10.2026
11
22
 
12
23
  ### Added
@@ -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
+ };
@@ -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
+ };
@@ -20,6 +20,8 @@ export interface QuickPlan {
20
20
  asked: number;
21
21
  }
22
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[];
23
25
  /** Messages read and identified, with their raw bytes so the save does not read them again. */
24
26
  read: {
25
27
  item: Listed;
@@ -36,4 +38,4 @@ export interface QuickRead {
36
38
  * One round of a folder whose server gives no cheap message id: UID, flags, date and size of what is not done, then
37
39
  * the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
38
40
  */
39
- export declare const readQuick: (source: MailSource & Required<Pick<MailSource, "quick" | "identify">>, since: Date, folder: string, searched: Searched, done: Ranges, max: number, plan: QuickPlan | undefined) => Promise<QuickRead>;
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>;
@@ -6,16 +6,18 @@ export const keyOf = (folder, uid) => `${folder}\0${uid}`;
6
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
7
  * the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
8
8
  */
9
- export const readQuick = async (source, since, folder, searched, done, max, plan) => {
10
- const answer = { read: [], unreadable: [], capped: false };
9
+ export const readQuick = async (source, since, folder, searched, done, max, plan, located) => {
10
+ const answer = { listing: [], read: [], unreadable: [], capped: false };
11
11
  let items = plan?.quick.get(folder);
12
12
  if (items === undefined) {
13
- const asked = searched.uids.filter((uid) => !within(done, Number(uid)) && (plan?.only === undefined || plan.only.has(keyOf(folder, uid))));
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));
14
16
  if (asked.length > 0 && plan) {
15
17
  const stop = plan.budget.take();
16
18
  if (stop) {
17
19
  plan.stopped = stop;
18
- plan.remaining += asked.length;
20
+ plan.remaining += wanted.length;
19
21
  return { ...answer, capped: true };
20
22
  }
21
23
  }
@@ -23,15 +25,16 @@ export const readQuick = async (source, since, folder, searched, done, max, plan
23
25
  if (plan !== undefined) {
24
26
  plan.quick.set(folder, items);
25
27
  const got = new Set(items.map(({ uid }) => uid));
26
- for (const uid of asked)
28
+ for (const uid of wanted)
27
29
  if (!got.has(uid)) {
28
30
  plan.failed.add(keyOf(folder, uid));
29
31
  plan.unlisted.add(keyOf(folder, uid));
30
32
  }
31
33
  }
32
34
  }
35
+ answer.listing = items;
33
36
  const open = items
34
- .filter(({ uid }) => !within(done, Number(uid)) && !plan?.tried.has(keyOf(folder, uid)))
37
+ .filter(({ uid }) => !within(done, Number(uid)) && !located.has(uid) && !plan?.tried.has(keyOf(folder, uid)))
35
38
  .sort((a, b) => Number(b.uid) - Number(a.uid));
36
39
  const slice = open.slice(0, plan === undefined ? max : (source.fetchBatch ?? FETCH_SLICE));
37
40
  if (plan !== undefined)
@@ -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
+ };
@@ -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)
@@ -29,7 +29,7 @@ export type { BotUpdate, BotUpdateStore } from "./sqlite/bot-updates.js";
29
29
  export type { CatalogActor, CatalogEntry, CatalogInput, CatalogQuery, CatalogStore } from "./sqlite/catalog.js";
30
30
  export { CATALOG_QUERY } from "./sqlite/catalog.js";
31
31
  export type { ChatMetadata } from "./sqlite/chats.js";
32
- export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
32
+ export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailLocationInput, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, FolderEmail, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
33
33
  export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
34
34
  export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
35
35
  export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
@@ -92,6 +92,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
92
92
  orm.delete(messages).where(eq(messages.accountId, accountKey)).run();
93
93
  orm.delete(chats).where(eq(chats.accountId, accountKey)).run();
94
94
  orm.delete(syncCursors).where(eq(syncCursors.accountId, accountKey)).run();
95
+ orm.run(sql `DELETE FROM email_locations WHERE mailbox_id IN (SELECT id FROM mailboxes WHERE account_id = ${accountKey})`);
95
96
  // Deleting an email clears its recipients, mailboxes, words, attachments and tags (trigger email_bd).
96
97
  orm.run(sql `DELETE FROM emails WHERE account_id = ${accountKey}`);
97
98
  orm.run(sql `DELETE FROM taggings WHERE taggable_type = 'email_thread' AND taggable_id IN (SELECT id FROM email_threads WHERE account_id = ${accountKey})`);
@@ -0,0 +1,24 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ /** Where an IMAP server holds an email: `folder` is the mailbox's external id. */
3
+ export interface EmailLocationInput {
4
+ folder: string;
5
+ uidvalidity: string;
6
+ uid: number;
7
+ }
8
+ /** One email of a folder as a sync sees it there. */
9
+ export interface FolderEmail {
10
+ uid: number;
11
+ externalId: string;
12
+ receivedAt: number | null;
13
+ read: boolean | null;
14
+ flagged: boolean | null;
15
+ /** Its locations in every folder, and the folders it is a member of: an email is gone only when all are lost. */
16
+ locations: number;
17
+ folders: number;
18
+ }
19
+ export declare const locate: (database: CacheDatabase, accountId: number, emailId: number, where: EmailLocationInput) => {
20
+ changes: number;
21
+ };
22
+ export declare const folderEmails: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string) => FolderEmail[];
23
+ export declare const setLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>) => number;
24
+ export declare const forgetLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, uids: number[]) => void;
@@ -0,0 +1,56 @@
1
+ export const locate = (database, accountId, emailId, where) => database
2
+ .prepare(`INSERT OR REPLACE INTO email_locations (mailbox_id, uidvalidity, uid, email_id)
3
+ SELECT id, ?, ?, ? FROM mailboxes WHERE account_id = ? AND external_id = ?`)
4
+ .run(where.uidvalidity, where.uid, emailId, accountId, where.folder);
5
+ export const folderEmails = (database, accountId, folder, uidvalidity) => database
6
+ .prepare(`SELECT l.uid, e.external_id, coalesce(e.received_at, e.sent_at) AS at, e.read, e.flagged,
7
+ (SELECT count(*) FROM email_locations x WHERE x.email_id = e.id) AS locations,
8
+ (SELECT count(*) FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
9
+ WHERE m.email_id = e.id AND b.kind IS NULL) AS folders
10
+ FROM email_locations l JOIN emails e ON e.id = l.email_id
11
+ WHERE l.mailbox_id = (SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?)
12
+ AND l.uidvalidity = ? AND e.deleted_at IS NULL
13
+ ORDER BY l.uid`)
14
+ .all(accountId, folder, uidvalidity)
15
+ .map((row) => ({
16
+ uid: Number(row.uid),
17
+ externalId: String(row.external_id),
18
+ receivedAt: row.at === null ? null : Number(row.at),
19
+ read: row.read === null ? null : Boolean(row.read),
20
+ flagged: row.flagged === null ? null : Boolean(row.flagged),
21
+ locations: Number(row.locations),
22
+ folders: Number(row.folders),
23
+ }));
24
+ export const setLocations = (database, accountId, folder, uidvalidity, found) => {
25
+ database
26
+ .prepare(`DELETE FROM email_locations WHERE uidvalidity <> ?
27
+ AND mailbox_id = (SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?)`)
28
+ .run(uidvalidity, accountId, folder);
29
+ const lookup = database.prepare("SELECT id FROM emails WHERE account_id = ? AND external_id = ?");
30
+ let count = 0;
31
+ for (const [externalId, uids] of found) {
32
+ const row = lookup.get(accountId, externalId);
33
+ if (!row)
34
+ continue;
35
+ for (const uid of uids)
36
+ locate(database, accountId, Number(row.id), { folder, uidvalidity, uid });
37
+ count++;
38
+ }
39
+ return count;
40
+ };
41
+ export const forgetLocations = (database, accountId, folder, uidvalidity, uids) => {
42
+ const box = database
43
+ .prepare("SELECT id FROM mailboxes WHERE account_id = ? AND external_id = ?")
44
+ .get(accountId, folder);
45
+ if (!box)
46
+ return;
47
+ const emails = database
48
+ .prepare(`DELETE FROM email_locations WHERE mailbox_id = ? AND uidvalidity = ?
49
+ AND uid IN (SELECT value FROM json_each(?)) RETURNING email_id`)
50
+ .all(Number(box.id), uidvalidity, JSON.stringify(uids))
51
+ .map((row) => Number(row.email_id));
52
+ const leave = database.prepare(`DELETE FROM email_mailboxes WHERE email_id = ? AND mailbox_id = ?
53
+ AND NOT EXISTS (SELECT 1 FROM email_locations WHERE email_id = ? AND mailbox_id = ?)`);
54
+ for (const emailId of new Set(emails))
55
+ leave.run(emailId, Number(box.id), emailId, Number(box.id));
56
+ };
@@ -6,3 +6,5 @@ export declare const setEmailFlags: (database: CacheDatabase, accountId: number,
6
6
  read?: boolean;
7
7
  flagged?: boolean;
8
8
  }>, now: number) => number;
9
+ export declare const markEmailsDeleted: (database: CacheDatabase, accountId: number, externalIds: string[], now: number) => number;
10
+ export declare const markEmailsRestored: (database: CacheDatabase, accountId: number, externalIds: string[], now: number) => number;
@@ -1,4 +1,6 @@
1
+ import { dropAttachmentChunks } from "./email-index.js";
1
2
  import { flag } from "./events.js";
3
+ import { dropUnusedVectors } from "./vector-index.js";
2
4
  export const recountEmailThread = (database, threadId, now) => database
3
5
  .prepare(`UPDATE email_threads SET
4
6
  emails_count = (SELECT count(*) FROM emails WHERE email_thread_id = ? AND deleted_at IS NULL),
@@ -23,3 +25,29 @@ export const setEmailFlags = (database, accountId, flags, now) => {
23
25
  }
24
26
  return changed;
25
27
  };
28
+ export const markEmailsDeleted = (database, accountId, externalIds, now) => {
29
+ const mark = database.prepare(`UPDATE emails SET deleted_at = ?, body_text = NULL, body_html = NULL, snippet = NULL, updated_at = ?
30
+ WHERE account_id = ? AND external_id = ? AND deleted_at IS NULL
31
+ RETURNING id, email_thread_id`);
32
+ const forget = database.prepare("UPDATE attachments SET text = NULL, normalized_text = NULL WHERE attachable_type = 'email' AND attachable_id = ?");
33
+ const unlocate = database.prepare("DELETE FROM email_locations WHERE email_id = ?");
34
+ const threads = externalIds.flatMap((id) => mark.all(now, now, accountId, id).map((r) => {
35
+ const pieces = dropAttachmentChunks(database, Number(r.id));
36
+ forget.run(Number(r.id));
37
+ unlocate.run(Number(r.id));
38
+ dropUnusedVectors(database, pieces);
39
+ return Number(r.email_thread_id);
40
+ }));
41
+ for (const id of new Set(threads))
42
+ recountEmailThread(database, id, now);
43
+ return threads.length;
44
+ };
45
+ export const markEmailsRestored = (database, accountId, externalIds, now) => {
46
+ const unmark = database.prepare(`UPDATE emails SET deleted_at = NULL, updated_at = ?
47
+ WHERE account_id = ? AND external_id = ? AND deleted_at IS NOT NULL
48
+ RETURNING email_thread_id`);
49
+ const threads = externalIds.flatMap((id) => unmark.all(now, accountId, id).map((r) => Number(r.email_thread_id)));
50
+ for (const id of new Set(threads))
51
+ recountEmailThread(database, id, now);
52
+ return threads.length;
53
+ };
@@ -3,9 +3,11 @@ import type { Attachment, Metadata } from "../../meetings/index.js";
3
3
  import type { Stemmer, Stemmers } from "../../search/stem.js";
4
4
  import type { CacheDatabase } from "../driver.js";
5
5
  import type { ConversationSummary, StoredHit } from "../types.js";
6
+ import { type EmailLocationInput, type FolderEmail } from "./email-locations.js";
6
7
  import { type MailRulesStore } from "./mail-rules.js";
7
8
  import { type AttachmentInput } from "./meetings.js";
8
9
  import type { StoreContext } from "./open.js";
10
+ export type { EmailLocationInput, FolderEmail };
9
11
  export interface EmailAddress {
10
12
  address: string;
11
13
  name: string | null;
@@ -43,6 +45,8 @@ export interface EmailInput {
43
45
  mailRuleId?: number | null;
44
46
  /** Added to the mailboxes the email is already in; one scan sees one folder, so none is taken away. */
45
47
  mailboxes?: MailboxInput[];
48
+ /** Added to the locations the email has; a UID already known in that folder now points here. */
49
+ locations?: EmailLocationInput[];
46
50
  /** Replaced when given; the text is word-indexed. */
47
51
  attachments?: AttachmentInput[];
48
52
  }
@@ -228,6 +232,15 @@ export interface MailStore {
228
232
  read?: boolean;
229
233
  flagged?: boolean;
230
234
  }>, now: number): Promise<number>;
235
+ /** The live emails whose location in `folder` has this UIDVALIDITY, oldest UID first. */
236
+ folderEmails(accountId: number, folder: string, uidvalidity: string): Promise<FolderEmail[]>;
237
+ /**
238
+ * Records where emails the store holds are in `folder`, by Message-ID; one it does not hold is left out. Locations
239
+ * of the folder under another UIDVALIDITY are dropped: its UIDs mean nothing now.
240
+ */
241
+ setLocations(accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>): Promise<number>;
242
+ /** Forgets that these UIDs of `folder` hold anything; an email left with no location there leaves the folder too. */
243
+ forgetLocations(accountId: number, folder: string, uidvalidity: string, uids: number[]): Promise<void>;
231
244
  /** Marks emails gone at the source and drops their text; saving one again brings it back. Answers how many changed. */
232
245
  markDeleted(accountId: number, externalIds: string[], now: number): Promise<number>;
233
246
  /**
@@ -2,7 +2,8 @@ import { formatLocator } from "../../domain/locator.js";
2
2
  import { formatReference } from "../../domain/references.js";
3
3
  import { fold, normalize } from "../normalize.js";
4
4
  import { chunkAttachments, drainEmailIndex, dropAttachmentChunks, MAIL_PIECES, queueThreads, threadPieces, titleOf, } from "./email-index.js";
5
- import { recountEmailThread as recount, setEmailFlags } from "./email-state.js";
5
+ import { folderEmails, forgetLocations, locate, setLocations, } from "./email-locations.js";
6
+ import { markEmailsDeleted, markEmailsRestored, recountEmailThread as recount, setEmailFlags } from "./email-state.js";
6
7
  import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
7
8
  import { folded } from "./folded.js";
8
9
  import { ensurePerson } from "./identities.js";
@@ -228,6 +229,8 @@ const saveEmail = (database, orm, accountId, threadId, mail, now) => {
228
229
  }
229
230
  for (const box of mail.mailboxes ?? [])
230
231
  join(database, emailId, mailboxPk(database, accountId, box, now), now);
232
+ for (const where of mail.locations ?? [])
233
+ locate(database, accountId, emailId, where);
231
234
  if (mail.attachments !== undefined) {
232
235
  const before = dropAttachmentChunks(database, emailId);
233
236
  const positions = mail.attachments.map(({ position }) => position);
@@ -545,34 +548,11 @@ export const mailStoreOver = ({ database, orm }, stemmerFor = stemmerCache()) =>
545
548
  return setEmailFlags(database, accountId, flags, now);
546
549
  });
547
550
  },
548
- async markDeleted(accountId, externalIds, now) {
549
- return write(database, () => {
550
- const mark = database.prepare(`UPDATE emails SET deleted_at = ?, body_text = NULL, body_html = NULL, snippet = NULL, updated_at = ?
551
- WHERE account_id = ? AND external_id = ? AND deleted_at IS NULL
552
- RETURNING id, email_thread_id`);
553
- const forget = database.prepare("UPDATE attachments SET text = NULL, normalized_text = NULL WHERE attachable_type = 'email' AND attachable_id = ?");
554
- const threads = externalIds.flatMap((id) => mark.all(now, now, accountId, id).map((r) => {
555
- const pieces = dropAttachmentChunks(database, Number(r.id));
556
- forget.run(Number(r.id));
557
- dropUnusedVectors(database, pieces);
558
- return Number(r.email_thread_id);
559
- }));
560
- for (const id of new Set(threads))
561
- recount(database, id, now);
562
- return threads.length;
563
- });
564
- },
565
- async markRestored(accountId, externalIds, now) {
566
- return write(database, () => {
567
- const unmark = database.prepare(`UPDATE emails SET deleted_at = NULL, updated_at = ?
568
- WHERE account_id = ? AND external_id = ? AND deleted_at IS NOT NULL
569
- RETURNING email_thread_id`);
570
- const threads = externalIds.flatMap((id) => unmark.all(now, accountId, id).map((r) => Number(r.email_thread_id)));
571
- for (const id of new Set(threads))
572
- recount(database, id, now);
573
- return threads.length;
574
- });
575
- },
551
+ folderEmails: async (accountId, folder, uidvalidity) => folderEmails(database, accountId, folder, uidvalidity),
552
+ setLocations: async (accountId, folder, uidvalidity, found) => write(database, () => setLocations(database, accountId, folder, uidvalidity, found)),
553
+ forgetLocations: async (accountId, folder, uidvalidity, uids) => write(database, () => forgetLocations(database, accountId, folder, uidvalidity, uids)),
554
+ markDeleted: async (accountId, externalIds, now) => write(database, () => markEmailsDeleted(database, accountId, externalIds, now)),
555
+ markRestored: async (accountId, externalIds, now) => write(database, () => markEmailsRestored(database, accountId, externalIds, now)),
576
556
  async chunksToEmbed(model, { after, limit }) {
577
557
  drainEmailIndex(database, stemmerFor);
578
558
  const file = database.prepare("SELECT text FROM attachments WHERE id = ?");
@@ -52,6 +52,7 @@ export const MANIFEST = [
52
52
  { name: "20261029181000_version-37-contact-mention-triggers", version: 37, minCompatible: 34 },
53
53
  { name: "20261029224106_version-38-folded-names", version: 38, minCompatible: 34 },
54
54
  { name: "20261029224107_version-38-folded-triggers", version: 38, minCompatible: 34 },
55
+ { name: "20261030114008_version-39-email-locations", version: 39, minCompatible: 34 },
55
56
  ];
56
57
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
57
58
  const migrations = [];
@@ -830,5 +830,12 @@ export const GENERATED = [
830
830
  "CREATE TRIGGER email_threads_subject_folded_au AFTER UPDATE OF subject ON email_threads\n WHEN new.subject IS NOT old.subject AND new.subject_folded IS old.subject_folded BEGIN\n UPDATE email_threads SET subject_folded = NULL WHERE id = new.id;\nEND;",
831
831
  "CREATE TRIGGER mailboxes_name_folded_au AFTER UPDATE OF name ON mailboxes\n WHEN new.name IS NOT old.name AND new.name_folded IS old.name_folded BEGIN\n UPDATE mailboxes SET name_folded = NULL WHERE id = new.id;\nEND;"
832
832
  ]
833
+ },
834
+ {
835
+ "name": "20261030114008_version-39-email-locations",
836
+ "statements": [
837
+ "CREATE TABLE `email_locations` (\n\t`mailbox_id` integer NOT NULL,\n\t`uidvalidity` text NOT NULL,\n\t`uid` integer NOT NULL,\n\t`email_id` integer NOT NULL,\n\tCONSTRAINT `email_locations_pk` PRIMARY KEY(`mailbox_id`, `uidvalidity`, `uid`),\n\tCONSTRAINT `fk_email_locations_mailbox_id_mailboxes_id_fk` FOREIGN KEY (`mailbox_id`) REFERENCES `mailboxes`(`id`),\n\tCONSTRAINT `fk_email_locations_email_id_emails_id_fk` FOREIGN KEY (`email_id`) REFERENCES `emails`(`id`)\n);",
838
+ "CREATE INDEX `email_locations_by_email_id` ON `email_locations` (`email_id`);"
839
+ ]
833
840
  }
834
841
  ];
@@ -6172,6 +6172,78 @@ export declare const emailMailboxes: import("drizzle-orm/sqlite-core").SQLiteTab
6172
6172
  dialect: 'sqlite';
6173
6173
  isAlias: false;
6174
6174
  }>;
6175
+ export declare const emailLocations: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
6176
+ name: "email_locations";
6177
+ schema: undefined;
6178
+ columns: {
6179
+ mailboxId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6180
+ name: string;
6181
+ tableName: "email_locations";
6182
+ dataType: "number int53";
6183
+ data: number;
6184
+ driverParam: number;
6185
+ notNull: true;
6186
+ hasDefault: false;
6187
+ isPrimaryKey: false;
6188
+ isAutoincrement: false;
6189
+ hasRuntimeDefault: false;
6190
+ enumValues: undefined;
6191
+ baseColumn: never;
6192
+ identity: undefined;
6193
+ generated: undefined;
6194
+ }, {}>;
6195
+ uidvalidity: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6196
+ name: string;
6197
+ tableName: "email_locations";
6198
+ dataType: "string";
6199
+ data: string;
6200
+ driverParam: string;
6201
+ notNull: true;
6202
+ hasDefault: false;
6203
+ isPrimaryKey: false;
6204
+ isAutoincrement: false;
6205
+ hasRuntimeDefault: false;
6206
+ enumValues: [string, ...string[]];
6207
+ baseColumn: never;
6208
+ identity: undefined;
6209
+ generated: undefined;
6210
+ }, {}>;
6211
+ uid: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6212
+ name: string;
6213
+ tableName: "email_locations";
6214
+ dataType: "number int53";
6215
+ data: number;
6216
+ driverParam: number;
6217
+ notNull: true;
6218
+ hasDefault: false;
6219
+ isPrimaryKey: false;
6220
+ isAutoincrement: false;
6221
+ hasRuntimeDefault: false;
6222
+ enumValues: undefined;
6223
+ baseColumn: never;
6224
+ identity: undefined;
6225
+ generated: undefined;
6226
+ }, {}>;
6227
+ emailId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6228
+ name: string;
6229
+ tableName: "email_locations";
6230
+ dataType: "number int53";
6231
+ data: number;
6232
+ driverParam: number;
6233
+ notNull: true;
6234
+ hasDefault: false;
6235
+ isPrimaryKey: false;
6236
+ isAutoincrement: false;
6237
+ hasRuntimeDefault: false;
6238
+ enumValues: undefined;
6239
+ baseColumn: never;
6240
+ identity: undefined;
6241
+ generated: undefined;
6242
+ }, {}>;
6243
+ };
6244
+ dialect: 'sqlite';
6245
+ isAlias: false;
6246
+ }>;
6175
6247
  export declare const emailIndexPending: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
6176
6248
  name: "email_index_pending";
6177
6249
  schema: undefined;
@@ -695,6 +695,19 @@ export const emailMailboxes = sqliteTable("email_mailboxes", {
695
695
  primaryKey({ columns: [table.emailId, table.mailboxId] }),
696
696
  index("email_mailboxes_by_mailbox_id").on(table.mailboxId),
697
697
  ]);
698
+ export const emailLocations = sqliteTable("email_locations", {
699
+ mailboxId: integer("mailbox_id")
700
+ .notNull()
701
+ .references(() => mailboxes.id),
702
+ uidvalidity: text("uidvalidity").notNull(),
703
+ uid: integer("uid").notNull(),
704
+ emailId: integer("email_id")
705
+ .notNull()
706
+ .references(() => emails.id),
707
+ }, (table) => [
708
+ primaryKey({ columns: [table.mailboxId, table.uidvalidity, table.uid] }),
709
+ index("email_locations_by_email_id").on(table.emailId),
710
+ ]);
698
711
  export const emailIndexPending = sqliteTable("email_index_pending", {
699
712
  id: integer("id").notNull(),
700
713
  indexableType: text("indexable_type").notNull(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.487.0",
3
+ "version": "0.488.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",