diff --git a/Gemfile b/Gemfile index ec8cd8455..677d56c1a 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ gem 'rails' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', require: false -##-- rails helper gems --## +##-- rails application helper gems --## gem 'acts-as-taggable-on' gem 'attr_extras' gem 'browser' @@ -23,6 +23,12 @@ gem 'tzinfo-data' gem 'valid_email2' # compress javascript config.assets.js_compressor gem 'uglifier' +##-- used for single column multiple binary flags in notification settings/feature flagging --## +gem 'flag_shih_tzu' +# Random name generator for user names +gem 'haikunator' +# Template parsing safetly +gem 'liquid' ##-- for active storage --## gem 'aws-sdk-s3', require: false @@ -67,8 +73,6 @@ gem 'twitty' gem 'koala' # slack client gem 'slack-ruby-client' -# Random name generator -gem 'haikunator' ##--- gems for debugging and error reporting ---## # static analysis @@ -79,9 +83,6 @@ gem 'sentry-raven' ##-- background job processing --## gem 'sidekiq' -##-- used for single column multiple binary flags in notification settings/feature flagging --## -gem 'flag_shih_tzu' - ##-- Push notification service --## gem 'fcm' gem 'webpush' diff --git a/Gemfile.lock b/Gemfile.lock index 27235335d..00e93c5e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -275,6 +275,7 @@ GEM addressable (~> 2.7) letter_opener (1.7.0) launchy (~> 2.2) + liquid (4.0.3) listen (3.2.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -584,6 +585,7 @@ DEPENDENCIES kaminari koala letter_opener + liquid listen mini_magick mock_redis! diff --git a/app/drops/account_drop.rb b/app/drops/account_drop.rb new file mode 100644 index 000000000..2e3a2530f --- /dev/null +++ b/app/drops/account_drop.rb @@ -0,0 +1,2 @@ +class AccountDrop < BaseDrop +end diff --git a/app/drops/base_drop.rb b/app/drops/base_drop.rb new file mode 100644 index 000000000..e4b00e5d4 --- /dev/null +++ b/app/drops/base_drop.rb @@ -0,0 +1,13 @@ +class BaseDrop < Liquid::Drop + def initialize(obj) + @obj = obj + end + + def id + @obj.try(:id) + end + + def name + @obj.try(:name) + end +end diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb new file mode 100644 index 000000000..03bab2f58 --- /dev/null +++ b/app/drops/conversation_drop.rb @@ -0,0 +1,5 @@ +class ConversationDrop < BaseDrop + def display_id + @obj.try(:display_id) + end +end diff --git a/app/drops/inbox_drop.rb b/app/drops/inbox_drop.rb new file mode 100644 index 000000000..7972e7325 --- /dev/null +++ b/app/drops/inbox_drop.rb @@ -0,0 +1,2 @@ +class InboxDrop < BaseDrop +end diff --git a/app/drops/user_drop.rb b/app/drops/user_drop.rb new file mode 100644 index 000000000..e2876a58a --- /dev/null +++ b/app/drops/user_drop.rb @@ -0,0 +1,2 @@ +class UserDrop < BaseDrop +end diff --git a/app/mailers/agent_notifications/conversation_notifications_mailer.rb b/app/mailers/agent_notifications/conversation_notifications_mailer.rb index 006cf1fb4..8bc872f20 100644 --- a/app/mailers/agent_notifications/conversation_notifications_mailer.rb +++ b/app/mailers/agent_notifications/conversation_notifications_mailer.rb @@ -1,14 +1,12 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer - default from: ENV.fetch('MAILER_SENDER_EMAIL', 'accounts@chatwoot.com') - layout 'mailer' - def conversation_creation(conversation, agent) return unless smtp_config_set_or_development? @agent = agent @conversation = conversation subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{@conversation.inbox&.name}." - mail(to: @agent.email, subject: subject) + @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) + send_mail_with_liquid(to: @agent.email, subject: subject) and return end def conversation_assignment(conversation, agent) @@ -16,6 +14,18 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer @agent = agent @conversation = conversation - mail(to: @agent.email, subject: "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been assigned to you.") + subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been assigned to you." + @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) + send_mail_with_liquid(to: @agent.email, subject: subject) and return + end + + private + + def liquid_droppables + super.merge({ + user: @agent, + conversation: @conversation, + inbox: @conversation.inbox + }) end end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 50d50f125..c5ce00349 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,9 +1,13 @@ class ApplicationMailer < ActionMailer::Base - default from: ENV.fetch('MAILER_SENDER_EMAIL', 'accounts@chatwoot.com') - layout 'mailer' - append_view_path Rails.root.join('app/views/mailers') + include ActionView::Helpers::SanitizeHelper - # helpers + default from: ENV.fetch('MAILER_SENDER_EMAIL', 'accounts@chatwoot.com') + before_action { ensure_current_account(params.try(:[], :account)) } + layout 'mailer/base' + # Fetch template from Database if available + # Order: Account Specific > Installation Specific > Fallback to file + prepend_view_path ::EmailTemplate.resolver + append_view_path Rails.root.join('app/views/mailers') helper :frontend_urls helper do def global_config @@ -14,4 +18,36 @@ class ApplicationMailer < ActionMailer::Base def smtp_config_set_or_development? ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development? end + + private + + def send_mail_with_liquid(*args) + mail(*args) do |format| + # explored sending a multipart email containg both text type and html + # parsing the html with nokogiri will remove the links as well + # might also remove tags like b,li etc. so lets rethink about this later + # format.text { Nokogiri::HTML(render(layout: false)).text } + format.html { render } + end + end + + def liquid_droppables + # Merge additional objects into this in your mailer + # liquid template handler converts these objects into drop objects + { + account: Current.account + } + end + + def liquid_locals + # expose variables you want to be exposed in liquid + { + global_config: GlobalConfig.get('INSTALLATION_NAME', 'BRAND_URL'), + action_url: @action_url + } + end + + def ensure_current_account(account) + Current.account = account if account.present? + end end diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 82c5b63e6..c60fb4363 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -105,6 +105,6 @@ class ConversationReplyMailer < ApplicationMailer def choose_layout return false if action_name == 'reply_without_summary' - 'mailer' + 'mailer/base' end end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 10a4cba84..62d06bd88 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -1,3 +1,10 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + DROPPABLES = %w[Account Channel Conversation Inbox User].freeze + + def to_drop + return unless DROPPABLES.include?(self.class.name) + + "#{self.class.name}Drop".constantize.new(self) + end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index aad39a5ee..773fe76b6 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -52,8 +52,10 @@ class Conversation < ApplicationRecord before_create :set_display_id, unless: :display_id? before_create :set_bot_conversation - after_create :notify_conversation_creation + after_create_commit :notify_conversation_creation after_save :run_round_robin + # wanted to change this to after_update commit. But it ended up creating a loop + # reinvestigate in future and identity the implications after_update :notify_status_change, :create_activity acts_as_taggable_on :labels diff --git a/app/models/email_template.rb b/app/models/email_template.rb new file mode 100644 index 000000000..5891626a4 --- /dev/null +++ b/app/models/email_template.rb @@ -0,0 +1,28 @@ +# == Schema Information +# +# Table name: email_templates +# +# id :bigint not null, primary key +# body :text not null +# locale :integer default("en"), not null +# name :string not null +# template_type :integer default("content") +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer +# +# Indexes +# +# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE +# +class EmailTemplate < ApplicationRecord + enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h + enum template_type: { layout: 0, content: 1 } + belongs_to :account, optional: true + + validates :name, uniqueness: { scope: :account } + + def self.resolver(options = {}) + ::EmailTemplates::DbResolverService.using self, options + end +end diff --git a/app/services/email_templates/db_resolver_service.rb b/app/services/email_templates/db_resolver_service.rb new file mode 100644 index 000000000..341e95ee3 --- /dev/null +++ b/app/services/email_templates/db_resolver_service.rb @@ -0,0 +1,87 @@ +# Code is heavily inspired by panaromic gem +# https://github.com/andreapavoni/panoramic +# We will try to find layouts and content from database +# layout will be rendered with erb and other content in html format +# Further processing in liquid is implemented in mailers + +# Note: rails resolver looks for templates in cache first +# which we don't want to happen here +# so we are overriding find_all method in action view resolver +# If anything breaks - look into rails : actionview/lib/action_view/template/resolver.rb + +class ::EmailTemplates::DbResolverService < ActionView::Resolver + require 'singleton' + include Singleton + + # Instantiate Resolver by passing a model. + def self.using(model, options = {}) + class_variable_set(:@@model, model) + class_variable_set(:@@resolver_options, options) + instance + end + + # Since rails picks up files from cache. lets override the method + # Normalizes the arguments and passes it on to find_templates. + # rubocop:disable Metrics/ParameterLists + def find_all(name, prefix = nil, partial = false, details = {}, key = nil, locals = []) + locals = locals.map(&:to_s).sort!.freeze + _find_all(name, prefix, partial, details, key, locals) + end + # rubocop:enable Metrics/ParameterLists + + # the function has to accept(name, prefix, partial, _details, _locals = []) + # _details contain local info which we can leverage in future + # cause of codeclimate issue with 4 args, relying on (*args) + def find_templates(name, prefix, partial, *_args) + @template_name = name + @template_type = prefix.include?('layout') ? 'layout' : 'content' + @db_template = find_db_template + + return [] if @db_template.blank? + + path = build_path(prefix) + handler = ActionView::Template.registered_template_handler(:liquid) + + template_details = { + format: Mime['html'].to_sym, + updated_at: @db_template.updated_at, + virtual_path: virtual_path(path, partial) + } + + [ActionView::Template.new(@db_template.body, "DB Template - #{@db_template.id}", handler, template_details)] + end + + private + + def find_db_template + find_account_template || find_installation_template + end + + def find_account_template + return unless Current.account + + @@model.find_by(name: @template_name, template_type: @template_type, account: Current.account) + end + + def find_installation_template + @@model.find_by(name: @template_name, template_type: @template_type, account: nil) + end + + # Build path with eventual prefix + def build_path(prefix) + prefix.present? ? "#{prefix}/#{@template_name}" : @template_name + end + + # returns a path depending if its a partial or template + # params path: path/to/file.ext partial: true/false + # the function appends _to make the file name _file.ext if partial: true + def virtual_path(path, partial) + return path unless partial + + if (index = path.rindex('/')) + path.insert(index + 1, '_') + else + "_#{path}" + end + end +end diff --git a/app/services/notification/email_notification_service.rb b/app/services/notification/email_notification_service.rb index ce4184871..7f5df8307 100644 --- a/app/services/notification/email_notification_service.rb +++ b/app/services/notification/email_notification_service.rb @@ -8,7 +8,7 @@ class Notification::EmailNotificationService # TODO : Clean up whatever happening over here # Segregate the mailers properly - AgentNotifications::ConversationNotificationsMailer.public_send(notification + AgentNotifications::ConversationNotificationsMailer.with(account: notification.account).public_send(notification .notification_type.to_s, notification.primary_actor, notification.user).deliver_now end diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer/base.liquid similarity index 91% rename from app/views/layouts/mailer.html.erb rename to app/views/layouts/mailer/base.liquid index babc451f7..6eea14f4e 100644 --- a/app/views/layouts/mailer.html.erb +++ b/app/views/layouts/mailer/base.liquid @@ -81,7 +81,7 @@