much better logging of disposition
replaced header parsing regexp with split. faster and more robust.
store the dspam results in a note
quiet some of the debug logging

---
plugins/dspam        |   74 +++++++++++++++++++++++++++-----------
t/plugin_tests/dspam |   98 ++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 152 insertions(+), 20 deletions(-)
create mode 100644 t/plugin_tests/dspam

diff --git a/plugins/dspam b/plugins/dspam
index 4bc3203..aeaffd6 100644
--- a/plugins/dspam
+++ b/plugins/dspam
@@ -12,8 +12,15 @@ train dspam.
Adds the X-DSPAM-Result and X-DSPAM-Signature headers to messages. The latter 
is essential for
training dspam and the former is useful to MDAs, MUAs, and humans.

+Adds a transaction note to the qpsmtpd transaction. The notes is a hashref
+with at least the 'class' field (Spam,Innocent,Whitelisted). It will normally
+contain a probability and confidence ratings as well.
+
=head1 TRAINING DSPAM

+Do not just enable dspam! Its false positive rate when untrained is high. The
+good news is; dspam learns very, very fast.
+
To get dspam into a useful state, it must be trained. The best method way to
train dspam is to feed it two large equal sized corpuses of spam and ham from
your mail server. The dspam authors suggest avoiding public corpuses. I train
@@ -171,7 +178,7 @@ sub hook_data_post {
    my $username = $self->select_username( $transaction );
    my $message  = $self->assemble_message($transaction);
    my $filtercmd = $self->get_filter_cmd( $transaction, $username );
-    $self->log(LOGINFO, $filtercmd);
+    $self->log(LOGDEBUG, $filtercmd);

    my $response = $self->dspam_process( $filtercmd, $message );
    if ( ! $response ) {
@@ -267,53 +274,80 @@ sub dspam_process_open2 {
sub dspam_reject {
    my ($self, $transaction) = @_;

-    my $reject = $self->{_args}->{reject} or return (DECLINED);
+    my $reject = $self->{_args}->{reject} or do {
+        $self->log(LOGWARN, "reject disabled");
+        return DECLINED;
+    };

-    my ($class, $probability, $confidence) = $self->get_dspam_results( 
$transaction );
+    my $d = $self->get_dspam_results( $transaction );

    if ( $reject eq 'agree' ) {
        my $sa = $transaction->notes('spamassassin' );

-        if ( ! $sa->{is_spam} && ! $class ) {
+        if ( ! $sa->{is_spam} && ! $d->{class} ) {
            $self->log(LOGWARN, "cannot agree: SA or dspam results missing");
-            return (DECLINED)
+            return DECLINED
        };

-        if ( $class eq 'Spam' && $sa->{is_spam} eq 'Yes' ) {
-            $self->log(LOGWARN, "agreement: SA: $sa->{is_spam}, dspam: 
$class");
-            return Qpsmtpd::DSN->media_unsupported('dspam says, no spam 
please')
+        if ( $d->{class} eq 'Spam' && $sa->{is_spam} eq 'Yes' ) {
+            $self->log(LOGDEBUG, "agreement: SA: $sa->{is_spam}, dspam: 
$d->{class}");
+            return Qpsmtpd::DSN->media_unsupported(DENY,'we agree, no spam 
please')
        };

-        return (DECLINED);
+        return DECLINED;
    };

-    return DECLINED if ! $class;
-    return DECLINED if $class eq 'Innocent';
+    if ( ! $d->{class} ) {
+        $self->log(LOGWARN, "no dspam class detected");
+        return DECLINED;
+    };
+    return DECLINED if $d->{class} eq 'Innocent';

    if ( $self->qp->connection->relay_client ) {
        $self->log(LOGWARN, "allowing spam since user authenticated");
        return DECLINED;
    };
-    return DECLINED if $probability >= $reject;
-    return DECLINED if $confidence != 1;
-# dspam is 100% sure this message is spam
-# default of media_unsupported is DENY, so just change the message
-    return Qpsmtpd::DSN->media_unsupported('dspam says, no spam please');
+    if ( $d->{probability} <= $reject ) {
+        $self->log(LOGINFO, "probability is too low ($d->{probability} < 
$reject)");
+        return DECLINED;
+    };
+    if ( $d->{confidence} != 1 ) {
+        $self->log(LOGINFO, "confidence is too low ($d->{confidence})");
+        return DECLINED;
+    };
+
+# dspam is more than $reject percent sure this message is spam
+    return Qpsmtpd::DSN->media_unsupported(DENY,'dspam says, no spam please');
}

sub get_dspam_results {
    my ( $self, $transaction ) = @_;

+    if ( $transaction->notes('dspam') ) {
+        return $transaction->notes('dspam');
+    };
+
    my $string = $transaction->header->get('X-DSPAM-Result') or do {
        $self->log(LOGWARN, "dspam_reject: failed to find the dspam header");
        return;
    };

-    my ($class,$probability,$confidence) =
-        $string =~ m/^([\w]+), probability=([\d\.]+), confidence=([\d\.]+)/i;
+    my @bits  = split(/,\s+/, $string); chomp @bits;
+    my $class = shift @bits;
+    my %d;
+    foreach (@bits) {
+        my ($key,$val) = split(/=/, $_);
+        $d{$key} = $val;
+    };
+    $d{class} = $class;

-    $self->log(LOGDEBUG, "$class, prob: $probability, conf: $confidence");
-    return ($class, $probability, $confidence);
+    my $message = $d{class};
+    if ( defined $d{probability} && defined $d{confidence} ) {
+        $message .= ", prob: $d{probability}, conf: $d{confidence}";
+    };
+    $self->log(LOGDEBUG, $message);
+    $transaction->notes('dspam', \%d);
+    return \%d;
};

sub get_filter_cmd {
diff --git a/t/plugin_tests/dspam b/t/plugin_tests/dspam
new file mode 100644
index 0000000..417db84
--- /dev/null
+++ b/t/plugin_tests/dspam
@@ -0,0 +1,98 @@
+#!perl -w
+
+use strict;
+use warnings;
+
+use Mail::Header;
+use Qpsmtpd::Constants;
+
+sub register_tests {
+    my $self = shift;
+
+    my %args = ( );
+    $self->register( $self->qp, %args );
+
+    $self->register_test('test_get_filter_cmd', 2);
+    $self->register_test('test_get_dspam_results', 6);
+    $self->register_test('test_dspam_reject', 6);
+}
+
+sub test_dspam_reject {
+    my $self = shift;
+
+    my $transaction = $self->qp->transaction;
+
+    # reject not set
+    $transaction->notes('dspam', { class=> 'Spam', probability => .99, 
confidence=>1 } );
+    my ($r) = $self->dspam_reject( $transaction );
+    cmp_ok( $r, '==', DECLINED, "dspam_reject ($r)");
+
+    # reject exceeded
+    $self->{_args}->{reject} = .95;
+    $transaction->notes('dspam', { class=> 'Spam', probability => .99, 
confidence=>1 } );
+    my ($r) = $self->dspam_reject( $transaction );
+    cmp_ok( $r, '==', DENY, "dspam_reject ($r)");
+
+    # below reject threshold
+    $transaction->notes('dspam', { class=> 'Spam', probability => .94, 
confidence=>1 } );
+    my ($r) = $self->dspam_reject( $transaction );
+    cmp_ok( $r, '==', DECLINED, "dspam_reject ($r)");
+
+    # requires agreement
+    $self->{_args}->{reject} = 'agree';
+    $transaction->notes('spamassassin', { is_spam => 'Yes' } );
+    $transaction->notes('dspam', { class=> 'Spam', probability => .90, 
confidence=>1 } );
+    my ($r) = $self->dspam_reject( $transaction );
+    cmp_ok( $r, '==', DENY, "dspam_reject ($r)");
+
+    # requires agreement
+    $transaction->notes('spamassassin', { is_spam => 'No' } );
+    $transaction->notes('dspam', { class=> 'Spam', probability => .96, 
confidence=>1 } );
+    my ($r) = $self->dspam_reject( $transaction );
+    cmp_ok( $r, '==', DECLINED, "dspam_reject ($r)");
+
+    # requires agreement
+    $transaction->notes('spamassassin', { is_spam => 'Yes' } );
+    $transaction->notes('dspam', { class=> 'Innocent', probability => .96, 
confidence=>1 } );
+    my ($r) = $self->dspam_reject( $transaction );
+    cmp_ok( $r, '==', DECLINED, "dspam_reject ($r)");
+};
+
+sub test_get_dspam_results {
+    my $self = shift;
+
+    my $transaction = $self->qp->transaction;
+    my $header = Mail::Header->new(Modify => 0, MailFrom => "COERCE");
+    $transaction->header( $header );
+
+    my @dspam_sample_headers = (
+        'Innocent, probability=0.0000, confidence=0.69',
+        'Innocent, probability=0.0000, confidence=0.85',
+        'Innocent, probability=0.0023, confidence=1.00',
+        'Spam, probability=1.0000, confidence=0.87',
+        'Spam, probability=1.0000, confidence=0.99',
+        'Whitelisted',
+    );
+
+    foreach my $header ( @dspam_sample_headers ) {
+        $transaction->header->delete('X-DSPAM-Result');
+        $transaction->header->add('X-DSPAM-Result', $header);
+        my $r = $self->get_dspam_results($transaction);
+        ok( ref $r, "get_dspam_results ($header)" );
+        #warn Data::Dumper::Dumper($r);
+    };
+};
+
+sub test_get_filter_cmd {
+    my $self = shift;
+
+    my $transaction = $self->qp->transaction;
+    my $dspam = "/usr/local/bin/dspam";
+    $self->{_args}{dspam_bin} = $dspam;
+
+    foreach my $user ( qw/ smtpd m...@example.com / ) {
+        my $answer = "$dspam --user smtpd --mode=tum --process 
--deliver=summary --stdout";
+        my $r = $self->get_filter_cmd($transaction, 'smtpd');
+        cmp_ok( $r, 'eq', $answer, "get_filter_cmd $user" );
+    };
+};
-- 
1.7.9.6

Reply via email to