@iola_adm/iola-cli 0.2.28 → 0.2.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -21,10 +21,14 @@ description: Сервисы Яндекса через Yandex Connector: ID, Ди
21
21
  - `yandex_disk_unshare` - снять публичную ссылку.
22
22
  - `yandex_disk_delete` - удалить файл или папку.
23
23
  - `yandex_mail_status` - проверить доступ к Яндекс Почте.
24
+ - `yandex_mail_folders` - показать папки Яндекс Почты.
24
25
  - `yandex_mail_list` - показать последние письма.
25
26
  - `yandex_mail_search` - найти письма.
26
- - `yandex_mail_read` - прочитать письмо по UID.
27
+ - `yandex_mail_read` - прочитать письмо по UID; обычное чтение помечает письмо прочитанным.
27
28
  - `yandex_mail_send` - отправить письмо.
29
+ - `yandex_mail_reply` - ответить на письмо по UID.
30
+ - `yandex_mail_delete` - переместить письмо в корзину.
31
+ - `yandex_mail_mark` - пометить письмо прочитанным или непрочитанным.
28
32
  - `yandex_calendar_status` - проверить доступ к Яндекс Календарю.
29
33
  - `yandex_calendar_list` - показать события.
30
34
  - `yandex_calendar_create_event` - создать событие.
@@ -35,7 +39,8 @@ description: Сервисы Яндекса через Yandex Connector: ID, Ди
35
39
 
36
40
  Безопасность:
37
41
 
38
- - Для отправки письма, удаления файлов, публикации ссылки и создания событий нужен явный запрос пользователя и `confirm=true`.
39
- - Не отправляй письма, не удаляй файлы и не публикуй ссылки по косвенному намерению.
42
+ - Для отправки письма, ответа на письмо, удаления письма, удаления файлов, публикации ссылки и создания событий нужен явный запрос пользователя и `confirm=true`.
43
+ - Не отправляй письма, не отвечай на письма, не удаляй письма/файлы и не публикуй ссылки по косвенному намерению.
44
+ - Список писем и поиск не должны помечать письма прочитанными. Чтение письма по просьбе пользователя помечает письмо прочитанным.
40
45
  - Не выводи OAuth-токены, API-ключи, пароли и секреты.
41
46
  - Если сервис не подключен, скажи запустить `iola yandex setup` или открыть `/master`.
package/src/cli.js CHANGED
@@ -164,10 +164,14 @@ const YANDEX_TOOLS = [
164
164
  "yandex_disk_unshare",
165
165
  "yandex_disk_delete",
166
166
  "yandex_mail_status",
167
+ "yandex_mail_folders",
167
168
  "yandex_mail_list",
168
169
  "yandex_mail_search",
169
170
  "yandex_mail_read",
170
171
  "yandex_mail_send",
172
+ "yandex_mail_reply",
173
+ "yandex_mail_delete",
174
+ "yandex_mail_mark",
171
175
  "yandex_calendar_status",
172
176
  "yandex_calendar_create_event",
173
177
  "yandex_calendar_list",
@@ -3949,10 +3953,14 @@ async function executeYandexTool(tool, args = {}) {
3949
3953
  if (tool === "yandex_disk_unshare") return yandexDiskUnshare(args.remotePath || args.path);
3950
3954
  if (tool === "yandex_disk_delete") return yandexDiskDelete(args.remotePath || args.path, args);
3951
3955
  if (tool === "yandex_mail_status") return yandexMailStatus();
3952
- if (tool === "yandex_mail_list") return yandexMailList({ mailbox: args.mailbox || "INBOX", limit: args.limit || 10, unread: Boolean(args.unread) });
3953
- if (tool === "yandex_mail_search") return yandexMailSearch(args.query || "", { mailbox: args.mailbox || "INBOX", limit: args.limit || 20 });
3954
- if (tool === "yandex_mail_read") return yandexMailRead(args.uid || args.id, { mailbox: args.mailbox || "INBOX" });
3956
+ if (tool === "yandex_mail_folders") return yandexMailFolders();
3957
+ if (tool === "yandex_mail_list") return yandexMailList({ mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), limit: args.limit || 10, unread: Boolean(args.unread) });
3958
+ if (tool === "yandex_mail_search") return yandexMailSearch(args.query || "", { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), limit: args.limit || 20 });
3959
+ if (tool === "yandex_mail_read") return yandexMailRead(args.uid || args.id, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: args.markSeen !== false });
3955
3960
  if (tool === "yandex_mail_send") return yandexMailSend(args);
3961
+ if (tool === "yandex_mail_reply") return yandexMailReply(args);
3962
+ if (tool === "yandex_mail_delete") return yandexMailDelete(args.uid || args.id, { ...args, mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX") });
3963
+ if (tool === "yandex_mail_mark") return yandexMailMark(args.uid || args.id, args.seen !== false && args.unread !== true, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX") });
3956
3964
  if (tool === "yandex_calendar_status") return yandexCalendarStatus();
3957
3965
  if (tool === "yandex_calendar_create_event") return yandexCalendarCreateEvent(args);
3958
3966
  if (tool === "yandex_calendar_list") return yandexCalendarList(args);
@@ -3985,6 +3993,62 @@ async function yandexMailStatus() {
3985
3993
  }
3986
3994
  }
3987
3995
 
3996
+ async function yandexMailFolders() {
3997
+ const { token, email } = await yandexMailCredentials();
3998
+ const session = await imapConnect();
3999
+ try {
4000
+ await imapAuthenticate(session, email, token);
4001
+ const text = await imapCommand(session, 'LIST "" "*"');
4002
+ return parseImapListMailboxes(text);
4003
+ } finally {
4004
+ await imapClose(session);
4005
+ }
4006
+ }
4007
+
4008
+ async function resolveYandexMailbox(value = "INBOX") {
4009
+ const requested = String(value || "INBOX").trim();
4010
+ if (!requested || /^inbox$/iu.test(requested)) return "INBOX";
4011
+ if (!isYandexMailboxAlias(requested)) return requested;
4012
+ const folders = await yandexMailFolders();
4013
+ const target = normalizeYandexMailboxAlias(requested);
4014
+ const found = findYandexMailbox(folders, target);
4015
+ if (found?.name) return found.name;
4016
+ throw new Error(`Папка Яндекс Почты не найдена: ${requested}. Проверьте список папок командой: покажи папки почты.`);
4017
+ }
4018
+
4019
+ function isYandexMailboxAlias(value) {
4020
+ return /^(sent|send|sentmail|отправ|исход|draft|drafts|чернов|spam|junk|спам|trash|bin|корзин|удален|удалён)$/iu.test(String(value || "").trim());
4021
+ }
4022
+
4023
+ function normalizeYandexMailboxAlias(value) {
4024
+ const text = String(value || "").toLocaleLowerCase("ru-RU");
4025
+ if (/(sent|send|sentmail|отправ|исход)/iu.test(text)) return "sent";
4026
+ if (/(draft|drafts|чернов)/iu.test(text)) return "drafts";
4027
+ if (/(spam|junk|спам)/iu.test(text)) return "spam";
4028
+ if (/(trash|bin|корзин|удален|удалён)/iu.test(text)) return "trash";
4029
+ return "inbox";
4030
+ }
4031
+
4032
+ function findYandexMailbox(folders, target) {
4033
+ const special = {
4034
+ sent: "\\Sent",
4035
+ drafts: "\\Drafts",
4036
+ spam: "\\Junk",
4037
+ trash: "\\Trash",
4038
+ }[target];
4039
+ if (special) {
4040
+ const bySpecial = folders.find((folder) => folder.flags.some((flag) => flag.toLocaleLowerCase("en-US") === special.toLocaleLowerCase("en-US")));
4041
+ if (bySpecial) return bySpecial;
4042
+ }
4043
+ const patterns = {
4044
+ sent: /(sent|отправ|исход)/iu,
4045
+ drafts: /(draft|чернов)/iu,
4046
+ spam: /(spam|junk|спам)/iu,
4047
+ trash: /(trash|bin|корзин|удален|удалён)/iu,
4048
+ }[target];
4049
+ return folders.find((folder) => patterns?.test(folder.name) || patterns?.test(folder.displayName || ""));
4050
+ }
4051
+
3988
4052
  async function yandexMailList(options = {}) {
3989
4053
  const { token, email } = await yandexMailCredentials();
3990
4054
  const session = await imapConnect();
@@ -4030,13 +4094,72 @@ async function yandexMailRead(uid, options = {}) {
4030
4094
  try {
4031
4095
  await imapAuthenticate(session, email, token);
4032
4096
  await imapCommand(session, `SELECT ${quoteImapMailbox(options.mailbox || "INBOX")}`);
4033
- const fetch = await imapCommand(session, `UID FETCH ${Number(uid)} (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (DATE FROM SUBJECT)] BODY.PEEK[TEXT])`, { timeout: 60000 });
4097
+ const bodyAccessor = options.markSeen === false ? "BODY.PEEK[TEXT]" : "BODY[TEXT]";
4098
+ const fetch = await imapCommand(session, `UID FETCH ${Number(uid)} (UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (DATE FROM SUBJECT MESSAGE-ID REFERENCES)] ${bodyAccessor})`, { timeout: 60000 });
4034
4099
  return parseImapFetchSummaries(fetch, { full: true })[0] || { uid, status: "not-found" };
4035
4100
  } finally {
4036
4101
  await imapClose(session);
4037
4102
  }
4038
4103
  }
4039
4104
 
4105
+ async function yandexMailMark(uid, seen = true, options = {}) {
4106
+ if (!uid) throw new Error("UID письма обязателен.");
4107
+ const { token, email } = await yandexMailCredentials();
4108
+ const session = await imapConnect();
4109
+ try {
4110
+ await imapAuthenticate(session, email, token);
4111
+ const mailbox = options.mailbox || "INBOX";
4112
+ await imapCommand(session, `SELECT ${quoteImapMailbox(mailbox)}`);
4113
+ const command = seen ? "+FLAGS.SILENT" : "-FLAGS.SILENT";
4114
+ await imapCommand(session, `UID STORE ${Number(uid)} ${command} (\\Seen)`);
4115
+ return { uid: Number(uid), mailbox, status: seen ? "seen" : "unseen" };
4116
+ } finally {
4117
+ await imapClose(session);
4118
+ }
4119
+ }
4120
+
4121
+ async function yandexMailDelete(uid, options = {}) {
4122
+ if (!options.confirm) throw new Error("Для удаления письма нужен аргумент confirm=true.");
4123
+ if (!uid) throw new Error("UID письма обязателен.");
4124
+ const folders = await yandexMailFolders();
4125
+ const trash = options.trashMailbox || findYandexMailbox(folders, "trash")?.name;
4126
+ if (!trash) throw new Error("Не нашел папку корзины в Яндекс Почте. Безопасное удаление невозможно.");
4127
+ const { token, email } = await yandexMailCredentials();
4128
+ const session = await imapConnect();
4129
+ try {
4130
+ await imapAuthenticate(session, email, token);
4131
+ const mailbox = options.mailbox || "INBOX";
4132
+ await imapCommand(session, `SELECT ${quoteImapMailbox(mailbox)}`);
4133
+ await imapCommand(session, `UID MOVE ${Number(uid)} ${quoteImapMailbox(trash)}`, { timeout: 60000 });
4134
+ return { uid: Number(uid), from: mailbox, to: trash, status: "moved-to-trash" };
4135
+ } finally {
4136
+ await imapClose(session);
4137
+ }
4138
+ }
4139
+
4140
+ async function yandexMailReply(args = {}) {
4141
+ if (!args.confirm) throw new Error("Для ответа на письмо нужен аргумент confirm=true.");
4142
+ const uid = args.uid || args.id;
4143
+ const text = args.text || args.body || args.message || "";
4144
+ if (!uid) throw new Error("UID письма обязателен.");
4145
+ if (!String(text).trim()) throw new Error("Текст ответа пустой.");
4146
+ const mailbox = await resolveYandexMailbox(args.mailbox || args.folder || "INBOX");
4147
+ const original = await yandexMailRead(uid, { mailbox, markSeen: true, includeMessageId: true });
4148
+ if (!original || original.status === "not-found") throw new Error(`Письмо #${uid} не найдено.`);
4149
+ const to = [extractEmailAddress(original.from)].filter(Boolean);
4150
+ if (!to.length) throw new Error("Не удалось определить получателя ответа из поля From.");
4151
+ const subject = /^re:/iu.test(original.subject || "") ? original.subject : `Re: ${original.subject || "(без темы)"}`;
4152
+ const result = await yandexMailSend({
4153
+ to,
4154
+ subject,
4155
+ text,
4156
+ confirm: true,
4157
+ inReplyTo: original.messageId || "",
4158
+ references: original.references || original.messageId || "",
4159
+ });
4160
+ return { ...result, replyToUid: Number(uid), originalFrom: original.from };
4161
+ }
4162
+
4040
4163
  async function yandexMailSend(args = {}) {
4041
4164
  if (!args.confirm) throw new Error("Для отправки письма нужен аргумент confirm=true.");
4042
4165
  const to = Array.isArray(args.to) ? args.to : String(args.to || "").split(/[;,]/).map((item) => item.trim()).filter(Boolean);
@@ -4052,7 +4175,7 @@ async function yandexMailSend(args = {}) {
4052
4175
  await smtpCommand(session, `MAIL FROM:<${email}>`);
4053
4176
  for (const recipient of to) await smtpCommand(session, `RCPT TO:<${recipient}>`);
4054
4177
  await smtpCommand(session, "DATA", { expect: /^354/u });
4055
- await smtpCommand(session, `${dotStuffSmtpData(buildMimeMessage({ from: email, to, subject, text }))}\r\n.`);
4178
+ await smtpCommand(session, `${dotStuffSmtpData(buildMimeMessage({ from: email, to, subject, text, inReplyTo: args.inReplyTo, references: args.references }))}\r\n.`);
4056
4179
  await smtpCommand(session, "QUIT").catch(() => {});
4057
4180
  return { from: email, to, subject, status: "sent" };
4058
4181
  } finally {
@@ -4064,7 +4187,7 @@ function buildXoauth2(email, token) {
4064
4187
  return Buffer.from(`user=${email}\x01auth=Bearer ${token}\x01\x01`).toString("base64");
4065
4188
  }
4066
4189
 
4067
- function buildMimeMessage({ from, to, subject, text }) {
4190
+ function buildMimeMessage({ from, to, subject, text, inReplyTo = "", references = "" }) {
4068
4191
  const encodedSubject = Buffer.from(subject, "utf8").toString("base64");
4069
4192
  const messageIdDomain = String(from || "").split("@")[1] || "localhost";
4070
4193
  const messageId = `${randomUUID()}@${messageIdDomain}`;
@@ -4075,13 +4198,15 @@ function buildMimeMessage({ from, to, subject, text }) {
4075
4198
  `Subject: =?UTF-8?B?${encodedSubject}?=`,
4076
4199
  `Date: ${new Date().toUTCString()}`,
4077
4200
  `Message-ID: <${messageId}>`,
4201
+ inReplyTo ? `In-Reply-To: ${inReplyTo}` : "",
4202
+ references ? `References: ${references}` : "",
4078
4203
  "MIME-Version: 1.0",
4079
4204
  "Content-Type: text/plain; charset=utf-8",
4080
4205
  "Content-Transfer-Encoding: base64",
4081
4206
  "X-Mailer: IOLA CLI",
4082
4207
  "",
4083
4208
  Buffer.from(text.replace(/\r?\n/g, "\r\n"), "utf8").toString("base64").replace(/.{1,76}/g, "$&\r\n").trim(),
4084
- ].join("\r\n");
4209
+ ].filter((line) => line !== "").join("\r\n");
4085
4210
  }
4086
4211
 
4087
4212
  function dotStuffSmtpData(message) {
@@ -4171,7 +4296,7 @@ async function imapClose(session) {
4171
4296
  }
4172
4297
 
4173
4298
  function quoteImapMailbox(value) {
4174
- return `"${String(value || "INBOX").replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4299
+ return `"${encodeImapModifiedUtf7(String(value || "INBOX")).replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
4175
4300
  }
4176
4301
 
4177
4302
  function parseImapSelect(text) {
@@ -4187,6 +4312,55 @@ function parseImapSearchUids(text) {
4187
4312
  .flatMap((match) => match[1].trim().split(/\s+/).map(Number).filter(Boolean));
4188
4313
  }
4189
4314
 
4315
+ function parseImapListMailboxes(text) {
4316
+ return String(text || "")
4317
+ .split(/\r?\n/u)
4318
+ .map((line) => {
4319
+ const match = line.match(/^\* LIST \(([^)]*)\) (?:"([^"]*)"|NIL) (?:"((?:\\"|[^"])*)"|(.+))$/iu);
4320
+ if (!match) return null;
4321
+ const flags = [...match[1].matchAll(/\\[^\s)]+/gu)].map((item) => item[0]);
4322
+ const delimiter = match[2] || "";
4323
+ const rawName = (match[3] || match[4] || "").trim();
4324
+ const name = decodeImapModifiedUtf7(rawName.replace(/\\"/g, "\""));
4325
+ return {
4326
+ name,
4327
+ displayName: name,
4328
+ delimiter,
4329
+ flags,
4330
+ special: flags.find((flag) => /\\(?:Inbox|Sent|Drafts|Junk|Trash)/iu.test(flag)) || "",
4331
+ };
4332
+ })
4333
+ .filter(Boolean);
4334
+ }
4335
+
4336
+ function decodeImapModifiedUtf7(value) {
4337
+ return String(value || "").replace(/&([^-]*)-/gu, (_, data) => {
4338
+ if (!data) return "&";
4339
+ const base64 = data.replace(/,/g, "/");
4340
+ try {
4341
+ const buffer = Buffer.from(base64, "base64");
4342
+ let decoded = "";
4343
+ for (let index = 0; index + 1 < buffer.length; index += 2) {
4344
+ decoded += String.fromCharCode(buffer.readUInt16BE(index));
4345
+ }
4346
+ return decoded;
4347
+ } catch {
4348
+ return `&${data}-`;
4349
+ }
4350
+ });
4351
+ }
4352
+
4353
+ function encodeImapModifiedUtf7(value) {
4354
+ return String(value || "").replace(/&/gu, "&-").replace(/[^\x20-\x7e]+/gu, (chunk) => {
4355
+ const bytes = [];
4356
+ for (const char of chunk) {
4357
+ const code = char.charCodeAt(0);
4358
+ bytes.push((code >> 8) & 0xff, code & 0xff);
4359
+ }
4360
+ return `&${Buffer.from(bytes).toString("base64").replace(/\//g, ",").replace(/=+$/u, "")}-`;
4361
+ });
4362
+ }
4363
+
4190
4364
  function parseImapFetchSummaries(text, options = {}) {
4191
4365
  const rows = [];
4192
4366
  const chunks = String(text || "").split(/\n(?=\* \d+ FETCH)/u);
@@ -4198,7 +4372,15 @@ function parseImapFetchSummaries(text, options = {}) {
4198
4372
  const from = headers.from || "";
4199
4373
  const date = headers.date || "";
4200
4374
  const body = options.full ? stripMailBody(chunk) : stripMailBody(chunk).slice(0, 800);
4201
- rows.push({ uid, date, from, subject, snippet: body.replace(/\s+/g, " ").trim().slice(0, options.full ? 12000 : 500) });
4375
+ rows.push({
4376
+ uid,
4377
+ date,
4378
+ from,
4379
+ subject,
4380
+ messageId: headers.messageId || "",
4381
+ references: headers.references || "",
4382
+ snippet: body.replace(/\s+/g, " ").trim().slice(0, options.full ? 12000 : 500),
4383
+ });
4202
4384
  }
4203
4385
  return rows;
4204
4386
  }
@@ -4213,9 +4395,18 @@ function parseMailHeaders(value) {
4213
4395
  date: headerValue("Date"),
4214
4396
  from: headerValue("From"),
4215
4397
  subject: headerValue("Subject"),
4398
+ messageId: headerValue("Message-ID"),
4399
+ references: headerValue("References"),
4216
4400
  };
4217
4401
  }
4218
4402
 
4403
+ function extractEmailAddress(value) {
4404
+ const text = String(value || "").trim();
4405
+ return text.match(/<([^<>@\s]+@[^<>@\s]+)>/u)?.[1]
4406
+ || text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu)?.[0]
4407
+ || "";
4408
+ }
4409
+
4219
4410
  function decodeMimeHeader(value) {
4220
4411
  return String(value || "")
4221
4412
  .replace(/\?=\s+=\?/gu, "?==?")
@@ -9637,12 +9828,38 @@ async function buildYandexDirectAnswer(question, history = []) {
9637
9828
  ].join("\n");
9638
9829
  }
9639
9830
 
9640
- if (/(почт|письм|email|e-mail)/iu.test(normalized)) {
9831
+ if (mailFollowup || /(почт|письм|email|e-mail|спам|чернов|отправлен|исходящ|корзин)/iu.test(normalized)) {
9832
+ if (/(папк|ящик|mailbox|folder)/iu.test(normalized) && /(покажи|список|какие|есть)/iu.test(normalized)) {
9833
+ const folders = await yandexMailFolders();
9834
+ if (!folders.length) return "Папки Яндекс Почты не найдены.";
9835
+ return ["Папки Яндекс Почты:", ...folders.map((folder, index) => `${index + 1}. ${folder.name}${folder.special ? ` (${folder.special})` : ""}`)].join("\n");
9836
+ }
9641
9837
  if (/(статус|проверь|работает|доступ)/iu.test(normalized)) {
9642
9838
  const result = await yandexMailStatus();
9643
9839
  return `Яндекс Почта подключена: ${result.email}. Входящие: ${result.inbox?.exists ?? "-"}.`;
9644
9840
  }
9645
- if (/(отправ|напиши|пошли)/iu.test(normalized)) {
9841
+ if (/(ответь|ответить|напиши\s+ответ)/iu.test(normalized)) {
9842
+ const reply = parseYandexMailReplyRequest(question, previousAssistantText);
9843
+ if (!reply.uid || !reply.text) return "Для ответа укажите письмо и текст. Пример: ответь на письмо #2382 текст: спасибо, получил.";
9844
+ const result = await yandexMailReply({ ...reply, confirm: true });
9845
+ return `Ответ отправлен на письмо #${result.replyToUid}: ${result.to.join(", ")}. Тема: ${result.subject}.`;
9846
+ }
9847
+ if (/(удали|удалить|перемести\s+в\s+корзин)/iu.test(normalized)) {
9848
+ const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
9849
+ if (!uid) return "Какое письмо удалить? Укажите номер из списка или UID, например: удали письмо #2382.";
9850
+ const mailbox = await resolveYandexMailbox(extractYandexMailboxName(question) || "INBOX");
9851
+ const result = await yandexMailDelete(uid, { mailbox, confirm: true });
9852
+ return `Письмо #${result.uid} перемещено в корзину: ${result.to}.`;
9853
+ }
9854
+ if (/(пометь|отметь|сделай)/iu.test(normalized) && /(прочитан|непрочитан)/iu.test(normalized)) {
9855
+ const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
9856
+ if (!uid) return "Какое письмо пометить? Укажите номер из списка или UID.";
9857
+ const seen = !/непрочитан/iu.test(normalized);
9858
+ const mailbox = await resolveYandexMailbox(extractYandexMailboxName(question) || "INBOX");
9859
+ const result = await yandexMailMark(uid, seen, { mailbox });
9860
+ return `Письмо #${result.uid} помечено как ${seen ? "прочитанное" : "непрочитанное"}.`;
9861
+ }
9862
+ if (/(отправь|отправить|напиши|пошли)/iu.test(normalized) && !/(отправлен|исходящ)/iu.test(normalized)) {
9646
9863
  const draft = parseYandexMailSendRequest(question);
9647
9864
  if (!draft.to.length || !draft.text) {
9648
9865
  return "Для отправки письма укажите получателя и текст. Пример: отправь письмо user@example.com тема: Привет текст: Проверка.";
@@ -9651,9 +9868,10 @@ async function buildYandexDirectAnswer(question, history = []) {
9651
9868
  return `Письмо отправлено: ${result.to.join(", ")}. Тема: ${result.subject}.`;
9652
9869
  }
9653
9870
  if (/(сколько|количеств|есть\s+ли)/iu.test(normalized) && /непрочитан/iu.test(normalized)) {
9654
- const count = await yandexMailCount({ unread: true });
9871
+ const mailbox = await resolveYandexMailbox(extractYandexMailboxName(question) || "INBOX");
9872
+ const count = await yandexMailCount({ mailbox, unread: true });
9655
9873
  if (!count) return "Непрочитанных писем нет.";
9656
- const latest = await yandexMailList({ limit: 1, unread: true });
9874
+ const latest = await yandexMailList({ mailbox, limit: 1, unread: true });
9657
9875
  return [
9658
9876
  `Непрочитанных писем: ${count}.`,
9659
9877
  latest[0] ? `Самое свежее: ${formatYandexMailSummary(latest[0])}` : "",
@@ -9663,13 +9881,14 @@ async function buildYandexDirectAnswer(question, history = []) {
9663
9881
  const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText)
9664
9882
  || await getLatestYandexMailUid({ unread: /непрочитан/iu.test(normalized) });
9665
9883
  if (!uid) return "Писем для чтения не найдено.";
9666
- const row = await yandexMailRead(uid);
9884
+ const row = await yandexMailRead(uid, { mailbox: await resolveYandexMailbox(extractYandexMailboxName(question) || "INBOX") });
9667
9885
  if (!row || row.status === "not-found") return `Письмо #${uid} не найдено.`;
9668
9886
  return formatYandexMailRead(row);
9669
9887
  }
9888
+ const mailbox = await resolveYandexMailbox(extractYandexMailboxName(question) || "INBOX");
9670
9889
  const rows = /(найди|поиск)/iu.test(normalized)
9671
- ? await yandexMailSearch(cleanupYandexQuery(question), { limit: 10 })
9672
- : await yandexMailList({ limit: 10, unread: /непрочитан/iu.test(normalized) });
9890
+ ? await yandexMailSearch(cleanupYandexQuery(question), { mailbox, limit: 10 })
9891
+ : await yandexMailList({ mailbox, limit: 10, unread: /непрочитан/iu.test(normalized) });
9673
9892
  if (!rows.length) return "Писем по запросу не найдено.";
9674
9893
  return ["Яндекс Почта:", ...rows.map((row, index) => `${index + 1}. ${formatYandexMailSummary(row)}`)].join("\n");
9675
9894
  }
@@ -9699,7 +9918,7 @@ async function buildYandexDirectAnswer(question, history = []) {
9699
9918
  }
9700
9919
 
9701
9920
  function isYandexServiceQuestion(normalized) {
9702
- return /(яндекс|яндес|язндекс|язндекс|яндкс|yandex|почт|письм|календар|контакт|телемост)/iu.test(String(normalized || ""));
9921
+ return /(яндекс|яндес|язндекс|язндекс|яндкс|yandex|почт|письм|календар|контакт|телемост|спам|чернов|отправлен|исходящ|корзин)/iu.test(String(normalized || ""));
9703
9922
  }
9704
9923
 
9705
9924
  function isYandexIdentityQuestion(normalized) {
@@ -9711,7 +9930,8 @@ function isYandexIdentityQuestion(normalized) {
9711
9930
  function isYandexMailFollowupQuestion(normalized, question) {
9712
9931
  return isYandexMailReadRequest(normalized)
9713
9932
  || isYandexMailSelectionQuestion(question)
9714
- || /(самое\s+свеж|последн|текст\s+(?:то\s+)?(?:письм|где)|содержим)/iu.test(String(normalized || ""));
9933
+ || /(ответь|ответить|напиши\s+ответ|удали|удалить|перемести\s+в\s+корзин|пометь|отметь|сделай)/iu.test(String(normalized || ""))
9934
+ || /(самое\s+свеж|последн|получи|получить|текст\s+(?:то\s+)?(?:письм|где)|содержим)/iu.test(String(normalized || ""));
9715
9935
  }
9716
9936
 
9717
9937
  function cleanupYandexQuery(question) {
@@ -9724,6 +9944,16 @@ function cleanupYandexQuery(question) {
9724
9944
  .trim();
9725
9945
  }
9726
9946
 
9947
+ function extractYandexMailboxName(question) {
9948
+ const text = String(question || "").toLocaleLowerCase("ru-RU");
9949
+ if (/(спам|spam|junk)/iu.test(text)) return "spam";
9950
+ if (/(чернов|draft)/iu.test(text)) return "drafts";
9951
+ if (/(отправлен|исходящ|sent)/iu.test(text)) return "sent";
9952
+ if (/(корзин|удален|удалён|trash|bin)/iu.test(text)) return "trash";
9953
+ const explicit = String(question || "").match(/(?:папк[аиуы]?|mailbox|folder)\s+["'«]?([^"'».,!?]+)["'»]?/iu)?.[1];
9954
+ return explicit ? explicit.trim() : "";
9955
+ }
9956
+
9727
9957
  function extractYandexMailUid(question) {
9728
9958
  const text = String(question || "");
9729
9959
  const explicit = text.match(/(?:#|uid\s*|письм[оа]?\s*)?(\d{3,})/iu)?.[1];
@@ -9742,7 +9972,12 @@ function resolveYandexMailUidFromQuestion(question, previousAssistantText = "")
9742
9972
  const uid = extractYandexMailUidByOrdinal(previousAssistantText, Number(ordinal));
9743
9973
  if (uid) return uid;
9744
9974
  }
9745
- if (/(самое\s+свеж|последн|текст\s+(?:то\s+)?(?:письм|где)|содержим)/iu.test(String(question || ""))) {
9975
+ const actionOrdinal = String(question || "").match(/(?:удали|удалить|пометь|отметь|сделай|ответь|ответить)\s+#?(\d{1,3})\.?(?!\d)/iu)?.[1];
9976
+ if (actionOrdinal) {
9977
+ const uid = extractYandexMailUidByOrdinal(previousAssistantText, Number(actionOrdinal));
9978
+ if (uid) return uid;
9979
+ }
9980
+ if (/(самое\s+свеж|последн|получи|получить|текст\s+(?:то\s+)?(?:письм|где)|содержим)/iu.test(String(question || ""))) {
9746
9981
  return extractFirstYandexMailUid(previousAssistantText);
9747
9982
  }
9748
9983
  return 0;
@@ -9763,10 +9998,22 @@ function extractFirstYandexMailUid(text) {
9763
9998
  }
9764
9999
 
9765
10000
  function isYandexMailReadRequest(normalizedQuestion) {
9766
- return /(^|\s)(прочитай|прочти|открой|раскрой|прочитаем)(\s|$)/iu.test(normalizedQuestion)
10001
+ return /(^|\s)(прочитай|прочти|открой|раскрой|прочитаем|получи|получить)(\s|$)/iu.test(normalizedQuestion)
9767
10002
  || /(покажи\s+содерж|о чем|о чём|текст\s+(?:то\s+)?(?:письм|где)|содержим)/iu.test(normalizedQuestion);
9768
10003
  }
9769
10004
 
10005
+ function parseYandexMailReplyRequest(question, previousAssistantText = "") {
10006
+ const text = String(question || "").replace(/\s+/g, " ").trim();
10007
+ const uid = resolveYandexMailUidFromQuestion(text, previousAssistantText);
10008
+ const bodyMatch = text.match(/(?:текст|body|сообщение)\s*:\s*(.*)$/iu)
10009
+ || text.match(/(?:ответь|ответить|напиши\s+ответ)(?:\s+на\s+письмо\s+#?\d+|\s+#?\d+)?\s*:?\s*(.*)$/iu);
10010
+ return {
10011
+ uid,
10012
+ text: (bodyMatch?.[1] || "").trim(),
10013
+ mailbox: extractYandexMailboxName(question) || "INBOX",
10014
+ };
10015
+ }
10016
+
9770
10017
  async function getLatestYandexMailUid(options = {}) {
9771
10018
  const rows = await yandexMailList({ limit: 1, unread: Boolean(options.unread) });
9772
10019
  return rows[0]?.uid || 0;
@@ -10413,8 +10660,8 @@ async function buildLocalToolPlan(question, providerConfig, options) {
10413
10660
  `Доступные tools: ${availableToolNames(options).join(", ")}.`,
10414
10661
  "Схема: {\"steps\":[{\"tool\":\"search_data\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
10415
10662
  "Минимальные tools: search_data {dataset,query,limit}, get_card {query}, export_report {name,format,output}, file_read {path}, browser_open {url}.",
10416
- "Yandex tools: yandex_identity_me {}, yandex_disk_ls {path}, yandex_disk_mkdir {path}, yandex_disk_find {query,path}, yandex_disk_save_text {path,text}, yandex_mail_list {limit,unread}, yandex_mail_search {query}, yandex_mail_read {uid}, yandex_calendar_list {start,end}, yandex_contacts_search {query}.",
10417
- "Опасные Yandex tools используй только при явной просьбе пользователя и с confirm=true: yandex_disk_share, yandex_disk_delete, yandex_mail_send, yandex_calendar_create_event, yandex_telemost_create_event.",
10663
+ "Yandex tools: yandex_identity_me {}, yandex_disk_ls {path}, yandex_disk_mkdir {path}, yandex_disk_find {query,path}, yandex_disk_save_text {path,text}, yandex_mail_folders {}, yandex_mail_list {mailbox,limit,unread}, yandex_mail_search {mailbox,query}, yandex_mail_read {mailbox,uid}, yandex_mail_mark {mailbox,uid,seen}, yandex_calendar_list {start,end}, yandex_contacts_search {query}.",
10664
+ "Опасные Yandex tools используй только при явной просьбе пользователя и с confirm=true: yandex_disk_share, yandex_disk_delete, yandex_mail_send, yandex_mail_reply, yandex_mail_delete, yandex_calendar_create_event, yandex_telemost_create_event.",
10418
10665
  "MCP tools доступны как mcp:SERVER:TOOL, например mcp:iola-local:search.",
10419
10666
  "Для выгрузки CSV добавь export_report с format=csv и output, если пользователь назвал файл.",
10420
10667
  `Вопрос: ${question}`,
@@ -10483,9 +10730,16 @@ function inferToolPlan(question, options = {}) {
10483
10730
  if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_disk_find", args: { query: question, path: CLOUD_DEFAULT_REMOTE_DIR, limit: 20 } }] };
10484
10731
  return { steps: [{ tool: "yandex_disk_ls", args: { path: CLOUD_DEFAULT_REMOTE_DIR } }] };
10485
10732
  }
10486
- if (/(почт|письм|email|e-mail)/iu.test(normalized)) {
10487
- if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_mail_search", args: { query: question, limit: 20 } }] };
10488
- return { steps: [{ tool: "yandex_mail_list", args: { limit: 10, unread: /непрочитан/iu.test(normalized) } }] };
10733
+ if (/(почт|письм|email|e-mail|спам|чернов|отправлен|исходящ|корзин)/iu.test(normalized)) {
10734
+ const mailbox = extractYandexMailboxName(question) || "INBOX";
10735
+ const uid = extractYandexMailUid(question);
10736
+ if (/(папк|ящик|mailbox|folder)/iu.test(normalized)) return { steps: [{ tool: "yandex_mail_folders", args: {} }] };
10737
+ if (/(ответь|ответить|напиши\s+ответ)/iu.test(normalized)) return { steps: [{ tool: "yandex_mail_reply", args: { uid, mailbox, text: parseYandexMailReplyRequest(question).text, confirm: true } }] };
10738
+ if (/(удали|удалить|перемести\s+в\s+корзин)/iu.test(normalized)) return { steps: [{ tool: "yandex_mail_delete", args: { uid, mailbox, confirm: true } }] };
10739
+ if (/(пометь|отметь|сделай)/iu.test(normalized) && /(прочитан|непрочитан)/iu.test(normalized)) return { steps: [{ tool: "yandex_mail_mark", args: { uid, mailbox, seen: !/непрочитан/iu.test(normalized) } }] };
10740
+ if (/(прочитай|прочти|открой|раскрой|получи|получить)/iu.test(normalized) && uid) return { steps: [{ tool: "yandex_mail_read", args: { uid, mailbox } }] };
10741
+ if (/(найди|поиск)/iu.test(normalized)) return { steps: [{ tool: "yandex_mail_search", args: { mailbox, query: question, limit: 20 } }] };
10742
+ return { steps: [{ tool: "yandex_mail_list", args: { mailbox, limit: 10, unread: /непрочитан/iu.test(normalized) } }] };
10489
10743
  }
10490
10744
  if (/(календар|событи|встреч|телемост)/iu.test(normalized)) {
10491
10745
  return { steps: [{ tool: normalized.includes("телемост") ? "yandex_telemost_create_event" : "yandex_calendar_list", args: { limit: 20 } }] };
@@ -11049,6 +11303,10 @@ function formatToolResult(result, options) {
11049
11303
  if (row.provider === "yandex-disk" && row.publicUrl) return `Публичная ссылка: ${row.publicUrl}`;
11050
11304
  if (row.provider === "yandex-disk" && row.remote) return `Яндекс Диск: ${row.status || "ok"} ${row.remote}`;
11051
11305
  if (row.uid && (row.subject || row.from)) return `Письмо #${row.uid}: ${row.subject || "(без темы)"}${row.from ? `, от ${row.from}` : ""}`;
11306
+ if (row.status === "moved-to-trash") return `Письмо #${row.uid} перемещено в корзину: ${row.to}`;
11307
+ if (row.status === "seen" || row.status === "unseen") return `Письмо #${row.uid}: ${row.status === "seen" ? "прочитано" : "непрочитано"}`;
11308
+ if (row.status === "sent" && row.to) return `Письмо отправлено: ${Array.isArray(row.to) ? row.to.join(", ") : row.to}. Тема: ${row.subject || "-"}`;
11309
+ if (row.special || row.delimiter) return `Папка почты: ${row.name}${row.special ? ` (${row.special})` : ""}`;
11052
11310
  if (row.login || row.defaultEmail) return `Yandex ID: ${row.displayName || row.login || "-"}${row.defaultEmail ? `, ${row.defaultEmail}` : ""}`;
11053
11311
  if (row.title && (row.start || row.end)) return `${row.title}: ${row.start || "-"}${row.end ? ` - ${row.end}` : ""}`;
11054
11312
  if (row.email || row.phone) return formatYandexContact(row);
@@ -81,6 +81,14 @@ assertIncludes(cliSource, "язндекс", "Yandex direct router should tolerat
81
81
  assertIncludes(cliSource, "yandexMailCount", "Yandex mail should answer unread count questions directly");
82
82
  assertIncludes(cliSource, "resolveYandexMailUidFromQuestion", "Yandex mail follow-ups should resolve selected message UID");
83
83
  assertIncludes(cliSource, "extractYandexMailUidByOrdinal", "Yandex mail follow-ups should support numbered selections");
84
+ assertIncludes(cliSource, "BODY[TEXT]", "Yandex mail read should mark opened messages as seen");
85
+ assertIncludes(cliSource, "markSeen === false", "Yandex mail read should keep an explicit no-mark fallback");
86
+ assertIncludes(cliSource, "yandex_mail_folders", "Yandex mail should expose folders as a tool");
87
+ assertIncludes(cliSource, "yandex_mail_reply", "Yandex mail should expose reply as a tool");
88
+ assertIncludes(cliSource, "yandex_mail_delete", "Yandex mail should expose safe delete as a tool");
89
+ assertIncludes(cliSource, "yandex_mail_mark", "Yandex mail should expose read/unread mark as a tool");
90
+ assertIncludes(cliSource, "decodeImapModifiedUtf7", "Yandex mail folders should decode IMAP modified UTF-7 names");
91
+ assertIncludes(cliSource, "encodeImapModifiedUtf7", "Yandex mail should select non-ASCII folders via IMAP modified UTF-7");
84
92
  assertIncludes(cliSource, "buildCasualDirectAnswer(question)", "Casual greetings should bypass external AI providers");
85
93
  assertNotIncludes(cliSource, "Сервисы через запятую [identity,disk]", "Yandex setup should not ask for services during connector setup");
86
94
  if (!packageJson.files.includes("docs/assets/iola-oauth-icon.png")) {
@@ -172,9 +172,17 @@ iola geo services "Йошкар-Ола, улица Петрова, 15"
172
172
 
173
173
  - проверить доступ к почте;
174
174
  - показать последние или непрочитанные письма;
175
+ - показать папки почты: входящие, отправленные, черновики, спам, корзина и пользовательские папки;
176
+ - показать письма из конкретной папки, например из спама, черновиков или отправленных;
175
177
  - найти письмо;
176
- - прочитать письмо по UID;
177
- - отправить письмо только после явного подтверждения.
178
+ - прочитать письмо по UID или номеру из последнего списка;
179
+ - при чтении по просьбе пользователя пометить письмо прочитанным;
180
+ - пометить письмо прочитанным или непрочитанным;
181
+ - отправить письмо только после явного запроса;
182
+ - ответить на письмо по UID или номеру из последнего списка;
183
+ - удалить письмо безопасно: переместить его в корзину, а не стирать безвозвратно.
184
+
185
+ Список писем и поиск не меняют статус письма. Статус меняется только при чтении письма, явной пометке или явном удалении.
178
186
 
179
187
  ### yandex-calendar
180
188