article.rb 11 KB

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