email_parser.rb 32 KB

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