feat: Customisable Email Templates (#1095)

This commit is contained in:
Sojan Jose
2020-08-06 15:21:06 +05:30
committed by GitHub
parent db877453a4
commit a04ca24def
27 changed files with 409 additions and 44 deletions

View File

@@ -0,0 +1,2 @@
class AccountDrop < BaseDrop
end

13
app/drops/base_drop.rb Normal file
View File

@@ -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

View File

@@ -0,0 +1,5 @@
class ConversationDrop < BaseDrop
def display_id
@obj.try(:display_id)
end
end

2
app/drops/inbox_drop.rb Normal file
View File

@@ -0,0 +1,2 @@
class InboxDrop < BaseDrop
end

2
app/drops/user_drop.rb Normal file
View File

@@ -0,0 +1,2 @@
class UserDrop < BaseDrop
end

View File

@@ -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

View File

@@ -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

View File

@@ -105,6 +105,6 @@ class ConversationReplyMailer < ApplicationMailer
def choose_layout
return false if action_name == 'reply_without_summary'
'mailer'
'mailer/base'
end
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -81,7 +81,7 @@
<td class="content-wrap" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 20px;" valign="top">
<meta itemprop="name" content="Confirm Email" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;" />
<table width="100%" cellpadding="0" cellspacing="0" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<%= yield %>
{{ content_for_layout }}
</table>
</td>
</tr>
@@ -89,16 +89,16 @@
<div class="footer" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; width: 100%; clear: both; color: #999; margin: 0; padding: 20px;">
<table width="100%" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<% if global_config['BRAND_NAME'].present? %>
{% if global_config['BRAND_NAME'] %}
<tr style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; margin: 0;">
<td class="content-block" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
Powered by
<a href="<%= global_config['BRAND_URL'] %>" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">
<%= global_config['BRAND_NAME'] %>
<a href="{{ global_config['BRAND_URL'] }}" style="font-family: 'Helvetica Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; vertical-align: top; color: #999; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">
{{ global_config['BRAND_NAME'] }}
</a>
</td>
</tr>
<% end %>
{% endif %}
</tr>
</table>
</div>
@@ -109,4 +109,4 @@
</table>
</body>
</html>
</html>

View File

@@ -1,10 +0,0 @@
<p>Hi <%= @agent.available_name %>,</p>
<p>Time to save the world. A new conversation has been assigned to you</p>
<p>
Click <%=
link_to 'here',
app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
%> to get cracking.
</p>

View File

@@ -0,0 +1,7 @@
<p>Hi {{user.available_name}},</p>
<p>Time to save the world. A new conversation has been assigned to you</p>
<p>
Click <a href="{{action_url}}">here</a> to get cracking.
</p>

View File

@@ -1,10 +0,0 @@
<p>Hi <%= @agent.available_name %>,</p>
<p>Time to save the world. A new conversation has been created in <%= @conversation.inbox.name %></p>
<p>
Click <%=
link_to 'here',
app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
%> to get cracking.
</p>

View File

@@ -0,0 +1,8 @@
<p>Hi {{user.available_name}}</p>
<p>Time to save the world. A new conversation has been created in {{ inbox.name }}</p>
<p>
Click <a href="{{ action_url }}">here</a> to get cracking.
</p>