@iola_adm/iola-cli 0.2.31 → 0.2.33
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
|
@@ -27,8 +27,15 @@ description: Сервисы Яндекса через Yandex Connector: ID, Ди
|
|
|
27
27
|
- `yandex_mail_read` - прочитать письмо по UID; обычное чтение помечает письмо прочитанным.
|
|
28
28
|
- `yandex_mail_send` - отправить письмо.
|
|
29
29
|
- `yandex_mail_reply` - ответить на письмо по UID.
|
|
30
|
+
- `yandex_mail_forward` - переслать письмо.
|
|
30
31
|
- `yandex_mail_delete` - переместить письмо в корзину.
|
|
31
32
|
- `yandex_mail_mark` - пометить письмо прочитанным или непрочитанным.
|
|
33
|
+
- `yandex_mail_save_to_disk` - сохранить письмо на Яндекс Диск в Markdown.
|
|
34
|
+
- `yandex_mail_create_calendar_event` - создать событие календаря из письма.
|
|
35
|
+
- `yandex_mail_sender_to_contact` - добавить отправителя письма в контакты.
|
|
36
|
+
- `yandex_mail_city_context` - найти в письме школы/детские сады/ИНН и подтянуть открытые городские слои.
|
|
37
|
+
- `yandex_mail_map_addresses` - найти адреса в письме и дать ссылки на Яндекс.Карты.
|
|
38
|
+
- `yandex_mail_create_task` - создать локальную задачу по письму.
|
|
32
39
|
- Автоопрос почты включается командой `iola yandex mail-watch on --minutes 5`, выключается `iola yandex mail-watch off`, ручная проверка `iola yandex mail-watch tick`.
|
|
33
40
|
- `yandex_calendar_status` - проверить доступ к Яндекс Календарю.
|
|
34
41
|
- `yandex_calendar_list` - показать события.
|
|
@@ -36,13 +43,15 @@ description: Сервисы Яндекса через Yandex Connector: ID, Ди
|
|
|
36
43
|
- `yandex_contacts_status` - проверить доступ к Яндекс Контактам.
|
|
37
44
|
- `yandex_contacts_list` - показать контакты.
|
|
38
45
|
- `yandex_contacts_search` - найти контакт.
|
|
46
|
+
- `yandex_contacts_create` - создать новый контакт.
|
|
39
47
|
- `yandex_contacts_add_email` - добавить email в существующий контакт.
|
|
40
48
|
- `yandex_telemost_create_event` - создать календарное событие для встречи.
|
|
41
49
|
|
|
42
50
|
Безопасность:
|
|
43
51
|
|
|
44
52
|
- Для отправки письма, ответа на письмо, удаления письма, удаления файлов, публикации ссылки и создания событий нужен явный запрос пользователя и `confirm=true`.
|
|
45
|
-
-
|
|
53
|
+
- Для пересылки письма, создания контакта, создания события из письма и добавления отправителя в контакты также нужен явный запрос пользователя и `confirm=true`.
|
|
54
|
+
- Не отправляй письма, не отвечай на письма, не пересылай письма, не создавай контакты/события, не удаляй письма/файлы и не публикуй ссылки по косвенному намерению.
|
|
46
55
|
- Список писем и поиск не должны помечать письма прочитанными. Чтение письма по просьбе пользователя помечает письмо прочитанным.
|
|
47
56
|
- Если пользователь просит отправить письмо по имени контакта, сначала ищи контакт. Если контактов несколько, покажи варианты и попроси уточнить. Если контакта нет или у него нет email, скажи прямо.
|
|
48
57
|
- Если пользователь отправил письмо на введенный email и указал имя контакта, можно предложить добавить email в найденный контакт без email.
|
package/src/cli.js
CHANGED
|
@@ -170,14 +170,22 @@ const YANDEX_TOOLS = [
|
|
|
170
170
|
"yandex_mail_read",
|
|
171
171
|
"yandex_mail_send",
|
|
172
172
|
"yandex_mail_reply",
|
|
173
|
+
"yandex_mail_forward",
|
|
173
174
|
"yandex_mail_delete",
|
|
174
175
|
"yandex_mail_mark",
|
|
176
|
+
"yandex_mail_save_to_disk",
|
|
177
|
+
"yandex_mail_create_calendar_event",
|
|
178
|
+
"yandex_mail_sender_to_contact",
|
|
179
|
+
"yandex_mail_city_context",
|
|
180
|
+
"yandex_mail_map_addresses",
|
|
181
|
+
"yandex_mail_create_task",
|
|
175
182
|
"yandex_calendar_status",
|
|
176
183
|
"yandex_calendar_create_event",
|
|
177
184
|
"yandex_calendar_list",
|
|
178
185
|
"yandex_contacts_status",
|
|
179
186
|
"yandex_contacts_list",
|
|
180
187
|
"yandex_contacts_search",
|
|
188
|
+
"yandex_contacts_create",
|
|
181
189
|
"yandex_contacts_add_email",
|
|
182
190
|
"yandex_telemost_create_event",
|
|
183
191
|
];
|
|
@@ -4007,14 +4015,22 @@ async function executeYandexTool(tool, args = {}) {
|
|
|
4007
4015
|
if (tool === "yandex_mail_read") return yandexMailRead(args.uid || args.id, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: args.markSeen !== false });
|
|
4008
4016
|
if (tool === "yandex_mail_send") return yandexMailSend(args);
|
|
4009
4017
|
if (tool === "yandex_mail_reply") return yandexMailReply(args);
|
|
4018
|
+
if (tool === "yandex_mail_forward") return yandexMailForward(args);
|
|
4010
4019
|
if (tool === "yandex_mail_delete") return yandexMailDelete(args.uid || args.id, { ...args, mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX") });
|
|
4011
4020
|
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") });
|
|
4021
|
+
if (tool === "yandex_mail_save_to_disk") return yandexMailSaveToDisk(args.uid || args.id, args);
|
|
4022
|
+
if (tool === "yandex_mail_create_calendar_event") return yandexMailCreateCalendarEvent(args.uid || args.id, args);
|
|
4023
|
+
if (tool === "yandex_mail_sender_to_contact") return yandexMailSenderToContact(args.uid || args.id, args);
|
|
4024
|
+
if (tool === "yandex_mail_city_context") return yandexMailCityContext(args.uid || args.id, args);
|
|
4025
|
+
if (tool === "yandex_mail_map_addresses") return yandexMailMapAddresses(args.uid || args.id, args);
|
|
4026
|
+
if (tool === "yandex_mail_create_task") return yandexMailCreateTask(args.uid || args.id, args);
|
|
4012
4027
|
if (tool === "yandex_calendar_status") return yandexCalendarStatus();
|
|
4013
4028
|
if (tool === "yandex_calendar_create_event") return yandexCalendarCreateEvent(args);
|
|
4014
4029
|
if (tool === "yandex_calendar_list") return yandexCalendarList(args);
|
|
4015
4030
|
if (tool === "yandex_contacts_status") return yandexContactsStatus();
|
|
4016
4031
|
if (tool === "yandex_contacts_list") return yandexContactsList(args);
|
|
4017
4032
|
if (tool === "yandex_contacts_search") return yandexContactsSearch(args.query || "", args);
|
|
4033
|
+
if (tool === "yandex_contacts_create") return yandexContactsCreate(args);
|
|
4018
4034
|
if (tool === "yandex_contacts_add_email") return yandexContactsAddEmail(args.query || args.name || "", args.email, args);
|
|
4019
4035
|
if (tool === "yandex_telemost_create_event") return yandexTelemostCreateEvent(args);
|
|
4020
4036
|
throw new Error(`Yandex tool неизвестен: ${tool}`);
|
|
@@ -4132,8 +4148,13 @@ async function yandexMailCount(options = {}) {
|
|
|
4132
4148
|
async function yandexMailSearch(query, options = {}) {
|
|
4133
4149
|
const normalized = normalizeGeoText(query);
|
|
4134
4150
|
if (!normalized) return yandexMailList(options);
|
|
4151
|
+
const tokens = normalized.split(/\s+/u).filter((token) => token.length > 2 && !/^(про|обо?|где|что|кто|как)$/iu.test(token));
|
|
4135
4152
|
const rows = await yandexMailList({ ...options, limit: Math.max(50, Number(options.limit || 20) * 3) });
|
|
4136
|
-
return rows.filter((row) =>
|
|
4153
|
+
return rows.filter((row) => {
|
|
4154
|
+
const haystack = normalizeGeoText(`${row.from} ${row.subject} ${row.snippet}`);
|
|
4155
|
+
if (haystack.includes(normalized)) return true;
|
|
4156
|
+
return tokens.length > 0 && tokens.every((token) => haystack.includes(token));
|
|
4157
|
+
}).slice(0, Number(options.limit || 20));
|
|
4137
4158
|
}
|
|
4138
4159
|
|
|
4139
4160
|
async function yandexMailWatchTick(options = {}) {
|
|
@@ -4189,7 +4210,7 @@ async function yandexMailRead(uid, options = {}) {
|
|
|
4189
4210
|
try {
|
|
4190
4211
|
await imapAuthenticate(session, email, token);
|
|
4191
4212
|
await imapCommand(session, `SELECT ${quoteImapMailbox(options.mailbox || "INBOX")}`);
|
|
4192
|
-
const bodyAccessor = options.markSeen === false ? "BODY.PEEK[
|
|
4213
|
+
const bodyAccessor = options.markSeen === false ? "BODY.PEEK[]" : "BODY[]";
|
|
4193
4214
|
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 });
|
|
4194
4215
|
return parseImapFetchSummaries(fetch, { full: true })[0] || { uid, status: "not-found" };
|
|
4195
4216
|
} finally {
|
|
@@ -4255,6 +4276,123 @@ async function yandexMailReply(args = {}) {
|
|
|
4255
4276
|
return { ...result, replyToUid: Number(uid), originalFrom: original.from };
|
|
4256
4277
|
}
|
|
4257
4278
|
|
|
4279
|
+
async function yandexMailForward(args = {}) {
|
|
4280
|
+
if (!args.confirm) throw new Error("Для пересылки письма нужен аргумент confirm=true.");
|
|
4281
|
+
const uid = args.uid || args.id;
|
|
4282
|
+
const to = Array.isArray(args.to) ? args.to : String(args.to || "").split(/[;,]/).map((item) => item.trim()).filter(Boolean);
|
|
4283
|
+
if (!uid) throw new Error("UID письма обязателен.");
|
|
4284
|
+
if (!to.length) throw new Error("Получатель пересылки не указан.");
|
|
4285
|
+
const original = await yandexMailRead(uid, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: true });
|
|
4286
|
+
if (!original || original.status === "not-found") throw new Error(`Письмо #${uid} не найдено.`);
|
|
4287
|
+
const text = [
|
|
4288
|
+
args.text || args.comment || "",
|
|
4289
|
+
"",
|
|
4290
|
+
"---------- Пересланное письмо ----------",
|
|
4291
|
+
`От: ${original.from || "-"}`,
|
|
4292
|
+
`Дата: ${original.date || "-"}`,
|
|
4293
|
+
`Тема: ${original.subject || "(без темы)"}`,
|
|
4294
|
+
"",
|
|
4295
|
+
original.snippet || "",
|
|
4296
|
+
].join("\n").trim();
|
|
4297
|
+
return yandexMailSend({ to, subject: `Fwd: ${original.subject || "(без темы)"}`, text, confirm: true });
|
|
4298
|
+
}
|
|
4299
|
+
|
|
4300
|
+
async function yandexMailSaveToDisk(uid, args = {}) {
|
|
4301
|
+
if (!uid) throw new Error("UID письма обязателен.");
|
|
4302
|
+
const row = await yandexMailRead(uid, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: args.markSeen !== false });
|
|
4303
|
+
if (!row || row.status === "not-found") throw new Error(`Письмо #${uid} не найдено.`);
|
|
4304
|
+
const safeSubject = slugForFile(row.subject || `mail-${uid}`).slice(0, 80);
|
|
4305
|
+
const remotePath = args.path || args.remotePath || `${CLOUD_DEFAULT_REMOTE_DIR}/Почта/mail-${uid}-${safeSubject}.md`;
|
|
4306
|
+
const text = [
|
|
4307
|
+
`# ${row.subject || "(без темы)"}`,
|
|
4308
|
+
"",
|
|
4309
|
+
`- UID: ${row.uid}`,
|
|
4310
|
+
`- От: ${row.from || "-"}`,
|
|
4311
|
+
`- Дата: ${row.date || "-"}`,
|
|
4312
|
+
"",
|
|
4313
|
+
row.snippet || "",
|
|
4314
|
+
].join("\n");
|
|
4315
|
+
const saved = await yandexDiskSaveText(text, remotePath);
|
|
4316
|
+
return { ...saved, uid: row.uid, subject: row.subject };
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
async function yandexMailCreateCalendarEvent(uid, args = {}) {
|
|
4320
|
+
if (!args.confirm) throw new Error("Для создания события из письма нужен аргумент confirm=true.");
|
|
4321
|
+
if (!uid) throw new Error("UID письма обязателен.");
|
|
4322
|
+
const row = await yandexMailRead(uid, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: true });
|
|
4323
|
+
if (!row || row.status === "not-found") throw new Error(`Письмо #${uid} не найдено.`);
|
|
4324
|
+
const detected = extractDateTimeFromText(`${row.subject}\n${row.snippet}`);
|
|
4325
|
+
const start = args.start || detected.start || new Date(Date.now() + 3600000).toISOString();
|
|
4326
|
+
const end = args.end || detected.end || new Date(new Date(start).getTime() + 3600000).toISOString();
|
|
4327
|
+
return yandexCalendarCreateEvent({
|
|
4328
|
+
title: args.title || row.subject || `Письмо #${uid}`,
|
|
4329
|
+
description: [`Создано из письма #${uid}.`, `От: ${row.from || "-"}`, "", row.snippet || ""].join("\n"),
|
|
4330
|
+
location: args.location || detected.location || "",
|
|
4331
|
+
start,
|
|
4332
|
+
end,
|
|
4333
|
+
confirm: true,
|
|
4334
|
+
});
|
|
4335
|
+
}
|
|
4336
|
+
|
|
4337
|
+
async function yandexMailSenderToContact(uid, args = {}) {
|
|
4338
|
+
if (!args.confirm) throw new Error("Для добавления отправителя в контакты нужен аргумент confirm=true.");
|
|
4339
|
+
if (!uid) throw new Error("UID письма обязателен.");
|
|
4340
|
+
const row = await yandexMailRead(uid, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: false });
|
|
4341
|
+
if (!row || row.status === "not-found") throw new Error(`Письмо #${uid} не найдено.`);
|
|
4342
|
+
const email = extractEmailAddress(row.from);
|
|
4343
|
+
const name = extractDisplayName(row.from) || email;
|
|
4344
|
+
if (!email) throw new Error("В отправителе письма не найден email.");
|
|
4345
|
+
return yandexContactsCreate({ name, email, confirm: true });
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
async function yandexMailCityContext(uid, args = {}) {
|
|
4349
|
+
if (!uid) throw new Error("UID письма обязателен.");
|
|
4350
|
+
const row = await yandexMailRead(uid, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: false });
|
|
4351
|
+
if (!row || row.status === "not-found") throw new Error(`Письмо #${uid} не найдено.`);
|
|
4352
|
+
const text = `${row.subject}\n${row.snippet}`;
|
|
4353
|
+
const queries = extractEducationQueriesFromText(text);
|
|
4354
|
+
const results = [];
|
|
4355
|
+
for (const query of queries) {
|
|
4356
|
+
results.push(...searchLocalRecords(query, { dataset: "all", limit: 5, fts: true }));
|
|
4357
|
+
}
|
|
4358
|
+
const unique = dedupeBy(results, (item) => `${item.dataset || ""}:${item.inn || item.name}`);
|
|
4359
|
+
return unique.slice(0, Number(args.limit || 10)).map((item) => ({
|
|
4360
|
+
name: item.name,
|
|
4361
|
+
inn: item.inn,
|
|
4362
|
+
address: item.address,
|
|
4363
|
+
phone: item.phone,
|
|
4364
|
+
email: item.email,
|
|
4365
|
+
website: item.website,
|
|
4366
|
+
dataset: item.dataset,
|
|
4367
|
+
}));
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
async function yandexMailMapAddresses(uid, args = {}) {
|
|
4371
|
+
if (!uid) throw new Error("UID письма обязателен.");
|
|
4372
|
+
const row = await yandexMailRead(uid, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: false });
|
|
4373
|
+
if (!row || row.status === "not-found") throw new Error(`Письмо #${uid} не найдено.`);
|
|
4374
|
+
const addresses = extractLikelyAddresses(`${row.subject}\n${row.snippet}`).slice(0, Number(args.limit || 5));
|
|
4375
|
+
const rows = [];
|
|
4376
|
+
for (const address of addresses) {
|
|
4377
|
+
const point = await geocodeCached(address).catch(() => null);
|
|
4378
|
+
rows.push({
|
|
4379
|
+
address,
|
|
4380
|
+
resolved: point?.address || "",
|
|
4381
|
+
map: point?.lat && point?.lon ? `https://yandex.ru/maps/?pt=${point.lon},${point.lat}&z=16&l=map` : "",
|
|
4382
|
+
});
|
|
4383
|
+
}
|
|
4384
|
+
return rows;
|
|
4385
|
+
}
|
|
4386
|
+
|
|
4387
|
+
async function yandexMailCreateTask(uid, args = {}) {
|
|
4388
|
+
if (!uid) throw new Error("UID письма обязателен.");
|
|
4389
|
+
const row = await yandexMailRead(uid, { mailbox: await resolveYandexMailbox(args.mailbox || args.folder || "INBOX"), markSeen: false });
|
|
4390
|
+
if (!row || row.status === "not-found") throw new Error(`Письмо #${uid} не найдено.`);
|
|
4391
|
+
const title = args.title || `Ответить/разобрать письмо #${uid}: ${row.subject || "(без темы)"}`;
|
|
4392
|
+
const id = addTask(title, `ask "прочитай письмо #${uid}"`);
|
|
4393
|
+
return { id, title, uid: Number(uid), status: "open" };
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4258
4396
|
async function yandexMailSend(args = {}) {
|
|
4259
4397
|
if (!args.confirm) throw new Error("Для отправки письма нужен аргумент confirm=true.");
|
|
4260
4398
|
const to = Array.isArray(args.to) ? args.to : String(args.to || "").split(/[;,]/).map((item) => item.trim()).filter(Boolean);
|
|
@@ -4513,14 +4651,14 @@ function stripMailBody(value) {
|
|
|
4513
4651
|
const raw = String(value || "").replace(/\r/g, "");
|
|
4514
4652
|
const withoutFetch = raw
|
|
4515
4653
|
.replace(/^\* \d+ FETCH[^\n]*\n?/u, "")
|
|
4516
|
-
.replace(/^BODY
|
|
4654
|
+
.replace(/^BODY(?:\.PEEK)?\[[^\n]*\]\s*\{\d+\}\n?/imu, "")
|
|
4517
4655
|
.replace(/\n\)\s*$/u, "");
|
|
4518
|
-
const bodyMatch = withoutFetch.match(/BODY
|
|
4656
|
+
const bodyMatch = withoutFetch.match(/BODY(?:\.PEEK)?\[[^\n]*\]\s*\{\d+\}\s*([\s\S]*)/iu);
|
|
4519
4657
|
if (bodyMatch?.[1]) return extractMimeText(bodyMatch[1]);
|
|
4520
4658
|
const headerEnd = withoutFetch.indexOf("\n\n");
|
|
4521
4659
|
if (headerEnd < 0) return withoutFetch.replace(/[^\S\n]+/g, " ").trim();
|
|
4522
4660
|
const headers = withoutFetch.slice(0, headerEnd);
|
|
4523
|
-
const body = withoutFetch.slice(headerEnd + 2).replace(/^BODY
|
|
4661
|
+
const body = withoutFetch.slice(headerEnd + 2).replace(/^BODY(?:\.PEEK)?\[[^\n]*\]\s*\{\d+\}\n?/imu, "");
|
|
4524
4662
|
return extractMimeText(`${headers}\n\n${body}`);
|
|
4525
4663
|
}
|
|
4526
4664
|
|
|
@@ -4557,17 +4695,52 @@ function decodeMailPart(part) {
|
|
|
4557
4695
|
const encoding = headers.match(/^Content-Transfer-Encoding:\s*([^\n]+)/imu)?.[1]?.trim().toLocaleLowerCase("en-US") || "";
|
|
4558
4696
|
let decoded = body;
|
|
4559
4697
|
if (encoding === "base64") {
|
|
4560
|
-
|
|
4698
|
+
const clean = body.replace(/\s+/g, "");
|
|
4699
|
+
decoded = /^[A-Z0-9+/]+={0,2}$/iu.test(clean) && clean.length >= 8
|
|
4700
|
+
? Buffer.from(clean, "base64").toString("utf8")
|
|
4701
|
+
: body;
|
|
4561
4702
|
} else if (encoding === "quoted-printable") {
|
|
4562
4703
|
decoded = decodeQuotedPrintable(body);
|
|
4704
|
+
} else {
|
|
4705
|
+
const clean = body.replace(/\s+/g, "");
|
|
4706
|
+
if (/^[A-Z0-9+/]+={0,2}$/iu.test(clean) && clean.length >= 80) {
|
|
4707
|
+
const candidate = Buffer.from(clean, "base64").toString("utf8");
|
|
4708
|
+
if (/(<!doctype|<html|<body|[А-Яа-яЁё]{3,})/u.test(candidate)) decoded = candidate;
|
|
4709
|
+
}
|
|
4563
4710
|
}
|
|
4564
|
-
|
|
4711
|
+
decoded = decodeEmbeddedBase64MailBody(decoded);
|
|
4712
|
+
const cleaned = decoded
|
|
4713
|
+
.replace(/<style\b[\s\S]*?<\/style>/giu, " ")
|
|
4714
|
+
.replace(/<script\b[\s\S]*?<\/script>/giu, " ")
|
|
4715
|
+
.replace(/<[^>]+>/g, " ")
|
|
4716
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]+/gu, " ")
|
|
4717
|
+
.replace(/[^\S\n]+/g, " ")
|
|
4718
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
4719
|
+
.trim();
|
|
4720
|
+
const firstCyrillic = cleaned.search(/[А-Яа-яЁё]{3,}/u);
|
|
4721
|
+
if (firstCyrillic > 0 && firstCyrillic < 120 && cleaned.slice(0, firstCyrillic).includes("�")) {
|
|
4722
|
+
return cleaned.slice(firstCyrillic).trim();
|
|
4723
|
+
}
|
|
4724
|
+
return cleaned;
|
|
4565
4725
|
}
|
|
4566
4726
|
|
|
4567
4727
|
function decodeQuotedPrintable(value) {
|
|
4568
4728
|
return Buffer.from(String(value || "").replace(/=\n/gu, "").replace(/=([A-F0-9]{2})/giu, (_, hex) => String.fromCharCode(parseInt(hex, 16))), "binary").toString("utf8");
|
|
4569
4729
|
}
|
|
4570
4730
|
|
|
4731
|
+
function decodeEmbeddedBase64MailBody(value) {
|
|
4732
|
+
const text = String(value || "");
|
|
4733
|
+
if (/(<!doctype|<html|<body|[А-Яа-яЁё]{3,})/u.test(text) && !/[A-Z0-9+/]{120,}/u.test(text)) return text;
|
|
4734
|
+
const matches = text.match(/[A-Z0-9+/=\s]{160,}/giu) || [];
|
|
4735
|
+
for (const match of matches) {
|
|
4736
|
+
const clean = match.replace(/\s+/g, "");
|
|
4737
|
+
if (!/^[A-Z0-9+/]+={0,2}$/iu.test(clean) || clean.length < 160) continue;
|
|
4738
|
+
const candidate = Buffer.from(clean, "base64").toString("utf8");
|
|
4739
|
+
if (/(<!doctype|<html|<body|[А-Яа-яЁё]{3,})/u.test(candidate)) return candidate;
|
|
4740
|
+
}
|
|
4741
|
+
return text;
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4571
4744
|
async function yandexDavRequest(url, token, options = {}) {
|
|
4572
4745
|
const response = await fetch(url, {
|
|
4573
4746
|
method: options.method || "GET",
|
|
@@ -4715,6 +4888,33 @@ async function yandexContactsAddEmail(query, email, args = {}) {
|
|
|
4715
4888
|
return { status: "updated", name: contact.name, email };
|
|
4716
4889
|
}
|
|
4717
4890
|
|
|
4891
|
+
async function yandexContactsCreate(args = {}) {
|
|
4892
|
+
if (!args.confirm) throw new Error("Для создания контакта нужен аргумент confirm=true.");
|
|
4893
|
+
const name = String(args.name || args.email || "").trim();
|
|
4894
|
+
const email = String(args.email || "").trim();
|
|
4895
|
+
if (!name) throw new Error("Имя контакта обязательно.");
|
|
4896
|
+
if (!email || !/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/iu.test(email)) throw new Error("Корректный email обязателен.");
|
|
4897
|
+
const token = await requireYandexOAuthToken("organizer", "Яндекс Контакты");
|
|
4898
|
+
const baseUrl = await yandexContactsBaseUrl(token);
|
|
4899
|
+
const uid = `${randomUUID()}@iola-cli`;
|
|
4900
|
+
const card = [
|
|
4901
|
+
"BEGIN:VCARD",
|
|
4902
|
+
"VERSION:3.0",
|
|
4903
|
+
`UID:${uid}`,
|
|
4904
|
+
`FN:${escapeVcardValue(name)}`,
|
|
4905
|
+
`EMAIL;TYPE=INTERNET:${email}`,
|
|
4906
|
+
"END:VCARD",
|
|
4907
|
+
"",
|
|
4908
|
+
].join("\r\n");
|
|
4909
|
+
await yandexDavRequest(new URL(`${encodeURIComponent(uid)}.vcf`, baseUrl).toString(), token, {
|
|
4910
|
+
method: "PUT",
|
|
4911
|
+
headers: { "content-type": "text/vcard; charset=utf-8" },
|
|
4912
|
+
body: card,
|
|
4913
|
+
timeout: 45000,
|
|
4914
|
+
});
|
|
4915
|
+
return { status: "created", name, email, uid };
|
|
4916
|
+
}
|
|
4917
|
+
|
|
4718
4918
|
function upsertVcardEmail(card, email, options = {}) {
|
|
4719
4919
|
const text = String(card || "").replace(/\r/g, "").trim();
|
|
4720
4920
|
if (!text.includes("BEGIN:VCARD")) throw new Error("Контакт не похож на vCard.");
|
|
@@ -4725,6 +4925,10 @@ function upsertVcardEmail(card, email, options = {}) {
|
|
|
4725
4925
|
return text.replace(/\nEND:VCARD/iu, `\nEMAIL;TYPE=INTERNET:${email}\nEND:VCARD`);
|
|
4726
4926
|
}
|
|
4727
4927
|
|
|
4928
|
+
function escapeVcardValue(value) {
|
|
4929
|
+
return String(value || "").replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;");
|
|
4930
|
+
}
|
|
4931
|
+
|
|
4728
4932
|
function contactMatchesQuery(contact, normalizedQuery) {
|
|
4729
4933
|
const contactText = normalizeContactLookupText(`${contact.name || ""} ${contact.email || ""}`);
|
|
4730
4934
|
if (!contactText || !normalizedQuery) return false;
|
|
@@ -10040,6 +10244,51 @@ async function buildYandexDirectAnswer(question, history = []) {
|
|
|
10040
10244
|
const result = await yandexMailReply({ ...reply, confirm: true });
|
|
10041
10245
|
return `Ответ отправлен на письмо #${result.replyToUid}: ${result.to.join(", ")}. Тема: ${result.subject}.`;
|
|
10042
10246
|
}
|
|
10247
|
+
if (/(перешли|переслать|перешли\s+письмо|fwd|forward)/iu.test(normalized)) {
|
|
10248
|
+
const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
|
|
10249
|
+
const to = [...String(question || "").matchAll(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/giu)].map((match) => match[0]);
|
|
10250
|
+
if (!uid || !to.length) return "Для пересылки укажите письмо и получателя. Пример: перешли письмо #2382 user@example.com.";
|
|
10251
|
+
const result = await yandexMailForward({ uid, to, confirm: true });
|
|
10252
|
+
return `Письмо переслано: ${result.to.join(", ")}. Тема: ${result.subject}.`;
|
|
10253
|
+
}
|
|
10254
|
+
if (/(сохрани|запиши).{0,60}(диск|яндекс.?диск|облак)/iu.test(normalized)) {
|
|
10255
|
+
const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
|
|
10256
|
+
if (!uid) return "Какое письмо сохранить на Диск? Укажите номер из списка или UID.";
|
|
10257
|
+
const result = await yandexMailSaveToDisk(uid, { mailbox: extractYandexMailboxName(question) || "INBOX" });
|
|
10258
|
+
return `Письмо #${uid} сохранено на Яндекс Диск: ${result.remote || result.path}.`;
|
|
10259
|
+
}
|
|
10260
|
+
if (/(создай|добавь).{0,40}(событи|встреч|календар)/iu.test(normalized)) {
|
|
10261
|
+
const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
|
|
10262
|
+
if (!uid) return "Из какого письма создать событие? Укажите номер из списка или UID.";
|
|
10263
|
+
const result = await yandexMailCreateCalendarEvent(uid, { mailbox: extractYandexMailboxName(question) || "INBOX", confirm: true });
|
|
10264
|
+
return `Событие создано в Яндекс Календаре: ${result.title || result.uid}.`;
|
|
10265
|
+
}
|
|
10266
|
+
if (/(добавь|создай|сохрани).{0,50}(отправител|автор|контакт)/iu.test(normalized)) {
|
|
10267
|
+
const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
|
|
10268
|
+
if (!uid) return "Из какого письма добавить отправителя в контакты? Укажите номер из списка или UID.";
|
|
10269
|
+
const result = await yandexMailSenderToContact(uid, { mailbox: extractYandexMailboxName(question) || "INBOX", confirm: true });
|
|
10270
|
+
return `Контакт создан: ${result.name}, ${result.email}.`;
|
|
10271
|
+
}
|
|
10272
|
+
if (/(школ|сад|детсад|инн|городск|сло[йи]|йошкар)/iu.test(normalized) && /(письм|письма|письме)/iu.test(normalized)) {
|
|
10273
|
+
const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
|
|
10274
|
+
if (!uid) return "По какому письму проверить городские слои? Укажите номер из списка или UID.";
|
|
10275
|
+
const rows = await yandexMailCityContext(uid, { mailbox: extractYandexMailboxName(question) || "INBOX" });
|
|
10276
|
+
if (!rows.length) return "В письме не нашел совпадений со слоями школ и детских садов.";
|
|
10277
|
+
return ["Нашел в городских слоях:", ...rows.map((row, index) => `${index + 1}. ${row.name}${row.inn ? `, ИНН ${row.inn}` : ""}${row.address ? `, ${row.address}` : ""}`)].join("\n");
|
|
10278
|
+
}
|
|
10279
|
+
if (/(адрес|карт|место|где)/iu.test(normalized) && /(письм|письма|письме)/iu.test(normalized)) {
|
|
10280
|
+
const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
|
|
10281
|
+
if (!uid) return "Из какого письма взять адрес? Укажите номер из списка или UID.";
|
|
10282
|
+
const rows = await yandexMailMapAddresses(uid, { mailbox: extractYandexMailboxName(question) || "INBOX" });
|
|
10283
|
+
if (!rows.length) return "В письме не нашел адресов для карты.";
|
|
10284
|
+
return ["Адреса из письма:", ...rows.map((row, index) => `${index + 1}. ${row.resolved || row.address}${row.map ? `\n${row.map}` : ""}`)].join("\n");
|
|
10285
|
+
}
|
|
10286
|
+
if (/(создай|добавь).{0,30}(задач|напомин)/iu.test(normalized)) {
|
|
10287
|
+
const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
|
|
10288
|
+
if (!uid) return "По какому письму создать задачу? Укажите номер из списка или UID.";
|
|
10289
|
+
const result = await yandexMailCreateTask(uid, { mailbox: extractYandexMailboxName(question) || "INBOX" });
|
|
10290
|
+
return `Задача создана #${result.id}: ${result.title}.`;
|
|
10291
|
+
}
|
|
10043
10292
|
if (/(удали|удалить|перемести\s+в\s+корзин)/iu.test(normalized)) {
|
|
10044
10293
|
const uid = resolveYandexMailUidFromQuestion(question, previousAssistantText);
|
|
10045
10294
|
if (!uid) return "Какое письмо удалить? Укажите номер из списка или UID, например: удали письмо #2382.";
|
|
@@ -10157,7 +10406,7 @@ function isYandexServiceQuestion(normalized) {
|
|
|
10157
10406
|
|
|
10158
10407
|
function isYandexIdentityQuestion(normalized) {
|
|
10159
10408
|
const text = String(normalized || "");
|
|
10160
|
-
return /(
|
|
10409
|
+
return /(аккаунт|аккант|акант|акаунт|акк?аунт|профил|логин|кто подключен|какой.*подключ|email|e-mail)/iu.test(text)
|
|
10161
10410
|
&& /(яндекс|яндес|язндекс|язндекс|яндкс|yandex)/iu.test(text);
|
|
10162
10411
|
}
|
|
10163
10412
|
|
|
@@ -10169,7 +10418,7 @@ function isYandexMailFollowupQuestion(normalized, question) {
|
|
|
10169
10418
|
}
|
|
10170
10419
|
|
|
10171
10420
|
function cleanupYandexQuery(question) {
|
|
10172
|
-
const stop = /^(
|
|
10421
|
+
const stop = /^(?:в|на|у|из|для|по|про|о|об|обо|яндекс|yandex|найди|поиск|покажи|посмотри|проверь|почт\p{L}*|письм\p{L}*|календар\p{L}*|контакт\p{L}*)$/iu;
|
|
10173
10422
|
return String(question || "")
|
|
10174
10423
|
.replace(/[?.!]+$/u, "")
|
|
10175
10424
|
.split(/[^\p{L}\p{N}@._+-]+/gu)
|
|
@@ -10309,6 +10558,79 @@ function formatYandexMailRead(row) {
|
|
|
10309
10558
|
].filter((line) => line !== "").join("\n");
|
|
10310
10559
|
}
|
|
10311
10560
|
|
|
10561
|
+
function slugForFile(value) {
|
|
10562
|
+
return String(value || "")
|
|
10563
|
+
.toLocaleLowerCase("ru-RU")
|
|
10564
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
10565
|
+
.replace(/-+/g, "-")
|
|
10566
|
+
.replace(/^-|-$/g, "")
|
|
10567
|
+
|| "item";
|
|
10568
|
+
}
|
|
10569
|
+
|
|
10570
|
+
function extractDisplayName(value) {
|
|
10571
|
+
const text = decodeMimeHeader(String(value || "").trim());
|
|
10572
|
+
return text.match(/^(.+?)\s*<[^<>]+>/u)?.[1]?.replace(/^["']|["']$/g, "").trim() || "";
|
|
10573
|
+
}
|
|
10574
|
+
|
|
10575
|
+
function extractDateTimeFromText(value) {
|
|
10576
|
+
const text = String(value || "").toLocaleLowerCase("ru-RU");
|
|
10577
|
+
let date = null;
|
|
10578
|
+
if (/завтра/u.test(text)) date = new Date(Date.now() + 86400000);
|
|
10579
|
+
if (/послезавтра/u.test(text)) date = new Date(Date.now() + 2 * 86400000);
|
|
10580
|
+
const explicit = text.match(/(\d{1,2})[.\-/](\d{1,2})(?:[.\-/](\d{2,4}))?/u);
|
|
10581
|
+
if (explicit) {
|
|
10582
|
+
const year = explicit[3] ? Number(explicit[3].length === 2 ? `20${explicit[3]}` : explicit[3]) : new Date().getFullYear();
|
|
10583
|
+
date = new Date(year, Number(explicit[2]) - 1, Number(explicit[1]), 9, 0, 0);
|
|
10584
|
+
}
|
|
10585
|
+
const time = text.match(/(?:в\s*)?(\d{1,2})[:.](\d{2})/u) || text.match(/(?:в\s+)(\d{1,2})\s*(?:час|ч\b)?/u);
|
|
10586
|
+
if (!date && time) date = new Date();
|
|
10587
|
+
if (date && time) {
|
|
10588
|
+
date.setHours(Number(time[1]), Number(time[2] || 0), 0, 0);
|
|
10589
|
+
}
|
|
10590
|
+
if (!date) return {};
|
|
10591
|
+
const start = date.toISOString();
|
|
10592
|
+
const end = new Date(date.getTime() + 3600000).toISOString();
|
|
10593
|
+
const location = extractLikelyAddresses(value)[0] || "";
|
|
10594
|
+
return { start, end, location };
|
|
10595
|
+
}
|
|
10596
|
+
|
|
10597
|
+
function extractEducationQueriesFromText(value) {
|
|
10598
|
+
const text = String(value || "");
|
|
10599
|
+
const queries = [];
|
|
10600
|
+
for (const match of text.matchAll(/(?:школ[ауыи]?|сош|гимнази[яи]|лице[йя])\s*№?\s*(\d{1,3})/giu)) queries.push(`школа ${match[1]}`);
|
|
10601
|
+
for (const match of text.matchAll(/(?:детск\w*\s+сад|детсад|садик)\s*№?\s*(\d{1,3})/giu)) queries.push(`детский сад ${match[1]}`);
|
|
10602
|
+
for (const match of text.matchAll(/\bинн\s*(\d{10,12})\b/giu)) queries.push(match[1]);
|
|
10603
|
+
return [...new Set(queries)];
|
|
10604
|
+
}
|
|
10605
|
+
|
|
10606
|
+
function extractLikelyAddresses(value) {
|
|
10607
|
+
const text = String(value || "").replace(/\s+/g, " ");
|
|
10608
|
+
const rows = [];
|
|
10609
|
+
const patterns = [
|
|
10610
|
+
/(?:адрес|по адресу|место)\s*:?\s*([^.!?\n]{8,120})/giu,
|
|
10611
|
+
/((?:ул\.?|улица|проспект|пр-т|бульвар|пер\.?|переулок)\s+[^.!?\n,]{3,80}(?:,\s*(?:д\.?\s*)?\d+[а-яa-z]?)?)/giu,
|
|
10612
|
+
];
|
|
10613
|
+
for (const pattern of patterns) {
|
|
10614
|
+
for (const match of text.matchAll(pattern)) {
|
|
10615
|
+
const address = String(match[1] || "").trim().replace(/[;,]+$/u, "");
|
|
10616
|
+
if (address.length >= 8 && !/https?:|www\.|@/iu.test(address) && /(ул\.?|улица|проспект|пр-т|бульвар|пер\.?|переулок|дом|д\.|\d)/iu.test(address)) rows.push(address);
|
|
10617
|
+
}
|
|
10618
|
+
}
|
|
10619
|
+
return [...new Set(rows)].slice(0, 10);
|
|
10620
|
+
}
|
|
10621
|
+
|
|
10622
|
+
function dedupeBy(rows, keyFn) {
|
|
10623
|
+
const seen = new Set();
|
|
10624
|
+
const result = [];
|
|
10625
|
+
for (const row of rows) {
|
|
10626
|
+
const key = keyFn(row);
|
|
10627
|
+
if (seen.has(key)) continue;
|
|
10628
|
+
seen.add(key);
|
|
10629
|
+
result.push(row);
|
|
10630
|
+
}
|
|
10631
|
+
return result;
|
|
10632
|
+
}
|
|
10633
|
+
|
|
10312
10634
|
async function buildCloudDirectAnswer(question) {
|
|
10313
10635
|
if (!isCloudQuestion(question)) return "";
|
|
10314
10636
|
const normalized = String(question || "").toLocaleLowerCase("ru-RU");
|
|
@@ -10918,8 +11240,8 @@ async function buildLocalToolPlan(question, providerConfig, options) {
|
|
|
10918
11240
|
`Доступные tools: ${availableToolNames(options).join(", ")}.`,
|
|
10919
11241
|
"Схема: {\"steps\":[{\"tool\":\"search_data\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
|
|
10920
11242
|
"Минимальные tools: search_data {dataset,query,limit}, get_card {query}, export_report {name,format,output}, file_read {path}, browser_open {url}.",
|
|
10921
|
-
"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}.",
|
|
10922
|
-
"Опасные 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.",
|
|
11243
|
+
"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_mail_save_to_disk {uid,path}, yandex_mail_city_context {uid}, yandex_mail_map_addresses {uid}, yandex_mail_create_task {uid,title}, yandex_calendar_list {start,end}, yandex_contacts_search {query}.",
|
|
11244
|
+
"Опасные Yandex tools используй только при явной просьбе пользователя и с confirm=true: yandex_disk_share, yandex_disk_delete, yandex_mail_send, yandex_mail_reply, yandex_mail_forward, yandex_mail_delete, yandex_mail_create_calendar_event, yandex_mail_sender_to_contact, yandex_contacts_create, yandex_contacts_add_email, yandex_calendar_create_event, yandex_telemost_create_event.",
|
|
10923
11245
|
"MCP tools доступны как mcp:SERVER:TOOL, например mcp:iola-local:search.",
|
|
10924
11246
|
"Для выгрузки CSV добавь export_report с format=csv и output, если пользователь назвал файл.",
|
|
10925
11247
|
`Вопрос: ${question}`,
|
package/test/smoke-test.js
CHANGED
|
@@ -81,7 +81,7 @@ 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[
|
|
84
|
+
assertIncludes(cliSource, "BODY[]", "Yandex mail read should fetch full MIME messages and mark opened messages as seen");
|
|
85
85
|
assertIncludes(cliSource, "markSeen === false", "Yandex mail read should keep an explicit no-mark fallback");
|
|
86
86
|
assertIncludes(cliSource, "yandex_mail_folders", "Yandex mail should expose folders as a tool");
|
|
87
87
|
assertIncludes(cliSource, "yandex_mail_reply", "Yandex mail should expose reply as a tool");
|
|
@@ -94,6 +94,13 @@ assertIncludes(cliSource, "yandexMailWatchTick", "Yandex mail should support man
|
|
|
94
94
|
assertIncludes(cliSource, "resolveYandexMailRecipientFromContacts", "Yandex mail should resolve recipients from contacts");
|
|
95
95
|
assertIncludes(cliSource, "yandexContactsAddEmail", "Yandex contacts should support adding email to a contact");
|
|
96
96
|
assertIncludes(cliSource, "yandex_contacts_add_email", "Yandex contacts add email should be exposed as a tool");
|
|
97
|
+
assertIncludes(cliSource, "yandex_mail_save_to_disk", "Yandex mail should save messages to Yandex Disk");
|
|
98
|
+
assertIncludes(cliSource, "yandex_mail_create_calendar_event", "Yandex mail should create calendar events from messages");
|
|
99
|
+
assertIncludes(cliSource, "yandex_mail_city_context", "Yandex mail should connect messages with city data layers");
|
|
100
|
+
assertIncludes(cliSource, "yandex_mail_map_addresses", "Yandex mail should map addresses from messages");
|
|
101
|
+
assertIncludes(cliSource, "yandex_mail_create_task", "Yandex mail should create local tasks from messages");
|
|
102
|
+
assertIncludes(cliSource, "yandex_mail_forward", "Yandex mail should support forwarding messages");
|
|
103
|
+
assertIncludes(cliSource, "yandexContactsCreate", "Yandex contacts should support creating contacts");
|
|
97
104
|
assertIncludes(cliSource, "buildCasualDirectAnswer(question)", "Casual greetings should bypass external AI providers");
|
|
98
105
|
assertNotIncludes(cliSource, "Сервисы через запятую [identity,disk]", "Yandex setup should not ask for services during connector setup");
|
|
99
106
|
if (!packageJson.files.includes("docs/assets/iola-oauth-icon.png")) {
|
|
@@ -180,10 +180,17 @@ iola geo services "Йошкар-Ола, улица Петрова, 15"
|
|
|
180
180
|
- пометить письмо прочитанным или непрочитанным;
|
|
181
181
|
- отправить письмо только после явного запроса;
|
|
182
182
|
- ответить на письмо по UID или номеру из последнего списка;
|
|
183
|
+
- переслать письмо другому получателю;
|
|
183
184
|
- удалить письмо безопасно: переместить его в корзину, а не стирать безвозвратно.
|
|
184
185
|
- включить автоопрос новых писем, например каждые 5 минут;
|
|
185
186
|
- выключить автоопрос новых писем;
|
|
186
187
|
- вручную проверить новые письма через mail-watch tick;
|
|
188
|
+
- сохранить письмо на Яндекс Диск в Markdown;
|
|
189
|
+
- создать событие календаря из письма;
|
|
190
|
+
- добавить отправителя письма в контакты;
|
|
191
|
+
- найти в письме школу, детский сад или ИНН и подтянуть карточку из открытых городских слоев;
|
|
192
|
+
- найти адреса в письме и дать ссылки на Яндекс.Карты;
|
|
193
|
+
- создать локальную задачу по письму;
|
|
187
194
|
- отправить письмо по имени контакта из Яндекс Контактов;
|
|
188
195
|
- если найдено несколько однофамильцев, попросить уточнить получателя;
|
|
189
196
|
- если контакт найден, но email не указан, прямо сообщить об этом;
|