Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Contributors

This handcrafted artisinal software is brought to you by:
This handcrafted artisanal software is brought to you by:

| <img height="80" src="https://avatars.githubusercontent.com/u/261635?v=4"><br><a href="https://github.com/msimerson">msimerson</a> (<a href="https://github.com/haraka/message-stream/commits?author=msimerson">6</a>) |
| <img height="80" src="https://avatars.githubusercontent.com/u/261635?v=4"><br><a href="https://github.com/msimerson">msimerson</a> (<a href="https://github.com/haraka/message-stream/commits?author=msimerson">7</a>) |
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |

<sub>this file is generated by [.release](https://github.com/msimerson/.release).
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- leave these buried at the bottom of the document -->

[ci-img]: https://github.com/haraka/message-stream/actions/workflows/ci.yml/badge.svg
Expand Down
8 changes: 7 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ export default [
},

rules: {
'no-unused-vars': ['warn'],
'no-unused-vars': [
'warn',
{
args: 'none',
caughtErrorsIgnorePattern: 'ignore',
},
],
},
},
]
37 changes: 19 additions & 18 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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 &&
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -400,7 +403,7 @@ class MessageStream extends Stream {
} else {
fs.unlink(this.filename, () => {})
}
} catch (err) {
} catch {
// Ignore any errors
}
}
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand All @@ -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"
},
Expand All @@ -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": {
Expand Down
72 changes: 33 additions & 39 deletions test/chunk-emitter.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
77 changes: 59 additions & 18 deletions test/message-stream.js
Original file line number Diff line number Diff line change
@@ -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)
})


})