haraka-plugin-amqp_reporting 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 1.0.0 - 2026-05-09
2
+
3
+ - Initial release
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Haraka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # haraka-plugin-amqp_reporting
2
+
3
+ [![npm version](https://img.shields.io/npm/v/haraka-plugin-amqp_reporting.svg)](https://www.npmjs.com/package/haraka-plugin-amqp_reporting)
4
+ [![CI](https://github.com/brassnode/haraka-plugin-amqp_reporting/actions/workflows/ci.yml/badge.svg)](https://github.com/brassnode/haraka-plugin-amqp_reporting/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ Haraka plugin that publishes outbound delivery outcome events to a RabbitMQ topic exchange.
8
+
9
+ Reports `delivered`, `bounced`, and `deferred` outcomes after each delivery attempt so downstream services can update job status without polling Haraka directly.
10
+
11
+ ## Features
12
+
13
+ - Three delivery outcome events (`delivered`, `bounced`, `deferred`) published to a RabbitMQ topic exchange
14
+ - Per-message routing keys (`outcome.delivered`, `outcome.bounced`, `outcome.deferred`) for selective consumer binding
15
+ - Confirm-channel publishing — the broker acknowledges each message before the plugin considers it sent
16
+ - 5-second per-publish timeout prevents a stalled broker from blocking Haraka's delivery pipeline
17
+ - One AMQP connection per worker process; connections are isolated and re-established automatically on worker restart
18
+ - Stashes `X-Job-Id` and `X-Ip-Id` headers at queue time and strips them from the outbound message
19
+ - Compatible with any AMQP 0-9-1 broker via `amqp://` or `amqps://` URLs (RabbitMQ, AWS MQ, CloudAMQP, etc.)
20
+
21
+ ## Requirements
22
+
23
+ - Node.js >= 22
24
+ - Haraka SMTP server
25
+ - RabbitMQ broker reachable from each Haraka worker
26
+
27
+ ## Install
28
+
29
+ Navigate to your Haraka installation directory:
30
+
31
+ ```sh
32
+ cd /path/to/local/haraka
33
+ ```
34
+
35
+ Install the plugin:
36
+
37
+ ```sh
38
+ npm install haraka-plugin-amqp_reporting
39
+ ```
40
+
41
+ Register the plugin:
42
+
43
+ ```sh
44
+ echo "amqp_reporting" >> config/plugins
45
+ ```
46
+
47
+ Restart Haraka:
48
+
49
+ ```sh
50
+ service haraka restart
51
+ ```
52
+
53
+ ## Configuration
54
+
55
+ Copy the default config into your Haraka config directory:
56
+
57
+ ```sh
58
+ cp node_modules/haraka-plugin-amqp_reporting/config/amqp_reporting.ini config/amqp_reporting.ini
59
+ ```
60
+
61
+ ### amqp_reporting.ini
62
+
63
+ ```ini
64
+ [main]
65
+ ; Set to false to disable the plugin entirely
66
+ enabled = true
67
+
68
+ ; AMQP broker URL — supports amqp:// and amqps://
69
+ amqp_url = amqp://guest:guest@localhost:5672
70
+
71
+ ; Topic exchange to publish delivery outcome events to
72
+ exchange = mailing.delivery.outcomes
73
+ ```
74
+
75
+ The plugin declares the exchange as `topic, durable` on startup. The exchange must not already exist with different options.
76
+
77
+ ## How it works
78
+
79
+ The plugin uses two inbound hooks and three outbound delivery hooks:
80
+
81
+ | Hook | Action |
82
+ | ------------ | ------------------------------------------------------------------ |
83
+ | `init_child` | Opens one AMQP connection + confirm channel per worker process |
84
+ | `queue` | Stashes `X-Job-Id`/`X-Ip-Id` into `hmail.todo.notes`; strips both |
85
+ | `delivered` | Publishes `outcome.delivered` after a 2xx acceptance |
86
+ | `bounce` | Publishes `outcome.bounced` after a permanent 5xx or exhausted 4xx |
87
+ | `deferred` | Publishes `outcome.deferred` on each temporary 4xx deferral |
88
+
89
+ ## Event schema
90
+
91
+ Every message published to RabbitMQ has the following JSON body:
92
+
93
+ | Field | Type | Description |
94
+ | ------------------ | ------ | ----------------------------------------------------- |
95
+ | `jobId` | string | Value of `X-Job-Id` header stashed at queue time |
96
+ | `ipId` | string | Value of `X-Ip-Id` header stashed at queue time |
97
+ | `status` | string | `"delivered"`, `"bounced"`, or `"deferred"` |
98
+ | `smtpCode` | number | Numeric SMTP response code (e.g. `250`, `550`, `421`) |
99
+ | `smtpMessage` | string | Full SMTP response text |
100
+ | `recipientAddress` | string | Recipient email address from `RCPT TO` |
101
+ | `domain` | string | Destination domain |
102
+ | `attemptedAt` | string | ISO 8601 timestamp of the delivery attempt |
103
+
104
+ ## Routing keys
105
+
106
+ | Routing key | Fires when |
107
+ | ------------------- | ------------------------------------------------------ |
108
+ | `outcome.delivered` | Destination server accepted the message (2xx) |
109
+ | `outcome.bounced` | Permanent rejection (5xx) or exhausted 4xx retry queue |
110
+ | `outcome.deferred` | Temporary deferral (4xx), Haraka will retry |
111
+
112
+ Bind all three routing keys to the same queue if a single consumer handles all outcomes:
113
+
114
+ ```sh
115
+ rabbitmqadmin declare queue name=mailing.delivery.outcomes.q durable=true
116
+ rabbitmqadmin declare binding source=mailing.delivery.outcomes \
117
+ destination=mailing.delivery.outcomes.q routing_key="outcome.*"
118
+ ```
119
+
120
+ ## Reliability notes
121
+
122
+ - One AMQP connection per worker process; no connection is shared across workers.
123
+ - Publishes use confirm channels — the broker acknowledges each message before the plugin considers it sent.
124
+ - A 5-second per-publish timeout prevents a stalled broker from blocking Haraka's delivery pipeline. If a publish times out or is nack'd, the error is logged and delivery continues unaffected.
125
+ - If the AMQP connection drops, the plugin logs the error and stops publishing. Worker restart re-establishes the connection via `hook_init_child`.
126
+
127
+ ## Changelog
128
+
129
+ See [CHANGELOG.md](CHANGELOG.md).
130
+
131
+ ## Contributing
132
+
133
+ Pull requests are welcome. Please open an issue first to discuss significant changes.
134
+
135
+ Run tests:
136
+
137
+ ```sh
138
+ npm test
139
+ ```
140
+
141
+ Check code style:
142
+
143
+ ```sh
144
+ npm run lint
145
+ ```
146
+
147
+ Auto-fix style:
148
+
149
+ ```sh
150
+ npm run format
151
+ ```
152
+
153
+ ## License
154
+
155
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+
2
+ [main]
3
+ ; Set to false to disable the plugin entirely
4
+ enabled = true
5
+
6
+ ; AMQP broker URL — supports amqp:// and amqps://
7
+ amqp_url = amqp://guest:guest@localhost:5672
8
+
9
+ ; Topic exchange to publish delivery outcome events to (must be pre-declared or declared here)
10
+ exchange = mailing.delivery.outcomes
package/index.js ADDED
@@ -0,0 +1,168 @@
1
+ 'use strict'
2
+
3
+ const amqplib = require('amqplib/callback_api')
4
+
5
+ const plugin = exports
6
+
7
+ plugin.register = function () {
8
+ this.load_amqp_reporting_ini()
9
+
10
+ if (!this.cfg.main.enabled) {
11
+ this.loginfo('disabled via config')
12
+ return
13
+ }
14
+
15
+ this.register_hook('init_child', 'hook_init_child')
16
+ this.register_hook('queue', 'hook_queue')
17
+ this.register_hook('delivered', 'hook_delivered')
18
+ this.register_hook('bounce', 'hook_bounce')
19
+ this.register_hook('deferred', 'hook_deferred')
20
+ }
21
+
22
+ plugin.load_amqp_reporting_ini = function () {
23
+ this.cfg = this.config.get(
24
+ 'amqp_reporting.ini',
25
+ { booleans: ['+enabled'] },
26
+ () => { this.load_amqp_reporting_ini() },
27
+ )
28
+ }
29
+
30
+ plugin.hook_init_child = function (next) {
31
+ const url = this.cfg.main.amqp_url
32
+ this._connect(url, (err, conn) => {
33
+ if (err) {
34
+ this.logerror(`AMQP connect failed: ${err.message}`)
35
+ return next()
36
+ }
37
+ conn.on('error', (e) => {
38
+ this.logerror(`AMQP connection error: ${e.message}`)
39
+ this.amqp = null
40
+ })
41
+ conn.createConfirmChannel((assertErr, ch) => this._setup_channel(next, conn, assertErr, ch))
42
+ })
43
+ }
44
+
45
+ plugin._connect = function (url, cb) {
46
+ amqplib.connect(url, cb)
47
+ }
48
+
49
+ plugin._setup_channel = function (next, conn, err, ch) {
50
+ if (err) {
51
+ this.logerror(`AMQP channel failed: ${err.message}`)
52
+ return next()
53
+ }
54
+ const exchange = this.cfg.main.exchange
55
+ ch.assertExchange(exchange, 'topic', { durable: true }, (assertErr) => {
56
+ if (assertErr) {
57
+ this.logerror(`AMQP assertExchange failed: ${assertErr.message}`)
58
+ return next()
59
+ }
60
+ this.amqp = { conn, ch, exchange }
61
+ this.loginfo(`AMQP ready - exchange: ${exchange}`)
62
+ next()
63
+ })
64
+ }
65
+
66
+ plugin.hook_queue = function (next, connection) {
67
+ const txn = connection.transaction
68
+ if (!txn) return next()
69
+
70
+ const jobId = txn.header.get('X-Job-Id') || ''
71
+ const ipId = txn.header.get('X-Ip-Id') || ''
72
+
73
+ txn.notes.amqp_job_id = jobId.trim()
74
+ txn.notes.amqp_ip_id = ipId.trim()
75
+
76
+ txn.remove_header('X-Job-Id')
77
+ txn.remove_header('X-Ip-Id')
78
+
79
+ next()
80
+ }
81
+
82
+ plugin.hook_delivered = function (next, hmail, connection, params) {
83
+ const notes = (hmail && hmail.todo && hmail.todo.notes) || {}
84
+ const rcpt = params && params[0] ? params[0].address() : ''
85
+ const msg = (params && params[1]) || ''
86
+ const domain = (params && params[2]) || ''
87
+
88
+ this._publish('outcome.delivered', {
89
+ jobId: notes.amqp_job_id || '',
90
+ ipId: notes.amqp_ip_id || '',
91
+ status: 'delivered',
92
+ smtpCode: this._parse_smtp_code(msg, 250),
93
+ smtpMessage: msg,
94
+ recipientAddress: rcpt,
95
+ domain,
96
+ attemptedAt: new Date().toISOString(),
97
+ })
98
+
99
+ next()
100
+ }
101
+
102
+ plugin.hook_bounce = function (next, hmail, error) {
103
+ const notes = (hmail && hmail.todo && hmail.todo.notes) || {}
104
+ const rcpts = (hmail && hmail.todo && hmail.todo.rcpt_to) || []
105
+ const code = (error && error.code) || 0
106
+ const msg = (error && error.message) || ''
107
+
108
+ this._publish('outcome.bounced', {
109
+ jobId: notes.amqp_job_id || '',
110
+ ipId: notes.amqp_ip_id || '',
111
+ status: 'bounced',
112
+ smtpCode: code,
113
+ smtpMessage: msg,
114
+ recipientAddress: rcpts.length ? rcpts[0].address() : '',
115
+ domain: (hmail && hmail.todo && hmail.todo.domain) || '',
116
+ attemptedAt: new Date().toISOString(),
117
+ })
118
+
119
+ next()
120
+ }
121
+
122
+ plugin.hook_deferred = function (next, hmail, params) {
123
+ const notes = (hmail && hmail.todo && hmail.todo.notes) || {}
124
+ const domain = (params && params[0]) || ''
125
+ const msg = (params && params[1]) || ''
126
+ const rcpt = params && params[2] ? params[2].address() : ''
127
+
128
+ this._publish('outcome.deferred', {
129
+ jobId: notes.amqp_job_id || '',
130
+ ipId: notes.amqp_ip_id || '',
131
+ status: 'deferred',
132
+ smtpCode: this._parse_smtp_code(msg, 421),
133
+ smtpMessage: msg,
134
+ recipientAddress: rcpt,
135
+ domain,
136
+ attemptedAt: new Date().toISOString(),
137
+ })
138
+
139
+ next()
140
+ }
141
+
142
+ plugin._publish = function (routingKey, event) {
143
+ if (!this.amqp) return
144
+
145
+ const { ch, exchange } = this.amqp
146
+ const body = Buffer.from(JSON.stringify(event))
147
+ const options = { persistent: true, mandatory: true }
148
+ const timeout = this.PUBLISH_TIMEOUT !== undefined ? this.PUBLISH_TIMEOUT : 5000
149
+
150
+ let settled = false
151
+
152
+ const timer = setTimeout(() => {
153
+ settled = true
154
+ this.logerror(`AMQP publish timeout - routingKey: ${routingKey}, jobId: ${event.jobId}`)
155
+ }, timeout)
156
+
157
+ ch.publish(exchange, routingKey, body, options, (err) => {
158
+ if (settled) return
159
+ settled = true
160
+ clearTimeout(timer)
161
+ if (err) this.logerror(`AMQP publish nack - routingKey: ${routingKey}: ${err.message}`)
162
+ })
163
+ }
164
+
165
+ plugin._parse_smtp_code = function (msg, fallback) {
166
+ const m = /^(\d{3})/.exec(msg || '')
167
+ return m ? parseInt(m[1], 10) : fallback
168
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "haraka-plugin-amqp_reporting",
3
+ "version": "1.1.1",
4
+ "description": "Haraka plugin that publishes outbound delivery outcome events to a RabbitMQ topic exchange",
5
+ "main": "index.js",
6
+ "files": [
7
+ "CHANGELOG.md",
8
+ "config"
9
+ ],
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "scripts": {
14
+ "format": "npm run prettier:fix && npm run lint:fix",
15
+ "lint": "npx eslint *.js test",
16
+ "lint:fix": "npx eslint *.js test --fix",
17
+ "prettier": "npx prettier . --check",
18
+ "prettier:fix": "npx prettier . --write --log-level=warn",
19
+ "test": "node --test",
20
+ "test:coverage": "HARAKA_COVERAGE=1 npx c8 npm test",
21
+ "versions": "npx dependency-version-checker check",
22
+ "versions:fix": "npx dependency-version-checker update"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/haraka/haraka-plugin-amqp_reporting.git"
27
+ },
28
+ "keywords": [
29
+ "haraka-plugin",
30
+ "amqp_reporting"
31
+ ],
32
+ "author": "Abdulmatin Sanni <abdulmatin@brassnode.com>",
33
+ "license": "MIT",
34
+ "bugs": {
35
+ "url": "https://github.com/haraka/haraka-plugin-amqp_reporting/issues"
36
+ },
37
+ "homepage": "https://github.com/haraka/haraka-plugin-amqp_reporting#readme",
38
+ "dependencies": {
39
+ "amqplib": "^0.10.4"
40
+ },
41
+ "devDependencies": {
42
+ "@haraka/eslint-config": "^2.0.2",
43
+ "haraka-test-fixtures": "^1.3.8"
44
+ },
45
+ "prettier": {
46
+ "printWidth": 100,
47
+ "singleQuote": true,
48
+ "semi": false
49
+ }
50
+ }