X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=rt%2Flib%2FRT%2FInterface%2FEmail.pm;h=74120ba07bf203ebed13439a964921b59f2df005;hp=de125f560f2100ca6b42909c9b6f2b83adf21a8f;hb=e9e0cf0989259b94d9758eceff448666a2e5a5cc;hpb=9c68254528b6f2c7d8c1921b452fa56064783782 diff --git a/rt/lib/RT/Interface/Email.pm b/rt/lib/RT/Interface/Email.pm index de125f560..74120ba07 100755 --- a/rt/lib/RT/Interface/Email.pm +++ b/rt/lib/RT/Interface/Email.pm @@ -2,8 +2,8 @@ # # COPYRIGHT: # -# This software is Copyright (c) 1996-2005 Best Practical Solutions, LLC -# +# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC +# # # (Except where explicitly superseded by other copyright notices) # @@ -22,7 +22,9 @@ # # 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., 675 Mass Ave, Cambridge, MA 02139, USA. +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301 or visit their web page on the internet at +# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html. # # # CONTRIBUTION SUBMISSION POLICY: @@ -43,24 +45,27 @@ # those contributions and any derivatives thereof. # # END BPS TAGGED BLOCK }}} + package RT::Interface::Email; use strict; -use Mail::Address; +use warnings; + +use Email::Address; use MIME::Entity; use RT::EmailParser; use File::Temp; use UNIVERSAL::require; +use Mail::Mailer (); +use Text::ParseWords qw/shellwords/; BEGIN { - use Exporter (); - use vars qw ( @ISA @EXPORT_OK); + use base 'Exporter'; + use vars qw ( @EXPORT_OK); # set the version for version checking our $VERSION = 2.0; - @ISA = qw(Exporter); - # your exported package globals go here, # as well as any optionally exported functions @EXPORT_OK = qw( @@ -93,37 +98,41 @@ BEGIN { =head1 DESCRIPTION -=begin testing -ok(require RT::Interface::Email); -=end testing +=head1 METHODS +=head2 CheckForLoops HEAD -=head1 METHODS +Takes a HEAD object of L class and returns true if the +message's been sent by this RT instance. Uses "X-RT-Loop-Prevention" +field of the head for test. =cut -# {{{ sub CheckForLoops - sub CheckForLoops { my $head = shift; - #If this instance of RT sent it our, we don't want to take it in + # If this instance of RT sent it our, we don't want to take it in my $RTLoop = $head->get("X-RT-Loop-Prevention") || ""; - chomp($RTLoop); #remove that newline - if ( $RTLoop eq "$RT::rtname" ) { - return (1); + chomp ($RTLoop); # remove that newline + if ( $RTLoop eq RT->Config->Get('rtname') ) { + return 1; } # TODO: We might not trap the case where RT instance A sends a mail # to RT instance B which sends a mail to ... - return (undef); + return undef; } -# }}} +=head2 CheckForSuspiciousSender HEAD + +Takes a HEAD object of L class and returns true if sender +is suspicious. Suspicious means mailer daemon. -# {{{ sub CheckForSuspiciousSender +See also L. + +=cut sub CheckForSuspiciousSender { my $head = shift; @@ -140,20 +149,28 @@ sub CheckForSuspiciousSender { my ( $From, $junk ) = ParseSenderAddressFromHead($head); + # If unparseable (non-ASCII), $From can come back undef + return undef if not defined $From; + if ( ( $From =~ /^mailer-daemon\@/i ) - or ( $From =~ /^postmaster\@/i ) ) + or ( $From =~ /^postmaster\@/i ) + or ( $From eq "" )) { return (1); } - return (undef); - + return undef; } -# }}} +=head2 CheckForAutoGenerated HEAD + +Takes a HEAD object of L class and returns true if message +is autogenerated. Checks 'Precedence' and 'X-FC-Machinegenerated' +fields of the head in tests. + +=cut -# {{{ sub CheckForAutoGenerated sub CheckForAutoGenerated { my $head = shift; @@ -162,6 +179,13 @@ sub CheckForAutoGenerated { return (1); } + # Per RFC3834, any Auto-Submitted header which is not "no" means + # it is auto-generated. + my $AutoSubmitted = $head->get("Auto-Submitted") || ""; + if ( length $AutoSubmitted and $AutoSubmitted ne "no" ) { + return (1); + } + # First Class mailer uses this as a clue. my $FCJunk = $head->get("X-FC-Machinegenerated") || ""; if ( $FCJunk =~ /^true/i ) { @@ -171,9 +195,7 @@ sub CheckForAutoGenerated { return (0); } -# }}} -# {{{ sub CheckForBounce sub CheckForBounce { my $head = shift; @@ -181,55 +203,40 @@ sub CheckForBounce { return ( $ReturnPath =~ /<>/ ); } -# }}} -# {{{ IsRTAddress +=head2 MailError PARAM HASH -=head2 IsRTAddress ADDRESS +Sends an error message. Takes a param hash: -Takes a single parameter, an email address. -Returns true if that address matches the $RTAddressRegexp. -Returns false, otherwise. +=over 4 -=cut +=item From - sender's address, by default is 'CorrespondAddress'; -sub IsRTAddress { - my $address = shift || ''; +=item To - recipient, by default is 'OwnerEmail'; - # Example: the following rule would tell RT not to Cc - # "tickets@noc.example.com" - if ( defined($RT::RTAddressRegexp) - && $address =~ /$RT::RTAddressRegexp/i ) - { - return (1); - } else { - return (undef); - } -} +=item Bcc - optional Bcc recipients; -# }}} +=item Subject - subject of the message, default is 'There has been an error'; -# {{{ CullRTAddresses +=item Explanation - main content of the error, default value is 'Unexplained error'; -=head2 CullRTAddresses ARRAY +=item MIMEObj - optional MIME entity that's attached to the error mail, as well we +add 'In-Reply-To' field to the error that points to this message. -Takes a single argument, an array of email addresses. -Returns the same array with any IsRTAddress()es weeded out. +=item Attach - optional text that attached to the error as 'message/rfc822' part. -=cut +=item LogLevel - log level under which we should write the subject and +explanation message into the log, by default we log it as critical. -sub CullRTAddresses { - return ( grep { IsRTAddress($_) } @_ ); -} +=back -# }}} +=cut -# {{{ sub MailError sub MailError { my %args = ( - To => $RT::OwnerEmail, + To => RT->Config->Get('OwnerEmail'), Bcc => undef, - From => $RT::CorrespondAddress, + From => RT->Config->Get('CorrespondAddress'), Subject => 'There has been an error', Explanation => 'Unexplained error', MIMEObj => undef, @@ -240,24 +247,32 @@ sub MailError { $RT::Logger->log( level => $args{'LogLevel'}, - message => $args{'Explanation'} - ); - my $entity = MIME::Entity->build( - Type => "multipart/mixed", - From => $args{'From'}, - Bcc => $args{'Bcc'}, - To => $args{'To'}, - Subject => $args{'Subject'}, - Precedence => 'bulk', - 'X-RT-Loop-Prevention' => $RT::rtname, + message => "$args{Subject}: $args{'Explanation'}", + ) if $args{'LogLevel'}; + + # the colons are necessary to make ->build include non-standard headers + my %entity_args = ( + Type => "multipart/mixed", + From => $args{'From'}, + Bcc => $args{'Bcc'}, + To => $args{'To'}, + Subject => $args{'Subject'}, + 'X-RT-Loop-Prevention:' => RT->Config->Get('rtname'), ); + # only set precedence if the sysadmin wants us to + if (defined(RT->Config->Get('DefaultErrorMailPrecedence'))) { + $entity_args{'Precedence:'} = RT->Config->Get('DefaultErrorMailPrecedence'); + } + + my $entity = MIME::Entity->build(%entity_args); + SetInReplyTo( Message => $entity, InReplyTo => $args{'MIMEObj'} ); + $entity->attach( Data => $args{'Explanation'} . "\n" ); - my $mimeobj = $args{'MIMEObj'}; - if ($mimeobj) { - $mimeobj->sync_headers(); - $entity->add_part($mimeobj); + if ( $args{'MIMEObj'} ) { + $args{'MIMEObj'}->sync_headers; + $entity->add_part( $args{'MIMEObj'} ); } if ( $args{'Attach'} ) { @@ -265,25 +280,732 @@ sub MailError { } - if ( $RT::MailCommand eq 'sendmailpipe' ) { - open( MAIL, - "|$RT::SendmailPath $RT::SendmailBounceArguments $RT::SendmailArguments" - ) - || return (0); - print MAIL $entity->as_string; - close(MAIL); + SendEmail( Entity => $entity, Bounce => 1 ); +} + + +=head2 SendEmail Entity => undef, [ Bounce => 0, Ticket => undef, Transaction => undef ] + +Sends an email (passed as a L object C) using +RT's outgoing mail configuration. If C is passed, and is a +true value, the message will be marked as an autogenerated error, if +possible. Sets Date field of the head to now if it's not set. + +If the C header is set to any true value, the mail will +not be sent. One use is to let extensions easily cancel outgoing mail. + +Ticket and Transaction arguments are optional. If Transaction is +specified and Ticket is not then ticket of the transaction is +used, but only if the transaction belongs to a ticket. + +Returns 1 on success, 0 on error or -1 if message has no recipients +and hasn't been sent. + +=head3 Signing and Encrypting + +This function as well signs and/or encrypts the message according to +headers of a transaction's attachment or properties of a ticket's queue. +To get full access to the configuration Ticket and/or Transaction +arguments must be provided, but you can force behaviour using Sign +and/or Encrypt arguments. + +The following precedence of arguments are used to figure out if +the message should be encrypted and/or signed: + +* if Sign or Encrypt argument is defined then its value is used + +* else if Transaction's first attachment has X-RT-Sign or X-RT-Encrypt +header field then it's value is used + +* else properties of a queue of the Ticket are used. + +=cut + +sub WillSignEncrypt { + my %args = @_; + my $attachment = delete $args{Attachment}; + my $ticket = delete $args{Ticket}; + + if ( not RT->Config->Get('GnuPG')->{'Enable'} ) { + $args{Sign} = $args{Encrypt} = 0; + return wantarray ? %args : 0; + } + + for my $argument ( qw(Sign Encrypt) ) { + next if defined $args{ $argument }; + + if ( $attachment and defined $attachment->GetHeader("X-RT-$argument") ) { + $args{$argument} = $attachment->GetHeader("X-RT-$argument"); + } elsif ( $ticket and $argument eq "Encrypt" ) { + $args{Encrypt} = $ticket->QueueObj->Encrypt(); + } elsif ( $ticket and $argument eq "Sign" ) { + # Note that $queue->Sign is UI-only, and that all + # UI-generated messages explicitly set the X-RT-Crypt header + # to 0 or 1; thus this path is only taken for messages + # generated _not_ via the web UI. + $args{Sign} = $ticket->QueueObj->SignAuto(); + } + } + + return wantarray ? %args : ($args{Sign} || $args{Encrypt}); +} + +sub SendEmail { + my (%args) = ( + Entity => undef, + Bounce => 0, + Ticket => undef, + Transaction => undef, + @_, + ); + + my $TicketObj = $args{'Ticket'}; + my $TransactionObj = $args{'Transaction'}; + + foreach my $arg( qw(Entity Bounce) ) { + next unless defined $args{ lc $arg }; + + $RT::Logger->warning("'". lc($arg) ."' argument is deprecated, use '$arg' instead"); + $args{ $arg } = delete $args{ lc $arg }; + } + + unless ( $args{'Entity'} ) { + $RT::Logger->crit( "Could not send mail without 'Entity' object" ); + return 0; + } + + my $msgid = $args{'Entity'}->head->get('Message-ID') || ''; + chomp $msgid; + + # If we don't have any recipients to send to, don't send a message; + unless ( $args{'Entity'}->head->get('To') + || $args{'Entity'}->head->get('Cc') + || $args{'Entity'}->head->get('Bcc') ) + { + $RT::Logger->info( $msgid . " No recipients found. Not sending." ); + return -1; + } + + if ($args{'Entity'}->head->get('X-RT-Squelch')) { + $RT::Logger->info( $msgid . " Squelch header found. Not sending." ); + return -1; + } + + if ( $TransactionObj && !$TicketObj + && $TransactionObj->ObjectType eq 'RT::Ticket' ) + { + $TicketObj = $TransactionObj->Object; + } + + if ( RT->Config->Get('GnuPG')->{'Enable'} ) { + %args = WillSignEncrypt( + %args, + Attachment => $TransactionObj ? $TransactionObj->Attachments->First : undef, + Ticket => $TicketObj, + ); + my $res = SignEncrypt( %args ); + return $res unless $res > 0; + } + + unless ( $args{'Entity'}->head->get('Date') ) { + require RT::Date; + my $date = RT::Date->new( RT->SystemUser ); + $date->SetToNow; + $args{'Entity'}->head->set( 'Date', $date->RFC2822( Timezone => 'server' ) ); + } + + my $mail_command = RT->Config->Get('MailCommand'); + + if ($mail_command eq 'testfile' and not $Mail::Mailer::testfile::config{outfile}) { + $Mail::Mailer::testfile::config{outfile} = File::Temp->new; + $RT::Logger->info("Storing outgoing emails in $Mail::Mailer::testfile::config{outfile}"); + } + + # if it is a sub routine, we just return it; + return $mail_command->($args{'Entity'}) if UNIVERSAL::isa( $mail_command, 'CODE' ); + + if ( $mail_command eq 'sendmailpipe' ) { + my $path = RT->Config->Get('SendmailPath'); + my @args = shellwords(RT->Config->Get('SendmailArguments')); + + # SetOutgoingMailFrom and bounces conflict, since they both want -f + if ( $args{'Bounce'} ) { + push @args, shellwords(RT->Config->Get('SendmailBounceArguments')); + } elsif ( my $MailFrom = RT->Config->Get('SetOutgoingMailFrom') ) { + my $OutgoingMailAddress = $MailFrom =~ /\@/ ? $MailFrom : undef; + my $Overrides = RT->Config->Get('OverrideOutgoingMailFrom') || {}; + + if ($TicketObj) { + my $QueueName = $TicketObj->QueueObj->Name; + my $QueueAddressOverride = $Overrides->{$QueueName}; + + if ($QueueAddressOverride) { + $OutgoingMailAddress = $QueueAddressOverride; + } else { + $OutgoingMailAddress ||= $TicketObj->QueueObj->CorrespondAddress + || RT->Config->Get('CorrespondAddress'); + } + } + elsif ($Overrides->{'Default'}) { + $OutgoingMailAddress = $Overrides->{'Default'}; + } + + push @args, "-f", $OutgoingMailAddress + if $OutgoingMailAddress; + } + + # VERP + if ( $TransactionObj and + my $prefix = RT->Config->Get('VERPPrefix') and + my $domain = RT->Config->Get('VERPDomain') ) + { + my $from = $TransactionObj->CreatorObj->EmailAddress; + $from =~ s/@/=/g; + $from =~ s/\s//g; + push @args, "-f", "$prefix$from\@$domain"; + } + + eval { + # don't ignore CHLD signal to get proper exit code + local $SIG{'CHLD'} = 'DEFAULT'; + + # if something wrong with $mail->print we will get PIPE signal, handle it + local $SIG{'PIPE'} = sub { die "program unexpectedly closed pipe" }; + + require IPC::Open2; + my ($mail, $stdout); + my $pid = IPC::Open2::open2( $stdout, $mail, $path, @args ) + or die "couldn't execute program: $!"; + + $args{'Entity'}->print($mail); + close $mail or die "close pipe failed: $!"; + + waitpid($pid, 0); + if ($?) { + # sendmail exit statuses mostly errors with data not software + # TODO: status parsing: core dump, exit on signal or EX_* + my $msg = "$msgid: `$path @args` exited with code ". ($?>>8); + $msg = ", interrupted by signal ". ($?&127) if $?&127; + $RT::Logger->error( $msg ); + die $msg; + } + }; + if ( $@ ) { + $RT::Logger->crit( "$msgid: Could not send mail with command `$path @args`: " . $@ ); + if ( $TicketObj ) { + _RecordSendEmailFailure( $TicketObj ); + } + return 0; + } + } + elsif ( $mail_command eq 'smtp' ) { + require Net::SMTP; + my $smtp = do { local $@; eval { Net::SMTP->new( + Host => RT->Config->Get('SMTPServer'), + Debug => RT->Config->Get('SMTPDebug'), + ) } }; + unless ( $smtp ) { + $RT::Logger->crit( "Could not connect to SMTP server."); + if ($TicketObj) { + _RecordSendEmailFailure( $TicketObj ); + } + return 0; + } + + # duplicate head as we want drop Bcc field + my $head = $args{'Entity'}->head->dup; + my @recipients = map $_->address, map + Email::Address->parse($head->get($_)), qw(To Cc Bcc); + $head->delete('Bcc'); + + my $sender = RT->Config->Get('SMTPFrom') + || $args{'Entity'}->head->get('From'); + chomp $sender; + + my $status = $smtp->mail( $sender ) + && $smtp->recipient( @recipients ); + + if ( $status ) { + $smtp->data; + my $fh = $smtp->tied_fh; + $head->print( $fh ); + print $fh "\n"; + $args{'Entity'}->print_body( $fh ); + $smtp->dataend; + } + $smtp->quit; + + unless ( $status ) { + $RT::Logger->crit( "$msgid: Could not send mail via SMTP." ); + if ( $TicketObj ) { + _RecordSendEmailFailure( $TicketObj ); + } + return 0; + } + } + else { + local ($ENV{'MAILADDRESS'}, $ENV{'PERL_MAILERS'}); + + my @mailer_args = ($mail_command); + if ( $mail_command eq 'sendmail' ) { + $ENV{'PERL_MAILERS'} = RT->Config->Get('SendmailPath'); + push @mailer_args, split(/\s+/, RT->Config->Get('SendmailArguments')); + } + else { + push @mailer_args, RT->Config->Get('MailParams'); + } + + unless ( $args{'Entity'}->send( @mailer_args ) ) { + $RT::Logger->crit( "$msgid: Could not send mail." ); + if ( $TicketObj ) { + _RecordSendEmailFailure( $TicketObj ); + } + return 0; + } + } + return 1; +} + +=head2 PrepareEmailUsingTemplate Template => '', Arguments => {} + +Loads a template. Parses it using arguments if it's not empty. +Returns a tuple (L object, error message). + +Note that even if a template object is returned MIMEObj method +may return undef for empty templates. + +=cut + +sub PrepareEmailUsingTemplate { + my %args = ( + Template => '', + Arguments => {}, + @_ + ); + + my $template = RT::Template->new( RT->SystemUser ); + $template->LoadGlobalTemplate( $args{'Template'} ); + unless ( $template->id ) { + return (undef, "Couldn't load template '". $args{'Template'} ."'"); + } + return $template if $template->IsEmpty; + + my ($status, $msg) = $template->Parse( %{ $args{'Arguments'} } ); + return (undef, $msg) unless $status; + + return $template; +} + +=head2 SendEmailUsingTemplate Template => '', Arguments => {}, From => CorrespondAddress, To => '', Cc => '', Bcc => '' + +Sends email using a template, takes name of template, arguments for it and recipients. + +=cut + +sub SendEmailUsingTemplate { + my %args = ( + Template => '', + Arguments => {}, + To => undef, + Cc => undef, + Bcc => undef, + From => RT->Config->Get('CorrespondAddress'), + InReplyTo => undef, + ExtraHeaders => {}, + @_ + ); + + my ($template, $msg) = PrepareEmailUsingTemplate( %args ); + return (0, $msg) unless $template; + + my $mail = $template->MIMEObj; + unless ( $mail ) { + $RT::Logger->info("Message is not sent as template #". $template->id ." is empty"); + return -1; + } + + $mail->head->set( $_ => Encode::encode_utf8( $args{ $_ } ) ) + foreach grep defined $args{$_}, qw(To Cc Bcc From); + + $mail->head->set( $_ => $args{ExtraHeaders}{$_} ) + foreach keys %{ $args{ExtraHeaders} }; + + SetInReplyTo( Message => $mail, InReplyTo => $args{'InReplyTo'} ); + + return SendEmail( Entity => $mail ); +} + +=head2 ForwardTransaction TRANSACTION, To => '', Cc => '', Bcc => '' + +Forwards transaction with all attachments as 'message/rfc822'. + +=cut + +sub ForwardTransaction { + my $txn = shift; + my %args = ( To => '', Cc => '', Bcc => '', @_ ); + + my $entity = $txn->ContentAsMIME; + + my ( $ret, $msg ) = SendForward( %args, Entity => $entity, Transaction => $txn ); + if ($ret) { + my $ticket = $txn->TicketObj; + my ( $ret, $msg ) = $ticket->_NewTransaction( + Type => 'Forward Transaction', + Field => $txn->id, + Data => join ', ', grep { length } $args{To}, $args{Cc}, $args{Bcc}, + ); + unless ($ret) { + $RT::Logger->error("Failed to create transaction: $msg"); + } + } + return ( $ret, $msg ); +} + +=head2 ForwardTicket TICKET, To => '', Cc => '', Bcc => '' + +Forwards a ticket's Create and Correspond Transactions and their Attachments as 'message/rfc822'. + +=cut + +sub ForwardTicket { + my $ticket = shift; + my %args = ( To => '', Cc => '', Bcc => '', @_ ); + + my $txns = $ticket->Transactions; + $txns->Limit( + FIELD => 'Type', + VALUE => $_, + ) for qw(Create Correspond); + + my $entity = MIME::Entity->build( + Type => 'multipart/mixed', + Description => 'forwarded ticket', + ); + $entity->add_part( $_ ) foreach + map $_->ContentAsMIME, + @{ $txns->ItemsArrayRef }; + + my ( $ret, $msg ) = SendForward( + %args, + Entity => $entity, + Ticket => $ticket, + Template => 'Forward Ticket', + ); + + if ($ret) { + my ( $ret, $msg ) = $ticket->_NewTransaction( + Type => 'Forward Ticket', + Field => $ticket->id, + Data => join ', ', grep { length } $args{To}, $args{Cc}, $args{Bcc}, + ); + unless ($ret) { + $RT::Logger->error("Failed to create transaction: $msg"); + } + } + + return ( $ret, $msg ); + +} + +=head2 SendForward Entity => undef, Ticket => undef, Transaction => undef, Template => undef, To => '', Cc => '', Bcc => '' + +Forwards an Entity representing Ticket or Transaction as 'message/rfc822'. Entity is wrapped into Template. + +=cut + +sub SendForward { + my (%args) = ( + Entity => undef, + Ticket => undef, + Transaction => undef, + Template => 'Forward', + To => '', Cc => '', Bcc => '', + @_ + ); + + my $txn = $args{'Transaction'}; + my $ticket = $args{'Ticket'}; + $ticket ||= $txn->Object if $txn; + + my $entity = $args{'Entity'}; + unless ( $entity ) { + require Carp; + $RT::Logger->error(Carp::longmess("No entity provided")); + return (0, $ticket->loc("Couldn't send email")); + } + + my ($template, $msg) = PrepareEmailUsingTemplate( + Template => $args{'Template'}, + Arguments => { + Ticket => $ticket, + Transaction => $txn, + }, + ); + + my $mail; + if ( $template ) { + $mail = $template->MIMEObj; } else { - $entity->send( $RT::MailCommand, $RT::MailParams ); + $RT::Logger->warning($msg); + } + unless ( $mail ) { + $RT::Logger->warning("Couldn't generate email using template '$args{Template}'"); + + my $description; + unless ( $args{'Transaction'} ) { + $description = 'This is forward of ticket #'. $ticket->id; + } else { + $description = 'This is forward of transaction #' + . $txn->id ." of a ticket #". $txn->ObjectId; + } + $mail = MIME::Entity->build( + Type => 'text/plain', + Data => $description, + ); + } + + $mail->head->set( $_ => EncodeToMIME( String => $args{$_} ) ) + foreach grep defined $args{$_}, qw(To Cc Bcc); + + $mail->make_multipart unless $mail->is_multipart; + $mail->add_part( $entity ); + + my $from; + unless (defined $mail->head->get('Subject')) { + my $subject = ''; + $subject = $txn->Subject if $txn; + $subject ||= $ticket->Subject if $ticket; + + unless ( RT->Config->Get('ForwardFromUser') ) { + # XXX: what if want to forward txn of other object than ticket? + $subject = AddSubjectTag( $subject, $ticket ); + } + + $mail->head->set( Subject => EncodeToMIME( String => "Fwd: $subject" ) ); + } + + $mail->head->set( + From => EncodeToMIME( + String => GetForwardFrom( Transaction => $txn, Ticket => $ticket ) + ) + ); + + my $status = RT->Config->Get('ForwardFromUser') + # never sign if we forward from User + ? SendEmail( %args, Entity => $mail, Sign => 0 ) + : SendEmail( %args, Entity => $mail ); + return (0, $ticket->loc("Couldn't send email")) unless $status; + return (1, $ticket->loc("Sent email successfully")); +} + +=head2 GetForwardFrom Ticket => undef, Transaction => undef + +Resolve the From field to use in forward mail + +=cut + +sub GetForwardFrom { + my %args = ( Ticket => undef, Transaction => undef, @_ ); + my $txn = $args{Transaction}; + my $ticket = $args{Ticket} || $txn->Object; + + if ( RT->Config->Get('ForwardFromUser') ) { + return ( $txn || $ticket )->CurrentUser->EmailAddress; + } + else { + return $ticket->QueueObj->CorrespondAddress + || RT->Config->Get('CorrespondAddress'); } } -# }}} +=head2 SignEncrypt Entity => undef, Sign => 0, Encrypt => 0 + +Signs and encrypts message using L, but as well +handle errors with users' keys. + +If a recipient has no key or has other problems with it, then the +unction sends a error to him using 'Error: public key' template. +Also, notifies RT's owner using template 'Error to RT owner: public key' +to inform that there are problems with users' keys. Then we filter +all bad recipients and retry. + +Returns 1 on success, 0 on error and -1 if all recipients are bad and +had been filtered out. + +=cut + +sub SignEncrypt { + my %args = ( + Entity => undef, + Sign => 0, + Encrypt => 0, + @_ + ); + return 1 unless $args{'Sign'} || $args{'Encrypt'}; + + my $msgid = $args{'Entity'}->head->get('Message-ID') || ''; + chomp $msgid; + + $RT::Logger->debug("$msgid Signing message") if $args{'Sign'}; + $RT::Logger->debug("$msgid Encrypting message") if $args{'Encrypt'}; + + require RT::Crypt::GnuPG; + my %res = RT::Crypt::GnuPG::SignEncrypt( %args ); + return 1 unless $res{'exit_code'}; + + my @status = RT::Crypt::GnuPG::ParseStatus( $res{'status'} ); + + my @bad_recipients; + foreach my $line ( @status ) { + # if the passphrase fails, either you have a bad passphrase + # or gpg-agent has died. That should get caught in Create and + # Update, but at least throw an error here + if (($line->{'Operation'}||'') eq 'PassphraseCheck' + && $line->{'Status'} =~ /^(?:BAD|MISSING)$/ ) { + $RT::Logger->error( "$line->{'Status'} PASSPHRASE: $line->{'Message'}" ); + return 0; + } + next unless ($line->{'Operation'}||'') eq 'RecipientsCheck'; + next if $line->{'Status'} eq 'DONE'; + $RT::Logger->error( $line->{'Message'} ); + push @bad_recipients, $line; + } + return 0 unless @bad_recipients; + + $_->{'AddressObj'} = (Email::Address->parse( $_->{'Recipient'} ))[0] + foreach @bad_recipients; + + foreach my $recipient ( @bad_recipients ) { + my $status = SendEmailUsingTemplate( + To => $recipient->{'AddressObj'}->address, + Template => 'Error: public key', + Arguments => { + %$recipient, + TicketObj => $args{'Ticket'}, + TransactionObj => $args{'Transaction'}, + }, + ); + unless ( $status ) { + $RT::Logger->error("Couldn't send 'Error: public key'"); + } + } + + my $status = SendEmailUsingTemplate( + To => RT->Config->Get('OwnerEmail'), + Template => 'Error to RT owner: public key', + Arguments => { + BadRecipients => \@bad_recipients, + TicketObj => $args{'Ticket'}, + TransactionObj => $args{'Transaction'}, + }, + ); + unless ( $status ) { + $RT::Logger->error("Couldn't send 'Error to RT owner: public key'"); + } + + DeleteRecipientsFromHead( + $args{'Entity'}->head, + map $_->{'AddressObj'}->address, @bad_recipients + ); + + unless ( $args{'Entity'}->head->get('To') + || $args{'Entity'}->head->get('Cc') + || $args{'Entity'}->head->get('Bcc') ) + { + $RT::Logger->debug("$msgid No recipients that have public key, not sending"); + return -1; + } + + # redo without broken recipients + %res = RT::Crypt::GnuPG::SignEncrypt( %args ); + return 0 if $res{'exit_code'}; + + return 1; +} + +use MIME::Words (); + +=head2 EncodeToMIME + +Takes a hash with a String and a Charset. Returns the string encoded +according to RFC2047, using B (base64 based) encoding. -# {{{ Create User +String must be a perl string, octets are returned. + +If Charset is not provided then $EmailOutputEncoding config option +is used, or "latin-1" if that is not set. + +=cut + +sub EncodeToMIME { + my %args = ( + String => undef, + Charset => undef, + @_ + ); + my $value = $args{'String'}; + return $value unless $value; # 0 is perfect ascii + my $charset = $args{'Charset'} || RT->Config->Get('EmailOutputEncoding'); + my $encoding = 'B'; + + # using RFC2047 notation, sec 2. + # encoded-word = "=?" charset "?" encoding "?" encoded-text "?=" + + # An 'encoded-word' may not be more than 75 characters long + # + # MIME encoding increases 4/3*(number of bytes), and always in multiples + # of 4. Thus we have to find the best available value of bytes available + # for each chunk. + # + # First we get the integer max which max*4/3 would fit on space. + # Then we find the greater multiple of 3 lower or equal than $max. + my $max = int( + ( ( 75 - length( '=?' . $charset . '?' . $encoding . '?' . '?=' ) ) + * 3 + ) / 4 + ); + $max = int( $max / 3 ) * 3; + + chomp $value; + + if ( $max <= 0 ) { + + # gives an error... + $RT::Logger->crit("Can't encode! Charset or encoding too big."); + return ($value); + } + + return ($value) if $value =~ /^(?:[\t\x20-\x7e]|\x0D*\x0A[ \t])+$/s; + + $value =~ s/\s+$//; + + # we need perl string to split thing char by char + Encode::_utf8_on($value) unless Encode::is_utf8($value); + + my ( $tmp, @chunks ) = ( '', () ); + while ( length $value ) { + my $char = substr( $value, 0, 1, '' ); + my $octets = Encode::encode( $charset, $char ); + if ( length($tmp) + length($octets) > $max ) { + push @chunks, $tmp; + $tmp = ''; + } + $tmp .= $octets; + } + push @chunks, $tmp if length $tmp; + + # encode an join chuncks + $value = join "\n ", + map MIME::Words::encode_mimeword( $_, $encoding, $charset ), + @chunks; + return ($value); +} sub CreateUser { my ( $Username, $Address, $Name, $ErrorsTo, $entity ) = @_; - my $NewUser = RT::User->new($RT::SystemUser); + + my $NewUser = RT::User->new( RT->SystemUser ); my ( $Val, $Message ) = $NewUser->Create( Name => ( $Username || $Address ), @@ -291,13 +1013,12 @@ sub CreateUser { RealName => $Name, Password => undef, Privileged => 0, - Comments => 'Autocreated on ticket submission' + Comments => 'Autocreated on ticket submission', ); unless ($Val) { # Deal with the race condition of two account creations at once - # if ($Username) { $NewUser->LoadByName($Username); } @@ -313,14 +1034,14 @@ sub CreateUser { Explanation => "User creation failed in mailgateway: $Message", MIMEObj => $entity, - LogLevel => 'crit' + LogLevel => 'crit', ); } } #Load the new user object - my $CurrentUser = RT::CurrentUser->new(); - $CurrentUser->LoadByEmail($Address); + my $CurrentUser = RT::CurrentUser->new; + $CurrentUser->LoadByEmail( $Address ); unless ( $CurrentUser->id ) { $RT::Logger->warning( @@ -338,15 +1059,13 @@ sub CreateUser { return $CurrentUser; } -# }}} -# {{{ ParseCcAddressesFromHead -=head2 ParseCcAddressesFromHead HASHREF +=head2 ParseCcAddressesFromHead HASH -Takes a hashref object containing QueueObj, Head and CurrentUser objects. -Returns a list of all email addresses in the To and Cc -headers b the current Queue\'s email addresses, the CurrentUser\'s +Takes a hash containing QueueObj, Head and CurrentUser objects. +Returns a list of all email addresses in the To and Cc +headers b the current Queue's email addresses, the CurrentUser's email address and anything that the configuration sub RT::IsRTAddress matches. =cut @@ -359,51 +1078,66 @@ sub ParseCcAddressesFromHead { @_ ); - my (@Addresses); + my $current_address = lc $args{'CurrentUser'}->EmailAddress; + my $user = $args{'CurrentUser'}->UserObj; + + return + grep { $_ ne $current_address + && !RT::EmailParser->IsRTAddress( $_ ) + && !IgnoreCcAddress( $_ ) + } + map lc $user->CanonicalizeEmailAddress( $_->address ), + map RT::EmailParser->CleanupAddresses( Email::Address->parse( $args{'Head'}->get( $_ ) ) ), + qw(To Cc); +} - my @ToObjs = Mail::Address->parse( $args{'Head'}->get('To') ); - my @CcObjs = Mail::Address->parse( $args{'Head'}->get('Cc') ); +=head2 IgnoreCcAddress ADDRESS - foreach my $AddrObj ( @ToObjs, @CcObjs ) { - my $Address = $AddrObj->address; - $Address = $args{'CurrentUser'} - ->UserObj->CanonicalizeEmailAddress($Address); - next if ( $args{'CurrentUser'}->EmailAddress =~ /^\Q$Address\E$/i ); - next if ( $args{'QueueObj'}->CorrespondAddress =~ /^\Q$Address\E$/i ); - next if ( $args{'QueueObj'}->CommentAddress =~ /^\Q$Address\E$/i ); - next if ( RT::EmailParser->IsRTAddress($Address) ); +Returns true if ADDRESS matches the $IgnoreCcRegexp config variable. - push( @Addresses, $Address ); +=cut + +sub IgnoreCcAddress { + my $address = shift; + if ( my $address_re = RT->Config->Get('IgnoreCcRegexp') ) { + return 1 if $address =~ /$address_re/i; } - return (@Addresses); + return undef; } -# }}} - -# {{{ ParseSenderAdddressFromHead +=head2 ParseSenderAddressFromHead HEAD -=head2 ParseSenderAddressFromHead +Takes a MIME::Header object. Returns (user@host, friendly name, errors) +where the first two values are the From (evaluated in order of +Reply-To:, From:, Sender). -Takes a MIME::Header object. Returns a tuple: (user@host, friendly name) -of the From (evaluated in order of Reply-To:, From:, Sender) +A list of error messages may be returned even when a Sender value is +found, since it could be a parse error for another (checked earlier) +sender field. In this case, the errors aren't fatal, but may be useful +to investigate the parse failure. =cut sub ParseSenderAddressFromHead { my $head = shift; + my @sender_headers = ('Reply-To', 'From', 'Sender'); + my @errors; # Accumulate any errors #Figure out who's sending this message. - my $From = $head->get('Reply-To') - || $head->get('From') - || $head->get('Sender'); - return ( ParseAddressFromHeader($From) ); -} - -# }}} + foreach my $header ( @sender_headers ) { + my $addr_line = $head->get($header) || next; + my ($addr, $name) = ParseAddressFromHeader( $addr_line ); + # only return if the address is not empty + return ($addr, $name, @errors) if $addr; + + chomp $addr_line; + push @errors, "$header: $addr_line"; + } -# {{{ ParseErrorsToAdddressFromHead + return (undef, undef, @errors); +} -=head2 ParseErrorsToAddressFromHead +=head2 ParseErrorsToAddressFromHead HEAD Takes a MIME::Header object. Return a single value : user@host of the From (evaluated in order of Return-path:,Errors-To:,Reply-To:, @@ -429,55 +1163,158 @@ sub ParseErrorsToAddressFromHead { } } -# }}} -# {{{ ParseAddressFromHeader =head2 ParseAddressFromHeader ADDRESS -Takes an address from $head->get('Line') and returns a tuple: user@host, friendly name +Takes an address from C<$head->get('Line')> and returns a tuple: user@host, friendly name =cut sub ParseAddressFromHeader { my $Addr = shift; - my @Addresses = Mail::Address->parse($Addr); - - my $AddrObj = $Addresses[0]; + # Some broken mailers send: ""Vincent, Jesse"" . Hate + $Addr =~ s/\"\"(.*?)\"\"/\"$1\"/g; + my @Addresses = RT::EmailParser->ParseEmailAddress($Addr); - unless ( ref($AddrObj) ) { + my ($AddrObj) = grep ref $_, @Addresses; + unless ( $AddrObj ) { return ( undef, undef ); } - my $Name = ( $AddrObj->phrase || $AddrObj->comment || $AddrObj->address ); + return ( $AddrObj->address, $AddrObj->phrase ); +} + +=head2 DeleteRecipientsFromHead HEAD RECIPIENTS + +Gets a head object and list of addresses. +Deletes addresses from To, Cc or Bcc fields. + +=cut - #Lets take the from and load a user object. - my $Address = $AddrObj->address; +sub DeleteRecipientsFromHead { + my $head = shift; + my %skip = map { lc $_ => 1 } @_; - return ( $Address, $Name ); + foreach my $field ( qw(To Cc Bcc) ) { + $head->set( $field => + join ', ', map $_->format, grep !$skip{ lc $_->address }, + Email::Address->parse( $head->get( $field ) ) + ); + } } -# }}} +sub GenMessageId { + my %args = ( + Ticket => undef, + Scrip => undef, + ScripAction => undef, + @_ + ); + my $org = RT->Config->Get('Organization'); + my $ticket_id = ( ref $args{'Ticket'}? $args{'Ticket'}->id : $args{'Ticket'} ) || 0; + my $scrip_id = ( ref $args{'Scrip'}? $args{'Scrip'}->id : $args{'Scrip'} ) || 0; + my $sent = ( ref $args{'ScripAction'}? $args{'ScripAction'}->{'_Message_ID'} : 0 ) || 0; -# {{{ sub ParseTicketId + return "" ; +} + +sub SetInReplyTo { + my %args = ( + Message => undef, + InReplyTo => undef, + Ticket => undef, + @_ + ); + return unless $args{'Message'} && $args{'InReplyTo'}; + + my $get_header = sub { + my @res; + if ( $args{'InReplyTo'}->isa('MIME::Entity') ) { + @res = $args{'InReplyTo'}->head->get( shift ); + } else { + @res = $args{'InReplyTo'}->GetHeader( shift ) || ''; + } + return grep length, map { split /\s+/m, $_ } grep defined, @res; + }; + + my @id = $get_header->('Message-ID'); + #XXX: custom header should begin with X- otherwise is violation of the standard + my @rtid = $get_header->('RT-Message-ID'); + my @references = $get_header->('References'); + unless ( @references ) { + @references = $get_header->('In-Reply-To'); + } + push @references, @id, @rtid; + if ( $args{'Ticket'} ) { + my $pseudo_ref = 'id .'@'. RT->Config->Get('Organization') .'>'; + push @references, $pseudo_ref unless grep $_ eq $pseudo_ref, @references; + } + @references = splice @references, 4, -6 + if @references > 10; + + my $mail = $args{'Message'}; + $mail->head->set( 'In-Reply-To' => Encode::encode_utf8(join ' ', @rtid? (@rtid) : (@id)) ) if @id || @rtid; + $mail->head->set( 'References' => Encode::encode_utf8(join ' ', @references) ); +} + +sub ExtractTicketId { + my $entity = shift; + + my $subject = $entity->head->get('Subject') || ''; + chomp $subject; + return ParseTicketId( $subject ); +} sub ParseTicketId { my $Subject = shift; - my $id; - my $test_name = $RT::EmailSubjectTagRegex || qr/\Q$RT::rtname\E/i; + my $rtname = RT->Config->Get('rtname'); + my $test_name = RT->Config->Get('EmailSubjectTagRegex') || qr/\Q$rtname\E/i; + my $id; if ( $Subject =~ s/\[$test_name\s+\#(\d+)\s*\]//i ) { - my $id = $1; - $RT::Logger->debug("Found a ticket ID. It's $id"); - return ($id); + $id = $1; } else { - return (undef); + foreach my $tag ( RT->System->SubjectTag ) { + next unless $Subject =~ s/\[\Q$tag\E\s+\#(\d+)\s*\]//i; + $id = $1; + last; + } + } + return undef unless $id; + + $RT::Logger->debug("Found a ticket ID. It's $id"); + return $id; +} + +sub AddSubjectTag { + my $subject = shift; + my $ticket = shift; + unless ( ref $ticket ) { + my $tmp = RT::Ticket->new( RT->SystemUser ); + $tmp->Load( $ticket ); + $ticket = $tmp; } + my $id = $ticket->id; + my $queue_tag = $ticket->QueueObj->SubjectTag; + + my $tag_re = RT->Config->Get('EmailSubjectTagRegex'); + unless ( $tag_re ) { + my $tag = $queue_tag || RT->Config->Get('rtname'); + $tag_re = qr/\Q$tag\E/; + } elsif ( $queue_tag ) { + $tag_re = qr/$tag_re|\Q$queue_tag\E/; + } + return $subject if $subject =~ /\[$tag_re\s+#$id\]/; + + $subject =~ s/(\r\n|\n|\s)/ /g; + chomp $subject; + return "[". ($queue_tag || RT->Config->Get('rtname')) ." #$id] $subject"; } -# }}} =head2 Gateway ARGSREF @@ -499,22 +1336,49 @@ any ticket id found in the subject. Returns: An array of: - + (status code, message, optional ticket object) status code is a numeric value. for temporary failures, the status code should be -75 - for permanent failures which are handled by RT, the status code + for permanent failures which are handled by RT, the status code should be 0 - + for succces, the status code should be 1 =cut +sub _LoadPlugins { + my @mail_plugins = @_; + + my @res; + foreach my $plugin (@mail_plugins) { + if ( ref($plugin) eq "CODE" ) { + push @res, $plugin; + } elsif ( !ref $plugin ) { + my $Class = $plugin; + $Class = "RT::Interface::Email::" . $Class + unless $Class =~ /^RT::/; + $Class->require or + do { $RT::Logger->error("Couldn't load $Class: $@"); next }; + + no strict 'refs'; + unless ( defined *{ $Class . "::GetCurrentUser" }{CODE} ) { + $RT::Logger->crit( "No GetCurrentUser code found in $Class module"); + next; + } + push @res, $Class; + } else { + $RT::Logger->crit( "$plugin - is not class name or code reference"); + } + } + return @res; +} + sub Gateway { my $argsref = shift; my %args = ( @@ -542,12 +1406,15 @@ sub Gateway { } my $parser = RT::EmailParser->new(); - $parser->SmartParseMIMEEntityFromScalar( Message => $args{'message'} ); - my $Message = $parser->Entity(); + $parser->SmartParseMIMEEntityFromScalar( + Message => $args{'message'}, + Decode => 0, + Exact => 1, + ); + my $Message = $parser->Entity(); unless ($Message) { MailError( - To => $RT::OwnerEmail, Subject => "RT Bounce: Unparseable message", Explanation => "RT couldn't process the message below", Attach => $args{'message'} @@ -558,20 +1425,83 @@ sub Gateway { ); } - my $head = $Message->head; + my @mail_plugins = grep $_, RT->Config->Get('MailPlugins'); + push @mail_plugins, "Auth::MailFrom" unless @mail_plugins; + @mail_plugins = _LoadPlugins( @mail_plugins ); - my $ErrorsTo = ParseErrorsToAddressFromHead($head); + my %skip_plugin; + foreach my $class( grep !ref, @mail_plugins ) { + # check if we should apply filter before decoding + my $check_cb = do { + no strict 'refs'; + *{ $class . "::ApplyBeforeDecode" }{CODE}; + }; + next unless defined $check_cb; + next unless $check_cb->( + Message => $Message, + RawMessageRef => \$args{'message'}, + ); + + $skip_plugin{ $class }++; + + my $Code = do { + no strict 'refs'; + *{ $class . "::GetCurrentUser" }{CODE}; + }; + my ($status, $msg) = $Code->( + Message => $Message, + RawMessageRef => \$args{'message'}, + ); + next if $status > 0; + + if ( $status == -2 ) { + return (1, $msg, undef); + } elsif ( $status == -1 ) { + return (0, $msg, undef); + } + } + @mail_plugins = grep !$skip_plugin{"$_"}, @mail_plugins; + $parser->_DecodeBodies; + $parser->RescueOutlook; + $parser->_PostProcessNewEntity; + + my $head = $Message->head; + my $ErrorsTo = ParseErrorsToAddressFromHead( $head ); + my $Sender = (ParseSenderAddressFromHead( $head ))[0]; + my $From = $head->get("From"); + chomp $From if defined $From; my $MessageId = $head->get('Message-ID') - || ""; + || "Config->Get('Organization') .'>'; #Pull apart the subject line my $Subject = $head->get('Subject') || ''; chomp $Subject; + + # Lets check for mail loops of various sorts. + my ($should_store_machine_generated_message, $IsALoop, $result); + ( $should_store_machine_generated_message, $ErrorsTo, $result, $IsALoop ) = + _HandleMachineGeneratedMail( + Message => $Message, + ErrorsTo => $ErrorsTo, + Subject => $Subject, + MessageId => $MessageId + ); - $args{'ticket'} ||= ParseTicketId($Subject); + # Do not pass loop messages to MailPlugins, to make sure the loop + # is broken, unless $RT::StoreLoops is set. + if ($IsALoop && !$should_store_machine_generated_message) { + return ( 0, $result, undef ); + } + # }}} - $SystemTicket = RT::Ticket->new($RT::SystemUser); + $args{'ticket'} ||= ExtractTicketId( $Message ); + + # ExtractTicketId may have been overridden, and edited the Subject + my $NewSubject = $Message->head->get('Subject'); + chomp $NewSubject; + + $SystemTicket = RT::Ticket->new( RT->SystemUser ); $SystemTicket->Load( $args{'ticket'} ) if ( $args{'ticket'} ) ; if ( $SystemTicket->id ) { $Right = 'ReplyToTicket'; @@ -580,7 +1510,7 @@ sub Gateway { } #Set up a queue object - my $SystemQueueObj = RT::Queue->new($RT::SystemUser); + my $SystemQueueObj = RT::Queue->new( RT->SystemUser ); $SystemQueueObj->Load( $args{'queue'} ); # We can safely have no queue of we have a known-good ticket @@ -588,64 +1518,16 @@ sub Gateway { return ( -75, "RT couldn't find the queue: " . $args{'queue'}, undef ); } - # Authentication Level ($AuthStat) - # -1 - Get out. this user has been explicitly declined - # 0 - User may not do anything (Not used at the moment) - # 1 - Normal user - # 2 - User is allowed to specify status updates etc. a la enhanced-mailgate - my ( $CurrentUser, $AuthStat, $error ); - - # Initalize AuthStat so comparisons work correctly - $AuthStat = -9999999; - - push @RT::MailPlugins, "Auth::MailFrom" unless @RT::MailPlugins; - - # if plugin returns AuthStat -2 we skip action - # NOTE: this is experimental API and it would be changed - my %skip_action = (); - - # Since this needs loading, no matter what - foreach (@RT::MailPlugins) { - my ($Code, $NewAuthStat); - if ( ref($_) eq "CODE" ) { - $Code = $_; - } else { - my $Class = $_; - $Class = "RT::Interface::Email::" . $Class - unless $Class =~ /^RT::Interface::Email::/; - $Class->require or - do { $RT::Logger->error("Couldn't load $Class: $@"); next }; - - no strict 'refs'; - unless ( defined( $Code = *{ $Class . "::GetCurrentUser" }{CODE} ) ) { - $RT::Logger->crit( "No 'GetCurrentUser' function found in '$Class' module"); - next; - } - } - - foreach my $action (@actions) { - ( $CurrentUser, $NewAuthStat ) = $Code->( - Message => $Message, - RawMessageRef => \$args{'message'}, - CurrentUser => $CurrentUser, - AuthLevel => $AuthStat, - Action => $action, - Ticket => $SystemTicket, - Queue => $SystemQueueObj - ); - -# You get the highest level of authentication you were assigned, unless you get the magic -1 -# If a module returns a "-1" then we discard the ticket, so. - $AuthStat = $NewAuthStat - if ( $NewAuthStat > $AuthStat or $NewAuthStat == -1 or $NewAuthStat == -2 ); - - last if $AuthStat == -1; - $skip_action{$action}++ if $AuthStat == -2; - } + my ($AuthStat, $CurrentUser, $error) = GetAuthenticationLevel( + MailPlugins => \@mail_plugins, + Actions => \@actions, + Message => $Message, + RawMessageRef => \$args{message}, + SystemTicket => $SystemTicket, + SystemQueue => $SystemQueueObj, + ); - last if $AuthStat == -1; - } - # {{{ If authentication fails and no new user was created, get out. + # If authentication fails and no new user was created, get out. if ( !$CurrentUser || !$CurrentUser->id || $AuthStat == -1 ) { # If the plugins refused to create one, they lose. @@ -672,28 +1554,18 @@ sub Gateway { ); return ( 0, - "$ErrorsTo tried to submit a message to " + ($CurrentUser->EmailAddress || $CurrentUser->Name) + . " ($Sender) tried to submit a message to " . $args{'Queue'} . " without permission.", undef ); } - # {{{ Lets check for mail loops of various sorts. - my ($continue, $result); - ( $continue, $ErrorsTo, $result ) = _HandleMachineGeneratedMail( - Message => $Message, - ErrorsTo => $ErrorsTo, - Subject => $Subject, - MessageId => $MessageId - ); - unless ($continue) { + unless ($should_store_machine_generated_message) { return ( 0, $result, undef ); } - - # strip actions we should skip - @actions = grep !$skip_action{$_}, @actions; # if plugin's updated SystemTicket then update arguments $args{'ticket'} = $SystemTicket->Id if $SystemTicket && $SystemTicket->Id; @@ -706,7 +1578,7 @@ sub Gateway { my @Cc; my @Requestors = ( $CurrentUser->id ); - if ($RT::ParseNewMessageForTicketCcs) { + if (RT->Config->Get('ParseNewMessageForTicketCcs')) { @Cc = ParseCcAddressesFromHead( Head => $head, CurrentUser => $CurrentUser, @@ -714,9 +1586,11 @@ sub Gateway { ); } + $head->replace('X-RT-Interface' => 'Email'); + my ( $id, $Transaction, $ErrStr ) = $Ticket->Create( Queue => $SystemQueueObj->Id, - Subject => $Subject, + Subject => $NewSubject, Requestor => \@Requestors, Cc => \@Cc, MIMEObj => $Message @@ -724,11 +1598,11 @@ sub Gateway { if ( $id == 0 ) { MailError( To => $ErrorsTo, - Subject => "Ticket creation failed", + Subject => "Ticket creation failed: $Subject", Explanation => $ErrStr, MIMEObj => $Message ); - return ( 0, "Ticket creation failed: $ErrStr", $Ticket ); + return ( 0, "Ticket creation From: $From failed: $ErrStr", $Ticket ); } # strip comments&corresponds from the actions we don't need @@ -736,23 +1610,28 @@ sub Gateway { @actions = grep !/^(comment|correspond)$/, @actions; $args{'ticket'} = $id; - } else { + } elsif ( $args{'ticket'} ) { $Ticket->Load( $args{'ticket'} ); unless ( $Ticket->Id ) { my $error = "Could not find a ticket with id " . $args{'ticket'}; MailError( To => $ErrorsTo, - Subject => "Message not recorded", + Subject => "Message not recorded: $Subject", Explanation => $error, MIMEObj => $Message ); return ( 0, $error ); } + $args{'ticket'} = $Ticket->id; + } else { + return ( 1, "Success", $Ticket ); } # }}} + + my $unsafe_actions = RT->Config->Get('UnsafeEmailCommands'); foreach my $action (@actions) { # If the action is comment, add a comment. @@ -764,13 +1643,13 @@ sub Gateway { #Warn the sender that we couldn't actually submit the comment. MailError( To => $ErrorsTo, - Subject => "Message not recorded", + Subject => "Message not recorded ($method): $Subject", Explanation => $msg, MIMEObj => $Message ); - return ( 0, "Message not recorded: $msg", $Ticket ); + return ( 0, "Message From: $From not recorded: $msg", $Ticket ); } - } elsif ($RT::UnsafeEmailCommands) { + } elsif ($unsafe_actions) { my ( $status, $msg ) = _RunUnsafeAction( Action => $action, ErrorsTo => $ErrorsTo, @@ -784,6 +1663,79 @@ sub Gateway { return ( 1, "Success", $Ticket ); } +=head2 GetAuthenticationLevel + + # Authentication Level + # -1 - Get out. this user has been explicitly declined + # 0 - User may not do anything (Not used at the moment) + # 1 - Normal user + # 2 - User is allowed to specify status updates etc. a la enhanced-mailgate + +=cut + +sub GetAuthenticationLevel { + my %args = ( + MailPlugins => [], + Actions => [], + Message => undef, + RawMessageRef => undef, + SystemTicket => undef, + SystemQueue => undef, + @_, + ); + + my ( $CurrentUser, $AuthStat, $error ); + + # Initalize AuthStat so comparisons work correctly + $AuthStat = -9999999; + + # if plugin returns AuthStat -2 we skip action + # NOTE: this is experimental API and it would be changed + my %skip_action = (); + + # Since this needs loading, no matter what + foreach (@{ $args{MailPlugins} }) { + my ($Code, $NewAuthStat); + if ( ref($_) eq "CODE" ) { + $Code = $_; + } else { + no strict 'refs'; + $Code = *{ $_ . "::GetCurrentUser" }{CODE}; + } + + foreach my $action (@{ $args{Actions} }) { + ( $CurrentUser, $NewAuthStat ) = $Code->( + Message => $args{Message}, + RawMessageRef => $args{RawMessageRef}, + CurrentUser => $CurrentUser, + AuthLevel => $AuthStat, + Action => $action, + Ticket => $args{SystemTicket}, + Queue => $args{SystemQueue}, + ); + +# You get the highest level of authentication you were assigned, unless you get the magic -1 +# If a module returns a "-1" then we discard the ticket, so. + $AuthStat = $NewAuthStat + if ( $NewAuthStat > $AuthStat or $NewAuthStat == -1 or $NewAuthStat == -2 ); + + last if $AuthStat == -1; + $skip_action{$action}++ if $AuthStat == -2; + } + + # strip actions we should skip + @{$args{Actions}} = grep !$skip_action{$_}, @{$args{Actions}} + if $AuthStat == -2; + last unless @{$args{Actions}}; + + last if $AuthStat == -1; + } + + return $AuthStat if !wantarray; + + return ($AuthStat, $CurrentUser, $error); +} + sub _RunUnsafeAction { my %args = ( Action => undef, @@ -794,6 +1746,8 @@ sub _RunUnsafeAction { @_ ); + my $From = $args{Message}->head->get("From"); + if ( $args{'Action'} =~ /^take$/i ) { my ( $status, $msg ) = $args{'Ticket'}->SetOwner( $args{'CurrentUser'}->id ); unless ($status) { @@ -803,23 +1757,26 @@ sub _RunUnsafeAction { Explanation => $msg, MIMEObj => $args{'Message'} ); - return ( 0, "Ticket not taken" ); + return ( 0, "Ticket not taken, by email From: $From" ); } } elsif ( $args{'Action'} =~ /^resolve$/i ) { - my ( $status, $msg ) = $args{'Ticket'}->SetStatus('resolved'); - unless ($status) { + my $new_status = $args{'Ticket'}->FirstInactiveStatus; + if ($new_status) { + my ( $status, $msg ) = $args{'Ticket'}->SetStatus($new_status); + unless ($status) { - #Warn the sender that we couldn't actually submit the comment. - MailError( - To => $args{'ErrorsTo'}, - Subject => "Ticket not resolved", - Explanation => $msg, - MIMEObj => $args{'Message'} - ); - return ( 0, "Ticket not resolved" ); + #Warn the sender that we couldn't actually submit the comment. + MailError( + To => $args{'ErrorsTo'}, + Subject => "Ticket not resolved", + Explanation => $msg, + MIMEObj => $args{'Message'} + ); + return ( 0, "Ticket not resolved, by email From: $From" ); + } } } else { - return ( 0, "Not supported unsafe action $args{'Action'}", $args{'Ticket'} ); + return ( 0, "Not supported unsafe action $args{'Action'}, by email From: $From", $args{'Ticket'} ); } return ( 1, "Success" ); } @@ -841,7 +1798,7 @@ sub _NoAuthorizedUserFound { # Notify the RT Admin of the failure. MailError( - To => $RT::OwnerEmail, + To => RT->Config->Get('OwnerEmail'), Subject => "Could not load a valid user", Explanation => <Config->Get('OwnerEmail')) { MailError( To => $args{'Requestor'}, Subject => "Could not load a valid user", @@ -867,6 +1825,7 @@ EOT MIMEObj => $args{'Message'}, LogLevel => 'error' ); + } } =head2 _HandleMachineGeneratedMail @@ -877,7 +1836,8 @@ Takes named params: Subject Checks the message to see if it's a bounce, if it looks like a loop, if it's autogenerated, etc. -Returns a triple of ("Should we continue (boolean)", "New value for $ErrorsTo", "Status message"); +Returns a triple of ("Should we continue (boolean)", "New value for $ErrorsTo", "Status message", +"This message appears to be a loop (boolean)" ); =cut @@ -896,21 +1856,23 @@ sub _HandleMachineGeneratedMail { my $SquelchReplies = 0; + my $owner_mail = RT->Config->Get('OwnerEmail'); + #If the message is autogenerated, we need to know, so we can not # send mail to the sender if ( $IsBounce || $IsSuspiciousSender || $IsAutoGenerated || $IsALoop ) { $SquelchReplies = 1; - $ErrorsTo = $RT::OwnerEmail; + $ErrorsTo = $owner_mail; } # Warn someone if it's a loop, before we drop it on the ground if ($IsALoop) { - $RT::Logger->crit("RT Recieved mail (".$args{MessageId}.") from itself."); + $RT::Logger->crit("RT Received mail (".$args{MessageId}.") from itself."); #Should we mail it to RTOwner? - if ($RT::LoopsToRTOwner) { + if ( RT->Config->Get('LoopsToRTOwner') ) { MailError( - To => $RT::OwnerEmail, + To => $owner_mail, Subject => "RT Bounce: ".$args{'Subject'}, Explanation => "RT thinks this message may be a bounce", MIMEObj => $args{Message} @@ -918,13 +1880,14 @@ sub _HandleMachineGeneratedMail { } #Do we actually want to store it? - return ( 0, $ErrorsTo, "Message Bounced" ) unless ($RT::StoreLoops); + return ( 0, $ErrorsTo, "Message Bounced", $IsALoop ) + unless RT->Config->Get('StoreLoops'); } # Squelch replies if necessary # Don't let the user stuff the RT-Squelch-Replies-To header. if ( $head->get('RT-Squelch-Replies-To') ) { - $head->add( + $head->replace( 'RT-Relocated-Squelch-Replies-To', $head->get('RT-Squelch-Replies-To') ); @@ -939,10 +1902,10 @@ sub _HandleMachineGeneratedMail { # to the scrip. We might want to notify nobody. Or just # the RT Owner. Or maybe all Privileged watchers. my ( $Sender, $junk ) = ParseSenderAddressFromHead($head); - $head->add( 'RT-Squelch-Replies-To', $Sender ); - $head->add( 'RT-DetectedAutoGenerated', 'true' ); + $head->replace( 'RT-Squelch-Replies-To', $Sender ); + $head->replace( 'RT-DetectedAutoGenerated', 'true' ); } - return ( 1, $ErrorsTo, "Handled machine detection" ); + return ( 1, $ErrorsTo, "Handled machine detection", $IsALoop ); } =head2 IsCorrectAction @@ -953,16 +1916,29 @@ Returns a list of valid actions we've found for this message sub IsCorrectAction { my $action = shift; - my @actions = split /-/, $action; - foreach (@actions) { + my @actions = grep $_, split /-/, $action; + return ( 0, '(no value)' ) unless @actions; + foreach ( @actions ) { return ( 0, $_ ) unless /^(?:comment|correspond|take|resolve)$/; } return ( 1, @actions ); } -eval "require RT::Interface::Email_Vendor"; -die $@ if ( $@ && $@ !~ qr{^Can't locate RT/Interface/Email_Vendor.pm} ); -eval "require RT::Interface::Email_Local"; -die $@ if ( $@ && $@ !~ qr{^Can't locate RT/Interface/Email_Local.pm} ); +sub _RecordSendEmailFailure { + my $ticket = shift; + if ($ticket) { + $ticket->_RecordNote( + NoteType => 'SystemError', + Content => "Sending the previous mail has failed. Please contact your admin, they can find more details in the logs.", + ); + return 1; + } + else { + $RT::Logger->error( "Can't record send email failure as ticket is missing" ); + return; + } +} + +RT::Base->_ImportOverlays(); 1;