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