📡 feat: Support Unauthenticated SMTP Relays (#12322)

* 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 <danny@librechat.ai>
This commit is contained in:
mfish911 2026-03-20 13:07:39 -04:00 committed by GitHub
parent 28c2e224ae
commit 4e5ae28fa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 289 additions and 7 deletions

View file

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

View file

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