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.
This commit is contained in:
1 parent
27e7633a30
commit
5479067fab
3 files changed
+78
-2
No files matched your search
@@ -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.
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import net, { type AddressInfo } from 'node:net'
|
import net, { type AddressInfo } from 'node:net'
|
||||||
import nodemailer from 'nodemailer'
|
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 { SMTPServer } from '../server.js'
|
||||||
|
import { RelayClient } from '../relay.js'
|
||||||
|
|
||||||
/** Grab an ephemeral free port so parallel test files don't collide. */
|
/** Grab an ephemeral free port so parallel test files don't collide. */
|
||||||
function freePort(): Promise<number> {
|
function freePort(): Promise<number> {
|
||||||
@@ -109,3 +112,62 @@ describe('relay delivery status', () => {
|
|||||||
expect(restored.relayedTo).toContain('b@example.com')
|
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<void>((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<void>((resolve) => rejecting.close(() => resolve()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -100,6 +100,7 @@ export class RelayClient {
|
|||||||
|
|
||||||
// Fail silently if no recipients
|
// Fail silently if no recipients
|
||||||
if (recipients.length === 0) {
|
if (recipients.length === 0) {
|
||||||
|
emailStream.destroy()
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Email had no recipients after filtering',
|
message: 'Email had no recipients after filtering',
|
||||||
@@ -132,6 +133,9 @@ export class RelayClient {
|
|||||||
error: err,
|
error: err,
|
||||||
}
|
}
|
||||||
} finally {
|
} 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()
|
await this.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user