File Coverage

lib/Mail/SpamAssassin/Client.pm
Criterion Covered Total %
statement 138 212 65.0
branch 48 136 35.2
condition 10 38 26.3
subroutine 16 18 88.8
pod 8 8 100.0
total 220 412 53.4


line stmt bran cond sub pod time code
1             # <@LICENSE>
2             # Licensed to the Apache Software Foundation (ASF) under one or more
3             # contributor license agreements. See the NOTICE file distributed with
4             # this work for additional information regarding copyright ownership.
5             # The ASF licenses this file to you under the Apache License, Version 2.0
6             # (the "License"); you may not use this file except in compliance with
7             # the License. You may obtain a copy of the License at:
8             #
9             # http://www.apache.org/licenses/LICENSE-2.0
10             #
11             # Unless required by applicable law or agreed to in writing, software
12             # distributed under the License is distributed on an "AS IS" BASIS,
13             # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14             # See the License for the specific language governing permissions and
15             # limitations under the License.
16             # </@LICENSE>
17              
18             =head1 NAME
19              
20             Mail::SpamAssassin::Client - Client for spamd Protocol
21              
22             =head1 SYNOPSIS
23              
24             my $client = Mail::SpamAssassin::Client->new({
25             port => 783,
26             host => 'localhost',
27             username => 'someuser'});
28             or
29              
30             my $client = Mail::SpamAssassin::Client->new({
31             socketpath => '/path/to/socket',
32             username => 'someuser'});
33              
34             Optionally takes timeout, which is applied to IO::Socket for the
35             initial connection. If not supplied, it defaults to 30 seconds.
36              
37             if ($client->ping()) {
38             print "Ping is ok\n";
39             }
40              
41             my $result = $client->process($testmsg);
42              
43             if ($result->{isspam} eq 'True') {
44             do something with spam message here
45             }
46              
47             =head1 DESCRIPTION
48              
49             Mail::SpamAssassin::Client is a module which provides a perl implementation of
50             the spamd protocol.
51              
52             =cut
53              
54             package Mail::SpamAssassin::Client;
55              
56 1     1   7036885 use strict;
  1         8  
  1         62  
57 1     1   15 use warnings;
  1         11  
  1         88  
58 1     1   12 use re 'taint';
  1         2  
  1         94  
59              
60 1     1   11 use IO::Socket;
  1         4  
  1         26  
61 1     1   1200 use Errno qw(EBADF);
  1         2  
  1         409  
62              
63             our($io_socket_module_name);
64             BEGIN {
65 1 50   1   8 if (eval { require IO::Socket::IP }) {
  1 0       798  
    0          
66 1         9334 $io_socket_module_name = 'IO::Socket::IP';
67 0         0 } elsif (eval { require IO::Socket::INET6 }) {
68 0         0 $io_socket_module_name = 'IO::Socket::INET6';
69 0         0 } elsif (eval { require IO::Socket::INET }) {
70 0         0 $io_socket_module_name = 'IO::Socket::INET';
71             }
72             }
73              
74             my $EOL = "\015\012";
75             my $BLANK = $EOL x 2;
76             my $PROTOVERSION = 'SPAMC/1.5';
77              
78             =head1 PUBLIC METHODS
79              
80             =head2 new
81              
82             public class (Mail::SpamAssassin::Client) new (\% $args)
83              
84             Description:
85             This method creates a new Mail::SpamAssassin::Client object.
86              
87             =cut
88              
89             sub new {
90 3     3 1 16027230 my ($class, $args) = @_;
91              
92 3   33     47 $class = ref($class) || $class;
93              
94 3         9 my $self = {};
95              
96             # with a sockets_path set then it makes no sense to set host and port
97 3 100       16 if ($args->{socketpath}) {
98 1         12 $self->{socketpath} = $args->{socketpath};
99             }
100             else {
101 2         8 $self->{port} = $args->{port};
102 2         5 $self->{host} = $args->{host};
103             }
104              
105 3 50       12 if (defined $args->{username}) {
106 0         0 $self->{username} = $args->{username};
107             }
108              
109 3 50       13 if ($args->{timeout}) {
110 0   0     0 $self->{timeout} = $args->{timeout} || 30;
111             }
112              
113 3         7 bless($self, $class);
114              
115 3         9 $self;
116             }
117              
118             =head2 process
119              
120             public instance (\%) process (String $msg)
121              
122             Description:
123             This method calls the spamd server with the PROCESS command.
124              
125             The return value is a hash reference containing several pieces of information,
126             if available:
127              
128             content_length
129              
130             isspam
131              
132             score
133              
134             threshold
135              
136             message
137              
138             =cut
139              
140             sub process {
141 2     2 1 2185 my ($self, $msg, $is_check_p) = @_;
142              
143 2         12 my $command = 'PROCESS';
144              
145 2 50       9 if ($is_check_p) {
146 0         0 warn "Passing in \$is_check_p is deprecated, just call the check method instead.\n";
147 0         0 $command = 'CHECK';
148             }
149              
150 2         9 return $self->_filter($msg, $command);
151             }
152              
153             =head2 check
154              
155             public instance (\%) check (String $msg)
156              
157             Description:
158             The method implements the check call.
159              
160             See the process method for the return value.
161              
162             =cut
163              
164             sub check {
165 2     2 1 15 my ($self, $msg) = @_;
166              
167 2         13 return $self->_filter($msg, 'CHECK');
168             }
169              
170             =head2 headers
171              
172             public instance (\%) headers (String $msg)
173              
174             Description:
175             This method implements the headers call.
176              
177             See the process method for the return value.
178              
179             =cut
180              
181             sub headers {
182 1     1 1 1864 my ($self, $msg) = @_;
183              
184 1         4 return $self->_filter($msg, 'HEADERS');
185             }
186              
187             =head2 learn
188              
189             public instance (Boolean) learn (String $msg, Integer $learntype)
190              
191             Description:
192             This method implements the learn call. C<$learntype> should be
193             an integer, 0 for spam, 1 for ham and 2 for forget. The return
194             value is a boolean indicating if the message was learned or not.
195              
196             An undef return value indicates that there was an error and you
197             should check the resp_code/resp_msg values to determine what
198             the error was.
199              
200             =cut
201              
202             sub learn {
203 6     6 1 1915526 my ($self, $msg, $learntype) = @_;
204              
205 6         75 $self->_clear_errors();
206              
207 6         54 my $remote = $self->_create_connection();
208              
209 6 50       29 return unless $remote;
210              
211 6         60 my $msgsize = length($msg.$EOL);
212              
213 6         302 print $remote "TELL $PROTOVERSION$EOL";
214 6         200 print $remote "Content-length: $msgsize$EOL";
215 6 50       45 print $remote "User: $self->{username}$EOL" if defined $self->{username};
216              
217 6 100       45 if ($learntype == 0) {
    100          
    50          
218 2         50 print $remote "Message-class: spam$EOL";
219 2         56 print $remote "Set: local$EOL";
220             }
221             elsif ($learntype == 1) {
222 2         60 print $remote "Message-class: ham$EOL";
223 2         55 print $remote "Set: local$EOL";
224             }
225             elsif ($learntype == 2) {
226 2         69 print $remote "Remove: local$EOL";
227             }
228             else { # bad learntype
229 0         0 $self->{resp_code} = 00;
230 0         0 $self->{resp_msg} = 'do not know';
231 0         0 return;
232             }
233              
234 6         140 print $remote "$EOL";
235 6         215 print $remote $msg;
236 6         187 print $remote "$EOL";
237              
238 6         33 $! = 0; my $line = <$remote>;
  6         235326  
239             # deal gracefully with a Perl I/O bug which may return status EBADF at eof
240 6 0 33     72 defined $line || $!==0 or
    50          
241             $!==EBADF ? dbg("error reading from spamd (1): $!")
242             : die "error reading from spamd (1): $!";
243 6 50       24 return unless defined $line;
244              
245 6         46 my ($version, $resp_code, $resp_msg) = $self->_parse_response_line($line);
246              
247 6         29 $self->{resp_code} = $resp_code;
248 6         16 $self->{resp_msg} = $resp_msg;
249              
250 6 50       33 return unless $resp_code == 0;
251              
252 6         42 my $did_set = '';
253 6         30 my $did_remove = '';
254              
255 6         48 for ($!=0; defined($line=<$remote>); $!=0) {
256 10         37 local $1;
257 10 100       153 if ($line =~ /DidSet: (.*)/i) {
    100          
    50          
258 2         17 $did_set = $1;
259             }
260             elsif ($line =~ /DidRemove: (.*)/i) {
261 2         25 $did_remove = $1;
262             }
263             elsif ($line =~ /^${EOL}$/) {
264 6         28 last;
265             }
266             }
267 6 0 33     34 defined $line || $!==0 or
    50          
268             $!==EBADF ? dbg("error reading from spamd (2): $!")
269             : die "error reading from spamd (2): $!";
270 6 50       354 close $remote or die "error closing socket: $!";
271              
272 6 100 100     63 if ($learntype == 0 || $learntype == 1) {
273 4         99 return $did_set =~ /local/;
274             }
275             else { #safe since we've already checked the $learntype values
276 2         76 return $did_remove =~ /local/;
277             }
278             }
279              
280             =head2 report
281              
282             public instance (Boolean) report (String $msg)
283              
284             Description:
285             This method provides the report interface to spamd.
286              
287             =cut
288              
289             sub report {
290 0     0 1 0 my ($self, $msg) = @_;
291              
292 0         0 $self->_clear_errors();
293              
294 0         0 my $remote = $self->_create_connection();
295              
296 0 0       0 return unless $remote;
297              
298 0         0 my $msgsize = length($msg.$EOL);
299              
300 0         0 print $remote "TELL $PROTOVERSION$EOL";
301 0         0 print $remote "Content-length: $msgsize$EOL";
302 0 0       0 print $remote "User: $self->{username}$EOL" if defined $self->{username};
303 0         0 print $remote "Message-class: spam$EOL";
304 0         0 print $remote "Set: local,remote$EOL";
305 0         0 print $remote "$EOL";
306 0         0 print $remote $msg;
307 0         0 print $remote "$EOL";
308              
309 0         0 $! = 0; my $line = <$remote>;
  0         0  
310 0 0 0     0 defined $line || $!==0 or
    0          
311             $!==EBADF ? dbg("error reading from spamd (3): $!")
312             : die "error reading from spamd (3): $!";
313 0 0       0 return unless defined $line;
314              
315 0         0 my ($version, $resp_code, $resp_msg) = $self->_parse_response_line($line);
316              
317 0         0 $self->{resp_code} = $resp_code;
318 0         0 $self->{resp_msg} = $resp_msg;
319              
320 0 0       0 return unless $resp_code == 0;
321              
322 0         0 my $reported_p = 0;
323              
324 0         0 for ($!=0; defined($line=<$remote>); $!=0) {
325 0 0       0 if ($line =~ /DidSet:\s+.*remote/i) {
    0          
326 0         0 $reported_p = 1;
327 0         0 last;
328             }
329             elsif ($line =~ /^${EOL}$/) {
330 0         0 last;
331             }
332             }
333 0 0 0     0 defined $line || $!==0 or
    0          
334             $!==EBADF ? dbg("error reading from spamd (4): $!")
335             : die "error reading from spamd (4): $!";
336 0 0       0 close $remote or die "error closing socket: $!";
337              
338 0         0 return $reported_p;
339             }
340              
341             =head2 revoke
342              
343             public instance (Boolean) revoke (String $msg)
344              
345             Description:
346             This method provides the revoke interface to spamd.
347              
348             =cut
349              
350             sub revoke {
351 0     0 1 0 my ($self, $msg) = @_;
352              
353 0         0 $self->_clear_errors();
354              
355 0         0 my $remote = $self->_create_connection();
356              
357 0 0       0 return unless $remote;
358              
359 0         0 my $msgsize = length($msg.$EOL);
360              
361 0         0 print $remote "TELL $PROTOVERSION$EOL";
362 0         0 print $remote "Content-length: $msgsize$EOL";
363 0 0       0 print $remote "User: $self->{username}$EOL" if defined $self->{username};
364 0         0 print $remote "Message-class: ham$EOL";
365 0         0 print $remote "Set: local$EOL";
366 0         0 print $remote "Remove: remote$EOL";
367 0         0 print $remote "$EOL";
368 0         0 print $remote $msg;
369 0         0 print $remote "$EOL";
370              
371 0         0 $! = 0; my $line = <$remote>;
  0         0  
372 0 0 0     0 defined $line || $!==0 or
    0          
373             $!==EBADF ? dbg("error reading from spamd (5): $!")
374             : die "error reading from spamd (5): $!";
375 0 0       0 return unless defined $line;
376              
377 0         0 my ($version, $resp_code, $resp_msg) = $self->_parse_response_line($line);
378              
379 0         0 $self->{resp_code} = $resp_code;
380 0         0 $self->{resp_msg} = $resp_msg;
381              
382 0 0       0 return unless $resp_code == 0;
383              
384 0         0 my $revoked_p = 0;
385              
386 0         0 for ($!=0; defined($line=<$remote>); $!=0) {
387 0 0       0 if ($line =~ /DidRemove:\s+remote/i) {
    0          
388 0         0 $revoked_p = 1;
389 0         0 last;
390             }
391             elsif ($line =~ /^${EOL}$/) {
392 0         0 last;
393             }
394             }
395 0 0 0     0 defined $line || $!==0 or
    0          
396             $!==EBADF ? dbg("error reading from spamd (6): $!")
397             : die "error reading from spamd (6): $!";
398 0 0       0 close $remote or die "error closing socket: $!";
399              
400 0         0 return $revoked_p;
401             }
402              
403              
404             =head2 ping
405              
406             public instance (Boolean) ping ()
407              
408             Description:
409             This method performs a server ping and returns 0 or 1 depending on
410             if the server responded correctly.
411              
412             =cut
413              
414             sub ping {
415 2     2 1 757 my ($self) = @_;
416              
417 2         13 my $remote = $self->_create_connection();
418              
419 2 50       7 return 0 unless ($remote);
420              
421 2         70 print $remote "PING $PROTOVERSION$EOL";
422 2         44 print $remote "$EOL"; # bug 6187, bumps protocol version to 1.5
423              
424 2         11 $! = 0; my $line = <$remote>;
  2         6307  
425 2 0 33     31 defined $line || $!==0 or
    50          
426             $!==EBADF ? dbg("error reading from spamd (7): $!")
427             : die "error reading from spamd (7): $!";
428 2 50       92 close $remote or die "error closing socket: $!";
429 2 50       12 return unless defined $line;
430              
431 2         15 my ($version, $resp_code, $resp_msg) = $self->_parse_response_line($line);
432 2 50       16 return 0 unless ($resp_msg eq 'PONG');
433              
434 2         44 return 1;
435             }
436              
437             =head1 PRIVATE METHODS
438              
439             =head2 _create_connection
440              
441             private instance (IO::Socket) _create_connection ()
442              
443             Description:
444             This method sets up a proper IO::Socket connection based on the arguments
445             used when creating the client object.
446              
447             On failure, it sets an internal error code and returns undef.
448              
449             =cut
450              
451             sub _create_connection {
452 13     13   45 my ($self) = @_;
453              
454 13         24 my $remote;
455              
456 13 100       124 if ($self->{socketpath}) {
457             $remote = IO::Socket::UNIX->new( Peer => $self->{socketpath},
458             Type => SOCK_STREAM,
459             Timeout => $self->{timeout},
460 3         68 );
461             }
462             else {
463             my %params = ( Proto => "tcp",
464             PeerAddr => $self->{host},
465             PeerPort => $self->{port},
466             Timeout => $self->{timeout},
467 10         125 );
468 10         194 $remote = $io_socket_module_name->new(%params);
469             }
470              
471 13 50       10363 unless ($remote) {
472 0         0 print "Failed to create connection to spamd daemon: $!\n";
473 0         0 return;
474             }
475              
476 13         32 $remote;
477             }
478              
479             =head2 _parse_response_line
480              
481             private instance (@) _parse_response_line (String $line)
482              
483             Description:
484             This method parses the initial response line/header from the server
485             and returns its parts.
486              
487             We have this as a separate method in case we ever decide to get fancy
488             with the response line.
489              
490             =cut
491              
492             sub _parse_response_line {
493 13     13   141 my ($self, $line) = @_;
494              
495 13         203 $line =~ s/\r?\n$//;
496 13         163 return split(/\s+/, $line, 3);
497             }
498              
499             =head2 _clear_errors
500              
501             private instance () _clear_errors ()
502              
503             Description:
504             This method clears out any current errors.
505              
506             =cut
507              
508             sub _clear_errors {
509 11     11   42 my ($self) = @_;
510              
511 11         34 $self->{resp_code} = undef;
512 11         32 $self->{resp_msg} = undef;
513             }
514              
515             =head2 _filter
516              
517             private instance (\%) _filter (String $msg, String $command)
518              
519             Description:
520             Makes the actual call to the spamd server for the various filter method
521             (ie PROCESS, CHECK, HEADERS, etc). The command that is passed in is
522             sent to the spamd server.
523              
524             The return value is a hash reference containing several pieces of information,
525             if available:
526              
527             content_length
528              
529             isspam
530              
531             score
532              
533             threshold
534              
535             message (if available)
536              
537             =cut
538              
539             sub _filter {
540 5     5   25 my ($self, $msg, $command) = @_;
541              
542 5         10 my %data;
543              
544 5         15 $self->_clear_errors();
545              
546 5         12 my $remote = $self->_create_connection();
547              
548 5 50       25 return 0 unless ($remote);
549              
550 5         37 my $msgsize = length($msg.$EOL);
551              
552 5         156 print $remote "$command $PROTOVERSION$EOL";
553 5         107 print $remote "Content-length: $msgsize$EOL";
554 5 50       53 print $remote "User: $self->{username}$EOL" if defined $self->{username};
555 5         93 print $remote "$EOL";
556 5         89 print $remote $msg;
557 5         83 print $remote "$EOL";
558              
559 5         15 $! = 0; my $line = <$remote>;
  5         162217  
560 5 0 33     65 defined $line || $!==0 or
    50          
561             $!==EBADF ? dbg("error reading from spamd (8): $!")
562             : die "error reading from spamd (8): $!";
563 5 50       17 return unless defined $line;
564              
565 5         44 my ($version, $resp_code, $resp_msg) = $self->_parse_response_line($line);
566            
567 5         35 $self->{resp_code} = $resp_code;
568 5         25 $self->{resp_msg} = $resp_msg;
569              
570 5 50       20 return unless $resp_code == 0;
571              
572 5         42 for ($!=0; defined($line=<$remote>); $!=0) {
573 13         82 local($1,$2,$3);
574 13 100       136 if ($line =~ /Content-length: (\d+)/) {
    100          
    50          
575 3         45 $data{content_length} = $1;
576             }
577             elsif ($line =~ m!Spam: (\S+) ; (\S+) / (\S+)!) {
578 5         27 $data{isspam} = $1;
579 5         31 $data{score} = $2 + 0;
580 5         49 $data{threshold} = $3 + 0;
581             }
582             elsif ($line =~ /^${EOL}$/) {
583 5         20 last;
584             }
585             }
586 5 0 33     31 defined $line || $!==0 or
    50          
587             $!==EBADF ? dbg("error reading from spamd (9): $!")
588             : die "error reading from spamd (9): $!";
589              
590 5         13 my $return_msg;
591 5         1732 for ($!=0; defined($line=<$remote>); $!=0) {
592 150         2273 $return_msg .= $line;
593             }
594 5 0 33     74 defined $line || $!==0 or
    50          
595             $!==EBADF ? dbg("error reading from spamd (10): $!")
596             : die "error reading from spamd (10): $!";
597              
598 5 100       45 $data{message} = $return_msg if ($return_msg);
599              
600 5 50       207 close $remote or die "error closing socket: $!";
601              
602 5         122 return \%data;
603             }
604              
605             1;
606