File Coverage

blib/lib/Mail/Milter/Authentication/Handler/SpamAssassin.pm
Criterion Covered Total %
statement 24 140 17.1
branch 0 40 0.0
condition 0 7 0.0
subroutine 8 19 42.1
pod 1 11 9.0
total 33 217 15.2


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