email_parser.rb 34 KB

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