Sanity check crashing spamassassin(1).
[PerlMail.git] / perlmail-accept
1 #! /usr/bin/perl
2
3 #       $Id$
4 # Copyright (C) 2002-2003 Jan Kratochvil <project-PerlMail@jankratochvil.net>
5
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 2 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 # GNU General Public License for more details.
15
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
19
20
21 use vars qw($VERSION);
22 $VERSION=do { my @r=(q$Revision$=~/\d+/g); sprintf "%d.".("%03d"x$#r),@r; };
23 use strict;
24 use warnings;
25
26
27 INIT {
28         require Sys::Syslog;
29         Sys::Syslog::openlog("perlmail","pid","mail");
30         my @syslogging_stack;
31         sub syslogging_on_save
32         {
33                 push @syslogging_stack,$SIG{"__WARN__"},$SIG{"__DIE__" };
34                 $SIG{"__WARN__"}=sub { Sys::Syslog::syslog("warning","WARN: %s",$_[0]); };      # disabled: print STDERR $_[0];
35                 $SIG{"__DIE__" }=sub { Sys::Syslog::syslog("crit"   ,"DIE: %s" ,$_[0]); };
36         }
37         syslogging_on_save();
38         sub syslogging_restore
39         {
40                 $SIG{"__DIE__" }=pop @syslogging_stack;
41                 $SIG{"__WARN__"}=pop @syslogging_stack;
42         }
43         }
44
45
46 use File::Basename;
47 BEGIN {
48         use lib $ENV{"PERLMAIL_BASEDIR"} || File::Basename::dirname($0);
49         use PerlMail::Config;
50         use PerlMail::Lib;
51         }
52
53 use Mail::Audit qw(MAPS);
54 require IO::Handle;
55 use Carp qw(cluck confess);
56 use POSIX qw(WIFEXITED WEXITSTATUS WIFSIGNALED WTERMSIG WIFSTOPPED WSTOPSIG);
57 require POSIX;  # for ceil
58 use User::Utmp;
59 use Getopt::Long;
60 require Mail::Address;
61 require MIME::Words;
62 require Cz::Cstocs;
63 require HTML::Entities;
64 require MIME::Head;
65 require Lingua::EN::Squeeze;
66 require Mail::Mailer;
67 require HTTP::Cookies;
68 require HTTP::Request;
69 require LWP::UserAgent;
70 use URI::Escape 'uri_escape';
71 require WWW::SMS;
72 require Authen::SASL;   # Sanity check for &Net::SMTP::auth
73 use MIME::Base64;
74 use IPC::Open3;
75 use POSIX ":sys_wait_h";
76
77
78 our($Message,@AuditStored,$DoBell);
79 my %alternates_host;    # from @alternates_host
80 my %dnsbl_whitelist;    # from @dnsbl_whitelist
81
82 # from RedHat "procmail-3.22-5"
83 # /i should be only $procmailFROM_DAEMON but how it can hurt to /i all?
84 our $procmailTO_        =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^-a-zA-Z0-9_.])?'mio;
85 our $procmailTO         =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^a-zA-Z])?'mio;
86 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;
87 $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;
88 # perl-5.8.0 does not cope w/original FROM_MAILER on the third '?' character
89 # Thus we did '([^>]*[^(.%@a-z0-9])?' -> '[^>]*\b', I hope it is somehow similiar
90 # 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;
91
92 my $opt_mode;
93 my $opt_smstest;        # 1 or $smscount
94 my $opt_idle;
95 my $opt_dry;
96 my $opt_single;
97
98
99 sub process;
100
101 sub stdin
102 {
103         syslogging_restore();   # This is more a debugging session
104         local $/="\n";
105         my $message="";
106         local $_;
107         while (<>) {
108                 die "Invalid 'From ' line: $_" if $message eq "" && !/^From /;
109                 if (!$opt_single && /^From / && $message) {
110                         process $message;
111                         $message="";
112                         }
113                 $message.=$_;
114                 }
115         process $message if $message;
116         exit 0;
117 }
118
119 # FIXME: separate 'perlmail'-transfer together with perlmail-submit away
120 sub inetd
121 {
122         die "Excessive arguments" if @ARGV;
123
124         IO::Handle::autoflush STDOUT 1;
125
126         while (1) {
127                 local $/="\n";
128                 my $length=<STDIN>;
129                 confess "Unexpected EOF" if !defined $length;
130                 confess "Missing EOL" if $length!~s/\n$//s;
131                 exit 0 if $length eq "BYE";
132                 confess "Unrecognized length: $length" if $length!~/^\d+$/;
133                 my $message;
134                 local $_;
135                 $length==($_=read STDIN,$message,$length) or confess "Got $_ out of required $length bytes";
136                 $length==length $message or confess "False read return ".length($message)." instead of $length";
137                 {
138                         # Do not: local *STDOUT;        # FIXME: fd's inherited by spawned processes are not closed this way!
139                         #         local *STDERR;        # FIXME: fd's inherited by spawned processes are not closed this way!
140                         # as IPC::Open3 and IPC::Open2 will not redirect the output
141                         # and send it to the original socket instead!
142                         local $DoBell=0;
143                         process $message;
144                         if ($DoBell) {
145                                 bell() or warn "Unable to BELL";
146                                 }
147                         }
148                 print STDOUT "1";
149                 }
150         die "NOTREACHED";
151 }
152
153 sub bell
154 {
155         local *BELL;
156         open BELL,">/dev/tty11" or return 0;
157         print BELL "\x07";
158         close BELL or return 0;
159         return 1;
160 }
161
162 sub useridle
163 {
164         return 0 if ! -e "$HOME/away";
165         my %valid_users=map(($_=>1),@ValidUsers);
166         my($idlebest,$linebest);
167         for my $utmp (User::Utmp::getut(),{ "ut_line"=>"psaux" }) {
168                 local $_;
169                 next if defined($_=$utmp->{"ut_type"}) && $_!=User::Utmp::USER_PROCESS;
170                 next if defined($_=$utmp->{"ut_user"}) && !$valid_users{$_};
171                 my $line="/dev/".$utmp->{"ut_line"};
172                 my $atime=(stat $line)[8];
173                 my $what="user \"".($utmp->{"ut_user"} || "<local>")."\", line \"$line\"";
174                 warn "Unable to stat $what" and next if !$atime;
175                 my $idle=time()-$atime;
176                 warn "atime in future for $what" and next if $idle<0;
177                 next if $idle>$IdleMax;
178                 next if defined $idlebest && $idlebest<=$idle;
179                 $idlebest=$idle;
180                 $linebest=$line;
181                 }
182         return !wantarray() ? $idlebest : ($idlebest,$linebest);
183 }
184
185 # return only the very (recursive) first part
186 sub body_first
187 {
188         return $Audit if !$Audit->is_mime();
189         my $first=$Audit;
190         local $_;
191         $first=$_ while $_=$first->parts(0);
192         return $first;
193 }
194
195 sub mimehead
196 {
197 my($part)=@_;
198
199         return $Audit->is_mime() ? $part->head()
200                         : MIME::Head->new([ split "\n",$Audit->head()->as_string() ])
201                         ;
202 }
203
204 sub mimebody
205 {
206 my($part)=@_;
207
208         # be vary cautious here as most of $part methods will encode it!
209         return join "",@{$Audit->body()} if !$Audit->is_mime();
210         my $bodyhandle=$part->bodyhandle();
211         # If MIME is corrupted we don't get bodyhandle() for this part
212         # It may occur when "boundary" is specified by header but no such boundary is found in the body
213         return $bodyhandle->as_string() if $bodyhandle;
214         warn "MIME corrupted, adapting";
215         return $part->body_as_string();
216 }
217
218 sub mime_type
219 {
220 my($part)=@_;
221
222         return $Audit->is_mime() ? $part->effective_type() : mimehead($part)->mime_type();
223 }
224
225 sub body_simple
226 {
227         my $first=body_first();
228         my $r=mimebody($first);
229         my $mime_type=mime_type($first);
230            if ($mime_type eq "text/html") {
231                 # HTML::FormatText just does a useless text layouts
232                 # PerlIO::via::StripHTML probably needs PerlIO input (?)
233                 $r=~s/<[^>]*>//gs;
234                 $r=HTML::Entities::decode($r);
235                 # FIXME: detect charset from <meta> tag: "Content-type: text/html; charset=<???>"
236                 }
237         elsif ($mime_type eq "application/pgp-encrypted"
238                && (my $filename=mimehead($first)->mime_attr("Content-Disposition.filename"))
239                ) {
240                 # first part contains just "Version: 1" as of GnuPG v1.0.4 (GNU/Linux)
241                 $r="pgp($filename)";
242                 }
243         if ((my $charset=mimehead($first)->mime_attr("Content-Type.charset"))) {
244                 my $cstocs=Cz::Cstocs->new($charset,"ascii");
245                 $r=&$cstocs($r) if $cstocs;     # charset may be unknown
246                 }
247         return $r;
248 }
249
250 sub parts_linear
251 {
252 my($part)=@_;
253
254         return $Audit if !$part && !$Audit->is_mime();
255         $part||=$Audit;
256         # don't use '!$part->parts()' as even 0-parts-multiparts are still multiparts
257         return $part if $part->bodyhandle();
258         return map { (parts_linear($_)); } $part->parts();
259 }
260
261 sub smsbuild
262 {
263 my($smsi,$smscount)=@_;
264
265         return "$smsi/$smscount:" if $smscount>1;
266         return "";
267 }
268
269 sub smslens
270 {
271 my($ignorenewmail,$smscount,%args)=@_;
272
273         return map({
274                         my $l=160;
275                         if (!$ignorenewmail) {  # send by mail
276                                 $l-=length("Z emailu FIXME SMSmailError: ");
277                                 $l-=length(smsbuild($_,$smscount));
278                                 }
279                         else {  # send by web
280                                 $l-=6;  # 154 is the max length before split; why?
281                                 }
282                         $l;
283                         } (0..$smscount-1));
284 }
285
286 sub smssend_web
287 {
288 my($squeezed,$smscount,@lens)=@_;
289
290         $smscount=POSIX::ceil($smscount/5);
291         for my $smsi (0..$smscount-1) {
292                 my $len=$lens[$smsi];
293                 $squeezed=~/^.{0,$len}/s;
294                 my $frag=$&;
295                 $squeezed=$';
296                 return 0 if 3!=@SMSwebRcpt;
297                 local *F;
298                 open F,"$HOME/priv/WWW-SMS-$SMSwebRcpt_username.pwd" or return 0;
299                 my $pwd=<F>;
300                 chomp $pwd;
301                 close F;
302                 my $sms=WWW::SMS->new(@SMSwebRcpt,$frag,"username"=>$SMSwebRcpt_username,"passwd"=>$pwd);
303                 for ($sms->gateways("sorted"=>"reliability")) {
304                         last if $sms->send($_);
305                         Sys::Syslog::syslog("warning","Web SMS send failed: %s",$WWW::SMS::Error);
306                         my $void=$WWW::SMS::Error;      # Prevent: Name "WWW::SMS::Error" used only once
307                         }
308                 }
309         return 1;
310 }
311
312 sub smssend_mail
313 {
314 my($squeezed,$smscount,@lens)=@_;
315
316         return 0;
317 }
318
319 sub smssend
320 {
321 my($ignorenewmail,$smscount,%args)=@_;
322
323         my $text=PerlMail::Config::audit_sms(
324                         "subject"=>unmime($Audit->subject()),
325                         "from"=>[ Mail::Address->parse(unmime($Audit->from())) ],
326                         "body"=>substr(body_simple(),0,$MaxBodySMS*(1+0.25*$smscount)),
327                         %args);
328         my $texthead="";
329         ($texthead,$text)=@$text if ref $text;
330         do { print "$texthead\n$text\n"; return; } if $opt_smstest;
331         my @lens=smslens($ignorenewmail,$smscount,%args);
332         my $maxlen=0;
333         $maxlen+=$_ for (@lens);
334         my $squeezed;
335         for my $squeeze (@sms_squeezes) {
336                 local $_;
337                  Lingua::EN::Squeeze::SqueezeControl($_)    if defined ($_=$squeeze->{"SqueezeControl"});
338                 $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL or 1;  # prevent: Name "$_" used only once: possible typo
339                 $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL=$_ if defined ($_=$squeeze->{"SQZ_OPTIMIZE_LEVEL"});
340                 $squeezed=Lingua::EN::Squeeze::SqueezeText($text);
341                 chomp $squeezed;
342                 last if $maxlen>=length($texthead.$squeezed);
343                 }
344         $squeezed=substr $texthead.$squeezed,0,$maxlen; # strip if we passed thru last for() above
345         my $recalclen=0;
346         for ($smscount=0;$recalclen<length $squeezed;$smscount++) {
347                 $recalclen+=$lens[$smscount];
348                 }
349         my $func=($ignorenewmail ? \&smssend_web : \&smssend_mail);
350         &$func($squeezed,$smscount,@lens);
351 }
352
353 sub smssend_tryall
354 {
355 my($ignorenewmail,@args)=@_;
356
357         return if !$opt_smstest && !$opt_idle && defined useridle();
358         local $_;
359         return $_ if                     $_=smssend(1,@args);   # web
360         return $_ if !$ignorenewmail && ($_=smssend(0,@args));  # mail
361         warn "Unable to SMSsend the mail";
362         return 0;
363 }
364
365 sub cut
366 {
367         local $_=$_[0];
368         return "<???>" if !defined($_) || /^\s*$/s;
369         s/^\s*//s;
370         s/\s*$//s;
371         return $_ if length($_)<128;
372         return substr($_,0,128)."...";
373 }
374
375 our $profile_eval_depth=0;
376 # ($name || @$name)
377 sub profile_eval
378 {
379 my($name)=@_;
380
381         die "Nesting profile: $name" if 0x10<=(local $profile_eval_depth=$profile_eval_depth+1);
382         return @$name if ref $name;
383         die "Profile not found: $name" if !exists $audit_profile{$name};
384         my @this=@{$audit_profile{$name}};
385         return (profile_eval($'),@this[1..$#this]) if $this[0] && $this[0]=~/^=/;
386         return @this;
387 }
388
389 sub address_show
390 {
391 my($text)=@_;
392
393         return join(",",map({ $_->name() or $_->address(); } Mail::Address->parse($text)));
394 }
395
396 sub unmime
397 {
398 my($text)=@_;
399
400         return join "",map({
401                         my $cstocs;
402                         for (${$_}[1],"iso-8859-2") {
403                                 last if $_ && ($cstocs=Cz::Cstocs->new($_,"ascii"));
404                                 }
405                         &$cstocs(${$_}[0]);
406                         } MIME::Words::decode_mimewords($text));
407 }
408
409 # $folder: "$folder; comment"
410 # $profile as profile_eval($name)
411 sub store
412 {
413 my($folder,$profile,%args)=@_;
414
415         $profile=$store_profile if !$profile;
416         my %do=map({ (!/=/ ? ($_=>1) : ($`=>$')); } profile_eval($profile));
417         Sys::Syslog::syslog("info","%s%s%s: %s: %s",
418                                         (!$opt_dry ? "" : "--dry: "),
419                                         (!$store_ignore ? "" : "IGNORED[$store_ignore]: "),
420                                         map({ cut($_); } $folder,address_show(unmime($Audit->from())),unmime($Audit->subject())),
421                                         )
422                         if $do{"syslog"} || $opt_dry;
423         $folder=~s/;.*$//s;
424         $folder="$Mail/".$' if $folder=~/^=/;
425         push @AuditStored,$folder if $do{"did"};
426         return if $store_ignore || $opt_dry;
427         $DoBell++ if $do{"bell"};
428         write_message($folder) or die;
429         smssend_tryall $store_ignorenewmail,$do{"sms"},%args if $do{"sms"};
430 }
431
432 our $did_last=0;
433
434 # no &$funcref=>did smth in this block
435 # &$funcref,@funcargs
436 sub did
437 {
438 my($funcref,@funcargs)=@_;
439
440         return @AuditStored!=$did_last if !$funcref;
441         local $did_last=@AuditStored;
442         &$funcref(@funcargs);
443         return @AuditStored!=$did_last;
444 }
445
446 # Never use Mail::Audit->store() as it will reformat MIME bodies and possibly corrupt OpenPGP!
447 sub write_message
448 {
449 my($folder)=@_;
450
451         return 1 if $opt_dry;   # simulate OK
452         local *F;
453         open F,">>$folder" or do { warn "Append \"$folder\": $!"; return 0; };
454         {
455                 local $_;
456                 ($_=Mail::Audit::audit_get_lock(\*F,$folder)) and do { warn "Lock \"$folder\": $!"; last; };
457                 seek F,0,IO::Handle::SEEK_END or do { warn "Seek-end \"$folder\": $!"; last; };
458                 # FIXME: Check for '^From ' to not to rely on our network peer
459                 print F $Message or do { warn "Write to \"$folder\": $!"; last; };
460                 do { print F "\n"; warn "Missing trailing newline, fixed"; } if $Message!~/\n$/s;
461                 close F or do { warn "Close \"$folder\""; last; };
462                 return 1;       # OK
463                 }
464         warn "MAIL DROPPED for folder: $folder";
465         close F;
466         return 0;       # failed
467 }
468
469 sub process
470 {
471 my($message)=@_;
472
473         local $_=$_;
474         my $save_=$_;
475         $message=~s/(\n)(From )/$1>$2/sg;
476         local $Message=$message;
477         # Cannot call 'local' for our-imported variable:
478         my $Audit_save=$Audit;
479         $Audit=Mail::Audit->new(
480                         "emergency"=>"$Mail/emergency",
481                         "data"=>[map("$_\n",split("\n",$message))],
482                         "log"=>"$HOME/.perlmail.log",
483                         "loglevel"=>99,
484                         );
485         local @AuditStored=();
486         do { smssend 0,$opt_smstest; return; } if $opt_smstest;
487         write_message("$Mail/input") or die;
488         PerlMail::Config::audit();
489         warn 'Corrupted $_, repaired' if defined($save_)!=defined($_) || (defined($_) && $save_ ne $_);
490         # restore:
491         $Audit=$Audit_save;
492 }
493
494 # utility functions:
495
496 sub _spamchildcode
497 {
498 my($err,$isspam)=@_;
499
500         $err=$? if !defined $err;
501         return undef()    if !WIFEXITED($?);
502         return undef()    if  WIFSIGNALED($?);
503         return undef()    if  WIFSTOPPED($?);
504         return 0 if !WEXITSTATUS($?);
505         return $isspam||1 if 1==WEXITSTATUS($?);        # isspam
506         cluck "Possible FIXME or your system is broken (WEXITSTATUS==".WEXITSTATUS($?).")";
507         return 0;       # simulate as not spam
508 }
509
510 # return: true (error-message or "1") if is spam
511 sub spamassassin
512 {
513 my($cmd)=@_;
514
515         $cmd||="$HOME/bin/spamassassin --exit-code 1";
516         # spamassassin has the specified exit code if IS spam, code 0 if NOT spam
517         # See &_spamchildcode for the code 1.
518         local *CHILD;
519         local $SIG{"PIPE"}=sub { warn "spamassassin gave me SIGPIPE: broken pipe"; };
520         # prevent Razor2's: Can't call method "log" on unblessed reference at Razor2/Client/Agent.pm line 212.
521         local $ENV{"HOME"}=$HOME;
522         # 2>/dev/null to prevent error messages to corrupt inetd() output of perlmail-accept(1)
523         open CHILD,"|$cmd --mbox >/dev/null 2>/dev/null"
524                         or return 0;
525         print CHILD $Message;
526         close CHILD;
527         return _spamchildcode;
528 }
529
530 # NOTE: returns undef() if !wantarray and the first header is unrecognized
531 # Returns always HOST:IP pair(s).
532 sub Received_for
533 {
534         my @r=();
535         for my $hdr ($Audit->head->get("Received")) {
536                 my($for)=($hdr=~/\bfor\s+\<?(\S+)\>?\b/);
537                 return $for if !wantarray();
538                 push @r,$for if $for;
539                 my($from,$fromaddr)=($hdr=~/\bfrom\s+(?:(\S+)\b.*?)??\[((?:\d{1,3}\.){3}\d{1,3})\]/);
540                 $from=$fromaddr if !defined $from;
541                 push @r,"$from:$fromaddr" if $from;
542                 }
543         return @r;
544 }
545
546 # Extended Mail::Audit::MAPS
547 # $domain,$full,[$timeout]
548 # Returns false if valid, code if spam detected.
549 sub dnsbl
550 {
551 my($domain,$full,$timeout)=@_;
552
553         $timeout||=30;  # sec
554         $Mail::Audit::MAPS::host=$domain;
555         for my $host (Received_for()) {
556                 next if $host!~/^([^:@]*):/;
557                 my $ip=$';
558                 # $1 is DNS name, $ip is IP address
559                 next if $alternates_host{$1};   # leave only foreign hosts
560                 next if $dnsbl_whitelist{$ip};
561                 {
562                         package My::Audit::Faked;
563                         sub received { return @{$_[0]->{"received"}}; }
564                         }
565                 my $self_faked={
566                                 "received"=>["[$ip]"],
567                                 };
568                 bless $self_faked,"My::Audit::Faked";
569                 my $code=Mail::Audit::rblcheck($self_faked,$timeout);
570                 next if !$code;
571                 # Some 0.0.0.0 etc. found for <linux-kernel@>, see: &Mail::Audit::MAPS::_checkit
572                 # Do not: $code!='1 Invalid IP address '
573                 # as it causes warn.
574                 return $code if $code ne '1 Invalid IP address ';
575                 return if !$full;
576                 }
577 }
578
579 # Returns true if IS virus; the message will contain the virus name
580 sub clamscan
581 {
582 my($cmd)=@_;
583
584         $cmd||='clamscan --no-summary -';
585         # clamscan has exit code 1 if IS virus , code 0 if NOT virus
586         # Do not use IPC::Open2 as it would try to use our STDERR which is not valid by: local *STDERR;
587         local(*WR,*RD,*ERR);
588         local $SIG{"PIPE"}=sub { warn "clamscan '$cmd' gave me SIGPIPE: broken pipe"; };
589         my $pid=open3(\*WR,\*RD,\*ERR,$cmd.' 2>&1')
590                         or do { cluck "IPC::Open3 $cmd: $!"; return 0; };
591         print WR $Message;
592         close WR or do { cluck "close WR of $cmd: $!"; return 0; };
593         my $status=do { local $/=undef(); <RD>; };
594         close RD or do { cluck "close RD of $cmd: $!"; return 0; };
595         # Do not: $status.=do { local $/=undef(); <ERR>; };
596         #         close ERR or do { cluck "close ERR of $cmd: $!"; return 0; };
597         # (FIXME) as it causes: Use of uninitialized value in <HANDLE>
598         # waitpid fills $? for: &_spamchildcode
599         local $SIG{"ALRM"}=sub { warn "Timeout $clamscan_waitpid_timeout sec waiting for child $cmd"; };
600         alarm $clamscan_waitpid_timeout;
601         # Do not: WNOHANG
602         # as it would not be enough for clamscan(1) even after all close-s above.
603         my $pidcheck=waitpid($pid,0);
604         alarm 0;
605         my $err=$?;
606         $pidcheck && $pidcheck==$pid
607                         or do { cluck "waitpid for $cmd returned $pidcheck!=$pid"; return 0; };
608         $status=~s/^stdin: //mg;
609         # Prevent: LibClamAV Warning: PGP encoded attachment not scanned
610         $status=~s/^.*\bwarning:.*\n//img;
611         $status=~s/\n$//;
612         return $status if $status ne "OK" && $status;
613         return _spamchildcode $err,$status;
614 }
615
616 sub muttrc_aliases
617 {
618         my %r=();
619         for (muttrc()) {
620                 next if !(my $key=(/^alias\s+(\S+)\s+/)[0]);
621                 for my $addrobj (Mail::Address->parse($')) {
622                         my $addr=$addrobj->address();
623                         my $ref=\$r{"\L$addr"};
624                         $$ref=$key if !$$ref;   # use always the first occurence to prefer nicks
625                         }
626                 }
627         return %r;
628 }
629
630 # FIXME: host may get multiple recipients and thus not showing "for <...>"
631 # FIXME: muttrc_get("from") is too strict
632 sub store_muttrc_alternates
633 {
634 my($prefix,$profile)=@_;
635
636         my $alternates=muttrc_get("alternates") or return;
637         my $alternatesre=qr/$alternates/si;
638         my $From=muttrc_get("from") or return;
639         my $Fromre=qr/^\Q$From\E$/si;
640         my $Fromobj=parseone $From or return;
641         warn "'From' \"$From\" not matched by 'alternates': $alternatesre"
642                         if $From!~/$alternates/si;
643         for my $for (reverse Received_for()) {
644                 $for=~s/:.*$//; # strip IP address here
645                 my $forobj=parseone $for;
646                 if ($forobj && $forobj->host()) {
647                         # it is 'for' our primary address
648                         next if lc($forobj->host()) eq lc($Fromobj->host());    # or 'return'? shouldn't matter
649                         }
650                 next if !$alternates_host{lc $for} && $for!~/$alternatesre/si;
651                 store "$prefix\L$for",($profile || []);
652                 return;
653                 }
654 }
655
656 # $header: ref CODE
657 # $header: !ref => $Audit->get($header)
658 # $maybeaddress: qr/regex/i
659 # $maybeaddress: "string"
660 # $maybeaddress: "<Regexp:regex>"       # hack :-(
661 # $maybeaddress: "<user@host>"
662 # $maybeaddress: "<user@>"
663 # $maybeaddress: "<@host>"
664 sub _headercore
665 {
666 my($re,$justone,$header,$maybeaddress)=@_;
667
668         if (ref $header) {
669                 $header=join(",",&$header());
670                 }
671         else {
672                 $header=$Audit->get($header);
673                 }
674         return 0 if !$header;
675         return $header=~/$maybeaddress/i if "Regexp" eq ref $maybeaddress;
676         return $header=~/$re/i if !defined(my $want=($maybeaddress=~/^\<(.*)\>$/)[0]);
677         my @parsed=Mail::Address->parse($header);
678         warn "'mailto:' forbidden in pattern: $want" if $want=~/^\Qmailto:\E/;
679         return 0 if $justone && 1!=@parsed;
680         return grep {
681                            if ($want=~/^Regexp:/)
682                                 { $_->address()=~/$'/i; }
683                         elsif ($want=~/\@$/)
684                                 { $_->user()   =~/^(?:\Qmailto:\E)?\Q$`\E/i; }
685                         elsif ($want=~/^\@/)
686                                 { $_->host()   =~/^\Q$'\E/i; }
687                         else
688                                 { $_->address()=~/^(?:\Qmailto:\E)?\Q$want\E/i; }
689                         } @parsed;
690 }
691
692 sub headerhas
693 {
694 my($header,$substr)=@_;
695
696         return _headercore(qr/\Q$substr\E/i,0,$header,$substr);
697 }
698
699 sub headeris
700 {
701 my($header,$string)=@_;
702
703         return _headercore(qr/\Q$string\E/i,1,$header,$string);
704 }
705
706 # $header,%$map
707 sub header_remap
708 {
709 my($header,$map)=@_;
710
711         my $text=$Audit->get($header);
712         my $orig=$text;
713         while (my($from,$to)=each(%$map)) {
714                 $text=~s/\b\Q$from\E\b/$to/gsi;
715                 }
716         return if $text eq $orig;
717         $Audit->put_header("X-PerlMail-header_remap-$header",$orig);
718         $Audit->replace_header($header,$text);
719 }
720
721 # LMTP engine:
722 use Net::Cmd qw(CMD_OK CMD_MORE);
723 {
724         package My::Net::SMTP::LMTP;
725         require Net::SMTP;
726         our @ISA=qw(Net::SMTP);
727         use Net::SMTP;
728         use Net::Cmd qw(CMD_OK);
729         use Carp qw(confess cluck);
730
731         # Do not: sub _HELO
732         # as it would not set {'net_smtp_esmtp'}
733         sub _EHLO { shift->command("LHLO", @_)->response()  == CMD_OK }
734
735         sub clucked
736         {
737         my($self,$func,@args)=@_;
738
739                 do { return $_ if defined $_; } for $self->$func(@args);
740                 cluck $func;
741                 return;
742         }
743 }
744
745
746 sub lmtp_deliver
747 {
748 my($admin_user,$admin_pwd,$user_from,$user_to)=@_;
749
750         my $lmtp=My::Net::SMTP::LMTP->clucked("new","localhost","Port"=>"lmtp",
751 #                       "Debug"=>1,
752                         ) or return;
753         bless $lmtp,"My::Net::SMTP::LMTP";
754 # Prevent:
755 # due to:
756 #       $lmtp->auth(Authen::SASL->new(
757 #                       "mechanism"=>"PLAIN",
758 #                       "callback"=>{
759 #                                       "user"=>$admin_user,
760 #                                       "pass"=>$admin_pwd,
761 #                                       # Prevent: "authname"=>$admin_user
762 #                                       # as it causes: DIE: Unknown callback: 'authname'. (user|auth|language|pass)
763 #                                       }));
764         # FIXME: Authentication hack:
765         $lmtp->command("AUTH PLAIN")->response()==CMD_MORE
766                         or do { cluck "auth announce"; return; };
767         $lmtp->clucked("command",encode_base64($user_from."\x00".$admin_user."\x00".$admin_pwd)) or return;
768         $lmtp->clucked("mail",$user_from) or return;
769         $lmtp->clucked("to",$user_to) or return;
770         $lmtp->clucked("data"); # Do not: or return;
771         # Prevent: 554 5.6.0 Message contains invalid header
772         (my $data=$Message)=~s/\AFrom .*\r?\n//;
773         $lmtp->clucked("datasend",$data) or return;
774         $lmtp->clucked("dataend") or return;
775         $lmtp->clucked("quit") or return;
776 }
777
778
779 # MAIN
780
781 $Getopt::Long::ignorecase=0;
782 die "GetOptions error" if !Getopt::Long::GetOptions(
783                   "inetd"    ,sub { $opt_mode=\&inetd; },
784                   "stdin"    ,sub { $opt_mode=\&stdin; },
785                   "single!"  ,\$opt_single,
786                   "dry"      ,\$opt_dry,
787                   "smstest:s",sub { $opt_mode=\&stdin; $opt_smstest=($_[1] || 1); },
788                   "idle!"    ,\$opt_idle,
789                   "idletest" ,sub { syslogging_restore(); print((defined($_=useridle()) ? $_ : "<undef>")."\n"); exit 0; },
790                   "muttrc"   ,sub { syslogging_restore(); print scalar muttrc(); exit 0; },
791                 );
792 # "Excessive arguments" checked in &inetd
793 die "Missing mode" if !$opt_mode;
794
795 %alternates_host=map((lc($_)=>1),@alternates_host);
796 %dnsbl_whitelist=map((   $_ =>1),@dnsbl_whitelist);
797
798 &$opt_mode();
799 die "NOTREACHED";