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';
51 my $HOME="/home/lace";
52 my $Mail="$HOME/Mail";
53 my @ValidUsers=qw(root lace short kratochvil _local);
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>';
61 our($Message,$Audit,@AuditStored,$store_ignore,$store_ignorenewmail,$store_profile,$DoBell);
62 our(%audit_profile,@sms_squeezes); # imported
64 # from RedHat "procmail-3.22-5"
65 # /i should be only $procmailFROM_DAEMON but how it can hurt to /i all?
66 our $procmailTO_ =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^-a-zA-Z0-9_.])?'mio;
67 our $procmailTO =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^a-zA-Z])?'mio;
68 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;
69 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;
70 # perl-5.8.0 does not cope w/original FROM_MAILER on the third '?' character
71 # Thus we did '([^>]*[^(.%@a-z0-9])?' -> '[^>]*\b', I hope it is somehow similiar
72 # 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;
75 my $opt_smstest; # 1 or $smscount
83 syslogging_restore(); # This is more a debugging session
88 die "Invalid 'From ' line: $_" if $message eq "" && !/^From /;
89 if (/^From / && $message) {
95 process $message if $message;
101 die "Excessive arguments" if @ARGV;
103 IO::Handle::autoflush STDOUT 1;
108 confess "Unexpected EOF" if !defined $length;
109 confess "Missing EOL" if $length!~s/\n$//s;
110 exit 0 if $length eq "BYE";
111 confess "Unrecognized length: $length" if $length!~/^\d+$/;
114 $length==($_=read STDIN,$message,$length) or confess "Got $_ out of required $length bytes";
115 $length==length $message or confess "False read return ".length($message)." instead of $length";
122 bell() or warn "Unable to BELL";
133 open BELL,">/dev/tty11" or return 0;
135 close BELL or return 0;
141 my %valid_users=map(($_=>1),@ValidUsers);
142 my($idlebest,$linebest);
143 for my $utmp (User::Utmp::getut(),{ "ut_line"=>"psaux" }) {
145 next if defined($_=$utmp->{"ut_type"}) && $_!=User::Utmp::USER_PROCESS;
146 next if defined($_=$utmp->{"ut_user"}) && !$valid_users{$_};
147 my $line="/dev/".$utmp->{"ut_line"};
148 my $atime=(stat $line)[8];
149 my $what="user \"".($utmp->{"ut_user"} || "<local>")."\", line \"$line\"";
150 warn "Unable to stat $what" and next if !$atime;
151 my $idle=time()-$atime;
152 warn "atime in future for $what" and next if $idle<0;
153 next if $idle>$IdleMax;
154 next if defined $idlebest && $idlebest<=$idle;
158 return !wantarray() ? $idlebest : ($idlebest,$linebest);
161 # return only the very (recursive) first part
164 return $Audit if !$Audit->is_mime();
167 $first=$_ while $_=$first->parts(0);
175 return $Audit->is_mime() ? $part->head()
176 : MIME::Head->new([ split "\n",$Audit->head()->as_string() ])
184 # be vary cautious here as most of $part methods will encode it!
185 return join "",@{$Audit->body()} if !$Audit->is_mime();
186 my $bodyhandle=$part->bodyhandle();
187 # If MIME is corrupted we don't get bodyhandle() for this part
188 # It may occur when "boundary" is specified by header but no such boundary is found in the body
189 return $bodyhandle->as_string() if $bodyhandle;
190 warn "MIME corrupted, adapting";
191 return $part->body_as_string();
198 return $Audit->is_mime() ? $part->effective_type() : mimehead($part)->mime_type();
203 my $first=body_first();
204 my $r=mimebody($first);
205 my $mime_type=mime_type($first);
206 if ($mime_type eq "text/html") {
207 # HTML::FormatText just does a useless text layouts
208 # PerlIO::via::StripHTML probably needs PerlIO input (?)
210 $r=HTML::Entities::decode($r);
211 # FIXME: detect charset from <meta> tag: "Content-type: text/html; charset=<???>"
213 elsif ($mime_type eq "application/pgp-encrypted"
214 && (my $filename=mimehead($first)->mime_attr("Content-Disposition.filename"))
216 # first part contains just "Version: 1" as of GnuPG v1.0.4 (GNU/Linux)
219 if ((my $charset=mimehead($first)->mime_attr("Content-Type.charset"))) {
220 my $cstocs=Cz::Cstocs->new($charset,"ascii");
221 $r=&$cstocs($r) if $cstocs; # charset may be unknown
230 return $Audit if !$part && !$Audit->is_mime();
232 # don't use '!$part->parts()' as even 0-parts-multiparts are still multiparts
233 return $part if $part->bodyhandle();
234 return map { (parts_linear($_)); } $part->parts();
239 my($smsi,$smscount)=@_;
241 return "$smsi/$smscount:" if $smscount>1;
245 # patch for http://kiwi.ms.mff.cuni.cz/%7Etom/programming/src/sendsms.tar.gz/sendsms.pl
246 my $agent=LWP::UserAgent->new();
247 $agent->agent("LaceMail $VERSION; contact=$SMScontact; ");
248 my($request1,$response1); # for &send_cz_eurotel
249 my($name,$value,$type,$disabled,$q2);
251 # &send_cz_eurotel returns: error
252 # BEGIN http://kiwi.ms.mff.cuni.cz/%7Etom/programming/src/sendsms.tar.gz/sendsms.pl
259 $ct=$resp->content();
263 if (/(<input|<select|<textarea)([^<>]*)(>|$)/i) {
264 my $txt=$2, $name="", $value="", $type="x", $disabled=0;
266 if ($txt =~ /type="([^"]*)"/i) { $type=$1; }
267 elsif ($txt =~ /type=([^" ]*)[ >]/i) { $type=$1; }
268 if ($txt =~ /name="([^"]*)"/i) { $name=$1; }
269 elsif ($txt =~ /name=([^" ]*)[ >]/i) { $name=$1; }
270 if ($txt =~ /value="([^"]*)"/i) { $value=$1; }
271 elsif ($txt =~ /value=([^" ]*)[ >]/i) { $value=$1; }
272 if ($txt =~ /disabled/i) { $disabled=1; }
273 if ($name ne "" && $type ne "" && $type ne "button" && ! $disabled)
276 $inputs[$ipoc+1][0]=$name;
277 $inputs[$ipoc+1][1]=$value;
291 for ($i=0; $i<=$#inputs; $i++)
294 if ($i>0) { $query="$query&"; }
295 $q1=uri_escape($inputs[$i][0]);
296 $q2=uri_escape($inputs[$i][1]);
297 $query="$query$q1=$q2";
300 #change @ and space back
308 my ($id,$text,$mail,$directd) = @_;
309 my $src_url = "http://www2.eurotel.cz/sms/index.html";
312 my $cookie = HTTP::Cookies->new;
315 #check if correct number
316 if (substr($id,0,5)!="00420") { return -1; }
317 $pref=substr($id,5,3);
318 if (!($pref eq "601" || $pref eq "602" || $pref eq "606" || $pref eq "607" || ($pref ge "720" && $pref le "729"))) { return -1; }
320 #get form page, extract cookies
321 $request1=new HTTP::Request('GET', "$src_url?n_pagestyle=new");
322 $response1=$agent->request($request1);
323 if ($response1->code != 200) { return -3; }
324 $cookie->extract_cookies($response1);
327 @inputs=parse_inputs($response1);
330 $inputs[2][1]=substr($id,5,3);
331 $inputs[3][1]=substr($id,8,6);
336 $inputs[6][1]="show" if ($directd>0);
340 # for ($i=0; $i<=$#inputs; $i++) { print "[$i] $inputs[$i][0] $inputs[$i][1]\n"; }
343 $query=make_query(@inputs);
346 my $header = new HTTP::Headers(
347 'Content-Length' => length($query),
348 'Content-Type' => 'application/x-www-form-urlencoded',
350 'Referer' => $src_url
352 my $request2 = new HTTP::Request('POST',$src_url, $header, $query);
353 $cookie->add_cookie_header($request2);
354 my $response2 = $agent->request($request2);
356 if ($response2->code != 200) { return -3; }
359 if ($response2->content() !~ /byla.*odesl.*na na SMS centrum/)
365 # END http://kiwi.ms.mff.cuni.cz/%7Etom/programming/src/sendsms.tar.gz/sendsms.pl
369 my($ignorenewmail,$smscount,%args)=@_;
373 if (!$ignorenewmail) { # send by mail
374 $l-=length("Z emailu $SMSmailError: ");
375 $l-=length(smsbuild($_,$smscount));
378 $l-=length("Z WWW x/5: ");
379 $l-=length(smsbuild($_,POSIX::ceil($smscount/5)));
387 my($squeezed,$smscount,@lens)=@_;
389 $smscount=POSIX::ceil($smscount/5);
390 for my $smsi (0..$smscount-1) {
391 my $len=$lens[$smsi];
392 $squeezed=~/^.{0,$len}/s;
395 return 0 if send_cz_eurotel($SMSwebRcpt,$frag,"",0);
402 my($squeezed,$smscount,@lens)=@_;
405 for ($smscount=0;$recalclen<length $squeezed;$smscount++) {
406 $recalclen+=$lens[$smscount];
408 for my $smsi (0..$smscount-1) {
409 my $len=$lens[$smsi];
410 $squeezed=~/^.{0,$len}/s;
413 my $mail=Mail::Mailer->new("sendmail","-f","$SMSmailError");
416 "From"=>$SMSmailError, # no longer displayed anyway
417 "X-LaceMail-Version"=>$VERSION,
418 "X-LaceMail-Contact"=>$SMScontact,
420 print $mail smsbuild($smsi,$smscount).$frag."\n";
421 # FIXME: check errors
429 my($ignorenewmail,$smscount,%args)=@_;
431 my %aliases=muttrc_aliases();
433 "subject"=>unmime($Audit->subject()),
434 "from"=>[ map({ $_=$_->address(); $_="\L$_"; $aliases{$_} || $_; } Mail::Address->parse(unmime($Audit->from()))) ],
435 "body"=>substr(body_simple(),0,$MaxBodySMS*(1+0.25*$smscount)),
438 ($texthead,$text)=@$text if ref $text;
439 do { print "$texthead\n$text\n"; return; } if $opt_smstest;
440 my @lens=smslens($ignorenewmail,$smscount,%args);
442 $maxlen+=$_ for (@lens);
444 for my $squeeze (@sms_squeezes) {
446 Lingua::EN::Squeeze::SqueezeControl($_) if defined ($_=$squeeze->{"SqueezeControl"});
447 $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL or 1; # prevent: Name "$_" used only once: possible typo
448 $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL=$_ if defined ($_=$squeeze->{"SQZ_OPTIMIZE_LEVEL"});
449 $squeezed=Lingua::EN::Squeeze::SqueezeText($text);
451 last if $maxlen>=length($texthead.$squeezed);
453 $squeezed=substr $texthead.$squeezed,0,$maxlen; # strip if we passed thru last for() above
455 for ($smscount=0;$recalclen<length $squeezed;$smscount++) {
456 $recalclen+=$lens[$smscount];
458 my $func=($ignorenewmail ? \&smssend_web : \&smssend_mail);
459 &$func($squeezed,$smscount,@lens);
464 my($ignorenewmail,@args)=@_;
466 return if !$opt_smstest && !$opt_idle && defined useridle();
468 return $_ if $_=smssend(1,@args); # web
469 return $_ if !$ignorenewmail && ($_=smssend(0,@args)); # mail
470 warn "Unable to SMSsend the mail";
477 return "<???>" if !defined($_) || /^\s*$/s;
480 return $_ if length($_)<64;
481 return substr($_,0,64)."...";
484 our $profile_eval_depth=0;
490 die "Nesting profile: $name" if 0x10<=(local $profile_eval_depth=$profile_eval_depth+1);
491 return @$name if ref $name;
492 die "Profile not found: $name" if !exists $audit_profile{$name};
493 my @this=@{$audit_profile{$name}};
494 return (profile_eval($'),@this[1..$#this]) if $this[0] && $this[0]=~/^=/;
502 return join(",",map({ $_->name() or $_->address(); } Mail::Address->parse($text)));
511 for (${$_}[1],"iso-8859-2") {
512 last if $_ && ($cstocs=Cz::Cstocs->new($_,"ascii"));
515 } MIME::Words::decode_mimewords($text));
518 # $folder: "$folder; comment"
519 # $profile as profile_eval($name)
522 my($folder,$profile,%args)=@_;
524 $profile=$store_profile if !$profile;
525 my %do=map({ (!/=/ ? ($_=>1) : ($`=>$')); } profile_eval($profile));
526 Sys::Syslog::syslog("info","%s%s: %s: %s",
527 (!$store_ignore ? "" : "IGNORED[$store_ignore]: "),
528 map({ cut($_); } $folder,address_show(unmime($Audit->from())),unmime($Audit->subject())),
531 $DoBell++ if $do{"bell"};
533 $folder="$Mail/".$' if $folder=~/^=/;
534 if (!$store_ignore) {
536 $Audit->accept($folder);
538 smssend_tryall $store_ignorenewmail,$do{"sms"},%args if $do{"sms"};
539 push @AuditStored,$folder if $do{"did"};
544 # no &$funcref=>did smth in this block
545 # &$funcref,@funcargs
548 my($funcref,@funcargs)=@_;
550 return @AuditStored!=$did_last if !$funcref;
551 local $did_last=@AuditStored;
552 &$funcref(@funcargs);
553 return @AuditStored!=$did_last;
562 local $Message=$message;
563 local $Audit=Mail::Audit->new(
564 "emergency"=>"$Mail/emergency",
565 "data"=>[map("$_\n",split("\n",$message))],
567 local @AuditStored=();
568 do { smssend $opt_smstest; return; } if $opt_smstest;
570 warn 'Corrupted $_, repaired' if defined($save_)!=defined($_) || (defined($_) && $save_ ne $_);
575 # return: true (error-message or "1") if is spam
578 # razor-check has exit code 1 if NOT spam, code 0 if IS spam
580 # prevent Razor2's: Can't call method "log" on unblessed reference at Razor2/Client/Agent.pm line 212.
581 local $ENV{"HOME"}=$HOME;
583 .'('.'(razor-check 2>&1;echo >&3 $?)'
584 .'|sed "s/^/razor-check: /"'
585 .'|logger -t "lacemail['.$$.']" -p mail.crit'
589 print CHILD $Message;
593 $return=<CHILD> || 1;
596 return undef() if !WIFEXITED($?);
597 return undef() if WIFSIGNALED($?);
598 return undef() if WIFSTOPPED($?);
599 return undef() if WEXITSTATUS($?);
600 return $return; # is-spam
603 # NOTE: returns undef() if !wantarray and the first header is unrecognized
607 for my $hdr ($Audit->head->get("Received")) {
608 my($for)=($hdr=~/\bfor\s+\<?(\S+)\>?\b/);
609 return $for if !wantarray();
610 push @r,$for if $for;
615 our %muttrc_pending=();
620 $muttrc||="$HOME/.muttrc";
621 $muttrc=~s/^\~/$HOME/;
622 do { warn "Looping muttrc, ignoring: $muttrc"; return (); } if $muttrc_pending{$muttrc};
623 local $muttrc_pending{$muttrc}=1;
625 open MUTTRC,$muttrc or do { warn "open \"$muttrc\": $!"; return (); };
629 # far emulation mutt/init.c/mutt_parse_rc_line()
634 next if !/^(\S+)\s*/s;
635 if ($1 eq "source") {
637 do { warn "Wrong 'source' parameters at $muttrc:$.: $_"; next; } if !/^\S+$/;
643 close MUTTRC or warn "close \"$muttrc\": $!";
644 return wantarray() ? @r : join("",map("$_\n",@r));
651 my @r=map({ (ref $_ ? $_ : qr/^\s*set\s+\Q$_\E\s*=\s*"([^"]*)"\s*$/si); } @headers);
652 my %r=map(($_=>undef()),@r);
659 for my $var (grep { !defined($r{$_}) } @r) {
660 warn "Variable '$var' not found in muttrc";
663 return wantarray() ? %r : $r{$r[0]};
670 next if !(my $key=(/^alias\s+(\S+)\s+/)[0]);
671 for my $addrobj (Mail::Address->parse($')) {
672 my $addr=$addrobj->address();
673 my $ref=\$r{"\L$addr"};
674 $$ref=$key if !$$ref;
680 sub store_muttrc_alternates
682 my($prefix,$profile)=@_;
684 my $alternates=muttrc_get("alternates") or return;
685 my $alternatesre=qr/$alternates/si;
686 my $From=muttrc_get(qr/^\s*my_hdr\s+From:.*\<(\S+)\>\s*$/si) or return;
687 my $Fromre=qr/^\Q$From\E$/si;
688 warn "'From' \"$From\" not matches by 'alternates': $alternatesre"
689 if $From!~/$alternates/si;
690 for my $for (reverse Received_for()) {
691 return if $for=~/$From/si;
692 next if $for!~/$alternatesre/si;
693 store "$prefix\L$for",($profile || []);
699 # $header: !ref => $Audit->get($header)
700 # $maybeaddress: qr/regex/i
701 # $maybeaddress: "string"
702 # $maybeaddress: "<Regexp:regex>" # hack :-(
703 # $maybeaddress: "<user@host>"
704 # $maybeaddress: "<user@>"
705 # $maybeaddress: "<@host>"
708 my($re,$justone,$header,$maybeaddress)=@_;
711 $header=join(",",&$header());
714 $header=$Audit->get($header);
716 return 0 if !$header;
717 return $header=~/$maybeaddress/i if "Regexp" eq ref $maybeaddress;
718 return $header=~/$re/i if !defined(my $want=($maybeaddress=~/^\<(.*)\>$/)[0]);
719 my @parsed=Mail::Address->parse($header);
720 warn "'mailto:' forbidden in pattern: $want" if $want=~/^\Qmailto:\E/;
721 return 0 if $justone && 1!=@parsed;
723 if ($want=~/^Regexp:/)
724 { $_->address()=~/$'/i; }
726 { $_->user() =~/^(?:\Qmailto:\E)?\Q$`\E/i; }
728 { $_->host() =~/^\Q$'\E/i; }
730 { $_->address()=~/^(?:\Qmailto:\E)?\Q$want\E/i; }
736 my($header,$substr)=@_;
738 return _headercore(qr/\Q$substr\E/i,0,$header,$substr);
743 my($header,$string)=@_;
745 return _headercore(qr/\Q$string\E/i,1,$header,$string);
753 my $text=$Audit->get($header);
755 while (my($from,$to)=each(%$map)) {
756 $text=~s/\b\Q$from\E\b/$to/gsi;
758 return if $text eq $orig;
759 $Audit->put_header("X-LaceMail-header_remap-$header",$orig);
760 $Audit->replace_header($header,$text);
766 my $basedir=File::Basename::dirname($0);
767 $Getopt::Long::ignorecase=0;
768 die "GetOptions error" if !Getopt::Long::GetOptions(
769 "inetd" ,sub { $opt_mode=\&inetd; },
770 "stdin" ,sub { $opt_mode=\&stdin; },
771 "smstest:s",sub { $opt_mode=\&stdin; $opt_smstest=($_[1] || 1); },
773 "idletest" ,sub { syslogging_restore(); print((defined($_=useridle()) ? $_ : "<undef>")."\n"); exit 0; },
774 "muttrc" ,sub { syslogging_restore(); print scalar muttrc(); exit 0; },
775 "d|basedir=s",\&basedir,
777 # "Excessive arguments" checked in &inetd
778 die "Missing mode" if !$opt_mode;
780 my $filenameMyAudit="$basedir/My-Audit.pm";
781 open AUDIT,$filenameMyAudit or die "open \"$filenameMyAudit\": $!";
784 eval <AUDIT> or die "eval \"$filenameMyAudit\": $@";
787 close AUDIT or warn "close \"$filenameMyAudit\": $!";