Skip to content

Commit d808d15

Browse files
authored
Merge pull request #1525 from postalserver/rspamd
Support for rspamd
2 parents 47fbe6a + a1277ba commit d808d15

File tree

11 files changed

+267
-128
lines changed

11 files changed

+267
-128
lines changed

app/models/server.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ class Server < ApplicationRecord
6969
default_value :raw_message_retention_days, -> { 30 }
7070
default_value :raw_message_retention_size, -> { 2048 }
7171
default_value :message_retention_days, -> { 60 }
72-
default_value :spam_threshold, -> { 5.0 }
73-
default_value :spam_failure_threshold, -> { 20.0 }
72+
default_value :spam_threshold, -> { Postal.config.general.default_spam_threshold }
73+
default_value :spam_failure_threshold, -> { Postal.config.general.default_spam_failure_threshold }
7474

7575
validates :name, :presence => true, :uniqueness => {:scope => :organization_id}
7676
validates :mode, :inclusion => {:in => MODES}

config/postal.defaults.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ general:
1515
maximum_hold_expiry_days: 7
1616
suppression_list_removal_delay: 30
1717
use_local_ns_for_domains: false
18+
default_spam_threshold: 5.0
19+
default_spam_failure_threshold: 20.0
1820

1921
web_server:
2022
bind_address: 127.0.0.1
@@ -100,6 +102,14 @@ rails:
100102
environment: production
101103
secret_key:
102104

105+
rspamd:
106+
enabled: false
107+
host: 127.0.0.1
108+
port: 11334
109+
ssl: false
110+
password: null
111+
flags: null
112+
103113
spamd:
104114
enabled: false
105115
host: 127.0.0.1

lib/postal.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ module Postal
1515
autoload :Job
1616
autoload :MessageDB
1717
autoload :MessageInspection
18+
autoload :MessageInspector
19+
autoload :MessageInspectors
1820
autoload :MessageParser
1921
autoload :MessageRequeuer
2022
autoload :MXLookup
@@ -37,6 +39,7 @@ def self.eager_load!
3739
super
3840
Postal::MessageDB.eager_load!
3941
Postal::SMTPServer.eager_load!
42+
Postal::MessageInspectors.eager_load!
4043
end
4144

4245
end

lib/postal/message_db/message.rb

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -498,14 +498,16 @@ def rcpt_to_return_path?
498498
# Inspect this message
499499
#
500500
def inspect_message
501-
if result = MessageInspection.new(self.raw_message, self.scope&.to_sym)
502-
# Update the messages table with the results of our inspection
503-
update(:inspected => 1, :spam_score => result.filtered_spam_score, :threat => result.threat?, :threat_details => result.threat_message)
504-
# Add any spam details into the spam checks database
505-
self.database.insert_multi(:spam_checks, [:message_id, :code, :score, :description], result.filtered_spam_checks.map { |d| [self.id, d.code, d.score, d.description]})
506-
# Return the result
507-
result
508-
end
501+
result = MessageInspection.scan(self, self.scope&.to_sym)
502+
503+
# Update the messages table with the results of our inspection
504+
update(:inspected => 1, :spam_score => result.spam_score, :threat => result.threat?, :threat_details => result.threat_message)
505+
506+
# Add any spam details into the spam checks database
507+
self.database.insert_multi(:spam_checks, [:message_id, :code, :score, :description], result.spam_checks.map { |d| [self.id, d.code, d.score, d.description] })
508+
509+
# Return the result
510+
result
509511
end
510512

511513
#

lib/postal/message_inspection.rb

Lines changed: 18 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,140 +1,41 @@
1-
require 'timeout'
2-
require 'socket'
3-
require 'json'
4-
51
module Postal
62
class MessageInspection
73

8-
SPAM_EXCLUSIONS = {
9-
:outgoing => ['NO_RECEIVED', 'NO_RELAYS', 'ALL_TRUSTED', 'FREEMAIL_FORGED_REPLYTO', 'RDNS_DYNAMIC', 'CK_HELO_GENERIC', /^SPF\_/, /^HELO\_/, /DKIM_/, /^RCVD_IN_/],
10-
:incoming => []
11-
}
4+
attr_reader :message
5+
attr_reader :scope
6+
attr_reader :spam_checks
7+
attr_accessor :threat
8+
attr_accessor :threat_message
129

13-
def initialize(message, scope = :incoming)
10+
def initialize(message, scope)
1411
@message = message
1512
@scope = scope
16-
@threat = false
17-
@spam_score = 0.0
1813
@spam_checks = []
19-
20-
if Postal.config.spamd.enabled?
21-
scan_for_spam
22-
end
23-
24-
if Postal.config.clamav.enabled?
25-
scan_for_threats
26-
end
14+
@threat = false
2715
end
2816

2917
def spam_score
30-
@spam_score
31-
end
32-
33-
def spam_checks
34-
@spam_checks
35-
end
18+
return 0 if @spam_checks.empty?
3619

37-
def filtered_spam_checks
38-
@filtered_spam_checks ||= @spam_checks.reject do |check|
39-
SPAM_EXCLUSIONS[@scope].any? do |item|
40-
item == check.code || (item.is_a?(Regexp) && item =~ check.code)
41-
end
42-
end
43-
end
44-
45-
def filtered_spam_score
46-
filtered_spam_checks.inject(0.0) do |total, check|
47-
total += check.score || 0.0
48-
end.round(2)
20+
@spam_checks.sum(&:score)
4921
end
5022

5123
def threat?
52-
@threat
53-
end
54-
55-
def threat_message
56-
@threat_message
24+
@threat == true
5725
end
5826

59-
private
60-
61-
def scan_for_spam
62-
data = nil
63-
Timeout.timeout(15) do
64-
tcp_socket = TCPSocket.new(Postal.config.spamd.host, Postal.config.spamd.port)
65-
tcp_socket.write("REPORT SPAMC/1.2\r\n")
66-
tcp_socket.write("Content-length: #{@message.bytesize}\r\n")
67-
tcp_socket.write("\r\n")
68-
tcp_socket.write(@message)
69-
tcp_socket.close_write
70-
data = tcp_socket.read
71-
end
72-
73-
spam_checks = []
74-
total = 0.0
75-
rules = data ? data.split(/^---(.*)\r?\n/).last.split(/\r?\n/) : []
76-
while line = rules.shift
77-
if line =~ /\A([\- ]?[\d\.]+)\s+(\w+)\s+(.*)/
78-
total += $1.to_f
79-
spam_checks << SPAMCheck.new($2, $1.to_f, $3)
80-
else
81-
spam_checks.last.description << " " + line.strip
82-
end
27+
def scan
28+
MessageInspector.inspectors.each do |inspector|
29+
inspector.inspect_message(self)
8330
end
84-
85-
@spam_score = total.round(1)
86-
@spam_checks = spam_checks
87-
88-
rescue Timeout::Error
89-
@spam_checks = [SPAMCheck.new("TIMEOUT", 0, "Timed out when scanning for spam")]
90-
rescue => e
91-
logger.error "Error talking to spamd: #{e.class} (#{e.message})"
92-
logger.error e.backtrace[0,5]
93-
@spam_checks = [SPAMCheck.new("ERROR", 0, "Error when scanning for spam")]
94-
ensure
95-
tcp_socket.close rescue nil
9631
end
9732

98-
def scan_for_threats
99-
@threat = false
100-
101-
data = nil
102-
Timeout.timeout(10) do
103-
tcp_socket = TCPSocket.new(Postal.config.clamav.host, Postal.config.clamav.port)
104-
tcp_socket.write("zINSTREAM\0")
105-
tcp_socket.write([@message.bytesize].pack("N"))
106-
tcp_socket.write(@message)
107-
tcp_socket.write([0].pack("N"))
108-
tcp_socket.close_write
109-
data = tcp_socket.read
110-
end
111-
112-
if data && data =~ /\Astream\:\s+(.*?)[\s\0]+?/
113-
if $1.upcase == 'OK'
114-
@threat = false
115-
@threat_message = "No threats found"
116-
else
117-
@threat = true
118-
@threat_message = $1
119-
end
120-
else
121-
@threat = false
122-
@threat_message = "Could not scan message"
33+
class << self
34+
def scan(message, scope)
35+
inspection = new(message, scope)
36+
inspection.scan
37+
inspection
12338
end
124-
rescue Timeout::Error
125-
@threat = false
126-
@threat_message = "Timed out scanning for threats"
127-
rescue => e
128-
logger.error "Error talking to clamav: #{e.class} (#{e.message})"
129-
logger.error e.backtrace[0,5]
130-
@threat = false
131-
@threat_message = "Error when scanning for threats"
132-
ensure
133-
tcp_socket.close rescue nil
134-
end
135-
136-
def logger
137-
Postal.logger_for(:message_inspection)
13839
end
13940

14041
end

lib/postal/message_inspector.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
module Postal
2+
class MessageInspector
3+
4+
def initialize(config)
5+
@config = config
6+
end
7+
8+
# Inspect a message and update the inspection with the results
9+
# as appropriate.
10+
def inspect_message(message, scope, inspection)
11+
end
12+
13+
private
14+
15+
def logger
16+
Postal.logger_for(:message_inspection)
17+
end
18+
19+
class << self
20+
# Return an array of all inspectors that are available for this
21+
# installation.
22+
def inspectors
23+
Array.new.tap do |inspectors|
24+
25+
if Postal.config.rspamd&.enabled
26+
inspectors << MessageInspectors::Rspamd.new(Postal.config.rspamd)
27+
elsif Postal.config.spamd&.enabled
28+
inspectors << MessageInspectors::SpamAssassin.new(Postal.config.spamd)
29+
end
30+
31+
if Postal.config.clamav&.enabled
32+
inspectors << MessageInspectors::Clamav.new(Postal.config.clamav)
33+
end
34+
35+
end
36+
end
37+
end
38+
39+
end
40+
end

lib/postal/message_inspectors.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module Postal
2+
module MessageInspectors
3+
extend ActiveSupport::Autoload
4+
eager_autoload do
5+
autoload :Clamav
6+
autoload :Rspamd
7+
autoload :SpamAssassin
8+
end
9+
end
10+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
module Postal
2+
module MessageInspectors
3+
class Clamav < MessageInspector
4+
5+
def inspect_message(inspection)
6+
raw_message = inspection.message.raw_message
7+
8+
data = nil
9+
Timeout.timeout(10) do
10+
tcp_socket = TCPSocket.new(@config.host, @config.port)
11+
tcp_socket.write("zINSTREAM\0")
12+
tcp_socket.write([raw_message.bytesize].pack("N"))
13+
tcp_socket.write(raw_message)
14+
tcp_socket.write([0].pack("N"))
15+
tcp_socket.close_write
16+
data = tcp_socket.read
17+
end
18+
19+
if data && data =~ /\Astream\:\s+(.*?)[\s\0]+?/
20+
if $1.upcase == 'OK'
21+
inspection.threat = false
22+
inspection.threat_message = "No threats found"
23+
else
24+
inspection.threat = true
25+
inspection.threat_message = $1
26+
end
27+
else
28+
inspection.threat = false
29+
inspection.threat_message = "Could not scan message"
30+
end
31+
rescue Timeout::Error
32+
inspection.threat = false
33+
inspection.threat_message = "Timed out scanning for threats"
34+
rescue => e
35+
logger.error "Error talking to clamav: #{e.class} (#{e.message})"
36+
logger.error e.backtrace[0,5]
37+
inspection.threat = false
38+
inspection.threat_message = "Error when scanning for threats"
39+
ensure
40+
tcp_socket.close rescue nil
41+
end
42+
43+
end
44+
end
45+
end

0 commit comments

Comments
 (0)