diff --git a/.env.example b/.env.example index f79b89a15..876535b34 100644 --- a/.env.example +++ b/.env.example @@ -515,6 +515,18 @@ EMAIL_PASSWORD= EMAIL_FROM_NAME= EMAIL_FROM=noreply@librechat.ai +#========================# +# Mailgun API # +#========================# + +# MAILGUN_API_KEY=your-mailgun-api-key +# MAILGUN_DOMAIN=mg.yourdomain.com +# EMAIL_FROM=noreply@yourdomain.com +# EMAIL_FROM_NAME="LibreChat" + +# # Optional: For EU region +# MAILGUN_HOST=https://api.eu.mailgun.net + #========================# # Firebase CDN # #========================# diff --git a/api/models/userMethods.js b/api/models/userMethods.js index e8bf5e478..a36409ebc 100644 --- a/api/models/userMethods.js +++ b/api/models/userMethods.js @@ -12,6 +12,10 @@ const comparePassword = async (user, candidatePassword) => { throw new Error('No user provided'); } + if (!user.password) { + throw new Error('No password, likely an email first registered via Social/OIDC login'); + } + return new Promise((resolve, reject) => { bcrypt.compare(candidatePassword, user.password, (err, isMatch) => { if (err) { diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index a2fbc3c48..4577d2015 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -163,7 +163,11 @@ const deleteUserController = async (req, res) => { await Balance.deleteMany({ user: user._id }); // delete user balances await deletePresets(user.id); // delete user presets /* TODO: Delete Assistant Threads */ - await deleteConvos(user.id); // delete user convos + try { + await deleteConvos(user.id); // delete user convos + } catch (error) { + logger.error('[deleteUserController] Error deleting user convos, likely no convos', error); + } await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth await deleteUserById(user.id); // delete user await deleteAllSharedLinks(user.id); // delete user shared links diff --git a/api/server/utils/index.js b/api/server/utils/index.js index b79b42f00..aa432ec37 100644 --- a/api/server/utils/index.js +++ b/api/server/utils/index.js @@ -13,12 +13,19 @@ const math = require('./math'); * @returns {Boolean} */ function checkEmailConfig() { - return ( + // Check if Mailgun is configured + const hasMailgunConfig = + !!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM; + + // Check if SMTP is configured + const hasSMTPConfig = (!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) && !!process.env.EMAIL_USERNAME && !!process.env.EMAIL_PASSWORD && - !!process.env.EMAIL_FROM - ); + !!process.env.EMAIL_FROM; + + // Return true if either Mailgun or SMTP is properly configured + return hasMailgunConfig || hasSMTPConfig; } module.exports = { diff --git a/api/server/utils/sendEmail.js b/api/server/utils/sendEmail.js index 59d75830f..42f99c78f 100644 --- a/api/server/utils/sendEmail.js +++ b/api/server/utils/sendEmail.js @@ -1,10 +1,69 @@ const fs = require('fs'); const path = require('path'); +const axios = require('axios'); +const FormData = require('form-data'); const nodemailer = require('nodemailer'); const handlebars = require('handlebars'); const { isEnabled } = require('~/server/utils/handleText'); +const { logAxiosError } = require('~/utils'); const logger = require('~/config/winston'); +/** + * Sends an email using Mailgun API. + * + * @async + * @function sendEmailViaMailgun + * @param {Object} params - The parameters for sending the email. + * @param {string} params.to - The recipient's email address. + * @param {string} params.from - The sender's email address. + * @param {string} params.subject - The subject of the email. + * @param {string} params.html - The HTML content of the email. + * @returns {Promise} - A promise that resolves to the response from Mailgun API. + */ +const sendEmailViaMailgun = async ({ to, from, subject, html }) => { + const mailgunApiKey = process.env.MAILGUN_API_KEY; + const mailgunDomain = process.env.MAILGUN_DOMAIN; + const mailgunHost = process.env.MAILGUN_HOST || 'https://api.mailgun.net'; + + if (!mailgunApiKey || !mailgunDomain) { + throw new Error('Mailgun API key and domain are required'); + } + + const formData = new FormData(); + formData.append('from', from); + formData.append('to', to); + formData.append('subject', subject); + formData.append('html', html); + + try { + const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, { + headers: { + ...formData.getHeaders(), + Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`, + }, + }); + + return response.data; + } catch (error) { + throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' })); + } +}; + +/** + * Sends an email using SMTP via Nodemailer. + * + * @async + * @function sendEmailViaSMTP + * @param {Object} params - The parameters for sending the email. + * @param {Object} params.transporterOptions - The transporter configuration options. + * @param {Object} params.mailOptions - The email options. + * @returns {Promise} - A promise that resolves to the info object of the sent email. + */ +const sendEmailViaSMTP = async ({ transporterOptions, mailOptions }) => { + const transporter = nodemailer.createTransport(transporterOptions); + return await transporter.sendMail(mailOptions); +}; + /** * Sends an email using the specified template, subject, and payload. * @@ -34,6 +93,30 @@ const logger = require('~/config/winston'); */ const sendEmail = async ({ email, subject, payload, template, throwError = true }) => { try { + // Read and compile the email template + const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8'); + const compiledTemplate = handlebars.compile(source); + const html = compiledTemplate(payload); + + // Prepare common email data + const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE; + const fromEmail = process.env.EMAIL_FROM; + const fromAddress = `"${fromName}" <${fromEmail}>`; + const toAddress = `"${payload.name}" <${email}>`; + + // Check if Mailgun is configured + if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) { + logger.debug('[sendEmail] Using Mailgun provider'); + return await sendEmailViaMailgun({ + from: fromAddress, + to: toAddress, + subject: subject, + html: html, + }); + } + + // Default to SMTP + logger.debug('[sendEmail] Using SMTP provider'); const transporterOptions = { // Use STARTTLS by default instead of obligatory TLS secure: process.env.EMAIL_ENCRYPTION === 'tls', @@ -62,30 +145,21 @@ const sendEmail = async ({ email, subject, payload, template, throwError = true transporterOptions.port = process.env.EMAIL_PORT ?? 25; } - const transporter = nodemailer.createTransport(transporterOptions); - - const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8'); - const compiledTemplate = handlebars.compile(source); - const options = () => { - return { - // Header address should contain name-addr - from: - `"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}"` + - `<${process.env.EMAIL_FROM}>`, - to: `"${payload.name}" <${email}>`, - envelope: { - // Envelope from should contain addr-spec - // Mistake in the Nodemailer documentation? - from: process.env.EMAIL_FROM, - to: email, - }, - subject: subject, - html: compiledTemplate(payload), - }; + const mailOptions = { + // Header address should contain name-addr + from: fromAddress, + to: toAddress, + envelope: { + // Envelope from should contain addr-spec + // Mistake in the Nodemailer documentation? + from: fromEmail, + to: email, + }, + subject: subject, + html: html, }; - // Send email - return await transporter.sendMail(options()); + return await sendEmailViaSMTP({ transporterOptions, mailOptions }); } catch (error) { if (throwError) { throw error; diff --git a/api/strategies/localStrategy.js b/api/strategies/localStrategy.js index edc749ee9..bc84e7c6b 100644 --- a/api/strategies/localStrategy.js +++ b/api/strategies/localStrategy.js @@ -29,6 +29,12 @@ async function passportLogin(req, email, password, done) { return done(null, false, { message: 'Email does not exist.' }); } + if (!user.password) { + logError('Passport Local Strategy - User has no password', { email }); + logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`); + return done(null, false, { message: 'Email does not exist.' }); + } + const isMatch = await comparePassword(user, password); if (!isMatch) { logError('Passport Local Strategy - Password does not match', { isMatch }); diff --git a/api/utils/azureUtils.js b/api/utils/azureUtils.js index 27396a8fc..7adb13344 100644 --- a/api/utils/azureUtils.js +++ b/api/utils/azureUtils.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('~/server/utils'); +const { isEnabled } = require('~/server/utils/handleText'); /** * Sanitizes the model name to be used in the URL by removing or replacing disallowed characters. diff --git a/packages/data-schemas/jest.config.mjs b/packages/data-schemas/jest.config.mjs index f5fb1f20d..b1fae4370 100644 --- a/packages/data-schemas/jest.config.mjs +++ b/packages/data-schemas/jest.config.mjs @@ -5,6 +5,7 @@ export default { testResultsProcessor: 'jest-junit', moduleNameMapper: { '^@src/(.*)$': '/src/$1', + '^~/(.*)$': '/src/$1', }, // coverageThreshold: { // global: { @@ -16,4 +17,4 @@ export default { // }, restoreMocks: true, testTimeout: 15000, -}; \ No newline at end of file +}; diff --git a/packages/data-schemas/src/methods/user.test.ts b/packages/data-schemas/src/methods/user.test.ts new file mode 100644 index 000000000..6dafd4e8f --- /dev/null +++ b/packages/data-schemas/src/methods/user.test.ts @@ -0,0 +1,163 @@ +import mongoose from 'mongoose'; +import { createUserMethods } from './user'; +import { signPayload } from '~/crypto'; +import type { IUser } from '~/types'; + +jest.mock('~/crypto', () => ({ + signPayload: jest.fn(), +})); + +describe('User Methods', () => { + const mockSignPayload = signPayload as jest.MockedFunction; + let userMethods: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + userMethods = createUserMethods(mongoose); + }); + + describe('generateToken', () => { + const mockUser = { + _id: 'user123', + username: 'testuser', + provider: 'local', + email: 'test@example.com', + name: 'Test User', + avatar: '', + role: 'user', + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + } as IUser; + + afterEach(() => { + delete process.env.SESSION_EXPIRY; + delete process.env.JWT_SECRET; + }); + + it('should default to 15 minutes when SESSION_EXPIRY is not set', async () => { + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + await userMethods.generateToken(mockUser); + + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 900, // 15 minutes in seconds + }); + }); + + it('should default to 15 minutes when SESSION_EXPIRY is empty string', async () => { + process.env.SESSION_EXPIRY = ''; + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + await userMethods.generateToken(mockUser); + + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 900, // 15 minutes in seconds + }); + }); + + it('should use custom expiry when SESSION_EXPIRY is set to a valid expression', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 30'; // 30 minutes + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + await userMethods.generateToken(mockUser); + + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 1800, // 30 minutes in seconds + }); + }); + + it('should default to 15 minutes when SESSION_EXPIRY evaluates to falsy value', async () => { + process.env.SESSION_EXPIRY = '0'; // This will evaluate to 0, which is falsy + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + await userMethods.generateToken(mockUser); + + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 900, // 15 minutes in seconds + }); + }); + + it('should throw error when no user is provided', async () => { + process.env.JWT_SECRET = 'test-secret'; + + await expect(userMethods.generateToken(null as unknown as IUser)).rejects.toThrow( + 'No user provided', + ); + }); + + it('should return the token from signPayload', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 60'; // 1 hour + process.env.JWT_SECRET = 'test-secret'; + const expectedToken = 'generated-jwt-token'; + mockSignPayload.mockResolvedValue(expectedToken); + + const token = await userMethods.generateToken(mockUser); + + expect(token).toBe(expectedToken); + }); + + it('should handle invalid SESSION_EXPIRY expressions gracefully', async () => { + process.env.SESSION_EXPIRY = 'invalid expression'; + process.env.JWT_SECRET = 'test-secret'; + mockSignPayload.mockResolvedValue('mocked-token'); + + // Mock console.warn to verify it's called + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + await userMethods.generateToken(mockUser); + + // Should use default value when eval fails + expect(mockSignPayload).toHaveBeenCalledWith({ + payload: { + id: mockUser._id, + username: mockUser.username, + provider: mockUser.provider, + email: mockUser.email, + }, + secret: 'test-secret', + expirationTime: 900, // 15 minutes in seconds (default) + }); + + // Verify warning was logged + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Invalid SESSION_EXPIRY expression, using default:', + expect.any(SyntaxError), + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index 5c7b2e40d..b3460faa7 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -145,7 +145,18 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { throw new Error('No user provided'); } - const expires = eval(process.env.SESSION_EXPIRY ?? '0') ?? 1000 * 60 * 15; + let expires = 1000 * 60 * 15; + + if (process.env.SESSION_EXPIRY !== undefined && process.env.SESSION_EXPIRY !== '') { + try { + const evaluated = eval(process.env.SESSION_EXPIRY); + if (evaluated) { + expires = evaluated; + } + } catch (error) { + console.warn('Invalid SESSION_EXPIRY expression, using default:', error); + } + } return await signPayload({ payload: {