email_parser.rb 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
  2. # encoding: utf-8
  3. class Channel::EmailParser
  4. PROZESS_TIME_MAX = 180
  5. EMAIL_REGEX = /.+@.+/.freeze
  6. RECIPIENT_FIELDS = %w[to cc delivered-to x-original-to envelope-to].freeze
  7. SENDER_FIELDS = %w[from reply-to return-path sender].freeze
  8. EXCESSIVE_LINKS_MSG = 'This message cannot be displayed because it contains over 5,000 links. Download the raw message below and open it via an Email client if you still wish to view it.'.freeze
  9. =begin
  10. parser = Channel::EmailParser.new
  11. mail = parser.parse(msg_as_string)
  12. mail = {
  13. from: 'Some Name <some@example.com>',
  14. from_email: 'some@example.com',
  15. from_local: 'some',
  16. from_domain: 'example.com',
  17. from_display_name: 'Some Name',
  18. message_id: 'some_message_id@example.com',
  19. to: 'Some System <system@example.com>',
  20. cc: 'Somebody <somebody@example.com>',
  21. subject: 'some message subject',
  22. body: 'some message body',
  23. content_type: 'text/html', # text/plain
  24. date: Time.zone.now,
  25. attachments: [
  26. {
  27. data: 'binary of attachment',
  28. filename: 'file_name_of_attachment.txt',
  29. preferences: {
  30. 'content-alternative' => true,
  31. 'Mime-Type' => 'text/plain',
  32. 'Charset: => 'iso-8859-1',
  33. },
  34. },
  35. ],
  36. # ignore email header
  37. x-zammad-ignore: 'false',
  38. # customer headers
  39. x-zammad-customer-login: '',
  40. x-zammad-customer-email: '',
  41. x-zammad-customer-firstname: '',
  42. x-zammad-customer-lastname: '',
  43. # ticket headers (for new tickets)
  44. x-zammad-ticket-group: 'some_group',
  45. x-zammad-ticket-state: 'some_state',
  46. x-zammad-ticket-priority: 'some_priority',
  47. x-zammad-ticket-owner: 'some_owner_login',
  48. # ticket headers (for existing tickets)
  49. x-zammad-ticket-followup-group: 'some_group',
  50. x-zammad-ticket-followup-state: 'some_state',
  51. x-zammad-ticket-followup-priority: 'some_priority',
  52. x-zammad-ticket-followup-owner: 'some_owner_login',
  53. # article headers
  54. x-zammad-article-internal: false,
  55. x-zammad-article-type: 'agent',
  56. x-zammad-article-sender: 'customer',
  57. # all other email headers
  58. some-header: 'some_value',
  59. }
  60. =end
  61. def parse(msg)
  62. msg = msg.force_encoding('binary')
  63. # mail 2.6 and earlier accepted non-conforming mails that lacked the correct CRLF seperators,
  64. # mail 2.7 and above require CRLF so we force it on using binary_unsafe_to_crlf
  65. msg = Mail::Utilities.binary_unsafe_to_crlf(msg)
  66. mail = Mail.new(msg)
  67. headers = message_header_hash(mail)
  68. body = message_body_hash(mail)
  69. message_attributes = [
  70. { mail_instance: mail },
  71. headers,
  72. body,
  73. self.class.sender_attributes(headers),
  74. ]
  75. message_attributes.reduce({}.with_indifferent_access, &:merge)
  76. end
  77. =begin
  78. parser = Channel::EmailParser.new
  79. ticket, article, user, mail = parser.process(channel, email_raw_string)
  80. returns
  81. [ticket, article, user, mail]
  82. do not raise an exception - e. g. if used by scheduler
  83. parser = Channel::EmailParser.new
  84. ticket, article, user, mail = parser.process(channel, email_raw_string, false)
  85. returns
  86. [ticket, article, user, mail] || false
  87. =end
  88. def process(channel, msg, exception = true)
  89. Timeout.timeout(PROZESS_TIME_MAX) do
  90. _process(channel, msg)
  91. end
  92. rescue => e
  93. # store unprocessable email for bug reporting
  94. path = Rails.root.join('tmp', 'unprocessable_mail')
  95. FileUtils.mkpath path
  96. md5 = Digest::MD5.hexdigest(msg)
  97. filename = "#{path}/#{md5}.eml"
  98. message = "ERROR: Can't process email, you will find it for bug reporting under #{filename}, please create an issue at https://github.com/zammad/zammad/issues"
  99. p message # rubocop:disable Rails/Output
  100. p 'ERROR: ' + e.inspect # rubocop:disable Rails/Output
  101. Rails.logger.error message
  102. Rails.logger.error e
  103. File.open(filename, 'wb') do |file|
  104. file.write msg
  105. end
  106. return false if exception == false
  107. raise e.inspect + "\n" + e.backtrace.join("\n")
  108. end
  109. def _process(channel, msg)
  110. # parse email
  111. mail = parse(msg)
  112. Rails.logger.info "Process email with msgid '#{mail[:message_id]}'"
  113. # run postmaster pre filter
  114. UserInfo.current_user_id = 1
  115. filters = {}
  116. Setting.where(area: 'Postmaster::PreFilter').order(:name).each do |setting|
  117. filters[setting.name] = Setting.get(setting.name).constantize
  118. end
  119. filters.each do |key, backend|
  120. Rails.logger.debug { "run postmaster pre filter #{key}: #{backend}" }
  121. begin
  122. backend.run(channel, mail)
  123. rescue => e
  124. Rails.logger.error "can't run postmaster pre filter #{key}: #{backend}"
  125. Rails.logger.error e.inspect
  126. raise e
  127. end
  128. end
  129. # check ignore header
  130. if mail['x-zammad-ignore'.to_sym] == 'true' || mail['x-zammad-ignore'.to_sym] == true
  131. Rails.logger.info "ignored email with msgid '#{mail[:message_id]}' from '#{mail[:from]}' because of x-zammad-ignore header"
  132. return
  133. end
  134. # set interface handle
  135. original_interface_handle = ApplicationHandleInfo.current
  136. ticket = nil
  137. article = nil
  138. session_user = nil
  139. # use transaction
  140. Transaction.execute(interface_handle: "#{original_interface_handle}.postmaster") do
  141. # get sender user
  142. session_user_id = mail['x-zammad-session-user-id'.to_sym]
  143. if !session_user_id
  144. raise 'No x-zammad-session-user-id, no sender set!'
  145. end
  146. session_user = User.lookup(id: session_user_id)
  147. if !session_user
  148. raise "No user found for x-zammad-session-user-id: #{session_user_id}!"
  149. end
  150. # set current user
  151. UserInfo.current_user_id = session_user.id
  152. # get ticket# based on email headers
  153. if mail['x-zammad-ticket-id'.to_sym]
  154. ticket = Ticket.find_by(id: mail['x-zammad-ticket-id'.to_sym])
  155. end
  156. if mail['x-zammad-ticket-number'.to_sym]
  157. ticket = Ticket.find_by(number: mail['x-zammad-ticket-number'.to_sym])
  158. end
  159. # set ticket state to open if not new
  160. if ticket
  161. set_attributes_by_x_headers(ticket, 'ticket', mail, 'followup')
  162. # save changes set by x-zammad-ticket-followup-* headers
  163. ticket.save! if ticket.has_changes_to_save?
  164. state = Ticket::State.find(ticket.state_id)
  165. state_type = Ticket::StateType.find(state.state_type_id)
  166. # set ticket to open again or keep create state
  167. if !mail['x-zammad-ticket-followup-state'.to_sym] && !mail['x-zammad-ticket-followup-state_id'.to_sym]
  168. new_state = Ticket::State.find_by(default_create: true)
  169. if ticket.state_id != new_state.id && !mail['x-zammad-out-of-office'.to_sym]
  170. ticket.state = Ticket::State.find_by(default_follow_up: true)
  171. ticket.save!
  172. end
  173. end
  174. end
  175. # create new ticket
  176. if !ticket
  177. preferences = {}
  178. if channel[:id]
  179. preferences = {
  180. channel_id: channel[:id]
  181. }
  182. end
  183. # get default group where ticket is created
  184. group = nil
  185. if channel[:group_id]
  186. group = Group.lookup(id: channel[:group_id])
  187. end
  188. if group.blank? || group.active == false
  189. group = Group.where(active: true).order('id ASC').first
  190. end
  191. if group.blank?
  192. group = Group.first
  193. end
  194. title = mail[:subject]
  195. if title.blank?
  196. title = '-'
  197. end
  198. ticket = Ticket.new(
  199. group_id: group.id,
  200. title: title,
  201. preferences: preferences,
  202. )
  203. set_attributes_by_x_headers(ticket, 'ticket', mail)
  204. # create ticket
  205. ticket.save!
  206. end
  207. # apply tags to ticket
  208. if mail['x-zammad-ticket-tags'.to_sym].present?
  209. mail['x-zammad-ticket-tags'.to_sym].each do |tag|
  210. ticket.tag_add(tag)
  211. end
  212. end
  213. # set attributes
  214. ticket.with_lock do
  215. article = Ticket::Article.new(
  216. ticket_id: ticket.id,
  217. type_id: Ticket::Article::Type.find_by(name: 'email').id,
  218. sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
  219. content_type: mail[:content_type],
  220. body: mail[:body],
  221. from: mail[:from],
  222. reply_to: mail[:"reply-to"],
  223. to: mail[:to],
  224. cc: mail[:cc],
  225. subject: mail[:subject],
  226. message_id: mail[:message_id],
  227. internal: false,
  228. )
  229. # x-headers lookup
  230. set_attributes_by_x_headers(article, 'article', mail)
  231. # create article
  232. article.save!
  233. # store mail plain
  234. article.save_as_raw(msg)
  235. # store attachments
  236. mail[:attachments]&.each do |attachment|
  237. filename = attachment[:filename].force_encoding('utf-8')
  238. if !filename.force_encoding('UTF-8').valid_encoding?
  239. filename = filename.utf8_encode(fallback: :read_as_sanitized_binary)
  240. end
  241. Store.add(
  242. object: 'Ticket::Article',
  243. o_id: article.id,
  244. data: attachment[:data],
  245. filename: filename,
  246. preferences: attachment[:preferences]
  247. )
  248. end
  249. end
  250. end
  251. ticket.reload
  252. article.reload
  253. session_user.reload
  254. # run postmaster post filter
  255. filters = {}
  256. Setting.where(area: 'Postmaster::PostFilter').order(:name).each do |setting|
  257. filters[setting.name] = Setting.get(setting.name).constantize
  258. end
  259. filters.each_value do |backend|
  260. Rails.logger.debug { "run postmaster post filter #{backend}" }
  261. begin
  262. backend.run(channel, mail, ticket, article, session_user)
  263. rescue => e
  264. Rails.logger.error "can't run postmaster post filter #{backend}"
  265. Rails.logger.error e.inspect
  266. end
  267. end
  268. # return new objects
  269. [ticket, article, session_user, mail]
  270. end
  271. def self.check_attributes_by_x_headers(header_name, value)
  272. class_name = nil
  273. attribute = nil
  274. # skip check attributes if it is tags
  275. return true if header_name == 'x-zammad-ticket-tags'
  276. if header_name =~ /^x-zammad-(.+?)-(followup-|)(.*)$/i
  277. class_name = $1
  278. attribute = $3
  279. end
  280. return true if !class_name
  281. if class_name.downcase == 'article'
  282. class_name = 'Ticket::Article'
  283. end
  284. return true if !attribute
  285. key_short = attribute[ attribute.length - 3, attribute.length ]
  286. return true if key_short != '_id'
  287. class_object = class_name.to_classname.constantize
  288. return if !class_object
  289. class_instance = class_object.new
  290. return false if !class_instance.association_id_validation(attribute, value)
  291. true
  292. end
  293. def self.sender_attributes(from)
  294. if from.is_a?(HashWithIndifferentAccess)
  295. from = SENDER_FIELDS.map { |f| from[f] }.compact
  296. .map(&:to_utf8).reject(&:blank?)
  297. .partition { |address| address.match?(EMAIL_REGEX) }
  298. .flatten.first
  299. end
  300. data = {}.with_indifferent_access
  301. return data if from.blank?
  302. from = from.gsub('<>', '').strip
  303. mail_address = begin
  304. Mail::AddressList.new(from).addresses
  305. .select { |a| a.address.present? }
  306. .partition { |a| a.address.match?(EMAIL_REGEX) }
  307. .flatten.first
  308. rescue Mail::Field::ParseError => e
  309. STDOUT.puts e
  310. end
  311. if mail_address&.address.present?
  312. data[:from_email] = mail_address.address
  313. data[:from_local] = mail_address.local
  314. data[:from_domain] = mail_address.domain
  315. data[:from_display_name] = mail_address.display_name || mail_address.comments&.first
  316. elsif from =~ /^(.+?)<((.+?)@(.+?))>/
  317. data[:from_email] = $2
  318. data[:from_local] = $3
  319. data[:from_domain] = $4
  320. data[:from_display_name] = $1
  321. else
  322. data[:from_email] = from
  323. data[:from_local] = from
  324. data[:from_domain] = from
  325. data[:from_display_name] = from
  326. end
  327. # do extra decoding because we needed to use field.value
  328. data[:from_display_name] =
  329. Mail::Field.new('X-From', data[:from_display_name].to_utf8)
  330. .to_s
  331. .delete('"')
  332. .strip
  333. .gsub(/(^'|'$)/, '')
  334. data
  335. end
  336. def set_attributes_by_x_headers(item_object, header_name, mail, suffix = false)
  337. # loop all x-zammad-header-* headers
  338. item_object.attributes.each_key do |key|
  339. # ignore read only attributes
  340. next if key == 'updated_by_id'
  341. next if key == 'created_by_id'
  342. # check if id exists
  343. key_short = key[ key.length - 3, key.length ]
  344. if key_short == '_id'
  345. key_short = key[ 0, key.length - 3 ]
  346. header = "x-zammad-#{header_name}-#{key_short}"
  347. if suffix
  348. header = "x-zammad-#{header_name}-#{suffix}-#{key_short}"
  349. end
  350. # only set value on _id if value/reference lookup exists
  351. if mail[ header.to_sym ]
  352. Rails.logger.info "set_attributes_by_x_headers header #{header} found #{mail[header.to_sym]}"
  353. item_object.class.reflect_on_all_associations.map do |assoc|
  354. next if assoc.name.to_s != key_short
  355. Rails.logger.info "set_attributes_by_x_headers found #{assoc.class_name} lookup for '#{mail[header.to_sym]}'"
  356. item = assoc.class_name.constantize
  357. assoc_object = nil
  358. if item.respond_to?(:name)
  359. assoc_object = item.lookup(name: mail[header.to_sym])
  360. end
  361. if !assoc_object && item.respond_to?(:login)
  362. assoc_object = item.lookup(login: mail[header.to_sym])
  363. end
  364. if assoc_object.blank?
  365. # no assoc exists, remove header
  366. mail.delete(header.to_sym)
  367. next
  368. end
  369. Rails.logger.info "set_attributes_by_x_headers assign #{item_object.class} #{key}=#{assoc_object.id}"
  370. item_object[key] = assoc_object.id
  371. end
  372. end
  373. end
  374. # check if attribute exists
  375. header = "x-zammad-#{header_name}-#{key}"
  376. if suffix
  377. header = "x-zammad-#{header_name}-#{suffix}-#{key}"
  378. end
  379. if mail[header.to_sym]
  380. Rails.logger.info "set_attributes_by_x_headers header #{header} found. Assign #{key}=#{mail[header.to_sym]}"
  381. item_object[key] = mail[header.to_sym]
  382. end
  383. end
  384. end
  385. =begin
  386. process unprocessable_mails (tmp/unprocessable_mail/*.eml) again
  387. Channel::EmailParser.process_unprocessable_mails
  388. =end
  389. def self.process_unprocessable_mails(params = {})
  390. path = Rails.root.join('tmp', 'unprocessable_mail')
  391. files = []
  392. Dir.glob("#{path}/*.eml") do |entry|
  393. ticket, article, user, mail = Channel::EmailParser.new.process(params, IO.binread(entry))
  394. next if ticket.blank?
  395. files.push entry
  396. File.delete(entry)
  397. end
  398. files
  399. end
  400. private
  401. def message_header_hash(mail)
  402. imported_fields = mail.header.fields.map do |f|
  403. begin
  404. value = f.to_utf8
  405. if value.blank?
  406. value = f.decoded.to_utf8
  407. end
  408. # fields that cannot be cleanly parsed fallback to the empty string
  409. rescue Mail::Field::IncompleteParseError
  410. value = ''
  411. rescue
  412. value = f.decoded.to_utf8(fallback: :read_as_sanitized_binary)
  413. end
  414. [f.name.downcase, value]
  415. end.to_h
  416. # imported_fields = mail.header.fields.map { |f| [f.name.downcase, f.to_utf8] }.to_h
  417. raw_fields = mail.header.fields.map { |f| ["raw-#{f.name.downcase}", f] }.to_h
  418. custom_fields = {}.tap do |h|
  419. h.replace(imported_fields.slice(*RECIPIENT_FIELDS)
  420. .transform_values { |v| v.match?(EMAIL_REGEX) ? v : '' })
  421. h['x-any-recipient'] = h.values.select(&:present?).join(', ')
  422. h['message_id'] = imported_fields['message-id']
  423. h['subject'] = imported_fields['subject']
  424. begin
  425. h['date'] = Time.zone.parse(mail.date.to_s) || imported_fields['date']
  426. rescue
  427. h['date'] = nil
  428. end
  429. end
  430. [imported_fields, raw_fields, custom_fields].reduce({}.with_indifferent_access, &:merge)
  431. end
  432. def message_body_hash(mail)
  433. message = [mail.html_part, mail.text_part, mail].find { |m| m&.body.present? }
  434. if message.present? && (message.mime_type.nil? || message.mime_type.match?(%r{^text/(plain|html)$}))
  435. content_type = message.mime_type || 'text/plain'
  436. body = body_text(message, strict_html: content_type.eql?('text/html'))
  437. end
  438. content_type = 'text/plain' if body.blank?
  439. {
  440. attachments: collect_attachments(mail),
  441. content_type: content_type || 'text/plain',
  442. body: body.presence || 'no visible content'
  443. }.with_indifferent_access
  444. end
  445. def body_text(message, **options)
  446. body_text = begin
  447. message.body.to_s
  448. rescue Mail::UnknownEncodingType # see test/data/mail/mail043.box / issue #348
  449. message.body.raw_source
  450. end
  451. body_text = body_text.utf8_encode(from: message.charset, fallback: :read_as_sanitized_binary)
  452. body_text = Mail::Utilities.to_lf(body_text)
  453. # plaintext body requires no processing
  454. return body_text if !options[:strict_html]
  455. # Issue #2390 - emails with >5k HTML links should be rejected
  456. return EXCESSIVE_LINKS_MSG if body_text.scan(/<a[[:space:]]/i).count >= 5_000
  457. body_text.html2html_strict
  458. end
  459. def collect_attachments(mail)
  460. attachments = []
  461. # Add non-plaintext body as an attachment
  462. if mail.html_part&.body.present? ||
  463. (!mail.multipart? && mail.mime_type.present? && mail.mime_type != 'text/plain')
  464. message = mail.html_part || mail
  465. filename = message.filename.presence ||
  466. (message.mime_type.eql?('text/html') ? 'message.html' : '-no name-')
  467. headers_store = {
  468. 'content-alternative' => true,
  469. 'original-format' => message.mime_type.eql?('text/html'),
  470. 'Mime-Type' => message.mime_type,
  471. 'Charset' => message.charset,
  472. }.reject { |_, v| v.blank? }
  473. attachments.push({ data: body_text(message),
  474. filename: filename,
  475. preferences: headers_store })
  476. end
  477. mail.parts.each do |part|
  478. begin
  479. new_attachments = get_attachments(part, attachments, mail).flatten.compact
  480. attachments.push(*new_attachments)
  481. rescue => e # Protect process to work with spam emails (see test/fixtures/mail15.box)
  482. raise e if (fail_count ||= 0).positive?
  483. (fail_count += 1) && retry
  484. end
  485. end
  486. attachments
  487. end
  488. def get_attachments(file, attachments, mail)
  489. return file.parts.map { |p| get_attachments(p, attachments, mail) } if file.parts.any?
  490. return [] if [mail.text_part&.body&.encoded, mail.html_part&.body&.encoded].include?(file.body.encoded)
  491. # get file preferences
  492. headers_store = {}
  493. file.header.fields.each do |field|
  494. # full line, encode, ready for storage
  495. begin
  496. value = field.to_utf8
  497. if value.blank?
  498. value = field.raw_value
  499. end
  500. headers_store[field.name.to_s] = value
  501. rescue => e
  502. headers_store[field.name.to_s] = field.raw_value
  503. end
  504. end
  505. # cleanup content id, <> will be added automatically later
  506. if headers_store['Content-ID']
  507. headers_store['Content-ID'].gsub!(/^</, '')
  508. headers_store['Content-ID'].gsub!(/>$/, '')
  509. end
  510. # get filename from content-disposition
  511. # workaround for: NoMethodError: undefined method `filename' for #<Mail::UnstructuredField:0x007ff109e80678>
  512. begin
  513. filename = file.header[:content_disposition].try(:filename)
  514. rescue
  515. begin
  516. if file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})="(.+?)"/i
  517. filename = $3
  518. elsif file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})='(.+?)'/i
  519. filename = $3
  520. elsif file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})=(.+?);/i
  521. filename = $3
  522. end
  523. rescue
  524. Rails.logger.debug { 'Unable to get filename' }
  525. end
  526. end
  527. begin
  528. if file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})="(.+?)"/i
  529. filename = $3
  530. elsif file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})='(.+?)'/i
  531. filename = $3
  532. elsif file.header[:content_disposition].to_s =~ /(filename|name)(\*{0,1})=(.+?);/i
  533. filename = $3
  534. end
  535. rescue
  536. Rails.logger.debug { 'Unable to get filename' }
  537. end
  538. # as fallback, use raw values
  539. if filename.blank?
  540. if headers_store['Content-Disposition'].to_s =~ /(filename|name)(\*{0,1})="(.+?)"/i
  541. filename = $3
  542. elsif headers_store['Content-Disposition'].to_s =~ /(filename|name)(\*{0,1})='(.+?)'/i
  543. filename = $3
  544. elsif headers_store['Content-Disposition'].to_s =~ /(filename|name)(\*{0,1})=(.+?);/i
  545. filename = $3
  546. end
  547. end
  548. # for some broken sm mail clients (X-MimeOLE: Produced By Microsoft Exchange V6.5)
  549. filename ||= file.header[:content_location].to_s.force_encoding('utf-8')
  550. # generate file name based on content-id
  551. if filename.blank? && headers_store['Content-ID'].present?
  552. if headers_store['Content-ID'] =~ /(.+?)@.+?/i
  553. filename = $1
  554. end
  555. end
  556. # generate file name based on content type
  557. if filename.blank? && headers_store['Content-Type'].present? && headers_store['Content-Type'].match?(%r{^message/rfc822}i)
  558. begin
  559. parser = Channel::EmailParser.new
  560. mail_local = parser.parse(file.body.to_s)
  561. filename = if mail_local[:subject].present?
  562. "#{mail_local[:subject]}.eml"
  563. elsif headers_store['Content-Description'].present?
  564. "#{headers_store['Content-Description']}.eml".to_s.force_encoding('utf-8')
  565. else
  566. 'Mail.eml'
  567. end
  568. rescue
  569. filename = 'Mail.eml'
  570. end
  571. end
  572. # e. g. Content-Type: video/quicktime; name="Video.MOV";
  573. if filename.blank?
  574. ['(filename|name)(\*{0,1})="(.+?)"(;|$)', '(filename|name)(\*{0,1})=\'(.+?)\'(;|$)', '(filename|name)(\*{0,1})=(.+?)(;|$)'].each do |regexp|
  575. if headers_store['Content-Type'] =~ /#{regexp}/i
  576. filename = $3
  577. break
  578. end
  579. end
  580. end
  581. # workaround for mail gem - decode filenames
  582. # https://github.com/zammad/zammad/issues/928
  583. if filename.present?
  584. filename = Mail::Encodings.value_decode(filename)
  585. end
  586. if !filename.force_encoding('UTF-8').valid_encoding?
  587. filename = filename.utf8_encode(fallback: :read_as_sanitized_binary)
  588. end
  589. # e. g. Content-Type: video/quicktime
  590. if filename.blank?
  591. map = {
  592. 'message/delivery-status': ['txt', 'delivery-status'],
  593. 'text/plain': %w[txt document],
  594. 'text/html': %w[html document],
  595. 'video/quicktime': %w[mov video],
  596. 'image/jpeg': %w[jpg image],
  597. 'image/jpg': %w[jpg image],
  598. 'image/png': %w[png image],
  599. 'image/gif': %w[gif image],
  600. }
  601. map.each do |type, ext|
  602. next if headers_store['Content-Type'] !~ /^#{Regexp.quote(type)}/i
  603. filename = if headers_store['Content-Description'].present?
  604. "#{headers_store['Content-Description']}.#{ext[0]}".to_s.force_encoding('utf-8')
  605. else
  606. "#{ext[1]}.#{ext[0]}"
  607. end
  608. break
  609. end
  610. end
  611. # set fallback filename
  612. if filename.blank?
  613. filename = 'file'
  614. end
  615. # create uniq filename
  616. local_filename = ''
  617. local_extention = ''
  618. if filename =~ /^(.*?)\.(.+?)$/
  619. local_filename = $1
  620. local_extention = $2
  621. end
  622. 1.upto(1000) do |i|
  623. filename_exists = false
  624. attachments.each do |attachment|
  625. if attachment[:filename] == filename
  626. filename_exists = true
  627. end
  628. end
  629. break if filename_exists == false
  630. filename = if local_extention.present?
  631. "#{local_filename}#{i}.#{local_extention}"
  632. else
  633. "#{local_filename}#{i}"
  634. end
  635. end
  636. # get mime type
  637. if file.header[:content_type]&.string
  638. headers_store['Mime-Type'] = file.header[:content_type].string
  639. end
  640. # get charset
  641. if file.header&.charset
  642. headers_store['Charset'] = file.header.charset
  643. end
  644. # remove not needed header
  645. headers_store.delete('Content-Transfer-Encoding')
  646. headers_store.delete('Content-Disposition')
  647. attach = {
  648. data: file.body.to_s,
  649. filename: filename,
  650. preferences: headers_store,
  651. }
  652. [attach]
  653. end
  654. end
  655. module Mail
  656. # workaround to get content of no parseable headers - in most cases with non 7 bit ascii signs
  657. class Field
  658. def raw_value
  659. begin
  660. value = @raw_value.try(:utf8_encode)
  661. rescue
  662. value = @raw_value.utf8_encode(fallback: :read_as_sanitized_binary)
  663. end
  664. return value if value.blank?
  665. value.sub(/^.+?:(\s|)/, '')
  666. end
  667. end
  668. # issue#348 - IMAP mail fetching stops because of broken spam email (e. g. broken Content-Transfer-Encoding value see test/fixtures/mail43.box)
  669. # https://github.com/zammad/zammad/issues/348
  670. class Body
  671. def decoded
  672. if !Encodings.defined?(encoding)
  673. #raise UnknownEncodingType, "Don't know how to decode #{encoding}, please call #encoded and decode it yourself."
  674. Rails.logger.info "UnknownEncodingType: Don't know how to decode #{encoding}!"
  675. raw_source
  676. else
  677. Encodings.get_encoding(encoding).decode(raw_source)
  678. end
  679. end
  680. end
  681. end