email_parser_spec.rb 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. require 'rails_helper'
  2. RSpec.describe Channel::EmailParser, type: :model do
  3. describe '#parse' do
  4. # regression test for issue 2390 - Add a postmaster filter to not show emails with potential issue
  5. describe 'handling HTML links in message content' do
  6. context 'with under 5,000 links' do
  7. it 'parses message content as normal' do
  8. expect(described_class.new.parse(<<~RAW)[:body]).to start_with('<a href="https://zammad.com/"')
  9. From: nicole.braun@zammad.com
  10. Content-Type: text/html
  11. <html><body>
  12. #{Array.new(10) { '<a href="https://zammad.com/">Dummy Link</a>' }.join(' ')}
  13. </body></html>
  14. RAW
  15. end
  16. end
  17. context 'with 5,000+ links' do
  18. it 'replaces message content with error message' do
  19. expect(described_class.new.parse(<<~RAW)).to include('body' => Channel::EmailParser::EXCESSIVE_LINKS_MSG)
  20. From: nicole.braun@zammad.com
  21. Content-Type: text/html
  22. <html><body>
  23. #{Array.new(5001) { '<a href="https://zammad.com/">Dummy Link</a>' }.join(' ')}
  24. </body></html>
  25. RAW
  26. end
  27. end
  28. end
  29. end
  30. describe '#process' do
  31. let(:raw_mail) { File.read(mail_file) }
  32. describe 'auto-creating new users' do
  33. context 'with one unrecognized email address' do
  34. it 'creates one new user' do
  35. expect { Channel::EmailParser.new.process({}, <<~RAW) }.to change { User.count }.by(1)
  36. From: #{Faker::Internet.unique.email}
  37. RAW
  38. end
  39. end
  40. context 'with a large number of unrecognized recipient addresses' do
  41. it 'never creates more than 40 users' do
  42. expect { Channel::EmailParser.new.process({}, <<~RAW) }.to change { User.count }.by(40)
  43. From: nicole.braun@zammad.org
  44. To: #{Array.new(20) { Faker::Internet.unique.email }.join(', ')}
  45. Cc: #{Array.new(21) { Faker::Internet.unique.email }.join(', ')}
  46. RAW
  47. end
  48. end
  49. end
  50. describe 'auto-updating existing users' do
  51. context 'with a previous email with no real name in the From: header' do
  52. let!(:customer) { Channel::EmailParser.new.process({}, previous_email).first.customer }
  53. let(:previous_email) { <<~RAW.chomp }
  54. From: customer@example.com
  55. To: myzammad@example.com
  56. Subject: test sender name update 1
  57. Some Text
  58. RAW
  59. context 'and a new email with a real name in the From: header' do
  60. let(:new_email) { <<~RAW.chomp }
  61. From: Max Smith <customer@example.com>
  62. To: myzammad@example.com
  63. Subject: test sender name update 2
  64. Some Text
  65. RAW
  66. it 'updates the customer’s #firstname and #lastname' do
  67. expect { Channel::EmailParser.new.process({}, new_email) }
  68. .to change { customer.reload.firstname }.from('').to('Max')
  69. .and change { customer.reload.lastname }.from('').to('Smith')
  70. end
  71. end
  72. end
  73. end
  74. describe 'creating new tickets' do
  75. context 'when subject contains no ticket reference' do
  76. let(:raw_mail) { <<~RAW.chomp }
  77. From: foo@bar.com
  78. To: baz@qux.net
  79. Subject: Foo
  80. Lorem ipsum dolor
  81. RAW
  82. it 'creates a ticket and article' do
  83. expect { Channel::EmailParser.new.process({}, raw_mail) }
  84. .to change { Ticket.count }.by(1)
  85. .and change { Ticket::Article.count }.by_at_least(1) # triggers may cause additional articles to be created
  86. end
  87. it 'sets #title to email subject' do
  88. Channel::EmailParser.new.process({}, raw_mail)
  89. expect(Ticket.last.title).to eq('Foo')
  90. end
  91. it 'sets #state to "new"' do
  92. Channel::EmailParser.new.process({}, raw_mail)
  93. expect(Ticket.last.state.name).to eq('new')
  94. end
  95. context 'when from address matches an existing agent' do
  96. let!(:agent) { create(:agent_user, email: 'foo@bar.com') }
  97. it 'sets article.sender to "Agent"' do
  98. Channel::EmailParser.new.process({}, raw_mail)
  99. expect(Ticket::Article.last.sender.name).to eq('Agent')
  100. end
  101. it 'sets ticket.state to "new"' do
  102. Channel::EmailParser.new.process({}, raw_mail)
  103. expect(Ticket.last.state.name).to eq('new')
  104. end
  105. end
  106. context 'when from address matches an existing customer' do
  107. let!(:customer) { create(:customer_user, email: 'foo@bar.com') }
  108. it 'sets article.sender to "Customer"' do
  109. Channel::EmailParser.new.process({}, raw_mail)
  110. expect(Ticket.last.articles.first.sender.name).to eq('Customer')
  111. end
  112. it 'sets ticket.state to "new"' do
  113. Channel::EmailParser.new.process({}, raw_mail)
  114. expect(Ticket.last.state.name).to eq('new')
  115. end
  116. end
  117. context 'when from address is unrecognized' do
  118. it 'sets article.sender to "Customer"' do
  119. Channel::EmailParser.new.process({}, raw_mail)
  120. expect(Ticket.last.articles.first.sender.name).to eq('Customer')
  121. end
  122. end
  123. end
  124. end
  125. describe 'associating emails to existing tickets' do
  126. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail001.box') }
  127. let(:ticket_ref) { Setting.get('ticket_hook') + Setting.get('ticket_hook_divider') + ticket.number }
  128. let(:ticket) { create(:ticket) }
  129. context 'when email subject contains ticket reference' do
  130. let(:raw_mail) { File.read(mail_file).sub(/(?<=^Subject: ).*$/, ticket_ref) }
  131. it 'adds message to ticket' do
  132. expect { described_class.new.process({}, raw_mail) }
  133. .to change { ticket.articles.length }
  134. end
  135. context 'and ticket is closed' do
  136. before { ticket.update(state: Ticket::State.find_by(name: 'closed')) }
  137. it 'adds message to ticket' do
  138. expect { described_class.new.process({}, raw_mail) }
  139. .to change { ticket.articles.length }
  140. end
  141. end
  142. context 'but ticket group’s #follow_up_possible attribute is "new_ticket"' do
  143. before { ticket.group.update(follow_up_possible: 'new_ticket') }
  144. context 'and ticket is open' do
  145. it 'still adds message to ticket' do
  146. expect { described_class.new.process({}, raw_mail) }
  147. .to change { ticket.articles.length }
  148. end
  149. end
  150. context 'and ticket is closed' do
  151. before { ticket.update(state: Ticket::State.find_by(name: 'closed')) }
  152. it 'creates a new ticket' do
  153. expect { described_class.new.process({}, raw_mail) }
  154. .to change { Ticket.count }.by(1)
  155. .and not_change { ticket.articles.length }
  156. end
  157. end
  158. context 'and ticket is merged' do
  159. before { ticket.update(state: Ticket::State.find_by(name: 'merged')) }
  160. it 'creates a new ticket' do
  161. expect { described_class.new.process({}, raw_mail) }
  162. .to change { Ticket.count }.by(1)
  163. .and not_change { ticket.articles.length }
  164. end
  165. end
  166. context 'and ticket is removed' do
  167. before { ticket.update(state: Ticket::State.find_by(name: 'removed')) }
  168. it 'creates a new ticket' do
  169. expect { described_class.new.process({}, raw_mail) }
  170. .to change { Ticket.count }.by(1)
  171. .and not_change { ticket.articles.length }
  172. end
  173. end
  174. end
  175. end
  176. context 'when configured to search body' do
  177. before { Setting.set('postmaster_follow_up_search_in', 'body') }
  178. context 'when body contains ticket reference' do
  179. context 'in visible text' do
  180. let(:raw_mail) { File.read(mail_file).sub(/Hallo =\nMartin,(?=<o:p>)/, ticket_ref) }
  181. it 'adds message to ticket' do
  182. expect { described_class.new.process({}, raw_mail) }
  183. .to change { ticket.articles.length }
  184. end
  185. end
  186. context 'as part of a larger word' do
  187. let(:raw_mail) { File.read(mail_file).sub(/(?<=Hallo) =\n(?=Martin,<o:p>)/, ticket_ref) }
  188. it 'creates a separate ticket' do
  189. expect { described_class.new.process({}, raw_mail) }
  190. .not_to change { ticket.articles.length }
  191. end
  192. end
  193. context 'in html attributes' do
  194. let(:raw_mail) { File.read(mail_file).sub(%r{<a href.*?/a>}m, %(<table bgcolor="#{ticket_ref}"> </table>)) }
  195. it 'creates a separate ticket' do
  196. expect { described_class.new.process({}, raw_mail) }
  197. .not_to change { ticket.articles.length }
  198. end
  199. end
  200. end
  201. end
  202. end
  203. describe 'assigning ticket.customer' do
  204. let(:agent) { create(:agent_user) }
  205. let(:customer) { create(:customer_user) }
  206. let(:raw_mail) { <<~RAW.chomp }
  207. From: #{agent.email}
  208. To: #{customer.email}
  209. Subject: Foo
  210. Lorem ipsum dolor
  211. RAW
  212. context 'when "postmaster_sender_is_agent_search_for_customer" setting is true (default)' do
  213. it 'sets ticket.customer to user with To: email' do
  214. expect { Channel::EmailParser.new.process({}, raw_mail) }
  215. .to change { Ticket.count }.by(1)
  216. expect(Ticket.last.customer).to eq(customer)
  217. end
  218. end
  219. context 'when "postmaster_sender_is_agent_search_for_customer" setting is false' do
  220. before { Setting.set('postmaster_sender_is_agent_search_for_customer', false) }
  221. it 'sets ticket.customer to user with To: email' do
  222. expect { Channel::EmailParser.new.process({}, raw_mail) }
  223. .to change { Ticket.count }.by(1)
  224. expect(Ticket.last.customer).to eq(agent)
  225. end
  226. end
  227. end
  228. describe 'formatting to/from addresses' do
  229. # see https://github.com/zammad/zammad/issues/2198
  230. context 'when sender address contains spaces (#2198)' do
  231. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail071.box') }
  232. let(:sender_email) { 'powerquadrantsystem@example.com' }
  233. it 'removes them before creating a new user' do
  234. expect { described_class.new.process({}, raw_mail) }
  235. .to change { User.exists?(email: sender_email) }
  236. end
  237. it 'marks new user email as invalid' do
  238. described_class.new.process({}, raw_mail)
  239. expect(User.find_by(email: sender_email).preferences)
  240. .to include('mail_delivery_failed' => true)
  241. .and include('mail_delivery_failed_reason' => 'invalid email')
  242. .and include('mail_delivery_failed_data' => a_kind_of(ActiveSupport::TimeWithZone))
  243. end
  244. end
  245. # see https://github.com/zammad/zammad/issues/2254
  246. context 'when sender address contains > (#2254)' do
  247. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail076.box') }
  248. let(:sender_email) { 'millionslotteryspaintransfer@example.com' }
  249. it 'removes them before creating a new user' do
  250. expect { described_class.new.process({}, raw_mail) }
  251. .to change { User.exists?(email: sender_email) }
  252. end
  253. it 'marks new user email as invalid' do
  254. described_class.new.process({}, raw_mail)
  255. expect(User.find_by(email: sender_email).preferences)
  256. .to include('mail_delivery_failed' => true)
  257. .and include('mail_delivery_failed_reason' => 'invalid email')
  258. .and include('mail_delivery_failed_data' => a_kind_of(ActiveSupport::TimeWithZone))
  259. end
  260. end
  261. end
  262. describe 'signature detection' do
  263. let(:raw_mail) { header + File.read(message_file) }
  264. let(:header) { <<~HEADER }
  265. From: Bob.Smith@music.com
  266. To: test@zammad.org
  267. Subject: test
  268. HEADER
  269. context 'for emails from an unrecognized email address' do
  270. let(:message_file) { Rails.root.join('test', 'data', 'email_signature_detection', 'client_a_1.txt') }
  271. it 'does not detect signatures' do
  272. described_class.new.process({}, raw_mail)
  273. expect { Scheduler.worker(true) }
  274. .to not_change { Ticket.last.customer.preferences[:signature_detection] }.from(nil)
  275. .and not_change { Ticket.last.articles.first.preferences[:signature_detection] }.from(nil)
  276. end
  277. end
  278. context 'for emails from a previously processed sender' do
  279. before do
  280. described_class.new.process({}, header + File.read(previous_message_file))
  281. end
  282. let(:previous_message_file) { Rails.root.join('test', 'data', 'email_signature_detection', 'client_a_1.txt') }
  283. let(:message_file) { Rails.root.join('test', 'data', 'email_signature_detection', 'client_a_2.txt') }
  284. it 'sets detected signature on user (in a background job)' do
  285. described_class.new.process({}, raw_mail)
  286. expect { Scheduler.worker(true) }
  287. .to change { Ticket.last.customer.preferences[:signature_detection] }
  288. end
  289. it 'sets line of detected signature on article (in a background job)' do
  290. described_class.new.process({}, raw_mail)
  291. expect { Scheduler.worker(true) }
  292. .to change { Ticket.last.articles.first.preferences[:signature_detection] }.to(20)
  293. end
  294. end
  295. end
  296. describe 'charset handling' do
  297. # see https://github.com/zammad/zammad/issues/2224
  298. context 'when header specifies Windows-1258 charset (#2224)' do
  299. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail072.box') }
  300. it 'does not raise Encoding::ConverterNotFoundError' do
  301. expect { described_class.new.process({}, raw_mail) }
  302. .not_to raise_error
  303. end
  304. end
  305. end
  306. describe 'attachment handling' do
  307. context 'with header "Content-Transfer-Encoding: x-uuencode"' do
  308. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail078-content_transfer_encoding_x_uuencode.box') }
  309. let(:article) { described_class.new.process({}, raw_mail).second }
  310. it 'does not raise RuntimeError' do
  311. expect { described_class.new.process({}, raw_mail) }
  312. .not_to raise_error
  313. end
  314. it 'parses the content correctly' do
  315. expect(article.attachments.first.filename).to eq('PGP_Cmts_on_12-14-01_Pkg.txt')
  316. expect(article.attachments.first.content).to eq('Hello Zammad')
  317. end
  318. end
  319. end
  320. describe 'inline image handling' do
  321. # see https://github.com/zammad/zammad/issues/2486
  322. context 'when image is large but not resizable' do
  323. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail079.box') }
  324. let(:attachment) { article.attachments.to_a.find { |i| i.filename == 'a.jpg' } }
  325. let(:article) { described_class.new.process({}, raw_mail).second }
  326. it "doesn't set resizable preference" do
  327. expect(attachment.filename).to eq('a.jpg')
  328. expect(attachment.preferences).not_to include('resizable' => true)
  329. end
  330. end
  331. end
  332. describe 'XSS protection' do
  333. let(:article) { described_class.new.process({}, raw_mail).second }
  334. let(:raw_mail) { <<~RAW.chomp }
  335. From: ME Bob <me@example.com>
  336. To: customer@example.com
  337. Subject: some subject
  338. Content-Type: #{content_type}
  339. MIME-Version: 1.0
  340. no HTML <script type="text/javascript">alert(\'XSS\')</script>
  341. RAW
  342. context 'for Content-Type: text/html' do
  343. let(:content_type) { 'text/html' }
  344. it 'removes injected <script> tags from body' do
  345. expect(article.body).to eq("no HTML alert('XSS')")
  346. end
  347. end
  348. context 'for Content-Type: text/plain' do
  349. let(:content_type) { 'text/plain' }
  350. it 'leaves body as-is' do
  351. expect(article.body).to eq(<<~SANITIZED.chomp)
  352. no HTML <script type="text/javascript">alert(\'XSS\')</script>
  353. SANITIZED
  354. end
  355. end
  356. end
  357. context 'for “delivery failed” notifications (a.k.a. bounce messages)' do
  358. let(:ticket) { article.ticket }
  359. let(:article) { create(:ticket_article, sender_name: 'Agent', message_id: message_id) }
  360. let(:message_id) { raw_mail[/(?<=^(References|Message-ID): )\S*/] }
  361. context 'with future retries (delayed)' do
  362. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail078.box') }
  363. context 'on a closed ticket' do
  364. before { ticket.update(state: Ticket::State.find_by(name: 'closed')) }
  365. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  366. article = Channel::EmailParser.new.process({}, raw_mail).second
  367. expect(article.preferences)
  368. .to include('send-auto-response' => false, 'is-auto-response' => true)
  369. end
  370. it 'returns a Mail object with an x-zammad-out-of-office header' do
  371. output_mail = Channel::EmailParser.new.process({}, raw_mail).last
  372. expect(output_mail).to include('x-zammad-out-of-office': true)
  373. end
  374. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  375. expect { Channel::EmailParser.new.process({}, raw_mail) }
  376. .to change { ticket.articles.count }.by(1)
  377. end
  378. it 'does not re-open the ticket' do
  379. expect { Channel::EmailParser.new.process({}, raw_mail) }
  380. .not_to change { ticket.reload.state.name }.from('closed')
  381. end
  382. end
  383. end
  384. context 'with no future retries (undeliverable): sample input 1' do
  385. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail033-undelivered-mail-returned-to-sender.box') }
  386. context 'for original message sent by Agent' do
  387. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  388. article = Channel::EmailParser.new.process({}, raw_mail).second
  389. expect(article.preferences)
  390. .to include('send-auto-response' => false, 'is-auto-response' => true)
  391. end
  392. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  393. expect { Channel::EmailParser.new.process({}, raw_mail) }
  394. .to change { ticket.articles.count }.by(1)
  395. end
  396. it 'does not alter the ticket state' do
  397. expect { Channel::EmailParser.new.process({}, raw_mail) }
  398. .not_to change { ticket.reload.state.name }.from('open')
  399. end
  400. end
  401. context 'for original message sent by Customer' do
  402. let(:article) { create(:ticket_article, sender_name: 'Customer', message_id: message_id) }
  403. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  404. article = Channel::EmailParser.new.process({}, raw_mail).second
  405. expect(article.preferences)
  406. .to include('send-auto-response' => false, 'is-auto-response' => true)
  407. end
  408. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  409. expect { Channel::EmailParser.new.process({}, raw_mail) }
  410. .to change { ticket.articles.count }.by(1)
  411. end
  412. it 'does not alter the ticket state' do
  413. expect { Channel::EmailParser.new.process({}, raw_mail) }
  414. .not_to change { ticket.reload.state.name }.from('new')
  415. end
  416. end
  417. end
  418. context 'with no future retries (undeliverable): sample input 2' do
  419. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail055.box') }
  420. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  421. expect { Channel::EmailParser.new.process({}, raw_mail) }
  422. .to change { ticket.articles.count }.by(1)
  423. end
  424. it 'does not alter the ticket state' do
  425. expect { Channel::EmailParser.new.process({}, raw_mail) }
  426. .not_to change { ticket.reload.state.name }.from('open')
  427. end
  428. end
  429. end
  430. context 'for “out-of-office” notifications (a.k.a. auto-response messages)' do
  431. let(:raw_mail) { <<~RAW.chomp }
  432. From: me@example.com
  433. To: customer@example.com
  434. Subject: #{subject_line}
  435. Some Text
  436. RAW
  437. let(:subject_line) { 'Lorem ipsum dolor' }
  438. it 'applies the OutOfOfficeCheck filter to given message' do
  439. expect(Channel::Filter::OutOfOfficeCheck)
  440. .to receive(:run)
  441. .with(kind_of(Hash), hash_including(subject: subject_line))
  442. described_class.new.process({}, raw_mail)
  443. end
  444. context 'on an existing, closed ticket' do
  445. let(:ticket) { create(:ticket, state_name: 'closed') }
  446. let(:subject_line) { ticket.subject_build('Lorem ipsum dolor') }
  447. context 'when OutOfOfficeCheck filter applies x-zammad-out-of-office: false' do
  448. before do
  449. allow(Channel::Filter::OutOfOfficeCheck)
  450. .to receive(:run) { |_, mail_hash| mail_hash[:'x-zammad-out-of-office'] = false }
  451. end
  452. it 're-opens a closed ticket' do
  453. expect { described_class.new.process({}, raw_mail) }
  454. .to not_change { Ticket.count }
  455. .and change { ticket.reload.state.name }.to('open')
  456. end
  457. end
  458. context 'when OutOfOfficeCheck filter applies x-zammad-out-of-office: true' do
  459. before do
  460. allow(Channel::Filter::OutOfOfficeCheck)
  461. .to receive(:run) { |_, mail_hash| mail_hash[:'x-zammad-out-of-office'] = true }
  462. end
  463. it 'does not re-open a closed ticket' do
  464. expect { described_class.new.process({}, raw_mail) }
  465. .to not_change { Ticket.count }
  466. .and not_change { ticket.reload.state.name }
  467. end
  468. end
  469. end
  470. end
  471. end
  472. end