Upstream maildev/maildev state of the touched files before 661742ab9d
This commit is contained in:
1 parent
a10bcd3e4c
commit
27e7633a30
2 files changed
+411
No files matched your search
@@ -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<number> {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<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) {
|
||||||
|
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<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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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<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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user