imap_spec.rb 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  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(Net::IMAP.encode_utf7(folder))
  109. imap.select(Net::IMAP.encode_utf7(folder))
  110. end
  111. after do
  112. imap.delete(Net::IMAP.encode_utf7(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. context 'when folder name contains special characters' do
  222. let(:folder) { 'uat-bk-rsc-20250130-!"§$%&()=?ß`\ß_<' }
  223. it 'handles messages correctly' do
  224. imap.append(Net::IMAP.encode_utf7(folder), email1, [], Time.zone.now)
  225. # verify if message is still on server
  226. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  227. expect(message_ids.count).to be(1)
  228. # fetch messages - will import
  229. expect { channel.fetch(true) }.to change(Ticket::Article, :count)
  230. end
  231. end
  232. end
  233. context 'when fetching oversized emails' do
  234. let(:sender_email_address) { ENV['MAIL_ADDRESS'] }
  235. let(:cid) { SecureRandom.uuid.tr('-', '.') }
  236. let(:oversized_email) do
  237. <<~OVERSIZED_EMAIL.gsub(%r{\n}, "\r\n")
  238. Subject: Oversized Email Message
  239. From: Max Mustermann <#{sender_email_address}>
  240. To: shugo@example.com
  241. Message-ID: <#{cid}@zammad.test.com>
  242. Oversized Email Message Body #{'#' * 120_000}
  243. OVERSIZED_EMAIL
  244. end
  245. let(:oversized_email_md5) { Digest::MD5.hexdigest(oversized_email) }
  246. let(:oversized_email_size) { format('%<MB>.2f', MB: oversized_email.size.to_f / 1024 / 1024) }
  247. let(:fetch_oversized_email) do
  248. imap.append(folder, oversized_email, [], Time.zone.now)
  249. channel.fetch(true)
  250. end
  251. context 'with email reply' do
  252. before do
  253. Setting.set('postmaster_max_size', 0.1)
  254. fetch_oversized_email
  255. end
  256. let(:oversized_email_reply) do
  257. imap.select('inbox')
  258. 5.times do |i|
  259. sleep i
  260. msg = imap.sort(['DATE'], ['ALL'], 'US-ASCII').first
  261. if msg
  262. return imap.fetch(msg, 'RFC822')[0].attr['RFC822']
  263. end
  264. end
  265. nil
  266. end
  267. let(:parsed_oversized_email_reply) do
  268. Channel::EmailParser.new.parse(oversized_email_reply)
  269. end
  270. it 'creates email reply correctly' do
  271. # verify that a postmaster response email has been sent to the sender
  272. expect(oversized_email_reply).to be_present
  273. # parse the reply mail and verify the various headers
  274. expect(parsed_oversized_email_reply).to include(
  275. {
  276. from_email: email_address.email,
  277. subject: '[undeliverable] Message too large',
  278. 'references' => "<#{cid}@zammad.test.com>",
  279. 'in-reply-to' => "<#{cid}@zammad.test.com>",
  280. }
  281. )
  282. # verify the reply mail body content
  283. 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)
  284. # check if original mail got removed
  285. imap.select(folder)
  286. expect(imap.sort(['DATE'], ['ALL'], 'US-ASCII')).to be_empty
  287. end
  288. end
  289. context 'without email reply' do
  290. before do
  291. Setting.set('postmaster_max_size', 0.1)
  292. Setting.set('postmaster_send_reject_if_mail_too_large', false)
  293. fetch_oversized_email
  294. end
  295. it 'does not create email reply' do
  296. # verify that no postmaster response email has been sent
  297. imap.select('inbox')
  298. sleep 1
  299. expect(imap.sort(['DATE'], ['ALL'], 'US-ASCII').count).to be_zero
  300. # check that original mail is still there
  301. imap.select(folder)
  302. expect(imap.sort(['DATE'], ['ALL'], 'US-ASCII').count).to be(1)
  303. end
  304. end
  305. end
  306. end
  307. describe '.fetch_message_body_key' do
  308. context 'with icloud mail server' do
  309. let(:host) { 'imap.mail.me.com' }
  310. it 'fetches mails with BODY field' do
  311. expect(described_class.new.fetch_message_body_key({ 'host' => host })).to eq('BODY[]')
  312. end
  313. end
  314. context 'with another mail server' do
  315. let(:host) { 'any.server.com' }
  316. it 'fetches mails with RFC822 field' do
  317. expect(described_class.new.fetch_message_body_key({ 'host' => host })).to eq('RFC822')
  318. end
  319. end
  320. end
  321. describe '#check_configuration' do
  322. include_context 'with channel and server configuration'
  323. before do
  324. imap.create(folder)
  325. imap.select(folder)
  326. end
  327. after do
  328. imap.delete(folder)
  329. end
  330. context 'when no messages exist' do
  331. it 'finds no content messages' do
  332. response = described_class
  333. .new
  334. .check_configuration(inbound_options[:options])
  335. expect(response).to include(
  336. result: 'ok',
  337. content_messages: be_zero,
  338. )
  339. end
  340. end
  341. context 'when a verify message exist' do
  342. it 'finds no content messages' do
  343. imap.append folder, mock_a_message(verify: true)
  344. response = described_class
  345. .new
  346. .check_configuration(inbound_options[:options])
  347. expect(response).to include(
  348. result: 'ok',
  349. content_messages: be_zero,
  350. )
  351. end
  352. end
  353. context 'when some content messages exist' do
  354. it 'finds content messages' do
  355. 3.times { imap.append folder, mock_a_message }
  356. response = described_class
  357. .new
  358. .check_configuration(inbound_options[:options])
  359. expect(response).to include(
  360. result: 'ok',
  361. content_messages: 3,
  362. )
  363. end
  364. end
  365. context 'when a verify and a content message exists' do
  366. it 'finds content messages' do
  367. imap.append folder, mock_a_message(verify: true)
  368. imap.append folder, mock_a_message
  369. response = described_class
  370. .new
  371. .check_configuration(inbound_options[:options])
  372. expect(response).to include(
  373. result: 'ok',
  374. content_messages: 2,
  375. )
  376. end
  377. end
  378. end
  379. describe '#verify_transport' do
  380. include_context 'with channel and server configuration'
  381. before do
  382. imap.create(folder)
  383. imap.select(folder)
  384. end
  385. after do
  386. imap.delete(folder)
  387. end
  388. let(:verify_message) { Faker::Lorem.unique.sentence }
  389. context 'when no messages exist' do
  390. it 'returns falsy response' do
  391. response = described_class
  392. .new
  393. .verify_transport(inbound_options[:options], verify_message)
  394. expect(response).to include(result: 'verify not ok')
  395. end
  396. end
  397. context 'when a content message exists' do
  398. it 'returns falsy response' do
  399. imap.append folder, mock_a_message
  400. response = described_class
  401. .new
  402. .verify_transport(inbound_options[:options], verify_message)
  403. expect(response).to include(result: 'verify not ok')
  404. end
  405. end
  406. context 'when a verify message exists' do
  407. before do
  408. imap.append folder, mock_a_message(verify: verify_message)
  409. end
  410. it 'returns truthy response with the correct verify string' do
  411. response = described_class
  412. .new
  413. .verify_transport(inbound_options[:options], verify_message)
  414. expect(response).to include(result: 'ok')
  415. end
  416. it 'deletes the correct verify message' do
  417. described_class
  418. .new
  419. .verify_transport(inbound_options[:options], verify_message)
  420. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  421. message_meta = imap.fetch(message_ids.first, ['FLAGS'])[0].attr
  422. expect(message_meta['FLAGS']).to include(:Deleted)
  423. end
  424. it 'returns falsy response with the wrong verify string' do
  425. response = described_class
  426. .new
  427. .verify_transport(inbound_options[:options], 'another message')
  428. expect(response).to include(result: 'verify not ok')
  429. end
  430. it 'does not delete not matching verify message' do
  431. described_class
  432. .new
  433. .verify_transport(inbound_options[:options], 'another message')
  434. message_ids = imap.sort(['DATE'], ['ALL'], 'US-ASCII')
  435. message_meta = imap.fetch(message_ids.first, ['FLAGS'])[0].attr
  436. expect(message_meta['FLAGS']).not_to include(:Deleted)
  437. end
  438. end
  439. context 'when a content and a verify message exists' do
  440. it 'returns truthy response' do
  441. imap.append folder, mock_a_message(verify: verify_message)
  442. imap.append folder, mock_a_message
  443. response = described_class
  444. .new
  445. .verify_transport(inbound_options[:options], verify_message)
  446. expect(response).to include(result: 'ok')
  447. end
  448. end
  449. end
  450. def mock_a_message(subject: nil, verify: false)
  451. attrs = {
  452. from: Faker::Internet.unique.email,
  453. to: Faker::Internet.unique.email,
  454. body: Faker::Lorem.sentence,
  455. subject: verify.presence || subject.presence || Faker::Lorem.word,
  456. content_type: 'text/html',
  457. }
  458. if verify.present?
  459. attrs[:'X-Zammad-Ignore'] = 'true'
  460. attrs[:'X-Zammad-Verify'] = 'true'
  461. attrs[:'X-Zammad-Verify-Time'] = Time.current.to_s
  462. end
  463. Channel::EmailBuild.build(**attrs).to_s
  464. end
  465. end