DEV: Add bounce_error_code to EmailLog (#15948)

Whenever we got a bounced email in the Email::Receiver we
previously would just set bounced: true on the EmailLog and
discard the status/diagnostic code. This commit changes this
flow to store the bounce error code (defined in the RFC at
https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xhtml)
not just in the Email::Receiver, but also via webhook events
from other mail services and from SNS.

This commit does not surface the bounce error in the UI,
we can do that later if necessary.
This commit is contained in:
Martin Brennan
2022-02-15 14:17:26 +10:00
committed by GitHub
parent a0c65e91d9
commit 4086ee551e
8 changed files with 92 additions and 42 deletions

View File

@ -15,14 +15,15 @@ class WebhooksController < ActionController::Base
events.each do |event| events.each do |event|
message_id = Email::MessageIdService.message_id_clean((event["smtp-id"] || "")) message_id = Email::MessageIdService.message_id_clean((event["smtp-id"] || ""))
to_address = event["email"] to_address = event["email"]
error_code = event["status"]
if event["event"] == "bounce" if event["event"] == "bounce"
if event["status"]["4."] if error_code[Email::SMTP_STATUS_TRANSIENT_FAILURE]
process_bounce(message_id, to_address, SiteSetting.soft_bounce_score) process_bounce(message_id, to_address, SiteSetting.soft_bounce_score, error_code)
else else
process_bounce(message_id, to_address, SiteSetting.hard_bounce_score) process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code)
end end
elsif event["event"] == "dropped" elsif event["event"] == "dropped"
process_bounce(message_id, to_address, SiteSetting.hard_bounce_score) process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code)
end end
end end
@ -51,12 +52,13 @@ class WebhooksController < ActionController::Base
events.each do |event| events.each do |event|
message_id = event.dig("msg", "metadata", "message_id") message_id = event.dig("msg", "metadata", "message_id")
to_address = event.dig("msg", "email") to_address = event.dig("msg", "email")
error_code = event.dig("msg", "diag")
case event["event"] case event["event"]
when "hard_bounce" when "hard_bounce"
process_bounce(message_id, to_address, SiteSetting.hard_bounce_score) process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code)
when "soft_bounce" when "soft_bounce"
process_bounce(message_id, to_address, SiteSetting.soft_bounce_score) process_bounce(message_id, to_address, SiteSetting.soft_bounce_score, error_code)
end end
end end
@ -152,14 +154,15 @@ class WebhooksController < ActionController::Base
event = params["event"] event = params["event"]
message_id = Email::MessageIdService.message_id_clean(params["Message-Id"]) message_id = Email::MessageIdService.message_id_clean(params["Message-Id"])
to_address = params["recipient"] to_address = params["recipient"]
error_code = params["code"]
# only handle soft bounces, because hard bounces are also handled # only handle soft bounces, because hard bounces are also handled
# by the "dropped" event and we don't want to increase bounce score twice # by the "dropped" event and we don't want to increase bounce score twice
# for the same message # for the same message
if event == "bounced" && params["error"]["4."] if event == "bounced" && params["error"][Email::SMTP_STATUS_TRANSIENT_FAILURE]
process_bounce(message_id, to_address, SiteSetting.soft_bounce_score) process_bounce(message_id, to_address, SiteSetting.soft_bounce_score, error_code)
elsif event == "dropped" elsif event == "dropped"
process_bounce(message_id, to_address, SiteSetting.hard_bounce_score) process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code)
end end
success success
@ -170,28 +173,29 @@ class WebhooksController < ActionController::Base
return mailgun_failure unless valid_mailgun_signature?(signature["token"], signature["timestamp"], signature["signature"]) return mailgun_failure unless valid_mailgun_signature?(signature["token"], signature["timestamp"], signature["signature"])
data = params["event-data"] data = params["event-data"]
error_code = params.dig("delivery-status", "code")
message_id = data.dig("message", "headers", "message-id") message_id = data.dig("message", "headers", "message-id")
to_address = data["recipient"] to_address = data["recipient"]
severity = data["severity"] severity = data["severity"]
if data["event"] == "failed" if data["event"] == "failed"
if severity == "temporary" if severity == "temporary"
process_bounce(message_id, to_address, SiteSetting.soft_bounce_score) process_bounce(message_id, to_address, SiteSetting.soft_bounce_score, error_code)
elsif severity == "permanent" elsif severity == "permanent"
process_bounce(message_id, to_address, SiteSetting.hard_bounce_score) process_bounce(message_id, to_address, SiteSetting.hard_bounce_score, error_code)
end end
end end
success success
end end
def process_bounce(message_id, to_address, bounce_score) def process_bounce(message_id, to_address, bounce_score, bounce_error_code = nil)
return if message_id.blank? || to_address.blank? return if message_id.blank? || to_address.blank?
email_log = EmailLog.find_by(message_id: message_id, to_address: to_address) email_log = EmailLog.find_by(message_id: message_id, to_address: to_address)
return if email_log.nil? return if email_log.nil?
email_log.update_columns(bounced: true) email_log.update_columns(bounced: true, bounce_error_code: bounce_error_code)
return if email_log.user.nil? || email_log.user.email.blank? return if email_log.user.nil? || email_log.user.email.blank?
Email::Receiver.update_bounce_score(email_log.user.email, bounce_score) Email::Receiver.update_bounce_score(email_log.user.email, bounce_score)

View File

@ -25,7 +25,7 @@ module Jobs
message.dig("bounce", "bouncedRecipients").each do |r| message.dig("bounce", "bouncedRecipients").each do |r|
if email_log = EmailLog.order("created_at DESC").where(to_address: r["emailAddress"]).first if email_log = EmailLog.order("created_at DESC").where(to_address: r["emailAddress"]).first
email_log.update_columns(bounced: true) email_log.update_columns(bounced: true, bounce_error_code: r["status"])
if email_log.user&.email.present? if email_log.user&.email.present?
if email_log.user.user_stat.bounce_score.to_s.start_with?("4.") || bounce_type == "Transient" if email_log.user.user_stat.bounce_score.to_s.start_with?("4.") || bounce_type == "Transient"

View File

@ -117,21 +117,22 @@ end
# #
# Table name: email_logs # Table name: email_logs
# #
# id :integer not null, primary key # id :integer not null, primary key
# to_address :string not null # to_address :string not null
# email_type :string not null # email_type :string not null
# user_id :integer # user_id :integer
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# post_id :integer # post_id :integer
# bounce_key :uuid # bounce_key :uuid
# bounced :boolean default(FALSE), not null # bounced :boolean default(FALSE), not null
# message_id :string # message_id :string
# smtp_group_id :integer # smtp_group_id :integer
# cc_addresses :text # cc_addresses :text
# cc_user_ids :integer is an Array # cc_user_ids :integer is an Array
# raw :text # raw :text
# topic_id :integer # topic_id :integer
# bounce_error_code :string
# #
# Indexes # Indexes
# #

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddBounceErrorCodeToEmailLog < ActiveRecord::Migration[6.1]
def change
add_column :email_logs, :bounce_error_code, :string, null: true
end
end

View File

@ -3,6 +3,11 @@
require 'mail' require 'mail'
module Email module Email
# See https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xhtml#smtp-enhanced-status-codes-1
SMTP_STATUS_SUCCESS = "2."
SMTP_STATUS_TRANSIENT_FAILURE = "4."
SMTP_STATUS_PERMANENT_FAILURE = "5."
def self.is_valid?(email) def self.is_valid?(email)
return false unless String === email return false unless String === email
!!(EmailValidator.email_regex =~ email) !!(EmailValidator.email_regex =~ email)

View File

@ -238,16 +238,20 @@ module Email
def handle_bounce def handle_bounce
@incoming_email.update_columns(is_bounce: true) @incoming_email.update_columns(is_bounce: true)
mail_error_statuses = Array.wrap(@mail.error_status)
if email_log.present? if email_log.present?
email_log.update_columns(bounced: true) email_log.update_columns(
bounced: true,
bounce_error_code: mail_error_statuses.first
)
post = email_log.post post = email_log.post
topic = email_log.topic topic = email_log.topic
end end
DiscourseEvent.trigger(:email_bounce, @mail, @incoming_email, @email_log) DiscourseEvent.trigger(:email_bounce, @mail, @incoming_email, @email_log)
if @mail.error_status.present? && Array.wrap(@mail.error_status).any? { |s| s.start_with?("4.") } if mail_error_statuses.any? { |s| s.start_with?(Email::SMTP_STATUS_TRANSIENT_FAILURE) }
Email::Receiver.update_bounce_score(@from_email, SiteSetting.soft_bounce_score) Email::Receiver.update_bounce_score(@from_email, SiteSetting.soft_bounce_score)
else else
Email::Receiver.update_bounce_score(@from_email, SiteSetting.hard_bounce_score) Email::Receiver.update_bounce_score(@from_email, SiteSetting.hard_bounce_score)

View File

@ -147,17 +147,29 @@ describe Email::Receiver do
expect(IncomingEmail.last.is_bounce).to eq(true) expect(IncomingEmail.last.is_bounce).to eq(true)
end end
it "when bounce with verp" do context "when bounce with verp" do
SiteSetting.reply_by_email_address = "foo+%{reply_key}@discourse.org" let(:bounce_key) { "14b08c855160d67f2e0c2f8ef36e251e" }
bounce_key = "14b08c855160d67f2e0c2f8ef36e251e"
create_post_reply_key(bounce_key)
Fabricate(:email_log, to_address: email_address, user: user2, bounce_key: bounce_key, post: post)
expect { process(:hard_bounce_via_verp) }.to raise_error(Email::Receiver::BouncedEmailError) before do
post = Post.last SiteSetting.reply_by_email_address = "foo+%{reply_key}@discourse.org"
expect(post.whisper?).to eq(true) create_post_reply_key(bounce_key)
expect(post.raw).to eq(I18n.t("system_messages.email_bounced", email: email_address, raw: "Your email bounced").strip) Fabricate(:email_log, to_address: email_address, user: user2, bounce_key: bounce_key, post: post)
expect(IncomingEmail.last.is_bounce).to eq(true) end
it "creates a post with the bounce error" do
expect { process(:hard_bounce_via_verp) }.to raise_error(Email::Receiver::BouncedEmailError)
post = Post.last
expect(post.whisper?).to eq(true)
expect(post.raw).to eq(I18n.t("system_messages.email_bounced", email: email_address, raw: "Your email bounced").strip)
expect(IncomingEmail.last.is_bounce).to eq(true)
end
it "updates the email log with the bounce error message" do
expect { process(:hard_bounce_via_verp) }.to raise_error(Email::Receiver::BouncedEmailError)
email_log = EmailLog.find_by(bounce_key: bounce_key)
expect(email_log.bounced).to eq(true)
expect(email_log.bounce_error_code).to eq("5.1.1")
end
end end
end end
end end

View File

@ -29,13 +29,16 @@ describe WebhooksController do
"event" => "dropped", "event" => "dropped",
"recipient" => email, "recipient" => email,
"Message-Id" => "<#{message_id}>", "Message-Id" => "<#{message_id}>",
"signature" => signature "signature" => signature,
"error" => "smtp; 550-5.1.1 The email account that you tried to reach does not exist.",
"code" => "5.1.1"
} }
expect(response.status).to eq(200) expect(response.status).to eq(200)
email_log.reload email_log.reload
expect(email_log.bounced).to eq(true) expect(email_log.bounced).to eq(true)
expect(email_log.bounce_error_code).to eq("5.1.1")
expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score)
end end
@ -58,6 +61,11 @@ describe WebhooksController do
"message-id" => message_id, "message-id" => message_id,
} }
} }
},
"delivery-status" => {
"message" => "smtp; 550-5.1.1 The email account that you tried to reach does not exist.",
"code" => "5.1.1",
"description" => ""
} }
} }
@ -65,6 +73,7 @@ describe WebhooksController do
email_log.reload email_log.reload
expect(email_log.bounced).to eq(true) expect(email_log.bounced).to eq(true)
expect(email_log.bounce_error_code).to eq("5.1.1")
expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.soft_bounce_score) expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.soft_bounce_score)
end end
end end
@ -89,6 +98,7 @@ describe WebhooksController do
email_log.reload email_log.reload
expect(email_log.bounced).to eq(true) expect(email_log.bounced).to eq(true)
expect(email_log.bounce_error_code).to eq("5.0.0")
expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score)
end end
end end
@ -109,6 +119,7 @@ describe WebhooksController do
email_log.reload email_log.reload
expect(email_log.bounced).to eq(true) expect(email_log.bounced).to eq(true)
expect(email_log.bounce_error_code).to eq(nil) # mailjet doesn't give us this
expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score)
end end
end end
@ -123,6 +134,8 @@ describe WebhooksController do
"event" => "hard_bounce", "event" => "hard_bounce",
"msg" => { "msg" => {
"email" => email, "email" => email,
"diag" => "5.1.1",
"bounce_description": "smtp; 550-5.1.1 The email account that you tried to reach does not exist.",
"metadata" => { "metadata" => {
"message_id" => message_id "message_id" => message_id
} }
@ -134,6 +147,7 @@ describe WebhooksController do
email_log.reload email_log.reload
expect(email_log.bounced).to eq(true) expect(email_log.bounced).to eq(true)
expect(email_log.bounce_error_code).to eq("5.1.1")
expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score)
end end
end end
@ -152,6 +166,7 @@ describe WebhooksController do
email_log.reload email_log.reload
expect(email_log.bounced).to eq(true) expect(email_log.bounced).to eq(true)
expect(email_log.bounce_error_code).to eq(nil) # postmark doesn't give us this
expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score) expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.hard_bounce_score)
end end
it "soft bounces" do it "soft bounces" do
@ -167,6 +182,7 @@ describe WebhooksController do
email_log.reload email_log.reload
expect(email_log.bounced).to eq(true) expect(email_log.bounced).to eq(true)
expect(email_log.bounce_error_code).to eq(nil) # postmark doesn't give us this
expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.soft_bounce_score) expect(email_log.user.user_stat.bounce_score).to eq(SiteSetting.soft_bounce_score)
end end
end end
@ -181,6 +197,7 @@ describe WebhooksController do
"msys" => { "msys" => {
"message_event" => { "message_event" => {
"bounce_class" => 10, "bounce_class" => 10,
"error_code" => "554",
"rcpt_to" => email, "rcpt_to" => email,
"rcpt_meta" => { "rcpt_meta" => {
"message_id" => message_id "message_id" => message_id