@andrey4emk/npm-app-back-b24 3.8.2 → 3.9.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.
@@ -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?: { filename: string; content: Buffer }[] | null;
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: ReturnType<typeof nodemailer.createTransport>;
171
+ private transporter: MailTransport;
35
172
 
36
- constructor(auth: EmailAuthParam) {
173
+ constructor(auth: EmailAuthParam, transporter?: MailTransport) {
37
174
  this.auth = auth;
38
- this.transporter = nodemailer.createTransport({
39
- auth: {
40
- user: this.auth.user,
41
- pass: this.auth.pass,
42
- },
43
- host: "smtp.yandex.ru",
44
- port: 465, // 465 = SMTPS, 587 = STARTTLS
45
- secure: true, // true для 465
46
- // Дефолты nodemailer (соединение 2 мин, приветствие 30с, сокет 10 мин) слишком щедры:
47
- // письмо с вложением уходит на Яндекс за секунды, а 10 минут молчания сокета
48
- // блокируют вызывающую очередь
49
- connectionTimeout: 15_000,
50
- greetingTimeout: 10_000,
51
- socketTimeout: 120_000,
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
- // Если прислали ссылку на файл, то скачиваем его и добавляем в attachments
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
- dataMail.attachments = [
66
- {
67
- filename: dataMail.fileName!,
68
- content: resDownloadFile.buffer!,
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: [dataMail.to, this.auth.user],
236
+ to: [to, this.auth.user],
78
237
  subject: dataMail.subject,
79
238
  text: dataMail.text,
80
239
  html: dataMail.html,
81
- attachments: dataMail.attachments ?? undefined,
240
+ attachments: attachments.length > 0 ? attachments : undefined,
82
241
  });
83
- return { error: false, info };
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);
@@ -31,7 +31,15 @@ function isValidTimeout(value: number): boolean {
31
31
  }
32
32
 
33
33
  /**
34
- * Читает бюджет запроса из переменной окружения `FETCH_TIMEOUT_MS`.
34
+ * Верхняя граница подозрительного бюджета: значение от 1 до 999 мс почти наверняка
35
+ * означает, что человек написал секунды вместо миллисекунд.
36
+ *
37
+ * Ноль сюда не входит — это задокументированное выключение таймаута.
38
+ */
39
+ const SUSPICIOUS_TIMEOUT_MAX_MS = 999;
40
+
41
+ /**
42
+ * Читает бюджет запроса из переменной окружения `name`.
35
43
  *
36
44
  * `dotenv.config()` вызывается в `logs/logs.ts`, а этот модуль импортирует `logs`
37
45
  * первой строкой — значит к моменту чтения `process.env` файл `.env` уже загружен.
@@ -39,15 +47,38 @@ function isValidTimeout(value: number): boolean {
39
47
  * Значение принимается, только если это целое число >= 0 (0 означает «без таймаута»).
40
48
  * Любой мусор в переменной не должен молча превращаться в `NaN` или в обрезанное
41
49
  * `parseInt`-число, поэтому проверяем фактом и падаем на дефолт с записью в лог.
50
+ * Пустая строка и строка из пробелов считаются «переменная не задана» — молча дефолт.
51
+ *
52
+ * Значение от 1 до 999 применяется как есть, но с записью `warn`: столько миллисекунд
53
+ * не хватит ни одному живому запросу, и это типовая путаница секунд с миллисекундами.
54
+ * Молча такое значение пропускать нельзя — на бюджетах отправки оно превращается
55
+ * в массовую недоставку с «неизвестным статусом» и потоком `error` в чат портала.
56
+ *
57
+ * Экспортируется по двум причинам: одинаковая семантика для всех бюджетов пакета
58
+ * (дефолт `fetchRetry` и каждый из `FETCH_TIMEOUTS` читаются одной и той же функцией)
59
+ * и проверяемость тестом без запуска дочернего процесса.
60
+ *
61
+ * Важно: все чтения происходят **один раз при загрузке модуля**. Правка `process.env`
62
+ * в рантайме на уже вычисленные константы не влияет.
63
+ *
64
+ * @param name — имя переменной окружения
65
+ * @param fallback — значение, если переменная не задана или непригодна
66
+ * @returns бюджет в мс
42
67
  */
43
- function readTimeoutFromEnv(): number {
44
- const raw = process.env.FETCH_TIMEOUT_MS;
45
- if (raw === undefined || raw.trim() === "") return FALLBACK_TIMEOUT_MS;
68
+ export function readTimeoutFromEnv(name: string, fallback: number): number {
69
+ const raw = process.env[name];
70
+ if (raw === undefined || raw.trim() === "") return fallback;
46
71
 
47
72
  const parsed = Number(raw);
48
73
  if (!isValidTimeout(parsed)) {
49
- logs.add(`FETCH_TIMEOUT_MS: значение '${raw}' непригодно (нужно целое от 0 до ${MAX_TIMEOUT_MS}) — берём ${FALLBACK_TIMEOUT_MS}мс`, "warn");
50
- return FALLBACK_TIMEOUT_MS;
74
+ logs.add(`${name}: значение '${raw}' непригодно (нужно целое от 0 до ${MAX_TIMEOUT_MS}) — берём ${fallback}мс`, "warn");
75
+ return fallback;
76
+ }
77
+
78
+ // Значение применяем как есть: аварийный рычаг остаётся в руках у человека,
79
+ // мы только делаем подозрительный ввод видимым
80
+ if (parsed >= 1 && parsed <= SUSPICIOUS_TIMEOUT_MAX_MS) {
81
+ logs.add(`${name}: значение '${raw}' похоже на секунды, а нужны миллисекунды — бюджет ${parsed}мс применён как есть`, "warn");
51
82
  }
52
83
 
53
84
  return parsed;
@@ -58,7 +89,13 @@ function readTimeoutFromEnv(): number {
58
89
  * из `FETCH_TIMEOUT_MS`, иначе 60 000. Значение 0 полностью выключает таймаут —
59
90
  * это аварийный рычаг для случая, когда дефолт обрывает живой долгий запрос.
60
91
  */
61
- export const DEFAULT_FETCH_TIMEOUT_MS: number = readTimeoutFromEnv();
92
+ export const DEFAULT_FETCH_TIMEOUT_MS: number = readTimeoutFromEnv("FETCH_TIMEOUT_MS", FALLBACK_TIMEOUT_MS);
93
+
94
+ /** Дефолты именованных бюджетов до чтения окружения, мс */
95
+ const FALLBACK_TIMEOUTS = { quick: 10_000, api: 20_000, send: 30_000, transfer: 60_000 } as const;
96
+
97
+ /** Имя именованного бюджета пакета */
98
+ export type FetchTimeoutName = keyof typeof FALLBACK_TIMEOUTS;
62
99
 
63
100
  /**
64
101
  * Именованные бюджеты для вызовов внутри пакета: смысл каждого запроса тут известен,
@@ -68,8 +105,32 @@ export const DEFAULT_FETCH_TIMEOUT_MS: number = readTimeoutFromEnv();
68
105
  * - `api` — короткое чтение (проверка номера, список лицензий, получение контакта);
69
106
  * - `send` — отправка текста, создание контакта, отправка SMS;
70
107
  * - `transfer` — скачивание файла и отправка файла/вложения.
108
+ *
109
+ * Каждый бюджет переопределяется своей переменной окружения:
110
+ *
111
+ * | Ключ | Переменная | Дефолт, мс |
112
+ * |---|---|---|
113
+ * | `quick` | `FETCH_TIMEOUT_QUICK_MS` | 10 000 |
114
+ * | `api` | `FETCH_TIMEOUT_API_MS` | 20 000 |
115
+ * | `send` | `FETCH_TIMEOUT_SEND_MS` | 30 000 |
116
+ * | `transfer` | `FETCH_TIMEOUT_TRANSFER_MS` | 60 000 |
117
+ *
118
+ * Значения читаются один раз при загрузке модуля: правка `process.env` в рантайме
119
+ * их не двигает, менять бюджет нужно до старта процесса.
120
+ *
121
+ * Значение 0 выключает таймаут для этого бюджета целиком. Это аварийный рычаг,
122
+ * и у отправок (`send`, `transfer`) он снимает заодно защиту «неизвестный статус
123
+ * доставки»: при нуле зависшая отправка снова держится до таймаутов undici,
124
+ * а `unknownDelivery` в `Smsgold` не выставится никогда.
125
+ *
126
+ * Непригодное значение защиту молча не выключает: одна строка `warn` и дефолт.
71
127
  */
72
- export const FETCH_TIMEOUTS = { quick: 10_000, api: 20_000, send: 30_000, transfer: 60_000 } as const;
128
+ export const FETCH_TIMEOUTS: Readonly<Record<FetchTimeoutName, number>> = {
129
+ quick: readTimeoutFromEnv("FETCH_TIMEOUT_QUICK_MS", FALLBACK_TIMEOUTS.quick),
130
+ api: readTimeoutFromEnv("FETCH_TIMEOUT_API_MS", FALLBACK_TIMEOUTS.api),
131
+ send: readTimeoutFromEnv("FETCH_TIMEOUT_SEND_MS", FALLBACK_TIMEOUTS.send),
132
+ transfer: readTimeoutFromEnv("FETCH_TIMEOUT_TRANSFER_MS", FALLBACK_TIMEOUTS.transfer),
133
+ };
73
134
 
74
135
  /** Читает `name` с произвольного значения ошибки, не полагаясь на её тип */
75
136
  function getErrorName(error: unknown): string | null {
@@ -135,7 +196,7 @@ export function isNetworkError(error: unknown): boolean {
135
196
 
136
197
  /**
137
198
  * Коды, доказывающие, что соединение не было установлено: DNS не разрешился либо
138
- * хост отверг подключение. Запрос при таких ошибках гарантированно не дошёл до сервера.
199
+ * хост отверг подключение. Соединение с целевым хостом не состоялось.
139
200
  */
140
201
  const PRE_CONNECTION_ERROR_CODES = ["ENOTFOUND", "EAI_AGAIN", "EAI_NODATA", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH", "ENETDOWN"];
141
202