Compare commits

...
16 changed files with 921 additions and 63 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ saml:
enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'
description: 'Apply your own branding to this installation, including email template layout customization.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-paint-brush-line'
config_key: 'custom_branding'
+3 -1
View File
@@ -1,6 +1,8 @@
module SuperAdmin::NavigationHelper
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
return false if params[:controller] == 'super_admin/email_layouts' && params[:account_id].present?
params[:controller].in? %w[super_admin/settings super_admin/app_configs super_admin/email_layouts]
end
def settings_pages
+1 -1
View File
@@ -20,7 +20,7 @@ class EmailTemplate < ApplicationRecord
enum :template_type, { layout: 0, content: 1 }
belongs_to :account, optional: true
validates :name, uniqueness: { scope: :account }
validates :name, uniqueness: { scope: [:account, :template_type, :locale] }
def self.resolver(options = {})
::EmailTemplates::DbResolverService.using self, options
@@ -35,6 +35,8 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
def find_templates(name, prefix, partial, *_args)
@template_name = name
@template_type = prefix.include?('layout') ? 'layout' : 'content'
@_prefix = prefix
@_details = _args.first || {}
@db_template = find_db_template
return [] if @db_template.blank?
@@ -60,11 +62,23 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
def find_account_template
return unless Current.account
@@model.find_by(name: @template_name, template_type: @template_type, account: Current.account)
find_template_for(Current.account)
end
def find_installation_template
@@model.find_by(name: @template_name, template_type: @template_type, account: nil)
find_template_for(nil)
end
def find_template_for(account)
@@model.find_by(name: db_template_name, template_type: @template_type, locale: locale_from_details, account: account) ||
@@model.find_by(name: @template_name, template_type: @template_type, locale: locale_from_details, account: account)
end
def locale_from_details
return :en if @_details.blank?
locale = Array(@_details[:locale]).first
EmailTemplate.locales.key?(locale.to_s) ? locale : :en
end
# Build path with eventual prefix
@@ -72,6 +86,12 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
prefix.present? ? "#{prefix}/#{@template_name}" : @template_name
end
def db_template_name
return @template_name if @template_type == 'layout'
build_path(@_prefix)
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
+59 -53
View File
@@ -14,66 +14,72 @@
}
</style>
<section class="main-content__body">
<section class="main-content__body relative">
<%= render partial: 'email_layout' if @config == 'custom_branding' && ChatwootApp.enterprise? %>
<%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %>
<% @allowed_configs.each do |key| %>
<div class="flex mb-8">
<div class="field-unit__label">
<%= form.label "app_config[#{key}]", @installation_configs[key]&.dig('display_title') || key %>
</div>
<div class="-mt-2 field-unit__field ">
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% elsif @installation_configs[key]&.dig('type') == 'code' %>
<%= form.text_area "app_config[#{key}]",
value: @app_config[key],
rows: 12,
wrap: 'off',
class: "mt-2 border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll"
%>
<% elsif @installation_configs[key]&.dig('type') == 'secret' %>
<div class="relative">
<%= form.password_field "app_config[#{key}]",
id: "app_config_#{key}",
value: @app_config[key],
class: "mt-2 border border-slate-100 p-1.5 pr-8 rounded-md w-full"
<% @allowed_configs.each do |key| %>
<div class="flex mb-8">
<div class="field-unit__label">
<%= form.label "app_config[#{key}]", @installation_configs[key]&.dig('display_title') || key %>
</div>
<div class="-mt-2 field-unit__field ">
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
<%= form.select "app_config[#{key}]",
[["True", true], ["False", false]],
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<button
type="button"
class="absolute reset-base !bg-white top-1/2 !outline-0 !text-n-slate-11 -translate-y-1/2 right-2 p-1 hover:!bg-n-slate-5 rounded-sm toggle-password"
data-target="app_config_<%= key %>"
>
<svg class="eye-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5C5.63636 5 2 12 2 12C2 12 5.63636 19 12 19C18.3636 19 22 12 22 12C22 12 18.3636 5 12 5Z"/>
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"/>
</svg>
</button>
</div>
<% elsif @installation_configs[key]&.dig('type') == 'select' && @installation_configs[key]&.dig('options').present? %>
<%= form.select "app_config[#{key}]",
@installation_configs[key]['options'].map { |val, label| [label, val] },
{ selected: @app_config[key] },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% else %>
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
<% end %>
<%if @installation_configs[key]&.dig('description').present? %>
<p class="pt-2 text-xs italic text-slate-400">
<%= @installation_configs[key]&.dig('description') %>
</p>
<% end %>
<% elsif @installation_configs[key]&.dig('type') == 'code' %>
<%= form.text_area "app_config[#{key}]",
value: @app_config[key],
rows: 12,
wrap: 'off',
class: "mt-2 border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll"
%>
<% elsif @installation_configs[key]&.dig('type') == 'secret' %>
<div class="relative">
<%= form.password_field "app_config[#{key}]",
id: "app_config_#{key}",
value: @app_config[key],
class: "mt-2 border border-slate-100 p-1.5 pr-8 rounded-md w-full"
%>
<button
type="button"
class="absolute reset-base !bg-white top-1/2 !outline-0 !text-n-slate-11 -translate-y-1/2 right-2 p-1 hover:!bg-n-slate-5 rounded-sm toggle-password"
data-target="app_config_<%= key %>"
>
<svg class="eye-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5C5.63636 5 2 12 2 12C2 12 5.63636 19 12 19C18.3636 19 22 12 22 12C22 12 18.3636 5 12 5Z"/>
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"/>
</svg>
</button>
</div>
<% elsif @installation_configs[key]&.dig('type') == 'select' && @installation_configs[key]&.dig('options').present? %>
<%= form.select "app_config[#{key}]",
@installation_configs[key]['options'].map { |val, label| [label, val] },
{ selected: @app_config[key] },
class: "mt-2 border border-slate-100 p-1 rounded-md"
%>
<% else %>
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
<% end %>
<% if @installation_configs[key]&.dig('description').present? %>
<p class="pt-2 text-xs italic text-slate-400">
<%= @installation_configs[key]&.dig('description') %>
</p>
<% end %>
</div>
</div>
</div>
<% end %>
<% end %>
<div class="form-actions">
<%= form.submit "Submit" %>
</div>
<% end %>
</section>
<% content_for :javascript do %>
@@ -1,5 +1,5 @@
<li class="px-4 mb-1">
<% text_class_name = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
<% text_class_name = local_assigns.fetch(:active, current_page?(url)) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
<%= link_to(url, class: text_class_name + " -ml-1 focus:outline-none cursor-pointer flex items-center px-2 py-1.5 text-slate-800 cursor-pointer hover:text-woot-500 hover:bg-slate-25 rounded-lg") do %>
<svg width="16" height="16"><use xlink:href="#<%= icon %>" /></svg>
<span class="ml-2 text-sm"><%= label %></span>
@@ -33,12 +33,15 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics", "email_layouts"].include? resource.resource %>
<% next if resource.resource == "platform_banners" && !ChatwootApp.chatwoot_cloud? %>
<% resource_url = resource_index_route(resource) %>
<% active = current_page?(resource_url) || (resource.resource == 'accounts' && params[:controller] == 'super_admin/email_layouts' && params[:account_id].present?) %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
url: resource_url,
label: display_resource_name(resource),
active: active,
}
%>
+2
View File
@@ -685,6 +685,7 @@ Rails.application.routes.draw do
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
post :seed, on: :member
post :reset_cache, on: :member
resource :email_layout, only: [:edit, :update, :destroy] if ChatwootApp.enterprise?
end
resources :users, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
delete :avatar, on: :member, action: :destroy_avatar
@@ -702,6 +703,7 @@ Rails.application.routes.draw do
resource :settings, only: [:show] do
get :refresh, on: :collection
end
resource :email_layout, only: [:edit, :update, :destroy] if ChatwootApp.enterprise?
# resources that doesn't appear in primary navigation in super admin
resources :account_users, only: [:new, :create, :show, :destroy]
@@ -0,0 +1,9 @@
class UpdateEmailTemplatesUniqueIndexForLocaleAndType < ActiveRecord::Migration[7.1]
def change
remove_index :email_templates, name: 'index_email_templates_on_name_and_account_id'
add_index :email_templates,
[:name, :account_id, :template_type, :locale],
unique: true,
name: 'index_email_templates_on_name_account_type_locale'
end
end
+1 -1
View File
@@ -859,7 +859,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.integer "locale", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true
t.index ["name", "account_id", "template_type", "locale"], name: "index_email_templates_on_name_account_type_locale", unique: true
end
create_table "folders", force: :cascade do |t|
@@ -0,0 +1,280 @@
class SuperAdmin::EmailLayoutsController < SuperAdmin::EnterpriseBaseController
before_action :set_account
before_action :set_custom_templates
before_action :set_template_selection
before_action :set_email_layout
before_action :ensure_custom_branding_access!
def edit
@show_template_editor = editor_requested?
return unless @show_template_editor
@layout_body = @email_layout&.body || inherited_template_body
set_preview_context
end
def update
@layout_body = layout_params[:body].to_s
validation_error = validate_layout_body(@layout_body)
return render_edit_with_alert(validation_error) if validation_error.present?
return render_edit_with_alert(conflict_error) if conflicting_template_exists?
@email_layout ||= EmailTemplate.new(name: template_name, template_type: template_type, locale: template_locale, account: @account)
@email_layout.body = @layout_body
if @email_layout.save
# rubocop:disable Rails/I18nLocaleTexts
redirect_to edit_email_layout_path, notice: 'Email layout updated successfully'
# rubocop:enable Rails/I18nLocaleTexts
else
render_edit_with_alert(@email_layout.errors.full_messages.join(', '))
end
end
def destroy
@email_layout&.destroy
redirect_to email_layout_overview_path, notice: reset_notice
end
private
def set_account
@account = Account.find(params[:account_id]) if params[:account_id].present?
end
def set_custom_templates
@custom_templates = EmailTemplate.where(account: @account).order(:template_type, :name, :locale)
end
def set_template_selection
@template_type = selected_template_type
@template_name = selected_template_name
@template_locale = selected_template_locale
@template_options = template_options
@locale_options = locale_options
end
def set_email_layout
@email_layout = EmailTemplate.find_by(name: template_name, template_type: template_type, locale: template_locale, account: @account)
end
attr_reader :template_name, :template_type, :template_locale
def default_layout_body
Rails.root.join('app/views/layouts/mailer/base.liquid').read
end
def default_content_body
content_template_path.read
end
def inherited_template_body
return global_template_body || default_template_body if @account.present?
default_template_body
end
def default_template_body
return default_layout_body if template_type == 'layout'
default_content_body
end
def global_template_body
EmailTemplate.find_by(name: template_name, template_type: template_type, locale: template_locale, account: nil)&.body
end
def layout_params
params.require(:email_layout).permit(:body, :template_type, :template_name, :locale)
end
def validate_layout_body(body)
return 'Template body cannot be blank' if body.blank?
return 'Layout must include {{ content_for_layout }}' if template_type == 'layout' && !body.match?(/\{\{\s*content_for_layout\s*\}\}/)
Liquid::Template.parse(body)
nil
rescue Liquid::SyntaxError => e
"Liquid syntax error: #{e.message}"
end
def render_edit_with_alert(message)
flash.now[:alert] = message
@show_template_editor = true
set_preview_context
render :edit, status: :unprocessable_entity
end
def set_preview_context
@preview_html = render_preview(@layout_body)
@supported_variables = supported_variables
@template_status = template_status
@preview_config = preview_config
rescue Liquid::SyntaxError
@preview_html = ''
@supported_variables = supported_variables
@template_status = template_status
@preview_config = preview_config
end
def render_preview(body)
assigns = {
'content_for_layout' => preview_content,
'global_config' => GlobalConfig.get('BRAND_NAME', 'BRAND_URL')
}
assigns['account'] = { 'name' => @account.name } if @account.present?
rendered_body = Liquid::Template.parse(body).render(assigns)
return rendered_body if template_type == 'layout'
Liquid::Template.parse(default_layout_body).render(assigns.merge('content_for_layout' => rendered_body))
end
def preview_content
<<~HTML
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; padding: 0;">
<h1 style="font-size: 20px; margin: 0 0 12px;">Sample notification</h1>
<p style="margin: 0 0 16px;">This preview shows how notification content renders inside your email layout.</p>
<a href="#" style="display: inline-block; background: #1f93ff; color: #fff; padding: 10px 14px; border-radius: 6px; text-decoration: none;">View conversation</a>
</td>
</tr>
HTML
end
def preview_config
brand_config = GlobalConfig.get('BRAND_NAME', 'BRAND_URL')
{
templateType: template_type,
defaultLayout: default_layout_body,
previewContent: preview_content,
brandName: brand_config['BRAND_NAME'].to_s,
brandUrl: brand_config['BRAND_URL'].to_s,
accountName: @account&.name.to_s
}
end
def supported_variables
variables = []
if template_type == 'layout'
variables << {
variable: '{{ content_for_layout }}',
description: 'Required. Renders the email body inside the layout.'
}
end
variables + [
{
variable: "{{ global_config['BRAND_NAME'] }}",
description: 'Current brand name from Custom Branding.'
},
{
variable: "{{ global_config['BRAND_URL'] }}",
description: 'Current brand URL from Custom Branding.'
}
].tap do |template_variables|
next if @account.blank?
template_variables << {
variable: '{{ account.name }}',
description: 'Current account name for account-specific previews.'
}
end
end
def conflict_error
"Cannot save template. '#{template_name}' is already used by another email template for this account, type, and language."
end
def conflicting_template_exists?
return false if @email_layout.present?
EmailTemplate.exists?(name: template_name, template_type: template_type, locale: template_locale, account: @account)
end
def edit_email_layout_path
query = { template_type: template_type, template_name: template_name, locale: template_locale }
return edit_super_admin_account_email_layout_path(@account, query) if @account.present?
edit_super_admin_email_layout_path(query)
end
def email_layout_overview_path
return edit_super_admin_account_email_layout_path(@account) if @account.present?
edit_super_admin_email_layout_path
end
def editor_requested?
params[:new].present? || params[:template_type].present? || params[:template_name].present? || params[:locale].present?
end
def reset_notice
return 'Account email template reset to inherited template' if @account.present?
'Email template reset to default'
end
def ensure_custom_branding_access!
return if ChatwootHub.pricing_plan != 'community'
invalid_action_perfomed
end
def selected_template_type
selected_type = params.dig(:email_layout, :template_type).presence || params[:template_type].presence || 'layout'
EmailTemplate.template_types.key?(selected_type) ? selected_type : 'layout'
end
def selected_template_name
selected_name = params.dig(:email_layout, :template_name).presence || params[:template_name].presence
selected_name = default_template_name if selected_name.blank?
available_template_names.include?(selected_name) ? selected_name : default_template_name
end
def selected_template_locale
selected_locale = params.dig(:email_layout, :locale).presence || params[:locale].presence || 'en'
EmailTemplate.locales.key?(selected_locale) ? selected_locale : 'en'
end
def default_template_name
return 'base' if template_type == 'layout'
content_template_options.first[:name]
end
def template_options
return [{ name: 'base', label: 'Base layout' }] if template_type == 'layout'
content_template_options
end
def available_template_names
template_options.pluck(:name)
end
def content_template_options
@content_template_options ||= Rails.root.glob('app/views/mailers/**/*.liquid').map do |path|
name = path.relative_path_from(Rails.root.join('app/views/mailers')).to_s.delete_suffix('.liquid')
{ name: name, label: name.humanize }
end.sort_by { |template| template[:label] }
end
def content_template_path
Rails.root.join('app/views/mailers', "#{template_name}.liquid")
end
def locale_options
EmailTemplate.locales.keys.map { |locale| [locale.upcase, locale] }
end
def template_status
return 'Account override configured' if @account.present? && @email_layout.present?
return 'Global override configured' if @account.blank? && @email_layout.present?
return 'Inherits global template' if @account.present? && global_template_body.present?
'Inherits default template'
end
end
@@ -30,5 +30,22 @@
</span>
</div>
<% end %>
<% if ChatwootHub.pricing_plan != 'community' %>
<div class="flex items-center justify-between p-3 bg-white rounded-md outline outline-n-container outline-1 shadow-sm">
<div class="flex items-center gap-2">
<span class="bg-n-amber-3 text-n-amber-12 rounded-full p-1 inline-flex right-4 top-5">
<svg width="12" height="12"><use xlink:href="#icon-lock-line" /></svg>
</span>
<span class="text-sm text-n-slate-12">Account Email Templates</span>
</div>
<%= link_to edit_super_admin_account_email_layout_path(field.resource),
aria: { label: 'Customize account email templates' },
title: 'Customize account email templates',
class: 'inline-flex rounded-full bg-slate-50 p-1 text-slate-800 hover:bg-n-slate-4 hover:text-n-slate-12 focus:outline-none' do %>
<svg width="12" height="12"><use xlink:href="#icon-settings-2-line" /></svg>
<% end %>
</div>
<% end %>
</div>
</div>
@@ -0,0 +1,15 @@
<% if ChatwootHub.pricing_plan != 'community' %>
<div class="absolute right-4 top-5 w-96 rounded-md border border-slate-100 p-4">
<div class="flex items-start justify-between gap-5">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-n-slate-12">Email templates</span>
<p class="m-0 text-xs italic leading-5 text-slate-400">
Customize the notification email layout for this brand.
</p>
</div>
<%= link_to 'Manage',
edit_super_admin_email_layout_path,
class: 'flex h-8 shrink-0 items-center rounded-md bg-transparent px-3 text-sm font-medium text-n-slate-11 outline outline-1 outline-n-container hover:bg-slate-50 hover:text-n-slate-12 focus:outline-none' %>
</div>
</div>
<% end %>
@@ -0,0 +1,241 @@
<% content_for(:title) do %>
Configure Settings - <%= @account.present? ? "#{@account.name} Email Templates" : 'Email Templates' %>
<% end %>
<% overview_path = @account.present? ? edit_super_admin_account_email_layout_path(@account) : edit_super_admin_email_layout_path %>
<% new_template_path = @account.present? ? edit_super_admin_account_email_layout_path(@account, new: 1, template_type: 'layout', template_name: 'base', locale: 'en') : edit_super_admin_email_layout_path(new: 1, template_type: 'layout', template_name: 'base', locale: 'en') %>
<header class="main-content__header" role="banner">
<div class="flex items-center justify-between w-full">
<h1 class="main-content__page-title m-0 mr-6" id="page-title">
<%= content_for(:title) %>
</h1>
<% unless @show_template_editor %>
<div class="whitespace-nowrap ml-4">
<%= link_to 'Create new template', new_template_path, class: 'button' %>
</div>
<% end %>
</div>
</header>
<% unless @show_template_editor %>
<section class="main-content__body main-content__body--flush">
<table aria-labelledby="page-title">
<thead>
<tr>
<th class="cell-label cell-label--string" scope="col">Type</th>
<th class="cell-label cell-label--string" scope="col">Template</th>
<th class="cell-label cell-label--string" scope="col">Language</th>
<th class="cell-label cell-label--string" scope="col">Updated</th>
</tr>
</thead>
<tbody>
<% @custom_templates.each do |template| %>
<% template_path = @account.present? ? edit_super_admin_account_email_layout_path(@account, template_type: template.template_type, template_name: template.name, locale: template.locale) : edit_super_admin_email_layout_path(template_type: template.template_type, template_name: template.name, locale: template.locale) %>
<tr class="js-table-row" tabindex="0" role="link" data-url="<%= template_path %>">
<td class="cell-data cell-data--string">
<%= link_to template.template_type.titleize, template_path, class: 'action-show' %>
</td>
<td class="cell-data cell-data--string">
<%= link_to template.name.humanize, template_path, class: 'action-show' %>
</td>
<td class="cell-data cell-data--string">
<%= link_to template.locale.upcase, template_path, class: 'action-show' %>
</td>
<td class="cell-data cell-data--string">
<%= link_to "#{time_ago_in_words(template.updated_at)} ago", template_path, class: 'action-show' %>
</td>
</tr>
<% end %>
</tbody>
</table>
</section>
<% else %>
<section class="main-content__body">
<div class="pr-16">
<%= form_tag overview_path, method: :get, id: 'email-template-selector-form', autocomplete: 'off' do %>
<%= hidden_field_tag :new, 1 %>
<fieldset>
<legend>Template details</legend>
<div class="field-unit field-unit--string">
<div class="field-unit__label">
<%= label_tag :template_type, 'Type' %>
</div>
<div class="field-unit__field">
<%= select_tag :template_type,
options_for_select(EmailTemplate.template_types.keys.map { |type| [type.titleize, type] }, @template_type),
data: { current_value: @template_type } %>
</div>
</div>
<div class="field-unit field-unit--string">
<div class="field-unit__label">
<%= label_tag :template_name, 'Template' %>
</div>
<div class="field-unit__field">
<%= select_tag :template_name,
options_for_select(@template_options.map { |template| [template[:label], template[:name]] }, @template_name),
data: { current_value: @template_name } %>
<p class="pt-2 text-xs italic text-slate-400"><%= @template_status %></p>
</div>
</div>
<div class="field-unit field-unit--string">
<div class="field-unit__label">
<%= label_tag :locale, 'Language' %>
</div>
<div class="field-unit__field">
<%= select_tag :locale,
options_for_select(@locale_options, @template_locale),
data: { current_value: @template_locale } %>
</div>
</div>
</fieldset>
<div class="form-actions">
<%= submit_tag 'Load' %>
<%= link_to 'All templates', overview_path, class: 'button' %>
</div>
<% end %>
<%= form_with scope: :email_layout,
url: @account.present? ? super_admin_account_email_layout_path(@account) : super_admin_email_layout_path,
method: :patch do |form| %>
<%= form.hidden_field :template_type, value: @template_type %>
<%= form.hidden_field :template_name, value: @template_name %>
<%= form.hidden_field :locale, value: @template_locale %>
<fieldset>
<legend>Template body</legend>
<div class="field-unit field-unit--text">
<div class="field-unit__label">
<%= form.label :body, 'HTML / Liquid' %>
</div>
<div class="field-unit__field">
<%= form.text_area :body,
value: @layout_body,
rows: 24,
wrap: 'off',
data: { email_template_editor: true },
class: 'mt-2 w-full border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll' %>
<% if @template_type == 'layout' %>
<p class="pt-2 text-xs italic text-slate-400">Keep {{ content_for_layout }} in the layout.</p>
<% end %>
</div>
</div>
</fieldset>
<div class="form-actions">
<%= form.submit @email_layout.present? ? 'Save template' : 'Create template override' %>
<% if @email_layout.present? %>
<%= link_to t('administrate.actions.destroy'),
@account.present? ? super_admin_account_email_layout_path(@account, template_type: @template_type, template_name: @template_name, locale: @template_locale) : super_admin_email_layout_path(template_type: @template_type, template_name: @template_name, locale: @template_locale),
class: 'button button--danger',
method: :delete,
data: { confirm: t('administrate.actions.confirm') } %>
<% end %>
</div>
<% end %>
<fieldset>
<legend>Preview</legend>
<div class="attribute-label">Email layout preview</div>
<div class="attribute-data attribute-data--string">
<iframe
title="Email layout preview"
sandbox
height="420"
srcdoc="<%= @preview_html %>"
data-email-template-preview="true"
class="w-full rounded-md border border-slate-100 bg-white"
></iframe>
<p class="pt-2 text-xs italic text-slate-400">Refreshes as you type.</p>
</div>
<div class="attribute-label">Variables</div>
<div class="attribute-data attribute-data--string">
<% @supported_variables.each do |supported_variable| %>
<p class="m-0 mb-2 text-xs text-slate-500">
<code class="rounded bg-slate-25 px-2 py-1 font-mono text-xs text-slate-700"><%= supported_variable[:variable] %></code>
<%= supported_variable[:description] %>
</p>
<% end %>
</div>
</fieldset>
<script type="application/json" id="email-preview-config"><%= raw json_escape(@preview_config.to_json) %></script>
</div>
</section>
<% end %>
<% if @show_template_editor %>
<% content_for :javascript do %>
<script>
const initializeEmailTemplateEditor = () => {
const selectorForm = document.getElementById('email-template-selector-form') || document.querySelector('form[method="get"]');
if (selectorForm) {
let isSubmitting = false;
const submitSelectorForm = select => {
if (isSubmitting) return;
const templateSelect = selectorForm.querySelector('select[name="template_name"]');
if (select.name === 'template_type' && templateSelect) {
templateSelect.disabled = true;
}
isSubmitting = true;
selectorForm.requestSubmit();
};
selectorForm.querySelectorAll('select').forEach(select => {
if (select.dataset.currentValue) {
if (select.selectize) {
select.selectize.setValue(select.dataset.currentValue, true);
} else {
select.value = select.dataset.currentValue;
}
}
select.addEventListener('change', () => submitSelectorForm(select));
select.selectize?.on('change', () => submitSelectorForm(select));
});
}
const editor = document.querySelector('[data-email-template-editor]');
const preview = document.querySelector('[data-email-template-preview]');
const configNode = document.getElementById('email-preview-config');
if (!editor || !preview || !configNode) return;
const config = JSON.parse(configNode.textContent);
const contentSlotPattern = /\{\{\s*content_for_layout\s*\}\}/g;
const replacements = [
[/\{\{\s*global_config\[['"]BRAND_NAME['"]\]\s*\}\}/g, config.brandName],
[/\{\{\s*global_config\[['"]BRAND_URL['"]\]\s*\}\}/g, config.brandUrl],
[/\{\{\s*account\.name\s*\}\}/g, config.accountName],
];
const renderTemplateVariables = value => replacements.reduce((body, [pattern, replacement]) => body.replace(pattern, replacement || ''), value || '');
const renderPreview = () => {
const renderedBody = renderTemplateVariables(editor.value);
preview.srcdoc = config.templateType === 'layout'
? renderedBody.replace(contentSlotPattern, config.previewContent)
: renderTemplateVariables(config.defaultLayout).replace(contentSlotPattern, renderedBody);
};
editor.addEventListener('input', renderPreview);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeEmailTemplateEditor);
} else {
initializeEmailTemplateEditor();
}
</script>
<% end %>
<% end %>
@@ -2,6 +2,7 @@ require 'rails_helper'
RSpec.describe 'Super Admin Application Config API', type: :request do
let(:super_admin) { create(:super_admin) }
let(:account) { create(:account) }
describe 'GET /super_admin/app_config' do
context 'when it is an unauthenticated super admin' do
@@ -12,7 +13,13 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
end
context 'when it is an authenticated super admin' do
let!(:config) { create(:installation_config, { name: 'FB_APP_ID', value: 'TESTVALUE' }) }
let!(:config) do
InstallationConfig.where(name: 'FB_APP_ID').first_or_initialize.tap do |installation_config|
installation_config.value = 'TESTVALUE'
installation_config.locked = false
installation_config.save!
end
end
it 'shows the app_config page' do
sign_in(super_admin, scope: :super_admin)
@@ -20,6 +27,32 @@ RSpec.describe 'Super Admin Application Config API', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(config.value)
end
it 'shows email template customization under custom branding' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get '/super_admin/app_config?config=custom_branding'
expect(response).to have_http_status(:success)
expect(response.body).to include('Email templates')
expect(response.body).to include('Customize the notification email layout for this brand')
expect(response.body).to include('Manage')
expect(response.body).not_to include('Shape the brand customers see across Chatwoot and email')
expect(response.body).to include('/super_admin/email_layout/edit')
end
it 'shows email template customization on account details' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}"
expect(response).to have_http_status(:success)
expect(response.body).to include('Account Email Templates')
expect(response.body).to include('Customize account email templates')
expect(response.body).to include(edit_super_admin_account_email_layout_path(account))
end
end
end
@@ -0,0 +1,230 @@
require 'rails_helper'
RSpec.describe 'Super Admin Email Layouts API', type: :request do
let(:super_admin) { create(:super_admin) }
let(:account) { create(:account, name: 'Acme Inc') }
describe 'GET /super_admin/email_layout/edit' do
context 'when it is an unauthenticated super admin' do
it 'returns unauthorized' do
get '/super_admin/email_layout/edit'
expect(response).to have_http_status(:redirect)
end
end
context 'when it is an authenticated super admin' do
it 'shows the email layout page' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get '/super_admin/email_layout/edit'
expect(response).to have_http_status(:success)
expect(response.body).to include('Configure Settings - Email Templates')
expect(response.body).to include('Create new template')
expect(response.body).to include('<th class="cell-label cell-label--string" scope="col">Type</th>')
expect(response.body).to include('<th class="cell-label cell-label--string" scope="col">Template</th>')
end
it 'shows the editor when creating a new template override' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get '/super_admin/email_layout/edit', params: { new: 1, template_type: 'layout', template_name: 'base', locale: 'en' }
expect(response).to have_http_status(:success)
expect(response.body).to include('Template details')
expect(response.body).to include('email-template-selector-form')
expect(response.body).to include('Type')
expect(response.body).to include('Language')
expect(response.body).to include('Inherits default template')
expect(response.body).to include('Email layout preview')
expect(response.body).to include('Variables')
expect(response.body).to include('{{ content_for_layout }}')
expect(response.body).to include('global_config[&#39;BRAND_NAME&#39;]')
expect(response.body).to include('global_config[&#39;BRAND_URL&#39;]')
end
it 'shows the account email layout page' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit"
expect(response).to have_http_status(:success)
expect(response.body).to include('Configure Settings - Acme Inc Email Templates')
expect(response.body).to include('Create new template')
expect(response.body).to include('<th class="cell-label cell-label--string" scope="col">Type</th>')
expect(response.body).to include('<th class="cell-label cell-label--string" scope="col">Template</th>')
end
it 'lists configured account email templates' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
create(
:email_template,
name: 'agent_notifications/conversation_notifications_mailer/conversation_assignment',
body: 'Assigned',
template_type: :content,
locale: :fr,
account: account
)
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit"
expect(response).to have_http_status(:success)
expect(response.body).to include('Create new template')
expect(response.body).to include('Content')
expect(response.body).to include('FR')
expect(response.body).to include('Agent notifications/conversation notifications mailer/conversation assignment')
end
it 'shows the account email layout editor when creating a new template override' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit",
params: { new: 1, template_type: 'layout', template_name: 'base', locale: 'en' }
expect(response).to have_http_status(:success)
expect(response.body).to include('Template details')
expect(response.body).to include('Acme Inc')
expect(response.body).to include('{{ account.name }}')
end
it 'shows delete action for configured account email template overrides' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
create(:email_template, name: 'base', body: '{{ content_for_layout }}', template_type: :layout, locale: :en, account: account)
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit", params: { template_type: 'layout', template_name: 'base', locale: 'en' }
expect(response).to have_http_status(:success)
expect(response.body).to include('Destroy')
expect(response.body).to include('button button--danger')
expect(response.body).to include('Are you sure?')
end
it 'supports selecting content templates by locale' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/email_layout/edit",
params: {
template_type: 'content',
template_name: 'agent_notifications/conversation_notifications_mailer/conversation_assignment',
locale: 'fr'
}
expect(response).to have_http_status(:success)
expect(response.body).to include('Content')
expect(response.body).to include('conversation assignment')
expect(response.body).to include('FR')
end
it 'blocks access on community plan' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('community')
sign_in(super_admin, scope: :super_admin)
get '/super_admin/email_layout/edit'
expect(response).to have_http_status(:redirect)
expect(response).to redirect_to(root_path)
end
end
end
describe 'PATCH /super_admin/email_layout' do
context 'when it is an unauthenticated super admin' do
it 'returns unauthorized' do
patch '/super_admin/email_layout', params: { email_layout: { body: '{{ content_for_layout }}' } }
expect(response).to have_http_status(:redirect)
end
end
context 'when it is an authenticated super admin' do
before do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
sign_in(super_admin, scope: :super_admin)
end
it 'creates a layout override' do
body = '<html><body>{{ content_for_layout }}</body></html>'
patch '/super_admin/email_layout', params: { email_layout: { body: body } }
expect(response).to have_http_status(:found)
expect(response).to redirect_to(edit_super_admin_email_layout_path(template_type: 'layout', template_name: 'base', locale: 'en'))
template = EmailTemplate.find_by(name: 'base', account: nil)
expect(template).to be_present
expect(template.template_type).to eq('layout')
expect(template.body).to eq(body)
end
it 'creates an account layout override' do
body = '<html><body>{{ content_for_layout }} {{ account.name }}</body></html>'
patch "/super_admin/accounts/#{account.id}/email_layout", params: { email_layout: { body: body } }
expect(response).to have_http_status(:found)
expect(response).to redirect_to(edit_super_admin_account_email_layout_path(account, template_type: 'layout', template_name: 'base',
locale: 'en'))
template = EmailTemplate.find_by(name: 'base', account: account)
expect(template).to be_present
expect(template.template_type).to eq('layout')
expect(template.body).to eq(body)
end
it 'returns validation error when content_for_layout is missing' do
patch '/super_admin/email_layout', params: { email_layout: { body: '<html><body>No slot</body></html>' } }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('Layout must include')
end
it 'returns validation error for invalid liquid syntax' do
patch '/super_admin/email_layout', params: { email_layout: { body: '{{ content_for_layout }} {{ invalid ' } }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('Liquid syntax error')
end
end
end
describe 'DELETE /super_admin/email_layout' do
context 'when it is an unauthenticated super admin' do
it 'returns unauthorized' do
delete '/super_admin/email_layout'
expect(response).to have_http_status(:redirect)
end
end
context 'when it is an authenticated super admin' do
it 'removes custom layout override' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
create(:email_template, name: 'base', body: '{{ content_for_layout }}', template_type: :layout, account: nil)
sign_in(super_admin, scope: :super_admin)
delete '/super_admin/email_layout'
expect(response).to have_http_status(:found)
expect(response).to redirect_to(edit_super_admin_email_layout_path)
expect(EmailTemplate.find_by(name: 'base', account: nil)).to be_nil
end
it 'removes account layout override' do
allow(ChatwootHub).to receive(:pricing_plan).and_return('enterprise')
create(:email_template, name: 'base', body: '{{ content_for_layout }}', template_type: :layout, account: account)
sign_in(super_admin, scope: :super_admin)
delete "/super_admin/accounts/#{account.id}/email_layout"
expect(response).to have_http_status(:found)
expect(response).to redirect_to(edit_super_admin_account_email_layout_path(account))
expect(EmailTemplate.find_by(name: 'base', account: account)).to be_nil
end
end
end
end