require "digest" require_dependency "new_post_manager" require_dependency "post_action_creator" require_dependency "html_to_markdown" require_dependency "plain_text_to_markdown" require_dependency "upload_creator" module Email class Receiver include ActionView::Helpers::NumberHelper # If you add a new error, you need to # * add it to Email::Processor#handle_failure() # * add text to server.en.yml (parent key: "emails.incoming.errors") class ProcessingError < StandardError; end class EmptyEmailError < ProcessingError; end class ScreenedEmailError < ProcessingError; end class UserNotFoundError < ProcessingError; end class AutoGeneratedEmailError < ProcessingError; end class BouncedEmailError < ProcessingError; end class NoBodyDetectedError < ProcessingError; end class NoSenderDetectedError < ProcessingError; end class InactiveUserError < ProcessingError; end class SilencedUserError < ProcessingError; end class BadDestinationAddress < ProcessingError; end class StrangersNotAllowedError < ProcessingError; end class InsufficientTrustLevelError < ProcessingError; end class ReplyUserNotMatchingError < ProcessingError; end class TopicNotFoundError < ProcessingError; end class TopicClosedError < ProcessingError; end class InvalidPost < ProcessingError; end class InvalidPostAction < ProcessingError; end class UnsubscribeNotAllowed < ProcessingError; end class EmailNotAllowed < ProcessingError; end attr_reader :incoming_email attr_reader :raw_email attr_reader :mail attr_reader :message_id COMMON_ENCODINGS ||= [-"utf-8", -"windows-1252", -"iso-8859-1"] def self.formats @formats ||= Enum.new(plaintext: 1, markdown: 2) end def initialize(mail_string, opts = {}) raise EmptyEmailError if mail_string.blank? @staged_users = [] @raw_email = mail_string COMMON_ENCODINGS.each do |encoding| fixed = try_to_encode(mail_string, encoding) break @raw_email = fixed if fixed.present? end @mail = Mail.new(@raw_email) @message_id = @mail.message_id.presence || Digest::MD5.hexdigest(mail_string) @opts = opts end def process! return if is_blacklisted? DistributedMutex.synchronize(@message_id) do begin return if IncomingEmail.exists?(message_id: @message_id) ensure_valid_address_lists @from_email, @from_display_name = parse_from_field(@mail) @incoming_email = create_incoming_email process_internal rescue => e error = e.to_s error = e.class.name if error.blank? @incoming_email.update_columns(error: error) if @incoming_email delete_staged_users raise end end end def ensure_valid_address_lists [:to, :cc, :bcc].each do |field| addresses = @mail[field] if addresses&.errors.present? @mail[field] = addresses.to_s.scan(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i) end end end def is_blacklisted? return false if SiteSetting.ignore_by_title.blank? Regexp.new(SiteSetting.ignore_by_title, Regexp::IGNORECASE) =~ @mail.subject end def create_incoming_email IncomingEmail.create( message_id: @message_id, raw: @raw_email, subject: subject, from_address: @from_email, to_addresses: @mail.to&.map(&:downcase)&.join(";"), cc_addresses: @mail.cc&.map(&:downcase)&.join(";"), ) end def process_internal raise BouncedEmailError if is_bounce? raise NoSenderDetectedError if @from_email.blank? raise ScreenedEmailError if ScreenedEmail.should_block?(@from_email) user = find_user(@from_email) if user.present? log_and_validate_user(user) else raise UserNotFoundError unless SiteSetting.enable_staged_users end body, elided = select_body body ||= "" raise NoBodyDetectedError if body.blank? && attachments.empty? if is_auto_generated? && !sent_to_mailinglist_mirror? @incoming_email.update_columns(is_auto_generated: true) if SiteSetting.block_auto_generated_emails? raise AutoGeneratedEmailError end end if action = subscription_action_for(body, subject) raise UnsubscribeNotAllowed if user.nil? send_subscription_mail(action, user) return end # Lets create a staged user if there isn't one yet. We will try to # delete staged users in process!() if something bad happens. if user.nil? user = find_or_create_user(@from_email, @from_display_name) log_and_validate_user(user) end if post = find_related_post create_reply(user: user, raw: body, elided: elided, post: post, topic: post.topic, skip_validations: user.staged?) else first_exception = nil destinations.each do |destination| begin process_destination(destination, user, body, elided) rescue => e first_exception ||= e else return end end raise first_exception || BadDestinationAddress end end def log_and_validate_user(user) @incoming_email.update_columns(user_id: user.id) raise InactiveUserError if !user.active && !user.staged raise SilencedUserError if user.silenced? end def is_bounce? return false unless @mail.bounced? || verp @incoming_email.update_columns(is_bounce: true) if verp && (bounce_key = verp[/\+verp-(\h{32})@/, 1]) && (email_log = EmailLog.find_by(bounce_key: bounce_key)) email_log.update_columns(bounced: true) email = email_log.user.try(:email).presence end email ||= @from_email if @mail.error_status.present? && Array.wrap(@mail.error_status).any? { |s| s.start_with?("4.") } Email::Receiver.update_bounce_score(email, SiteSetting.soft_bounce_score) else Email::Receiver.update_bounce_score(email, SiteSetting.hard_bounce_score) end true end def verp @verp ||= all_destinations.select { |to| to[/\+verp-\h{32}@/] }.first end def self.update_bounce_score(email, score) # only update bounce score once per day key = "bounce_score:#{email}:#{Date.today}" if $redis.setnx(key, "1") $redis.expire(key, 25.hours) if user = User.find_by_email(email) user.user_stat.bounce_score += score user.user_stat.reset_bounce_score_after = SiteSetting.reset_bounce_score_after_days.days.from_now user.user_stat.save! bounce_score = user.user_stat.bounce_score if user.active && bounce_score >= SiteSetting.bounce_score_threshold_deactivate user.update!(active: false) reason = I18n.t("user.deactivated", email: user.email) StaffActionLogger.new(Discourse.system_user).log_user_deactivate(user, reason) elsif bounce_score >= SiteSetting.bounce_score_threshold # NOTE: we check bounce_score before sending emails, nothing to do # here other than log it happened. reason = I18n.t("user.email.revoked", email: user.email, date: user.user_stat.reset_bounce_score_after) StaffActionLogger.new(Discourse.system_user).log_revoke_email(user, reason) end end true else false end end def is_auto_generated? return false if SiteSetting.auto_generated_whitelist.split('|').include?(@from_email) @mail[:precedence].to_s[/list|junk|bulk|auto_reply/i] || @mail[:from].to_s[/(mailer[\-_]?daemon|post[\-_]?master|no[\-_]?reply)@/i] || @mail[:subject].to_s[/^\s*(Auto:|Automatic reply|Autosvar|Automatisk svar|Automatisch antwoord|Abwesenheitsnotiz|Risposta Non al computer|Automatisch antwoord|Auto Response|Respuesta automática|Fuori sede|Out of Office|Frånvaro|Réponse automatique)/i] || @mail.header.to_s[/auto[\-_]?(response|submitted|replied|reply|generated|respond)|holidayreply|machinegenerated/i] end def select_body text = nil html = nil text_content_type = nil if @mail.multipart? text = fix_charset(@mail.text_part) html = fix_charset(@mail.html_part) text_content_type = @mail.text_part&.content_type elsif @mail.content_type.to_s["text/html"] html = fix_charset(@mail) elsif @mail.content_type.blank? || @mail.content_type["text/plain"] text = fix_charset(@mail) text_content_type = @mail.content_type end return unless text.present? || html.present? if text.present? text = trim_discourse_markers(text) text, elided_text = trim_reply_and_extract_elided(text) if @opts[:convert_plaintext] || sent_to_mailinglist_mirror? text_content_type ||= "" converter_opts = { format_flowed: !!(text_content_type =~ /format\s*=\s*["']?flowed["']?/i), delete_flowed_space: !!(text_content_type =~ /DelSp\s*=\s*["']?yes["']?/i) } text = PlainTextToMarkdown.new(text, converter_opts).to_markdown elided_text = PlainTextToMarkdown.new(elided_text, converter_opts).to_markdown end end markdown, elided_markdown = if html.present? # use the first html extracter that matches if html_extracter = HTML_EXTRACTERS.select { |_, r| html[r] }.min_by { |_, r| html =~ r } doc = Nokogiri::HTML.fragment(html) self.send(:"extract_from_#{html_extracter[0]}", doc) else markdown = HtmlToMarkdown.new(html, keep_img_tags: true, keep_cid_imgs: true).to_markdown markdown = trim_discourse_markers(markdown) trim_reply_and_extract_elided(markdown) end end if text.blank? || (SiteSetting.incoming_email_prefer_html && markdown.present?) return [markdown, elided_markdown, Receiver::formats[:markdown]] else return [text, elided_text, Receiver::formats[:plaintext]] end end def to_markdown(html, elided_html) markdown = HtmlToMarkdown.new(html, keep_img_tags: true, keep_cid_imgs: true).to_markdown [EmailReplyTrimmer.trim(markdown), HtmlToMarkdown.new(elided_html).to_markdown] end HTML_EXTRACTERS ||= [ [:gmail, / class="gmail_/], [:outlook, / id="(divRplyFwdMsg|Signature)"/], [:word, / class="WordSection1"/], [:exchange, / name="message(Body|Reply)Section"/], [:apple_mail, / id="AppleMailSignature"/], [:mozilla, / class="moz-/], [:protonmail, / class="protonmail_/], [:zimbra, / data-marker="__/], [:newton, /(id|class)="cm_/], ] def extract_from_gmail(doc) # GMail adds a bunch of 'gmail_' prefixed classes like: gmail_signature, gmail_extra, gmail_quote # Just elide them all elided = doc.css("*[class^='gmail_']").remove to_markdown(doc.to_html, elided.to_html) end def extract_from_outlook(doc) # Outlook properly identifies the signature and any replied/forwarded email # Use their id to remove them and anything that comes after elided = doc.css("#Signature, #Signature ~ *, hr, #divRplyFwdMsg, #divRplyFwdMsg ~ *").remove to_markdown(doc.to_html, elided.to_html) end def extract_from_word(doc) # Word (?) keeps the content in the 'WordSection1' class and uses
tags # When there's something else (