diff --git a/.release b/.release index 7307651..e5ca169 160000 --- a/.release +++ b/.release @@ -1 +1 @@ -Subproject commit 73076513e83c2057a32515831b638771c15b1d83 +Subproject commit e5ca16937e4a27d394544100e39e2d1fb532e77e diff --git a/CHANGELOG.md b/CHANGELOG.md index d89d8b8..162469b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Unreleased +### [1.3.0] - 2025-06-27 + +- fix: also remove dot-stuffing from leftovers #9 + - thanks to report at haraka/haraka-plugin-dkim#17 +- test: add tests for removing dot-stuffing +- fix: replace polynomial regex with trimEnd() +- change: rename dot_stuffing -> dot_stuffed, consistent with Haraka + - improves readability, fixes a case of the not nots +- change: switch test runner from mocha to `node --test` +- doc(README): add ref to Haraka Transaction docs showing usage +- deps(test-fixtures): bump to latest + ### [1.2.3] - 2025-02-02 - dep(eslint): upgrade to v9 @@ -43,3 +55,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). [1.2.2]: https://github.com/haraka/message-stream/releases/tag/v1.2.2 [1.2.3]: https://github.com/haraka/message-stream/releases/tag/v1.2.3 [1.0.0]: https://github.com/haraka/message-stream/releases/tag/v1.0.0 +[1.3.0]: https://github.com/haraka/message-stream/releases/tag/v1.3.0 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index c1bfa38..2212ac0 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,8 +1,8 @@ # Contributors -This handcrafted artisinal software is brought to you by: +This handcrafted artisanal software is brought to you by: -|
msimerson (6) | +|
msimerson (7) | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | this file is generated by [.release](https://github.com/msimerson/.release). diff --git a/README.md b/README.md index d661c6f..6d82975 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ new MessageStream(cfg, uuid, header_list) ``` +## REFERENCES + +Usage of this module is documented in the Haraka [Transaction](https://haraka.github.io/core/Transaction) docs. + [ci-img]: https://github.com/haraka/message-stream/actions/workflows/ci.yml/badge.svg diff --git a/eslint.config.mjs b/eslint.config.mjs index 0377960..41dd6b0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,7 +23,13 @@ export default [ }, rules: { - 'no-unused-vars': ['warn'], + 'no-unused-vars': [ + 'warn', + { + args: 'none', + caughtErrorsIgnorePattern: 'ignore', + }, + ], }, }, ] diff --git a/index.js b/index.js index 6937576..e84e78d 100644 --- a/index.js +++ b/index.js @@ -41,7 +41,7 @@ class MessageStream extends Stream { this.headers_done = false this.headers_found_eoh = false this.line_endings = '\r\n' - this.dot_stuffing = false + this.dot_stuffed = cfg?.main.dot_stuffed ?? true this.ending_dot = false this.buffer_size = 1024 * 64 this.start = 0 @@ -81,7 +81,7 @@ class MessageStream extends Stream { if (this.state === STATE.BODY) { // Look for MIME boundaries if (line.length > 4 && line[0] === 0x2d && line[1] == 0x2d) { - let boundary = line.slice(2).toString().replace(/\s*$/, '') + let boundary = line.slice(2).toString().trimEnd() if (/--\s*$/.test(line)) { // End of boundary? boundary = boundary.slice(0, -2) @@ -261,6 +261,15 @@ class MessageStream extends Stream { } } + remove_dot_stuffing(buf) { + if (!this.dot_stuffed) return buf + + if (buf.length >= 4 && buf[0] === 0x2e && buf[1] === 0x2e) { + return buf.slice(1) + } + return buf + } + process_buf(buf) { let offset = 0 while ((offset = indexOfLF(buf)) !== -1) { @@ -277,16 +286,10 @@ class MessageStream extends Stream { } continue } - // Remove dot-stuffing if required - if ( - !this.dot_stuffing && - line.length >= 4 && - line[0] === 0x2e && - line[1] === 0x2e - ) { - line = line.slice(1) - } - // We store lines in native CRLF format; so strip CR if requested + + line = this.remove_dot_stuffing(line) + + // lines are stored in native CRLF format; strip CR if requested if ( this.line_endings === '\n' && line.length >= 2 && @@ -302,7 +305,7 @@ class MessageStream extends Stream { } // Check for data left in the buffer if (buf.length > 0 && this.headers_found_eoh) { - this.read_ce.fill(buf) + this.read_ce.fill(this.remove_dot_stuffing(buf)) } } @@ -334,7 +337,7 @@ class MessageStream extends Stream { Stream.prototype.pipe.call(this, destination, options) // Options this.line_endings = options?.line_endings ?? '\r\n' - this.dot_stuffing = options?.dot_stuffing ?? false + this.dot_stuffed = options?.dot_stuffed ?? true this.ending_dot = options?.ending_dot ?? false this.clamd_style = !!options?.clamd_style this.buffer_size = options?.buffer_size ?? 1024 * 64 @@ -400,7 +403,7 @@ class MessageStream extends Stream { } else { fs.unlink(this.filename, () => {}) } - } catch (err) { + } catch { // Ignore any errors } } @@ -463,9 +466,7 @@ class ChunkEmitter extends EventEmitter { } fill(input) { - if (typeof input === 'string') { - input = Buffer.from(input) - } + if (typeof input === 'string') input = Buffer.from(input) // Optimization: don't allocate a new buffer until the input we've // had so far is bigger than our buffer size. diff --git a/package.json b/package.json index 16f2c55..59f1913 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "haraka-message-stream", - "version": "1.2.3", + "version": "1.3.0", "description": "Haraka email message stream", "main": "index.js", "files": [ @@ -12,7 +12,7 @@ "lint:fix": "npx eslint@^9 --fix *.js test", "prettier": "npx prettier . --check", "prettier:fix": "npx prettier . --write --log-level=warn", - "test": "npx mocha@^11", + "test": "node --test", "versions": "npx dependency-version-checker check", "versions:fix": "npx dependency-version-checker update && npm run prettier:fix" }, @@ -32,8 +32,8 @@ }, "homepage": "https://github.com/haraka/message-stream#readme", "devDependencies": { - "@haraka/eslint-config": "^2.0.2", - "haraka-test-fixtures": "^1.3.9" + "@haraka/eslint-config": "^2.0.3", + "haraka-test-fixtures": "^1.3.10" }, "dependencies": {}, "prettier": { diff --git a/test/chunk-emitter.js b/test/chunk-emitter.js index 705e728..9e3d8fd 100644 --- a/test/chunk-emitter.js +++ b/test/chunk-emitter.js @@ -1,66 +1,60 @@ const assert = require('assert') +const { describe, it } = require('node:test') const fs = require('fs') const path = require('path') const ChunkEmitter = require('../index').ChunkEmitter describe('chunk-emitter', function () { - beforeEach(function () { - this.ce = new ChunkEmitter() - this._written = 0 - }) - - it('loads', function () { - assert.ok(this.ce) - }) - - it('emits all unbuffered bytes', function (done) { + it('emits all unbuffered bytes', async () => { const msgPath = path.join(__dirname, 'fixtures', 'haraka-icon-attach.eml') const eml = fs.readFileSync(msgPath, 'utf8') - this._write = (data) => { - this._written = (this._written || 0) + data.length - if (eml.length === this._written) { - assert.equal(eml.length, this._written) - done() - } - } + let written = 0 - this.ce.on('data', (chunk) => { - this._write(chunk) + const ce = new ChunkEmitter() + + const dataPromise = new Promise((resolve) => { + ce.on('data', (chunk) => { + written += chunk.length + if (written === eml.length) { + resolve(written) + } + }) }) - this.ce.fill(eml) - this.ce.end() + ce.fill(eml) + ce.end() + + const total = await dataPromise + assert.equal(total, eml.length) }) - it('emits all bigger than buffer bytes', function (done) { + it('emits all bigger than buffer bytes', async () => { const msgPath = path.join( __dirname, 'fixtures', 'haraka-tarball-attach.eml', ) - // console.log(`msgPath: ${msgPath}`) const eml = fs.readFileSync(msgPath, 'utf8') - // console.log(`length: ${eml.length}`) - this._write = (data) => { - // console.log(`_write: ${data.length} bytes`) - this._written = this._written + data.length - // console.log(`_written: ${this._written}`) - if (eml.length === this._written) { - assert.equal(eml.length, this._written) - // console.log(this.ce) - done() - } - } + let written = 0 - this.ce.on('data', (chunk) => { - // console.log(`ce.on.data: ${chunk.length} bytes`) - this._write(chunk) + const ce = new ChunkEmitter() + + const dataPromise = new Promise((resolve) => { + ce.on('data', (chunk) => { + written += chunk.length + if (written === eml.length) { + resolve(written) + } + }) }) - this.ce.fill(eml) - this.ce.end() + ce.fill(eml) + ce.end() + + const total = await dataPromise + assert.equal(total, eml.length) }) }) diff --git a/test/message-stream.js b/test/message-stream.js index de11d14..1e83817 100644 --- a/test/message-stream.js +++ b/test/message-stream.js @@ -1,29 +1,70 @@ -const assert = require('assert') +const assert = require('assert/strict') +const { describe, it } = require('node:test') const stream = require('stream') const MessageStream = require('../index') -function _set_up() { - this.ms = new MessageStream({ main: {} }, 'msg', []) +describe('message-stream', () => { + it('is a Stream', () => { + const ms = new MessageStream({ main: {} }, 'msg', []) + assert.ok(ms instanceof MessageStream) + assert.ok(ms instanceof stream.Stream) + }) + + it('gets message data', async () => { + const ms = new MessageStream({ main: {} }, 'msg', []) + ms.add_line('Header: test\r\n') + ms.add_line('\r\n') + ms.add_line('I am body text\r\n') + ms.add_line_end() + + const data = await new Promise((resolve) => { + ms.get_data((data) => resolve(data)) + }) + assert.ok(/^[A-Za-z]+: /.test(data.toString())) + }) +}) + +function getOutputFromStream(inputLines) { + return new Promise((resolve) => { + const ms = new MessageStream({ main: {} }, 'msg', []) + const output = new stream.PassThrough() + const chunks = [] + + output.on('data', chunk => chunks.push(chunk.toString())) + output.on('end', () => resolve(chunks.join(''))) + + ms.pipe(output, { dot_stuffed: true }) + + inputLines.forEach(line => ms.add_line(line)) + ms.add_line_end() + }) } -describe('message-stream', function () { - beforeEach(_set_up) +describe('dot-unstuffing', function () { - it('is a Stream', function (done) { - assert.ok(this.ms instanceof MessageStream) - assert.ok(this.ms instanceof stream.Stream) - done() + it('unstuffs "..\\r\\n" to ".\\r\\n"', async () => { + const result = await getOutputFromStream(['..\r\n']) + assert.match(result, /^.\r\n/m) }) - it('gets message data', function (done) { - this.ms.add_line('Header: test\r\n') - this.ms.add_line('\r\n') - this.ms.add_line('I am body text\r\n') - this.ms.add_line_end() - this.ms.get_data((data) => { - assert.ok(/^[A-Za-z]+: /.test(data.toString())) - done() - }) + it('unstuffs "..dot start\\r\\n" to ".dot start\\r\\n"', async () => { + const result = await getOutputFromStream(['..dot start\r\n']) + assert.match(result, /^.dot start\r\n/m) + }) + + it('leaves normal lines untouched', async () => { + const result = await getOutputFromStream([ + 'hello\r\n', + '..dot line\r\n', + '..\r\n', + ]) + + assert.equal(result, 'hello\r\n.dot line\r\n.\r\n') + assert.match(result, /^hello\r\n/m) + assert.match(result, /^.dot line\r\n/m) + assert.match(result, /^.\r\n/m) }) + + })