&Received_for: host: Parse also: [10.14.83.0] as hostname
[PerlMail.git] / perlmail-accept
1 #! /usr/bin/perl
2 #
3 # $Id$
4
5 use vars qw($VERSION);
6 $VERSION=do { my @r=(q$Revision$=~/\d+/g); sprintf "%d.".("%03d"x$#r),@r; };
7 use strict;
8 use warnings;
9
10
11 INIT {
12         require Sys::Syslog;
13         Sys::Syslog::openlog("lacemail","pid","mail");
14         my @syslogging_stack;
15         sub syslogging_on_save
16         {
17                 push @syslogging_stack,$SIG{"__WARN__"},$SIG{"__DIE__" };
18                 $SIG{"__WARN__"}=sub { Sys::Syslog::syslog("warning","WARN: %s",$_[0]); };      # disabled: print STDERR $_[0];
19                 $SIG{"__DIE__" }=sub { Sys::Syslog::syslog("crit"   ,"DIE: %s" ,$_[0]); };
20         }
21         syslogging_on_save();
22         sub syslogging_restore
23         {
24                 $SIG{"__DIE__" }=pop @syslogging_stack;
25                 $SIG{"__WARN__"}=pop @syslogging_stack;
26         }
27         }
28
29
30 use Mail::Audit qw(MAPS);
31 require IO::Handle;
32 use Carp qw(cluck confess);
33 use POSIX qw(WIFEXITED WEXITSTATUS WIFSIGNALED WTERMSIG WIFSTOPPED WSTOPSIG);
34 require POSIX;  # for ceil
35 use User::Utmp;
36 require File::Basename;
37 use Getopt::Long;
38 require Mail::Address;
39 require MIME::Words;
40 require Cz::Cstocs;
41 require HTML::Entities;
42 require MIME::Head;
43 require Lingua::EN::Squeeze;
44 require Mail::Mailer;
45 require HTTP::Cookies;
46 require HTTP::Request;
47 require LWP::UserAgent;
48 use URI::Escape 'uri_escape';
49
50
51 my $HOME="/home/lace";
52 my $Mail="$HOME/Mail";
53 my @ValidUsers=qw(root lace short kratochvil _local);
54 my $IdleMax=60;
55 my $MaxBodySMS=0x1000;  # max bytes to pass to Lingua::EN::Squeeze
56 my $SMSmailError='short+err@ucw.cz';
57 my $SMSwebRcpt='00420602431329';
58 my $SMSmailRcpt=$SMSwebRcpt.'@sms.eurotel.cz';
59 my $SMScontact='<short@ucw.cz>';
60
61 our($Message,$Audit,@AuditStored,$store_ignore,$store_ignorenewmail,$store_profile,$DoBell);
62 our(%audit_profile,@sms_squeezes,@alternates_host);     # imported
63 my %alternates_host;    # from @alternates_host
64
65 # from RedHat "procmail-3.22-5"
66 # /i should be only $procmailFROM_DAEMON but how it can hurt to /i all?
67 our $procmailTO_        =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^-a-zA-Z0-9_.])?'mio;
68 our $procmailTO         =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^a-zA-Z])?'mio;
69 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;
70 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;
71 # perl-5.8.0 does not cope w/original FROM_MAILER on the third '?' character
72 # Thus we did '([^>]*[^(.%@a-z0-9])?' -> '[^>]*\b', I hope it is somehow similiar
73 # 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;
74
75 my $opt_mode;
76 my $opt_smstest;        # 1 or $smscount
77 my $opt_idle;
78 my $opt_dry;
79
80
81 sub process;
82
83 sub stdin
84 {
85         syslogging_restore();   # This is more a debugging session
86         local $/="\n";
87         my $message="";
88         local $_;
89         while (<>) {
90                 die "Invalid 'From ' line: $_" if $message eq "" && !/^From /;
91                 if (/^From / && $message) {
92                         process $message;
93                         $message="";
94                         }
95                 $message.=$_;
96                 }
97         process $message if $message;
98         exit 0;
99 }
100
101 # FIXME: separate 'lacemail'-transfer together with lacemail-submit away
102 sub inetd
103 {
104         die "Excessive arguments" if @ARGV;
105
106         IO::Handle::autoflush STDOUT 1;
107
108         while (1) {
109                 local $/="\n";
110                 my $length=<STDIN>;
111                 confess "Unexpected EOF" if !defined $length;
112                 confess "Missing EOL" if $length!~s/\n$//s;
113                 exit 0 if $length eq "BYE";
114                 confess "Unrecognized length: $length" if $length!~/^\d+$/;
115                 my $message;
116                 local $_;
117                 $length==($_=read STDIN,$message,$length) or confess "Got $_ out of required $length bytes";
118                 $length==length $message or confess "False read return ".length($message)." instead of $length";
119                 {
120                         local *STDOUT;
121                         local *STDERR;
122                         local $DoBell=0;
123                         process $message;
124                         if ($DoBell) {
125                                 bell() or warn "Unable to BELL";
126                                 }
127                         }
128                 print STDOUT "1";
129                 }
130         die "NOTREACHED";
131 }
132
133 sub bell
134 {
135         local *BELL;
136         open BELL,">/dev/tty11" or return 0;
137         print BELL "\x07";
138         close BELL or return 0;
139         return 1;
140 }
141
142 sub useridle
143 {
144         my %valid_users=map(($_=>1),@ValidUsers);
145         my($idlebest,$linebest);
146         for my $utmp (User::Utmp::getut(),{ "ut_line"=>"psaux" }) {
147                 local $_;
148                 next if defined($_=$utmp->{"ut_type"}) && $_!=User::Utmp::USER_PROCESS;
149                 next if defined($_=$utmp->{"ut_user"}) && !$valid_users{$_};
150                 my $line="/dev/".$utmp->{"ut_line"};
151                 my $atime=(stat $line)[8];
152                 my $what="user \"".($utmp->{"ut_user"} || "<local>")."\", line \"$line\"";
153                 warn "Unable to stat $what" and next if !$atime;
154                 my $idle=time()-$atime;
155                 warn "atime in future for $what" and next if $idle<0;
156                 next if $idle>$IdleMax;
157                 next if defined $idlebest && $idlebest<=$idle;
158                 $idlebest=$idle;
159                 $linebest=$line;
160                 }
161         return !wantarray() ? $idlebest : ($idlebest,$linebest);
162 }
163
164 # return only the very (recursive) first part
165 sub body_first
166 {
167         return $Audit if !$Audit->is_mime();
168         my $first=$Audit;
169         local $_;
170         $first=$_ while $_=$first->parts(0);
171         return $first;
172 }
173
174 sub mimehead
175 {
176 my($part)=@_;
177
178         return $Audit->is_mime() ? $part->head()
179                         : MIME::Head->new([ split "\n",$Audit->head()->as_string() ])
180                         ;
181 }
182
183 sub mimebody
184 {
185 my($part)=@_;
186
187         # be vary cautious here as most of $part methods will encode it!
188         return join "",@{$Audit->body()} if !$Audit->is_mime();
189         my $bodyhandle=$part->bodyhandle();
190         # If MIME is corrupted we don't get bodyhandle() for this part
191         # It may occur when "boundary" is specified by header but no such boundary is found in the body
192         return $bodyhandle->as_string() if $bodyhandle;
193         warn "MIME corrupted, adapting";
194         return $part->body_as_string();
195 }
196
197 sub mime_type
198 {
199 my($part)=@_;
200
201         return $Audit->is_mime() ? $part->effective_type() : mimehead($part)->mime_type();
202 }
203
204 sub body_simple
205 {
206         my $first=body_first();
207         my $r=mimebody($first);
208         my $mime_type=mime_type($first);
209            if ($mime_type eq "text/html") {
210                 # HTML::FormatText just does a useless text layouts
211                 # PerlIO::via::StripHTML probably needs PerlIO input (?)
212                 $r=~s/<[^>]*>//gs;
213                 $r=HTML::Entities::decode($r);
214                 # FIXME: detect charset from <meta> tag: "Content-type: text/html; charset=<???>"
215                 }
216         elsif ($mime_type eq "application/pgp-encrypted"
217                && (my $filename=mimehead($first)->mime_attr("Content-Disposition.filename"))
218                ) {
219                 # first part contains just "Version: 1" as of GnuPG v1.0.4 (GNU/Linux)
220                 $r="pgp($filename)";
221                 }
222         if ((my $charset=mimehead($first)->mime_attr("Content-Type.charset"))) {
223                 my $cstocs=Cz::Cstocs->new($charset,"ascii");
224                 $r=&$cstocs($r) if $cstocs;     # charset may be unknown
225                 }
226         return $r;
227 }
228
229 sub parts_linear
230 {
231 my($part)=@_;
232
233         return $Audit if !$part && !$Audit->is_mime();
234         $part||=$Audit;
235         # don't use '!$part->parts()' as even 0-parts-multiparts are still multiparts
236         return $part if $part->bodyhandle();
237         return map { (parts_linear($_)); } $part->parts();
238 }
239
240 sub smsbuild
241 {
242 my($smsi,$smscount)=@_;
243
244         return "$smsi/$smscount:" if $smscount>1;
245         return "";
246 }
247
248 # FIXME: rewrite &send_cz_eurotel properly by own code
249 # patch for http://kiwi.ms.mff.cuni.cz/%7Etom/programming/src/sendsms.tar.gz/sendsms.pl
250 my $agent=LWP::UserAgent->new();
251 $agent->agent("LaceMail $VERSION; contact=$SMScontact; ");
252 my($request1,$response1);       # for &send_cz_eurotel
253 my($name,$value,$type,$disabled,$q2);
254
255 # &send_cz_eurotel returns: error
256 # BEGIN http://kiwi.ms.mff.cuni.cz/%7Etom/programming/src/sendsms.tar.gz/sendsms.pl
257 sub parse_inputs
258 {
259         my ($resp) = @_;
260         my @inputs;
261         my $ct;
262         my @c;
263         $ct=$resp->content();
264
265         @c=split '>', $ct;
266         grep {
267            if (/(<input|<select|<textarea)([^<>]*)(>|$)/i) { 
268              my $txt=$2, $name="", $value="", $type="x", $disabled=0;
269              my $ipoc;
270              if ($txt =~ /type="([^"]*)"/i) { $type=$1; }
271              elsif ($txt =~ /type=([^" ]*)[ >]/i) { $type=$1; }
272              if ($txt =~ /name="([^"]*)"/i) { $name=$1; }
273              elsif ($txt =~ /name=([^" ]*)[ >]/i) { $name=$1; }
274              if ($txt =~ /value="([^"]*)"/i) { $value=$1; }
275              elsif ($txt =~ /value=([^" ]*)[ >]/i) { $value=$1; }
276              if ($txt =~ /disabled/i) { $disabled=1; }
277              if ($name ne "" && $type ne "" && $type ne "button" && ! $disabled)
278              {
279                $ipoc=$#inputs;
280                $inputs[$ipoc+1][0]=$name;
281                $inputs[$ipoc+1][1]=$value;
282              }
283            }
284         0; } @c;
285         return @inputs;
286 }
287
288 sub make_query
289 {
290         my (@inputs) = @_;
291
292         my $i;
293         my $query = "";
294
295         for ($i=0; $i<=$#inputs; $i++)
296         {
297           my($q1, $q2);
298           if ($i>0) { $query="$query&"; }
299           $q1=uri_escape($inputs[$i][0]);
300           $q2=uri_escape($inputs[$i][1]);
301           $query="$query$q1=$q2";
302         }
303
304         #change @ and space back
305         $query=~ s/%20/+/g;
306         $query=~ s/%40/@/g;
307         return $query;
308 }
309
310 sub send_cz_eurotel
311 {
312         my ($id,$text,$mail,$directd) = @_;
313         my $src_url = "http://www2.eurotel.cz/sms/index.html";
314         my @inputs;
315         my $query = "";
316         my $cookie = HTTP::Cookies->new;
317         my $pref;
318
319         #check if correct number
320         if (substr($id,0,5)!="00420") { return -1; }
321         $pref=substr($id,5,3);
322         if (!($pref eq "601" || $pref eq "602" || $pref eq "606" || $pref eq "607"  || ($pref ge "720" && $pref le "729"))) { return -1; }
323         
324         #get form page, extract cookies
325         $request1=new HTTP::Request('GET', "$src_url?n_pagestyle=new");
326         $response1=$agent->request($request1);
327         if ($response1->code != 200) { return -3; }
328         $cookie->extract_cookies($response1);
329
330         #parse the form
331         @inputs=parse_inputs($response1);
332         
333         #fill the form
334         $inputs[2][1]=substr($id,5,3);
335         $inputs[3][1]=substr($id,8,6);
336         $inputs[4][1]=$mail;
337
338         #direct display
339         $inputs[6][1]="sms";
340         $inputs[6][1]="show" if ($directd>0);
341
342         $inputs[7][1]=$text;
343
344 #       for ($i=0; $i<=$#inputs; $i++) { print "[$i] $inputs[$i][0] $inputs[$i][1]\n"; } 
345
346         #make query
347         $query=make_query(@inputs);
348
349         #POST the form
350         my $header = new HTTP::Headers( 
351                 'Content-Length' => length($query),
352                 'Content-Type' => 'application/x-www-form-urlencoded',
353                 'Accept' => '*/*',
354                 'Referer' => $src_url
355         );
356         my $request2 = new HTTP::Request('POST',$src_url, $header, $query);
357         $cookie->add_cookie_header($request2);
358         my $response2 = $agent->request($request2);
359
360         if ($response2->code != 200) { return -3; }
361
362         #check for success
363         if ($response2->content() !~ /byla.*odesl.*na na SMS centrum/)
364         {
365           return -2;
366         }
367         return 0;
368 }
369 # END http://kiwi.ms.mff.cuni.cz/%7Etom/programming/src/sendsms.tar.gz/sendsms.pl
370
371 sub smslens
372 {
373 my($ignorenewmail,$smscount,%args)=@_;
374
375         return map({
376                         my $l=160;
377                         if (!$ignorenewmail) {  # send by mail
378                                 $l-=length("Z emailu $SMSmailError: ");
379                                 $l-=length(smsbuild($_,$smscount));
380                                 }
381                         else {  # send by web
382                                 $l-=length("Z WWW x/5: ");
383                                 $l-=length(smsbuild($_,POSIX::ceil($smscount/5)));
384                                 }
385                         $l;
386                         } (0..$smscount-1));
387 }
388
389 sub smssend_web
390 {
391 my($squeezed,$smscount,@lens)=@_;
392
393         $smscount=POSIX::ceil($smscount/5);
394         for my $smsi (0..$smscount-1) {
395                 my $len=$lens[$smsi];
396                 $squeezed=~/^.{0,$len}/s;
397                 my $frag=$&;
398                 $squeezed=$';
399                 return 0 if send_cz_eurotel($SMSwebRcpt,$frag,"",0);
400                 }
401         return 1;
402 }
403
404 sub smssend_mail
405 {
406 my($squeezed,$smscount,@lens)=@_;
407
408         my $recalclen=0;
409         for ($smscount=0;$recalclen<length $squeezed;$smscount++) {
410                 $recalclen+=$lens[$smscount];
411                 }
412         for my $smsi (0..$smscount-1) {
413                 my $len=$lens[$smsi];
414                 $squeezed=~/^.{0,$len}/s;
415                 my $frag=$&;
416                 $squeezed=$';
417                 my $mail=Mail::Mailer->new("sendmail","-f","$SMSmailError");
418                 $mail->open({
419                                 "To"=>$SMSmailRcpt,
420                                 "From"=>$SMSmailError,  # no longer displayed anyway
421                                 "X-LaceMail-Version"=>$VERSION,
422                                 "X-LaceMail-Contact"=>$SMScontact,
423                                 });
424                 print $mail smsbuild($smsi,$smscount).$frag."\n";
425                 # FIXME: check errors
426                 $mail->close();
427                 }
428         return 1;
429 }
430
431 sub smssend
432 {
433 my($ignorenewmail,$smscount,%args)=@_;
434
435         my $text=audit_sms(
436                         "subject"=>unmime($Audit->subject()),
437                         "from"=>[ Mail::Address->parse(unmime($Audit->from())) ],
438                         "body"=>substr(body_simple(),0,$MaxBodySMS*(1+0.25*$smscount)),
439                         %args);
440         my $texthead="";
441         ($texthead,$text)=@$text if ref $text;
442         do { print "$texthead\n$text\n"; return; } if $opt_smstest;
443         my @lens=smslens($ignorenewmail,$smscount,%args);
444         my $maxlen=0;
445         $maxlen+=$_ for (@lens);
446         my $squeezed;
447         for my $squeeze (@sms_squeezes) {
448                 local $_;
449                  Lingua::EN::Squeeze::SqueezeControl($_)    if defined ($_=$squeeze->{"SqueezeControl"});
450                 $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL or 1;  # prevent: Name "$_" used only once: possible typo
451                 $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL=$_ if defined ($_=$squeeze->{"SQZ_OPTIMIZE_LEVEL"});
452                 $squeezed=Lingua::EN::Squeeze::SqueezeText($text);
453                 chomp $squeezed;
454                 last if $maxlen>=length($texthead.$squeezed);
455                 }
456         $squeezed=substr $texthead.$squeezed,0,$maxlen; # strip if we passed thru last for() above
457         my $recalclen=0;
458         for ($smscount=0;$recalclen<length $squeezed;$smscount++) {
459                 $recalclen+=$lens[$smscount];
460                 }
461         my $func=($ignorenewmail ? \&smssend_web : \&smssend_mail);
462         &$func($squeezed,$smscount,@lens);
463 }
464
465 sub smssend_tryall
466 {
467 my($ignorenewmail,@args)=@_;
468
469         return if !$opt_smstest && !$opt_idle && defined useridle();
470         local $_;
471         return $_ if                     $_=smssend(1,@args);   # web
472         return $_ if !$ignorenewmail && ($_=smssend(0,@args));  # mail
473         warn "Unable to SMSsend the mail";
474         return 0;
475 }
476
477 sub cut
478 {
479         local $_=$_[0];
480         return "<???>" if !defined($_) || /^\s*$/s;
481         s/^\s*//s;
482         s/\s*$//s;
483         return $_ if length($_)<128;
484         return substr($_,0,128)."...";
485 }
486
487 our $profile_eval_depth=0;
488 # ($name || @$name)
489 sub profile_eval
490 {
491 my($name)=@_;
492
493         die "Nesting profile: $name" if 0x10<=(local $profile_eval_depth=$profile_eval_depth+1);
494         return @$name if ref $name;
495         die "Profile not found: $name" if !exists $audit_profile{$name};
496         my @this=@{$audit_profile{$name}};
497         return (profile_eval($'),@this[1..$#this]) if $this[0] && $this[0]=~/^=/;
498         return @this;
499 }
500
501 sub address_show
502 {
503 my($text)=@_;
504
505         return join(",",map({ $_->name() or $_->address(); } Mail::Address->parse($text)));
506 }
507
508 sub unmime
509 {
510 my($text)=@_;
511
512         return join "",map({
513                         my $cstocs;
514                         for (${$_}[1],"iso-8859-2") {
515                                 last if $_ && ($cstocs=Cz::Cstocs->new($_,"ascii"));
516                                 }
517                         &$cstocs(${$_}[0]);
518                         } MIME::Words::decode_mimewords($text));
519 }
520
521 # $folder: "$folder; comment"
522 # $profile as profile_eval($name)
523 sub store
524 {
525 my($folder,$profile,%args)=@_;
526
527         $profile=$store_profile if !$profile;
528         my %do=map({ (!/=/ ? ($_=>1) : ($`=>$')); } profile_eval($profile));
529         Sys::Syslog::syslog("info","%s%s%s: %s: %s",
530                                         (!$opt_dry ? "" : "--dry: "),
531                                         (!$store_ignore ? "" : "IGNORED[$store_ignore]: "),
532                                         map({ cut($_); } $folder,address_show(unmime($Audit->from())),unmime($Audit->subject())),
533                                         )
534                         if $do{"syslog"} || $opt_dry;
535         $folder=~s/;.*$//s;
536         $folder="$Mail/".$' if $folder=~/^=/;
537         push @AuditStored,$folder if $do{"did"};
538         return if $store_ignore || $opt_dry;
539         $DoBell++ if $do{"bell"};
540         write_message($folder);
541         smssend_tryall $store_ignorenewmail,$do{"sms"},%args if $do{"sms"};
542 }
543
544 our $did_last=0;
545
546 # no &$funcref=>did smth in this block
547 # &$funcref,@funcargs
548 sub did
549 {
550 my($funcref,@funcargs)=@_;
551
552         return @AuditStored!=$did_last if !$funcref;
553         local $did_last=@AuditStored;
554         &$funcref(@funcargs);
555         return @AuditStored!=$did_last;
556 }
557
558 # Never use Mail::Audit->store() as it will reformat MIME bodies and possibly corrupt OpenPGP!
559 sub write_message
560 {
561 my($folder)=@_;
562
563         return if $opt_dry;
564         local *F;
565         open F,">>$folder" or do { warn "Append \"$folder\": $!"; return 0; };
566         {
567                 local $_;
568                 ($_=Mail::Audit::audit_get_lock(\*F,$folder)) and do { warn "Lock \"$folder\": $!"; last; };
569                 seek F,0,IO::Handle::SEEK_END or do { warn "Seek-end \"$folder\": $!"; last; };
570                 # FIXME: Check for '^From ' to not to rely on our network peer
571                 print F $Message or do { warn "Write to \"$folder\": $!"; last; };
572                 do { print F "\n"; warn "Missing trailing newline, fixed"; } if $Message!~/\n$/s;
573                 close F or do { warn "Close \"$folder\""; last; };
574                 return 1;       # OK
575                 }
576         warn "MAIL DROPPED for folder: $folder";
577         close F;
578         return 0;       # failed
579 }
580
581 sub process
582 {
583 my($message)=@_;
584
585         local $_=$_;
586         my $save_=$_;
587         local $Message=$message;
588         local $Audit=Mail::Audit->new(
589                         "emergency"=>"$Mail/emergency",
590                         "data"=>[map("$_\n",split("\n",$message))],
591                         "log"=>"$HOME/.lacemail.log",
592                         "loglevel"=>99,
593                         );
594         local @AuditStored=();
595         do { smssend 0,$opt_smstest; return; } if $opt_smstest;
596         write_message("$Mail/input");
597         audit();
598         warn 'Corrupted $_, repaired' if defined($save_)!=defined($_) || (defined($_) && $save_ ne $_);
599 }
600
601 # utility functions:
602
603 # return: true (error-message or "1") if is spam
604 sub razor2
605 {
606         # razor-check has exit code 1 if NOT spam, code 0 if IS spam
607         local *CHILD;
608         local $SIG{"PIPE"}=sub { warn "razor2 gave me SIGPIPE: broken pipe"; };
609         # prevent Razor2's: Can't call method "log" on unblessed reference at Razor2/Client/Agent.pm line 212.
610         local $ENV{"HOME"}=$HOME;
611         open CHILD,'|'
612                                         .'('.'(razor-check 2>&1;echo >&3 $?)'
613                                                         .'|sed "s/^/razor-check: /"'
614                                                         .'|logger -t "lacemail['.$$.']" -p mail.crit'
615                                                         .') 3>&1'
616                                         .'|exit `cat`'
617                         or return 0;
618         print CHILD $Message;
619         my $return;
620         {
621                 local $/=undef();
622                 $return=<CHILD> || 1;
623                 }
624         close CHILD;
625         return undef() if !WIFEXITED($?);
626         return undef() if  WIFSIGNALED($?);
627         return undef() if  WIFSTOPPED($?);
628         return undef() if WEXITSTATUS($?);
629         return $return; # is-spam
630 }
631
632 # NOTE: returns undef() if !wantarray and the first header is unrecognized
633 # Returns also hosts
634 sub Received_for
635 {
636         my @r=();
637         for my $hdr ($Audit->head->get("Received")) {
638                 my($for)=($hdr=~/\bfor\s+\<?(\S+)\>?\b/);
639                 return $for if !wantarray();
640                 push @r,$for if $for;
641                 my($from,$fromaddr)=($hdr=~/\bfrom\s+(?:(\S+)\b.*?)??\[((?:\d{1,3}\.){3}\d{1,3})\]/);
642                 $from=$fromaddr if !defined $from;
643                 push @r,"$from:$fromaddr" if $from;
644                 }
645         return @r;
646 }
647
648 # Extended Mail::Audit::MAPS
649 # $domain,$full,[$timeout]
650 sub dnsbl
651 {
652 my($domain,$full,$timeout)=@_;
653
654         $timeout||=30;  # sec
655         $Mail::Audit::MAPS::host=$domain;
656         my @hosts=map({ s/^.*://; "[$_]"; }     # strip DNS part
657                         grep({ /^([^:@]*):/ && !$alternates_host{$1}; } (Received_for()))       # leave only foreign hosts
658                         );
659         splice @hosts,1 if !$full && @hosts;    # "&& @hosts" to prevent: WARN: splice() offset past end of array
660         {
661                 package My::Audit::Faked;
662                 sub received { return @{$_[0]->{"received"}}; }
663                 }
664         my $self_faked={
665                         "received"=>[@hosts],
666                         };
667         bless $self_faked,"My::Audit::Faked";
668         return Mail::Audit::rblcheck($self_faked,$timeout);
669 }
670
671 our %muttrc_pending=();
672 sub muttrc
673 {
674 my($muttrc)=@_;
675
676         $muttrc||="$HOME/.muttrc";
677         $muttrc=~s/^\~/$HOME/;
678         do { warn "Looping muttrc, ignoring: $muttrc"; return (); } if $muttrc_pending{$muttrc};
679         local $muttrc_pending{$muttrc}=1;
680         local *MUTTRC;
681         open MUTTRC,$muttrc or do { warn "open \"$muttrc\": $!"; return (); };
682         local $/="\n";
683         local $_;
684         my @r=();
685         # far emulation mutt/init.c/mutt_parse_rc_line()
686         while (<MUTTRC>) {
687                 s/^[\s;]*//s;
688                 s/[#;].*$//s;
689                 s/\s*$//s;
690                 next if !/^(\S+)\s*/s;
691                 if ($1 eq "source") {
692                         $_=$';
693                         do { warn "Wrong 'source' parameters at $muttrc:$.: $_"; next; } if !/^\S+$/;
694                         push @r,muttrc($_);
695                         next;
696                         }
697                 push @r,$_;
698                 }
699         close MUTTRC or warn "close \"$muttrc\": $!";
700         return wantarray() ? @r : join("",map("$_\n",@r));
701 }
702
703 my %mutteval_charmap=(          # WARNING: Don't use "" or "0" here, see below for "|| warn"!
704                 '\\'=>"\\",
705                 'r'=>"\r",
706                 'n'=>"\n",
707                 't'=>"\t",
708                 'f'=>"\f",
709                 'e'=>"\e",
710                 );
711 # mutt/init.c/mutt_extract_token()
712 sub mutteval
713 {
714         local $_=$_[0];
715         return $_ if !s/^"//;
716         do { warn "Missing trailing quote in: $_"; return $_; } if !s/"$//;
717         s/\\(.)/$mutteval_charmap{$1} || warn "Undefined '\\$1' sequence in: $_";/ges;
718         return $_;
719 }
720
721 sub muttrc_get
722 {
723 my(@headers)=@_;
724
725         my @r=map({ (ref $_ ? $_ : qr/^\s*set\s+\Q$_\E\s*=\s*(.*?)\s*$/si); } @headers);
726         my %r=map(($_=>undef()),@r);
727         for (muttrc()) {
728                 for my $ritem (@r) {
729                         /$ritem/si or next;
730                         $r{$ritem}=mutteval $1;
731                         }
732                 }
733         for my $var (grep { !defined($r{$_}) } @r) {
734                 warn "Variable '$var' not found in muttrc";
735                 return undef();
736                 }
737         return wantarray() ? %r : $r{$r[0]};
738 }
739
740 sub muttrc_aliases
741 {
742         my %r=();
743         for (muttrc()) {
744                 next if !(my $key=(/^alias\s+(\S+)\s+/)[0]);
745                 for my $addrobj (Mail::Address->parse($')) {
746                         my $addr=$addrobj->address();
747                         my $ref=\$r{"\L$addr"};
748                         $$ref=$key if !$$ref;   # use always the first occurence to prefer nicks
749                         }
750                 }
751         return %r;
752 }
753
754 # FIXME: Unify
755 # BEGIN lacemail-sendmail
756 # return: Mail::Address instance or undef()
757 sub parseone
758 {
759 my($line)=@_;
760
761         return undef() if !defined $line;
762         my @r=Mail::Address->parse($line);
763         warn "Got ".scalar(@r)." addresses while wanting just one; when parsing: $line" if 1!=@r;
764         return $r[0];
765 }
766 # END lacemail-sendmail
767
768 # FIXME: host may get multiple recipients and thus not showing "for <...>"
769 # FIXME: muttrc_get("from") is too strict
770 sub store_muttrc_alternates
771 {
772 my($prefix,$profile)=@_;
773
774         my $alternates=muttrc_get("alternates") or return;
775         my $alternatesre=qr/$alternates/si;
776         my $From=muttrc_get("from") or return;
777         my $Fromre=qr/^\Q$From\E$/si;
778         my $Fromobj=parseone $From or return;
779         warn "'From' \"$From\" not matched by 'alternates': $alternatesre"
780                         if $From!~/$alternates/si;
781         for my $for (reverse Received_for()) {
782                 $for=~s/:.*$//; # strip IP address here
783                 if ($Fromobj->user() ne "prog-mutt") {
784                         next if lc($for) eq lc($From);
785                         }
786                 else {
787                         my $forobj=parseone $for;
788                         if ($forobj && $forobj->host()) {
789                                 # it is 'for' our primary address
790                                 next if lc($forobj->host()) eq lc($Fromobj->host());    # or 'return'? shouldn't matter
791                                 }
792                         }
793                 next if !$alternates_host{lc $for} && $for!~/$alternatesre/si;
794                 store "$prefix\L$for",($profile || []);
795                 return;
796                 }
797 }
798
799 # $header: ref CODE
800 # $header: !ref => $Audit->get($header)
801 # $maybeaddress: qr/regex/i
802 # $maybeaddress: "string"
803 # $maybeaddress: "<Regexp:regex>"       # hack :-(
804 # $maybeaddress: "<user@host>"
805 # $maybeaddress: "<user@>"
806 # $maybeaddress: "<@host>"
807 sub _headercore
808 {
809 my($re,$justone,$header,$maybeaddress)=@_;
810
811         if (ref $header) {
812                 $header=join(",",&$header());
813                 }
814         else {
815                 $header=$Audit->get($header);
816                 }
817         return 0 if !$header;
818         return $header=~/$maybeaddress/i if "Regexp" eq ref $maybeaddress;
819         return $header=~/$re/i if !defined(my $want=($maybeaddress=~/^\<(.*)\>$/)[0]);
820         my @parsed=Mail::Address->parse($header);
821         warn "'mailto:' forbidden in pattern: $want" if $want=~/^\Qmailto:\E/;
822         return 0 if $justone && 1!=@parsed;
823         return grep {
824                            if ($want=~/^Regexp:/)
825                                 { $_->address()=~/$'/i; }
826                         elsif ($want=~/\@$/)
827                                 { $_->user()   =~/^(?:\Qmailto:\E)?\Q$`\E/i; }
828                         elsif ($want=~/^\@/)
829                                 { $_->host()   =~/^\Q$'\E/i; }
830                         else
831                                 { $_->address()=~/^(?:\Qmailto:\E)?\Q$want\E/i; }
832                         } @parsed;
833 }
834
835 sub headerhas
836 {
837 my($header,$substr)=@_;
838
839         return _headercore(qr/\Q$substr\E/i,0,$header,$substr);
840 }
841
842 sub headeris
843 {
844 my($header,$string)=@_;
845
846         return _headercore(qr/\Q$string\E/i,1,$header,$string);
847 }
848
849 # $header,%$map
850 sub header_remap
851 {
852 my($header,$map)=@_;
853
854         my $text=$Audit->get($header);
855         my $orig=$text;
856         while (my($from,$to)=each(%$map)) {
857                 $text=~s/\b\Q$from\E\b/$to/gsi;
858                 }
859         return if $text eq $orig;
860         $Audit->put_header("X-LaceMail-header_remap-$header",$orig);
861         $Audit->replace_header($header,$text);
862 }
863
864
865 # MAIN
866
867 my $basedir=File::Basename::dirname($0);
868 $Getopt::Long::ignorecase=0;
869 die "GetOptions error" if !Getopt::Long::GetOptions(
870                   "inetd"    ,sub { $opt_mode=\&inetd; },
871                   "stdin"    ,sub { $opt_mode=\&stdin; },
872                   "dry"      ,\$opt_dry,
873                   "smstest:s",sub { $opt_mode=\&stdin; $opt_smstest=($_[1] || 1); },
874                   "idle!"    ,\$opt_idle,
875                   "idletest" ,sub { syslogging_restore(); print((defined($_=useridle()) ? $_ : "<undef>")."\n"); exit 0; },
876                   "muttrc"   ,sub { syslogging_restore(); print scalar muttrc(); exit 0; },
877                 "d|basedir=s",\&basedir,
878                 );
879 # "Excessive arguments" checked in &inetd
880 die "Missing mode" if !$opt_mode;
881
882 my $filenameMyAudit="$basedir/My-Audit.pm";
883 open AUDIT,$filenameMyAudit or die "open \"$filenameMyAudit\": $!";
884 {
885         local $/=undef();
886         eval <AUDIT> or die "eval \"$filenameMyAudit\": $@";
887         audit_init();
888         %alternates_host=map((lc($_)=>1),@alternates_host);
889         }
890 close AUDIT or warn "close \"$filenameMyAudit\": $!";
891
892 &$opt_mode();
893 die "NOTREACHED";