@wirecat/cli-core 0.470.0 → 0.472.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cli/app-doctor-command.d.ts +107 -0
  3. package/dist/cli/app-doctor-command.js +101 -0
  4. package/dist/cli/app-mcp-command.d.ts +23 -0
  5. package/dist/cli/app-mcp-command.js +49 -0
  6. package/dist/cli/index.d.ts +4 -2
  7. package/dist/cli/index.js +2 -1
  8. package/dist/cli/messenger/mcp-command.d.ts +29 -3
  9. package/dist/cli/messenger/mcp-command.js +59 -39
  10. package/dist/cli/shared-command-exports.d.ts +348 -0
  11. package/dist/cli/shared-command-exports.js +4 -0
  12. package/dist/errors/reasons.js +1 -0
  13. package/dist/mail/bulk.d.ts +7 -0
  14. package/dist/mail/bulk.js +3 -0
  15. package/dist/mail/facts.d.ts +9 -0
  16. package/dist/mail/facts.js +22 -0
  17. package/dist/mail/folders.d.ts +10 -0
  18. package/dist/mail/folders.js +13 -0
  19. package/dist/mail/gone.d.ts +20 -0
  20. package/dist/mail/gone.js +53 -0
  21. package/dist/mail/headers.d.ts +43 -0
  22. package/dist/mail/headers.js +88 -0
  23. package/dist/mail/index.d.ts +17 -0
  24. package/dist/mail/index.js +16 -0
  25. package/dist/mail/parse.d.ts +24 -0
  26. package/dist/mail/parse.js +79 -0
  27. package/dist/mail/presets.d.ts +30 -0
  28. package/dist/mail/presets.js +95 -0
  29. package/dist/mail/probe.d.ts +49 -0
  30. package/dist/mail/probe.js +81 -0
  31. package/dist/mail/resume.d.ts +61 -0
  32. package/dist/mail/resume.js +61 -0
  33. package/dist/mail/rule-input.d.ts +14 -0
  34. package/dist/mail/rule-input.js +32 -0
  35. package/dist/mail/rule-tags.d.ts +40 -0
  36. package/dist/mail/rule-tags.js +87 -0
  37. package/dist/mail/rules.d.ts +83 -0
  38. package/dist/mail/rules.js +108 -0
  39. package/dist/mail/score.d.ts +67 -0
  40. package/dist/mail/score.js +174 -0
  41. package/dist/mail/sender-scores.d.ts +30 -0
  42. package/dist/mail/sender-scores.js +96 -0
  43. package/dist/mail/source.d.ts +67 -0
  44. package/dist/mail/source.js +1 -0
  45. package/dist/mail/stored.d.ts +7 -0
  46. package/dist/mail/stored.js +36 -0
  47. package/dist/mail/sync.d.ts +91 -0
  48. package/dist/mail/sync.js +572 -0
  49. package/dist/mail/testing/contract.d.ts +20 -0
  50. package/dist/mail/testing/contract.js +106 -0
  51. package/dist/mail/testing/fake.d.ts +61 -0
  52. package/dist/mail/testing/fake.js +136 -0
  53. package/dist/mail/testing/index.d.ts +2 -0
  54. package/dist/mail/testing/index.js +2 -0
  55. package/dist/mcp/agent.js +14 -2
  56. package/dist/mcp/app-server.d.ts +42 -0
  57. package/dist/mcp/app-server.js +162 -0
  58. package/dist/mcp/instructions.d.ts +12 -0
  59. package/dist/mcp/instructions.js +23 -0
  60. package/dist/mcp/server.d.ts +4 -0
  61. package/dist/mcp/server.js +2 -2
  62. package/package.json +12 -1
@@ -0,0 +1,572 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { extractText, importEngine } from "../attachments/extract.js";
3
+ import { BULK, bulkBox } from "./bulk.js";
4
+ import { factsOf } from "./facts.js";
5
+ import { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
6
+ import { dropMissing, tooManyGone } from "./gone.js";
7
+ import { mailHeadersOf } from "./headers.js";
8
+ import { MAX_BODY_CHARS, parseMail } from "./parse.js";
9
+ import { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
10
+ import { ruleTagger, settleRuleTags } from "./rule-tags.js";
11
+ import { decide, ENVELOPE, envelopeBox, envelopeBoxOf, tagsOf } from "./rules.js";
12
+ const DAY = 86_400_000;
13
+ /** IMAP's SINCE counts whole days in the server's zone, so the window's first days are not trusted. */
14
+ const EDGE = 2 * DAY;
15
+ export const MAX_ATTACHMENT_CHARS = 200_000;
16
+ /** One chunk of raw mail is held in memory at a time. */
17
+ const FETCH_CHUNK = 50;
18
+ /** How often a long run saves where it is, so a kill costs at most this much listing again. */
19
+ const SAVE_EVERY_MS = 30_000;
20
+ /** About a minute of listing; the 30-day window of the timer stays below it. */
21
+ const RESUME_FROM = 5_000;
22
+ const addressOf = ({ email, name }) => ({ address: email, name });
23
+ /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
24
+ const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
25
+ const files = [];
26
+ for (const [position, part] of mail.attachments.entries()) {
27
+ const extraction = part.bytes === null
28
+ ? { status: "unavailable" }
29
+ : await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
30
+ log.push({ message: messageId, part: part.id, status: extraction.status });
31
+ const extracted = extraction.status === "extracted" ? extraction : undefined;
32
+ files.push({
33
+ position,
34
+ kind: "file",
35
+ mime: part.mime,
36
+ name: part.name,
37
+ title: null,
38
+ url: null,
39
+ size: part.bytes === null ? null : part.bytes.byteLength,
40
+ width: null,
41
+ height: null,
42
+ duration: null,
43
+ providerRef: { message: messageId, part: part.id },
44
+ localPath: null,
45
+ text: extracted ? extracted.text.slice(0, maxAttachmentChars) : null,
46
+ normalizedText: null,
47
+ extraction: extracted ? "text" : "failed",
48
+ extractor: extracted ? extracted.extractor : null,
49
+ extractionError: extracted ? null : extraction.status,
50
+ contentSha256: null,
51
+ extractedAt: at,
52
+ });
53
+ }
54
+ return files;
55
+ };
56
+ export const syncMail = async (source, store, { account, since, max = 200, maxBodyChars = MAX_BODY_CHARS, maxAttachmentChars = MAX_ATTACHMENT_CHARS, now = Date.now, ages, loadEngine = importEngine, retryAttachments = false, rescan = false, resumeFrom = RESUME_FROM, index, signal, }) => {
57
+ if (retryAttachments)
58
+ return retryUnread({
59
+ store,
60
+ source,
61
+ account,
62
+ since,
63
+ max,
64
+ maxBodyChars,
65
+ maxAttachmentChars,
66
+ now,
67
+ loadEngine,
68
+ index,
69
+ });
70
+ const startedAt = now();
71
+ const key = { provider: "email", account: account.address.toLowerCase() };
72
+ const accountId = await store.accounts.saveAccount(key, { name: account.address });
73
+ const folders = await mailFolders(account, source);
74
+ if (folders.length < 1 || folders.length > 50)
75
+ throw new Error("select 1–50 mail folders");
76
+ const rules = account.rules ?? [];
77
+ // An archive file is a snapshot of the same account: it adds mail, and changes nothing IMAP keeps current.
78
+ const snapshot = account.mode === "mbox";
79
+ const ruleSet = rulesKey(rules);
80
+ const collected = [];
81
+ const searches = new Map();
82
+ /** The earlier point of each folder whose UIDs and rules still hold; its done mail stays done. */
83
+ const earlier = new Map();
84
+ /** Every folder's stored point, valid or not: the widest window outlives what invalidates the rest. */
85
+ const storedPoints = new Map();
86
+ const resumed = new Set();
87
+ let mboxPoint;
88
+ let mboxStamp;
89
+ // A rerun lists only what the last left; --rescan, a rule change or a new UIDVALIDITY lists everything again.
90
+ const fresh = rescan;
91
+ if (snapshot && source.stamp !== undefined) {
92
+ mboxStamp = await source.stamp();
93
+ const point = fresh ? undefined : parsePoint((await store.sync.syncState(key, MBOX_RESUME))?.value);
94
+ if (point?.complete && point.size === mboxStamp.size && point.mtimeMs === mboxStamp.mtimeMs)
95
+ mboxPoint = point;
96
+ }
97
+ for (const folder of folders) {
98
+ if (mboxPoint !== undefined)
99
+ break;
100
+ const searched = source.search === undefined || snapshot ? undefined : await source.search(since, folder);
101
+ let wanted = searched;
102
+ if (searched !== undefined) {
103
+ searches.set(folder, searched);
104
+ const kept = parsePoint((await store.sync.syncState(key, resumeKey(folder)))?.value);
105
+ if (kept !== undefined)
106
+ storedPoints.set(folder, kept);
107
+ const point = fresh ? undefined : kept;
108
+ if (point !== undefined && point.validity === searched.validity && point.rules === ruleSet) {
109
+ earlier.set(folder, point);
110
+ if (searched.uids.length >= resumeFrom &&
111
+ Date.parse(point.full.since) <= since.getTime() &&
112
+ startedAt - Date.parse(point.full.at) < FULL_PASS_MS) {
113
+ resumed.add(folder);
114
+ wanted = { ...searched, uids: searched.uids.filter((uid) => !within(point.done, Number(uid))) };
115
+ }
116
+ }
117
+ }
118
+ const found = await source.list(since, folder, {
119
+ headers: rules.length > 0,
120
+ ...(wanted === undefined ? {} : { searched: wanted }),
121
+ });
122
+ // Spreading a whole mailbox into push() overflows the stack past ~120,000 emails.
123
+ for (const item of found)
124
+ collected.push(item);
125
+ }
126
+ const listed = [...new Map(collected.map((item) => [item.messageId, item])).values()].sort((a, b) => b.receivedAt.localeCompare(a.receivedAt));
127
+ const present = new Set(listed.map(({ messageId }) => messageId));
128
+ const factsFor = factsOf(listed, rules, ages);
129
+ // A rule's tag goes on mail this run stores or records; mail stored earlier gets it from memo mail rules apply.
130
+ const tagger = ruleTagger(store, key);
131
+ const tagStored = async (item) => {
132
+ const tags = tagsOf(rules, factsFor(item));
133
+ if (tags.length)
134
+ await tagger.tag({ type: "message", chatId: item.threadId, messageId: item.messageId }, tags);
135
+ };
136
+ const foundFolders = new Map();
137
+ for (const item of collected) {
138
+ const current = foundFolders.get(item.messageId) ?? new Set();
139
+ current.add(item.folder ?? folders[0]);
140
+ foundFolders.set(item.messageId, current);
141
+ }
142
+ const mailbox = (folder) => ({ externalId: folder, name: folder, kind: null });
143
+ const label = (name) => ({ externalId: `label:${name}`, name, kind: "label" });
144
+ const labelsOf = new Map(collected.flatMap((item) => (item.labels === undefined ? [] : [[item.messageId, item]])));
145
+ const allMailOnly = account.mode !== "imap" && folders.length === 1 && (account.folders === undefined || folders[0] === GMAIL_ALL_MAIL);
146
+ // Deletions are proved against the mailboxes as stored before this scan moves anything.
147
+ const scope = JSON.stringify({ mode: account.mode ?? "gmail", folders: [...folders].sort() });
148
+ const previousScope = snapshot ? undefined : await store.sync.syncState(key, "mail_folder_scope");
149
+ const windowStart = new Date(since.getTime() + EDGE);
150
+ let deleted = 0;
151
+ let skipped;
152
+ if (snapshot)
153
+ skipped = "an mbox file is a snapshot; mail missing from it is not proven deleted";
154
+ else if (resumed.size > 0)
155
+ skipped =
156
+ "this run listed only mail not read before; a full listing, at least daily or with --rescan, checks deletions";
157
+ else if (previousScope !== undefined && previousScope.value !== scope)
158
+ skipped = "folder scope changed; missing mail is not proven deleted";
159
+ else {
160
+ // A label is not where mail lives, so only folders prove that a missing mail was deleted.
161
+ const candidates = (await store.mail.emailRefs({ accountId, since: windowStart.getTime() })).filter(({ mailboxes }) => {
162
+ const inFolders = mailboxes.filter(({ kind }) => kind !== "label" && kind !== ENVELOPE && kind !== BULK);
163
+ return inFolders.length ? inFolders.every(({ externalId }) => folders.includes(externalId)) : allMailOnly;
164
+ });
165
+ const gone = candidates.filter(({ externalId }) => !present.has(externalId)).map(({ externalId }) => externalId);
166
+ skipped = tooManyGone(gone.length, candidates.length);
167
+ if (!skipped && gone.length)
168
+ deleted = await store.mail.markDeleted(accountId, gone, startedAt);
169
+ // Mail an earlier import saved as messages keeps the rule it was saved under until those rows are removed.
170
+ const older = await dropMissing(store, key, { has: (id) => present.has(id.replace(/:attachment:\d+$/, "")) }, windowStart.toISOString(), async ({ id }) => {
171
+ const membership = await store.sync.syncState(key, `mail_message_folders:${id.replace(/:attachment:\d+$/, "")}`);
172
+ if (membership === undefined)
173
+ return allMailOnly;
174
+ return JSON.parse(membership.value).every((folder) => folders.includes(folder));
175
+ });
176
+ deleted += older.deleted;
177
+ skipped ??= older.skipped;
178
+ }
179
+ if (!snapshot)
180
+ await store.mail.setMailboxes(accountId, folders.map(mailbox), new Map([...foundFolders].map(([id, found]) => [id, [...found]])), startedAt);
181
+ // A message's label list is whole, so every label the account has is replaced for each message read.
182
+ if (labelsOf.size && !snapshot) {
183
+ const known = (await store.mail.mailboxes(accountId)).filter(({ kind }) => kind === "label");
184
+ const seen = [...labelsOf.values()].flatMap(({ labels }) => labels ?? []);
185
+ const scanned = new Map([
186
+ ...known.map(({ name }) => [name, label(name)]),
187
+ ...seen.map((name) => [name, label(name)]),
188
+ ]);
189
+ await store.mail.setMailboxes(accountId, [...scanned.values()], new Map([...labelsOf].map(([id, { labels }]) => [id, (labels ?? []).map((name) => label(name).externalId)])), startedAt);
190
+ }
191
+ const attachments = [];
192
+ const unreadable = [];
193
+ let saved = 0;
194
+ let alreadyStored = 0;
195
+ let ruledOut = 0;
196
+ let envelopes = 0;
197
+ const pending = [];
198
+ // IMAP lists what exists now, so a mail marked deleted that it lists again is saved again, which brings it back.
199
+ // An archive is old: what it holds that IMAP proved deleted stays deleted.
200
+ const refs = await store.mail.emailRefs({ accountId, includeDeleted: snapshot });
201
+ const known = new Set(refs.map(({ externalId }) => externalId));
202
+ const enveloped = new Map(refs.flatMap(({ externalId, mailboxes }) => {
203
+ const boxes = mailboxes.filter(({ kind }) => kind === ENVELOPE).map((box) => box.externalId);
204
+ return boxes.length ? [[externalId, boxes]] : [];
205
+ }));
206
+ const upgraded = new Map();
207
+ const toRecord = [];
208
+ // Mail whose outcome is final under these rules; only these UIDs are skipped by the next run's listing.
209
+ const settled = new Set();
210
+ let full = false;
211
+ for (const item of listed) {
212
+ const rule = decide(rules, factsFor(item));
213
+ const boxes = enveloped.get(item.messageId);
214
+ // An envelope whose rule is gone or now keeps the mail is read in full; one a skip now matches stays as it is.
215
+ if (boxes !== undefined && rule?.action !== ENVELOPE && rule?.action !== "skip") {
216
+ if (pending.length < max) {
217
+ upgraded.set(item.messageId, boxes);
218
+ pending.push(item);
219
+ }
220
+ else
221
+ full = true;
222
+ continue;
223
+ }
224
+ if (known.has(item.messageId)) {
225
+ alreadyStored++;
226
+ settled.add(item.messageId);
227
+ continue;
228
+ }
229
+ // Stored mail a newer skip or envelope rule matches stays whole: removing it is a separate, explicit step.
230
+ if (rule?.action === "skip") {
231
+ ruledOut++;
232
+ settled.add(item.messageId);
233
+ continue;
234
+ }
235
+ if (rule?.action === ENVELOPE) {
236
+ if (!known.has(item.messageId))
237
+ toRecord.push({ item, rule });
238
+ else {
239
+ alreadyStored++;
240
+ settled.add(item.messageId);
241
+ }
242
+ continue;
243
+ }
244
+ if (pending.length < max)
245
+ pending.push(item);
246
+ else
247
+ full = true;
248
+ }
249
+ const inMailboxes = (item) => [
250
+ ...[...(foundFolders.get(item.messageId) ?? [])].map(mailbox),
251
+ ...(labelsOf.get(item.messageId)?.labels ?? []).map(label),
252
+ ];
253
+ const flagsOf = (item) => {
254
+ const { seen, flagged } = labelsOf.get(item.messageId) ?? {};
255
+ return { ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) };
256
+ };
257
+ const uidsOf = new Map();
258
+ for (const item of collected) {
259
+ const folder = item.folder ?? folders[0];
260
+ if (!searches.has(folder))
261
+ continue;
262
+ const inFolder = uidsOf.get(folder) ?? new Map();
263
+ inFolder.set(item.messageId, [...(inFolder.get(item.messageId) ?? []), Number(item.uid)]);
264
+ uidsOf.set(folder, inFolder);
265
+ }
266
+ const saveProgress = async () => {
267
+ for (const [folder, searched] of searches) {
268
+ if (searched.validity === undefined)
269
+ continue;
270
+ const done = [];
271
+ for (const [messageId, uids] of uidsOf.get(folder) ?? [])
272
+ if (settled.has(messageId))
273
+ done.push(...uids);
274
+ const before = earlier.get(folder);
275
+ // A narrower whole listing, like the timer's 30 days, does not replace a wider one that is still fresh.
276
+ const keepFull = before !== undefined &&
277
+ (resumed.has(folder) ||
278
+ (Date.parse(before.full.since) < since.getTime() && startedAt - Date.parse(before.full.at) < FULL_PASS_MS));
279
+ const merged = mergeRanges(before?.done ?? [], rangesOf(done));
280
+ const widest = before === undefined || since.getTime() <= Date.parse(before.window.since);
281
+ const thisRun = { since: since.toISOString(), total: searched.uids.length, at: new Date(startedAt).toISOString() };
282
+ const prior = storedPoints.get(folder);
283
+ const priorWidest = prior?.widest ??
284
+ (prior === undefined
285
+ ? undefined
286
+ : { since: prior.window.since, total: prior.window.total, at: prior.updatedAt });
287
+ const widestEver = priorWidest === undefined || since.getTime() <= Date.parse(priorWidest.since) ? thisRun : priorWidest;
288
+ const point = {
289
+ validity: searched.validity,
290
+ done: merged,
291
+ rules: ruleSet,
292
+ full: keepFull ? before.full : { since: since.toISOString(), at: new Date(startedAt).toISOString() },
293
+ window: widest
294
+ ? {
295
+ since: since.toISOString(),
296
+ total: searched.uids.length,
297
+ done: searched.uids.filter((uid) => within(merged, Number(uid))).length,
298
+ }
299
+ : before.window,
300
+ widest: widestEver,
301
+ last: { ...thisRun, done: searched.uids.filter((uid) => within(merged, Number(uid))).length },
302
+ updatedAt: new Date(now()).toISOString(),
303
+ };
304
+ await store.sync.setSyncState(key, resumeKey(folder), JSON.stringify(point));
305
+ }
306
+ };
307
+ let savedAt = now();
308
+ const saveNow = async (force = false) => {
309
+ if (!force && now() - savedAt < SAVE_EVERY_MS)
310
+ return;
311
+ savedAt = now();
312
+ await saveProgress();
313
+ };
314
+ const stopIfAborted = async () => {
315
+ if (!signal?.aborted)
316
+ return;
317
+ await saveNow(true);
318
+ throw signal.reason;
319
+ };
320
+ await saveNow(true);
321
+ // An envelope costs nothing more from the source: its fields were read with the listing.
322
+ for (const { item, rule } of toRecord) {
323
+ await stopIfAborted();
324
+ const headers = item.headers;
325
+ await store.mail.saveThread({
326
+ accountId,
327
+ externalId: item.threadId,
328
+ now: startedAt,
329
+ emails: [
330
+ {
331
+ externalId: item.messageId,
332
+ // Subject and text stay out of the columns search reads, so recorded mail is never a search hit.
333
+ subject: null,
334
+ from: headers?.from === undefined ? null : { address: headers.from, name: headers.fromName ?? null },
335
+ sentAt: null,
336
+ receivedAt: Date.parse(item.receivedAt),
337
+ bodyText: null,
338
+ size: item.size ?? null,
339
+ outgoing: headers?.from === key.account,
340
+ mailboxes: [...inMailboxes(item), envelopeBox(rule.match)],
341
+ ...flagsOf(item),
342
+ mailRuleId: rule.id ?? null,
343
+ metadata: {
344
+ rule: { action: rule.action, match: { ...rule.match } },
345
+ ...(headers?.subject ? { subject: headers.subject } : {}),
346
+ ...(headers?.listId ? { listId: headers.listId } : {}),
347
+ ...(headers?.replyTo ? { replyTo: headers.replyTo } : {}),
348
+ ...(headers?.auth ? { auth: { ...headers.auth } } : {}),
349
+ },
350
+ },
351
+ ],
352
+ });
353
+ envelopes++;
354
+ settled.add(item.messageId);
355
+ await tagStored(item);
356
+ await saveNow();
357
+ }
358
+ for (let start = 0; start < pending.length; start += FETCH_CHUNK) {
359
+ await stopIfAborted();
360
+ const chunk = pending.slice(start, start + FETCH_CHUNK);
361
+ const raws = await source.fetchRaw(chunk);
362
+ await stopIfAborted();
363
+ // Every email of a chunk failing is the connection or the login, not the mail: stop instead of trying the rest.
364
+ if (chunk.length > 1 && raws.every((raw) => raw instanceof Error))
365
+ throw raws[0];
366
+ for (const [index, item] of chunk.entries()) {
367
+ let mail;
368
+ try {
369
+ const raw = raws[index];
370
+ if (raw === undefined || raw instanceof Error)
371
+ throw raw ?? new Error("not read");
372
+ mail = await parseMail(raw, maxBodyChars);
373
+ }
374
+ catch (error) {
375
+ unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
376
+ continue;
377
+ }
378
+ const header = mailHeadersOf(raws[index]);
379
+ const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
380
+ await store.mail.saveThread({
381
+ accountId,
382
+ externalId: item.threadId,
383
+ now: startedAt,
384
+ emails: [
385
+ {
386
+ externalId: item.messageId,
387
+ subject: mail.subject,
388
+ from: mail.from === null ? null : addressOf(mail.from),
389
+ to: mail.to.map(addressOf),
390
+ cc: mail.cc.map(addressOf),
391
+ bcc: mail.bcc.map(addressOf),
392
+ sentAt: null,
393
+ receivedAt: Date.parse(item.receivedAt),
394
+ bodyText: mail.text || null,
395
+ size: item.size ?? null,
396
+ ...(Object.keys(header).length ? { headers: header } : {}),
397
+ outgoing: mail.from === null ? null : mail.from.email === key.account,
398
+ mailboxes: inMailboxes(item),
399
+ ...flagsOf(item),
400
+ ...(upgraded.has(item.messageId) ? { metadata: {} } : {}),
401
+ mailRuleId: decide(rules, factsFor(item))?.id ?? null,
402
+ attachments: files,
403
+ },
404
+ ],
405
+ });
406
+ saved++;
407
+ settled.add(item.messageId);
408
+ await tagStored(item);
409
+ const boxes = upgraded.get(item.messageId);
410
+ if (boxes !== undefined)
411
+ await store.mail.setMailboxes(accountId, boxes.map(envelopeBoxOf), new Map([[item.messageId, []]]), startedAt);
412
+ }
413
+ await saveNow();
414
+ }
415
+ await saveNow(true);
416
+ if (snapshot && mboxStamp !== undefined && mboxPoint === undefined) {
417
+ const point = {
418
+ ...mboxStamp,
419
+ complete: !full && unreadable.length === 0,
420
+ updatedAt: new Date(now()).toISOString(),
421
+ };
422
+ await store.sync.setSyncState(key, MBOX_RESUME, JSON.stringify(point));
423
+ }
424
+ // Read and starred change at the source after import; mail saved in this run already holds them.
425
+ if (!snapshot)
426
+ await store.mail.setFlags(accountId, new Map([...labelsOf].flatMap(([id, { seen, flagged }]) => seen === undefined && flagged === undefined
427
+ ? []
428
+ : [[id, { ...(seen === undefined ? {} : { read: seen }), ...(flagged === undefined ? {} : { flagged }) }]])), startedAt);
429
+ const complete = !full && unreadable.length === 0;
430
+ const bulk = new Map(listed.filter((item) => item.headers?.bulk).map((item) => [item.messageId, [bulkBox.externalId]]));
431
+ if (bulk.size)
432
+ await store.mail.setMailboxes(accountId, [bulkBox], bulk, startedAt);
433
+ if (!snapshot)
434
+ await store.sync.setSyncState(key, "mail_folder_scope", scope);
435
+ await stopIfAborted();
436
+ const indexed = await index?.(store, key);
437
+ const coverage = {
438
+ folders,
439
+ since: since.toISOString(),
440
+ until: new Date(startedAt).toISOString(),
441
+ state: complete ? "window" : "partial",
442
+ identity: account.mode === "imap" ? "message-id-or-uidvalidity-folder-uid" : "gmail-message-id",
443
+ };
444
+ if (!snapshot)
445
+ await store.sync.setSyncState(key, "mail_coverage", JSON.stringify(coverage));
446
+ const tagging = tagger.outcome();
447
+ await settleRuleTags(store, accountId, rules, tagging);
448
+ return {
449
+ account: key.account,
450
+ since: since.toISOString(),
451
+ listed: listed.length,
452
+ saved,
453
+ alreadyStored,
454
+ skipped: ruledOut,
455
+ envelopes,
456
+ deleted,
457
+ complete,
458
+ ...(indexed === undefined ? {} : { indexed }),
459
+ coverage,
460
+ ...(attachments.length ? { attachments } : {}),
461
+ ...(unreadable.length ? { unreadable } : {}),
462
+ ...(skipped === undefined ? {} : { deletionsSkipped: skipped }),
463
+ ...(searches.size > 0 ? { pass: resumed.size > 0 ? "resumed" : "full" } : {}),
464
+ ...(tagging.reused.length ? { tagsReused: tagging.reused } : {}),
465
+ ...(tagging.skipped.length ? { tagsSkipped: tagging.skipped } : {}),
466
+ };
467
+ };
468
+ /** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
469
+ export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
470
+ /**
471
+ * Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
472
+ * attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
473
+ * resume point moves; the email keeps its stored mailboxes, flags and rule.
474
+ */
475
+ const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
476
+ const startedAt = now();
477
+ const key = { provider: "email", account: account.address.toLowerCase() };
478
+ const find = source.find;
479
+ if (find === undefined)
480
+ throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
481
+ const accountId = await store.accounts.saveAccount(key, { name: account.address });
482
+ const wanted = (await store.mail.emailRefs({ accountId, attachmentErrors: RETRYABLE_ATTACHMENTS })).map(({ externalId }) => externalId);
483
+ const chosen = wanted.slice(0, max);
484
+ const found = new Map();
485
+ for (const folder of await mailFolders(account, source)) {
486
+ const left = chosen.filter((id) => !found.has(id));
487
+ if (left.length === 0)
488
+ break;
489
+ for (const item of await find(left, folder))
490
+ if (!found.has(item.messageId))
491
+ found.set(item.messageId, item);
492
+ }
493
+ const items = [...found.values()];
494
+ const attachments = [];
495
+ const unreadable = [];
496
+ let emails = 0;
497
+ let extracted = 0;
498
+ let stillFailed = 0;
499
+ for (let start = 0; start < items.length; start += FETCH_CHUNK) {
500
+ const chunk = items.slice(start, start + FETCH_CHUNK);
501
+ const raws = await source.fetchRaw(chunk);
502
+ for (const [index, item] of chunk.entries()) {
503
+ const raw = raws[index];
504
+ const stored = await store.mail.email(accountId, item.messageId);
505
+ if (stored === null)
506
+ continue;
507
+ let mail;
508
+ try {
509
+ if (raw === undefined || raw instanceof Error)
510
+ throw raw ?? new Error("not read");
511
+ mail = await parseMail(raw, maxBodyChars);
512
+ }
513
+ catch (error) {
514
+ unreadable.push({ message: item.messageId, reason: error instanceof Error ? error.message : String(error) });
515
+ continue;
516
+ }
517
+ const before = new Map(stored.attachments.map(({ position, extractionError }) => [position, extractionError]));
518
+ const files = await readAttachments(mail, item.messageId, { loadEngine, maxAttachmentChars, at: startedAt }, attachments);
519
+ for (const file of files)
520
+ if (RETRYABLE_ATTACHMENTS.includes(before.get(file.position) ?? ""))
521
+ if (file.extraction === "text")
522
+ extracted++;
523
+ else
524
+ stillFailed++;
525
+ const header = mailHeadersOf(raw);
526
+ await store.mail.saveThread({
527
+ accountId,
528
+ externalId: item.threadId,
529
+ now: startedAt,
530
+ emails: [
531
+ {
532
+ externalId: item.messageId,
533
+ subject: mail.subject,
534
+ from: mail.from === null ? null : addressOf(mail.from),
535
+ to: mail.to.map(addressOf),
536
+ cc: mail.cc.map(addressOf),
537
+ bcc: mail.bcc.map(addressOf),
538
+ sentAt: null,
539
+ receivedAt: Date.parse(item.receivedAt),
540
+ bodyText: mail.text || null,
541
+ size: item.size ?? stored.size,
542
+ ...(Object.keys(header).length ? { headers: header } : {}),
543
+ outgoing: mail.from === null ? null : mail.from.email === key.account,
544
+ mailboxes: stored.mailboxes.map(({ externalId, name, kind }) => ({ externalId, name, kind })),
545
+ ...(stored.read === null ? {} : { read: stored.read }),
546
+ ...(stored.flagged === null ? {} : { flagged: stored.flagged }),
547
+ metadata: stored.metadata,
548
+ mailRuleId: stored.mailRuleId,
549
+ attachments: files,
550
+ },
551
+ ],
552
+ });
553
+ emails++;
554
+ }
555
+ }
556
+ const indexed = await index?.(store, key);
557
+ return {
558
+ account: key.account,
559
+ since: since.toISOString(),
560
+ listed: items.length,
561
+ saved: 0,
562
+ alreadyStored: 0,
563
+ skipped: 0,
564
+ envelopes: 0,
565
+ deleted: 0,
566
+ complete: wanted.length <= max && unreadable.length === 0,
567
+ ...(indexed === undefined ? {} : { indexed }),
568
+ retried: { emails, extracted, stillFailed, missing: chosen.length - items.length },
569
+ ...(attachments.length ? { attachments } : {}),
570
+ ...(unreadable.length ? { unreadable } : {}),
571
+ };
572
+ };
@@ -0,0 +1,20 @@
1
+ import type { ContractCase } from "../../meetings/index.js";
2
+ import type { MessageStore } from "../../store/index.js";
3
+ import type { MailSource } from "../source.js";
4
+ import type { FakeMail } from "./fake.js";
5
+ /** The fixtures every adapter is seeded with: its harness makes a mailbox that holds exactly these. */
6
+ export declare const CONTRACT_MAIL: FakeMail[];
7
+ /** An adapter over a mailbox holding `CONTRACT_MAIL`, fresh for each case. */
8
+ export type MailSourceFixture = () => MailSource | Promise<MailSource>;
9
+ /** What every adapter must answer; the optional members are checked only where the source has them. */
10
+ export declare const mailSourceContract: (make: MailSourceFixture) => ContractCase[];
11
+ /** A source over `CONTRACT_MAIL` and the store it syncs into, fresh for each case. */
12
+ export type MailSyncFixture = () => {
13
+ source: MailSource;
14
+ store: MessageStore;
15
+ } | Promise<{
16
+ source: MailSource;
17
+ store: MessageStore;
18
+ }>;
19
+ /** What `syncMail` must do with any adapter: store each message once under the address's account, and only once. */
20
+ export declare const mailSyncContract: (make: MailSyncFixture) => ContractCase[];