article.rb 10 KB

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