require "digest" require_dependency "new_post_manager" 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 FromReplyByAddressError < 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 TooShortPost < ProcessingError; end class InvalidPostAction < ProcessingError; end class UnsubscribeNotAllowed < ProcessingError; end class EmailNotAllowed < ProcessingError; end class OldDestinationError < 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 ensure_valid_date @from_email, @from_display_name = parse_from_field @from_user = User.find_by_email(@from_email) @incoming_email = create_incoming_email process_internal raise BouncedEmailError if is_bounce? 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 ensure_valid_date if @mail.date.nil? raise InvalidPost, I18n.t("system_messages.email_reject_invalid_post_specified.date_invalid") 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 handle_bounce if is_bounce? raise NoSenderDetectedError if @from_email.blank? raise FromReplyByAddressError if is_from_reply_by_email_address? raise ScreenedEmailError if ScreenedEmail.should_block?(@from_email) user = @from_user 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? && !is_bounce? if is_auto_generated? && !sent_to_mailinglist_mirror? @incoming_email.update_columns(is_auto_generated: true) if SiteSetting.block_auto_generated_emails? && !is_bounce? raise AutoGeneratedEmailError end end if action = subscription_action_for(body, subject) raise UnsubscribeNotAllowed if user.nil? send_subscription_mail(action, user) return end if post = find_related_post # Most of the time, it is impossible to **reply** without a reply key, so exit early if user.blank? if sent_to_mailinglist_mirror? || !SiteSetting.find_related_post_with_key user = stage_from_user elsif user.blank? raise BadDestinationAddress end end create_reply(user: user, raw: body, elided: elided, hidden_reason_id: hidden_reason_id, post: post, topic: post.topic, skip_validations: user.staged?, bounce: is_bounce?) else first_exception = nil destinations.each do |destination| begin return process_destination(destination, user, body, elided, hidden_reason_id) rescue => e first_exception ||= e end end raise first_exception if first_exception # We don't stage new users for emails to reply addresses, exit if user is nil raise BadDestinationAddress if user.blank? post = find_related_post(force: true) if post && Guardian.new(user).can_see_post?(post) num_of_days = SiteSetting.disallow_reply_by_email_after_days if num_of_days > 0 && post.created_at < num_of_days.days.ago raise OldDestinationError.new("#{Discourse.base_url}/p/#{post.id}") end end raise BadDestinationAddress end end def hidden_reason_id @hidden_reason_id ||= is_spam? ? Post.hidden_reasons[:email_spam_header_found] : nil 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? @mail.bounced? || bounce_key end def handle_bounce @incoming_email.update_columns(is_bounce: true) if email_log.present? email_log.update_columns(bounced: true) post = email_log.post topic = email_log.topic end if @mail.error_status.present? && Array.wrap(@mail.error_status).any? { |s| s.start_with?("4.") } Email::Receiver.update_bounce_score(@from_email, SiteSetting.soft_bounce_score) else Email::Receiver.update_bounce_score(@from_email, SiteSetting.hard_bounce_score) end if SiteSetting.enable_whispers? && @from_user&.staged? return if email_log.blank? if post.present? && topic.present? && topic.archetype == Archetype.private_message body, elided = select_body body ||= "" create_reply(user: @from_user, raw: body, elided: elided, hidden_reason_id: hidden_reason_id, post: post, topic: topic, skip_validations: true, bounce: true) end end raise BouncedEmailError end def is_from_reply_by_email_address? Email::Receiver.reply_by_email_address_regex.match(@from_email) end def bounce_key @bounce_key ||= begin verp = all_destinations.select { |to| to[/\+verp-\h{32}@/] }.first verp && verp[/\+verp-(\h{32})@/, 1] end end def email_log return nil if bounce_key.blank? @email_log ||= EmailLog.find_by(bounce_key: bounce_key) end def self.update_bounce_score(email, score) if user = User.find_by_email(email) old_bounce_score = user.user_stat.bounce_score new_bounce_score = old_bounce_score + score range = (old_bounce_score + 1..new_bounce_score) user.user_stat.bounce_score = new_bounce_score user.user_stat.reset_bounce_score_after = SiteSetting.reset_bounce_score_after_days.days.from_now user.user_stat.save! if user.active && range === 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 range === SiteSetting.bounce_score_threshold # NOTE: we check bounce_score before sending emails # So log we revoked the email... 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) # ... and PM the user SystemMessage.create_from_system_user(user, :email_revoked) end 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 is_spam? case SiteSetting.email_in_spam_header when 'X-Spam-Flag' @mail[:x_spam_flag].to_s[/YES/i] when 'X-Spam-Status' @mail[:x_spam_status].to_s[/^Yes, /i] else false end 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 text_format = Receiver::formats[:plaintext] if text.blank? || (SiteSetting.incoming_email_prefer_html && markdown.present?) text, elided_text, text_format = markdown, elided_markdown, Receiver::formats[:markdown] end if SiteSetting.strip_incoming_email_lines in_code = nil text = text.lines.map! do |line| stripped = line.strip << "\n" # Do not strip list items. next line if (stripped[0] == '*' || stripped[0] == '-' || stripped[0] == '+') && stripped[1] == ' ' # Match beginning and ending of code blocks. if !in_code && stripped[0..2] == '```' in_code = '```' elsif in_code == '```' && stripped[0..2] == '```' in_code = nil elsif !in_code && stripped[0..4] == '[code' in_code = '[code]' elsif in_code == '[code]' && stripped[0..6] == '[/code]' in_code = nil end # Strip only lines outside code blocks. in_code ? line : stripped end.join end [text, elided_text, text_format] 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_(signature|extra)/], [: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, gmail_default... elided = doc.css(".gmail_signature, .gmail_extra").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 (