# 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{([^<]*)}, '[\2|attachment](' + url_prefix + '\1)', ) @message.parts[0].body = @message.parts[0].body.to_s.gsub( %r{]*)>}, "![](" + 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_no_prefix}#{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) elsif @email_type.to_s == "digest" @stripped_secure_upload_shas = style.stripped_upload_sha_map.values add_attachments(*digest_posts) 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 =~ /]+>/ 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 digest_posts Post.where(id: header_value("X-Discourse-Post-Ids")&.split(",")) end def add_attachments(*posts) max_email_size = SiteSetting.email_total_attachment_size_limit_kb.kilobytes return if max_email_size == 0 email_size = 0 posts.each do |post| next unless DiscoursePluginRegistry.apply_modifier(:should_add_email_attachments, post) 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 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. # # Most imporantly, we need to specify the boundary for the multipart/mixed # part of the email, otherwise we can end up with an email that appears to # be empty with the entire body attached as a single attachment, and some # mail parsers consider the entire email as a preamble/epilogue. # # c.f. https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html 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; boundary=\"#{@message.body.boundary}\"" html_part = @message.html_part @message.html_part = nil @message.parts.reject! { |p| p.content_type.start_with?("text/html") } text_part = @message.text_part @message.text_part = nil @message.parts.reject! { |p| p.content_type.start_with?("text/plain") } 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