Skip to content

Commit 7b61010

Browse files
danny-avilaMichielMAnalytics
authored andcommitted
📧 feat: Mailgun API Email Configuration (danny-avila#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.
1 parent 9d13bd5 commit 7b61010

File tree

9 files changed

+310
-28
lines changed

9 files changed

+310
-28
lines changed

‎.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,18 @@ EMAIL_PASSWORD=
489489
EMAIL_FROM_NAME=
490490
491491

492+
#========================#
493+
# Mailgun API #
494+
#========================#
495+
496+
# MAILGUN_API_KEY=your-mailgun-api-key
497+
# MAILGUN_DOMAIN=mg.yourdomain.com
498+
499+
# EMAIL_FROM_NAME="LibreChat"
500+
501+
# # Optional: For EU region
502+
# MAILGUN_HOST=https://api.eu.mailgun.net
503+
492504
#========================#
493505
# Firebase CDN #
494506
#========================#

‎api/models/userMethods.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const comparePassword = async (user, candidatePassword) => {
1212
throw new Error('No user provided');
1313
}
1414

15+
if (!user.password) {
16+
throw new Error('No password, likely an email first registered via Social/OIDC login');
17+
}
18+
1519
return new Promise((resolve, reject) => {
1620
bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
1721
if (err) {

‎api/server/controllers/UserController.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,11 @@ const deleteUserController = async (req, res) => {
163163
await Balance.deleteMany({ user: user._id }); // delete user balances
164164
await deletePresets(user.id); // delete user presets
165165
/* TODO: Delete Assistant Threads */
166-
await deleteConvos(user.id); // delete user convos
166+
try {
167+
await deleteConvos(user.id); // delete user convos
168+
} catch (error) {
169+
logger.error('[deleteUserController] Error deleting user convos, likely no convos', error);
170+
}
167171
await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
168172
await deleteUserById(user.id); // delete user
169173
await deleteAllSharedLinks(user.id); // delete user shared links

‎api/server/utils/index.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,19 @@ const math = require('./math');
1313
* @returns {Boolean}
1414
*/
1515
function checkEmailConfig() {
16-
return (
16+
// Check if Mailgun is configured
17+
const hasMailgunConfig =
18+
!!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM;
19+
20+
// Check if SMTP is configured
21+
const hasSMTPConfig =
1722
(!!process.env.EMAIL_SERVICE || !!process.env.EMAIL_HOST) &&
1823
!!process.env.EMAIL_USERNAME &&
1924
!!process.env.EMAIL_PASSWORD &&
20-
!!process.env.EMAIL_FROM
21-
);
25+
!!process.env.EMAIL_FROM;
26+
27+
// Return true if either Mailgun or SMTP is properly configured
28+
return hasMailgunConfig || hasSMTPConfig;
2229
}
2330

2431
module.exports = {

‎api/server/utils/sendEmail.js

Lines changed: 96 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,69 @@
11
const fs = require('fs');
22
const path = require('path');
3+
const axios = require('axios');
4+
const FormData = require('form-data');
35
const nodemailer = require('nodemailer');
46
const handlebars = require('handlebars');
57
const { isEnabled } = require('~/server/utils/handleText');
8+
const { logAxiosError } = require('~/utils');
69
const logger = require('~/config/winston');
710

11+
/**
12+
* Sends an email using Mailgun API.
13+
*
14+
* @async
15+
* @function sendEmailViaMailgun
16+
* @param {Object} params - The parameters for sending the email.
17+
* @param {string} params.to - The recipient's email address.
18+
* @param {string} params.from - The sender's email address.
19+
* @param {string} params.subject - The subject of the email.
20+
* @param {string} params.html - The HTML content of the email.
21+
* @returns {Promise<Object>} - A promise that resolves to the response from Mailgun API.
22+
*/
23+
const sendEmailViaMailgun = async ({ to, from, subject, html }) => {
24+
const mailgunApiKey = process.env.MAILGUN_API_KEY;
25+
const mailgunDomain = process.env.MAILGUN_DOMAIN;
26+
const mailgunHost = process.env.MAILGUN_HOST || 'https://api.mailgun.net';
27+
28+
if (!mailgunApiKey || !mailgunDomain) {
29+
throw new Error('Mailgun API key and domain are required');
30+
}
31+
32+
const formData = new FormData();
33+
formData.append('from', from);
34+
formData.append('to', to);
35+
formData.append('subject', subject);
36+
formData.append('html', html);
37+
38+
try {
39+
const response = await axios.post(`${mailgunHost}/v3/${mailgunDomain}/messages`, formData, {
40+
headers: {
41+
...formData.getHeaders(),
42+
Authorization: `Basic ${Buffer.from(`api:${mailgunApiKey}`).toString('base64')}`,
43+
},
44+
});
45+
46+
return response.data;
47+
} catch (error) {
48+
throw new Error(logAxiosError({ error, message: 'Failed to send email via Mailgun' }));
49+
}
50+
};
51+
52+
/**
53+
* Sends an email using SMTP via Nodemailer.
54+
*
55+
* @async
56+
* @function sendEmailViaSMTP
57+
* @param {Object} params - The parameters for sending the email.
58+
* @param {Object} params.transporterOptions - The transporter configuration options.
59+
* @param {Object} params.mailOptions - The email options.
60+
* @returns {Promise<Object>} - A promise that resolves to the info object of the sent email.
61+
*/
62+
const sendEmailViaSMTP = async ({ transporterOptions, mailOptions }) => {
63+
const transporter = nodemailer.createTransport(transporterOptions);
64+
return await transporter.sendMail(mailOptions);
65+
};
66+
867
/**
968
* Sends an email using the specified template, subject, and payload.
1069
*
@@ -34,6 +93,30 @@ const logger = require('~/config/winston');
3493
*/
3594
const sendEmail = async ({ email, subject, payload, template, throwError = true }) => {
3695
try {
96+
// Read and compile the email template
97+
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
98+
const compiledTemplate = handlebars.compile(source);
99+
const html = compiledTemplate(payload);
100+
101+
// Prepare common email data
102+
const fromName = process.env.EMAIL_FROM_NAME || process.env.APP_TITLE;
103+
const fromEmail = process.env.EMAIL_FROM;
104+
const fromAddress = `"${fromName}" <${fromEmail}>`;
105+
const toAddress = `"${payload.name}" <${email}>`;
106+
107+
// Check if Mailgun is configured
108+
if (process.env.MAILGUN_API_KEY && process.env.MAILGUN_DOMAIN) {
109+
logger.debug('[sendEmail] Using Mailgun provider');
110+
return await sendEmailViaMailgun({
111+
from: fromAddress,
112+
to: toAddress,
113+
subject: subject,
114+
html: html,
115+
});
116+
}
117+
118+
// Default to SMTP
119+
logger.debug('[sendEmail] Using SMTP provider');
37120
const transporterOptions = {
38121
// Use STARTTLS by default instead of obligatory TLS
39122
secure: process.env.EMAIL_ENCRYPTION === 'tls',
@@ -62,30 +145,21 @@ const sendEmail = async ({ email, subject, payload, template, throwError = true
62145
transporterOptions.port = process.env.EMAIL_PORT ?? 25;
63146
}
64147

65-
const transporter = nodemailer.createTransport(transporterOptions);
66-
67-
const source = fs.readFileSync(path.join(__dirname, 'emails', template), 'utf8');
68-
const compiledTemplate = handlebars.compile(source);
69-
const options = () => {
70-
return {
71-
// Header address should contain name-addr
72-
from:
73-
`"${process.env.EMAIL_FROM_NAME || process.env.APP_TITLE}"` +
74-
`<${process.env.EMAIL_FROM}>`,
75-
to: `"${payload.name}" <${email}>`,
76-
envelope: {
77-
// Envelope from should contain addr-spec
78-
// Mistake in the Nodemailer documentation?
79-
from: process.env.EMAIL_FROM,
80-
to: email,
81-
},
82-
subject: subject,
83-
html: compiledTemplate(payload),
84-
};
148+
const mailOptions = {
149+
// Header address should contain name-addr
150+
from: fromAddress,
151+
to: toAddress,
152+
envelope: {
153+
// Envelope from should contain addr-spec
154+
// Mistake in the Nodemailer documentation?
155+
from: fromEmail,
156+
to: email,
157+
},
158+
subject: subject,
159+
html: html,
85160
};
86161

87-
// Send email
88-
return await transporter.sendMail(options());
162+
return await sendEmailViaSMTP({ transporterOptions, mailOptions });
89163
} catch (error) {
90164
if (throwError) {
91165
throw error;

‎api/strategies/localStrategy.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ async function passportLogin(req, email, password, done) {
2929
return done(null, false, { message: 'Email does not exist.' });
3030
}
3131

32+
if (!user.password) {
33+
logError('Passport Local Strategy - User has no password', { email });
34+
logger.error(`[Login] [Login failed] [Username: ${email}] [Request-IP: ${req.ip}]`);
35+
return done(null, false, { message: 'Email does not exist.' });
36+
}
37+
3238
const isMatch = await comparePassword(user, password);
3339
if (!isMatch) {
3440
logError('Passport Local Strategy - Password does not match', { isMatch });

‎packages/data-schemas/jest.config.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default {
55
testResultsProcessor: 'jest-junit',
66
moduleNameMapper: {
77
'^@src/(.*)$': '<rootDir>/src/$1',
8+
'^~/(.*)$': '<rootDir>/src/$1',
89
},
910
// coverageThreshold: {
1011
// global: {
@@ -16,4 +17,4 @@ export default {
1617
// },
1718
restoreMocks: true,
1819
testTimeout: 15000,
19-
};
20+
};

0 commit comments

Comments
 (0)