@wirecat/cli-core 0.518.0 → 0.520.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,28 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.520.0 — 01.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`@wirecat/cli-core/mail/cli`**: `mailReadCommands({ app, read })` mounts `search messages`, `messages show` and
15
+ `conversations list|show` on a mail CLI, read only and scoped to the profile's mailbox. `conversations list` takes a
16
+ query over `subject`, `emails_count` and `last_email_at`; `store.mail.threads({ where })` and `THREAD_COLUMNS`
17
+ answer it in SQL.
18
+ - **`searchMail`, `showMail`, `hitText`, `attachmentsText`, `withAttachmentText`, `lowerSuspicious`** in
19
+ `@wirecat/cli-core/mail`, moved from memo: mail search with demoted senders last (now also across pages), and an
20
+ email with the text of its attachments.
21
+ - **`list:`** in the query language: a mailing list by its List-Id, or by a domain for every list under it
22
+ (`list:github.com`); mail only.
23
+
24
+ ## 0.519.0 — 01.11.2026
25
+
26
+ ### Added
27
+
28
+ - **`scheduleCommandFor(host)`** mounts the `schedule` command on a CLI that is not a messenger (mail-cli): the host
29
+ gives its identity and a context with the environment, the profile and the output. `scheduleCommand(messenger)`
30
+ is the same command, unchanged.
31
+
10
32
  ## 0.518.0 — 01.11.2026
11
33
 
12
34
  ### Added
@@ -96,6 +96,7 @@ export { httpTokenFile, type McpEnvironment, type McpHost, serverEntry } from ".
96
96
  export { type MessageItem, messageItem, type ReadMessage } from "./messenger/message-items.js";
97
97
  export { type ModeProblem, type PrivateFiles, privateFiles, withSqliteSidecars } from "./messenger/private-files.js";
98
98
  export { buildReport as buildDoctorReport, type Report as DoctorReport, type ReportContext as DoctorReportContext, reportFileName as doctorReportFileName, } from "./messenger/report.js";
99
+ export type { ScheduleHost } from "./messenger/schedule-command.js";
99
100
  export { allAnswer, allHitItem, coverageGaps, type HitItem, type MessageHitItem, messageHitItem, type NoteHitItem, noteHitItem, shownOf, } from "./messenger/search-answers.js";
100
101
  export { SEARCHES_QUERY } from "./messenger/searches-command.js";
101
102
  export { servingProfiles } from "./messenger/serve-command.js";
@@ -114,7 +115,7 @@ export { fieldsOf, projectFields } from "./result-fields.js";
114
115
  export { type Config, type Configuration, count, first, flag, fromFile, type GlobalFlags, NONE_NAMED, namedProfile, type ProfileKind, plain, type ResolveOptions, type Settings, type SettingsExtension, type SettingsExtensionContext, type Source, settingsFor, } from "./settings.js";
115
116
  export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
116
117
  export { AGENTS, type Agent, agentFor, screenFor } from "./setup-screen.js";
117
- export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, addMeetingProjectCandidatesCommand, addMeetingSeriesInventoryCommand, appDoctorCommand, appMcpCommand, attachmentsCommand, botAdminsCommand, botCommand, botMcpCommand, botMembersCommand, botMessagesCommand, callsCommand, chatsCommand, commandsCommand, completeCommand, configCommand, contactsCommand, conversationsCommand, deleteCommand, doctorCommand, editCommand, floodCommand, forwardCommand, generatedApiCommand, guideCommand, imagesCommand, inboxCommand, markReadCommand, mcpCommand, mentionsCommand, messagesCommand, metadataCommand, modelCatalogueCommand, modelsCommand, pinCommand, pollsCommand, projectsCommand, reactionsCommand, recipientsCommand, repliesCommand, reviewCommand, runsCommand, scheduleCommand, searchCommand, searchesCommand, sendCommand, sendsCommand, serveCommand, serverCommand, skillCommand, starCommands, statsCommand, stickersCommand, storeCommand, tagsCommand, tasksCommand, tasksStatsCommand, topicsCommand, unpinCommand, upgradeCommand, watchCommand, } from "./shared-command-exports.js";
118
+ export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, addMeetingProjectCandidatesCommand, addMeetingSeriesInventoryCommand, appDoctorCommand, appMcpCommand, attachmentsCommand, botAdminsCommand, botCommand, botMcpCommand, botMembersCommand, botMessagesCommand, callsCommand, chatsCommand, commandsCommand, completeCommand, configCommand, contactsCommand, conversationsCommand, deleteCommand, doctorCommand, editCommand, floodCommand, forwardCommand, generatedApiCommand, guideCommand, imagesCommand, inboxCommand, markReadCommand, mcpCommand, mentionsCommand, messagesCommand, metadataCommand, modelCatalogueCommand, modelsCommand, pinCommand, pollsCommand, projectsCommand, reactionsCommand, recipientsCommand, repliesCommand, reviewCommand, runsCommand, scheduleCommand, scheduleCommandFor, searchCommand, searchesCommand, sendCommand, sendsCommand, serveCommand, serverCommand, skillCommand, starCommands, statsCommand, stickersCommand, storeCommand, tagsCommand, tasksCommand, tasksStatsCommand, topicsCommand, unpinCommand, upgradeCommand, watchCommand, } from "./shared-command-exports.js";
118
119
  export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, type SharedContractOverride, sharedContractOverrides, sharedContracts, } from "./shared-contracts.js";
119
120
  export { runnable, withSafety } from "./steps.js";
120
121
  export { type StoreInfo, sharedStoreCommand, storeInfo, storeInfoCommand } from "./store-info.js";
package/dist/cli/index.js CHANGED
@@ -104,7 +104,7 @@ export { fieldsOf, projectFields } from "./result-fields.js";
104
104
  export { count, first, flag, fromFile, NONE_NAMED, namedProfile, plain, settingsFor, } from "./settings.js";
105
105
  export { readSetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
106
106
  export { AGENTS, agentFor, screenFor } from "./setup-screen.js";
107
- export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, addMeetingProjectCandidatesCommand, addMeetingSeriesInventoryCommand, appDoctorCommand, appMcpCommand, attachmentsCommand, botAdminsCommand, botCommand, botMcpCommand, botMembersCommand, botMessagesCommand, callsCommand, chatsCommand, commandsCommand, completeCommand, configCommand, contactsCommand, conversationsCommand, deleteCommand, doctorCommand, editCommand, floodCommand, forwardCommand, generatedApiCommand, guideCommand, imagesCommand, inboxCommand, markReadCommand, mcpCommand, mentionsCommand, messagesCommand, metadataCommand, modelCatalogueCommand, modelsCommand, pinCommand, pollsCommand, projectsCommand, reactionsCommand, recipientsCommand, repliesCommand, reviewCommand, runsCommand, scheduleCommand, searchCommand, searchesCommand, sendCommand, sendsCommand, serveCommand, serverCommand, skillCommand, starCommands, statsCommand, stickersCommand, storeCommand, tagsCommand, tasksCommand, tasksStatsCommand, topicsCommand, unpinCommand, upgradeCommand, watchCommand, } from "./shared-command-exports.js";
107
+ export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, addMeetingProjectCandidatesCommand, addMeetingSeriesInventoryCommand, appDoctorCommand, appMcpCommand, attachmentsCommand, botAdminsCommand, botCommand, botMcpCommand, botMembersCommand, botMessagesCommand, callsCommand, chatsCommand, commandsCommand, completeCommand, configCommand, contactsCommand, conversationsCommand, deleteCommand, doctorCommand, editCommand, floodCommand, forwardCommand, generatedApiCommand, guideCommand, imagesCommand, inboxCommand, markReadCommand, mcpCommand, mentionsCommand, messagesCommand, metadataCommand, modelCatalogueCommand, modelsCommand, pinCommand, pollsCommand, projectsCommand, reactionsCommand, recipientsCommand, repliesCommand, reviewCommand, runsCommand, scheduleCommand, scheduleCommandFor, searchCommand, searchesCommand, sendCommand, sendsCommand, serveCommand, serverCommand, skillCommand, starCommands, statsCommand, stickersCommand, storeCommand, tagsCommand, tasksCommand, tasksStatsCommand, topicsCommand, unpinCommand, upgradeCommand, watchCommand, } from "./shared-command-exports.js";
108
108
  export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, sharedContractOverrides, sharedContracts, } from "./shared-contracts.js";
109
109
  export { runnable, withSafety } from "./steps.js";
110
110
  export { sharedStoreCommand, storeInfo, storeInfoCommand } from "./store-info.js";
@@ -1,8 +1,19 @@
1
1
  import { Command } from "commander";
2
+ import { type AppIdentity } from "../../ports/app.js";
3
+ import { type AnswerOutput } from "../envelope.js";
2
4
  import { type Messenger } from "./context.js";
5
+ /** What the schedule command needs of a CLI: who it is and, per command, where it prints and which profile runs. */
6
+ export interface ScheduleHost {
7
+ app: AppIdentity;
8
+ context(command: Command): AnswerOutput & {
9
+ env: NodeJS.ProcessEnv;
10
+ profile: string;
11
+ };
12
+ }
3
13
  /**
4
14
  * Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
5
15
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
6
16
  * the rest, so what runs and with which variables is the same on all three.
7
17
  */
18
+ export declare const scheduleCommandFor: ({ app, context: contextOf }: ScheduleHost) => Command;
8
19
  export declare const scheduleCommand: (messenger: Messenger) => Command;
@@ -61,11 +61,10 @@ const withWhen = (command) => command
61
61
  * (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
62
62
  * the rest, so what runs and with which variables is the same on all three.
63
63
  */
64
- export const scheduleCommand = (messenger) => {
65
- const { app } = messenger;
64
+ export const scheduleCommandFor = ({ app, context: contextOf }) => {
66
65
  const command = new Command("schedule").description(`run a ${app.command} command or an agent later or on a timer (systemd, launchd, Task Scheduler); an agent cannot send`);
67
66
  const prepare = (self) => {
68
- const context = messengerContext(self, messenger);
67
+ const context = contextOf(self);
69
68
  const system = environmentOf(self).system ?? thisMachine();
70
69
  const state = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env: context.env }).state;
71
70
  const say = (value, lines) => renderAnswer(self, context, itemOf(value), () => lines.join("\n"));
@@ -230,3 +229,4 @@ export const scheduleCommand = (messenger) => {
230
229
  });
231
230
  return command;
232
231
  };
232
+ export const scheduleCommand = (messenger) => scheduleCommandFor({ app: messenger.app, context: (command) => messengerContext(command, messenger) });
@@ -764,6 +764,7 @@ export declare const stickersCommand: (messenger: import("./index.js").Messenger
764
764
  export declare const repliesCommand: (messenger: import("./index.js").Messenger) => import("commander").Command;
765
765
  export declare const reviewCommand: (messenger: import("./index.js").Messenger) => import("commander").Command;
766
766
  export declare const scheduleCommand: (messenger: import("./index.js").Messenger) => import("commander").Command;
767
+ export declare const scheduleCommandFor: (args_0: import("./messenger/schedule-command.js").ScheduleHost) => import("commander").Command;
767
768
  export declare const searchCommand: (messenger: import("./index.js").Messenger, args_1?: {
768
769
  topics?: boolean;
769
770
  } | undefined) => import("commander").Command;
@@ -41,7 +41,7 @@ import { reactionsCommand as reactionsCommandDefinition } from "./messenger/reac
41
41
  import { callsCommand as callsCommandDefinition, stickersCommand as stickersCommandDefinition, } from "./messenger/records-command.js";
42
42
  import { repliesCommand as repliesCommandDefinition } from "./messenger/replies-command.js";
43
43
  import { reviewCommand as reviewCommandDefinition } from "./messenger/review.js";
44
- import { scheduleCommand as scheduleCommandDefinition } from "./messenger/schedule-command.js";
44
+ import { scheduleCommand as scheduleCommandDefinition, scheduleCommandFor as scheduleCommandForDefinition, } from "./messenger/schedule-command.js";
45
45
  import { searchCommand as searchCommandDefinition } from "./messenger/search-command.js";
46
46
  import { searchesCommand as searchesCommandDefinition } from "./messenger/searches-command.js";
47
47
  import { serveCommand as serveCommandDefinition } from "./messenger/serve-command.js";
@@ -104,6 +104,7 @@ export const stickersCommand = sharedCommand(stickersCommandDefinition);
104
104
  export const repliesCommand = sharedCommand(repliesCommandDefinition);
105
105
  export const reviewCommand = sharedCommand(reviewCommandDefinition);
106
106
  export const scheduleCommand = sharedCommand(scheduleCommandDefinition);
107
+ export const scheduleCommandFor = sharedCommand(scheduleCommandForDefinition);
107
108
  export const searchCommand = sharedCommand(searchCommandDefinition);
108
109
  export const searchesCommand = sharedCommand(searchesCommandDefinition);
109
110
  export const serveCommand = sharedCommand(serveCommandDefinition);
@@ -0,0 +1,22 @@
1
+ import { Command } from "commander";
2
+ import type { AppIdentity } from "../../ports/app.js";
3
+ import { type AccountKey, type MessageStore } from "../../store/store.js";
4
+ /** What a mail CLI hands the shared commands: how to open the store for one run, and whose mail it reads. */
5
+ export interface MailReads {
6
+ app: AppIdentity;
7
+ /**
8
+ * Runs `work` over the shared store as the selected profile's mailbox, and closes the store after. Every command
9
+ * reads that mailbox alone; the store must take writes, since a search tops up its word index first.
10
+ */
11
+ read: <T>(command: Command, work: (store: MessageStore, mailbox: AccountKey) => Promise<T>) => Promise<T>;
12
+ }
13
+ export declare const THREAD_QUERY: {
14
+ readonly entity: "mail_thread";
15
+ readonly backend: "sql";
16
+ readonly fields: import("@wirecat/cli-base/query").FieldSpec[];
17
+ };
18
+ /**
19
+ * `search messages`, `messages show` and `conversations list|show` over the mail a mail CLI stored: the same
20
+ * query language, ranking and answers as memo's mail search, read only, scoped to the profile's mailbox.
21
+ */
22
+ export declare const mailReadCommands: (mail: MailReads) => Command[];
@@ -0,0 +1,243 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { annotate, sharedCommand } from "@wirecat/cli-base/commands";
3
+ import { queryFieldsOf } from "@wirecat/cli-base/query";
4
+ import { Command, InvalidArgumentError } from "commander";
5
+ import * as v from "valibot";
6
+ import { answerEntity } from "../../cli/answer-entity.js";
7
+ import { outputFor } from "../../cli/context.js";
8
+ import { LOCAL_READ } from "../../cli/effects.js";
9
+ import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
10
+ import { listQuery, typedQuery, withQuery } from "../../cli/list-query.js";
11
+ import { messagesAnswer, offsetOf, shownOf } from "../../cli/messenger/search-answers.js";
12
+ import { positiveCount } from "../../cli/paging.js";
13
+ import { formatReference, parseReference } from "../../domain/references.js";
14
+ import { count, reference, text, time, untrusted } from "../../entities/fields.js";
15
+ import { attachmentsText, hitText, searchMail, showMail, withAttachmentText } from "../../mail/read.js";
16
+ import { atDetail } from "../../output-detail.js";
17
+ import { queryFilter, THREAD_COLUMNS } from "../../store/store.js";
18
+ const DEFAULT_LIMIT = 20;
19
+ const MAX_LIMIT = 100;
20
+ const limitOf = (value) => {
21
+ const limit = positiveCount("--limit")(value);
22
+ if (limit > MAX_LIMIT)
23
+ throw new InvalidArgumentError(`at most ${MAX_LIMIT}`);
24
+ return limit;
25
+ };
26
+ const wholeCount = (value) => {
27
+ if (!/^\d+$/.test(value.trim()))
28
+ throw new InvalidArgumentError("expected a whole number from 0");
29
+ return Number(value);
30
+ };
31
+ const detailOf = (command) => {
32
+ const { brief, full } = command.optsWithGlobals();
33
+ return brief ? "brief" : full ? "full" : undefined;
34
+ };
35
+ /** The thread a `chat:email/…` or `msg:email/…` reference names; another mailbox's is refused. */
36
+ const threadOf = (mailbox, given) => {
37
+ const place = parseReference(given);
38
+ if (place.type !== "chat" && place.type !== "message")
39
+ throw new CliError("validation_error", `${given} names no email thread — give chat:email/… or msg:email/…`);
40
+ if (place.provider !== mailbox.provider || place.account !== mailbox.account)
41
+ throw new CliError("not_found", `${given} is not mail of this profile's mailbox ${mailbox.account}`);
42
+ return place.chat;
43
+ };
44
+ const searchCommand = (mail) => {
45
+ const leaf = annotate(new Command("messages"), {
46
+ effects: LOCAL_READ,
47
+ partial: true,
48
+ output: { entity: "message", view: "hit" },
49
+ examples: [
50
+ `${mail.app.command} ya search messages invoice`,
51
+ `${mail.app.command} ya search messages 'from:ann@example.com has:attachment date>30d'`,
52
+ `${mail.app.command} ya search messages 'list:github.com is:unread' --newest`,
53
+ ],
54
+ })
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")
57
+ .argument("<query...>", 'strict Lucene query: words, "phrases", AND/OR/NOT, field:value and date ranges')
58
+ .option("--limit <n>", `how many, at most ${MAX_LIMIT}`, limitOf)
59
+ .option("--page <n>", "which page, starting at 1", positiveCount("--page"))
60
+ .option("--newest", "newest first instead of best first")
61
+ .option("--exact", "bare words and quotes match their exact form only, as exact:word does")
62
+ .option("--plain-order", "best first by the words alone, without moving mail from suspicious senders down")
63
+ .option("--timezone <zone>", "the IANA timezone for calendar date boundaries")
64
+ .addHelpText("after", `
65
+ Fields: from: to: cc: bcc: subject: body: mailbox: (or label:) list: (a List-Id or its domain) is:unread|read|starred
66
+ has:attachment, filename:, content: (the text of attachments), size>, date: — \`${mail.app.command} ya search messages 'content:invoice'\`.
67
+ chat: names a thread by its subject. Mail sent to many is in the mailbox bulk; NOT mailbox:bulk leaves it out.
68
+ 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) {
72
+ const options = this.opts();
73
+ const limit = options.limit ?? DEFAULT_LIMIT;
74
+ const page = options.page ?? 1;
75
+ 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 }));
86
+ const output = outputFor(this);
87
+ const again = {
88
+ query,
89
+ ...(options.newest ? { newest: true } : {}),
90
+ ...(options.exact ? { exact: true } : {}),
91
+ ...(options.plainOrder ? { plain_order: true } : {}),
92
+ ...(options.timezone === undefined ? {} : { timezone: options.timezone }),
93
+ };
94
+ const level = detailOf(this);
95
+ renderAnswer(this, output, messagesAnswer("search messages", found, { limit, page, again }, query), () => {
96
+ if (!found.wordsReady)
97
+ output.renderer.note(`the word index is still being built — \`${mail.app.command} store migrate\` finishes it`);
98
+ if (found.items.length === 0)
99
+ output.renderer.note("nothing found — only mail sync stored is searched");
100
+ const shown = shownOf(found.items.length, (page - 1) * limit, found.total);
101
+ if (shown)
102
+ output.renderer.note(shown);
103
+ return found.items.map((item) => `${hitText(item, level)}\n`).join("");
104
+ });
105
+ });
106
+ return new Command("search").summary("search the stored mail").description("find stored emails").addCommand(leaf);
107
+ };
108
+ const shownText = (emails) => `${emails.map((email) => [hitText(email, "full"), attachmentsText(email)].filter(Boolean).join("\n")).join("\n\n")}\n`;
109
+ const messagesCommand = (mail) => {
110
+ const messages = new Command("messages").summary("read stored emails").description("read the emails sync stored");
111
+ annotate(messages.command("show"), {
112
+ effects: LOCAL_READ,
113
+ examples: [
114
+ `${mail.app.command} ya messages show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E'`,
115
+ `${mail.app.command} ya messages show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E' --before-n 2`,
116
+ ],
117
+ })
118
+ .summary("one stored email whole, with the text of its attachments")
119
+ .description("one stored email: its people, folders, whole text and the first 4000 characters of each attachment's text; " +
120
+ "--full gives all of it and the HTML body. The store keeps text, not the original message source")
121
+ .argument("<message>", "the msg:email/… reference a search gives")
122
+ .option("--before-n <n>", "also this many earlier emails of its thread", wholeCount, 0)
123
+ .option("--after-n <n>", "also this many later emails of its thread", wholeCount, 0)
124
+ .action(async function (reference) {
125
+ const { beforeN, afterN } = this.opts();
126
+ const full = detailOf(this) === "full";
127
+ const emails = await mail.read(this, async (store, mailbox) => {
128
+ threadOf(mailbox, reference);
129
+ return showMail(store, reference, { before: beforeN, after: afterN, full });
130
+ });
131
+ const output = outputFor(this);
132
+ const shown = emails.map((email) => snakeKeys(atDetail(email)));
133
+ renderAnswer(this, output, beforeN || afterN
134
+ ? listOf("messages show", { items: shown, limit: Math.max(1, shown.length), has_more: false })
135
+ : itemOf(shown.find((_, at) => emails[at]?.anchor) ?? shown[0]), () => shownText(emails));
136
+ });
137
+ return messages;
138
+ };
139
+ const mailThread = v.strictObject({
140
+ /** The thread as a `chat:email/…` reference, which `conversations show` takes. */
141
+ ref: reference,
142
+ subject: v.nullable(text),
143
+ emails_count: count,
144
+ last_email_at: v.nullable(time),
145
+ untrusted,
146
+ });
147
+ export const THREAD_QUERY = {
148
+ entity: "mail_thread",
149
+ backend: "sql",
150
+ fields: queryFieldsOf(mailThread, {
151
+ exclude: ["ref", "untrusted"],
152
+ text: ["subject"],
153
+ examples: { subject: "subject:invoice", emails_count: "emails_count>2", last_email_at: "last_email_at>7d" },
154
+ }),
155
+ };
156
+ const conversationsCommand = (mail) => {
157
+ const conversations = new Command("conversations")
158
+ .summary("email threads")
159
+ .description("the threads of the stored mail: an email and its replies");
160
+ const list = withQuery(annotate(conversations.command("list"), {
161
+ effects: LOCAL_READ,
162
+ examples: [
163
+ `${mail.app.command} ya conversations list`,
164
+ `${mail.app.command} ya conversations list subject:invoice emails_count>1`,
165
+ ],
166
+ })
167
+ .summary("stored email threads, newest first")
168
+ .description("this profile's email threads, the newest activity first: subject, how many emails, when"), THREAD_QUERY)
169
+ .option("--limit <n>", `how many, at most ${MAX_LIMIT}`, limitOf)
170
+ .option("--page <n>", "which page, starting at 1", positiveCount("--page"))
171
+ .action(async function (words) {
172
+ const options = this.opts();
173
+ const limit = options.limit ?? DEFAULT_LIMIT;
174
+ const page = options.page ?? 1;
175
+ const typed = typedQuery(words);
176
+ const query = listQuery(typed, THREAD_QUERY, "conversations list");
177
+ const { threads, mailbox } = await mail.read(this, async (store, mailbox) => {
178
+ const account = await store.accounts.storedAccount(mailbox).catch(() => undefined);
179
+ if (!account)
180
+ return { threads: [], mailbox };
181
+ const where = query ? queryFilter(query, THREAD_COLUMNS, { command: "conversations list" }) : undefined;
182
+ const threads = await store.mail.threads({
183
+ accountId: account.id,
184
+ limit: limit + 1,
185
+ offset: (page - 1) * limit,
186
+ ...(where ? { where } : {}),
187
+ });
188
+ return { threads, mailbox };
189
+ });
190
+ const items = threads.slice(0, limit).map((thread) => ({
191
+ ref: formatReference({ type: "chat", ...mailbox, chat: thread.externalId }),
192
+ subject: thread.subject,
193
+ emails_count: thread.emailsCount,
194
+ last_email_at: thread.lastEmailAt === null ? null : new Date(thread.lastEmailAt).toISOString(),
195
+ untrusted: ["subject"],
196
+ }));
197
+ renderAnswer(this, outputFor(this), listOf("conversations list", {
198
+ items,
199
+ limit,
200
+ has_more: threads.length > limit,
201
+ page,
202
+ ...(typed.trim() ? { args: { query: typed } } : {}),
203
+ }), () => items
204
+ .map((one) => `${one.ref}\n ${one.last_email_at ?? "time unknown"} ${one.emails_count} ${one.subject ?? ""}\n`)
205
+ .join(""));
206
+ });
207
+ answerEntity(list, "mail_thread", mailThread);
208
+ annotate(conversations.command("show"), {
209
+ effects: LOCAL_READ,
210
+ examples: [
211
+ `${mail.app.command} ya conversations show 'chat:email/me%40example.com/thread-1'`,
212
+ `${mail.app.command} ya conversations show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E'`,
213
+ ],
214
+ })
215
+ .summary("one email thread, every email oldest first")
216
+ .description("every stored email of one thread, oldest first, each with the text of its attachments")
217
+ .argument("<conversation>", "a chat:email/… thread from conversations list, or a msg:email/… email in it")
218
+ .action(async function (given) {
219
+ const full = detailOf(this) === "full";
220
+ const emails = await mail.read(this, async (store, mailbox) => {
221
+ const thread = threadOf(mailbox, given);
222
+ const account = await store.accounts.storedAccount(mailbox).catch(() => undefined);
223
+ const hits = account ? await store.mail.threadHits(account.id, thread) : [];
224
+ if (!account || hits.length === 0)
225
+ throw new CliError("not_found", `no stored thread ${given}`);
226
+ return Promise.all(hits.map(async (hit) => hit.attachments?.length
227
+ ? withAttachmentText(hit, (await store.mail.email(account.id, hit.id))?.attachments ?? [], full)
228
+ : hit));
229
+ });
230
+ const items = emails.map((email) => snakeKeys(atDetail(email)));
231
+ renderAnswer(this, outputFor(this), listOf("conversations show", { items, limit: Math.max(1, items.length), has_more: false }), () => shownText(emails));
232
+ });
233
+ return conversations;
234
+ };
235
+ /**
236
+ * `search messages`, `messages show` and `conversations list|show` over the mail a mail CLI stored: the same
237
+ * query language, ranking and answers as memo's mail search, read only, scoped to the profile's mailbox.
238
+ */
239
+ export const mailReadCommands = sharedCommand((mail) => [
240
+ searchCommand(mail),
241
+ messagesCommand(mail),
242
+ conversationsCommand(mail),
243
+ ]);
@@ -8,6 +8,7 @@ export { type Address, htmlToText, looksLikeHtml, MAX_BODY_CHARS, type ParsedMai
8
8
  export { PRESET_NAMES, PRESETS, type PresetName, presetMatches } from "./presets.js";
9
9
  export { domainsToAge, groupKey, type Probe, type ProbeGroup, probeMail, SUGGEST_RULE } from "./probe.js";
10
10
  export { type MailFolderProgress, mailProgress } from "./progress.js";
11
+ export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, type MailSearch, type ShownMail, searchMail, showMail, withAttachmentText, } from "./read.js";
11
12
  export { type FileStamp, FULL_PASS_MS, MBOX_RESUME, type MboxPoint, mergeRanges, parsePoint, type Ranges, type ResumePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
12
13
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
13
14
  export { storedMailRule, storedMailRules } from "./rule-stored.js";
@@ -8,6 +8,7 @@ export { htmlToText, looksLikeHtml, MAX_BODY_CHARS, parseMail, } from "./parse.j
8
8
  export { PRESET_NAMES, PRESETS, presetMatches } from "./presets.js";
9
9
  export { domainsToAge, groupKey, probeMail, SUGGEST_RULE } from "./probe.js";
10
10
  export { mailProgress } from "./progress.js";
11
+ export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, searchMail, showMail, withAttachmentText, } from "./read.js";
11
12
  export { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
12
13
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
13
14
  export { storedMailRule, storedMailRules } from "./rule-stored.js";
@@ -0,0 +1,55 @@
1
+ import type { Saved } from "../services/message-resolution.js";
2
+ import type { SearchFound } from "../services/messages.js";
3
+ import type { AccountKey, MessageStore, StoredHit } from "../store/store.js";
4
+ /** Mail from senders with signs of fraud after the rest of the page, each part in the order it had. */
5
+ export declare const lowerSuspicious: <T>(items: readonly T[], suspicious: (item: T) => boolean) => T[];
6
+ export interface MailSearch {
7
+ text: string;
8
+ limit: number;
9
+ offset?: number;
10
+ /** Only these mailboxes; every stored one when neither this nor `mailAccount` is given. */
11
+ accounts?: AccountKey[];
12
+ /** Only this mailbox, by its address. */
13
+ mailAccount?: string;
14
+ exact?: boolean;
15
+ timezone?: string;
16
+ newest?: boolean;
17
+ /** Leave mail from suspicious senders where the words put it, instead of after the rest. */
18
+ plainOrder?: boolean;
19
+ }
20
+ /**
21
+ * Stored mail by the shared query language, best first. Mail whose sender search demotes comes after the rest,
22
+ * unless `plainOrder` or `newest` asks for another order. `runAs` is the account the search runs as.
23
+ */
24
+ export declare const searchMail: (store: MessageStore, runAs: AccountKey, { text, limit, offset, accounts, mailAccount, exact, timezone, newest, plainOrder }: MailSearch, saved?: Saved) => Promise<SearchFound>;
25
+ /** Enough for a bill's amounts and dates; the whole text is asked for separately. */
26
+ export declare const ATTACHMENT_CHARS = 4000;
27
+ export type ShownMail = StoredHit & {
28
+ anchor?: true;
29
+ };
30
+ interface StoredFile {
31
+ text: string | null;
32
+ extraction: string | null;
33
+ extractionError: string | null;
34
+ }
35
+ /** The extracted text of each attachment, next to the name the email lists; a file without text says why. */
36
+ export declare const withAttachmentText: (hit: ShownMail, files: readonly StoredFile[], full: boolean) => ShownMail;
37
+ /**
38
+ * One stored email by its `msg:email/…` locator, with up to `before` and `after` emails of its thread, each with
39
+ * the text of its attachments: the first `ATTACHMENT_CHARS` of each, or all of it and the HTML body with `full`.
40
+ */
41
+ export declare const showMail: (store: MessageStore, locator: string, { before, after, full }?: {
42
+ before?: number;
43
+ after?: number;
44
+ full?: boolean;
45
+ }) => Promise<ShownMail[]>;
46
+ /**
47
+ * A found message or email for a reader: its reference first, then when and from whom, who else it went to and
48
+ * where it is filed, its subject or chat, and an excerpt — the whole text only at `full`.
49
+ */
50
+ export declare const hitText: (item: StoredHit & {
51
+ excerpt?: string;
52
+ }, level?: "brief" | "full") => string;
53
+ /** Each attachment of a shown email with its text indented under it, or why it has none. */
54
+ export declare const attachmentsText: (hit: ShownMail) => string;
55
+ export {};
@@ -0,0 +1,114 @@
1
+ import { parseLocator } from "../domain/locator.js";
2
+ import { readMail } from "../services/mail-search.js";
3
+ import { searchStore } from "../services/messages-archive-search.js";
4
+ /** Mail from senders with signs of fraud after the rest of the page, each part in the order it had. */
5
+ export const lowerSuspicious = (items, suspicious) => [
6
+ ...items.filter((item) => !suspicious(item)),
7
+ ...items.filter(suspicious),
8
+ ];
9
+ /** A few more than asked, so mail moved down makes room for the next best rather than an empty slot. */
10
+ const LOWERED_SPARE = 20;
11
+ /**
12
+ * Stored mail by the shared query language, best first. Mail whose sender search demotes comes after the rest,
13
+ * unless `plainOrder` or `newest` asks for another order. `runAs` is the account the search runs as.
14
+ */
15
+ export const searchMail = async (store, runAs, { text, limit, offset = 0, accounts, mailAccount, exact, timezone, newest, plainOrder }, saved = {}) => {
16
+ const lower = !plainOrder && !newest;
17
+ const request = {
18
+ text,
19
+ kind: "mail",
20
+ ...(accounts === undefined ? {} : { accounts }),
21
+ ...(mailAccount === undefined ? {} : { mailAccount }),
22
+ ...(exact ? { exact: true } : {}),
23
+ ...(timezone === undefined ? {} : { timezone }),
24
+ ...(newest ? { newest: true } : {}),
25
+ };
26
+ if (!lower)
27
+ return searchStore(store, runAs, { ...request, limit, offset }, saved);
28
+ // Lowered from the first hit on, so a later page never repeats or skips what an earlier one moved.
29
+ const found = await searchStore(store, runAs, { ...request, limit: offset + limit + LOWERED_SPARE }, saved);
30
+ const items = lowerSuspicious(found.items, (item) => item.email?.senderScore?.demoted === true);
31
+ const hasMore = found.hasMore || items.length > offset + limit;
32
+ const { nextOffset: _, ...rest } = found;
33
+ return {
34
+ ...rest,
35
+ items: items.slice(offset, offset + limit),
36
+ hasMore,
37
+ ...(hasMore ? { nextOffset: offset + limit } : {}),
38
+ };
39
+ };
40
+ /** Enough for a bill's amounts and dates; the whole text is asked for separately. */
41
+ export const ATTACHMENT_CHARS = 4000;
42
+ /** The HTML and AMP alternatives of the body: markup, not attachments, and thousands of characters each. */
43
+ const isBodyAlternative = (attachment) => attachment.mime === "text/html" || attachment.mime === "text/x-amp-html";
44
+ /** The extracted text of each attachment, next to the name the email lists; a file without text says why. */
45
+ export const withAttachmentText = (hit, files, full) => ({
46
+ ...hit,
47
+ attachments: (hit.attachments ?? [])
48
+ .map((attachment, at) => ({ attachment, file: files[at] }))
49
+ .filter(({ attachment }) => full || !isBodyAlternative(attachment))
50
+ .map(({ attachment, file }) => {
51
+ const text = file?.text ?? "";
52
+ if (!text)
53
+ return { ...attachment, extraction: file?.extractionError ?? file?.extraction ?? "none" };
54
+ const cut = !full && text.length > ATTACHMENT_CHARS;
55
+ return {
56
+ ...attachment,
57
+ text: cut ? text.slice(0, ATTACHMENT_CHARS) : text,
58
+ text_chars: text.length,
59
+ ...(cut ? { text_cut: true } : {}),
60
+ };
61
+ }),
62
+ });
63
+ /**
64
+ * One stored email by its `msg:email/…` locator, with up to `before` and `after` emails of its thread, each with
65
+ * the text of its attachments: the first `ATTACHMENT_CHARS` of each, or all of it and the HTML body with `full`.
66
+ */
67
+ export const showMail = async (store, locator, { before = 0, after = 0, full = false } = {}) => {
68
+ const shown = await readMail(store, locator, { before, after });
69
+ const account = await store.accounts.storedAccount({ provider: "email", account: parseLocator(locator).account });
70
+ if (!account)
71
+ return shown;
72
+ return Promise.all(shown.map(async (hit) => {
73
+ if (!hit.attachments?.length)
74
+ return hit;
75
+ const email = await store.mail.email(account.id, hit.id);
76
+ return withAttachmentText(hit, email?.attachments ?? [], full);
77
+ }));
78
+ };
79
+ const line = (text) => text.replace(/\s+/g, " ").trim();
80
+ /**
81
+ * A found message or email for a reader: its reference first, then when and from whom, who else it went to and
82
+ * where it is filed, its subject or chat, and an excerpt — the whole text only at `full`.
83
+ */
84
+ export const hitText = (item, level) => {
85
+ const mail = item.email;
86
+ const names = (people) => people.map(({ name, address }) => line(name ?? address)).join(", ");
87
+ const between = mail
88
+ ? [
89
+ mail.to.length ? `to ${names(mail.to)}` : "",
90
+ mail.cc.length ? `cc ${names(mail.cc)}` : "",
91
+ mail.labels.join(", "),
92
+ ]
93
+ .filter(Boolean)
94
+ .join(" · ")
95
+ : "";
96
+ const who = line(item.senderName ?? item.senderId ?? "unknown");
97
+ return [
98
+ item.locator,
99
+ ` ${item.timestamp} ${who}${level === "brief" || !between ? "" : ` ${between}`}`,
100
+ ...(level === "brief" || !item.chatTitle ? [] : [` ${line(item.chatTitle)}`]),
101
+ ` ${level === "full" ? item.text.replace(/\n/g, "\n ") : line(item.excerpt ?? item.text)}`,
102
+ ].join("\n");
103
+ };
104
+ /** Each attachment of a shown email with its text indented under it, or why it has none. */
105
+ export const attachmentsText = (hit) => (hit.attachments ?? [])
106
+ .map((attachment) => {
107
+ const file = attachment;
108
+ const name = file.name ?? file.kind;
109
+ if (file.text === undefined)
110
+ return ` ── attachment ${name}: no text (${file.extraction}) ──`;
111
+ const cut = file.text.length < (file.text_chars ?? 0) ? `, first ${file.text.length} of ${file.text_chars}` : "";
112
+ return ` ── attachment ${name} (${file.text_chars} characters${cut}) ──\n ${file.text.replace(/\n/g, "\n ")}`;
113
+ })
114
+ .join("\n");
@@ -125,6 +125,17 @@ export const QUERY_FIELDS = [
125
125
  normalization: "the same as mailbox:; mail only",
126
126
  support: "A1",
127
127
  },
128
+ {
129
+ name: "list",
130
+ example: "list:github.com",
131
+ index: "emails.headers list-id",
132
+ aliases: [],
133
+ type: "kw",
134
+ operators: terms,
135
+ normalization: "the List-Id of a mailing list, lower case; a domain matches every list under it; mail only",
136
+ support: "A1",
137
+ validate: filled,
138
+ },
128
139
  {
129
140
  name: "is",
130
141
  example: "is:unread",
@@ -54,6 +54,7 @@ export { savedTaskCategories } from "./sqlite/task-category-settings.js";
54
54
  export { DEFAULT_AGENT_CLOSE, savedAgentClose } from "./sqlite/task-close.js";
55
55
  export { DEFAULT_FIRST_PASS_PER_CHAT, firstPassPerChatOf, savedFirstPassPerChat } from "./sqlite/task-first-pass.js";
56
56
  export { inboxKey } from "./sqlite/tasks.js";
57
+ export { THREAD_COLUMNS } from "./sqlite/thread-columns.js";
57
58
  export { dropAllUnusedVectors, vectorIndexCounts } from "./sqlite/vector-index.js";
58
59
  export type { MatchPlace, ScoredHit } from "./sqlite/words.js";
59
60
  export type { TaskDetails } from "./sqlite/work-tasks.js";
@@ -24,4 +24,5 @@ export { savedTaskCategories } from "./sqlite/task-category-settings.js";
24
24
  export { DEFAULT_AGENT_CLOSE, savedAgentClose } from "./sqlite/task-close.js";
25
25
  export { DEFAULT_FIRST_PASS_PER_CHAT, firstPassPerChatOf, savedFirstPassPerChat } from "./sqlite/task-first-pass.js";
26
26
  export { inboxKey } from "./sqlite/tasks.js";
27
+ export { THREAD_COLUMNS } from "./sqlite/thread-columns.js";
27
28
  export { dropAllUnusedVectors, vectorIndexCounts } from "./sqlite/vector-index.js";
@@ -8,6 +8,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
+ import { type ThreadFilter } from "./thread-columns.js";
11
12
  export type { EmailLocationInput, FolderEmail };
12
13
  export interface EmailAddress {
13
14
  address: string;
@@ -206,8 +207,8 @@ export interface MailStore {
206
207
  saveThread(input: ThreadSave): Promise<ThreadDetails>;
207
208
  /** Several threads and sync state in one transaction: a slice of mail commits with its resume point, or not at all. */
208
209
  saveThreads(inputs: ThreadSave[], states?: SyncStateSave[]): Promise<ThreadDetails[]>;
209
- /** Newest activity first. */
210
- threads(filter?: MailFilter): Promise<EmailThread[]>;
210
+ /** Newest activity first; `where` is a list query compiled over `THREAD_COLUMNS`. */
211
+ threads(filter?: ThreadFilter): Promise<EmailThread[]>;
211
212
  thread(id: number): Promise<ThreadDetails | null>;
212
213
  email(accountId: number, externalId: string): Promise<Email | null>;
213
214
  /** Newest first. */
@@ -11,6 +11,7 @@ import { mailRulesOver } from "./mail-rules.js";
11
11
  import { attachmentOf, saveAttachment, wordsQuery } from "./meetings.js";
12
12
  import { stemmerCache } from "./stems.js";
13
13
  import { writeState } from "./sync.js";
14
+ import { threadRows } from "./thread-columns.js";
14
15
  import { write } from "./transaction.js";
15
16
  import { dropUnusedVectors } from "./vector-index.js";
16
17
  import { dot } from "./vectors.js";
@@ -432,12 +433,7 @@ export const mailStoreOver = ({ database, orm, now }, stemmerFor = stemmerCache(
432
433
  return ids.map((id) => threadDetails(database, id));
433
434
  },
434
435
  async threads(filter = {}) {
435
- const [limit, offset] = page(filter);
436
- return database
437
- .prepare(`SELECT * FROM email_threads WHERE (? IS NULL OR account_id = ?) AND (? OR deleted_at IS NULL)
438
- ORDER BY coalesce(last_email_at, 0) DESC, id DESC LIMIT ? OFFSET ?`)
439
- .all(filter.accountId ?? null, filter.accountId ?? null, filter.includeDeleted ? 1 : 0, limit, offset)
440
- .map(threadOf);
436
+ return threadRows(database, filter).map(threadOf);
441
437
  },
442
438
  async thread(id) {
443
439
  return threadDetails(database, id);
@@ -381,6 +381,14 @@ const mailbox = ({ value }, { mail }) => always(mail
381
381
  `m.id IN (SELECT em.email_id FROM email_mailboxes em WHERE em.mailbox_id IN
382
382
  (SELECT b.id FROM mailboxes b WHERE lower(b.external_id) = lower(?) OR b.name_folded = ?))`, value, fold(value))
383
383
  : bound("0"));
384
+ // Mail stored in full keeps List-Id in its headers; mail only recorded, in its metadata.
385
+ const list = ({ value }, { mail }) => {
386
+ const wanted = value.trim().toLowerCase();
387
+ return always(mail
388
+ ? bound(`m.id IN (SELECT id FROM (SELECT id, coalesce(json_extract(headers, '$."list-id"'),
389
+ json_extract(metadata, '$.listId')) AS list_id FROM emails) WHERE list_id = ? OR list_id LIKE '%.' || ? ESCAPE '\\')`, wanted, wanted.replace(/[\\%_]/g, "\\$&"))
390
+ : bound("0"));
391
+ };
384
392
  const is = ({ value }, { mail }) => {
385
393
  const state = value.toLowerCase();
386
394
  return always(state === "starred"
@@ -441,6 +449,7 @@ export const FIELD_COMPILERS = {
441
449
  source: notesOnly,
442
450
  mailbox,
443
451
  label: mailbox,
452
+ list,
444
453
  is,
445
454
  subject,
446
455
  };
@@ -0,0 +1,24 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ import type { MailFilter } from "./emails.js";
3
+ import type { Where } from "./query-filter.js";
4
+ /** A thread list query's columns. `subject:` finds a word or phrase at a word's start, `word*` included. */
5
+ export declare const THREAD_COLUMNS: {
6
+ readonly subject: {
7
+ readonly where: ({ value }: import("@wirecat/cli-base/query").Predicate) => {
8
+ sql: string;
9
+ params: string[];
10
+ };
11
+ };
12
+ readonly emails_count: {
13
+ readonly type: "num";
14
+ readonly sql: "email_threads.emails_count";
15
+ };
16
+ readonly last_email_at: {
17
+ readonly type: "time";
18
+ readonly sql: "email_threads.last_email_at";
19
+ };
20
+ };
21
+ export type ThreadFilter = MailFilter & {
22
+ where?: Where;
23
+ };
24
+ export declare const threadRows: (database: CacheDatabase, filter: ThreadFilter) => Record<string, unknown>[];
@@ -0,0 +1,20 @@
1
+ import { fold } from "../normalize.js";
2
+ import { page } from "./events.js";
3
+ /** A thread list query's columns. `subject:` finds a word or phrase at a word's start, `word*` included. */
4
+ export const THREAD_COLUMNS = {
5
+ subject: {
6
+ where: ({ value }) => ({
7
+ sql: "instr(' ' || coalesce(email_threads.subject_folded, lower(email_threads.subject)), ' ' || ?) > 0",
8
+ params: [fold(value).replace(/\*+$/, "")],
9
+ }),
10
+ },
11
+ emails_count: { type: "num", sql: "email_threads.emails_count" },
12
+ last_email_at: { type: "time", sql: "email_threads.last_email_at" },
13
+ };
14
+ export const threadRows = (database, filter) => {
15
+ const [limit, offset] = page(filter);
16
+ return database
17
+ .prepare(`SELECT * FROM email_threads WHERE (? IS NULL OR account_id = ?) AND (? OR deleted_at IS NULL)
18
+ AND (${filter.where?.sql ?? "1"}) ORDER BY coalesce(last_email_at, 0) DESC, id DESC LIMIT ? OFFSET ?`)
19
+ .all(filter.accountId ?? null, filter.accountId ?? null, filter.includeDeleted ? 1 : 0, ...(filter.where?.params ?? []), limit, offset);
20
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.518.0",
3
+ "version": "0.520.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",
@@ -129,6 +129,10 @@
129
129
  "types": "./dist/mail/index.d.ts",
130
130
  "default": "./dist/mail/index.js"
131
131
  },
132
+ "./mail/cli": {
133
+ "types": "./dist/mail/cli/index.d.ts",
134
+ "default": "./dist/mail/cli/index.js"
135
+ },
132
136
  "./mail/testing": {
133
137
  "types": "./dist/mail/testing/index.d.ts",
134
138
  "default": "./dist/mail/testing/index.js"