imap_spec.rb 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. # Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
  2. require 'rails_helper'
  3. RSpec.describe Channel::Driver::Imap, integration: true, required_envs: %w[MAIL_SERVER MAIL_ADDRESS MAIL_PASS MAIL_ADDRESS_ASCII MAIL_PASS_ASCII] do
  4. # https://github.com/zammad/zammad/issues/2964
  5. context 'when connecting with a ASCII 8-Bit password' do
  6. it 'succeeds' do
  7. params = {
  8. host: ENV['MAIL_SERVER'],
  9. user: ENV['MAIL_ADDRESS_ASCII'],
  10. password: ENV['MAIL_PASS_ASCII'],
  11. ssl_verify: false,
  12. }
  13. result = described_class.new.check_configuration(params)
  14. expect(result[:result]).to eq 'ok'
  15. end
  16. end
  17. describe '.parse_rfc822_headers' do
  18. it 'parses simple header' do
  19. expect(described_class.parse_rfc822_headers('Key: Value')).to have_key('Key').and(have_value('Value'))
  20. end
  21. it 'parses header with no white space' do
  22. expect(described_class.parse_rfc822_headers('Key:Value')).to have_key('Key').and(have_value('Value'))
  23. end
  24. it 'parses multiline header' do
  25. expect(described_class.parse_rfc822_headers("Key: Value\r\n2nd-key: 2nd-value"))
  26. .to have_key('Key').and(have_value('Value')).and(have_key('2nd-key')).and(have_value('2nd-value'))
  27. end
  28. it 'parses value with semicolons' do
  29. expect(described_class.parse_rfc822_headers('Key: Val:ue')).to have_key('Key').and(have_value('Val:ue'))
  30. end
  31. it 'parses key-only lines' do
  32. expect(described_class.parse_rfc822_headers('Key')).to have_key('Key')
  33. end
  34. it 'handles empty line' do
  35. expect { described_class.parse_rfc822_headers("Key: Value\r\n") }.not_to raise_error
  36. end
  37. it 'handles tabbed value' do
  38. expect(described_class.parse_rfc822_headers("Key: \r\n\tValue")).to have_key('Key').and(have_value('Value'))
  39. end
  40. end
  41. describe '.extract_rfc822_headers' do
  42. it 'extracts header' do
  43. object = Net::IMAP::FetchData.new :id, { 'RFC822.HEADER' => 'Key: Value' }
  44. expect(described_class.extract_rfc822_headers(object)).to have_key('Key').and(have_value('Value'))
  45. end
  46. it 'returns nil when header attribute is missing' do
  47. object = Net::IMAP::FetchData.new :id, { 'Another' => 'Key: Value' }
  48. expect(described_class.extract_rfc822_headers(object)).to be_nil
  49. end
  50. it 'does not raise error when given nil' do
  51. expect { described_class.extract_rfc822_headers(nil) }.not_to raise_error
  52. end
  53. end
  54. shared_context 'with channel and server configuration' do
  55. let(:folder) { "imap_spec-#{SecureRandom.uuid}" }
  56. let(:server_address) { ENV['MAIL_SERVER'] }
  57. let(:server_login) { ENV['MAIL_ADDRESS'] }
  58. let(:server_password) { ENV['MAIL_PASS'] }
  59. let(:email_address) { create(:email_address, name: 'Zammad Helpdesk', email: "some-zammad-#{ENV['MAIL_ADDRESS']}") }
  60. let(:group) { create(:group, email_address: email_address) }
  61. let(:inbound_options) do
  62. {
  63. adapter: 'imap',
  64. options: {
  65. host: ENV['MAIL_SERVER'],
  66. user: ENV['MAIL_ADDRESS'],
  67. password: server_password,
  68. ssl: true,
  69. ssl_verify: false,
  70. folder: folder,
  71. keep_on_server: false,
  72. }
  73. }
  74. end
  75. let(:outbound_options) do
  76. {
  77. adapter: 'smtp',
  78. options: {
  79. host: server_address,
  80. port: 25,
  81. start_tls: true,
  82. ssl_verify: false,
  83. user: server_login,
  84. password: server_password,
  85. email: email_address.email
  86. },
  87. }
  88. end
  89. let(:channel) do
  90. create(:email_channel, group: group, inbound: inbound_options, outbound: outbound_options).tap do |channel|
  91. email_address.channel = channel
  92. email_address.save!
  93. end
  94. end
  95. let(:imap) { Net::IMAP.new(server_address, port: 993, ssl: { verify_mode: OpenSSL::SSL::VERIFY_NONE }).tap { |imap| imap.login(server_login, server_password) } }
  96. end
  97. describe '#fetch', :aggregate_failures do
  98. include_context 'with channel and server configuration'
  99. let(:purge_inbox) do
  100. imap.select('inbox')
  101. imap.sort(['DATE'], ['ALL'], 'US-ASCII').each do |msg|
  102. imap.store(msg, '+FLAGS', [:Deleted])
  103. end
  104. imap.expunge
  105. end
  106. before do
  107. purge_inbox
  108. imap.create(folder)
  109. imap.select(folder)
  110. end
  111. after do
  112. imap.delete(folder)
  113. end
  114. context 'when fetching regular emails' do
  115. let(:email1) do
  116. <<~EMAIL.gsub(%r{\n}, "\r\n")
  117. Subject: hello1
  118. From: shugo@example.com
  119. To: shugo@example.com
  120. Message-ID: <some1@example_keep_on_server>
  121. hello world
  122. EMAIL
  123. end
  124. let(:email2) do
  125. <<~EMAIL.gsub(%r{\n}, "\r\n")
  126. Subject: hello2
  127. From: shugo@example.com
  128. To: shugo@example.com
  129. Message-ID: <some2@example_keep_on_server>
  130. hello world
  131. EMAIL
  132. end
  133. context 'with keep_on_server flag' do
  134. let(:inbound_options) do
  135. {
  136. adapter: 'imap',
  137. options: {
  138. host: ENV['MAIL_SERVER'],
  139. user: ENV['MAIL_ADDRESS'],
  140. password: server_password,
  141. ssl: true,
  142. ssl_verify: false,
  143. folder: folder,
  144. keep_on_server: true,
  145. }
  146. }
  147. end
  148. it 'handles messages correctly' do # rubocop:disable RSpec/ExampleLength
  149. imap.append(folder, email1, [], Time.zone.now)
  150. # verify if message is still on server
  151. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  152. expect(message_ids.count).to be(1)
  153. message_meta = imap.fetch(1, ['FLAGS'])[0].attr
  154. expect(message_meta['FLAGS']).not_to include(:Seen)
  155. # fetch messages - will import
  156. expect { channel.fetch(true) }.to change(Ticket::Article, :count)
  157. # verify if message is still on server
  158. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  159. expect(message_ids.count).to be(1)
  160. # message now has :seen flag
  161. message_meta = imap.fetch(1, ['RFC822.HEADER', 'FLAGS'])[0].attr
  162. expect(message_meta['FLAGS']).to include(:Seen)
  163. # fetch messages - will not import
  164. expect { channel.fetch(true) }.not_to change(Ticket::Article, :count)
  165. # verify if message is still on server
  166. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  167. expect(message_ids.count).to be(1)
  168. # put unseen message in it
  169. imap.append(folder, email2, [], Time.zone.now)
  170. message_meta = imap.fetch(1, ['FLAGS'])[0].attr
  171. expect(message_meta['FLAGS']).to include(:Seen)
  172. message_meta = imap.fetch(2, ['FLAGS'])[0].attr
  173. expect(message_meta['FLAGS']).not_to include(:Seen)
  174. # fetch messages - will import new
  175. expect { channel.fetch(true) }.to change(Ticket::Article, :count)
  176. # verify if message is still on server
  177. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  178. expect(message_ids.count).to be(2)
  179. message_meta = imap.fetch(1, ['FLAGS'])[0].attr
  180. expect(message_meta['FLAGS']).to include(:Seen)
  181. message_meta = imap.fetch(2, ['FLAGS'])[0].attr
  182. expect(message_meta['FLAGS']).to include(:Seen)
  183. # set messages to not seen
  184. imap.store(1, '-FLAGS', [:Seen])
  185. imap.store(2, '-FLAGS', [:Seen])
  186. # fetch messages - will still not import
  187. expect { channel.fetch(true) }.not_to change(Ticket::Article, :count)
  188. end
  189. end
  190. context 'without keep_on_server flag' do
  191. it 'handles messages correctly' do
  192. imap.append(folder, email1, [], Time.zone.now)
  193. # verify if message is still on server
  194. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  195. expect(message_ids.count).to be(1)
  196. message_meta = imap.fetch(1, ['FLAGS'])[0].attr
  197. expect(message_meta['FLAGS']).not_to include(:Seen)
  198. # fetch messages - will import
  199. expect { channel.fetch(true) }.to change(Ticket::Article, :count)
  200. # verify if message is still on server
  201. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  202. expect(message_ids.count).to be(1)
  203. message_meta = imap.fetch(1, ['FLAGS'])[0].attr
  204. expect(message_meta['FLAGS']).to include(:Seen, :Deleted)
  205. # put unseen message in it
  206. imap.append(folder, email2, [], Time.zone.now)
  207. # verify if message is still on server
  208. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  209. expect(message_ids.count).to be(1)
  210. message_meta = imap.fetch(1, ['FLAGS'])[0].attr
  211. expect(message_meta['FLAGS']).not_to include(:Seen)
  212. # fetch messages - will import
  213. expect { channel.fetch(true) }.to change(Ticket::Article, :count)
  214. # verify if message is still on server
  215. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  216. expect(message_ids.count).to be(1)
  217. message_meta = imap.fetch(1, ['FLAGS'])[0].attr
  218. expect(message_meta['FLAGS']).to include(:Seen)
  219. end
  220. end
  221. end
  222. context 'when fetching oversized emails' do
  223. let(:sender_email_address) { ENV['MAIL_ADDRESS'] }
  224. let(:cid) { SecureRandom.uuid.tr('-', '.') }
  225. let(:oversized_email) do
  226. <<~OVERSIZED_EMAIL.gsub(%r{\n}, "\r\n")
  227. Subject: Oversized Email Message
  228. From: Max Mustermann <#{sender_email_address}>
  229. To: shugo@example.com
  230. Message-ID: <#{cid}@zammad.test.com>
  231. Oversized Email Message Body #{'#' * 120_000}
  232. OVERSIZED_EMAIL
  233. end
  234. let(:oversized_email_md5) { Digest::MD5.hexdigest(oversized_email) }
  235. let(:oversized_email_size) { format('%<MB>.2f', MB: oversized_email.size.to_f / 1024 / 1024) }
  236. let(:fetch_oversized_email) do
  237. imap.append(folder, oversized_email, [], Time.zone.now)
  238. channel.fetch(true)
  239. end
  240. context 'with email reply' do
  241. before do
  242. Setting.set('postmaster_max_size', 0.1)
  243. fetch_oversized_email
  244. end
  245. let(:oversized_email_reply) do
  246. imap.select('inbox')
  247. 5.times do |i|
  248. sleep i
  249. msg = imap.sort(['DATE'], ['ALL'], 'US-ASCII').first
  250. if msg
  251. return imap.fetch(msg, 'RFC822')[0].attr['RFC822']
  252. end
  253. end
  254. nil
  255. end
  256. let(:parsed_oversized_email_reply) do
  257. Channel::EmailParser.new.parse(oversized_email_reply)
  258. end
  259. it 'creates email reply correctly' do
  260. # verify that a postmaster response email has been sent to the sender
  261. expect(oversized_email_reply).to be_present
  262. # parse the reply mail and verify the various headers
  263. expect(parsed_oversized_email_reply).to include(
  264. {
  265. from_email: email_address.email,
  266. subject: '[undeliverable] Message too large',
  267. 'references' => "<#{cid}@zammad.test.com>",
  268. 'in-reply-to' => "<#{cid}@zammad.test.com>",
  269. }
  270. )
  271. # verify the reply mail body content
  272. expect(parsed_oversized_email_reply[:body]).to match(%r{^Dear Max Mustermann.*Oversized Email Message.*#{oversized_email_size} MB.*0.1 MB.*#{Setting.get('fqdn')}}sm)
  273. # check if original mail got removed
  274. imap.select(folder)
  275. expect(imap.sort(['DATE'], ['ALL'], 'US-ASCII')).to be_empty
  276. end
  277. end
  278. context 'without email reply' do
  279. before do
  280. Setting.set('postmaster_max_size', 0.1)
  281. Setting.set('postmaster_send_reject_if_mail_too_large', false)
  282. fetch_oversized_email
  283. end
  284. it 'does not create email reply' do
  285. # verify that no postmaster response email has been sent
  286. imap.select('inbox')
  287. sleep 1
  288. expect(imap.sort(['DATE'], ['ALL'], 'US-ASCII').count).to be_zero
  289. # check that original mail is still there
  290. imap.select(folder)
  291. expect(imap.sort(['DATE'], ['ALL'], 'US-ASCII').count).to be(1)
  292. end
  293. end
  294. end
  295. end
  296. describe '.fetch_message_body_key' do
  297. context 'with icloud mail server' do
  298. let(:host) { 'imap.mail.me.com' }
  299. it 'fetches mails with BODY field' do
  300. expect(described_class.new.fetch_message_body_key({ 'host' => host })).to eq('BODY[]')
  301. end
  302. end
  303. context 'with another mail server' do
  304. let(:host) { 'any.server.com' }
  305. it 'fetches mails with RFC822 field' do
  306. expect(described_class.new.fetch_message_body_key({ 'host' => host })).to eq('RFC822')
  307. end
  308. end
  309. end
  310. describe '#check_configuration' do
  311. include_context 'with channel and server configuration'
  312. before do
  313. imap.create(folder)
  314. imap.select(folder)
  315. end
  316. after do
  317. imap.delete(folder)
  318. end
  319. context 'when no messages exist' do
  320. it 'finds no content messages' do
  321. response = described_class
  322. .new
  323. .check_configuration(inbound_options[:options])
  324. expect(response).to include(
  325. result: 'ok',
  326. content_messages: be_zero,
  327. )
  328. end
  329. end
  330. context 'when a verify message exist' do
  331. it 'finds no content messages' do
  332. imap.append folder, mock_a_message(verify: true)
  333. response = described_class
  334. .new
  335. .check_configuration(inbound_options[:options])
  336. expect(response).to include(
  337. result: 'ok',
  338. content_messages: be_zero,
  339. )
  340. end
  341. end
  342. context 'when some content messages exist' do
  343. it 'finds content messages' do
  344. 3.times { imap.append folder, mock_a_message }
  345. response = described_class
  346. .new
  347. .check_configuration(inbound_options[:options])
  348. expect(response).to include(
  349. result: 'ok',
  350. content_messages: 3,
  351. )
  352. end
  353. end
  354. context 'when a verify and a content message exists' do
  355. it 'finds content messages' do
  356. imap.append folder, mock_a_message(verify: true)
  357. imap.append folder, mock_a_message
  358. response = described_class
  359. .new
  360. .check_configuration(inbound_options[:options])
  361. expect(response).to include(
  362. result: 'ok',
  363. content_messages: 2,
  364. )
  365. end
  366. end
  367. end
  368. describe '#verify_transport' do
  369. include_context 'with channel and server configuration'
  370. before do
  371. imap.create(folder)
  372. imap.select(folder)
  373. end
  374. after do
  375. imap.delete(folder)
  376. end
  377. let(:verify_message) { Faker::Lorem.unique.sentence }
  378. context 'when no messages exist' do
  379. it 'returns falsy response' do
  380. response = described_class
  381. .new
  382. .verify_transport(inbound_options[:options], verify_message)
  383. expect(response).to include(result: 'verify not ok')
  384. end
  385. end
  386. context 'when a content message exists' do
  387. it 'returns falsy response' do
  388. imap.append folder, mock_a_message
  389. response = described_class
  390. .new
  391. .verify_transport(inbound_options[:options], verify_message)
  392. expect(response).to include(result: 'verify not ok')
  393. end
  394. end
  395. context 'when a verify message exists' do
  396. before do
  397. imap.append folder, mock_a_message(verify: verify_message)
  398. end
  399. it 'returns truthy response with the correct verify string' do
  400. response = described_class
  401. .new
  402. .verify_transport(inbound_options[:options], verify_message)
  403. expect(response).to include(result: 'ok')
  404. end
  405. it 'deletes the correct verify message' do
  406. described_class
  407. .new
  408. .verify_transport(inbound_options[:options], verify_message)
  409. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  410. message_meta = imap.fetch(message_ids.first, ['FLAGS'])[0].attr
  411. expect(message_meta['FLAGS']).to include(:Deleted)
  412. end
  413. it 'returns falsy response with the wrong verify string' do
  414. response = described_class
  415. .new
  416. .verify_transport(inbound_options[:options], 'another message')
  417. expect(response).to include(result: 'verify not ok')
  418. end
  419. it 'does not delete not matching verify message' do
  420. described_class
  421. .new
  422. .verify_transport(inbound_options[:options], 'another message')
  423. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  424. message_meta = imap.fetch(message_ids.first, ['FLAGS'])[0].attr
  425. expect(message_meta['FLAGS']).not_to include(:Deleted)
  426. end
  427. end
  428. context 'when a content and a verify message exists' do
  429. it 'returns truthy response' do
  430. imap.append folder, mock_a_message(verify: verify_message)
  431. imap.append folder, mock_a_message
  432. response = described_class
  433. .new
  434. .verify_transport(inbound_options[:options], verify_message)
  435. expect(response).to include(result: 'ok')
  436. end
  437. end
  438. end
  439. def mock_a_message(subject: nil, verify: false)
  440. attrs = {
  441. from: Faker::Internet.unique.email,
  442. to: Faker::Internet.unique.email,
  443. body: Faker::Lorem.sentence,
  444. subject: verify.presence || subject.presence || Faker::Lorem.word,
  445. content_type: 'text/html',
  446. }
  447. if verify.present?
  448. attrs[:'X-Zammad-Ignore'] = 'true'
  449. attrs[:'X-Zammad-Verify'] = 'true'
  450. attrs[:'X-Zammad-Verify-Time'] = Time.current.to_s
  451. end
  452. Channel::EmailBuild.build(**attrs).to_s
  453. end
  454. end