email_parser.rb 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028
  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{.+@.+}.freeze
  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).reject(&: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\?(.+?)\?=}.freeze
  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
  468. value = f.decoded.to_utf8(fallback: :read_as_sanitized_binary)
  469. end
  470. [f.name.downcase, value]
  471. end
  472. # imported_fields = mail.header.fields.map { |f| [f.name.downcase, f.to_utf8] }.to_h
  473. raw_fields = mail.header.fields.index_by { |f| "raw-#{f.name.downcase}" }
  474. custom_fields = {}.tap do |h|
  475. h.replace(imported_fields.slice(*RECIPIENT_FIELDS)
  476. .transform_values { |v| v.match?(EMAIL_REGEX) ? v : '' })
  477. h['x-any-recipient'] = h.values.select(&:present?).join(', ')
  478. h['message_id'] = imported_fields['message-id']
  479. h['subject'] = imported_fields['subject']
  480. begin
  481. h['date'] = Time.zone.parse(mail.date.to_s) || imported_fields['date']
  482. rescue
  483. h['date'] = nil
  484. end
  485. end
  486. [imported_fields, raw_fields, custom_fields].reduce({}.with_indifferent_access, &:merge)
  487. end
  488. def message_body_hash(mail)
  489. if mail.html_part&.body.present?
  490. content_type = mail.html_part.mime_type || 'text/plain'
  491. body = body_text(mail.html_part, strict_html: true)
  492. elsif mail.text_part.present? && mail.all_parts.any? { |elem| elem.inline? && elem.content_type&.start_with?('image') }
  493. content_type = 'text/html'
  494. body = mail
  495. .all_parts
  496. .reduce('') do |memo, part|
  497. if part.mime_type == 'text/plain' && !part.attachment?
  498. memo += body_text(part, strict_html: false).text2html
  499. elsif part.inline? && part.content_type&.start_with?('image')
  500. memo += "<img src=\'cid:#{part.cid}\'>"
  501. end
  502. memo
  503. end
  504. elsif mail.text_part.present?
  505. content_type = 'text/plain'
  506. body = mail
  507. .all_parts
  508. .reduce('') do |memo, part|
  509. if part.mime_type == 'text/plain' && !part.attachment?
  510. memo += body_text(part, strict_html: false)
  511. end
  512. memo
  513. end
  514. elsif mail&.body.present? && (mail.mime_type.nil? || mail.mime_type.match?(%r{^text/(plain|html)$}))
  515. content_type = mail.mime_type || 'text/plain'
  516. body = body_text(mail, strict_html: content_type.eql?('text/html'))
  517. end
  518. content_type = 'text/plain' if body.blank?
  519. {
  520. attachments: collect_attachments(mail),
  521. content_type: content_type || 'text/plain',
  522. body: body.presence || 'no visible content'
  523. }.with_indifferent_access
  524. end
  525. def body_text(message, **options)
  526. body_text = begin
  527. message.body.to_s
  528. rescue Mail::UnknownEncodingType # see test/data/mail/mail043.box / issue #348
  529. message.body.raw_source
  530. end
  531. body_text = body_text.utf8_encode(from: message.charset, fallback: :read_as_sanitized_binary)
  532. body_text = Mail::Utilities.to_lf(body_text)
  533. # plaintext body requires no processing
  534. return body_text if !options[:strict_html]
  535. # Issue #2390 - emails with >5k HTML links should be rejected
  536. return EXCESSIVE_LINKS_MSG if body_text.scan(%r{<a[[:space:]]}i).count >= 5_000
  537. body_text.html2html_strict
  538. end
  539. def collect_attachments(mail)
  540. attachments = []
  541. attachments.push(*get_nonplaintext_body_as_attachment(mail))
  542. mail.parts.each do |part|
  543. attachments.push(*gracefully_get_attachments(part, attachments, mail))
  544. end
  545. attachments
  546. end
  547. def get_nonplaintext_body_as_attachment(mail)
  548. if !(mail.html_part&.body.present? || (!mail.multipart? && mail.mime_type.present? && mail.mime_type != 'text/plain'))
  549. return
  550. end
  551. message = mail.html_part || mail
  552. if !mail.mime_type.starts_with?('text/') && mail.html_part.blank?
  553. return gracefully_get_attachments(message, [], mail)
  554. end
  555. filename = message.filename.presence || (message.mime_type.eql?('text/html') ? 'message.html' : '-no name-')
  556. headers_store = {
  557. 'content-alternative' => true,
  558. 'original-format' => message.mime_type.eql?('text/html'),
  559. 'Mime-Type' => message.mime_type,
  560. 'Charset' => message.charset,
  561. }.reject { |_, v| v.blank? }
  562. [{
  563. data: body_text(message),
  564. filename: filename,
  565. preferences: headers_store
  566. }]
  567. end
  568. def gracefully_get_attachments(part, attachments, mail)
  569. get_attachments(part, attachments, mail).flatten.compact
  570. rescue => e # Protect process to work with spam emails (see test/fixtures/mail15.box)
  571. raise e if (fail_count ||= 0).positive?
  572. (fail_count += 1) && retry
  573. end
  574. def get_attachments(file, attachments, mail)
  575. return file.parts.map { |p| get_attachments(p, attachments, mail) } if file.parts.any?
  576. return [] if [mail.text_part&.body&.encoded, mail.html_part&.body&.encoded].include?(file.body.encoded)
  577. return [] if file.content_type&.start_with?('text/plain') && !file.attachment?
  578. # get file preferences
  579. headers_store = {}
  580. file.header.fields.each do |field|
  581. # full line, encode, ready for storage
  582. value = field.to_utf8
  583. if value.blank?
  584. value = field.raw_value
  585. end
  586. headers_store[field.name.to_s] = value
  587. rescue
  588. headers_store[field.name.to_s] = field.raw_value
  589. end
  590. # cleanup content id, <> will be added automatically later
  591. if headers_store['Content-ID'].blank? && headers_store['Content-Id'].present?
  592. headers_store['Content-ID'] = headers_store['Content-Id']
  593. end
  594. if headers_store['Content-ID']
  595. headers_store['Content-ID'].delete_prefix!('<')
  596. headers_store['Content-ID'].delete_suffix!('>')
  597. end
  598. # get filename from content-disposition
  599. # workaround for: NoMethodError: undefined method `filename' for #<Mail::UnstructuredField:0x007ff109e80678>
  600. begin
  601. filename = file.header[:content_disposition].try(:filename)
  602. rescue
  603. begin
  604. case file.header[:content_disposition].to_s
  605. when %r{(filename|name)(\*{0,1})="(.+?)"}i, %r{(filename|name)(\*{0,1})='(.+?)'}i, %r{(filename|name)(\*{0,1})=(.+?);}i
  606. filename = $3
  607. end
  608. rescue
  609. Rails.logger.debug { 'Unable to get filename' }
  610. end
  611. end
  612. begin
  613. case file.header[:content_disposition].to_s
  614. when %r{(filename|name)(\*{0,1})="(.+?)"}i, %r{(filename|name)(\*{0,1})='(.+?)'}i, %r{(filename|name)(\*{0,1})=(.+?);}i
  615. filename = $3
  616. end
  617. rescue
  618. Rails.logger.debug { 'Unable to get filename' }
  619. end
  620. # as fallback, use raw values
  621. if filename.blank?
  622. case headers_store['Content-Disposition'].to_s
  623. when %r{(filename|name)(\*{0,1})="(.+?)"}i, %r{(filename|name)(\*{0,1})='(.+?)'}i, %r{(filename|name)(\*{0,1})=(.+?);}i
  624. filename = $3
  625. end
  626. end
  627. # for some broken sm mail clients (X-MimeOLE: Produced By Microsoft Exchange V6.5)
  628. filename ||= file.header[:content_location].to_s.dup.force_encoding('utf-8')
  629. file_body = String.new(file.body.to_s)
  630. # generate file name based on content type
  631. if filename.blank? && headers_store['Content-Type'].present? && headers_store['Content-Type'].match?(%r{^message/rfc822}i)
  632. begin
  633. parser = Channel::EmailParser.new
  634. mail_local = parser.parse(file_body)
  635. filename = if mail_local[:subject].present?
  636. "#{mail_local[:subject]}.eml"
  637. elsif headers_store['Content-Description'].present?
  638. "#{headers_store['Content-Description']}.eml".to_s.force_encoding('utf-8')
  639. else
  640. 'Mail.eml'
  641. end
  642. rescue
  643. filename = 'Mail.eml'
  644. end
  645. end
  646. # e. g. Content-Type: video/quicktime; name="Video.MOV";
  647. if filename.blank?
  648. ['(filename|name)(\*{0,1})="(.+?)"(;|$)', '(filename|name)(\*{0,1})=\'(.+?)\'(;|$)', '(filename|name)(\*{0,1})=(.+?)(;|$)'].each do |regexp|
  649. if headers_store['Content-Type'] =~ %r{#{regexp}}i
  650. filename = $3
  651. break
  652. end
  653. end
  654. end
  655. # workaround for mail gem - decode filenames
  656. # https://github.com/zammad/zammad/issues/928
  657. if filename.present?
  658. filename = Mail::Encodings.value_decode(filename)
  659. end
  660. if !filename.force_encoding('UTF-8').valid_encoding?
  661. filename = filename.utf8_encode(fallback: :read_as_sanitized_binary)
  662. end
  663. # generate file name based on content-id with file extention
  664. if filename.blank? && headers_store['Content-ID'].present? && headers_store['Content-ID'] =~ %r{(.+?\..{2,6})@.+?}i
  665. filename = $1
  666. end
  667. # e. g. Content-Type: video/quicktime
  668. if filename.blank? && (content_type = headers_store['Content-Type'])
  669. map = {
  670. 'message/delivery-status': %w[txt delivery-status],
  671. 'text/plain': %w[txt document],
  672. 'text/html': %w[html document],
  673. 'video/quicktime': %w[mov video],
  674. 'image/jpeg': %w[jpg image],
  675. 'image/jpg': %w[jpg image],
  676. 'image/png': %w[png image],
  677. 'image/gif': %w[gif image],
  678. }
  679. map.each do |type, ext|
  680. next if !content_type.match?(%r{^#{Regexp.quote(type)}}i)
  681. filename = if headers_store['Content-Description'].present?
  682. "#{headers_store['Content-Description']}.#{ext[0]}".to_s.force_encoding('utf-8')
  683. else
  684. "#{ext[1]}.#{ext[0]}"
  685. end
  686. break
  687. end
  688. end
  689. # generate file name based on content-id without file extention
  690. if filename.blank? && headers_store['Content-ID'].present? && headers_store['Content-ID'] =~ %r{(.+?)@.+?}i
  691. filename = $1
  692. end
  693. # set fallback filename
  694. if filename.blank?
  695. filename = 'file'
  696. end
  697. # create uniq filename
  698. local_filename = ''
  699. local_extention = ''
  700. if filename =~ %r{^(.*?)\.(.+?)$}
  701. local_filename = $1
  702. local_extention = $2
  703. end
  704. 1.upto(1000) do |i|
  705. filename_exists = false
  706. attachments.each do |attachment|
  707. if attachment[:filename] == filename
  708. filename_exists = true
  709. end
  710. end
  711. break if filename_exists == false
  712. filename = if local_extention.present?
  713. "#{local_filename}#{i}.#{local_extention}"
  714. else
  715. "#{local_filename}#{i}"
  716. end
  717. end
  718. # get mime type
  719. if file.header[:content_type]&.string
  720. headers_store['Mime-Type'] = file.header[:content_type].string
  721. end
  722. # get charset
  723. if file.header&.charset
  724. headers_store['Charset'] = file.header.charset
  725. end
  726. # remove not needed header
  727. headers_store.delete('Content-Transfer-Encoding')
  728. headers_store.delete('Content-Disposition')
  729. attach = {
  730. data: file_body,
  731. filename: filename,
  732. preferences: headers_store,
  733. }
  734. [attach]
  735. end
  736. # Archive the given message as tmp/folder/md5.eml
  737. def archive_mail(folder, msg)
  738. path = Rails.root.join('tmp', folder)
  739. FileUtils.mkpath path
  740. # MD5 hash the msg and save it as "md5.eml"
  741. md5 = Digest::MD5.hexdigest(msg)
  742. file_path = Rails.root.join('tmp', folder, "#{md5}.eml")
  743. File.binwrite(file_path, msg)
  744. file_path
  745. end
  746. # Auto reply as the postmaster to oversized emails with:
  747. # [undeliverable] Message too large
  748. def postmaster_response(channel, msg)
  749. begin
  750. reply_mail = compose_postmaster_reply(msg)
  751. rescue NotificationFactory::FileNotFoundError => e
  752. Rails.logger.error "No valid postmaster email_oversized template found. Skipping postmaster reply. #{e.inspect}"
  753. return
  754. end
  755. Rails.logger.info "Send mail too large postmaster message to: #{reply_mail[:to]}"
  756. reply_mail[:from] = EmailAddress.find_by(channel: channel).email
  757. channel.deliver(reply_mail)
  758. rescue => e
  759. Rails.logger.error "Error during sending of postmaster oversized email auto-reply: #{e.inspect}\n#{e.backtrace}"
  760. end
  761. # Compose a "Message too large" reply to the given message
  762. def compose_postmaster_reply(raw_incoming_mail, locale = nil)
  763. parsed_incoming_mail = Channel::EmailParser.new.parse(raw_incoming_mail)
  764. # construct a dummy mail object
  765. mail = MESSAGE_STRUCT.new
  766. mail.from_display_name = parsed_incoming_mail[:from_display_name]
  767. mail.subject = parsed_incoming_mail[:subject]
  768. mail.msg_size = format('%<MB>.2f', MB: raw_incoming_mail.size.to_f / 1024 / 1024)
  769. reply = NotificationFactory::Mailer.template(
  770. template: 'email_oversized',
  771. locale: locale,
  772. format: 'txt',
  773. objects: {
  774. mail: mail,
  775. },
  776. raw: true, # will not add application template
  777. standalone: true, # default: false - will send header & footer
  778. )
  779. reply.merge(
  780. to: parsed_incoming_mail[:from_email],
  781. body: reply[:body].gsub(%r{\n}, "\r\n"),
  782. content_type: 'text/plain',
  783. References: parsed_incoming_mail[:message_id],
  784. 'In-Reply-To': parsed_incoming_mail[:message_id],
  785. )
  786. end
  787. def guess_email_fqdn(from)
  788. Mail::Address.new(from).domain.strip
  789. rescue
  790. nil
  791. end
  792. def generate_message_id(raw_message, from)
  793. fqdn = guess_email_fqdn(from) || 'zammad_generated'
  794. "<gen-#{Digest::MD5.hexdigest(raw_message)}@#{fqdn}>"
  795. end
  796. # https://github.com/zammad/zammad/issues/3096
  797. # specific email needs to be forced to ISO-2022-JP
  798. # but that breaks other emails that can be forced to SJIS only
  799. # thus force to ISO-2022-JP but fallback to SJIS
  800. #
  801. # https://github.com/zammad/zammad/issues/3368
  802. # some characters are not included in the official ISO-2022-JP
  803. # ISO-2022-JP-KDDI superset provides support for more characters
  804. def force_japanese_encoding(input)
  805. %w[ISO-2022-JP ISO-2022-JP-KDDI SJIS]
  806. .lazy
  807. .map { |encoding| try_encoding(input, encoding) }
  808. .detect(&:present?)
  809. end
  810. def try_encoding(input, encoding)
  811. input.force_encoding(encoding).encode('UTF-8')
  812. rescue
  813. nil
  814. end
  815. end
  816. module Mail
  817. # workaround to get content of no parseable headers - in most cases with non 7 bit ascii signs
  818. class Field
  819. def raw_value
  820. begin
  821. value = @raw_value.try(:utf8_encode)
  822. rescue
  823. value = @raw_value.utf8_encode(fallback: :read_as_sanitized_binary)
  824. end
  825. return value if value.blank?
  826. value.sub(%r{^.+?:(\s|)}, '')
  827. end
  828. end
  829. # issue#348 - IMAP mail fetching stops because of broken spam email (e. g. broken Content-Transfer-Encoding value see test/fixtures/mail43.box)
  830. # https://github.com/zammad/zammad/issues/348
  831. class Body
  832. def decoded
  833. if Encodings.defined?(encoding)
  834. Encodings.get_encoding(encoding).decode(raw_source)
  835. else
  836. Rails.logger.info "UnknownEncodingType: Don't know how to decode #{encoding}!"
  837. raw_source
  838. end
  839. end
  840. end
  841. end