File Coverage

blib/lib/Mail/Milter/Authentication/Handler/SpamAssassin.pm
Criterion Covered Total %
statement 24 144 16.6
branch 0 40 0.0
condition 0 7 0.0
subroutine 8 20 40.0
pod 1 12 8.3
total 33 223 14.8


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::SpamAssassin;
2 1     1   1199 use strict;
  1         4  
  1         35  
3 1     1   7 use warnings;
  1         2  
  1         35  
4 1     1   7 use base 'Mail::Milter::Authentication::Handler';
  1         4  
  1         502  
5 1     1   401451 use version; our $VERSION = version->declare('v1.1.3');
  1         15  
  1         11  
6              
7 1     1   139 use English qw{ -no_match_vars };
  1         4  
  1         9  
8 1     1   670 use Sys::Syslog qw{:standard :macros};
  1         8  
  1         409  
9              
10 1     1   681 use Mail::SpamAssassin;
  1         150446  
  1         33  
11 1     1   294 use Mail::SpamAssassin::Client;
  1         2321  
  1         991  
12              
13             # Issues
14             #
15             # Message may have multiple rcpt to addresses, in this
16             # case we can't load individual configs, would need to
17             # split the message and re-inject, which is a bloody
18             # meess!
19             # HOWEVER, spamassass-milter doesn't appear to do the
20             # right thing either, so we're actually no worse off.
21              
22             sub default_config {
23             return {
24 0     0 0   'default_user' => 'nobody',
25             'sa_host' => 'localhost',
26             'sa_port' => '783',
27             'hard_reject_at' => 10,
28             'remove_headers' => 'yes',
29             }
30             }
31              
32             sub grafana_rows {
33 0     0 0   my ( $self ) = @_;
34 0           my @rows;
35 0           push @rows, $self->get_json( 'SpamAssassin_metrics' );
36 0           return \@rows;
37             }
38              
39             sub register_metrics {
40             return {
41 0     0 1   'spamassassin_total' => 'The number of emails processed for SpamAssassin',
42             };
43             }
44              
45             sub get_user {
46 0     0 0   my ( $self ) = @_;
47 0           my $user_handler = $self->get_handler('UserDB');
48 0           my $user = $user_handler->{'local_user'};
49 0 0         return $user if $user;
50 0           my $config = $self->handler_config();
51 0           return $config->{'default_user'};
52             }
53              
54             sub remove_header {
55 0     0 0   my ( $self, $key, $value ) = @_;
56 0 0         if ( !exists( $self->{'remove_headers'} ) ) {
57 0           $self->{'remove_headers'} = {};
58             }
59 0 0         if ( !exists( $self->{'remove_headers'}->{ lc $key } ) ) {
60 0           $self->{'remove_headers'}->{ $key } = [];
61             }
62 0           push @{ $self->{'remove_headers'}->{ lc $key } }, $value;
  0            
63 0           return;
64             }
65              
66             sub envfrom_callback {
67 0     0 0   my ($self) = @_;
68 0           $self->{'lines'} = [];
69 0           $self->{'rcpt_to'} = q{};
70 0           delete $self->{'header_index'};
71 0           delete $self->{'remove_headers'};
72 0           $self->{'metrics_data'} = {};
73 0           $self->{ 'metrics_data' }->{ 'header_removed' } = 'no';
74 0           return;
75             }
76              
77             sub envrcpt_callback {
78 0     0 0   my ( $self, $env_to ) = @_;
79 0           $self->{'rcpt_to'} = $env_to;
80 0           return;
81             }
82              
83             sub header_callback {
84 0     0 0   my ( $self, $header, $value ) = @_;
85 0           push @{$self->{'lines'}} ,$header . ': ' . $value . "\r\n";
  0            
86 0           my $config = $self->handler_config();
87              
88 0 0         return if ( $self->is_trusted_ip_address() );
89 0 0         return if ( lc $config->{'remove_headers'} eq 'no' );
90              
91 0           foreach my $header_type ( qw{ X-Spam-score X-Spam-Status X-Spam-hits } ) {
92 0 0         if ( lc $header eq lc $header_type ) {
93 0 0         if ( !exists $self->{'header_index'} ) {
94 0           $self->{'header_index'} = {};
95             }
96 0 0         if ( !exists $self->{'header_index'}->{ lc $header_type } ) {
97 0           $self->{'header_index'}->{ lc $header_type } = 0;
98             }
99             $self->{'header_index'}->{ lc $header_type } =
100 0           $self->{'header_index'}->{ lc $header_type } + 1;
101 0           $self->remove_header( $header_type, $self->{'header_index'}->{ lc $header_type } );
102 0           $self->{ 'metrics_data' }->{ 'header_removed' } = 'yes';
103 0 0         if ( lc $config->{'remove_headers'} ne 'silent' ) {
104 0           my $forged_header =
105             '(Received ' . $header_type . ' header removed by '
106             . $self->get_my_hostname()
107             . ')' . "\n"
108             . ' '
109             . $value;
110 0           $self->append_header( 'X-Received-' . $header_type,
111             $forged_header );
112             }
113             }
114             }
115              
116 0           return;
117             }
118              
119             sub eoh_callback {
120 0     0 0   my ( $self ) = @_;
121 0           push @{$self->{'lines'}} , "\r\n";
  0            
122 0           return;
123             }
124              
125             sub body_callback {
126 0     0 0   my ( $self, $chunk ) = @_;
127 0           push @{$self->{'lines'}} , $chunk;
  0            
128 0           return;
129             }
130              
131             sub eom_callback {
132 0     0 0   my ($self) = @_;
133              
134 0           my $config = $self->handler_config();
135              
136 0   0       my $host = $config->{'sa_host'} || 'localhost';
137 0   0       my $port = $config->{'sa_port'} || 783;
138 0           my $user = $self->get_user();
139              
140 0           $self->dbgout( 'SpamAssassinUser', $user, LOG_INFO );
141              
142 0           my $sa_client = Mail::SpamAssassin::Client->new({
143             'port' => $port,
144             'host' => $host,
145             'username' => $user,
146             });
147              
148 0 0         if ( ! $sa_client->ping() ) {
149 0           $self->log_error( 'SpamAssassin could not connect to server' );
150 0           $self->add_auth_header('x-spam=temperror');
151 0           $self->{ 'metrics_data' }->{ 'result' } = 'servererror';
152 0           $self->metric_count( 'spamassassin_total', $self->{ 'metrics_data' } );
153 0           return;
154             }
155              
156 0           my $message = join( q{} , @{$self->{'lines'} } );
  0            
157              
158 0           my $sa_status = $sa_client->_filter( $message, 'SYMBOLS' );
159             #my $sa_status = $sa_client->check( $message );
160              
161             my $status = join( q{},
162             ( $sa_status->{'isspam'} eq 'False' ? 'No, ' : 'Yes, ' ),
163             'score=', sprintf( '%.02f', $sa_status->{'score'} ),
164             ' ',
165 0 0         'required=', sprintf( '%.02f', $sa_status->{'threshold'} ),
166             );
167              
168 0           my $hits = $sa_status->{'message'};
169             # Wrap hits header
170             {
171 0           my @hitsplit = split ',', $hits;
  0            
172 0           my $header = q{};
173 0           my $max = 74;
174 0           my $part = q{};
175 0           my $last_hit = pop @hitsplit;
176 0           @hitsplit = map { "$_," } @hitsplit;
  0            
177 0           push @hitsplit, $last_hit;
178 0           foreach my $hit ( @hitsplit ) {
179 0 0         if ( length ( $part . $hit ) > $max ) {
180 0           $header .= $part . "\n ";
181 0           $part = q{};
182             }
183 0           $part .= $hit;
184             }
185 0           $header .= $part;
186 0           $hits = $header;
187             }
188              
189 0           $self->prepend_header( 'X-Spam-score', sprintf( '%.02f', $sa_status->{'score'} ) );
190 0           $self->prepend_header( 'X-Spam-Status', $status );
191 0           $self->prepend_header( 'X-Spam-hits', $hits );
192              
193             my $header = join(
194             q{ },
195             $self->format_header_entry(
196             'x-spam',
197             ( $sa_status->{'isspam'} eq 'False' ? 'pass' : 'fail' ),
198             ),
199             $self->format_header_entry( 'score', sprintf ( '%.02f', $sa_status->{'score'} ) ),
200 0 0         $self->format_header_entry( 'required', sprintf ( '%.02f', $sa_status->{'threshold'} ) ),
201             );
202              
203 0           $self->add_auth_header($header);
204              
205 0 0         $self->{ 'metrics_data' }->{ 'result' } = ( $sa_status->{'isspam'} eq 'False' ? 'pass' : 'fail' );
206              
207 0 0         if ( $sa_status->{'isspam'} eq 'True' ) {
208 0 0         if ( $config->{'hard_reject_at'} ) {
209 0 0         if ( $sa_status->{'score'} >= $config->{'hard_reject_at'} ) {
210 0 0 0       if ( ( ! $self->is_local_ip_address() ) && ( ! $self->is_trusted_ip_address() ) ) {
211 0           $self->reject_mail( '550 5.7.0 SPAM policy violation' );
212 0           $self->dbgout( 'SpamAssassinReject', "Policy reject", LOG_INFO );
213             }
214             }
215             }
216             }
217              
218 0           $self->metric_count( 'spamassassin_total', $self->{ 'metrics_data' } );
219 0 0         return if ( lc $config->{'remove_headers'} eq 'no' );
220              
221 0           foreach my $header_type ( qw{ X-Spam-score X-Spam-Status X-Spam-hits } ) {
222 0 0         if ( exists( $self->{'remove_headers'}->{ lc $header_type } ) ) {
223 0           foreach my $header ( reverse @{ $self->{'remove_headers'}->{ lc $header_type } } ) {
  0            
224 0           $self->dbgout( 'RemoveSpamHeader', $header_type . ', ' . $header, LOG_DEBUG );
225 0           $self->change_header( lc $header_type, $header, q{} );
226             }
227             }
228             }
229              
230 0           return;
231             }
232              
233             sub close_callback {
234 0     0 0   my ( $self ) = @_;
235              
236 0           delete $self->{'lines'};
237 0           delete $self->{'rcpt_to'};
238 0           delete $self->{'remove_headers'};
239 0           delete $self->{'header_index'};
240 0           delete $self->{'metrics_data'};
241 0           return;
242             }
243              
244             1;
245              
246             __END__
247              
248             =head1 NAME
249              
250             Authentication Milter - SpamAssassin Module
251              
252             =head1 DESCRIPTION
253              
254             Check email for spam using SpamAssassin spamd.
255              
256             =head1 CONFIGURATION
257              
258             "SpamAssassin" : {
259             "default_user" : "nobody",
260             "sa_host" : "localhost",
261             "sa_port" : "783",
262             "hard_reject_at" : "10",
263             "remove_headers" : "yes"
264             },
265              
266             =head1 SYNOPSIS
267              
268             =head2 CONFIG
269              
270             Add a block to the handlers section of your config as follows.
271              
272             "SpamAssassin" : {
273             "default_user" : "nobody",
274             "sa_host" : "localhost",
275             "sa_port" : "783",
276             "hard_reject_at" : "10",
277             "remove_headers" : "yes"
278             },
279              
280             =head1 AUTHORS
281              
282             Marc Bradshaw E<lt>marc@marcbradshaw.netE<gt>
283              
284             =head1 COPYRIGHT
285              
286             Copyright 2017
287              
288             This library is free software; you may redistribute it and/or
289             modify it under the same terms as Perl itself.
290              
291