neuroalpha-portal 0.7.0 → 0.7.1

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": "neuroalpha-portal",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "НейроАльфа Портал — витрина, личные кабинеты пользователей, беспарольный вход по Email-коду, приём оплат ЮKassa, консоль администратора и сервер подписанных лицензий для торгового терминала neuroalpha",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
@@ -42,6 +42,7 @@
42
42
  "node": ">=22.13"
43
43
  },
44
44
  "dependencies": {
45
- "express": "^4.21.2"
45
+ "express": "^4.21.2",
46
+ "nodemailer": "^6.10.0"
46
47
  }
47
48
  }
package/public/admin.html CHANGED
@@ -137,7 +137,13 @@
137
137
  <section data-pane="smtp" hidden>
138
138
  <div class="card">
139
139
  <h2>Настройка почтового сервера SMTP (для отправки 6-значных кодов входа)</h2>
140
- <p class="sub">Поддерживаются Яндекс 360 (<code>smtp.yandex.ru:465</code>), Mail.ru (<code>smtp.mail.ru:465</code>), Timeweb, корпоративный Postfix и любые SMTPS / STARTTLS серверы. Пароль шифруется в <code>portal.db</code> и сохраняется при обновлениях <code>npx neuroalpha-portal</code>.</p>
140
+ <p class="sub">Поддерживаются Яндекс (<code>smtp.yandex.ru:465</code>), Gmail (<code>smtp.gmail.com:465</code>), Mail.ru (<code>smtp.mail.ru:465</code>) и любые SMTPS / STARTTLS серверы (или файл <code>mail.json</code> в <code>~/.neuroalpha-portal/mail.json</code>).</p>
141
+ <div class="row-gap" style="flex-wrap:wrap;margin-bottom:14px">
142
+ <span class="muted small">Быстрый шаблон:</span>
143
+ <button type="button" data-smtp-preset="yandex">Яндекс (465)</button>
144
+ <button type="button" data-smtp-preset="gmail">Gmail (465)</button>
145
+ <button type="button" data-smtp-preset="mailru">Mail.ru (465)</button>
146
+ </div>
141
147
  <form id="form-smtp" class="grid-form">
142
148
  <div class="field-row"><span>SMTP сервер (Host)</span><input name="host" placeholder="smtp.yandex.ru" required></div>
143
149
  <div class="field-row"><span>Порт</span><input name="port" type="number" value="465" required></div>
@@ -428,6 +434,14 @@
428
434
  } catch (err) { toast(err.message); }
429
435
  });
430
436
 
437
+ document.querySelectorAll('[data-smtp-preset]').forEach((b) => b.addEventListener('click', () => {
438
+ const f = $('#form-smtp').elements;
439
+ const map = { yandex: 'smtp.yandex.ru', gmail: 'smtp.gmail.com', mailru: 'smtp.mail.ru' };
440
+ f.host.value = map[b.dataset.smtpPreset] || '';
441
+ f.port.value = 465;
442
+ f.secure.checked = true;
443
+ }));
444
+
431
445
  $('#form-smtp').addEventListener('submit', async (e) => {
432
446
  e.preventDefault();
433
447
  const f = e.target.elements;
@@ -438,7 +438,17 @@
438
438
  $('#form-verify-code').hidden = true;
439
439
  $('#auth-err-1').textContent = '';
440
440
  $('#auth-err-2').textContent = '';
441
- $('#auth-first-admin-note').hidden = Boolean(meState?.hasUsers);
441
+ const firstNoUsers = !meState?.hasUsers;
442
+ const noSmtp = !meState?.smtpEnabled;
443
+ $('#auth-first-admin-note').hidden = !firstNoUsers;
444
+ if (firstNoUsers) {
445
+ $('#auth-first-admin-note').innerHTML = noSmtp
446
+ ? '⚡ <b>Первый запуск портала (SMTP ещё не настроен):</b> первый вошедший пользователь станет <b>Администратором</b> с бессрочной лицензией «Хозяин». Одноразовый 6-значный код выведется в окно консоли сервера <code>neuroalpha-portal</code>.'
447
+ : '⚡ <b>Первый запуск портала:</b> база пользователей пуста — первый вошедший по коду из письма пользователь автоматически станет <b>Администратором</b> с бессрочной лицензией «Хозяин».';
448
+ }
449
+ $('#auth-sub').textContent = (firstNoUsers && noSmtp)
450
+ ? 'Введите ваш Email администратора — одноразовый 6-значный код появится в консоли сервера.'
451
+ : 'Введите ваш Email — мы отправим одноразовый 6-значный код на вашу почту (если аккаунта ещё нет, он создастся автоматически).';
442
452
  $('#auth-modal').hidden = false;
443
453
  setTimeout(() => $('#auth-email').focus(), 40);
444
454
  }
package/server/auth.js CHANGED
@@ -508,6 +508,7 @@ export function fetchAndBindLicense({ token, installId, hostname = '', slotId =
508
508
  totalRemainingDays: s.totalRemainingDays,
509
509
  perpetual: s.perpetual,
510
510
  })),
511
+ allSlots,
511
512
  };
512
513
  } else {
513
514
  const boundNames = allSlots.map((s) => `«${s.label || s.id}» (${s.hostname || 'ПК'})`).join(', ');
@@ -547,6 +548,7 @@ export function fetchAndBindLicense({ token, installId, hostname = '', slotId =
547
548
  chooseSlot: false,
548
549
  publicKeyPem: portalPublicKeyPem(),
549
550
  slot: activeView,
551
+ allSlots: listUserLicenseSlots(u.id),
550
552
  licenseToken,
551
553
  license: certPayload,
552
554
  };
package/server/smtp.js CHANGED
@@ -1,21 +1,63 @@
1
1
  // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- // Встроенный SMTP-клиент (SMTPS 465 и STARTTLS 587/25 на чистом node:net + node:tls без внешних зависимостей).
2
+ // Отправка 6-значных кодов подтверждения по SMTP (через nodemailer с text + html multipart/alternative,
3
+ // а также авточтением mail.json из ~/.neuroalpha-portal/mail.json или #system/mail.json по аналогии с ODANT-WORK).
4
+ import fs from 'node:fs';
3
5
  import net from 'node:net';
4
6
  import tls from 'node:tls';
7
+ import path from 'node:path';
5
8
  import crypto from 'node:crypto';
9
+ import { ROOT, DATA_DIR } from './config.js';
6
10
  import { kvGet, kvSet } from './db.js';
7
11
  import { encryptSecret, decryptSecret } from './crypto.js';
8
12
 
13
+ function loadFileMailJson() {
14
+ const candidates = [
15
+ path.join(DATA_DIR, 'mail.json'),
16
+ path.resolve(process.cwd(), '#system/mail.json'),
17
+ path.resolve(process.cwd(), '../#system/mail.json'),
18
+ path.resolve(process.cwd(), '../work/#system/mail.json'),
19
+ path.resolve(ROOT, '../#system/mail.json'),
20
+ path.resolve(ROOT, '../../work/#system/mail.json'),
21
+ ];
22
+ for (const p of candidates) {
23
+ try {
24
+ if (fs.existsSync(p)) {
25
+ const j = JSON.parse(fs.readFileSync(p, 'utf8'));
26
+ const user = String(j?.auth?.user || j?.user || '').trim();
27
+ const pass = String(j?.auth?.pass || j?.pass || '').trim();
28
+ const host = String(j?.host || '').trim();
29
+ if (host && user && pass) {
30
+ return {
31
+ host,
32
+ port: Number(j.port || 465) || 465,
33
+ secure: j.secure !== undefined ? Boolean(j.secure) : Number(j.port || 465) === 465,
34
+ user,
35
+ pass,
36
+ from: String(j.from || user).trim(),
37
+ };
38
+ }
39
+ }
40
+ } catch { /* ignore */ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ let cachedTransportKey = '';
46
+ let cachedTransporter = null;
47
+
9
48
  export function getSmtpConfig() {
10
49
  const saved = kvGet('smtpConfig') || {};
11
- const host = String(saved.host || process.env.SMTP_HOST || '').trim();
12
- const port = Number(saved.port || process.env.SMTP_PORT || 465) || 465;
50
+ const fileCfg = loadFileMailJson();
51
+ const host = String(saved.host || process.env.SMTP_HOST || fileCfg?.host || '').trim();
52
+ const port = Number(saved.port || process.env.SMTP_PORT || fileCfg?.port || 465) || 465;
13
53
  const secure = saved.secure !== undefined
14
54
  ? Boolean(saved.secure)
15
- : (process.env.SMTP_SECURE !== undefined ? process.env.SMTP_SECURE === '1' || process.env.SMTP_SECURE === 'true' : port === 465);
16
- const user = String(saved.user || process.env.SMTP_USER || '').trim();
17
- const pass = decryptSecret(saved.pass || '') || String(process.env.SMTP_PASS || '').trim();
18
- const from = String(saved.from || process.env.SMTP_FROM || user || '').trim();
55
+ : (process.env.SMTP_SECURE !== undefined
56
+ ? (process.env.SMTP_SECURE === '1' || process.env.SMTP_SECURE === 'true')
57
+ : (fileCfg?.secure !== undefined ? fileCfg.secure : port === 465));
58
+ const user = String(saved.user || process.env.SMTP_USER || fileCfg?.user || '').trim();
59
+ const pass = decryptSecret(saved.pass || '') || String(process.env.SMTP_PASS || fileCfg?.pass || '').trim();
60
+ const from = String(saved.from || process.env.SMTP_FROM || fileCfg?.from || user || '').trim();
19
61
  const fromName = String(saved.fromName || process.env.SMTP_FROM_NAME || 'НейроАльфа').trim();
20
62
  return {
21
63
  host,
@@ -25,7 +67,7 @@ export function getSmtpConfig() {
25
67
  pass,
26
68
  from,
27
69
  fromName,
28
- enabled: Boolean(host && port && from),
70
+ enabled: Boolean(host && port && from && pass),
29
71
  };
30
72
  }
31
73
 
@@ -55,164 +97,127 @@ export function saveSmtpConfig(input = {}) {
55
97
  from: String(input.from ?? prev.from ?? '').trim(),
56
98
  fromName: String(input.fromName ?? prev.fromName ?? 'НейроАльфа').trim(),
57
99
  };
100
+ cachedTransporter = null;
101
+ cachedTransportKey = '';
58
102
  kvSet('smtpConfig', next);
59
103
  return publicSmtpConfig();
60
104
  }
61
105
 
62
- const encodeHeaderUtf8 = (s) => `=?UTF-8?B?${Buffer.from(String(s || ''), 'utf8').toString('base64')}?=`;
63
-
64
- function createLineSession(initialSocket) {
65
- let sock = initialSocket;
66
- let buf = '';
67
- let waiter = null;
68
- let closedErr = null;
69
- const blockLines = [];
70
-
71
- const attach = (s) => {
72
- sock = s;
73
- s.setEncoding('utf8');
74
- s.on('data', (chunk) => {
75
- buf += chunk;
76
- let nl;
77
- while ((nl = buf.indexOf('\n')) >= 0) {
78
- const line = buf.slice(0, nl).replace(/\r$/, '');
79
- buf = buf.slice(nl + 1);
80
- blockLines.push(line);
81
- if (/^\d{3} /.test(line) || !/^\d{3}-/.test(line)) {
82
- const code = Number(line.slice(0, 3)) || 0;
83
- const text = blockLines.splice(0, blockLines.length).join('\n');
84
- if (waiter) {
85
- const w = waiter;
86
- waiter = null;
87
- w.resolve({ code, text });
88
- }
89
- }
90
- }
91
- });
92
- s.on('error', (err) => {
93
- closedErr = err;
94
- if (waiter) {
95
- const w = waiter;
96
- waiter = null;
97
- w.reject(err);
98
- }
99
- });
100
- s.on('close', () => {
101
- const err = closedErr || new Error('Соединение с SMTP-сервером закрыто');
102
- closedErr = err;
103
- if (waiter) {
104
- const w = waiter;
105
- waiter = null;
106
- w.reject(err);
107
- }
108
- });
109
- };
110
-
111
- attach(initialSocket);
112
-
113
- const readReply = (timeoutMs = 15000) => new Promise((resolve, reject) => {
114
- if (closedErr) { reject(closedErr); return; }
115
- const timer = setTimeout(() => {
116
- if (waiter) waiter = null;
117
- reject(new Error('Таймаут ответа SMTP-сервера'));
118
- }, timeoutMs);
119
- waiter = {
120
- resolve: (v) => { clearTimeout(timer); resolve(v); },
121
- reject: (e) => { clearTimeout(timer); reject(e); },
122
- };
123
- });
124
-
125
- const cmd = async (line, expectPrefix = [2, 3]) => {
126
- sock.write(`${line}\r\n`);
127
- const rep = await readReply();
128
- const pfx = Math.floor(rep.code / 100);
129
- if (!expectPrefix.includes(pfx)) {
130
- throw new Error(`SMTP ошибка (${rep.code}): ${rep.text.slice(0, 200)}`);
131
- }
132
- return rep;
133
- };
134
-
135
- const upgradeTls = (host) => new Promise((resolve, reject) => {
136
- sock.removeAllListeners('data');
137
- sock.removeAllListeners('error');
138
- sock.removeAllListeners('close');
139
- const tlsSock = tls.connect({ socket: sock, servername: host }, () => {
140
- closedErr = null;
141
- buf = '';
142
- blockLines.length = 0;
143
- attach(tlsSock);
144
- resolve();
145
- });
146
- tlsSock.once('error', reject);
147
- });
148
-
149
- const close = () => {
150
- try { sock.destroy(); } catch { /* ignore */ }
151
- };
152
-
153
- return { readReply, cmd, upgradeTls, close };
154
- }
155
-
156
- export async function sendEmail({ to, subject, html }) {
106
+ export async function sendEmail({ to, subject, text = '', html = '' }) {
157
107
  const cfg = getSmtpConfig();
158
108
  if (!cfg.enabled) {
159
- throw new Error('Почтовый сервер SMTP ещё не настроен администратором');
109
+ throw new Error('Почтовый сервер SMTP ещё не настроен администратором в /admin');
160
110
  }
161
111
  const cleanAddr = (s) => String(s || '').replace(/[<>\r\n]/g, '').trim();
162
- const fromEmail = cleanAddr(cfg.from);
112
+ const fromEmail = cleanAddr(cfg.from || cfg.user);
163
113
  const toEmail = cleanAddr(to);
164
- const domain = fromEmail.split('@')[1] || 'neuroalpha.local';
165
-
166
- const rawSock = await new Promise((resolve, reject) => {
167
- const timer = setTimeout(() => reject(new Error(`Не удалось подключиться к SMTP ${cfg.host}:${cfg.port}`)), 15000);
168
- const onConnect = function () { clearTimeout(timer); resolve(this); };
169
- const s = cfg.secure
170
- ? tls.connect({ host: cfg.host, port: cfg.port, servername: cfg.host }, onConnect)
171
- : net.connect({ host: cfg.host, port: cfg.port }, onConnect);
172
- s.once('error', (e) => { clearTimeout(timer); reject(e); });
173
- });
114
+ const plainText = text || String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
174
115
 
175
- const sess = createLineSession(rawSock);
116
+ // Основной транспорт — nodemailer (RFC 5322 multipart text+html для мгновенной доставки в Gmail / Яндекс / Mail.ru)
176
117
  try {
177
- const greet = await sess.readReply();
178
- if (Math.floor(greet.code / 100) !== 2) throw new Error(`SMTP приветствие: ${greet.text}`);
179
-
180
- let ehlo = await sess.cmd(`EHLO ${domain}`, [2]);
181
- if (!cfg.secure && /STARTTLS/i.test(ehlo.text)) {
182
- await sess.cmd('STARTTLS', [2]);
183
- await sess.upgradeTls(cfg.host);
184
- ehlo = await sess.cmd(`EHLO ${domain}`, [2]);
118
+ const nmMod = await import('nodemailer');
119
+ const nodemailer = nmMod.default || nmMod;
120
+ const tKey = `${cfg.host}:${cfg.port}:${cfg.secure}:${cfg.user}:${cfg.pass}`;
121
+ if (!cachedTransporter || cachedTransportKey !== tKey) {
122
+ cachedTransporter = nodemailer.createTransport({
123
+ host: cfg.host,
124
+ port: cfg.port,
125
+ secure: cfg.port === 465 || cfg.secure,
126
+ auth: { user: cfg.user, pass: cfg.pass },
127
+ });
128
+ cachedTransportKey = tKey;
185
129
  }
186
-
187
- if (cfg.user && cfg.pass) {
188
- await sess.cmd('AUTH LOGIN', [3]);
189
- await sess.cmd(Buffer.from(cfg.user, 'utf8').toString('base64'), [3]);
190
- await sess.cmd(Buffer.from(cfg.pass, 'utf8').toString('base64'), [2]);
130
+ await cachedTransporter.sendMail({
131
+ from: cfg.fromName ? `"${cfg.fromName.replace(/"/g, '')}" <${fromEmail}>` : fromEmail,
132
+ to: toEmail,
133
+ subject,
134
+ text: plainText,
135
+ html,
136
+ });
137
+ return;
138
+ } catch (nmErr) {
139
+ if (nmErr?.code !== 'ERR_MODULE_NOT_FOUND') {
140
+ throw new Error(`Ошибка отправки SMTP: ${nmErr.message || nmErr}`);
191
141
  }
142
+ }
192
143
 
193
- await sess.cmd(`MAIL FROM:<${fromEmail}>`, [2]);
194
- await sess.cmd(`RCPT TO:<${toEmail}>`, [2]);
195
- await sess.cmd('DATA', [3]);
144
+ // Резервный нативный SMTPS multipart/alternative клиент (если пакет запущен без nodemailer)
145
+ const domain = (fromEmail.split('@')[1] || 'yandex.ru').trim();
146
+ const boundary = `----=_Part_${crypto.randomBytes(8).toString('hex')}`;
147
+ const b64 = (s) => (Buffer.from(String(s || ''), 'utf8').toString('base64').match(/.{1,76}/g) || []).join('\r\n');
148
+ const subjB64 = Buffer.from(String(subject || ''), 'utf8').toString('base64');
149
+
150
+ const sock = await new Promise((resolve, reject) => {
151
+ const timer = setTimeout(() => reject(new Error(`Таймаут SMTP ${cfg.host}:${cfg.port}`)), 15000);
152
+ const onConn = function () { clearTimeout(timer); resolve(this); };
153
+ const s = (cfg.port === 465 || cfg.secure)
154
+ ? tls.connect({ host: cfg.host, port: cfg.port, servername: cfg.host }, onConn)
155
+ : net.connect({ host: cfg.host, port: cfg.port }, onConn);
156
+ s.once('error', (e) => { clearTimeout(timer); reject(e); });
157
+ });
196
158
 
197
- const b64Body = Buffer.from(String(html || ''), 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
198
- const msg = [
199
- `From: ${cfg.fromName ? `${encodeHeaderUtf8(cfg.fromName)} <${fromEmail}>` : `<${fromEmail}>`}`,
200
- `To: <${toEmail}>`,
201
- `Subject: ${encodeHeaderUtf8(subject)}`,
159
+ try {
160
+ let buf = '';
161
+ const readRep = () => new Promise((resolve, reject) => {
162
+ const lines = [];
163
+ const onData = (ch) => {
164
+ buf += ch;
165
+ let idx;
166
+ while ((idx = buf.indexOf('\n')) >= 0) {
167
+ const line = buf.slice(0, idx).replace(/\r$/, '');
168
+ buf = buf.slice(idx + 1);
169
+ lines.push(line);
170
+ if (/^\d{3} /.test(line) || !/^\d{3}-/.test(line)) {
171
+ sock.off('data', onData);
172
+ resolve({ code: Number(line.slice(0, 3)) || 0, text: lines.join('\n') });
173
+ return;
174
+ }
175
+ }
176
+ };
177
+ sock.on('data', onData);
178
+ sock.once('error', reject);
179
+ });
180
+ sock.setEncoding('utf8');
181
+ await readRep();
182
+ const cmd = async (line, ok = [2, 3]) => {
183
+ sock.write(`${line}\r\n`);
184
+ const r = await readRep();
185
+ if (!ok.includes(Math.floor(r.code / 100))) throw new Error(`SMTP ${r.code}: ${r.text}`);
186
+ return r;
187
+ };
188
+ await cmd(`EHLO ${domain}`);
189
+ await cmd('AUTH LOGIN', [3]);
190
+ await cmd(Buffer.from(cfg.user, 'utf8').toString('base64'), [3]);
191
+ await cmd(Buffer.from(cfg.pass, 'utf8').toString('base64'), [2]);
192
+ await cmd(`MAIL FROM:<${fromEmail}>`);
193
+ await cmd(`RCPT TO:<${toEmail}>`);
194
+ await cmd('DATA', [3]);
195
+ const mime = [
202
196
  `Date: ${new Date().toUTCString()}`,
203
197
  `Message-ID: <${crypto.randomUUID()}@${domain}>`,
198
+ `From: "=?UTF-8?B?${Buffer.from(cfg.fromName || 'NeuroAlpha', 'utf8').toString('base64')}?=" <${fromEmail}>`,
199
+ `To: <${toEmail}>`,
200
+ `Subject: =?UTF-8?B?${subjB64}?=`,
204
201
  'MIME-Version: 1.0',
202
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
203
+ '',
204
+ `--${boundary}`,
205
+ 'Content-Type: text/plain; charset=UTF-8',
206
+ 'Content-Transfer-Encoding: base64',
207
+ '',
208
+ b64(plainText),
209
+ `--${boundary}`,
205
210
  'Content-Type: text/html; charset=UTF-8',
206
211
  'Content-Transfer-Encoding: base64',
207
212
  '',
208
- b64Body,
213
+ b64(html),
214
+ `--${boundary}--`,
209
215
  '.',
210
216
  ].join('\r\n');
211
-
212
- await sess.cmd(msg, [2]);
213
- try { await sess.cmd('QUIT', [2]); } catch { /* ignore */ }
217
+ await cmd(mime, [2]);
218
+ try { await cmd('QUIT'); } catch { /* ignore */ }
214
219
  } finally {
215
- sess.close();
220
+ try { sock.destroy(); } catch { /* ignore */ }
216
221
  }
217
222
  }
218
223
 
@@ -230,17 +235,19 @@ export async function deliverLoginOtp(email, code, { isFirstUser = false } = {})
230
235
  throw new Error('Отправка почты (SMTP) ещё не настроена администратором в консоли /admin');
231
236
  }
232
237
 
238
+ const text = `Ваш одноразовый код для входа в личный кабинет НейроАльфа: ${code}\n\nКод действует 10 минут. Никому не сообщайте его.`;
233
239
  const html = `
234
240
  <div style="font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;background:#0b101d;color:#f8fafc;border-radius:14px;border:1px solid #1e293b">
235
241
  <div style="font-size:18px;font-weight:800;margin-bottom:12px;color:#60a5fa">◈ НейроАльфа</div>
236
242
  <p style="margin:0 0 14px;font-size:15px;color:#cbd5e1">Ваш одноразовый код для входа в личный кабинет:</p>
237
- <div style="background:#111827;border:1px solid rgba(96,165,250,0.4);border-radius:10px;padding:16px;text-align:center;font-size:30px;font-weight:800;letter-spacing:6px;color:#ffffff;font-family:monospace">${code}</div>
243
+ <div style="background:#111827;border:1px solid rgba(96,165,250,0.4);border-radius:10px;padding:16px;text-align:center;font-size:32px;font-weight:800;letter-spacing:8px;color:#ffffff;font-family:monospace">${code}</div>
238
244
  <p style="margin:14px 0 0;font-size:12.5px;color:#94a3b8">Код действует 10 минут. Никому не сообщайте его. Если вы не запрашивали вход, просто проигнорируйте это письмо.</p>
239
245
  </div>`;
240
246
 
241
247
  await sendEmail({
242
248
  to: email,
243
- subject: `${code} — код входа в НейроАльфа`,
249
+ subject: `${code} — код подтверждения НейроАльфа`,
250
+ text,
244
251
  html,
245
252
  });
246
253
  return { delivery: 'smtp' };