discourse/lib/email/sender.rb
Martin Brennan 61c87fb59f
FIX: Properly attach secure images to email for non-secure uploads (#23865)
There are cases where a user can copy image markdown from a public
post (such as via the discourse-templates plugin) into a PM which
is then sent via an email. Since a PM is a secure context (via the
.with_secure_uploads? check on Post), the image will get a secure
URL in the PM post even though the backing upload is not secure.

This fixes the bug in that case where the image would be stripped
from the email (since it had a /secure-uploads/ URL) but not re-attached
further down the line using the secure_uploads_allow_embed_images_in_emails
setting because the upload itself was not secure.

The flow in Email::Sender for doing this is still not ideal, but
there are chicken and egg problems around when to strip the images,
how to fit in with other attachments and email size limits, and
when to apply the images inline via Email::Styles. It's convoluted,
but at least this fixes the Template use case for now.
2023-10-17 14:08:21 +10:00

641 lines
24 KiB
Ruby

# frozen_string_literal: true
#
# A helper class to send an email. It will also handle a nil message, which it considers
# to be "do nothing". This is because some Mailers will decide not to do work for some
# reason. For example, emailing a user too frequently. A nil to address is also considered
# "do nothing"
#
# It also adds an HTML part for the plain text body
#
require "uri"
require "net/smtp"
SMTP_CLIENT_ERRORS = [Net::SMTPFatalError, Net::SMTPSyntaxError]
BYPASS_DISABLE_TYPES = %w[
admin_login
test_message
new_version
group_smtp
invite_password_instructions
download_backup_message
admin_confirmation_message
]
module Email
class Sender
def initialize(message, email_type, user = nil)
@message = message
@message_attachments_index = {}
@email_type = email_type
@user = user
end
def send
bypass_disable = BYPASS_DISABLE_TYPES.include?(@email_type.to_s)
return if SiteSetting.disable_emails == "yes" && !bypass_disable
return if ActionMailer::Base::NullMail === @message
if ActionMailer::Base::NullMail ===
(
begin
@message.message
rescue StandardError
nil
end
)
return
end
return skip(SkippedEmailLog.reason_types[:sender_message_blank]) if @message.blank?
return skip(SkippedEmailLog.reason_types[:sender_message_to_blank]) if @message.to.blank?
if SiteSetting.disable_emails == "non-staff" && !bypass_disable
return unless find_user&.staff?
end
if to_address.end_with?(".invalid")
return skip(SkippedEmailLog.reason_types[:sender_message_to_invalid])
end
if @message.text_part
if @message.text_part.body.to_s.blank?
return skip(SkippedEmailLog.reason_types[:sender_text_part_body_blank])
end
else
return skip(SkippedEmailLog.reason_types[:sender_body_blank]) if @message.body.to_s.blank?
end
@message.charset = "UTF-8"
opts = {}
renderer = Email::Renderer.new(@message, opts)
if @message.html_part
@message.html_part.body = renderer.html
else
@message.html_part =
Mail::Part.new do
content_type "text/html; charset=UTF-8"
body renderer.html
end
end
# Fix relative (ie upload) HTML links in markdown which do not work well in plain text emails.
# These are the links we add when a user uploads a file or image.
# Ideally we would parse general markdown into plain text, but that is almost an intractable problem.
url_prefix = Discourse.base_url
@message.parts[0].body =
@message.parts[0].body.to_s.gsub(
%r{<a class="attachment" href="(/uploads/default/[^"]+)">([^<]*)</a>},
'[\2|attachment](' + url_prefix + '\1)',
)
@message.parts[0].body =
@message.parts[0].body.to_s.gsub(
%r{<img src="(/uploads/default/[^"]+)"([^>]*)>},
"![](" + url_prefix + '\1)',
)
@message.text_part.content_type = "text/plain; charset=UTF-8"
user_id = @user&.id
# Set up the email log
email_log = EmailLog.new(email_type: @email_type, to_address: to_address, user_id: user_id)
if cc_addresses.any?
email_log.cc_addresses = cc_addresses.join(";")
email_log.cc_user_ids = User.with_email(cc_addresses).pluck(:id)
end
email_log.bcc_addresses = bcc_addresses.join(";") if bcc_addresses.any?
host = Email::Sender.host_for(Discourse.base_url)
post_id = header_value("X-Discourse-Post-Id")
topic_id = header_value("X-Discourse-Topic-Id")
reply_key = get_reply_key(post_id, user_id)
from_address = @message.from&.first
smtp_group_id =
(
if from_address.blank?
nil
else
Group.where(email_username: from_address, smtp_enabled: true).pick(:id)
end
)
# always set a default Message ID from the host
@message.header["Message-ID"] = Email::MessageIdService.generate_default
post = nil
topic = nil
if topic_id.present? && post_id.present?
post = Post.find_by(id: post_id, topic_id: topic_id)
# guards against deleted posts and topics
return skip(SkippedEmailLog.reason_types[:sender_post_deleted]) if post.blank?
topic = post.topic
return skip(SkippedEmailLog.reason_types[:sender_topic_deleted]) if topic.blank?
add_identification_field_headers(topic, post)
# See https://www.ietf.org/rfc/rfc2919.txt for the List-ID
# specification.
if topic&.category && !topic.category.uncategorized?
list_id =
"#{SiteSetting.title} | #{topic.category.name} <#{topic.category.name.downcase.tr(" ", "-")}.#{host}>"
# subcategory case
if !topic.category.parent_category_id.nil?
parent_category_name = Category.find_by(id: topic.category.parent_category_id).name
list_id =
"#{SiteSetting.title} | #{parent_category_name} #{topic.category.name} <#{topic.category.name.downcase.tr(" ", "-")}.#{parent_category_name.downcase.tr(" ", "-")}.#{host}>"
end
else
list_id = "#{SiteSetting.title} <#{host}>"
end
# When we are emailing people from a group inbox, we are having a PM
# conversation with them, as a support account would. In this case
# mailing list headers do not make sense. It is not like a forum topic
# where you may have tens or hundreds of participants -- it is a
# conversation between the group and a small handful of people
# directly contacting the group, often just one person.
if !smtp_group_id
# https://www.ietf.org/rfc/rfc3834.txt
@message.header["Precedence"] = "list"
@message.header["List-ID"] = list_id
if topic
if SiteSetting.private_email?
@message.header["List-Archive"] = "#{Discourse.base_url}#{topic.slugless_url}"
else
@message.header["List-Archive"] = topic.url
end
end
end
end
if Email::Sender.bounceable_reply_address?
email_log.bounce_key = SecureRandom.hex
# WARNING: RFC claims you can not set the Return Path header, this is 100% correct
# however Rails has special handling for this header and ends up using this value
# as the Envelope From address so stuff works as expected
@message.header[:return_path] = Email::Sender.bounce_address(email_log.bounce_key)
end
email_log.post_id = post_id if post_id.present?
email_log.topic_id = topic_id if topic_id.present?
if reply_key.present?
@message.header["Reply-To"] = header_value("Reply-To").gsub!("%{reply_key}", reply_key)
@message.header[Email::MessageBuilder::ALLOW_REPLY_BY_EMAIL_HEADER] = nil
end
MessageBuilder
.custom_headers(SiteSetting.email_custom_headers)
.each do |key, _|
# Any custom headers added via MessageBuilder that are doubled up here
# with values that we determine should be set to the last value, which is
# the one we determined. Our header values should always override the email_custom_headers.
#
# While it is valid via RFC5322 to have more than one value for certain headers,
# we just want to keep it to one, especially in cases where the custom value
# would conflict with our own.
#
# See https://datatracker.ietf.org/doc/html/rfc5322#section-3.6 and
# https://github.com/mikel/mail/blob/8ef377d6a2ca78aa5bd7f739813f5a0648482087/lib/mail/header.rb#L109-L132
custom_header = @message.header[key]
if custom_header.is_a?(Array)
our_value = custom_header.last.value
# Must be set to nil first otherwise another value is just added
# to the array of values for the header.
@message.header[key] = nil
@message.header[key] = our_value
end
value = header_value(key)
# Remove Auto-Submitted header for group private message emails, it does
# not make sense there and may hurt deliverability.
#
# From https://www.iana.org/assignments/auto-submitted-keywords/auto-submitted-keywords.xhtml:
#
# > Indicates that a message was generated by an automatic process, and is not a direct response to another message.
@message.header[key] = nil if key.downcase == "auto-submitted" && smtp_group_id
# Replace reply_key in custom headers or remove
if value&.include?("%{reply_key}")
# Delete old header first or else the same header will be added twice
@message.header[key] = nil
@message.header[key] = value.gsub!("%{reply_key}", reply_key) if reply_key.present?
end
end
# pass the original message_id when using mailjet/mandrill/sparkpost
case ActionMailer::Base.smtp_settings[:address]
when /\.mailjet\.com/
@message.header["X-MJ-CustomID"] = @message.message_id
when "smtp.mandrillapp.com"
merge_json_x_header("X-MC-Metadata", message_id: @message.message_id)
when "smtp.sparkpostmail.com"
merge_json_x_header("X-MSYS-API", metadata: { message_id: @message.message_id })
end
# Parse the HTML again so we can make any final changes before
# sending
style = Email::Styles.new(@message.html_part.body.to_s)
if post.present?
@stripped_secure_upload_shas = style.stripped_upload_sha_map.values
add_attachments(post)
end
# Suppress images from short emails
if SiteSetting.strip_images_from_short_emails &&
@message.html_part.body.to_s.bytesize <= SiteSetting.short_email_length &&
@message.html_part.body =~ /<img[^>]+>/
style.strip_avatars_and_emojis
end
# Embeds any of the secure images that have been attached inline,
# removing the redaction notice.
if SiteSetting.secure_uploads_allow_embed_images_in_emails
style.inline_secure_images(@message.attachments, @message_attachments_index)
end
@message.html_part.body = style.to_s
email_log.message_id = @message.message_id
# Log when a message is being sent from a group SMTP address, so we
# can debug deliverability issues.
if smtp_group_id
email_log.smtp_group_id = smtp_group_id
# Store contents of all outgoing emails using group SMTP
# for greater visibility and debugging. If the size of this
# gets out of hand, we should look into a group-level setting
# to enable this; size should be kept in check by regular purging
# of EmailLog though.
email_log.raw = Email::Cleaner.new(@message).execute
end
DiscourseEvent.trigger(:before_email_send, @message, @email_type)
begin
message_response = @message.deliver!
# TestMailer from the Mail gem does not return a real response, it
# returns an array containing @message, so we have to have this workaround.
if message_response.kind_of?(Net::SMTP::Response)
email_log.smtp_transaction_response = message_response.message&.chomp
end
rescue *SMTP_CLIENT_ERRORS => e
return skip(SkippedEmailLog.reason_types[:custom], custom_reason: e.message)
end
DiscourseEvent.trigger(:after_email_send, @message, @email_type)
email_log.save!
email_log
end
def find_user
return @user if @user
User.find_by_email(to_address)
end
def to_address
@to_address ||=
begin
to = @message.try(:to)
to = to.first if Array === to
to.presence || "no_email_found"
end
end
def cc_addresses
@cc_addresses ||=
begin
@message.try(:cc) || []
end
end
def bcc_addresses
@bcc_addresses ||=
begin
@message.try(:bcc) || []
end
end
def self.host_for(base_url)
host = "localhost"
if base_url.present?
begin
uri = URI.parse(base_url)
host = uri.host.downcase if uri.host.present?
rescue URI::Error
end
end
host
end
private
def add_attachments(post)
max_email_size = SiteSetting.email_total_attachment_size_limit_kb.kilobytes
return if max_email_size == 0
email_size = 0
post.uploads.each do |original_upload|
optimized_1X = original_upload.optimized_images.first
if FileHelper.is_supported_image?(original_upload.original_filename) &&
!should_attach_image?(original_upload, optimized_1X)
next
end
attached_upload = optimized_1X || original_upload
next if email_size + attached_upload.filesize > max_email_size
begin
path =
if attached_upload.local?
Discourse.store.path_for(attached_upload)
else
Discourse.store.download!(attached_upload).path
end
@message_attachments_index[original_upload.sha1] = @message.attachments.size
@message.attachments[original_upload.original_filename] = File.read(path)
email_size += File.size(path)
rescue => e
Discourse.warn_exception(
e,
message: "Failed to attach file to email",
env: {
post_id: post.id,
upload_id: original_upload.id,
filename: original_upload.original_filename,
},
)
end
end
fix_parts_after_attachments!
end
def should_attach_image?(upload, optimized_1X = nil)
if !SiteSetting.secure_uploads_allow_embed_images_in_emails ||
# Sometimes images in a post have a secure URL but are not secure uploads,
# for example if a user uploads an image to a public post then copies the markdown
# into a PM which sends an email, so we have to make sure we attached those
# stripped images here as well.
(
!upload.secure? && !@stripped_secure_upload_shas.include?(upload.sha1) &&
!@stripped_secure_upload_shas.include?(optimized_1X&.sha1)
)
return
end
if (optimized_1X&.filesize || upload.filesize) >
SiteSetting.secure_uploads_max_email_embed_image_size_kb.kilobytes
return
end
true
end
#
# Two behaviors in the mail gem collide:
#
# 1. Attachments are added as extra parts at the top level,
# 2. When there are both text and html parts, the content type is set
# to 'multipart/alternative'.
#
# Since attachments aren't alternative renderings, for emails that contain
# attachments and both html and text parts, some coercing is necessary.
#
# When there are alternative rendering and attachments, this method causes
# the top level to be 'multipart/mixed' and puts the html and text parts
# into a nested 'multipart/alternative' part.
#
# Due to mail gem magic, @message.text_part and @message.html_part still
# refer to the same objects.
#
def fix_parts_after_attachments!
has_attachments = @message.attachments.present?
has_alternative_renderings = @message.html_part.present? && @message.text_part.present?
if has_attachments && has_alternative_renderings
@message.content_type = "multipart/mixed"
html_part = @message.html_part
@message.html_part = nil
text_part = @message.text_part
@message.text_part = nil
content =
Mail::Part.new do
content_type "multipart/alternative"
# we have to re-specify the charset and give the part the decoded body
# here otherwise the parts will get encoded with US-ASCII which makes
# a bunch of characters not render correctly in the email
part content_type: "text/html; charset=utf-8", body: html_part.body.decoded
part content_type: "text/plain; charset=utf-8", body: text_part.body.decoded
end
@message.parts.unshift(content)
end
end
def header_value(name)
header = @message.header[name]
return nil unless header
# NOTE: In most cases this is not a problem, but if a header has
# doubled up the header[] method will return an array. So we always
# get the last value of the array and assume that is the correct
# value.
#
# See https://github.com/mikel/mail/blob/8ef377d6a2ca78aa5bd7f739813f5a0648482087/lib/mail/header.rb#L109-L132
return header.last.value if header.is_a?(Array)
header.value
end
def skip(reason_type, custom_reason: nil)
attributes = {
email_type: @email_type,
to_address: to_address,
user_id: @user&.id,
reason_type: reason_type,
}
attributes[:custom_reason] = custom_reason if custom_reason
SkippedEmailLog.create!(attributes)
end
def merge_json_x_header(name, value)
data =
begin
JSON.parse(@message.header[name].to_s)
rescue StandardError
nil
end
data ||= {}
data.merge!(value)
# /!\ @message.header is not a standard ruby hash.
# It can have multiple values attached to the same key...
# In order to remove all the previous keys, we have to "nil" it.
# But for "nil" to work, there must already be a key...
@message.header[name] = ""
@message.header[name] = nil
@message.header[name] = data.to_json
end
def get_reply_key(post_id, user_id)
# ALLOW_REPLY_BY_EMAIL_HEADER is only added if we are _not_ sending
# via group SMTP and if reply by email site settings are configured
if !user_id || !post_id ||
!header_value(Email::MessageBuilder::ALLOW_REPLY_BY_EMAIL_HEADER).present?
return
end
PostReplyKey.create_or_find_by!(post_id: post_id, user_id: user_id).reply_key
end
def self.bounceable_reply_address?
SiteSetting.reply_by_email_address.present? && SiteSetting.reply_by_email_address["+"]
end
def self.bounce_address(bounce_key)
SiteSetting.reply_by_email_address.sub("%{reply_key}", "verp-#{bounce_key}")
end
##
# When sending an email for the first post (OP) of the topic, we do not
# set References or In-Reply-To headers, since there is nothing yet
# to reference. This counts as the first email in the thread.
#
# Once set, the post's `outbound_message_id` should _always_ be used
# when sending emails relating to a particular post to maintain threading.
# This will either be:
#
# a) A Message-ID generated in an external main client or service which
# is recorded when creating a post from an IncomingEmail via Email::Receiver
# b) A Message-ID generated by Discourse and recorded when sending an email
# for a newly created post, which is created and saved here to the
# outbound_message_id column on the Post.
#
# The RFC that covers using "Identification Fields", which are References,
# In-Reply-To, Message-ID, et. al. can be in the RFC link below. It's a good idea to read
# this beginning in the area immediately after these quotes, at least to understand
# the 3 main headers:
#
# > The "Message-ID:" field provides a unique message identifier that
# > refers to a particular version of a particular message. The
# > uniqueness of the message identifier is guaranteed by the host that
# > generates it.
#
# > ...
#
# > The "In-Reply-To:" field may be used to identify the message (or
# > messages) to which the new message is a reply, while the "References:"
# > field may be used to identify a "thread" of conversation.
#
# https://www.rfc-editor.org/rfc/rfc5322.html#section-3.6.4
#
# It is a long read, but to understand the decision making process for this
# threading logic you can take a look at:
#
# https://meta.discourse.org/t/discourse-email-messages-are-incorrectly-threaded/233499
def add_identification_field_headers(topic, post)
@message.header["Message-ID"] = Email::MessageIdService.generate_or_use_existing(
post.id,
).first
if post.post_number > 1
op_message_id = Email::MessageIdService.generate_or_use_existing(topic.first_post.id).first
##
# Whenever we reply to a post directly _or_ quote a post, a PostReply
# record is made, with the reply_post_id referencing the newly created
# post, and the post_id referencing the post that was quoted or replied to.
referenced_posts =
Post
.joins("INNER JOIN post_replies ON post_replies.post_id = posts.id ")
.where("post_replies.reply_post_id = ?", post.id)
.order(id: :desc)
.to_a
##
# No referenced posts means that we are just creating a new post not
# referring to anything, and as such we should just fall back to using
# the OP.
if referenced_posts.empty?
@message.header["In-Reply-To"] = op_message_id
@message.header["References"] = op_message_id
else
##
# When referencing _multiple_ posts then we just choose the most recent one
# to use for References so we have a single parent to work with, but
# every directly replied to post can go into In-Reply-To.
#
# We want to make sure all of the outbound_message_ids are already filled here.
in_reply_to_message_ids =
MessageIdService.generate_or_use_existing(referenced_posts.map(&:id))
@message.header["In-Reply-To"] = in_reply_to_message_ids
most_recent_post_message_id = in_reply_to_message_ids.last
##
# The RFC specifically states that the content of the parent's References
# field (in our case a tree of replies based on the PostReply table in
# addition to the OP post's Message-ID) first, _then_ the parent's
# Message-ID (in our case the outbound_message_id of the post we are replying to).
#
# This creates a thread from the OP all the way down to the most recent post we
# are replying to.
reply_tree = referenced_post_reply_tree(referenced_posts.first)
parent_message_ids = MessageIdService.generate_or_use_existing(reply_tree.values.flatten)
@message.header["References"] = [
op_message_id,
parent_message_ids,
most_recent_post_message_id,
].flatten.uniq
end
end
end
def referenced_post_reply_tree(post)
results = DB.query(<<~SQL, start_post_id: post.id)
WITH RECURSIVE cte AS (
SELECT reply_post_id, post_id FROM post_replies
WHERE reply_post_id = :start_post_id
UNION
SELECT pr.reply_post_id, pr.post_id
FROM post_replies pr
INNER JOIN cte
ON cte.post_id = pr.reply_post_id
)
SELECT DISTINCT cte.*, posts.created_at, posts.outbound_message_id
FROM cte
INNER JOIN posts ON posts.id = cte.reply_post_id
ORDER BY posts.created_at DESC, post_id DESC;
SQL
results.inject({}) do |hash, value|
# We only want to get a single replied-to post, which is the most recently
# created post, since we cannot deal with multiple parents for References
hash[value.reply_post_id] ||= [value.post_id]
hash
end
end
end
end