@andrey4emk/npm-app-back-b24 3.8.2 → 4.0.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/README.md +165 -13
- package/bitrix24/b24/config.ts +24 -0
- package/bitrix24/b24/facade.ts +371 -0
- package/bitrix24/b24/instance.ts +186 -0
- package/bitrix24/b24/proxy.ts +120 -0
- package/bitrix24/b24/retry.ts +490 -0
- package/bitrix24/b24/state.ts +39 -0
- package/bitrix24/b24/tokens.ts +280 -0
- package/bitrix24/b24/types.ts +93 -0
- package/bitrix24/b24.ts +97 -955
- package/bitrix24/errTaskB24.ts +8 -5
- package/bitrix24/eventB24.ts +301 -63
- package/index.ts +4 -1
- package/package.json +26 -7
- package/sendMessage/chatApp.ts +101 -24
- package/sendMessage/email.ts +233 -32
- package/sendMessage/smsgold.ts +11 -2
- package/sendMessage/wappi.ts +41 -15
- package/utils/fetchRetry.ts +70 -9
package/sendMessage/chatApp.ts
CHANGED
|
@@ -45,6 +45,63 @@ interface FileMessageData extends MessageData {
|
|
|
45
45
|
fileName: string;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Ответ ChatApp в той части, которую разбирает класс.
|
|
50
|
+
*
|
|
51
|
+
* Описан по фактически читаемым полям, а не по всему API: остальное уезжает
|
|
52
|
+
* потребителю в `data` как есть и типом не сужается
|
|
53
|
+
*/
|
|
54
|
+
interface ChatAppResponse {
|
|
55
|
+
success?: boolean;
|
|
56
|
+
/** Полезная нагрузка ответа. Форму задаёт сам ChatApp, разбираем на месте */
|
|
57
|
+
data?: unknown;
|
|
58
|
+
/** Дописывается классом в phoneCheckChatApp. В ответе ChatApp этого поля нет */
|
|
59
|
+
check?: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Комплект токенов ChatApp — то, что сервис обещает в ответе на выдачу и обновление.
|
|
64
|
+
*
|
|
65
|
+
* Поля необязательные намеренно: обязательность в чужом JSON ничем не подкреплена,
|
|
66
|
+
* а усечённый ответ клал бы `undefined` под типом `string`. Комплект проверяется
|
|
67
|
+
* на месте разбора
|
|
68
|
+
*/
|
|
69
|
+
interface ChatAppTokens {
|
|
70
|
+
accessToken?: string;
|
|
71
|
+
accessTokenEndTime?: string;
|
|
72
|
+
refreshToken?: string;
|
|
73
|
+
refreshTokenEndTime?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Ответ на выдачу или обновление токена */
|
|
77
|
+
interface ChatAppTokenResponse {
|
|
78
|
+
success?: boolean;
|
|
79
|
+
data?: ChatAppTokens;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Проверяет, что в ответе пришёл весь комплект токенов.
|
|
84
|
+
*
|
|
85
|
+
* Частичный ответ до этой проверки клал `undefined` в `this.auth` под типом `string`:
|
|
86
|
+
* метод отчитывался успехом, а следующее обновление уходило с пустым refresh-токеном
|
|
87
|
+
* и не восстанавливалось до перезапуска процесса
|
|
88
|
+
*/
|
|
89
|
+
function isFullTokenSet(tokens: ChatAppTokens | undefined): tokens is ChatAppAuthParam {
|
|
90
|
+
return !!(
|
|
91
|
+
tokens &&
|
|
92
|
+
typeof tokens.accessToken === "string" && tokens.accessToken.length > 0 &&
|
|
93
|
+
typeof tokens.accessTokenEndTime === "string" &&
|
|
94
|
+
typeof tokens.refreshToken === "string" && tokens.refreshToken.length > 0 &&
|
|
95
|
+
typeof tokens.refreshTokenEndTime === "string"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Ответ на запрос списка лицензий */
|
|
100
|
+
interface ChatAppLicensesResponse {
|
|
101
|
+
success?: boolean;
|
|
102
|
+
licenses?: unknown;
|
|
103
|
+
}
|
|
104
|
+
|
|
48
105
|
/** Стандартный результат операции */
|
|
49
106
|
interface ChatAppResult {
|
|
50
107
|
error: boolean;
|
|
@@ -136,7 +193,7 @@ export class ChatApp {
|
|
|
136
193
|
|
|
137
194
|
const url = `${baseUrl}/chats/${phone}/messages/text`;
|
|
138
195
|
|
|
139
|
-
let data:
|
|
196
|
+
let data: ChatAppResponse;
|
|
140
197
|
try {
|
|
141
198
|
const res = await fetchWithTimeout(
|
|
142
199
|
url,
|
|
@@ -152,7 +209,7 @@ export class ChatApp {
|
|
|
152
209
|
},
|
|
153
210
|
FETCH_TIMEOUTS.send
|
|
154
211
|
);
|
|
155
|
-
data = await res.json();
|
|
212
|
+
data = (await res.json()) as ChatAppResponse;
|
|
156
213
|
} catch (error: unknown) {
|
|
157
214
|
// Метод исторически без try/catch — перехватываем только таймаут, который сами
|
|
158
215
|
// и вводим. Остальные ошибки летят наружу как раньше
|
|
@@ -210,7 +267,7 @@ export class ChatApp {
|
|
|
210
267
|
|
|
211
268
|
const url = `${baseUrl}/chats/${phone}/messages/file`;
|
|
212
269
|
|
|
213
|
-
let data:
|
|
270
|
+
let data: ChatAppResponse;
|
|
214
271
|
try {
|
|
215
272
|
// Файл по ссылке забирает сам ChatApp, поэтому ответа можно ждать дольше — бюджет transfer
|
|
216
273
|
const res = await fetchWithTimeout(
|
|
@@ -227,7 +284,7 @@ export class ChatApp {
|
|
|
227
284
|
},
|
|
228
285
|
FETCH_TIMEOUTS.transfer
|
|
229
286
|
);
|
|
230
|
-
data = await res.json();
|
|
287
|
+
data = (await res.json()) as ChatAppResponse;
|
|
231
288
|
} catch (error: unknown) {
|
|
232
289
|
// Перехватываем только таймаут, остальные ошибки ведут себя как раньше
|
|
233
290
|
if (!isTimeoutError(error)) throw error;
|
|
@@ -280,7 +337,7 @@ export class ChatApp {
|
|
|
280
337
|
|
|
281
338
|
const url = `${baseUrl}/phones/${phone}/check`;
|
|
282
339
|
|
|
283
|
-
let data:
|
|
340
|
+
let data: ChatAppResponse;
|
|
284
341
|
try {
|
|
285
342
|
const res = await fetchWithTimeout(
|
|
286
343
|
url,
|
|
@@ -294,7 +351,7 @@ export class ChatApp {
|
|
|
294
351
|
},
|
|
295
352
|
FETCH_TIMEOUTS.api
|
|
296
353
|
);
|
|
297
|
-
data = await res.json();
|
|
354
|
+
data = (await res.json()) as ChatAppResponse;
|
|
298
355
|
} catch (error: unknown) {
|
|
299
356
|
// Перехватываем только таймаут, остальные ошибки ведут себя как раньше
|
|
300
357
|
if (!isTimeoutError(error)) throw error;
|
|
@@ -315,13 +372,14 @@ export class ChatApp {
|
|
|
315
372
|
newAuth = this.auth;
|
|
316
373
|
this.updateToken = false;
|
|
317
374
|
}
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
375
|
+
// Полезная нагрузка ответа объявлена unknown: обращение к data.data.exist
|
|
376
|
+
// без проверки давало TypeError мимо всякого try/catch
|
|
377
|
+
const payload = data.data;
|
|
378
|
+
if (typeof payload !== "object" || payload === null) {
|
|
321
379
|
return { error: true, message: `Ответ ChatApp без поля data при проверке телефона через ${messangerType}`, data, auth: newAuth };
|
|
322
380
|
}
|
|
323
381
|
|
|
324
|
-
data.check =
|
|
382
|
+
data.check = (payload as Record<string, unknown>)["exist"];
|
|
325
383
|
return { error: false, message: `Телефон успешно проверен в ChatApp через ${messangerType}`, data, auth: newAuth };
|
|
326
384
|
}
|
|
327
385
|
|
|
@@ -340,9 +398,14 @@ export class ChatApp {
|
|
|
340
398
|
FETCH_TIMEOUTS.quick
|
|
341
399
|
);
|
|
342
400
|
|
|
343
|
-
const data
|
|
401
|
+
const data = (await response.json()) as ChatAppResponse;
|
|
344
402
|
if (!data.success) {
|
|
345
|
-
|
|
403
|
+
// Результат обновления не проглатываем: раньше «Токен обновлен» уходило
|
|
404
|
+
// и когда обновление провалилось, а мёртвый токен доезжал до отправки
|
|
405
|
+
const refreshResult = await this.refreshTokenChatApp();
|
|
406
|
+
if (refreshResult.error) {
|
|
407
|
+
return { error: true, message: `Не удалось обновить токен ChatApp: ${refreshResult.message ?? ""}`, data: null };
|
|
408
|
+
}
|
|
346
409
|
return { error: false, message: "Токен обновлен" };
|
|
347
410
|
}
|
|
348
411
|
return { error: false, message: "Токен действителен" };
|
|
@@ -353,7 +416,6 @@ export class ChatApp {
|
|
|
353
416
|
}
|
|
354
417
|
|
|
355
418
|
async makeTokenChatApp(): Promise<{ error: boolean; message?: string; data?: unknown }> {
|
|
356
|
-
console.warn("makeTokenChatApp called");
|
|
357
419
|
try {
|
|
358
420
|
const response = await fetchWithTimeout(
|
|
359
421
|
"https://api.chatapp.online/v1/tokens",
|
|
@@ -373,15 +435,24 @@ export class ChatApp {
|
|
|
373
435
|
FETCH_TIMEOUTS.quick
|
|
374
436
|
);
|
|
375
437
|
|
|
376
|
-
const data
|
|
438
|
+
const data = (await response.json()) as ChatAppTokenResponse;
|
|
377
439
|
if (!data.success) {
|
|
378
440
|
return { error: true, message: "Не удалось получить токен ChatApp", data };
|
|
379
441
|
}
|
|
380
442
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.auth
|
|
443
|
+
// Успех без комплекта токенов раньше давал TypeError на undefined и уходил
|
|
444
|
+
// в catch общим сообщением. Отказ явный, флаг ошибки тот же.
|
|
445
|
+
// Проверяем весь комплект, а не наличие контейнера: усечённый ответ клал бы
|
|
446
|
+
// undefined в this.auth, и обновление токена не восстановилось бы до перезапуска
|
|
447
|
+
const tokens = data.data;
|
|
448
|
+
if (!isFullTokenSet(tokens)) {
|
|
449
|
+
return { error: true, message: "ChatApp: в ответе нет данных токена", data };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this.auth.accessToken = tokens.accessToken;
|
|
453
|
+
this.auth.accessTokenEndTime = tokens.accessTokenEndTime;
|
|
454
|
+
this.auth.refreshToken = tokens.refreshToken;
|
|
455
|
+
this.auth.refreshTokenEndTime = tokens.refreshTokenEndTime;
|
|
385
456
|
this.updateToken = true;
|
|
386
457
|
return { error: false };
|
|
387
458
|
} catch (error: unknown) {
|
|
@@ -406,7 +477,7 @@ export class ChatApp {
|
|
|
406
477
|
FETCH_TIMEOUTS.quick
|
|
407
478
|
);
|
|
408
479
|
|
|
409
|
-
const data
|
|
480
|
+
const data = (await response.json()) as ChatAppTokenResponse;
|
|
410
481
|
if (!data.success) {
|
|
411
482
|
const resMakeToken = await this.makeTokenChatApp();
|
|
412
483
|
if (resMakeToken.error) {
|
|
@@ -415,10 +486,16 @@ export class ChatApp {
|
|
|
415
486
|
return { error: false };
|
|
416
487
|
}
|
|
417
488
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
489
|
+
// Тот же явный отказ, что и в makeTokenChatApp: успех без комплекта токенов
|
|
490
|
+
const tokens = data.data;
|
|
491
|
+
if (!isFullTokenSet(tokens)) {
|
|
492
|
+
return { error: true, message: "ChatApp: в ответе нет данных токена", data };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
this.auth.accessToken = tokens.accessToken;
|
|
496
|
+
this.auth.accessTokenEndTime = tokens.accessTokenEndTime;
|
|
497
|
+
this.auth.refreshToken = tokens.refreshToken;
|
|
498
|
+
this.auth.refreshTokenEndTime = tokens.refreshTokenEndTime;
|
|
422
499
|
this.updateToken = true;
|
|
423
500
|
return { error: false };
|
|
424
501
|
} catch (error: unknown) {
|
|
@@ -441,7 +518,7 @@ export class ChatApp {
|
|
|
441
518
|
},
|
|
442
519
|
FETCH_TIMEOUTS.api
|
|
443
520
|
);
|
|
444
|
-
const data
|
|
521
|
+
const data = (await response.json()) as ChatAppLicensesResponse;
|
|
445
522
|
|
|
446
523
|
if (!data.success) {
|
|
447
524
|
return { error: true, message: "Не удалось получить лицензии ChatApp", data: null };
|
package/sendMessage/email.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import nodemailer from "nodemailer";
|
|
1
|
+
import nodemailer, { type SendMailOptions } from "nodemailer";
|
|
2
|
+
import { logs } from "../logs/logs.ts";
|
|
2
3
|
import { fetchWithTimeout, FETCH_TIMEOUTS } from "../utils/fetchRetry.ts";
|
|
3
4
|
|
|
4
5
|
// ==================== Типы ====================
|
|
@@ -9,6 +10,12 @@ interface EmailAuthParam {
|
|
|
9
10
|
pass: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
/** Вложение письма */
|
|
14
|
+
interface EmailAttachment {
|
|
15
|
+
filename: string;
|
|
16
|
+
content: Buffer;
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
/** Данные для отправки письма */
|
|
13
20
|
interface EmailData {
|
|
14
21
|
to: string;
|
|
@@ -17,7 +24,7 @@ interface EmailData {
|
|
|
17
24
|
html?: string;
|
|
18
25
|
fileUrl?: string;
|
|
19
26
|
fileName?: string;
|
|
20
|
-
attachments?:
|
|
27
|
+
attachments?: EmailAttachment[] | null;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/** Результат отправки письма */
|
|
@@ -27,66 +34,260 @@ interface EmailResult {
|
|
|
27
34
|
info?: unknown;
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Ответ транспорта в той части, которую разбирает класс.
|
|
39
|
+
*
|
|
40
|
+
* Собственный узкий тип, а не `SentMessageInfo` из `@types/nodemailer`: там он
|
|
41
|
+
* объявлен как `any`, и в сигнатуру пакета это тянуть нельзя.
|
|
42
|
+
*/
|
|
43
|
+
export interface SendMailInfo {
|
|
44
|
+
accepted?: unknown;
|
|
45
|
+
rejected?: unknown;
|
|
46
|
+
response?: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Минимальный контракт транспорта — ровно то, что использует класс.
|
|
51
|
+
*
|
|
52
|
+
* Второй аргумент конструктора существует ради тестов и подмены транспорта
|
|
53
|
+
* у потребителя; по умолчанию класс поднимает SMTP Яндекса сам.
|
|
54
|
+
*/
|
|
55
|
+
export interface MailTransport {
|
|
56
|
+
sendMail(mail: SendMailOptions): Promise<SendMailInfo>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ==================== Утилиты ====================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Грубая проверка адреса: непустая строка без пробелов, одна собака,
|
|
63
|
+
* непустые части слева и справа.
|
|
64
|
+
*
|
|
65
|
+
* Полная валидация адресов регуляркой — известная ловушка, и она здесь не нужна:
|
|
66
|
+
* цель — отсечь пустую строку и явный мусор, отказ сервера всё равно проверяется
|
|
67
|
+
* по ответу. Точку в домене не требуем: внутрикорпоративные адреса без точки существуют.
|
|
68
|
+
*/
|
|
69
|
+
function isLikelyEmail(value: unknown): value is string {
|
|
70
|
+
if (typeof value !== "string") return false;
|
|
71
|
+
if (/\s/.test(value)) return false;
|
|
72
|
+
|
|
73
|
+
const parts = value.split("@");
|
|
74
|
+
if (parts.length !== 2) return false;
|
|
75
|
+
|
|
76
|
+
const [local, domain] = parts;
|
|
77
|
+
return Boolean(local) && Boolean(domain);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Приводит адрес к той форме, в которой его возвращает почтовый сервер.
|
|
82
|
+
*
|
|
83
|
+
* Сравнивать «как передали» нельзя: конверт письма строит nodemailer, и в `accepted`
|
|
84
|
+
* приходит адрес из конверта — домен в punycode, угловые скобки сняты. Живой адрес
|
|
85
|
+
* `ivan@пример.рф` возвращался как `ivan@xn--e1afmkfd.xn--p1ai`, с исходным не совпадал,
|
|
86
|
+
* и доставленное письмо объявлялось недоставленным.
|
|
87
|
+
*
|
|
88
|
+
* Применяется к обеим сторонам сравнения. Локальная часть приводится к нижнему регистру
|
|
89
|
+
* намеренно: формально она регистрозависима, но здесь адрес сверяется сам с собой,
|
|
90
|
+
* а не с чужим.
|
|
91
|
+
*/
|
|
92
|
+
function normalizeAddress(value: string): string {
|
|
93
|
+
let address = value.trim();
|
|
94
|
+
|
|
95
|
+
if (address.startsWith("<") && address.endsWith(">")) {
|
|
96
|
+
address = address.slice(1, -1).trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Разделяем по последней собаке: в локальной части она допустима в кавычках
|
|
100
|
+
const separator = address.lastIndexOf("@");
|
|
101
|
+
if (separator <= 0) return address.toLowerCase();
|
|
102
|
+
|
|
103
|
+
const local = address.slice(0, separator);
|
|
104
|
+
const domain = address.slice(separator + 1);
|
|
105
|
+
|
|
106
|
+
let host: string;
|
|
107
|
+
try {
|
|
108
|
+
// Доступный в Node способ получить punycode-форму домена: разбор хоста в URL
|
|
109
|
+
// делает то же преобразование, что и nodemailer при построении конверта
|
|
110
|
+
host = new URL(`http://${domain}`).hostname;
|
|
111
|
+
} catch {
|
|
112
|
+
// Домен как хост не разбирается (пустой, с недопустимым символом) — сверяем как есть
|
|
113
|
+
host = domain.toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return `${local.toLowerCase()}@${host}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Приводит `accepted`/`rejected` к списку нормализованных адресов.
|
|
121
|
+
*
|
|
122
|
+
* Элементы приходят строкой либо объектом `{ name, address }` — форма зависит
|
|
123
|
+
* от того, как адрес был передан в `sendMail`.
|
|
124
|
+
*/
|
|
125
|
+
function toAddressList(value: unknown): string[] {
|
|
126
|
+
if (!Array.isArray(value)) return [];
|
|
127
|
+
|
|
128
|
+
const addresses: string[] = [];
|
|
129
|
+
|
|
130
|
+
for (const item of value as unknown[]) {
|
|
131
|
+
if (typeof item === "string") {
|
|
132
|
+
addresses.push(item);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (item && typeof item === "object") {
|
|
137
|
+
const address = (item as { address?: unknown }).address;
|
|
138
|
+
if (typeof address === "string") addresses.push(address);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return addresses.map((address) => normalizeAddress(address)).filter((address) => address !== "");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Имя для скачанного вложения: явно переданное `fileName`, иначе последний
|
|
147
|
+
* сегмент пути из URL (без query), иначе запасное `attachment`.
|
|
148
|
+
*
|
|
149
|
+
* Прежде здесь стояло `dataMail.fileName!`, и письмо с `fileUrl` без имени
|
|
150
|
+
* уезжало с вложением без имени файла.
|
|
151
|
+
*/
|
|
152
|
+
function resolveAttachmentName(fileUrl: string, fileName?: string): string {
|
|
153
|
+
const explicit = fileName?.trim();
|
|
154
|
+
if (explicit) return explicit;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const segment = new URL(fileUrl).pathname.split("/").pop();
|
|
158
|
+
const decoded = segment ? decodeURIComponent(segment) : "";
|
|
159
|
+
if (decoded) return decoded;
|
|
160
|
+
} catch {
|
|
161
|
+
// Адрес может быть относительным или битым — тогда берём запасное имя
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return "attachment";
|
|
165
|
+
}
|
|
166
|
+
|
|
30
167
|
// ==================== Класс ====================
|
|
31
168
|
|
|
32
169
|
export class Email {
|
|
33
170
|
private auth: EmailAuthParam;
|
|
34
|
-
private transporter:
|
|
171
|
+
private transporter: MailTransport;
|
|
35
172
|
|
|
36
|
-
constructor(auth: EmailAuthParam) {
|
|
173
|
+
constructor(auth: EmailAuthParam, transporter?: MailTransport) {
|
|
37
174
|
this.auth = auth;
|
|
38
|
-
this.transporter =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
175
|
+
this.transporter =
|
|
176
|
+
transporter ??
|
|
177
|
+
nodemailer.createTransport({
|
|
178
|
+
auth: {
|
|
179
|
+
user: this.auth.user,
|
|
180
|
+
pass: this.auth.pass,
|
|
181
|
+
},
|
|
182
|
+
host: "smtp.yandex.ru",
|
|
183
|
+
port: 465, // 465 = SMTPS, 587 = STARTTLS
|
|
184
|
+
secure: true, // true для 465
|
|
185
|
+
// Дефолты nodemailer (соединение 2 мин, приветствие 30с, сокет 10 мин) слишком щедры:
|
|
186
|
+
// письмо с вложением уходит на Яндекс за секунды, а 10 минут молчания сокета
|
|
187
|
+
// блокируют вызывающую очередь
|
|
188
|
+
connectionTimeout: 15_000,
|
|
189
|
+
greetingTimeout: 10_000,
|
|
190
|
+
socketTimeout: 120_000,
|
|
191
|
+
});
|
|
53
192
|
}
|
|
54
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Отправляет письмо получателю, копию — на адрес отправителя.
|
|
196
|
+
*
|
|
197
|
+
* Адрес получателя проверяется до вызова транспорта: пустой или мусорный `to`
|
|
198
|
+
* раньше давал «успешную» отправку копии себе и ни одной жалобы в логе.
|
|
199
|
+
*
|
|
200
|
+
* Вложения вызывающего (`attachments`) сохраняются и дополняются файлом,
|
|
201
|
+
* скачанным по `fileUrl`. Входной объект не мутируется.
|
|
202
|
+
*
|
|
203
|
+
* @param dataMail — адресат, тема, тело письма, вложения
|
|
204
|
+
* @returns `{ error, message?, info? }`; `error: true` приходит и тогда, когда
|
|
205
|
+
* сервер принял копию отправителю, но отверг получателя
|
|
206
|
+
*/
|
|
55
207
|
async send(dataMail: EmailData): Promise<EmailResult> {
|
|
56
208
|
try {
|
|
57
|
-
|
|
209
|
+
const to = typeof dataMail.to === "string" ? dataMail.to.trim() : "";
|
|
210
|
+
if (!isLikelyEmail(to)) {
|
|
211
|
+
return { error: true, message: `Email: адрес получателя некорректен ('${String(dataMail.to)}') — письмо не отправлено` };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Вложения вызывающего не затираем и входной объект не мутируем:
|
|
215
|
+
// `attachments` и пара `fileUrl`/`fileName` объявлены независимыми полями,
|
|
216
|
+
// ни одно не документировано как «вместо». Тот, кто заполнил оба, хочет оба;
|
|
217
|
+
// замена молча теряла данные. Порядок: сначала переданные, затем скачанное
|
|
218
|
+
const attachments: EmailAttachment[] = [...(dataMail.attachments ?? [])];
|
|
219
|
+
|
|
58
220
|
if (dataMail.fileUrl) {
|
|
59
221
|
const resDownloadFile = await this.downloadFileToUrl(dataMail.fileUrl);
|
|
60
|
-
if (resDownloadFile.error) {
|
|
222
|
+
if (resDownloadFile.error || !resDownloadFile.buffer) {
|
|
61
223
|
// Подстановка вместо возможного undefined: при exactOptionalPropertyTypes
|
|
62
224
|
// у потребителя явный undefined не подходит полю message?: string
|
|
63
225
|
return { error: true, message: resDownloadFile.message ?? "Не удалось скачать файл по fileUrl" };
|
|
64
226
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
];
|
|
71
|
-
} else {
|
|
72
|
-
dataMail.attachments = null;
|
|
227
|
+
|
|
228
|
+
attachments.push({
|
|
229
|
+
filename: resolveAttachmentName(dataMail.fileUrl, dataMail.fileName),
|
|
230
|
+
content: resDownloadFile.buffer,
|
|
231
|
+
});
|
|
73
232
|
}
|
|
74
233
|
|
|
75
234
|
const info = await this.transporter.sendMail({
|
|
76
235
|
from: `Натяжные потолки Репа. <${this.auth.user}>`,
|
|
77
|
-
to: [
|
|
236
|
+
to: [to, this.auth.user],
|
|
78
237
|
subject: dataMail.subject,
|
|
79
238
|
text: dataMail.text,
|
|
80
239
|
html: dataMail.html,
|
|
81
|
-
attachments:
|
|
240
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
82
241
|
});
|
|
83
|
-
|
|
242
|
+
|
|
243
|
+
return this.checkDelivery(to, info);
|
|
84
244
|
} catch (error: unknown) {
|
|
85
245
|
const message = error instanceof Error ? error.message : String(error);
|
|
86
246
|
return { error: true, message };
|
|
87
247
|
}
|
|
88
248
|
}
|
|
89
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Проверяет по ответу транспорта, принял ли сервер основного адресата.
|
|
252
|
+
*
|
|
253
|
+
* В `to` всегда уходит пара «получатель + отправитель», а `sendMail` резолвится,
|
|
254
|
+
* если принят хотя бы один адрес. Без этой проверки отказ по получателю
|
|
255
|
+
* возвращался вызывающему как `error: false`.
|
|
256
|
+
*
|
|
257
|
+
* Отказ логируется не здесь: результат уходит вызывающему, а уровень `error`
|
|
258
|
+
* дублировался бы в чат B24. От таймаутов отправки в `Smsgold`/`Wappi` случай
|
|
259
|
+
* отличается тем, что статус известен — сервер прямо сказал «не принял».
|
|
260
|
+
*/
|
|
261
|
+
private checkDelivery(to: string, info: SendMailInfo): EmailResult {
|
|
262
|
+
const hasReport = Array.isArray(info?.accepted) || Array.isArray(info?.rejected);
|
|
263
|
+
|
|
264
|
+
// Транспорт вообще не сообщил, какие адреса приняты. Отказ здесь не выдумываем:
|
|
265
|
+
// у SMTP-транспорта эти поля есть всегда, а объявить успешно доставленное письмо
|
|
266
|
+
// ошибкой — регресс хуже исходного дефекта. Успех плюс строка в лог
|
|
267
|
+
if (!hasReport) {
|
|
268
|
+
logs.add("Email: транспорт не сообщил, какие адреса приняты — проверить доставку нечем", "warn");
|
|
269
|
+
return { error: false, info };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Обе стороны сравнения нормализуем одинаково: сервер отдаёт адреса из конверта,
|
|
273
|
+
// а не в том виде, в каком их передали
|
|
274
|
+
const target = normalizeAddress(to);
|
|
275
|
+
const accepted = toAddressList(info.accepted);
|
|
276
|
+
const rejected = toAddressList(info.rejected);
|
|
277
|
+
|
|
278
|
+
if (rejected.includes(target) || !accepted.includes(target)) {
|
|
279
|
+
const response = typeof info.response === "string" && info.response.trim() !== "" ? ` Ответ сервера: ${info.response}` : "";
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
error: true,
|
|
283
|
+
message: `Email: почтовый сервер не принял адрес получателя ${to}, письмо ему не ушло (копия отправителю могла уйти).${response}`,
|
|
284
|
+
info,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { error: false, info };
|
|
289
|
+
}
|
|
290
|
+
|
|
90
291
|
private async downloadFileToUrl(fileUrl: string): Promise<{ error: boolean; message?: string; buffer?: Buffer }> {
|
|
91
292
|
try {
|
|
92
293
|
const response = await fetchWithTimeout(fileUrl, undefined, FETCH_TIMEOUTS.transfer);
|
package/sendMessage/smsgold.ts
CHANGED
|
@@ -27,6 +27,15 @@ interface SmsMessageData {
|
|
|
27
27
|
*/
|
|
28
28
|
const DEFAULT_UPLOAD_FOLDER_ID = 2792881;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Файл, загруженный на диск Bitrix24 — то, что классу нужно от ответа
|
|
32
|
+
* `disk.folder.uploadfile`. Идентификатор портал отдаёт то строкой, то числом,
|
|
33
|
+
* и класс лишь передаёт его дальше в `disk.file.getExternalLink`
|
|
34
|
+
*/
|
|
35
|
+
interface UploadedFile {
|
|
36
|
+
ID: string | number;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
/** Результат отправки SMS */
|
|
31
40
|
interface SmsResult {
|
|
32
41
|
error: boolean;
|
|
@@ -175,7 +184,7 @@ export class Smsgold {
|
|
|
175
184
|
}
|
|
176
185
|
|
|
177
186
|
/** Загружает файл в папку Битрикс24 через метод disk.folder.uploadfile */
|
|
178
|
-
private async uploadFileInFolderB24(fileBuffer: Buffer, fileName: string): Promise<{ error: boolean; message?: string; data?:
|
|
187
|
+
private async uploadFileInFolderB24(fileBuffer: Buffer, fileName: string): Promise<{ error: boolean; message?: string; data?: UploadedFile }> {
|
|
179
188
|
try {
|
|
180
189
|
const base64File = fileBuffer.toString("base64");
|
|
181
190
|
|
|
@@ -185,7 +194,7 @@ export class Smsgold {
|
|
|
185
194
|
data: { NAME: fileName },
|
|
186
195
|
fileContent: [fileName, base64File],
|
|
187
196
|
});
|
|
188
|
-
const data = getResultData(res, "disk.folder.uploadfile");
|
|
197
|
+
const data = getResultData<UploadedFile>(res, "disk.folder.uploadfile");
|
|
189
198
|
return { error: false, data };
|
|
190
199
|
} catch (error: unknown) {
|
|
191
200
|
const errMsg = error instanceof Error ? error.message : String(error);
|