From 1e57bbf5c8f28e4439bd09a09e8ad368c56afd04 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9gis=20Hanol?= <regis@hanol.fr>
Date: Fri, 6 May 2016 19:34:33 +0200
Subject: [PATCH] Lots bounce emails related fixes

- Show bounce score on user admin page
- Added reset bounce score button on user admin page
- Only whitelisted email types are sent to emails with high bounce score
- FIX: properly detect bounces even when there is no TO: header in the email
- Don't desactivate a user when reaching the bounce threshold
---
 .../admin/models/admin-user.js.es6            | 36 +++++++++++++++++--
 .../admin/templates/email-bounced.hbs         | 11 +-----
 .../admin/templates/user-index.hbs            | 15 +++++++-
 app/controllers/admin/users_controller.rb     |  9 ++++-
 app/jobs/regular/user_email.rb                | 23 ++++++++++--
 app/jobs/scheduled/poll_mailbox.rb            |  2 +-
 .../admin_detailed_user_serializer.rb         | 12 ++++++-
 config/locales/client.en.yml                  | 10 +++++-
 config/locales/server.en.yml                  |  2 +-
 config/routes.rb                              |  1 +
 config/site_settings.yml                      |  1 +
 lib/email/receiver.rb                         | 24 ++++++-------
 lib/guardian/user_guardian.rb                 |  4 +++
 spec/components/email/receiver_spec.rb        |  5 +--
 14 files changed, 118 insertions(+), 37 deletions(-)

diff --git a/app/assets/javascripts/admin/models/admin-user.js.es6 b/app/assets/javascripts/admin/models/admin-user.js.es6
index 5c0660fedd9..b5767f781a5 100644
--- a/app/assets/javascripts/admin/models/admin-user.js.es6
+++ b/app/assets/javascripts/admin/models/admin-user.js.es6
@@ -1,3 +1,4 @@
+import computed from 'ember-addons/ember-computed-decorators';
 import { propertyNotEqual } from 'discourse/lib/computed';
 import { popupAjaxError } from 'discourse/lib/ajax-error';
 import ApiKey from 'admin/models/api-key';
@@ -6,11 +7,42 @@ import TL3Requirements from 'admin/models/tl3-requirements';
 
 const AdminUser = Discourse.User.extend({
 
-  customGroups: Em.computed.filter("groups", (g) => !g.automatic && Group.create(g)),
-  automaticGroups: Em.computed.filter("groups", (g) => g.automatic && Group.create(g)),
+  customGroups: Ember.computed.filter("groups", g => !g.automatic && Group.create(g)),
+  automaticGroups: Ember.computed.filter("groups", g => g.automatic && Group.create(g)),
 
   canViewProfile: Ember.computed.or("active", "staged"),
 
+  @computed("bounce_score", "reset_bounce_score_after")
+  bounceScore(bounce_score, reset_bounce_score_after) {
+    if (bounce_score > 0) {
+      return `${bounce_score} - ${moment(reset_bounce_score_after).format('LL')}`;
+    } else {
+      return bounce_score;
+    }
+  },
+
+  @computed("bounce_score")
+  bounceScoreExplanation(bounce_score) {
+    if (bounce_score === 0) {
+      return I18n.t("admin.user.bounce_score_explanation.none");
+    } else if (bounce_score < Discourse.SiteSettings.bounce_score_threshold) {
+      return I18n.t("admin.user.bounce_score_explanation.some");
+    } else {
+      return I18n.t("admin.user.bounce_score_explanation.threshold_reached");
+    }
+  },
+
+  canResetBounceScore: Ember.computed.gt("bounce_score", 0),
+
+  resetBounceScore() {
+    return Discourse.ajax(`/admin/users/${this.get("id")}/reset_bounce_score`, {
+      type: 'POST'
+    }).then(() => this.setProperties({
+      "bounce_score": 0,
+      "reset_bounce_score_after": null
+    }));
+  },
+
   generateApiKey() {
     const self = this;
     return Discourse.ajax("/admin/users/" + this.get('id') + "/generate_api_key", {
diff --git a/app/assets/javascripts/admin/templates/email-bounced.hbs b/app/assets/javascripts/admin/templates/email-bounced.hbs
index b03e6179bec..e2df9e04bf4 100644
--- a/app/assets/javascripts/admin/templates/email-bounced.hbs
+++ b/app/assets/javascripts/admin/templates/email-bounced.hbs
@@ -6,7 +6,6 @@
         <th>{{i18n 'admin.email.user'}}</th>
         <th>{{i18n 'admin.email.to_address'}}</th>
         <th>{{i18n 'admin.email.email_type'}}</th>
-        <th>{{i18n 'admin.email.skipped_reason'}}</th>
       </tr>
     </thead>
 
@@ -15,7 +14,6 @@
       <td>{{text-field value=filter.user placeholderKey="admin.email.logs.filters.user_placeholder"}}</td>
       <td>{{text-field value=filter.address placeholderKey="admin.email.logs.filters.address_placeholder"}}</td>
       <td>{{text-field value=filter.type placeholderKey="admin.email.logs.filters.type_placeholder"}}</td>
-      <td>{{text-field value=filter.skipped_reason placeholderKey="admin.email.logs.filters.skipped_reason_placeholder"}}</td>
     </tr>
 
     {{#each model as |l|}}
@@ -31,16 +29,9 @@
         </td>
         <td><a href='mailto:{{unbound l.to_address}}'>{{l.to_address}}</a></td>
         <td>{{l.email_type}}</td>
-        <td>
-          {{#if l.post_url}}
-            <a href="{{l.post_url}}">{{l.skipped_reason}}</a>
-          {{else}}
-            {{l.skipped_reason}}
-          {{/if}}
-        </td>
       </tr>
     {{else}}
-      <tr><td colspan="5">{{i18n 'admin.email.logs.none'}}</td></tr>
+      <tr><td colspan="4">{{i18n 'admin.email.logs.none'}}</td></tr>
     {{/each}}
 
   </table>
diff --git a/app/assets/javascripts/admin/templates/user-index.hbs b/app/assets/javascripts/admin/templates/user-index.hbs
index 663d5062f7f..6499840ac28 100644
--- a/app/assets/javascripts/admin/templates/user-index.hbs
+++ b/app/assets/javascripts/admin/templates/user-index.hbs
@@ -350,7 +350,20 @@
   <div class="display-row">
     <div class='field'>{{i18n 'admin.user.staged'}}</div>
     <div class='value'>{{model.staged}}</div>
-    <div class='controls'>{{i18n 'admin.user.stage_explanation'}}</div>
+    <div class='controls'>{{i18n 'admin.user.staged_explanation'}}</div>
+  </div>
+
+  <div class="display-row">
+    <div class='field'>{{i18n 'admin.user.bounce_score'}}</div>
+    <div class='value'>{{model.bounceScore}}</div>
+    <div class='controls'>
+      {{#if model.canResetBounceScore}}
+        <button class='btn' {{action "resetBounceScore" target="content"}} title={{i18n "admin.user.reset_bounce_score.title"}}>
+          {{i18n "admin.user.reset_bounce_score.label"}}
+        </button>
+      {{/if}}
+      {{model.bounceScoreExplanation}}
+    </div>
   </div>
 </section>
 
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 8b10de76627..4f02e19ab27 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -23,7 +23,8 @@ class Admin::UsersController < Admin::AdminController
                                     :primary_group,
                                     :generate_api_key,
                                     :revoke_api_key,
-                                    :anonymize]
+                                    :anonymize,
+                                    :reset_bounce_score]
 
   def index
     users = ::AdminUserIndexQuery.new(params).find_users
@@ -355,6 +356,12 @@ class Admin::UsersController < Admin::AdminController
     end
   end
 
+  def reset_bounce_score
+    guardian.ensure_can_reset_bounce_score!(@user)
+    @user.user_stat.update_columns(bounce_score: 0, reset_bounce_score_after: nil)
+    render json: success_json
+  end
+
   private
 
     def fetch_user
diff --git a/app/jobs/regular/user_email.rb b/app/jobs/regular/user_email.rb
index 4a7a87d96db..8a109d233de 100644
--- a/app/jobs/regular/user_email.rb
+++ b/app/jobs/regular/user_email.rb
@@ -48,7 +48,24 @@ module Jobs
       @skip_context = { type: type, user_id: user_id, to_address: to_address, post_id: post_id }
     end
 
-    NOTIFICATIONS_SENT_BY_MAILING_LIST ||= Set.new %w{posted replied mentioned group_mentioned quoted}
+    NOTIFICATIONS_SENT_BY_MAILING_LIST ||= Set.new %w{
+      posted
+      replied
+      mentioned
+      group_mentioned
+      quoted
+    }
+
+    CRITICAL_EMAIL_TYPES = Set.new %i{
+      account_created
+      admin_login
+      confirm_new_email
+      confirm_old_email
+      forgot_password
+      notify_old_email
+      signup
+      signup_after_approval
+    }
 
     def message_for_email(user, post, type, notification,
                          notification_type=nil, notification_data_hash=nil,
@@ -109,7 +126,7 @@ module Jobs
         email_args[:email_token] = email_token
       end
 
-      if type == 'notify_old_email'
+      if type == :notify_old_email
         email_args[:new_email] = user.email
       end
 
@@ -117,7 +134,7 @@ module Jobs
         return skip_message(I18n.t('email_log.exceeded_emails_limit'))
       end
 
-      if (user.user_stat.try(:bounce_score) || 0) >= SiteSetting.bounce_score_threshold
+      if !CRITICAL_EMAIL_TYPES.include?(type) && user.user_stat.bounce_score >= SiteSetting.bounce_score_threshold
         return skip_message(I18n.t('email_log.exceeded_bounces_limit'))
       end
 
diff --git a/app/jobs/scheduled/poll_mailbox.rb b/app/jobs/scheduled/poll_mailbox.rb
index cd6b3226f8a..583f8e76302 100644
--- a/app/jobs/scheduled/poll_mailbox.rb
+++ b/app/jobs/scheduled/poll_mailbox.rb
@@ -30,7 +30,7 @@ module Jobs
 
         set_incoming_email_rejection_message(
           receiver.incoming_email,
-          I18n.t("email.incoming.errors.bounced_email_report")
+          I18n.t("email.incoming.errors.bounced_email_error")
         )
       rescue Email::Receiver::AutoGeneratedEmailReplyError => e
         log_email_process_failure(mail_string, e)
diff --git a/app/serializers/admin_detailed_user_serializer.rb b/app/serializers/admin_detailed_user_serializer.rb
index ea7bfbabba9..b6a2072f97c 100644
--- a/app/serializers/admin_detailed_user_serializer.rb
+++ b/app/serializers/admin_detailed_user_serializer.rb
@@ -20,7 +20,9 @@ class AdminDetailedUserSerializer < AdminUserSerializer
              :primary_group_id,
              :badge_count,
              :warnings_received_count,
-             :user_fields
+             :user_fields,
+             :bounce_score,
+             :reset_bounce_score_after
 
   has_one :approved_by, serializer: BasicUserSerializer, embed: :objects
   has_one :api_key, serializer: ApiKeySerializer, embed: :objects
@@ -76,4 +78,12 @@ class AdminDetailedUserSerializer < AdminUserSerializer
     object.user_fields.present?
   end
 
+  def bounce_score
+    object.user_stat.bounce_score
+  end
+
+  def reset_bounce_score_after
+    object.user_stat.reset_bounce_score_after
+  end
+
 end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 59b4791ade3..95200674c17 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2593,10 +2593,18 @@ en:
         block_failed: 'There was a problem blocking the user.'
         block_confirm: 'Are you sure you want to block this user? They will not be able to create any new topics or posts.'
         block_accept: 'Yes, block this user'
+        bounce_score: "Bounce Score"
+        reset_bounce_score:
+          label: "Reset"
+          title: "Reset bounce score back to 0"
         deactivate_explanation: "A deactivated user must re-validate their email."
         suspended_explanation: "A suspended user can't log in."
         block_explanation: "A blocked user can't post or start topics."
-        stage_explanation: "A staged user can only post via email in specific topics."
+        staged_explanation: "A staged user can only post via email in specific topics."
+        bounce_score_explanation:
+          none: "No bounces were received recently from that email."
+          some: "Some bounces were received recently from that email."
+          threshold_reached: "Received too many bounces from that email."
         trust_level_change_failed: "There was a problem changing the user's trust level."
         suspend_modal_title: "Suspend User"
         trust_level_2_users: "Trust Level 2 Users"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 01345d0094c..e67d48fff2d 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -64,7 +64,7 @@ en:
         reply_user_not_matching_error: "Happens when a reply came in from a different email address the notification was sent to."
         topic_not_found_error: "Happens when a reply came in but the related topic has been deleted."
         topic_closed_error: "Happens when a reply came in but the related topic has been closed."
-        bounced_email_report: "Email is a bounced email report."
+        bounced_email_error: "Email is a bounced email report."
         auto_generated_email_reply: "Email contains a reply to an auto generated email."
         screened_email_error: "Happens when the sender's email address was already screened."
 
diff --git a/config/routes.rb b/config/routes.rb
index 31682f385ec..21459fc9c3f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -108,6 +108,7 @@ Discourse::Application.routes.draw do
       get "leader_requirements" => "users#tl3_requirements"
       get "tl3_requirements"
       put "anonymize"
+      post "reset_bounce_score"
     end
     get "users/:id.json" => 'users#show', defaults: {format: 'json'}
     get 'users/:id/:username' => 'users#show'
diff --git a/config/site_settings.yml b/config/site_settings.yml
index a8fac94afbd..937752b5f33 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -572,6 +572,7 @@ email:
     type: list
   block_auto_generated_emails: true
   bounce_score_threshold:
+    client: true
     default: 4
     min: 1
 
diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb
index 83543fe36ae..e766554a3ce 100644
--- a/lib/email/receiver.rb
+++ b/lib/email/receiver.rb
@@ -156,7 +156,7 @@ module Email
     end
 
     def verp
-      @verp ||= @mail.destinations.select { |to| to[/\+verp-\h{32}@/] }.first
+      @verp ||= all_destinations.select { |to| to[/\+verp-\h{32}@/] }.first
     end
 
     def update_bounce_score(email, score)
@@ -171,10 +171,8 @@ module Email
           user.user_stat.reset_bounce_score_after = 30.days.from_now
           user.user_stat.save
 
-          if user.active && user.user_stat.bounce_score >= SiteSetting.bounce_score_threshold
-            user.deactivate
+          if user.user_stat.bounce_score >= SiteSetting.bounce_score_threshold
             StaffActionLogger.new(Discourse.system_user).log_revoke_email(user)
-            EmailToken.where(email: user.email, confirmed: true).update_all(confirmed: false)
           end
         end
 
@@ -292,16 +290,18 @@ module Email
       user
     end
 
-    def destinations
-      [  @mail.destinations,
+    def all_destinations
+      @all_destinations ||= [
+        @mail.destinations,
         [@mail[:x_forwarded_to]].flatten.compact.map(&:decoded),
         [@mail[:delivered_to]].flatten.compact.map(&:decoded),
-      ].flatten
-       .select(&:present?)
-       .uniq
-       .lazy
-       .map { |d| check_address(d) }
-       .drop_while(&:blank?)
+      ].flatten.select(&:present?).uniq.lazy
+    end
+
+    def destinations
+      all_destinations
+        .map { |d| check_address(d) }
+        .drop_while(&:blank?)
     end
 
     def check_address(address)
diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb
index f67f494108e..3eca04f589b 100644
--- a/lib/guardian/user_guardian.rb
+++ b/lib/guardian/user_guardian.rb
@@ -51,6 +51,10 @@ module UserGuardian
     is_staff? && !user.nil? && !user.staff?
   end
 
+  def can_reset_bounce_score?(user)
+    user && is_staff?
+  end
+
   def can_check_emails?(user)
     is_admin? || (is_staff? && SiteSetting.show_email_on_profile)
   end
diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb
index b6b33fefd43..f8497dc28bd 100644
--- a/spec/components/email/receiver_spec.rb
+++ b/spec/components/email/receiver_spec.rb
@@ -68,7 +68,7 @@ describe Email::Receiver do
 
     let(:bounce_key) { "14b08c855160d67f2e0c2f8ef36e251e" }
     let(:bounce_key_2) { "b542fb5a9bacda6d28cc061d18e4eb83" }
-    let!(:user) { Fabricate(:user, email: "foo@bar.com", active: true) }
+    let!(:user) { Fabricate(:user, email: "foo@bar.com") }
     let!(:email_log) { Fabricate(:email_log, user: user, bounce_key: bounce_key) }
     let!(:email_log_2) { Fabricate(:email_log, user: user, bounce_key: bounce_key_2) }
 
@@ -82,7 +82,6 @@ describe Email::Receiver do
 
       email_log.reload
       expect(email_log.bounced).to eq(true)
-      expect(email_log.user.active).to eq(true)
       expect(email_log.user.user_stat.bounce_score).to eq(1)
     end
 
@@ -91,7 +90,6 @@ describe Email::Receiver do
 
       email_log.reload
       expect(email_log.bounced).to eq(true)
-      expect(email_log.user.active).to eq(true)
       expect(email_log.user.user_stat.bounce_score).to eq(2)
 
       Timecop.freeze(2.days.from_now) do
@@ -99,7 +97,6 @@ describe Email::Receiver do
 
         email_log_2.reload
         expect(email_log_2.bounced).to eq(true)
-        expect(email_log_2.user.active).to eq(false)
         expect(email_log_2.user.user_stat.bounce_score).to eq(4)
       end
     end