# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ require 'rails_helper' RSpec.describe Channel::EmailParser, type: :model do describe '#parse' do shared_examples 'parses email correctly' do |stored_email| context "for #{stored_email}" do let(:yml_file) { stored_email.ext('yml') } let(:content) { YAML.load_file(yml_file, permitted_classes: [ActiveSupport::HashWithIndifferentAccess]) } let(:parsed) { described_class.new.parse(File.read(stored_email)) } let(:expected_msg) { content.except(:attachments) } let(:parsed_msg) { parsed.slice(*expected_msg.keys) } let(:content_attachments_md5s) { (content[:attachments]&.map { |a| Digest::MD5.hexdigest(a[:data]) } || []).to_set } let(:parsed_attachments_md5s) { (parsed[:attachments]&.map { |a| Digest::MD5.hexdigest(a[:data]) } || []).to_set } it 'parses correctly' do expect(File).to exist(yml_file) expect(parsed_msg).to include(expected_msg) expect(content_attachments_md5s).to be_subset(parsed_attachments_md5s) end end end describe 'when mail does not contain any sender specification' do subject(:instance) { described_class.new } let(:raw_mail) { <<~RAW.chomp } To: baz@qux.net Subject: Foo Lorem ipsum dolor RAW it 'raises error even if exception is false' do expect { described_class.new.parse(raw_mail) } .to raise_error(Exceptions::MissingAttribute, 'Could not parse any sender attribute from the email. Checked fields: From, Reply-To, Return-Path, Sender') end end describe 'when mail does not contain any sender specification with disabled missing attribute exceptions' do subject(:instance) { described_class.new } let(:raw_mail) { <<~RAW.chomp } To: baz@qux.net Subject: Foo Lorem ipsum dolor RAW it 'prevents raising an error' do expect { described_class.new.parse(raw_mail, allow_missing_attribute_exceptions: false) } .not_to raise_error end end # To write new .yml files for emails you can use the following code: # # File.write('test/data/mail/mailXXX.yml', Channel::EmailParser.new.parse(File.read('test/data/mail/mailXXX.box')).slice(:from, :from_email, :from_display_name, :to, :cc, :subject, :body, :content_type, :'reply-to', :attachments).to_yaml) # # To renew all existing files, you can use the following code: # # Dir.glob(Rails.root.join('test/data/mail/mail*.box')).each { |mail_file| File.write(mail_file.gsub('.box', '.yml'), Channel::EmailParser.new.parse(File.read(mail_file)).slice(:from, :from_email, :from_display_name, :to, :cc, :subject, :body, :content_type, :'reply-to', :attachments).to_yaml) } # context 'when checking a bunch of stored emails for correct parsing behaviour' do tests = Dir.glob(Rails.root.join('test/data/mail/mail*.box')).each do |stored_email| # rubocop:disable Rails/RootPathnameMethods include_examples('parses email correctly', stored_email) end it 'ensures tests were dynamically generated' do expect(tests.count).to eq(108) end end # regression test for issue 2390 - Add a postmaster filter to not show emails with potential issue describe 'handling HTML links in message content' do context 'with under 5,000 links' do it 'parses message content as normal' do expect(described_class.new.parse(<<~RAW)[:body]).to start_with(' #{Array.new(10) { 'Dummy Link' }.join(' ')} RAW end end context 'with 5,000+ links' do it 'replaces message content with error message' do expect(described_class.new.parse(<<~RAW)).to include('body' => Channel::EmailParser::EXCESSIVE_LINKS_MSG) From: nicole.braun@zammad.com Content-Type: text/html #{Array.new(5001) { 'Dummy Link' }.join(' ')} RAW end end end describe 'handling Japanese email in ISO-2022-JP encoding' do let(:mail_file) { Rails.root.join('test/data/mail/mail091.box') } let(:raw_mail) { File.read(mail_file) } let(:parsed) { described_class.new.parse(raw_mail) } it { expect(parsed['body']).to eq '
このアドレスへのメルマガを解除してください。
' } it { expect(parsed['subject']).to eq 'メルマガ解除' } end describe "invalid 'Resent-Date' header field" do it 'is ignored' do expect(described_class.new.parse(<<~RAW)['resent_date']).to be_nil From: me@example.com To: to@example.com Subject: 123 Resent-Date: 6/29/2022 11:57:13 AM body 123 RAW end end describe 'inline attachment' do let(:cid) { '485376C9-2486-4351-B932-E2010998F579@home' } let(:html) { "test " } let(:store) { create(:store, :image, preferences: store_preferences) } let(:store_preferences) do { 'Content-ID': cid, 'Mime-Type': 'image/jpg', 'Content-Type': 'application/others; name=inline_image.jpg' } end it 'gets Content-ID' do mail = Channel::EmailBuild.build( from: 'sender@example.com', to: 'recipient@example.com', body: html, content_type: 'text/html', attachments: [ store ], ) parser = described_class.new data = parser.parse(mail.to_s) inline_image_attachment = data[:attachments].last expect(inline_image_attachment[:preferences]['Content-ID']).to eq cid end end end describe '#process' do let(:raw_mail) { File.read(mail_file) } before { Trigger.destroy_all } # triggers may cause additional articles to be created describe 'auto-creating new users' do context 'with one unrecognized email address' do it 'creates one new user' do expect { described_class.new.process({}, <<~RAW) }.to change(User, :count).by(1) From: #{Faker::Internet.unique.email} RAW end end context 'with a large number of unrecognized recipient addresses' do it 'never creates more than 40 users' do expect { described_class.new.process({}, <<~RAW) }.to change(User, :count).by(40) From: nicole.braun@zammad.org To: #{Array.new(20) { Faker::Internet.unique.email }.join(', ')} Cc: #{Array.new(21) { Faker::Internet.unique.email }.join(', ')} RAW end end context 'with two unrecognizded email addresses with international domain name' do it 'create new user email unicode characters', :aggregate_failures do expect { described_class.new.process({}, <<~RAW) }.to change(User, :count).by(2) From: john.doe@xn--cme-pla.corp To: jane.doe@xn--cme-pla.corp RAW expect(User).to exist(login: 'john.doe@äcme.corp') .and(exist(email: 'jane.doe@äcme.corp')) end end context 'with existing system email address' do let!(:email_address) { create(:email_address, email: 'baz@qux.net', channel: nil) } let!(:group) { create(:group, name: 'baz headquarter', email_address: email_address) } let!(:channel) do channel = create(:email_channel, group: group) email_address.update(channel: channel) channel end it 'creates no new user for system mail adress in cc' do expect { described_class.new.process({}, <<~RAW) }.to change(User, :count).by(1) From: nicole.braun@zammad.org To: #{email_address.email} Cc: #{email_address.email}, #{Faker::Internet.unique.email} RAW end end end describe 'auto-updating existing users' do context 'with a previous email with no real name in the From: header' do let!(:customer) { described_class.new.process({}, previous_email).first.customer } let(:previous_email) { <<~RAW.chomp } From: customer@example.com To: myzammad@example.com Subject: test sender name update 1 Some Text RAW context 'and a new email with a real name in the From: header' do let(:new_email) { <<~RAW.chomp } From: Max Smith To: myzammad@example.com Subject: test sender name update 2 Some Text RAW it 'updates the customer’s #firstname and #lastname' do expect { described_class.new.process({}, new_email) } .to change { customer.reload.firstname }.from('').to('Max') .and change { customer.reload.lastname }.from('').to('Smith') end end end describe 'handle database failures' do subject(:instance) { described_class.new } let(:mail_data) { attributes_for(:failed_email)[:data] } before do allow(instance).to receive(:process_with_timeout).and_raise('error') allow_any_instance_of(FailedEmail).to receive(:valid?).and_return(false) end it 'raises error even if exception is false' do expect { instance.process({}, mail_data, false) } .to raise_error(ActiveRecord::ActiveRecordError) end end end describe 'creating new tickets' do context 'when subject contains no ticket reference' do let(:raw_mail) { <<~RAW.chomp } From: foo@bar.com To: baz@qux.net Subject: Foo Lorem ipsum dolor RAW it 'creates a ticket and article' do expect { described_class.new.process({}, raw_mail) } .to change(Ticket, :count).by(1) .and change(Ticket::Article, :count).by_at_least(1) end it 'sets #title to email subject' do described_class.new.process({}, raw_mail) expect(Ticket.last.title).to eq('Foo') end it 'sets #state to "new"' do described_class.new.process({}, raw_mail) expect(Ticket.last.state.name).to eq('new') end context 'when no channel is given but a group with the :to address exists' do let!(:email_address) { create(:email_address, email: 'baz@qux.net', channel: nil) } let!(:group) { create(:group, name: 'baz headquarter', email_address: email_address) } let!(:channel) do channel = create(:email_channel, group: group) email_address.update(channel: channel) channel end it 'sets the group based on the :to field' do described_class.new.process({}, raw_mail) expect(Ticket.last.group.id).to eq(group.id) end end context 'when from address matches an existing agent' do let!(:agent) { create(:agent, email: 'foo@bar.com') } it 'sets article.sender to "Agent"' do described_class.new.process({}, raw_mail) expect(Ticket::Article.last.sender.name).to eq('Agent') end it 'sets ticket.state to "new"' do described_class.new.process({}, raw_mail) expect(Ticket.last.state.name).to eq('new') end end context 'when from address matches an existing agent customer' do let!(:agent_customer) { create(:agent_and_customer, email: 'foo@bar.com') } let!(:ticket) { create(:ticket, customer: agent_customer) } let!(:raw_email) { <<~RAW.chomp } From: foo@bar.com To: myzammad@example.com Subject: [#{Setting.get('ticket_hook') + Setting.get('ticket_hook_divider') + ticket.number}] test Lorem ipsum dolor RAW it 'sets article.sender to "Customer"' do described_class.new.process({}, raw_email) expect(Ticket::Article.last.sender.name).to eq('Customer') end end context 'when reply-to is taken as sender/from of email' do let(:reply_to) { 'jane.doe@example.corp' } let(:raw_mail) { <<~RAW.chomp } From: foo@bar.com To: baz@qux.net Reply-To: #{reply_to} Subject: Foo Lorem ipsum dolor RAW before do Setting.set('postmaster_sender_based_on_reply_to', 'as_sender_of_email') end it 'sets reply-to as from value' do described_class.new.process({}, raw_mail) expect(Ticket.last.articles.reload.first.from).to eq('jane.doe@example.corp') end context 'with broken reply-to value' do let(:reply_to) { '' } it 'ignores reply-to and keeps from' do described_class.new.process({}, raw_mail) expect(Ticket.last.articles.reload.first.from).to eq('foo@bar.com') end end end context 'when from address matches an existing customer' do let!(:customer) { create(:customer, email: 'foo@bar.com') } it 'sets article.sender to "Customer"' do described_class.new.process({}, raw_mail) expect(Ticket.last.articles.reload.first.sender.name).to eq('Customer') end it 'sets ticket.state to "new"' do described_class.new.process({}, raw_mail) expect(Ticket.last.state.name).to eq('new') end end context 'when from address is unrecognized' do it 'sets article.sender to "Customer"' do described_class.new.process({}, raw_mail) expect(Ticket.last.articles.reload.first.sender.name).to eq('Customer') end end end context 'when email contains x-headers' do let(:raw_mail) { <<~RAW.chomp } From: foo@bar.com To: baz@qux.net Subject: Foo X-Zammad-Ticket-priority: 3 high Lorem ipsum dolor RAW context 'when channel is not trusted' do let(:channel) { create(:channel, options: { inbound: { trusted: false } }) } it 'does not change the priority of the ticket (no channel)' do described_class.new.process({}, raw_mail) expect(Ticket.last.priority.name).to eq('2 normal') end it 'does not change the priority of the ticket (untrusted)' do described_class.new.process(channel, raw_mail) expect(Ticket.last.priority.name).to eq('2 normal') end end context 'when channel is trusted' do let(:channel) { create(:channel, options: { inbound: { trusted: true } }) } it 'does not change the priority of the ticket' do described_class.new.process(channel, raw_mail) expect(Ticket.last.priority.name).to eq('3 high') end end end context 'Mentions:' do let(:agent) { create(:agent) } let(:raw_mail) { <<~RAW.chomp } From: foo@bar.com To: baz@qux.net Subject: Foo Lorem ipsum dolor agent RAW it 'creates a ticket and article without mentions and no exception raised' do expect { described_class.new.process({}, raw_mail) } .to change(Ticket, :count).by(1) .and change(Ticket::Article, :count).by_at_least(1) .and not_change(Mention, :count) end end end describe 'associating emails to existing tickets' do let!(:ticket) { create(:ticket) } let(:ticket_ref) { Setting.get('ticket_hook') + Setting.get('ticket_hook_divider') + ticket.number } describe 'based on where a ticket reference appears in the message' do shared_context 'ticket reference in subject' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: #{ticket_ref} Lorem ipsum dolor RAW end shared_context 'ticket reference in body' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference Lorem ipsum dolor #{ticket_ref} RAW end shared_context 'ticket reference in body (text/html)' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference Content-Transfer-Encoding: 7bit Content-Type: text/html; Lorem ipsum dolor #{ticket_ref} RAW end shared_context 'ticket reference in text/plain attachment' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com Content-Type: multipart/mixed; boundary="Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2" Subject: no reference Date: Sun, 30 Aug 2015 23:20:54 +0200 To: Martin Edenhofer Mime-Version: 1.0 (Mac OS X Mail 8.2 (2104)) X-Mailer: Apple Mail (2.2104) --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2 Content-Transfer-Encoding: 7bit Content-Type: text/plain; charset=us-ascii no reference --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2 Content-Disposition: attachment; filename=test1.txt Content-Type: text/plain; name="test.txt" Content-Transfer-Encoding: 7bit Some Text #{ticket_ref} --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2-- RAW end shared_context 'ticket reference in text/html (as content) attachment' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com Content-Type: multipart/mixed; boundary="Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2" Subject: no reference Date: Sun, 30 Aug 2015 23:20:54 +0200 To: Martin Edenhofer Mime-Version: 1.0 (Mac OS X Mail 8.2 (2104)) X-Mailer: Apple Mail (2.2104) --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2 Content-Transfer-Encoding: 7bit Content-Type: text/plain; charset=us-ascii no reference --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2 Content-Disposition: attachment; filename=test1.txt Content-Type: text/html; name="test.txt" Content-Transfer-Encoding: 7bit
Some Text #{ticket_ref}
--Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2-- RAW end shared_context 'ticket reference in text/html (attribute) attachment' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com Content-Type: multipart/mixed; boundary="Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2" Subject: no reference Date: Sun, 30 Aug 2015 23:20:54 +0200 To: Martin Edenhofer Mime-Version: 1.0 (Mac OS X Mail 8.2 (2104)) X-Mailer: Apple Mail (2.2104) --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2 Content-Transfer-Encoding: 7bit Content-Type: text/plain; charset=us-ascii no reference --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2 Content-Disposition: attachment; filename=test1.txt Content-Type: text/html; name="test.txt" Content-Transfer-Encoding: 7bit
Some Text some text
--Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2-- RAW end shared_context 'ticket reference in image/jpg attachment' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com Content-Type: multipart/mixed; boundary="Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2" Subject: no reference Date: Sun, 30 Aug 2015 23:20:54 +0200 To: Martin Edenhofer Mime-Version: 1.0 (Mac OS X Mail 8.2 (2104)) X-Mailer: Apple Mail (2.2104) --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2 Content-Transfer-Encoding: 7bit Content-Type: text/plain; charset=us-ascii no reference --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2 Content-Disposition: attachment; filename=test1.jpg Content-Type: image/jpg; name="test.jpg" Content-Transfer-Encoding: 7bit Some Text #{ticket_ref} --Apple-Mail=_ED77AC8D-FB6F-40E5-8FBE-D41FF5E1BAF2-- RAW end shared_context 'ticket reference in In-Reply-To header' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference In-Reply-To: #{article.message_id} Lorem ipsum dolor RAW let!(:article) { create(:ticket_article, ticket: ticket, message_id: '<20150830145601.30.608882@edenhofer.zammad.com>') } end shared_context 'ticket reference in References header' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference References: #{article.message_id} Lorem ipsum dolor RAW let!(:article) { create(:ticket_article, ticket: ticket, message_id: '<20150830145601.30.608882@edenhofer.zammad.com>') } end shared_examples 'adds message to ticket' do it 'adds message to ticket' do expect { described_class.new.process({}, raw_mail) } .to change { ticket.articles.reload.length }.by(1) end end shared_examples 'creates a new ticket' do it 'creates a new ticket' do expect { described_class.new.process({}, raw_mail) } .to change(Ticket, :count).by(1) .and not_change { ticket.articles.reload.length } end end context 'when configured to search subject_references' do context 'when subject contains ticket reference' do include_context 'ticket reference in subject' include_examples 'adds message to ticket' context 'alongside other, invalid ticket references' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: [#{Setting.get('ticket_hook') + Setting.get('ticket_hook_divider') + Ticket::Number.generate}] #{ticket_ref} Lorem ipsum dolor RAW include_examples 'adds message to ticket' end context 'and ticket is closed' do before { ticket.update(state: Ticket::State.find_by(name: 'closed')) } include_examples 'adds message to ticket' end context 'but ticket group’s #follow_up_possible attribute is "new_ticket"' do before { ticket.group.update(follow_up_possible: 'new_ticket') } context 'and ticket is open' do include_examples 'adds message to ticket' end context 'and ticket is closed' do before { ticket.update(state: Ticket::State.find_by(name: 'closed')) } include_examples 'creates a new ticket' end context 'and ticket is merged' do before { ticket.update(state: Ticket::State.find_by(name: 'merged')) } include_examples 'creates a new ticket' end end context 'and "ticket_hook" setting is non-default value' do before { Setting.set('ticket_hook', 'VD-Ticket#') } include_examples 'adds message to ticket' end end context 'when body contains ticket reference' do include_context 'ticket reference in body' include_examples 'creates a new ticket' end context 'when text/plain attachment contains ticket reference' do include_context 'ticket reference in text/plain attachment' include_examples 'creates a new ticket' end context 'when text/html attachment (as content) contains ticket reference' do include_context 'ticket reference in text/html (as content) attachment' include_examples 'creates a new ticket' end context 'when text/html attachment (attribute) contains ticket reference' do include_context 'ticket reference in text/html (attribute) attachment' include_examples 'creates a new ticket' end context 'when image/jpg attachment contains ticket reference' do include_context 'ticket reference in image/jpg attachment' include_examples 'creates a new ticket' end context 'when In-Reply-To header contains article message-id' do include_context 'ticket reference in In-Reply-To header' include_examples 'creates a new ticket' context 'and subject matches article subject' do let(:raw_mail) { <<~RAW.chomp } From: customer@example.com To: me@example.com Subject: AW: RE: #{article.subject} In-Reply-To: #{article.message_id} Lorem ipsum dolor RAW include_examples 'adds message to ticket' end context 'and "ticket_hook_position" setting is "none"' do before { Setting.set('ticket_hook_position', 'none') } let(:raw_mail) { <<~RAW.chomp } From: customer@example.com To: me@example.com Subject: RE: Foo bar In-Reply-To: #{article.message_id} Lorem ipsum dolor RAW include_examples 'adds message to ticket' end end context 'when References header contains article message-id' do include_context 'ticket reference in References header' include_examples 'creates a new ticket' context 'and Auto-Submitted header reads "auto-replied"' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference References: #{article.message_id} Auto-Submitted: auto-replied Lorem ipsum dolor RAW include_examples 'adds message to ticket' end context 'and subject matches article subject' do let(:raw_mail) { <<~RAW.chomp } From: customer@example.com To: me@example.com Subject: AW: RE: #{article.subject} References: #{article.message_id} Lorem ipsum dolor RAW include_examples 'adds message to ticket' end context 'and "ticket_hook_position" setting is "none"' do before { Setting.set('ticket_hook_position', 'none') } let(:raw_mail) { <<~RAW.chomp } From: customer@example.com To: me@example.com Subject: RE: Foo bar References: #{article.message_id} Lorem ipsum dolor RAW include_examples 'adds message to ticket' end end end context 'when configured to search body' do before { Setting.set('postmaster_follow_up_search_in', 'body') } context 'when subject contains ticket reference' do include_context 'ticket reference in subject' include_examples 'adds message to ticket' end context 'when body contains ticket reference' do context 'in visible text' do include_context 'ticket reference in body' include_examples 'adds message to ticket' end context 'in visible text with a linebreak' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference Lorem ipsum dolor #{ticket_ref} consetetur sadipscing elitr sed diam nonumy eirmod RAW include_examples 'adds message to ticket' end context 'as part of a larger word' do let(:ticket_ref) { "Foo#{Setting.get('ticket_hook')}#{Setting.get('ticket_hook_divider')}#{ticket.number}bar" } include_context 'ticket reference in body' include_examples 'creates a new ticket' end context 'between html tags' do include_context 'ticket reference in body (text/html)' include_examples 'adds message to ticket' end context 'in html attributes' do let(:ticket_ref) { %(
) } include_context 'ticket reference in body (text/html)' include_examples 'creates a new ticket' end end context 'when text/plain attachment contains ticket reference' do include_context 'ticket reference in text/plain attachment' include_examples 'creates a new ticket' end context 'when text/html attachment (as content) contains ticket reference' do include_context 'ticket reference in text/html (as content) attachment' include_examples 'creates a new ticket' end context 'when text/html attachment (attribute) contains ticket reference' do include_context 'ticket reference in text/html (attribute) attachment' include_examples 'creates a new ticket' end context 'when image/jpg attachment contains ticket reference' do include_context 'ticket reference in image/jpg attachment' include_examples 'creates a new ticket' end context 'when In-Reply-To header contains article message-id' do include_context 'ticket reference in In-Reply-To header' include_examples 'creates a new ticket' context 'and Auto-Submitted header reads "auto-replied"' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference References: #{article.message_id} Auto-Submitted: auto-replied Lorem ipsum dolor RAW include_examples 'adds message to ticket' end end context 'when References header contains article message-id' do include_context 'ticket reference in References header' include_examples 'creates a new ticket' end end context 'when configured to search attachments' do before { Setting.set('postmaster_follow_up_search_in', 'attachment') } context 'when subject contains ticket reference' do include_context 'ticket reference in subject' include_examples 'adds message to ticket' end context 'when body contains ticket reference' do include_context 'ticket reference in body' include_examples 'creates a new ticket' end context 'when text/plain attachment contains ticket reference' do include_context 'ticket reference in text/plain attachment' include_examples 'adds message to ticket' end context 'when text/html attachment (as content) contains ticket reference' do include_context 'ticket reference in text/html (as content) attachment' include_examples 'adds message to ticket' end context 'when text/html attachment (attribute) contains ticket reference' do include_context 'ticket reference in text/html (attribute) attachment' include_examples 'creates a new ticket' end context 'when image/jpg attachment contains ticket reference' do include_context 'ticket reference in image/jpg attachment' include_examples 'creates a new ticket' end context 'when In-Reply-To header contains article message-id' do include_context 'ticket reference in In-Reply-To header' include_examples 'creates a new ticket' end context 'when References header contains article message-id' do include_context 'ticket reference in References header' include_examples 'creates a new ticket' context 'and Auto-Submitted header reads "auto-replied"' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference References: #{article.message_id} Auto-Submitted: auto-replied Lorem ipsum dolor RAW include_examples 'adds message to ticket' end end end context 'when configured to search headers' do before { Setting.set('postmaster_follow_up_search_in', 'references') } context 'when subject contains ticket reference' do include_context 'ticket reference in subject' include_examples 'adds message to ticket' end context 'when body contains ticket reference' do include_context 'ticket reference in body' include_examples 'creates a new ticket' end context 'when text/plain attachment contains ticket reference' do include_context 'ticket reference in text/plain attachment' include_examples 'creates a new ticket' end context 'when text/html attachment (as content) contains ticket reference' do include_context 'ticket reference in text/html (as content) attachment' include_examples 'creates a new ticket' end context 'when text/html attachment (attribute) contains ticket reference' do include_context 'ticket reference in text/html (attribute) attachment' include_examples 'creates a new ticket' end context 'when image/jpg attachment contains ticket reference' do include_context 'ticket reference in image/jpg attachment' include_examples 'creates a new ticket' end context 'when In-Reply-To header contains article message-id' do include_context 'ticket reference in In-Reply-To header' include_examples 'adds message to ticket' end context 'when References header contains article message-id' do include_context 'ticket reference in References header' include_examples 'adds message to ticket' context 'that matches two separate tickets' do let!(:newer_ticket) { create(:ticket) } let!(:newer_article) { create(:ticket_article, ticket: newer_ticket, message_id: article.message_id) } it 'returns more recently created ticket' do expect(described_class.new.process({}, raw_mail).first).to eq(newer_ticket) end it 'adds message to more recently created ticket' do expect { described_class.new.process({}, raw_mail) } .to change { newer_ticket.articles.reload.count }.by(1) .and not_change { ticket.articles.reload.count } end end context 'and Auto-Submitted header reads "auto-replied"' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference References: #{article.message_id} Auto-Submitted: auto-replied Lorem ipsum dolor RAW include_examples 'adds message to ticket' end end end context 'when configured to search everything' do before { Setting.set('postmaster_follow_up_search_in', %w[body attachment references]) } context 'when subject contains ticket reference' do include_context 'ticket reference in subject' include_examples 'adds message to ticket' end context 'when body contains ticket reference' do include_context 'ticket reference in body' include_examples 'adds message to ticket' end context 'when text/plain attachment contains ticket reference' do include_context 'ticket reference in text/plain attachment' include_examples 'adds message to ticket' end context 'when text/html attachment (as content) contains ticket reference' do include_context 'ticket reference in text/html (as content) attachment' include_examples 'adds message to ticket' end context 'when text/html attachment (attribute) contains ticket reference' do include_context 'ticket reference in text/html (attribute) attachment' include_examples 'creates a new ticket' end context 'when image/jpg attachment contains ticket reference' do include_context 'ticket reference in image/jpg attachment' include_examples 'creates a new ticket' end context 'when In-Reply-To header contains article message-id' do include_context 'ticket reference in In-Reply-To header' include_examples 'adds message to ticket' end context 'when References header contains article message-id' do include_context 'ticket reference in References header' include_examples 'adds message to ticket' context 'and Auto-Submitted header reads "auto-replied"' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: no reference References: #{article.message_id} Auto-Submitted: auto-replied Lorem ipsum dolor RAW include_examples 'adds message to ticket' end end end end context 'for a closed ticket' do let(:ticket) { create(:ticket, state_name: 'closed') } let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: #{ticket_ref} Lorem ipsum dolor RAW it 'reopens it' do expect { described_class.new.process({}, raw_mail) } .to change { ticket.reload.state.name }.to('open') end context 'when group has follow_up_assignment true' do let(:group) { create(:group, follow_up_assignment: true) } let(:agent) { create(:agent, groups: [group]) } let(:ticket) { create(:ticket, state_name: 'closed', owner: agent, group: group) } it 'does not change the owner' do expect { described_class.new.process({}, raw_mail) } .not_to change { ticket.reload.owner.login } end end context 'when group has follow_up_assignment false' do let(:group) { create(:group, follow_up_assignment: false) } let(:agent) { create(:agent, groups: [group]) } let(:ticket) { create(:ticket, state_name: 'closed', owner: agent, group: group) } it 'does change the owner' do expect { described_class.new.process({}, raw_mail) } .to change { ticket.reload.owner.login }.to eq(User.find(1).login) end end end end describe 'assigning ticket.customer' do let(:agent) { create(:agent) } let(:customer) { create(:customer) } let(:raw_mail) { <<~RAW.chomp } From: #{agent.email} To: #{customer.email} Subject: Foo Lorem ipsum dolor RAW context 'when "postmaster_sender_is_agent_search_for_customer" setting is true (default)' do it 'sets ticket.customer to user with To: email' do expect { described_class.new.process({}, raw_mail) } .to change(Ticket, :count).by(1) expect(Ticket.last.customer).to eq(customer) end end context 'when "postmaster_sender_is_agent_search_for_customer" setting is false' do before { Setting.set('postmaster_sender_is_agent_search_for_customer', false) } it 'sets ticket.customer to user with To: email' do expect { described_class.new.process({}, raw_mail) } .to change(Ticket, :count).by(1) expect(Ticket.last.customer).to eq(agent) end end end describe 'formatting to/from addresses' do # see https://github.com/zammad/zammad/issues/2198 context 'when sender address contains spaces (#2198)' do let(:mail_file) { Rails.root.join('test/data/mail/mail071.box') } let(:sender_email) { 'powerquadrantsystem@example.com' } it 'removes them before creating a new user' do expect { described_class.new.process({}, raw_mail) } .to change { User.exists?(email: sender_email) } end it 'marks new user email as invalid' do described_class.new.process({}, raw_mail) expect(User.find_by(email: sender_email).preferences) .to include('mail_delivery_failed' => true) .and include('mail_delivery_failed_reason' => 'invalid email') .and include('mail_delivery_failed_data' => a_kind_of(ActiveSupport::TimeWithZone)) end end # see https://github.com/zammad/zammad/issues/2254 context 'when sender address contains > (#2254)' do let(:mail_file) { Rails.root.join('test/data/mail/mail076.box') } let(:sender_email) { 'millionslotteryspaintransfer@example.com' } it 'removes them before creating a new user' do expect { described_class.new.process({}, raw_mail) } .to change { User.exists?(email: sender_email) } end it 'marks new user email as invalid' do described_class.new.process({}, raw_mail) expect(User.find_by(email: sender_email).preferences) .to include('mail_delivery_failed' => true) .and include('mail_delivery_failed_reason' => 'invalid email') .and include('mail_delivery_failed_data' => a_kind_of(ActiveSupport::TimeWithZone)) end end end describe 'signature detection', performs_jobs: true do let(:raw_mail) { header + File.read(message_file) } let(:header) { <<~HEADER } From: Bob.Smith@music.com To: test@zammad.org Subject: test HEADER context 'for emails from an unrecognized email address' do let(:message_file) { Rails.root.join('test/data/email_signature_detection/client_a_1.txt') } it 'does not detect signatures' do described_class.new.process({}, raw_mail) expect { perform_enqueued_jobs } .to not_change { Ticket.last.customer.preferences[:signature_detection] }.from(nil) .and not_change { Ticket.last.articles.reload.first.preferences[:signature_detection] }.from(nil) end end context 'for emails from a previously processed sender' do before do described_class.new.process({}, header + File.read(previous_message_file)) end let(:previous_message_file) { Rails.root.join('test/data/email_signature_detection/client_a_1.txt') } let(:message_file) { Rails.root.join('test/data/email_signature_detection/client_a_2.txt') } it 'sets detected signature on user (in a background job)' do described_class.new.process({}, raw_mail) expect { perform_enqueued_jobs } .to change { Ticket.last.customer.preferences[:signature_detection] } end it 'sets line of detected signature on article (in a background job)' do described_class.new.process({}, raw_mail) expect { perform_enqueued_jobs } .to change { Ticket.last.articles.reload.first.preferences[:signature_detection] }.to(20) end end end describe 'charset handling' do # see https://github.com/zammad/zammad/issues/2224 context 'when header specifies Windows-1258 charset (#2224)' do let(:mail_file) { Rails.root.join('test/data/mail/mail072.box') } it 'does not raise Encoding::ConverterNotFoundError' do expect { described_class.new.process({}, raw_mail) } .not_to raise_error end end context 'when attachment for follow up check contains invalid charsets (#2808)' do let(:mail_file) { Rails.root.join('test/data/mail/mail085.box') } before { Setting.set('postmaster_follow_up_search_in', %w[attachment body]) } it 'does not raise Encoding::CompatibilityError:' do expect { described_class.new.process({}, raw_mail) } .not_to raise_error end end end describe 'attachment handling' do context 'with header "Content-Transfer-Encoding: x-uuencode"' do let(:mail_file) { Rails.root.join('test/data/mail/mail078-content_transfer_encoding_x_uuencode.box') } let(:article) { described_class.new.process({}, raw_mail).second } it 'does not raise RuntimeError' do expect { described_class.new.process({}, raw_mail) } .not_to raise_error end it 'parses the content correctly' do expect(article.attachments.first.filename).to eq('PGP_Cmts_on_12-14-01_Pkg.txt') expect(article.attachments.first.content).to eq('Hello Zammad') end end # https://github.com/zammad/zammad/issues/3529 context 'Attachments sent by Zammad not shown in Outlook' do subject(:mail) do Channel::EmailBuild.build( from: 'sender@example.com', to: 'recipient@example.com', body: body, content_type: 'text/html', attachments: Store.where(filename: 'super-seven.jpg') ) end let(:mail_file) { Rails.root.join('test/data/mail/mail101.box') } before do described_class.new.process({}, raw_mail) end context 'when no reference in body' do let(:body) { 'no reference here' } it 'does not have content disposition inline' do expect(mail.to_s).to include('Content-Disposition: attachment').and not_include('Content-Disposition: inline') end end context 'when reference in body' do let(:body) { %(somebody with some text ) } it 'does have content disposition inline' do expect(mail.to_s).to include('Content-Disposition: inline').and not_include('Content-Disposition: attachment') end context 'when encoded as ISO-8859-1' do let(:body) { super().encode('ISO-8859-1') } it 'does not raise exception' do expect { mail.to_s }.not_to raise_error end end end end end describe 'inline image handling' do # see https://github.com/zammad/zammad/issues/2486 context 'when image is large but not resizable' do let(:mail_file) { Rails.root.join('test/data/mail/mail079.box') } let(:attachment) { article.attachments.to_a.find { |i| i.filename == 'a.jpg' } } let(:article) { described_class.new.process({}, raw_mail).second } it "doesn't set resizable preference" do expect(attachment.filename).to eq('a.jpg') expect(attachment.preferences).not_to include('resizable' => true) end end end describe 'ServiceNow handling' do context 'new Ticket' do let(:mail_file) { Rails.root.join('test/data/mail/mail089.box') } it 'creates an ExternalSync reference' do described_class.new.process({}, raw_mail) expect(ExternalSync.last).to have_attributes( source: 'ServiceNow-example@service-now.com', source_id: 'INC678439', object: 'Ticket', o_id: Ticket.last.id, ) end end context 'follow up' do let(:mail_file) { Rails.root.join('test/data/mail/mail090.box') } let(:ticket) { create(:ticket) } let!(:external_sync) do create(:external_sync, source: 'ServiceNow-example@service-now.com', source_id: 'INC678439', object: 'Ticket', o_id: ticket.id,) end it 'adds Article to existing Ticket' do expect { described_class.new.process({}, raw_mail) }.to change { ticket.reload.articles.reload.count } end context 'key insensitive sender address' do let(:raw_mail) { super().gsub('example@service-now.com', 'Example@Service-Now.com') } it 'adds Article to existing Ticket' do expect { described_class.new.process({}, raw_mail) }.to change { ticket.reload.articles.reload.count } end end end end describe 'Jira handling' do context 'new Ticket' do let(:mail_file) { Rails.root.join('test/data/mail/mail103.box') } it 'creates an ExternalSync reference' do described_class.new.process({}, raw_mail) expect(ExternalSync.last).to have_attributes( source: 'Jira-example@jira.com', source_id: 'SYS-422', object: 'Ticket', o_id: Ticket.last.id, ) end end context 'follow up' do let(:mail_file) { Rails.root.join('test/data/mail/mail104.box') } let(:ticket) { create(:ticket) } let!(:external_sync) do create(:external_sync, source: 'Jira-example@jira.com', source_id: 'SYS-422', object: 'Ticket', o_id: ticket.id,) end it 'adds Article to existing Ticket' do expect { described_class.new.process({}, raw_mail) }.to change { ticket.reload.articles.reload.count } end context 'key insensitive sender address' do let(:raw_mail) { super().gsub('example@service-now.com', 'Example@Service-Now.com') } it 'adds Article to existing Ticket' do expect { described_class.new.process({}, raw_mail) }.to change { ticket.reload.articles.reload.count } end end end end describe 'XSS protection' do before do # XSS processing may run into a timeout on slow CI systems, so turn the timeout off for the test. stub_const("#{HtmlSanitizer}::PROCESSING_TIMEOUT", nil) end let(:article) { described_class.new.process({}, raw_mail).second } let(:raw_mail) { <<~RAW.chomp } From: ME Bob To: customer@example.com Subject: some subject Content-Type: #{content_type} MIME-Version: 1.0 no HTML RAW context 'for Content-Type: text/html' do let(:content_type) { 'text/html' } it 'removes injected SANITIZED end end end context 'for “delivery failed” notifications (a.k.a. bounce messages)' do let(:ticket) { article.ticket } let(:article) { create(:ticket_article, sender_name: 'Agent', message_id: message_id) } let(:message_id) { raw_mail[%r{(?<=^(References|Message-ID): )\S*}] } context 'with future retries (delayed)' do let(:mail_file) { Rails.root.join('test/data/mail/mail078.box') } context 'on a closed ticket' do before { ticket.update(state: Ticket::State.find_by(name: 'closed')) } it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do article = described_class.new.process({}, raw_mail).second expect(article.preferences) .to include('send-auto-response' => false, 'is-auto-response' => true) end it 'returns a Mail object with an x-zammad-out-of-office header' do output_mail = described_class.new.process({}, raw_mail).last expect(output_mail).to include('x-zammad-out-of-office': true) end it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do expect { described_class.new.process({}, raw_mail) } .to change { ticket.articles.reload.count }.by(1) end it 'does not re-open the ticket' do expect { described_class.new.process({}, raw_mail) } .not_to change { ticket.reload.state.name }.from('closed') end end end context 'with no future retries (undeliverable): sample input 1' do let(:mail_file) { Rails.root.join('test/data/mail/mail033-undelivered-mail-returned-to-sender.box') } context 'for original message sent by Agent' do it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do article = described_class.new.process({}, raw_mail).second expect(article.preferences) .to include('send-auto-response' => false, 'is-auto-response' => true) end it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do expect { described_class.new.process({}, raw_mail) } .to change { ticket.articles.reload.count }.by(1) end it 'does not alter the ticket state' do expect { described_class.new.process({}, raw_mail) } .not_to change { ticket.reload.state.name }.from('open') end end context 'for original message sent by Customer' do let(:article) { create(:ticket_article, sender_name: 'Customer', message_id: message_id) } it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do article = described_class.new.process({}, raw_mail).second expect(article.preferences) .to include('send-auto-response' => false, 'is-auto-response' => true) end it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do expect { described_class.new.process({}, raw_mail) } .to change { ticket.articles.reload.count }.by(1) end it 'does not alter the ticket state' do expect { described_class.new.process({}, raw_mail) } .not_to change { ticket.reload.state.name }.from('new') end end end context 'with no future retries (undeliverable): sample input 2' do let(:mail_file) { Rails.root.join('test/data/mail/mail055.box') } it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do expect { described_class.new.process({}, raw_mail) } .to change { ticket.articles.reload.count }.by(1) end it 'does not alter the ticket state' do expect { described_class.new.process({}, raw_mail) } .not_to change { ticket.reload.state.name }.from('open') end end end context 'for “out-of-office” notifications (a.k.a. auto-response messages)' do let(:raw_mail) { <<~RAW.chomp } From: me@example.com To: customer@example.com Subject: #{subject_line} Some Text RAW let(:subject_line) { 'Lorem ipsum dolor' } it 'applies the OutOfOfficeCheck filter to given message' do expect(Channel::Filter::OutOfOfficeCheck) .to receive(:run) .with(kind_of(Hash), hash_including(subject: subject_line), kind_of(Hash)) described_class.new.process({}, raw_mail) end context 'on an existing, closed ticket' do let(:ticket) { create(:ticket, state_name: 'closed') } let(:subject_line) { ticket.subject_build('Lorem ipsum dolor') } context 'when OutOfOfficeCheck filter applies x-zammad-out-of-office: false' do before do allow(Channel::Filter::OutOfOfficeCheck) .to receive(:run) { |_, mail_hash| mail_hash[:'x-zammad-out-of-office'] = false } end it 're-opens a closed ticket' do expect { described_class.new.process({}, raw_mail) } .to not_change(Ticket, :count) .and change { ticket.reload.state.name }.to('open') end end context 'when OutOfOfficeCheck filter applies x-zammad-out-of-office: true' do before do allow(Channel::Filter::OutOfOfficeCheck) .to receive(:run) { |_, mail_hash| mail_hash[:'x-zammad-out-of-office'] = true } end it 'does not re-open a closed ticket' do expect { described_class.new.process({}, raw_mail) } .to not_change(Ticket, :count) .and not_change { ticket.reload.state.name } end end end end describe 'suppressing normal Ticket::Article callbacks' do context 'from sender: "Agent"' do let(:agent) { create(:agent) } it 'does not dispatch an email on article creation' do expect(TicketArticleCommunicateEmailJob).not_to receive(:perform_later) described_class.new.process({}, <<~RAW.chomp) From: #{agent.email} To: customer@example.com Subject: some subject Some Text RAW end end end context 'when an unprocessable mail is received' do let(:parser) { described_class.new } let(:mail) { attributes_for(:failed_email)[:data] } before do allow(parser).to receive(:_process).and_raise(Timeout::Error) end it 'saves the unprocessable email' do begin parser.process({}, mail) rescue RuntimeError # expected end expect(FailedEmail).to be_exist end end end describe '#compose_postmaster_reply' do let(:raw_incoming_mail) { Rails.root.join('test/data/mail/mail010.box').read } shared_examples 'postmaster reply' do it 'composes postmaster reply' do reply = described_class.new.send(:compose_postmaster_reply, raw_incoming_mail, locale) expect(reply[:to]).to eq('smith@example.com') expect(reply[:content_type]).to eq('text/plain') expect(reply[:subject]).to eq(expected_subject) expect(reply[:body]).to eq(expected_body) end end context 'for English locale (en)' do include_examples 'postmaster reply' do let(:locale) { 'en' } let(:expected_subject) { '[undeliverable] Message too large' } let(:expected_body) do body = <<~BODY Dear Smith Sepp, Unfortunately your email titled "Gruß aus Oberalteich" could not be delivered to one or more recipients. Your message was 0.01 MB but we only accept messages up to 10 MB. Please reduce the message size and try again. Thank you for your understanding. Regretfully, Postmaster of zammad.example.com BODY body.gsub(%r{\n}, "\r\n") end end end context 'for German locale (de)' do include_examples 'postmaster reply' do let(:locale) { 'de' } let(:expected_subject) { '[Unzustellbar] Nachricht zu groß' } let(:expected_body) do body = <<~BODY Hallo Smith Sepp, Ihre E-Mail mit dem Betreff "Gruß aus Oberalteich" konnte leider nicht an einen oder mehrere Empfänger zugestellt werden. Die Nachricht hatte eine Größe von 0.01 MB, wir akzeptieren jedoch nur E-Mails mit einer Größe von bis zu 10 MB. Bitte reduzieren Sie die Größe Ihrer Nachricht und versuchen Sie es erneut. Vielen Dank für Ihr Verständnis. Mit freundlichen Grüßen Postmaster von zammad.example.com BODY body.gsub(%r{\n}, "\r\n") end end end end describe '#mail_to_group' do context 'when EmailAddress exists' do context 'when gives address matches exactly' do let(:group) { create(:group) } let(:channel) { create(:email_channel, group: group) } let!(:email_address) { create(:email_address, channel: channel) } it 'returns the Channel Group' do expect(described_class.mail_to_group(email_address.email)).to eq(group) end end context 'when gives address matches key insensitive' do let(:group) { create(:group) } let(:channel) { create(:email_channel, group: group) } let(:address) { 'KeyInsensitive@example.COM' } let!(:email_address) { create(:email_address, email: address, channel: channel) } it 'returns the Channel Group' do expect(described_class.mail_to_group(address)).to eq(group) end end context 'when no Channel is assigned' do let!(:email_address) { create(:email_address, channel: nil) } it 'returns nil' do expect(described_class.mail_to_group(email_address.email)).to be_nil end end context 'when Channel has no Group assigned' do let(:channel) { create(:email_channel, group: nil) } let!(:email_address) { create(:email_address, channel: channel) } it 'returns nil' do expect(described_class.mail_to_group(email_address.email)).to be_nil end end end context 'when given address is not parse-able' do let(:address) { 'this_is_not_a_valid_email_address' } it 'returns nil' do expect(described_class.mail_to_group(address)).to be_nil end end end describe 'Updating group settings causes huge numbers of delayed jobs #4306', searchindex: true do let(:new_email) { <<~RAW.chomp } From: Max Smith To: myzammad@example.com Subject: test sender name update 2 Some Text RAW it 'does create search index jobs for new email tickets' do ticket, = described_class.new.process({}, new_email) job = Delayed::Job.all.detect { |row| YAML.load(row.handler, permitted_classes: [ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper]).job_data['arguments'] == ['Ticket', ticket.id] } expect(job).to be_present end end end