diff --git a/lib/email/html_cleaner.rb b/lib/email/html_cleaner.rb
new file mode 100644
index 00000000000..19e4b417327
--- /dev/null
+++ b/lib/email/html_cleaner.rb
@@ -0,0 +1,120 @@
+module Email
+ # HtmlCleaner cleans up the extremely dirty HTML that many email clients
+ # generate by stripping out any excess divs or spans, removing styling in
+ # the process (which also makes the html more suitable to be parsed as
+ # Markdown).
+ class HtmlCleaner
+ # Elements to hoist all children out of
+ HTML_HOIST_ELEMENTS = %w(div span font table tbody th tr td)
+ # Node types to always delete
+ HTML_DELETE_ELEMENT_TYPES = [Nokogiri::XML::Node::DTD_NODE,
+ Nokogiri::XML::Node::COMMENT_NODE,
+ ]
+
+ # Private variables:
+ # @doc - nokogiri document
+ # @out - same as @doc, but only if trimming has occured
+ def initialize(html)
+ if String === html
+ @doc = Nokogiri::HTML(html)
+ else
+ @doc = html
+ end
+ end
+
+ class << self
+ # Email::HtmlCleaner.trim(inp, opts={})
+ #
+ # Arguments:
+ # inp - Either a HTML string or a Nokogiri document.
+ # Options:
+ # :return => :doc, :string
+ # Specify the desired return type.
+ # Defaults to the type of the input.
+ # A value of :string is equivalent to calling get_document_text()
+ # on the returned document.
+ def trim(inp, opts={})
+ cleaner = HtmlCleaner.new(inp)
+
+ opts[:return] ||= ((String === inp) ? :string : :doc)
+
+ if opts[:return] == :string
+ cleaner.output_html
+ else
+ cleaner.output_document
+ end
+ end
+
+ # Email::HtmlCleaner.get_document_text(doc)
+ #
+ # Get the body portion of the document, including html, as a string.
+ def get_document_text(doc)
+ body = doc.xpath('//body')
+ if body
+ body.inner_html
+ else
+ doc.inner_html
+ end
+ end
+ end
+
+ def output_document
+ @out ||= begin
+ doc = @doc
+ trim_process_node doc
+ add_newlines doc
+ doc
+ end
+ end
+
+ def output_html
+ HtmlCleaner.get_document_text(output_document)
+ end
+
+ private
+
+ def add_newlines(doc)
+ doc.xpath('//br').each do |br|
+ br.replace(Nokogiri::XML::Text.new("\n", doc))
+ end
+ end
+
+ def trim_process_node(node)
+ if should_hoist?(node)
+ hoisted = trim_hoist_element node
+ hoisted.each { |child| trim_process_node child }
+ elsif should_delete?(node)
+ node.remove
+ else
+ if children = node.children
+ children.each { |child| trim_process_node child }
+ end
+ end
+
+ node
+ end
+
+ def trim_hoist_element(element)
+ hoisted = []
+ element.children.each do |child|
+ element.before(child)
+ hoisted << child
+ end
+ element.remove
+ hoisted
+ end
+
+ def should_hoist?(node)
+ return false unless node.element?
+ HTML_HOIST_ELEMENTS.include? node.name
+ end
+
+ def should_delete?(node)
+ return true if HTML_DELETE_ELEMENT_TYPES.include? node.type
+ return true if node.element? && node.name == 'head'
+ return true if node.text? && node.text.strip.blank?
+
+ false
+ end
+ end
+end
diff --git a/lib/email/receiver.rb b/lib/email/receiver.rb
index 3845a97985f..c15f8c42739 100644
--- a/lib/email/receiver.rb
+++ b/lib/email/receiver.rb
@@ -1,3 +1,4 @@
+require 'email/html_cleaner'
#
# Handles an incoming message
#
@@ -26,20 +27,12 @@ module Email
def process
raise EmptyEmailError if @raw.blank?
- @message = Mail.new(@raw)
+ 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?
+ body = parse_body message
dest_info = {type: :invalid, obj: nil}
- @message.to.each do |to_address|
+ message.to.each do |to_address|
if dest_info[:type] == :invalid
dest_info = check_address to_address
end
@@ -47,6 +40,10 @@ module Email
raise BadDestinationAddress if dest_info[:type] == :invalid
+ # TODO get to a state where we can remove this
+ @message = message
+ @body = body
+
if dest_info[:type] == :category
raise BadDestinationAddress unless SiteSetting.email_in
category = dest_info[:obj]
@@ -74,6 +71,8 @@ module Email
create_reply
end
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
+ raise EmailUnparsableError.new(e)
end
def check_address(address)
@@ -94,57 +93,64 @@ module Email
{type: :invalid, obj: nil}
end
- private
+ def parse_body(message)
+ body = select_body message
+ encoding = body.encoding
+ raise EmptyEmailError if body.strip.blank?
- def parse_body
+ body = discourse_email_trimmer body
+ raise EmptyEmailError if body.strip.blank?
+
+ body = EmailReplyParser.parse_reply body
+ raise EmptyEmailError if body.strip.blank?
+
+ body.force_encoding(encoding).encode("UTF-8")
+ end
+
+ def select_body(message)
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
+ # If the message is multipart, return that part (favor html)
+ if message.multipart?
+ html = fix_charset message.html_part
+ text = fix_charset message.text_part
+ # TODO picking text if available may be better
+ if text && !html
+ return text
end
+ elsif message.content_type =~ /text\/html/
+ html = fix_charset message
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
+ if html
+ body = HtmlCleaner.new(html).output_html
+ else
+ body = fix_charset message
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/
+ if body =~ /Content\-Type\:/ || body =~ /multipart\/alternative/ || body =~ /text\/plain/
+ raise EmptyEmailError
+ end
- @body
+ body
end
- def scrub_html(html)
- # If we have an HTML message, strip the markup
- doc = Nokogiri::HTML(html)
+ # Force encoding to UTF-8 on a Mail::Message or Mail::Part
+ def fix_charset(object)
+ return nil if object.nil?
- # 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
+ if object.charset
+ object.body.decoded.force_encoding(object.charset).encode("UTF-8").to_s
+ else
+ object.body.to_s
+ end
end
- def discourse_email_parser
- lines = @body.scrub.lines.to_a
+ REPLYING_HEADER_LABELS = ['From', 'Sent', 'To', 'Subject', 'Reply To']
+ REPLYING_HEADER_REGEX = Regexp.union(REPLYING_HEADER_LABELS.map { |lbl| "#{lbl}:" })
+
+ def discourse_email_trimmer(body)
+ lines = body.scrub.lines.to_a
range_end = 0
lines.each_with_index do |l, idx|
@@ -155,11 +161,15 @@ module Email
# Let's try it and see how well it works.
(l =~ /\d{4}/ && l =~ /\d:\d\d/ && 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 = idx
end
- @body = lines[0..range_end].join
- @body.strip!
+ lines[0..range_end].join.strip
end
def wrap_body_in_quote(user_email)
@@ -168,37 +178,37 @@ module Email
[/quote]"
end
+ private
+
def create_reply
- create_post_with_attachments(email_log.user, @body, @email_log.topic_id, @email_log.post.post_number)
+ 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
- topic = TopicCreator.new(
- @user,
- Guardian.new(@user),
- category: @category_id,
- title: @message.subject,
- ).create
-
- post = create_post_with_attachments(@user, @body, topic.id)
+ 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.to.first,
- topic_id: topic.id,
+ 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, raw, topic_id, reply_to_post_number=nil)
+ def create_post_with_attachments(user, post_opts={})
options = {
- raw: raw,
- topic_id: topic_id,
cooking_options: { traditional_markdown_linebreaks: true },
- }
- options[:reply_to_post_number] = reply_to_post_number if reply_to_post_number
+ }.merge(post_opts)
+
+ raw = options[:raw]
# deal with attachments
@message.attachments.each do |attachment|
@@ -215,9 +225,10 @@ module Email
ensure
tmp.close!
end
-
end
+ options[:raw] = raw
+
create_post(user, options)
end
@@ -232,9 +243,11 @@ module Email
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
diff --git a/spec/components/email/receiver_spec.rb b/spec/components/email/receiver_spec.rb
index 71bc6a4d504..a0431606773 100644
--- a/spec/components/email/receiver_spec.rb
+++ b/spec/components/email/receiver_spec.rb
@@ -8,122 +8,233 @@ describe Email::Receiver do
before do
SiteSetting.reply_by_email_address = "reply+%{reply_key}@appmail.adventuretime.ooo"
SiteSetting.email_in = false
+ SiteSetting.title = "Discourse"
end
- describe 'invalid emails' do
+ describe 'parse_body' do
+ def test_parse_body(mail_string)
+ Email::Receiver.new(nil).parse_body(Mail::Message.new mail_string)
+ end
+
it "raises EmptyEmailError if the message is blank" do
- expect { Email::Receiver.new("").process }.to raise_error(Email::Receiver::EmptyEmailError)
+ expect { test_parse_body("") }.to raise_error(Email::Receiver::EmptyEmailError)
end
it "raises EmptyEmailError if the message is not an email" do
- expect { Email::Receiver.new("asdf" * 30).process}.to raise_error(Email::Receiver::EmptyEmailError)
+ expect { test_parse_body("asdf" * 30) }.to raise_error(Email::Receiver::EmptyEmailError)
end
- it "raises EmailUnparsableError if there is no reply content" do
- expect { Email::Receiver.new(fixture_file("emails/no_content_reply.eml")).process}.to raise_error(Email::Receiver::EmailUnparsableError)
+ it "raises EmptyEmailError if there is no reply content" do
+ expect { test_parse_body(fixture_file("emails/no_content_reply.eml")) }.to raise_error(Email::Receiver::EmptyEmailError)
end
- end
- describe "with multipart" do
- let(:reply_below) { fixture_file("emails/multipart.eml") }
- let(:receiver) { Email::Receiver.new(reply_below) }
-
- it "processes correctly" do
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq(
-"So presumably all the quoted garbage and my (proper) signature will get
-stripped from my reply?")
+ pending "raises EmailUnparsableError if the headers are corrupted" do
+ expect { ; }.to raise_error(Email::Receiver::EmailUnparsableError)
end
- end
- describe "html only" do
- let(:reply_below) { fixture_file("emails/html_only.eml") }
- let(:receiver) { Email::Receiver.new(reply_below) }
-
- it "processes correctly" do
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq("The EC2 instance - I've seen that there tends to be odd and " +
- "unrecommended settings on the Bitnami installs that I've checked out.")
+ it "can parse the html section" do
+ test_parse_body(fixture_file("emails/html_only.eml")).should == "The EC2 instance - I've seen that there tends to be odd and " +
+ "unrecommended settings on the Bitnami installs that I've checked out."
end
- end
- describe "it supports a dutch reply" do
- let(:dutch) { fixture_file("emails/dutch.eml") }
- let(:receiver) { Email::Receiver.new(dutch) }
-
- it "processes correctly" do
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq("Dit is een antwoord in het Nederlands.")
+ it "supports a Dutch reply" do
+ test_parse_body(fixture_file("emails/dutch.eml")).should == "Dit is een antwoord in het Nederlands."
end
- end
- describe "It supports a non english reply" do
- let(:hebrew) { fixture_file("emails/hebrew.eml") }
- let(:receiver) { Email::Receiver.new(hebrew) }
-
- it "processes correctly" do
+ it "supports a Hebrew reply" do
I18n.expects(:t).with('user_notifications.previous_discussion').returns('כלטוב')
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq("שלום")
+
+ # The force_encoding call is only needed for the test - it is passed on fine to the cooked post
+ test_parse_body(fixture_file("emails/hebrew.eml")).should == "שלום"
end
- end
- describe "It supports a non UTF-8 reply" do
- let(:big5) { fixture_file("emails/big5.eml") }
- let(:receiver) { Email::Receiver.new(big5) }
-
- it "processes correctly" do
+ it "supports a BIG5-encoded reply" do
I18n.expects(:t).with('user_notifications.previous_discussion').returns('媽!我上電視了!')
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq("媽!我上電視了!")
+
+ # The force_encoding call is only needed for the test - it is passed on fine to the cooked post
+ test_parse_body(fixture_file("emails/big5.eml")).should == "媽!我上電視了!"
end
- end
- describe "via" do
- let(:wrote) { fixture_file("emails/via_line.eml") }
- let(:receiver) { Email::Receiver.new(wrote) }
+ it "removes 'via' lines if they match the site title" do
+ SiteSetting.title = "Discourse"
- it "removes via lines if we know them" do
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq("Hello this email has content!")
+ test_parse_body(fixture_file("emails/via_line.eml")).should == "Hello this email has content!"
end
- end
- describe "if wrote is on a second line" do
- let(:wrote) { fixture_file("emails/multiline_wrote.eml") }
- let(:receiver) { Email::Receiver.new(wrote) }
-
- it "processes correctly" do
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq("Thanks!")
+ it "removes the 'Previous Discussion' marker" do
+ test_parse_body(fixture_file("emails/previous.eml")).should == "This will not include the previous discussion that is present in this email."
end
- end
- describe "remove previous discussion" do
- let(:previous) { fixture_file("emails/previous.eml") }
- let(:receiver) { Email::Receiver.new(previous) }
-
- it "processes correctly" do
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq("This will not include the previous discussion that is present in this email.")
- end
- end
-
- describe "multiple paragraphs" do
- let(:paragraphs) { fixture_file("emails/paragraphs.eml") }
- let(:receiver) { Email::Receiver.new(paragraphs) }
-
- it "processes correctly" do
- expect { receiver.process}.to raise_error(Email::Receiver::EmailLogNotFound)
- expect(receiver.body).to eq(
+ it "handles multiple paragraphs" do
+ test_parse_body(fixture_file("emails/paragraphs.eml")).
+ should == (
"Is there any reason the *old* candy can't be be kept in silos while the new candy
is imported into *new* silos?
The thing about candy is it stays delicious for a long time -- we can just keep
it there without worrying about it too much, imo.
-Thanks for listening.")
+Thanks for listening."
+ )
end
+
+ it "converts back to UTF-8 at the end" do
+ result = test_parse_body(fixture_file("emails/big5.eml"))
+ result.encoding.should == Encoding::UTF_8
+
+ # should not throw
+ TextCleaner.normalize_whitespaces(
+ test_parse_body(fixture_file("emails/big5.eml"))
+ )
+ end
+ end
+
+ describe "posting replies" do
+ let(:reply_key) { raise "Override this in a lower describe block" }
+ let(:email_raw) { raise "Override this in a lower describe block" }
+ # ----
+ let(:receiver) { Email::Receiver.new(email_raw) }
+ let(:post) { create_post }
+ let(:topic) { post.topic }
+ let(:posting_user) { post.user }
+ let(:replying_user_email) { 'jake@adventuretime.ooo' }
+ let(:replying_user) { Fabricate(:user, email: replying_user_email, trust_level: 2)}
+ let(:email_log) { EmailLog.new(reply_key: reply_key,
+ post: post,
+ post_id: post.id,
+ topic_id: post.topic_id,
+ email_type: 'user_posted',
+ user: replying_user,
+ user_id: replying_user.id,
+ to_address: replying_user_email
+ ) }
+
+ before do
+ email_log.save
+ end
+
+ # === Success Posting ===
+
+ describe "valid_reply.eml" do
+ let!(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
+ let!(:email_raw) { fixture_file("emails/valid_reply.eml") }
+
+ it "creates a post with the correct content" do
+ start_count = topic.posts.count
+
+ receiver.process
+
+ topic.posts.count.should == (start_count + 1)
+ topic.posts.last.cooked.strip.should == fixture_file("emails/valid_reply.cooked").strip
+ end
+ end
+
+ describe "paragraphs.eml" do
+ let!(:reply_key) { '59d8df8370b7e95c5a49fbf86aeb2c93' }
+ let!(:email_raw) { fixture_file("emails/paragraphs.eml") }
+
+ it "cooks multiple paragraphs with traditional Markdown linebreaks" do
+ start_count = topic.posts.count
+
+ receiver.process
+
+ topic.posts.count.should == (start_count + 1)
+ topic.posts.last.cooked.strip.should == fixture_file("emails/paragraphs.cooked").strip
+ topic.posts.last.cooked.should_not match /
/
+ Upload.find_by(sha1: upload_sha).should_not be_nil
+ end
+
+ end
+
+ # === Failure Conditions ===
+
+ describe "too_short.eml" do
+ let!(:reply_key) { '636ca428858779856c226bb145ef4fad' }
+ let!(:email_raw) {
+ fixture_file("emails/too_short.eml")
+ .gsub("TO", "reply+#{reply_key}@appmail.adventuretime.ooo")
+ .gsub("FROM", replying_user_email)
+ .gsub("SUBJECT", "re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux'")
+ }
+
+ it "raises an InvalidPost error" do
+ SiteSetting.min_post_length = 5
+ expect { receiver.process }.to raise_error(Email::Receiver::InvalidPost)
+ end
+ end
+
+ describe "too_many_mentions.eml" do
+ let!(:reply_key) { '636ca428858779856c226bb145ef4fad' }
+ let!(:email_raw) { fixture_file("emails/too_many_mentions.eml") }
+
+ it "raises an InvalidPost error" do
+ SiteSetting.max_mentions_per_post = 10
+ (1..11).each do |i|
+ Fabricate(:user, username: "user#{i}").save
+ end
+
+ expect { receiver.process }.to raise_error(Email::Receiver::InvalidPost)
+ end
+ end
+
+ end
+
+ describe "posting a new topic" do
+ let(:category_destination) { raise "Override this in a lower describe block" }
+ let(:email_raw) { raise "Override this in a lower describe block" }
+ let(:allow_strangers) { false }
+ # ----
+ let(:receiver) { Email::Receiver.new(email_raw) }
+ let(:user_email) { 'jake@adventuretime.ooo' }
+ let(:user) { Fabricate(:user, email: user_email, trust_level: 2)}
+ let(:category) { Fabricate(:category, email_in: category_destination, email_in_allow_strangers: allow_strangers) }
+
+ before do
+ SiteSetting.email_in = true
+ user.save
+ category.save
+ end
+
+ describe "too_short.eml" do
+ let!(:category_destination) { 'incoming+amazing@appmail.adventuretime.ooo' }
+ let(:email_raw) {
+ fixture_file("emails/too_short.eml")
+ .gsub("TO", category_destination)
+ .gsub("FROM", user_email)
+ .gsub("SUBJECT", "A long subject that passes the checks")
+ }
+
+ it "does not create a topic if the post fails" do
+ before_topic_count = Topic.count
+
+ expect { receiver.process }.to raise_error(Email::Receiver::InvalidPost)
+
+ Topic.count.should == before_topic_count
+ end
+
+ end
+
end
def fill_email(mail, from, to, body = nil, subject = nil)
@@ -181,12 +292,6 @@ greatest show ever created. Everyone should watch it.
expect(receiver.body).to eq(reply_body)
expect(receiver.email_log).to eq(email_log)
-
- attachment_email = fixture_file("emails/attachment.eml")
- attachment_email = fill_email(attachment_email, "test@test.com", to)
- r = Email::Receiver.new(attachment_email)
- expect { r.process }.to_not raise_error
- expect(r.body).to match(/here is an image attachment\n\n/)
end
end
diff --git a/spec/fixtures/emails/boundary.eml b/spec/fixtures/emails/boundary.eml
index 92eb4347f9c..1250fe498b0 100644
--- a/spec/fixtures/emails/boundary.eml
+++ b/spec/fixtures/emails/boundary.eml
@@ -18,7 +18,7 @@ Content-Type: text/plain; charset=ISO-8859-1
I'll look into it, thanks!
-On Wednesday, June 19, 2013, jake via Adventure Time wrote:
+On Wednesday, June 19, 2013, jake via Discourse wrote:
> jake mentioned you in 'peppermint butler is missing' on Adventure
> Time:
@@ -58,4 +58,4 @@ p>
ime.ooo/user_preferences" target=3D"_blank">user preferences.
-- So presumably all the quoted garbage and my (proper) signature will - get stripped from my reply?Han Solo just sent you a private message
-
-I got it here! Yay it worked!
-
-Please visit this link to respond: http://darthvader.ca/t/regarding-your-post-in-site-customization-not-working/7641/2
-To unsubscribe from these emails, visit your user - preferences.
-
-- -Anakin Skywalker | `One of the main causes of the fall of -evildad@darthvader.ca | the Roman Empire was that, lacking zero, - | they had no way to indicate successful - | termination of their C programs.' - Firth -- - - ---------------070503080300090900010604-- diff --git a/spec/fixtures/emails/paragraphs.cooked b/spec/fixtures/emails/paragraphs.cooked new file mode 100644 index 00000000000..da83260e09c --- /dev/null +++ b/spec/fixtures/emails/paragraphs.cooked @@ -0,0 +1,7 @@ +
Is there any reason the old candy can't be be kept in silos while the new candy +is imported into new silos?
+ +The thing about candy is it stays delicious for a long time -- we can just keep +it there without worrying about it too much, imo.
+ +Thanks for listening.
\ No newline at end of file diff --git a/spec/fixtures/emails/too_many_mentions.eml b/spec/fixtures/emails/too_many_mentions.eml new file mode 100644 index 00000000000..9cc7b75c94f --- /dev/null +++ b/spec/fixtures/emails/too_many_mentions.eml @@ -0,0 +1,31 @@ +Return-Path: