diff --git a/packages/smtp/src/__tests__/relay.test.ts b/packages/smtp/src/__tests__/relay.test.ts new file mode 100644 index 0000000..eadda5b --- /dev/null +++ b/packages/smtp/src/__tests__/relay.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import net, { type AddressInfo } from 'node:net' +import nodemailer from 'nodemailer' +import { MemoryStorage, FileStorage } from '@maildev/core' +import { SMTPServer } from '../server.js' + +/** Grab an ephemeral free port so parallel test files don't collide. */ +function freePort(): Promise { + return new Promise((resolve) => { + const srv = net.createServer() + srv.listen(0, '127.0.0.1', () => { + const port = (srv.address() as AddressInfo).port + srv.close(() => resolve(port)) + }) + }) +} + +describe('relay delivery status', () => { + let sourceDir: string + let destDir: string + let sourceStore: MemoryStorage + let destStore: MemoryStorage + let source: SMTPServer + let dest: SMTPServer + let sourcePort: number + let destPort: number + + beforeEach(async () => { + sourceDir = await mkdtemp(join(tmpdir(), 'maildev-relay-src-')) + destDir = await mkdtemp(join(tmpdir(), 'maildev-relay-dst-')) + sourceStore = new MemoryStorage() + destStore = new MemoryStorage() + await sourceStore.initialize() + await destStore.initialize() + sourcePort = await freePort() + destPort = await freePort() + + dest = new SMTPServer({ storage: destStore, mailDir: destDir, port: destPort, host: '127.0.0.1' }) + await dest.start() + + source = new SMTPServer({ storage: sourceStore, mailDir: sourceDir, port: sourcePort, host: '127.0.0.1' }) + await source.start() + source.setupRelay({ host: '127.0.0.1', port: destPort, secure: false }) + source.setAutoRelay({ enabled: true }) + }) + + afterEach(async () => { + await source.stop() + await dest.stop() + await rm(sourceDir, { recursive: true, force: true }) + await rm(destDir, { recursive: true, force: true }) + }) + + const sendTo = (port: number) => { + const transport = nodemailer.createTransport({ host: '127.0.0.1', port, secure: false }) + return transport.sendMail({ + from: 'a@example.com', + to: 'b@example.com', + subject: 'relay me', + text: 'hello', + }) + } + + it('records relayedAt and relayedTo on the stored email after an auto-relay', async () => { + await sendTo(sourcePort) + + const stored = (await sourceStore.getAll())[0]! + expect(stored.relayedAt).toBeInstanceOf(Date) + expect(stored.relayedTo).toContain('b@example.com') + + // The relay actually delivered it downstream. + expect(await destStore.getAll()).toHaveLength(1) + }) + + it('leaves relayedAt unset for an email that was never relayed', async () => { + await sendTo(destPort) + + const stored = (await destStore.getAll())[0]! + expect(stored.relayedAt).toBeUndefined() + expect(stored.relayedTo).toBeUndefined() + }) + + it('restores relay status from disk on a fresh FileStorage-backed server', async () => { + // Use a disk-backed source so the relay status is persisted to a sidecar. + await source.stop() + const fileStore = new FileStorage({ mailDirectory: sourceDir }) + await fileStore.initialize() + source = new SMTPServer({ storage: fileStore, mailDir: sourceDir, port: sourcePort, host: '127.0.0.1' }) + await source.start() + source.setupRelay({ host: '127.0.0.1', port: destPort, secure: false }) + source.setAutoRelay({ enabled: true }) + + await sendTo(sourcePort) + const id = (await fileStore.getAll())[0]!.id + expect((await fileStore.getById(id))!.relayedAt).toBeInstanceOf(Date) + + // Simulate a restart: a brand-new store + server reading the same directory. + await source.stop() + const restoredStore = new FileStorage({ mailDirectory: sourceDir }) + await restoredStore.initialize() + source = new SMTPServer({ storage: restoredStore, mailDir: sourceDir, port: sourcePort, host: '127.0.0.1' }) + await source.loadMailsFromDirectory() + + const restored = (await restoredStore.getById(id))! + expect(restored.relayedAt).toBeInstanceOf(Date) + expect(restored.relayedTo).toContain('b@example.com') + }) +}) diff --git a/packages/smtp/src/relay.ts b/packages/smtp/src/relay.ts new file mode 100644 index 0000000..7fe5850 --- /dev/null +++ b/packages/smtp/src/relay.ts @@ -0,0 +1,300 @@ +/** + * @maildev/smtp - Relay Client + * + * Async/await based email relay client for forwarding emails + * to external SMTP servers. + */ + +import SMTPConnection from 'nodemailer/lib/smtp-connection/index.js' +// @ts-expect-error - wildstring has no type definitions +import wildstring from 'wildstring' +import type { Readable } from 'node:stream' +import type { Email, EnvelopeAddress as CoreEnvelopeAddress } from '@maildev/core' +import type { + RelayConfig, + AutoRelayConfig, + RelayRule, + RelayResult, + Logger, +} from './types.js' +import { createLogger } from './logger.js' + +// Configure wildstring for case-insensitive matching +wildstring.caseSensitive = false + +/** + * RelayClient handles forwarding emails to external SMTP servers + */ +export class RelayClient { + private config: RelayConfig + private autoRelayConfig?: AutoRelayConfig + private logger: Logger + private client: SMTPConnection | null = null + + constructor(config: RelayConfig, logger?: Logger | false) { + this.config = { + host: config.host || 'localhost', + port: config.port || (config.secure ? 465 : 25), + secure: config.secure || false, + auth: config.auth, + } + this.logger = createLogger(logger) + } + + /** + * Configure auto-relay settings + */ + setAutoRelay(config: AutoRelayConfig): void { + this.autoRelayConfig = config + + if (config.enabled) { + const messages = ['Auto-Relay mode enabled'] + if (config.defaultRecipient) { + messages.push(`Relaying all emails to ${config.defaultRecipient}`) + } + if (config.rules) { + messages.push(`Relay rules: ${JSON.stringify(config.rules)}`) + } + this.logger.info(messages.join(', ')) + } + } + + /** + * Check if auto-relay is enabled + */ + isAutoRelayEnabled(): boolean { + return this.autoRelayConfig?.enabled ?? false + } + + /** + * Get the outgoing host + */ + getHost(): string { + return this.config.host + } + + /** + * Relay an email to the configured SMTP server + * + * @param email - Email object to relay + * @param emailStream - Readable stream of raw email content + * @param isAutoRelay - Whether this is an auto-relay operation + * @returns Relay result + */ + async relay( + email: Email, + emailStream: Readable, + isAutoRelay = false + ): Promise { + let recipients = this.getRecipients(email) + + // Override recipients for auto-relay with default address + if (isAutoRelay && this.autoRelayConfig?.defaultRecipient) { + recipients = [this.autoRelayConfig.defaultRecipient] + } + + // Filter recipients based on auto-relay rules + if (isAutoRelay && this.autoRelayConfig?.rules) { + recipients = this.filterByRules(recipients, this.autoRelayConfig.rules) + } + + // Fail silently if no recipients + if (recipients.length === 0) { + return { + success: false, + message: 'Email had no recipients after filtering', + } + } + + const sender = this.getSender(email) + + try { + await this.connect() + + if (this.config.auth) { + await this.login() + } + + await this.send(sender, recipients, emailStream) + + this.logger.log('Mail Delivered:', email.subject) + + return { + success: true, + message: `Relayed to ${recipients.join(', ')}`, + recipients, + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)) + this.logger.error('Relay error:', err) + return { + success: false, + error: err, + } + } finally { + await this.disconnect() + } + } + + /** + * Close the client connection + */ + async close(): Promise { + await this.disconnect() + } + + /** + * Connect to the SMTP server + */ + private async connect(): Promise { + return new Promise((resolve, reject) => { + this.client = new SMTPConnection({ + port: this.config.port, + host: this.config.host, + secure: this.config.secure, + tls: { rejectUnauthorized: false }, + debug: false, + }) + + this.client.on('error', (err: Error) => { + this.logger.error('SMTP Connection error:', err) + }) + + this.client.connect((err?: Error) => { + if (err) { + reject(err) + } else { + resolve() + } + }) + }) + } + + /** + * Login to the SMTP server + */ + private async login(): Promise { + if (!this.client || !this.config.auth) { + throw new Error('Cannot login: client not connected or no auth configured') + } + + return new Promise((resolve, reject) => { + this.client!.login(this.config.auth!, (err?: Error) => { + if (err) { + this.logger.error('Outgoing client login error:', err) + reject(err) + } else { + resolve() + } + }) + }) + } + + /** + * Send the email + */ + private async send( + from: string, + to: string[], + stream: Readable + ): Promise { + if (!this.client) { + throw new Error('Cannot send: client not connected') + } + + return new Promise((resolve, reject) => { + this.client!.send({ from, to }, stream, (err: Error | null) => { + if (err) { + this.logger.error('Mail Delivery Error:', err) + reject(err) + } else { + resolve() + } + }) + }) + } + + /** + * Disconnect from the SMTP server + */ + private async disconnect(): Promise { + if (this.client) { + try { + this.client.quit() + } catch { + // Ignore quit errors + } + this.client = null + } + } + + /** + * Extract recipients from email envelope + */ + private getRecipients(email: Email): string[] { + const envelopeTo = email.envelope?.to + if (!envelopeTo || !Array.isArray(envelopeTo)) { + return [] + } + + return envelopeTo.map((addr) => this.extractAddress(addr)) + } + + /** + * Extract sender from email envelope + */ + private getSender(email: Email): string { + const from = email.envelope?.from + if (!from) { + return '' + } + return this.extractAddress(from) + } + + /** + * Extract address string from address object + */ + private extractAddress(addr: CoreEnvelopeAddress | string): string { + if (typeof addr === 'string') { + return addr + } + return addr.address || '' + } + + /** + * Filter recipients based on relay rules + */ + private filterByRules(recipients: string[], rules: RelayRule[]): string[] { + return recipients.filter((email) => this.validateAgainstRules(email, rules)) + } + + /** + * Validate an email address against relay rules + */ + private validateAgainstRules(email: string, rules: RelayRule[]): boolean { + // Default to allowing if no rules + let result = true + + for (const rule of rules) { + const pattern = rule.allow || rule.deny || '' + const isMatch = wildstring.match(pattern, email) + + if (isMatch) { + // Override previous result if this rule matches + result = !!rule.allow + } + } + + return result + } +} + +/** + * Create a RelayClient instance + */ +export function createRelayClient( + config: RelayConfig, + logger?: Logger | false +): RelayClient { + return new RelayClient(config, logger) +}