@wirecat/cli-core 0.546.0 → 0.547.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 (96) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/dist/cli/messenger/history-query.d.ts +10 -4
  3. package/dist/cli/messenger/history-query.js +87 -31
  4. package/dist/cli/messenger/messages-command.js +10 -4
  5. package/dist/cli/messenger/messages-comments-command.js +4 -2
  6. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  7. package/dist/cli/option-hints.d.ts +6 -0
  8. package/dist/cli/option-hints.js +15 -0
  9. package/dist/errors/reasons.js +13 -1
  10. package/dist/mail/cli/index.js +4 -4
  11. package/dist/mail/index.d.ts +2 -0
  12. package/dist/mail/index.js +2 -0
  13. package/dist/mail/partial.d.ts +34 -0
  14. package/dist/mail/partial.js +94 -0
  15. package/dist/mail/server-search.js +17 -9
  16. package/dist/mail/source.d.ts +13 -2
  17. package/dist/mail/source.js +7 -1
  18. package/dist/mail/sync-types.d.ts +112 -0
  19. package/dist/mail/sync-types.js +1 -0
  20. package/dist/mail/sync.d.ts +4 -106
  21. package/dist/mail/sync.js +49 -4
  22. package/dist/mail/testing/fake.d.ts +3 -1
  23. package/dist/mail/testing/fake.js +10 -2
  24. package/dist/services/deps.d.ts +0 -2
  25. package/dist/services/deps.js +0 -2
  26. package/dist/services/documents/ingestion.js +1 -1
  27. package/dist/services/documents/knowledge.d.ts +10 -6
  28. package/dist/services/documents/knowledge.js +25 -11
  29. package/dist/services/index.d.ts +1 -0
  30. package/dist/services/index.js +1 -0
  31. package/dist/services/messages.d.ts +2 -0
  32. package/dist/services/messages.js +25 -23
  33. package/dist/services/proposal-tasks.d.ts +3 -2
  34. package/dist/services/proposal-tasks.js +5 -2
  35. package/dist/services/proposals/ids.d.ts +5 -0
  36. package/dist/services/proposals/ids.js +43 -0
  37. package/dist/services/proposals/index.d.ts +3 -0
  38. package/dist/services/proposals/index.js +2 -0
  39. package/dist/services/proposals/kinds/action.d.ts +7 -0
  40. package/dist/services/proposals/kinds/action.js +45 -0
  41. package/dist/services/proposals/kinds/fact.d.ts +4 -0
  42. package/dist/services/proposals/kinds/fact.js +43 -0
  43. package/dist/services/proposals/kinds/findings.d.ts +7 -0
  44. package/dist/services/proposals/kinds/findings.js +75 -0
  45. package/dist/services/proposals/kinds/key-document.d.ts +9 -0
  46. package/dist/services/proposals/kinds/key-document.js +75 -0
  47. package/dist/services/proposals/kinds/memory.d.ts +4 -0
  48. package/dist/services/proposals/kinds/memory.js +42 -0
  49. package/dist/services/proposals/kinds/relationship.d.ts +10 -0
  50. package/dist/services/proposals/kinds/relationship.js +82 -0
  51. package/dist/services/proposals/kinds/shared.d.ts +25 -0
  52. package/dist/services/proposals/kinds/shared.js +49 -0
  53. package/dist/services/proposals/registry.d.ts +9 -0
  54. package/dist/services/proposals/registry.js +129 -0
  55. package/dist/services/proposals/types.d.ts +85 -0
  56. package/dist/services/proposals/types.js +1 -0
  57. package/dist/services/server-search.d.ts +7 -4
  58. package/dist/services/server-search.js +5 -2
  59. package/dist/services/tasks.js +8 -4
  60. package/dist/store/decisions.d.ts +65 -0
  61. package/dist/store/decisions.js +55 -0
  62. package/dist/store/evidence-findings.d.ts +6 -6
  63. package/dist/store/index.d.ts +4 -3
  64. package/dist/store/index.js +2 -1
  65. package/dist/store/meeting-findings.d.ts +2 -0
  66. package/dist/store/messages.d.ts +4 -9
  67. package/dist/store/sqlite/actors.d.ts +13 -2
  68. package/dist/store/sqlite/actors.js +24 -1
  69. package/dist/store/sqlite/email-locations.d.ts +16 -0
  70. package/dist/store/sqlite/email-locations.js +26 -0
  71. package/dist/store/sqlite/emails.d.ts +6 -2
  72. package/dist/store/sqlite/emails.js +2 -1
  73. package/dist/store/sqlite/evidence-findings.js +13 -3
  74. package/dist/store/sqlite/facts.d.ts +16 -8
  75. package/dist/store/sqlite/facts.js +48 -15
  76. package/dist/store/sqlite/findings-core.d.ts +2 -1
  77. package/dist/store/sqlite/findings-core.js +4 -4
  78. package/dist/store/sqlite/knowledge.d.ts +43 -7
  79. package/dist/store/sqlite/knowledge.js +55 -15
  80. package/dist/store/sqlite/meeting-findings.js +6 -6
  81. package/dist/store/sqlite/memories.d.ts +12 -8
  82. package/dist/store/sqlite/memories.js +33 -10
  83. package/dist/store/sqlite/notes.d.ts +5 -1
  84. package/dist/store/sqlite/notes.js +18 -2
  85. package/dist/store/sqlite/open-store.d.ts +4 -1
  86. package/dist/store/sqlite/open-store.js +8 -2
  87. package/dist/store/sqlite/open.d.ts +3 -0
  88. package/dist/store/sqlite/proposed-actions.d.ts +21 -8
  89. package/dist/store/sqlite/proposed-actions.js +29 -9
  90. package/dist/store/sqlite/reads.d.ts +13 -2
  91. package/dist/store/sqlite/reads.js +8 -4
  92. package/dist/store/sqlite/relation-proposals.d.ts +34 -0
  93. package/dist/store/sqlite/relation-proposals.js +114 -0
  94. package/dist/store/store.d.ts +3 -0
  95. package/dist/store/store.js +2 -0
  96. package/package.json +1 -1
@@ -0,0 +1,94 @@
1
+ import { FETCH_CHUNK, readAttachments } from "./attachments.js";
2
+ import { parsedEmail } from "./email-input.js";
3
+ import { mailHeadersOf } from "./headers.js";
4
+ import { parseMail } from "./parse.js";
5
+ /** The `emails.metadata` key of an email stored from its first bytes: `{ bytes }` read of its `size`. */
6
+ export const PARTIAL = "partial";
7
+ /** How many partial emails one sync reads whole at most; the rest wait for the next run. */
8
+ export const PARTIAL_PER_RUN = 100;
9
+ /**
10
+ * Reads whole the emails a server search stored from their first bytes, so the archive holds their attachments too.
11
+ * Sync never reads stored mail again, so without this step a partial email would stay partial. Gmail's are found by
12
+ * their id; elsewhere at a stored location, trusted only when the bytes read there carry the stored id.
13
+ */
14
+ export const completePartial = async ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, take, signal, }) => {
15
+ const { emails, total } = await store.mail.partialEmails(accountId, PARTIAL_PER_RUN);
16
+ if (emails.length === 0)
17
+ return { completed: 0, left: 0 };
18
+ const byId = new Map(emails.map((email) => [email.externalId, email]));
19
+ const items = new Map();
20
+ if (source.find) {
21
+ for (const folder of await folders()) {
22
+ const left = emails.filter((email) => !items.has(email)).map(({ externalId }) => externalId);
23
+ if (left.length === 0)
24
+ break;
25
+ if (take?.())
26
+ return { completed: 0, left: total };
27
+ for (const found of await source.find(left, folder)) {
28
+ const email = byId.get(found.messageId);
29
+ if (email && !items.has(email))
30
+ items.set(email, found);
31
+ }
32
+ }
33
+ }
34
+ else if (source.identify)
35
+ for (const email of emails) {
36
+ const place = email.locations[0];
37
+ if (place)
38
+ items.set(email, {
39
+ uid: String(place.uid),
40
+ folder: place.folder,
41
+ receivedAt: new Date(email.receivedAt ?? 0).toISOString(),
42
+ });
43
+ }
44
+ let completed = 0;
45
+ const wanted = [...items];
46
+ for (let start = 0; start < wanted.length; start += FETCH_CHUNK) {
47
+ if (signal?.aborted || take?.())
48
+ break;
49
+ const chunk = wanted.slice(start, start + FETCH_CHUNK);
50
+ let raws;
51
+ try {
52
+ raws = await source.fetchRaw(chunk.map(([, item]) => item));
53
+ }
54
+ catch {
55
+ // A partial email stays readable as it is; the next run tries again.
56
+ break;
57
+ }
58
+ const attachments = [];
59
+ for (const [index, [email, item]] of chunk.entries()) {
60
+ const raw = raws[index];
61
+ if (!(raw instanceof Uint8Array))
62
+ continue;
63
+ // A UID means another message after a UIDVALIDITY change; only the stored id proves it is this one.
64
+ if (!source.find && source.identify?.(raw, item).messageId !== email.externalId)
65
+ continue;
66
+ try {
67
+ const mail = await parseMail(raw, maxBodyChars);
68
+ const files = await readAttachments(mail, email.externalId, { loadEngine, maxAttachmentChars, at: now, ...(signal ? { signal } : {}) }, attachments);
69
+ const { [PARTIAL]: _partial, ...metadata } = email.metadata ?? {};
70
+ const whole = { ...item, messageId: email.externalId, threadId: email.threadExternalId };
71
+ await store.mail.saveThread({
72
+ accountId,
73
+ externalId: email.threadExternalId,
74
+ now,
75
+ emails: [
76
+ {
77
+ ...parsedEmail(mail, whole, mailHeadersOf(raw), address),
78
+ receivedAt: email.receivedAt,
79
+ size: raw.byteLength,
80
+ metadata,
81
+ attachments: files,
82
+ },
83
+ ],
84
+ });
85
+ completed++;
86
+ }
87
+ catch {
88
+ if (signal?.aborted)
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ return { completed, left: total - completed };
94
+ };
@@ -9,6 +9,7 @@ import { parsedEmail } from "./email-input.js";
9
9
  import { factsOf } from "./facts.js";
10
10
  import { mailHeadersOf } from "./headers.js";
11
11
  import { parseMail } from "./parse.js";
12
+ import { PARTIAL } from "./partial.js";
12
13
  import { storedMailRules } from "./rule-stored.js";
13
14
  import { ruleTagger, settleRuleTags } from "./rule-tags.js";
14
15
  import { decide, tagsOf } from "./rules.js";
@@ -84,6 +85,7 @@ export const searchMailServer = async (store, mailbox, access, request) => {
84
85
  const ruled = new Map();
85
86
  const inputs = [];
86
87
  const saved = [];
88
+ let cutShort = 0;
87
89
  let opening;
88
90
  let openFailure;
89
91
  let unsupported = false;
@@ -146,17 +148,12 @@ export const searchMailServer = async (store, mailbox, access, request) => {
146
148
  sources.set(sourceKey(item.threadId, item.messageId), held ? "both" : "server");
147
149
  if (held)
148
150
  continue;
149
- // A big message would hold the connection and the parser past the bound; sync reads it whole later.
150
- if ((item.size ?? 0) > SERVER_BOUNDS.maxMessageBytes) {
151
- report.tooLarge = (report.tooLarge ?? 0) + 1;
152
- report.complete = false;
153
- sources.delete(sourceKey(item.threadId, item.messageId));
154
- continue;
155
- }
156
151
  fresh.push(item);
157
152
  ruled.set(item, { ruleId: rule?.id ?? null, tags: tagsOf(rules, facts(item)) });
158
153
  }
159
- const raws = fresh.length && open ? await source.fetchRaw(fresh) : [];
154
+ // A big message would hold the connection and the parser past the bound: its first bytes carry the text.
155
+ const bytes = SERVER_BOUNDS.textBytes;
156
+ const raws = fresh.length && open ? await source.fetchRaw(fresh, { bytes }) : [];
160
157
  // Parsing and reading attachments stay inside the bound: after it, nothing more is read or kept.
161
158
  const now = Date.now();
162
159
  for (const [index, item] of fresh.entries()) {
@@ -166,8 +163,12 @@ export const searchMailServer = async (store, mailbox, access, request) => {
166
163
  try {
167
164
  if (raw === undefined || raw instanceof Error)
168
165
  throw raw ?? new Error("not read");
166
+ const cut = raw.byteLength >= bytes && (item.size === undefined || item.size > raw.byteLength);
169
167
  const mail = await parseMail(raw);
170
- const files = await readAttachments(mail, item.messageId, { loadEngine: importEngine, maxAttachmentChars: MAX_ATTACHMENT_CHARS, at: now, signal: stop }, []);
168
+ // An attachment cut short is not the file: none is kept until the sync reads the whole message.
169
+ const files = cut
170
+ ? []
171
+ : await readAttachments(mail, item.messageId, { loadEngine: importEngine, maxAttachmentChars: MAX_ATTACHMENT_CHARS, at: now, signal: stop }, []);
171
172
  if (!open)
172
173
  return;
173
174
  const folder = item.folder;
@@ -189,12 +190,15 @@ export const searchMailServer = async (store, mailbox, access, request) => {
189
190
  : { locations: [{ folder, uidvalidity: validity, uid: Number(item.uid) }] }),
190
191
  ...(item.seen === undefined ? {} : { read: item.seen }),
191
192
  ...(item.flagged === undefined ? {} : { flagged: item.flagged }),
193
+ ...(cut ? { metadata: { [PARTIAL]: { bytes: raw.byteLength } } } : {}),
192
194
  mailRuleId: ruled.get(item)?.ruleId ?? null,
193
195
  attachments: files,
194
196
  },
195
197
  ],
196
198
  });
197
199
  saved.push(item);
200
+ if (cut)
201
+ cutShort++;
198
202
  }
199
203
  catch {
200
204
  sources.delete(sourceKey(item.threadId, item.messageId));
@@ -248,6 +252,10 @@ export const searchMailServer = async (store, mailbox, access, request) => {
248
252
  }
249
253
  await settleRuleTags(store, accountId, rules, tagger.outcome());
250
254
  report.new = inputs.length;
255
+ if (cutShort) {
256
+ report.partial = cutShort;
257
+ report.complete = false;
258
+ }
251
259
  report.returned = sources.size;
252
260
  return { report, sources };
253
261
  };
@@ -1,6 +1,12 @@
1
1
  import type { MailHeaders } from "./headers.js";
2
2
  import type { FileStamp } from "./resume.js";
3
3
  import type { MailCriteria } from "./server-query.js";
4
+ /**
5
+ * The reason of the CliError `search`, `query` and `present` throw when the server answers SEARCH with NO or BAD:
6
+ * read as "nothing found", it would look like an empty folder and prove its stored mail deleted.
7
+ */
8
+ export declare const SEARCH_REFUSED = "mail.search-refused";
9
+ export declare const searchRefused: (error: unknown) => boolean;
4
10
  export interface Listed {
5
11
  uid: string;
6
12
  /** Gmail's own ids: the same in every folder, and they survive a UID reset. */
@@ -94,6 +100,11 @@ export interface MailSource {
94
100
  present?(uids: readonly string[], folder: string): Promise<string[]>;
95
101
  /** Puts labels back on messages in `folder`, by Gmail id. */
96
102
  label?(messageIds: readonly string[], folder: string, labels: readonly string[]): Promise<void>;
97
- /** Each item's raw RFC 5322 bytes, in order; one that could not be read is an Error, not a failed call. */
98
- fetchRaw(items: Quick[]): Promise<(Uint8Array | Error)[]>;
103
+ /**
104
+ * Each item's raw RFC 5322 bytes, in order; one that could not be read is an Error, not a failed call. With
105
+ * `bytes`, at most the first `bytes` of each: a message cut short comes back shorter than its `size`.
106
+ */
107
+ fetchRaw(items: Quick[], options?: {
108
+ bytes?: number;
109
+ }): Promise<(Uint8Array | Error)[]>;
99
110
  }
@@ -1 +1,7 @@
1
- export {};
1
+ import { CliError } from "@wirecat/cli-base";
2
+ /**
3
+ * The reason of the CliError `search`, `query` and `present` throw when the server answers SEARCH with NO or BAD:
4
+ * read as "nothing found", it would look like an empty folder and prove its stored mail deleted.
5
+ */
6
+ export const SEARCH_REFUSED = "mail.search-refused";
7
+ export const searchRefused = (error) => error instanceof CliError && error.details.reason === SEARCH_REFUSED;
@@ -0,0 +1,112 @@
1
+ import type { BudgetStop, RunBudget } from "../background/budget.js";
2
+ import type { LoadEngine } from "../readers/types.js";
3
+ import type { AccountKey, MessageStore } from "../store/index.js";
4
+ import type { PartialOutcome } from "./partial.js";
5
+ import type { TagOutcome } from "./rule-tags.js";
6
+ import type { MailRule } from "./rules.js";
7
+ import type { DomainAge } from "./score.js";
8
+ export interface MailAccount {
9
+ /** The mailbox address: the store's account id. */
10
+ address: string;
11
+ mode?: "gmail" | "imap" | "mbox";
12
+ folders?: string[];
13
+ rules?: MailRule[];
14
+ }
15
+ /** What an index step reports, as memo's embedding does; the sync only passes it on. */
16
+ export interface MailIndexed {
17
+ chats: number;
18
+ chunks: number;
19
+ left: boolean;
20
+ notEmbedded?: unknown;
21
+ }
22
+ export interface MailSyncResult {
23
+ account: string;
24
+ since: string;
25
+ listed: number;
26
+ saved: number;
27
+ alreadyStored: number;
28
+ /** New emails a skip rule kept out: never read, never stored. */
29
+ skipped: number;
30
+ /** New emails an envelope rule recorded: sender, date, size and labels, never their text. */
31
+ envelopes: number;
32
+ deleted: number;
33
+ /** False when `max` stopped the run before every new message was read; the next run continues. */
34
+ complete: boolean;
35
+ /** `partial`: a budgeted run stopped before the whole window was read; the next run goes on. */
36
+ state?: "partial" | "complete";
37
+ /** Why a budgeted run stopped early: its request budget, its deadline, or a cancel. */
38
+ stopped?: BudgetStop;
39
+ /** Messages of the window this run did not get to read: left by a budget, or by `max`. */
40
+ remaining?: number;
41
+ /** Requests a budgeted run spent. */
42
+ requests?: number;
43
+ /** Emails that could not be read or parsed; they are tried again next run. */
44
+ unreadable?: {
45
+ message: string;
46
+ reason: string;
47
+ }[];
48
+ /** Why stored messages missing at the source were left alone, when they were. */
49
+ deletionsSkipped?: string;
50
+ /** Folders whose server refused SEARCH: left unread, so the run is partial and proves no deletion. */
51
+ refusedFolders?: string[];
52
+ /** "resumed": only mail not done before was listed; "full": every message in the window was. */
53
+ pass?: "full" | "resumed";
54
+ indexed?: MailIndexed;
55
+ coverage?: {
56
+ folders: string[];
57
+ since: string;
58
+ until: string;
59
+ state: "window" | "partial";
60
+ identity: string;
61
+ };
62
+ attachments?: {
63
+ message: string;
64
+ part: number;
65
+ status: string;
66
+ }[];
67
+ /** Rule tags put on mail as an existing label the store pointed to. */
68
+ tagsReused?: TagOutcome["reused"];
69
+ /** Rule tags the store refused with nothing to reuse; that mail is stored untagged. */
70
+ tagsSkipped?: TagOutcome["skipped"];
71
+ /** Emails a server search stored from their first bytes, read whole this run, and those still partial. */
72
+ partial?: PartialOutcome;
73
+ /** `--retry-attachments`: emails read again for attachments a missing reader left unread. */
74
+ retried?: {
75
+ emails: number;
76
+ extracted: number;
77
+ stillFailed: number;
78
+ missing: number;
79
+ };
80
+ }
81
+ export interface MailSyncOptions {
82
+ account: MailAccount;
83
+ since: Date;
84
+ max?: number;
85
+ maxBodyChars?: number;
86
+ maxAttachmentChars?: number;
87
+ now?: () => number;
88
+ /** Domain registration dates already known, for the sender score a rule asks for. */
89
+ ages?: ReadonlyMap<string, DomainAge>;
90
+ loadEngine?: LoadEngine;
91
+ retryAttachments?: boolean;
92
+ /** List every message in the window, not only those an earlier run left: proves deletions, refreshes labels. */
93
+ rescan?: boolean;
94
+ /** `false` for a spot read, like the copy of a sent message: the run leaves the saved coverage as the last full run set it. */
95
+ saveCoverage?: boolean;
96
+ /** A window smaller than this is listed whole every run: it costs seconds and keeps flags and deletions current. */
97
+ resumeFrom?: number;
98
+ /** Runs after the mail is saved, so chats and mail still waiting for vectors are indexed; its answer is `indexed`. */
99
+ index?: (store: MessageStore, key: AccountKey) => Promise<MailIndexed>;
100
+ /** Stops between batches of emails, after saving the resume point. */
101
+ signal?: AbortSignal;
102
+ /**
103
+ * Makes the run budgeted: it reads the window in rounds, one request of the budget per call to the source, saves
104
+ * after each, stops when the budget says so and answers `state: "partial"`. A message the source could not give
105
+ * is tried once more at the end of the run and, if it still fails, left for the next one. `max` defaults to no limit.
106
+ */
107
+ budget?: RunBudget;
108
+ /** Pauses before a round is read again after the store was locked; tests pass one that does not wait. */
109
+ wait?: (ms: number) => Promise<void>;
110
+ /** `false` for a spot read: emails a server search stored from their first bytes are not read whole this run. */
111
+ completePartial?: boolean;
112
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,109 +1,7 @@
1
- import type { BudgetStop, RunBudget } from "../background/budget.js";
2
- import type { LoadEngine } from "../readers/types.js";
3
- import type { AccountKey, MessageStore } from "../store/index.js";
4
- import { type TagOutcome } from "./rule-tags.js";
5
- import { type MailRule } from "./rules.js";
6
- import type { DomainAge } from "./score.js";
7
- import type { MailSource } from "./source.js";
8
- export interface MailAccount {
9
- /** The mailbox address: the store's account id. */
10
- address: string;
11
- mode?: "gmail" | "imap" | "mbox";
12
- folders?: string[];
13
- rules?: MailRule[];
14
- }
15
- /** What an index step reports, as memo's embedding does; the sync only passes it on. */
16
- export interface MailIndexed {
17
- chats: number;
18
- chunks: number;
19
- left: boolean;
20
- notEmbedded?: unknown;
21
- }
22
- export interface MailSyncResult {
23
- account: string;
24
- since: string;
25
- listed: number;
26
- saved: number;
27
- alreadyStored: number;
28
- /** New emails a skip rule kept out: never read, never stored. */
29
- skipped: number;
30
- /** New emails an envelope rule recorded: sender, date, size and labels, never their text. */
31
- envelopes: number;
32
- deleted: number;
33
- /** False when `max` stopped the run before every new message was read; the next run continues. */
34
- complete: boolean;
35
- /** `partial`: a budgeted run stopped before the whole window was read; the next run goes on. */
36
- state?: "partial" | "complete";
37
- /** Why a budgeted run stopped early: its request budget, its deadline, or a cancel. */
38
- stopped?: BudgetStop;
39
- /** Messages of the window this run did not get to read: left by a budget, or by `max`. */
40
- remaining?: number;
41
- /** Requests a budgeted run spent. */
42
- requests?: number;
43
- /** Emails that could not be read or parsed; they are tried again next run. */
44
- unreadable?: {
45
- message: string;
46
- reason: string;
47
- }[];
48
- /** Why stored messages missing at the source were left alone, when they were. */
49
- deletionsSkipped?: string;
50
- /** "resumed": only mail not done before was listed; "full": every message in the window was. */
51
- pass?: "full" | "resumed";
52
- indexed?: MailIndexed;
53
- coverage?: {
54
- folders: string[];
55
- since: string;
56
- until: string;
57
- state: "window" | "partial";
58
- identity: string;
59
- };
60
- attachments?: {
61
- message: string;
62
- part: number;
63
- status: string;
64
- }[];
65
- /** Rule tags put on mail as an existing label the store pointed to. */
66
- tagsReused?: TagOutcome["reused"];
67
- /** Rule tags the store refused with nothing to reuse; that mail is stored untagged. */
68
- tagsSkipped?: TagOutcome["skipped"];
69
- /** `--retry-attachments`: emails read again for attachments a missing reader left unread. */
70
- retried?: {
71
- emails: number;
72
- extracted: number;
73
- stillFailed: number;
74
- missing: number;
75
- };
76
- }
1
+ import type { MessageStore } from "../store/index.js";
2
+ import { type MailSource } from "./source.js";
3
+ import type { MailSyncOptions, MailSyncResult } from "./sync-types.js";
4
+ export type { MailAccount, MailIndexed, MailSyncOptions, MailSyncResult } from "./sync-types.js";
77
5
  export { MAX_ATTACHMENT_CHARS } from "./attachments.js";
78
6
  export { RETRYABLE_ATTACHMENTS } from "./retry.js";
79
- export interface MailSyncOptions {
80
- account: MailAccount;
81
- since: Date;
82
- max?: number;
83
- maxBodyChars?: number;
84
- maxAttachmentChars?: number;
85
- now?: () => number;
86
- /** Domain registration dates already known, for the sender score a rule asks for. */
87
- ages?: ReadonlyMap<string, DomainAge>;
88
- loadEngine?: LoadEngine;
89
- retryAttachments?: boolean;
90
- /** List every message in the window, not only those an earlier run left: proves deletions, refreshes labels. */
91
- rescan?: boolean;
92
- /** `false` for a spot read, like the copy of a sent message: the run leaves the saved coverage as the last full run set it. */
93
- saveCoverage?: boolean;
94
- /** A window smaller than this is listed whole every run: it costs seconds and keeps flags and deletions current. */
95
- resumeFrom?: number;
96
- /** Runs after the mail is saved, so chats and mail still waiting for vectors are indexed; its answer is `indexed`. */
97
- index?: (store: MessageStore, key: AccountKey) => Promise<MailIndexed>;
98
- /** Stops between batches of emails, after saving the resume point. */
99
- signal?: AbortSignal;
100
- /**
101
- * Makes the run budgeted: it reads the window in rounds, one request of the budget per call to the source, saves
102
- * after each, stops when the budget says so and answers `state: "partial"`. A message the source could not give
103
- * is tried once more at the end of the run and, if it still fails, left for the next one. `max` defaults to no limit.
104
- */
105
- budget?: RunBudget;
106
- /** Pauses before a round is read again after the store was locked; tests pass one that does not wait. */
107
- wait?: (ms: number) => Promise<void>;
108
- }
109
7
  export declare const syncMail: (source: MailSource, store: MessageStore, options: MailSyncOptions) => Promise<MailSyncResult>;
package/dist/mail/sync.js CHANGED
@@ -12,11 +12,13 @@ import { dropMissing, tooManyGone } from "./gone.js";
12
12
  import { mailHeadersOf } from "./headers.js";
13
13
  import { flagsToRefresh, proveByLocation } from "./locations.js";
14
14
  import { MAX_BODY_CHARS, parseMail } from "./parse.js";
15
+ import { completePartial } from "./partial.js";
15
16
  import { FETCH_SLICE, keyOf, readQuick } from "./quick.js";
16
17
  import { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
17
18
  import { retryUnread } from "./retry.js";
18
19
  import { ruleTagger, settleRuleTags } from "./rule-tags.js";
19
20
  import { decide, ENVELOPE, envelopeBox, envelopeBoxOf, tagsOf } from "./rules.js";
21
+ import { searchRefused } from "./source.js";
20
22
  const DAY = 86_400_000;
21
23
  /** IMAP's SINCE counts whole days in the server's zone, so the window's first days are not trusted. */
22
24
  const EDGE = 2 * DAY;
@@ -32,6 +34,29 @@ const LIST_SLICE = 200;
32
34
  const STORE_BUSY_TRIES = 5;
33
35
  const STORE_BUSY_WAIT_MS = 2_000;
34
36
  export const syncMail = async (source, store, options) => {
37
+ const result = await syncRounds(source, store, options);
38
+ if (options.completePartial === false || options.retryAttachments || options.account.mode === "mbox")
39
+ return result;
40
+ if (result.stopped !== undefined || options.signal?.aborted)
41
+ return result;
42
+ const partial = await completePartial({
43
+ store,
44
+ source,
45
+ accountId: await store.accounts.saveAccount(mailAccountKey(options.account.address), {
46
+ name: options.account.address,
47
+ }),
48
+ address: options.account.address,
49
+ folders: () => mailFolders(options.account, source),
50
+ maxBodyChars: options.maxBodyChars ?? MAX_BODY_CHARS,
51
+ maxAttachmentChars: options.maxAttachmentChars ?? MAX_ATTACHMENT_CHARS,
52
+ loadEngine: options.loadEngine ?? importEngine,
53
+ now: (options.now ?? Date.now)(),
54
+ ...(options.budget ? { take: () => options.budget?.take() ?? null } : {}),
55
+ ...(options.signal ? { signal: options.signal } : {}),
56
+ });
57
+ return partial.completed || partial.left ? { ...result, partial } : result;
58
+ };
59
+ const syncRounds = async (source, store, options) => {
35
60
  const { budget, index } = options;
36
61
  if (budget === undefined || options.retryAttachments || options.account.mode === "mbox")
37
62
  return syncRound(source, store, options);
@@ -45,6 +70,7 @@ export const syncMail = async (source, store, options) => {
45
70
  unlisted: new Set(),
46
71
  quick: new Map(),
47
72
  quickFolders: new Set(),
73
+ refused: new Set(),
48
74
  stopped: null,
49
75
  remaining: 0,
50
76
  asked: 0,
@@ -195,10 +221,16 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
195
221
  /** A quick folder's stored mail as it was before this run changed anything, with the UID of each. */
196
222
  const folderMail = new Map();
197
223
  const refreshed = new Map();
224
+ /** Folders whose server refused SEARCH: nothing of them is known, so nothing of them is proved or moved. */
225
+ const refused = new Set();
198
226
  const holdsMail = async (folder) => (await store.mail.emails({ accountId, mailbox: folder, limit: 1 })).length > 0;
199
227
  for (const folder of folders) {
200
228
  if (mboxPoint !== undefined)
201
229
  break;
230
+ if (plan?.refused.has(folder)) {
231
+ refused.add(folder);
232
+ continue;
233
+ }
202
234
  let searched;
203
235
  if (source.search !== undefined && !snapshot) {
204
236
  searched = plan?.searches.get(folder);
@@ -209,7 +241,16 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
209
241
  capped = true;
210
242
  continue;
211
243
  }
212
- searched = await source.search(since, folder);
244
+ try {
245
+ searched = await source.search(since, folder);
246
+ }
247
+ catch (error) {
248
+ if (!searchRefused(error))
249
+ throw error;
250
+ refused.add(folder);
251
+ plan?.refused.add(folder);
252
+ continue;
253
+ }
213
254
  plan?.searches.set(folder, searched);
214
255
  }
215
256
  }
@@ -380,6 +421,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
380
421
  let skipped;
381
422
  if (snapshot)
382
423
  skipped = "an mbox file is a snapshot; mail missing from it is not proven deleted";
424
+ else if (refused.size > 0)
425
+ skipped = `the server refused to search ${[...refused].join(", ")}; no deletion is checked until it answers`;
383
426
  else if (plan !== undefined && (capped || plan.tried.size > plan.asked || plan.only !== undefined))
384
427
  skipped = "this run read the window in parts; a run that lists all of it at once checks deletions";
385
428
  else if (quickRan.size > 0) {
@@ -427,7 +470,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
427
470
  skipped ??= older.skipped;
428
471
  }
429
472
  if (!snapshot)
430
- await store.mail.setMailboxes(accountId, folders.map(mailbox), new Map([...foundFolders].map(([id, found]) => [id, [...found]])), startedAt);
473
+ await store.mail.setMailboxes(accountId, folders.filter((folder) => !refused.has(folder)).map(mailbox), new Map([...foundFolders].map(([id, found]) => [id, [...found]])), startedAt);
431
474
  // A message's label list is whole, so every label the account has is replaced for each message read.
432
475
  if (labelsOf.size && !snapshot) {
433
476
  const known = (await store.mail.mailboxes(accountId)).filter(({ kind }) => kind === "label");
@@ -766,7 +809,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
766
809
  ],
767
810
  ]),
768
811
  ]), startedAt);
769
- const incomplete = capped || (plan !== undefined && (plan.stopped !== null || plan.failed.size > 0));
812
+ const incomplete = capped || refused.size > 0 || (plan !== undefined && (plan.stopped !== null || plan.failed.size > 0));
770
813
  const complete = !full && !incomplete && unreadable.length === 0 && (plan?.unreadable.size ?? 0) === 0;
771
814
  const bulk = new Map(listed.filter((item) => item.headers?.bulk).map((item) => [item.messageId, [bulkBox.externalId]]));
772
815
  if (bulk.size)
@@ -782,7 +825,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
782
825
  state: complete ? "window" : "partial",
783
826
  identity: account.mode === "imap" ? "message-id-or-uidvalidity-folder-uid" : "gmail-message-id",
784
827
  };
785
- if (!snapshot && saveCoverage)
828
+ // A refused folder says nothing of its mail, so the coverage an earlier run proved stays as it was.
829
+ if (!snapshot && saveCoverage && refused.size === 0)
786
830
  await store.sync.setSyncState(key, "mail_coverage", JSON.stringify(coverage));
787
831
  const tagging = tagger.outcome();
788
832
  await settleRuleTags(store, accountId, rules, tagging);
@@ -802,6 +846,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
802
846
  ...(attachments.length ? { attachments } : {}),
803
847
  ...(unreadable.length ? { unreadable } : {}),
804
848
  ...(skipped === undefined ? {} : { deletionsSkipped: skipped }),
849
+ ...(refused.size ? { refusedFolders: [...refused] } : {}),
805
850
  ...(searches.size > 0 ? { pass: resumed.size > 0 ? "resumed" : "full" } : {}),
806
851
  ...(tagging.reused.length ? { tagsReused: tagging.reused } : {}),
807
852
  ...(tagging.skipped.length ? { tagsSkipped: tagging.skipped } : {}),
@@ -1,6 +1,6 @@
1
1
  import type { FileStamp } from "../resume.js";
2
2
  import type { MailCriteria } from "../server-query.js";
3
- import type { MailSource } from "../source.js";
3
+ import { type MailSource } from "../source.js";
4
4
  export type FakePerson = [name: string | null, address: string];
5
5
  /** One message of a fake Gmail-like mailbox: its ids, envelope and the parts `rawOf` writes as RFC 5322. */
6
6
  export interface FakeMail {
@@ -39,6 +39,8 @@ export interface FakeMailbox {
39
39
  validity?: number;
40
40
  /** Each answer of `search` and `list` ends in this failure. */
41
41
  searchFails?: string;
42
+ /** Folders whose SEARCH the server refuses: `search` and `query` throw `SEARCH_REFUSED`. */
43
+ refusesSearch?: string[];
42
44
  /** A refused header fetch fails the whole listing. */
43
45
  headersFail?: boolean;
44
46
  moveFails?: boolean;
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { GMAIL_ALL_MAIL } from "../folders.js";
3
3
  import { headersOf } from "../headers.js";
4
+ import { SEARCH_REFUSED } from "../source.js";
4
5
  /** As IMAP SEARCH matches: a case-blind substring of the field, every part required. */
5
6
  const matches = (m, criteria) => {
6
7
  const has = (field, value) => field.toLowerCase().includes(value.toLowerCase());
@@ -73,9 +74,14 @@ export const fakeMailSource = (mailbox) => {
73
74
  throw failure(`Gmail has no such mailbox: ${folder}`);
74
75
  return mailbox.messages.filter((m) => (folder === trash() ? m.folder === "trash" : m.folder !== "trash") && !m.absentFrom?.includes(folder));
75
76
  };
77
+ const refuse = (folder) => {
78
+ if (mailbox.refusesSearch?.includes(folder))
79
+ throw new CliError("provider_error", `the mail server refused to search ${folder}`, { reason: SEARCH_REFUSED });
80
+ };
76
81
  const search = async (since, folder) => {
77
82
  if (mailbox.searchFails)
78
83
  throw failure(mailbox.searchFails);
84
+ refuse(folder);
79
85
  const from = Date.UTC(since.getUTCFullYear(), since.getUTCMonth(), since.getUTCDate());
80
86
  return {
81
87
  validity: String(mailbox.validity ?? 12),
@@ -137,6 +143,7 @@ export const fakeMailSource = (mailbox) => {
137
143
  count("search");
138
144
  if (mailbox.searchFails)
139
145
  throw failure(mailbox.searchFails);
146
+ refuse(folder);
140
147
  mailbox.queried?.push({ criteria, folder });
141
148
  return {
142
149
  validity: String(mailbox.validity ?? 12),
@@ -180,14 +187,15 @@ export const fakeMailSource = (mailbox) => {
180
187
  if (messageIds.includes(m.messageId))
181
188
  m.labels = [...new Set([...(m.labels ?? []), ...labels])];
182
189
  },
183
- fetchRaw: async (items) => {
190
+ fetchRaw: async (items, options) => {
184
191
  count("fetchRaw");
185
192
  mailbox.read?.push(...items.map(({ uid }) => uid));
186
193
  return items.map((item) => {
187
194
  const m = mailbox.messages.find((each) => each.uid === item.uid);
188
195
  if (m === undefined || mailbox.unreadable?.includes(item.uid) || failsNow(item.uid))
189
196
  return failure(`email ${item.uid} was not read`);
190
- return rawOf(m);
197
+ const raw = rawOf(m);
198
+ return options?.bytes === undefined ? raw : raw.subarray(0, options.bytes);
191
199
  });
192
200
  },
193
201
  };
@@ -70,8 +70,6 @@ export declare const storeIfOpen: (deps: ServiceDeps) => Promise<{
70
70
  export declare const fromStore: (deps: Pick<ServiceDeps, "offline" | "reads">) => boolean;
71
71
  /** What a store-mode read answers for a chat the store holds nothing of. */
72
72
  export declare const nothingStored: (messenger: MessengerTraits) => string;
73
- /** Where a store-mode messenger's history comes from, for what it cannot do. */
74
- export declare const PUSHED = "this messenger's history is read from the local store";
75
73
  export declare const OFFLINE = "--offline answers only from what is kept locally: `chats list|show`, `messages list|show|context|download`, `contacts list|show` and `review`";
76
74
  /** Over a connection that is already open — an MCP session holds one for minutes. */
77
75
  export declare const onlineDeps: (messenger: ServiceMessenger, adapter: MessengerAdapter, guard: SendGuard, where?: {
@@ -53,8 +53,6 @@ export const storeIfOpen = async (deps) => {
53
53
  export const fromStore = (deps) => deps.offline || deps.reads === "store";
54
54
  /** What a store-mode read answers for a chat the store holds nothing of. */
55
55
  export const nothingStored = (messenger) => `nothing stored for this chat yet — keep \`${typedCommand(messenger.app.command, "serve")}\` running`;
56
- /** Where a store-mode messenger's history comes from, for what it cannot do. */
57
- export const PUSHED = "this messenger's history is read from the local store";
58
56
  export const OFFLINE = "--offline answers only from what is kept locally: `chats list|show`, `messages list|show|context|download`, `contacts list|show` and `review`";
59
57
  /** Over a connection that is already open — an MCP session holds one for minutes. */
60
58
  export const onlineDeps = (messenger, adapter, guard, where = {}) => ({