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)
})
+
+
})