@wirecat/cli-core 0.525.0 → 0.526.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,25 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.526.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`mailIds` from `@wirecat/cli-core/mail`: one email id for mail and memo.** On every server but Gmail the id is
15
+ the hash of the Message-ID (the UID with its folder and UIDVALIDITY when there is none) and the thread is the first
16
+ id of References, else In-Reply-To. OBJECTID's EMAILID is not used, so a server that turns OBJECTID on keeps its
17
+ ids. Gmail keeps X-GM-MSGID and X-GM-THRID.
18
+ - **`decodedLabelName` for a Gmail label already decoded.** imapflow decodes X-GM-LABELS; `labelName` decodes the
19
+ modified UTF-7 again and turned `R&D-team` into `Rteam`. `labelName` stays for the wire form.
20
+ - **`storedMailRuleSet`: the stored rules this build cannot read, with why.** A sync reports them instead of dropping
21
+ them silently; `storedMailRules` still returns only the rules.
22
+
23
+ ### Fixed
24
+
25
+ - **`conversations list` and `conversations show` no longer read a failing store as "nothing stored".** Only a
26
+ missing account answers empty or not found; a locked or broken store fails the command.
27
+ - **The messenger `search messages` help sends mail searches to `mail <profile> search messages`**, not to memo.
28
+
10
29
  ## 0.525.0 — 01.11.2026
11
30
 
12
31
  ### Added
@@ -49,7 +49,7 @@ offer \`${messenger.app.command} messages sync <chat>\` and run it only once the
49
49
  : `Search reads the local archive: what \`${messenger.app.command} serve\` has kept since the account was linked.
50
50
  Keep serve running so new messages are searchable.`} Notes are
51
51
  \`${messenger.app.command} search notes\`, messages and notes at once \`${messenger.app.command} search all\`; for mail,
52
- the separate memo CLI: \`memo search mail\`.
52
+ the mail CLI: \`mail <profile> search messages\`.
53
53
  A bare word looks in message text, voice transcripts, file names and file text. text:, transcript:,
54
54
  content: (the text of files) and filename: (their names) narrow it to one —
55
55
  \`${messenger.app.command} search messages 'content:invoice'\`. chat: narrows it to one chat; has:voice, has:file and
@@ -18,6 +18,12 @@ import { searchMailServer } from "../../mail/server-search.js";
18
18
  import { atDetail } from "../../output-detail.js";
19
19
  import { sourceKey } from "../../services/server-search.js";
20
20
  import { queryFilter, THREAD_COLUMNS } from "../../store/store.js";
21
+ /** A locked or broken store must fail the command, not read as "nothing stored yet". */
22
+ const absentAccount = (error) => {
23
+ if (error instanceof CliError && error.code === "not_found")
24
+ return undefined;
25
+ throw error;
26
+ };
21
27
  const DEFAULT_LIMIT = 20;
22
28
  const MAX_LIMIT = 100;
23
29
  const limitOf = (value) => {
@@ -252,7 +258,7 @@ const conversationsCommand = (mail) => {
252
258
  const typed = typedQuery(words);
253
259
  const query = listQuery(typed, THREAD_QUERY, "conversations list");
254
260
  const { threads, mailbox } = await mail.read(this, async (store, mailbox) => {
255
- const account = await store.accounts.storedAccount(mailbox).catch(() => undefined);
261
+ const account = await store.accounts.storedAccount(mailbox).catch(absentAccount);
256
262
  if (!account)
257
263
  return { threads: [], mailbox };
258
264
  const where = query ? queryFilter(query, THREAD_COLUMNS, { command: "conversations list" }) : undefined;
@@ -296,7 +302,7 @@ const conversationsCommand = (mail) => {
296
302
  const full = detailOf(this) === "full";
297
303
  const emails = await mail.read(this, async (store, mailbox) => {
298
304
  const thread = threadOf(mailbox, given);
299
- const account = await store.accounts.storedAccount(mailbox).catch(() => undefined);
305
+ const account = await store.accounts.storedAccount(mailbox).catch(absentAccount);
300
306
  const hits = account ? await store.mail.threadHits(account.id, thread) : [];
301
307
  if (!account || hits.length === 0)
302
308
  throw new CliError("not_found", `no stored thread ${given}`);
@@ -0,0 +1,20 @@
1
+ export interface MailIdsInput {
2
+ /** As the server's envelope gives it; read from `head` when absent. */
3
+ messageId?: string | undefined;
4
+ /** Header text holding at least References and In-Reply-To. */
5
+ head: string;
6
+ folder: string;
7
+ validity: string;
8
+ uid: string;
9
+ }
10
+ /**
11
+ * The ids of an email on any server but Gmail, whose X-GM-MSGID and X-GM-THRID are used as they are. OBJECTID's
12
+ * EMAILID is not used: mail and memo must give one email one id, and a server that turns OBJECTID on later must not
13
+ * store its mail again. The Message-ID is the identity; without one, the UID, which means nothing without
14
+ * UIDVALIDITY. The thread is the first Message-ID of References, the thread's first email (RFC 5322 §3.6.4), else
15
+ * In-Reply-To's parent, so a server without thread ids (Yandex, Mail.ru) still keeps a conversation together.
16
+ */
17
+ export declare const mailIds: ({ messageId, head, folder, validity, uid }: MailIdsInput) => {
18
+ messageId: string;
19
+ threadId: string;
20
+ };
@@ -0,0 +1,17 @@
1
+ import { createHash } from "node:crypto";
2
+ import { field, unfold } from "./headers.js";
3
+ const digest = (id) => `imap:${createHash("sha256").update(id).digest("hex")}`;
4
+ const firstId = (value) => /<[^<>\s]+>/.exec(value ?? "")?.[0];
5
+ /**
6
+ * The ids of an email on any server but Gmail, whose X-GM-MSGID and X-GM-THRID are used as they are. OBJECTID's
7
+ * EMAILID is not used: mail and memo must give one email one id, and a server that turns OBJECTID on later must not
8
+ * store its mail again. The Message-ID is the identity; without one, the UID, which means nothing without
9
+ * UIDVALIDITY. The thread is the first Message-ID of References, the thread's first email (RFC 5322 §3.6.4), else
10
+ * In-Reply-To's parent, so a server without thread ids (Yandex, Mail.ru) still keeps a conversation together.
11
+ */
12
+ export const mailIds = ({ messageId, head, folder, validity, uid }) => {
13
+ const unfolded = unfold(head);
14
+ const identity = (messageId ?? field(unfolded, "message-id"))?.trim() || `uid:${folder}:${validity}:${uid}`;
15
+ const root = firstId(field(unfolded, "references")) ?? firstId(field(unfolded, "in-reply-to"));
16
+ return { messageId: digest(identity), threadId: digest(root ?? identity) };
17
+ };
@@ -3,7 +3,8 @@ export { factsOf } from "./facts.js";
3
3
  export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
4
4
  export { dropMissing, type Gone, tooManyGone } from "./gone.js";
5
5
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
6
- export { decodeMailboxName, labelName, systemLabel } from "./labels.js";
6
+ export { type MailIdsInput, mailIds } from "./ids.js";
7
+ export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
7
8
  export { type Address, htmlToText, looksLikeHtml, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
8
9
  export { PRESET_NAMES, PRESETS, type PresetName, presetMatches } from "./presets.js";
9
10
  export { domainsToAge, groupKey, type Probe, type ProbeGroup, probeMail, SUGGEST_RULE } from "./probe.js";
@@ -11,7 +12,7 @@ export { type MailFolderProgress, mailProgress } from "./progress.js";
11
12
  export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, type MailSearch, type ShownMail, searchMail, showMail, withAttachmentText, } from "./read.js";
12
13
  export { type FileStamp, FULL_PASS_MS, MBOX_RESUME, type MboxPoint, mergeRanges, parsePoint, type Ranges, type ResumePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
13
14
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
14
- export { storedMailRule, storedMailRules } from "./rule-stored.js";
15
+ export { type MailRuleSet, storedMailRule, storedMailRuleSet, storedMailRules } from "./rule-stored.js";
15
16
  export { mapProposedTags, ruleTagger, settleRuleTags, type TagOutcome } from "./rule-tags.js";
16
17
  export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, type MailAction, type MailFacts, type MailRule, type MailRuleMatch, matches, sameMatch, tagsOf, } from "./rules.js";
17
18
  export { type DomainAge, FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, type Scored, type Signal, scoreGroup, WEIGHTS, } from "./score.js";
@@ -3,7 +3,8 @@ export { factsOf } from "./facts.js";
3
3
  export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
4
4
  export { dropMissing, tooManyGone } from "./gone.js";
5
5
  export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
6
- export { decodeMailboxName, labelName, systemLabel } from "./labels.js";
6
+ export { mailIds } from "./ids.js";
7
+ export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
7
8
  export { htmlToText, looksLikeHtml, MAX_BODY_CHARS, parseMail, } from "./parse.js";
8
9
  export { PRESET_NAMES, PRESETS, presetMatches } from "./presets.js";
9
10
  export { domainsToAge, groupKey, probeMail, SUGGEST_RULE } from "./probe.js";
@@ -11,7 +12,7 @@ export { mailProgress } from "./progress.js";
11
12
  export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, searchMail, showMail, withAttachmentText, } from "./read.js";
12
13
  export { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
13
14
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
14
- export { storedMailRule, storedMailRules } from "./rule-stored.js";
15
+ export { storedMailRule, storedMailRuleSet, storedMailRules } from "./rule-stored.js";
15
16
  export { mapProposedTags, ruleTagger, settleRuleTags } from "./rule-tags.js";
16
17
  export { conditionKeys, decide, describeMatch, ENVELOPE, envelopeBox, envelopeBoxOf, matches, sameMatch, tagsOf, } from "./rules.js";
17
18
  export { FRAUD_SIGNALS, FREE_MAIL, registrableOf, SCORE_RULE, scoreGroup, WEIGHTS, } from "./score.js";
@@ -1,5 +1,10 @@
1
1
  /** IMAP's modified UTF-7 (RFC 3501 §5.1.3): `&` opens base64 of UTF-16BE, `,` stands for `/`, `-` closes. */
2
2
  export declare const decodeMailboxName: (name: string) => string;
3
3
  export declare const systemLabel: (name: string) => string;
4
- /** A label as people read it: `\\Important` and `Important` are one label, and names are decoded. */
4
+ /**
5
+ * A label as people read it, from a name already decoded (imapflow decodes X-GM-LABELS): `\\Important` and
6
+ * `Important` are one label. Decoding twice would turn `R&D-team` into `Rteam`.
7
+ */
8
+ export declare const decodedLabelName: (name: string) => string;
9
+ /** The same from X-GM-LABELS as the server sends it, in modified UTF-7. */
5
10
  export declare const labelName: (raw: string) => string;
@@ -20,8 +20,13 @@ export const decodeMailboxName = (name) => name.replace(/&([^-]*)-/g, (_, run) =
20
20
  return text;
21
21
  });
22
22
  export const systemLabel = (name) => SYSTEM_LABELS[name.toLowerCase()] ?? name;
23
- /** A label as people read it: `\\Important` and `Important` are one label, and names are decoded. */
24
- export const labelName = (raw) => {
25
- const system = raw.startsWith("\\") ? raw.slice(1) : raw;
26
- return SYSTEM_LABELS[system.toLowerCase()] ?? decodeMailboxName(system);
23
+ /**
24
+ * A label as people read it, from a name already decoded (imapflow decodes X-GM-LABELS): `\\Important` and
25
+ * `Important` are one label. Decoding twice would turn `R&D-team` into `Rteam`.
26
+ */
27
+ export const decodedLabelName = (name) => {
28
+ const system = name.startsWith("\\") ? name.slice(1) : name;
29
+ return SYSTEM_LABELS[system.toLowerCase()] ?? system;
27
30
  };
31
+ /** The same from X-GM-LABELS as the server sends it, in modified UTF-7. */
32
+ export const labelName = (raw) => decodedLabelName(decodeMailboxName(raw));
@@ -3,7 +3,21 @@ import type { MessageStore, MailRule as StoredRule } from "../store/index.js";
3
3
  import type { MailRule } from "./rules.js";
4
4
  /** A stored rule as the sync reads it; `undefined` for one this build cannot read, so it is left to the build that can. */
5
5
  export declare const storedMailRule: (stored: StoredRule) => MailRule | undefined;
6
+ export interface MailRuleSet {
7
+ rules: MailRule[];
8
+ /**
9
+ * Stored rules this build cannot read, each with what it does not know. The sync leaves them out, and a build that
10
+ * reads them decides mail differently, so a sync reports them rather than drop them silently.
11
+ */
12
+ unreadable: {
13
+ id: number;
14
+ why: string;
15
+ }[];
16
+ }
6
17
  /** One account's rules from the store, for `account.rules` of the sync; none when the store has not seen the account. */
18
+ export declare const storedMailRuleSet: (store: MessageStore, account: {
19
+ address: string;
20
+ }, query?: QueryAst) => Promise<MailRuleSet>;
7
21
  export declare const storedMailRules: (store: MessageStore, account: {
8
22
  address: string;
9
23
  }, query?: QueryAst) => Promise<MailRule[]>;
@@ -17,15 +17,24 @@ const KINDS = {
17
17
  score_at_least: "number",
18
18
  preset: "string",
19
19
  };
20
+ /** Why this build cannot read a stored rule, or undefined when it can. */
21
+ const unreadable = (stored) => {
22
+ if (ACTION_OF[String(stored.actions.store)] === undefined)
23
+ return `action ${String(stored.actions.store)}`;
24
+ const entries = Object.entries(stored.conditions);
25
+ if (entries.length === 0)
26
+ return "no condition";
27
+ const unknown = entries.find(([key, value]) => !(key in KINDS) ||
28
+ typeof value !== KINDS[key] ||
29
+ (key === "preset" && !PRESET_NAMES.includes(value)));
30
+ return unknown && `condition ${unknown[0]}`;
31
+ };
20
32
  /** A stored rule as the sync reads it; `undefined` for one this build cannot read, so it is left to the build that can. */
21
33
  export const storedMailRule = (stored) => {
34
+ if (unreadable(stored) !== undefined)
35
+ return undefined;
22
36
  const action = ACTION_OF[String(stored.actions.store)];
23
37
  const entries = Object.entries(stored.conditions);
24
- const known = entries.every(([key, value]) => key in KINDS &&
25
- typeof value === KINDS[key] &&
26
- !(key === "preset" && !PRESET_NAMES.includes(value)));
27
- if (action === undefined || !known || entries.length === 0)
28
- return undefined;
29
38
  const tags = Array.isArray(stored.actions.tags) ? stored.actions.tags.filter((tag) => typeof tag === "string") : [];
30
39
  const skipped = stored.actions.tags_skipped;
31
40
  const tagsSkipped = skipped !== null && typeof skipped === "object" && !Array.isArray(skipped)
@@ -44,14 +53,25 @@ export const storedMailRule = (stored) => {
44
53
  };
45
54
  };
46
55
  /** One account's rules from the store, for `account.rules` of the sync; none when the store has not seen the account. */
47
- export const storedMailRules = async (store, account, query) => {
56
+ export const storedMailRuleSet = async (store, account, query) => {
57
+ let stored;
48
58
  try {
49
59
  const { id } = await store.accounts.storedAccount({ provider: "email", account: account.address.toLowerCase() });
50
- return (await store.mail.rules.list(id, query)).flatMap((stored) => storedMailRule(stored) ?? []);
60
+ stored = await store.mail.rules.list(id, query);
51
61
  }
52
62
  catch (error) {
53
63
  if (error instanceof CliError && error.code === "not_found")
54
- return [];
64
+ return { rules: [], unreadable: [] };
55
65
  throw error;
56
66
  }
67
+ const set = { rules: [], unreadable: [] };
68
+ for (const one of stored) {
69
+ const why = unreadable(one);
70
+ if (why === undefined)
71
+ set.rules.push(storedMailRule(one));
72
+ else
73
+ set.unreadable.push({ id: one.id, why });
74
+ }
75
+ return set;
57
76
  };
77
+ export const storedMailRules = async (store, account, query) => (await storedMailRuleSet(store, account, query)).rules;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.525.0",
3
+ "version": "0.526.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",