Website : rimsha.abasa.com
backdoor
Home
Console
Upload
information
Create File
Create Folder
About
Tools
:
/
var
/
canvas
/
app
/
models
/
Filename :
message.rb
back
Copy
# frozen_string_literal: true # # Copyright (C) 2011 - present Instructure, Inc. # # This file is part of Canvas. # # Canvas is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License as published by the Free # Software Foundation, version 3 of the License. # # Canvas is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR # A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along # with this program. If not, see <http://www.gnu.org/licenses/>. # class Message < ActiveRecord::Base # Included modules include Rails.application.routes.url_helpers include ERB::Util include SendToStream include TextHelper include HtmlTextHelper include Workflow include RruleHelper include Messages::PeerReviewsHelper include Messages::SendStudentNamesHelper include CanvasPartman::Concerns::Partitioned self.partitioning_strategy = :by_date self.partitioning_interval = :weeks extend TextHelper MAX_TWITTER_MESSAGE_LENGTH = 140 class QueuedNotFound < StandardError; end class Queued # use this to queue messages for delivery so we find them using the created_at in the scope # instead of using id alone when reconstituting the AR object attr_accessor :id, :created_at def initialize(id, created_at) @id, @created_at = id, created_at end delegate :dispatch_at, to: :message def deliver message.deliver rescue QueuedNotFound raise Delayed::RetriableError, "Message does not (yet?) exist" end def message return @message if @message.present? @message = Message.in_partition("id" => id, "created_at" => @created_at).where(id: @id, created_at: @created_at).first || Message.where(id: @id).first raise QueuedNotFound if @message.nil? @message end end def for_queue Queued.new(id, created_at) end # Associations belongs_to :communication_channel belongs_to :context, polymorphic: [], exhaustive: false include NotificationPreloader belongs_to :user belongs_to :root_account, class_name: "Account" has_many :attachments, as: :context, inverse_of: :context attr_writer :delayed_messages attr_accessor :output_buffer # Callbacks after_save :stage_message before_save :infer_defaults before_save :move_dashboard_messages before_save :move_messages_for_deleted_users before_validation :truncate_invalid_message # Validations validate :prevent_updates validates :body, length: { maximum: maximum_text_length }, allow_blank: true validates :html_body, length: { maximum: maximum_text_length }, allow_blank: true validates :transmission_errors, length: { maximum: maximum_text_length }, allow_blank: true validates :to, length: { maximum: maximum_text_length }, allow_blank: true validates :from, length: { maximum: maximum_text_length }, allow_blank: true validates :url, length: { maximum: maximum_text_length }, allow_blank: true validates :subject, length: { maximum: maximum_text_length }, allow_blank: true validates :from_name, length: { maximum: maximum_text_length }, allow_blank: true validates :reply_to_name, length: { maximum: maximum_string_length }, allow_blank: true def prevent_updates unless new_record? # e.g. Message.where(:id => self.id, :created_at => self.created_at).update_all(...) errors.add(:base, "Regular saving on messages is disabled - use save_using_update_all") end end # Stream policy on_create_send_to_streams do if to == "dashboard" && Notification.types_to_show_in_feed.include?(notification_name) user_id else [] end end # State machine workflow do state :created do event :stage, transitions_to: :staged do self.dispatch_at = Time.now.utc + delay_for if to != "dashboard" MessageDispatcher.dispatch(self) end end event :set_transmission_error, transitions_to: :transmission_error event :cancel, transitions_to: :cancelled event :close, transitions_to: :closed # needed for dashboard messages end state :staged do event :dispatch, transitions_to: :sending event :set_transmission_error, transitions_to: :transmission_error event :cancel, transitions_to: :cancelled event :close, transitions_to: :closed # needed for dashboard messages end state :sending do event :complete_dispatch, transitions_to: :sent do self.sent_at ||= Time.now end event :set_transmission_error, transitions_to: :transmission_error event :cancel, transitions_to: :cancelled event :close, transitions_to: :closed event :errored_dispatch, transitions_to: :staged do # A little delay so we don't churn so much when the server is down. self.dispatch_at = Time.now.utc + 5.minutes end end state :sent do event :set_transmission_error, transitions_to: :transmission_error event :close, transitions_to: :closed event :bounce, transitions_to: :bounced do # Permenant reminder that this bounced. communication_channel.bounce_count += 1 communication_channel.save! self.is_bounced = true end event :recycle, transitions_to: :staged end state :bounced do event :close, transitions_to: :closed end state :dashboard do event :set_transmission_error, transitions_to: :transmission_error event :close, transitions_to: :closed event :cancel, transitions_to: :closed end state :cancelled state :transmission_error do event :close, transitions_to: :closed end state :closed do event :set_transmission_error, transitions_to: :transmission_error event :send_message, transitions_to: :closed do self.sent_at ||= Time.now end end end # turns out we can override this method inside the workflow gem to get a custom save for workflow transitions def persist_workflow_state(new_state) self.workflow_state = new_state save_using_update_all end def save_using_update_all shard.activate do self.updated_at = Time.now.utc updates = changes_to_save.transform_values(&:last) self.class.in_partition(attributes).where(id:, created_at:).update_all(updates) clear_changes_information end end # Named scopes scope :for, ->(context) { where(context:) } scope :after, ->(date) { where("messages.created_at>?", date) } scope :more_recent_than, ->(date) { where("messages.created_at>? AND messages.dispatch_at>?", date, date) } scope :to_dispatch, lambda { where("messages.workflow_state='staged' AND messages.dispatch_at<=? AND 'messages.to'<>'dashboard'", Time.now.utc) } scope :to_email, -> { where(path_type: ["email", "sms"]) } scope :not_to_email, -> { where("messages.path_type NOT IN ('email', 'sms')") } scope :by_name, ->(notification_name) { where(notification_name:) } scope :before, ->(date) { where("messages.created_at<?", date) } scope :for_user, ->(user) { where(user_id: user) } # messages that can be moved to the 'cancelled' state. dashboard messages # can be closed by calling 'cancel', but aren't included scope :cancellable, -> { where(workflow_state: %w[created staged sending]) } # For finding a very particular message: # Message.for(context).by_name(name).directed_to(to).for_user(user), or # messages.for(context).by_name(name).directed_to(to).for_user(user) # Where user can be a User or id, name needs to be the Notification name. scope :staged, -> { where("messages.workflow_state='staged' AND messages.dispatch_at>?", Time.now.utc) } scope :in_state, ->(state) { where(workflow_state: Array(state).map(&:to_s)) } scope :at_timestamp, ->(timestamp) { where(created_at: Time.at(timestamp.to_i)...Time.at(timestamp.to_i + 1)) } # an optimization for queries that would otherwise target the main table to # make them target the specific partition table. Naturally this only works if # the records all reside within the same partition!!! # # for example, this takes us from: # # Message.where(id: 3) # => SELECT "messages".* FROM "messages" WHERE "messages"."id" = 3 # to: # # Message.in_partition(Message.last.attributes).where(id: 3) # => SELECT "messages_2020_35".* FROM "messages_2020_35" WHERE "messages_2020_35"."id" = 3 # scope :in_partition, lambda { |attrs| dup.instance_eval do tap do @table = klass.arel_table_from_key_values(attrs) @predicate_builder = predicate_builder.dup @predicate_builder.instance_variable_set(:@table, ActiveRecord::TableMetadata.new(klass, @table)) end end } # Public: Helper methods for grabbing a user via the "from" field and using it to # populate the avatar, name, and email in the conversation email notification def author @_author ||= if author_context.has_attribute?(:user_id) User.find(context.user_id) elsif author_context.has_attribute?(:author_id) User.find(context.author_id) else nil end end def author_context # the user_id on a mention is the user that was mentioned instead of the # author of the message. context.is_a?(Mention) ? context.discussion_entry : context end def avatar_enabled? return false unless author_account.present? author_account.service_enabled?(:avatars) end def author_account # Root account is populated during save return nil unless author.present? root_account_id ? Account.find(root_account_id) : author.account end def author_avatar_url if context.is_a?(DiscussionEntry) && context.discussion_topic.anonymous? return "https://canvas.instructure.com/images/messages/avatar-50.png" end url = author.try(:avatar_url) # The User model currently supports storing either a path or full # URL for an avatar. Because of this, alternatives to URI::DEFAULT_PARSER.escape # such as CGI.escape end up escaping too much for full URLs. In # order to escape just the path, we'd need to utilize URI.parse # which can't handle URLs with spaces. As that is the root cause # of this change, we'll just use the deprecated URI::DEFAULT_PARSER.escape method. # # rubocop:disable Lint/UriEscapeUnescape URI.join("#{HostUrl.protocol}://#{HostUrl.context_host(author_account)}", URI::DEFAULT_PARSER.escape(url)).to_s if url # rubocop:enable Lint/UriEscapeUnescape end def author_short_name if context.is_a?(DiscussionEntry) && context.discussion_topic.anonymous? return context.author_name end author.try(:short_name) end def author_email_address if context.is_a?(DiscussionEntry) && context.discussion_topic.anonymous? return nil end if context_root_account.try(:author_email_in_notifications?) author.try(:email) end end # Public: Helper to generate a URI for the given subject. Overrides Rails' # built-in ActionController::PolymorphicRoutes#polymorphic_url method because # it forces option defaults for protocol and host. def default_url_options { protocol: HostUrl.protocol, host: HostUrl.context_host(link_root_account, ApplicationController.test_cluster_name) } end # Public: Helper to generate JSON suitable for publishing via Amazon SNS # # Currently pulls data from email template contents # # Returns a JSON string def sns_json @sns_json ||= begin custom_data = { html_url: url, user_id: user.global_id } custom_data[:api_url] = content(:api_url) if content(:api_url) # no templates define this right now { default: subject, GCM: { data: { alert: subject, }.merge(custom_data) }.to_json, APNS_SANDBOX: { aps: { alert: subject } }.merge(custom_data).to_json, APNS: { aps: { alert: subject } }.merge(custom_data).to_json }.to_json end end # overwrite existing html_to_text so that messages with links can have the ids # translated to be shard aware while preserving the link_root_account for the # host. def html_to_text(html, *opts) super(transpose_url_ids(html), *opts) end # overwrite existing html_to_simple_html so that messages with links can have # the ids translated to be shard aware while preserving the link_root_account # for the host. def html_to_simple_html(html, *opts) super(transpose_url_ids(html), *opts) end def transpose_url_ids(html) url_helper = Api::Html::UrlProxy.new(self, context, HostUrl.context_host(link_root_account), HostUrl.protocol, target_shard: link_root_account.shard) Api::Html::Content.rewrite_outgoing(html, link_root_account, url_helper) end # infer a root account associated with the context that the user can log in to def link_root_account(pre_loaded_account: nil) context = pre_loaded_account @root_account ||= begin context ||= self.context if context.is_a?(CommunicationChannel) && @data&.root_account_id root_account = Account.where(id: @data.root_account_id).first context = root_account if root_account end # root_account is on lots of objects, use it when we can. context = context.root_account if context.respond_to?(:root_account) # some of these `context =` may not be relevant now that we have # root_account on many classes, but root_account doesn't respond to them # and so it's fast, and there are a lot of ways to generate a message. context = context.assignment.root_account if context.respond_to?(:assignment) && context.assignment context = context.rubric_association.context if context.respond_to?(:rubric_association) && context.rubric_association context = context.appointment_group.contexts.first if context.respond_to?(:appointment_group) && context.appointment_group context = context.master_template.course if context.respond_to?(:master_template) && context.master_template context = context.context if context.respond_to?(:context) context = context.account if context.respond_to?(:account) context = context.root_account if context.respond_to?(:root_account) # Going through SisPseudonym.for is important since the account could change if context.respond_to?(:root_account) p = SisPseudonym.for(user, context, type: :implicit, require_sis: false) context = p.account if p else # nothing? okay, just something the user can log in to context = user.pseudonym.try(:account) context ||= self.context end context end end # infer a root account time zone def root_account_time_zone link_root_account.time_zone if link_root_account.respond_to?(:time_zone) end # Internal: Store any transmission errors in the database to help with later # debugging. # # val - An error string. # # Returns nothing. def transmission_errors=(val) write_attribute(:transmission_errors, val[0, self.class.maximum_text_length]) end # Public: Custom getter that delegates and caches notification category to # associated notification # # Returns a notification category string. def notification_category @cat ||= notification.try(:category) end # Public: Return associated notification's display category. # # Returns notification display category string. def notification_display_category notification.try(:display_category) end # Public: Skip message dispatch during stage transition. Used when batch # dispatching. # # Returns nothing. def stage_without_dispatch! return if state == :bounced self.dispatch_at = Time.now.utc + delay_for self.workflow_state = "staged" end # Public: Stage the message during the dispatch process. Messages travel # from created -> staged -> sending -> sent. # # Returns nothing. def stage_message stage if state == :created if dashboard? messages = Message.in_state(:dashboard).where( notification_id:, context_id:, context_type:, user_id: ) (messages - [self]).each(&:close) end end # acts like safe buffer except for the actually being safe part class UnescapedBuffer def initialize(buffer = "") @raw_buffer = String.new(buffer) @raw_buffer.encode! end delegate :concat, :<<, :length, :empty?, :blank?, :encoding, :encode!, :force_encoding, to: :@raw_buffer def to_s @raw_buffer.dup end alias_method :html_safe, :to_s alias_method :to_str, :to_s def html_safe? true end alias_method :append=, :<< alias_method :safe_concat, :concat alias_method :safe_append=, :concat end module OutputBufferDeleteSuffix def delete_suffix(str) self.class.new(@raw_buffer.delete_suffix(str)) end end UnescapedBuffer.include(OutputBufferDeleteSuffix) ActionView::OutputBuffer.include(OutputBufferDeleteSuffix) # Public: Store content in a message_content_... instance variable. # # name - The symbol name of the content. # # Returns an empty string. def define_content(name) if name == :subject || name == :user_name old_output_buffer, @output_buffer = [@output_buffer, UnescapedBuffer.new] else old_output_buffer, @output_buffer = [@output_buffer, @output_buffer.class.new] end yield instance_variable_set(:"@message_content_#{name}", @output_buffer.to_s.strip) @output_buffer = old_output_buffer.delete_suffix("\n") if old_output_buffer.is_a?(ActiveSupport::SafeBuffer) && old_output_buffer.html_safe? @output_buffer = old_output_buffer.class.new(@output_buffer) end "" end # Public: Get a message_content_... instance variable. # # name - The name of the message content variable as a symbol. # # Returns value of instance variable (should be a string?). def content(name) instance_variable_get(:"@message_content_#{name}") end # Public: Custom getter for @message_content_link. # # Returns string content from @message_content_link. def main_link content(:link) end # Public: Load a message template from app/messages. Also sets @i18n_scope. # # filename - The string path to the template (e.g. "/var/web/canvas/app/messages/template.email.erb") # # Returns a template string or false if it can't be found. def get_template(filename) path = Canvas::MessageHelper.find_message_path(filename) unless (File.exist?(path) rescue false) return false if filename.include?("slack") filename = notification.name.downcase.gsub(/\s/, "_") + ".email.erb" path = Canvas::MessageHelper.find_message_path(filename) end @i18n_scope = "messages." + filename.delete_suffix(".erb") if (File.exist?(path) rescue false) File.read(path) else false end end # Public: Get the template name based on the path type. # # path_type - The path to send the message across, e.g, 'email'. # # Returns file name for erb template def template_filename(path_type = nil) notification.name.parameterize.underscore + "." + path_type + ".erb" end # rubocop:disable Security/Eval ERB rendering # Public: Apply an HTML email template to this message. # # Returns an HTML template (or nil). def apply_html_template(binding) orig_i18n_scope = @i18n_scope @i18n_scope = "#{@i18n_scope}.html" template, template_path = load_html_template return nil unless template # Add the attribute 'inner_html' with the value of inner_html into the _binding @output_buffer = ActionView::OutputBuffer.new inner_html = eval(ActionView::Template::Handlers::ERB::Erubi.new(template, bufvar: "@output_buffer").src, binding, template_path) setter = eval "inner_html = nil; lambda { |v| inner_html = v }", binding, __FILE__, __LINE__ setter.call(inner_html) layout_path = Canvas::MessageHelper.find_message_path("_layout.email.html.erb") @output_buffer = ActionView::OutputBuffer.new eval(ActionView::Template::Handlers::ERB::Erubi.new(File.read(layout_path)).src, binding, layout_path) ensure @i18n_scope = orig_i18n_scope end def load_html_template html_file = template_filename("email.html") html_path = Canvas::MessageHelper.find_message_path(html_file) [File.read(html_path), html_path] if File.exist?(html_path) end # Public: Assign the body, subject and url to the message. # # message_body_template - Raw template body # path_type - Path to send the message across, e.g, 'email'. # # Returns message body def populate_body(message_body_template, path_type, binding, filename) # Build the body content based on the path type self.body = eval(Erubi::Engine.new(message_body_template, bufvar: "@output_buffer").src, binding, filename) self.html_body = apply_html_template(binding) if path_type == "email" # Append a footer to the body if the path type is email if path_type == "email" footer_path = Canvas::MessageHelper.find_message_path("_email_footer.email.erb") raw_footer_message = File.read(footer_path) footer_message = eval(Erubi::Engine.new(raw_footer_message, bufvar: "@output_buffer").src, nil, footer_path) # currently, _email_footer.email.erb only contains a way for users to change notification prefs # they can only change it if they are registered in the first place # do not show this for emails telling users to register if footer_message.present? && !notification&.registration? self.body = <<~TEXT #{body} ________________________________________ #{footer_message} TEXT end end body end # Public: Prepare a message for delivery by setting body, subject, etc. # # path_type - The path to send the message across, e.g, 'email'. # # Returns nothing. def parse!(path_type = nil, root_account: nil) raise StandardError, "Cannot parse without a context" unless context # set @root_account using our pre_loaded_account, because link_root_account # is called many times. link_root_account(pre_loaded_account: root_account) # Get the users timezone but maintain the original timezone in order to set it back at the end original_time_zone = Time.zone.name || "UTC" user_time_zone = user.try(:time_zone) || root_account_time_zone || original_time_zone Time.zone = user_time_zone # (temporarily) override course name with user's nickname for the course hacked_course = apply_course_nickname_to_asset(context, user) path_type ||= communication_channel.try(:path_type) || "email" # Determine the message template file to be used in the message filename = template_filename(path_type) message_body_template = get_template(filename) if !message_body_template && path_type == "slack" filename = template_filename("sms") message_body_template = get_template(filename) end context, asset, user, delayed_messages, data = [self.context, self.context, self.user, @delayed_messages, @data] link_root_account.shard.activate do if message_body_template.present? populate_body(message_body_template, path_type, binding, filename) # Set the subject and url self.subject = @message_content_subject || t("#message.default_subject", "Canvas Alert") self.url = @message_content_link || nil else # Message doesn't exist so we flag the message as an error self.subject = eval(Erubi::Engine.new(subject).src) self.body = eval(Erubi::Engine.new(body).src) self.transmission_errors = "couldn't find #{Canvas::MessageHelper.find_message_path(filename)}" end end body ensure # Set the timezone back to what it originally was Time.zone = original_time_zone if original_time_zone.present? hacked_course&.apply_nickname_for!(nil) @i18n_scope = nil end # rubocop:enable Security/Eval # Public: Deliver this message. # # Returns nothing. def deliver # don't dispatch canceled or already-sent messages. return nil unless dispatch unless path_type.present? logger.warn "Could not find a path type for #{inspect}" return nil end if path_type == "slack" && !context_root_account.settings[:encrypted_slack_key] logger.warn("Could not send slack message without configured key") return nil end check_acct = infer_feature_account return skip_and_cancel if path_type == "sms" if path_type == "push" && (Notification.types_to_send_in_push.exclude?(notification_name) || !check_acct.enable_push_notifications?) return skip_and_cancel end InstStatsd::Statsd.increment("message.deliver.#{path_type}.#{notification_name}", short_stat: "message.deliver", tags: { path_type:, notification_name: }) global_account_id = Shard.global_id_for(root_account_id, shard) InstStatsd::Statsd.increment("message.deliver.#{path_type}.#{global_account_id}", short_stat: "message.deliver_per_account", tags: { path_type:, root_account_id: global_account_id }) if check_acct.feature_enabled?(:notification_service) enqueue_to_sqs else delivery_method = :"deliver_via_#{path_type}" if !delivery_method || !respond_to?(delivery_method, true) logger.warn("Could not set delivery_method from #{path_type}") return nil end send(delivery_method) end end def skip_and_cancel InstStatsd::Statsd.increment("message.skip.#{path_type}.#{notification_name}", short_stat: "message.skip", tags: { path_type:, notification_name: }) cancel end # Public: Enqueues a message to the notification_service's sqs queue # # Returns nothing def enqueue_to_sqs targets = notification_targets if targets.empty? # Log no_targets_specified error to DataDog InstStatsd::Statsd.increment("message.no_targets_specified", short_stat: "message.no_targets_specified", tags: { path_type: }) self.transmission_errors = "No notification targets specified" set_transmission_error else targets.each do |target| Services::NotificationService.process( notification_service_id, notification_message, path_type, target, notification&.priority? ) end complete_dispatch end rescue => e Canvas::Errors.capture( e, message: "Message delivery failed", to:, object: inspect.to_s ) error_string = "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}" self.transmission_errors = error_string errored_dispatch raise end # Public: Determines the message body for a notification endpoint # # Returns target notification message body def notification_message case path_type when "email" Mailer.create_message(self).to_s when "push" sns_json when "twitter" url = main_link || self.url message_length = MAX_TWITTER_MESSAGE_LENGTH - url.length - 1 truncated_body = HtmlTextHelper.strip_and_truncate(body, max_length: message_length) "#{truncated_body} #{url}" else if to =~ /^\+[0-9]+$/ || path_type == "slack" body else Mailer.create_message(self).to_s end end end # Public: Returns all notification_service targets to send to # # Returns the targets in which to send the notification to def notification_targets case path_type when "push" user.notification_endpoints.select("DISTINCT ON (token, arn) *").map(&:arn) when "twitter" twitter_service = user.user_services.where(service: "twitter").first return [] unless twitter_service [ "access_token" => twitter_service.token, "access_token_secret" => twitter_service.secret, "user_id" => twitter_service.service_user_id ] when "slack" [ "recipient" => to, "access_token" => Canvas::Security.decrypt_password(context_root_account.settings[:encrypted_slack_key], context_root_account.settings[:encrypted_slack_key_salt], "instructure_slack_encrypted_key") ] else [to] end end # Public: Fetch the dashboard messages for the given messages. # # messages - An array of message objects. # # Returns an array of dashboard messages. def self.dashboard_messages(messages) message_types = messages.inject({}) do |types, message| type = message.notification.category rescue "Other" if type.present? types[type] ||= [] types[type] << message end hash end # not sure what this is even doing? message_types.to_a.sort_by { |m| (m[0] == "Other") ? CanvasSort::Last : m[0] } end # Public: Message to use if the message is unavailable to send. # # Returns a string def self.unavailable_message I18n.t("message preview unavailable") end # Public: Get the root account of this message's context. # # Returns an account. def context_root_account if context.is_a?(AccountNotification) return context.account.root_account end unbounded_loop_paranoia_counter = 10 current_context = context until current_context.respond_to?(:root_account) return nil if unbounded_loop_paranoia_counter <= 0 || current_context.nil? return nil unless current_context.respond_to?(:context) current_context = current_context.context unbounded_loop_paranoia_counter -= 1 end current_context.root_account end # This is a dumb name, but it's the context (course/group/account/user) of # the message.context (which should really be message.asset) def context_context @context_context ||= begin unbounded_loop_paranoia_counter = 10 current_context = context loop do break if unbounded_loop_paranoia_counter.zero? || current_context.nil? || current_context.is_a_context? current_context = current_context.try(:context) unbounded_loop_paranoia_counter -= 1 end current_context end end def media_context context = self.context context = context.context if context.respond_to?(:context) return context if context.is_a?(Course) (context.respond_to?(:course) && context.course) ? context.course : link_root_account end def notification_service_id "#{global_id}+#{created_at.to_i}" end def self.parse_notification_service_id(service_id) if service_id.to_s.include?("+") service_id.split("+") else [service_id, nil] end end def custom_logo context_root_account && context_root_account.settings[:email_logo] end # Internal: Set default values before save. # # Returns true. def infer_defaults if notification self.notification_name ||= notification.name end self.path_type ||= communication_channel.try(:path_type) self.path_type = "summary" if to == "dashboard" self.path_type = "email" if context_type == "ErrorReport" self.to_email = true if %w[email sms].include?(path_type) root_account = context_root_account self.root_account_id ||= root_account.try(:id) self.from_name = infer_from_name self.reply_to_name = name_helper.reply_to_name true end # Public: Convenience method for translation calls. # # key - The translation key. # default - The English default of the key. # options - An options hash passed to translate (default: {}). # # Returns a translated string. def translate(*args) key, options = I18nliner::CallHelpers.infer_arguments(args) # Add scope if it's present in the model and missing from the key. if !options[:i18nliner_inferred_key] && @i18n_scope && key !~ /\A#/ key = "##{@i18n_scope}.#{key}" end super(key, options) end alias_method :t, :translate # Public: Store data on the message for use at delivery-time. # # values_hash - A hash of values to store in the model's data attribute. # # Returns nothing. def data=(values_hash) @data = OpenStruct.new(values_hash) end # Public: Before save, close this message if it has no user or a deleted # user and isn't for an ErrorReport. # # Returns nothing. def move_messages_for_deleted_users if context_type != "ErrorReport" && (!user || user.deleted?) self.workflow_state = "closed" end end # Public: Truncate the message if it exceeds 64kb # # Returns nothing. def truncate_invalid_message [:body, :html_body].each do |attr| if send(attr) && send(attr).bytesize > self.class.maximum_text_length send(:"#{attr}=", Message.unavailable_message) end end end # Public: Before save, prepare dashboard messages for display on dashboard. # # Returns nothing. def move_dashboard_messages if to == "dashboard" && !cancelled? && !closed? self.workflow_state = "dashboard" end end # Public: Return the message as JSON filtered to selected fields and # flattened appropriately. # # Returns json hash. def as_json(*) super(only: %i[id created_at sent_at workflow_state from from_name to reply_to subject body html_body])["message"] end protected # Internal: Choose account to check feature flags on. # # used to choose which account to trust for inspecting # feature state to decide how to send messages. In general # the root account is a good choice, but for a user-context # message (which would intentionally have a dummy root account), # we want to make sure we aren't inspecting # features on the dummy account def infer_feature_account root_account&.unless_dummy || user&.account || Account.site_admin end # Internal: Deliver the message through email. # # Returns nothing. # Raises Net::SMTPServerBusy if the message cannot be sent. # Raises Timeout::Error if the remote server times out. def deliver_via_email res = nil logger.info "Delivering mail: #{inspect}" begin res = Mailer.create_message(self).deliver_now rescue Net::SMTPServerBusy => e @exception = e logger.error "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}" cancel if e.message.try(:match, /Bad recipient/) rescue => e @exception = e logger.error "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}" end if res complete_dispatch elsif @exception raise_error = @exception.to_s !~ /^450/ log_error = raise_error && !@exception.is_a?(Timeout::Error) if log_error Canvas::Errors.capture( @exception, message: "Message delivery failed", to:, object: inspect.to_s ) end errored_dispatch if raise_error raise @exception else return false end end true end # Internal: Deliver the message through Twitter. # # The template should define the content for :link and not place into the body of the template itself # # Returns nothing. def deliver_via_twitter twitter_service = user.user_services.where(service: "twitter").first host = HostUrl.context_host(link_root_account) msg_id = AssetSignature.generate(self) Twitter::Messenger.new(self, twitter_service, host, msg_id).deliver complete_dispatch end # Internal: Send the message through SMS. This currently sends it via Twilio if the recipient is a E.164 phone # number, or via email otherwise. # # Returns nothing. def deliver_via_sms if /^\+[0-9]+$/.match?(to) begin unless user.account.feature_enabled?(:international_sms) raise "International SMS is currently disabled for this user's account" end if Canvas::Twilio.enabled? Canvas::Twilio.deliver( to, body, from_recipient_country: true ) end rescue => e logger.error "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}" Canvas::Errors.capture( e, message: "SMS delivery failed", to:, object: inspect.to_s, tags: { type: :sms_message } ) cancel else complete_dispatch end else deliver_via_email end end # Internal: Deliver the message using AWS SNS. # # Returns nothing. def deliver_via_push user.notification_endpoints.each do |notification_endpoint| notification_endpoint.destroy unless notification_endpoint.push_json(sns_json) end complete_dispatch rescue => e @exception = e error_string = "Exception: #{e.class}: #{e.message}\n\t#{e.backtrace.join("\n\t")}" logger.error error_string cancel raise e end private def outgoing_email_default_name_for_messages if root_account && root_account.settings[:outgoing_email_default_name] root_account.settings[:outgoing_email_default_name] else HostUrl.outgoing_email_default_name end end def infer_from_name if notification_category == "Summaries" return outgoing_email_default_name_for_messages end if context.is_a?(DiscussionEntry) && context.discussion_topic.anonymous? return context.author_name end return name_helper.from_name if name_helper.from_name.present? if name_helper.asset.is_a?(AppointmentGroup) && !(names = name_helper.asset.contexts_for_user(user)).nil? names = names.map(&:name).join(", ") if names == "" return name_helper.asset.context.name else return names end end return context_context.nickname_for(user) if can_use_name_for_from?(context_context) outgoing_email_default_name_for_messages end def can_use_name_for_from?(c) c && !c.is_a?(Account) && notification&.dashboard? && c.respond_to?(:name) && c.name.present? end def name_helper @name_helper ||= Messages::NameHelper.new( asset: context, message_recipient: user, notification_name: ) end def apply_course_nickname_to_asset(asset, user) hacked_course = if asset.is_a?(Course) asset elsif asset.respond_to?(:context) && asset.context.is_a?(Course) asset.context elsif asset.respond_to?(:course) && asset.course.is_a?(Course) asset.course end hacked_course&.apply_nickname_for!(user) hacked_course end end