email_parser_spec.rb 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  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. context 'for “delivery failed” notifications (a.k.a. bounce messages)' do
  325. let(:ticket) { article.ticket }
  326. let(:article) { create(:ticket_article, sender_name: 'Agent', message_id: message_id) }
  327. let(:message_id) { raw_mail[/(?<=^(References|Message-ID): )\S*/] }
  328. context 'with future retries (delayed)' do
  329. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail078.box') }
  330. context 'on a closed ticket' do
  331. before { ticket.update(state: Ticket::State.find_by(name: 'closed')) }
  332. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  333. article = Channel::EmailParser.new.process({}, raw_mail).second
  334. expect(article.preferences)
  335. .to include('send-auto-response' => false, 'is-auto-response' => true)
  336. end
  337. it 'returns a Mail object with an x-zammad-out-of-office header' do
  338. output_mail = Channel::EmailParser.new.process({}, raw_mail).last
  339. expect(output_mail).to include('x-zammad-out-of-office': true)
  340. end
  341. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  342. expect { Channel::EmailParser.new.process({}, raw_mail) }
  343. .to change { ticket.articles.count }.by(1)
  344. end
  345. it 'does not re-open the ticket' do
  346. expect { Channel::EmailParser.new.process({}, raw_mail) }
  347. .not_to change { ticket.reload.state.name }.from('closed')
  348. end
  349. end
  350. end
  351. context 'with no future retries (undeliverable): sample input 1' do
  352. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail033-undelivered-mail-returned-to-sender.box') }
  353. context 'for original message sent by Agent' do
  354. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  355. article = Channel::EmailParser.new.process({}, raw_mail).second
  356. expect(article.preferences)
  357. .to include('send-auto-response' => false, 'is-auto-response' => true)
  358. end
  359. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  360. expect { Channel::EmailParser.new.process({}, raw_mail) }
  361. .to change { ticket.articles.count }.by(1)
  362. end
  363. it 'does not alter the ticket state' do
  364. expect { Channel::EmailParser.new.process({}, raw_mail) }
  365. .not_to change { ticket.reload.state.name }.from('open')
  366. end
  367. end
  368. context 'for original message sent by Customer' do
  369. let(:article) { create(:ticket_article, sender_name: 'Customer', message_id: message_id) }
  370. it 'sets #preferences on resulting ticket to { "send-auto-responses" => false, "is-auto-reponse" => true }' do
  371. article = Channel::EmailParser.new.process({}, raw_mail).second
  372. expect(article.preferences)
  373. .to include('send-auto-response' => false, 'is-auto-response' => true)
  374. end
  375. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  376. expect { Channel::EmailParser.new.process({}, raw_mail) }
  377. .to change { ticket.articles.count }.by(1)
  378. end
  379. it 'does not alter the ticket state' do
  380. expect { Channel::EmailParser.new.process({}, raw_mail) }
  381. .not_to change { ticket.reload.state.name }.from('new')
  382. end
  383. end
  384. end
  385. context 'with no future retries (undeliverable): sample input 2' do
  386. let(:mail_file) { Rails.root.join('test', 'data', 'mail', 'mail055.box') }
  387. it 'finds the article referenced in the bounce message headers, then adds the bounce message to its ticket' do
  388. expect { Channel::EmailParser.new.process({}, raw_mail) }
  389. .to change { ticket.articles.count }.by(1)
  390. end
  391. it 'does not alter the ticket state' do
  392. expect { Channel::EmailParser.new.process({}, raw_mail) }
  393. .not_to change { ticket.reload.state.name }.from('open')
  394. end
  395. end
  396. end
  397. end
  398. end