article.rb 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
  2. class Ticket::Article < ApplicationModel
  3. include CanBeImported
  4. include HasActivityStreamLog
  5. include ChecksClientNotification
  6. include HasHistory
  7. include ChecksHtmlSanitized
  8. include CanCsvImport
  9. include HasObjectManagerAttributesValidation
  10. include Ticket::Article::ChecksAccess
  11. include Ticket::Article::Assets
  12. belongs_to :ticket, optional: true
  13. has_one :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', foreign_key: :ticket_article_id, dependent: :destroy, inverse_of: :ticket_article
  14. belongs_to :type, class_name: 'Ticket::Article::Type', optional: true
  15. belongs_to :sender, class_name: 'Ticket::Article::Sender', optional: true
  16. belongs_to :created_by, class_name: 'User', optional: true
  17. belongs_to :updated_by, class_name: 'User', optional: true
  18. belongs_to :origin_by, class_name: 'User', optional: true
  19. before_create :check_subject, :check_body, :check_message_id_md5
  20. before_update :check_subject, :check_body, :check_message_id_md5
  21. after_destroy :store_delete
  22. store :preferences
  23. validates :ticket_id, presence: true
  24. validates :type_id, presence: true
  25. validates :sender_id, presence: true
  26. sanitized_html :body
  27. activity_stream_permission 'ticket.agent'
  28. activity_stream_attributes_ignored :type_id,
  29. :sender_id,
  30. :preferences
  31. history_attributes_ignored :type_id,
  32. :sender_id,
  33. :preferences,
  34. :message_id,
  35. :from,
  36. :to,
  37. :cc
  38. attr_accessor :should_clone_inline_attachments
  39. alias should_clone_inline_attachments? should_clone_inline_attachments
  40. # fillup md5 of message id to search easier on very long message ids
  41. def check_message_id_md5
  42. return true if message_id.blank?
  43. self.message_id_md5 = Digest::MD5.hexdigest(message_id.to_s)
  44. end
  45. =begin
  46. insert inline image urls to body
  47. article_attributes = Ticket::Article.insert_urls(article_attributes)
  48. returns
  49. article_attributes_with_body_and_urls
  50. =end
  51. def self.insert_urls(article)
  52. return article if article['attachments'].blank?
  53. return article if !article['content_type'].match?(%r{text/html}i)
  54. return article if article['body'] !~ /<img/i
  55. inline_attachments = {}
  56. article['body'].gsub!( /(<img[[:space:]](|.+?)src=")cid:(.+?)"(|.+?)>/im ) do |item|
  57. tag_start = $1
  58. cid = $3
  59. tag_end = $4
  60. replace = item
  61. # look for attachment
  62. article['attachments'].each do |file|
  63. next if !file[:preferences] || !file[:preferences]['Content-ID'] || (file[:preferences]['Content-ID'] != cid && file[:preferences]['Content-ID'] != "<#{cid}>" )
  64. replace = "#{tag_start}/api/v1/ticket_attachment/#{article['ticket_id']}/#{article['id']}/#{file[:id]}?view=inline\"#{tag_end}>"
  65. inline_attachments[file[:id]] = true
  66. break
  67. end
  68. replace
  69. end
  70. new_attachments = []
  71. article['attachments'].each do |file|
  72. next if inline_attachments[file[:id]]
  73. new_attachments.push file
  74. end
  75. article['attachments'] = new_attachments
  76. article
  77. end
  78. =begin
  79. get inline attachments of article
  80. article = Ticket::Article.find(123)
  81. attachments = article.attachments_inline
  82. returns
  83. [attachment1, attachment2, ...]
  84. =end
  85. def attachments_inline
  86. inline_attachments = {}
  87. body.gsub( /<img[[:space:]](|.+?)src="cid:(.+?)"(|.+?)>/im ) do |_item|
  88. cid = $2
  89. # look for attachment
  90. attachments.each do |file|
  91. content_id = file.preferences['Content-ID'] || file.preferences['content_id']
  92. next if content_id.blank? || (content_id != cid && content_id != "<#{cid}>" )
  93. inline_attachments[file.id] = true
  94. break
  95. end
  96. end
  97. new_attachments = []
  98. attachments.each do |file|
  99. next if !inline_attachments[file.id]
  100. new_attachments.push file
  101. end
  102. new_attachments
  103. end
  104. =begin
  105. clone existing attachments of article to the target object
  106. article_parent = Ticket::Article.find(123)
  107. article_new = Ticket::Article.find(456)
  108. attached_attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_attached_attachments: true)
  109. inline_attachments = article_parent.clone_attachments(article_new.class.name, article_new.id, only_inline_attachments: true)
  110. returns
  111. [attachment1, attachment2, ...]
  112. =end
  113. def clone_attachments(object_type, object_id, options = {})
  114. existing_attachments = Store.list(
  115. object: object_type,
  116. o_id: object_id,
  117. )
  118. is_html_content = false
  119. if content_type.present? && content_type =~ %r{text/html}i
  120. is_html_content = true
  121. end
  122. new_attachments = []
  123. attachments.each do |new_attachment|
  124. next if new_attachment.preferences['content-alternative'] == true
  125. # only_attached_attachments mode is used by apply attached attachments to forwared article
  126. if options[:only_attached_attachments] == true
  127. if is_html_content == true
  128. content_id = new_attachment.preferences['Content-ID'] || new_attachment.preferences['content_id']
  129. next if content_id.present? && body.present? && body.match?(/#{Regexp.quote(content_id)}/i)
  130. end
  131. end
  132. # only_inline_attachments mode is used when quoting HTML mail with #{article.body_as_html}
  133. if options[:only_inline_attachments] == true
  134. next if is_html_content == false
  135. next if body.blank?
  136. content_disposition = new_attachment.preferences['Content-Disposition'] || new_attachment.preferences['content_disposition']
  137. next if content_disposition.present? && content_disposition !~ /inline/
  138. content_id = new_attachment.preferences['Content-ID'] || new_attachment.preferences['content_id']
  139. next if content_id.blank?
  140. next if !body.match?(/#{Regexp.quote(content_id)}/i)
  141. end
  142. already_added = false
  143. existing_attachments.each do |existing_attachment|
  144. next if existing_attachment.filename != new_attachment.filename || existing_attachment.size != new_attachment.size
  145. already_added = true
  146. break
  147. end
  148. next if already_added == true
  149. file = Store.add(
  150. object: object_type,
  151. o_id: object_id,
  152. data: new_attachment.content,
  153. filename: new_attachment.filename,
  154. preferences: new_attachment.preferences,
  155. )
  156. new_attachments.push file
  157. end
  158. new_attachments
  159. end
  160. def self.last_customer_agent_article(ticket_id)
  161. sender = Ticket::Article::Sender.lookup(name: 'System')
  162. Ticket::Article.where('ticket_id = ? AND sender_id NOT IN (?)', ticket_id, sender.id).order(created_at: :desc).first
  163. end
  164. =begin
  165. get body as html
  166. article = Ticket::Article.find(123)
  167. article.body_as_html
  168. =end
  169. def body_as_html
  170. return '' if !body
  171. return body if content_type && content_type =~ %r{text/html}i
  172. body.text2html
  173. end
  174. =begin
  175. get body as text
  176. article = Ticket::Article.find(123)
  177. article.body_as_text
  178. =end
  179. def body_as_text
  180. return '' if !body
  181. return body if content_type.blank? || content_type =~ %r{text/plain}i
  182. body.html2text
  183. end
  184. =begin
  185. get body as text with quote sign "> " at the beginning of each line
  186. article = Ticket::Article.find(123)
  187. article.body_as_text
  188. =end
  189. def body_as_text_with_quote
  190. body_as_text.word_wrap.message_quote
  191. end
  192. =begin
  193. get article as raw (e. g. if it's a email, the raw email)
  194. article = Ticket::Article.find(123)
  195. article.as_raw
  196. returns:
  197. file # Store
  198. =end
  199. def as_raw
  200. list = Store.list(
  201. object: 'Ticket::Article::Mail',
  202. o_id: id,
  203. )
  204. return if list.blank?
  205. list[0]
  206. end
  207. =begin
  208. save article as raw (e. g. if it's a email, the raw email)
  209. article = Ticket::Article.find(123)
  210. article.save_as_raw(msg)
  211. returns:
  212. file # Store
  213. =end
  214. def save_as_raw(msg)
  215. Store.add(
  216. object: 'Ticket::Article::Mail',
  217. o_id: id,
  218. data: msg,
  219. filename: "ticket-#{ticket.number}-#{id}.eml",
  220. preferences: {},
  221. created_by_id: created_by_id,
  222. )
  223. end
  224. def sanitizeable?(attribute, _value)
  225. return true if attribute != :body
  226. return false if content_type.blank?
  227. content_type =~ /html/i
  228. end
  229. =begin
  230. get relation name of model based on params
  231. model = Model.find(1)
  232. attributes = model.attributes_with_association_names
  233. returns
  234. hash with attributes, association ids, association names and relation name
  235. =end
  236. def attributes_with_association_names
  237. attributes = super
  238. add_attachments_to_attributes(attributes)
  239. Ticket::Article.insert_urls(attributes)
  240. end
  241. =begin
  242. get relations of model based on params
  243. model = Model.find(1)
  244. attributes = model.attributes_with_association_ids
  245. returns
  246. hash with attributes and association ids
  247. =end
  248. def attributes_with_association_ids
  249. attributes = super
  250. add_attachments_to_attributes(attributes)
  251. if attributes['body'] && attributes['content_type'] =~ %r{text/html}i
  252. attributes['body'] = HtmlSanitizer.dynamic_image_size(attributes['body'])
  253. end
  254. Ticket::Article.insert_urls(attributes)
  255. end
  256. private
  257. def add_attachments_to_attributes(attributes)
  258. attributes['attachments'] = attachments.map(&:attributes_for_display)
  259. attributes
  260. end
  261. # strip not wanted chars
  262. def check_subject
  263. return true if subject.blank?
  264. subject.gsub!(/\s|\t|\r/, ' ')
  265. true
  266. end
  267. # strip body length or raise exception
  268. def check_body
  269. return true if body.blank?
  270. limit = 1_500_000
  271. current_length = body.length
  272. return true if body.length <= limit
  273. raise Exceptions::UnprocessableEntity, "body of article is too large, #{current_length} chars - only #{limit} allowed" if !ApplicationHandleInfo.postmaster?
  274. logger.warn "WARNING: cut string because of database length #{self.class}.body(#{limit} but is #{current_length})"
  275. self.body = body[0, limit]
  276. end
  277. def history_log_attributes
  278. {
  279. related_o_id: self['ticket_id'],
  280. related_history_object: 'Ticket',
  281. }
  282. end
  283. # callback function to overwrite
  284. # default history stream log attributes
  285. # gets called from activity_stream_log
  286. def activity_stream_log_attributes
  287. {
  288. group_id: Ticket.find(ticket_id).group_id,
  289. }
  290. end
  291. # delete attachments and mails of article
  292. def store_delete
  293. Store.remove(
  294. object: 'Ticket::Article',
  295. o_id: id,
  296. )
  297. Store.remove(
  298. object: 'Ticket::Article::Mail',
  299. o_id: id,
  300. )
  301. end
  302. end