123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423 |
- # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- class Ticket::Article < ApplicationModel
- include HasDefaultModelUserRelations
- include CanBeImported
- include HasActivityStreamLog
- include ChecksClientNotification
- include HasHistory
- include ChecksHtmlSanitized
- include CanCsvImport
- include CanCloneAttachments
- include HasObjectManagerAttributes
- include Ticket::Article::Assets
- include Ticket::Article::EnqueueCommunicateEmailJob
- include Ticket::Article::EnqueueCommunicateFacebookJob
- include Ticket::Article::EnqueueCommunicateSmsJob
- include Ticket::Article::EnqueueCommunicateTelegramJob
- include Ticket::Article::EnqueueCommunicateTwitterJob
- include Ticket::Article::EnqueueCommunicateWhatsappJob
- include Ticket::Article::HasTicketContactAttributesImpact
- include Ticket::Article::ResetsTicketState
- include Ticket::Article::TriggersSubscriptions
- # AddsMetadataGeneral depends on AddsMetadataOriginById, so load that first
- include Ticket::Article::AddsMetadataOriginById
- include Ticket::Article::AddsMetadataGeneral
- include Ticket::Article::AddsMetadataEmail
- include Ticket::Article::AddsMetadataWhatsapp
- include HasTransactionDispatcher
- belongs_to :ticket, optional: true
- has_one :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', foreign_key: :ticket_article_id, dependent: :destroy, inverse_of: :ticket_article
- belongs_to :type, class_name: 'Ticket::Article::Type', optional: true
- belongs_to :sender, class_name: 'Ticket::Article::Sender', optional: true
- belongs_to :origin_by, class_name: 'User', optional: true
- before_validation :check_mentions, on: :create
- before_create :check_subject, :check_body, :check_message_id_md5
- before_update :check_subject, :check_body, :check_message_id_md5
- after_destroy :store_delete, :update_time_units
- after_commit :ticket_touch, if: :persisted?
- store :preferences
- validates :ticket_id, presence: true
- validates :type_id, presence: true
- validates :sender_id, presence: true
- validates_with Validations::TicketArticleValidator
- sanitized_html :body
- activity_stream_permission 'ticket.agent'
- activity_stream_attributes_ignored :type_id,
- :sender_id,
- :preferences
- history_attributes_ignored :type_id,
- :sender_id,
- :preferences,
- :message_id,
- :from,
- :to,
- :cc
- attr_accessor :should_clone_inline_attachments, :check_mentions_raises_error
- alias should_clone_inline_attachments? should_clone_inline_attachments
- # fillup md5 of message id to search easier on very long message ids
- def check_message_id_md5
- return true if message_id.blank?
- self.message_id_md5 = Digest::MD5.hexdigest(message_id.to_s)
- end
- =begin
- insert inline image urls to body
- article_attributes = Ticket::Article.insert_urls(article_attributes)
- returns
- article_attributes_with_body_and_urls
- =end
- def self.insert_urls(article)
- return article if article['attachments'].blank?
- return article if !article['content_type'].match?(%r{text/html}i)
- return article if article['body'] !~ %r{<img}i
- inline_attachments = {}
- article['body'].gsub!(%r{(<img[[:space:]](|.+?)src=")cid:(.+?)"(|.+?)>}im) do |item|
- tag_start = $1
- cid = $3
- tag_end = $4
- replace = item
- # look for attachment
- article['attachments'].each do |file|
- next if !file[:preferences] || !file[:preferences]['Content-ID'] || (file[:preferences]['Content-ID'] != cid && file[:preferences]['Content-ID'] != "<#{cid}>")
- replace = "#{tag_start}/api/v1/ticket_attachment/#{article['ticket_id']}/#{article['id']}/#{file[:id]}?view=inline\"#{tag_end}>"
- inline_attachments[file[:id]] = true
- break
- end
- replace
- end
- new_attachments = []
- article['attachments'].each do |file|
- next if inline_attachments[file[:id]]
- new_attachments.push file
- end
- article['attachments'] = new_attachments
- article
- end
- =begin
- get inline attachments of article
- article = Ticket::Article.find(123)
- attachments = article.attachments_inline
- returns
- [attachment1, attachment2, ...]
- =end
- def attachments_inline
- inline_attachments = {}
- body.gsub(%r{<img[[:space:]](|.+?)src="cid:(.+?)"(|.+?)>}im) do |_item|
- cid = $2
- # look for attachment
- attachments.each do |file|
- content_id = file.preferences['Content-ID'] || file.preferences['content_id']
- next if content_id.blank? || (content_id != cid && content_id != "<#{cid}>")
- inline_attachments[file.id] = true
- break
- end
- end
- new_attachments = []
- attachments.each do |file|
- next if !inline_attachments[file.id]
- new_attachments.push file
- end
- new_attachments
- end
- def self.last_customer_agent_article(ticket_id)
- sender = Ticket::Article::Sender.lookup(name: 'System')
- Ticket::Article.where('ticket_id = ? AND sender_id NOT IN (?)', ticket_id, sender.id).reorder(created_at: :desc).first
- end
- =begin
- The originator (origin_by, if any) or the creator of an article.
- =end
- def author
- origin_by || created_by
- end
- =begin
- get body as html
- article = Ticket::Article.find(123)
- article.body_as_html
- =end
- def body_as_html
- return '' if !body
- return body if content_type && content_type =~ %r{text/html}i
- body.text2html
- end
- =begin
- get body as text
- article = Ticket::Article.find(123)
- article.body_as_text
- =end
- def body_as_text
- return '' if !body
- return body if content_type.blank? || content_type =~ %r{text/plain}i
- body.html2text
- end
- =begin
- get body as text with quote sign "> " at the beginning of each line
- article = Ticket::Article.find(123)
- article.body_as_text
- =end
- def body_as_text_with_quote
- body_as_text.word_wrap.message_quote
- end
- =begin
- get article as raw (e. g. if it's a email, the raw email)
- article = Ticket::Article.find(123)
- article.as_raw
- returns:
- file # Store
- =end
- def as_raw
- list = Store.list(
- object: 'Ticket::Article::Mail',
- o_id: id,
- )
- return if list.blank?
- list[0]
- end
- =begin
- save article as raw (e. g. if it's a email, the raw email)
- article = Ticket::Article.find(123)
- article.save_as_raw(msg)
- returns:
- file # Store
- =end
- def save_as_raw(msg)
- Store.create!(
- object: 'Ticket::Article::Mail',
- o_id: id,
- data: msg,
- filename: "ticket-#{ticket.number}-#{id}.eml",
- preferences: {},
- created_by_id: created_by_id,
- )
- end
- def sanitizeable?(attribute, _value)
- return true if attribute != :body
- return false if content_type.blank?
- content_type =~ %r{html}i
- end
- =begin
- get relation name of model based on params
- model = Model.find(1)
- attributes = model.attributes_with_association_names
- returns
- hash with attributes, association ids, association names and relation name
- =end
- def attributes_with_association_names(empty_keys: false)
- attributes = super
- add_attachments_to_attributes(attributes)
- add_time_unit_to_attributes(attributes)
- Ticket::Article.insert_urls(attributes)
- end
- =begin
- get relations of model based on params
- model = Model.find(1)
- attributes = model.attributes_with_association_ids
- returns
- hash with attributes and association ids
- =end
- def attributes_with_association_ids
- attributes = super
- add_attachments_to_attributes(attributes)
- if attributes['body'] && attributes['content_type'] =~ %r{text/html}i
- attributes['body'] = Rails.cache.fetch("#{self.class}/#{cache_key_with_version}/body/dynamic_image_size") do
- HtmlSanitizer.dynamic_image_size(attributes['body'])
- end
- end
- Ticket::Article.insert_urls(attributes)
- end
- private
- def add_attachments_to_attributes(attributes)
- attributes['attachments'] = attachments.map(&:attributes_for_display)
- attributes
- end
- def add_time_unit_to_attributes(attributes)
- attributes['time_unit'] = ticket_time_accounting&.time_unit.presence || nil
- attributes
- end
- # strip not wanted chars
- def check_subject
- return true if subject.blank?
- subject.gsub!(%r{\s|\t|\r}, ' ')
- true
- end
- # strip body length or raise exception
- def check_body
- return true if body.blank?
- limit = 1_500_000
- current_length = body.length
- return true if body.length <= limit
- raise Exceptions::UnprocessableEntity, "body of article is too large, #{current_length} chars - only #{limit} allowed" if !ApplicationHandleInfo.postmaster? && !Setting.get('import_mode')
- logger.warn "WARNING: cut string because of database length #{self.class}.body(#{limit} but is #{current_length}) - ticket_id(#{ticket_id})"
- self.body = body[0, limit]
- end
- def check_mentions
- begin
- mention_user_ids = Nokogiri::HTML(body).css('a[data-mention-user-id]').pluck('data-mention-user-id')
- rescue => e
- Rails.logger.error "Can't parse body '#{body}' as HTML for extracting Mentions."
- Rails.logger.error e
- return
- end
- return if mention_user_ids.blank?
- begin
- Pundit.authorize updated_by, ticket, :create_mentions?
- rescue Pundit::NotAuthorizedError => e
- return if ApplicationHandleInfo.postmaster?
- return if updated_by.id == 1
- return if !check_mentions_raises_error
- raise e
- end
- mention_user_ids.each do |user_id|
- begin
- Mention.subscribe! ticket, User.find(user_id)
- rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid => e
- next if ApplicationHandleInfo.postmaster?
- next if updated_by.id == 1
- next if !check_mentions_raises_error
- raise e
- end
- end
- end
- def history_log_attributes
- {
- related_o_id: self['ticket_id'],
- related_history_object: 'Ticket',
- }
- end
- # callback function to overwrite
- # default history stream log attributes
- # gets called from activity_stream_log
- def activity_stream_log_attributes
- {
- group_id: Ticket.find(ticket_id).group_id,
- }
- end
- # delete attachments and mails of article
- def store_delete
- Store.remove(
- object: 'Ticket::Article',
- o_id: id,
- )
- Store.remove(
- object: 'Ticket::Article::Mail',
- o_id: id,
- )
- end
- # recalculate time accounting
- def update_time_units
- Ticket::TimeAccounting.update_ticket(ticket)
- end
- def ticket_touch
- ticket.touch # rubocop:disable Rails/SkipsModelValidations
- end
- end
|