File Coverage

blib/lib/Mail/SpamTest/Bayesian.pm
Criterion Covered Total %
statement 13 15 86.6
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 18 20 90.0


line stmt bran cond sub pod time code
1             package Mail::SpamTest::Bayesian;
2              
3             =head1 NAME
4              
5             Mail::SpamTest::Bayesian - Perl extension for Bayesian spam-testing
6              
7             =head1 SYNOPSIS
8              
9             use Mail::SpamTest::Bayesian;
10              
11             my $j=Mail::SpamTest::Bayesian->new(dir => '.');
12             $j->init_db;
13             $j->merge_mbox_spam($scalar_spam_box);
14             $j->merge_mbox_nonspam($scalar_nonspam_box);
15             $message=$j->markup_message($message);
16              
17             =head1 DESCRIPTION
18              
19             This module implements the Bayesian spam-testing algorithm described by
20             Paul Graham at:
21              
22             http://www.paulgraham.com/spam.html
23              
24             In short: the system is trained by exposure to mailboxes of known spam
25             and non-spam messages. These are (1) MIME-decoded, and non-text parts
26             deleted; (2) tokenised. The database files spam.db and nonspam.db
27             contain lists of tokens and the number of messages in which they have
28             occurred; general.db holds a message count.
29              
30             This module is in early development; it is functional but basic. It is
31             expected that more mailbox parsing routines will be added, probably
32             using Mail::Box; and that ancillary programs will be supplied for use of
33             the module as a personal mail filter.
34              
35             =head1 METHODS
36              
37             =cut
38              
39 1     1   15435 use 5.006;
  1         5  
  1         56  
40 1     1   7 use strict;
  1         3  
  1         44  
41 1     1   6 use warnings;
  1         19  
  1         96  
42              
43             require Exporter;
44              
45             our @ISA = qw(Exporter);
46              
47             our $VERSION = '0.02';
48              
49 1     1   7 use strict;
  1         126  
  1         54  
50 1     1   1940 use BerkeleyDB; # libberkeleydb-perl
  0            
  0            
51             use MIME::Parser; # libmime-perl
52              
53             =head2 new()
54              
55             Standard constructor. Pass a hash or hashref with parameters.
56              
57             Useful parameters:
58             dir -> database directory (.)
59             significant -> number of significant tokens to consider (15)
60             threshold -> spam threshold (0.9)
61             fudgefactor -> Non-spam priority (2)
62              
63             =cut
64              
65             sub new {
66             my $proto = shift;
67             my $class = ref($proto) || $proto;
68             my $self={};
69             bless ($self, $class);
70             $self->{dir}='.';
71             $self->{significant}=15;
72             $self->{threshold}=0.9;
73             $self->{fudgefactor}=2;
74             my @param;
75             while (my $p=shift) {
76             if (ref($p) eq 'HASH') {
77             map {$self->{lc($_)}=$p->{$_}} keys %{$p};
78             } else {
79             my $v=shift;
80             $self->{$p}=$v;
81             }
82             }
83             foreach my $db (qw(spam nonspam general)) {
84             $self->{$db}=new BerkeleyDB::Hash(
85             -Filename => "$self->{dir}/$db.db",
86             -Flags => DB_CREATE
87             );
88             }
89             $self->{parser}=new MIME::Parser;
90             $self->{parser}->output_to_core(1);
91             $self->{parser}->tmp_to_core(1);
92             $self->{parser}->tmp_recycling(1);
93             return $self;
94             }
95              
96             =head2 init_db()
97              
98             Deletes and re-initialises databases. Call this only once, when you
99             first set up the database.
100              
101             =cut
102              
103             sub init_db {
104             my $self=shift;
105             foreach my $db (qw(spam nonspam general)) {
106             undef $self->{$db};
107             unlink "$self->{dir}/$db.db";
108             $self->{$db}=new BerkeleyDB::Hash(
109             -Filename => "$self->{dir}/$db.db",
110             -Flags => DB_CREATE
111             );
112             }
113             $self->{general}->db_put('spam',0);
114             $self->{general}->db_put('nonspam',0);
115             }
116              
117             =head2 merge_mbox_spam()
118              
119             Train the system by giving it a mailbox full of spam.
120              
121             Pass a scalar or array or arrayref containing raw messages.
122              
123             =cut
124              
125             sub merge_mbox_spam {
126             my $self=shift;
127             $self->merge_mbox(1,@_);
128             }
129              
130             =head2 merge_mbox_nonspam()
131              
132             Train the system by giving it a mailbox full of legitimate email.
133              
134             Pass a scalar or array or arrayref containing raw messages.
135              
136             =cut
137              
138             sub merge_mbox_nonspam {
139             my $self=shift;
140             $self->merge_mbox(0,@_);
141             }
142              
143             sub merge_mbox {
144             my $self=shift;
145             my $spamstate=shift;
146             my @message=@_;
147             if (scalar @message == 1) {
148             my $m=$message[0];
149             if (ref($m) eq 'ARRAY') {
150             @message=@{$m};
151             $m='';
152             } elsif (ref($m) eq 'SCALAR') {
153             $m=$$m;
154             }
155             if ($m ne '') {
156             @message=map {"From $_"} grep !/^$/, (split /^From /m,$m);
157             }
158             }
159             foreach my $m (@message) {
160             $self->merge_message($spamstate,$m);
161             }
162             }
163              
164             =head1 merge_stream_spam()
165              
166             Pass a stream (pointing to an mbox file) from which to read messages.
167             For example, an IO::File object.
168              
169             =cut
170              
171             sub merge_stream_spam {
172             my $self=shift;
173             $self->merge_stream(1,@_);
174             }
175              
176             =head1 merge_stream_nonspam()
177              
178             Pass a stream (pointing to an mbox file) from which to read messages.
179              
180             =cut
181              
182             sub merge_stream_nonspam {
183             my $self=shift;
184             $self->merge_stream(0,@_);
185             }
186              
187             sub merge_stream {
188             my $self=shift;
189             my $spamstate=shift;
190             my $handle=shift;
191             my $message = '';
192             while (my $line = <$handle>) {
193             if ($line =~ /^From / && length($message) > 0) {
194             $self->merge_message($spamstate,$message);
195             $message='';
196             }
197             $message .= $line;
198             }
199             if (length($message) > 0) {
200             $self->merge_message($spamstate,$message);
201             }
202             }
203              
204              
205             =head2 merge_message_spam()
206              
207             As merge_mbox_spam, but for a single message; pass in a scalar.
208              
209             =cut
210              
211             sub merge_message_spam {
212             my $self=shift;
213             $self->merge_message(1,@_);
214             }
215              
216             =head2 merge_message_nonspam()
217              
218             As merge_mbox_nonspam, but for a single message; pass in a scalar.
219              
220             =cut
221              
222             sub merge_message_nonspam {
223             my $self=shift;
224             $self->merge_message(0,@_);
225             }
226              
227             sub merge_message {
228             my $self=shift;
229             my $spamstate=shift;
230             my $message=shift;
231             my @tokens=$self->_tokenise_message($message);
232             @tokens=keys %{{ map {$_ => 1} @tokens }};
233             my $sk=($spamstate==1)?'spam':'nonspam';
234             foreach my $t (@tokens) {
235             my $old;
236             if ($self->{$sk}->db_get($t,$old) == 0) {
237             $old++;
238             } else {
239             $old=1;
240             }
241             $self->{$sk}->db_put($t,$old);
242             delete $self->{tokencache}->{$t};
243             }
244             my $old;
245             $self->{general}->db_get($sk,$old);
246             $old++;
247             $self->{general}->db_put($sk,$old);
248             }
249              
250             =head2 markup_message()
251              
252             Test a message for possible spammishness. Pass a scalar containing a
253             single message. Will return the original message with inserted headers:
254              
255             X-Bayesian-Spam: (YES|NO) (probability%)
256             X-Bayesian-Test: the significant tests and their weights
257              
258             =cut
259              
260             sub markup_message {
261             my $self=shift;
262             my $message=shift;
263             my ($spam,$prob,$list)=$self->test_message($message);
264             my $text=($spam)?'YES':'NO';
265             $prob=sprintf("%.1f",100*$prob);
266             $message =~ s/^$/X-Bayesian-Spam: $text ($prob%)\n/m;
267             $text=join(', ',@{$list});
268             $message =~ s/^$/X-Bayesian-Test: $text\n/m;
269             return $message;
270             }
271              
272             =head2 test_message()
273              
274             Pass a scalar containing a single message. Returns a list:
275              
276             0: spam status (1 for spam, 0 for non spam)
277             1: probability of spam
278             2: listref of significant tests
279              
280             =cut
281              
282             sub test_message {
283             my $self=shift;
284             my $message=shift;
285             my @tokens=$self->_tokenise_message($message);
286             my %total;
287             foreach my $mode (qw(spam nonspam)) {
288             if ($self->{general}->db_get($mode,$total{$mode})) {
289             $total{$mode}=1;
290             }
291             unless ($total{$mode}) {
292             $total{$mode}=1;
293             }
294             }
295             foreach my $token (@tokens) {
296             unless (exists $self->{tokencache}->{$token}) {
297             $self->{tokencache}->{$token}=0.2;
298             my %this;
299             foreach my $mode (qw(spam nonspam)) {
300             if ($self->{$mode}->db_get($token,$this{$mode})) {
301             $this{$mode}=0;
302             }
303             }
304             $this{nonspam}*=$self->{fudgefactor};
305             if ($this{spam}+$this{nonspam}>5) {
306             $self->{tokencache}->{$token}=
307             &_max(0.01,&_min(0.99,
308             &_min($this{spam}/$total{spam},1)/
309             (&_min($this{nonspam}/$total{nonspam},1)+
310             &_min($this{spam}/$total{spam},1))
311             ));
312             }
313             }
314             }
315             my @toklist=sort {abs($self->{tokencache}->{$b}-0.5) <=> abs($self->{tokencache}->{$a}-0.5)} @tokens;
316             @toklist=@toklist[0..($self->{significant}-1)];
317             my $p=0.5;
318             foreach (map {$self->{tokencache}->{$_}} @toklist) {
319             $p *= $_ / ( ($p*$_) + ((1-$p) * (1-$_)));
320             }
321             my $s=0;
322             if ($p >= $self->{threshold}) {
323             $s=1;
324             }
325             @toklist=map {"$_ (".sprintf('%.3f',$self->{tokencache}->{$_}).")"}
326             sort {$self->{tokencache}->{$a} <=> $self->{tokencache}->{$b}
327             ||
328             $a cmp $b}
329             @toklist;
330             return ($s,$p,\@toklist);
331             }
332              
333             sub _tokenise_message {
334             my $self=shift;
335             my ($message)=@_;
336             my $data=$self->{parser}->parse_data($message);
337             my @keep=grep { $_->mime_type =~ /^text\/(plain|html)$/ } $data->parts;
338             $data->parts(\@keep);
339             my @message=($data->head->as_string);
340             for (my $i = 0; $i < $data->parts; $i++) {
341             my $ent = $data->parts($i);
342             if (my $io = $ent->open("r")) {
343             while (defined(my $line = $io->getline)) {
344             push(@message, $line);
345             }
346             $io->close;
347             }
348             }
349             my @token;
350             foreach my $line (@message) {
351             foreach my $token (split /[^-\$A-Za-z0-9\']+/o,$line) {
352             if ($token =~ /\D/o) {
353             push @token,$token;
354             }
355             }
356             }
357             return @token;
358             }
359              
360             sub _min {
361             my @t=@_;
362             my $a=$t[0];
363             foreach my $b (@t[1..$#t]) {
364             if ($b<$a) {
365             $a=$b;
366             }
367             }
368             return $a;
369             }
370              
371             sub _max {
372             my @t=@_;
373             my $a=$t[0];
374             foreach my $b (@t[1..$#t]) {
375             if ($b>$a) {
376             $a=$b;
377             }
378             }
379             return $a;
380             }
381              
382             1;
383             __END__