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 +3 -0
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/config/amqp_reporting.ini +10 -0
- package/index.js +168 -0
- package/package.json +50 -0
package/CHANGELOG.md
ADDED
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
|
+
[](https://www.npmjs.com/package/haraka-plugin-amqp_reporting)
|
|
4
|
+
[](https://github.com/brassnode/haraka-plugin-amqp_reporting/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
+
}
|