GWM : forgot pw , change pw , profile update api's

This commit is contained in:
Gowtham M 2026-07-14 18:15:39 +05:30
parent 9526870bd8
commit dae02a30fd
13 changed files with 650 additions and 51 deletions

View File

@ -41,6 +41,10 @@ MAX_FILE_SIZE_MB=5
MAX_LOGIN_ATTEMPTS=5
LOCKOUT_DURATION_MINUTES=30
# Frontend URL used in password-reset emails
FRONTEND_URL=http://localhost:8080
PASSWORD_RESET_EXPIRY_MINUTES=60
# Bitbucket deploy webhook (POST /deploy?apikey=...)
BITBUCKET_API_KEY=
EXPECTED_REPO=your-org/your-repo

View File

@ -61,7 +61,7 @@ Build in this order. Each module: `*.routes.js` → `*.controller.js` → `*.ser
| # | Module | Status | Notes |
|---|--------|--------|-------|
| 8 | **auth** | [x] Done | login, refresh, logout, me; rotating hashed refresh tokens; account lockout |
| 8 | **auth** | [x] Done | login, refresh, logout, me, profile, avatar, change/forgot/reset password |
| 9 | **masters** | [x] Done | 15 sub-masters under `masters/index.js` (UOM template replicated) |
| — | **users** | [x] Done | CRUD + summary/filters/export; RBAC: `USERS` |
| — | **roles** | [x] Done | CRUD + permission assignment + matrix; RBAC: `ROLES` |
@ -118,8 +118,16 @@ Base path: `/api/v1` · Auth: `Authorization: Bearer <accessToken>` (except publ
| [x] | POST | `/auth/login` | Public | Returns `accessToken`, `refreshToken` |
| [x] | POST | `/auth/refresh` | Public | Rotating refresh token |
| [x] | POST | `/auth/logout` | Public | Revoke refresh token |
| [ ] | POST | `/auth/forgot-password` | Public | Not implemented |
| [x] | POST | `/auth/forgot-password` | Public | Sends reset link via SMTP (`email_settings`) |
| [x] | POST | `/auth/reset-password` | Public | Reset password with token from email |
| [x] | GET | `/auth/me` | Authenticated | Current user + permissions (for FE) |
| [x] | PUT | `/auth/profile` | Authenticated | Update `full_name` / `email` / `mobile` |
| [x] | POST | `/auth/profile/avatar` | Authenticated | Upload avatar (`multipart`, field `avatar`) |
| [x] | POST | `/auth/change-password` | Authenticated | Change password (revokes refresh tokens) |
**DB patch:** `scripts/patch-users-avatar.sql` — adds `users.avatar_path`.
**Env:** `FRONTEND_URL` (reset link base), `PASSWORD_RESET_EXPIRY_MINUTES` (default 60).
---
@ -358,7 +366,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| Module | Endpoints done | Endpoints total | Status |
|--------|----------------|-----------------|--------|
| System | 4 | 4 | [x] Done |
| Auth | 3 | 5 | [ ] Partial |
| Auth | 9 | 9 | [x] Done |
| Users | 8 | 8 | [x] Done |
| Roles | 10 | 10 | [x] Done |
| Masters | 70 | 70 | [x] Done |

View File

@ -44,7 +44,8 @@
"swagger-ui-express": "^5.0.1",
"uuid": "^9.0.1",
"winston": "^3.18.3",
"winston-daily-rotate-file": "^5.0.0"
"winston-daily-rotate-file": "^5.0.0",
"nodemailer": "^6.10.1"
},
"devDependencies": {
"eslint": "^8.57.1",

View File

@ -802,6 +802,7 @@ model users {
mobile String? @db.VarChar(255)
mobile_index String? @db.VarChar(64)
password_hash String @db.VarChar(255)
avatar_path String? @db.VarChar(500)
department_id BigInt?
designation_id BigInt?
plant_id BigInt?

View File

@ -0,0 +1,4 @@
-- Add user profile avatar path
ALTER TABLE users
ADD COLUMN IF NOT EXISTS avatar_path VARCHAR(500);

View File

@ -36,6 +36,9 @@ const envSchema = Joi.object({
MAX_LOGIN_ATTEMPTS: Joi.number().default(5),
LOCKOUT_DURATION_MINUTES: Joi.number().default(30),
FRONTEND_URL: Joi.string().uri({ allowRelative: false }).default('http://localhost:8080'),
PASSWORD_RESET_EXPIRY_MINUTES: Joi.number().integer().min(5).default(60),
BITBUCKET_API_KEY: Joi.string().allow('').optional(),
EXPECTED_REPO: Joi.string().allow('').optional(),
EXPECTED_BRANCH: Joi.string().allow('').optional(),

View File

@ -39,6 +39,33 @@ components:
required: [refresh_token]
properties:
refresh_token: { type: string, example: "<refresh-token>" }
AuthUpdateProfileBody:
type: object
minProperties: 1
properties:
full_name: { type: string, example: 'suren' }
name: { type: string, example: 'suren', description: 'Alias for full_name' }
email: { type: string, format: email, example: 'surendar.m@venbaitinfotech.com' }
mobile: { type: string, example: '9874563698' }
AuthChangePasswordBody:
type: object
required: [current_password, new_password, confirm_password]
properties:
current_password: { type: string, example: 'OldPass@123' }
new_password: { type: string, example: 'NewPass@123' }
confirm_password: { type: string, example: 'NewPass@123' }
AuthForgotPasswordBody:
type: object
required: [email]
properties:
email: { type: string, format: email, example: 'surendar.m@venbaitinfotech.com' }
AuthResetPasswordBody:
type: object
required: [token, new_password, confirm_password]
properties:
token: { type: string, example: 'raw-token-from-email-link' }
new_password: { type: string, example: 'NewPass@123' }
confirm_password: { type: string, example: 'NewPass@123' }
AuthMeData:
type: object
properties:
@ -47,6 +74,8 @@ components:
full_name: { type: string, example: Super Admin }
email: { type: string, format: email, example: "admin@bharaterp.com" }
mobile: { type: string, nullable: true, example: "9876543210" }
avatar_path: { type: string, nullable: true }
avatar_url: { type: string, nullable: true, example: '/uploads/avatars/abc.png' }
status: { type: string, example: active }
is_active: { type: boolean, example: true }
last_login_at: { type: string, format: date-time, nullable: true }
@ -575,6 +604,87 @@ paths:
properties:
data: { $ref: "#/components/schemas/AuthMeData" }
"401": { description: Unauthorized }
/auth/profile:
put:
tags: [Auth]
summary: Update current user profile
description: Updates name, email, and/or mobile. Department and role are read-only.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AuthUpdateProfileBody" }
responses:
"200":
description: Profile updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"409": { description: Email already in use }
/auth/profile/avatar:
post:
tags: [Auth]
summary: Upload profile avatar
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [avatar]
properties:
avatar:
type: string
format: binary
description: JPEG, PNG, or WebP image
responses:
"200":
description: Avatar updated
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
/auth/change-password:
post:
tags: [Auth]
summary: Change password for current user
description: Validates current password, then updates password and revokes all refresh tokens.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AuthChangePasswordBody" }
responses:
"200":
description: Password changed
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"400": { description: Current password incorrect or passwords mismatch }
/auth/forgot-password:
post:
tags: [Auth]
summary: Request password reset email
description: Uses SMTP from Settings → Email. Always returns a generic success message. FE should open `/reset-password?token=...` from the email link.
security: []
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AuthForgotPasswordBody" }
responses:
"200":
description: If account exists, reset email was sent
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"503": { description: Email settings not configured }
/auth/reset-password:
post:
tags: [Auth]
summary: Reset password using email token
security: []
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AuthResetPasswordBody" }
responses:
"200":
description: Password reset
content: { application/json: { schema: { $ref: "#/components/schemas/ApiResponse" } } }
"400": { description: Invalid or expired token }
/masters/uom:
get:
tags: [UOM]

View File

@ -22,4 +22,39 @@ const me = asyncHandler(async (req, res) => {
res.json(new ApiResponse(200, data, 'Current user fetched'));
});
module.exports = { login, refresh, logout, me };
const updateProfile = asyncHandler(async (req, res) => {
const data = await authService.updateProfile(req.user.id, req.body, req.id);
res.json(new ApiResponse(200, data, 'Profile updated successfully'));
});
const uploadAvatar = asyncHandler(async (req, res) => {
const data = await authService.updateAvatar(req.user.id, req.file, req.id);
res.json(new ApiResponse(200, data, 'Avatar updated successfully'));
});
const changePassword = asyncHandler(async (req, res) => {
await authService.changePassword(req.user.id, req.body, req.id);
res.json(new ApiResponse(200, null, 'Password changed successfully. Please login again.'));
});
const forgotPassword = asyncHandler(async (req, res) => {
const result = await authService.forgotPassword(req.body.email);
res.json(new ApiResponse(200, null, result.message));
});
const resetPassword = asyncHandler(async (req, res) => {
await authService.resetPassword(req.body);
res.json(new ApiResponse(200, null, 'Password reset successfully. Please login.'));
});
module.exports = {
login,
refresh,
logout,
me,
updateProfile,
uploadAvatar,
changePassword,
forgotPassword,
resetPassword,
};

View File

@ -2,7 +2,15 @@ const express = require('express');
const authenticate = require('../../middlewares/auth.middleware');
const validate = require('../../middlewares/validate.middleware');
const { authLimiter } = require('../../middlewares/rateLimiter.middleware');
const { loginSchema, refreshSchema } = require('./auth.validation');
const {
loginSchema,
refreshSchema,
updateProfileSchema,
changePasswordSchema,
forgotPasswordSchema,
resetPasswordSchema,
} = require('./auth.validation');
const { avatarUpload } = require('./auth.upload.middleware');
const controller = require('./auth.controller');
const router = express.Router();
@ -10,6 +18,27 @@ const router = express.Router();
router.post('/login', authLimiter, validate(loginSchema), controller.login);
router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(refreshSchema), controller.logout);
router.post(
'/forgot-password',
authLimiter,
validate(forgotPasswordSchema),
controller.forgotPassword
);
router.post('/reset-password', authLimiter, validate(resetPasswordSchema), controller.resetPassword);
router.get('/me', authenticate, controller.me);
router.put('/profile', authenticate, validate(updateProfileSchema), controller.updateProfile);
router.post(
'/profile/avatar',
authenticate,
avatarUpload.single('avatar'),
controller.uploadAvatar
);
router.post(
'/change-password',
authenticate,
validate(changePasswordSchema),
controller.changePassword
);
module.exports = router;

View File

@ -1,14 +1,92 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const prisma = require('../../config/prisma');
const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const env = require('../../config/env');
const { decrypt } = require('../../utils/encryption');
const { encrypt, decrypt, blindIndex } = require('../../utils/encryption');
const { collectUserPermissions, extractUserRoles } = require('../../utils/userPermissions');
const { sendMail } = require('../../utils/email');
const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
const buildPublicUrl = (filePath) => {
if (!filePath) return null;
const normalized = filePath.replace(/\\/g, '/');
return normalized.startsWith('/') ? normalized : `/${normalized}`;
};
const unlinkIfExists = (relativePath) => {
if (!relativePath) return;
const absolute = path.resolve(process.cwd(), relativePath);
if (fs.existsSync(absolute)) {
fs.unlinkSync(absolute);
}
};
const profileInclude = {
user_roles: {
include: {
roles: {
include: {
role_permissions: {
include: {
permissions: { include: { modules: true } },
},
},
},
},
},
},
departments: { select: { id: true, name: true } },
designations: { select: { id: true, name: true } },
plant_location: { select: { id: true, code: true, name: true } },
};
const getActiveUserOrThrow = async (userId) => {
const user = await prisma.users.findFirst({
where: { id: BigInt(userId), deleted_at: null },
include: profileInclude,
});
if (!user || !user.is_active || user.status !== 'active') {
throw new ApiError(401, 'Invalid or inactive user');
}
return user;
};
const sanitizeProfile = (user) => {
const roles = extractUserRoles(user).map((role) => ({
id: role.id,
name: role.name,
description: role.description,
}));
const permissions = collectUserPermissions(user);
return {
id: user.id,
employee_code: user.employee_code,
full_name: user.full_name,
email: user.email,
mobile: user.mobile ? decrypt(user.mobile) : null,
avatar_path: user.avatar_path || null,
avatar_url: buildPublicUrl(user.avatar_path),
status: user.status,
is_active: user.is_active,
last_login_at: user.last_login_at,
roles,
role: roles[0] || null,
department: user.departments,
designation: user.designations,
plant: user.plant_location,
permissions,
};
};
const issueTokens = async (user) => {
const accessToken = jwt.sign({ sub: user.id.toString() }, env.JWT_ACCESS_SECRET, {
expiresIn: env.JWT_ACCESS_EXPIRY,
@ -91,56 +169,278 @@ const logout = async (refreshToken) => {
});
};
const getMe = async (userId) => {
const user = await prisma.users.findFirst({
where: { id: BigInt(userId), deleted_at: null },
include: {
user_roles: {
include: {
roles: {
include: {
role_permissions: {
include: {
permissions: { include: { modules: true } },
},
},
},
},
const getMe = async (userId) => sanitizeProfile(await getActiveUserOrThrow(userId));
const updateProfile = async (userId, payload, requestId) => {
const existing = await getActiveUserOrThrow(userId);
const fullName = payload.full_name ?? payload.name;
const data = {
...(fullName !== undefined ? { full_name: fullName } : {}),
updated_by: BigInt(userId),
};
if (payload.email !== undefined) {
const email = String(payload.email).trim().toLowerCase();
if (email !== existing.email.toLowerCase()) {
const duplicate = await prisma.users.findFirst({
where: {
email: { equals: email, mode: 'insensitive' },
deleted_at: null,
NOT: { id: existing.id },
},
},
departments: { select: { id: true, name: true } },
designations: { select: { id: true, name: true } },
plant_location: { select: { id: true, code: true, name: true } },
},
});
if (duplicate) throw new ApiError(409, 'Email is already in use by another user');
}
data.email = email;
}
if (payload.mobile !== undefined) {
const mobile = payload.mobile || null;
if (mobile) {
data.mobile = encrypt(mobile);
data.mobile_index = blindIndex(mobile);
} else {
data.mobile = null;
data.mobile_index = null;
}
}
const updated = await prisma.users.update({
where: { id: existing.id },
data,
include: profileInclude,
});
await auditLog({
tableName: 'users',
recordId: existing.id,
action: 'UPDATE',
oldValue: {
full_name: existing.full_name,
email: existing.email,
mobile: existing.mobile ? decrypt(existing.mobile) : null,
},
newValue: {
full_name: updated.full_name,
email: updated.email,
mobile: updated.mobile ? decrypt(updated.mobile) : null,
},
userId,
requestId,
});
return sanitizeProfile(updated);
};
const updateAvatar = async (userId, file, requestId) => {
if (!file) throw new ApiError(400, 'Avatar file is required');
const existing = await getActiveUserOrThrow(userId);
unlinkIfExists(existing.avatar_path);
const avatarPath = path.join(env.UPLOAD_DIR, 'avatars', file.filename).replace(/\\/g, '/');
const updated = await prisma.users.update({
where: { id: existing.id },
data: {
avatar_path: avatarPath,
updated_by: BigInt(userId),
},
include: profileInclude,
});
await auditLog({
tableName: 'users',
recordId: existing.id,
action: 'UPDATE',
oldValue: { avatar_path: existing.avatar_path },
newValue: { avatar_path: updated.avatar_path },
userId,
requestId,
});
return sanitizeProfile(updated);
};
const changePassword = async (userId, payload, requestId) => {
const user = await prisma.users.findFirst({
where: { id: BigInt(userId), deleted_at: null },
});
if (!user || !user.is_active || user.status !== 'active') {
throw new ApiError(401, 'Invalid or inactive user');
}
const roles = extractUserRoles(user).map((role) => ({
id: role.id,
name: role.name,
description: role.description,
}));
const permissions = collectUserPermissions(user);
const match = await bcrypt.compare(payload.current_password, user.password_hash);
if (!match) throw new ApiError(400, 'Current password is incorrect');
return {
id: user.id,
employee_code: user.employee_code,
full_name: user.full_name,
email: user.email,
mobile: user.mobile ? decrypt(user.mobile) : null,
status: user.status,
is_active: user.is_active,
last_login_at: user.last_login_at,
roles,
role: roles[0] || null,
department: user.departments,
designation: user.designations,
plant: user.plant_location,
permissions,
};
if (payload.current_password === payload.new_password) {
throw new ApiError(400, 'New password must be different from current password');
}
const passwordHash = await bcrypt.hash(payload.new_password, env.BCRYPT_SALT_ROUNDS);
await prisma.$transaction(async (tx) => {
await tx.users.update({
where: { id: user.id },
data: {
password_hash: passwordHash,
failed_login_attempts: 0,
locked_until: null,
updated_by: BigInt(userId),
},
});
await tx.refresh_tokens.updateMany({
where: { user_id: user.id, revoked_at: null },
data: { revoked_at: new Date() },
});
});
await auditLog({
tableName: 'users',
recordId: user.id,
action: 'UPDATE',
oldValue: { password_changed: false },
newValue: { password_changed: true },
userId,
requestId,
});
};
module.exports = { login, refresh, logout, getMe };
const forgotPassword = async (email) => {
const genericMessage =
'If an account exists for this email, a password reset link has been sent';
const user = await prisma.users.findFirst({
where: {
email: { equals: String(email).trim(), mode: 'insensitive' },
deleted_at: null,
is_active: true,
status: 'active',
},
});
if (!user) {
return { message: genericMessage };
}
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(rawToken);
const expiresAt = new Date(Date.now() + env.PASSWORD_RESET_EXPIRY_MINUTES * 60 * 1000);
await prisma.$transaction(async (tx) => {
await tx.password_reset_tokens.updateMany({
where: { user_id: user.id, used_at: null },
data: { used_at: new Date() },
});
await tx.password_reset_tokens.create({
data: {
user_id: user.id,
token_hash: tokenHash,
expires_at: expiresAt,
},
});
});
const resetUrl = `${env.FRONTEND_URL.replace(/\/$/, '')}/reset-password?token=${rawToken}`;
const subject = 'Reset your ERP password';
const text = [
`Hello ${user.full_name},`,
'',
'We received a request to reset your password.',
`Open this link to set a new password (valid for ${env.PASSWORD_RESET_EXPIRY_MINUTES} minutes):`,
resetUrl,
'',
'If you did not request this, you can ignore this email.',
].join('\n');
const html = `
<p>Hello ${user.full_name},</p>
<p>We received a request to reset your password.</p>
<p><a href="${resetUrl}">Click here to reset your password</a></p>
<p>This link expires in ${env.PASSWORD_RESET_EXPIRY_MINUTES} minutes.</p>
<p>If you did not request this, you can ignore this email.</p>
`;
await sendMail({
to: user.email,
subject,
text,
html,
});
return { message: genericMessage };
};
const resetPassword = async (payload) => {
const tokenHash = hashToken(payload.token);
const record = await prisma.password_reset_tokens.findFirst({
where: {
token_hash: tokenHash,
used_at: null,
expires_at: { gt: new Date() },
},
include: { users: true },
});
if (!record || !record.users || record.users.deleted_at) {
throw new ApiError(400, 'Invalid or expired password reset token');
}
const user = record.users;
if (!user.is_active || user.status !== 'active') {
throw new ApiError(403, 'Account is inactive. Contact administrator.');
}
const passwordHash = await bcrypt.hash(payload.new_password, env.BCRYPT_SALT_ROUNDS);
await prisma.$transaction(async (tx) => {
await tx.users.update({
where: { id: user.id },
data: {
password_hash: passwordHash,
failed_login_attempts: 0,
locked_until: null,
updated_by: user.id,
},
});
await tx.password_reset_tokens.update({
where: { id: record.id },
data: { used_at: new Date() },
});
await tx.password_reset_tokens.updateMany({
where: { user_id: user.id, used_at: null },
data: { used_at: new Date() },
});
await tx.refresh_tokens.updateMany({
where: { user_id: user.id, revoked_at: null },
data: { revoked_at: new Date() },
});
});
await auditLog({
tableName: 'users',
recordId: user.id,
action: 'UPDATE',
oldValue: { password_reset: false },
newValue: { password_reset: true },
userId: user.id,
requestId: null,
});
};
module.exports = {
login,
refresh,
logout,
getMe,
updateProfile,
updateAvatar,
changePassword,
forgotPassword,
resetPassword,
};

View File

@ -0,0 +1,31 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const ApiError = require('../../utils/ApiError');
const env = require('../../config/env');
const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp'];
const avatarUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
const dir = path.join(env.UPLOAD_DIR, 'avatars');
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const unique = crypto.randomBytes(16).toString('hex');
cb(null, `${unique}${path.extname(file.originalname).toLowerCase()}`);
},
}),
fileFilter: (_req, file, cb) => {
if (!ALLOWED_MIME.includes(file.mimetype)) {
return cb(new ApiError(400, `Unsupported file type: ${file.mimetype}`), false);
}
return cb(null, true);
},
limits: { fileSize: env.MAX_FILE_SIZE_MB * 1024 * 1024 },
});
module.exports = { avatarUpload };

View File

@ -9,4 +9,42 @@ const refreshSchema = Joi.object({
refresh_token: Joi.string().required(),
});
module.exports = { loginSchema, refreshSchema };
const updateProfileSchema = Joi.object({
full_name: Joi.string().max(200).optional(),
name: Joi.string().max(200).optional(),
email: Joi.string().email().max(200).optional(),
mobile: Joi.string().max(20).allow(null, '').optional(),
})
.or('full_name', 'name', 'email', 'mobile')
.messages({
'object.missing': 'At least one of full_name, email, or mobile is required',
});
const changePasswordSchema = Joi.object({
current_password: Joi.string().min(8).required(),
new_password: Joi.string().min(8).required(),
confirm_password: Joi.string().valid(Joi.ref('new_password')).required().messages({
'any.only': 'confirm_password must match new_password',
}),
});
const forgotPasswordSchema = Joi.object({
email: Joi.string().email().required(),
});
const resetPasswordSchema = Joi.object({
token: Joi.string().required(),
new_password: Joi.string().min(8).required(),
confirm_password: Joi.string().valid(Joi.ref('new_password')).required().messages({
'any.only': 'confirm_password must match new_password',
}),
});
module.exports = {
loginSchema,
refreshSchema,
updateProfileSchema,
changePasswordSchema,
forgotPasswordSchema,
resetPasswordSchema,
};

35
src/utils/email.js Normal file
View File

@ -0,0 +1,35 @@
const nodemailer = require('nodemailer');
const ApiError = require('./ApiError');
const { getSmtpConfig } = require('../modules/settings/settings.service');
const sendMail = async ({ to, subject, text, html }) => {
const smtp = await getSmtpConfig();
if (!smtp?.host) {
throw new ApiError(503, 'Email service is not configured. Update Settings → Email.');
}
if (!smtp.fromEmail) {
throw new ApiError(503, 'Sender email is not configured in email settings');
}
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port || 587,
secure: Number(smtp.port) === 465,
auth: smtp.username
? {
user: smtp.username,
pass: smtp.password || '',
}
: undefined,
});
await transporter.sendMail({
from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromEmail}>` : smtp.fromEmail,
to,
subject,
text,
html,
});
};
module.exports = { sendMail };