Mail-Milter-Authentication-Extra/lib/Mail/Milter/Authentication/Handler/RSpamD.pm
package Mail::Milter::Authentication::Handler::RSpamD;
use strict;
use warnings;
use Mail::Milter::Authentication 2.20180607;
use base 'Mail::Milter::Authentication::Handler';
our $VERSION = '2.20180611'; # VERSION
# #ABSTRACT: RSpamD scanning for Authentication Milter
use English qw{ -no_match_vars };
use Sys::Syslog qw{:standard :macros};
use HTTP::Tiny;
use JSON;
use Mail::AuthenticationResults::Header::Entry;
use Mail::AuthenticationResults::Header::SubEntry;
use Mail::AuthenticationResults::Header::Comment;
use Data::Dumper;
sub default_config {
return {
'default_user' => 'nobody',
'rs_host' => 'localhost',
'rs_port' => '11333',
'hard_reject' => 1,
'remove_headers' => 'yes',
}
}
sub grafana_rows {
my ( $self ) = @_;
my @rows;
push @rows, $self->get_json( 'RSpamD_metrics' );
return \@rows;
}
sub register_metrics {
return {
'rspamd_total' => 'The number of emails processed for RSpamD',
};
}
sub get_user {
my ( $self ) = @_;
my $user_handler = $self->get_handler('UserDB');
my $user = $user_handler->{'local_user'};
return $user if $user;
my $config = $self->handler_config();
return $config->{'default_user'};
}
sub remove_header {
my ( $self, $key, $value ) = @_;
if ( !exists( $self->{'remove_headers'} ) ) {
$self->{'remove_headers'} = {};
}
if ( !exists( $self->{'remove_headers'}->{ lc $key } ) ) {
$self->{'remove_headers'}->{ $key } = [];
}
push @{ $self->{'remove_headers'}->{ lc $key } }, $value;
return;
}
sub helo_callback {
my ( $self, $helo_host ) = @_;
$self->{'helo_name'} = $helo_host;
return;
}
sub envfrom_callback {
my ($self, $from) = @_;
$self->{'lines'} = [];
$self->{'rcpt_to'} = [];
$self->{'mail_from'} = $from;
delete $self->{'header_index'};
delete $self->{'remove_headers'};
$self->{'metrics_data'} = {};
$self->{ 'metrics_data' }->{ 'header_removed' } = 'no';
return;
}
sub envrcpt_callback {
my ( $self, $env_to ) = @_;
push @{ $self->{'rcpt_to'} }, $env_to;
return;
}
sub header_callback {
my ( $self, $header, $value ) = @_;
push @{$self->{'lines'}} ,$header . ': ' . $value . "\r\n";
my $config = $self->handler_config();
return if ( $self->is_trusted_ip_address() );
return if ( lc $config->{'remove_headers'} eq 'no' );
foreach my $header_type ( qw{ X-Spam-score X-Spam-Status X-Spam-Action } ) {
if ( lc $header eq lc $header_type ) {
if ( !exists $self->{'header_index'} ) {
$self->{'header_index'} = {};
}
if ( !exists $self->{'header_index'}->{ lc $header_type } ) {
$self->{'header_index'}->{ lc $header_type } = 0;
}
$self->{'header_index'}->{ lc $header_type } =
$self->{'header_index'}->{ lc $header_type } + 1;
$self->remove_header( $header_type, $self->{'header_index'}->{ lc $header_type } );
$self->{ 'metrics_data' }->{ 'header_removed' } = 'yes';
if ( lc $config->{'remove_headers'} ne 'silent' ) {
my $forged_header =
'(Received ' . $header_type . ' header removed by '
. $self->get_my_hostname()
. ')' . "\n"
. ' '
. $value;
$self->append_header( 'X-Received-' . $header_type,
$forged_header );
}
}
}
return;
}
sub eoh_callback {
my ( $self ) = @_;
push @{$self->{'lines'}} , "\r\n";
return;
}
sub body_callback {
my ( $self, $chunk ) = @_;
push @{$self->{'lines'}} , $chunk;
return;
}
sub get_auth_name {
my ($self) = @_;
my $protocol = Mail::Milter::Authentication::Config::get_config()->{'protocol'};
if ( $protocol ne 'milter' ) {
return 'unauthorized';
}
my $name = $self->get_symbol('{auth_authen}') || 'unauthorized';
return $name;
}
sub eom_callback {
my ($self) = @_;
my $config = $self->handler_config();
my $host = $config->{'rs_host'} || 'localhost';
my $port = $config->{'rs_port'} || 11333;
my $user = $self->get_user();
$self->dbgout( 'RSpamDUser', $user, LOG_INFO );
my $queue_id = $self->get_symbol('i') || q{--};
my $message = join( q{} , @{$self->{'lines'} } );
my $headers = {
'Deliver-To' => $user,
'IP' => $self->ip_address(),
'Helo' => $self->{'helo_name'},
'From' => $self->{'mail_from'},
'Queue-Id' => $queue_id,
'Rcpt' => $self->{'rcpt_to'},
'Pass' => 'all', # all to check all filters
'User' => $self->get_auth_name(),
};
my $http = HTTP::Tiny->new(
'keep_alive' => 0,
);
my $response = $http->post( "http://$host:$port/check", { 'headers' => $headers, 'content' => $message } );
if ( ! $response->{'success'} ) {
$self->log_error( 'RSpamD could not connect to server - ' . $response->{'status'} . ' - ' . $response->{'reason'} . ' - ' . $response->{'content'} );
my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'x-rspam' )->safe_set_value( 'temperror' );
$self->add_auth_header( $header );
$self->{ 'metrics_data' }->{ 'result' } = 'servererror';
$self->metric_count( 'rspamd_total', $self->{ 'metrics_data' } );
return;
}
my $j = JSON->new();
my $rspamd_data = eval{ $j->decode( $response->{'content'} ); };
$self->handle_exception( $@ );
if ( ! exists( $rspamd_data->{'default'} ) ) {
$self->log_error( 'RSpamD bad data from server' );
my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'x-rspam' )->safe_set_value( 'temperror' );
$self->add_auth_header( $header );
$self->{ 'metrics_data' }->{ 'result' } = 'serverdataerror';
$self->metric_count( 'rspamd_total', $self->{ 'metrics_data' } );
return;
}
my $spam = $rspamd_data->{ 'default' };
my $status = join( q{},
( $spam->{'is_spam'} eq 0 ? 'No, ' : 'Yes, ' ),
'score=', sprintf( '%.02f', $spam->{'score'} ),
' ',
'required=', sprintf( '%.02f', $spam->{'required_score'} ),
);
my $action = $spam->{'action'};
if ( $action eq 'rewrite subject' ) {
$action .= ' - ' . $spam->{'subject'};
## ToDo - Rewrite the subject
}
if ( $action eq 'reject' ) {
if ( $config->{'hard_reject'} ) {
if ( ( ! $self->is_local_ip_address() ) && ( ! $self->is_trusted_ip_address() ) ) {
$self->reject_mail( '550 5.7.0 SPAM policy violation' );
$self->dbgout( 'RSpamDReject', "Policy reject", LOG_INFO );
}
else {
$self->quarantine_mail( 'Quarantined due to SPAM policy' );
}
}
else {
$self->quarantine_mail( 'Quarantined due to SPAM policy' );
}
}
if ( $action eq 'greylist' ) {
## TODO actual greylisting
$self->quarantine_mail( 'Quarantined due to SPAM policy' );
}
if ( $action eq 'rewrite_subject' ) {
$self->quarantine_mail( 'Quarantined due to SPAM policy' );
}
if ( $action eq 'add_header' ) {
$self->quarantine_mail( 'Quarantined due to SPAM policy' );
}
if ( $action eq 'soft_reject' ) {
$self->defer_mail( 'SPAM policy violation, come back later' );
}
$self->prepend_header( 'X-Spam-score', sprintf( '%.02f', $spam->{'score'} ) );
$self->prepend_header( 'X-Spam-Status', $status );
$self->prepend_header( 'X-Spam-Action', $action );
my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'x-rspam' )->safe_set_value( ( $spam->{'is_spam'} eq 0 ? 'pass' : 'fail' ) );
$header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'score' )->safe_set_value( sprintf ( '%.02f', $spam->{'score'} ) ) );
$header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'required' )->safe_set_value( sprintf ( '%.02f', $spam->{'required_score'} ) ) );
$self->add_auth_header($header);
$self->{ 'metrics_data' }->{ 'result' } = ( $spam->{'is_spam'} eq 0 ? 'pass' : 'fail' );
$self->metric_count( 'rspamd_total', $self->{ 'metrics_data' } );
return if ( lc $config->{'remove_headers'} eq 'no' );
foreach my $header_type ( qw{ X-Spam-score X-Spam-Status X-Spam-Action } ) {
if ( exists( $self->{'remove_headers'}->{ lc $header_type } ) ) {
foreach my $header ( reverse @{ $self->{'remove_headers'}->{ lc $header_type } } ) {
$self->dbgout( 'RemoveSpamHeader', $header_type . ', ' . $header, LOG_DEBUG );
$self->change_header( lc $header_type, $header, q{} );
}
}
}
return;
}
sub close_callback {
my ( $self ) = @_;
delete $self->{'lines'};
delete $self->{'mail_from'};
delete $self->{'helo_name'};
delete $self->{'rcpt_to'};
delete $self->{'remove_headers'};
delete $self->{'header_index'};
delete $self->{'metrics_data'};
return;
}
1;
__END__
=pod
=encoding UTF-8
=head1 NAME
Mail::Milter::Authentication::Handler::RSpamD
=head1 VERSION
version 2.20180611
=head1 DESCRIPTION
Check email for spam using rspamd.
=head1 CONFIGURATION
"RSpamD" : {
"default_user" : "nobody",
"rs_host" : "localhost",
"rs_port" : "11333",
"hard_reject" : "1",
"remove_headers" : "yes"
},
=head2 CONFIG
Add a block to the handlers section of your config as follows.
"RSpamD" : {
"default_user" : "nobody",
"rs_host" : "localhost",
"rs_port" : "11333",
"hard_reject" : "1",
"remove_headers" : "yes"
},
=head1 AUTHOR
Marc Bradshaw <marc@marcbradshaw.net>
=head1 COPYRIGHT AND LICENSE
This software is copyright (c) 2018 by Marc Bradshaw.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.
=cut