email_parser.rb 33 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. # encoding: utf-8
  3. class Channel::EmailParser
  4. include Channel::EmailHelper
  5. PROCESS_TIME_MAX = 180
  6. EMAIL_REGEX = %r{.+@.+}
  7. RECIPIENT_FIELDS = %w[to cc delivered-to x-original-to envelope-to].freeze
  8. SENDER_FIELDS = %w[from reply-to return-path sender].freeze
  9. 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
  10. MESSAGE_STRUCT = Struct.new(:from_display_name, :subject, :msg_size).freeze
  11. =begin
  12. parser = Channel::EmailParser.new
  13. mail = parser.parse(msg_as_string)
  14. mail = {
  15. from: 'Some Name <some@example.com>',
  16. from_email: 'some@example.com',
  17. from_local: 'some',
  18. from_domain: 'example.com',
  19. from_display_name: 'Some Name',
  20. message_id: 'some_message_id@example.com',
  21. to: 'Some System <system@example.com>',
  22. cc: 'Somebody <somebody@example.com>',
  23. subject: 'some message subject',
  24. body: 'some message body',
  25. content_type: 'text/html', # text/plain
  26. date: Time.zone.now,
  27. attachments: [
  28. {
  29. data: 'binary of attachment',
  30. filename: 'file_name_of_attachment.txt',
  31. preferences: {
  32. 'content-alternative' => true,
  33. 'Mime-Type' => 'text/plain',
  34. 'Charset: => 'iso-8859-1',
  35. },
  36. },
  37. ],
  38. # ignore email header
  39. x-zammad-ignore: 'false',
  40. # customer headers
  41. x-zammad-customer-login: '',
  42. x-zammad-customer-email: '',
  43. x-zammad-customer-firstname: '',
  44. x-zammad-customer-lastname: '',
  45. # ticket headers (for new tickets)
  46. x-zammad-ticket-group: 'some_group',
  47. x-zammad-ticket-state: 'some_state',
  48. x-zammad-ticket-priority: 'some_priority',
  49. x-zammad-ticket-owner: 'some_owner_login',
  50. # ticket headers (for existing tickets)
  51. x-zammad-ticket-followup-group: 'some_group',
  52. x-zammad-ticket-followup-state: 'some_state',
  53. x-zammad-ticket-followup-priority: 'some_priority',
  54. x-zammad-ticket-followup-owner: 'some_owner_login',
  55. # article headers
  56. x-zammad-article-internal: false,
  57. x-zammad-article-type: 'agent',
  58. x-zammad-article-sender: 'customer',
  59. # all other email headers
  60. some-header: 'some_value',
  61. }
  62. =end
  63. def parse(msg)
  64. msg = msg.force_encoding('binary')
  65. # mail 2.6 and earlier accepted non-conforming mails that lacked the correct CRLF seperators,
  66. # mail 2.7 and above require CRLF so we force it on using binary_unsafe_to_crlf
  67. msg = Mail::Utilities.binary_unsafe_to_crlf(msg)
  68. mail = Mail.new(msg)
  69. message_ensure_message_id(msg, mail)
  70. force_parts_encoding_if_needed(mail)
  71. headers = message_header_hash(mail)
  72. body = message_body_hash(mail)
  73. message_attributes = [
  74. { mail_instance: mail },
  75. headers,
  76. body,
  77. self.class.sender_attributes(headers),
  78. { raw: msg },
  79. ]
  80. message_attributes.reduce({}.with_indifferent_access, &:merge)
  81. end
  82. =begin
  83. parser = Channel::EmailParser.new
  84. ticket, article, user, mail = parser.process(channel, email_raw_string)
  85. returns
  86. [ticket, article, user, mail]
  87. do not raise an exception - e. g. if used by scheduler
  88. parser = Channel::EmailParser.new
  89. ticket, article, user, mail = parser.process(channel, email_raw_string, false)
  90. returns
  91. [ticket, article, user, mail] || false
  92. =end
  93. def process(channel, msg, exception = true)
  94. process_with_timeout(channel, msg)
  95. rescue => e
  96. failed_email = ::FailedEmail.create!(data: msg, parsing_error: e)
  97. message = <<~MESSAGE.chomp
  98. Can't process email. Run the following command to get the message for issue report at https://github.com/zammad/zammad/issues:
  99. zammad run rails r "puts FailedEmail.find(#{failed_email.id}).data"
  100. MESSAGE
  101. puts "ERROR: #{message}" # rubocop:disable Rails/Output
  102. puts "ERROR: #{e.inspect}" # rubocop:disable Rails/Output
  103. Rails.logger.error message
  104. Rails.logger.error e
  105. return false if exception == false
  106. raise failed_email.parsing_error
  107. end
  108. def process_with_timeout(channel, msg)
  109. Timeout.timeout(PROCESS_TIME_MAX) do
  110. _process(channel, msg)
  111. end
  112. end
  113. def _process(channel, msg)
  114. # parse email
  115. mail = parse(msg)
  116. Rails.logger.info "Process email with msgid '#{mail[:message_id]}'"
  117. # run postmaster pre filter
  118. UserInfo.current_user_id = 1
  119. # set interface handle
  120. original_interface_handle = ApplicationHandleInfo.current
  121. transaction_params = { interface_handle: "#{original_interface_handle}.postmaster", disable: [] }
  122. filters = {}
  123. Setting.where(area: 'Postmaster::PreFilter').reorder(:name).each do |setting|
  124. filters[setting.name] = Setting.get(setting.name).constantize
  125. end
  126. filters.each do |key, backend|
  127. Rails.logger.debug { "run postmaster pre filter #{key}: #{backend}" }
  128. begin
  129. backend.run(channel, mail, transaction_params)
  130. rescue => e
  131. Rails.logger.error "can't run postmaster pre filter #{key}: #{backend}"
  132. Rails.logger.error e.inspect
  133. raise e
  134. end
  135. end
  136. # check ignore header
  137. if mail[:'x-zammad-ignore'] == 'true' || mail[:'x-zammad-ignore'] == true
  138. Rails.logger.info "ignored email with msgid '#{mail[:message_id]}' from '#{mail[:from]}' because of x-zammad-ignore header"
  139. return
  140. end
  141. ticket = nil
  142. article = nil
  143. session_user = nil
  144. # https://github.com/zammad/zammad/issues/2401
  145. mail = prepare_idn_inbound(mail)
  146. # use transaction
  147. Transaction.execute(transaction_params) do
  148. # get sender user
  149. session_user_id = mail[:'x-zammad-session-user-id']
  150. if !session_user_id
  151. raise __('No x-zammad-session-user-id, no sender set!')
  152. end
  153. session_user = User.lookup(id: session_user_id)
  154. if !session_user
  155. raise "No user found for x-zammad-session-user-id: #{session_user_id}!"
  156. end
  157. # set current user
  158. UserInfo.current_user_id = session_user.id
  159. # get ticket# based on email headers
  160. if mail[:'x-zammad-ticket-id']
  161. ticket = Ticket.find_by(id: mail[:'x-zammad-ticket-id'])
  162. end
  163. if mail[:'x-zammad-ticket-number']
  164. ticket = Ticket.find_by(number: mail[:'x-zammad-ticket-number'])
  165. end
  166. # set ticket state to open if not new
  167. if ticket
  168. set_attributes_by_x_headers(ticket, 'ticket', mail, 'followup')
  169. # save changes set by x-zammad-ticket-followup-* headers
  170. ticket.save! if ticket.has_changes_to_save?
  171. # set ticket to open again or keep create state
  172. if !mail[:'x-zammad-ticket-followup-state'] && !mail[:'x-zammad-ticket-followup-state_id']
  173. new_state = Ticket::State.find_by(default_create: true)
  174. if ticket.state_id != new_state.id && !mail[:'x-zammad-out-of-office']
  175. ticket.state = Ticket::State.find_by(default_follow_up: true)
  176. ticket.save!
  177. end
  178. end
  179. # apply tags to ticket
  180. if mail[:'x-zammad-ticket-followup-tags'].present?
  181. mail[:'x-zammad-ticket-followup-tags'].each do |tag|
  182. ticket.tag_add(tag, sourceable: mail[:'x-zammad-ticket-followup-tags-source'])
  183. end
  184. end
  185. end
  186. # create new ticket
  187. if !ticket
  188. preferences = {}
  189. if channel[:id]
  190. preferences = {
  191. channel_id: channel[:id]
  192. }
  193. end
  194. # get default group where ticket is created
  195. group = nil
  196. if channel[:group_id]
  197. group = Group.lookup(id: channel[:group_id])
  198. else
  199. mail_to_group = self.class.mail_to_group(mail[:to])
  200. if mail_to_group.present?
  201. group = mail_to_group
  202. end
  203. end
  204. if group.blank? || group.active == false
  205. group = Group.where(active: true).reorder(id: :asc).first
  206. end
  207. if group.blank?
  208. group = Group.first
  209. end
  210. title = mail[:subject]
  211. if title.blank?
  212. title = '-'
  213. end
  214. ticket = Ticket.new(
  215. group_id: group.id,
  216. title: title,
  217. preferences: preferences,
  218. )
  219. set_attributes_by_x_headers(ticket, 'ticket', mail)
  220. # create ticket
  221. ticket.save!
  222. # apply tags to ticket
  223. if mail[:'x-zammad-ticket-tags'].present?
  224. mail[:'x-zammad-ticket-tags'].each do |tag|
  225. ticket.tag_add(tag, sourceable: mail[:'x-zammad-ticket-tags-source'])
  226. end
  227. end
  228. end
  229. # set attributes
  230. article = Ticket::Article.new(
  231. ticket_id: ticket.id,
  232. type_id: Ticket::Article::Type.find_by(name: 'email').id,
  233. sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
  234. content_type: mail[:content_type],
  235. body: mail[:body],
  236. from: mail[:from],
  237. reply_to: mail[:'reply-to'],
  238. to: mail[:to],
  239. cc: mail[:cc],
  240. subject: mail[:subject],
  241. message_id: mail[:message_id],
  242. internal: false,
  243. )
  244. # x-headers lookup
  245. set_attributes_by_x_headers(article, 'article', mail)
  246. # create article
  247. article.save!
  248. # store mail plain
  249. article.save_as_raw(msg)
  250. # store attachments
  251. mail[:attachments]&.each do |attachment|
  252. filename = attachment[:filename].force_encoding('utf-8')
  253. if !filename.force_encoding('UTF-8').valid_encoding?
  254. filename = filename.utf8_encode(fallback: :read_as_sanitized_binary)
  255. end
  256. Store.create!(
  257. object: 'Ticket::Article',
  258. o_id: article.id,
  259. data: attachment[:data],
  260. filename: filename,
  261. preferences: attachment[:preferences]
  262. )
  263. end
  264. end
  265. ticket.reload
  266. article.reload
  267. session_user.reload
  268. # run postmaster post filter
  269. filters = {}
  270. Setting.where(area: 'Postmaster::PostFilter').reorder(:name).each do |setting|
  271. filters[setting.name] = Setting.get(setting.name).constantize
  272. end
  273. filters.each_value do |backend|
  274. Rails.logger.debug { "run postmaster post filter #{backend}" }
  275. begin
  276. backend.run(channel, mail, ticket, article, session_user)
  277. rescue => e
  278. Rails.logger.error "can't run postmaster post filter #{backend}"
  279. Rails.logger.error e.inspect
  280. end
  281. end
  282. # return new objects
  283. [ticket, article, session_user, mail]
  284. end
  285. def self.mail_to_group(to)
  286. begin
  287. to = Mail::AddressList.new(to)&.addresses&.first&.address
  288. rescue
  289. Rails.logger.error 'Can not parse :to field for group destination!'
  290. end
  291. return if to.blank?
  292. email = EmailAddress.find_by(email: to.downcase)
  293. return if email&.channel.blank?
  294. email.channel&.group
  295. end
  296. def self.check_attributes_by_x_headers(header_name, value)
  297. class_name = nil
  298. attribute = nil
  299. # skip check attributes if it is tags
  300. return true if header_name == 'x-zammad-ticket-tags'
  301. if header_name =~ %r{^x-zammad-(.+?)-(followup-|)(.*)$}i
  302. class_name = $1
  303. attribute = $3
  304. end
  305. return true if !class_name
  306. if class_name.casecmp('article').zero?
  307. class_name = 'Ticket::Article'
  308. end
  309. return true if !attribute
  310. key_short = attribute[ attribute.length - 3, attribute.length ]
  311. return true if key_short != '_id'
  312. class_object = class_name.to_classname.constantize
  313. return if !class_object
  314. class_instance = class_object.new
  315. return false if !class_instance.association_id_validation(attribute, value)
  316. true
  317. end
  318. def self.sender_attributes(from)
  319. if from.is_a?(ActiveSupport::HashWithIndifferentAccess)
  320. from = SENDER_FIELDS.filter_map { |f| from[f] }
  321. .map(&:to_utf8).compact_blank
  322. .partition { |address| address.match?(EMAIL_REGEX) }
  323. .flatten.first
  324. end
  325. data = {}.with_indifferent_access
  326. return data if from.blank?
  327. from = from.gsub('<>', '').strip
  328. mail_address = begin
  329. Mail::AddressList.new(from).addresses
  330. .select { |a| a.address.present? }
  331. .partition { |a| a.address.match?(EMAIL_REGEX) }
  332. .flatten.first
  333. rescue Mail::Field::ParseError => e
  334. $stdout.puts e
  335. end
  336. if mail_address&.address.present?
  337. data[:from_email] = mail_address.address
  338. data[:from_local] = mail_address.local
  339. data[:from_domain] = mail_address.domain
  340. data[:from_display_name] = mail_address.display_name || mail_address.comments&.first
  341. elsif from =~ %r{^(.+?)<((.+?)@(.+?))>}
  342. data[:from_email] = $2
  343. data[:from_local] = $3
  344. data[:from_domain] = $4
  345. data[:from_display_name] = $1
  346. else
  347. data[:from_email] = from
  348. data[:from_local] = from
  349. data[:from_domain] = from
  350. data[:from_display_name] = from
  351. end
  352. # do extra decoding because we needed to use field.value
  353. data[:from_display_name] =
  354. Mail::Field.new('X-From', data[:from_display_name].to_utf8)
  355. .to_s
  356. .delete('"')
  357. .strip
  358. .gsub(%r{(^'|'$)}, '')
  359. data
  360. end
  361. def set_attributes_by_x_headers(item_object, header_name, mail, suffix = false)
  362. # loop all x-zammad-header-* headers
  363. item_object.attributes.each_key do |key|
  364. # ignore read only attributes
  365. next if key == 'updated_by_id'
  366. next if key == 'created_by_id'
  367. # check if id exists
  368. key_short = key[ key.length - 3, key.length ]
  369. if key_short == '_id'
  370. key_short = key[ 0, key.length - 3 ]
  371. header = "x-zammad-#{header_name}-#{key_short}"
  372. if suffix
  373. header = "x-zammad-#{header_name}-#{suffix}-#{key_short}"
  374. end
  375. # only set value on _id if value/reference lookup exists
  376. if mail[header.to_sym]
  377. Rails.logger.info "set_attributes_by_x_headers header #{header} found #{mail[header.to_sym]}"
  378. item_object.class.reflect_on_all_associations.map do |assoc|
  379. next if assoc.name.to_s != key_short
  380. Rails.logger.info "set_attributes_by_x_headers found #{assoc.class_name} lookup for '#{mail[header.to_sym]}'"
  381. item = assoc.class_name.constantize
  382. assoc_object = nil
  383. if item.new.respond_to?(:name)
  384. assoc_object = item.lookup(name: mail[header.to_sym])
  385. end
  386. if !assoc_object && item.new.respond_to?(:login)
  387. assoc_object = item.lookup(login: mail[header.to_sym])
  388. end
  389. if !assoc_object && item.new.respond_to?(:email)
  390. assoc_object = item.lookup(email: mail[header.to_sym])
  391. end
  392. if assoc_object.blank?
  393. # no assoc exists, remove header
  394. mail.delete(header.to_sym)
  395. next
  396. end
  397. Rails.logger.info "set_attributes_by_x_headers assign #{item_object.class} #{key}=#{assoc_object.id}"
  398. item_object[key] = assoc_object.id
  399. item_object.history_change_source_attribute(mail[:"#{header}-source"], key)
  400. end
  401. end
  402. end
  403. # check if attribute exists
  404. header = "x-zammad-#{header_name}-#{key}"
  405. if suffix
  406. header = "x-zammad-#{header_name}-#{suffix}-#{key}"
  407. end
  408. next if !mail[header.to_sym]
  409. Rails.logger.info "set_attributes_by_x_headers header #{header} found. Assign #{key}=#{mail[header.to_sym]}"
  410. item_object[key] = mail[header.to_sym]
  411. item_object.history_change_source_attribute(mail[:"#{header}-source"], key)
  412. end
  413. end
  414. def self.reprocess_failed_articles
  415. articles = Ticket::Article.where(body: ::HtmlSanitizer::UNPROCESSABLE_HTML_MSG)
  416. articles.reorder(id: :desc).as_batches do |article|
  417. if !article.as_raw&.content
  418. puts "No raw content for article id #{article.id}! Please verify manually via command: Ticket::Article.find(#{article.id}).as_raw" # rubocop:disable Rails/Output
  419. next
  420. end
  421. puts "Fix article #{article.id}..." # rubocop:disable Rails/Output
  422. ApplicationHandleInfo.use('email_parser.postmaster') do
  423. parsed = Channel::EmailParser.new.parse(article.as_raw.content)
  424. if parsed[:body] == ::HtmlSanitizer::UNPROCESSABLE_HTML_MSG
  425. puts "ERROR: Failed to reprocess the article, please verify the content of the article and if needed increase the timeout (see: Setting.get('html_sanitizer_processing_timeout'))." # rubocop:disable Rails/Output
  426. next
  427. end
  428. article.update!(body: parsed[:body], content_type: parsed[:content_type])
  429. end
  430. end
  431. puts "#{articles.count} articles are affected." # rubocop:disable Rails/Output
  432. end
  433. =begin
  434. process oversized emails by
  435. - Reply with a postmaster message to inform the sender
  436. =end
  437. def process_oversized_mail(channel, msg)
  438. postmaster_response(channel, msg)
  439. end
  440. private
  441. # https://github.com/zammad/zammad/issues/2922
  442. def force_parts_encoding_if_needed(mail)
  443. # enforce encoding on both multipart parts and main body
  444. ([mail] + mail.parts).each { |elem| force_single_part_encoding_if_needed(elem) }
  445. end
  446. # https://github.com/zammad/zammad/issues/2922
  447. def force_single_part_encoding_if_needed(part)
  448. return if part.charset&.downcase != 'iso-2022-jp'
  449. part.body = force_japanese_encoding part.body.encoded.unpack1('M')
  450. end
  451. ISO2022JP_REGEXP = %r{=\?ISO-2022-JP\?B\?(.+?)\?=}
  452. # https://github.com/zammad/zammad/issues/3115
  453. def header_field_unpack_japanese(field)
  454. field.value.gsub ISO2022JP_REGEXP do
  455. force_japanese_encoding Base64.decode64($1)
  456. end
  457. end
  458. # generate Message ID on the fly if it was missing
  459. # yes, Mail gem generates one in some cases
  460. # but it is 100% random so duplicate messages would not be detected
  461. def message_ensure_message_id(raw, parsed)
  462. field = parsed.header.fields.find { |elem| elem.name == 'Message-ID' }
  463. return true if field&.unparsed_value.present?
  464. parsed.message_id = generate_message_id(raw, parsed.from)
  465. end
  466. def message_header_hash(mail)
  467. imported_fields = mail.header.fields.to_h do |f|
  468. begin
  469. value = if f.value.match?(ISO2022JP_REGEXP)
  470. value = header_field_unpack_japanese(f)
  471. else
  472. f.decoded.to_utf8
  473. end
  474. # fields that cannot be cleanly parsed fallback to the empty string
  475. rescue Mail::Field::IncompleteParseError
  476. value = ''
  477. rescue Encoding::CompatibilityError => e
  478. try_iso88591 = f.value.force_encoding('iso-8859-1').encode('utf-8')
  479. raise e if !try_iso88591.is_utf8?
  480. f.value = try_iso88591
  481. value = f.decoded.to_utf8
  482. rescue Date::Error => e
  483. raise e if !f.name.eql?('Resent-Date')
  484. f.value = ''
  485. rescue
  486. value = f.decoded.to_utf8(fallback: :read_as_sanitized_binary)
  487. end
  488. [f.name.downcase, value]
  489. end
  490. # imported_fields = mail.header.fields.map { |f| [f.name.downcase, f.to_utf8] }.to_h
  491. raw_fields = mail.header.fields.index_by { |f| "raw-#{f.name.downcase}" }
  492. custom_fields = {}.tap do |h|
  493. h.replace(imported_fields.slice(*RECIPIENT_FIELDS)
  494. .transform_values { |v| v.match?(EMAIL_REGEX) ? v : '' })
  495. h['x-any-recipient'] = h.values.select(&:present?).join(', ')
  496. h['message_id'] = imported_fields['message-id']
  497. h['subject'] = imported_fields['subject']
  498. h['date'] = begin
  499. Time.zone.parse(mail.date.to_s)
  500. rescue
  501. nil
  502. end
  503. end
  504. [imported_fields, raw_fields, custom_fields].reduce({}.with_indifferent_access, &:merge)
  505. end
  506. def message_body_hash(mail)
  507. if mail.html_part&.body.present?
  508. content_type = mail.html_part.mime_type || 'text/plain'
  509. body = body_text(mail.html_part, strict_html: true)
  510. elsif mail.text_part.present? && mail.all_parts.any? { |elem| elem.inline? && elem.content_type&.start_with?('image') }
  511. content_type = 'text/html'
  512. body = mail
  513. .all_parts
  514. .reduce('') do |memo, part|
  515. if part.mime_type == 'text/plain' && !part.attachment?
  516. memo += body_text(part, strict_html: false).text2html
  517. elsif part.inline? && part.content_type&.start_with?('image')
  518. memo += "<img src='cid:#{part.cid}'>"
  519. end
  520. memo
  521. end
  522. elsif mail.text_part.present?
  523. content_type = 'text/plain'
  524. body = mail
  525. .all_parts
  526. .reduce('') do |memo, part|
  527. if part.mime_type == 'text/plain' && !part.attachment?
  528. memo += body_text(part, strict_html: false)
  529. end
  530. memo
  531. end
  532. elsif mail&.body.present? && (mail.mime_type.nil? || mail.mime_type.match?(%r{^text/(plain|html)$}))
  533. content_type = mail.mime_type || 'text/plain'
  534. body = body_text(mail, strict_html: content_type.eql?('text/html'))
  535. end
  536. content_type = 'text/plain' if body.blank?
  537. {
  538. attachments: collect_attachments(mail),
  539. content_type: content_type || 'text/plain',
  540. body: body.presence || 'no visible content'
  541. }.with_indifferent_access
  542. end
  543. def body_text(message, **options)
  544. body_text = begin
  545. message.body.to_s
  546. rescue Mail::UnknownEncodingType # see test/data/mail/mail043.box / issue #348
  547. message.body.raw_source
  548. end
  549. body_text = body_text.utf8_encode(from: message.charset, fallback: :read_as_sanitized_binary)
  550. body_text = Mail::Utilities.to_lf(body_text)
  551. # plaintext body requires no processing
  552. return body_text if !options[:strict_html]
  553. # Issue #2390 - emails with >5k HTML links should be rejected
  554. return EXCESSIVE_LINKS_MSG if body_text.scan(%r{<a[[:space:]]}i).count >= 5_000
  555. body_text.html2html_strict
  556. end
  557. def collect_attachments(mail)
  558. attachments = []
  559. attachments.push(*get_nonplaintext_body_as_attachment(mail))
  560. mail.parts.each do |part|
  561. attachments.push(*gracefully_get_attachments(part, attachments, mail))
  562. end
  563. attachments
  564. end
  565. def get_nonplaintext_body_as_attachment(mail)
  566. if !(mail.html_part&.body.present? || (!mail.multipart? && mail.mime_type.present? && mail.mime_type != 'text/plain'))
  567. return
  568. end
  569. message = mail.html_part || mail
  570. if !mail.mime_type.starts_with?('text/') && mail.html_part.blank?
  571. return gracefully_get_attachments(message, [], mail)
  572. end
  573. filename = message.filename.presence || (message.mime_type.eql?('text/html') ? 'message.html' : '-no name-')
  574. headers_store = {
  575. 'content-alternative' => true,
  576. 'original-format' => message.mime_type.eql?('text/html'),
  577. 'Mime-Type' => message.mime_type,
  578. 'Charset' => message.charset,
  579. }.compact_blank
  580. [{
  581. data: body_text(message),
  582. filename: filename,
  583. preferences: headers_store
  584. }]
  585. end
  586. def gracefully_get_attachments(part, attachments, mail)
  587. get_attachments(part, attachments, mail).flatten.compact
  588. rescue => e # Protect process to work with spam emails (see test/fixtures/mail15.box)
  589. raise e if (fail_count ||= 0).positive?
  590. (fail_count += 1) && retry
  591. end
  592. def get_attachments(file, attachments, mail)
  593. return file.parts.map { |p| get_attachments(p, attachments, mail) } if file.parts.any?
  594. return [] if [mail.text_part&.body&.encoded, mail.html_part&.body&.encoded].include?(file.body.encoded)
  595. return [] if file.content_type&.start_with?('text/plain') && !file.attachment?
  596. # get file preferences
  597. headers_store = {}
  598. file.header.fields.each do |field|
  599. # full line, encode, ready for storage
  600. value = field.to_utf8
  601. if value.blank?
  602. value = field.raw_value
  603. end
  604. headers_store[field.name.to_s] = value
  605. rescue
  606. headers_store[field.name.to_s] = field.raw_value
  607. end
  608. # cleanup content id, <> will be added automatically later
  609. if headers_store['Content-ID'].blank? && headers_store['Content-Id'].present?
  610. headers_store['Content-ID'] = headers_store['Content-Id']
  611. end
  612. if headers_store['Content-ID']
  613. headers_store['Content-ID'].delete_prefix!('<')
  614. headers_store['Content-ID'].delete_suffix!('>')
  615. end
  616. # get filename from content-disposition
  617. # workaround for: NoMethodError: undefined method `filename' for #<Mail::UnstructuredField:0x007ff109e80678>
  618. begin
  619. filename = file.header[:content_disposition].try(:filename)
  620. rescue
  621. begin
  622. case file.header[:content_disposition].to_s
  623. when %r{(filename|name)(\*{0,1})="(.+?)"}i, %r{(filename|name)(\*{0,1})='(.+?)'}i, %r{(filename|name)(\*{0,1})=(.+?);}i
  624. filename = $3
  625. end
  626. rescue
  627. Rails.logger.debug { 'Unable to get filename' }
  628. end
  629. end
  630. begin
  631. case file.header[:content_disposition].to_s
  632. when %r{(filename|name)(\*{0,1})="(.+?)"}i, %r{(filename|name)(\*{0,1})='(.+?)'}i, %r{(filename|name)(\*{0,1})=(.+?);}i
  633. filename = $3
  634. end
  635. rescue
  636. Rails.logger.debug { 'Unable to get filename' }
  637. end
  638. # as fallback, use raw values
  639. if filename.blank?
  640. case headers_store['Content-Disposition'].to_s
  641. when %r{(filename|name)(\*{0,1})="(.+?)"}i, %r{(filename|name)(\*{0,1})='(.+?)'}i, %r{(filename|name)(\*{0,1})=(.+?);}i
  642. filename = $3
  643. end
  644. end
  645. # for some broken sm mail clients (X-MimeOLE: Produced By Microsoft Exchange V6.5)
  646. filename ||= file.header[:content_location].to_s.dup.force_encoding('utf-8')
  647. file_body = String.new(file.body.to_s)
  648. # generate file name based on content type
  649. if filename.blank? && headers_store['Content-Type'].present? && headers_store['Content-Type'].match?(%r{^message/rfc822}i)
  650. begin
  651. parser = Channel::EmailParser.new
  652. mail_local = parser.parse(file_body)
  653. filename = if mail_local[:subject].present?
  654. "#{mail_local[:subject]}.eml"
  655. elsif headers_store['Content-Description'].present?
  656. "#{headers_store['Content-Description']}.eml".to_s.force_encoding('utf-8')
  657. else
  658. 'Mail.eml'
  659. end
  660. rescue
  661. filename = 'Mail.eml'
  662. end
  663. end
  664. # e. g. Content-Type: video/quicktime; name="Video.MOV";
  665. if filename.blank?
  666. ['(filename|name)(\*{0,1})="(.+?)"(;|$)', '(filename|name)(\*{0,1})=\'(.+?)\'(;|$)', '(filename|name)(\*{0,1})=(.+?)(;|$)'].each do |regexp|
  667. if headers_store['Content-Type'] =~ %r{#{regexp}}i
  668. filename = $3
  669. break
  670. end
  671. end
  672. end
  673. # workaround for mail gem - decode filenames
  674. # https://github.com/zammad/zammad/issues/928
  675. if filename.present?
  676. filename = Mail::Encodings.value_decode(filename)
  677. end
  678. if !filename.force_encoding('UTF-8').valid_encoding?
  679. filename = filename.utf8_encode(fallback: :read_as_sanitized_binary)
  680. end
  681. # generate file name based on content-id with file extention
  682. if filename.blank? && headers_store['Content-ID'].present? && headers_store['Content-ID'] =~ %r{(.+?\..{2,6})@.+?}i
  683. filename = $1
  684. end
  685. # e. g. Content-Type: video/quicktime
  686. if filename.blank? && (content_type = headers_store['Content-Type'])
  687. map = {
  688. 'message/delivery-status': %w[txt delivery-status],
  689. 'text/plain': %w[txt document],
  690. 'text/html': %w[html document],
  691. 'video/quicktime': %w[mov video],
  692. 'image/jpeg': %w[jpg image],
  693. 'image/jpg': %w[jpg image],
  694. 'image/png': %w[png image],
  695. 'image/gif': %w[gif image],
  696. }
  697. map.each do |type, ext|
  698. next if !content_type.match?(%r{^#{Regexp.quote(type)}}i)
  699. filename = if headers_store['Content-Description'].present?
  700. "#{headers_store['Content-Description']}.#{ext[0]}".to_s.force_encoding('utf-8')
  701. else
  702. "#{ext[1]}.#{ext[0]}"
  703. end
  704. break
  705. end
  706. end
  707. # generate file name based on content-id without file extention
  708. if filename.blank? && headers_store['Content-ID'].present? && headers_store['Content-ID'] =~ %r{(.+?)@.+?}i
  709. filename = $1
  710. end
  711. # set fallback filename
  712. if filename.blank?
  713. filename = 'file'
  714. end
  715. # create uniq filename
  716. local_filename = ''
  717. local_extention = ''
  718. if filename =~ %r{^(.*?)\.(.+?)$}
  719. local_filename = $1
  720. local_extention = $2
  721. end
  722. 1.upto(1000) do |i|
  723. filename_exists = false
  724. attachments.each do |attachment|
  725. if attachment[:filename] == filename
  726. filename_exists = true
  727. end
  728. end
  729. break if filename_exists == false
  730. filename = if local_extention.present?
  731. "#{local_filename}#{i}.#{local_extention}"
  732. else
  733. "#{local_filename}#{i}"
  734. end
  735. end
  736. # get mime type
  737. if file.header[:content_type]&.string
  738. headers_store['Mime-Type'] = file.header[:content_type].string
  739. end
  740. # get charset
  741. if file.header&.charset
  742. headers_store['Charset'] = file.header.charset
  743. end
  744. # remove not needed header
  745. headers_store.delete('Content-Transfer-Encoding')
  746. headers_store.delete('Content-Disposition')
  747. attach = {
  748. data: file_body,
  749. filename: filename,
  750. preferences: headers_store,
  751. }
  752. [attach]
  753. end
  754. # Auto reply as the postmaster to oversized emails with:
  755. # [undeliverable] Message too large
  756. def postmaster_response(channel, msg)
  757. begin
  758. reply_mail = compose_postmaster_reply(msg)
  759. rescue NotificationFactory::FileNotFoundError => e
  760. Rails.logger.error "No valid postmaster email_oversized template found. Skipping postmaster reply. #{e.inspect}"
  761. return
  762. end
  763. Rails.logger.info "Send mail too large postmaster message to: #{reply_mail[:to]}"
  764. reply_mail[:from] = EmailAddress.find_by(channel: channel).email
  765. channel.deliver(reply_mail)
  766. rescue => e
  767. Rails.logger.error "Error during sending of postmaster oversized email auto-reply: #{e.inspect}\n#{e.backtrace}"
  768. end
  769. # Compose a "Message too large" reply to the given message
  770. def compose_postmaster_reply(raw_incoming_mail, locale = nil)
  771. parsed_incoming_mail = Channel::EmailParser.new.parse(raw_incoming_mail)
  772. # construct a dummy mail object
  773. mail = MESSAGE_STRUCT.new
  774. mail.from_display_name = parsed_incoming_mail[:from_display_name]
  775. mail.subject = parsed_incoming_mail[:subject]
  776. mail.msg_size = format('%<MB>.2f', MB: raw_incoming_mail.size.to_f / 1024 / 1024)
  777. reply = NotificationFactory::Mailer.template(
  778. template: 'email_oversized',
  779. locale: locale,
  780. format: 'txt',
  781. objects: {
  782. mail: mail,
  783. },
  784. raw: true, # will not add application template
  785. standalone: true, # default: false - will send header & footer
  786. )
  787. reply.merge(
  788. to: parsed_incoming_mail[:from_email],
  789. body: reply[:body].gsub(%r{\n}, "\r\n"),
  790. content_type: 'text/plain',
  791. References: parsed_incoming_mail[:message_id],
  792. 'In-Reply-To': parsed_incoming_mail[:message_id],
  793. )
  794. end
  795. def guess_email_fqdn(from)
  796. Mail::Address.new(from).domain.strip
  797. rescue
  798. nil
  799. end
  800. def generate_message_id(raw_message, from)
  801. fqdn = guess_email_fqdn(from) || 'zammad_generated'
  802. "<gen-#{Digest::MD5.hexdigest(raw_message)}@#{fqdn}>"
  803. end
  804. # https://github.com/zammad/zammad/issues/3096
  805. # specific email needs to be forced to ISO-2022-JP
  806. # but that breaks other emails that can be forced to SJIS only
  807. # thus force to ISO-2022-JP but fallback to SJIS
  808. #
  809. # https://github.com/zammad/zammad/issues/3368
  810. # some characters are not included in the official ISO-2022-JP
  811. # ISO-2022-JP-KDDI superset provides support for more characters
  812. def force_japanese_encoding(input)
  813. %w[ISO-2022-JP ISO-2022-JP-KDDI SJIS]
  814. .lazy
  815. .map { |encoding| try_encoding(input, encoding) }
  816. .detect(&:present?)
  817. end
  818. def try_encoding(input, encoding)
  819. input.force_encoding(encoding).encode('UTF-8')
  820. rescue
  821. nil
  822. end
  823. end
  824. module Mail
  825. # workaround to get content of no parseable headers - in most cases with non 7 bit ascii signs
  826. class Field
  827. def raw_value
  828. begin
  829. value = @raw_value.try(:utf8_encode)
  830. rescue
  831. value = @raw_value.utf8_encode(fallback: :read_as_sanitized_binary)
  832. end
  833. return value if value.blank?
  834. value.sub(%r{^.+?:(\s|)}, '')
  835. end
  836. end
  837. # issue#348 - IMAP mail fetching stops because of broken spam email (e. g. broken Content-Transfer-Encoding value see test/fixtures/mail43.box)
  838. # https://github.com/zammad/zammad/issues/348
  839. class Body
  840. def decoded
  841. if Encodings.defined?(encoding)
  842. Encodings.get_encoding(encoding).decode(raw_source)
  843. else
  844. Rails.logger.info "UnknownEncodingType: Don't know how to decode #{encoding}!"
  845. raw_source
  846. end
  847. end
  848. end
  849. end