diff --git a/packages/cli/src/__tests__/config/env.test.ts b/packages/cli/src/__tests__/config/env.test.ts new file mode 100644 index 0000000..95b048e --- /dev/null +++ b/packages/cli/src/__tests__/config/env.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { loadEnvConfig, getEnvVarName } from '../../config/env.js' + +describe('loadEnvConfig', () => { + const originalEnv = process.env + + beforeEach(() => { + // Reset process.env before each test + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it('should return empty config when no env vars set', () => { + // Clear relevant env vars + delete process.env.MAILDEV_SMTP_PORT + delete process.env.MAILDEV_WEB_PORT + delete process.env.MAILDEV_IP + + const config = loadEnvConfig() + expect(config.smtp).toBeUndefined() + expect(config.web).toBeUndefined() + }) + + it('should parse MAILDEV_SMTP_PORT as number', () => { + process.env.MAILDEV_SMTP_PORT = '2025' + + const config = loadEnvConfig() + expect(config.smtp).toBe(2025) + expect(typeof config.smtp).toBe('number') + }) + + it('should parse MAILDEV_WEB_PORT as number', () => { + process.env.MAILDEV_WEB_PORT = '3080' + + const config = loadEnvConfig() + expect(config.web).toBe(3080) + }) + + it('should parse MAILDEV_MAX_MESSAGE_SIZE as number', () => { + process.env.MAILDEV_MAX_MESSAGE_SIZE = '1048576' + + const config = loadEnvConfig() + expect(config.maxMessageSize).toBe(1048576) + expect(typeof config.maxMessageSize).toBe('number') + }) + + it('should load MAILDEV_IP as string', () => { + process.env.MAILDEV_IP = '127.0.0.1' + + const config = loadEnvConfig() + expect(config.ip).toBe('127.0.0.1') + }) + + it('should load MAILDEV_WEB_IP as string', () => { + process.env.MAILDEV_WEB_IP = '192.168.1.1' + + const config = loadEnvConfig() + expect(config.webIp).toBe('192.168.1.1') + }) + + it('should load authentication env vars', () => { + process.env.MAILDEV_INCOMING_USER = 'smtp_user' + process.env.MAILDEV_INCOMING_PASS = 'smtp_pass' + process.env.MAILDEV_WEB_USER = 'web_user' + process.env.MAILDEV_WEB_PASS = 'web_pass' + + const config = loadEnvConfig() + expect(config.incomingUser).toBe('smtp_user') + expect(config.incomingPass).toBe('smtp_pass') + expect(config.webUser).toBe('web_user') + expect(config.webPass).toBe('web_pass') + }) + + it('should load outgoing relay env vars', () => { + process.env.MAILDEV_OUTGOING_HOST = 'smtp.example.com' + process.env.MAILDEV_OUTGOING_PORT = '587' + process.env.MAILDEV_OUTGOING_USER = 'relay_user' + process.env.MAILDEV_OUTGOING_PASS = 'relay_pass' + + const config = loadEnvConfig() + expect(config.outgoingHost).toBe('smtp.example.com') + expect(config.outgoingPort).toBe(587) + expect(config.outgoingUser).toBe('relay_user') + expect(config.outgoingPass).toBe('relay_pass') + }) + + it('should load MAILDEV_MAIL_DIRECTORY', () => { + process.env.MAILDEV_MAIL_DIRECTORY = '/var/mail' + + const config = loadEnvConfig() + expect(config.mailDirectory).toBe('/var/mail') + }) + + it('should load MAILDEV_BASE_PATHNAME', () => { + process.env.MAILDEV_BASE_PATHNAME = '/mail/' + + const config = loadEnvConfig() + expect(config.basePathname).toBe('/mail/') + }) + + it('should ignore invalid port numbers', () => { + process.env.MAILDEV_SMTP_PORT = 'not-a-number' + + const config = loadEnvConfig() + expect(config.smtp).toBeUndefined() + }) + + it('should handle multiple env vars simultaneously', () => { + process.env.MAILDEV_SMTP_PORT = '2025' + process.env.MAILDEV_WEB_PORT = '3080' + process.env.MAILDEV_IP = '0.0.0.0' + process.env.MAILDEV_MAIL_DIRECTORY = '/tmp/mail' + + const config = loadEnvConfig() + expect(config.smtp).toBe(2025) + expect(config.web).toBe(3080) + expect(config.ip).toBe('0.0.0.0') + expect(config.mailDirectory).toBe('/tmp/mail') + }) + + it('should parse MAILDEV_AUTO_RELAY=true as boolean true', () => { + process.env.MAILDEV_AUTO_RELAY = 'true' + + const config = loadEnvConfig() + expect(config.autoRelay).toBe(true) + }) + + it('should parse MAILDEV_AUTO_RELAY=1 as boolean true', () => { + process.env.MAILDEV_AUTO_RELAY = '1' + + const config = loadEnvConfig() + expect(config.autoRelay).toBe(true) + }) + + it('should parse present-but-empty MAILDEV_AUTO_RELAY as boolean true', () => { + process.env.MAILDEV_AUTO_RELAY = '' + + const config = loadEnvConfig() + expect(config.autoRelay).toBe(true) + }) + + it('should parse MAILDEV_AUTO_RELAY=false as boolean false', () => { + process.env.MAILDEV_AUTO_RELAY = 'false' + + const config = loadEnvConfig() + expect(config.autoRelay).toBe(false) + }) + + it('should parse MAILDEV_AUTO_RELAY=0 as boolean false', () => { + process.env.MAILDEV_AUTO_RELAY = '0' + + const config = loadEnvConfig() + expect(config.autoRelay).toBe(false) + }) + + it('should parse MAILDEV_AUTO_RELAY as a recipient email', () => { + process.env.MAILDEV_AUTO_RELAY = 'qa@example.com' + + const config = loadEnvConfig() + expect(config.autoRelay).toBe('qa@example.com') + }) + + it('should load MAILDEV_AUTO_RELAY_RULES as a path string', () => { + process.env.MAILDEV_AUTO_RELAY_RULES = '/config/rules.json' + + const config = loadEnvConfig() + expect(config.autoRelayRules).toBe('/config/rules.json') + }) + + it('should leave autoRelay and autoRelayRules unset when env vars are absent', () => { + delete process.env.MAILDEV_AUTO_RELAY + delete process.env.MAILDEV_AUTO_RELAY_RULES + + const config = loadEnvConfig() + expect(config.autoRelay).toBeUndefined() + expect(config.autoRelayRules).toBeUndefined() + }) +}) + +describe('getEnvVarName', () => { + it('should return env var name for smtp', () => { + expect(getEnvVarName('smtp')).toBe('MAILDEV_SMTP_PORT') + }) + + it('should return env var name for web', () => { + expect(getEnvVarName('web')).toBe('MAILDEV_WEB_PORT') + }) + + it('should return env var name for ip', () => { + expect(getEnvVarName('ip')).toBe('MAILDEV_IP') + }) + + it('should return env var name for mailDirectory', () => { + expect(getEnvVarName('mailDirectory')).toBe('MAILDEV_MAIL_DIRECTORY') + }) + + it('should return null for unmapped config keys', () => { + expect(getEnvVarName('mcp')).toBeNull() + expect(getEnvVarName('verbose')).toBeNull() + }) +}) diff --git a/packages/cli/src/config/env.ts b/packages/cli/src/config/env.ts new file mode 100644 index 0000000..404d0a5 --- /dev/null +++ b/packages/cli/src/config/env.ts @@ -0,0 +1,166 @@ +/** + * MailDev Environment Variable Configuration + * + * Maps MAILDEV_* environment variables to configuration options + */ + +import type { PartialMailDevConfig } from './types.js' + +/** + * Environment variable to config key mapping + */ +const ENV_MAPPING: Record = { + // SMTP + MAILDEV_SMTP_PORT: 'smtp', + MAILDEV_IP: 'ip', + MAILDEV_INCOMING_USER: 'incomingUser', + MAILDEV_INCOMING_PASS: 'incomingPass', + MAILDEV_MAX_MESSAGE_SIZE: 'maxMessageSize', + + // Web/API + MAILDEV_WEB_PORT: 'web', + MAILDEV_WEB_IP: 'webIp', + MAILDEV_WEB_USER: 'webUser', + MAILDEV_WEB_PASS: 'webPass', + MAILDEV_BASE_PATHNAME: 'basePathname', + MAILDEV_HTTPS: 'https', + MAILDEV_HTTPS_CERT: 'httpsCert', + MAILDEV_HTTPS_KEY: 'httpsKey', + + // Outgoing/Relay + MAILDEV_OUTGOING_HOST: 'outgoingHost', + MAILDEV_OUTGOING_PORT: 'outgoingPort', + MAILDEV_OUTGOING_USER: 'outgoingUser', + MAILDEV_OUTGOING_PASS: 'outgoingPass', + MAILDEV_AUTO_RELAY: 'autoRelay', + MAILDEV_AUTO_RELAY_RULES: 'autoRelayRules', + + // Storage + MAILDEV_MAIL_DIRECTORY: 'mailDirectory', + MAILDEV_MAX_EMAILS: 'maxEmails', + + // API URL (for MCP client mode) + MAILDEV_API_URL: 'webIp', // Special handling needed +} + +/** + * Environment variables that should be parsed as numbers + */ +const NUMBER_VARS = new Set([ + 'MAILDEV_SMTP_PORT', + 'MAILDEV_WEB_PORT', + 'MAILDEV_OUTGOING_PORT', + 'MAILDEV_MAX_MESSAGE_SIZE', + 'MAILDEV_MAX_EMAILS', +]) + +/** + * Environment variables that should be parsed as booleans + */ +const BOOLEAN_VARS = new Set([ + 'MAILDEV_INCOMING_SECURE', + 'MAILDEV_OUTGOING_SECURE', + 'MAILDEV_HTTPS', + 'MAILDEV_DISABLE_WEB', + 'MAILDEV_MCP', + 'MAILDEV_VERBOSE', + 'MAILDEV_SILENT', +]) + +/** + * Parse boolean environment variable + */ +function parseBoolean(value: string): boolean { + return value.toLowerCase() === 'true' || value === '1' +} + +/** + * MAILDEV_AUTO_RELAY is boolean | string: true/1/empty enable relay to + * each mail's own recipients; false/0 disable; any other value is the + * override recipient. + * Do not put this in BOOLEAN_VARS — that would parse an email as false. + */ +function parseAutoRelay(value: string): boolean | string { + if (value === '' || value.toLowerCase() === 'true' || value === '1') { + return true + } + if (value.toLowerCase() === 'false' || value === '0') { + return false + } + return value +} + +/** + * Load configuration from environment variables + * + * @returns Partial configuration from environment + */ +export function loadEnvConfig(): PartialMailDevConfig { + const config: PartialMailDevConfig = {} + + for (const [envVar, configKey] of Object.entries(ENV_MAPPING)) { + const value = process.env[envVar] + if (value === undefined) continue + + if (envVar === 'MAILDEV_AUTO_RELAY') { + config.autoRelay = parseAutoRelay(value) + } else if (NUMBER_VARS.has(envVar)) { + const num = parseInt(value, 10) + if (!isNaN(num)) { + ;(config as Record)[configKey] = num + } + } else { + ;(config as Record)[configKey] = value + } + } + + // Handle boolean environment variables + for (const envVar of BOOLEAN_VARS) { + const value = process.env[envVar] + if (value !== undefined) { + const key = envVarToConfigKey(envVar) + if (key) { + ;(config as Record)[key] = parseBoolean(value) + } + } + } + + return config +} + +/** + * Convert environment variable name to config key + */ +function envVarToConfigKey(envVar: string): keyof PartialMailDevConfig | null { + // Remove MAILDEV_ prefix and convert to camelCase + const withoutPrefix = envVar.replace(/^MAILDEV_/, '') + const parts = withoutPrefix.toLowerCase().split('_') + const camelCase = parts + .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join('') + + // Map specific env vars to config keys + const mapping: Record = { + smtpPort: 'smtp', + webPort: 'web', + incomingSecure: 'incomingSecure', + outgoingSecure: 'outgoingSecure', + disableWeb: 'disableWeb', + verbose: 'verbose', + silent: 'silent', + mcp: 'mcp', + } + + return mapping[camelCase] || null +} + +/** + * Get environment variable name for a config key + * (useful for error messages and documentation) + */ +export function getEnvVarName(configKey: keyof PartialMailDevConfig): string | null { + for (const [envVar, key] of Object.entries(ENV_MAPPING)) { + if (key === configKey) return envVar + } + return null +}