1 # -*- Mode: perl; indent-tabs-mode: nil -*-
3 # The contents of this file are subject to the Mozilla Public
4 # License Version 1.1 (the "License"); you may not use this file
5 # except in compliance with the License. You may obtain a copy of
6 # the License at http://www.mozilla.org/MPL/
8 # Software distributed under the License is distributed on an "AS
9 # IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
10 # implied. See the License for the specific language governing
11 # rights and limitations under the License.
13 # The Original Code is the Bugzilla Bug Tracking System.
15 # The Initial Developer of the Original Code is Netscape Communications
16 # Corporation. Portions created by Netscape are
17 # Copyright (C) 1998 Netscape Communications Corporation. All
20 # Contributor(s): Terry Weissman <terry@mozilla.org>,
21 # Bryce Nesbitt <bryce-mozilla@nextbus.com>
22 # Dan Mosedale <dmose@mozilla.org>
23 # Alan Raetz <al_raetz@yahoo.com>
24 # Jacob Steenhagen <jake@actex.net>
25 # Matthew Tuck <matty@chariot.net.au>
26 # Bradley Baetz <bbaetz@student.usyd.edu.au>
27 # J. Paul Reed <preed@sigkill.com>
28 # Gervase Markham <gerv@gerv.net>
29 # Byron Jones <bugzilla@glob.com.au>
33 package Bugzilla::BugMail;
37 use Bugzilla::Constants;
40 use Bugzilla::Classification;
41 use Bugzilla::Product;
42 use Bugzilla::Component;
49 use constant FORMAT_TRIPLE => "%19s|%-28s|%-28s";
50 use constant FORMAT_3_SIZE => [19,28,28];
51 use constant FORMAT_DOUBLE => "%19s %-55s";
52 use constant FORMAT_2_SIZE => [19,55];
54 use constant BIT_DIRECT => 1;
55 use constant BIT_WATCHING => 2;
57 # We need these strings for the X-Bugzilla-Reasons header
58 # Note: this hash uses "," rather than "=>" to avoid auto-quoting of the LHS.
59 use constant REL_NAMES => {
60 REL_ASSIGNEE , "AssignedTo",
61 REL_REPORTER , "Reporter",
65 REL_GLOBAL_WATCHER, "GlobalWatcher"
68 # We use this instead of format because format doesn't deal well with
69 # multi-byte languages.
70 sub multiline_sprintf {
71 my ($format, $args, $sizes) = @_;
73 my @my_sizes = @$sizes; # Copy this so we don't modify the input array.
74 foreach my $string (@$args) {
75 my $size = shift @my_sizes;
76 my @pieces = split("\n", wrap_hard($string, $size));
77 push(@parts, \@pieces);
82 # Get the first item of each part.
83 my @line = map { shift @$_ } @parts;
84 # If they're all undef, we're done.
85 last if !grep { defined $_ } @line;
86 # Make any single undef item into ''
87 @line = map { defined $_ ? $_ : '' } @line;
88 # And append a formatted line
89 $formatted .= sprintf($format, @line);
90 # Remove trailing spaces, or they become lots of =20's in
91 # quoted-printable emails.
92 $formatted =~ s/\s+$//;
99 return multiline_sprintf(FORMAT_TRIPLE, \@_, FORMAT_3_SIZE);
102 # This is a bit of a hack, basically keeping the old system()
103 # cmd line interface. Should clean this up at some point.
105 # args: bug_id, and an optional hash ref which may have keys for:
106 # changer, owner, qa, reporter, cc
107 # Optional hash contains values of people which will be forced to those
108 # roles when the email is sent.
109 # All the names are email addresses, not userids
110 # values are scalars, except for cc, which is a list
111 # This hash usually comes from the "mailrecipients" var in a template call.
113 my ($id, $forced) = (@_);
117 my %fielddescription;
121 my $dbh = Bugzilla->dbh;
123 # XXX - These variables below are useless. We could use field object
124 # methods directly. But we first have to implement a cache in
125 # Bugzilla->get_fields to avoid querying the DB all the time.
126 foreach my $field (Bugzilla->get_fields({obsolete => 0})) {
127 push(@headerlist, $field->name);
128 $defmailhead{$field->name} = $field->in_new_bugmail;
129 $fielddescription{$field->name} = $field->description;
132 my %values = %{$dbh->selectrow_hashref(
133 'SELECT ' . join(',', editable_bug_fields()) . ', reporter,
134 lastdiffed AS start_time, LOCALTIMESTAMP(0) AS end_time
135 FROM bugs WHERE bug_id = ?',
138 my $product = new Bugzilla::Product($values{product_id});
139 $values{product} = $product->name;
140 if (Bugzilla->params->{'useclassification'}) {
141 $values{classification} = Bugzilla::Classification->new($product->classification_id)->name;
143 my $component = new Bugzilla::Component($values{component_id});
144 $values{component} = $component->name;
146 my ($start, $end) = ($values{start_time}, $values{end_time});
148 # User IDs of people in various roles. More than one person can 'have' a
149 # role, if the person in that role has changed, or people are watching.
150 my $reporter = $values{'reporter'};
151 my @assignees = ($values{'assigned_to'});
152 my @qa_contacts = ($values{'qa_contact'});
154 my $cc_users = $dbh->selectall_arrayref(
155 "SELECT cc.who, profiles.login_name
158 ON cc.who = profiles.userid
162 my (@ccs, @cc_login_names);
163 foreach my $cc_user (@$cc_users) {
164 my ($user_id, $user_login) = @$cc_user;
165 push (@ccs, $user_id);
166 push (@cc_login_names, $user_login);
169 # Include the people passed in as being in particular roles.
170 # This can include people who used to hold those roles.
171 # At this point, we don't care if there are duplicates in these arrays.
172 my $changer = $forced->{'changer'};
173 if ($forced->{'owner'}) {
174 push (@assignees, login_to_id($forced->{'owner'}, THROW_ERROR));
177 if ($forced->{'qacontact'}) {
178 push (@qa_contacts, login_to_id($forced->{'qacontact'}, THROW_ERROR));
181 if ($forced->{'cc'}) {
182 foreach my $cc (@{$forced->{'cc'}}) {
183 push(@ccs, login_to_id($cc, THROW_ERROR));
187 # Convert to names, for later display
188 $values{'changer'} = $changer;
189 # If no changer is specified, then it has no name.
191 $values{'changername'} = Bugzilla::User->new({name => $changer})->name;
193 $values{'assigned_to'} = user_id_to_login($values{'assigned_to'});
194 $values{'reporter'} = user_id_to_login($values{'reporter'});
195 if ($values{'qa_contact'}) {
196 $values{'qa_contact'} = user_id_to_login($values{'qa_contact'});
198 $values{'cc'} = join(', ', @cc_login_names);
199 $values{'estimated_time'} = format_time_decimal($values{'estimated_time'});
201 if ($values{'deadline'}) {
202 $values{'deadline'} = time2str("%Y-%m-%d", str2time($values{'deadline'}));
205 my $dependslist = $dbh->selectcol_arrayref(
206 'SELECT dependson FROM dependencies
207 WHERE blocked = ? ORDER BY dependson',
210 $values{'dependson'} = join(",", @$dependslist);
212 my $blockedlist = $dbh->selectcol_arrayref(
213 'SELECT blocked FROM dependencies
214 WHERE dependson = ? ORDER BY blocked',
217 $values{'blocked'} = join(",", @$blockedlist);
221 # If lastdiffed is NULL, then we don't limit the search on time.
222 my $when_restriction = '';
224 $when_restriction = ' AND bug_when > ? AND bug_when <= ?';
225 push @args, ($start, $end);
228 my $diffs = $dbh->selectall_arrayref(
229 "SELECT profiles.login_name, profiles.realname, fielddefs.description,
230 bugs_activity.bug_when, bugs_activity.removed,
231 bugs_activity.added, bugs_activity.attach_id, fielddefs.name
234 ON fielddefs.id = bugs_activity.fieldid
236 ON profiles.userid = bugs_activity.who
237 WHERE bugs_activity.bug_id = ?
239 ORDER BY bugs_activity.bug_when", undef, @args);
248 foreach my $ref (@$diffs) {
249 my ($who, $whoname, $what, $when, $old, $new, $attachid, $fieldname) = (@$ref);
251 if ($who ne $lastwho) {
253 $fullwho = $whoname ? "$whoname <$who>" : $who;
254 $diffheader = "\n$fullwho changed:\n\n";
255 $diffheader .= three_columns("What ", "Removed", "Added");
256 $diffheader .= ('-' x 76) . "\n";
258 $what =~ s/^(Attachment )?/Attachment #$attachid / if $attachid;
259 if( $fieldname eq 'estimated_time' ||
260 $fieldname eq 'remaining_time' ) {
261 $old = format_time_decimal($old);
262 $new = format_time_decimal($new);
264 if ($fieldname eq 'dependson') {
265 push(@new_depbugs, grep {$_ =~ /^\d+$/} split(/[\s,]+/, $new));
268 ($diffpart->{'isprivate'}) = $dbh->selectrow_array(
269 'SELECT isprivate FROM attachments WHERE attach_id = ?',
272 $difftext = three_columns($what, $old, $new);
273 $diffpart->{'header'} = $diffheader;
274 $diffpart->{'fieldname'} = $fieldname;
275 $diffpart->{'text'} = $difftext;
276 push(@diffparts, $diffpart);
277 push(@changedfields, $what);
279 $values{'changed_fields'} = join(' ', @changedfields);
283 # Do not include data about dependent bugs when they have just been added.
284 # Completely skip checking for dependent bugs on bug creation as all
285 # dependencies bugs will just have been added.
287 my $dep_restriction = "";
288 if (scalar @new_depbugs) {
289 $dep_restriction = "AND bugs_activity.bug_id NOT IN (" .
290 join(", ", @new_depbugs) . ")";
293 my $dependency_diffs = $dbh->selectall_arrayref(
294 "SELECT bugs_activity.bug_id, bugs.short_desc, fielddefs.name,
295 bugs_activity.removed, bugs_activity.added
298 ON bugs.bug_id = bugs_activity.bug_id
299 INNER JOIN dependencies
300 ON bugs_activity.bug_id = dependencies.dependson
302 ON fielddefs.id = bugs_activity.fieldid
303 WHERE dependencies.blocked = ?
304 AND (fielddefs.name = 'bug_status'
305 OR fielddefs.name = 'resolution')
308 ORDER BY bugs_activity.bug_when, bugs.bug_id", undef, @args);
312 my $interestingchange = 0;
313 foreach my $dependency_diff (@$dependency_diffs) {
314 my ($depbug, $summary, $what, $old, $new) = @$dependency_diff;
316 if ($depbug ne $lastbug) {
317 if ($interestingchange) {
318 $deptext .= $thisdiff;
321 my $urlbase = Bugzilla->params->{"urlbase"};
323 "\nBug $id depends on bug $depbug, which changed state.\n\n" .
324 "Bug $depbug Summary: $summary\n" .
325 "${urlbase}show_bug.cgi?id=$depbug\n\n";
326 $thisdiff .= three_columns("What ", "Old Value", "New Value");
327 $thisdiff .= ('-' x 76) . "\n";
328 $interestingchange = 0;
330 $thisdiff .= three_columns($fielddescription{$what}, $old, $new);
331 if ($what eq 'bug_status'
332 && is_open_state($old) ne is_open_state($new))
334 $interestingchange = 1;
336 push(@depbugs, $depbug);
339 if ($interestingchange) {
340 $deptext .= $thisdiff;
342 $deptext = trim($deptext);
346 $diffpart->{'text'} = "\n" . trim("\n\n" . $deptext);
347 push(@diffparts, $diffpart);
351 my ($raw_comments, $anyprivate, $count) = get_comments_by_bug($id, $start, $end);
353 ###########################################################################
354 # Start of email filtering code
355 ###########################################################################
357 # A user_id => roles hash to keep track of people.
361 # Now we work out all the people involved with this bug, and note all of
362 # the relationships in a hash. The keys are userids, the values are an
363 # array of role constants.
366 my $voters = $dbh->selectcol_arrayref(
367 "SELECT who FROM votes WHERE bug_id = ?", undef, ($id));
369 $recipients{$_}->{+REL_VOTER} = BIT_DIRECT foreach (@$voters);
372 $recipients{$_}->{+REL_CC} = BIT_DIRECT foreach (@ccs);
374 # Reporter (there's only ever one)
375 $recipients{$reporter}->{+REL_REPORTER} = BIT_DIRECT;
378 if (Bugzilla->params->{'useqacontact'}) {
379 foreach (@qa_contacts) {
380 # QA Contact can be blank; ignore it if so.
381 $recipients{$_}->{+REL_QA} = BIT_DIRECT if $_;
386 $recipients{$_}->{+REL_ASSIGNEE} = BIT_DIRECT foreach (@assignees);
388 # The last relevant set of people are those who are being removed from
389 # their roles in this change. We get their names out of the diffs.
390 foreach my $ref (@$diffs) {
391 my ($who, $whoname, $what, $when, $old, $new) = (@$ref);
393 # You can't stop being the reporter, and mail isn't sent if you
395 # Ignore people whose user account has been deleted or renamed.
397 foreach my $cc_user (split(/[\s,]+/, $old)) {
398 my $uid = login_to_id($cc_user);
399 $recipients{$uid}->{+REL_CC} = BIT_DIRECT if $uid;
402 elsif ($what eq "QAContact") {
403 my $uid = login_to_id($old);
404 $recipients{$uid}->{+REL_QA} = BIT_DIRECT if $uid;
406 elsif ($what eq "AssignedTo") {
407 my $uid = login_to_id($old);
408 $recipients{$uid}->{+REL_ASSIGNEE} = BIT_DIRECT if $uid;
413 if (Bugzilla->params->{"supportwatchers"}) {
414 # Find all those user-watching anyone on the current list, who is not
415 # on it already themselves.
416 my $involved = join(",", keys %recipients);
419 $dbh->selectall_arrayref("SELECT watcher, watched FROM watch
420 WHERE watched IN ($involved)");
422 # Mark these people as having the role of the person they are watching
423 foreach my $watch (@$userwatchers) {
424 while (my ($role, $bits) = each %{$recipients{$watch->[1]}}) {
425 $recipients{$watch->[0]}->{$role} |= BIT_WATCHING
426 if $bits & BIT_DIRECT;
428 push (@{$watching{$watch->[0]}}, $watch->[1]);
433 my @watchers = split(/[,\s]+/, Bugzilla->params->{'globalwatchers'});
434 foreach (@watchers) {
435 my $watcher_id = login_to_id($_);
436 next unless $watcher_id;
437 $recipients{$watcher_id}->{+REL_GLOBAL_WATCHER} = BIT_DIRECT;
440 # We now have a complete set of all the users, and their relationships to
441 # the bug in question. However, we are not necessarily going to mail them
442 # all - there are preferences, permissions checks and all sorts to do yet.
446 # Some comments are language specific. We cache them here.
449 foreach my $user_id (keys %recipients) {
453 my $user = new Bugzilla::User($user_id);
454 # Deleted users must be excluded.
457 # What's the language chosen by this user for email?
458 my $lang = $user->settings->{'lang'}->{'value'};
460 if ($user->can_see_bug($id)) {
461 # It's time to format language specific comments.
462 unless (exists $comments{$lang}) {
463 Bugzilla->template_inner($lang);
464 $comments{$lang} = prepare_comments($raw_comments, $count);
465 Bugzilla->template_inner("");
468 # Go through each role the user has and see if they want mail in
470 foreach my $relationship (keys %{$recipients{$user_id}}) {
471 if ($user->wants_bug_mail($id,
479 $rels_which_want{$relationship} =
480 $recipients{$user_id}->{$relationship};
485 if (scalar(%rels_which_want)) {
486 # So the user exists, can see the bug, and wants mail in at least
487 # one role. But do we want to send it to them?
489 # If we are using insiders, and the comment is private, only send
492 $insider_ok = 0 if (Bugzilla->params->{"insidergroup"} &&
493 ($anyprivate != 0) &&
494 (!$user->groups->{Bugzilla->params->{"insidergroup"}}));
496 # We shouldn't send mail if this is a dependency mail (i.e. there
497 # is something in @depbugs), and any of the depending bugs are not
498 # visible to the user. This is to avoid leaking the summaries of
501 foreach my $dep_id (@depbugs) {
502 if (!$user->can_see_bug($dep_id)) {
508 # Make sure the user isn't in the nomail list, and the insider and
510 if ($user->email_enabled &&
514 # OK, OK, if we must. Email the user.
515 $sent_mail = sendMail($user,
526 exists $watching{$user_id} ?
527 $watching{$user_id} : undef);
532 push(@sent, $user->login);
535 push(@excluded, $user->login);
539 $dbh->do('UPDATE bugs SET lastdiffed = ? WHERE bug_id = ?',
542 return {'sent' => \@sent, 'excluded' => \@excluded};
546 my ($user, $hlRef, $relRef, $valueRef, $dmhRef, $fdRef,
547 $diffRef, $newcomments, $anyprivate, $isnew,
548 $id, $watchingRef) = @_;
550 my %values = %$valueRef;
551 my @headerlist = @$hlRef;
552 my %mailhead = %$dmhRef;
553 my %fielddescription = %$fdRef;
554 my @diffparts = @$diffRef;
556 # Build difftext (the actions) by verifying the user should see them
561 foreach my $diff (@diffparts) {
564 if (exists($diff->{'fieldname'}) &&
565 ($diff->{'fieldname'} eq 'estimated_time' ||
566 $diff->{'fieldname'} eq 'remaining_time' ||
567 $diff->{'fieldname'} eq 'work_time' ||
568 $diff->{'fieldname'} eq 'deadline')){
569 if ($user->groups->{Bugzilla->params->{"timetrackinggroup"}}) {
572 } elsif (($diff->{'isprivate'})
573 && Bugzilla->params->{'insidergroup'}
574 && !($user->groups->{Bugzilla->params->{'insidergroup'}})
578 # If the only thing we are modifying is the in-rietveld flag, don't
579 # include this diff. If multiple flags are being modified,
580 # the diff text will have a comma seperating it.
581 # This will prevent mail from being sent.
582 } elsif ($diff->{'text'} =~ /in-rietveld/ && !($diff->{'text'} =~ /,/)) {
584 #endif // WEBKIT_CHANGES
590 if (exists($diff->{'header'}) &&
591 ($diffheader ne $diff->{'header'})) {
592 $diffheader = $diff->{'header'};
593 $difftext .= $diffheader;
595 $difftext .= $diff->{'text'};
599 if ($difftext eq "" && $newcomments eq "" && !$isnew) {
600 # Whoops, no differences!
604 # If an attachment was created, then add an URL. (Note: the 'g'lobal
605 # replace should work with comments with multiple attachments.)
607 if ( $newcomments =~ /Created an attachment \(/ ) {
609 my $showattachurlbase =
610 Bugzilla->params->{'urlbase'} . "attachment.cgi?id=";
612 $newcomments =~ s/(Created an attachment \(id=([0-9]+)\))/$1\n --> \(${showattachurlbase}$2\)/g;
615 my $diffs = $difftext . "\n\n" . $newcomments;
618 foreach my $f (@headerlist) {
619 next unless $mailhead{$f};
620 my $value = $values{$f};
621 # If there isn't anything to show, don't include this header.
623 # Only send estimated_time if it is enabled and the user is in the group.
624 if (($f ne 'estimated_time' && $f ne 'deadline')
625 || $user->groups->{Bugzilla->params->{'timetrackinggroup'}})
627 my $desc = $fielddescription{$f};
628 $head .= multiline_sprintf(FORMAT_DOUBLE, ["$desc:", $value],
632 $diffs = $head . ($difftext ? "\n\n" : "") . $diffs;
635 my (@reasons, @reasons_watch);
636 while (my ($relationship, $bits) = each %{$relRef}) {
637 push(@reasons, $relationship) if ($bits & BIT_DIRECT);
638 push(@reasons_watch, $relationship) if ($bits & BIT_WATCHING);
641 my @headerrel = map { REL_NAMES->{$_} } @reasons;
642 my @watchingrel = map { REL_NAMES->{$_} } @reasons_watch;
643 push(@headerrel, 'None') unless @headerrel;
644 push(@watchingrel, 'None') unless @watchingrel;
645 push @watchingrel, map { user_id_to_login($_) } @$watchingRef;
647 my $threadingmarker = build_thread_marker($id, $user->id, $isnew);
653 alias => Bugzilla->params->{'usebugaliases'} ? $values{'alias'} : "",
654 classification => $values{'classification'},
655 product => $values{'product'},
656 comp => $values{'component'},
657 keywords => $values{'keywords'},
658 severity => $values{'bug_severity'},
659 status => $values{'bug_status'},
660 priority => $values{'priority'},
661 assignedto => $values{'assigned_to'},
662 assignedtoname => Bugzilla::User->new({name => $values{'assigned_to'}})->name,
663 targetmilestone => $values{'target_milestone'},
664 changedfields => $values{'changed_fields'},
665 summary => $values{'short_desc'},
666 reasons => \@reasons,
667 reasons_watch => \@reasons_watch,
668 reasonsheader => join(" ", @headerrel),
669 reasonswatchheader => join(" ", @watchingrel),
670 changer => $values{'changer'},
671 changername => $values{'changername'},
672 reporter => $values{'reporter'},
673 reportername => Bugzilla::User->new({name => $values{'reporter'}})->name,
675 threadingmarker => $threadingmarker
679 my $template = Bugzilla->template_inner($user->settings->{'lang'}->{'value'});
680 $template->process("email/newchangedmail.txt.tmpl", $vars, \$msg)
681 || ThrowTemplateError($template->error());
682 Bugzilla->template_inner("");
689 # Get bug comments for the given period.
690 sub get_comments_by_bug {
691 my ($id, $start, $end) = @_;
692 my $dbh = Bugzilla->dbh;
698 # $start will be undef for new bugs, and defined for pre-existing bugs.
700 # If $start is not NULL, obtain the count-index
701 # of this comment for the leading "Comment #xxx" line.
702 $count = $dbh->selectrow_array('SELECT COUNT(*) FROM longdescs
703 WHERE bug_id = ? AND bug_when <= ?',
704 undef, ($id, $start));
707 my $raw = 1; # Do not format comments which are not of type CMT_NORMAL.
708 my $comments = Bugzilla::Bug::GetComments($id, "oldest_to_newest", $start, $end, $raw);
710 if (Bugzilla->params->{'insidergroup'}) {
711 $anyprivate = 1 if scalar(grep {$_->{'isprivate'} > 0} @$comments);
714 return ($comments, $anyprivate, $count);
717 # Prepare comments for the given language.
718 sub prepare_comments {
719 my ($raw_comments, $count) = @_;
722 foreach my $comment (@$raw_comments) {
724 $result .= "\n\n--- Comment #$count from " . $comment->{'author'}->identity .
725 " " . format_time($comment->{'time'}) . " ---\n";
727 # Format language specific comments. We don't update $comment->{'body'}
728 # directly, otherwise it would grow everytime you call format_comment()
729 # with a different language as some text may be appended to the existing one.
730 my $body = Bugzilla::Bug::format_comment($comment);
731 $result .= ($comment->{'already_wrapped'} ? $body : wrap_comment($body));