email_parser_spec.rb 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  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. end
  102. context 'when from address matches an existing customer' do
  103. let!(:customer) { create(:customer_user, email: 'foo@bar.com') }
  104. it 'sets article.sender to "Customer"' do
  105. Channel::EmailParser.new.process({}, raw_mail)
  106. expect(Ticket.last.articles.first.sender.name).to eq('Customer')
  107. end
  108. end
  109. context 'when from address is unrecognized' do
  110. it 'sets article.sender to "Customer"' do
  111. Channel::EmailParser.new.process({}, raw_mail)
  112. expect(Ticket.last.articles.first.sender.name).to eq('Customer')
  113. end
  114. end
  115. end
  116. end
  117. describe 'associating emails to existing tickets' do
  118. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail001.box') }
  119. let(:ticket_ref) { Setting.get('ticket_hook') + Setting.get('ticket_hook_divider') + ticket.number }
  120. let(:ticket) { create(:ticket) }
  121. context 'when email subject contains ticket reference' do
  122. let(:raw_mail) { File.read(mail_file).sub(/(?<=^Subject: ).*$/, ticket_ref) }
  123. it 'adds message to ticket' do
  124. expect { described_class.new.process({}, raw_mail) }
  125. .to change { ticket.articles.length }
  126. end
  127. context 'and ticket is closed' do
  128. before { ticket.update(state: Ticket::State.find_by(name: 'closed')) }
  129. it 'adds message to ticket' do
  130. expect { described_class.new.process({}, raw_mail) }
  131. .to change { ticket.articles.length }
  132. end
  133. end
  134. context 'but ticket group’s #follow_up_possible attribute is "new_ticket"' do
  135. before { ticket.group.update(follow_up_possible: 'new_ticket') }
  136. context 'and ticket is open' do
  137. it 'still 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 'and ticket is closed' do
  143. before { ticket.update(state: Ticket::State.find_by(name: 'closed')) }
  144. it 'creates a new ticket' do
  145. expect { described_class.new.process({}, raw_mail) }
  146. .to change { Ticket.count }.by(1)
  147. .and not_change { ticket.articles.length }
  148. end
  149. end
  150. context 'and ticket is merged' do
  151. before { ticket.update(state: Ticket::State.find_by(name: 'merged')) }
  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 removed' do
  159. before { ticket.update(state: Ticket::State.find_by(name: 'removed')) }
  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. end
  167. end
  168. context 'when configured to search body' do
  169. before { Setting.set('postmaster_follow_up_search_in', 'body') }
  170. context 'when body contains ticket reference' do
  171. context 'in visible text' do
  172. let(:raw_mail) { File.read(mail_file).sub(/Hallo =\nMartin,(?=<o:p>)/, ticket_ref) }
  173. it 'adds message to ticket' do
  174. expect { described_class.new.process({}, raw_mail) }
  175. .to change { ticket.articles.length }
  176. end
  177. end
  178. context 'as part of a larger word' do
  179. let(:raw_mail) { File.read(mail_file).sub(/(?<=Hallo) =\n(?=Martin,<o:p>)/, ticket_ref) }
  180. it 'creates a separate ticket' do
  181. expect { described_class.new.process({}, raw_mail) }
  182. .not_to change { ticket.articles.length }
  183. end
  184. end
  185. context 'in html attributes' do
  186. let(:raw_mail) { File.read(mail_file).sub(%r{<a href.*?/a>}m, %(<table bgcolor="#{ticket_ref}"> </table>)) }
  187. it 'creates a separate ticket' do
  188. expect { described_class.new.process({}, raw_mail) }
  189. .not_to change { ticket.articles.length }
  190. end
  191. end
  192. end
  193. end
  194. end
  195. describe 'assigning ticket.customer' do
  196. let(:agent) { create(:agent_user) }
  197. let(:customer) { create(:customer_user) }
  198. let(:raw_mail) { <<~RAW.chomp }
  199. From: #{agent.email}
  200. To: #{customer.email}
  201. Subject: Foo
  202. Lorem ipsum dolor
  203. RAW
  204. context 'when "postmaster_sender_is_agent_search_for_customer" setting is true (default)' do
  205. it 'sets ticket.customer to user with To: email' do
  206. expect { Channel::EmailParser.new.process({}, raw_mail) }
  207. .to change { Ticket.count }.by(1)
  208. expect(Ticket.last.customer).to eq(customer)
  209. end
  210. end
  211. context 'when "postmaster_sender_is_agent_search_for_customer" setting is false' do
  212. before { Setting.set('postmaster_sender_is_agent_search_for_customer', false) }
  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(agent)
  217. end
  218. end
  219. end
  220. describe 'formatting to/from addresses' do
  221. # see https://github.com/zammad/zammad/issues/2198
  222. context 'when sender address contains spaces (#2198)' do
  223. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail071.box') }
  224. let(:sender_email) { 'powerquadrantsystem@example.com' }
  225. it 'removes them before creating a new user' do
  226. expect { described_class.new.process({}, raw_mail) }
  227. .to change { User.exists?(email: sender_email) }
  228. end
  229. it 'marks new user email as invalid' do
  230. described_class.new.process({}, raw_mail)
  231. expect(User.find_by(email: sender_email).preferences)
  232. .to include('mail_delivery_failed' => true)
  233. .and include('mail_delivery_failed_reason' => 'invalid email')
  234. .and include('mail_delivery_failed_data' => a_kind_of(ActiveSupport::TimeWithZone))
  235. end
  236. end
  237. # see https://github.com/zammad/zammad/issues/2254
  238. context 'when sender address contains > (#2254)' do
  239. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail076.box') }
  240. let(:sender_email) { 'millionslotteryspaintransfer@example.com' }
  241. it 'removes them before creating a new user' do
  242. expect { described_class.new.process({}, raw_mail) }
  243. .to change { User.exists?(email: sender_email) }
  244. end
  245. it 'marks new user email as invalid' do
  246. described_class.new.process({}, raw_mail)
  247. expect(User.find_by(email: sender_email).preferences)
  248. .to include('mail_delivery_failed' => true)
  249. .and include('mail_delivery_failed_reason' => 'invalid email')
  250. .and include('mail_delivery_failed_data' => a_kind_of(ActiveSupport::TimeWithZone))
  251. end
  252. end
  253. end
  254. describe 'signature detection' do
  255. let(:raw_mail) { header + File.read(message_file) }
  256. let(:header) { <<~HEADER }
  257. From: Bob.Smith@music.com
  258. To: test@zammad.org
  259. Subject: test
  260. HEADER
  261. context 'for emails from an unrecognized email address' do
  262. let(:message_file) { Rails.root.join('test', 'data', 'email_signature_detection', 'client_a_1.txt') }
  263. it 'does not detect signatures' do
  264. described_class.new.process({}, raw_mail)
  265. expect { Scheduler.worker(true) }
  266. .to not_change { Ticket.last.customer.preferences[:signature_detection] }.from(nil)
  267. .and not_change { Ticket.last.articles.first.preferences[:signature_detection] }.from(nil)
  268. end
  269. end
  270. context 'for emails from a previously processed sender' do
  271. before do
  272. described_class.new.process({}, header + File.read(previous_message_file))
  273. end
  274. let(:previous_message_file) { Rails.root.join('test', 'data', 'email_signature_detection', 'client_a_1.txt') }
  275. let(:message_file) { Rails.root.join('test', 'data', 'email_signature_detection', 'client_a_2.txt') }
  276. it 'sets detected signature on user (in a background job)' do
  277. described_class.new.process({}, raw_mail)
  278. expect { Scheduler.worker(true) }
  279. .to change { Ticket.last.customer.preferences[:signature_detection] }
  280. end
  281. it 'sets line of detected signature on article (in a background job)' do
  282. described_class.new.process({}, raw_mail)
  283. expect { Scheduler.worker(true) }
  284. .to change { Ticket.last.articles.first.preferences[:signature_detection] }.to(20)
  285. end
  286. end
  287. end
  288. describe 'charset handling' do
  289. # see https://github.com/zammad/zammad/issues/2224
  290. context 'when header specifies Windows-1258 charset (#2224)' do
  291. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail072.box') }
  292. it 'does not raise Encoding::ConverterNotFoundError' do
  293. expect { described_class.new.process({}, raw_mail) }
  294. .not_to raise_error
  295. end
  296. end
  297. end
  298. describe 'attachment handling' do
  299. context 'with header "Content-Transfer-Encoding: x-uuencode"' do
  300. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail078-content_transfer_encoding_x_uuencode.box') }
  301. let(:article) { described_class.new.process({}, raw_mail).second }
  302. it 'does not raise RuntimeError' do
  303. expect { described_class.new.process({}, raw_mail) }
  304. .not_to raise_error
  305. end
  306. it 'parses the content correctly' do
  307. expect(article.attachments.first.filename).to eq('PGP_Cmts_on_12-14-01_Pkg.txt')
  308. expect(article.attachments.first.content).to eq('Hello Zammad')
  309. end
  310. end
  311. end
  312. describe 'inline image handling' do
  313. # see https://github.com/zammad/zammad/issues/2486
  314. context 'when image is large but not resizable' do
  315. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail079.box') }
  316. let(:attachment) { article.attachments.to_a.find { |i| i.filename == 'a.jpg' } }
  317. let(:article) { described_class.new.process({}, raw_mail).second }
  318. it "doesn't set resizable preference" do
  319. expect(attachment.filename).to eq('a.jpg')
  320. expect(attachment.preferences).not_to include('resizable' => true)
  321. end
  322. end
  323. end
  324. describe 'XSS protection' do
  325. let(:article) { described_class.new.process({}, raw_mail).second }
  326. let(:raw_mail) { <<~RAW.chomp }
  327. From: ME Bob <me@example.com>
  328. To: customer@example.com
  329. Subject: some subject
  330. Content-Type: #{content_type}
  331. MIME-Version: 1.0
  332. no HTML <script type="text/javascript">alert(\'XSS\')</script>
  333. RAW
  334. context 'for Content-Type: text/html' do
  335. let(:content_type) { 'text/html' }
  336. it 'removes injected <script> tags from body' do
  337. expect(article.body).to eq("no HTML alert('XSS')")
  338. end
  339. end
  340. context 'for Content-Type: text/plain' do
  341. let(:content_type) { 'text/plain' }
  342. it 'leaves body as-is' do
  343. expect(article.body).to eq(<<~SANITIZED.chomp)
  344. no HTML <script type="text/javascript">alert(\'XSS\')</script>
  345. SANITIZED
  346. end
  347. end
  348. end
  349. context 'for “delivery failed” notifications (a.k.a. bounce messages)' do
  350. let(:ticket) { article.ticket }
  351. let(:article) { create(:ticket_article, sender_name: 'Agent', message_id: message_id) }
  352. let(:message_id) { raw_mail[/(?<=^(References|Message-ID): )\S*/] }
  353. context 'with future retries (delayed)' do
  354. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail078.box') }
  355. context 'on a closed ticket' do
  356. before { ticket.update(state: Ticket::State.find_by(name: 'closed')) }
  357. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  358. article = Channel::EmailParser.new.process({}, raw_mail).second
  359. expect(article.preferences)
  360. .to include('send-auto-response' => false, 'is-auto-response' => true)
  361. end
  362. it 'returns a Mail object with an x-zammad-out-of-office header' do
  363. output_mail = Channel::EmailParser.new.process({}, raw_mail).last
  364. expect(output_mail).to include('x-zammad-out-of-office': true)
  365. end
  366. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  367. expect { Channel::EmailParser.new.process({}, raw_mail) }
  368. .to change { ticket.articles.count }.by(1)
  369. end
  370. it 'does not re-open the ticket' do
  371. expect { Channel::EmailParser.new.process({}, raw_mail) }
  372. .not_to change { ticket.reload.state.name }.from('closed')
  373. end
  374. end
  375. end
  376. context 'with no future retries (undeliverable): sample input 1' do
  377. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail033-undelivered-mail-returned-to-sender.box') }
  378. context 'for original message sent by Agent' do
  379. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  380. article = Channel::EmailParser.new.process({}, raw_mail).second
  381. expect(article.preferences)
  382. .to include('send-auto-response' => false, 'is-auto-response' => true)
  383. end
  384. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  385. expect { Channel::EmailParser.new.process({}, raw_mail) }
  386. .to change { ticket.articles.count }.by(1)
  387. end
  388. it 'does not alter the ticket state' do
  389. expect { Channel::EmailParser.new.process({}, raw_mail) }
  390. .not_to change { ticket.reload.state.name }.from('open')
  391. end
  392. end
  393. context 'for original message sent by Customer' do
  394. let(:article) { create(:ticket_article, sender_name: 'Customer', message_id: message_id) }
  395. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  396. article = Channel::EmailParser.new.process({}, raw_mail).second
  397. expect(article.preferences)
  398. .to include('send-auto-response' => false, 'is-auto-response' => true)
  399. end
  400. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  401. expect { Channel::EmailParser.new.process({}, raw_mail) }
  402. .to change { ticket.articles.count }.by(1)
  403. end
  404. it 'does not alter the ticket state' do
  405. expect { Channel::EmailParser.new.process({}, raw_mail) }
  406. .not_to change { ticket.reload.state.name }.from('new')
  407. end
  408. end
  409. end
  410. context 'with no future retries (undeliverable): sample input 2' do
  411. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail055.box') }
  412. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  413. expect { Channel::EmailParser.new.process({}, raw_mail) }
  414. .to change { ticket.articles.count }.by(1)
  415. end
  416. it 'does not alter the ticket state' do
  417. expect { Channel::EmailParser.new.process({}, raw_mail) }
  418. .not_to change { ticket.reload.state.name }.from('open')
  419. end
  420. end
  421. end
  422. end
  423. end