📧 feat: Mailgun API Email Configuration (#7742)

* fix: add undefined password check in local user authentication

* fix: edge case - issue deleting user when no conversations in deleteUserController

* feat: Integrate Mailgun API for email sending functionality

* fix: undefined SESSION_EXPIRY handling and add tests

* fix: update import path for isEnabled utility in azureUtils.js to resolve circular dep.
This commit is contained in:
Danny Avila 2025-06-04 13:12:37 -04:00 committed by GitHub
parent 6bb78247b3
commit be4cf5846c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 311 additions and 29 deletions

View file

@ -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 #
#========================#

View file

@ -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) {

View file

@ -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

View file

@ -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 = {

View file

@ -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<Object>} - 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<Object>} - 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;

View file

@ -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 });

View file

@ -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.

View file

@ -5,6 +5,7 @@ export default {
testResultsProcessor: 'jest-junit',
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/src/$1',
},
// coverageThreshold: {
// global: {
@ -16,4 +17,4 @@ export default {
// },
restoreMocks: true,
testTimeout: 15000,
};
};

View file

@ -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<typeof signPayload>;
let userMethods: ReturnType<typeof createUserMethods>;
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();
});
});
});

View file

@ -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: {