This patch adds a basic DSPAM module.

Several things need work here:
1. Messages with multiple recipients may cause problems when the recipients
   have different DSPAM profiles (which basically means that DSPAM has to
   be called n times resulting in n different bodies).
2. DSPAM factors must not be included in the header (dspam.conf, Preference "showFactors=off").
   Either checkConfiguration() should check this or (better) is_DSPAM_header(), 
   handle_DSPAM_header() should be changed.
3. Amavis user must considered trusted by DSPAM (dspam.conf, Trust amavis).   
   checkConfiguration() should check this.
4. Mails with encoded bodies will be broken because this module does not change the mime
   type.

You will suffer the usual disadvantages when using DSPAM:
- your digitally signed mails (PGP/MIME, S/MIME) will be destroyed unless you use my DSPAM patch.
- DSPAM may change headers (encoding, subject line) and break DomainKeys with that(?)
- DSPAM changes your body unless told otherwise (signaturelocation) and will break DomainKeys


--- original/amavisd	2006-05-10 00:00:10.000000000 +0200
+++ modified/amavisd	2006-05-10 00:36:21.000000000 +0200
@@ -233,7 +233,7 @@
       %hdrfrom_notify_sender_by_ccat
       %admin_maps_by_ccat %dsn_bcc_by_ccat
       %warnrecip_maps_by_ccat %addr_extension_maps_by_ccat
-      @load_spamscanners $spamscanner_tables
+      @load_spamscanners $spamscanner_tables $dspamuser_tables
     )],
     'confvars' => [qw(
       $myproduct_name $myversion_id $myversion_id_numeric $myversion_date
@@ -14730,7 +14730,7 @@
   import Amavis::Conf qw(:platform c cr ca);
   import Amavis::Lookup qw(lookup);
   import Amavis::Out::EditHeader;
-  import Amavis::Util qw(do_log run_command);
+  import Amavis::Util qw(do_log run_command untaint);
   use File::Temp();
 }
 
@@ -14772,12 +14772,284 @@
   $self->{'initialized_stage'} = 3;
 }
 
+sub _get_set_value($$$) {
+  my($self, $key, $value) = @_;
+  if (defined $value) {
+    $self->{$key} = $value;
+  }
+  if (not defined $self->{$key}) { 
+    do_log(0, "internal DSPAM error: not defined $key!");
+  }
+  return $self->{$key};
+}
+
+sub probability {
+  my($self, $probability) = @_;
+  return $self->_get_set_value("probability", $probability);
+}
+
+sub confidence {
+  my($self, $confidence) = @_;
+  return $self->_get_set_value("confidence", $confidence);
+}
+
+sub insert_dspam_headers {
+  my($self, $insert_dspam_headers) = @_;
+  return $self->_get_set_value("insert_dspam_headers", $insert_dspam_headers);
+}
+
+sub msginfo {
+  my($self, $msginfo) = @_;
+  return $self->_get_set_value("msginfo", $msginfo);
+}
+
+sub header_edits {
+  my($self, $header_edits) = @_;
+  return $self->_get_set_value("header_edits", $header_edits);
+}
+
+sub dspam_stdout {
+  my($self, $iohandle) = @_;
+  return $self->_get_set_value("dspam_stdout", $iohandle);
+}
+
+sub dspam_stderr {
+  my($self, $iohandle) = @_;
+  return $self->_get_set_value("dspam_stderr", $iohandle);
+}
+
+sub lookup_dspam_username($$) {
+  my($self, $recipient) = (shift, shift);
+  
+  my($username) = lookup(0, $recipient, cr('dspamuser_tables'));
+  if (defined $username) {
+    return $username;
+  } else {
+    return $recipient;
+  }
+}
+
+sub get_dspam_username($$) {
+  my($self) = shift;
+  my(@recipients) = @{$self->msginfo()->recips()};
+  
+  if (scalar(@recipients) > 0) {
+    my($recipient) = $recipients[0];
+    if (scalar(@recipients) > 1) {
+      do_log(0, "warning: multiple recipients not supported by DSPAM module! Using '".$recipient."' as DSPAM username");
+    }
+    return $self->lookup_dspam_username($recipient);
+  } 
+  die "ERROR in SpamControl::DSPAM: no recipients for mail!";
+}
+
+sub assert_no_error_occurred($$) {
+  my($self, $io_handle) = @_;
+  if ($io_handle->error()) {
+    $self->{'error'} = 1;
+    my($errormessage) = sprintf("error in SpamControl::DSPAM during spamcheck: %d", $ERRNO);
+    do_log(0, $errormessage);
+  }
+}
+
+sub dspam_error($$) {
+  my($self, $dspam_stderr) = @_;
+  if (not $self->error_occurred) {
+    # Felix Schwarz: I don't know why we have to seek to the beginning just before reading but this way it works. I saw weird results such as eof, seek, not eof (this does not seem to be a timing issue).
+    # Seeking to the beginning and reading the complete output on STDERR is ok, because DSPAM prints only something in case of errors and will exit afterwards (TODO: Does this assumption always hold? No warnings?).
+    seek($dspam_stderr, 0,0);
+    while (not $dspam_stderr->eof) {
+      $self->{'error'} = 1;
+      do_log(0, "DSPAM ERROR: ".$dspam_stderr->getline);
+    }
+  }
+  return $self->error_occurred;
+}
+
+sub start_dspam($) {
+  my($self) = shift;
+  my($dspam_username) = $self->get_dspam_username($self->msginfo);
+  do_log(5, "dspam username is ".$dspam_username);
+  my($fh) = $self->msginfo()->mail_text;
+  $fh->seek(0,0) or die "Can't rewind mail file: $ERRNO";
+
+  my($errorfh) = new File::Temp(DIR=>'/tmp', UNLINK=>0);
+  
+  my(@dspam_arguments) = ('--process', '--user', untaint($dspam_username), '--deliver=spam,innocent', '--stdout');
+  my($proc_fh,$pid) = run_command('&'.$fh->fileno, '&'.$errorfh->fileno, untaint($self->{'dspam'}), @dspam_arguments);
+  $self->assert_no_error_occurred($proc_fh);
+
+  $self->dspam_stdout($proc_fh);
+  $self->dspam_stderr($errorfh);
+}
+
+sub header_complete($$) {
+  my($self, $line) = @_;
+  return ($line eq "\n");
+}
+
+sub more_lines_available($$) {
+  my($self, $io_handle) = @_;
+  my($error_occurred) = $io_handle->error();
+  my($eof) = $io_handle->eof();
+  return (not $error_occurred and not $eof);
+}
+
+sub starts_with($$) {
+  my($self, $string, $substring) = @_;
+  if (substr($string, 0, length($substring)) eq $substring) {
+    return 1;
+  }
+  return 0;
+}
+
+sub add_header($$$$) {
+  my($self, $name, $value) = @_;
+  if ($self->insert_dspam_headers()) {
+    $self->header_edits()->add_header($name, $value);
+  }
+}
+
+sub is_DSPAM_header($) {
+  my($self, $line) = @_;
+  return ($line =~ m/^X-DSPAM-[^:]*:[ \t]*.*$/); # this does not handle folding!
+}
+
+sub handle_DSPAM_header($$) {
+  my($self, $line) = @_;
+  $line =~ m/^(X-DSPAM-[^:]*):[ \t]*(.*)$/;
+  my($header_name, $header_value) = ($1, $2);
+  if ($header_name eq "X-DSPAM-Confidence") {
+    $self->confidence($header_value);
+  } elsif ($header_name eq "X-DSPAM-Probability") {
+    $self->probability($header_value);
+  }
+  $self->add_header($header_name, $header_value);
+}
+
+sub all_recipients_are_local($$) {
+  my($self) = shift;
+  return (not grep { not lookup(0,$_,@{ca('local_domains_maps')}) } @{$self->msginfo()->recips});
+}
+
+sub prepare_header_edits($$) {
+  my($self) = shift;
+  my($msginfo) = $self->msginfo();
+  my($header_edits) = $msginfo->header_edits;
+  if (not $header_edits) {
+    $header_edits = Amavis::Out::EditHeader->new;
+    $msginfo->header_edits($header_edits);
+  }
+  $self->header_edits($header_edits);
+}
+
+sub assert_header_complete($$$) {
+  my($self, $header, $line) = @_;
+  if (not (length($header) > 0 and $self->header_complete($line))) {
+    $self->{'error'} = 1;
+    do_log(0, "premature end of message header read from DSPAM output!");
+  }
+}
+
+sub error_occurred($) {
+  my($self) = @_;
+  return $self->{'error'};
+}
+
+sub read_header($$$) {
+  my($self) = @_;
+  my($stdout, $stderr) = ($self->dspam_stdout, $self->dspam_stderr());
+
+  my($line, $header) = ("", "");
+  while ($self->more_lines_available($stdout) and not $self->header_complete($line) and not $self->dspam_error($stderr)) {
+      $line = $stdout->getline();
+      if ($self->is_DSPAM_header($line)) {
+        $self->handle_DSPAM_header($line);
+      } else {
+        $header .= $line;
+      }
+  }
+
+  $self->dspam_error($stderr);
+  unless ($self->error_occurred) {
+    $self->assert_header_complete($header, $line);
+    $self->assert_no_error_occurred($stdout);
+  }
+  return $header;
+}
+
+sub write_new_mail($$$$) {
+  my($self, $dspam_handle, $mailbody, $header) = @_;
+  $mailbody->truncate(0);
+  $mailbody->seek(0,0);
+  $mailbody->print($header);
+  while ($self->more_lines_available($dspam_handle)) {
+      $mailbody->print($dspam_handle->getline());
+  }
+  $mailbody->seek(0,0);
+  $self->assert_no_error_occurred($dspam_handle);
+}
+
+sub read_dspam_output($) {
+  my($self) = @_;
+  my($header) = $self->read_header();
+  unless ($self->error_occurred) {
+    $self->write_new_mail($self->dspam_stdout, $self->msginfo()->mail_text(), $header);
+  }  
+  $self->dspam_stdout()->close();
+}
+
+# amavisd-new needs to get a single numeric spam score which is not quite 
+# appropriate for DSPAM but we have to compute one. Please note that a 
+# "spam score" from the DSPAM module MUST NOT be compared to one from 
+# SpamAssassin as the criteria are fundamentally different and I won't take 
+# any efforts to make them more comparable!
+#
+# However, I created this formula so that the DSPAM and the amavisd-new 
+# criteria for spam should match in the default setting:
+# p=0.5 and confidence=0.5 => spam score=+5 (amavisd-new default value)
+# 
+# DSPAM counts something as spam if a mail has a spam probability > 0.5 and
+# a confidence > 0.5. The function below is just a quick shot and subject to
+# changes as soon I have a distribution of DSPAM probabilities from my servers.
+# The basic idea was to use mainly the probability but high confidence values
+# should boost the score in its original direction.
+# The function below should give you a range from -10 to +20. 
+# Felix Schwarz, April 2006
+sub compute_spam_level($$$) {
+  my($self, $probability, $confidence) = @_;
+  if ($confidence > 0.5) {
+    return 20 * ($probability - 0.5) * (1 + $confidence - 0.5) + 5;
+  } else {
+    return 20 * ($probability - 0.5) + 5;
+  }
+}
+
+sub prepareSpamcheck($$) {
+  my($self, $msginfo) = @_;
+  $self->{'error'} = 0;
+  $self->probability(0.0);
+  $self->confidence(0.0);
+  $self->msginfo($msginfo);
+  $self->insert_dspam_headers($self->all_recipients_are_local());
+  $self->header_edits(0);   # This will make wrongly added headers much more obvious and easier to spot
+  $self->prepare_header_edits() if $self->insert_dspam_headers();
+}
+
 sub check {
   my($self,$msginfo) = @_;
   do_log(5, "DSPAM check called!");
-  $msginfo->spam_level(0);
+  $self->prepareSpamcheck($msginfo);
+  $self->start_dspam();
+  $self->read_dspam_output();
+  if ($self->error_occurred) {
+    return undef;
+  }
+  my($spam_level) = $self->compute_spam_level($self->probability, $self->confidence);
+  do_log(1, "spam check resulted in p=".sprintf("%1.3f", $self->probability).", confidence=".sprintf("%1.3f", $self->confidence)." => spam level=".sprintf("%1.1f", $spam_level));
+  $msginfo->spam_level($spam_level);
   $msginfo->spam_status("DSPAM");
-  return 0;
+  return $spam_level;
 }
 
 1;
