email_parser.rb 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. # Copyright (C) 2012-2013 Zammad Foundation, http://zammad-foundation.org/
  2. # encoding: utf-8
  3. require 'mail'
  4. require 'encode'
  5. class Channel::EmailParser
  6. =begin
  7. mail = parse( msg_as_string )
  8. mail = {
  9. :from => 'Some Name <some@example.com>',
  10. :from_email => 'some@example.com',
  11. :from_local => 'some',
  12. :from_domain => 'example.com',
  13. :from_display_name => 'Some Name',
  14. :message_id => 'some_message_id@example.com',
  15. :to => 'Some System <system@example.com>',
  16. :cc => 'Somebody <somebody@example.com>',
  17. :subject => 'some message subject',
  18. :body => 'some message body',
  19. :attachments => [
  20. {
  21. :data => 'binary of attachment',
  22. :filename => 'file_name_of_attachment.txt',
  23. :preferences => {
  24. :content-alternative => true,
  25. :Mime-Type => 'text/plain',
  26. :Charset => 'iso-8859-1',
  27. },
  28. },
  29. ],
  30. # ignore email header
  31. :x-zammad-ignore => 'false',
  32. # customer headers
  33. :x-zammad-customer-login => '',
  34. :x-zammad-customer-email => '',
  35. :x-zammad-customer-firstname => '',
  36. :x-zammad-customer-lastname => '',
  37. # ticket headers
  38. :x-zammad-group => 'some_group',
  39. :x-zammad-state => 'some_state',
  40. :x-zammad-priority => 'some_priority',
  41. :x-zammad-owner => 'some_owner_login',
  42. # article headers
  43. :x-zammad-article-visibility => 'internal',
  44. :x-zammad-article-type => 'agent',
  45. :x-zammad-article-sender => 'customer',
  46. # all other email headers
  47. :some-header => 'some_value',
  48. }
  49. =end
  50. def parse (msg)
  51. data = {}
  52. mail = Mail.new( msg )
  53. # set all headers
  54. mail.header.fields.each { |field|
  55. data[field.name.downcase.to_sym] = Encode.conv( 'utf8', field.to_s )
  56. }
  57. # set extra headers
  58. data[:from_email] = Mail::Address.new( mail[:from].value ).address
  59. data[:from_local] = Mail::Address.new( mail[:from].value ).local
  60. data[:from_domain] = Mail::Address.new( mail[:from].value ).domain
  61. data[:from_display_name] = Mail::Address.new( mail[:from].value ).display_name ||
  62. ( Mail::Address.new( mail[:from].value ).comments && Mail::Address.new( mail[:from].value ).comments[0] )
  63. # do extra decoding because we needed to use field.value
  64. data[:from_display_name] = Mail::Field.new( 'X-From', data[:from_display_name] ).to_s
  65. # compat headers
  66. data[:message_id] = data['message-id'.to_sym]
  67. # body
  68. # plain_part = mail.multipart? ? (mail.text_part ? mail.text_part.body.decoded : nil) : mail.body.decoded
  69. # html_part = message.html_part ? message.html_part.body.decoded : nil
  70. data[:attachments] = []
  71. # multi part email
  72. if mail.multipart?
  73. # text attachment/body exists
  74. if mail.text_part
  75. data[:body] = mail.text_part.body.decoded
  76. data[:body] = Encode.conv( mail.text_part.charset, data[:body] )
  77. # html attachment/body may exists and will be converted to text
  78. else
  79. filename = '-no name-'
  80. if mail.html_part.body
  81. filename = 'html-email'
  82. data[:body] = mail.html_part.body.to_s
  83. data[:body] = Encode.conv( mail.html_part.charset.to_s, data[:body] )
  84. data[:body] = html2ascii( data[:body] )
  85. # any other attachments
  86. else
  87. data[:body] = 'no visible content'
  88. end
  89. end
  90. # add html attachment/body as real attachment
  91. if mail.html_part
  92. filename = 'message.html'
  93. headers_store = {
  94. 'content-alternative' => true,
  95. }
  96. if mail.mime_type
  97. headers_store['Mime-Type'] = mail.html_part.mime_type
  98. end
  99. if mail.charset
  100. headers_store['Charset'] = mail.html_part.charset
  101. end
  102. attachment = {
  103. :data => mail.html_part.body.to_s,
  104. :filename => mail.html_part.filename || filename,
  105. :preferences => headers_store
  106. }
  107. data[:attachments].push attachment
  108. end
  109. # get attachments
  110. if mail.parts
  111. attachment_count_total = 0
  112. mail.parts.each { |part|
  113. attachment_count_total += 1
  114. # protect process to work fine with spam emails, see test/fixtures/mail15.box
  115. begin
  116. if mail.text_part && mail.text_part == part
  117. # ignore text/plain attachments - already shown in view
  118. elsif mail.html_part && mail.html_part == part
  119. # ignore text/html - html part, already shown in view
  120. else
  121. attachs = self._get_attachment( part, data[:attachments] )
  122. data[:attachments].concat( attachs )
  123. end
  124. rescue
  125. attachs = self._get_attachment( part, data[:attachments] )
  126. data[:attachments].concat( attachs )
  127. end
  128. }
  129. end
  130. # not multipart email
  131. else
  132. # text part
  133. if !mail.mime_type || mail.mime_type.to_s == '' || mail.mime_type.to_s.downcase == 'text/plain'
  134. data[:body] = mail.body.decoded
  135. data[:body] = Encode.conv( mail.charset, data[:body] )
  136. # html part
  137. else
  138. filename = '-no name-'
  139. if mail.mime_type.to_s.downcase == 'text/html'
  140. filename = 'html-email'
  141. data[:body] = mail.body.decoded
  142. data[:body] = Encode.conv( mail.charset, data[:body] )
  143. data[:body] = html2ascii( data[:body] )
  144. # any other attachments
  145. else
  146. data[:body] = 'no visible content'
  147. end
  148. # add body as attachment
  149. headers_store = {
  150. 'content-alternative' => true,
  151. }
  152. if mail.mime_type
  153. headers_store['Mime-Type'] = mail.mime_type
  154. end
  155. if mail.charset
  156. headers_store['Charset'] = mail.charset
  157. end
  158. attachment = {
  159. :data => mail.body.decoded,
  160. :filename => mail.filename || filename,
  161. :preferences => headers_store
  162. }
  163. data[:attachments].push attachment
  164. end
  165. end
  166. # strip not wanted chars
  167. data[:body].gsub!( /\r\n/, "\n" )
  168. data[:body].gsub!( /\r/, "\n" )
  169. return data
  170. end
  171. def _get_attachment( file, attachments )
  172. # check if sub parts are available
  173. if !file.parts.empty?
  174. a = []
  175. file.parts.each {|p|
  176. a.concat( self._get_attachment( p, attachments ) )
  177. }
  178. return a
  179. end
  180. # get file preferences
  181. headers_store = {}
  182. file.header.fields.each { |field|
  183. headers_store[field.name.to_s] = field.value.to_s
  184. }
  185. # get filename from content-disposition
  186. filename = nil
  187. # workaround for: NoMethodError: undefined method `filename' for #<Mail::UnstructuredField:0x007ff109e80678>
  188. begin
  189. filename = file.header[:content_disposition].filename
  190. rescue
  191. result = file.header[:content_disposition].to_s.scan( /filename=("|)(.+?)("|);/i )
  192. if result && result[0] && result[0][1]
  193. filename = result[0][1]
  194. end
  195. end
  196. # for some broken sm mail clients (X-MimeOLE: Produced By Microsoft Exchange V6.5)
  197. if !filename
  198. filename = file.header[:content_location].to_s
  199. end
  200. # generate file name
  201. if !filename || filename.empty?
  202. attachment_count = 0
  203. (1..1000).each {|count|
  204. filename_exists = false
  205. filename = 'file-' + count.to_s
  206. attachments.each {|attachment|
  207. if attachment[:filename] == filename
  208. filename_exists = true
  209. end
  210. }
  211. break if filename_exists == false
  212. }
  213. end
  214. # get mime type
  215. if file.header[:content_type] && file.header[:content_type].string
  216. headers_store['Mime-Type'] = file.header[:content_type].string
  217. end
  218. # get charset
  219. if file.header && file.header.charset
  220. headers_store['Charset'] = file.header.charset
  221. end
  222. # remove not needed header
  223. headers_store.delete('Content-Transfer-Encoding')
  224. headers_store.delete('Content-Disposition')
  225. attach = {
  226. :data => file.body.to_s,
  227. :filename => filename,
  228. :preferences => headers_store,
  229. }
  230. return [attach]
  231. end
  232. def process(channel, msg)
  233. mail = parse( msg )
  234. # run postmaster pre filter
  235. filters = {
  236. '0010' => Channel::Filter::Trusted,
  237. '1000' => Channel::Filter::Database,
  238. }
  239. # filter( channel, mail )
  240. filters.each {|prio, backend|
  241. begin
  242. backend.run( channel, mail )
  243. rescue Exception => e
  244. puts "can't run postmaster pre filter #{backend}"
  245. puts e.inspect
  246. return false
  247. end
  248. }
  249. # check ignore header
  250. return true if mail[ 'x-zammad-ignore'.to_sym ] == 'true' || mail[ 'x-zammad-ignore'.to_sym ] == true
  251. ticket = nil
  252. article = nil
  253. user = nil
  254. # use transaction
  255. ActiveRecord::Base.transaction do
  256. # reset current_user
  257. UserInfo.current_user_id = 1
  258. if mail[ 'x-zammad-customer-login'.to_sym ]
  259. user = User.where( :login => mail[ 'x-zammad-customer-login'.to_sym ] ).first
  260. end
  261. if !user
  262. user = User.where( :email => mail[ 'x-zammad-customer-email'.to_sym ] || mail[:from_email] ).first
  263. end
  264. if !user
  265. puts 'create user...'
  266. roles = Role.where( :name => 'Customer' )
  267. user = User.create(
  268. :login => mail[ 'x-zammad-customer-login'.to_sym ] || mail[ 'x-zammad-customer-email'.to_sym ] || mail[:from_email],
  269. :firstname => mail[ 'x-zammad-customer-firstname'.to_sym ] || mail[:from_display_name],
  270. :lastname => mail[ 'x-zammad-customer-lastname'.to_sym ],
  271. :email => mail[ 'x-zammad-customer-email'.to_sym ] || mail[:from_email],
  272. :password => '',
  273. :active => true,
  274. :roles => roles,
  275. :updated_by_id => 1,
  276. :created_by_id => 1,
  277. )
  278. end
  279. # set current user
  280. UserInfo.current_user_id = user.id
  281. # get ticket# from subject
  282. ticket = Ticket::Number.check( mail[:subject] )
  283. # set ticket state to open if not new
  284. if ticket
  285. ticket_state = Ticket::State.find( ticket.ticket_state_id )
  286. ticket_state_type = Ticket::StateType.find( ticket_state.state_type_id )
  287. # if tickte is merged, find linked ticket
  288. if ticket_state_type.name == 'merged'
  289. end
  290. if ticket_state_type.name != 'new'
  291. ticket.ticket_state = Ticket::State.where( :name => 'open' ).first
  292. ticket.save
  293. end
  294. end
  295. # create new ticket
  296. if !ticket
  297. # set attributes
  298. ticket_attributes = {
  299. :group_id => channel[:group_id] || 1,
  300. :customer_id => user.id,
  301. :title => mail[:subject] || '',
  302. :ticket_state_id => Ticket::State.where( :name => 'new' ).first.id,
  303. :ticket_priority_id => Ticket::Priority.where( :name => '2 normal' ).first.id,
  304. }
  305. # x-headers lookup
  306. map = [
  307. [ 'x-zammad-group', Group, 'group_id', 'name' ],
  308. [ 'x-zammad-state', Ticket::State, 'ticket_state_id', 'name' ],
  309. [ 'x-zammad-priority', Ticket::Priority, 'ticket_priority_id', 'name' ],
  310. [ 'x-zammad-owner', User, 'owner_id', 'login' ],
  311. ]
  312. object_lookup( ticket_attributes, map, mail )
  313. # create ticket
  314. ticket = Ticket.create( ticket_attributes )
  315. end
  316. # import mail
  317. # set attributes
  318. internal = false
  319. if mail[ 'X-Zammad-Article-Visibility'.to_sym ] && mail[ 'X-Zammad-Article-Visibility'.to_sym ] == 'internal'
  320. internal = true
  321. end
  322. article_attributes = {
  323. :ticket_id => ticket.id,
  324. :ticket_article_type_id => Ticket::Article::Type.where( :name => 'email' ).first.id,
  325. :ticket_article_sender_id => Ticket::Article::Sender.where( :name => 'Customer' ).first.id,
  326. :body => mail[:body],
  327. :from => mail[:from],
  328. :to => mail[:to],
  329. :cc => mail[:cc],
  330. :subject => mail[:subject],
  331. :message_id => mail[:message_id],
  332. :internal => internal,
  333. }
  334. # x-headers lookup
  335. map = [
  336. [ 'x-zammad-article-type', Ticket::Article::Type, 'ticket_article_type_id', 'name' ],
  337. [ 'x-zammad-article-sender', Ticket::Article::Sender, 'ticket_article_sender_id', 'name' ],
  338. ]
  339. object_lookup( article_attributes, map, mail )
  340. # create article
  341. article = Ticket::Article.create(article_attributes)
  342. # store mail plain
  343. Store.add(
  344. :object => 'Ticket::Article::Mail',
  345. :o_id => article.id,
  346. :data => msg,
  347. :filename => "ticket-#{ticket.number}-#{article.id}.eml",
  348. :preferences => {}
  349. )
  350. # store attachments
  351. if mail[:attachments]
  352. mail[:attachments].each do |attachment|
  353. Store.add(
  354. :object => 'Ticket::Article',
  355. :o_id => article.id,
  356. :data => attachment[:data],
  357. :filename => attachment[:filename],
  358. :preferences => attachment[:preferences]
  359. )
  360. end
  361. end
  362. end
  363. # execute ticket events
  364. Observer::Ticket::Notification.transaction
  365. # run postmaster post filter
  366. filters = {
  367. # '0010' => Channel::Filter::Trusted,
  368. }
  369. # filter( channel, mail )
  370. filters.each {|prio, backend|
  371. begin
  372. backend.run( channel, mail, ticket, article, user )
  373. rescue Exception => e
  374. puts "can't run postmaster post filter #{backend}"
  375. puts e.inspect
  376. end
  377. }
  378. # return new objects
  379. return ticket, article, user
  380. end
  381. def object_lookup( attributes, map, mail )
  382. map.each { |item|
  383. if mail[ item[0].to_sym ]
  384. new_object = item[1].where( "lower(#{item[3]}) = ?", mail[ item[0].to_sym ].downcase ).first
  385. if new_object
  386. attributes[ item[2].to_sym ] = new_object.id
  387. end
  388. end
  389. }
  390. end
  391. def html2ascii(string)
  392. # find <a href=....> and replace it with [x]
  393. link_list = ''
  394. counter = 0
  395. string.gsub!( /<a\s.*?href=("|')(.+?)("|').*?>/ix ) { |item|
  396. link = $2
  397. counter = counter + 1
  398. link_list += "[#{counter}] #{link}\n"
  399. "[#{counter}]"
  400. }
  401. # remove empty lines
  402. string.gsub!( /^\s*/m, '' )
  403. # fix some bad stuff from opera and others
  404. string.gsub!( /(\n\r|\r\r\n|\r\n)/, "\n" )
  405. # strip all other tags
  406. string.gsub!( /\<(br|br\/|br\s\/)\>/, "\n" )
  407. # strip all other tags
  408. string.gsub!( /\<.+?\>/, '' )
  409. # encode html entities like "&#8211;"
  410. string.gsub!( /(&\#(\d+);?)/x ) { |item|
  411. $2.chr
  412. }
  413. # encode html entities like "&#3d;"
  414. string.gsub!( /(&\#[xX]([0-9a-fA-F]+);?)/x ) { |item|
  415. chr_orig = $1
  416. hex = $2.hex
  417. if hex
  418. chr = hex.chr
  419. if chr
  420. chr
  421. else
  422. chr_orig
  423. end
  424. else
  425. chr_orig
  426. end
  427. }
  428. # remove empty lines
  429. string.gsub!( /^\s*\n\s*\n/m, "\n" )
  430. # add extracted links
  431. if link_list
  432. string += "\n\n" + link_list
  433. end
  434. return string
  435. end
  436. end
  437. # workaround to parse subjects with 2 different encodings correctly (e. g. quoted-printable see test/fixtures/mail9.box)
  438. module Mail
  439. module Encodings
  440. def Encodings.value_decode(str)
  441. # Optimization: If there's no encoded-words in the string, just return it
  442. return str unless str.index("=?")
  443. str = str.gsub(/\?=(\s*)=\?/, '?==?') # Remove whitespaces between 'encoded-word's
  444. # Split on white-space boundaries with capture, so we capture the white-space as well
  445. str.split(/([ \t])/).map do |text|
  446. if text.index('=?') .nil?
  447. text
  448. else
  449. # Join QP encoded-words that are adjacent to avoid decoding partial chars
  450. # text.gsub!(/\?\=\=\?.+?\?[Qq]\?/m, '') if text =~ /\?==\?/
  451. # Search for occurences of quoted strings or plain strings
  452. text.scan(/( # Group around entire regex to include it in matches
  453. \=\?[^?]+\?([QB])\?[^?]+?\?\= # Quoted String with subgroup for encoding method
  454. | # or
  455. .+?(?=\=\?|$) # Plain String
  456. )/xmi).map do |matches|
  457. string, method = *matches
  458. if method == 'b' || method == 'B'
  459. b_value_decode(string)
  460. elsif method == 'q' || method == 'Q'
  461. q_value_decode(string)
  462. else
  463. string
  464. end
  465. end
  466. end
  467. end.join("")
  468. end
  469. end
  470. end