@wirecat/cli-core 0.549.0 → 0.550.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,53 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.550.0 — 02.11.2026
11
+
12
+ ### Changed — may break callers
13
+
14
+ - **cli-core registers `mail.search-refused`.** A CLI that listed it in its own reasons gets "listed twice" from
15
+ `defineReasons` and must drop its entry; mail does.
16
+ - **`MailStore` has `markPartial(accountId, externalId, mark)`, and `partialEmails` answers `stuck` and leaves out an
17
+ email marked `partial.stuck`.** A store of its own must add both. `PartialOutcome` (`syncMail`'s `partial`) gains
18
+ `stuck`, and `partial` is answered whenever any of `completed`, `left` or `stuck` is not 0.
19
+ - **cli-core now needs `@wirecat/cli-base` 0.74.0**, which holds the `--compact` switch's name. A CLI that pins
20
+ cli-base bumps it to 0.74.0 together with cli-core.
21
+
22
+ ### Fixed
23
+
24
+ - **A partial email the server no longer gives at its stored place is given up on instead of read whole on every
25
+ sync.** `completePartial` reads it at its newest stored location under that location's UIDVALIDITY (the new optional
26
+ `Quick.validity`, which a source checks before reading), counts each failed read in `metadata.partial.tries`, and
27
+ after 3, or at once when the place holds another message, marks it `partial.stuck`: it stays readable as it is, is
28
+ counted in `partial.stuck`, and no longer takes one of the 100 places a run reads, so it never holds back newer ones.
29
+ An email with no stored location is given up on at once.
30
+ - **A server search never stores a partial copy over an email already stored whole.** A sync in another process could
31
+ save the email between the search's check and its write; the write then dropped every attachment. The store now
32
+ keeps a live whole email as it is when a copy marked `partial` is saved over it.
33
+ - **`--brief` now cuts a messenger search hit.** It only knew mail's camelCase hits, so `search messages --brief` in
34
+ tg, max and wa printed every field; it now keeps `ref`, `chat_title`, `timestamp`, `sender_name`, `excerpt` and
35
+ `score`. The default and `--full` answers are unchanged. Commands that print through `outputFor` now honour
36
+ `--brief` and `--full` too, and refuse them together as the others do.
37
+
38
+ ### Added
39
+
40
+ - **`--compact`: one line per item for an agent, from the same answer `--json` prints.** `messages list <chat>
41
+ --compact` prints `#<id> · 10-01 09:12 · Анна · <text>` under one header line that names the shared ref prefix
42
+ (`ref: msg:max/500/-1001/<id>`), what every row has in common (the chat's title), the date span and the zone. Rows
43
+ from several chats keep their whole refs. Text stays on one line with control and direction characters made
44
+ visible, and is cut at 400 characters with `…(+N)`; `--full` keeps it whole. The paging, `missing`, the next steps
45
+ and an incomplete answer follow as `# more:`, `# missing:`, `# next:` and `# complete: false` lines on stdout, and
46
+ each command line in them carries `--compact` again. On 1 000 messages of one chat it is about 48 % of the JSON
47
+ bytes; the texts alone are 40 %. `--compact` with `--json`, `--json=…`, `--jsonl` or `--brief` fails with
48
+ `validation_error` (`output.conflict`); it never prompts, and exits with the code JSON would. MCP keeps JSON.
49
+ - **Every command that answers through `renderAnswer` gets it.** A command draws with its own row
50
+ (`answerEntity(cmd, name, schema, { row })`), its app's (`rows: { name: shape }` in the program definition,
51
+ which also draws an app entity listed inside one thing), the shared one in `SHARED_ROWS` (`message`,
52
+ `message.summary`, `message.hit`, `chat`), or the generic row: key, ref or id · title, name or path · state,
53
+ status or due date · text. A record or a list is never printed as a column's value. `RowShape` is data, and `compactText(body, shape,
54
+ options)` is pure, for an app that prints its own answers.
55
+ - What a command prints without `renderAnswer` stays JSON under `--compact`.
56
+
10
57
  ## 0.549.0 — 02.11.2026
11
58
 
12
59
  ### Added
@@ -293,22 +340,6 @@ shape of this file.
293
340
  **`knowledge.decideSuggestedLink(id, decision)`** an older unconfirmed link, each in one transaction: the link and
294
341
  the decision land together or not at all. A decision's `reason` is kept: on a rejected proposal as its result, on a
295
342
  fact in `metadata.decision`, on an evidence findings receipt.
296
- - **`--compact`: one line per item for an agent, from the same answer `--json` prints.** `messages list <chat>
297
- --compact` prints `#<id> · 10-01 09:12 · Анна · <text>` under one header line that names the shared ref prefix
298
- (`ref: msg:max/500/-1001/<id>`), what every row has in common (the chat's title), the date span and the zone. Rows
299
- from several chats keep their whole refs. Text stays on one line with control and direction characters made
300
- visible, and is cut at 400 characters with `…(+N)`; `--full` keeps it whole. The paging, `missing`, the next steps
301
- and an incomplete answer follow as `# more:`, `# missing:`, `# next:` and `# complete: false` lines on stdout, and
302
- each command line in them carries `--compact` again. On 1 000 messages of one chat it is about 48 % of the JSON
303
- bytes; the texts alone are 40 %. `--compact` with `--json`, `--json=…`, `--jsonl` or `--brief` fails with
304
- `validation_error` (`output.conflict`); it never prompts, and exits with the code JSON would. MCP keeps JSON.
305
- - **Every command that answers through `renderAnswer` gets it.** A command draws with its own row
306
- (`answerEntity(cmd, name, schema, { row })`), its app's (`rows: { name: shape }` in the program definition,
307
- which also draws an app entity listed inside one thing), the shared one in `SHARED_ROWS` (`message`,
308
- `message.summary`, `message.hit`, `chat`), or the generic row: key, ref or id · title, name or path · state,
309
- status or due date · text. A record or a list is never printed as a column's value. `RowShape` is data, and `compactText(body, shape,
310
- options)` is pure, for an app that prints its own answers.
311
- - What a command prints without `renderAnswer` stays JSON under `--compact`.
312
343
 
313
344
  ### Changed — may break callers
314
345
 
@@ -351,8 +382,6 @@ shape of this file.
351
382
  the agent's bot and reason otherwise.
352
383
  An agent's confirmation of a relationship is recorded in the link's provenance, and of a fact or proposal as its
353
384
  decider.
354
- - **cli-core now needs `@wirecat/cli-base` 0.74.0**, which holds the `--compact` switch's name. A CLI that pins
355
- cli-base bumps it to 0.74.0 together with cli-core.
356
385
 
357
386
  ### Fixed
358
387
 
@@ -366,10 +395,6 @@ shape of this file.
366
395
  `starred:`.
367
396
  - **An inclusive start keeps a message sent exactly at it.** `messages list 'date>=2026-10-01'` skipped a message
368
397
  sent at the first millisecond of the day, and `date<="…"` one sent at that moment.
369
- - **`--brief` now cuts a messenger search hit.** It only knew mail's camelCase hits, so `search messages --brief` in
370
- tg, max and wa printed every field; it now keeps `ref`, `chat_title`, `timestamp`, `sender_name`, `excerpt` and
371
- `score`. The default and `--full` answers are unchanged. Commands that print through `outputFor` now honour
372
- `--brief` and `--full` too, and refuse them together as the others do.
373
398
 
374
399
  ### Security
375
400
 
@@ -50,6 +50,7 @@ const OTHERS = [
50
50
  validation("extraction.text-too-long", "the text is too long for memory extraction"),
51
51
  validation("file.locked", "another process holds the file's lock"),
52
52
  ["mail.source-failed", "provider_error", "the mail server refused or did not complete a request"],
53
+ ["mail.search-refused", "provider_error", "the mail server refused to search a folder"],
53
54
  ["mirror.partial", "provider_error", "some mirrored files were not synced"],
54
55
  ["profile.wrong-account", "authentication_error", "the signed-in source account does not match this profile"],
55
56
  validation("push.too-many-files", "the folder holds more files than one push takes"),
@@ -6,16 +6,22 @@ import type { MailSource } from "./source.js";
6
6
  export declare const PARTIAL = "partial";
7
7
  /** How many partial emails one sync reads whole at most; the rest wait for the next run. */
8
8
  export declare const PARTIAL_PER_RUN = 100;
9
+ /** Failed reads after which a partial email is given up on (`partial.stuck`) and stays as it is. */
10
+ export declare const PARTIAL_TRIES = 3;
9
11
  export interface PartialOutcome {
10
12
  /** Read whole this run, the mark cleared. */
11
13
  completed: number;
12
- /** Still partial after this run. */
14
+ /** Still partial after this run, tried again by the next. */
13
15
  left: number;
16
+ /** Given up on: the server no longer gives this email at its stored place. They stay readable as they are. */
17
+ stuck: number;
14
18
  }
15
19
  /**
16
20
  * Reads whole the emails a server search stored from their first bytes, so the archive holds their attachments too.
17
- * Sync never reads stored mail again, so without this step a partial email would stay partial. Gmail's are found by
18
- * their id; elsewhere at a stored location, trusted only when the bytes read there carry the stored id.
21
+ * Sync never reads stored mail again, so without this step a partial email would stay partial. A source with `find`
22
+ * finds them by id; elsewhere they are read at their newest stored location under its UIDVALIDITY, trusted only when
23
+ * `identify` gives the stored id for the bytes read there. Each failed read is counted, and after `PARTIAL_TRIES`, or
24
+ * at once when the place holds another message, the email is left partial for good.
19
25
  */
20
26
  export declare const completePartial: ({ store, source, accountId, address, folders, maxBodyChars, maxAttachmentChars, loadEngine, now, take, signal, }: {
21
27
  store: MessageStore;
@@ -6,15 +6,29 @@ import { parseMail } from "./parse.js";
6
6
  export const PARTIAL = "partial";
7
7
  /** How many partial emails one sync reads whole at most; the rest wait for the next run. */
8
8
  export const PARTIAL_PER_RUN = 100;
9
+ /** Failed reads after which a partial email is given up on (`partial.stuck`) and stays as it is. */
10
+ export const PARTIAL_TRIES = 3;
9
11
  /**
10
12
  * 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
+ * Sync never reads stored mail again, so without this step a partial email would stay partial. A source with `find`
14
+ * finds them by id; elsewhere they are read at their newest stored location under its UIDVALIDITY, trusted only when
15
+ * `identify` gives the stored id for the bytes read there. Each failed read is counted, and after `PARTIAL_TRIES`, or
16
+ * at once when the place holds another message, the email is left partial for good.
13
17
  */
14
18
  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);
19
+ const { emails, total, stuck } = await store.mail.partialEmails(accountId, PARTIAL_PER_RUN);
16
20
  if (emails.length === 0)
17
- return { completed: 0, left: 0 };
21
+ return { completed: 0, left: 0, stuck };
22
+ let given = 0;
23
+ // A failed read is counted, so an email the server no longer gives is not read whole on every sync forever.
24
+ const failed = async (email, forGood = false) => {
25
+ const mark = (email.metadata?.[PARTIAL] ?? {});
26
+ const tries = Number(mark.tries ?? 0) + 1;
27
+ const gaveUp = forGood || tries >= PARTIAL_TRIES;
28
+ await store.mail.markPartial(accountId, email.externalId, { ...mark, tries, ...(gaveUp ? { stuck: true } : {}) });
29
+ if (gaveUp)
30
+ given++;
31
+ };
18
32
  const byId = new Map(emails.map((email) => [email.externalId, email]));
19
33
  const items = new Map();
20
34
  if (source.find) {
@@ -23,21 +37,27 @@ export const completePartial = async ({ store, source, accountId, address, folde
23
37
  if (left.length === 0)
24
38
  break;
25
39
  if (take?.())
26
- return { completed: 0, left: total };
40
+ return { completed: 0, left: total, stuck };
27
41
  for (const found of await source.find(left, folder)) {
28
42
  const email = byId.get(found.messageId);
29
43
  if (email && !items.has(email))
30
44
  items.set(email, found);
31
45
  }
32
46
  }
47
+ for (const email of emails)
48
+ if (!items.has(email))
49
+ await failed(email);
33
50
  }
34
51
  else if (source.identify)
35
52
  for (const email of emails) {
36
53
  const place = email.locations[0];
37
- if (place)
54
+ if (!place)
55
+ await failed(email, true);
56
+ else
38
57
  items.set(email, {
39
58
  uid: String(place.uid),
40
59
  folder: place.folder,
60
+ validity: place.uidvalidity,
41
61
  receivedAt: new Date(email.receivedAt ?? 0).toISOString(),
42
62
  });
43
63
  }
@@ -58,11 +78,15 @@ export const completePartial = async ({ store, source, accountId, address, folde
58
78
  const attachments = [];
59
79
  for (const [index, [email, item]] of chunk.entries()) {
60
80
  const raw = raws[index];
61
- if (!(raw instanceof Uint8Array))
81
+ if (!(raw instanceof Uint8Array)) {
82
+ await failed(email);
62
83
  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)
84
+ }
85
+ // Only the stored id proves the bytes at a stored UID are this email; another one there will not change back.
86
+ if (!source.find && source.identify?.(raw, item).messageId !== email.externalId) {
87
+ await failed(email, true);
65
88
  continue;
89
+ }
66
90
  try {
67
91
  const mail = await parseMail(raw, maxBodyChars);
68
92
  const files = await readAttachments(mail, email.externalId, { loadEngine, maxAttachmentChars, at: now, ...(signal ? { signal } : {}) }, attachments);
@@ -87,8 +111,9 @@ export const completePartial = async ({ store, source, accountId, address, folde
87
111
  catch {
88
112
  if (signal?.aborted)
89
113
  break;
114
+ await failed(email);
90
115
  }
91
116
  }
92
117
  }
93
- return { completed, left: total - completed };
118
+ return { completed, left: total - completed - given, stuck: stuck + given };
94
119
  };
@@ -24,7 +24,13 @@ export interface Listed {
24
24
  headers?: MailHeaders;
25
25
  }
26
26
  /** A message as `quick` lists it: everything but the ids, which a source without a cheap one reads from the raw bytes. */
27
- export type Quick = Omit<Listed, "messageId" | "threadId">;
27
+ export type Quick = Omit<Listed, "messageId" | "threadId"> & {
28
+ /**
29
+ * The folder's UIDVALIDITY the UID was stored under. `fetchRaw` reads nothing for it when the folder's differs, and
30
+ * `identify` works the ids out under it.
31
+ */
32
+ validity?: string;
33
+ };
28
34
  export interface Searched {
29
35
  /** Absent when the server does not say; then no UID is trusted across runs. */
30
36
  validity?: string;
package/dist/mail/sync.js CHANGED
@@ -54,7 +54,7 @@ export const syncMail = async (source, store, options) => {
54
54
  ...(options.budget ? { take: () => options.budget?.take() ?? null } : {}),
55
55
  ...(options.signal ? { signal: options.signal } : {}),
56
56
  });
57
- return partial.completed || partial.left ? { ...result, partial } : result;
57
+ return partial.completed || partial.left || partial.stuck ? { ...result, partial } : result;
58
58
  };
59
59
  const syncRounds = async (source, store, options) => {
60
60
  const { budget, index } = options;
@@ -23,7 +23,8 @@ export declare const locate: (database: CacheDatabase, accountId: number, emailI
23
23
  export declare const folderEmails: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string) => FolderEmail[];
24
24
  /**
25
25
  * An email stored from its first bytes only (`metadata.partial`): a sync reads it whole and clears the mark.
26
- * `partialEmails` lists an account's live ones newest first, with how many there are in all.
26
+ * `partialEmails` lists an account's live ones newest first, with how many there are in all; one given up on
27
+ * (`partial.stuck`) is left out and only counted, so it never holds back newer ones. Its newest location comes first.
27
28
  */
28
29
  export interface PartialEmail {
29
30
  externalId: string;
@@ -35,6 +36,26 @@ export interface PartialEmail {
35
36
  export declare const partialEmails: (database: CacheDatabase, accountId: number, limit: number) => {
36
37
  emails: PartialEmail[];
37
38
  total: number;
39
+ stuck: number;
38
40
  };
41
+ /** Replaces a partial email's `metadata.partial`, as a failed read records itself; a whole email is left alone. */
42
+ export declare const markPartial: (database: CacheDatabase, accountId: number, externalId: string, mark: Metadata) => {
43
+ changes: number;
44
+ };
45
+ export interface PartialMailStore {
46
+ partialEmails(accountId: number, limit: number): Promise<{
47
+ emails: PartialEmail[];
48
+ total: number;
49
+ stuck: number;
50
+ }>;
51
+ /** Records a failed read of a partial email in its `metadata.partial`; a whole email is left alone. */
52
+ markPartial(accountId: number, externalId: string, mark: Metadata): Promise<void>;
53
+ }
54
+ export declare const partialMail: (database: CacheDatabase) => PartialMailStore;
55
+ /**
56
+ * A live email stored whole, which a partial copy must not replace: a server search checks what is held before its
57
+ * slow read, and a sync in another process may store the email whole in between.
58
+ */
59
+ export declare const storedWhole: (database: CacheDatabase, emailId: number) => boolean;
39
60
  export declare const setLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, found: ReadonlyMap<string, number[]>) => number;
40
61
  export declare const forgetLocations: (database: CacheDatabase, accountId: number, folder: string, uidvalidity: string, uids: number[]) => void;
@@ -1,4 +1,5 @@
1
1
  import { fromJson } from "./events.js";
2
+ import { write } from "./transaction.js";
2
3
  export const locate = (database, accountId, emailId, where) => database
3
4
  .prepare(`INSERT OR REPLACE INTO email_locations (mailbox_id, uidvalidity, uid, email_id)
4
5
  SELECT id, ?, ?, ? FROM mailboxes WHERE account_id = ? AND external_id = ?`)
@@ -26,14 +27,22 @@ export const partialEmails = (database, accountId, limit) => {
26
27
  const rows = database
27
28
  .prepare(`SELECT e.external_id, t.external_id AS thread, coalesce(e.received_at, e.sent_at) AS at, e.metadata,
28
29
  count(*) OVER () AS total,
29
- (SELECT json_group_array(json_object('folder', b.external_id, 'uidvalidity', l.uidvalidity, 'uid', l.uid))
30
- FROM email_locations l JOIN mailboxes b ON b.id = l.mailbox_id WHERE l.email_id = e.id) AS places
30
+ (SELECT json_group_array(json_object('folder', folder, 'uidvalidity', uidvalidity, 'uid', uid))
31
+ FROM (SELECT b.external_id AS folder, l.uidvalidity, l.uid
32
+ FROM email_locations l JOIN mailboxes b ON b.id = l.mailbox_id
33
+ WHERE l.email_id = e.id ORDER BY l.rowid DESC)) AS places
31
34
  FROM emails e JOIN email_threads t ON t.id = e.email_thread_id
32
35
  WHERE e.account_id = ? AND e.deleted_at IS NULL AND json_extract(e.metadata, '$.partial') IS NOT NULL
36
+ AND json_extract(e.metadata, '$.partial.stuck') IS NULL
33
37
  ORDER BY at DESC, e.id DESC LIMIT ?`)
34
38
  .all(accountId, limit);
39
+ const stuck = database
40
+ .prepare(`SELECT count(*) AS n FROM emails WHERE account_id = ? AND deleted_at IS NULL
41
+ AND json_extract(metadata, '$.partial.stuck') IS NOT NULL`)
42
+ .get(accountId);
35
43
  return {
36
44
  total: rows.length ? Number(rows[0]?.total) : 0,
45
+ stuck: Number(stuck?.n ?? 0),
37
46
  emails: rows.map((row) => ({
38
47
  externalId: String(row.external_id),
39
48
  threadExternalId: String(row.thread),
@@ -47,6 +56,24 @@ export const partialEmails = (database, accountId, limit) => {
47
56
  })),
48
57
  };
49
58
  };
59
+ /** Replaces a partial email's `metadata.partial`, as a failed read records itself; a whole email is left alone. */
60
+ export const markPartial = (database, accountId, externalId, mark) => database
61
+ .prepare(`UPDATE emails SET metadata = json_set(metadata, '$.partial', json(?)) WHERE account_id = ? AND external_id = ?
62
+ AND json_extract(metadata, '$.partial') IS NOT NULL`)
63
+ .run(JSON.stringify(mark), accountId, externalId);
64
+ export const partialMail = (database) => ({
65
+ partialEmails: async (accountId, limit) => partialEmails(database, accountId, limit),
66
+ markPartial: async (accountId, externalId, mark) => {
67
+ write(database, () => markPartial(database, accountId, externalId, mark));
68
+ },
69
+ });
70
+ /**
71
+ * A live email stored whole, which a partial copy must not replace: a server search checks what is held before its
72
+ * slow read, and a sync in another process may store the email whole in between.
73
+ */
74
+ export const storedWhole = (database, emailId) => database
75
+ .prepare("SELECT 1 FROM emails WHERE id = ? AND deleted_at IS NULL AND json_extract(metadata, '$.partial') IS NULL")
76
+ .get(emailId) !== undefined;
50
77
  export const setLocations = (database, accountId, folder, uidvalidity, found) => {
51
78
  database
52
79
  .prepare(`DELETE FROM email_locations WHERE uidvalidity <> ?
@@ -3,13 +3,13 @@ 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, type PartialEmail } from "./email-locations.js";
6
+ import { type EmailLocationInput, type FolderEmail, type PartialMailStore } from "./email-locations.js";
7
7
  import { type MailRulesStore } from "./mail-rules.js";
8
8
  import { type AttachmentInput } from "./meetings.js";
9
9
  import type { StoreContext } from "./open.js";
10
10
  import { type SyncStateSave } from "./sync.js";
11
11
  import { type ThreadFilter } from "./thread-columns.js";
12
- export type { EmailLocationInput, FolderEmail, PartialEmail };
12
+ export type { EmailLocationInput, FolderEmail, PartialEmail } from "./email-locations.js";
13
13
  export interface EmailAddress {
14
14
  address: string;
15
15
  name: string | null;
@@ -205,7 +205,7 @@ export interface SenderScore {
205
205
  demoted?: boolean;
206
206
  }
207
207
  /** Mail in tables of its own: threads, emails, recipients and mailboxes, keyed by account and Message-ID. */
208
- export interface MailStore {
208
+ export interface MailStore extends PartialMailStore {
209
209
  saveThread(input: ThreadSave): Promise<ThreadDetails>;
210
210
  /** Several threads and sync state in one transaction: a slice of mail commits with its resume point, or not at all. */
211
211
  saveThreads(inputs: ThreadSave[], states?: SyncStateSave[]): Promise<ThreadDetails[]>;
@@ -217,10 +217,6 @@ export interface MailStore {
217
217
  emails(filter?: EmailFilter): Promise<Email[]>;
218
218
  /** Each matching email's Message-ID and mailboxes, without text or people: cheap over a whole mailbox. */
219
219
  emailRefs(filter?: Omit<EmailFilter, "limit" | "offset">): Promise<EmailRef[]>;
220
- partialEmails(accountId: number, limit: number): Promise<{
221
- emails: PartialEmail[];
222
- total: number;
223
- }>;
224
220
  /** Each matching email's sender, recipients, list, size, flag and mailboxes, oldest first; no text. */
225
221
  emailFacts(filter?: Omit<EmailFilter, "limit" | "offset">): Promise<EmailFacts[]>;
226
222
  /**
@@ -2,7 +2,7 @@ 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 { folderEmails, forgetLocations, locate, partialEmails, setLocations, } from "./email-locations.js";
5
+ import { folderEmails, forgetLocations, locate, partialMail, setLocations, storedWhole, } from "./email-locations.js";
6
6
  import { markEmailsDeleted, markEmailsRestored, recountEmailThread as recount, setEmailFlags } from "./email-state.js";
7
7
  import { bool, flag, fromJson, int, page, str, toJson } from "./events.js";
8
8
  import { folded } from "./folded.js";
@@ -204,6 +204,8 @@ const saveEmail = (database, orm, accountId, threadId, mail, now) => {
204
204
  const found = database
205
205
  .prepare("SELECT id, email_thread_id FROM emails WHERE account_id = ? AND external_id = ?")
206
206
  .get(accountId, mail.externalId);
207
+ if (found && mail.metadata?.partial !== undefined && storedWhole(database, Number(found.id)))
208
+ return null;
207
209
  let emailId;
208
210
  if (found) {
209
211
  emailId = Number(found.id);
@@ -469,7 +471,7 @@ export const mailStoreOver = ({ database, orm, now }, stemmerFor = stemmerCache(
469
471
  mailboxes: JSON.parse(String(row.boxes)),
470
472
  }));
471
473
  },
472
- partialEmails: async (accountId, limit) => partialEmails(database, accountId, limit),
474
+ ...partialMail(database),
473
475
  async emailFacts(filter = {}) {
474
476
  const [where, params] = emailWhere(filter);
475
477
  return database
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.549.0",
3
+ "version": "0.550.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",