FEATURE: email attachments in a details (#27804)

This change how we present attachments from incoming emails to now be "hidden" in a "[details]" so they don't "hang" at the end of the post.

This is especially useful when using Discourse as a support tool where email is the main communication channel. For various reasons, images are often duplicated by email user agents, and hiding them behind the details block help keep the conversation focused on the isssue at hand.

Internal ref t/122333
This commit is contained in:
Régis Hanol
2024-07-10 09:59:27 +02:00
committed by GitHub
parent 301713ef96
commit 758b9dd0ba
3 changed files with 80 additions and 66 deletions

View File

@ -245,6 +245,7 @@ en:
maximum_staged_user_per_email_reached: "Reached maximum number of staged users created per email." maximum_staged_user_per_email_reached: "Reached maximum number of staged users created per email."
no_subject: "(no subject)" no_subject: "(no subject)"
no_body: "(no body)" no_body: "(no body)"
attachments: "(attachments)"
missing_attachment: "(Attachment %{filename} is missing)" missing_attachment: "(Attachment %{filename} is missing)"
continuing_old_discussion: continuing_old_discussion:
one: "Continuing the discussion from [%{title}](%{url}), because it was created more than %{count} day ago." one: "Continuing the discussion from [%{title}](%{url}), because it was created more than %{count} day ago."

View File

@ -462,6 +462,10 @@ module Email
end end
end end
# keep track of inlined images in html version
# so we can later check if they were elided
@cids = (html.presence || "").scan(/src\s*=\s*['"](cid:.+?)["']/).flatten
markdown, elided_markdown = markdown, elided_markdown =
if html.present? if html.present?
# use the first html extracter that matches # use the first html extracter that matches
@ -772,7 +776,7 @@ module Email
# return the email address and name # return the email address and name
[mail, name] [mail, name]
end end
rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError => e rescue Mail::Field::ParseError, Mail::Field::IncompleteParseError
# something went wrong parsing the email header value, return nil # something went wrong parsing the email header value, return nil
end end
end end
@ -1366,7 +1370,21 @@ module Email
upload_shas = Upload.where(id: upload_ids).pluck("DISTINCT COALESCE(original_sha1, sha1)") upload_shas = Upload.where(id: upload_ids).pluck("DISTINCT COALESCE(original_sha1, sha1)")
is_duplicate = ->(upload_id, upload_sha, attachment) do
return true if upload_id && upload_ids.include?(upload_id)
return true if upload_sha && upload_shas.include?(upload_sha)
if attachment.respond_to?(:url) && attachment.url&.start_with?("cid:") &&
attachment.content_type&.start_with?("image/")
return true if @cids&.include?(attachment.url)
end
false
end
added_attachments = []
rejected_attachments = [] rejected_attachments = []
attachments.each do |attachment| attachments.each do |attachment|
tmp = Tempfile.new(["discourse-email-attachment", File.extname(attachment.filename)]) tmp = Tempfile.new(["discourse-email-attachment", File.extname(attachment.filename)])
begin begin
@ -1392,11 +1410,11 @@ module Email
end end
elsif raw[/\[image:[^\]]*\]/i] elsif raw[/\[image:[^\]]*\]/i]
raw.sub!(/\[image:[^\]]*\]/i, UploadMarkdown.new(upload).to_markdown) raw.sub!(/\[image:[^\]]*\]/i, UploadMarkdown.new(upload).to_markdown)
elsif !upload_ids.include?(upload.id) && !upload_shas.include?(upload_sha) elsif !is_duplicate[upload.id, upload_sha, attachment]
raw << "\n\n#{UploadMarkdown.new(upload).to_markdown}\n\n" added_attachments << upload
end end
elsif !upload_ids.include?(upload.id) && !upload_shas.include?(upload_sha) elsif !is_duplicate[upload.id, upload_sha, attachment]
raw << "\n\n#{UploadMarkdown.new(upload).to_markdown}\n\n" added_attachments << upload
end end
else else
rejected_attachments << upload rejected_attachments << upload
@ -1406,10 +1424,24 @@ module Email
tmp&.close! tmp&.close!
end end
end end
if rejected_attachments.present? && !user.staged? if rejected_attachments.present? && !user.staged?
notify_about_rejected_attachment(rejected_attachments) notify_about_rejected_attachment(rejected_attachments)
end end
if added_attachments.present?
markdown =
added_attachments.map { |upload| UploadMarkdown.new(upload).to_markdown }.join("\n")
if markdown.present?
raw << "\n\n"
raw << "[details=\"#{I18n.t("emails.incoming.attachments")}\"]"
raw << "\n\n"
raw << markdown
raw << "\n\n"
raw << "[/details]"
end
end
raw raw
end end

View File

@ -71,13 +71,12 @@ RSpec.describe Email::Receiver do
user = Fabricate(:user, email: "staged@bar.com", active: false, staged: true) user = Fabricate(:user, email: "staged@bar.com", active: false, staged: true)
post = Fabricate(:post) post = Fabricate(:post)
post_reply_key = Fabricate(
Fabricate( :post_reply_key,
:post_reply_key, user: user,
user: user, post: post,
post: post, reply_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
reply_key: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", )
)
expect { process(:staged_sender) }.not_to raise_error expect { process(:staged_sender) }.not_to raise_error
end end
@ -91,7 +90,7 @@ RSpec.describe Email::Receiver do
topic = Fabricate(:topic) topic = Fabricate(:topic)
post = Fabricate(:post, topic: topic) post = Fabricate(:post, topic: topic)
user = Fabricate(:user, email: "discourse@bar.com") Fabricate(:user, email: "discourse@bar.com")
mail = email(:old_destination).gsub(":post_id", post.id.to_s) mail = email(:old_destination).gsub(":post_id", post.id.to_s)
expect { Email::Receiver.new(mail).process! }.to raise_error( expect { Email::Receiver.new(mail).process! }.to raise_error(
@ -666,18 +665,14 @@ RSpec.describe Email::Receiver do
post = topic.posts.last post = topic.posts.last
upload = post.uploads.first upload = post.uploads.first
expect(post.raw).to include( expect(post.raw).to include UploadMarkdown.new(upload).to_markdown
"![#{upload.original_filename}|#{upload.width}x#{upload.height}](#{upload.short_url})",
)
expect { process(:inline_image) }.to change { topic.posts.count } expect { process(:inline_image) }.to change { topic.posts.count }
post = topic.posts.last post = topic.posts.last
upload = post.uploads.first upload = post.uploads.first
expect(post.raw).to include( expect(post.raw).to include UploadMarkdown.new(upload).to_markdown
"![#{upload.original_filename}|#{upload.width}x#{upload.height}](#{upload.short_url})",
)
end end
it "supports attached images in HTML part" do it "supports attached images in HTML part" do
@ -706,7 +701,11 @@ RSpec.describe Email::Receiver do
expect(post.raw).to eq(<<~MD.chomp) expect(post.raw).to eq(<<~MD.chomp)
[image:#{"0" * 5000} [image:#{"0" * 5000}
![#{upload.original_filename}|#{upload.width}x#{upload.height}](#{upload.short_url}) [details="#{I18n.t("emails.incoming.attachments")}"]
#{UploadMarkdown.new(upload).to_markdown}
[/details]
MD MD
end end
@ -725,7 +724,7 @@ RSpec.describe Email::Receiver do
<details class='elided'> <details class='elided'>
<summary title='Show trimmed content'>&#183;&#183;&#183;</summary> <summary title='Show trimmed content'>&#183;&#183;&#183;</summary>
<img src="upload://qUm0DGR49PAZshIi7HxMd3cAlzn.png" width="300" height="200"> <img src="#{upload.short_url}" width="300" height="200">
</details> </details>
MD MD
@ -740,7 +739,11 @@ RSpec.describe Email::Receiver do
expect(post.raw).to eq(<<~MD.chomp) expect(post.raw).to eq(<<~MD.chomp)
Please find some text file attached. Please find some text file attached.
[#{upload.original_filename}|attachment](#{upload.short_url}) (20 Bytes) [details="#{I18n.t("emails.incoming.attachments")}"]
#{UploadMarkdown.new(upload).to_markdown}
[/details]
MD MD
expect { process(:apple_mail_attachment) }.to change { topic.posts.count } expect { process(:apple_mail_attachment) }.to change { topic.posts.count }
@ -758,33 +761,21 @@ RSpec.describe Email::Receiver do
it "tries not to repeat duplicate attachments" do it "tries not to repeat duplicate attachments" do
SiteSetting.authorized_extensions = "jpg" SiteSetting.authorized_extensions = "jpg"
SiteSetting.always_show_trimmed_content = true
expect { process(:logo_1) }.to change { UploadReference.count }.by(1) expect { process(:logo_1) }.to change { Upload.count }.by(1)
expect(topic.posts.last.raw).to match %r{upload://} logo = Upload.last
expect(topic.posts.last.raw).to include logo.short_url
expect { process(:logo_2) }.not_to change { UploadReference.count } expect { process(:logo_2) }.not_to change { Upload.count }
expect(topic.posts.last.raw).not_to match %r{upload://} expect(topic.posts.last.raw).to include logo.short_url
end
it "tries not to repeat duplicate secure attachments" do
setup_s3
stub_s3_store
SiteSetting.secure_uploads = true
SiteSetting.authorized_extensions = "jpg"
expect { process(:logo_1) }.to change { UploadReference.count }.by(1)
expect(topic.posts.last.raw).to match %r{upload://}
expect { process(:logo_2) }.not_to change { UploadReference.count }
expect(topic.posts.last.raw).not_to match %r{upload://}
end end
it "works with removed attachments" do it "works with removed attachments" do
SiteSetting.authorized_extensions = "jpg" SiteSetting.authorized_extensions = "jpg"
expect { process(:removed_attachments) }.to change { topic.posts.count } expect { process(:removed_attachments) }.to change { topic.posts.count }
post = topic.posts.last expect(topic.posts.last.uploads).to be_empty
expect(post.uploads).to be_empty
end end
it "supports eml attachments" do it "supports eml attachments" do
@ -796,7 +787,11 @@ RSpec.describe Email::Receiver do
expect(post.raw).to eq(<<~MD.chomp) expect(post.raw).to eq(<<~MD.chomp)
Please find the eml file attached. Please find the eml file attached.
[#{upload.original_filename}|attachment](#{upload.short_url}) (193 Bytes) [details="#{I18n.t("emails.incoming.attachments")}"]
#{UploadMarkdown.new(upload).to_markdown}
[/details]
MD MD
end end
@ -837,9 +832,7 @@ RSpec.describe Email::Receiver do
post = topic.posts.last post = topic.posts.last
upload = post.uploads.last upload = post.uploads.last
expect(post.raw).to include( expect(post.raw).to include UploadMarkdown.new(upload).to_markdown
"[#{upload.original_filename}|attachment](#{upload.short_url}) (64 KB)",
)
end end
it "supports liking via email" do it "supports liking via email" do
@ -1020,20 +1013,16 @@ RSpec.describe Email::Receiver do
it "extracts address and uses it for comparison" do it "extracts address and uses it for comparison" do
expect { process(:reply_to_whitespaces) }.to change(Topic, :count).by(1) expect { process(:reply_to_whitespaces) }.to change(Topic, :count).by(1)
user = User.last
incoming = IncomingEmail.find_by(message_id: "TXULO4v6YU0TzeL2buFAJNU2MK21c7t4@example.com") incoming = IncomingEmail.find_by(message_id: "TXULO4v6YU0TzeL2buFAJNU2MK21c7t4@example.com")
topic = incoming.topic
expect(incoming.from_address).to eq("johndoe@example.com") expect(incoming.from_address).to eq("johndoe@example.com")
expect(user.email).to eq("johndoe@example.com") expect(User.last.email).to eq("johndoe@example.com")
end end
it "handles emails where there is a Reply-To address, using that instead of the from address, if X-Original-From is present" do it "handles emails where there is a Reply-To address, using that instead of the from address, if X-Original-From is present" do
expect { process(:reply_to_different_to_from) }.to change(Topic, :count).by(1) expect { process(:reply_to_different_to_from) }.to change(Topic, :count).by(1)
user = User.last
incoming = IncomingEmail.find_by(message_id: "3848c3m98r439c348mc349@test.mailinglist.com") incoming = IncomingEmail.find_by(message_id: "3848c3m98r439c348mc349@test.mailinglist.com")
topic = incoming.topic
expect(incoming.from_address).to eq("arthurmorgan@reddeadtest.com") expect(incoming.from_address).to eq("arthurmorgan@reddeadtest.com")
expect(user.email).to eq("arthurmorgan@reddeadtest.com") expect(User.last.email).to eq("arthurmorgan@reddeadtest.com")
end end
it "allows for quotes around the display name of the Reply-To address" do it "allows for quotes around the display name of the Reply-To address" do
@ -1041,20 +1030,16 @@ RSpec.describe Email::Receiver do
Topic, Topic,
:count, :count,
).by(1) ).by(1)
user = User.last
incoming = IncomingEmail.find_by(message_id: "3848c3m98r439c348mc349@test.mailinglist.com") incoming = IncomingEmail.find_by(message_id: "3848c3m98r439c348mc349@test.mailinglist.com")
topic = incoming.topic
expect(incoming.from_address).to eq("johnmarston@reddeadtest.com") expect(incoming.from_address).to eq("johnmarston@reddeadtest.com")
expect(user.email).to eq("johnmarston@reddeadtest.com") expect(User.last.email).to eq("johnmarston@reddeadtest.com")
end end
it "does not use the reply-to address if an X-Original-From header is not present" do it "does not use the reply-to address if an X-Original-From header is not present" do
expect { process(:reply_to_different_to_from_no_x_original) }.to change(Topic, :count).by(1) expect { process(:reply_to_different_to_from_no_x_original) }.to change(Topic, :count).by(1)
user = User.last
incoming = IncomingEmail.find_by(message_id: "3848c3m98r439c348mc349@test.mailinglist.com") incoming = IncomingEmail.find_by(message_id: "3848c3m98r439c348mc349@test.mailinglist.com")
topic = incoming.topic
expect(incoming.from_address).to eq("westernsupport@test.mailinglist.com") expect(incoming.from_address).to eq("westernsupport@test.mailinglist.com")
expect(user.email).to eq("westernsupport@test.mailinglist.com") expect(User.last.email).to eq("westernsupport@test.mailinglist.com")
end end
it "does not use the reply-to address if the X-Original-From header is different from the reply-to address" do it "does not use the reply-to address if the X-Original-From header is different from the reply-to address" do
@ -1062,11 +1047,9 @@ RSpec.describe Email::Receiver do
Topic, Topic,
:count, :count,
).by(1) ).by(1)
user = User.last
incoming = IncomingEmail.find_by(message_id: "3848c3m98r439c348mc349@test.mailinglist.com") incoming = IncomingEmail.find_by(message_id: "3848c3m98r439c348mc349@test.mailinglist.com")
topic = incoming.topic
expect(incoming.from_address).to eq("westernsupport@test.mailinglist.com") expect(incoming.from_address).to eq("westernsupport@test.mailinglist.com")
expect(user.email).to eq("westernsupport@test.mailinglist.com") expect(User.last.email).to eq("westernsupport@test.mailinglist.com")
end end
end end
@ -1138,9 +1121,7 @@ RSpec.describe Email::Receiver do
post = Topic.last.first_post post = Topic.last.first_post
upload = post.uploads.first upload = post.uploads.first
expect(post.raw).to include( expect(post.raw).to include UploadMarkdown.new(upload).to_markdown
"[#{upload.original_filename}|attachment](#{upload.short_url}) (#{upload.filesize} Bytes)",
)
end end
it "reenables user's PM email notifications when user emails new topic to group" do it "reenables user's PM email notifications when user emails new topic to group" do
@ -1401,7 +1382,7 @@ RSpec.describe Email::Receiver do
end end
it "processes the reply from the user as a brand new topic if they have replied from a different address (e.g. auto forward) and allow_unknown_sender_topic_replies is disabled" do it "processes the reply from the user as a brand new topic if they have replied from a different address (e.g. auto forward) and allow_unknown_sender_topic_replies is disabled" do
email_log, group_post = reply_as_group_user email_log, _group_post = reply_as_group_user
reply_email = email(:email_to_group_email_username_2_as_unknown_sender) reply_email = email(:email_to_group_email_username_2_as_unknown_sender)
reply_email.gsub!("MESSAGE_ID_REPLY_TO", email_log.message_id) reply_email.gsub!("MESSAGE_ID_REPLY_TO", email_log.message_id)
@ -1415,7 +1396,7 @@ RSpec.describe Email::Receiver do
it "processes the reply from the user as a reply if they have replied from a different address (e.g. auto forward) and allow_unknown_sender_topic_replies is enabled" do it "processes the reply from the user as a reply if they have replied from a different address (e.g. auto forward) and allow_unknown_sender_topic_replies is enabled" do
group.update!(allow_unknown_sender_topic_replies: true) group.update!(allow_unknown_sender_topic_replies: true)
email_log, group_post = reply_as_group_user email_log, _group_post = reply_as_group_user
reply_email = email(:email_to_group_email_username_2_as_unknown_sender) reply_email = email(:email_to_group_email_username_2_as_unknown_sender)
reply_email.gsub!("MESSAGE_ID_REPLY_TO", email_log.message_id) reply_email.gsub!("MESSAGE_ID_REPLY_TO", email_log.message_id)
@ -2216,7 +2197,7 @@ RSpec.describe Email::Receiver do
SiteSetting.strip_incoming_email_lines = true SiteSetting.strip_incoming_email_lines = true
receiver = Email::Receiver.new(email) receiver = Email::Receiver.new(email)
text, elided, format = receiver.select_body text, _elided, _format = receiver.select_body
expect(text).to eq(stripped_text) expect(text).to eq(stripped_text)
end end