6 $VERSION=do { my @r=(q$Revision$=~/\d+/g); sprintf "%d.".("%03d"x$#r),@r; };
13 Sys::Syslog::openlog("lacemail","pid","mail");
15 sub syslogging_on_save
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]); };
22 sub syslogging_restore
24 $SIG{"__DIE__" }=pop @syslogging_stack;
25 $SIG{"__WARN__"}=pop @syslogging_stack;
30 use Mail::Audit qw(MAPS);
32 use Carp qw(cluck confess);
33 use POSIX qw(WIFEXITED WEXITSTATUS WIFSIGNALED WTERMSIG WIFSTOPPED WSTOPSIG);
34 require POSIX; # for ceil
36 require File::Basename;
38 require Mail::Address;
41 require HTML::Entities;
43 require Lingua::EN::Squeeze;
45 require HTTP::Cookies;
46 require HTTP::Request;
47 require LWP::UserAgent;
48 use URI::Escape 'uri_escape';
52 my $HOME="/home/lace";
53 my $Mail="$HOME/Mail";
54 my @ValidUsers=qw(root lace short kratochvil _local);
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>';
62 our($Message,$Audit,@AuditStored,$store_ignore,$store_ignorenewmail,$store_profile,$DoBell);
63 our(%audit_profile,@sms_squeezes,@alternates_host); # imported
64 my %alternates_host; # from @alternates_host
66 # from RedHat "procmail-3.22-5"
67 # /i should be only $procmailFROM_DAEMON but how it can hurt to /i all?
68 our $procmailTO_ =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^-a-zA-Z0-9_.])?'mio;
69 our $procmailTO =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^a-zA-Z])?'mio;
70 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;
71 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;
72 # perl-5.8.0 does not cope w/original FROM_MAILER on the third '?' character
73 # Thus we did '([^>]*[^(.%@a-z0-9])?' -> '[^>]*\b', I hope it is somehow similiar
74 # 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;
77 my $opt_smstest; # 1 or $smscount
86 syslogging_restore(); # This is more a debugging session
91 die "Invalid 'From ' line: $_" if $message eq "" && !/^From /;
92 if (/^From / && $message) {
98 process $message if $message;
102 # FIXME: separate 'lacemail'-transfer together with lacemail-submit away
105 die "Excessive arguments" if @ARGV;
107 IO::Handle::autoflush STDOUT 1;
112 confess "Unexpected EOF" if !defined $length;
113 confess "Missing EOL" if $length!~s/\n$//s;
114 exit 0 if $length eq "BYE";
115 confess "Unrecognized length: $length" if $length!~/^\d+$/;
118 $length==($_=read STDIN,$message,$length) or confess "Got $_ out of required $length bytes";
119 $length==length $message or confess "False read return ".length($message)." instead of $length";
126 bell() or warn "Unable to BELL";
137 open BELL,">/dev/tty11" or return 0;
139 close BELL or return 0;
145 return 0 if ! -e $ENV{"HOME"}."/idle";
146 my %valid_users=map(($_=>1),@ValidUsers);
147 my($idlebest,$linebest);
148 for my $utmp (User::Utmp::getut(),{ "ut_line"=>"psaux" }) {
150 next if defined($_=$utmp->{"ut_type"}) && $_!=User::Utmp::USER_PROCESS;
151 next if defined($_=$utmp->{"ut_user"}) && !$valid_users{$_};
152 my $line="/dev/".$utmp->{"ut_line"};
153 my $atime=(stat $line)[8];
154 my $what="user \"".($utmp->{"ut_user"} || "<local>")."\", line \"$line\"";
155 warn "Unable to stat $what" and next if !$atime;
156 my $idle=time()-$atime;
157 warn "atime in future for $what" and next if $idle<0;
158 next if $idle>$IdleMax;
159 next if defined $idlebest && $idlebest<=$idle;
163 return !wantarray() ? $idlebest : ($idlebest,$linebest);
166 # return only the very (recursive) first part
169 return $Audit if !$Audit->is_mime();
172 $first=$_ while $_=$first->parts(0);
180 return $Audit->is_mime() ? $part->head()
181 : MIME::Head->new([ split "\n",$Audit->head()->as_string() ])
189 # be vary cautious here as most of $part methods will encode it!
190 return join "",@{$Audit->body()} if !$Audit->is_mime();
191 my $bodyhandle=$part->bodyhandle();
192 # If MIME is corrupted we don't get bodyhandle() for this part
193 # It may occur when "boundary" is specified by header but no such boundary is found in the body
194 return $bodyhandle->as_string() if $bodyhandle;
195 warn "MIME corrupted, adapting";
196 return $part->body_as_string();
203 return $Audit->is_mime() ? $part->effective_type() : mimehead($part)->mime_type();
208 my $first=body_first();
209 my $r=mimebody($first);
210 my $mime_type=mime_type($first);
211 if ($mime_type eq "text/html") {
212 # HTML::FormatText just does a useless text layouts
213 # PerlIO::via::StripHTML probably needs PerlIO input (?)
215 $r=HTML::Entities::decode($r);
216 # FIXME: detect charset from <meta> tag: "Content-type: text/html; charset=<???>"
218 elsif ($mime_type eq "application/pgp-encrypted"
219 && (my $filename=mimehead($first)->mime_attr("Content-Disposition.filename"))
221 # first part contains just "Version: 1" as of GnuPG v1.0.4 (GNU/Linux)
224 if ((my $charset=mimehead($first)->mime_attr("Content-Type.charset"))) {
225 my $cstocs=Cz::Cstocs->new($charset,"ascii");
226 $r=&$cstocs($r) if $cstocs; # charset may be unknown
235 return $Audit if !$part && !$Audit->is_mime();
237 # don't use '!$part->parts()' as even 0-parts-multiparts are still multiparts
238 return $part if $part->bodyhandle();
239 return map { (parts_linear($_)); } $part->parts();
244 my($smsi,$smscount)=@_;
246 return "$smsi/$smscount:" if $smscount>1;
252 my($ignorenewmail,$smscount,%args)=@_;
256 if (!$ignorenewmail) { # send by mail
257 $l-=length("Z emailu $SMSmailError: ");
258 $l-=length(smsbuild($_,$smscount));
261 $l-=6; # 154 is the max length before split; why?
269 my($squeezed,$smscount,@lens)=@_;
271 $smscount=POSIX::ceil($smscount/5);
272 for my $smsi (0..$smscount-1) {
273 my $len=$lens[$smsi];
274 $squeezed=~/^.{0,$len}/s;
277 return 0 if 3!=@SMSwebRcpt;
279 open F,"$HOME/priv/WWW-SMS-$SMSwebRcpt_username.pwd" or return 0;
283 my $sms=WWW::SMS->new(@SMSwebRcpt,$frag,"username"=>$SMSwebRcpt_username,"passwd"=>$pwd);
284 for ($sms->gateways("sorted"=>"reliability")) {
285 last if $sms->send($_);
286 Sys::Syslog::syslog("warning","Web SMS send failed: %s",$WWW::SMS::Error);
287 my $void=$WWW::SMS::Error; # Prevent: Name "WWW::SMS::Error" used only once
295 my($squeezed,$smscount,@lens)=@_;
302 my($ignorenewmail,$smscount,%args)=@_;
305 "subject"=>unmime($Audit->subject()),
306 "from"=>[ Mail::Address->parse(unmime($Audit->from())) ],
307 "body"=>substr(body_simple(),0,$MaxBodySMS*(1+0.25*$smscount)),
310 ($texthead,$text)=@$text if ref $text;
311 do { print "$texthead\n$text\n"; return; } if $opt_smstest;
312 my @lens=smslens($ignorenewmail,$smscount,%args);
314 $maxlen+=$_ for (@lens);
316 for my $squeeze (@sms_squeezes) {
318 Lingua::EN::Squeeze::SqueezeControl($_) if defined ($_=$squeeze->{"SqueezeControl"});
319 $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL or 1; # prevent: Name "$_" used only once: possible typo
320 $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL=$_ if defined ($_=$squeeze->{"SQZ_OPTIMIZE_LEVEL"});
321 $squeezed=Lingua::EN::Squeeze::SqueezeText($text);
323 last if $maxlen>=length($texthead.$squeezed);
325 $squeezed=substr $texthead.$squeezed,0,$maxlen; # strip if we passed thru last for() above
327 for ($smscount=0;$recalclen<length $squeezed;$smscount++) {
328 $recalclen+=$lens[$smscount];
330 my $func=($ignorenewmail ? \&smssend_web : \&smssend_mail);
331 &$func($squeezed,$smscount,@lens);
336 my($ignorenewmail,@args)=@_;
338 return if !$opt_smstest && !$opt_idle && defined useridle();
340 return $_ if $_=smssend(1,@args); # web
341 return $_ if !$ignorenewmail && ($_=smssend(0,@args)); # mail
342 warn "Unable to SMSsend the mail";
349 return "<???>" if !defined($_) || /^\s*$/s;
352 return $_ if length($_)<128;
353 return substr($_,0,128)."...";
356 our $profile_eval_depth=0;
362 die "Nesting profile: $name" if 0x10<=(local $profile_eval_depth=$profile_eval_depth+1);
363 return @$name if ref $name;
364 die "Profile not found: $name" if !exists $audit_profile{$name};
365 my @this=@{$audit_profile{$name}};
366 return (profile_eval($'),@this[1..$#this]) if $this[0] && $this[0]=~/^=/;
374 return join(",",map({ $_->name() or $_->address(); } Mail::Address->parse($text)));
383 for (${$_}[1],"iso-8859-2") {
384 last if $_ && ($cstocs=Cz::Cstocs->new($_,"ascii"));
387 } MIME::Words::decode_mimewords($text));
390 # $folder: "$folder; comment"
391 # $profile as profile_eval($name)
394 my($folder,$profile,%args)=@_;
396 $profile=$store_profile if !$profile;
397 my %do=map({ (!/=/ ? ($_=>1) : ($`=>$')); } profile_eval($profile));
398 Sys::Syslog::syslog("info","%s%s%s: %s: %s",
399 (!$opt_dry ? "" : "--dry: "),
400 (!$store_ignore ? "" : "IGNORED[$store_ignore]: "),
401 map({ cut($_); } $folder,address_show(unmime($Audit->from())),unmime($Audit->subject())),
403 if $do{"syslog"} || $opt_dry;
405 $folder="$Mail/".$' if $folder=~/^=/;
406 push @AuditStored,$folder if $do{"did"};
407 return if $store_ignore || $opt_dry;
408 $DoBell++ if $do{"bell"};
409 write_message($folder);
410 smssend_tryall $store_ignorenewmail,$do{"sms"},%args if $do{"sms"};
415 # no &$funcref=>did smth in this block
416 # &$funcref,@funcargs
419 my($funcref,@funcargs)=@_;
421 return @AuditStored!=$did_last if !$funcref;
422 local $did_last=@AuditStored;
423 &$funcref(@funcargs);
424 return @AuditStored!=$did_last;
427 # Never use Mail::Audit->store() as it will reformat MIME bodies and possibly corrupt OpenPGP!
434 open F,">>$folder" or do { warn "Append \"$folder\": $!"; return 0; };
437 ($_=Mail::Audit::audit_get_lock(\*F,$folder)) and do { warn "Lock \"$folder\": $!"; last; };
438 seek F,0,IO::Handle::SEEK_END or do { warn "Seek-end \"$folder\": $!"; last; };
439 # FIXME: Check for '^From ' to not to rely on our network peer
440 print F $Message or do { warn "Write to \"$folder\": $!"; last; };
441 do { print F "\n"; warn "Missing trailing newline, fixed"; } if $Message!~/\n$/s;
442 close F or do { warn "Close \"$folder\""; last; };
445 warn "MAIL DROPPED for folder: $folder";
456 local $Message=$message;
457 local $Audit=Mail::Audit->new(
458 "emergency"=>"$Mail/emergency",
459 "data"=>[map("$_\n",split("\n",$message))],
460 "log"=>"$HOME/.lacemail.log",
463 local @AuditStored=();
464 do { smssend 0,$opt_smstest; return; } if $opt_smstest;
465 write_message("$Mail/input");
467 warn 'Corrupted $_, repaired' if defined($save_)!=defined($_) || (defined($_) && $save_ ne $_);
472 # return: true (error-message or "1") if is spam
475 # razor-check has exit code 1 if NOT spam, code 0 if IS spam
477 local $SIG{"PIPE"}=sub { warn "razor2 gave me SIGPIPE: broken pipe"; };
478 # prevent Razor2's: Can't call method "log" on unblessed reference at Razor2/Client/Agent.pm line 212.
479 local $ENV{"HOME"}=$HOME;
481 .'('.'(razor-check 2>&1;echo >&3 $?)'
482 .'|sed "s/^/razor-check: /"'
483 .'|logger -t "lacemail['.$$.']" -p mail.crit'
487 print CHILD $Message;
491 $return=<CHILD> || 1;
494 return undef() if !WIFEXITED($?);
495 return undef() if WIFSIGNALED($?);
496 return undef() if WIFSTOPPED($?);
497 return undef() if WEXITSTATUS($?);
498 return $return; # is-spam
501 # NOTE: returns undef() if !wantarray and the first header is unrecognized
506 for my $hdr ($Audit->head->get("Received")) {
507 my($for)=($hdr=~/\bfor\s+\<?(\S+)\>?\b/);
508 return $for if !wantarray();
509 push @r,$for if $for;
510 my($from,$fromaddr)=($hdr=~/\bfrom\s+(?:(\S+)\b.*?)??\[((?:\d{1,3}\.){3}\d{1,3})\]/);
511 $from=$fromaddr if !defined $from;
512 push @r,"$from:$fromaddr" if $from;
517 my %dnsbl_whitelist=map(($_=>1),(
518 "195.250.128.83" # smtp3.vol.cz; 83.128.250.195.blackholes.five-ten-sg.com
521 # Extended Mail::Audit::MAPS
522 # $domain,$full,[$timeout]
525 my($domain,$full,$timeout)=@_;
528 $Mail::Audit::MAPS::host=$domain;
529 my @hosts=map({ s/^.*://; "[$_]"; } # strip DNS part
530 grep({ /^([^:@]*):/ && !$alternates_host{$1} # leave only foreign hosts
531 && !$dnsbl_whitelist{$1}; } (Received_for()))
533 splice @hosts,1 if !$full && @hosts; # "&& @hosts" to prevent: WARN: splice() offset past end of array
535 package My::Audit::Faked;
536 sub received { return @{$_[0]->{"received"}}; }
539 "received"=>[@hosts],
541 bless $self_faked,"My::Audit::Faked";
542 return Mail::Audit::rblcheck($self_faked,$timeout);
545 our %muttrc_pending=();
550 $muttrc||="$HOME/.muttrc";
551 $muttrc=~s/^\~/$HOME/;
552 do { warn "Looping muttrc, ignoring: $muttrc"; return (); } if $muttrc_pending{$muttrc};
553 local $muttrc_pending{$muttrc}=1;
555 open MUTTRC,$muttrc or do { warn "open \"$muttrc\": $!"; return (); };
559 # far emulation mutt/init.c/mutt_parse_rc_line()
564 next if !/^(\S+)\s*/s;
565 if ($1 eq "source") {
567 do { warn "Wrong 'source' parameters at $muttrc:$.: $_"; next; } if !/^\S+$/;
573 close MUTTRC or warn "close \"$muttrc\": $!";
574 return wantarray() ? @r : join("",map("$_\n",@r));
577 my %mutteval_charmap=( # WARNING: Don't use "" or "0" here, see below for "|| warn"!
585 # mutt/init.c/mutt_extract_token()
589 return $_ if !s/^"//;
590 do { warn "Missing trailing quote in: $_"; return $_; } if !s/"$//;
591 s/\\(.)/$mutteval_charmap{$1} || warn "Undefined '\\$1' sequence in: $_";/ges;
599 my @r=map({ (ref $_ ? $_ : qr/^\s*set\s+\Q$_\E\s*=\s*(.*?)\s*$/si); } @headers);
600 my %r=map(($_=>undef()),@r);
604 $r{$ritem}=mutteval $1;
607 for my $var (grep { !defined($r{$_}) } @r) {
608 warn "Variable '$var' not found in muttrc";
611 return wantarray() ? %r : $r{$r[0]};
618 next if !(my $key=(/^alias\s+(\S+)\s+/)[0]);
619 for my $addrobj (Mail::Address->parse($')) {
620 my $addr=$addrobj->address();
621 my $ref=\$r{"\L$addr"};
622 $$ref=$key if !$$ref; # use always the first occurence to prefer nicks
629 # BEGIN lacemail-sendmail
630 # return: Mail::Address instance or undef()
635 return undef() if !defined $line;
636 my @r=Mail::Address->parse($line);
637 warn "Got ".scalar(@r)." addresses while wanting just one; when parsing: $line" if 1!=@r;
640 # END lacemail-sendmail
642 # FIXME: host may get multiple recipients and thus not showing "for <...>"
643 # FIXME: muttrc_get("from") is too strict
644 sub store_muttrc_alternates
646 my($prefix,$profile)=@_;
648 my $alternates=muttrc_get("alternates") or return;
649 my $alternatesre=qr/$alternates/si;
650 my $From=muttrc_get("from") or return;
651 my $Fromre=qr/^\Q$From\E$/si;
652 my $Fromobj=parseone $From or return;
653 warn "'From' \"$From\" not matched by 'alternates': $alternatesre"
654 if $From!~/$alternates/si;
655 for my $for (reverse Received_for()) {
656 $for=~s/:.*$//; # strip IP address here
657 if ($Fromobj->user() ne "prog-mutt") {
658 next if lc($for) eq lc($From);
661 my $forobj=parseone $for;
662 if ($forobj && $forobj->host()) {
663 # it is 'for' our primary address
664 next if lc($forobj->host()) eq lc($Fromobj->host()); # or 'return'? shouldn't matter
667 next if !$alternates_host{lc $for} && $for!~/$alternatesre/si;
668 store "$prefix\L$for",($profile || []);
674 # $header: !ref => $Audit->get($header)
675 # $maybeaddress: qr/regex/i
676 # $maybeaddress: "string"
677 # $maybeaddress: "<Regexp:regex>" # hack :-(
678 # $maybeaddress: "<user@host>"
679 # $maybeaddress: "<user@>"
680 # $maybeaddress: "<@host>"
683 my($re,$justone,$header,$maybeaddress)=@_;
686 $header=join(",",&$header());
689 $header=$Audit->get($header);
691 return 0 if !$header;
692 return $header=~/$maybeaddress/i if "Regexp" eq ref $maybeaddress;
693 return $header=~/$re/i if !defined(my $want=($maybeaddress=~/^\<(.*)\>$/)[0]);
694 my @parsed=Mail::Address->parse($header);
695 warn "'mailto:' forbidden in pattern: $want" if $want=~/^\Qmailto:\E/;
696 return 0 if $justone && 1!=@parsed;
698 if ($want=~/^Regexp:/)
699 { $_->address()=~/$'/i; }
701 { $_->user() =~/^(?:\Qmailto:\E)?\Q$`\E/i; }
703 { $_->host() =~/^\Q$'\E/i; }
705 { $_->address()=~/^(?:\Qmailto:\E)?\Q$want\E/i; }
711 my($header,$substr)=@_;
713 return _headercore(qr/\Q$substr\E/i,0,$header,$substr);
718 my($header,$string)=@_;
720 return _headercore(qr/\Q$string\E/i,1,$header,$string);
728 my $text=$Audit->get($header);
730 while (my($from,$to)=each(%$map)) {
731 $text=~s/\b\Q$from\E\b/$to/gsi;
733 return if $text eq $orig;
734 $Audit->put_header("X-LaceMail-header_remap-$header",$orig);
735 $Audit->replace_header($header,$text);
741 my $basedir=File::Basename::dirname($0);
742 $Getopt::Long::ignorecase=0;
743 die "GetOptions error" if !Getopt::Long::GetOptions(
744 "inetd" ,sub { $opt_mode=\&inetd; },
745 "stdin" ,sub { $opt_mode=\&stdin; },
747 "smstest:s",sub { $opt_mode=\&stdin; $opt_smstest=($_[1] || 1); },
749 "idletest" ,sub { syslogging_restore(); print((defined($_=useridle()) ? $_ : "<undef>")."\n"); exit 0; },
750 "muttrc" ,sub { syslogging_restore(); print scalar muttrc(); exit 0; },
751 "d|basedir=s",\&basedir,
753 # "Excessive arguments" checked in &inetd
754 die "Missing mode" if !$opt_mode;
756 my $filenameMyAudit="$basedir/My-Audit.pm";
757 open AUDIT,$filenameMyAudit or die "open \"$filenameMyAudit\": $!";
760 eval <AUDIT> or die "eval \"$filenameMyAudit\": $@";
762 %alternates_host=map((lc($_)=>1),@alternates_host);
764 close AUDIT or warn "close \"$filenameMyAudit\": $!";