discourse/app/jobs/regular/user_email.rb
Martin Brennan 6e2be3e60b
FIX: When admin changes an email for the user the user must confirm the change (#10830)
See https://meta.discourse.org/t/changing-a-users-email/164512 for additional context.

Previously when an admin user changed a user's email we assumed that they would need a password reset too because they likely did not have access to their account. This proved to be incorrect, as there are other reasons a user needs admin to change their email. This PR:

* Changes the admin change email for user flow so the user is sent an email to confirm the change
* We now record who the email change request was requested by
* If the requested by user is admin and not the user we note this in the email sent to the user
* We also make the confirm change email route open to anonymous users, so it can be clicked by the user even if they do not have access to their account. If there is a logged in user we make sure the confirmation matches the current user.
2020-10-07 13:02:24 +10:00

267 lines
9.0 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?
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