#! /usr/bin/perl # # $Id$ # Copyright (C) 2002-2003 Jan Kratochvil # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA use vars qw($VERSION); $VERSION=do { my @r=(q$Revision$=~/\d+/g); sprintf "%d.".("%03d"x$#r),@r; }; use strict; use warnings; INIT { require Sys::Syslog; Sys::Syslog::openlog("perlmail","pid","mail"); my @syslogging_stack; sub syslogging_on_save { push @syslogging_stack,$SIG{"__WARN__"},$SIG{"__DIE__" }; $SIG{"__WARN__"}=sub { Sys::Syslog::syslog("warning","WARN: %s",$_[0]); }; # disabled: print STDERR $_[0]; $SIG{"__DIE__" }=sub { Sys::Syslog::syslog("crit" ,"DIE: %s" ,$_[0]); }; } syslogging_on_save(); sub syslogging_restore { $SIG{"__DIE__" }=pop @syslogging_stack; $SIG{"__WARN__"}=pop @syslogging_stack; } } use File::Basename; BEGIN { use lib $ENV{"PERLMAIL_BASEDIR"} || File::Basename::dirname($0); use PerlMail::Config; use PerlMail::Lib; } use Mail::Audit qw(MAPS); require IO::Handle; use Carp qw(cluck confess); use POSIX qw(WIFEXITED WEXITSTATUS WIFSIGNALED WTERMSIG WIFSTOPPED WSTOPSIG); require POSIX; # for ceil use User::Utmp; use Getopt::Long; require Mail::Address; require MIME::Words; require Cz::Cstocs; require HTML::Entities; require MIME::Head; require Lingua::EN::Squeeze; require Mail::Mailer; require HTTP::Cookies; require HTTP::Request; require LWP::UserAgent; use URI::Escape 'uri_escape'; require WWW::SMS; our($Message,@AuditStored,$DoBell); my %alternates_host; # from @alternates_host my %dnsbl_whitelist; # from @dnsbl_whitelist # from RedHat "procmail-3.22-5" # /i should be only $procmailFROM_DAEMON but how it can hurt to /i all? our $procmailTO_ =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^-a-zA-Z0-9_.])?'mio; our $procmailTO =qr'^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope|Apparently(-Resent)?)-To):(.*[^a-zA-Z])?'mio; 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; $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; # perl-5.8.0 does not cope w/original FROM_MAILER on the third '?' character # Thus we did '([^>]*[^(.%@a-z0-9])?' -> '[^>]*\b', I hope it is somehow similiar # 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; my $opt_mode; my $opt_smstest; # 1 or $smscount my $opt_idle; my $opt_dry; sub process; sub stdin { syslogging_restore(); # This is more a debugging session local $/="\n"; my $message=""; local $_; while (<>) { die "Invalid 'From ' line: $_" if $message eq "" && !/^From /; if (/^From / && $message) { process $message; $message=""; } $message.=$_; } process $message if $message; exit 0; } # FIXME: separate 'perlmail'-transfer together with perlmail-submit away sub inetd { die "Excessive arguments" if @ARGV; IO::Handle::autoflush STDOUT 1; while (1) { local $/="\n"; my $length=; confess "Unexpected EOF" if !defined $length; confess "Missing EOL" if $length!~s/\n$//s; exit 0 if $length eq "BYE"; confess "Unrecognized length: $length" if $length!~/^\d+$/; my $message; local $_; $length==($_=read STDIN,$message,$length) or confess "Got $_ out of required $length bytes"; $length==length $message or confess "False read return ".length($message)." instead of $length"; { local *STDOUT; local *STDERR; local $DoBell=0; process $message; if ($DoBell) { bell() or warn "Unable to BELL"; } } print STDOUT "1"; } die "NOTREACHED"; } sub bell { local *BELL; open BELL,">/dev/tty11" or return 0; print BELL "\x07"; close BELL or return 0; return 1; } sub useridle { return 0 if ! -e "$HOME/away"; my %valid_users=map(($_=>1),@ValidUsers); my($idlebest,$linebest); for my $utmp (User::Utmp::getut(),{ "ut_line"=>"psaux" }) { local $_; next if defined($_=$utmp->{"ut_type"}) && $_!=User::Utmp::USER_PROCESS; next if defined($_=$utmp->{"ut_user"}) && !$valid_users{$_}; my $line="/dev/".$utmp->{"ut_line"}; my $atime=(stat $line)[8]; my $what="user \"".($utmp->{"ut_user"} || "")."\", line \"$line\""; warn "Unable to stat $what" and next if !$atime; my $idle=time()-$atime; warn "atime in future for $what" and next if $idle<0; next if $idle>$IdleMax; next if defined $idlebest && $idlebest<=$idle; $idlebest=$idle; $linebest=$line; } return !wantarray() ? $idlebest : ($idlebest,$linebest); } # return only the very (recursive) first part sub body_first { return $Audit if !$Audit->is_mime(); my $first=$Audit; local $_; $first=$_ while $_=$first->parts(0); return $first; } sub mimehead { my($part)=@_; return $Audit->is_mime() ? $part->head() : MIME::Head->new([ split "\n",$Audit->head()->as_string() ]) ; } sub mimebody { my($part)=@_; # be vary cautious here as most of $part methods will encode it! return join "",@{$Audit->body()} if !$Audit->is_mime(); my $bodyhandle=$part->bodyhandle(); # If MIME is corrupted we don't get bodyhandle() for this part # It may occur when "boundary" is specified by header but no such boundary is found in the body return $bodyhandle->as_string() if $bodyhandle; warn "MIME corrupted, adapting"; return $part->body_as_string(); } sub mime_type { my($part)=@_; return $Audit->is_mime() ? $part->effective_type() : mimehead($part)->mime_type(); } sub body_simple { my $first=body_first(); my $r=mimebody($first); my $mime_type=mime_type($first); if ($mime_type eq "text/html") { # HTML::FormatText just does a useless text layouts # PerlIO::via::StripHTML probably needs PerlIO input (?) $r=~s/<[^>]*>//gs; $r=HTML::Entities::decode($r); # FIXME: detect charset from tag: "Content-type: text/html; charset=" } elsif ($mime_type eq "application/pgp-encrypted" && (my $filename=mimehead($first)->mime_attr("Content-Disposition.filename")) ) { # first part contains just "Version: 1" as of GnuPG v1.0.4 (GNU/Linux) $r="pgp($filename)"; } if ((my $charset=mimehead($first)->mime_attr("Content-Type.charset"))) { my $cstocs=Cz::Cstocs->new($charset,"ascii"); $r=&$cstocs($r) if $cstocs; # charset may be unknown } return $r; } sub parts_linear { my($part)=@_; return $Audit if !$part && !$Audit->is_mime(); $part||=$Audit; # don't use '!$part->parts()' as even 0-parts-multiparts are still multiparts return $part if $part->bodyhandle(); return map { (parts_linear($_)); } $part->parts(); } sub smsbuild { my($smsi,$smscount)=@_; return "$smsi/$smscount:" if $smscount>1; return ""; } sub smslens { my($ignorenewmail,$smscount,%args)=@_; return map({ my $l=160; if (!$ignorenewmail) { # send by mail $l-=length("Z emailu FIXME SMSmailError: "); $l-=length(smsbuild($_,$smscount)); } else { # send by web $l-=6; # 154 is the max length before split; why? } $l; } (0..$smscount-1)); } sub smssend_web { my($squeezed,$smscount,@lens)=@_; $smscount=POSIX::ceil($smscount/5); for my $smsi (0..$smscount-1) { my $len=$lens[$smsi]; $squeezed=~/^.{0,$len}/s; my $frag=$&; $squeezed=$'; return 0 if 3!=@SMSwebRcpt; local *F; open F,"$HOME/priv/WWW-SMS-$SMSwebRcpt_username.pwd" or return 0; my $pwd=; chomp $pwd; close F; my $sms=WWW::SMS->new(@SMSwebRcpt,$frag,"username"=>$SMSwebRcpt_username,"passwd"=>$pwd); for ($sms->gateways("sorted"=>"reliability")) { last if $sms->send($_); Sys::Syslog::syslog("warning","Web SMS send failed: %s",$WWW::SMS::Error); my $void=$WWW::SMS::Error; # Prevent: Name "WWW::SMS::Error" used only once } } return 1; } sub smssend_mail { my($squeezed,$smscount,@lens)=@_; return 0; } sub smssend { my($ignorenewmail,$smscount,%args)=@_; my $text=PerlMail::Config::audit_sms( "subject"=>unmime($Audit->subject()), "from"=>[ Mail::Address->parse(unmime($Audit->from())) ], "body"=>substr(body_simple(),0,$MaxBodySMS*(1+0.25*$smscount)), %args); my $texthead=""; ($texthead,$text)=@$text if ref $text; do { print "$texthead\n$text\n"; return; } if $opt_smstest; my @lens=smslens($ignorenewmail,$smscount,%args); my $maxlen=0; $maxlen+=$_ for (@lens); my $squeezed; for my $squeeze (@sms_squeezes) { local $_; Lingua::EN::Squeeze::SqueezeControl($_) if defined ($_=$squeeze->{"SqueezeControl"}); $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL or 1; # prevent: Name "$_" used only once: possible typo $Lingua::EN::Squeeze::SQZ_OPTIMIZE_LEVEL=$_ if defined ($_=$squeeze->{"SQZ_OPTIMIZE_LEVEL"}); $squeezed=Lingua::EN::Squeeze::SqueezeText($text); chomp $squeezed; last if $maxlen>=length($texthead.$squeezed); } $squeezed=substr $texthead.$squeezed,0,$maxlen; # strip if we passed thru last for() above my $recalclen=0; for ($smscount=0;$recalclen" if !defined($_) || /^\s*$/s; s/^\s*//s; s/\s*$//s; return $_ if length($_)<128; return substr($_,0,128)."..."; } our $profile_eval_depth=0; # ($name || @$name) sub profile_eval { my($name)=@_; die "Nesting profile: $name" if 0x10<=(local $profile_eval_depth=$profile_eval_depth+1); return @$name if ref $name; die "Profile not found: $name" if !exists $audit_profile{$name}; my @this=@{$audit_profile{$name}}; return (profile_eval($'),@this[1..$#this]) if $this[0] && $this[0]=~/^=/; return @this; } sub address_show { my($text)=@_; return join(",",map({ $_->name() or $_->address(); } Mail::Address->parse($text))); } sub unmime { my($text)=@_; return join "",map({ my $cstocs; for (${$_}[1],"iso-8859-2") { last if $_ && ($cstocs=Cz::Cstocs->new($_,"ascii")); } &$cstocs(${$_}[0]); } MIME::Words::decode_mimewords($text)); } # $folder: "$folder; comment" # $profile as profile_eval($name) sub store { my($folder,$profile,%args)=@_; $profile=$store_profile if !$profile; my %do=map({ (!/=/ ? ($_=>1) : ($`=>$')); } profile_eval($profile)); Sys::Syslog::syslog("info","%s%s%s: %s: %s", (!$opt_dry ? "" : "--dry: "), (!$store_ignore ? "" : "IGNORED[$store_ignore]: "), map({ cut($_); } $folder,address_show(unmime($Audit->from())),unmime($Audit->subject())), ) if $do{"syslog"} || $opt_dry; $folder=~s/;.*$//s; $folder="$Mail/".$' if $folder=~/^=/; push @AuditStored,$folder if $do{"did"}; return if $store_ignore || $opt_dry; $DoBell++ if $do{"bell"}; write_message($folder) or die; smssend_tryall $store_ignorenewmail,$do{"sms"},%args if $do{"sms"}; } our $did_last=0; # no &$funcref=>did smth in this block # &$funcref,@funcargs sub did { my($funcref,@funcargs)=@_; return @AuditStored!=$did_last if !$funcref; local $did_last=@AuditStored; &$funcref(@funcargs); return @AuditStored!=$did_last; } # Never use Mail::Audit->store() as it will reformat MIME bodies and possibly corrupt OpenPGP! sub write_message { my($folder)=@_; return 1 if $opt_dry; # simulate OK local *F; open F,">>$folder" or do { warn "Append \"$folder\": $!"; return 0; }; { local $_; ($_=Mail::Audit::audit_get_lock(\*F,$folder)) and do { warn "Lock \"$folder\": $!"; last; }; seek F,0,IO::Handle::SEEK_END or do { warn "Seek-end \"$folder\": $!"; last; }; # FIXME: Check for '^From ' to not to rely on our network peer print F $Message or do { warn "Write to \"$folder\": $!"; last; }; do { print F "\n"; warn "Missing trailing newline, fixed"; } if $Message!~/\n$/s; close F or do { warn "Close \"$folder\""; last; }; return 1; # OK } warn "MAIL DROPPED for folder: $folder"; close F; return 0; # failed } sub process { my($message)=@_; local $_=$_; my $save_=$_; local $Message=$message; # Cannot call 'local' for our-imported variable: my $Audit_save=$Audit; $Audit=Mail::Audit->new( "emergency"=>"$Mail/emergency", "data"=>[map("$_\n",split("\n",$message))], "log"=>"$HOME/.perlmail.log", "loglevel"=>99, ); local @AuditStored=(); do { smssend 0,$opt_smstest; return; } if $opt_smstest; write_message("$Mail/input") or die; PerlMail::Config::audit(); warn 'Corrupted $_, repaired' if defined($save_)!=defined($_) || (defined($_) && $save_ ne $_); # restore: $Audit=$Audit_save; } # utility functions: # return: true (error-message or "1") if is spam sub razor2 { # razor-check has exit code 1 if NOT spam, code 0 if IS spam local *CHILD; local $SIG{"PIPE"}=sub { warn "razor2 gave me SIGPIPE: broken pipe"; }; # prevent Razor2's: Can't call method "log" on unblessed reference at Razor2/Client/Agent.pm line 212. local $ENV{"HOME"}=$HOME; open CHILD,'|' .'('.'(razor-check 2>&1;echo >&3 $?)' .'|sed "s/^/razor-check: /"' .'|logger -t "perlmail['.$$.']" -p mail.crit' .') 3>&1' .'|exit `cat`' or return 0; print CHILD $Message; my $return; { local $/=undef(); $return= || 1; } close CHILD; return undef() if !WIFEXITED($?); return undef() if WIFSIGNALED($?); return undef() if WIFSTOPPED($?); return undef() if WEXITSTATUS($?); return $return; # is-spam } # NOTE: returns undef() if !wantarray and the first header is unrecognized # Returns also hosts sub Received_for { my @r=(); for my $hdr ($Audit->head->get("Received")) { my($for)=($hdr=~/\bfor\s+\?\b/); return $for if !wantarray(); push @r,$for if $for; my($from,$fromaddr)=($hdr=~/\bfrom\s+(?:(\S+)\b.*?)??\[((?:\d{1,3}\.){3}\d{1,3})\]/); $from=$fromaddr if !defined $from; push @r,"$from:$fromaddr" if $from; } return @r; } # Extended Mail::Audit::MAPS # $domain,$full,[$timeout] sub dnsbl { my($domain,$full,$timeout)=@_; $timeout||=30; # sec $Mail::Audit::MAPS::host=$domain; my @hosts=map({ s/^.*://; "[$_]"; } # strip DNS part grep({ /^([^:@]*):/ # $1 is DNS name, $' is IP address && !$alternates_host{$1} # leave only foreign hosts && !$dnsbl_whitelist{$'}; } (Received_for())) ); splice @hosts,1 if !$full && @hosts; # "&& @hosts" to prevent: WARN: splice() offset past end of array { package My::Audit::Faked; sub received { return @{$_[0]->{"received"}}; } } my $self_faked={ "received"=>[@hosts], }; bless $self_faked,"My::Audit::Faked"; return Mail::Audit::rblcheck($self_faked,$timeout); } sub muttrc_aliases { my %r=(); for (muttrc()) { next if !(my $key=(/^alias\s+(\S+)\s+/)[0]); for my $addrobj (Mail::Address->parse($')) { my $addr=$addrobj->address(); my $ref=\$r{"\L$addr"}; $$ref=$key if !$$ref; # use always the first occurence to prefer nicks } } return %r; } # FIXME: host may get multiple recipients and thus not showing "for <...>" # FIXME: muttrc_get("from") is too strict sub store_muttrc_alternates { my($prefix,$profile)=@_; my $alternates=muttrc_get("alternates") or return; my $alternatesre=qr/$alternates/si; my $From=muttrc_get("from") or return; my $Fromre=qr/^\Q$From\E$/si; my $Fromobj=parseone $From or return; warn "'From' \"$From\" not matched by 'alternates': $alternatesre" if $From!~/$alternates/si; for my $for (reverse Received_for()) { $for=~s/:.*$//; # strip IP address here if ($Fromobj->user() ne "prog-mutt") { next if lc($for) eq lc($From); } else { my $forobj=parseone $for; if ($forobj && $forobj->host()) { # it is 'for' our primary address next if lc($forobj->host()) eq lc($Fromobj->host()); # or 'return'? shouldn't matter } } next if !$alternates_host{lc $for} && $for!~/$alternatesre/si; store "$prefix\L$for",($profile || []); return; } } # $header: ref CODE # $header: !ref => $Audit->get($header) # $maybeaddress: qr/regex/i # $maybeaddress: "string" # $maybeaddress: "" # hack :-( # $maybeaddress: "" # $maybeaddress: "" # $maybeaddress: "<@host>" sub _headercore { my($re,$justone,$header,$maybeaddress)=@_; if (ref $header) { $header=join(",",&$header()); } else { $header=$Audit->get($header); } return 0 if !$header; return $header=~/$maybeaddress/i if "Regexp" eq ref $maybeaddress; return $header=~/$re/i if !defined(my $want=($maybeaddress=~/^\<(.*)\>$/)[0]); my @parsed=Mail::Address->parse($header); warn "'mailto:' forbidden in pattern: $want" if $want=~/^\Qmailto:\E/; return 0 if $justone && 1!=@parsed; return grep { if ($want=~/^Regexp:/) { $_->address()=~/$'/i; } elsif ($want=~/\@$/) { $_->user() =~/^(?:\Qmailto:\E)?\Q$`\E/i; } elsif ($want=~/^\@/) { $_->host() =~/^\Q$'\E/i; } else { $_->address()=~/^(?:\Qmailto:\E)?\Q$want\E/i; } } @parsed; } sub headerhas { my($header,$substr)=@_; return _headercore(qr/\Q$substr\E/i,0,$header,$substr); } sub headeris { my($header,$string)=@_; return _headercore(qr/\Q$string\E/i,1,$header,$string); } # $header,%$map sub header_remap { my($header,$map)=@_; my $text=$Audit->get($header); my $orig=$text; while (my($from,$to)=each(%$map)) { $text=~s/\b\Q$from\E\b/$to/gsi; } return if $text eq $orig; $Audit->put_header("X-PerlMail-header_remap-$header",$orig); $Audit->replace_header($header,$text); } # MAIN $Getopt::Long::ignorecase=0; die "GetOptions error" if !Getopt::Long::GetOptions( "inetd" ,sub { $opt_mode=\&inetd; }, "stdin" ,sub { $opt_mode=\&stdin; }, "dry" ,\$opt_dry, "smstest:s",sub { $opt_mode=\&stdin; $opt_smstest=($_[1] || 1); }, "idle!" ,\$opt_idle, "idletest" ,sub { syslogging_restore(); print((defined($_=useridle()) ? $_ : "")."\n"); exit 0; }, "muttrc" ,sub { syslogging_restore(); print scalar muttrc(); exit 0; }, ); # "Excessive arguments" checked in &inetd die "Missing mode" if !$opt_mode; %alternates_host=map((lc($_)=>1),@alternates_host); %dnsbl_whitelist=map(( $_ =>1),@dnsbl_whitelist); &$opt_mode(); die "NOTREACHED";