mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
📧 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:
parent
6bb78247b3
commit
be4cf5846c
10 changed files with 311 additions and 29 deletions
12
.env.example
12
.env.example
|
@ -515,6 +515,18 @@ EMAIL_PASSWORD=
|
||||||
EMAIL_FROM_NAME=
|
EMAIL_FROM_NAME=
|
||||||
EMAIL_FROM=noreply@librechat.ai
|
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 #
|
# Firebase CDN #
|
||||||
#========================#
|
#========================#
|
||||||
|
|
|
@ -12,6 +12,10 @@ const comparePassword = async (user, candidatePassword) => {
|
||||||
throw new Error('No user provided');
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
|
bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
|
@ -163,7 +163,11 @@ const deleteUserController = async (req, res) => {
|
||||||
await Balance.deleteMany({ user: user._id }); // delete user balances
|
await Balance.deleteMany({ user: user._id }); // delete user balances
|
||||||
await deletePresets(user.id); // delete user presets
|
await deletePresets(user.id); // delete user presets
|
||||||
/* TODO: Delete Assistant Threads */
|
/* 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 deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
|
||||||
await deleteUserById(user.id); // delete user
|
await deleteUserById(user.id); // delete user
|
||||||
await deleteAllSharedLinks(user.id); // delete user shared links
|
await deleteAllSharedLinks(user.id); // delete user shared links
|
||||||
|
|
|
@ -13,12 +13,19 @@ const math = require('./math');
|
||||||
* @returns {Boolean}
|
* @returns {Boolean}
|
||||||
*/
|
*/
|
||||||
function checkEmailConfig() {
|
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_SERVICE || !!process.env.EMAIL_HOST) &&
|
||||||
!!process.env.EMAIL_USERNAME &&
|
!!process.env.EMAIL_USERNAME &&
|
||||||
!!process.env.EMAIL_PASSWORD &&
|
!!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 = {
|
module.exports = {
|
||||||
|
|
|
@ -1,10 +1,69 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const axios = require('axios');
|
||||||
|
const FormData = require('form-data');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const handlebars = require('handlebars');
|
const handlebars = require('handlebars');
|
||||||
const { isEnabled } = require('~/server/utils/handleText');
|
const { isEnabled } = require('~/server/utils/handleText');
|
||||||
|
const { logAxiosError } = require('~/utils');
|
||||||
const logger = require('~/config/winston');
|
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.
|
* 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 }) => {
|
const sendEmail = async ({ email, subject, payload, template, throwError = true }) => {
|
||||||
try {
|
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 = {
|
const transporterOptions = {
|
||||||
// Use STARTTLS by default instead of obligatory TLS
|
// Use STARTTLS by default instead of obligatory TLS
|
||||||
secure: process.env.EMAIL_ENCRYPTION === '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;
|
transporterOptions.port = process.env.EMAIL_PORT ?? 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport(transporterOptions);
|
const mailOptions = {
|
||||||
|
// Header address should contain name-addr
|
||||||
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
|
from: fromAddress,
|
||||||
const compiledTemplate = handlebars.compile(source);
|
to: toAddress,
|
||||||
const options = () => {
|
envelope: {
|
||||||
return {
|
// Envelope from should contain addr-spec
|
||||||
// Header address should contain name-addr
|
// Mistake in the Nodemailer documentation?
|
||||||
from:
|
from: fromEmail,
|
||||||
`"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}"` +
|
to: email,
|
||||||
`<${process.env.EMAIL_FROM}>`,
|
},
|
||||||
to: `"${payload.name}" <${email}>`,
|
subject: subject,
|
||||||
envelope: {
|
html: html,
|
||||||
// Envelope from should contain addr-spec
|
|
||||||
// Mistake in the Nodemailer documentation?
|
|
||||||
from: process.env.EMAIL_FROM,
|
|
||||||
to: email,
|
|
||||||
},
|
|
||||||
subject: subject,
|
|
||||||
html: compiledTemplate(payload),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Send email
|
return await sendEmailViaSMTP({ transporterOptions, mailOptions });
|
||||||
return await transporter.sendMail(options());
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (throwError) {
|
if (throwError) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
@ -29,6 +29,12 @@ async function passportLogin(req, email, password, done) {
|
||||||
return done(null, false, { message: 'Email does not exist.' });
|
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);
|
const isMatch = await comparePassword(user, password);
|
||||||
if (!isMatch) {
|
if (!isMatch) {
|
||||||
logError('Passport Local Strategy - Password does not match', { isMatch });
|
logError('Passport Local Strategy - Password does not match', { isMatch });
|
||||||
|
|
|
@ -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.
|
* Sanitizes the model name to be used in the URL by removing or replacing disallowed characters.
|
||||||
|
|
|
@ -5,6 +5,7 @@ export default {
|
||||||
testResultsProcessor: 'jest-junit',
|
testResultsProcessor: 'jest-junit',
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@src/(.*)$': '<rootDir>/src/$1',
|
'^@src/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^~/(.*)$': '<rootDir>/src/$1',
|
||||||
},
|
},
|
||||||
// coverageThreshold: {
|
// coverageThreshold: {
|
||||||
// global: {
|
// global: {
|
||||||
|
@ -16,4 +17,4 @@ export default {
|
||||||
// },
|
// },
|
||||||
restoreMocks: true,
|
restoreMocks: true,
|
||||||
testTimeout: 15000,
|
testTimeout: 15000,
|
||||||
};
|
};
|
||||||
|
|
163
packages/data-schemas/src/methods/user.test.ts
Normal file
163
packages/data-schemas/src/methods/user.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -145,7 +145,18 @@ export function createUserMethods(mongoose: typeof import('mongoose')) {
|
||||||
throw new Error('No user provided');
|
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({
|
return await signPayload({
|
||||||
payload: {
|
payload: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue