@wirecat/cli-core 0.524.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
@@ -5,6 +5,45 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
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
+
29
+ ## 0.525.0 — 01.11.2026
30
+
31
+ ### Added
32
+
33
+ - **Mail server search.** `mailReadCommands({ app, read, server })` gives `search messages` the shared
34
+ `--backend archive|server|both` and `--server-time`, as tg has them: the mail server is asked for candidates, the
35
+ new ones are stored, and the same strict query runs on the store, so each hit says `found_in`. `searchMailServer`
36
+ does it; `mailCriteriaOf` turns the query into `MailCriteria` (words, `from:` `to:` `cc:` `subject:` `body:`
37
+ `is:` and `date:`, one per top-level OR) for a `MailSource.query`, which a mail CLI translates into its server's
38
+ syntax. An owner's skip and envelope rules keep a find out of the store, as in a sync; nothing moves the sync's saved
39
+ position. The fake mail source answers `query` and records it in `queried`. When the server answers in part,
40
+ `meta.complete` is false with an issue per reason (`search.offline`, `search.server-timeout`, …; new
41
+ `search.server-*` reasons).
42
+ - `searchMail` takes `only`, and the store's word search reads `only` over mail as over messages.
43
+ - `serverSearchOptions(command, where)` mounts `--backend` and `--server-time` on any search; `backendOptions` uses it.
44
+ - **`AppIdentity.sends`**: a CLI that is not a messenger yet sends to people (mail) keeps `sends_per_hour`, so the
45
+ owner can set its hourly limit with `config set sends_per_hour`; the other messenger settings stay hidden.
46
+
8
47
  ## 0.524.0 — 01.11.2026
9
48
 
10
49
  ### 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
@@ -1,10 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import type { Backend, ServerOptions, ServerSearched } from "../../services/server-search.js";
3
3
  import type { Messenger, MessengerContext } from "./context.js";
4
+ /** `--backend` and `--server-time`, for any search whose source has a server that searches: `where` names it. */
5
+ export declare const serverSearchOptions: (command: Command, where: string) => Command;
4
6
  /** Mounted only where the messenger's server can search messages. */
5
7
  export declare const backendOptions: (command: Command, messenger: Messenger) => Command;
6
8
  export declare const backendRequest: (command: Command) => {
7
9
  backend?: Backend;
8
10
  server?: ServerOptions;
9
11
  };
10
- export declare const noteServer: (context: MessengerContext, server: ServerSearched | undefined) => void;
12
+ export declare const noteServer: (context: Pick<MessengerContext, "renderer">, server: ServerSearched | undefined) => void;
@@ -5,11 +5,13 @@ const backendOf = (value) => {
5
5
  throw new CliError("validation_error", "--backend takes archive, server or both");
6
6
  return value;
7
7
  };
8
+ /** `--backend` and `--server-time`, for any search whose source has a server that searches: `where` names it. */
9
+ export const serverSearchOptions = (command, where) => command
10
+ .option("--backend <archive|server|both>", `where to search: the local archive, ${where}, or both`, backendOf)
11
+ .option("--server-time <duration>", "stop waiting for the server after this long (default: 5s)");
8
12
  /** Mounted only where the messenger's server can search messages. */
9
13
  export const backendOptions = (command, messenger) => messenger.serverSearch
10
- ? command
11
- .option("--backend <archive|server|both>", "where to search: the local archive, the messenger's server, or both (default: both; message discovery uses archive only)", backendOf)
12
- .option("--server-time <duration>", "stop waiting for the server after this long (default: 5s)")
14
+ ? serverSearchOptions(command, "the messenger's server (default: both; message discovery uses archive only)")
13
15
  : command;
14
16
  export const backendRequest = (command) => {
15
17
  const { backend, serverTime } = command.opts();
@@ -162,7 +162,9 @@ export const settingsFor = (app, extension = {}) => {
162
162
  const schema = strictOf(configEntries);
163
163
  const defaultsOnlyKeys = Object.keys(defaultsOnly);
164
164
  const botOnlyKeys = Object.keys(BOT_ONLY_ENTRIES);
165
- const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys].filter((setting) => app.messenger !== false || !MESSENGER_ONLY_SETTINGS.includes(setting));
165
+ const allSettings = [...Object.keys(profileEntries), ...botOnlyKeys, ...defaultsOnlyKeys].filter((setting) => app.messenger !== false ||
166
+ !MESSENGER_ONLY_SETTINGS.includes(setting) ||
167
+ (app.sends === true && setting === "sends_per_hour"));
166
168
  const PROFILE = envName(app, "PROFILE");
167
169
  const PROFILE_LOCK = envName(app, "PROFILE_LOCK");
168
170
  const TIMEOUT = envName(app, "TIMEOUT");
@@ -69,6 +69,13 @@ const OTHERS = [
69
69
  validation("search.no-calendars", "no calendar of this profile syncs yet, so the provider has nothing to search"),
70
70
  validation("search.server-options", "search --backend server takes neither --exact, --cursor nor --newest"),
71
71
  validation("search.one-backend", "search answers from one backend at a time: archive or server"),
72
+ ["search.server-not-allowed", "permission_error", "the profile does not allow messages.server-search"],
73
+ validation("search.server-no-criteria", "the query has nothing the server can search, so only the store is read"),
74
+ ["search.server-unsupported", "provider_error", "this source's server cannot search"],
75
+ ["search.server-rate-limited", "rate_limited", "the server refused a search as too many requests"],
76
+ ["search.server-failed", "provider_unavailable", "the server did not answer a search"],
77
+ ["search.server-timeout", "timeout", "the server did not answer within --server-time"],
78
+ ["search.server-capped", "outcome_unknown", "the server found more than one search reads, or a branch was not sent"],
72
79
  validation("store.refused", "the local store refused the document text"),
73
80
  ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
74
81
  [
@@ -1,4 +1,5 @@
1
1
  import { Command } from "commander";
2
+ import { type MailServerAccess } from "../../mail/server-search.js";
2
3
  import type { AppIdentity } from "../../ports/app.js";
3
4
  import { type AccountKey, type MessageStore } from "../../store/store.js";
4
5
  /** What a mail CLI hands the shared commands: how to open the store for one run, and whose mail it reads. */
@@ -9,6 +10,8 @@ export interface MailReads {
9
10
  * reads that mailbox alone; the store must take writes, since a search tops up its word index first.
10
11
  */
11
12
  read: <T>(command: Command, work: (store: MessageStore, mailbox: AccountKey) => Promise<T>) => Promise<T>;
13
+ /** The mailbox's server, for `search messages --backend`; without it the search reads the store only. */
14
+ server?: (command: Command) => MailServerAccess;
12
15
  }
13
16
  export declare const THREAD_QUERY: {
14
17
  readonly entity: "mail_thread";
@@ -5,16 +5,25 @@ import { Command, InvalidArgumentError } from "commander";
5
5
  import * as v from "valibot";
6
6
  import { answerEntity } from "../../cli/answer-entity.js";
7
7
  import { outputFor } from "../../cli/context.js";
8
- import { LOCAL_READ } from "../../cli/effects.js";
8
+ import { LOCAL_READ, REMOTE_READ } from "../../cli/effects.js";
9
9
  import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
10
10
  import { listQuery, typedQuery, withQuery } from "../../cli/list-query.js";
11
11
  import { messagesAnswer, offsetOf, shownOf } from "../../cli/messenger/search-answers.js";
12
+ import { backendRequest, noteServer, serverSearchOptions } from "../../cli/messenger/search-backend-options.js";
12
13
  import { positiveCount } from "../../cli/paging.js";
13
14
  import { formatReference, parseReference } from "../../domain/references.js";
14
15
  import { count, reference, text, time, untrusted } from "../../entities/fields.js";
15
16
  import { attachmentsText, hitText, searchMail, showMail, withAttachmentText } from "../../mail/read.js";
17
+ import { searchMailServer } from "../../mail/server-search.js";
16
18
  import { atDetail } from "../../output-detail.js";
19
+ import { sourceKey } from "../../services/server-search.js";
17
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
+ };
18
27
  const DEFAULT_LIMIT = 20;
19
28
  const MAX_LIMIT = 100;
20
29
  const limitOf = (value) => {
@@ -41,9 +50,37 @@ const threadOf = (mailbox, given) => {
41
50
  throw new CliError("not_found", `${given} is not mail of this profile's mailbox ${mailbox.account}`);
42
51
  return place.chat;
43
52
  };
53
+ const SKIPPED = {
54
+ unsupported: { code: "provider_error", reason: "search.server-unsupported" },
55
+ pushed_history: { code: "provider_error", reason: "search.server-unsupported" },
56
+ offline: { code: "permission_error", reason: "search.offline" },
57
+ not_allowed: { code: "permission_error", reason: "search.server-not-allowed" },
58
+ other_accounts: { code: "validation_error", reason: "search.server-no-criteria" },
59
+ no_words: { code: "validation_error", reason: "search.server-no-criteria" },
60
+ needs_chat: { code: "validation_error", reason: "search.server-no-criteria" },
61
+ };
62
+ const FAILED = {
63
+ rate_limited: { code: "rate_limited", reason: "search.server-rate-limited" },
64
+ search_failed: { code: "provider_unavailable", reason: "search.server-failed" },
65
+ time_or_abort_bound: { code: "timeout", reason: "search.server-timeout" },
66
+ };
67
+ /** What the server search left out, so `meta.complete` is false whenever any of it is (WC-OUT-7). */
68
+ const serverIssues = (server) => {
69
+ if (!server || server.complete)
70
+ return [];
71
+ if (server.skipped)
72
+ return [{ ...SKIPPED[server.skipped], source: "server" }];
73
+ const failed = [...new Set(server.failed.map(({ reason }) => reason))].map((reason) => ({
74
+ ...FAILED[reason],
75
+ source: "server",
76
+ }));
77
+ return failed.length
78
+ ? failed
79
+ : [{ code: "outcome_unknown", reason: "search.server-capped", source: "server" }];
80
+ };
44
81
  const searchCommand = (mail) => {
45
82
  const leaf = annotate(new Command("messages"), {
46
- effects: LOCAL_READ,
83
+ effects: mail.server ? REMOTE_READ : LOCAL_READ,
47
84
  partial: true,
48
85
  output: { entity: "message", view: "hit" },
49
86
  examples: [
@@ -52,8 +89,11 @@ const searchCommand = (mail) => {
52
89
  `${mail.app.command} ya search messages 'list:github.com is:unread' --newest`,
53
90
  ],
54
91
  })
55
- .summary("find stored emails by words and fields")
56
- .description("search this profile's mail in the local store — what sync read — by words, people, folders, lists and dates; best match first")
92
+ .summary(mail.server ? "find emails by words and fields" : "find stored emails by words and fields")
93
+ .description(mail.server
94
+ ? "search this profile's mail in the local store and, by default, on the mail server for mail sync has not read " +
95
+ "yet (--backend); the server's finds are stored, then one strict query decides; best match first"
96
+ : "search this profile's mail in the local store — what sync read — by words, people, folders, lists and dates; best match first")
57
97
  .argument("<query...>", 'strict Lucene query: words, "phrases", AND/OR/NOT, field:value and date ranges')
58
98
  .option("--limit <n>", `how many, at most ${MAX_LIMIT}`, limitOf)
59
99
  .option("--page <n>", "which page, starting at 1", positiveCount("--page"))
@@ -66,23 +106,59 @@ Fields: from: to: cc: bcc: subject: body: mailbox: (or label:) list: (a List-Id
66
106
  has:attachment, filename:, content: (the text of attachments), size>, date: — \`${mail.app.command} ya search messages 'content:invoice'\`.
67
107
  chat: names a thread by its subject. Mail sent to many is in the mailbox bulk; NOT mailbox:bulk leaves it out.
68
108
  Mail from senders with signs of fraud comes after the rest of the page; --plain-order keeps it in place.
69
- --json: the list is items; each hit's reference is ref (msg:email/…), which messages show reads.
70
- Search guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md`)
71
- .action(async function (words) {
109
+ --json: the list is items; each hit's reference is ref (msg:email/…), which messages show reads.${mail.server
110
+ ? `
111
+ --backend: the server is asked for words, from: to: cc: subject: body: is: and date:, in INBOX, Sent and Archive or
112
+ all of Gmail; the rest stays with the strict query on the store. Permission key: messages.server-search.
113
+ found_in says archive, server (new from the server) or both; meta.complete is false when the server answered in part.`
114
+ : ""}
115
+ Search guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/search/query-language.md`);
116
+ if (mail.server)
117
+ serverSearchOptions(leaf, "the mail server (default: both)");
118
+ leaf.action(async function (words) {
72
119
  const options = this.opts();
73
120
  const limit = options.limit ?? DEFAULT_LIMIT;
74
121
  const page = options.page ?? 1;
75
122
  const query = words.join(" ");
76
- const found = await mail.read(this, (store, mailbox) => searchMail(store, mailbox, {
77
- text: query,
78
- limit,
79
- mailAccount: mailbox.account,
80
- ...(offsetOf(page, limit) === undefined ? {} : { offset: offsetOf(page, limit) }),
81
- ...(options.newest ? { newest: true } : {}),
82
- ...(options.exact ? { exact: true } : {}),
83
- ...(options.plainOrder ? { plainOrder: true } : {}),
84
- ...(options.timezone === undefined ? {} : { timezone: options.timezone }),
85
- }, { app: mail.app }));
123
+ const { backend, server: bounds } = mail.server ? backendRequest(this) : {};
124
+ const found = await mail.read(this, async (store, mailbox) => {
125
+ const step = mail.server
126
+ ? await searchMailServer(store, mailbox, mail.server(this), {
127
+ text: query,
128
+ ...(backend === undefined ? {} : { backend }),
129
+ ...(bounds?.timeMs === undefined ? {} : { timeMs: bounds.timeMs }),
130
+ ...(options.exact ? { exact: true } : {}),
131
+ ...(options.timezone === undefined ? {} : { timezone: options.timezone }),
132
+ })
133
+ : undefined;
134
+ const only = backend === "server" && step
135
+ ? [...step.sources.keys()].map((key) => {
136
+ const [chatId, id] = JSON.parse(key);
137
+ return { ...mailbox, chatId, id };
138
+ })
139
+ : undefined;
140
+ const found = await searchMail(store, mailbox, {
141
+ text: query,
142
+ limit,
143
+ mailAccount: mailbox.account,
144
+ ...(offsetOf(page, limit) === undefined ? {} : { offset: offsetOf(page, limit) }),
145
+ ...(options.newest ? { newest: true } : {}),
146
+ ...(options.exact ? { exact: true } : {}),
147
+ ...(options.plainOrder ? { plainOrder: true } : {}),
148
+ ...(options.timezone === undefined ? {} : { timezone: options.timezone }),
149
+ ...(only ? { only } : {}),
150
+ }, { app: mail.app });
151
+ if (!step)
152
+ return found;
153
+ return {
154
+ ...found,
155
+ server: step.report,
156
+ items: found.items.map((hit) => ({
157
+ ...hit,
158
+ source: step.sources.get(sourceKey(hit.chatId, hit.id)) ?? "archive",
159
+ })),
160
+ };
161
+ });
86
162
  const output = outputFor(this);
87
163
  const again = {
88
164
  query,
@@ -92,11 +168,18 @@ Search guide: https://github.com/WireCatLabs/cli-core/blob/main/docs/search/quer
92
168
  ...(options.timezone === undefined ? {} : { timezone: options.timezone }),
93
169
  };
94
170
  const level = detailOf(this);
95
- renderAnswer(this, output, messagesAnswer("search messages", found, { limit, page, again }, query), () => {
171
+ const answer = messagesAnswer("search messages", found, { limit, page, again }, query);
172
+ const issues = serverIssues(found.server);
173
+ if (issues.length)
174
+ answer.meta = { ...answer.meta, complete: false, issues: [...(answer.meta?.issues ?? []), ...issues] };
175
+ renderAnswer(this, output, answer, () => {
96
176
  if (!found.wordsReady)
97
177
  output.renderer.note(`the word index is still being built — \`${mail.app.command} store migrate\` finishes it`);
178
+ noteServer(output, found.server);
98
179
  if (found.items.length === 0)
99
- output.renderer.note("nothing found — only mail sync stored is searched");
180
+ output.renderer.note(found.server && !found.server.skipped
181
+ ? "nothing found in the local store or on the mail server"
182
+ : "nothing found — only mail sync stored is searched");
100
183
  const shown = shownOf(found.items.length, (page - 1) * limit, found.total);
101
184
  if (shown)
102
185
  output.renderer.note(shown);
@@ -175,7 +258,7 @@ const conversationsCommand = (mail) => {
175
258
  const typed = typedQuery(words);
176
259
  const query = listQuery(typed, THREAD_QUERY, "conversations list");
177
260
  const { threads, mailbox } = await mail.read(this, async (store, mailbox) => {
178
- const account = await store.accounts.storedAccount(mailbox).catch(() => undefined);
261
+ const account = await store.accounts.storedAccount(mailbox).catch(absentAccount);
179
262
  if (!account)
180
263
  return { threads: [], mailbox };
181
264
  const where = query ? queryFilter(query, THREAD_COLUMNS, { command: "conversations list" }) : undefined;
@@ -219,7 +302,7 @@ const conversationsCommand = (mail) => {
219
302
  const full = detailOf(this) === "full";
220
303
  const emails = await mail.read(this, async (store, mailbox) => {
221
304
  const thread = threadOf(mailbox, given);
222
- const account = await store.accounts.storedAccount(mailbox).catch(() => undefined);
305
+ const account = await store.accounts.storedAccount(mailbox).catch(absentAccount);
223
306
  const hits = account ? await store.mail.threadHits(account.id, thread) : [];
224
307
  if (!account || hits.length === 0)
225
308
  throw new CliError("not_found", `no stored thread ${given}`);
@@ -0,0 +1,5 @@
1
+ import type { EmailInput } from "../store/index.js";
2
+ import type { ParsedMail } from "./parse.js";
3
+ import type { Listed } from "./source.js";
4
+ /** What one read email stores of itself, whichever run read it; where it is filed is the caller's. */
5
+ export declare const parsedEmail: (mail: ParsedMail, item: Listed, header: Record<string, string | boolean>, address: string) => Omit<EmailInput, "mailboxes">;
@@ -0,0 +1,16 @@
1
+ import { addressOf } from "./attachments.js";
2
+ /** What one read email stores of itself, whichever run read it; where it is filed is the caller's. */
3
+ export const parsedEmail = (mail, item, header, address) => ({
4
+ externalId: item.messageId,
5
+ subject: mail.subject,
6
+ from: mail.from === null ? null : addressOf(mail.from),
7
+ to: mail.to.map(addressOf),
8
+ cc: mail.cc.map(addressOf),
9
+ bcc: mail.bcc.map(addressOf),
10
+ sentAt: null,
11
+ receivedAt: Date.parse(item.receivedAt),
12
+ bodyText: mail.text || null,
13
+ size: item.size ?? null,
14
+ ...(Object.keys(header).length ? { headers: header } : {}),
15
+ outgoing: mail.from === null ? null : mail.from.email === address,
16
+ });
@@ -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,11 +12,13 @@ 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";
18
19
  export { DEMOTE_AT, type DomainAges, demotedSenders, type LookupResult, SCORE_VERSION, scoreSenders, } from "./sender-scores.js";
20
+ export { type MailCriteria, mailCriteriaOf, narrows } from "./server-query.js";
21
+ export { type MailServerAccess, type MailServerRequest, searchMailServer } from "./server-search.js";
19
22
  export type { Listed, MailSource, Quick, Searched } from "./source.js";
20
23
  export { listedOf, type StoredMail } from "./stored.js";
21
24
  export { MAX_ATTACHMENT_CHARS, type MailAccount, type MailIndexed, type MailSyncOptions, type MailSyncResult, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.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,10 +12,12 @@ 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";
18
19
  export { DEMOTE_AT, demotedSenders, SCORE_VERSION, scoreSenders, } from "./sender-scores.js";
20
+ export { mailCriteriaOf, narrows } from "./server-query.js";
21
+ export { searchMailServer } from "./server-search.js";
19
22
  export { listedOf } from "./stored.js";
20
23
  export { MAX_ATTACHMENT_CHARS, RETRYABLE_ATTACHMENTS, syncMail, } from "./sync.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));
@@ -16,12 +16,17 @@ export interface MailSearch {
16
16
  newest?: boolean;
17
17
  /** Leave mail from suspicious senders where the words put it, instead of after the rest. */
18
18
  plainOrder?: boolean;
19
+ /** Only these emails, by thread and email id: `--backend server` reads what the server returned. */
20
+ only?: (AccountKey & {
21
+ chatId: string;
22
+ id: string;
23
+ })[];
19
24
  }
20
25
  /**
21
26
  * Stored mail by the shared query language, best first. Mail whose sender search demotes comes after the rest,
22
27
  * unless `plainOrder` or `newest` asks for another order. `runAs` is the account the search runs as.
23
28
  */
24
- export declare const searchMail: (store: MessageStore, runAs: AccountKey, { text, limit, offset, accounts, mailAccount, exact, timezone, newest, plainOrder }: MailSearch, saved?: Saved) => Promise<SearchFound>;
29
+ export declare const searchMail: (store: MessageStore, runAs: AccountKey, { text, limit, offset, accounts, mailAccount, exact, timezone, newest, plainOrder, only }: MailSearch, saved?: Saved) => Promise<SearchFound>;
25
30
  /** Enough for a bill's amounts and dates; the whole text is asked for separately. */
26
31
  export declare const ATTACHMENT_CHARS = 4000;
27
32
  export type ShownMail = StoredHit & {
package/dist/mail/read.js CHANGED
@@ -12,7 +12,7 @@ const LOWERED_SPARE = 20;
12
12
  * Stored mail by the shared query language, best first. Mail whose sender search demotes comes after the rest,
13
13
  * unless `plainOrder` or `newest` asks for another order. `runAs` is the account the search runs as.
14
14
  */
15
- export const searchMail = async (store, runAs, { text, limit, offset = 0, accounts, mailAccount, exact, timezone, newest, plainOrder }, saved = {}) => {
15
+ export const searchMail = async (store, runAs, { text, limit, offset = 0, accounts, mailAccount, exact, timezone, newest, plainOrder, only }, saved = {}) => {
16
16
  const lower = !plainOrder && !newest;
17
17
  const request = {
18
18
  text,
@@ -22,6 +22,7 @@ export const searchMail = async (store, runAs, { text, limit, offset = 0, accoun
22
22
  ...(exact ? { exact: true } : {}),
23
23
  ...(timezone === undefined ? {} : { timezone }),
24
24
  ...(newest ? { newest: true } : {}),
25
+ ...(only === undefined ? {} : { only }),
25
26
  };
26
27
  if (!lower)
27
28
  return searchStore(store, runAs, { ...request, limit, offset }, saved);
@@ -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;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What a mail server is asked, in no server's syntax: every part must hold. Each list is AND-ed; a part the server
3
+ * cannot read is left out, which only widens the candidates, since the strict query on the store decides.
4
+ */
5
+ export interface MailCriteria {
6
+ /** Words and phrases anywhere in the email. */
7
+ words: string[];
8
+ from: string[];
9
+ to: string[];
10
+ cc: string[];
11
+ subject: string[];
12
+ body: string[];
13
+ /** Received at or after this time, in ms. */
14
+ since?: number;
15
+ /** Received before this time, in ms. */
16
+ before?: number;
17
+ seen?: boolean;
18
+ flagged?: boolean;
19
+ }
20
+ /** Whether the criteria narrow at all: a server asked for everything would only return the newest mail. */
21
+ export declare const narrows: (criteria: MailCriteria) => boolean;
22
+ /**
23
+ * The shared query as mail server searches, one per top-level OR branch. Negations, patterns, `has:`, `list:`,
24
+ * `mailbox:` and the rest stay with the strict query on the store. A branch that narrows nothing is not sent, since a
25
+ * server asked for everything would only answer the newest mail; `partial` says one was left out.
26
+ */
27
+ export declare const mailCriteriaOf: (text: string, { exact, timezone }?: {
28
+ exact?: boolean;
29
+ timezone?: string;
30
+ }) => {
31
+ branches: MailCriteria[];
32
+ partial: boolean;
33
+ };
@@ -0,0 +1,92 @@
1
+ import { dateRange, parseLucene, timezoneOf } from "@wirecat/cli-base/query";
2
+ import { validateFields } from "../search/lucene/registry.js";
3
+ import { MESSAGE_PARSING } from "../search/lucene/types.js";
4
+ const DAY = 86_400_000;
5
+ const empty = () => ({ words: [], from: [], to: [], cc: [], subject: [], body: [] });
6
+ /** Whether the criteria narrow at all: a server asked for everything would only return the newest mail. */
7
+ export const narrows = (criteria) => criteria.words.length +
8
+ criteria.from.length +
9
+ criteria.to.length +
10
+ criteria.cc.length +
11
+ criteria.subject.length +
12
+ criteria.body.length >
13
+ 0 ||
14
+ criteria.since !== undefined ||
15
+ criteria.before !== undefined ||
16
+ criteria.seen !== undefined ||
17
+ criteria.flagged !== undefined;
18
+ const merge = (one, other) => {
19
+ if (one.seen !== undefined && other.seen !== undefined && one.seen !== other.seen)
20
+ return undefined;
21
+ if (one.flagged !== undefined && other.flagged !== undefined && one.flagged !== other.flagged)
22
+ return undefined;
23
+ const since = Math.max(one.since ?? -Infinity, other.since ?? -Infinity);
24
+ const before = Math.min(one.before ?? Infinity, other.before ?? Infinity);
25
+ const seen = one.seen ?? other.seen;
26
+ const flagged = one.flagged ?? other.flagged;
27
+ return {
28
+ words: [...one.words, ...other.words],
29
+ from: [...one.from, ...other.from],
30
+ to: [...one.to, ...other.to],
31
+ cc: [...one.cc, ...other.cc],
32
+ subject: [...one.subject, ...other.subject],
33
+ body: [...one.body, ...other.body],
34
+ ...(Number.isFinite(since) ? { since } : {}),
35
+ ...(Number.isFinite(before) ? { before } : {}),
36
+ ...(seen === undefined ? {} : { seen }),
37
+ ...(flagged === undefined ? {} : { flagged }),
38
+ };
39
+ };
40
+ const TEXT_FIELDS = { from: "from", to: "to", cc: "cc", subject: "subject", body: "body" };
41
+ const leafOf = (node, timezone) => {
42
+ const criteria = empty();
43
+ const plain = node.operator === "term" || node.operator === "phrase";
44
+ if ((node.field === "text" || node.field === "exact") && plain && node.value !== "")
45
+ return { ...criteria, words: [node.value] };
46
+ const field = TEXT_FIELDS[node.field];
47
+ // `from:me` is the mailbox's own address, which the store knows and the server's FROM does not.
48
+ if (field && plain && node.value !== "" && !(field === "from" && node.value.toLowerCase() === "me"))
49
+ return { ...criteria, [field]: [node.value] };
50
+ if (node.field === "is" && node.operator === "term") {
51
+ const state = node.value.toLowerCase();
52
+ if (state === "unread")
53
+ return { ...criteria, seen: false };
54
+ if (state === "read")
55
+ return { ...criteria, seen: true };
56
+ if (state === "starred")
57
+ return { ...criteria, flagged: true };
58
+ }
59
+ if (node.field === "date") {
60
+ const range = dateRange(node.value, node.operator === "range" ? (node.upper ?? "*") : node.value, node.operator === "range" ? node.lower_inclusive === true : true, node.operator === "range" ? node.upper_inclusive === true : true, timezone, node.span);
61
+ // A server reads whole days in its own zone: a day more each side, and the store cuts to the exact moment.
62
+ return {
63
+ ...criteria,
64
+ ...(range.lower === undefined ? {} : { since: range.lower - DAY }),
65
+ ...(range.upper === undefined ? {} : { before: range.upper + DAY }),
66
+ };
67
+ }
68
+ return criteria;
69
+ };
70
+ const branchesOf = (node, timezone) => {
71
+ if (node.kind === "predicate")
72
+ return [leafOf(node, timezone)];
73
+ const must = node.clauses.filter(({ occur }) => occur === "must");
74
+ if (must.length)
75
+ return must.reduce((product, { node }) => product.flatMap((one) => branchesOf(node, timezone).flatMap((other) => merge(one, other) ?? [])), [empty()]);
76
+ const should = node.clauses.filter(({ occur }) => occur === "should");
77
+ return should.length ? should.flatMap(({ node }) => branchesOf(node, timezone)) : [empty()];
78
+ };
79
+ /**
80
+ * The shared query as mail server searches, one per top-level OR branch. Negations, patterns, `has:`, `list:`,
81
+ * `mailbox:` and the rest stay with the strict query on the store. A branch that narrows nothing is not sent, since a
82
+ * server asked for everything would only answer the newest mail; `partial` says one was left out.
83
+ */
84
+ export const mailCriteriaOf = (text, { exact, timezone } = {}) => {
85
+ const ast = validateFields(parseLucene(text, { ...MESSAGE_PARSING, defaultField: exact ? "exact" : "text" }));
86
+ const all = branchesOf(ast.root, timezoneOf(timezone));
87
+ const sent = all.filter(narrows);
88
+ return {
89
+ branches: [...new Map(sent.map((branch) => [JSON.stringify(branch), branch])).values()],
90
+ partial: sent.length < all.length,
91
+ };
92
+ };
@@ -0,0 +1,33 @@
1
+ import { type Level, type PermissionKey } from "../sends/permissions.js";
2
+ import { type Backend, type ServerStep } from "../services/server-search.js";
3
+ import type { AccountKey, MessageStore } from "../store/index.js";
4
+ import type { MailSource } from "./source.js";
5
+ /** What a run lets the server search do, read from the profile before anything is opened. */
6
+ export interface MailServerAccess {
7
+ offline: boolean;
8
+ permissions: Readonly<Record<PermissionKey, Level>>;
9
+ defaults?: Readonly<Record<PermissionKey, Level>>;
10
+ /** `<PREFIX>_READ_ONLY`: the search writes what the server finds into the store, so it stays the archive's. */
11
+ readOnly?: boolean;
12
+ /** The mailbox and the folders a search reads, closed after the search. */
13
+ open: () => Promise<{
14
+ source: MailSource;
15
+ folders: string[];
16
+ close?: () => Promise<void>;
17
+ }>;
18
+ }
19
+ export interface MailServerRequest {
20
+ text: string;
21
+ exact?: boolean;
22
+ timezone?: string;
23
+ backend?: Backend;
24
+ timeMs?: number;
25
+ signal?: AbortSignal;
26
+ }
27
+ /**
28
+ * Asks the mailbox's server for candidates and saves the new ones, so the strict query that runs next on the store
29
+ * sees them: the messenger rule (cli-core docs/search/query-language.md, "Поиск на сервере мессенджера"). Each source
30
+ * key is `sourceKey(thread, email)`. Mail a skip or envelope rule keeps out of the store is not saved. Nothing moves the
31
+ * sync's saved position or coverage, and a reply after the time bound is dropped unsaved.
32
+ */
33
+ export declare const searchMailServer: (store: MessageStore, mailbox: AccountKey, access: MailServerAccess, request: MailServerRequest) => Promise<ServerStep | undefined>;
@@ -0,0 +1,215 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { importEngine } from "../attachments/extract.js";
3
+ import { levelFor } from "../sends/permissions.js";
4
+ import { DEFAULT_BACKEND, SERVER_BOUNDS, SERVER_SEARCH_KEY, sourceKey, } from "../services/server-search.js";
5
+ import { MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
6
+ import { bulkBox } from "./bulk.js";
7
+ import { parsedEmail } from "./email-input.js";
8
+ import { factsOf } from "./facts.js";
9
+ import { mailHeadersOf } from "./headers.js";
10
+ import { parseMail } from "./parse.js";
11
+ import { storedMailRules } from "./rule-stored.js";
12
+ import { decide } from "./rules.js";
13
+ import { mailCriteriaOf } from "./server-query.js";
14
+ const refusal = {
15
+ unsupported: "this mailbox's server cannot be searched — use --backend archive",
16
+ offline: "--backend server needs the network — not with --offline",
17
+ not_allowed: "the server search is not allowed by this profile",
18
+ no_words: "--backend server needs words, people, a subject, a date or a state to send to the server",
19
+ };
20
+ const BOUND = Symbol("bound");
21
+ /** The newest first: a server's UIDs grow as mail arrives in the folder. */
22
+ const newest = (uids, count) => [...uids].sort((one, other) => Number(other) - Number(one)).slice(0, count);
23
+ /**
24
+ * Asks the mailbox's server for candidates and saves the new ones, so the strict query that runs next on the store
25
+ * sees them: the messenger rule (cli-core docs/search/query-language.md, "Поиск на сервере мессенджера"). Each source
26
+ * key is `sourceKey(thread, email)`. Mail a skip or envelope rule keeps out of the store is not saved. Nothing moves the
27
+ * sync's saved position or coverage, and a reply after the time bound is dropped unsaved.
28
+ */
29
+ export const searchMailServer = async (store, mailbox, access, request) => {
30
+ const backend = request.backend ?? DEFAULT_BACKEND;
31
+ if (backend === "archive")
32
+ return undefined;
33
+ const quiet = request.backend === undefined;
34
+ const timeMs = request.timeMs ?? SERVER_BOUNDS.timeMs;
35
+ if (!Number.isSafeInteger(timeMs) || timeMs < 1 || timeMs > 60_000)
36
+ throw new CliError("validation_error", "the server time takes a whole number of milliseconds from 1 to 60000");
37
+ const report = { backend, skipped: null, calls: 0, returned: 0, new: 0, failed: [], complete: true };
38
+ const sources = new Map();
39
+ const skip = (reason) => {
40
+ if (quiet)
41
+ return undefined;
42
+ if (backend === "server")
43
+ throw new CliError(reason === "not_allowed" ? "permission_error" : "validation_error", refusal[reason], {
44
+ reason,
45
+ });
46
+ return { report: { ...report, skipped: reason, complete: false }, sources };
47
+ };
48
+ if (access.offline)
49
+ return skip("offline");
50
+ // As tg's: `ask` and `readonly` give the archive's answer, since nobody is asked before a search.
51
+ if (access.readOnly || levelFor(access.permissions, SERVER_SEARCH_KEY, access.defaults).level !== "allow")
52
+ return skip("not_allowed");
53
+ const { branches, partial } = mailCriteriaOf(request.text, {
54
+ ...(request.exact ? { exact: true } : {}),
55
+ ...(request.timezone === undefined ? {} : { timezone: request.timezone }),
56
+ });
57
+ if (!branches.length)
58
+ return skip("no_words");
59
+ if (partial)
60
+ report.complete = false;
61
+ const pending = new Set();
62
+ const listed = [];
63
+ const validities = new Map();
64
+ let open = true;
65
+ const fail = (call, reason) => {
66
+ report.complete = false;
67
+ report.failed.push({ chat: call?.folder ?? null, reason });
68
+ };
69
+ const accountId = await store.accounts.saveAccount(mailbox, { name: mailbox.account });
70
+ const fresh = [];
71
+ let raws;
72
+ let opening;
73
+ let openFailure;
74
+ let unsupported = false;
75
+ // Opening lists the folders, a call to the server, so it runs inside the time bound too.
76
+ const run = async () => {
77
+ opening = access.open();
78
+ const { source, folders } = await opening.catch((error) => {
79
+ openFailure = error;
80
+ throw error;
81
+ });
82
+ const query = source.query?.bind(source);
83
+ if (!query) {
84
+ unsupported = true;
85
+ return;
86
+ }
87
+ const asked = branches.flatMap((criteria) => folders.map((folder) => ({ criteria, folder })));
88
+ if (asked.length > SERVER_BOUNDS.maxCalls)
89
+ report.complete = false;
90
+ const calls = asked.slice(0, SERVER_BOUNDS.maxCalls);
91
+ for (const call of calls)
92
+ pending.add(call);
93
+ await Promise.all(calls.map(async (call) => {
94
+ report.calls += 1;
95
+ try {
96
+ const found = await query(call.criteria, call.folder);
97
+ const uids = newest(found.uids, SERVER_BOUNDS.perCall);
98
+ if (uids.length < found.uids.length)
99
+ report.complete = false;
100
+ const items = uids.length
101
+ ? await source.list(new Date(0), call.folder, { headers: true, searched: { ...found, uids } })
102
+ : [];
103
+ if (!open)
104
+ return;
105
+ pending.delete(call);
106
+ listed.push(...items);
107
+ if (found.validity)
108
+ for (const item of items)
109
+ validities.set(item, found.validity);
110
+ }
111
+ catch (error) {
112
+ if (!open)
113
+ return;
114
+ pending.delete(call);
115
+ fail(call, error instanceof CliError && error.code === "rate_limited" ? "rate_limited" : "search_failed");
116
+ }
117
+ }));
118
+ if (!open)
119
+ return;
120
+ const unique = [...new Map(listed.map((item) => [item.messageId, item])).values()];
121
+ const rules = await storedMailRules(store, { address: mailbox.account });
122
+ const facts = factsOf(unique, rules);
123
+ for (const item of unique) {
124
+ const held = (await store.mail.email(accountId, item.messageId)) !== null;
125
+ const rule = decide(rules, facts(item));
126
+ if (!held && (rule?.action === "skip" || rule?.action === "envelope"))
127
+ continue;
128
+ report.returned += 1;
129
+ sources.set(sourceKey(item.threadId, item.messageId), held ? "both" : "server");
130
+ if (!held)
131
+ fresh.push(item);
132
+ }
133
+ if (fresh.length)
134
+ raws = await source.fetchRaw(fresh);
135
+ };
136
+ const controller = new AbortController();
137
+ const timer = setTimeout(() => controller.abort(), timeMs);
138
+ const stop = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
139
+ const bound = new Promise((resolve) => {
140
+ if (stop.aborted)
141
+ resolve(BOUND);
142
+ stop.addEventListener("abort", () => resolve(BOUND), { once: true });
143
+ });
144
+ let outcome;
145
+ try {
146
+ outcome = await Promise.race([
147
+ run().then(() => "done", () => "failed"),
148
+ bound,
149
+ ]);
150
+ }
151
+ finally {
152
+ open = false;
153
+ clearTimeout(timer);
154
+ // A late open is closed too, once it lands, so no connection outlives the answer.
155
+ void opening?.then((one) => one.close?.(), () => undefined);
156
+ }
157
+ if (openFailure !== undefined && backend === "server")
158
+ throw openFailure;
159
+ if (unsupported)
160
+ return skip("unsupported");
161
+ for (const call of pending)
162
+ fail(call, outcome === "failed" ? "search_failed" : "time_or_abort_bound");
163
+ if (outcome !== "done" || (fresh.length > 0 && raws === undefined)) {
164
+ // What was found but not read in time is the archive's to answer: none of it is claimed as the server's.
165
+ if (pending.size === 0)
166
+ fail(null, outcome === "failed" ? "search_failed" : "time_or_abort_bound");
167
+ for (const item of fresh)
168
+ sources.delete(sourceKey(item.threadId, item.messageId));
169
+ report.returned = sources.size;
170
+ return { report, sources };
171
+ }
172
+ const now = Date.now();
173
+ const inputs = [];
174
+ for (const [index, item] of fresh.entries()) {
175
+ const raw = raws?.[index];
176
+ try {
177
+ if (raw === undefined || raw instanceof Error)
178
+ throw raw ?? new Error("not read");
179
+ const mail = await parseMail(raw);
180
+ const files = await readAttachments(mail, item.messageId, { loadEngine: importEngine, maxAttachmentChars: MAX_ATTACHMENT_CHARS, at: now }, []);
181
+ const folder = item.folder;
182
+ const validity = validities.get(item);
183
+ inputs.push({
184
+ accountId,
185
+ externalId: item.threadId,
186
+ now,
187
+ emails: [
188
+ {
189
+ ...parsedEmail(mail, item, mailHeadersOf(raw), mailbox.account),
190
+ mailboxes: [
191
+ { externalId: folder, name: folder, kind: null },
192
+ ...(item.labels ?? []).map((name) => ({ externalId: `label:${name}`, name, kind: "label" })),
193
+ ...(item.headers?.bulk ? [bulkBox] : []),
194
+ ],
195
+ ...(validity === undefined
196
+ ? {}
197
+ : { locations: [{ folder, uidvalidity: validity, uid: Number(item.uid) }] }),
198
+ ...(item.seen === undefined ? {} : { read: item.seen }),
199
+ ...(item.flagged === undefined ? {} : { flagged: item.flagged }),
200
+ attachments: files,
201
+ },
202
+ ],
203
+ });
204
+ }
205
+ catch {
206
+ sources.delete(sourceKey(item.threadId, item.messageId));
207
+ report.complete = false;
208
+ }
209
+ }
210
+ if (inputs.length)
211
+ await store.mail.saveThreads(inputs);
212
+ report.new = inputs.length;
213
+ report.returned = sources.size;
214
+ return { report, sources };
215
+ };
@@ -1,5 +1,6 @@
1
1
  import type { MailHeaders } from "./headers.js";
2
2
  import type { FileStamp } from "./resume.js";
3
+ import type { MailCriteria } from "./server-query.js";
3
4
  export interface Listed {
4
5
  uid: string;
5
6
  /** Gmail's own ids: the same in every folder, and they survive a UID reset. */
@@ -62,6 +63,11 @@ export interface MailSource {
62
63
  messageId: string;
63
64
  threadId: string;
64
65
  };
66
+ /**
67
+ * The UIDs of `folder` the server's own search finds for `criteria`, and its UIDVALIDITY; `list` given them reads
68
+ * them. Absent where nothing can be asked: a file. A server reads only the parts its syntax has.
69
+ */
70
+ query?(criteria: MailCriteria, folder: string): Promise<Searched>;
65
71
  /** A file's size and modification time, so an unchanged file read to the end is not read again. */
66
72
  stamp?(): Promise<FileStamp>;
67
73
  /**
package/dist/mail/sync.js CHANGED
@@ -2,8 +2,9 @@ import { setTimeout } from "node:timers/promises";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { importEngine } from "../attachments/extract.js";
4
4
  import { isBusy } from "../store/sqlite/transaction.js";
5
- import { addressOf, FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
5
+ import { FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
6
6
  import { BULK, bulkBox } from "./bulk.js";
7
+ import { parsedEmail } from "./email-input.js";
7
8
  import { factsOf } from "./facts.js";
8
9
  import { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
9
10
  import { dropMissing, tooManyGone } from "./gone.js";
@@ -717,18 +718,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
717
718
  now: startedAt,
718
719
  emails: [
719
720
  {
720
- externalId: item.messageId,
721
- subject: mail.subject,
722
- from: mail.from === null ? null : addressOf(mail.from),
723
- to: mail.to.map(addressOf),
724
- cc: mail.cc.map(addressOf),
725
- bcc: mail.bcc.map(addressOf),
726
- sentAt: null,
727
- receivedAt: Date.parse(item.receivedAt),
728
- bodyText: mail.text || null,
729
- size: item.size ?? null,
730
- ...(Object.keys(header).length ? { headers: header } : {}),
731
- outgoing: mail.from === null ? null : mail.from.email === key.account,
721
+ ...parsedEmail(mail, item, header, key.account),
732
722
  mailboxes: inMailboxes(item),
733
723
  ...locationOf(item),
734
724
  ...flagsOf(item),
@@ -1,4 +1,5 @@
1
1
  import type { FileStamp } from "../resume.js";
2
+ import type { MailCriteria } from "../server-query.js";
2
3
  import type { MailSource } from "../source.js";
3
4
  export type FakePerson = [name: string | null, address: string];
4
5
  /** One message of a fake Gmail-like mailbox: its ids, envelope and the parts `rawOf` writes as RFC 5322. */
@@ -61,6 +62,11 @@ export interface FakeMailbox {
61
62
  fetchBatch?: number;
62
63
  /** What the source was asked: the UIDs of each listing, and `msgid:<id>` for each `find`. */
63
64
  asked?: string[];
65
+ /** Each server search `query` was given, with its folder, so a test sees what was sent. */
66
+ queried?: {
67
+ criteria: MailCriteria;
68
+ folder: string;
69
+ }[];
64
70
  }
65
71
  /** The RFC 5322 bytes of a fake message, CRLF line ends, the body and attachment in base64. */
66
72
  export declare const rawOf: (m: FakeMail) => Uint8Array;
@@ -1,6 +1,23 @@
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
+ /** As IMAP SEARCH matches: a case-blind substring of the field, every part required. */
5
+ const matches = (m, criteria) => {
6
+ const has = (field, value) => field.toLowerCase().includes(value.toLowerCase());
7
+ const people = (list) => list.map(person).join(", ");
8
+ const text = `${m.subject}\n${m.text ?? m.html ?? ""}`;
9
+ const at = Date.parse(m.receivedAt);
10
+ return (criteria.words.every((word) => has(`${people([m.from])}\n${people(m.to)}\n${text}`, word)) &&
11
+ criteria.from.every((value) => has(person(m.from), value)) &&
12
+ criteria.to.every((value) => has(people(m.to), value)) &&
13
+ criteria.cc.every((value) => has(people(m.cc ?? []), value)) &&
14
+ criteria.subject.every((value) => has(m.subject, value)) &&
15
+ criteria.body.every((value) => has(m.text ?? m.html ?? "", value)) &&
16
+ (criteria.since === undefined || at >= criteria.since) &&
17
+ (criteria.before === undefined || at < criteria.before) &&
18
+ (criteria.seen === undefined || (m.seen ?? false) === criteria.seen) &&
19
+ (criteria.flagged === undefined || (m.flagged ?? false) === criteria.flagged));
20
+ };
4
21
  const TRASH = "[Gmail]/Bin";
5
22
  const person = ([name, address]) => (name ? `"${name}" <${address}>` : address);
6
23
  /** The RFC 5322 bytes of a fake message, CRLF line ends, the body and attachment in base64. */
@@ -116,6 +133,18 @@ export const fakeMailSource = (mailbox) => {
116
133
  },
117
134
  }
118
135
  : {}),
136
+ query: async (criteria, folder) => {
137
+ count("search");
138
+ if (mailbox.searchFails)
139
+ throw failure(mailbox.searchFails);
140
+ mailbox.queried?.push({ criteria, folder });
141
+ return {
142
+ validity: String(mailbox.validity ?? 12),
143
+ uids: inFolder(folder)
144
+ .filter((m) => matches(m, criteria))
145
+ .map((m) => m.uid),
146
+ };
147
+ },
119
148
  find: async (messageIds, folder, options) => {
120
149
  for (const id of messageIds)
121
150
  mailbox.asked?.push(`msgid:${id}`);
@@ -25,6 +25,8 @@ export interface AppIdentity {
25
25
  * tg, max and wa print what they always did.
26
26
  */
27
27
  messenger?: boolean;
28
+ /** A CLI that is not a messenger yet sends to people, as mail does: it keeps the hourly limit, `sends_per_hour`. */
29
+ sends?: true;
28
30
  /**
29
31
  * How long one request to the provider usually takes, in ms: a sync's budget is the deadline, less its margin,
30
32
  * divided by it (`requestsFor`). 2 s when unset.
@@ -71,8 +71,8 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
71
71
  const { database } = context;
72
72
  const corpus = execution.corpus === "mail" ? MAIL : MESSAGES;
73
73
  const mail = corpus === MAIL;
74
- if (mail && (execution.only || execution.conversationIds || execution.conversationSince !== undefined))
75
- throw new CliError("validation_error", "mail has no server search or conversations");
74
+ if (mail && (execution.conversationIds || execution.conversationSince !== undefined))
75
+ throw new CliError("validation_error", "mail has no conversations to search within");
76
76
  const started = context.now();
77
77
  const budget = { work: 0 };
78
78
  let states = 0, expansions = 0;
@@ -216,11 +216,11 @@ const compileQuery = (context, execution, boundedAttachments = false) => {
216
216
  where = combine([
217
217
  where,
218
218
  {
219
- sql: `m.id IN (SELECT picked.id FROM json_each(?) j CROSS JOIN accounts allowed CROSS JOIN chats scoped CROSS JOIN messages picked
219
+ sql: `m.id IN (SELECT picked.id FROM json_each(?) j CROSS JOIN accounts allowed CROSS JOIN ${mail ? "email_threads" : "chats"} scoped CROSS JOIN ${mail ? "emails" : "messages"} picked
220
220
  WHERE (${execution.accounts.map(() => "(allowed.provider=? AND allowed.external_id=?)").join(" OR ") || "0"})
221
221
  AND allowed.provider=json_extract(j.value,'$.provider') AND allowed.external_id=json_extract(j.value,'$.account')
222
222
  AND scoped.account_id=allowed.id AND scoped.external_id=json_extract(j.value,'$.chatId')
223
- AND picked.chat_id=scoped.id AND picked.external_id=json_extract(j.value,'$.id'))`,
223
+ AND picked.${mail ? "email_thread_id" : "chat_id"}=scoped.id AND picked.external_id=json_extract(j.value,'$.id'))`,
224
224
  params: [JSON.stringify(execution.only), ...execution.accounts.flatMap((key) => [key.provider, key.account])],
225
225
  exact: true,
226
226
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.524.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",