email_parser.rb 31 KB

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