Fixed warn.
[PerlMail.git] / perlmail-accept
index 967a758..3432acb 100755 (executable)
@@ -69,10 +69,13 @@ require HTTP::Request;
 require LWP::UserAgent;
 use URI::Escape 'uri_escape';
 require WWW::SMS;
+require Authen::SASL;  # Sanity check for &Net::SMTP::auth
+use MIME::Base64;
+use IPC::Open3;
+use POSIX ":sys_wait_h";
 
 
-our($Message,$Audit,@AuditStored,$store_ignore,$store_ignorenewmail,$store_profile,$DoBell);
-our(%audit_profile,@sms_squeezes,@alternates_host,@dnsbl_whitelist);   # imported
+our($Message,@AuditStored,$DoBell);
 my %alternates_host;   # from @alternates_host
 my %dnsbl_whitelist;   # from @dnsbl_whitelist
 
@@ -81,7 +84,7 @@ my %dnsbl_whitelist;  # from @dnsbl_whitelist
 our $procmailTO_        =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^-a-zA-Z0-9_.])?'mio;
 our $procmailTO         =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^a-zA-Z])?'mio;
 our $procmailFROM_DAEMON=qr'^(Mailing-List:|Precedence:.*(junk|bulk|list)|To: Multiple recipients of |(((Resent-)?(From|Sender)|X-Envelope-From):|>?From )([^>]*[^(.%@a-z0-9])?(Post(ma?(st(e?r)?|n)|office)|(send)?Mail(er)?|daemon|m(mdf|ajordomo)|n?uucp|LIST(SERV|proc)|NETSERV|o(wner|ps)|r(e(quest|sponse)|oot)|b(ounce|bs\.smtp)|echo|mirror|s(erv(ices?|er)|mtp(error)?|ystem)|A(dmin(istrator)?|MMGR|utoanswer))(([^).!:a-z0-9][-_a-z0-9]*)?[%@>       ][^<)]*(\(.*\).*)?)?$([^>]|$))'mio;
-our $procmailFROM_MAILER=qr'^(((Resent-)?(From|Sender)|X-Envelope-From):|>?From )[^>]*\b(Post(ma(st(er)?|n)|office)|(send)?Mail(er)?|daemon|mmdf|n?uucp|ops|r(esponse|oot)|(bbs\.)?smtp(error)?|s(erv(ices?|er)|ystem)|A(dmin(istrator)?|MMGR))(([^).!:a-z0-9][-_a-z0-9]*)?[%@>         ][^<)]*(\(.*\).*)?)?$([^>]|$)'mio;
+$procmailFROM_MAILER=qr'^(((Resent-)?(From|Sender)|X-Envelope-From):|>?From )[^>]*\b(Post(ma(st(er)?|n)|office)|(send)?Mail(er)?|daemon|mmdf|n?uucp|ops|r(esponse|oot)|(bbs\.)?smtp(error)?|s(erv(ices?|er)|ystem)|A(dmin(istrator)?|MMGR))(([^).!:a-z0-9][-_a-z0-9]*)?[%@>     ][^<)]*(\(.*\).*)?)?$([^>]|$)'mio;
 # perl-5.8.0 does not cope w/original FROM_MAILER on the third '?' character
 # Thus we did '([^>]*[^(.%@a-z0-9])?' -> '[^>]*\b', I hope it is somehow similiar
 # original FROM_MAILER  =qr'^(((Resent-)?(From|Sender)|X-Envelope-From):|>?From )([^>]*[^(.%@a-z0-9])?(Post(ma(st(er)?|n)|office)|(send)?Mail(er)?|daemon|mmdf|n?uucp|ops|r(esponse|oot)|(bbs\.)?smtp(error)?|s(erv(ices?|er)|ystem)|A(dmin(istrator)?|MMGR))(([^).!:a-z0-9][-_a-z0-9]*)?[%@>   ][^<)]*(\(.*\).*)?)?$([^>]|$)'mio;
@@ -90,6 +93,7 @@ my $opt_mode;
 my $opt_smstest;       # 1 or $smscount
 my $opt_idle;
 my $opt_dry;
+my $opt_single;
 
 
 sub process;
@@ -102,7 +106,7 @@ sub stdin
        local $_;
        while (<>) {
                die "Invalid 'From ' line: $_" if $message eq "" && !/^From /;
-               if (/^From / && $message) {
+               if (!$opt_single && /^From / && $message) {
                        process $message;
                        $message="";
                        }
@@ -131,8 +135,10 @@ sub inetd
                $length==($_=read STDIN,$message,$length) or confess "Got $_ out of required $length bytes";
                $length==length $message or confess "False read return ".length($message)." instead of $length";
                {
-                       local *STDOUT;
-                       local *STDERR;
+                       # Do not: local *STDOUT;        # FIXME: fd's inherited by spawned processes are not closed this way!
+                       #         local *STDERR;        # FIXME: fd's inherited by spawned processes are not closed this way!
+                       # as IPC::Open3 and IPC::Open2 will not redirect the output
+                       # and send it to the original socket instead!
                        local $DoBell=0;
                        process $message;
                        if ($DoBell) {
@@ -442,7 +448,7 @@ sub write_message
 {
 my($folder)=@_;
 
-       return if $opt_dry;
+       return 1 if $opt_dry;   # simulate OK
        local *F;
        open F,">>$folder" or do { warn "Append \"$folder\": $!"; return 0; };
        {
@@ -466,8 +472,11 @@ my($message)=@_;
 
        local $_=$_;
        my $save_=$_;
+       $message=~s/(\n)(From )/$1>$2/sg;
        local $Message=$message;
-       local $Audit=Mail::Audit->new(
+       # Cannot call 'local' for our-imported variable:
+       my $Audit_save=$Audit;
+       $Audit=Mail::Audit->new(
                        "emergency"=>"$Mail/emergency",
                        "data"=>[map("$_\n",split("\n",$message))],
                        "log"=>"$HOME/.perlmail.log",
@@ -478,41 +487,45 @@ my($message)=@_;
        write_message("$Mail/input") or die;
        PerlMail::Config::audit();
        warn 'Corrupted $_, repaired' if defined($save_)!=defined($_) || (defined($_) && $save_ ne $_);
+       # restore:
+       $Audit=$Audit_save;
 }
 
 # utility functions:
 
+sub _spamchildcode
+{
+my($err,$isspam)=@_;
+
+       $err=$? if !defined $err;
+       return undef() if !WIFEXITED($?);
+       return undef() if  WIFSIGNALED($?);
+       return undef() if  WIFSTOPPED($?);
+       return $isspam||1 if  WEXITSTATUS($?);  # is-spam
+       return 0;       # not-spam
+}
+
 # return: true (error-message or "1") if is spam
-sub razor2
+sub spamassassin
 {
-       # razor-check has exit code 1 if NOT spam, code 0 if IS spam
+my($cmd)=@_;
+
+       $cmd||="$HOME/bin/spamassassin --exit-code";
+       # spamassassin has exit code 1 if IS spam, code 0 if NOT spam
        local *CHILD;
-       local $SIG{"PIPE"}=sub { warn "razor2 gave me SIGPIPE: broken pipe"; };
+       local $SIG{"PIPE"}=sub { warn "spamassassin gave me SIGPIPE: broken pipe"; };
        # prevent Razor2's: Can't call method "log" on unblessed reference at Razor2/Client/Agent.pm line 212.
        local $ENV{"HOME"}=$HOME;
-       open CHILD,'|'
-                                       .'('.'(razor-check 2>&1;echo >&3 $?)'
-                                                       .'|sed "s/^/razor-check: /"'
-                                                       .'|logger -t "perlmail['.$$.']" -p mail.crit'
-                                                       .') 3>&1'
-                                       .'|exit `cat`'
+       # 2>/dev/null to prevent error messages to corrupt inetd() output of perlmail-accept(1)
+       open CHILD,"|$cmd --mbox >/dev/null 2>/dev/null"
                        or return 0;
        print CHILD $Message;
-       my $return;
-       {
-               local $/=undef();
-               $return=<CHILD> || 1;
-               }
        close CHILD;
-       return undef() if !WIFEXITED($?);
-       return undef() if  WIFSIGNALED($?);
-       return undef() if  WIFSTOPPED($?);
-       return undef() if WEXITSTATUS($?);
-       return $return; # is-spam
+       return _spamchildcode;
 }
 
 # NOTE: returns undef() if !wantarray and the first header is unrecognized
-# Returns also hosts
+# Returns always HOST:IP pair(s).
 sub Received_for
 {
        my @r=();
@@ -529,27 +542,72 @@ sub Received_for
 
 # Extended Mail::Audit::MAPS
 # $domain,$full,[$timeout]
+# Returns false if valid, code if spam detected.
 sub dnsbl
 {
 my($domain,$full,$timeout)=@_;
 
        $timeout||=30;  # sec
        $Mail::Audit::MAPS::host=$domain;
-       my @hosts=map({ s/^.*://; "[$_]"; }     # strip DNS part
-                       grep({ /^([^:@]*):/     # $1 is DNS name, $' is IP address
-                                       && !$alternates_host{$1}        # leave only foreign hosts
-                                       && !$dnsbl_whitelist{$'}; } (Received_for()))
-                       );
-       splice @hosts,1 if !$full && @hosts;    # "&& @hosts" to prevent: WARN: splice() offset past end of array
-       {
-               package My::Audit::Faked;
-               sub received { return @{$_[0]->{"received"}}; }
+       for my $host (Received_for()) {
+               next if $host!~/^([^:@]*):/;
+               my $ip=$';
+               # $1 is DNS name, $ip is IP address
+               next if $alternates_host{$1};   # leave only foreign hosts
+               next if $dnsbl_whitelist{$ip};
+               {
+                       package My::Audit::Faked;
+                       sub received { return @{$_[0]->{"received"}}; }
+                       }
+               my $self_faked={
+                               "received"=>["[$ip]"],
+                               };
+               bless $self_faked,"My::Audit::Faked";
+               my $code=Mail::Audit::rblcheck($self_faked,$timeout);
+               next if !$code;
+               # Some 0.0.0.0 etc. found for <linux-kernel@>, see: &Mail::Audit::MAPS::_checkit
+               # Do not: $code!='1 Invalid IP address '
+               # as it causes warn.
+               return $code if $code ne '1 Invalid IP address ';
+               return if !$full;
                }
-       my $self_faked={
-                       "received"=>[@hosts],
-                       };
-       bless $self_faked,"My::Audit::Faked";
-       return Mail::Audit::rblcheck($self_faked,$timeout);
+}
+
+# Returns true if IS virus; the message will contain the virus name
+sub clamscan
+{
+my($cmd)=@_;
+
+       $cmd||='clamscan --no-summary -';
+       # clamscan has exit code 1 if IS virus , code 0 if NOT virus
+       # Do not use IPC::Open2 as it would try to use our STDERR which is not valid by: local *STDERR;
+       local(*WR,*RD,*ERR);
+       local $SIG{"PIPE"}=sub { warn "clamscan '$cmd' gave me SIGPIPE: broken pipe"; };
+       my $pid=open3(\*WR,\*RD,\*ERR,$cmd.' 2>&1')
+                       or do { cluck "IPC::Open3 $cmd: $!"; return 0; };
+       print WR $Message;
+       close WR or do { cluck "close WR of $cmd: $!"; return 0; };
+       my $status=do { local $/=undef(); <RD>; };
+       close RD or do { cluck "close RD of $cmd: $!"; return 0; };
+       # Do not: $status.=do { local $/=undef(); <ERR>; };
+       #         close ERR or do { cluck "close ERR of $cmd: $!"; return 0; };
+       # (FIXME) as it causes: Use of uninitialized value in <HANDLE>
+       # waitpid fills $? for: &_spamchildcode
+       local $SIG{"ALRM"}=sub { warn "Timeout $clamscan_waitpid_timeout sec waiting for child $cmd"; };
+       alarm $clamscan_waitpid_timeout;
+       # Do not: WNOHANG
+       # as it would not be enough for clamscan(1) even after all close-s above.
+       my $pidcheck=waitpid($pid,0);
+       alarm 0;
+       my $err=$?;
+       $pidcheck && $pidcheck==$pid
+                       or do { cluck "waitpid for $cmd returned $pidcheck!=$pid"; return 0; };
+       $status=~s/^stdin: //mg;
+       # Prevent: LibClamAV Warning: PGP encoded attachment not scanned
+       $status=~s/^.*\bwarning:.*\n//img;
+       $status=~s/\n$//;
+       return $status if $status ne "OK" && $status;
+       return _spamchildcode $err,$status;
 }
 
 sub muttrc_aliases
@@ -581,15 +639,10 @@ my($prefix,$profile)=@_;
                        if $From!~/$alternates/si;
        for my $for (reverse Received_for()) {
                $for=~s/:.*$//; # strip IP address here
-               if ($Fromobj->user() ne "prog-mutt") {
-                       next if lc($for) eq lc($From);
-                       }
-               else {
-                       my $forobj=parseone $for;
-                       if ($forobj && $forobj->host()) {
-                               # it is 'for' our primary address
-                               next if lc($forobj->host()) eq lc($Fromobj->host());    # or 'return'? shouldn't matter
-                               }
+               my $forobj=parseone $for;
+               if ($forobj && $forobj->host()) {
+                       # it is 'for' our primary address
+                       next if lc($forobj->host()) eq lc($Fromobj->host());    # or 'return'? shouldn't matter
                        }
                next if !$alternates_host{lc $for} && $for!~/$alternatesre/si;
                store "$prefix\L$for",($profile || []);
@@ -662,6 +715,63 @@ my($header,$map)=@_;
        $Audit->replace_header($header,$text);
 }
 
+# LMTP engine:
+use Net::Cmd qw(CMD_OK CMD_MORE);
+{
+       package My::Net::SMTP::LMTP;
+       require Net::SMTP;
+       our @ISA=qw(Net::SMTP);
+       use Net::SMTP;
+       use Net::Cmd qw(CMD_OK);
+       use Carp qw(confess cluck);
+
+       # Do not: sub _HELO
+       # as it would not set {'net_smtp_esmtp'}
+       sub _EHLO { shift->command("LHLO", @_)->response()  == CMD_OK }
+
+       sub clucked
+       {
+       my($self,$func,@args)=@_;
+
+               do { return $_ if defined $_; } for $self->$func(@args);
+               cluck $func;
+               return;
+       }
+}
+
+
+sub lmtp_deliver
+{
+my($admin_user,$admin_pwd,$user_from,$user_to)=@_;
+
+       my $lmtp=My::Net::SMTP::LMTP->clucked("new","localhost","Port"=>"lmtp",
+#                      "Debug"=>1,
+                       ) or return;
+       bless $lmtp,"My::Net::SMTP::LMTP";
+# Prevent:
+# due to:
+#      $lmtp->auth(Authen::SASL->new(
+#                      "mechanism"=>"PLAIN",
+#                      "callback"=>{
+#                                      "user"=>$admin_user,
+#                                      "pass"=>$admin_pwd,
+#                                      # Prevent: "authname"=>$admin_user
+#                                      # as it causes: DIE: Unknown callback: 'authname'. (user|auth|language|pass)
+#                                      }));
+       # FIXME: Authentication hack:
+       $lmtp->command("AUTH PLAIN")->response()==CMD_MORE
+                       or do { cluck "auth announce"; return; };
+       $lmtp->clucked("command",encode_base64($user_from."\x00".$admin_user."\x00".$admin_pwd)) or return;
+       $lmtp->clucked("mail",$user_from) or return;
+       $lmtp->clucked("to",$user_to) or return;
+       $lmtp->clucked("data"); # Do not: or return;
+       # Prevent: 554 5.6.0 Message contains invalid header
+       (my $data=$Message)=~s/\AFrom .*\r?\n//;
+       $lmtp->clucked("datasend",$data) or return;
+       $lmtp->clucked("dataend") or return;
+       $lmtp->clucked("quit") or return;
+}
+
 
 # MAIN
 
@@ -669,6 +779,7 @@ $Getopt::Long::ignorecase=0;
 die "GetOptions error" if !Getopt::Long::GetOptions(
                  "inetd"    ,sub { $opt_mode=\&inetd; },
                  "stdin"    ,sub { $opt_mode=\&stdin; },
+                 "single!"  ,\$opt_single,
                  "dry"      ,\$opt_dry,
                  "smstest:s",sub { $opt_mode=\&stdin; $opt_smstest=($_[1] || 1); },
                  "idle!"    ,\$opt_idle,