require_dependency 'new_post_manager' require_dependency 'email/html_cleaner' module Email class Receiver include ActionView::Helpers::NumberHelper class ProcessingError < StandardError; end class EmailUnparsableError < ProcessingError; end class EmptyEmailError < ProcessingError; end class UserNotFoundError < ProcessingError; end class UserNotSufficientTrustLevelError < ProcessingError; end class BadDestinationAddress < ProcessingError; end class TopicNotFoundError < ProcessingError; end class TopicClosedError < ProcessingError; end class AutoGeneratedEmailError < ProcessingError; end class EmailLogNotFound < ProcessingError; end class InvalidPost < ProcessingError; end class ReplyUserNotFoundError < ProcessingError; end class ReplyUserNotMatchingError < ProcessingError; end class InactiveUserError < ProcessingError; end attr_reader :body, :email_log def initialize(raw, opts=nil) @raw = raw @opts = opts || {} end def process raise EmptyEmailError if @raw.blank? @message = Mail.new(@raw) raise AutoGeneratedEmailError if @message.header.to_s =~ /auto-(replied|generated)/ @body = parse_body(@message) # 'smtp_envelope_to' is a combination of: to, cc and bcc fields # prioriziting the `:reply` types dest_infos = @message.smtp_envelope_to .map { |to_address| check_address(to_address) } .compact .sort do |a, b| if a[:type] == :reply && b[:type] != :reply 1 elsif a[:type] != :reply && b[:type] == :reply -1 else 0 end end raise BadDestinationAddress if dest_infos.empty? from = @message[:from].address_list.addresses.first user_email = from.address user_name = from.display_name user = User.find_by_email(user_email) raise InactiveUserError if user.present? && !user.active && !user.staged # TODO: take advantage of all the "TO"s dest_info = dest_infos[0] case dest_info[:type] when :group group = dest_info[:obj] if user.blank? if SiteSetting.allow_staged_accounts user = create_staged_account(user_email, user_name) else wrap_body_in_quote(user_email) user = Discourse.system_user end end create_new_topic(user, archetype: Archetype.private_message, target_group_names: [group.name]) when :category category = dest_info[:obj] if user.blank? && category.email_in_allow_strangers if SiteSetting.allow_staged_accounts user = create_staged_account(user_email) else wrap_body_in_quote(user_email) user = Discourse.system_user end end raise UserNotFoundError if user.blank? raise UserNotSufficientTrustLevelError.new(user) unless category.email_in_allow_strangers || user.has_trust_level?(TrustLevel[SiteSetting.email_in_min_trust.to_i]) create_new_topic(user, category: category.id) when :reply @email_log = dest_info[:obj] raise EmailLogNotFound if @email_log.blank? raise TopicNotFoundError if Topic.find_by_id(@email_log.topic_id).nil? raise TopicClosedError if Topic.find_by_id(@email_log.topic_id).closed? raise ReplyUserNotFoundError if user.blank? raise ReplyUserNotMatchingError if @email_log.user_id != user.id create_reply(@email_log) end rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e raise EmailUnparsableError.new(e) end def create_staged_account(email, name=nil) User.create( email: email, username: UserNameSuggester.suggest(name.presence || email), name: name.presence || User.suggest_name(email), staged: true, ) end def check_address(address) # only check for a group/category when 'email_in' is enabled if SiteSetting.email_in group = Group.find_by_email(address) return { address: address, type: :group, obj: group } if group category = Category.find_by_email(address) return { address: address, type: :category, obj: category } if category end match = reply_by_email_address_regex.match(address) if match && match[1].present? email_log = EmailLog.for(match[1]) return { address: address, type: :reply, obj: email_log } end end def reply_by_email_address_regex @reply_by_email_address_regex ||= Regexp.new Regexp.escape(SiteSetting.reply_by_email_address) .gsub(Regexp.escape("%{reply_key}"), "([[:xdigit:]]{32})") end def parse_body(message) body = select_body(message) encoding = body.encoding raise EmptyEmailError if body.strip.blank? body = discourse_email_trimmer(body) raise EmptyEmailError if body.strip.blank? body = DiscourseEmailParser.parse_reply(body) raise EmptyEmailError if body.strip.blank? body.force_encoding(encoding).encode("UTF-8") end def select_body(message) html = nil if message.multipart? text = fix_charset message.text_part # prefer text over html return text if text html = fix_charset message.html_part elsif message.content_type =~ /text\/html/ html = fix_charset message end if html body = HtmlCleaner.new(html).output_html else body = fix_charset message end return body if @opts[:skip_sanity_check] # Certain trigger phrases that means we didn't parse correctly if body =~ /Content\-Type\:/ || body =~ /multipart\/alternative/ || body =~ /text\/plain/ raise EmptyEmailError end body end # Force encoding to UTF-8 on a Mail::Message or Mail::Part def fix_charset(object) return nil if object.nil? if object.charset object.body.decoded.force_encoding(object.charset.gsub(/utf8/i, "UTF-8")).encode("UTF-8").to_s else object.body.to_s end rescue nil end REPLYING_HEADER_LABELS = ['From', 'Sent', 'To', 'Subject', 'In-Reply-To', 'Cc', 'Bcc', 'Date'] REPLYING_HEADER_REGEX = Regexp.union(REPLYING_HEADER_LABELS.map { |lbl| "#{lbl}:" }) def line_is_quote?(l) l =~ /\A\s*\-{3,80}\s*\z/ || l =~ Regexp.new("\\A\\s*" + I18n.t('user_notifications.previous_discussion') + "\\s*\\Z") || (l =~ /via #{SiteSetting.title}(.*)\:$/) || # This one might be controversial but so many reply lines have years, times and end with a colon. # Let's try it and see how well it works. (l =~ /\d{4}/ && l =~ /\d:\d\d/ && l =~ /\:$/) || (l =~ /On [\w, ]+\d+.*wrote:/) end def discourse_email_trimmer(body) lines = body.scrub.lines.to_a range_start = 0 range_end = 0 # If we started with a quote, skip it lines.each_with_index do |l, idx| break unless line_is_quote?(l) or l =~ /^>/ or l.blank? range_start = idx + 1 end lines[range_start..-1].each_with_index do |l, idx| break if line_is_quote?(l) # Headers on subsequent lines break if (0..2).all? { |off| lines[idx+off] =~ REPLYING_HEADER_REGEX } # Headers on the same line break if REPLYING_HEADER_LABELS.count { |lbl| l.include? lbl } >= 3 range_end = range_start + idx end lines[range_start..range_end].join.strip end private def wrap_body_in_quote(user_email) @body = "[quote=\"#{user_email}\"]\n#{@body}\n[/quote]" end def create_reply(email_log) create_post_with_attachments(email_log.user, raw: @body, topic_id: email_log.topic_id, reply_to_post_number: email_log.post.post_number) end def create_new_topic(user, topic_options={}) topic_options[:raw] = @body topic_options[:title] = @message.subject result = create_post_with_attachments(user, topic_options) topic_id = result.post.present? ? result.post.topic_id : nil EmailLog.create( email_type: "topic_via_incoming_email", to_address: user.email, topic_id: topic_id, user_id: user.id, ) result end def create_post_with_attachments(user, post_options={}) options = { cooking_options: { traditional_markdown_linebreaks: true }, }.merge(post_options) raw = options[:raw] # deal with attachments @message.attachments.each do |attachment| tmp = Tempfile.new("discourse-email-attachment") begin # read attachment File.open(tmp.path, "w+b") { |f| f.write attachment.body.decoded } # create the upload for the user upload = Upload.create_for(user.id, tmp, attachment.filename, tmp.size) if upload && upload.errors.empty? # try to inline images if attachment.content_type.start_with?("image/") if raw =~ /\[image: Inline image \d+\]/ raw.sub!(/\[image: Inline image \d+\]/, attachment_markdown(upload)) next end end raw << "\n#{attachment_markdown(upload)}\n" end ensure tmp.close! end end options[:raw] = raw create_post(user, options) end def attachment_markdown(upload) if FileHelper.is_image?(upload.original_filename) "" else "#{upload.original_filename} (#{number_to_human_size(upload.filesize)})" end end def create_post(user, options) # Mark the reply as incoming via email options[:via_email] = true options[:raw_email] = @raw manager = NewPostManager.new(user, options) result = manager.perform if result.errors.present? raise InvalidPost, result.errors.full_messages.join("\n") end result end end end