]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 1438206 - Process SES email bounces properly
authorbyron jones <byron@glob.com.au>
Tue, 27 Feb 2018 22:45:15 +0000 (06:45 +0800)
committerDylan William Hardison <dylan@hardison.net>
Tue, 27 Feb 2018 22:45:15 +0000 (17:45 -0500)
Bugzilla/Install/Localconfig.pm
Bugzilla/ModPerl.pm
Bugzilla/ModPerl/BasicAuth.pm [new file with mode: 0644]
ses/index.cgi [new file with mode: 0755]
template/en/default/admin/users/bounce-disabled.txt.tmpl [new file with mode: 0644]
template/en/default/email/ses-complaint.txt.tmpl [new file with mode: 0644]
template/en/default/setup/strings.txt.pl
vagrant_support/checksetup_answers.j2

index f877829c518f5ca8c4358272d80e1990a2fdaef0..646dbc1a73f6054daae4564533484f719d88f25a 100644 (file)
@@ -163,7 +163,15 @@ use constant LOCALCONFIG_VARS => (
     {
         name    => 'attachment_base',
         default => _migrate_param( "attachment_base", '' ),
-    }
+    },
+    {
+        name    => 'ses_username',
+        default => '',
+    },
+    {
+        name    => 'ses_password',
+        default => '',
+    },
 );
 
 
index 142df63d47ef8437323c0a85880a50a130a2b3f3..a5c8408971596c5ab856c8486cb43818ba24c3fa 100644 (file)
@@ -97,6 +97,16 @@ ErrorDocument 500 /errors/500.html
     [% root_htaccess.contents FILTER indent %]
 </Directory>
 
+# AWS SES endpoint for handling mail bounces/complaints
+<Location "/ses">
+    PerlSetEnv AUTH_VAR_NAME ses_username
+    PerlSetEnv AUTH_VAR_PASS ses_password
+    PerlAuthenHandler Bugzilla::ModPerl::BasicAuth
+    AuthName SES
+    AuthType Basic
+    require valid-user
+</Location>
+
 # directory rules for all the other places we have .htaccess files
 [% FOREACH htaccess IN htaccess_files %]
 # from [% htaccess.file %]
diff --git a/Bugzilla/ModPerl/BasicAuth.pm b/Bugzilla/ModPerl/BasicAuth.pm
new file mode 100644 (file)
index 0000000..e93680e
--- /dev/null
@@ -0,0 +1,60 @@
+# 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::ModPerl::BasicAuth;
+use 5.10.1;
+use strict;
+use warnings;
+
+# Protects a mod_perl <Location> with Basic HTTP authentication.
+#
+# Example use:
+#
+# <Location "/ses">
+#   PerlAuthenHandler Bugzilla::ModPerl::BasicAuth
+#   PerlSetEnv AUTH_VAR_NAME ses_username
+#   PerlSetEnv AUTH_VAR_PASS ses_password
+#   AuthName SES
+#   AuthType Basic
+#   require valid-user
+# </Location>
+#
+# AUTH_VAR_NAME and AUTH_VAR_PASS are the names of variables defined in
+# `localconfig` which hold the authentication credentials.
+
+use Apache2::Const -compile => qw(OK HTTP_UNAUTHORIZED);
+use Bugzilla ();
+
+sub handler {
+    my $r = shift;
+    my ($status, $password) = $r->get_basic_auth_pw;
+    return $status if $status != Apache2::Const::OK;
+
+    my $auth_var_name = $ENV{AUTH_VAR_NAME};
+    my $auth_var_pass = $ENV{AUTH_VAR_PASS};
+    unless ($auth_var_name && $auth_var_pass) {
+        warn "AUTH_VAR_NAME and AUTH_VAR_PASS environmental vars not set\n";
+        $r->note_basic_auth_failure;
+        return Apache2::Const::HTTP_UNAUTHORIZED;
+    }
+
+    my $auth_user = Bugzilla->localconfig->{$auth_var_name};
+    my $auth_pass = Bugzilla->localconfig->{$auth_var_pass};
+    unless ($auth_user && $auth_pass) {
+        warn "$auth_var_name and $auth_var_pass not configured\n";
+        $r->note_basic_auth_failure;
+        return Apache2::Const::HTTP_UNAUTHORIZED;
+    }
+
+    unless ($r->user eq $auth_user && $password eq $auth_pass) {
+        $r->note_basic_auth_failure;
+        return Apache2::Const::HTTP_UNAUTHORIZED;
+    }
+
+    return Apache2::Const::OK;
+}
+
+1;
diff --git a/ses/index.cgi b/ses/index.cgi
new file mode 100755 (executable)
index 0000000..aa5b347
--- /dev/null
@@ -0,0 +1,206 @@
+#!/usr/bin/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 5.10.1;
+use strict;
+use warnings;
+
+use lib qw(.. ../lib ../local/lib/perl5);
+
+use Bugzilla ();
+use Bugzilla::Constants qw( ERROR_MODE_DIE );
+use Bugzilla::Mailer qw( MessageToMTA );
+use Bugzilla::User ();
+use Bugzilla::Util qw( html_quote remote_ip );
+use JSON::XS qw( decode_json encode_json );
+use LWP::UserAgent ();
+use Try::Tiny qw( try catch );
+
+Bugzilla->error_mode(ERROR_MODE_DIE);
+try {
+    main();
+} catch {
+    warn "SES: Fatal error: $_\n";
+    respond(500 => 'Internal Server Error');
+};
+
+sub main {
+    my $message = decode_json_wrapper(Bugzilla->cgi->param('POSTDATA')) // return;
+    my $message_type = $ENV{HTTP_X_AMZ_SNS_MESSAGE_TYPE} // '(missing)';
+
+    if ($message_type eq 'SubscriptionConfirmation') {
+        confirm_subscription($message);
+    }
+
+    elsif ($message_type eq 'Notification') {
+        my $notification = decode_json_wrapper($message->{Message}) // return;
+
+        my $notification_type = $notification->{notificationType} // '';
+        if ($notification_type eq 'Bounce') {
+            process_bounce($notification);
+        }
+        elsif ($notification_type eq 'Complaint') {
+            process_complaint($notification);
+        }
+        else {
+            warn "SES: Unsupported notification-type: $notification_type\n";
+            respond(200 => 'OK');
+        }
+    }
+
+    else {
+        warn "SES: Unsupported message-type: $message_type\n";
+        respond(200 => 'OK');
+    }
+}
+
+sub confirm_subscription {
+    my ($message) = @_;
+
+    my $subscribe_url = $message->{SubscribeURL};
+    if (!$subscribe_url) {
+        warn "SES: Bad SubscriptionConfirmation request: missing SubscribeURL\n";
+        respond(400 => 'Bad Request');
+        return;
+    }
+
+    my $ua = ua();
+    my $res = $ua->get($message->{SubscribeURL});
+    if (!$res->is_success) {
+        warn "SES: Bad response from SubscribeURL: " . $res->status_line . "\n";
+        respond(400 => 'Bad Request');
+        return;
+    }
+
+    respond(200 => 'OK');
+}
+
+sub process_bounce {
+    my ($notification) = @_;
+    my $type = $notification->{bounce}->{bounceType};
+
+    # these should be infrequent and hopefully small
+    warn("SES: notification: " . encode_json($notification));
+
+    if ($type eq 'Transient') {
+        # just log transient bounces
+        foreach my $recipient (@{ $notification->{bounce}->{bouncedRecipients} }) {
+            my $address = $recipient->{emailAddress};
+            Bugzilla->audit("SES: transient bounce for <$address>");
+        }
+    }
+
+    elsif ($type eq 'Permanent') {
+        # disable each account that is permanently bouncing
+        foreach my $recipient (@{ $notification->{bounce}->{bouncedRecipients} }) {
+            my $address = $recipient->{emailAddress};
+            my $reason = sprintf('(%s) %s', $recipient->{action} // 'error',
+                                            $recipient->{diagnosticCode} // 'unknown');
+
+            my $user = Bugzilla::User->new({ name => $address, cache => 1 });
+            if ($user) {
+                # never auto-disable admin accounts
+                if ($user->in_group('admin')) {
+                    Bugzilla->audit("SES: ignoring permanent bounce for admin <$address>: $reason");
+                }
+
+                else {
+                    my $template = Bugzilla->template_inner();
+                    my $vars = {
+                        mta    => $notification->{bounce}->{reportingMTA} // 'unknown',
+                        reason => $reason,
+                    };
+                    my $disable_text;
+                    $template->process('admin/users/bounce-disabled.txt.tmpl', $vars, \$disable_text)
+                        || die $template->error();
+
+                                       $user->set_disabledtext($disable_text);
+                                       $user->set_disable_mail(1);
+                                       $user->update();
+                    Bugzilla->audit("SES: permanent bounce for <$address> disabled userid-" . $user->id . ": $reason");
+                }
+            }
+
+            else {
+                Bugzilla->audit("SES: permanent bounce for <$address> has no user: $reason");
+            }
+        }
+    }
+
+    else {
+        warn "SES: Unsupported bounce type: $type\n";
+    }
+
+    respond(200 => 'OK');
+}
+
+sub process_complaint {
+    # email notification to bugzilla admin
+    my ($notification) = @_;
+    my $template = Bugzilla->template_inner();
+    my $json = JSON::XS->new->pretty->utf8->canonical;
+
+    foreach my $recipient (@{ $notification->{complaint}->{complainedRecipients} }) {
+        my $reason  = $notification->{complaint}->{complaintFeedbackType} // 'unknown';
+        my $address = $recipient->{emailAddress};
+        Bugzilla->audit("SES: complaint for <$address> for '$reason'");
+        my $vars = {
+            email        => $address,
+            user         => Bugzilla::User->new({ name => $address, cache => 1 }),
+            reason       => $reason,
+            notification => $json->encode($notification),
+        };
+        my $message;
+        $template->process('email/ses-complaint.txt.tmpl', $vars, \$message)
+            || die $template->error();
+        MessageToMTA($message);
+    }
+
+    respond(200 => 'OK');
+}
+
+sub respond {
+    my ($code, $message) = @_;
+    print Bugzilla->cgi->header(
+        -status => "$code $message",
+    );
+    # apache will generate non-200 response pages for us
+    say html_quote($message) if $code == 200;
+}
+
+sub decode_json_wrapper {
+    my ($json) = @_;
+    my $result;
+    if (!defined $json) {
+        warn 'SES: Missing JSON from ' . remote_ip() . "\n";
+        respond(400 => 'Bad Request');
+        return undef;
+    }
+    my $ok = try {
+        $result = decode_json($json);
+    }
+    catch {
+        warn 'SES: Malformed JSON from ' . remote_ip() . "\n";
+        respond(400 => 'Bad Request');
+        return undef;
+    };
+    return $ok ? $result : undef;
+}
+
+sub ua {
+    my $ua = LWP::UserAgent->new();
+    $ua->timeout(10);
+    $ua->protocols_allowed(['http', 'https']);
+    if (my $proxy_url = Bugzilla->params->{'proxy_url'}) {
+        $ua->proxy(['http', 'https'], $proxy_url);
+    }
+    else {
+        $ua->env_proxy;
+    }
+    return $ua;
+}
diff --git a/template/en/default/admin/users/bounce-disabled.txt.tmpl b/template/en/default/admin/users/bounce-disabled.txt.tmpl
new file mode 100644 (file)
index 0000000..f4ae6a3
--- /dev/null
@@ -0,0 +1,19 @@
+[%# 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.
+  #%]
+
+[%# INTERFACE:
+  # mta: mail server reporting error
+  # reason: Reason for bounce (diagnostic code)
+  #%]
+
+[% PROCESS global/variables.none.tmpl %]
+
+Your [% terms.Bugzilla %] account has been disabled due to issues delivering
+emails to your address.<br>
+<br>
+Your mail server ([% mta FILTER html %]) said: [% reason FILTER html %]<br>
diff --git a/template/en/default/email/ses-complaint.txt.tmpl b/template/en/default/email/ses-complaint.txt.tmpl
new file mode 100644 (file)
index 0000000..93ad5ef
--- /dev/null
@@ -0,0 +1,31 @@
+[%# 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.
+  #%]
+
+[%# INTERFACE:
+  # email: email address the complaint pertains to
+  # user: Bugzilla::User object associated with email (may be undef)
+  # reason: Reason for complaint
+  # notification: notification from SES (JSON)
+  #%]
+
+[% PROCESS global/variables.none.tmpl %]
+
+From: [% Param('mailfrom') %]
+To: [% Param('maintainer') %]
+Subject: [% terms.Bugzilla %]: SES Complaint: [% email %]: [% reason %]
+X-Bugzilla-Type: admin
+
+SES Complaint received for [% email %]: [% reason %]
+
+[% IF user %]
+[% urlbase %]/editusers.cgi?action=edit&userid=[% user.id %]
+[% ELSE %]
+Failed to find corresponding user in Bugzilla.
+[% END %]
+
+[%+ notification %]
index 35a771ff397109c6cebe8b44af17ebd8f43f6426..ce4785b04fa96fc10ace06099bc6ff046228cf20 100644 (file)
@@ -203,6 +203,12 @@ notation (for example: 127.0.0.1:11211).
 END
     localconfig_memcached_namespace => <<'END',
 Specify a string to prefix each key on Memcached.
+END
+    localconfig_ses_username => <<'END',
+Username for HTTP Basic Authentication in front of the SES bounce handler.
+END
+    localconfig_ses_password => <<'END',
+Password for HTTP Basic Authentication in front of the SES bounce handler.
 END
     localconfig_site_wide_secret => <<'END',
 This secret key is used by your installation for the creation and
index e0ec2ef96bd0a47631acb92b4f4596bf768dedf4..683a28a6fc0ece6051791666c53587bf86cef4a9 100644 (file)
@@ -36,3 +36,5 @@ $answer{'defaultpriority'}      = '--';
 $answer{'defaultseverity'}      = 'normal';
 $answer{'skin'}                 = 'Mozilla';
 $answer{'docs_urlbase'}         = 'https://bmo.readthedocs.org/en/latest/';
+$answer{'ses_username'}         = 'ses';
+$answer{'ses_password'}         = 'secret';