Send login codes via SMTP using Mailpit-compatible transport

This commit is contained in:
Ruslan Bakiev
2026-04-02 14:58:00 +07:00
parent dd92172cac
commit 5ba87a1242
5 changed files with 93 additions and 1 deletions

View File

@@ -7,3 +7,10 @@ VAULT_TOKEN=
VAULT_KV_MOUNT=secret VAULT_KV_MOUNT=secret
VAULT_SHARED_PATH= VAULT_SHARED_PATH=
VAULT_PROJECT_PATH= VAULT_PROJECT_PATH=
SMTP_HOST=
SMTP_PORT=
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=

10
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"graphql": "^16.13.2", "graphql": "^16.13.2",
"nodemailer": "^8.0.4",
"pg": "^8.20.0", "pg": "^8.20.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -2078,6 +2079,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.5", "version": "0.6.5",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",

View File

@@ -27,6 +27,7 @@
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"graphql": "^16.13.2", "graphql": "^16.13.2",
"nodemailer": "^8.0.4",
"pg": "^8.20.0", "pg": "^8.20.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },

69
src/mailer.js Normal file
View File

@@ -0,0 +1,69 @@
import nodemailer from 'nodemailer';
let cachedTransporter = null;
function getSmtpConfig() {
const host = process.env.SMTP_HOST;
const portRaw = process.env.SMTP_PORT;
const from = process.env.SMTP_FROM;
const secureRaw = process.env.SMTP_SECURE ?? 'false';
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host) {
throw new Error('SMTP_HOST is required for email login code delivery.');
}
if (!portRaw) {
throw new Error('SMTP_PORT is required for email login code delivery.');
}
if (!from) {
throw new Error('SMTP_FROM is required for email login code delivery.');
}
const port = Number(portRaw);
if (!Number.isInteger(port) || port <= 0) {
throw new Error('SMTP_PORT must be a valid integer port number.');
}
const secure = String(secureRaw).toLowerCase() === 'true';
const hasUser = Boolean(user);
const hasPass = Boolean(pass);
if (hasUser !== hasPass) {
throw new Error('SMTP_USER and SMTP_PASS must be set together.');
}
return {
from,
transport: {
host,
port,
secure,
...(hasUser && hasPass ? { auth: { user, pass } } : {}),
},
};
}
function getTransporter() {
if (cachedTransporter) {
return cachedTransporter;
}
const config = getSmtpConfig();
cachedTransporter = nodemailer.createTransport(config.transport);
return cachedTransporter;
}
export async function sendLoginCodeEmail({ to, code, expiresAt }) {
const { from } = getSmtpConfig();
const transporter = getTransporter();
const expiresText = new Date(expiresAt).toLocaleString('ru-RU', { hour12: false });
await transporter.sendMail({
from,
to,
subject: 'Код входа в личный кабинет Fregat',
text: `Код входа: ${code}\nДействует до: ${expiresText}`,
html: `<p>Код входа: <b>${code}</b></p><p>Действует до: ${expiresText}</p>`,
});
}

View File

@@ -8,6 +8,7 @@ import {
maskAuthDestination, maskAuthDestination,
verifyLoginChallengeCode, verifyLoginChallengeCode,
} from './auth.js'; } from './auth.js';
import { sendLoginCodeEmail } from './mailer.js';
import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js'; import { dispatchToUserConnections, sendMessengerMessage } from './messenger.js';
import { dateTimeScalar, jsonScalar } from './scalars.js'; import { dateTimeScalar, jsonScalar } from './scalars.js';
@@ -287,7 +288,11 @@ export const resolvers = {
}); });
const code = getStaticAuthCode(); const code = getStaticAuthCode();
console.info(`[auth] login code for ${destination}: ${code}`); await sendLoginCodeEmail({
to: destination,
code,
expiresAt: challenge.expiresAt,
});
return { return {
challengeToken: challenge.challengeToken, challengeToken: challenge.challengeToken,