BugMail.pm 20.6 KB
Newer Older
1 2 3
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
terry%netscape.com's avatar
terry%netscape.com committed
4
#
5 6
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.
7

8
package Bugzilla::BugMail;
terry%netscape.com's avatar
terry%netscape.com committed
9

10 11 12
use 5.10.1;
use strict;

13
use Bugzilla::Error;
14
use Bugzilla::User;
15
use Bugzilla::Constants;
16
use Bugzilla::Util;
17
use Bugzilla::Bug;
18
use Bugzilla::Comment;
19
use Bugzilla::Mailer;
20
use Bugzilla::Hook;
21

22 23
use Date::Parse;
use Date::Format;
24 25
use Scalar::Util qw(blessed);
use List::MoreUtils qw(uniq);
26
use Storable qw(dclone);
27

28 29 30
use constant BIT_DIRECT    => 1;
use constant BIT_WATCHING  => 2;

31 32 33 34 35 36 37 38 39
sub relationships {
    my $ref = RELATIONSHIPS;
    # Clone it so that we don't modify the constant;
    my %relationships = %$ref;
    Bugzilla::Hook::process('bugmail_relationships', 
                            { relationships => \%relationships });
    return %relationships;
}

40 41 42 43 44 45 46 47 48
# This is a bit of a hack, basically keeping the old system()
# cmd line interface. Should clean this up at some point.
#
# args: bug_id, and an optional hash ref which may have keys for:
# changer, owner, qa, reporter, cc
# Optional hash contains values of people which will be forced to those
# roles when the email is sent.
# All the names are email addresses, not userids
# values are scalars, except for cc, which is a list
49
sub Send {
50 51
    my ($id, $forced, $params) = @_;
    $params ||= {};
52

53
    my $dbh = Bugzilla->dbh;
54
    my $bug = new Bugzilla::Bug($id);
55

56 57
    my $start = $bug->lastdiffed;
    my $end   = $dbh->selectrow_array('SELECT LOCALTIMESTAMP(0)');
58

59 60 61 62
    # Bugzilla::User objects of people in various roles. More than one person
    # can 'have' a role, if the person in that role has changed, or people are
    # watching.
    my @assignees = ($bug->assigned_to);
63
    my @qa_contacts = $bug->qa_contact || ();
64

65
    my @ccs = @{ $bug->cc_users };
66 67 68 69 70
    # Include the people passed in as being in particular roles.
    # This can include people who used to hold those roles.
    # At this point, we don't care if there are duplicates in these arrays.
    my $changer = $forced->{'changer'};
    if ($forced->{'owner'}) {
71
        push (@assignees, Bugzilla::User->check($forced->{'owner'}));
72
    }
73
    
74
    if ($forced->{'qacontact'}) {
75
        push (@qa_contacts, Bugzilla::User->check($forced->{'qacontact'}));
76
    }
77 78 79
    
    if ($forced->{'cc'}) {
        foreach my $cc (@{$forced->{'cc'}}) {
80
            push(@ccs, Bugzilla::User->check($cc));
81 82
        }
    }
83
    my %user_cache = map { $_->id => $_ } (@assignees, @qa_contacts, @ccs);
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102

    my @diffs;
    if (!$start) {
        @diffs = _get_new_bugmail_fields($bug);
    }

    if ($params->{dep_only}) {
        push(@diffs, { field_name => 'bug_status',
                       old => $params->{changes}->{bug_status}->[0],
                       new => $params->{changes}->{bug_status}->[1],
                       login_name => $changer->login,
                       blocker => $params->{blocker} },
                     { field_name => 'resolution',
                       old => $params->{changes}->{resolution}->[0],
                       new => $params->{changes}->{resolution}->[1],
                       login_name => $changer->login,
                       blocker => $params->{blocker} });
    }
    else {
103
        push(@diffs, _get_diffs($bug, $end, \%user_cache));
104 105
    }

106
    my $comments = $bug->comments({ after => $start, to => $end });
107 108
    # Skip empty comments.
    @$comments = grep { $_->type || $_->body =~ /\S/ } @$comments;
109

110 111 112
    # If no changes have been made, there is no need to process further.
    return {'sent' => []} unless scalar(@diffs) || scalar(@$comments);

113
    ###########################################################################
114
    # Start of email filtering code
115
    ###########################################################################
116
    
117 118
    # A user_id => roles hash to keep track of people.
    my %recipients;
119
    my %watching;
120 121 122 123 124 125
    
    # Now we work out all the people involved with this bug, and note all of
    # the relationships in a hash. The keys are userids, the values are an
    # array of role constants.
    
    # CCs
126
    $recipients{$_->id}->{+REL_CC} = BIT_DIRECT foreach (@ccs);
127 128
    
    # Reporter (there's only ever one)
129
    $recipients{$bug->reporter->id}->{+REL_REPORTER} = BIT_DIRECT;
130 131
    
    # QA Contact
132
    if (Bugzilla->params->{'useqacontact'}) {
133 134
        foreach (@qa_contacts) {
            # QA Contact can be blank; ignore it if so.
135
            $recipients{$_->id}->{+REL_QA} = BIT_DIRECT if $_;
136
        }
137
    }
138

139
    # Assignee
140
    $recipients{$_->id}->{+REL_ASSIGNEE} = BIT_DIRECT foreach (@assignees);
141

142 143
    # The last relevant set of people are those who are being removed from 
    # their roles in this change. We get their names out of the diffs.
144 145
    foreach my $change (@diffs) {
        if ($change->{old}) {
146 147
            # You can't stop being the reporter, so we don't check that
            # relationship here.
148
            # Ignore people whose user account has been deleted or renamed.
149 150
            if ($change->{field_name} eq 'cc') {
                foreach my $cc_user (split(/[\s,]+/, $change->{old})) {
151
                    my $uid = login_to_id($cc_user);
152
                    $recipients{$uid}->{+REL_CC} = BIT_DIRECT if $uid;
153
                }
154
            }
155 156
            elsif ($change->{field_name} eq 'qa_contact') {
                my $uid = login_to_id($change->{old});
157
                $recipients{$uid}->{+REL_QA} = BIT_DIRECT if $uid;
158
            }
159 160
            elsif ($change->{field_name} eq 'assigned_to') {
                my $uid = login_to_id($change->{old});
161
                $recipients{$uid}->{+REL_ASSIGNEE} = BIT_DIRECT if $uid;
162
            }
163
        }
164
    }
165

166 167 168 169 170
    # Make sure %user_cache has every user in it so far referenced
    foreach my $user_id (keys %recipients) {
        $user_cache{$user_id} ||= new Bugzilla::User($user_id);
    }
    
171
    Bugzilla::Hook::process('bugmail_recipients',
172
                            { bug => $bug, recipients => \%recipients,
173
                              users => \%user_cache, diffs => \@diffs });
174

175 176 177 178 179 180 181 182 183 184 185 186 187
    # Find all those user-watching anyone on the current list, who is not
    # on it already themselves.
    my $involved = join(",", keys %recipients);

    my $userwatchers =
        $dbh->selectall_arrayref("SELECT watcher, watched FROM watch
                                  WHERE watched IN ($involved)");

    # Mark these people as having the role of the person they are watching
    foreach my $watch (@$userwatchers) {
        while (my ($role, $bits) = each %{$recipients{$watch->[1]}}) {
            $recipients{$watch->[0]}->{$role} |= BIT_WATCHING
                if $bits & BIT_DIRECT;
188
        }
189
        push(@{$watching{$watch->[0]}}, $watch->[1]);
190
    }
191 192 193 194 195 196 197 198 199

    # Global watcher
    my @watchers = split(/[,\s]+/, Bugzilla->params->{'globalwatchers'});
    foreach (@watchers) {
        my $watcher_id = login_to_id($_);
        next unless $watcher_id;
        $recipients{$watcher_id}->{+REL_GLOBAL_WATCHER} = BIT_DIRECT;
    }

200 201 202 203
    # We now have a complete set of all the users, and their relationships to
    # the bug in question. However, we are not necessarily going to mail them
    # all - there are preferences, permissions checks and all sorts to do yet.
    my @sent;
204

205 206 207 208 209
    # The email client will display the Date: header in the desired timezone,
    # so we can always use UTC here.
    my $date = $params->{dep_only} ? $end : $bug->delta_ts;
    $date = format_time($date, '%a, %d %b %Y %T %z', 'UTC');

210
    foreach my $user_id (keys %recipients) {
211
        my %rels_which_want;
212
        my $user = $user_cache{$user_id} ||= new Bugzilla::User($user_id);
213 214 215
        # Deleted users must be excluded.
        next unless $user;

216 217
        # If email notifications are disabled for this account, or the bug
        # is ignored, there is no need to do additional checks.
218
        next if ($user->email_disabled || $user->is_bug_ignored($id));
219

220
        if ($user->can_see_bug($id)) {
221 222
            # Go through each role the user has and see if they want mail in
            # that role.
223
            foreach my $relationship (keys %{$recipients{$user_id}}) {
224
                if ($user->wants_bug_mail($bug,
225
                                          $relationship, 
226
                                          $start ? \@diffs : [],
227
                                          $comments,
228
                                          $params->{dep_only},
229
                                          $changer))
230
                {
231 232
                    $rels_which_want{$relationship} = 
                        $recipients{$user_id}->{$relationship};
233 234
                }
            }
235
        }
236

237
        if (scalar(%rels_which_want)) {
238 239 240
            # So the user exists, can see the bug, and wants mail in at least
            # one role. But do we want to send it to them?

241 242 243
            # We shouldn't send mail if this is a dependency mail and the
            # depending bug is not visible to the user.
            # This is to avoid leaking the summary of a confidential bug.
244
            my $dep_ok = 1;
245 246
            if ($params->{dep_only}) {
                $dep_ok = $user->can_see_bug($params->{blocker}->id) ? 1 : 0;
247 248
            }

249 250
            # Email the user if the dep check passed.
            if ($dep_ok) {
251
                my $sent_mail = sendMail(
252 253 254
                    { to       => $user, 
                      bug      => $bug,
                      comments => $comments,
255
                      date     => $date,
256 257 258
                      changer  => $changer,
                      watchers => exists $watching{$user_id} ?
                                  $watching{$user_id} : undef,
259
                      diffs    => \@diffs,
260
                      rels_which_want => \%rels_which_want,
261
                      dep_only => $params->{dep_only}
262
                    });
263
                push(@sent, $user->login) if $sent_mail;
264
            }
265
        }
266
    }
267 268 269 270 271 272 273 274 275

    # When sending bugmail about a blocker being reopened or resolved,
    # we say nothing about changes in the bug being blocked, so we must
    # not update lastdiffed in this case.
    if (!$params->{dep_only}) {
        $dbh->do('UPDATE bugs SET lastdiffed = ? WHERE bug_id = ?',
                 undef, ($end, $id));
        $bug->{lastdiffed} = $end;
    }
276

277
    return {'sent' => \@sent};
278 279
}

280
sub sendMail {
281
    my $params = shift;
282

283 284 285
    my $user   = $params->{to};
    my $bug    = $params->{bug};
    my @send_comments = @{ $params->{comments} };
286
    my $date = $params->{date};
287 288
    my $changer = $params->{changer};
    my $watchingRef = $params->{watchers};
289
    my @diffs = @{ $params->{diffs} };
290
    my $relRef      = $params->{rels_which_want};
291
    my $dep_only = $params->{dep_only};
292

293 294
    # Only display changes the user is allowed see.
    my @display_diffs;
295

296 297
    foreach my $diff (@diffs) {
        my $add_diff = 0;
298

299
        if (grep { $_ eq $diff->{field_name} } TIMETRACKING_FIELDS) {
300
            $add_diff = 1 if $user->is_timetracker;
301
        }
302 303
        elsif (!$diff->{isprivate} || $user->is_insider) {
            $add_diff = 1;
304
        }
305
        push(@display_diffs, $diff) if $add_diff;
306
    }
307

308
    if (!$user->is_insider) {
309
        @send_comments = grep { !$_->is_private } @send_comments;
310 311
    }

312
    if (!scalar(@display_diffs) && !scalar(@send_comments)) {
313
      # Whoops, no differences!
314
      return 0;
315
    }
316

317 318 319 320
    my (@reasons, @reasons_watch);
    while (my ($relationship, $bits) = each %{$relRef}) {
        push(@reasons, $relationship) if ($bits & BIT_DIRECT);
        push(@reasons_watch, $relationship) if ($bits & BIT_WATCHING);
321
    }
322

323 324 325
    my %relationships = relationships();
    my @headerrel   = map { $relationships{$_} } @reasons;
    my @watchingrel = map { $relationships{$_} } @reasons_watch;
326 327
    push(@headerrel,   'None') unless @headerrel;
    push(@watchingrel, 'None') unless @watchingrel;
328
    push @watchingrel, map { user_id_to_login($_) } @$watchingRef;
329

330
    my @changedfields = uniq map { $_->{field_name} } @display_diffs;
331

332 333 334 335 336 337
    # Add attachments.created to changedfields if one or more
    # comments contain information about a new attachment
    if (grep($_->type == CMT_ATTACHMENT_CREATED, @send_comments)) {
        push(@changedfields, 'attachments.created');
    }

338 339 340 341
    my $bugmailtype = "changed";
    $bugmailtype = "new" if !$bug->lastdiffed;
    $bugmailtype = "dep_changed" if $dep_only;

342
    my $vars = {
343 344 345 346 347 348
        date               => $date,
        to_user            => $user,
        bug                => $bug,
        reasons            => \@reasons,
        reasons_watch      => \@reasons_watch,
        reasonsheader      => join(" ", @headerrel),
349
        reasonswatchheader => join(" ", @watchingrel),
350 351 352 353 354 355
        changer            => $changer,
        diffs              => \@display_diffs,
        changedfields      => \@changedfields,
        new_comments       => \@send_comments,
        threadingmarker    => build_thread_marker($bug->id, $user->id, !$bug->lastdiffed),
        bugmailtype        => $bugmailtype,
356
    };
357 358 359 360 361
    if (Bugzilla->params->{'use_mailer_queue'}) {
        enqueue($vars);
    } else {
        MessageToMTA(_generate_bugmail($vars));
    }
362 363 364 365

    return 1;
}

366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406
sub enqueue {
    my ($vars) = @_;
    # we need to flatten all objects to a hash before pushing to the job queue.
    # the hashes need to be inflated in the dequeue method.
    $vars->{bug}          = _flatten_object($vars->{bug});
    $vars->{to_user}      = $vars->{to_user}->flatten_to_hash;
    $vars->{changer}      = _flatten_object($vars->{changer});
    $vars->{new_comments} = [ map { _flatten_object($_) } @{ $vars->{new_comments} } ];
    foreach my $diff (@{ $vars->{diffs} }) {
        $diff->{who} = _flatten_object($diff->{who});
    }
    Bugzilla->job_queue->insert('bug_mail', { vars => $vars });
}

sub dequeue {
    my ($payload) = @_;
    # clone the payload so we can modify it without impacting TheSchwartz's
    # ability to process the job when we've finished
    my $vars = dclone($payload);
    # inflate objects
    $vars->{bug}          = Bugzilla::Bug->new_from_hash($vars->{bug});
    $vars->{to_user}      = Bugzilla::User->new_from_hash($vars->{to_user});
    $vars->{changer}      = Bugzilla::User->new_from_hash($vars->{changer});
    $vars->{new_comments} = [ map { Bugzilla::Comment->new_from_hash($_) } @{ $vars->{new_comments} } ];
    foreach my $diff (@{ $vars->{diffs} }) {
        $diff->{who} = Bugzilla::User->new_from_hash($diff->{who});
    }
    # generate bugmail and send
    MessageToMTA(_generate_bugmail($vars), 1);
}

sub _flatten_object {
    my ($object) = @_;
    # nothing to do if it's already flattened
    return $object unless blessed($object);
    # the same objects are used for each recipient, so cache the flattened hash
    my $cache = Bugzilla->request_cache->{bugmail_flat_objects} ||= {};
    my $key = blessed($object) . '-' . $object->id;
    return $cache->{$key} ||= $object->flatten_to_hash;
}

407
sub _generate_bugmail {
408 409
    my ($vars) = @_;
    my $user = $vars->{to_user};
410
    my $template = Bugzilla->template_inner($user->setting('lang'));
411
    my ($msg_text, $msg_html, $msg_header);
412

413 414 415 416
    $template->process("email/bugmail-header.txt.tmpl", $vars, \$msg_header)
        || ThrowTemplateError($template->error());
    $template->process("email/bugmail.txt.tmpl", $vars, \$msg_text)
        || ThrowTemplateError($template->error());
417

418 419 420 421 422 423
    my @parts = (
        Email::MIME->create(
            attributes => {
                content_type => "text/plain",
            },
            body => $msg_text,
424 425 426 427 428 429
        )
    );
    if ($user->setting('email_format') eq 'html') {
        $template->process("email/bugmail.html.tmpl", $vars, \$msg_html)
            || ThrowTemplateError($template->error());
        push @parts, Email::MIME->create(
430 431 432 433
            attributes => {
                content_type => "text/html",         
            },
            body => $msg_html,
434 435
        );
    }
436 437 438

    # TT trims the trailing newline, and threadingmarker may be ignored.
    my $email = new Email::MIME("$msg_header\n");
439 440 441 442
    if (scalar(@parts) == 1) {
        $email->content_type_set($parts[0]->content_type);
    } else {
        $email->content_type_set('multipart/alternative');
443 444
        # Some mail clients need same encoding for each part, even empty ones.
        $email->charset_set('UTF-8') if Bugzilla->params->{'utf8'};
445
    }
446 447 448 449
    $email->parts_set(\@parts);
    return $email;
}

450
sub _get_diffs {
451
    my ($bug, $end, $user_cache) = @_;
452 453 454 455 456 457 458 459 460 461 462
    my $dbh = Bugzilla->dbh;

    my @args = ($bug->id);
    # If lastdiffed is NULL, then we don't limit the search on time.
    my $when_restriction = '';
    if ($bug->lastdiffed) {
        $when_restriction = ' AND bug_when > ? AND bug_when <= ?';
        push @args, ($bug->lastdiffed, $end);
    }

    my $diffs = $dbh->selectall_arrayref(
463
           "SELECT fielddefs.name AS field_name,
464 465
                   bugs_activity.bug_when, bugs_activity.removed AS old,
                   bugs_activity.added AS new, bugs_activity.attach_id,
466
                   bugs_activity.comment_id, bugs_activity.who
467 468 469 470 471
              FROM bugs_activity
        INNER JOIN fielddefs
                ON fielddefs.id = bugs_activity.fieldid
             WHERE bugs_activity.bug_id = ?
                   $when_restriction
472 473
          ORDER BY bugs_activity.bug_when, bugs_activity.id",
        {Slice=>{}}, @args);
474 475

    foreach my $diff (@$diffs) {
476 477
        $user_cache->{$diff->{who}} ||= new Bugzilla::User($diff->{who}); 
        $diff->{who} =  $user_cache->{$diff->{who}};
478 479 480 481 482 483 484 485 486 487 488 489
        if ($diff->{attach_id}) {
            $diff->{isprivate} = $dbh->selectrow_array(
                'SELECT isprivate FROM attachments WHERE attach_id = ?',
                undef, $diff->{attach_id});
         }
         if ($diff->{field_name} eq 'longdescs.isprivate') {
             my $comment = Bugzilla::Comment->new($diff->{comment_id});
             $diff->{num} = $comment->count;
             $diff->{isprivate} = $diff->{new};
         }
    }

490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508
    my @changes = ();
    foreach my $diff (@$diffs) {
        # If this is the same field as the previous item, then concatenate
        # the data into the same change.
        if (scalar(@changes)
            && $diff->{field_name}        eq $changes[-1]->{field_name}
            && $diff->{bug_when}          eq $changes[-1]->{bug_when}
            && $diff->{who}               eq $changes[-1]->{who}
            && ($diff->{attach_id} // 0)  == ($changes[-1]->{attach_id} // 0)
            && ($diff->{comment_id} // 0) == ($changes[-1]->{comment_id} // 0)
        ) {
            my $old_change = pop @changes;
            $diff->{old} = join_activity_entries($diff->{field_name}, $old_change->{old}, $diff->{old});
            $diff->{new} = join_activity_entries($diff->{field_name}, $old_change->{new}, $diff->{new});
        }
        push @changes, $diff;
    }

    return @changes;
509 510 511 512
}

sub _get_new_bugmail_fields {
    my $bug = shift;
513
    my @fields = @{ Bugzilla->fields({obsolete => 0, in_new_bugmail => 1}) };
514
    my @diffs;
515
    my $params = Bugzilla->params;
516 517 518 519 520

    foreach my $field (@fields) {
        my $name = $field->name;
        my $value = $bug->$name;

521 522 523 524 525 526
        next if !$field->is_visible_on_bug($bug)
            || ($name eq 'classification' && !$params->{'useclassification'})
            || ($name eq 'status_whiteboard' && !$params->{'usestatuswhiteboard'})
            || ($name eq 'qa_contact' && !$params->{'useqacontact'})
            || ($name eq 'target_milestone' && !$params->{'usetargetmilestone'});

527 528
        if (ref $value eq 'ARRAY') {
            $value = join(', ', @$value);
529
        }
530 531
        elsif (blessed($value) && $value->isa('Bugzilla::User')) {
            $value = $value->login;
532
        }
533 534 535 536 537 538 539
        elsif (blessed($value) && $value->isa('Bugzilla::Object')) {
            $value = $value->name;
        }
        elsif ($name eq 'estimated_time') {
            # "0.00" (which is what we get from the DB) is true,
            # so we explicitly do a numerical comparison with 0.
            $value = 0 if $value == 0;
540
        }
541 542
        elsif ($name eq 'deadline') {
            $value = time2str("%Y-%m-%d", str2time($value)) if $value;
543
        }
544 545 546 547 548

        # If there isn't anything to show, don't include this header.
        next unless $value;

        push(@diffs, {field_name => $name, new => $value});
549 550
    }

551
    return @diffs;
552 553
}

554
1;
555

556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576
=head1 NAME

BugMail - Routines to generate email notifications when a bug is created or
modified.

=head1 METHODS

=over 4

=item C<enqueue>

Serialises the variables required to generate bugmail and pushes the result to
the job-queue for processing by TheSchwartz.

=item C<dequeue>

When given serialised variables from the job-queue, recreates the objects from
the flattened hashes, generates the bugmail, and sends it.

=back

577 578 579 580 581 582 583 584 585 586 587
=head1 B<Methods in need of POD>

=over

=item relationships

=item sendMail

=item Send

=back