مراية لـ
https://github.com/postalserver/postal.git
تم المزامنة 2025-12-01 05:43:04 +00:00
@@ -69,8 +69,8 @@ class Server < ApplicationRecord
|
|||||||
default_value :raw_message_retention_days, -> { 30 }
|
default_value :raw_message_retention_days, -> { 30 }
|
||||||
default_value :raw_message_retention_size, -> { 2048 }
|
default_value :raw_message_retention_size, -> { 2048 }
|
||||||
default_value :message_retention_days, -> { 60 }
|
default_value :message_retention_days, -> { 60 }
|
||||||
default_value :spam_threshold, -> { 5.0 }
|
default_value :spam_threshold, -> { Postal.config.general.default_spam_threshold }
|
||||||
default_value :spam_failure_threshold, -> { 20.0 }
|
default_value :spam_failure_threshold, -> { Postal.config.general.default_spam_failure_threshold }
|
||||||
|
|
||||||
validates :name, :presence => true, :uniqueness => {:scope => :organization_id}
|
validates :name, :presence => true, :uniqueness => {:scope => :organization_id}
|
||||||
validates :mode, :inclusion => {:in => MODES}
|
validates :mode, :inclusion => {:in => MODES}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ general:
|
|||||||
maximum_hold_expiry_days: 7
|
maximum_hold_expiry_days: 7
|
||||||
suppression_list_removal_delay: 30
|
suppression_list_removal_delay: 30
|
||||||
use_local_ns_for_domains: false
|
use_local_ns_for_domains: false
|
||||||
|
default_spam_threshold: 5.0
|
||||||
|
default_spam_failure_threshold: 20.0
|
||||||
|
|
||||||
web_server:
|
web_server:
|
||||||
bind_address: 127.0.0.1
|
bind_address: 127.0.0.1
|
||||||
@@ -100,6 +102,14 @@ rails:
|
|||||||
environment: production
|
environment: production
|
||||||
secret_key:
|
secret_key:
|
||||||
|
|
||||||
|
rspamd:
|
||||||
|
enabled: false
|
||||||
|
host: 127.0.0.1
|
||||||
|
port: 11334
|
||||||
|
ssl: false
|
||||||
|
password: null
|
||||||
|
flags: null
|
||||||
|
|
||||||
spamd:
|
spamd:
|
||||||
enabled: false
|
enabled: false
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ module Postal
|
|||||||
autoload :Job
|
autoload :Job
|
||||||
autoload :MessageDB
|
autoload :MessageDB
|
||||||
autoload :MessageInspection
|
autoload :MessageInspection
|
||||||
|
autoload :MessageInspector
|
||||||
|
autoload :MessageInspectors
|
||||||
autoload :MessageParser
|
autoload :MessageParser
|
||||||
autoload :MessageRequeuer
|
autoload :MessageRequeuer
|
||||||
autoload :MXLookup
|
autoload :MXLookup
|
||||||
@@ -37,6 +39,7 @@ module Postal
|
|||||||
super
|
super
|
||||||
Postal::MessageDB.eager_load!
|
Postal::MessageDB.eager_load!
|
||||||
Postal::SMTPServer.eager_load!
|
Postal::SMTPServer.eager_load!
|
||||||
|
Postal::MessageInspectors.eager_load!
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -498,15 +498,17 @@ module Postal
|
|||||||
# Inspect this message
|
# Inspect this message
|
||||||
#
|
#
|
||||||
def inspect_message
|
def inspect_message
|
||||||
if result = MessageInspection.new(self.raw_message, self.scope&.to_sym)
|
result = MessageInspection.scan(self, self.scope&.to_sym)
|
||||||
|
|
||||||
# Update the messages table with the results of our inspection
|
# Update the messages table with the results of our inspection
|
||||||
update(:inspected => 1, :spam_score => result.filtered_spam_score, :threat => result.threat?, :threat_details => result.threat_message)
|
update(:inspected => 1, :spam_score => result.spam_score, :threat => result.threat?, :threat_details => result.threat_message)
|
||||||
|
|
||||||
# Add any spam details into the spam checks database
|
# Add any spam details into the spam checks database
|
||||||
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]})
|
self.database.insert_multi(:spam_checks, [:message_id, :code, :score, :description], result.spam_checks.map { |d| [self.id, d.code, d.score, d.description] })
|
||||||
|
|
||||||
# Return the result
|
# Return the result
|
||||||
result
|
result
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Return all spam checks for this message
|
# Return all spam checks for this message
|
||||||
|
|||||||
@@ -1,140 +1,41 @@
|
|||||||
require 'timeout'
|
|
||||||
require 'socket'
|
|
||||||
require 'json'
|
|
||||||
|
|
||||||
module Postal
|
module Postal
|
||||||
class MessageInspection
|
class MessageInspection
|
||||||
|
|
||||||
SPAM_EXCLUSIONS = {
|
attr_reader :message
|
||||||
:outgoing => ['NO_RECEIVED', 'NO_RELAYS', 'ALL_TRUSTED', 'FREEMAIL_FORGED_REPLYTO', 'RDNS_DYNAMIC', 'CK_HELO_GENERIC', /^SPF\_/, /^HELO\_/, /DKIM_/, /^RCVD_IN_/],
|
attr_reader :scope
|
||||||
:incoming => []
|
attr_reader :spam_checks
|
||||||
}
|
attr_accessor :threat
|
||||||
|
attr_accessor :threat_message
|
||||||
|
|
||||||
def initialize(message, scope = :incoming)
|
def initialize(message, scope)
|
||||||
@message = message
|
@message = message
|
||||||
@scope = scope
|
@scope = scope
|
||||||
@threat = false
|
|
||||||
@spam_score = 0.0
|
|
||||||
@spam_checks = []
|
@spam_checks = []
|
||||||
|
@threat = false
|
||||||
if Postal.config.spamd.enabled?
|
|
||||||
scan_for_spam
|
|
||||||
end
|
|
||||||
|
|
||||||
if Postal.config.clamav.enabled?
|
|
||||||
scan_for_threats
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def spam_score
|
def spam_score
|
||||||
@spam_score
|
return 0 if @spam_checks.empty?
|
||||||
end
|
|
||||||
|
|
||||||
def spam_checks
|
@spam_checks.sum(&:score)
|
||||||
@spam_checks
|
|
||||||
end
|
|
||||||
|
|
||||||
def filtered_spam_checks
|
|
||||||
@filtered_spam_checks ||= @spam_checks.reject do |check|
|
|
||||||
SPAM_EXCLUSIONS[@scope].any? do |item|
|
|
||||||
item == check.code || (item.is_a?(Regexp) && item =~ check.code)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def filtered_spam_score
|
|
||||||
filtered_spam_checks.inject(0.0) do |total, check|
|
|
||||||
total += check.score || 0.0
|
|
||||||
end.round(2)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def threat?
|
def threat?
|
||||||
@threat
|
@threat == true
|
||||||
end
|
end
|
||||||
|
|
||||||
def threat_message
|
def scan
|
||||||
@threat_message
|
MessageInspector.inspectors.each do |inspector|
|
||||||
end
|
inspector.inspect_message(self)
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def scan_for_spam
|
|
||||||
data = nil
|
|
||||||
Timeout.timeout(15) do
|
|
||||||
tcp_socket = TCPSocket.new(Postal.config.spamd.host, Postal.config.spamd.port)
|
|
||||||
tcp_socket.write("REPORT SPAMC/1.2\r\n")
|
|
||||||
tcp_socket.write("Content-length: #{@message.bytesize}\r\n")
|
|
||||||
tcp_socket.write("\r\n")
|
|
||||||
tcp_socket.write(@message)
|
|
||||||
tcp_socket.close_write
|
|
||||||
data = tcp_socket.read
|
|
||||||
end
|
|
||||||
|
|
||||||
spam_checks = []
|
|
||||||
total = 0.0
|
|
||||||
rules = data ? data.split(/^---(.*)\r?\n/).last.split(/\r?\n/) : []
|
|
||||||
while line = rules.shift
|
|
||||||
if line =~ /\A([\- ]?[\d\.]+)\s+(\w+)\s+(.*)/
|
|
||||||
total += $1.to_f
|
|
||||||
spam_checks << SPAMCheck.new($2, $1.to_f, $3)
|
|
||||||
else
|
|
||||||
spam_checks.last.description << " " + line.strip
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spam_score = total.round(1)
|
class << self
|
||||||
@spam_checks = spam_checks
|
def scan(message, scope)
|
||||||
|
inspection = new(message, scope)
|
||||||
rescue Timeout::Error
|
inspection.scan
|
||||||
@spam_checks = [SPAMCheck.new("TIMEOUT", 0, "Timed out when scanning for spam")]
|
inspection
|
||||||
rescue => e
|
|
||||||
logger.error "Error talking to spamd: #{e.class} (#{e.message})"
|
|
||||||
logger.error e.backtrace[0,5]
|
|
||||||
@spam_checks = [SPAMCheck.new("ERROR", 0, "Error when scanning for spam")]
|
|
||||||
ensure
|
|
||||||
tcp_socket.close rescue nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def scan_for_threats
|
|
||||||
@threat = false
|
|
||||||
|
|
||||||
data = nil
|
|
||||||
Timeout.timeout(10) do
|
|
||||||
tcp_socket = TCPSocket.new(Postal.config.clamav.host, Postal.config.clamav.port)
|
|
||||||
tcp_socket.write("zINSTREAM\0")
|
|
||||||
tcp_socket.write([@message.bytesize].pack("N"))
|
|
||||||
tcp_socket.write(@message)
|
|
||||||
tcp_socket.write([0].pack("N"))
|
|
||||||
tcp_socket.close_write
|
|
||||||
data = tcp_socket.read
|
|
||||||
end
|
|
||||||
|
|
||||||
if data && data =~ /\Astream\:\s+(.*?)[\s\0]+?/
|
|
||||||
if $1.upcase == 'OK'
|
|
||||||
@threat = false
|
|
||||||
@threat_message = "No threats found"
|
|
||||||
else
|
|
||||||
@threat = true
|
|
||||||
@threat_message = $1
|
|
||||||
end
|
|
||||||
else
|
|
||||||
@threat = false
|
|
||||||
@threat_message = "Could not scan message"
|
|
||||||
end
|
|
||||||
rescue Timeout::Error
|
|
||||||
@threat = false
|
|
||||||
@threat_message = "Timed out scanning for threats"
|
|
||||||
rescue => e
|
|
||||||
logger.error "Error talking to clamav: #{e.class} (#{e.message})"
|
|
||||||
logger.error e.backtrace[0,5]
|
|
||||||
@threat = false
|
|
||||||
@threat_message = "Error when scanning for threats"
|
|
||||||
ensure
|
|
||||||
tcp_socket.close rescue nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def logger
|
|
||||||
Postal.logger_for(:message_inspection)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
40
lib/postal/message_inspector.rb
Normal file
40
lib/postal/message_inspector.rb
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
module Postal
|
||||||
|
class MessageInspector
|
||||||
|
|
||||||
|
def initialize(config)
|
||||||
|
@config = config
|
||||||
|
end
|
||||||
|
|
||||||
|
# Inspect a message and update the inspection with the results
|
||||||
|
# as appropriate.
|
||||||
|
def inspect_message(message, scope, inspection)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def logger
|
||||||
|
Postal.logger_for(:message_inspection)
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Return an array of all inspectors that are available for this
|
||||||
|
# installation.
|
||||||
|
def inspectors
|
||||||
|
Array.new.tap do |inspectors|
|
||||||
|
|
||||||
|
if Postal.config.rspamd&.enabled
|
||||||
|
inspectors << MessageInspectors::Rspamd.new(Postal.config.rspamd)
|
||||||
|
elsif Postal.config.spamd&.enabled
|
||||||
|
inspectors << MessageInspectors::SpamAssassin.new(Postal.config.spamd)
|
||||||
|
end
|
||||||
|
|
||||||
|
if Postal.config.clamav&.enabled
|
||||||
|
inspectors << MessageInspectors::Clamav.new(Postal.config.clamav)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
10
lib/postal/message_inspectors.rb
Normal file
10
lib/postal/message_inspectors.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
module Postal
|
||||||
|
module MessageInspectors
|
||||||
|
extend ActiveSupport::Autoload
|
||||||
|
eager_autoload do
|
||||||
|
autoload :Clamav
|
||||||
|
autoload :Rspamd
|
||||||
|
autoload :SpamAssassin
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
45
lib/postal/message_inspectors/clamav.rb
Normal file
45
lib/postal/message_inspectors/clamav.rb
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
module Postal
|
||||||
|
module MessageInspectors
|
||||||
|
class Clamav < MessageInspector
|
||||||
|
|
||||||
|
def inspect_message(inspection)
|
||||||
|
raw_message = inspection.message.raw_message
|
||||||
|
|
||||||
|
data = nil
|
||||||
|
Timeout.timeout(10) do
|
||||||
|
tcp_socket = TCPSocket.new(@config.host, @config.port)
|
||||||
|
tcp_socket.write("zINSTREAM\0")
|
||||||
|
tcp_socket.write([raw_message.bytesize].pack("N"))
|
||||||
|
tcp_socket.write(raw_message)
|
||||||
|
tcp_socket.write([0].pack("N"))
|
||||||
|
tcp_socket.close_write
|
||||||
|
data = tcp_socket.read
|
||||||
|
end
|
||||||
|
|
||||||
|
if data && data =~ /\Astream\:\s+(.*?)[\s\0]+?/
|
||||||
|
if $1.upcase == 'OK'
|
||||||
|
inspection.threat = false
|
||||||
|
inspection.threat_message = "No threats found"
|
||||||
|
else
|
||||||
|
inspection.threat = true
|
||||||
|
inspection.threat_message = $1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
inspection.threat = false
|
||||||
|
inspection.threat_message = "Could not scan message"
|
||||||
|
end
|
||||||
|
rescue Timeout::Error
|
||||||
|
inspection.threat = false
|
||||||
|
inspection.threat_message = "Timed out scanning for threats"
|
||||||
|
rescue => e
|
||||||
|
logger.error "Error talking to clamav: #{e.class} (#{e.message})"
|
||||||
|
logger.error e.backtrace[0,5]
|
||||||
|
inspection.threat = false
|
||||||
|
inspection.threat_message = "Error when scanning for threats"
|
||||||
|
ensure
|
||||||
|
tcp_socket.close rescue nil
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
74
lib/postal/message_inspectors/rspamd.rb
Normal file
74
lib/postal/message_inspectors/rspamd.rb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
require 'net/http'
|
||||||
|
|
||||||
|
module Postal
|
||||||
|
module MessageInspectors
|
||||||
|
class Rspamd < MessageInspector
|
||||||
|
|
||||||
|
class Error < StandardError
|
||||||
|
end
|
||||||
|
|
||||||
|
def inspect_message(inspection)
|
||||||
|
response = request(inspection.message, inspection.scope)
|
||||||
|
response = JSON.parse(response.body)
|
||||||
|
return unless response['symbols'].is_a?(Hash)
|
||||||
|
|
||||||
|
response['symbols'].values.each do |symbol|
|
||||||
|
next if symbol['description'].blank?
|
||||||
|
|
||||||
|
inspection.spam_checks << SpamCheck.new(symbol['name'], symbol['score'], symbol['description'])
|
||||||
|
end
|
||||||
|
rescue Error => e
|
||||||
|
inspection.spam_checks << SpamCheck.new("ERROR", 0, e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def request(message, scope)
|
||||||
|
http = Net::HTTP.new(@config.host, @config.port)
|
||||||
|
http.use_ssl = true if @config.ssl
|
||||||
|
http.read_timeout = 10
|
||||||
|
http.open_timeout = 10
|
||||||
|
|
||||||
|
raw_message = message.raw_message
|
||||||
|
|
||||||
|
request = Net::HTTP::Post.new('/checkv2')
|
||||||
|
request.body = raw_message
|
||||||
|
request['Content-Length'] = raw_message.bytesize.to_s
|
||||||
|
request['Password'] = @config.password if @config.password
|
||||||
|
request['Flags'] = @config.flags if @config.flags
|
||||||
|
request['User-Agent'] = 'Postal'
|
||||||
|
request['Deliver-To'] = message.rcpt_to
|
||||||
|
request['From'] = message.mail_from
|
||||||
|
request['Rcpt'] = message.rcpt_to
|
||||||
|
request['Queue-Id'] = message.token
|
||||||
|
|
||||||
|
if scope == :outgoing
|
||||||
|
request['User'] = ''
|
||||||
|
# We don't actually know the IP but an empty input here will
|
||||||
|
# still trigger rspamd to treat this as an outbound email
|
||||||
|
# and disable certain checks.
|
||||||
|
# https://rspamd.com/doc/tutorials/scanning_outbound.html
|
||||||
|
request['Ip'] = ''
|
||||||
|
end
|
||||||
|
|
||||||
|
response = nil
|
||||||
|
begin
|
||||||
|
response = http.request(request)
|
||||||
|
rescue Exception => e
|
||||||
|
logger.error "Error talking to rspamd: #{e.class} (#{e.message})"
|
||||||
|
logger.error e.backtrace[0,5]
|
||||||
|
|
||||||
|
raise Error, "Error when scanning with rspamd (#{e.class})"
|
||||||
|
end
|
||||||
|
|
||||||
|
unless response.is_a?(Net::HTTPOK)
|
||||||
|
logger.info "Got #{response.code} status from rspamd, wanted 200"
|
||||||
|
raise Error, "Error when scanning with rspamd (got #{response.code})"
|
||||||
|
end
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
54
lib/postal/message_inspectors/spam_assassin.rb
Normal file
54
lib/postal/message_inspectors/spam_assassin.rb
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
module Postal
|
||||||
|
module MessageInspectors
|
||||||
|
class SpamAssassin < MessageInspector
|
||||||
|
|
||||||
|
EXCLUSIONS = {
|
||||||
|
:outgoing => ['NO_RECEIVED', 'NO_RELAYS', 'ALL_TRUSTED', 'FREEMAIL_FORGED_REPLYTO', 'RDNS_DYNAMIC', 'CK_HELO_GENERIC', /^SPF\_/, /^HELO\_/, /DKIM_/, /^RCVD_IN_/],
|
||||||
|
:incoming => []
|
||||||
|
}
|
||||||
|
|
||||||
|
def inspect_message(inspection)
|
||||||
|
data = nil
|
||||||
|
raw_message = inspection.message.raw_message
|
||||||
|
Timeout.timeout(15) do
|
||||||
|
tcp_socket = TCPSocket.new(@config.host, @config.port)
|
||||||
|
tcp_socket.write("REPORT SPAMC/1.2\r\n")
|
||||||
|
tcp_socket.write("Content-length: #{raw_message.bytesize}\r\n")
|
||||||
|
tcp_socket.write("\r\n")
|
||||||
|
tcp_socket.write(raw_message)
|
||||||
|
tcp_socket.close_write
|
||||||
|
data = tcp_socket.read
|
||||||
|
end
|
||||||
|
|
||||||
|
spam_checks = []
|
||||||
|
total = 0.0
|
||||||
|
rules = data ? data.split(/^---(.*)\r?\n/).last.split(/\r?\n/) : []
|
||||||
|
while line = rules.shift
|
||||||
|
if line =~ /\A([\- ]?[\d\.]+)\s+(\w+)\s+(.*)/
|
||||||
|
total += $1.to_f
|
||||||
|
spam_checks << SpamCheck.new($2, $1.to_f, $3)
|
||||||
|
else
|
||||||
|
spam_checks.last.description << " " + line.strip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
checks = spam_checks.reject { |s| EXCLUSIONS[inspection.scope].include?(s.code) }
|
||||||
|
checks.each do |check|
|
||||||
|
inspection.spam_checks << check
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue Timeout::Error
|
||||||
|
inspection.spam_checks << SpamCheck.new("TIMEOUT", 0, "Timed out when scanning for spam")
|
||||||
|
|
||||||
|
rescue => e
|
||||||
|
logger.error "Error talking to spamd: #{e.class} (#{e.message})"
|
||||||
|
logger.error e.backtrace[0,5]
|
||||||
|
inspection.spam_checks << SpamCheck.new("ERROR", 0, "Error when scanning for spam")
|
||||||
|
|
||||||
|
ensure
|
||||||
|
tcp_socket.close rescue nil
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
module Postal
|
module Postal
|
||||||
class SPAMCheck
|
class SpamCheck
|
||||||
|
|
||||||
attr_reader :code, :score, :description
|
attr_reader :code, :score, :description
|
||||||
|
|
||||||
|
|||||||
المرجع في مشكلة جديدة
حظر مستخدم