Files
maildev-maildev/packages/smtp/src/relay.ts
T
dualfroz 53640c6990 fix(smtp): reject the relay when the outgoing server cannot be reached
SMTPConnection only calls the connect() callback once the handshake
succeeds; a refused or timed out connection is emitted as an 'error'
event, which was only logged. relay() then never settled, so its finally
block (which now releases the email stream) never ran, auto-relay held
the incoming SMTP transaction open without a reply, and the REST relay
request hung. Reject the connect promise from the 'error' handler.
2026-10-02 15:59:20 +02:00

307 lines
7.3 KiB
TypeScript

/**
* @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<RelayResult> {
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) {
emailStream.destroy()
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 {
// The stream holds an open file descriptor until it is fully read or
// destroyed; on a failed relay it was never (fully) piped to the server.
emailStream.destroy()
await this.disconnect()
}
}
/**
* Close the client connection
*/
async close(): Promise<void> {
await this.disconnect()
}
/**
* Connect to the SMTP server
*/
private async connect(): Promise<void> {
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,
})
// A failed connection is reported here, not through the connect callback
this.client.on('error', (err: Error) => {
this.logger.error('SMTP Connection error:', err)
reject(err)
})
this.client.connect((err?: Error) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
/**
* Login to the SMTP server
*/
private async login(): Promise<void> {
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<void> {
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<void> {
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)
}