email_parser.rb 33 KB

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