email_parser.rb 33 KB

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