use Bugzilla::Extension ();
use Bugzilla::Install::Requirements ();
use Bugzilla::Logging;
+use Bugzilla::App::API;
+use Bugzilla::App::BouncedEmails;
use Bugzilla::App::CGI;
+use Bugzilla::App::Main;
use Bugzilla::App::OAuth2::Clients;
use Bugzilla::App::SES;
-use Bugzilla::App::Main;
-use Bugzilla::App::API;
use Bugzilla::App::Static;
use Mojo::Loader qw( find_modules );
use Module::Runtime qw( require_module );
my ($self) = @_;
my $r = $self->routes;
+ Bugzilla::App::API->setup_routes($r);
+ Bugzilla::App::BouncedEmails->setup_routes($r);
Bugzilla::App::CGI->setup_routes($r);
Bugzilla::App::Main->setup_routes($r);
- Bugzilla::App::API->setup_routes($r);
- Bugzilla::App::SES->setup_routes($r);
Bugzilla::App::OAuth2::Clients->setup_routes($r);
+ Bugzilla::App::SES->setup_routes($r);
$r->static_file('/__lbheartbeat__');
$r->static_file(
--- /dev/null
+# 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.
+
+package Bugzilla::App::BouncedEmails;
+
+use 5.10.1;
+use Mojo::Base qw( Mojolicious::Controller );
+
+use Bugzilla::Constants;
+use Bugzilla::Error;
+use Bugzilla::Token;
+
+sub setup_routes {
+ my ($class, $r) = @_;
+ $r->any('/bounced_emails/:userid')->to('BouncedEmails#view');
+}
+
+sub view {
+ my ($self) = @_;
+ my $user = $self->bugzilla->login(LOGIN_REQUIRED);
+ my $other_user = Bugzilla::User->check({id => $self->param('userid')});
+
+ unless ($user->in_group('editusers')
+ || $user->in_group('disableusers')
+ || $user->id == $other_user->id)
+ {
+ ThrowUserError('auth_failure',
+ {reason => "not_visible", action => "modify", object => "user"});
+ }
+
+ if ( $self->param('enable_email')
+ && $user->id == $other_user->id
+ && $other_user->email_disabled)
+ {
+ my $token = $self->param('token');
+ check_token_data($token, 'bounced_emails');
+
+ $other_user->set_email_enabled(1);
+ $other_user->update();
+
+ return $self->redirect_to('/home');
+ }
+
+ my $token = issue_session_token('bounced_emails');
+ $self->stash(
+ {
+ bounce_max => BOUNCE_COUNT_MAX,
+ user => $user,
+ other_user => $other_user,
+ token => $token
+ }
+ );
+ return $self->render(
+ template => 'account/email/bounced-emails',
+ handler => 'bugzilla'
+ );
+}
+
+1;
use 5.10.1;
use Mojo::Base qw( Mojolicious::Controller );
-use Bugzilla::Constants qw(ERROR_MODE_DIE);
+use Bugzilla::Constants qw(BOUNCE_COUNT_MAX ERROR_MODE_DIE);
use Bugzilla::Logging;
use Bugzilla::Mailer qw(MessageToMTA);
use Bugzilla::User ();
# disable each account that is bouncing
foreach my $recipient (@{$notification->{bounce}->{bouncedRecipients}}) {
my $address = $recipient->{emailAddress};
- my $reason = sprintf '(%s) %s', $recipient->{action} // 'error',
+ my $reason = sprintf '(%s) %s', $recipient->{action} // 'error',
$recipient->{diagnosticCode} // 'unknown';
my $user = Bugzilla::User->new({name => $address, cache => 1});
mta => $notification->{bounce}->{reportingMTA} // 'unknown',
reason => $reason,
};
- my $disable_text;
+ my $bounce_message;
$template->process('admin/users/bounce-disabled.txt.tmpl',
- $vars, \$disable_text)
+ $vars, \$bounce_message)
|| die $template->error();
- $user->set_disabledtext($disable_text);
+ # Increment bounce count for user
+ my $bounce_count = $user->bounce_count + 1;
+
+ # If user has not had a bounce in less than 30 days, set the bounce count to 1 instead
+ my $dbh = Bugzilla->dbh;
+ my ($has_recent_bounce) = $dbh->selectrow_array(
+ "SELECT 1 FROM audit_log WHERE object_id = ? AND class = 'Bugzilla::User' AND field = 'bounce_message' AND ("
+ . $dbh->sql_date_math('at_time', '+', 30, 'DAY')
+ . ") > NOW()",
+ undef, $user->id
+ );
+ $bounce_count = 1 if !$has_recent_bounce;
+
$user->set_disable_mail(1);
+ $user->set_bounce_count($bounce_count);
+
+ # if we hit the max amount, go ahead and disabled the account
+ # and an admin will need to reactivate the account.
+ if ($bounce_count == BOUNCE_COUNT_MAX) {
+ $user->set_disabledtext($bounce_message);
+ }
+
$user->update();
+
+ # Do this outside of Object.pm as we do not want to
+ # store the messages anywhere else.
+ $dbh->do(
+ "INSERT INTO audit_log (user_id, class, object_id, field, added, at_time)
+ VALUES (?, 'Bugzilla::User', ?, 'bounce_message', ?, LOCALTIMESTAMP(0))",
+ undef, $user->id, $user->id, $bounce_message
+ );
+
Bugzilla->audit(
- "bounce for <$address> disabled userid-" . $user->id . ": $reason");
+ "bounce for <$address> disabled email for userid-" . $user->id . ": $reason");
}
}
state $check = compile(Self, ComplaintNotification);
my ($self, $notification) = $check->(@_);
my $template = Bugzilla->template_inner();
- my $json = JSON::MaybeXS->new(pretty => 1, utf8 => 1, canonical => 1,);
+ my $json = JSON::MaybeXS->new(pretty => 1, utf8 => 1, canonical => 1,);
foreach my $recipient (@{$notification->{complaint}->{complainedRecipients}}) {
my $reason = $notification->{complaint}->{complaintFeedbackType} // 'unknown';
EMAIL_LIMIT_EXCEPTION
JOB_QUEUE_VIEW_MAX_JOBS
+
+ BOUNCE_COUNT_MAX
);
@Bugzilla::Constants::EXPORT_OK = qw(contenttypes);
# (view_job_queue.cgi).
use constant JOB_QUEUE_VIEW_MAX_JOBS => 2500;
+# Maximum number of times an email can bounce for an account
+# before the account is completely disabled.
+use constant BOUNCE_COUNT_MAX => 5;
+
sub bz_locations {
# Force memoize() to re-compute data per project, to avoid
'profiles.password_change_reason',
'profiles.mfa',
'profiles.mfa_required_date',
- 'profiles.nickname'
+ 'profiles.nickname',
+ 'profiles.bounce_count'
),
;
}
password_change_required => \&Bugzilla::Object::check_boolean,
password_change_reason => \&_check_password_change_reason,
mfa => \&_check_mfa,
+ bounce_count => \&_check_numeric,
};
sub UPDATE_COLUMNS {
mfa
mfa_required_date
nickname
+ bounce_count
);
push(@cols, 'cryptpassword') if exists $self->{cryptpassword};
return @cols;
return '';
}
+sub _check_numeric {
+ my ($self, $value) = (@_);
+ if ($value !~ /^[0-9]+$/) {
+ ThrowCodeError('param_must_be_numeric',
+ {param => $value, function => 'Bugzilla::User::_check_numeric'});
+ return "must be a numeric value";
+ }
+ return $value;
+}
+
################################################################################
# Mutators
################################################################################
return $self->{account_ip_login_failures};
}
+#################
+# Email Bounces #
+#################
+
+sub bounce_count { $_[0]->{bounce_count}; }
+
+sub bounce_messages {
+ my ($self) = @_;
+ my $dbh = Bugzilla->dbh;
+ my $bounce_count = $self->bounce_count;
+ return $self->{bounce_messages} ||= $dbh->selectall_arrayref(
+ "SELECT " . $dbh->sql_date_format('at_time', '%Y-%m-%d %H:%i') . "
+ AS bounce_when, added AS bounce_message FROM audit_log
+ WHERE object_id = ? AND class = 'Bugzilla::User' AND field = 'bounce_message'
+ ORDER BY at_time DESC LIMIT $bounce_count",
+ {Slice => {}},
+ $self->id
+ );
+}
+
+sub set_bounce_count {
+ my ($self, $count) = @_;
+ $self->set('bounce_count', $count);
+ $self->{bounce_count} = $count;
+}
+
+
###############
# Subroutines #
###############
- BMO_db_user=bugs
- BMO_memcached_namespace=bugzilla
- BMO_memcached_servers=memcached:11211
+ - BMO_ses_username=ses@mozilla.bugs
+ - BMO_ses_password=password123456789!
- BMO_urlbase=AUTOMATIC
- BUGZILLA_ALLOW_INSECURE_HTTP=1
- BZ_ANSWERS_FILE=/app/conf/checksetup_answers.txt
bmo.db:
image: mozillabteam/bmo-mysql:5.7
+ tmpfs:
+ - /tmp
logging:
driver: "none"
environment:
- BMO_db_user=bugs
- BMO_memcached_namespace=bugzilla
- BMO_memcached_servers=memcached:11211
+ - BMO_ses_username=ses@mozilla.bugs
+ - BMO_ses_password=password123456789!
- BMO_urlbase=http://bmo.test/
- BUGZILLA_ALLOW_INSECURE_HTTP=1
- BZ_ANSWERS_FILE=/app/conf/checksetup_answers.txt
$otherUser->set_name($cgi->param('name'));
$otherUser->set_disabledtext($cgi->param('disabledtext'));
$otherUser->set_disable_mail($cgi->param('disable_mail'));
+ $otherUser->set_bounce_count(0) if $cgi->param('reset_bounce');
}
$changes = $otherUser->update();
--- /dev/null
+#!/usr/bin/env perl
+# 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 strict;
+use warnings;
+use 5.10.1;
+use lib qw( . lib local/lib/perl5 );
+
+BEGIN {
+ $ENV{LOG4PERL_CONFIG_FILE} = 'log4perl-t.conf';
+ $ENV{BUGZILLA_DISABLE_HOSTAGE} = 1;
+}
+
+use Mojo::URL;
+use Mojo::UserAgent;
+use Test2::V0;
+use Test::Selenium::Remote::Driver;
+
+my $ADMIN_LOGIN = $ENV{BZ_TEST_ADMIN} // 'admin@mozilla.bugs';
+my $ADMIN_PW_OLD = $ENV{BZ_TEST_ADMIN_PASS} // 'Te6Oovohch';
+my $SES_USERNAME = $ENV{BMO_ses_username} // 'ses@mozilla.bugs';
+my $SES_PASSWORD = $ENV{BMO_ses_password} // 'password123456789!';
+
+my @require_env = qw(
+ BZ_BASE_URL
+ BZ_TEST_NEWBIE
+ BZ_TEST_NEWBIE_PASS
+ TWD_HOST
+ TWD_PORT
+);
+
+my @missing_env = grep { !exists $ENV{$_} } @require_env;
+BAIL_OUT("Missing env: @missing_env") if @missing_env;
+
+my $sel = Test::Selenium::Remote::Driver->new(
+ base_url => $ENV{BZ_BASE_URL},
+ browser => 'firefox',
+ version => '',
+ javascript => 1
+);
+
+my $ua = Mojo::UserAgent->new;
+$ua->on(
+ start => sub {
+ my ($ua, $tx) = @_;
+ $tx->req->headers->header('X-Amz-SNS-Message-Type' => 'Notification');
+ }
+);
+
+my $ses_data = <DATA>;
+my $ses_url = Mojo::URL->new($ENV{BZ_BASE_URL} . 'ses/index.cgi')
+ ->userinfo("$SES_USERNAME:$SES_PASSWORD");
+
+# First bounce
+my $result = $ua->post($ses_url => $ses_data)->result;
+ok($result->is_success, 'Posting first bounce was successful');
+
+# Allow user to reset their email
+$sel->set_implicit_wait_timeout(600);
+login_ok($sel, $ENV{BZ_TEST_NEWBIE}, $ENV{BZ_TEST_NEWBIE_PASS});
+$sel->body_text_contains('Change notification emails have been disabled',
+ 'Email disabled warning is displayed');
+$sel->click_element_ok('//a[@id="bounced_emails_link"]');
+sleep(2);
+$sel->title_is('Bounced Emails');
+$sel->click_element_ok('//input[@id="enable_email"]');
+submit($sel, '//input[@value="Submit"]');
+sleep(2);
+$sel->title_is('Bugzilla Main Page');
+$sel->body_text_lacks(
+ 'Change notification emails have been disabled',
+ 'Email disabled warning is no longer displayed'
+);
+logout_ok($sel);
+
+# Bounce 4 more times causing account to be locked
+$result = $ua->post($ses_url => $ses_data)->result;
+ok($result->is_success, 'Posting third bounce was successful');
+$result = $ua->post($ses_url => $ses_data)->result;
+ok($result->is_success, 'Posting fourth bounce was successful');
+$result = $ua->post($ses_url => $ses_data)->result;
+ok($result->is_success, 'Posting fifth bounce was successful');
+$result = $ua->post($ses_url => $ses_data)->result;
+ok($result->is_success, 'Posting fifth bounce was successful');
+
+# User should not be able to login again
+login($sel, $ENV{BZ_TEST_NEWBIE}, $ENV{BZ_TEST_NEWBIE_PASS});
+$sel->title_is('Account Disabled');
+$sel->body_text_contains(
+ 'Your Bugzilla account has been disabled due to issues delivering emails to your address.',
+ 'Account disabled message is displayed'
+);
+
+done_testing;
+
+sub submit {
+ my ($sel, $xpath) = @_;
+ $sel->find_element($xpath, 'xpath')->click_ok('Submit OK');
+}
+
+sub click_and_type {
+ my ($sel, $name, $text) = @_;
+
+ eval {
+ my $el
+ = $sel->find_element(qq{//*[\@id="bugzilla-body"]//input[\@name="$name"]},
+ 'xpath');
+ $el->click();
+ $sel->send_keys_to_active_element($text);
+ pass("found $name and typed $text");
+ };
+ if ($@) {
+ fail("failed to find $name");
+ }
+}
+
+sub login {
+ my ($sel, $login, $password) = @_;
+ $sel->get_ok("/login");
+ $sel->title_is("Log in to Bugzilla");
+ click_and_type($sel, 'Bugzilla_login', $login);
+ click_and_type($sel, 'Bugzilla_password', $password);
+ submit($sel, '//input[@id="log_in"]');
+}
+
+sub login_ok {
+ my ($sel) = @_;
+ login(@_);
+ $sel->title_is('Bugzilla Main Page');
+}
+
+sub logout_ok {
+ my ($sel) = @_;
+ $sel->get_ok('/index.cgi?logout=1');
+ $sel->title_is("Logged Out");
+}
+
+__DATA__
+{"Type":"Notification","Message":"{\"eventType\":\"Bounce\",\"bounce\":{\"bounceType\":\"Permanent\",\"bounceSubType\":\"General\",\"bouncedRecipients\":[{\"emailAddress\":\"newbie@mozilla.example\",\"action\":\"failed\",\"status\":\"5.1.1\",\"diagnosticCode\":\"smtp;5505.1.1userunknown\"}],\"timestamp\":\"2017-08-05T00:41:02.669Z\",\"feedbackId\":\"01000157c44f053b-61b59c11-9236-11e6-8f96-7be8aexample-000000\",\"reportingMTA\":\"dsn;mta.example.com\"},\"mail\":{\"timestamp\":\"2017-08-05T00:40:02.012Z\",\"source\":\"BugzillaDaemon<bugzilla@mozilla.bugs>\",\"sourceArn\":\"arn:aws:ses:us-east-1:123456789012:identity/bugzilla@mozilla.bugs\",\"sendingAccountId\":\"123456789012\",\"messageId\":\"EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000\",\"destination\":[\"newbie@mozilla.example\"],\"headersTruncated\":false,\"headers\":[{\"name\":\"From\",\"value\":\"BugzillaDaemon<bugzilla@mozilla.bugs>\"},{\"name\":\"To\",\"value\":\"newbie@mozilla.example\"},{\"name\":\"Subject\",\"value\":\"MessagesentfromAmazonSES\"},{\"name\":\"MIME-Version\",\"value\":\"1.0\"},{\"name\":\"Content-Type\",\"value\":\"multipart/alternative;boundary=\"}],\"commonHeaders\":{\"from\":[\"BugzillaDaemon<bugzilla@mozilla.bugs>\"],\"to\":[\"newbie@mozilla.example\"],\"messageId\":\"EXAMPLE7c191be45-e9aedb9a-02f9-4d12-a87d-dd0099a07f8a-000000\",\"subject\":\"MessagesentfromAmazonSES\"},\"tags\":{\"ses:configuration-set\":[\"ConfigSet\"],\"ses:source-ip\":[\"192.0.2.0\"],\"ses:from-domain\":[\"example.com\"],\"ses:caller-identity\":[\"ses_user\"]}}}"}
--- /dev/null
+[%# 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.
+ #%]
+
+[% PROCESS global/header.html.tmpl
+ title = "Bounced Emails"
+ style_urls = [ "skins/standard/describecomponents.css" ]
+%]
+
+<h2>[% title FILTER html %]</h2>
+
+[% IF user.id == other_user.id AND other_user.bounce_count AND other_user.email_disabled %]
+<p>
+ Due to issues delivering email to your account, we have temporarily disabled email notifications
+ being sent to it. If you feel like the issue has been resolved, you may reactivate email delivery
+ below. After a maximum of [% bounce_max FILTER html %] bounces, we will disable logging in to your
+ account and you will need to contact an administrator to reactivate it.</p>
+
+<form action="/bounced_emails/[% other_user.id FILTER uri %]">
+ <input type="hidden" name="token" value="[% token FILTER html %]">
+ <table>
+ <tr>
+ <td><input type="checkbox" name="enable_email" id="enable_email" value="1"></td>
+ <td>I have resolved the issue and would like email delivery for my account to be reactivated.</td>
+ </tr>
+ </table>
+ <br>
+ <input type="submit" value="Submit">
+</form>
+[% END %]
+
+[% IF (user.id == other_user.id OR user.in_group('editusers') OR user.in_group('disableusers'))
+ AND other_user.bounce_count %]
+<h3>History</h3>
+
+<div class="list">
+ [% FOREACH bounce = other_user.bounce_messages %]
+ [% IF loop.first %]
+ <strong>Current</strong>
+ [% ELSIF loop.count == 2 %]
+ <strong>Older</strong>
+ [% END %]
+ <section class="component">
+ <header>
+ <h2>[% bounce.bounce_when FILTER time FILTER html %]</h2>
+ </header>
+ <div>
+ <p class="description">[% bounce.bounce_message FILTER html %]</p>
+ </div>
+ </section>
+ [% END %]
+</div>
+[% ELSE %]
+No bounced email messages have been recorded.
+[% END %]
+
+[% PROCESS global/footer.html.tmpl %]
</label>
</td>
</tr>
+ [% IF otheruser.bounce_count %]
+ <tr>
+ <th><label for="reset_bounce">Reset Bounce Count</label></th>
+ <td>
+ <input type="checkbox" name="reset_bounce" id="reset_bounce" value="1">
+ (<a href="/bounced_emails/[% otheruser.id FILTER uri %]" title="View bounce email history">
+ [% otheruser.bounce_count FILTER html %]</a>)
+ </td>
+ </tr>
+ [% END %]
<tr>
<th><label for="disabledtext">Disable text:</label></th>
<td>
<h2>[% header FILTER none %]</h2>
[% END %]
+[%# Show banner for users who have email disabled due to bounces %]
+[% IF user.bounce_count AND user.email_disabled %]
+ <div id="message">
+ Change notification emails have been disabled for your account due to issues delivering to your address.
+ <a href="/bounced_emails/[% user.id FILTER uri %]" id="bounced_emails_link">View recent errors and reactivate email</a>.
+ </div>
+[% END %]
+
[% IF message %]
<div id="message">[% message %]</div>
[% END %]
[% ELSE %]
[% terms.Bug %]mail has been enabled.
[% END %]
+ [% ELSIF field == 'bounce_count' %]
+ Bounced email count has been reset.
[% ELSIF field == 'password_change_required' %]
The user [% otheruser.password_change_required ? "must" : "no longer needs to" %] update their password.
[% ELSIF field == 'password_change_reason' %]