1#!/usr/local/bin/perl -T
2# This Source Code Form is subject to the terms of the Mozilla Public
3# License, v. 2.0. If a copy of the MPL was not distributed with this
4# file, You can obtain one at http://mozilla.org/MPL/2.0/.
5#
6# This Source Code Form is "Incompatible With Secondary Licenses", as
7# defined by the Mozilla Public License, v. 2.0.
8
9use 5.10.1;
10use strict;
11use warnings;
12
13# MTAs may call this script from any directory, but it should always
14# run from this one so that it can find its modules.
15use Cwd qw(abs_path);
16use File::Basename qw(dirname);
17BEGIN {
18    # Untaint the abs_path.
19    my ($a) = abs_path($0) =~ /^(.*)$/;
20    chdir dirname($a);
21}
22
23use lib qw(. lib);
24
25use Data::Dumper;
26use Email::Address;
27use Email::Reply qw(reply);
28use Email::MIME;
29use Getopt::Long qw(:config bundling);
30use HTML::FormatText::WithLinks;
31use Pod::Usage;
32use Encode;
33use Scalar::Util qw(blessed);
34use List::MoreUtils qw(firstidx);
35
36use Bugzilla;
37use Bugzilla::Attachment;
38use Bugzilla::Bug;
39use Bugzilla::BugMail;
40use Bugzilla::Constants;
41use Bugzilla::Error;
42use Bugzilla::Field;
43use Bugzilla::Mailer;
44use Bugzilla::Token;
45use Bugzilla::User;
46use Bugzilla::Util;
47use Bugzilla::Hook;
48
49#############
50# Constants #
51#############
52
53# This is the USENET standard line for beginning a signature block
54# in a message. RFC-compliant mailers use this.
55use constant SIGNATURE_DELIMITER => '-- ';
56
57# These MIME types represent a "body" of an email if they have an
58# "inline" Content-Disposition (or no content disposition).
59use constant BODY_TYPES => qw(
60    text/plain
61    text/html
62    application/xhtml+xml
63    multipart/alternative
64);
65
66# $input_email is a global so that it can be used in die_handler.
67our ($input_email, %switch);
68
69####################
70# Main Subroutines #
71####################
72
73sub parse_mail {
74    my ($mail_text) = @_;
75    debug_print('Parsing Email');
76    $input_email = Email::MIME->new($mail_text);
77
78    my %fields = %{ $switch{'default'} || {} };
79    Bugzilla::Hook::process('email_in_before_parse', { mail => $input_email,
80                                                       fields => \%fields });
81
82    my $summary = $input_email->header('Subject');
83    if ($summary =~ /\[\S+ (\d+)\](.*)/i) {
84        $fields{'bug_id'} = $1;
85        $summary = trim($2);
86    }
87
88    # Ignore automatic replies.
89    # XXX - Improve the way to detect such subjects in different languages.
90    my $auto_submitted = $input_email->header('Auto-Submitted') || '';
91    if ($summary =~ /out of( the)? office/i || $auto_submitted eq 'auto-replied') {
92        debug_print("Automatic reply detected: $summary");
93        exit;
94    }
95
96    my ($body, $attachments) = get_body_and_attachments($input_email);
97
98    debug_print("Body:\n" . $body, 3);
99
100    $body = remove_leading_blank_lines($body);
101    my @body_lines = split(/\r?\n/s, $body);
102
103    # If there are fields specified.
104    if ($body =~ /^\s*@/s) {
105        my $current_field;
106        while (my $line = shift @body_lines) {
107            # If the sig is starting, we want to keep this in the
108            # @body_lines so that we don't keep the sig as part of the
109            # comment down below.
110            if ($line eq SIGNATURE_DELIMITER) {
111                unshift(@body_lines, $line);
112                last;
113            }
114            # Otherwise, we stop parsing fields on the first blank line.
115            $line = trim($line);
116            last if !$line;
117            if ($line =~ /^\@(\w+)\s*(?:=|\s|$)\s*(.*)\s*/) {
118                $current_field = lc($1);
119                $fields{$current_field} = $2;
120            }
121            else {
122                $fields{$current_field} .= " $line";
123            }
124        }
125    }
126
127    %fields = %{ Bugzilla::Bug::map_fields(\%fields) };
128
129    my ($reporter) = Email::Address->parse($input_email->header('From'));
130    $fields{'reporter'} = $reporter->address;
131
132    # The summary line only affects us if we're doing a post_bug.
133    # We have to check it down here because there might have been
134    # a bug_id specified in the body of the email.
135    if (!$fields{'bug_id'} && !$fields{'short_desc'}) {
136        $fields{'short_desc'} = $summary;
137    }
138
139    # The Importance/X-Priority headers are only used when creating a new bug.
140    # 1) If somebody specifies a priority, use it.
141    # 2) If there is an Importance or X-Priority header, use it as
142    #    something that is relative to the default priority.
143    #    If the value is High or 1, increase the priority by 1.
144    #    If the value is Low or 5, decrease the priority by 1.
145    # 3) Otherwise, use the default priority.
146    # Note: this will only work if the 'letsubmitterchoosepriority'
147    # parameter is enabled.
148    my $importance = $input_email->header('Importance')
149                     || $input_email->header('X-Priority');
150    if (!$fields{'bug_id'} && !$fields{'priority'} && $importance) {
151        my @legal_priorities = @{get_legal_field_values('priority')};
152        my $i = firstidx { $_ eq Bugzilla->params->{'defaultpriority'} } @legal_priorities;
153        if ($importance =~ /(high|[12])/i) {
154            $i-- unless $i == 0;
155        }
156        elsif ($importance =~ /(low|[45])/i) {
157            $i++ unless $i == $#legal_priorities;
158        }
159        $fields{'priority'} = $legal_priorities[$i];
160    }
161
162    my $comment = '';
163    # Get the description, except the signature.
164    foreach my $line (@body_lines) {
165        last if $line eq SIGNATURE_DELIMITER;
166        $comment .= "$line\n";
167    }
168    $fields{'comment'} = $comment;
169
170    my %override = %{ $switch{'override'} || {} };
171    foreach my $key (keys %override) {
172        $fields{$key} = $override{$key};
173    }
174
175    debug_print("Parsed Fields:\n" . Dumper(\%fields), 2);
176
177    debug_print("Attachments:\n" . Dumper($attachments), 3);
178    if (@$attachments) {
179        $fields{'attachments'} = $attachments;
180    }
181
182    return \%fields;
183}
184
185sub check_email_fields {
186    my ($fields) = @_;
187
188    my ($retval, $non_conclusive_fields) =
189      Bugzilla::User::match_field({
190        'assigned_to'   => { 'type' => 'single' },
191        'qa_contact'    => { 'type' => 'single' },
192        'cc'            => { 'type' => 'multi'  },
193        'newcc'         => { 'type' => 'multi'  }
194      }, $fields, MATCH_SKIP_CONFIRM);
195
196    if ($retval != USER_MATCH_SUCCESS) {
197        ThrowUserError('user_match_too_many', {fields => $non_conclusive_fields});
198    }
199}
200
201sub post_bug {
202    my ($fields) = @_;
203    debug_print('Posting a new bug...');
204
205    my $user = Bugzilla->user;
206
207    check_email_fields($fields);
208
209    my $bug = Bugzilla::Bug->create($fields);
210    debug_print("Created bug " . $bug->id);
211    return ($bug, $bug->comments->[0]);
212}
213
214sub process_bug {
215    my ($fields_in) = @_;
216    my %fields = %$fields_in;
217
218    my $bug_id = $fields{'bug_id'};
219    $fields{'id'} = $bug_id;
220    delete $fields{'bug_id'};
221
222    debug_print("Updating Bug $fields{id}...");
223
224    my $bug = Bugzilla::Bug->check($bug_id);
225
226    if ($fields{'bug_status'}) {
227        $fields{'knob'} = $fields{'bug_status'};
228    }
229    # If no status is given, then we only want to change the resolution.
230    elsif ($fields{'resolution'}) {
231        $fields{'knob'} = 'change_resolution';
232        $fields{'resolution_knob_change_resolution'} = $fields{'resolution'};
233    }
234    if ($fields{'dup_id'}) {
235        $fields{'knob'} = 'duplicate';
236    }
237
238    # Move @cc to @newcc as @cc is used by process_bug.cgi to remove
239    # users from the CC list when @removecc is set.
240    $fields{'newcc'} = delete $fields{'cc'} if $fields{'cc'};
241
242    # Make it possible to remove CCs.
243    if ($fields{'removecc'}) {
244        $fields{'cc'} = [split(',', $fields{'removecc'})];
245        $fields{'removecc'} = 1;
246    }
247
248    check_email_fields(\%fields);
249
250    my $cgi = Bugzilla->cgi;
251    foreach my $field (keys %fields) {
252        $cgi->param(-name => $field, -value => $fields{$field});
253    }
254    $cgi->param('token', issue_hash_token([$bug->id, $bug->delta_ts]));
255
256    require 'process_bug.cgi';
257    debug_print("Bug processed.");
258
259    my $added_comment;
260    if (trim($fields{'comment'})) {
261        # The "old" bug object doesn't contain the comment we just added.
262        $added_comment = Bugzilla::Bug->check($bug_id)->comments->[-1];
263    }
264    return ($bug, $added_comment);
265}
266
267sub handle_attachments {
268    my ($bug, $attachments, $comment) = @_;
269    return if !$attachments;
270    debug_print("Handling attachments...");
271    my $dbh = Bugzilla->dbh;
272    $dbh->bz_start_transaction();
273    my ($update_comment, $update_bug);
274    foreach my $attachment (@$attachments) {
275        debug_print("Inserting Attachment: " . Dumper($attachment), 3);
276        my $type = $attachment->content_type || 'application/octet-stream';
277        # MUAs add stuff like "name=" to content-type that we really don't
278        # want.
279        $type =~ s/;.*//;
280        my $obj = Bugzilla::Attachment->create({
281            bug         => $bug,
282            description => $attachment->filename(1),
283            filename    => $attachment->filename(1),
284            mimetype    => $type,
285            data        => $attachment->body,
286        });
287        # If we added a comment, and our comment does not already have a type,
288        # and this is our first attachment, then we make the comment an
289        # "attachment created" comment.
290        if ($comment and !$comment->type and !$update_comment) {
291            $comment->set_all({ type       => CMT_ATTACHMENT_CREATED,
292                                extra_data => $obj->id });
293            $update_comment = 1;
294        }
295        else {
296            $bug->add_comment('', { type => CMT_ATTACHMENT_CREATED,
297                                    extra_data => $obj->id });
298            $update_bug = 1;
299        }
300    }
301    # We only update the comments and bugs at the end of the transaction,
302    # because doing so modifies bugs_fulltext, which is a non-transactional
303    # table.
304    $bug->update() if $update_bug;
305    $comment->update() if $update_comment;
306    $dbh->bz_commit_transaction();
307}
308
309######################
310# Helper Subroutines #
311######################
312
313sub debug_print {
314    my ($str, $level) = @_;
315    $level ||= 1;
316    print STDERR "$str\n" if $level <= $switch{'verbose'};
317}
318
319sub get_body_and_attachments {
320    my ($email) = @_;
321
322    my $ct = $email->content_type || 'text/plain';
323    debug_print("Splitting Body and Attachments [Type: $ct]...", 2);
324
325    my ($bodies, $attachments) = split_body_and_attachments($email);
326    debug_print(scalar(@$bodies) . " body part(s) and " . scalar(@$attachments)
327                . " attachment part(s).");
328    debug_print('Bodies: ' . Dumper($bodies), 3);
329
330    # Get the first part of the email that contains a text body,
331    # and make all the other pieces into attachments. (This handles
332    # people or MUAs who accidentally attach text files as an "inline"
333    # attachment.)
334    my $body;
335    while (@$bodies) {
336        my $possible = shift @$bodies;
337        $body = get_text_alternative($possible);
338        if (defined $body) {
339            unshift(@$attachments, @$bodies);
340            last;
341        }
342    }
343
344    if (!defined $body) {
345        # Note that this only happens if the email does not contain any
346        # text/plain parts. If the email has an empty text/plain part,
347        # you're fine, and this message does NOT get thrown.
348        ThrowUserError('email_no_body');
349    }
350
351    debug_print("Picked Body:\n$body", 2);
352
353    return ($body, $attachments);
354}
355
356sub get_text_alternative {
357    my ($email) = @_;
358
359    my @parts = $email->parts;
360    my $body;
361    foreach my $part (@parts) {
362        my $ct = $part->content_type || 'text/plain';
363        my $charset = 'iso-8859-1';
364        # The charset may be quoted.
365        if ($ct =~ /charset="?([^;"]+)/) {
366            $charset= $1;
367        }
368        debug_print("Alternative Part Content-Type: $ct", 2);
369        debug_print("Alternative Part Character Encoding: $charset", 2);
370        # If we find a text/plain body here, return it immediately.
371        if (!$ct || $ct =~ m{^text/plain}i) {
372            return _decode_body($charset, $part->body);
373        }
374        # If we find a text/html body, decode it, but don't return
375        # it immediately, because there might be a text/plain alternative
376        # later. This could be any HTML type.
377        if ($ct =~ m{^application/xhtml\+xml}i or $ct =~ m{text/html}i) {
378            my $parser = HTML::FormatText::WithLinks->new(
379                # Put footnnote indicators after the text, not before it.
380                before_link => '',
381                after_link  => '[%n]',
382                # Convert bold and italics, use "*" for bold instead of "_".
383                with_emphasis => 1,
384                bold_marker => '*',
385                # If the same link appears multiple times, only create
386                # one footnote.
387                unique_links => 1,
388                # If the link text is the URL, don't create a footnote.
389                skip_linked_urls => 1,
390            );
391            $body = _decode_body($charset, $part->body);
392            $body = $parser->parse($body);
393        }
394    }
395
396    return $body;
397}
398
399sub _decode_body {
400    my ($charset, $body) = @_;
401    if (Bugzilla->params->{'utf8'} && !utf8::is_utf8($body)) {
402        return Encode::decode($charset, $body);
403    }
404    return $body;
405}
406
407sub remove_leading_blank_lines {
408    my ($text) = @_;
409    $text =~ s/^(\s*\n)+//s;
410    return $text;
411}
412
413sub html_strip {
414    my ($var) = @_;
415    # Trivial HTML tag remover (this is just for error messages, really.)
416    $var =~ s/<[^>]*>//g;
417    # And this basically reverses the Template-Toolkit html filter.
418    $var =~ s/\&amp;/\&/g;
419    $var =~ s/\&lt;/</g;
420    $var =~ s/\&gt;/>/g;
421    $var =~ s/\&quot;/\"/g;
422    $var =~ s/&#64;/@/g;
423    # Also remove undesired newlines and consecutive spaces.
424    $var =~ s/[\n\s]+/ /gms;
425    return $var;
426}
427
428sub split_body_and_attachments {
429    my ($email) = @_;
430
431    my (@body, @attachments);
432    foreach my $part ($email->parts) {
433        my $ct = lc($part->content_type || 'text/plain');
434        my $disposition = lc($part->header('Content-Disposition') || 'inline');
435        # Remove the charset, etc. from the content-type, we don't care here.
436        $ct =~ s/;.*//;
437        debug_print("Part Content-Type: [$ct]", 2);
438        debug_print("Part Disposition: [$disposition]", 2);
439
440        if ($disposition eq 'inline' and grep($_ eq $ct, BODY_TYPES)) {
441            push(@body, $part);
442            next;
443        }
444
445        if (scalar($part->parts) == 1) {
446            push(@attachments, $part);
447            next;
448        }
449
450        # If this part has sub-parts, analyze them similarly to how we
451        # did above and return the relevant pieces.
452        my ($add_body, $add_attachments) = split_body_and_attachments($part);
453        push(@body, @$add_body);
454        push(@attachments, @$add_attachments);
455    }
456
457    return (\@body, \@attachments);
458}
459
460
461sub die_handler {
462    my ($msg) = @_;
463
464    # In Template-Toolkit, [% RETURN %] is implemented as a call to "die".
465    # But of course, we really don't want to actually *die* just because
466    # the user-error or code-error template ended. So we don't really die.
467    return if blessed($msg) && $msg->isa('Template::Exception')
468              && $msg->type eq 'return';
469
470    # If this is inside an eval, then we should just act like...we're
471    # in an eval (instead of printing the error and exiting).
472    die @_ if ($^S // Bugzilla::Error::_in_eval());
473
474    # We can't depend on the MTA to send an error message, so we have
475    # to generate one properly.
476    if ($input_email) {
477       $msg =~ s/at .+ line.*$//ms;
478       $msg =~ s/^Compilation failed in require.+$//ms;
479       $msg = html_strip($msg);
480       my $from = Bugzilla->params->{'mailfrom'};
481       my $reply = reply(to => $input_email, from => $from, top_post => 1,
482                         body => "$msg\n");
483       MessageToMTA($reply->as_string);
484    }
485    print STDERR "$msg\n";
486    # We exit with a successful value, because we don't want the MTA
487    # to *also* send a failure notice.
488    exit;
489}
490
491###############
492# Main Script #
493###############
494
495$SIG{__DIE__} = \&die_handler;
496
497GetOptions(\%switch, 'help|h', 'verbose|v+', 'default=s%', 'override=s%');
498$switch{'verbose'} ||= 0;
499
500# Print the help message if that switch was selected.
501pod2usage({-verbose => 0, -exitval => 1}) if $switch{'help'};
502
503Bugzilla->usage_mode(USAGE_MODE_EMAIL);
504
505my @mail_lines = <STDIN>;
506my $mail_text = join("", @mail_lines);
507my $mail_fields = parse_mail($mail_text);
508
509Bugzilla::Hook::process('email_in_after_parse', { fields => $mail_fields });
510
511my $attachments = delete $mail_fields->{'attachments'};
512
513my $username = $mail_fields->{'reporter'};
514# If emailsuffix is in use, we have to remove it from the email address.
515if (my $suffix = Bugzilla->params->{'emailsuffix'}) {
516    $username =~ s/\Q$suffix\E$//i;
517}
518
519my $user = Bugzilla::User->check($username);
520Bugzilla->set_user($user);
521
522my ($bug, $comment);
523if ($mail_fields->{'bug_id'}) {
524    ($bug, $comment) = process_bug($mail_fields);
525}
526else {
527    ($bug, $comment) = post_bug($mail_fields);
528}
529
530handle_attachments($bug, $attachments, $comment);
531
532# This is here for post_bug and handle_attachments, so that when posting a bug
533# with an attachment, any comment goes out as an attachment comment.
534#
535# Eventually this should be sending the mail for process_bug, too, but we have
536# to wait for $bug->update() to be fully used in email_in.pl first. So
537# currently, process_bug.cgi does the mail sending for bugs, and this does
538# any mail sending for attachments after the first one.
539Bugzilla::BugMail::Send($bug->id, { changer => Bugzilla->user });
540debug_print("Sent bugmail");
541
542
543__END__
544
545=head1 NAME
546
547email_in.pl - The Bugzilla Inbound Email Interface
548
549=head1 SYNOPSIS
550
551./email_in.pl [-vvv] [--default name=value] [--override name=value] < email.txt
552
553Reads an email on STDIN (the standard input).
554
555Options:
556
557   --verbose (-v)        - Make the script print more to STDERR.
558                           Specify multiple times to print even more.
559
560   --default name=value  - Specify defaults for field values, like
561                           product=TestProduct. Can be specified multiple
562                           times to specify defaults for multiple fields.
563
564   --override name=value - Override field values specified in the email,
565                           like product=TestProduct. Can be specified
566                           multiple times to override multiple fields.
567
568=head1 DESCRIPTION
569
570This script processes inbound email and creates a bug, or appends data
571to an existing bug.
572
573=head2 Creating a New Bug
574
575The script expects to read an email with the following format:
576
577 From: account@domain.com
578 Subject: Bug Summary
579
580 @product ProductName
581 @component ComponentName
582 @version 1.0
583
584 This is a bug description. It will be entered into the bug exactly as
585 written here.
586
587 It can be multiple paragraphs.
588
589 --
590 This is a signature line, and will be removed automatically, It will not
591 be included in the bug description.
592
593For the list of valid field names for the C<@> fields, including
594a list of which ones are required, see L<Bugzilla::WebService::Bug/create>.
595(Note, however, that you cannot specify C<@description> as a field--
596you just add a comment by adding text after the C<@> fields.)
597
598The values for the fields can be split across multiple lines, but
599note that a newline will be parsed as a single space, for the value.
600So, for example:
601
602 @summary This is a very long
603 description
604
605Will be parsed as "This is a very long description".
606
607If you specify C<@summary>, it will override the summary you specify
608in the Subject header.
609
610C<account@domain.com> (the value of the C<From> header) must be a valid
611Bugzilla account.
612
613Note that signatures must start with '-- ', the standard signature
614border.
615
616=head2 Modifying an Existing Bug
617
618Bugzilla determines what bug you want to modify in one of two ways:
619
620=over
621
622=item *
623
624Your subject starts with [Bug 123456] -- then it modifies bug 123456.
625
626=item *
627
628You include C<@id 123456> in the first lines of the email.
629
630=back
631
632If you do both, C<@id> takes precedence.
633
634You send your email in the same format as for creating a bug, except
635that you only specify the fields you want to change. If the very
636first non-blank line of the email doesn't begin with C<@>, then it
637will be assumed that you are only adding a comment to the bug.
638
639Note that when updating a bug, the C<Subject> header is ignored,
640except for getting the bug ID. If you want to change the bug's summary,
641you have to specify C<@summary> as one of the fields to change.
642
643Please remember not to include any extra text in your emails, as that
644text will also be added as a comment. This includes any text that your
645email client automatically quoted and included, if this is a reply to
646another email.
647
648=head3 Adding/Removing CCs
649
650To add CCs, you can specify them in a comma-separated list in C<@cc>.
651
652To remove CCs, specify them as a comma-separated list in C<@removecc>.
653
654=head2 Errors
655
656If your request cannot be completed for any reason, Bugzilla will
657send an email back to you. If your request succeeds, Bugzilla will
658not send you anything.
659
660If any part of your request fails, all of it will fail. No partial
661changes will happen.
662
663=head1 CAUTION
664
665The script does not do any validation that the user is who they say
666they are. That is, it accepts I<any> 'From' address, as long as it's
667a valid Bugzilla account. So make sure that your MTA validates that
668the message is actually coming from who it says it's coming from,
669and only allow access to the inbound email system from people you trust.
670
671=head1 LIMITATIONS
672
673The email interface only accepts emails that are correctly formatted
674per RFC2822. If you send it an incorrectly formatted message, it
675may behave in an unpredictable fashion.
676
677You cannot modify Flags through the email interface.
678