diff --git a/.env.example b/.env.example index d58c7a3..db033e9 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,10 @@ VAULT_TOKEN= VAULT_KV_MOUNT=secret VAULT_SHARED_PATH= VAULT_PROJECT_PATH= + +SMTP_HOST= +SMTP_PORT= +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +SMTP_FROM= diff --git a/package-lock.json b/package-lock.json index 82e1db1..e70aca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "graphql": "^16.13.2", + "nodemailer": "^8.0.4", "pg": "^8.20.0", "zod": "^4.3.6" }, @@ -2078,6 +2079,15 @@ "devOptional": true, "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": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", diff --git a/package.json b/package.json index 100da51..8b1d1e3 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "graphql": "^16.13.2", + "nodemailer": "^8.0.4", "pg": "^8.20.0", "zod": "^4.3.6" }, diff --git a/src/mailer.js b/src/mailer.js new file mode 100644 index 0000000..59b4e78 --- /dev/null +++ b/src/mailer.js @@ -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: `

Код входа: ${code}

Действует до: ${expiresText}

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