From 5479067fab0a5d439ec437c3b8a8f2875a60be47 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 2 Oct 2026 15:57:49 +0200 Subject: [PATCH] fix(smtp): close the raw email stream when a relay does not send it (#423) relayEmail opens a read stream on the .eml file for every relay, and only client.send() ever consumed it. When the auto-relay rules left no recipients, or the outgoing server failed the send (e.g. rejected a recipient), the stream was never read or destroyed and its file descriptor stayed open. Destroy it on the early return and in the finally block; after a successful send it is already fully read, so that is a no-op. --- .changeset/fix-relay-stream-leak.md | 10 ++++ packages/smtp/src/__tests__/relay.test.ts | 66 ++++++++++++++++++++++- packages/smtp/src/relay.ts | 4 ++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-relay-stream-leak.md diff --git a/.changeset/fix-relay-stream-leak.md b/.changeset/fix-relay-stream-leak.md new file mode 100644 index 0000000..315dccc --- /dev/null +++ b/.changeset/fix-relay-stream-leak.md @@ -0,0 +1,10 @@ +--- +'@maildev/smtp': patch +'maildev': patch +--- + +Close the raw `.eml` stream when a relay does not send it (#423). Every relay +that ended without sending (no recipients left after the auto-relay rules, or +an error from the outgoing server) kept one file descriptor open, so a +long-running instance eventually hit the descriptor limit and auto-relay +stopped working. diff --git a/packages/smtp/src/__tests__/relay.test.ts b/packages/smtp/src/__tests__/relay.test.ts index eadda5b..1e0a2ff 100644 --- a/packages/smtp/src/__tests__/relay.test.ts +++ b/packages/smtp/src/__tests__/relay.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtemp, rm } from 'node:fs/promises' +import { createReadStream } from 'node:fs' +import { mkdtemp, rm, writeFile } 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 as SMTPServerLib } from 'smtp-server' +import { MemoryStorage, FileStorage, type Email } from '@maildev/core' import { SMTPServer } from '../server.js' +import { RelayClient } from '../relay.js' /** Grab an ephemeral free port so parallel test files don't collide. */ function freePort(): Promise { @@ -109,3 +112,62 @@ describe('relay delivery status', () => { expect(restored.relayedTo).toContain('b@example.com') }) }) + +describe('relay raw email stream', () => { + let dir: string + let emlPath: string + + const email = { + id: 'leak0001', + subject: 'relay me', + envelope: { + from: { address: 'a@example.com' }, + to: [{ address: 'b@example.com' }], + }, + } as Email + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'maildev-relay-stream-')) + emlPath = join(dir, `${email.id}.eml`) + await writeFile(emlPath, 'Subject: relay me\r\n\r\nhello\r\n') + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('closes the stream when rules leave no recipients', async () => { + const client = new RelayClient({ host: '127.0.0.1', port: await freePort() }, false) + client.setAutoRelay({ enabled: true, rules: [{ deny: '*' }] }) + const stream = createReadStream(emlPath) + + const result = await client.relay(email, stream, true) + + expect(result.success).toBe(false) + expect(stream.destroyed).toBe(true) + }) + + it('closes the stream when the outgoing server rejects the recipient', async () => { + const port = await freePort() + const rejecting = new SMTPServerLib({ + authOptional: true, + disabledCommands: ['STARTTLS'], + onRcptTo(_address, _session, callback) { + callback(new Error('Recipient rejected')) + }, + }) + await new Promise((resolve) => rejecting.listen(port, '127.0.0.1', resolve)) + + try { + const client = new RelayClient({ host: '127.0.0.1', port }, false) + const stream = createReadStream(emlPath) + + const result = await client.relay(email, stream) + + expect(result.success).toBe(false) + expect(stream.destroyed).toBe(true) + } finally { + await new Promise((resolve) => rejecting.close(() => resolve())) + } + }) +}) diff --git a/packages/smtp/src/relay.ts b/packages/smtp/src/relay.ts index 7fe5850..fcb982e 100644 --- a/packages/smtp/src/relay.ts +++ b/packages/smtp/src/relay.ts @@ -100,6 +100,7 @@ export class RelayClient { // Fail silently if no recipients if (recipients.length === 0) { + emailStream.destroy() return { success: false, message: 'Email had no recipients after filtering', @@ -132,6 +133,9 @@ export class RelayClient { 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() } }