discourse/lib/email/receiver.rb
riking 0d0225133c FIX: Failed incoming emails could create empty topics
A failure condition is eliminated where a topic would be created, but post
creation would fail, leaving the forum with a topic without any posts.
By asking PostCreator to create the topic instead, inside of its
transaction, this failure condition is eliminated.

Additionally, attachments are restored to working status. Previously,
the attachment code would build up the post raw, but then drop it and
not do anything with the result (creating orphaned uploads). By actually
placing the raw value back in the options hash, it is included in the
created post.
2014-08-28 14:35:43 -07:00

244 lines
7.3 KiB
Ruby

#
# Handles an incoming message
#
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 EmailLogNotFound < ProcessingError; end
class InvalidPost < ProcessingError; end
attr_reader :body, :email_log
def initialize(raw)
@raw = raw
end
def process
raise EmptyEmailError if @raw.blank?
@message = Mail.new(@raw)
# First remove the known discourse stuff.
parse_body
raise EmptyEmailError if @body.blank?
# Then run the github EmailReplyParser on it in case we didn't catch it
@body = EmailReplyParser.read(@body).visible_text.force_encoding('UTF-8')
discourse_email_parser
raise EmailUnparsableError if @body.blank?
dest_info = {type: :invalid, obj: nil}
@message.to.each do |to_address|
if dest_info[:type] == :invalid
dest_info = check_address to_address
end
end
raise BadDestinationAddress if dest_info[:type] == :invalid
if dest_info[:type] == :category
raise BadDestinationAddress unless SiteSetting.email_in
category = dest_info[:obj]
@category_id = category.id
@allow_strangers = category.email_in_allow_strangers
user_email = @message.from.first
@user = User.find_by_email(user_email)
if @user.blank? && @allow_strangers
wrap_body_in_quote user_email
# TODO This is WRONG it should register an account
# and email the user details on how to log in / activate
@user = Discourse.system_user
end
raise UserNotFoundError if @user.blank?
raise UserNotSufficientTrustLevelError.new @user unless @allow_strangers || @user.has_trust_level?(TrustLevel.levels[SiteSetting.email_in_min_trust.to_i])
create_new_topic
else
@email_log = dest_info[:obj]
raise EmailLogNotFound if @email_log.blank?
create_reply
end
end
def check_address(address)
category = Category.find_by_email(address)
return {type: :category, obj: category} if category
regex = Regexp.escape SiteSetting.reply_by_email_address
regex = regex.gsub(Regexp.escape('%{reply_key}'), "(.*)")
regex = Regexp.new regex
match = regex.match address
if match && match[1].present?
reply_key = match[1]
email_log = EmailLog.for(reply_key)
return {type: :reply, obj: email_log}
end
{type: :invalid, obj: nil}
end
def parse_body
html = nil
# If the message is multipart, find the best type for our purposes
if @message.multipart?
if p = @message.text_part
@body = p.charset ? p.body.decoded.force_encoding(p.charset).encode("UTF-8").to_s : p.body.to_s
return @body
elsif p = @message.html_part
html = p.charset ? p.body.decoded.force_encoding(p.charset).encode("UTF-8").to_s : p.body.to_s
end
end
if @message.content_type =~ /text\/html/
if defined? @message.charset
html = @message.body.decoded.force_encoding(@message.charset).encode("UTF-8").to_s
else
html = @message.body.to_s
end
end
if html.present?
@body = scrub_html(html)
return @body
end
@body = @message.charset ? @message.body.decoded.force_encoding(@message.charset).encode("UTF-8").to_s.strip : @message.body.to_s
# Certain trigger phrases that means we didn't parse correctly
@body = nil if @body =~ /Content\-Type\:/ ||
@body =~ /multipart\/alternative/ ||
@body =~ /text\/plain/
@body
end
def scrub_html(html)
# If we have an HTML message, strip the markup
doc = Nokogiri::HTML(html)
# Blackberry is annoying in that it only provides HTML. We can easily extract it though
content = doc.at("#BB10_response_div")
return content.text if content.present?
doc.xpath("//text()").text
end
def discourse_email_parser
lines = @body.scrub.lines.to_a
range_end = 0
lines.each_with_index do |l, idx|
break if 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 =~ /\:$/)
range_end = idx
end
@body = lines[0..range_end].join
@body.strip!
end
def wrap_body_in_quote(user_email)
@body = "[quote=\"#{user_email}\"]
#{@body}
[/quote]"
end
private
def create_reply
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
post = create_post_with_attachments(@user,
raw: @body,
title: @message.subject,
category: @category_id)
EmailLog.create(
email_type: "topic_via_incoming_email",
to_address: @message.from.first, # pick from address because we want the user's email
topic_id: post.topic.id,
user_id: @user.id,
)
post
end
def create_post_with_attachments(user, post_opts={})
options = {
cooking_options: { traditional_markdown_linebreaks: true },
}.merge(post_opts)
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, File.size(tmp))
if upload && upload.errors.empty?
# TODO: should use the same code as the client to insert attachments
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)
"<img src='#{upload.url}' width='#{upload.width}' height='#{upload.height}'>"
else
"<a class='attachment' href='#{upload.url}'>#{upload.original_filename}</a> (#{number_to_human_size(upload.filesize)})"
end
end
def create_post(user, options)
creator = PostCreator.new(user, options)
post = creator.create
if creator.errors.present?
raise InvalidPost, creator.errors.full_messages.join("\n")
end
post
end
end
end