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 +3 -2
- package/public/admin.html +15 -1
- package/public/landing.html +11 -1
- package/server/auth.js +2 -0
- package/server/smtp.js +151 -144
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha-portal",
|
|
3
|
-
"version": "0.7.
|
|
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">Поддерживаются Яндекс
|
|
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;
|
package/public/landing.html
CHANGED
|
@@ -438,7 +438,17 @@
|
|
|
438
438
|
$('#form-verify-code').hidden = true;
|
|
439
439
|
$('#auth-err-1').textContent = '';
|
|
440
440
|
$('#auth-err-2').textContent = '';
|
|
441
|
-
|
|
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
|
-
//
|
|
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
|
|
12
|
-
const
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
116
|
+
// Основной транспорт — nodemailer (RFC 5322 multipart text+html для мгновенной доставки в Gmail / Яндекс / Mail.ru)
|
|
176
117
|
try {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
213
|
+
b64(html),
|
|
214
|
+
`--${boundary}--`,
|
|
209
215
|
'.',
|
|
210
216
|
].join('\r\n');
|
|
211
|
-
|
|
212
|
-
await
|
|
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
|
-
|
|
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:
|
|
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' };
|