haraka-plugin-spamassassin 1.0.0
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 +11 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/config/spamassassin.ini +56 -0
- package/index.js +405 -0
- package/package.json +44 -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,192 @@
|
|
|
1
|
+
[![CI Test Status][ci-img]][ci-url]
|
|
2
|
+
[![Code Climate][clim-img]][clim-url]
|
|
3
|
+
|
|
4
|
+
[![NPM][npm-img]][npm-url]
|
|
5
|
+
|
|
6
|
+
# haraka-plugin-spamassassin
|
|
7
|
+
|
|
8
|
+
This plugin implements the spamd protocol and will send messages to spamd for scoring.
|
|
9
|
+
|
|
10
|
+
## Configuration
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
cp node_modules/haraka-plugin-spamassassin/config/spamassassin.ini config/spamassassin.ini
|
|
14
|
+
$EDITOR config/spamassassin.ini
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
spamassassin.ini
|
|
18
|
+
|
|
19
|
+
- spamd_socket = \[host:port | /path/to/socket\] _optional_
|
|
20
|
+
|
|
21
|
+
Default: localhost:783
|
|
22
|
+
|
|
23
|
+
Host or path to socket where spamd is running.
|
|
24
|
+
|
|
25
|
+
- spamd_user = \[user\] _optional_
|
|
26
|
+
|
|
27
|
+
Default: default
|
|
28
|
+
|
|
29
|
+
Username to pass to spamd. This is useful when you are running
|
|
30
|
+
spamd with virtual users.
|
|
31
|
+
|
|
32
|
+
You can also pass this value in dynamically by setting:
|
|
33
|
+
|
|
34
|
+
1. `connection.transaction.notes.spamd_user` in another plugin.
|
|
35
|
+
|
|
36
|
+
2. The special username: _first-recipient_. The first envelope recipient
|
|
37
|
+
will be used as the username.
|
|
38
|
+
|
|
39
|
+
3. the special username _all-recipients_ may eventually be supported. See
|
|
40
|
+
the get_spamd_username function in the plugin.
|
|
41
|
+
|
|
42
|
+
- max_size = N _optional_
|
|
43
|
+
|
|
44
|
+
Default: 500000
|
|
45
|
+
|
|
46
|
+
Maximum size of messages (in bytes) to send to spamd.
|
|
47
|
+
Messages over this size will be skipped.
|
|
48
|
+
|
|
49
|
+
- reject_threshold = N _optional_
|
|
50
|
+
|
|
51
|
+
Default: none (do not reject any mail)
|
|
52
|
+
|
|
53
|
+
SpamAssassin score at which the mail should be rejected.
|
|
54
|
+
|
|
55
|
+
- relay_reject_threshold = N _optional_
|
|
56
|
+
|
|
57
|
+
Default: none
|
|
58
|
+
|
|
59
|
+
As above, except this threshold only applies to connections
|
|
60
|
+
that are relays (e.g. AUTH) where connection.relaying = true.
|
|
61
|
+
This is used to set a _lower_ thresold at which to reject mail
|
|
62
|
+
from these hosts to prevent sending outbound spam.
|
|
63
|
+
|
|
64
|
+
If this is not set, then the `reject_thresold` value is used.
|
|
65
|
+
|
|
66
|
+
- munge_subject_threshold = N _optional_
|
|
67
|
+
|
|
68
|
+
Default: none (do not munge the subject)
|
|
69
|
+
|
|
70
|
+
Score at which the subject should be munged (prefixed).
|
|
71
|
+
|
|
72
|
+
- subject_prefix = \[prefix\] _optional_
|
|
73
|
+
|
|
74
|
+
Default: **_ SPAM _**
|
|
75
|
+
|
|
76
|
+
Prefix to use when munging the subject.
|
|
77
|
+
|
|
78
|
+
- old_headers_action = \[rename | drop | keep\] _optional_
|
|
79
|
+
|
|
80
|
+
Default: rename
|
|
81
|
+
|
|
82
|
+
If old X-Spam-\* headers are in the email, what do we do with them?
|
|
83
|
+
|
|
84
|
+
`rename` them to X-Old-Spam-\*.
|
|
85
|
+
|
|
86
|
+
`drop` will delete them.
|
|
87
|
+
|
|
88
|
+
`keep` will keep them (new X-Spam-\* headers appear lower down in
|
|
89
|
+
the headers then).
|
|
90
|
+
|
|
91
|
+
- connect_timeout = N _optional_
|
|
92
|
+
|
|
93
|
+
Default: 30
|
|
94
|
+
|
|
95
|
+
Time in seconds to wait for a connection to spamd
|
|
96
|
+
|
|
97
|
+
- results_timeout = N _optional_
|
|
98
|
+
|
|
99
|
+
Default: 300
|
|
100
|
+
|
|
101
|
+
Time in seconds to wait for results from spamd
|
|
102
|
+
|
|
103
|
+
### [check]
|
|
104
|
+
|
|
105
|
+
The optional check section can allow skipping SpamAssassin check for remote connection
|
|
106
|
+
meeting following criteria.
|
|
107
|
+
|
|
108
|
+
- authenticated
|
|
109
|
+
|
|
110
|
+
Default: true
|
|
111
|
+
|
|
112
|
+
If true, messages from authenticated users will be scored.
|
|
113
|
+
|
|
114
|
+
- private_ip
|
|
115
|
+
|
|
116
|
+
Default: true
|
|
117
|
+
|
|
118
|
+
If true, messages from private IPs will be scored.
|
|
119
|
+
|
|
120
|
+
- local_ip
|
|
121
|
+
|
|
122
|
+
Default: true
|
|
123
|
+
|
|
124
|
+
If true, messages from localhost will be scored.
|
|
125
|
+
|
|
126
|
+
- relay
|
|
127
|
+
|
|
128
|
+
Default: true
|
|
129
|
+
|
|
130
|
+
If true, messages that are to be relayed will be scored.
|
|
131
|
+
|
|
132
|
+
### [defer]
|
|
133
|
+
|
|
134
|
+
The optional defer section can allow returning a DENYSOFT status back to the
|
|
135
|
+
client. Setting these to true will force the client to retry later in cases where
|
|
136
|
+
spamassassin is not responding properly. If set to false, then the errors
|
|
137
|
+
will be ignored and message processing will continue.
|
|
138
|
+
|
|
139
|
+
- error
|
|
140
|
+
|
|
141
|
+
Default: false
|
|
142
|
+
|
|
143
|
+
If true, return DENYSOFT on socket errors
|
|
144
|
+
|
|
145
|
+
- connect_timeout
|
|
146
|
+
|
|
147
|
+
Default: false
|
|
148
|
+
|
|
149
|
+
If true, return DENYSOFT on socket connection timeouts
|
|
150
|
+
|
|
151
|
+
- scan_timeout
|
|
152
|
+
|
|
153
|
+
Default: false
|
|
154
|
+
|
|
155
|
+
If true, return DENYSOFT on scan timeouts
|
|
156
|
+
|
|
157
|
+
## Extras
|
|
158
|
+
|
|
159
|
+
A SpamAssassin plugin can be found in the `contrib` directory.
|
|
160
|
+
The `Haraka.\[pm|cf\]` files should be placed in the SpamAssassin local
|
|
161
|
+
site rules directory (/etc/mail/spamassassin on Linux), spamd should be
|
|
162
|
+
restarted and the plugin will make spamd output the Haraka UUID as part
|
|
163
|
+
of its log output to aid debugging when searching the mail logs.
|
|
164
|
+
|
|
165
|
+
## Changes
|
|
166
|
+
|
|
167
|
+
This plugin now passes the X-Spam-\* headers generated by SA through
|
|
168
|
+
unaltered. You can control the presence and appearance of X-Spam-\*
|
|
169
|
+
headers by editing your SpamAssassin config.
|
|
170
|
+
|
|
171
|
+
The default headers added by SpamAssassin are:
|
|
172
|
+
|
|
173
|
+
add_header all Checker-Version SpamAssassin _VERSION_ (_SUBVERSION_) on _HOSTNAME_
|
|
174
|
+
add_header spam Flag _YESNOCAPS_
|
|
175
|
+
add_header all Level _STARS(\*)_
|
|
176
|
+
add_header all Status "_YESNO_, score=_SCORE_ required=_REQD_ tests=_TESTS_ autolearn=_AUTOLEARN_ version=_VERSION_"
|
|
177
|
+
|
|
178
|
+
Other headers options you might find interesting or useful are:
|
|
179
|
+
|
|
180
|
+
add_header all DCC _DCCB_: _DCCR_
|
|
181
|
+
add_header all Tests _TESTS_
|
|
182
|
+
|
|
183
|
+
## USAGE
|
|
184
|
+
|
|
185
|
+
<!-- leave these buried at the bottom of the document -->
|
|
186
|
+
|
|
187
|
+
[ci-img]: https://github.com/haraka/haraka-plugin-spamassassin/actions/workflows/ci.yml/badge.svg
|
|
188
|
+
[ci-url]: https://github.com/haraka/haraka-plugin-spamassassin/actions/workflows/ci.yml
|
|
189
|
+
[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-spamassassin/badges/gpa.svg
|
|
190
|
+
[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-spamassassin
|
|
191
|
+
[npm-img]: https://nodei.co/npm/haraka-plugin-spamassassin.png
|
|
192
|
+
[npm-url]: https://www.npmjs.com/package/haraka-plugin-spamassassin
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
; How does Haraka connect to the SpamAssassin spamd daemon?
|
|
2
|
+
; TCP/IP: 127.0.0.1:783
|
|
3
|
+
; socket: /var/run/spamd/spamd.sock
|
|
4
|
+
spamd_socket=127.0.0.1:783
|
|
5
|
+
|
|
6
|
+
; the username we tell spamd the message is to (default: default)
|
|
7
|
+
;spamd_user=first-recipient (see docs)
|
|
8
|
+
;spamd_user=
|
|
9
|
+
|
|
10
|
+
; messages larger than this are not scored by SA
|
|
11
|
+
max_size=500000
|
|
12
|
+
|
|
13
|
+
; Munge the subject of messages with a score higher than..
|
|
14
|
+
; munge_subject_threshold=5
|
|
15
|
+
subject_prefix=*** SPAM ***
|
|
16
|
+
|
|
17
|
+
; what to do with incoming messages with X-Spam-* headers
|
|
18
|
+
; options are: rename, drop, keep
|
|
19
|
+
old_headers_action=rename
|
|
20
|
+
|
|
21
|
+
; use the SpamAssassin 3.0+ syntax in X-Spam-Status header
|
|
22
|
+
; modern: No, score=0.8 required=8.0 tests=...
|
|
23
|
+
; legacy: No, hits=0.8 required=8.0 tests=...
|
|
24
|
+
modern_status_syntax=1
|
|
25
|
+
|
|
26
|
+
; Reject all messages with more than this many hits
|
|
27
|
+
; reject_threshold=10
|
|
28
|
+
|
|
29
|
+
; when a connection has relay privileges, the rejection limit
|
|
30
|
+
; relay_reject_threshold=7
|
|
31
|
+
|
|
32
|
+
; How long should we wait for SpamAssassin to answer the socket
|
|
33
|
+
; in seconds (default: 30)
|
|
34
|
+
;connect_timeout=
|
|
35
|
+
|
|
36
|
+
; How long should we wait for a result from SpamAssassin
|
|
37
|
+
; in seconds (default: 300)
|
|
38
|
+
;results_timeout=
|
|
39
|
+
|
|
40
|
+
; Merge SpamAssassin's headers into the message
|
|
41
|
+
;add_headers=true
|
|
42
|
+
|
|
43
|
+
; the header that is sent to spamc
|
|
44
|
+
;spamc_auth_header = X-Haraka-Relay
|
|
45
|
+
|
|
46
|
+
[check]
|
|
47
|
+
;authenticated=true
|
|
48
|
+
;private_ip=true
|
|
49
|
+
;local_ip=true
|
|
50
|
+
;relay=true
|
|
51
|
+
|
|
52
|
+
[defer]
|
|
53
|
+
; Set to true to return DENYSOFT on errors, connection timeouts, or scanning timeouts
|
|
54
|
+
;error=false
|
|
55
|
+
;connect_timeout=false
|
|
56
|
+
;scan_timeout=false
|
package/index.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Call spamassassin via spamd
|
|
3
|
+
|
|
4
|
+
const net = require('node:net')
|
|
5
|
+
|
|
6
|
+
const utils = require('haraka-utils')
|
|
7
|
+
const net_utils = require('haraka-net-utils')
|
|
8
|
+
|
|
9
|
+
exports.register = function () {
|
|
10
|
+
this.load_spamassassin_ini()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
exports.load_spamassassin_ini = function () {
|
|
14
|
+
this.cfg = this.config.get(
|
|
15
|
+
'spamassassin.ini',
|
|
16
|
+
{
|
|
17
|
+
booleans: [
|
|
18
|
+
'+add_headers',
|
|
19
|
+
'+check.authenticated',
|
|
20
|
+
'+check.private_ip',
|
|
21
|
+
'+check.local_ip',
|
|
22
|
+
'+check.relay',
|
|
23
|
+
|
|
24
|
+
'-defer.error',
|
|
25
|
+
'-defer.connect_timeout',
|
|
26
|
+
'-defer.scan_timeout',
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
() => {
|
|
30
|
+
this.load_spamassassin_ini()
|
|
31
|
+
},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const defaults = {
|
|
35
|
+
spamd_socket: 'localhost:783',
|
|
36
|
+
max_size: 500000,
|
|
37
|
+
old_headers_action: 'rename',
|
|
38
|
+
subject_prefix: '*** SPAM ***',
|
|
39
|
+
spamc_auth_header: 'X-Haraka-Relay',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const key in defaults) {
|
|
43
|
+
if (this.cfg.main[key]) continue
|
|
44
|
+
this.cfg.main[key] = defaults[key]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const item of [
|
|
48
|
+
'reject_threshold',
|
|
49
|
+
'relay_reject_threshold',
|
|
50
|
+
'munge_subject_threshold',
|
|
51
|
+
'max_size',
|
|
52
|
+
]) {
|
|
53
|
+
if (!this.cfg.main[item]) continue
|
|
54
|
+
this.cfg.main[item] = Number(this.cfg.main[item])
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
exports.hook_data_post = function (next, connection) {
|
|
59
|
+
if (this.should_skip(connection)) return next()
|
|
60
|
+
|
|
61
|
+
const txn = connection.transaction
|
|
62
|
+
txn.remove_header(this.cfg.main.spamc_auth_header) // just to be safe
|
|
63
|
+
|
|
64
|
+
const username = this.get_spamd_username(connection)
|
|
65
|
+
const headers = this.get_spamd_headers(connection, username)
|
|
66
|
+
const socket = this.get_spamd_socket(next, connection, headers)
|
|
67
|
+
|
|
68
|
+
const spamd_response = { headers: {} }
|
|
69
|
+
let state = 'line0'
|
|
70
|
+
let last_header
|
|
71
|
+
const start = Date.now()
|
|
72
|
+
|
|
73
|
+
socket.on('line', (line) => {
|
|
74
|
+
connection.logprotocol(this, `Spamd C: ${line} state=${state}`)
|
|
75
|
+
line = line.replace(/\r?\n/, '')
|
|
76
|
+
if (state === 'line0') {
|
|
77
|
+
spamd_response.line0 = line
|
|
78
|
+
state = 'response'
|
|
79
|
+
} else if (state === 'response') {
|
|
80
|
+
if (line.match(/\S/)) {
|
|
81
|
+
const matches = line.match(
|
|
82
|
+
/Spam: (True|False) ; (-?\d+\.\d) \/ (-?\d+\.\d)/,
|
|
83
|
+
)
|
|
84
|
+
if (matches) {
|
|
85
|
+
spamd_response.flag = matches[1]
|
|
86
|
+
spamd_response.score = matches[2]
|
|
87
|
+
spamd_response.hits = matches[2] // backwards compat
|
|
88
|
+
spamd_response.reqd = matches[3]
|
|
89
|
+
spamd_response.flag = spamd_response.flag === 'True' ? 'Yes' : 'No'
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
state = 'headers'
|
|
93
|
+
}
|
|
94
|
+
} else if (state === 'headers') {
|
|
95
|
+
const m = line.match(/^X-Spam-([\x21-\x39\x3B-\x7E]+):\s*(.*)/)
|
|
96
|
+
if (m) {
|
|
97
|
+
connection.logdebug(this, `header: ${line}`)
|
|
98
|
+
last_header = m[1]
|
|
99
|
+
spamd_response.headers[m[1]] = m[2]
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
let fold
|
|
103
|
+
if (last_header && (fold = line.match(/^(\s+.*)/))) {
|
|
104
|
+
spamd_response.headers[last_header] += `\r\n${fold[1]}`
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
last_header = ''
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
socket.once('end', () => {
|
|
112
|
+
if (!connection.transaction) return next() // client gone
|
|
113
|
+
|
|
114
|
+
if (spamd_response.headers?.Tests) {
|
|
115
|
+
spamd_response.tests = spamd_response.headers.Tests.replace(/\s/g, '')
|
|
116
|
+
}
|
|
117
|
+
if (spamd_response.tests === undefined) {
|
|
118
|
+
// strip the 'tests' from the X-Spam-Status header
|
|
119
|
+
if (spamd_response.headers?.Status) {
|
|
120
|
+
// SpamAssassin appears to have a bug that causes a space not to
|
|
121
|
+
// be added before autolearn= when the header line has been folded.
|
|
122
|
+
// So we modify the regexp here not to match autolearn onwards.
|
|
123
|
+
const tests = /tests=((?:(?!autolearn)[^ ])+)/.exec(
|
|
124
|
+
spamd_response.headers.Status.replace(/\r?\n\t/g, ''),
|
|
125
|
+
)
|
|
126
|
+
if (tests) spamd_response.tests = tests[1]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// do stuff with the results...
|
|
131
|
+
txn.notes.spamassassin = spamd_response
|
|
132
|
+
connection.results.add(this, {
|
|
133
|
+
time: (Date.now() - start) / 1000,
|
|
134
|
+
hits: spamd_response.hits,
|
|
135
|
+
flag: spamd_response.flag,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
this.fixup_old_headers(txn)
|
|
139
|
+
this.do_header_updates(connection, spamd_response)
|
|
140
|
+
this.log_results(connection, spamd_response)
|
|
141
|
+
|
|
142
|
+
const exceeds_err = this.score_too_high(connection, spamd_response)
|
|
143
|
+
if (exceeds_err) return next(DENY, exceeds_err)
|
|
144
|
+
|
|
145
|
+
this.munge_subject(connection, spamd_response.score)
|
|
146
|
+
|
|
147
|
+
next()
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
exports.fixup_old_headers = function (txn) {
|
|
152
|
+
const action = this.cfg.main.old_headers_action
|
|
153
|
+
const { headers } = txn.notes.spamassassin
|
|
154
|
+
|
|
155
|
+
let key
|
|
156
|
+
switch (action) {
|
|
157
|
+
case 'keep':
|
|
158
|
+
break
|
|
159
|
+
case 'drop':
|
|
160
|
+
for (key in headers) {
|
|
161
|
+
if (!key) continue
|
|
162
|
+
txn.remove_header(`X-Spam-${key}`)
|
|
163
|
+
}
|
|
164
|
+
break
|
|
165
|
+
// case 'rename':
|
|
166
|
+
default:
|
|
167
|
+
for (key in headers) {
|
|
168
|
+
if (!key) continue
|
|
169
|
+
key = `X-Spam-${key}`
|
|
170
|
+
const old_val = txn.header.get(key)
|
|
171
|
+
txn.remove_header(key)
|
|
172
|
+
if (old_val) {
|
|
173
|
+
// plugin.logdebug(plugin, `header: ${key}, ${old_val}`);
|
|
174
|
+
txn.add_header(key.replace(/^X-/, 'X-Old-'), old_val)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
exports.munge_subject = function (conn, score) {
|
|
182
|
+
const munge = this.cfg.main.munge_subject_threshold
|
|
183
|
+
if (!munge) return
|
|
184
|
+
if (parseFloat(score) < parseFloat(munge)) return
|
|
185
|
+
|
|
186
|
+
const subj = conn.transaction.header.get('Subject')
|
|
187
|
+
const subject_re = new RegExp(
|
|
188
|
+
`^${utils.regexp_escape(this.cfg.main.subject_prefix)}`,
|
|
189
|
+
)
|
|
190
|
+
if (subject_re.test(subj)) return // prevent double munge
|
|
191
|
+
|
|
192
|
+
conn.transaction.remove_header('Subject')
|
|
193
|
+
conn.transaction.add_header(
|
|
194
|
+
'Subject',
|
|
195
|
+
`${this.cfg.main.subject_prefix} ${subj}`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
exports.do_header_updates = function (conn, spamd_response) {
|
|
200
|
+
if (spamd_response.flag === 'Yes') {
|
|
201
|
+
// X-Spam-Flag is added by SpamAssassin
|
|
202
|
+
conn.transaction.remove_header('precedence')
|
|
203
|
+
conn.transaction.add_header('Precedence', 'junk')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const modern = this.cfg.main.modern_status_syntax
|
|
207
|
+
if (!this.cfg.main.add_headers) return
|
|
208
|
+
|
|
209
|
+
for (const key in spamd_response.headers) {
|
|
210
|
+
if (!key || key === '' || key === undefined) continue
|
|
211
|
+
let val = spamd_response.headers[key]
|
|
212
|
+
if (val === undefined) {
|
|
213
|
+
val = ''
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (key === 'Status' && !modern) {
|
|
217
|
+
const legacy = spamd_response.headers[key].replace(/ score=/, ' hits=')
|
|
218
|
+
conn.transaction.add_header('X-Spam-Status', legacy)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (val === '') continue
|
|
222
|
+
conn.transaction.add_header(`X-Spam-${key}`, val)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
exports.score_too_high = function (conn, spamd_response) {
|
|
227
|
+
const { score } = spamd_response
|
|
228
|
+
if (conn.relaying) {
|
|
229
|
+
const rmax = this.cfg.main.relay_reject_threshold
|
|
230
|
+
if (rmax && score >= rmax) {
|
|
231
|
+
return 'spam score exceeded relay threshold'
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const max = this.cfg.main.reject_threshold
|
|
236
|
+
if (max && score >= max) return 'spam score exceeded threshold'
|
|
237
|
+
|
|
238
|
+
return ''
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
exports.get_spamd_username = function (conn) {
|
|
242
|
+
let user = conn.transaction.notes.spamd_user // 1st priority
|
|
243
|
+
if (user && user !== undefined) return user
|
|
244
|
+
|
|
245
|
+
if (!this.cfg.main.spamd_user) return 'default' // when not defined
|
|
246
|
+
user = this.cfg.main.spamd_user
|
|
247
|
+
|
|
248
|
+
// Enable per-user SA prefs
|
|
249
|
+
if (user === 'first-recipient') {
|
|
250
|
+
// special cases
|
|
251
|
+
return conn.transaction.rcpt_to[0].address()
|
|
252
|
+
}
|
|
253
|
+
if (user === 'all-recipients') {
|
|
254
|
+
throw 'Unimplemented'
|
|
255
|
+
// TODO: pass the message through SA for each recipient. Then apply
|
|
256
|
+
// the least strict result to the connection. That is useful when
|
|
257
|
+
// one user blacklists a sender that another user wants to get mail
|
|
258
|
+
// from. If this is something you care about, this is the spot.
|
|
259
|
+
}
|
|
260
|
+
return user
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
exports.get_spamd_headers = function (conn, username) {
|
|
264
|
+
// http://svn.apache.org/repos/asf/spamassassin/trunk/spamd/PROTOCOL
|
|
265
|
+
const headers = [
|
|
266
|
+
'HEADERS SPAMC/1.4',
|
|
267
|
+
`User: ${username}`,
|
|
268
|
+
'',
|
|
269
|
+
`X-Envelope-From: ${conn.transaction.mail_from.address()}`,
|
|
270
|
+
`X-Haraka-UUID: ${conn.transaction.uuid}`,
|
|
271
|
+
]
|
|
272
|
+
if (conn.relaying) {
|
|
273
|
+
headers.push(`${this.cfg.main.spamc_auth_header}: true`)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return headers
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
exports.get_spamd_socket = function (next, conn, headers) {
|
|
280
|
+
const plugin = this
|
|
281
|
+
const txn = conn.transaction
|
|
282
|
+
|
|
283
|
+
const socket = new net.Socket()
|
|
284
|
+
socket.is_connected = false
|
|
285
|
+
net_utils.add_line_processor(socket)
|
|
286
|
+
const results_timeout = parseInt(plugin.cfg.main.results_timeout) || 300
|
|
287
|
+
|
|
288
|
+
socket.on('connect', function () {
|
|
289
|
+
// Abort if the transaction is gone
|
|
290
|
+
if (!txn) {
|
|
291
|
+
plugin.logwarn(conn, 'Transaction gone, cancelling SPAMD connection')
|
|
292
|
+
socket.end()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
this.is_connected = true
|
|
297
|
+
// Reset timeout
|
|
298
|
+
this.setTimeout(results_timeout * 1000)
|
|
299
|
+
socket.write(`${headers.join('\r\n')}\r\n`)
|
|
300
|
+
conn.transaction.message_stream.pipe(socket)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
socket.on('error', (err) => {
|
|
304
|
+
socket.destroy()
|
|
305
|
+
if (txn) txn.results.add(plugin, { err: `socket error: ${err.message}` })
|
|
306
|
+
if (plugin.cfg.defer.error) return next(DENYSOFT, 'spamd scan error')
|
|
307
|
+
return next()
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
socket.on('timeout', function () {
|
|
311
|
+
socket.destroy()
|
|
312
|
+
if (!this.is_connected) {
|
|
313
|
+
if (txn) txn.results.add(plugin, { err: `socket connect timeout` })
|
|
314
|
+
if (plugin.cfg.defer.connect_timeout)
|
|
315
|
+
return next(DENYSOFT, 'spamd connect timeout')
|
|
316
|
+
} else {
|
|
317
|
+
if (txn) txn.results.add(plugin, { err: `timeout waiting for results` })
|
|
318
|
+
if (plugin.cfg.defer.scan_timeout)
|
|
319
|
+
return next(DENYSOFT, 'spamd scan timeout')
|
|
320
|
+
}
|
|
321
|
+
return next()
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
const connect_timeout = parseInt(plugin.cfg.main.connect_timeout) || 30
|
|
325
|
+
socket.setTimeout(connect_timeout * 1000)
|
|
326
|
+
|
|
327
|
+
if (plugin.cfg.main.spamd_socket.match(/\//)) {
|
|
328
|
+
// assume unix socket
|
|
329
|
+
socket.connect(plugin.cfg.main.spamd_socket)
|
|
330
|
+
} else {
|
|
331
|
+
const hostport = plugin.cfg.main.spamd_socket.split(/:/)
|
|
332
|
+
socket.connect(hostport[1] || 783, hostport[0])
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return socket
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
exports.log_results = function (conn, spamd_response) {
|
|
339
|
+
const cfg = this.cfg.main
|
|
340
|
+
const reject_threshold = conn.relaying
|
|
341
|
+
? cfg.relay_reject_threshold || cfg.reject_threshold
|
|
342
|
+
: cfg.reject_threshold
|
|
343
|
+
|
|
344
|
+
const human_text =
|
|
345
|
+
`status=${spamd_response.flag}` +
|
|
346
|
+
`, score=${spamd_response.score}` +
|
|
347
|
+
`, required=${spamd_response.reqd}` +
|
|
348
|
+
`, reject=${reject_threshold}` +
|
|
349
|
+
`, tests="${spamd_response.tests}"`
|
|
350
|
+
|
|
351
|
+
conn.transaction.results.add(this, {
|
|
352
|
+
human: human_text,
|
|
353
|
+
status: spamd_response.flag,
|
|
354
|
+
score: parseFloat(spamd_response.score),
|
|
355
|
+
required: parseFloat(spamd_response.reqd),
|
|
356
|
+
reject: reject_threshold,
|
|
357
|
+
tests: spamd_response.tests,
|
|
358
|
+
emit: true,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
exports.should_skip = function (connection = {}) {
|
|
363
|
+
const { transaction } = connection
|
|
364
|
+
if (!transaction) return true
|
|
365
|
+
|
|
366
|
+
// a message might be skipped for multiple reasons, store each in results
|
|
367
|
+
let result = false // default
|
|
368
|
+
|
|
369
|
+
const max = this.cfg.main.max_size
|
|
370
|
+
if (max) {
|
|
371
|
+
const size = connection.transaction.data_bytes
|
|
372
|
+
if (size > max) {
|
|
373
|
+
connection.transaction.results.add(this, {
|
|
374
|
+
skip: `size ${utils.prettySize(size)} exceeds max: ${utils.prettySize(max)}`,
|
|
375
|
+
})
|
|
376
|
+
result = true
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (this.cfg.check.authenticated == false && connection.notes.auth_user) {
|
|
381
|
+
connection.transaction.results.add(this, { skip: 'authed' })
|
|
382
|
+
result = true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (this.cfg.check.relay == false && connection.relaying) {
|
|
386
|
+
connection.transaction.results.add(this, { skip: 'relay' })
|
|
387
|
+
result = true
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (this.cfg.check.local_ip == false && connection.remote.is_local) {
|
|
391
|
+
connection.transaction.results.add(this, { skip: 'local_ip' })
|
|
392
|
+
result = true
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (this.cfg.check.private_ip == false && connection.remote.is_private) {
|
|
396
|
+
if (this.cfg.check.local_ip == true && connection.remote.is_local) {
|
|
397
|
+
// local IPs are included in private IPs
|
|
398
|
+
} else {
|
|
399
|
+
connection.transaction.results.add(this, { skip: 'private_ip' })
|
|
400
|
+
result = true
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return result
|
|
405
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "haraka-plugin-spamassassin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Haraka plugin that...CHANGE THIS",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"CHANGELOG.md",
|
|
8
|
+
"config"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"format": "npm run prettier:fix && npm run lint:fix",
|
|
12
|
+
"lint": "npx eslint@^8 *.js test",
|
|
13
|
+
"lint:fix": "npx eslint@^8 *.js test --fix",
|
|
14
|
+
"prettier": "npx prettier . --check",
|
|
15
|
+
"prettier:fix": "npx prettier . --write --log-level=warn",
|
|
16
|
+
"test": "node --test",
|
|
17
|
+
"versions": "npx dependency-version-checker check",
|
|
18
|
+
"versions:fix": "npx dependency-version-checker update"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/haraka/haraka-plugin-spamassassin.git"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"haraka",
|
|
26
|
+
"plugin",
|
|
27
|
+
"spamassassin"
|
|
28
|
+
],
|
|
29
|
+
"author": "Haraka Team <haraka.team@gmail.com>",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/haraka/haraka-plugin-spamassassin/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/haraka/haraka-plugin-spamassassin#readme",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"haraka-net-utils": "^1.7.0",
|
|
37
|
+
"haraka-utils": "^1.1.3"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@haraka/eslint-config": "1.1.3",
|
|
41
|
+
"address-rfc2821": "^2.1.2",
|
|
42
|
+
"haraka-test-fixtures": "1.3.5"
|
|
43
|
+
}
|
|
44
|
+
}
|