email_parser.rb 32 KB

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