discourse/app/jobs/regular/user_email.rb
David Taylor c0293339b8
PERF: Do not enqueue digest emails when attempted recently (#10849)
Previously, Jobs::EnqueueDigestEmails would enqueue a digest job for every user, even if there are no topics to send. The digest job would exit, no email would send, and last_emailed_at would not change. 30 minutes later, Jobs::EnqueueDigestEmails would run again and re-enqueue jobs for the same users.

120fa8ad introduced a temporary mitigation for this issue, by randomly selecting a subset of those users each time.

This commit adds a new `digest_attempted_at` column to the `user_stats` table. This column is updated every time a digest job completes for a user. Using this, we can avoid scheduling digest jobs for the same user every 30 minutes. This also removes the random user selection in 120fa8ad, and instead prioritizes users who had digests attempted the longest time ago.
2020-10-07 15:30:38 +01:00

276 lines
9.3 KiB
Ruby

# frozen_string_literal: true
module Jobs
# Asynchronously send an email to a user
class UserEmail < ::Jobs::Base
include Skippable
sidekiq_options queue: 'low'
# Can be overridden by subclass, for example critical email
# should always consider being sent
def quit_email_early?
SiteSetting.disable_emails == 'yes'
end
def execute(args)
raise Discourse::InvalidParameters.new(:user_id) unless args[:user_id].present?
raise Discourse::InvalidParameters.new(:type) unless args[:type].present?
# This is for performance. Quit out fast without doing a bunch
# of extra work when emails are disabled.
return if quit_email_early?
send_user_email(args)
if args[:user_id].present? && args[:type] == :digest
# Record every attempt at sending a digest email, even if it was skipped
UserStat.where(user_id: args[:user_id]).update_all(digest_attempted_at: Time.zone.now)
end
end
def send_user_email(args)
post = nil
notification = nil
type = args[:type]
user = User.find_by(id: args[:user_id])
to_address = args[:to_address].presence || user&.primary_email&.email.presence || "no_email_found"
set_skip_context(type, args[:user_id], to_address, args[:post_id])
return skip(SkippedEmailLog.reason_types[:user_email_no_user]) if !user
return skip(SkippedEmailLog.reason_types[:user_email_no_email]) if to_address == "no_email_found"
if args[:post_id].present?
post = Post.find_by(id: args[:post_id])
if post.blank?
return skip(SkippedEmailLog.reason_types[:user_email_post_not_found])
end
if !Guardian.new(user).can_see?(post)
return skip(SkippedEmailLog.reason_types[:user_email_access_denied])
end
end
if args[:notification_id].present?
notification = Notification.find_by(id: args[:notification_id])
end
message, skip_reason_type = message_for_email(
user,
post,
type,
notification,
args
)
if message
Email::Sender.new(message, type, user).send
if (b = user.user_stat.bounce_score) > SiteSetting.bounce_score_erode_on_send
# erode bounce score each time we send an email
# this means that we are punished a lot less for bounces
# and we can recover more quickly
user.user_stat.update(bounce_score: b - SiteSetting.bounce_score_erode_on_send)
end
else
skip_reason_type
end
end
def set_skip_context(type, user_id, to_address, post_id)
@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
}
def message_for_email(user, post, type, notification, args = nil)
args ||= {}
notification_type = args[:notification_type]
notification_data_hash = args[:notification_data_hash]
email_token = args[:email_token]
to_address = args[:to_address]
set_skip_context(type, user.id, to_address || user.email, post.try(:id))
if user.anonymous?
return skip_message(SkippedEmailLog.reason_types[:user_email_anonymous_user])
end
if user.suspended? && !["user_private_message", "account_suspended"].include?(type.to_s)
return skip_message(SkippedEmailLog.reason_types[:user_email_user_suspended_not_pm])
end
if type.to_s == "digest"
return if user.staged
return if user.last_emailed_at &&
user.last_emailed_at >
(user.user_option&.digest_after_minutes || SiteSetting.default_email_digest_frequency.to_i).minutes.ago
end
seen_recently = (user.last_seen_at.present? && user.last_seen_at > SiteSetting.email_time_window_mins.minutes.ago)
seen_recently = false if always_email_regular?(user, type) || always_email_private_message?(user, type) || user.staged
email_args = {}
if (post || notification || notification_type) &&
(seen_recently && !user.suspended?)
return skip_message(SkippedEmailLog.reason_types[:user_email_seen_recently])
end
email_args[:post] = post if post
if notification || notification_type
email_args[:notification_type] ||= notification_type || notification.try(:notification_type)
email_args[:notification_data_hash] ||= notification_data_hash || notification.try(:data_hash)
unless String === email_args[:notification_type]
if Numeric === email_args[:notification_type]
email_args[:notification_type] = Notification.types[email_args[:notification_type]]
end
email_args[:notification_type] = email_args[:notification_type].to_s
end
if user.user_option.mailing_list_mode? &&
user.user_option.mailing_list_mode_frequency > 0 && # don't catch notifications for users on daily mailing list mode
(!post.try(:topic).try(:private_message?)) &&
NOTIFICATIONS_SENT_BY_MAILING_LIST.include?(email_args[:notification_type])
# no need to log a reason when the mail was already sent via the mailing list job
return [nil, nil]
end
unless always_email_regular?(user, type) || always_email_private_message?(user, type)
if (notification && notification.read?) || (post && post.seen?(user))
return skip_message(SkippedEmailLog.reason_types[:user_email_notification_already_read])
end
end
end
skip_reason_type = skip_email_for_post(post, user)
return skip_message(skip_reason_type) if skip_reason_type.present?
# Make sure that mailer exists
raise Discourse::InvalidParameters.new("type=#{type}") unless UserNotifications.respond_to?(type)
if email_token.present?
email_args[:email_token] = email_token
if type.to_s == "confirm_new_email"
change_req = EmailChangeRequest.find_by_new_token(email_token)
if change_req
email_args[:requested_by_admin] = change_req.requested_by_admin?
end
end
end
email_args[:new_email] = args[:new_email] || user.email if type.to_s == "notify_old_email" || type.to_s == "notify_old_email_add"
if args[:client_ip] && args[:user_agent]
email_args[:client_ip] = args[:client_ip]
email_args[:user_agent] = args[:user_agent]
end
if EmailLog.reached_max_emails?(user, type.to_s)
return skip_message(SkippedEmailLog.reason_types[:exceeded_emails_limit])
end
if !EmailLog::CRITICAL_EMAIL_TYPES.include?(type.to_s) && user.user_stat.bounce_score >= SiteSetting.bounce_score_threshold
return skip_message(SkippedEmailLog.reason_types[:exceeded_bounces_limit])
end
if args[:user_history_id]
email_args[:user_history] = UserHistory.where(id: args[:user_history_id]).first
end
message = EmailLog.unique_email_per_post(post, user) do
UserNotifications.public_send(type, user, email_args)
end
# Update the to address if we have a custom one
message.to = to_address if message && to_address.present?
[message, nil]
end
sidekiq_retry_in do |count, exception|
# retry in an hour when SMTP server is busy
# or use default sidekiq retry formula
case exception.wrapped
when Net::SMTPServerBusy
1.hour + (rand(30) * (count + 1))
else
::Jobs::UserEmail.seconds_to_delay(count)
end
end
# extracted from sidekiq
def self.seconds_to_delay(count)
(count**4) + 15 + (rand(30) * (count + 1))
end
private
def skip_message(reason)
[nil, skip(reason)]
end
# If this email has a related post, don't send an email if it's been deleted or seen recently.
def skip_email_for_post(post, user)
if post
if post.topic.blank?
return SkippedEmailLog.reason_types[:user_email_topic_nil]
end
if post.user.blank?
return SkippedEmailLog.reason_types[:user_email_post_user_deleted]
end
if post.user_deleted?
return SkippedEmailLog.reason_types[:user_email_post_deleted]
end
if user.suspended? && (!post.user&.staff? || !post.user&.human?)
return SkippedEmailLog.reason_types[:user_email_user_suspended]
end
already_read = user.user_option.email_level != UserOption.email_level_types[:always] && PostTiming.exists?(topic_id: post.topic_id, post_number: post.post_number, user_id: user.id)
if already_read
SkippedEmailLog.reason_types[:user_email_already_read]
end
else
false
end
end
def skip(reason_type)
create_skipped_email_log(
email_type: @skip_context[:type],
to_address: @skip_context[:to_address],
user_id: @skip_context[:user_id],
post_id: @skip_context[:post_id],
reason_type: reason_type
)
end
def always_email_private_message?(user, type)
type.to_s == "user_private_message" && user.user_option.email_messages_level == UserOption.email_level_types[:always]
end
def always_email_regular?(user, type)
type.to_s != "user_private_message" && user.user_option.email_level == UserOption.email_level_types[:always]
end
end
end