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