BugMail.pm 16.5 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
use strict;
terry%netscape.com's avatar
terry%netscape.com committed
9

10
package Bugzilla::BugMail;
terry%netscape.com's avatar
terry%netscape.com committed
11

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

21 22
use Date::Parse;
use Date::Format;
23 24
use Scalar::Util qw(blessed);
use List::MoreUtils qw(uniq);
25

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

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

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

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

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

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

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

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

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

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

134
    # Assignee
135
    $recipients{$_->id}->{+REL_ASSIGNEE} = BIT_DIRECT foreach (@assignees);
136

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

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

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

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

195 196 197 198 199
    # 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;
    my @excluded;
200

201 202 203 204 205
    # 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');

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

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

235 236 237
            # 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.
238
            my $dep_ok = 1;
239 240
            if ($params->{dep_only}) {
                $dep_ok = $user->can_see_bug($params->{blocker}->id) ? 1 : 0;
241 242
            }

243
            # Make sure the user isn't in the nomail list, and the dep check passed.
244
            if ($user->email_enabled && $dep_ok) {
245
                # OK, OK, if we must. Email the user.
246 247 248 249
                $sent_mail = sendMail(
                    { to       => $user, 
                      bug      => $bug,
                      comments => $comments,
250
                      date     => $date,
251 252 253
                      changer  => $changer,
                      watchers => exists $watching{$user_id} ?
                                  $watching{$user_id} : undef,
254
                      diffs    => \@diffs,
255 256
                      rels_which_want => \%rels_which_want,
                    });
257
            }
258
        }
259

260 261 262 263 264 265 266
        if ($sent_mail) {
            push(@sent, $user->login); 
        } 
        else {
            push(@excluded, $user->login); 
        } 
    }
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, 'excluded' => \@excluded};
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 291
    my $relRef      = $params->{rels_which_want};

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

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

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

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

316 317 318 319
    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);
320
    }
321

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

329 330 331 332 333 334 335 336
    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');
    }

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

    return 1;
}

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

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

    # TT trims the trailing newline, and threadingmarker may be ignored.
    my $email = new Email::MIME("$msg_header\n");
388 389 390 391 392
    if (scalar(@parts) == 1) {
        $email->content_type_set($parts[0]->content_type);
    } else {
        $email->content_type_set('multipart/alternative');
    }
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 421
          ORDER BY bugs_activity.bug_when", {Slice=>{}}, @args);

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

    return @$diffs;
}

sub _get_new_bugmail_fields {
    my $bug = shift;
441
    my @fields = @{ Bugzilla->fields({obsolete => 0, in_new_bugmail => 1}) };
442 443 444 445 446 447 448 449
    my @diffs;

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

        if (ref $value eq 'ARRAY') {
            $value = join(', ', @$value);
450
        }
451 452
        elsif (blessed($value) && $value->isa('Bugzilla::User')) {
            $value = $value->login;
453
        }
454 455 456 457 458 459 460
        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;
461
        }
462 463
        elsif ($name eq 'deadline') {
            $value = time2str("%Y-%m-%d", str2time($value)) if $value;
464
        }
465 466 467 468 469

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

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

472
    return @diffs;
473 474
}

475
1;