article.rb 11 KB

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