diff --git a/app/controllers/concerns/clones_ticket_article_attachments.rb b/app/controllers/concerns/clones_ticket_article_attachments.rb index 5a47d57ae..a91a6bce9 100644 --- a/app/controllers/concerns/clones_ticket_article_attachments.rb +++ b/app/controllers/concerns/clones_ticket_article_attachments.rb @@ -6,41 +6,7 @@ module ClonesTicketArticleAttachments def article_attachments_clone(article) raise Exceptions::UnprocessableEntity, 'Need form_id to attach attachments to new form.' if params[:form_id].blank? - existing_attachments = Store.list( - object: 'UploadCache', - o_id: params[:form_id], - ) - attachments = [] - article.attachments.each do |new_attachment| - next if new_attachment.preferences['content-alternative'] == true - - if article.content_type.present? && article.content_type =~ %r{text/html}i - next if new_attachment.preferences['content_disposition'].present? && new_attachment.preferences['content_disposition'] !~ /inline/ - - if new_attachment.preferences['Content-ID'].present? && article.body.present? - next if article.body.match?(/#{Regexp.quote(new_attachment.preferences['Content-ID'])}/i) - end - end - already_added = false - existing_attachments.each do |existing_attachment| - next if existing_attachment.filename != new_attachment.filename || existing_attachment.size != new_attachment.size - - already_added = true - break - end - next if already_added == true - - file = Store.add( - object: 'UploadCache', - o_id: params[:form_id], - data: new_attachment.content, - filename: new_attachment.filename, - preferences: new_attachment.preferences, - ) - attachments.push file - end - - attachments + article.clone_attachments('UploadCache', params[:form_id], only_attached_attachments: true) end end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index fd74bc376..28dd42579 100644 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -1486,6 +1486,12 @@ result preferences: attachment[:preferences], ) end + + original_article = objects[:article] + if original_article&.should_clone_inline_attachments? # rubocop:disable Style/GuardClause + original_article.clone_attachments('Ticket::Article', message.id, only_inline_attachments: true) + original_article.should_clone_inline_attachments = false # cancel the temporary flag after cloning + end end def sms_recipients_by_type(recipient_type, article) diff --git a/app/models/ticket/article.rb b/app/models/ticket/article.rb index 7fc8b6771..e5c1c56f7 100644 --- a/app/models/ticket/article.rb +++ b/app/models/ticket/article.rb @@ -44,6 +44,9 @@ class Ticket::Article < ApplicationModel :to, :cc + attr_accessor :should_clone_inline_attachments + alias should_clone_inline_attachments? should_clone_inline_attachments + # fillup md5 of message id to search easier on very long message ids def check_message_id_md5 return true if message_id.blank? @@ -130,6 +133,77 @@ returns new_attachments end +=begin + +clone existing attachments of article to the target object + + article_parent = Ticket::Article.find(123) + article_new = Ticket::Article.find(456) + + attached_attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_attached_attachments: true) + + inline_attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_inline_attachments: true) + +returns + + [attachment1, attachment2, ...] + +=end + + def clone_attachments(object_type, object_id, options = {}) + existing_attachments = Store.list( + object: object_type, + o_id: object_id, + ) + + is_html_content = false + if content_type.present? && content_type =~ %r{text/html}i + is_html_content = true + end + + new_attachments = [] + attachments.each do |new_attachment| + next if new_attachment.preferences['content-alternative'] == true + + # only_attached_attachments mode is used by apply attached attachments to forwared article + if options[:only_attached_attachments] == true + if is_html_content + next if new_attachment.preferences['content_disposition'].present? && new_attachment.preferences['content_disposition'] =~ /inline/ + next if new_attachment.preferences['Content-ID'].blank? + next if body.present? && body.match?(/#{Regexp.quote(new_attachment.preferences['Content-ID'])}/i) + end + end + + # only_inline_attachments mode is used when quoting HTML mail with #{article.body_as_html} + if options[:only_inline_attachments] == true + next if is_html_content == false + next if body.blank? + next if new_attachment.preferences['content_disposition'].present? && new_attachment.preferences['content_disposition'] !~ /inline/ + next if new_attachment.preferences['Content-ID'].present? && !body.match?(/#{Regexp.quote(new_attachment.preferences['Content-ID'])}/i) + end + + already_added = false + existing_attachments.each do |existing_attachment| + next if existing_attachment.filename != new_attachment.filename || existing_attachment.size != new_attachment.size + + already_added = true + break + end + next if already_added == true + + file = Store.add( + object: object_type, + o_id: object_id, + data: new_attachment.content, + filename: new_attachment.filename, + preferences: new_attachment.preferences, + ) + new_attachments.push file + end + + new_attachments + end + def self.last_customer_agent_article(ticket_id) sender = Ticket::Article::Sender.lookup(name: 'System') Ticket::Article.where('ticket_id = ? AND sender_id NOT IN (?)', ticket_id, sender.id).order('created_at DESC').first diff --git a/lib/notification_factory/renderer.rb b/lib/notification_factory/renderer.rb index a03876026..bc7f5dfa8 100644 --- a/lib/notification_factory/renderer.rb +++ b/lib/notification_factory/renderer.rb @@ -126,6 +126,11 @@ examples how to use begin previous_object_refs = object_refs object_refs = object_refs.send(method.to_sym, *arguments) + + # body_as_html should trigger the cloning of all inline attachments from the parent article (issue #2399) + if method.to_sym == :body_as_html && previous_object_refs.respond_to?(:should_clone_inline_attachments) + previous_object_refs.should_clone_inline_attachments = true + end rescue => e value = "\#{#{object_name}.#{object_methods_s} / #{e.message}}" break diff --git a/spec/models/ticket/article_spec.rb b/spec/models/ticket/article_spec.rb index 5b2a87dbd..88303271a 100644 --- a/spec/models/ticket/article_spec.rb +++ b/spec/models/ticket/article_spec.rb @@ -124,4 +124,95 @@ RSpec.describe Ticket::Article, type: :model do end end end + + describe 'clone attachments' do + context 'of forwarded article' do + context 'via email' do + + it 'only need to clone attached attachments' do + article_parent = create(:ticket_article, + type: Ticket::Article::Type.find_by(name: 'email'), + content_type: 'text/html', + body: ' some text',) + Store.add( + object: 'Ticket::Article', + o_id: article_parent.id, + data: 'content_file1_normally_should_be_an_image', + filename: 'some_file1.jpg', + preferences: { + 'Content-Type' => 'image/jpeg', + 'Mime-Type' => 'image/jpeg', + 'Content-ID' => '15.274327094.140938@zammad.example.com', + 'Content-Disposition' => 'inline', + }, + created_by_id: 1, + ) + Store.add( + object: 'Ticket::Article', + o_id: article_parent.id, + data: 'content_file2_normally_should_be_an_image', + filename: 'some_file2.jpg', + preferences: { + 'Content-Type' => 'image/jpeg', + 'Mime-Type' => 'image/jpeg', + 'Content-ID' => '15.274327094.140938_not_reffered@zammad.example.com', + 'Content-Disposition' => 'inline', + }, + created_by_id: 1, + ) + article_new = create(:ticket_article) + UserInfo.current_user_id = 1 + + attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_attached_attachments: true) + + expect(attachments.count).to eq(1) + expect(attachments[0].filename).to eq('some_file2.jpg') + end + end + end + + context 'of trigger' do + context 'via email notifications' do + it 'only need to clone inline attachments used in body' do + article_parent = create(:ticket_article, + type: Ticket::Article::Type.find_by(name: 'email'), + content_type: 'text/html', + body: ' some text',) + Store.add( + object: 'Ticket::Article', + o_id: article_parent.id, + data: 'content_file1_normally_should_be_an_image', + filename: 'some_file1.jpg', + preferences: { + 'Content-Type' => 'image/jpeg', + 'Mime-Type' => 'image/jpeg', + 'Content-ID' => '15.274327094.140938@zammad.example.com', + 'Content-Disposition' => 'inline', + }, + created_by_id: 1, + ) + Store.add( + object: 'Ticket::Article', + o_id: article_parent.id, + data: 'content_file2_normally_should_be_an_image', + filename: 'some_file2.jpg', + preferences: { + 'Content-Type' => 'image/jpeg', + 'Mime-Type' => 'image/jpeg', + 'Content-ID' => '15.274327094.140938_not_reffered@zammad.example.com', + 'Content-Disposition' => 'inline', + }, + created_by_id: 1, + ) + article_new = create(:ticket_article) + UserInfo.current_user_id = 1 + + attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_inline_attachments: true ) + + expect(attachments.count).to eq(1) + expect(attachments[0].filename).to eq('some_file1.jpg') + end + end + end + end end diff --git a/test/unit/ticket_trigger_test.rb b/test/unit/ticket_trigger_test.rb index 1475d7569..975953622 100644 --- a/test/unit/ticket_trigger_test.rb +++ b/test/unit/ticket_trigger_test.rb @@ -4596,4 +4596,91 @@ class TicketTriggerTest < ActiveSupport::TestCase end end + #2399 - Attached images are broken on trigger reply with #{article.body_as_html} + test 'make sure auto reply using #{article.body_as_html} copies all articles image attachments as well' do + # make sure that this auto reply trigger only reacts to this particular test in order not to interfer with other auto reply tests + trigger1 = Trigger.create!( + name: 'auto reply with HTML quote', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + 'ticket.state_id' => { + 'operator' => 'is', + 'value' => Ticket::State.lookup(name: 'new').id.to_s, + }, + 'ticket.title' => { + 'operator' => 'contains', + 'value' => 'AW: OTRS / Anfrage OTRS Einführung/Präsentation [Ticket#11545]', + }, + }, + perform: { + 'notification.email' => { + 'body' => '#{article.body_as_html}', + 'recipient' => 'article_last_sender', + 'subject' => 'Thanks for your inquiry (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + ticket1, article1, user, mail = Channel::EmailParser.new.process({}, File.read(Rails.root.join('test', 'data', 'mail', 'mail048.box'))) + + assert_equal('AW: OTRS / Anfrage OTRS Einführung/Präsentation [Ticket#11545]', ticket1.title, 'ticket1.title verify') + assert_equal(2, ticket1.articles.count, 'ticket1.articles verify') + assert_equal(2, ticket1.articles.first.attachments.count) + + article1 = ticket1.articles.last + assert_match('Thanks for your inquiry (AW: OTRS / Anfrage OTRS Einführung/Präsentation [Ticket#11545])!', article1.subject) + assert_equal(1, article1.attachments.count) + assert_equal('50606', article1.attachments[0].size) + assert_equal('CPG-Reklamationsmitteilung bezügl.01234567895 an Voda-28.03.2017.jpg', article1.attachments[0].filename) + end + + #2399 - Attached images are broken on trigger reply with #{article.body_as_html} + test 'make sure auto reply using #{article.body_as_html} does not copy any non-image attachments' do + # make sure that this auto reply trigger only reacts to this particular test in order not to interfer with other auto reply tests + trigger1 = Trigger.create!( + name: 'auto reply with HTML quote', + condition: { + 'ticket.action' => { + 'operator' => 'is', + 'value' => 'create', + }, + 'ticket.state_id' => { + 'operator' => 'is', + 'value' => Ticket::State.lookup(name: 'new').id.to_s, + }, + 'ticket.title' => { + 'operator' => 'contains', + 'value' => 'Online-apotheke. Günstigster Preis. Ohne Rezepte', + }, + }, + perform: { + 'notification.email' => { + 'body' => '#{article.body_as_html}', + 'recipient' => 'article_last_sender', + 'subject' => 'Thanks for your inquiry (#{ticket.title})!', + }, + }, + disable_notification: true, + active: true, + created_by_id: 1, + updated_by_id: 1, + ) + + ticket1, article1, user, mail = Channel::EmailParser.new.process({}, File.read(Rails.root.join('test', 'data', 'mail', 'mail069.box'))) + + assert_equal('Online-apotheke. Günstigster Preis. Ohne Rezepte', ticket1.title, 'ticket1.title verify') + assert_equal(2, ticket1.articles.count, 'ticket1.articles verify') + assert_equal(1, ticket1.articles.first.attachments.count) + + article1 = ticket1.articles.last + assert_match('Thanks for your inquiry (Online-apotheke. Günstigster Preis. Ohne Rezepte)!', article1.subject) + assert_equal(0, article1.attachments.count) + end end