#!/usr/bin/perl # # # lib: MIME::Parser, Mail::Address, cgi-bin/ljlib.pl, cgi-bin/supportlib.pl # use strict; use lib "$ENV{LJHOME}/cgi-bin"; use Getopt::Long; use Sys::Hostname; use MIME::Parser; use Mail::Address; use Proc::ProcessTable; use Unicode::MapUTF8 (); use File::Temp (); use File::Path (); use Danga::Daemon; require "$ENV{LJHOME}/cgi-bin/ljconfig.pl"; # worker globals use vars qw/ $mailspool $mailspool_new $workdir $maxloop $hostname $locktype $opt /; $opt = {}; Getopt::Long::GetOptions $opt, qw/ workdir=s lock=s maxloop=s /; # mailspool should match the MTA delivery location. $mailspool = $LJ::MAILSPOOL || "$ENV{'LJHOME'}/mail"; $mailspool_new = "$mailspool/new"; # setup defaults $hostname = $1 if Sys::Hostname::hostname() =~ /^([\w-]+)/; $locktype = $opt->{'lock'} || $LJ::MAILLOCK; die "Invalid lock mechanism specified. Set \$LJ::MAILLOCK or use --lock.\n" unless $locktype =~ /hostname|none|ddlockd/i; $workdir = $opt->{'workdir'} || "$mailspool/tmp"; $maxloop = $opt->{'maxloop'} || 100; # sanity checks die "Invalid mailspool: $mailspool\n" unless -d $mailspool_new; die "Unable to read mailspool: $mailspool\n" unless -r $mailspool; Danga::Daemon::daemonize( \&worker, { interval => 10, shedprivs => 'lj', listenport => 15000, listencode => \&cmd_interface, } ); # main event loop for mailgated. # examine mailspool, populate queues, and call # process() as needed. sub worker { require "$ENV{'LJHOME'}/cgi-bin/ljemailgateway.pl"; require "$ENV{'LJHOME'}/cgi-bin/supportlib.pl"; require "$ENV{'LJHOME'}/cgi-bin/ljlib.pl"; require "$ENV{'LJHOME'}/cgi-bin/sysban.pl"; $| = 1; debug("Starting loop:"); cleanup(); LJ::start_request(); # Get list of files to process. # If a file simply exists in the mailspool, it needs attention. debug("\tprocess"); opendir( MDIR, $mailspool_new ) || die "Unable to open mailspool $mailspool_new: $!\n"; my @all_files = readdir(MDIR); closedir MDIR; # Separate new messages from retries. # Hostname as part of the filename is Maildir specification - # use 'hostname' locking to be safe across NFS. my ( @new_messages, @retry_messages ); foreach (@all_files) { next if /^\./; next if $locktype eq 'hostname' && !/\.$hostname\b/; if ( get_pcount($_) == 0 ) { # new message push @new_messages, $_; } else { # message retry push @retry_messages, $_; } } # Make sure at least half of our processesing # queue is made up of new messages. # Randomize, so if we're running multiple mailgated # processess, they'll be more likely to be working on # different messages. rand_array( \@retry_messages, int( $maxloop / 2 ) ); # half queue max # fill the rest of the queue with new messages. rand_array( \@new_messages, $maxloop - ( scalar @retry_messages ) ); # do the work foreach my $file ( @new_messages, @retry_messages ) { my $lock; if ( get_pcount($file) % 20 == 0 ) { # only retry every 20th iteration if ( lc($locktype) eq 'ddlockd' ) { $lock = LJ::locker()->trylock("mailgated-$file"); next unless $lock; } eval { process($file); }; if ($@) { debug("\t\t$@"); set_pcount($file); } } else { set_pcount($file); } } debug("\tdone\n"); LJ::end_request(); } # additional command line options sub cmd_interface { my ( $line, $s, $c, $codeloop, $codeopts ) = @_; if ($line =~ /help/i) { foreach (sort qw/ ping pids reload stop queuesize status /) { print $c "\t$_\n"; } print $c ".\nOK\n"; return 1; } if ($line =~ /queuesize/) { if (! opendir(MDIR, $mailspool_new)) { print $c "Unable to open mailspool $mailspool_new: $!\n"; } else { my $count = 0; foreach (readdir(MDIR)) { next if /^\./; $count++; } closedir MDIR; print $c "$count\n"; } return 1; } if ($line =~ /status/) { my $pid = $Danga::Daemon::pid; my $t = new Proc::ProcessTable; my $state; foreach my $p ( @{$t->table} ){ $state = $p->state if $p->pid == $pid; } print $c "mailgate "; print $c ($state ne 'defunct' && kill 0, $pid) ? "running" : "down"; print $c "\n"; return 1; } return; } # Remove prior run workdirs. # File::Temp's CLEANUP only works upon program exit. sub cleanup { debug("\tcleanup"); my $now = time(); unless ( opendir( TMP, $workdir ) ) { debug("\t\tCan't open workdir $workdir: $!"); return; } my $limit = 0; foreach ( readdir(TMP) ) { next unless /^ljmailgate_/; last if $limit >= 50; $limit++; my $modtime = ( stat("$workdir/$_") )[9]; if ( $now - $modtime > 300 ) { File::Path::rmtree("$workdir/$_"); debug("\t\t$workdir/$_"); } } closedir TMP; return 0; } # takes an array ref - truncates to max size and shuffles it. sub rand_array { my ( $array, $max ) = @_; my ( @tmp, $c ); while (@$array) { push( @tmp, splice( @$array, rand(@$array), 1 ) ); last if ++$c == $max; } @$array = @tmp; return; } sub set_pcount { my ( $file, $resetattempt ) = @_; my $attempt = get_pcount($file); $attempt++; $attempt = 0 if $resetattempt; my $name = $file; $name =~ s/:\d+$//; $name = $name . ":" . $attempt; rename "$mailspool_new/$file", "$mailspool_new/$name"; return 0; } # return the number of times we've seen this # message in the queue sub get_pcount { return 0 unless shift() =~ /:(\d+)$/; return $1; } # Either an unrecoverable error, or a total success. ;) # Regardless, we're done with this message. # Remove it so it isn't processed again. our $last_file; our $last_tempdir; sub dequeue { my $msg = shift; debug("\t\t dequeued: $msg") if $msg; unlink("$mailspool_new/$last_file") || debug("\t\t Can't unlink $last_file!"); File::Path::rmtree($last_tempdir); return 0; } # cleanup mime tempdirs, update attempt number, # but don't delete the message. sub retry { my $msg = shift; debug("\t\t retrying: $msg") if $msg; set_pcount($last_file); File::Path::rmtree($last_tempdir); return 0; } # examine message contents and decide what to do # with it. sub process { my $file = shift; debug("\t\t$file"); my $tmpdir = File::Temp::tempdir( "ljmailgate_" . 'X' x 20, DIR => $workdir ); my $parser = new MIME::Parser; # for dequeue sub: $last_file = $file; $last_tempdir = $tmpdir; $parser->output_dir($tmpdir); # Close the message as quickly as possible, in case # we need to change status mid process. open( MESSAGE, "$mailspool_new/$file" ) || debug("\t\t Can't open file: $!") && return; my $entity; eval { $entity = $parser->parse( \*MESSAGE ) }; close MESSAGE; return dequeue("Can't parse MIME") if $@; my $head = $entity->head; $head->unfold; my $subject = $head->get('Subject'); chomp $subject; # ignore spam/vacation/auto-reply messages if ( $subject =~ /auto.?(response|reply)/i || $subject =~ /^(Undelive|Mail System Error - |ScanMail Message: |\+\s*SPAM|Norton AntiVirus)/i || $subject =~ /^(Mail Delivery Problem|Mail delivery failed)/i || $subject =~ /^failure notice$/i || $subject =~ /\[BOUNCED SPAM\]/i || $subject =~ /^Symantec AVF /i || $subject =~ /Attachment block message/i || $subject =~ /Use this patch immediately/i || $subject =~ /^YOUR PAYPAL\.COM ACCOUNT EXPIRES/i || $subject =~ /^don\'t be late! ([\w\-]{1,15})$/i || $subject =~ /^your account ([\w\-]{1,15})$/i ) { return dequeue("Spam"); } # quick and dirty (and effective) scan for viruses return dequeue("Virus found") if virus_check($entity); # see if it's a post-by-email my @to = Mail::Address->parse( $head->get('To') ); if ( scalar @to > 0 ) { foreach my $dest ( @to ) { next unless $dest->address =~ /^(\S+?)\@\Q$LJ::EMAIL_POST_DOMAIN\E$/i; my $user = $1; # FIXME: verify auth (extra from $user/$subject/$body), require ljprotocol.pl, do post. # unresolved: where to temporarily store messages before they're approved? # perhaps the modblob table? perhaps a column it can be used to determine # whether it's a moderated community post vs. an un-acked phone post. my $post_rv; my $post_msg = LJ::Emailpost::process( $entity, $user, \$post_rv ); return $post_rv ? dequeue($post_msg) : retry($post_msg); } } # stop more spam, based on body text checks my $tent = LJ::Emailpost::get_entity($entity); $tent = LJ::Emailpost::get_entity( $entity, 'html' ) unless $tent; return dequeue("Can't find text or html entity") unless $tent; my $body = $tent->bodyhandle->as_string; $body = LJ::trim($body); ### spam if ( $body =~ /I send you this file in order to have your advice/i || $body =~ /^Content-Type: application\/octet-stream/i || $body =~ /^(Please see|See) the attached file for details\.?$/i ) { return dequeue("Spam"); } # From this point on we know it's a support request of some type, my $email2cat = LJ::Support::load_email_to_cat_map(); my $to; my $toarg; my $ignore = 0; foreach my $a ( @to, Mail::Address->parse( $head->get('Cc') ) ) { my $address = $a->address; my $arg; if ( $address =~ /^(.+)\+(.*)\@(.+)$/ ) { ( $address, $arg ) = ( "$1\@$3", $2 ); } if ( defined $LJ::ALIAS_TO_SUPPORTCAT{$address} ) { $address = $LJ::ALIAS_TO_SUPPORTCAT{$address}; } if ( defined $email2cat->{$address} ) { $to = $address; $toarg = $arg; } $ignore = 1 if $address eq $LJ::IGNORE_EMAIL; $ignore = 1 if $address eq $LJ::BOGUS_EMAIL; } return dequeue("Not deliverable to support system (no match To:)") unless $to; my $adf = ( Mail::Address->parse( $head->get('From') ) )[0]; return dequeue("Bogus From: header") unless $adf; my $name = $adf->name; my $from = $adf->address; $subject ||= "(No Subject)"; # is this a reply to another post? if ( $toarg =~ /^(\d+)z(.+)$/ ) { my $spid = $1; my $miniauth = $2; my $sp = LJ::Support::load_request($spid); LJ::Support::mini_auth($sp) eq $miniauth or die "Invalid authentication?"; if ( LJ::sysban_check( 'support_email', $from ) ) { my $msg = "Support request blocked based on email."; LJ::sysban_block( 0, $msg, { 'email' => $from } ); return dequeue($msg); } # make sure it's not locked return dequeue("Request is locked, can't append comment.") if LJ::Support::is_locked($sp); # valid. need to strip out stuff now with authcodes: $body =~ s!http://.+/support/act\.bml\S+![snipped]!g; $body =~ s!\+(\d)+z\w{1,10}\@!\@!g; $body =~ s!&auth=\S+!!g; ## try to get rid of reply stuff. # Outlook Express: $body =~ s!(\S+.*?)-{4,10} Original Message -{4,10}.+!$1!s; # Pine/Netscape $body =~ s!(\S+.*?)\bOn [^\n]+ wrote:\n.+!$1!s; # append the comment, re-open the request if necessary my $splid = LJ::Support::append_request( $sp, { 'type' => 'comment', 'body' => $body, } ) or return dequeue("Error appending request?"); LJ::Support::touch_request($spid); return dequeue("Support reply success"); } # Now see if we want to ignore this particular email and bounce it back with # the contents from a file. Check $LJ::DENY_REQUEST_FROM_EMAIL first. Note # that this will only bounce initial emails; if a user replies to an email # from a request that's open, it'll be accepted above. my ( $content_file, $content ); if ( %LJ::DENY_REQUEST_FROM_EMAIL && $LJ::DENY_REQUEST_FROM_EMAIL{$to} ) { $content_file = $LJ::DENY_REQUEST_FROM_EMAIL{$to}; $content = LJ::load_include($content_file); } if ( $content_file && $content ) { # construct mail to send to user my $email = < $from, 'from' => $LJ::BOGUS_EMAIL, 'subject' => "Your Email to $to", 'body' => $email, 'wrap' => 1, } ); # all done return dequeue("Support request bounced to origin"); } # make a new post. my @errors; # convert email body to utf-8 my $content_type = $head->get('Content-type:'); my $charset = $1 if $content_type =~ /\bcharset=[\'\"]?(\S+?)[\'\"]?[\s\;]/i; if ( defined($charset) && $charset !~ /^UTF-?8$/i && Unicode::MapUTF8::utf8_supported_charset($charset) ) { $body = Unicode::MapUTF8::to_utf8( { -string => $body, -charset => $charset } ); } my $spid = LJ::Support::file_request( \@errors, { 'spcatid' => $email2cat->{$to}->{'spcatid'}, 'subject' => $subject, 'reqtype' => 'email', 'reqname' => $name, 'reqemail' => $from, 'body' => $body, } ); if (@errors) { # FIXME: detect trasient vs. permanent errors (changes to # file_request above, probably) and either dequeue or try # later return dequeue("Support errors: @errors"); } else { return dequeue("Support request success"); } } # returns true on found virus sub virus_check { my $entity = shift; return unless $entity; my @exe = LJ::Emailpost::get_entity( $entity, 'all' ); return unless scalar @exe; # If an attachment's encoding begins with one of these strings, # we want to completely drop the message. # (Other 'clean' attachments are silently ignored, and the # message is allowed.) my @virus_sigs = qw( TVqQAAMAA TVpQAAIAA TVpAALQAc TVpyAXkAX TVrmAU4AA TVrhARwAk TVoFAQUAA TVoAAAQAA TVoIARMAA TVouARsAA TVrQAT8AA UEsDBBQAA UEsDBAoAAA R0lGODlhaAA7APcAAP///+rp6puSp6GZrDUjUUc6Zn53mFJMdbGvvVtXh2xre8bF1x8cU4yLprOy ); # get the length of the longest virus signature my $maxlength = length( ( sort { length $b <=> length $a } @virus_sigs )[0] ); $maxlength = 1024 if $maxlength >= 1024; # capped at 1k foreach my $part (@exe) { my $contents = $part->stringify_body; $contents = substr $contents, 0, $maxlength; foreach (@virus_sigs) { return 1 if index( $contents, $_ ) == 0; } } return; }