From 4e5ae28fa90063a36d755fa217d6a0f517a6cc12 Mon Sep 17 00:00:00 2001 From: mfish911 <33205066+mfish911@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:07:39 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=A1=20feat:=20Support=20Unauthenticate?= =?UTF-8?q?d=20SMTP=20Relays=20(#12322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * allow smtp server that does not have authentication * fix: align checkEmailConfig with optional SMTP credentials and add tests Remove EMAIL_USERNAME/EMAIL_PASSWORD requirements from the hasSMTPConfig predicate in checkEmailConfig() so the rest of the codebase (login, startup checks, invite-user) correctly recognizes unauthenticated SMTP as a valid email configuration. Add a warning when only one of the two credential env vars is set, in both sendEmail.js and checkEmailConfig(), to catch partial misconfigurations early. Add test coverage for both the transporter auth assembly in sendEmail.js and the checkEmailConfig predicate in packages/api. Document in .env.example that credentials are optional for unauthenticated SMTP relays. --------- Co-authored-by: Danny Avila --- .env.example | 1 + api/server/utils/__tests__/sendEmail.spec.js | 143 ++++++++++++++++++ api/server/utils/sendEmail.js | 15 +- .../api/src/utils/__tests__/email.test.ts | 120 +++++++++++++++ packages/api/src/utils/email.ts | 17 ++- 5 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 api/server/utils/__tests__/sendEmail.spec.js create mode 100644 packages/api/src/utils/__tests__/email.test.ts diff --git a/.env.example b/.env.example index 73e95c394c..ae3537038a 100644 --- a/.env.example +++ b/.env.example @@ -625,6 +625,7 @@ EMAIL_PORT=25 EMAIL_ENCRYPTION= EMAIL_ENCRYPTION_HOSTNAME= EMAIL_ALLOW_SELFSIGNED= +# Leave both empty for SMTP servers that do not require authentication EMAIL_USERNAME= EMAIL_PASSWORD= EMAIL_FROM_NAME= diff --git a/api/server/utils/__tests__/sendEmail.spec.js b/api/server/utils/__tests__/sendEmail.spec.js new file mode 100644 index 0000000000..5c79094c53 --- /dev/null +++ b/api/server/utils/__tests__/sendEmail.spec.js @@ -0,0 +1,143 @@ +const nodemailer = require('nodemailer'); +const { readFileAsString } = require('@librechat/api'); + +jest.mock('nodemailer'); +jest.mock('@librechat/data-schemas', () => ({ + logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); +jest.mock('@librechat/api', () => ({ + logAxiosError: jest.fn(), + isEnabled: jest.fn((val) => val === 'true' || val === true), + readFileAsString: jest.fn(), +})); + +const savedEnv = { ...process.env }; + +const mockSendMail = jest.fn().mockResolvedValue({ messageId: 'test-id' }); + +beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...savedEnv }; + process.env.EMAIL_HOST = 'smtp.example.com'; + process.env.EMAIL_PORT = '587'; + process.env.EMAIL_FROM = 'noreply@example.com'; + process.env.APP_TITLE = 'TestApp'; + delete process.env.EMAIL_USERNAME; + delete process.env.EMAIL_PASSWORD; + delete process.env.MAILGUN_API_KEY; + delete process.env.MAILGUN_DOMAIN; + delete process.env.EMAIL_SERVICE; + delete process.env.EMAIL_ENCRYPTION; + delete process.env.EMAIL_ENCRYPTION_HOSTNAME; + delete process.env.EMAIL_ALLOW_SELFSIGNED; + + readFileAsString.mockResolvedValue({ content: '

{{name}}

' }); + nodemailer.createTransport.mockReturnValue({ sendMail: mockSendMail }); +}); + +afterAll(() => { + process.env = savedEnv; +}); + +/** Loads a fresh copy of sendEmail so process.env reads are re-evaluated. */ +function loadSendEmail() { + jest.resetModules(); + jest.mock('nodemailer', () => ({ + createTransport: jest.fn().mockReturnValue({ sendMail: mockSendMail }), + })); + jest.mock('@librechat/data-schemas', () => ({ + logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() }, + })); + jest.mock('@librechat/api', () => ({ + logAxiosError: jest.fn(), + isEnabled: jest.fn((val) => val === 'true' || val === true), + readFileAsString: jest.fn().mockResolvedValue({ content: '

{{name}}

' }), + })); + return require('../sendEmail'); +} + +const baseParams = { + email: 'user@example.com', + subject: 'Test', + payload: { name: 'User' }, + template: 'test.handlebars', +}; + +describe('sendEmail SMTP auth assembly', () => { + it('includes auth when both EMAIL_USERNAME and EMAIL_PASSWORD are set', async () => { + process.env.EMAIL_USERNAME = 'smtp_user'; + process.env.EMAIL_PASSWORD = 'smtp_pass'; + const sendEmail = loadSendEmail(); + const { createTransport } = require('nodemailer'); + + await sendEmail(baseParams); + + expect(createTransport).toHaveBeenCalledTimes(1); + const transporterOptions = createTransport.mock.calls[0][0]; + expect(transporterOptions.auth).toEqual({ + user: 'smtp_user', + pass: 'smtp_pass', + }); + }); + + it('omits auth when both EMAIL_USERNAME and EMAIL_PASSWORD are absent', async () => { + const sendEmail = loadSendEmail(); + const { createTransport } = require('nodemailer'); + + await sendEmail(baseParams); + + expect(createTransport).toHaveBeenCalledTimes(1); + const transporterOptions = createTransport.mock.calls[0][0]; + expect(transporterOptions.auth).toBeUndefined(); + }); + + it('omits auth and logs a warning when only EMAIL_USERNAME is set', async () => { + process.env.EMAIL_USERNAME = 'smtp_user'; + const sendEmail = loadSendEmail(); + const { createTransport } = require('nodemailer'); + const { logger: freshLogger } = require('@librechat/data-schemas'); + + await sendEmail(baseParams); + + const transporterOptions = createTransport.mock.calls[0][0]; + expect(transporterOptions.auth).toBeUndefined(); + expect(freshLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('EMAIL_USERNAME and EMAIL_PASSWORD must both be set'), + ); + }); + + it('omits auth and logs a warning when only EMAIL_PASSWORD is set', async () => { + process.env.EMAIL_PASSWORD = 'smtp_pass'; + const sendEmail = loadSendEmail(); + const { createTransport } = require('nodemailer'); + const { logger: freshLogger } = require('@librechat/data-schemas'); + + await sendEmail(baseParams); + + const transporterOptions = createTransport.mock.calls[0][0]; + expect(transporterOptions.auth).toBeUndefined(); + expect(freshLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('EMAIL_USERNAME and EMAIL_PASSWORD must both be set'), + ); + }); + + it('does not log a warning when both credentials are properly set', async () => { + process.env.EMAIL_USERNAME = 'smtp_user'; + process.env.EMAIL_PASSWORD = 'smtp_pass'; + const sendEmail = loadSendEmail(); + const { logger: freshLogger } = require('@librechat/data-schemas'); + + await sendEmail(baseParams); + + expect(freshLogger.warn).not.toHaveBeenCalled(); + }); + + it('does not log a warning when both credentials are absent', async () => { + const sendEmail = loadSendEmail(); + const { logger: freshLogger } = require('@librechat/data-schemas'); + + await sendEmail(baseParams); + + expect(freshLogger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/utils/sendEmail.js b/api/server/utils/sendEmail.js index 432a571ffb..3fa3e6fcba 100644 --- a/api/server/utils/sendEmail.js +++ b/api/server/utils/sendEmail.js @@ -124,11 +124,20 @@ const sendEmail = async ({ email, subject, payload, template, throwError = true // Whether to accept unsigned certificates rejectUnauthorized: !isEnabled(process.env.EMAIL_ALLOW_SELFSIGNED), }, - auth: { + }; + + const hasUsername = !!process.env.EMAIL_USERNAME; + const hasPassword = !!process.env.EMAIL_PASSWORD; + if (hasUsername && hasPassword) { + transporterOptions.auth = { user: process.env.EMAIL_USERNAME, pass: process.env.EMAIL_PASSWORD, - }, - }; + }; + } else if (hasUsername !== hasPassword) { + logger.warn( + '[sendEmail] EMAIL_USERNAME and EMAIL_PASSWORD must both be set for authenticated SMTP, or both omitted for unauthenticated SMTP. Proceeding without authentication.', + ); + } if (process.env.EMAIL_ENCRYPTION_HOSTNAME) { // Check the certificate against this name explicitly diff --git a/packages/api/src/utils/__tests__/email.test.ts b/packages/api/src/utils/__tests__/email.test.ts new file mode 100644 index 0000000000..ccbd0aabfe --- /dev/null +++ b/packages/api/src/utils/__tests__/email.test.ts @@ -0,0 +1,120 @@ +import { logger } from '@librechat/data-schemas'; +import { checkEmailConfig } from '../email'; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { warn: jest.fn() }, +})); + +const savedEnv = { ...process.env }; + +beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...savedEnv }; + delete process.env.EMAIL_SERVICE; + delete process.env.EMAIL_HOST; + delete process.env.EMAIL_USERNAME; + delete process.env.EMAIL_PASSWORD; + delete process.env.EMAIL_FROM; + delete process.env.MAILGUN_API_KEY; + delete process.env.MAILGUN_DOMAIN; +}); + +afterAll(() => { + process.env = savedEnv; +}); + +describe('checkEmailConfig', () => { + describe('SMTP configuration', () => { + it('returns true with EMAIL_HOST and EMAIL_FROM (no credentials)', () => { + process.env.EMAIL_HOST = 'smtp.example.com'; + process.env.EMAIL_FROM = 'noreply@example.com'; + expect(checkEmailConfig()).toBe(true); + }); + + it('returns true with EMAIL_SERVICE and EMAIL_FROM (no credentials)', () => { + process.env.EMAIL_SERVICE = 'gmail'; + process.env.EMAIL_FROM = 'noreply@example.com'; + expect(checkEmailConfig()).toBe(true); + }); + + it('returns true with EMAIL_HOST, EMAIL_FROM, and full credentials', () => { + process.env.EMAIL_HOST = 'smtp.example.com'; + process.env.EMAIL_FROM = 'noreply@example.com'; + process.env.EMAIL_USERNAME = 'user'; + process.env.EMAIL_PASSWORD = 'pass'; + expect(checkEmailConfig()).toBe(true); + }); + + it('returns false when EMAIL_FROM is missing', () => { + process.env.EMAIL_HOST = 'smtp.example.com'; + expect(checkEmailConfig()).toBe(false); + }); + + it('returns false when neither EMAIL_HOST nor EMAIL_SERVICE is set', () => { + process.env.EMAIL_FROM = 'noreply@example.com'; + expect(checkEmailConfig()).toBe(false); + }); + + it('returns false when no email env vars are set', () => { + expect(checkEmailConfig()).toBe(false); + }); + }); + + describe('partial credential warning', () => { + it('logs a warning when only EMAIL_USERNAME is set', () => { + process.env.EMAIL_HOST = 'smtp.example.com'; + process.env.EMAIL_FROM = 'noreply@example.com'; + process.env.EMAIL_USERNAME = 'user'; + checkEmailConfig(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('EMAIL_USERNAME and EMAIL_PASSWORD must both be set'), + ); + }); + + it('logs a warning when only EMAIL_PASSWORD is set', () => { + process.env.EMAIL_HOST = 'smtp.example.com'; + process.env.EMAIL_FROM = 'noreply@example.com'; + process.env.EMAIL_PASSWORD = 'pass'; + checkEmailConfig(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('EMAIL_USERNAME and EMAIL_PASSWORD must both be set'), + ); + }); + + it('does not warn when both credentials are set', () => { + process.env.EMAIL_HOST = 'smtp.example.com'; + process.env.EMAIL_FROM = 'noreply@example.com'; + process.env.EMAIL_USERNAME = 'user'; + process.env.EMAIL_PASSWORD = 'pass'; + checkEmailConfig(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not warn when neither credential is set', () => { + process.env.EMAIL_HOST = 'smtp.example.com'; + process.env.EMAIL_FROM = 'noreply@example.com'; + checkEmailConfig(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not warn for partial credentials when SMTP is not configured', () => { + process.env.EMAIL_USERNAME = 'user'; + checkEmailConfig(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); + + describe('Mailgun configuration', () => { + it('returns true with Mailgun API key, domain, and EMAIL_FROM', () => { + process.env.MAILGUN_API_KEY = 'key-abc123'; + process.env.MAILGUN_DOMAIN = 'mg.example.com'; + process.env.EMAIL_FROM = 'noreply@example.com'; + expect(checkEmailConfig()).toBe(true); + }); + + it('returns false when Mailgun is partially configured', () => { + process.env.MAILGUN_API_KEY = 'key-abc123'; + expect(checkEmailConfig()).toBe(false); + }); + }); +}); diff --git a/packages/api/src/utils/email.ts b/packages/api/src/utils/email.ts index f98e7c51be..6f9171a43b 100644 --- a/packages/api/src/utils/email.ts +++ b/packages/api/src/utils/email.ts @@ -1,3 +1,5 @@ +import { logger } from '@librechat/data-schemas'; + /** * Check if email configuration is set * @returns Returns `true` if either Mailgun or SMTP is properly configured @@ -7,10 +9,17 @@ export function checkEmailConfig(): boolean { !!process.env.MAILGUN_API_KEY && !!process.env.MAILGUN_DOMAIN && !!process.env.EMAIL_FROM; 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_SERVICE || !!process.env.EMAIL_HOST) && !!process.env.EMAIL_FROM; + + if (hasSMTPConfig) { + const hasUsername = !!process.env.EMAIL_USERNAME; + const hasPassword = !!process.env.EMAIL_PASSWORD; + if (hasUsername !== hasPassword) { + logger.warn( + '[checkEmailConfig] EMAIL_USERNAME and EMAIL_PASSWORD must both be set for authenticated SMTP, or both omitted for unauthenticated SMTP.', + ); + } + } return hasMailgunConfig || hasSMTPConfig; }