BugMail.pm 18 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

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

30 31 32 33 34 35 36 37 38
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;
}

39 40 41 42 43 44 45 46 47
# 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
48
sub Send {
49 50
    my ($id, $forced, $params) = @_;
    $params ||= {};
51

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

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

58 59 60 61
    # 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);
62
    my @qa_contacts = $bug->qa_contact || ();
63

64
    my @ccs = @{ $bug->cc_users };
65 66 67 68 69
    # 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'}) {
70
        push (@assignees, Bugzilla::User->check($forced->{'owner'}));
71
    }
72
    
73
    if ($forced->{'qacontact'}) {
74
        push (@qa_contacts, Bugzilla::User->check($forced->{'qacontact'}));
75
    }
76 77 78
    
    if ($forced->{'cc'}) {
        foreach my $cc (@{$forced->{'cc'}}) {
79
            push(@ccs, Bugzilla::User->check($cc));
80 81
        }
    }
82
    my %user_cache = map { $_->id => $_ } (@assignees, @qa_contacts, @ccs);
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101

    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 {
102
        push(@diffs, _get_diffs($bug, $end, \%user_cache));
103 104
    }

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

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

112
    ###########################################################################
113
    # Start of email filtering code
114
    ###########################################################################
115
    
116 117
    # A user_id => roles hash to keep track of people.
    my %recipients;
118
    my %watching;
119 120 121 122 123 124
    
    # 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
125
    $recipients{$_->id}->{+REL_CC} = BIT_DIRECT foreach (@ccs);
126 127
    
    # Reporter (there's only ever one)
128
    $recipients{$bug->reporter->id}->{+REL_REPORTER} = BIT_DIRECT;
129 130
    
    # QA Contact
131
    if (Bugzilla->params->{'useqacontact'}) {
132 133
        foreach (@qa_contacts) {
            # QA Contact can be blank; ignore it if so.
134
            $recipients{$_->id}->{+REL_QA} = BIT_DIRECT if $_;
135
        }
136
    }
137

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

141 142
    # 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.
143 144
    foreach my $change (@diffs) {
        if ($change->{old}) {
145 146
            # You can't stop being the reporter, so we don't check that
            # relationship here.
147
            # Ignore people whose user account has been deleted or renamed.
148 149
            if ($change->{field_name} eq 'cc') {
                foreach my $cc_user (split(/[\s,]+/, $change->{old})) {
150
                    my $uid = login_to_id($cc_user);
151
                    $recipients{$uid}->{+REL_CC} = BIT_DIRECT if $uid;
152
                }
153
            }
154 155
            elsif ($change->{field_name} eq 'qa_contact') {
                my $uid = login_to_id($change->{old});
156
                $recipients{$uid}->{+REL_QA} = BIT_DIRECT if $uid;
157
            }
158 159
            elsif ($change->{field_name} eq 'assigned_to') {
                my $uid = login_to_id($change->{old});
160
                $recipients{$uid}->{+REL_ASSIGNEE} = BIT_DIRECT if $uid;
161
            }
162
        }
163
    }
164

165 166 167 168 169
    # 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);
    }
    
170
    Bugzilla::Hook::process('bugmail_recipients',
171
                            { bug => $bug, recipients => \%recipients,
172
                              users => \%user_cache, diffs => \@diffs });
173

174 175 176 177 178 179 180 181 182 183 184 185 186
    # 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;
187
        }
188
        push(@{$watching{$watch->[0]}}, $watch->[1]);
189
    }
190 191 192 193 194 195 196 197 198

    # 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;
    }

199 200 201 202
    # 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;
203

204 205 206 207 208
    # 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');

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

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

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

236
        if (scalar(%rels_which_want)) {
237 238 239
            # 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?

240 241 242
            # 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.
243
            my $dep_ok = 1;
244 245
            if ($params->{dep_only}) {
                $dep_ok = $user->can_see_bug($params->{blocker}->id) ? 1 : 0;
246 247
            }

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

    # 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;
    }
274

275
    return {'sent' => \@sent};
276 277
}

278
sub sendMail {
279
    my $params = shift;
280
    
281 282 283
    my $user   = $params->{to};
    my $bug    = $params->{bug};
    my @send_comments = @{ $params->{comments} };
284
    my $date = $params->{date};
285 286
    my $changer = $params->{changer};
    my $watchingRef = $params->{watchers};
287
    my @diffs = @{ $params->{diffs} };
288 289
    my $relRef      = $params->{rels_which_want};

290 291
    # Only display changes the user is allowed see.
    my @display_diffs;
292

293 294
    foreach my $diff (@diffs) {
        my $add_diff = 0;
295
        
296
        if (grep { $_ eq $diff->{field_name} } TIMETRACKING_FIELDS) {
297
            $add_diff = 1 if $user->is_timetracker;
298
        }
299 300
        elsif (!$diff->{isprivate} || $user->is_insider) {
            $add_diff = 1;
301
        }
302
        push(@display_diffs, $diff) if $add_diff;
303
    }
304

305
    if (!$user->is_insider) {
306
        @send_comments = grep { !$_->is_private } @send_comments;
307 308
    }

309
    if (!scalar(@display_diffs) && !scalar(@send_comments)) {
310
      # Whoops, no differences!
311
      return 0;
312
    }
313

314 315 316 317
    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);
318
    }
319

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

327 328 329 330 331 332 333 334
    my @changedfields = uniq map { $_->{field_name} } @display_diffs;
    
    # 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');
    }

335
    my $vars = {
336
        date => $date,
337
        to_user => $user,
338
        bug => $bug,
339 340
        reasons => \@reasons,
        reasons_watch => \@reasons_watch,
341 342
        reasonsheader => join(" ", @headerrel),
        reasonswatchheader => join(" ", @watchingrel),
343
        changer => $changer,
344
        diffs => \@display_diffs,
345
        changedfields => \@changedfields, 
346
        new_comments => \@send_comments,
347
        threadingmarker => build_thread_marker($bug->id, $user->id, !$bug->lastdiffed),
348
    };
349
    my $msg =  _generate_bugmail($user, $vars);
350 351 352 353 354
    MessageToMTA($msg);

    return 1;
}

355 356
sub _generate_bugmail {
    my ($user, $vars) = @_;
357
    my $template = Bugzilla->template_inner($user->setting('lang'));
358 359 360 361 362 363
    my ($msg_text, $msg_html, $msg_header);
  
    $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());
364

365 366 367 368 369 370
    my @parts = (
        Email::MIME->create(
            attributes => {
                content_type => "text/plain",
            },
            body => $msg_text,
371 372 373 374 375 376
        )
    );
    if ($user->setting('email_format') eq 'html') {
        $template->process("email/bugmail.html.tmpl", $vars, \$msg_html)
            || ThrowTemplateError($template->error());
        push @parts, Email::MIME->create(
377 378 379 380
            attributes => {
                content_type => "text/html",         
            },
            body => $msg_html,
381 382
        );
    }
383 384 385

    # TT trims the trailing newline, and threadingmarker may be ignored.
    my $email = new Email::MIME("$msg_header\n");
386 387 388 389
    if (scalar(@parts) == 1) {
        $email->content_type_set($parts[0]->content_type);
    } else {
        $email->content_type_set('multipart/alternative');
390 391
        # Some mail clients need same encoding for each part, even empty ones.
        $email->charset_set('UTF-8') if Bugzilla->params->{'utf8'};
392
    }
393 394 395 396
    $email->parts_set(\@parts);
    return $email;
}

397
sub _get_diffs {
398
    my ($bug, $end, $user_cache) = @_;
399 400 401 402 403 404 405 406 407 408 409
    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(
410
           "SELECT fielddefs.name AS field_name,
411 412
                   bugs_activity.bug_when, bugs_activity.removed AS old,
                   bugs_activity.added AS new, bugs_activity.attach_id,
413
                   bugs_activity.comment_id, bugs_activity.who
414 415 416 417 418
              FROM bugs_activity
        INNER JOIN fielddefs
                ON fielddefs.id = bugs_activity.fieldid
             WHERE bugs_activity.bug_id = ?
                   $when_restriction
419 420
          ORDER BY bugs_activity.bug_when, bugs_activity.id",
        {Slice=>{}}, @args);
421 422

    foreach my $diff (@$diffs) {
423 424
        $user_cache->{$diff->{who}} ||= new Bugzilla::User($diff->{who}); 
        $diff->{who} =  $user_cache->{$diff->{who}};
425 426 427 428 429 430 431 432 433 434 435 436
        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};
         }
    }

437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455
    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;
456 457 458 459
}

sub _get_new_bugmail_fields {
    my $bug = shift;
460
    my @fields = @{ Bugzilla->fields({obsolete => 0, in_new_bugmail => 1}) };
461
    my @diffs;
462
    my $params = Bugzilla->params;
463 464 465 466 467

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

468 469 470 471 472 473
        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'});

474 475
        if (ref $value eq 'ARRAY') {
            $value = join(', ', @$value);
476
        }
477 478
        elsif (blessed($value) && $value->isa('Bugzilla::User')) {
            $value = $value->login;
479
        }
480 481 482 483 484 485 486
        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;
487
        }
488 489
        elsif ($name eq 'deadline') {
            $value = time2str("%Y-%m-%d", str2time($value)) if $value;
490
        }
491 492 493 494 495

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

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

498
    return @diffs;
499 500
}

501
1;
502 503 504 505 506 507 508 509 510 511 512 513

=head1 B<Methods in need of POD>

=over

=item relationships

=item sendMail

=item Send

=back