]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 1062739: add the ability for administrators to limit the number of emails sent...
authorByron Jones <glob@mozilla.com>
Fri, 31 Oct 2014 07:20:42 +0000 (15:20 +0800)
committerByron Jones <glob@mozilla.com>
Fri, 31 Oct 2014 07:20:42 +0000 (15:20 +0800)
r=dylan,a=glob

Bugzilla/Constants.pm
Bugzilla/DB/Schema.pm
Bugzilla/Install/Requirements.pm
Bugzilla/Job/BugMail.pm
Bugzilla/Job/Mailer.pm
Bugzilla/Mailer.pm
skins/standard/admin.css
template/en/default/admin/admin.html.tmpl
template/en/default/admin/reports/job_queue.html.tmpl [new file with mode: 0644]
template/en/default/global/user-error.html.tmpl
view_job_queue.cgi [new file with mode: 0755]

index 4c1f1100367a8f6d73fba32a87e46e5e9b8197e0..a770e7eb7d0a37684d4a2536b63bd7a5dafd7af7 100644 (file)
@@ -195,6 +195,12 @@ use Memoize;
     MOST_FREQUENT_THRESHOLD
 
     MARKDOWN_TAB_WIDTH
+
+    EMAIL_LIMIT_PER_MINUTE
+    EMAIL_LIMIT_PER_HOUR
+    EMAIL_LIMIT_EXCEPTION
+
+    JOB_QUEUE_VIEW_MAX_JOBS
 );
 
 @Bugzilla::Constants::EXPORT_OK = qw(contenttypes);
@@ -641,6 +647,18 @@ use constant MOST_FREQUENT_THRESHOLD => 2;
 # by Markdown engine
 use constant MARKDOWN_TAB_WIDTH => 2;
 
+# The maximum number of emails per minute and hour a recipient can receive.
+# Email will be queued/backlogged to avoid exceeeding these limits.
+# Setting a limit to 0 will disable this feature.
+use constant EMAIL_LIMIT_PER_MINUTE => 1000;
+use constant EMAIL_LIMIT_PER_HOUR   => 2500;
+# Don't change this exception message.
+use constant EMAIL_LIMIT_EXCEPTION  => "email_limit_exceeded\n";
+
+# The maximum number of jobs to show when viewing the job queue
+# (view_job_queue.cgi).
+use constant JOB_QUEUE_VIEW_MAX_JOBS => 500;
+
 sub bz_locations {
     # Force memoize() to re-compute data per project, to avoid
     # sharing the same data across different installations.
index ebe2cb4264ac8855d1ed770c732b3363b5162f21..0698585bb361b63ad077bf1d05c5d7660c699cbd 100644 (file)
@@ -1640,6 +1640,18 @@ use constant ABSTRACT_SCHEMA => {
         ],
     },
 
+    email_rates => {
+        FIELDS => [
+            id         => {TYPE => 'INTSERIAL', NOTNULL => 1,
+                           PRIMARYKEY => 1},
+            recipient  => {TYPE => 'varchar(255)', NOTNULL => 1},
+            message_ts => {TYPE => 'DATETIME', NOTNULL => 1},
+        ],
+        INDEXES => [
+            email_rates_idx => [qw(recipient message_ts)],
+        ],
+    },
+
     # THESCHWARTZ TABLES
     # ------------------
     # Note: In the standard TheSchwartz schema, most integers are unsigned,
index db3d7b0286250f8f29730be841557948fa8e203e..491bf8a72baf482ebc3cf158e4145a40d982f635 100644 (file)
@@ -349,8 +349,8 @@ sub OPTIONAL_MODULES {
     {
         package => 'TheSchwartz',
         module  => 'TheSchwartz',
-        # 1.07 supports the prioritization of jobs.
-        version => 1.07,
+        # 1.10 supports declining of jobs.
+        version => 1.10,
         feature => ['jobqueue'],
     },
     {
index e0b7f54482ec211e7e9c28efc397dc42edd4524c..b4887c47026398ff588a6fa4202e4346e158c826 100644 (file)
@@ -14,19 +14,9 @@ use warnings;
 use Bugzilla::BugMail;
 BEGIN { eval "use parent qw(Bugzilla::Job::Mailer)"; }
 
-sub work {
-    my ($class, $job) = @_;
-    my $success = eval {
-        Bugzilla::BugMail::dequeue($job->arg->{vars});
-        1;
-    };
-    if (!$success) {
-        $job->failed($@);
-        undef $@;
-    }
-    else {
-        $job->completed;
-    }
+sub process_job {
+    my ($class, $arg) = @_;
+    Bugzilla::BugMail::dequeue($arg->{vars});
 }
 
 1;
index cd1c23445a261f4ad843079b09687644ac4dfa37..7e7549de80a08a521a15e9a1fffee04caeb33301 100644 (file)
@@ -11,6 +11,7 @@ use 5.10.1;
 use strict;
 use warnings;
 
+use Bugzilla::Constants;
 use Bugzilla::Mailer;
 BEGIN { eval "use parent qw(TheSchwartz::Worker)"; }
 
@@ -32,15 +33,24 @@ sub retry_delay {
 
 sub work {
     my ($class, $job) = @_;
-    my $msg = $job->arg->{msg};
-    my $success = eval { MessageToMTA($msg, 1); 1; };
-    if (!$success) {
-        $job->failed($@);
+    eval { $class->process_job($job->arg) };
+    if (my $error = $@) {
+        if ($error eq EMAIL_LIMIT_EXCEPTION) {
+            $job->declined();
+        }
+        else {
+            $job->failed($error);
+        }
         undef $@;
-    } 
+    }
     else {
         $job->completed;
     }
 }
 
+sub process_job {
+    my ($class, $arg) = @_;
+    MessageToMTA($arg, 1);
+}
+
 1;
index 4447d4046207001829f68aeb8e487e609f98c09d..389b6f69eb767bd841ed155ba9963ac931a17bf0 100644 (file)
@@ -41,6 +41,8 @@ sub MessageToMTA {
         return;
     }
 
+    my $dbh = Bugzilla->dbh;
+
     my $email;
     if (ref $msg) {
         $email = $msg;
@@ -58,14 +60,50 @@ sub MessageToMTA {
     # email immediately, in case the transaction is rolled back. Instead we
     # insert it into the mail_staging table, and bz_commit_transaction calls
     # send_staged_mail() after the transaction is committed.
-    if (! $send_now && Bugzilla->dbh->bz_in_transaction()) {
+    if (! $send_now && $dbh->bz_in_transaction()) {
         # The e-mail string may contain tainted values.
         my $string = $email->as_string;
         trick_taint($string);
-        Bugzilla->dbh->do("INSERT INTO mail_staging (message) VALUES(?)", undef, $string);
+        $dbh->do("INSERT INTO mail_staging (message) VALUES(?)", undef, $string);
         return;
     }
 
+    # Ensure that we are not sending emails too quickly to recipients.
+    if (Bugzilla->params->{use_mailer_queue}
+        && (EMAIL_LIMIT_PER_MINUTE || EMAIL_LIMIT_PER_HOUR))
+    {
+        $dbh->do(
+            "DELETE FROM email_rates WHERE message_ts < "
+            . $dbh->sql_date_math('LOCALTIMESTAMP(0)', '-', '1', 'HOUR'));
+
+        my $recipient = $email->header('To');
+
+        if (EMAIL_LIMIT_PER_MINUTE) {
+            my $minute_rate = $dbh->selectrow_array(
+                "SELECT COUNT(*)
+                   FROM email_rates
+                  WHERE recipient = ?  AND message_ts >= "
+                        . $dbh->sql_date_math('LOCALTIMESTAMP(0)', '-', '1', 'MINUTE'),
+                undef,
+                $recipient);
+            if ($minute_rate >= EMAIL_LIMIT_PER_MINUTE) {
+                die EMAIL_LIMIT_EXCEPTION;
+            }
+        }
+        if (EMAIL_LIMIT_PER_HOUR) {
+            my $hour_rate = $dbh->selectrow_array(
+                "SELECT COUNT(*)
+                   FROM email_rates
+                  WHERE recipient = ?  AND message_ts >= "
+                        . $dbh->sql_date_math('LOCALTIMESTAMP(0)', '-', '1', 'HOUR'),
+                undef,
+                $recipient);
+            if ($hour_rate >= EMAIL_LIMIT_PER_HOUR) {
+                die EMAIL_LIMIT_EXCEPTION;
+            }
+        }
+    }
+
     # We add this header to uniquely identify all email that we
     # send as coming from this Bugzilla installation.
     #
@@ -181,6 +219,17 @@ sub MessageToMTA {
             ThrowCodeError('mail_send_error', { msg => $@->message, mail => $email });
         }
     }
+
+    # insert into email_rates
+    if (Bugzilla->params->{use_mailer_queue}
+        && (EMAIL_LIMIT_PER_MINUTE || EMAIL_LIMIT_PER_HOUR))
+    {
+        $dbh->do(
+            "INSERT INTO email_rates(recipient, message_ts) VALUES (?, LOCALTIMESTAMP(0))",
+            undef,
+            $email->header('To')
+        );
+    }
 }
 
 # Builds header suitable for use as a threading marker in email notifications
index cdf75ac2c0130c3cddc8da0b5639dbe70cab1aed..232076846c8848daf6556e2b469b116821b270c7 100644 (file)
@@ -303,3 +303,38 @@ table.schedule_list th, table.search_list th {
     padding: .5em;
     font-size: small;
 }
+
+#report {
+    border: 1px solid #888888;
+}
+
+#report td, #report th {
+    padding: 3px 10px 3px 3px;
+    border: 0px;
+}
+
+#report th {
+    text-align: left;
+}
+
+#report-header {
+    background-color: #cccccc;
+}
+
+.report_row_odd {
+    background-color: #eeeeee;
+    color: #000000;
+}
+
+.report_row_even {
+    background-color: #ffffff;
+    color: #000000;
+}
+
+#report.hover tr:hover {
+    background-color: #ccccff;
+}
+
+.report_information {
+    font-style: italic;
+}
index 9f60eb6625b1d23f8f14fb45a52f94b242a0d735..01e9f309b850652d9df2a8cf942af444a40541b1 100644 (file)
         and time, and get the result of these queries directly per email. This is a
         good way to create reminders and to keep track of the activity in your installation.</dd>
 
+        [% IF Param('use_mailer_queue') %]
+          [% class = user.in_group('admin') ? "" : "forbidden" %]
+          <dt id="view_job_queue" class="[% class %]"><a href="view_job_queue.cgi">Job Queue</a></dt>
+          <dd class="[% class %]">View the queue of undelivered/deferred jobs/emails.</dd>
+        [% END %]
+
         [% Hook.process('end_links_right') %]
       </dl>
     </td>
diff --git a/template/en/default/admin/reports/job_queue.html.tmpl b/template/en/default/admin/reports/job_queue.html.tmpl
new file mode 100644 (file)
index 0000000..6057eac
--- /dev/null
@@ -0,0 +1,74 @@
+[%# 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/.
+  #
+  # This Source Code Form is "Incompatible With Secondary Licenses", as
+  # defined by the Mozilla Public License, v. 2.0.
+  #%]
+
+[% INCLUDE global/header.html.tmpl
+  title = "Job Queue Status"
+  style_urls = [ "skins/standard/admin.css" ]
+%]
+
+[% IF jobs.size %]
+
+  <p class="report_information">
+    [% IF too_many_jobs %]
+      [% job_count FILTER html %] jobs found,
+      limiting results to [% constants.JOB_QUEUE_VIEW_MAX_JOBS FILTER html %] jobs.
+    [% ELSE %]
+      [% jobs.size FILTER none %] jobs(s) in the queue.
+    [% END %]
+  </p>
+
+  <table id="report" class="hover" cellspacing="0" border="0" width="100%">
+  <tr id="report-header">
+    <th>Next Attempt After</th>
+    <th>Error Count</th>
+    <th>Error Time</th>
+    <th>Error Message</th>
+    <th>Job</th>
+  </tr>
+  [% FOREACH job IN jobs %]
+    <tr class="report item [% loop.count % 2 == 1 ? "report_row_odd" : "report_row_even" %]">
+      <td nowrap>
+        [% IF job.grabbed_until %]
+          [% time2str("%Y-%m-%d %H:%M:%S %Z", job.grabbed_until) FILTER html %]
+        [% ELSE %]
+          [% time2str("%Y-%m-%d %H:%M:%S %Z", job.run_time) FILTER html %]
+        [% END %]
+      </td>
+      <td>
+        [% job.error_count || "-" FILTER html %]
+      </td>
+      <td nowrap>
+        [% IF job.error_count %]
+          [% time2str("%Y-%m-%d %H:%M:%S %Z", job.error_time) FILTER html %]
+        [% ELSE %]
+          -
+        [% END %]
+      </td>
+      <td>
+        [% IF job.grabbed_until %]
+          Deferred
+        [% ELSIF job.error_count %]
+          [% job.error_message FILTER html %]
+        [% ELSE %]
+          -
+        [% END %]
+      </td>
+      <td>[% job.subject || '-' FILTER html %]</td>
+    </tr>
+  [% END %]
+  </table>
+
+[% ELSE %]
+
+  <p class="report_information">
+    The job queue is empty.
+  </p>
+
+[% END %]
+
+[% INCLUDE global/footer.html.tmpl %]
index e9bdb63c4567540ff7c7199b0a74f18fbc0a94d2..78d8823f5cfdde6809cef68642855f41d8317492 100644 (file)
       group access
     [% ELSIF object == "groups" %]
       groups
+    [% ELSIF object == "job_queue" %]
+      the job queue
     [% ELSIF object == "keywords" %]
       keywords
     [% ELSIF object == "milestones" %]
diff --git a/view_job_queue.cgi b/view_job_queue.cgi
new file mode 100755 (executable)
index 0000000..5d0ed95
--- /dev/null
@@ -0,0 +1,118 @@
+#!/usr/bin/perl -T
+# 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/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+use 5.10.1;
+use strict;
+use warnings;
+
+use lib qw(. lib);
+
+use Bugzilla;
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Util qw(template_var);
+use Scalar::Util qw(blessed);
+use Storable qw(read_magic thaw);
+
+my $user = Bugzilla->login(LOGIN_REQUIRED);
+$user->in_group("admin")
+    || ThrowUserError("auth_failure", { group  => "admin",
+                                        action => "access",
+                                        object => "job_queue" });
+
+my $vars = {};
+generate_report($vars);
+
+print Bugzilla->cgi->header();
+my $template = Bugzilla->template;
+$template->process('admin/reports/job_queue.html.tmpl', $vars)
+    || ThrowTemplateError($template->error());
+
+sub generate_report {
+    my ($vars) = @_;
+    my $dbh = Bugzilla->dbh;
+    my $user = Bugzilla->user;
+
+    my $query = "
+        SELECT
+            j.jobid,
+            j.arg,
+            j.run_after AS run_time,
+            j.grabbed_until,
+            f.funcname AS func,
+            e.jobid AS error_count,
+            e.error_time AS error_time,
+            e.message AS error_message
+        FROM
+            ts_job j
+            INNER JOIN ts_funcmap f
+                ON f.funcid = j.funcid
+            NATURAL LEFT JOIN (
+                SELECT MAX(error_time) AS error_time, jobid
+                  FROM ts_error
+                 GROUP BY jobid
+            ) t
+            LEFT JOIN ts_error e
+                ON (e.error_time  = t.error_time) AND (e.jobid = t.jobid)
+        ORDER BY
+            j.run_after, j.grabbed_until, j.insert_time, j.jobid
+        " . $dbh->sql_limit(JOB_QUEUE_VIEW_MAX_JOBS + 1);
+
+    $vars->{jobs} = $dbh->selectall_arrayref($query, { Slice => {} });
+    if (@{ $vars->{jobs} } == JOB_QUEUE_VIEW_MAX_JOBS + 1) {
+        pop @{ $vars->{jobs} };
+        $vars->{job_count} = $dbh->selectrow_array("SELECT COUNT(*) FROM ts_job");
+        $vars->{too_many_jobs} = 1;
+    }
+
+    my $bug_word = template_var('terms')->{bug};
+    foreach my $job (@{ $vars->{jobs} }) {
+        my ($recipient, $description);
+        eval {
+            if ($job->{func} eq 'Bugzilla::Job::BugMail') {
+                my $arg = _cond_thaw(delete $job->{arg});
+                next unless $arg;
+                my $vars = $arg->{vars};
+                $recipient = $vars->{to_user}->{login_name};
+                $description = "[$bug_word " . $vars->{bug}->{bug_id} . '] '
+                               . $vars->{bug}->{short_desc};
+            }
+
+            elsif ($job->{func} eq 'Bugzilla::Job::Mailer') {
+                my $arg = _cond_thaw(delete $job->{arg});
+                next unless $arg;
+                my $msg = $arg->{msg};
+                if (ref($msg) && blessed($msg) eq 'Email::MIME') {
+                    $recipient = $msg->header('to');
+                    $description = $msg->header('subject');
+                } else {
+                    ($recipient) = $msg =~ /\nTo: ([^\n]+)/i;
+                    ($description) = $msg =~ /\nSubject: ([^\n]+)/i;
+                }
+            }
+        };
+        if ($recipient) {
+            $job->{subject} = "<$recipient> $description";
+        }
+    }
+}
+
+sub _cond_thaw {
+    my $data = shift;
+    my $magic = eval { read_magic($data); };
+    if ($magic && $magic->{major} && $magic->{major} >= 2 && $magic->{major} <= 5) {
+        my $thawed = eval { thaw($data) };
+        if ($@) {
+            # false alarm... looked like a Storable, but wasn't
+            return undef;
+        }
+        return $thawed;
+    } else {
+        return undef;
+    }
+}