feat: branded email layouts for email inboxes (#14936)

## Description

Adds an API-only branded email layout feature for Email inbox replies.
Administrators can configure an account-level fallback layout and
per-email-inbox overrides with Liquid HTML using `{{ content_for_layout
}}`, and eligible outbound email replies/transcripts render through the
scoped layout when the account feature flag `branded_email_templates` is
enabled.

The feature is disabled by default and is manually controlled through
the normal account feature flag mechanism.

Fixes
https://linear.app/chatwoot/issue/CW-7514/branded-html-email-templates-per-inboxbrand

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## How to test

1. Start Chatwoot locally and sign in as an administrator.
2. Enable the account feature flag for the account you are testing:

   ```ruby
   account = Account.find(<account_id>)
   account.enable_features!(:branded_email_templates)
   ```

3. Create or pick an Email inbox, then note the `account_id` and
`inbox_id`.
4. Configure an account-level fallback layout through the API using
authenticated admin headers:

   ```http
   PATCH /api/v1/accounts/:account_id/branded_email_layout
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Account Brand</header>{{
content_for_layout }}<footer>Account footer</footer></body></html>"
   }
   ```

5. Confirm `GET /api/v1/accounts/:account_id/branded_email_layout`
returns the saved account layout.
6. Configure an inbox-level override for the Email inbox:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
"branded_email_layout": "<html><body><header>Inbox Brand</header>{{
content_for_layout }}<footer>Inbox footer</footer></body></html>"
   }
   ```

7. Confirm `GET /api/v1/accounts/:account_id/inboxes/:inbox_id` returns
the inbox `branded_email_layout`.
8. Send an Email inbox reply and verify the outbound email body is
wrapped with the inbox layout around the generated reply content.
9. Clear the inbox layout by sending a blank value, then send another
reply and verify it falls back to the account layout:

   ```http
   PATCH /api/v1/accounts/:account_id/inboxes/:inbox_id
   Content-Type: application/json

   {
     "branded_email_layout": ""
   }
   ```

10. Clear the account layout with a blank value and verify Email replies
return to the existing no-layout behavior.
11. Verify validation behavior:
- Updating either API with a layout that omits `{{ content_for_layout
}}` returns `422`.
    - Updating either API with invalid Liquid returns `422`.
- Updating a non-Email inbox with `branded_email_layout` returns `422`.
- Disabling `branded_email_templates` and updating a layout returns
`422`.

## How Has This Been Tested?

Validation:

- `bundle exec rspec spec/models/email_template_spec.rb
spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb
spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
spec/lib/email_templates/db_resolver_service_spec.rb
spec/mailers/conversation_reply_mailer_spec.rb`
- `bundle exec rspec
spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb
spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb`
- `bundle exec rubocop` on changed Ruby files, excluding generated
`db/schema.rb`
- `git diff --check` and `git diff --cached --check`
- YAML parsing for changed config/Swagger files
- `bundle exec rails routes -g branded_email_layout`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] My changes generate no new warnings
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Sony Mathew
2026-07-17 15:25:55 +05:30
committed by GitHub
parent 331875cdaa
commit c420edce58
25 changed files with 1023 additions and 22 deletions
@@ -0,0 +1,28 @@
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
def show
set_branded_email_layout
end
def update
unless Current.account.feature_enabled?(:branded_email_templates)
render_could_not_create_error('Branded email templates feature is not enabled')
return
end
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
set_branded_email_layout
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
end
private
def set_branded_email_layout
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
end
end
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
@@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def update def update
inbox_params = permitted_params.except(:channel, :csat_config) continue_update = false
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params) ActiveRecord::Base.transaction do
update_inbox_working_hours continue_update = update_branded_email_layout
update_channel if channel_update_required? raise ActiveRecord::Rollback unless continue_update
inbox_params = permitted_params.except(:channel, :csat_config)
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
update_inbox_working_hours
update_channel if channel_update_required?
end
return unless continue_update
end end
def agent_bot def agent_bot
@@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
formatted['template'] = config['template'] if config['template'].present? formatted['template'] = config['template'] if config['template'].present?
end end
def update_branded_email_layout
return true unless params.key?(:branded_email_layout)
branded_email_layout = normalized_branded_email_layout
unless Current.account.feature_enabled?(:branded_email_templates)
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email templates feature is not enabled')
return false
end
unless @inbox.email?
return true if branded_email_layout.blank?
render_could_not_create_error('Branded email layout is only supported for email inboxes')
return false
end
@inbox.update_branded_email_layout!(branded_email_layout)
true
rescue ActiveRecord::RecordInvalid => e
render_could_not_create_error(e.record.errors.full_messages.join(', '))
false
end
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
def inbox_attributes def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled, [:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved, :enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
+14
View File
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
def name def name
@obj.try(:name) @obj.try(:name)
end end
def business_name
@obj.try(:sanitized_business_name)
end
def avatar_url
@obj.try(:avatar_url)
end
def email
return unless @obj.try(:email?)
@obj.try(:email_address).presence || @obj.try(:channel).try(:email)
end
end end
+18
View File
@@ -69,6 +69,8 @@ class ConversationReplyMailer < ApplicationMailer
@agent = @conversation.assignee @agent = @conversation.assignee
@inbox = @conversation.inbox @inbox = @conversation.inbox
@channel = @inbox.channel @channel = @inbox.channel
Current.account = @account
Current.inbox = @inbox
end end
def should_use_conversation_email_address? def should_use_conversation_email_address?
@@ -200,8 +202,24 @@ class ConversationReplyMailer < ApplicationMailer
end end
def choose_layout def choose_layout
return 'mailer/base' if branded_email_layout_action?
return false if action_name == 'reply_without_summary' || action_name == 'email_reply' return false if action_name == 'reply_without_summary' || action_name == 'email_reply'
'mailer/base' 'mailer/base'
end end
def branded_email_layout_action?
return false unless action_name.in?(%w[email_reply reply_without_summary])
return @inbox.branded_email_layout_available? if @inbox&.email?
@account&.feature_enabled?(:branded_email_templates) && EmailTemplate.account_branded_layout_template_for(@account).present?
end
def liquid_droppables
super.merge({
agent: current_message&.sender || @agent,
contact: @contact,
message: @message || @messages&.last
})
end
end end
@@ -0,0 +1,36 @@
# frozen_string_literal: true
module InboxBrandedEmailLayoutable
extend ActiveSupport::Concern
def branded_email_layout
branded_email_layout_template&.body
end
def branded_email_layout_template
email_templates.find_by(name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, locale: EmailTemplate::DEFAULT_LOCALE)
end
def effective_branded_email_layout_template(locale = I18n.locale)
EmailTemplate.branded_layout_for(inbox: self, account: account, locale: locale)
end
def branded_email_layout_available?
email? && account.feature_enabled?(:branded_email_templates) && effective_branded_email_layout_template.present?
end
def update_branded_email_layout!(body)
if body.blank?
branded_email_layout_template&.destroy!
return
end
template = branded_email_layout_template || email_templates.new(
name: EmailTemplate::BRANDED_LAYOUT_NAME,
template_type: :layout,
locale: EmailTemplate::DEFAULT_LOCALE,
account: account
)
template.update!(body: body)
end
end
+94 -2
View File
@@ -10,19 +10,111 @@
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :integer # account_id :integer
# inbox_id :integer
# #
# Indexes # Indexes
# #
# index_email_templates_on_name_and_account_id (name,account_id) UNIQUE # index_email_templates_on_account_scope (account_id,name,template_type,locale) UNIQUE WHERE ((account_id IS NOT NULL) AND (inbox_id IS NULL))
# index_email_templates_on_inbox_id (inbox_id)
# index_email_templates_on_inbox_scope (inbox_id,name,template_type,locale) UNIQUE WHERE (inbox_id IS NOT NULL)
# index_email_templates_on_installation_scope (name,template_type,locale) UNIQUE WHERE ((account_id IS NULL) AND (inbox_id IS NULL))
# #
class EmailTemplate < ApplicationRecord class EmailTemplate < ApplicationRecord
BRANDED_LAYOUT_NAME = 'base'.freeze
DEFAULT_LOCALE = 'en'.freeze
CONTENT_FOR_LAYOUT_PATTERN = /\{\{\s*content_for_layout\s*\}\}/
enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true enum :locale, LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h, prefix: true
enum :template_type, { layout: 0, content: 1 } enum :template_type, { layout: 0, content: 1 }
belongs_to :account, optional: true belongs_to :account, optional: true
belongs_to :inbox, optional: true
validates :name, uniqueness: { scope: :account } validates :name,
uniqueness: { scope: %i[template_type locale], conditions: -> { where(account_id: nil, inbox_id: nil) } },
if: :installation_scoped?
validates :name, uniqueness: { scope: %i[account_id template_type locale], conditions: -> { where(inbox_id: nil) } }, if: :account_scoped?
validates :name, uniqueness: { scope: %i[inbox_id template_type locale] }, if: :inbox_scoped?
validate :validate_inbox_account
validate :validate_liquid_body
validate :validate_layout_slot, if: :layout?
def self.resolver(options = {}) def self.resolver(options = {})
::EmailTemplates::DbResolverService.using self, options ::EmailTemplates::DbResolverService.using self, options
end end
def self.branded_layout_for(inbox:, account:, locale: I18n.locale)
layout_template_for_scope(inbox: inbox, account: account, locale: locale)
end
def self.account_branded_layout_template_for(account)
find_by(account: account, inbox: nil, name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: DEFAULT_LOCALE)
end
def self.update_account_branded_layout!(account:, body:)
if body.blank?
account_branded_layout_template_for(account)&.destroy!
return
end
template = account_branded_layout_template_for(account) || new(
account: account,
name: BRANDED_LAYOUT_NAME,
template_type: :layout,
locale: DEFAULT_LOCALE
)
template.update!(body: body)
end
def self.locale_candidates(locale)
candidate = locale.to_s
([candidate] + [DEFAULT_LOCALE]).select { |locale_key| locales.key?(locale_key) }.uniq
end
def self.layout_template_for_scope(inbox:, account:, locale:)
scoped_relations = []
scoped_relations << where(inbox: inbox) if inbox.present?
scoped_relations << where(account: account, inbox: nil) if account.present?
scoped_relations << where(account: nil, inbox: nil)
scoped_relations.each do |relation|
locale_candidates(locale).each do |locale_key|
template = relation.find_by(name: BRANDED_LAYOUT_NAME, template_type: :layout, locale: locale_key)
return template if template.present?
end
end
nil
end
private
def installation_scoped?
account_id.nil? && inbox_id.nil?
end
def account_scoped?
account_id.present? && inbox_id.nil?
end
def inbox_scoped?
inbox_id.present?
end
def validate_inbox_account
return if inbox.blank? || account.blank?
return if inbox.account_id == account_id
errors.add(:account, 'must match inbox account')
end
def validate_liquid_body
Liquid::Template.parse(body.to_s)
rescue Liquid::Error => e
errors.add(:body, "has invalid Liquid syntax: #{e.message}")
end
def validate_layout_slot
return if body.to_s.match?(CONTENT_FOR_LAYOUT_PATTERN)
errors.add(:body, 'must include {{ content_for_layout }}')
end
end end
+2
View File
@@ -45,6 +45,7 @@ class Inbox < ApplicationRecord
include OutOfOffisable include OutOfOffisable
include AccountCacheRevalidator include AccountCacheRevalidator
include InboxAgentAvailability include InboxAgentAvailability
include InboxBrandedEmailLayoutable
# Not allowing characters: # Not allowing characters:
validates :name, presence: true validates :name, presence: true
@@ -67,6 +68,7 @@ class Inbox < ApplicationRecord
has_many :members, through: :inbox_members, source: :user has_many :members, through: :inbox_members, source: :user
has_many :conversations, dependent: :destroy_async has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async has_many :messages, dependent: :destroy_async
has_many :email_templates, dependent: :destroy_async
has_one :inbox_assignment_policy, dependent: :destroy has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy has_one :assignment_policy, through: :inbox_assignment_policy
@@ -29,12 +29,14 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
end end
# rubocop:enable Metrics/ParameterLists # rubocop:enable Metrics/ParameterLists
# the function has to accept(name, prefix, partial, _details, _locals = []) # the function has to accept(name, prefix, partial, details, locals = [])
# _details contain local info which we can leverage in future # details contain local info which we can leverage in future
# cause of codeclimate issue with 4 args, relying on (*args) # cause of codeclimate issue with 4 args, relying on (*args)
def find_templates(name, prefix, partial, *_args) def find_templates(name, prefix, partial, *args)
@template_name = name @template_name = name
@template_type = prefix.include?('layout') ? 'layout' : 'content' @template_type = prefix.to_s.include?('layout') ? 'layout' : 'content'
@prefix = prefix
@details = args.first if args.first.is_a?(Hash)
@db_template = find_db_template @db_template = find_db_template
return [] if @db_template.blank? return [] if @db_template.blank?
@@ -54,17 +56,62 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver
private private
def find_db_template def find_db_template
find_account_template || find_installation_template find_inbox_template || find_account_template || find_installation_template
end
def find_inbox_template
return unless email_inbox_layout_lookup? && branded_email_templates_enabled?
find_template_for(@@model.where(inbox: Current.inbox))
end end
def find_account_template def find_account_template
return unless Current.account return unless Current.account
return if account_layout_lookup? && !branded_email_templates_enabled?
@@model.find_by(name: @template_name, template_type: @template_type, account: Current.account) find_template_for(@@model.where(account: Current.account, inbox: nil))
end end
def find_installation_template def find_installation_template
@@model.find_by(name: @template_name, template_type: @template_type, account: nil) find_template_for(@@model.where(account: nil, inbox: nil))
end
def account_layout_lookup?
@template_type == 'layout' && Current.account.present?
end
def email_inbox_layout_lookup?
account_layout_lookup? && Current.inbox&.email?
end
def branded_email_templates_enabled?
Current.account&.feature_enabled?(:branded_email_templates)
end
def find_template_for(relation)
locale_candidates.each do |locale|
template_names.each do |name|
template = relation.find_by(name: name, template_type: @template_type, locale: locale)
return template if template.present?
end
end
nil
end
def locale_candidates
locale = Array(@details&.dig(:locale)).first
EmailTemplate.locale_candidates(locale.presence || EmailTemplate::DEFAULT_LOCALE)
end
def template_names
[db_template_name, @template_name].uniq
end
def db_template_name
return @template_name if @template_type == 'layout'
build_path(@prefix)
end end
# Build path with eventual prefix # Build path with eventual prefix
@@ -0,0 +1 @@
json.branded_email_layout @branded_email_layout
@@ -0,0 +1 @@
json.branded_email_layout @branded_email_layout
@@ -1 +1 @@
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true
@@ -1 +1 @@
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox, with_branded_email_layout: true
@@ -81,6 +81,10 @@ if resource.email?
json.email resource.channel.try(:email) json.email resource.channel.try(:email)
json.forwarding_enabled ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present? json.forwarding_enabled ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
json.forward_to_email resource.channel.try(:forward_to_email) if ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present? json.forward_to_email resource.channel.try(:forward_to_email) if ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', '').present?
if Current.account_user&.administrator? && defined?(with_branded_email_layout) && with_branded_email_layout.present? &&
Current.account.feature_enabled?(:branded_email_templates)
json.branded_email_layout resource.branded_email_layout
end
## IMAP ## IMAP
if Current.account_user&.administrator? if Current.account_user&.administrator?
+2 -3
View File
@@ -120,10 +120,9 @@
display_name: Message Reply To display_name: Message Reply To
enabled: false enabled: false
deprecated: true deprecated: true
- name: insert_article_in_reply - name: branded_email_templates
display_name: Insert Article in Reply display_name: Branded Email Templates
enabled: false enabled: false
deprecated: true
- name: inbox_view - name: inbox_view
display_name: Inbox View display_name: Inbox View
enabled: false enabled: false
+1
View File
@@ -267,6 +267,7 @@ Rails.application.routes.draw do
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy] resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy] resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resource :branded_email_layout, only: [:show, :update]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
get :assignable_agents, on: :member get :assignable_agents, on: :member
get :campaigns, on: :member get :campaigns, on: :member
@@ -0,0 +1,50 @@
class AddInboxScopeToEmailTemplates < ActiveRecord::Migration[7.1]
def up
add_column :email_templates, :inbox_id, :integer
add_index :email_templates, :inbox_id, name: 'index_email_templates_on_inbox_id'
remove_index :email_templates, name: 'index_email_templates_on_name_and_account_id'
ensure_no_duplicate_installation_templates!
add_index :email_templates,
[:name, :template_type, :locale],
unique: true,
where: 'account_id IS NULL AND inbox_id IS NULL',
name: 'index_email_templates_on_installation_scope'
add_index :email_templates,
[:account_id, :name, :template_type, :locale],
unique: true,
where: 'account_id IS NOT NULL AND inbox_id IS NULL',
name: 'index_email_templates_on_account_scope'
add_index :email_templates,
[:inbox_id, :name, :template_type, :locale],
unique: true,
where: 'inbox_id IS NOT NULL',
name: 'index_email_templates_on_inbox_scope'
end
def down
remove_index :email_templates, name: 'index_email_templates_on_inbox_scope'
remove_index :email_templates, name: 'index_email_templates_on_account_scope'
remove_index :email_templates, name: 'index_email_templates_on_installation_scope'
remove_index :email_templates, name: 'index_email_templates_on_inbox_id'
add_index :email_templates, [:name, :account_id], unique: true, name: 'index_email_templates_on_name_and_account_id'
remove_column :email_templates, :inbox_id
end
private
def ensure_no_duplicate_installation_templates!
duplicates = select_values <<~SQL.squish
SELECT CONCAT(name, '/', template_type, '/', locale)
FROM email_templates
WHERE account_id IS NULL
GROUP BY name, template_type, locale
HAVING COUNT(*) > 1
SQL
return if duplicates.empty?
raise ActiveRecord::IrreversibleMigration,
"Duplicate installation email templates must be resolved before migrating: #{duplicates.join(', ')}"
end
end
@@ -0,0 +1,21 @@
class RepurposeInsertArticleInReplyForBrandedEmailTemplates < ActiveRecord::Migration[7.1]
def up
Account.feature_branded_email_templates.find_each(batch_size: 100) do |account|
account.disable_features(:branded_email_templates)
account.save!(validate: false)
end
remove_stale_default_feature
end
private
def remove_stale_default_feature
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
return if config&.value.blank?
config.value = config.value.reject { |feature| feature['name'] == 'insert_article_in_reply' }
config.save!
GlobalConfig.clear_cache
end
end
+5 -1
View File
@@ -987,7 +987,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.integer "locale", default: 0, null: false t.integer "locale", default: 0, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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.integer "inbox_id"
t.index ["account_id", "name", "template_type", "locale"], name: "index_email_templates_on_account_scope", unique: true, where: "(account_id IS NOT NULL) AND (inbox_id IS NULL)"
t.index ["inbox_id", "name", "template_type", "locale"], name: "index_email_templates_on_inbox_scope", unique: true, where: "(inbox_id IS NOT NULL)"
t.index ["inbox_id"], name: "index_email_templates_on_inbox_id"
t.index ["name", "template_type", "locale"], name: "index_email_templates_on_installation_scope", unique: true, where: "(account_id IS NULL) AND (inbox_id IS NULL)"
end end
create_table "folders", force: :cascade do |t| create_table "folders", force: :cascade do |t|
+2
View File
@@ -4,6 +4,7 @@ module Current
thread_mattr_accessor :account_user thread_mattr_accessor :account_user
thread_mattr_accessor :executed_by thread_mattr_accessor :executed_by
thread_mattr_accessor :contact thread_mattr_accessor :contact
thread_mattr_accessor :inbox
def self.reset def self.reset
Current.user = nil Current.user = nil
@@ -11,5 +12,6 @@ module Current
Current.account_user = nil Current.account_user = nil
Current.executed_by = nil Current.executed_by = nil
Current.contact = nil Current.contact = nil
Current.inbox = nil
end end
end end
@@ -0,0 +1,152 @@
require 'rails_helper'
RSpec.describe 'Branded Email Layout API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:layout) { '<html><body><header>Brand</header>{{ content_for_layout }}</body></html>' }
describe 'GET /api/v1/accounts/{account.id}/branded_email_layout' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/branded_email_layout"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an administrator' do
it 'returns the account-scoped branded email layout' do
create(:email_template, :layout, account: account, body: layout)
get "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['branded_email_layout']).to eq(layout)
end
it 'returns null when no account-scoped layout exists' do
get "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['branded_email_layout']).to be_nil
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/branded_email_layout' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
params: { branded_email_layout: layout },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: agent.create_new_auth_token,
params: { branded_email_layout: layout },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an administrator' do
it 'updates account-scoped branded email layout when feature is enabled' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: layout },
as: :json
template = EmailTemplate.account_branded_layout_template_for(account)
expect(response).to have_http_status(:success)
expect(template.body).to eq(layout)
expect(response.parsed_body['branded_email_layout']).to eq(layout)
end
it 'clears account-scoped branded email layout when blank value is passed' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, account: account, body: layout)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '' },
as: :json
expect(response).to have_http_status(:success)
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
expect(response.parsed_body['branded_email_layout']).to be_nil
end
it 'clears account-scoped branded email layout when null string is passed' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, account: account, body: layout)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: 'null' },
as: :json
expect(response).to have_http_status(:success)
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
expect(response.parsed_body['branded_email_layout']).to be_nil
end
it 'rejects updates when feature is disabled' do
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: layout },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Branded email templates feature is not enabled')
expect(EmailTemplate.account_branded_layout_template_for(account)).to be_nil
end
it 'rejects account-scoped branded email layout without content slot' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>No slot</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('must include {{ content_for_layout }}')
end
it 'rejects account-scoped branded email layout with invalid liquid syntax' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/branded_email_layout",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>{{ content_for_layout }} {{ broken </html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('has invalid Liquid syntax')
end
end
end
end
@@ -36,6 +36,18 @@ RSpec.describe 'Inboxes API', type: :request do
expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2) expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2)
end end
it 'does not include branded email layout in index responses' do
email_inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
get "/api/v1/accounts/#{account.id}/inboxes",
headers: admin.create_new_auth_token,
as: :json
inbox_data = JSON.parse(response.body, symbolize_names: true)[:payload].find { |item| item[:id] == email_inbox.id }
expect(inbox_data).not_to have_key(:branded_email_layout)
end
it 'returns only assigned inboxes of current_account as agent' do it 'returns only assigned inboxes of current_account as agent' do
get "/api/v1/accounts/#{account.id}/inboxes", get "/api/v1/accounts/#{account.id}/inboxes",
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
@@ -161,8 +173,10 @@ RSpec.describe 'Inboxes API', type: :request do
end end
it 'returns imap details in inbox when admin' do it 'returns imap details in inbox when admin' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account, imap_enabled: true, imap_login: 'test@test.com') email_channel = create(:channel_email, account: account, imap_enabled: true, imap_login: 'test@test.com')
email_inbox = create(:inbox, channel: email_channel, account: account) email_inbox = create(:inbox, channel: email_channel, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
imap_connection = double imap_connection = double
allow(Mail).to receive(:connection).and_return(imap_connection) allow(Mail).to receive(:connection).and_return(imap_connection)
@@ -176,6 +190,35 @@ RSpec.describe 'Inboxes API', type: :request do
expect(data[:imap_enabled]).to be_truthy expect(data[:imap_enabled]).to be_truthy
expect(data[:imap_login]).to eq('test@test.com') expect(data[:imap_login]).to eq('test@test.com')
expect(data[:branded_email_layout]).to eq('<html>{{ content_for_layout }} Branded</html>')
end
it 'does not return saved branded email layout when feature is disabled' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body).not_to have_key('branded_email_layout')
end
it 'does not return branded email layout for an agent' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:inbox_member, user: agent, inbox: email_inbox)
create(:email_template, :layout, account: account, inbox: email_inbox, body: '<html>{{ content_for_layout }} Branded</html>')
get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
data = JSON.parse(response.body, symbolize_names: true)
expect(data[:branded_email_layout]).to be_nil
end end
context 'when it is a Twilio inbox' do context 'when it is a Twilio inbox' do
@@ -577,6 +620,146 @@ RSpec.describe 'Inboxes API', type: :request do
expect(email_channel.reload.email).to eq('emailtest@email.test') expect(email_channel.reload.email).to eq('emailtest@email.test')
end end
it 'updates branded email layout for email inbox when feature is enabled' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
layout = '<html><body><header>Brand</header>{{ content_for_layout }}</body></html>'
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: layout },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.branded_email_layout).to eq(layout)
expect(response.parsed_body['branded_email_layout']).to eq(layout)
end
it 'rolls back branded email layout when inbox update fails' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { name: '', branded_email_layout: '<html>{{ content_for_layout }} Branded</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(email_inbox.reload.branded_email_layout).to be_nil
end
it 'clears branded email layout when blank value is passed' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '' },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.branded_email_layout).to be_nil
end
it 'clears branded email layout when null string value is passed' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
create(:email_template, :layout, account: account, inbox: email_inbox)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: 'null' },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.branded_email_layout).to be_nil
end
it 'rejects branded email layout when feature is disabled' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>{{ content_for_layout }}</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Branded email templates feature is not enabled')
expect(email_inbox.reload.branded_email_layout).to be_nil
end
it 'ignores blank branded email layout when feature is disabled' do
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { name: 'Renamed Email Inbox', branded_email_layout: nil },
as: :json
expect(response).to have_http_status(:success)
expect(email_inbox.reload.name).to eq('Renamed Email Inbox')
expect(email_inbox.branded_email_layout).to be_nil
end
it 'rejects branded email layout for non-email inboxes' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>{{ content_for_layout }}</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Branded email layout is only supported for email inboxes')
end
it 'ignores blank branded email layout for non-email inboxes' do
account.enable_features!(:branded_email_templates)
patch "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
headers: admin.create_new_auth_token,
params: { name: 'Renamed Inbox', branded_email_layout: '' },
as: :json
expect(response).to have_http_status(:success)
expect(inbox.reload.name).to eq('Renamed Inbox')
end
it 'rejects branded email layout without content slot' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>No slot</html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('must include {{ content_for_layout }}')
end
it 'rejects branded email layout with invalid liquid syntax' do
account.enable_features!(:branded_email_templates)
email_channel = create(:channel_email, account: account)
email_inbox = create(:inbox, channel: email_channel, account: account)
patch "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}",
headers: admin.create_new_auth_token,
params: { branded_email_layout: '<html>{{ content_for_layout }} {{ broken </html>' },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('has invalid Liquid syntax')
end
it 'updates twilio sms inbox when administrator' do it 'updates twilio sms inbox when administrator' do
twilio_sms_channel = create(:channel_twilio_sms, account: account) twilio_sms_channel = create(:channel_twilio_sms, account: account)
twilio_sms_inbox = create(:inbox, channel: twilio_sms_channel, account: account) twilio_sms_inbox = create(:inbox, channel: twilio_sms_channel, account: account)
+7
View File
@@ -1,5 +1,12 @@
FactoryBot.define do FactoryBot.define do
factory :email_template do factory :email_template do
name { 'MyString' } name { 'MyString' }
body { 'Email template body' }
trait :layout do
name { EmailTemplate::BRANDED_LAYOUT_NAME }
template_type { :layout }
body { '<html><body>{{ content_for_layout }}</body></html>' }
end
end end
end end
@@ -4,6 +4,10 @@ describe EmailTemplates::DbResolverService do
subject(:resolver) { described_class.using(EmailTemplate, {}) } subject(:resolver) { described_class.using(EmailTemplate, {}) }
describe '#find_templates' do describe '#find_templates' do
after do
Current.reset
end
context 'when template does not exist in db' do context 'when template does not exist in db' do
it 'return empty array' do it 'return empty array' do
expect(resolver.find_templates('test', '', false, [])).to eq([]) expect(resolver.find_templates('test', '', false, [])).to eq([])
@@ -53,7 +57,6 @@ describe EmailTemplates::DbResolverService do
"DB Template - #{account_template.id}", handler, **template_details "DB Template - #{account_template.id}", handler, **template_details
).inspect ).inspect
) )
Current.account = nil
end end
it 'return installation template when current account dont have template' do it 'return installation template when current account dont have template' do
@@ -73,7 +76,69 @@ describe EmailTemplates::DbResolverService do
"DB Template - #{installation_template.id}", handler, **template_details "DB Template - #{installation_template.id}", handler, **template_details
).inspect ).inspect
) )
Current.account = nil end
end
context 'when inbox template exists in db' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, :with_email, account: account) }
let!(:inbox_template) { create(:email_template, :layout, account: account, inbox: inbox, body: 'inbox {{ content_for_layout }}') }
let!(:installation_template) { create(:email_template, :layout, body: 'global {{ content_for_layout }}') }
it 'returns inbox template when branded email templates feature is enabled' do
account.enable_features!(:branded_email_templates)
Current.account = account
Current.inbox = inbox
expect(resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first.source).to eq(inbox_template.body)
end
it 'skips account template when branded email templates feature is disabled' do
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
Current.account = account
Current.inbox = inbox
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
expect(resolved_template.source).to eq(installation_template.body)
expect(resolved_template.source).not_to eq(account_template.body)
end
it 'returns account template when current inbox is not email and feature is enabled' do
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
account.enable_features!(:branded_email_templates)
Current.account = account
Current.inbox = create(:inbox, account: account)
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
expect(resolved_template.source).to eq(account_template.body)
expect(resolved_template.source).not_to eq(installation_template.body)
end
it 'skips account template when current inbox is not email and feature is disabled' do
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
Current.account = account
Current.inbox = create(:inbox, account: account)
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
expect(resolved_template.source).to eq(installation_template.body)
expect(resolved_template.source).not_to eq(account_template.body)
end
it 'skips account template without an inbox when feature is disabled' do
account_template = create(:email_template, :layout, account: account, body: 'account {{ content_for_layout }}')
Current.account = account
resolved_template = resolver.find_templates('base', 'layouts/mailer', false, { locale: [:en] }).first
expect(resolved_template.source).to eq(installation_template.body)
expect(resolved_template.source).not_to eq(account_template.body)
end
it 'falls back to english when requested locale does not have a template' do
account.enable_features!(:branded_email_templates)
Current.account = account
Current.inbox = inbox
expect(resolver.find_templates('base', 'layouts/mailer', false, { locale: [:fr] }).first.source).to eq(inbox_template.body)
end end
end end
end end
@@ -137,6 +137,34 @@ RSpec.describe ConversationReplyMailer do
end end
end end
context 'without summary for a non-email inbox' do
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account)) }
let(:conversation) { create(:conversation, assignee: agent, account: account, inbox: inbox) }
let!(:incoming_email_message) do
create(:message, conversation: conversation, account: account, message_type: :incoming, content_type: :incoming_email)
end
let!(:outgoing_message) do
create(:message, conversation: conversation, account: account, message_type: :outgoing, content: 'Outgoing email reply')
end
let(:mail) { described_class.reply_without_summary(conversation, incoming_email_message.id).deliver_now }
it 'applies the account branded email layout' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, account: account, body: '<html><body>Account Brand {{ content_for_layout }}</body></html>')
expect(mail.decoded).to include('Account Brand')
expect(mail.decoded).to include(outgoing_message.content)
end
it 'does not apply an installation layout without an account override' do
account.enable_features!(:branded_email_templates)
create(:email_template, :layout, body: '<html><body>Installation Brand {{ content_for_layout }}</body></html>')
expect(mail.decoded).not_to include('Installation Brand')
expect(mail.decoded).to include(outgoing_message.content)
end
end
context 'with references header' do context 'with references header' do
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload } let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') } let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
@@ -243,6 +271,73 @@ RSpec.describe ConversationReplyMailer do
expect(mail.decoded).to include message.content expect(mail.decoded).to include message.content
end end
it 'does not apply branded email layout when feature is disabled' do
create(
:email_template,
:layout,
account: account,
inbox: conversation.inbox,
body: '<html><body>Inbox Brand {{ content_for_layout }}</body></html>'
)
expect(mail.decoded).not_to include('Inbox Brand')
expect(mail.decoded).to include(message.content)
end
it 'exposes the reply sender in inbox branded email layouts' do
account.enable_features!(:branded_email_templates)
conversation.inbox.update!(business_name: 'Acme Support')
create(
:email_template,
:layout,
account: account,
inbox: conversation.inbox,
body: [
'<html><body><header>{{ inbox.business_name }}</header>',
'{{ content_for_layout }}',
'<span>{{ agent.email }}</span>',
'<footer>{{ message.sender_display_name }}</footer></body></html>'
].join
)
expect(mail.decoded).to include('Acme Support')
expect(mail.decoded).to include(message.content)
expect(message.sender).not_to eq(agent)
expect(mail.decoded).to include(message.sender.email)
expect(mail.decoded).to include(message.sender.available_name)
end
it 'falls back to account branded email layout when inbox layout is absent' do
account.enable_features!(:branded_email_templates)
create(
:email_template,
:layout,
account: account,
body: '<html><body>Account Brand {{ content_for_layout }}</body></html>'
)
expect(mail.decoded).to include('Account Brand')
expect(mail.decoded).to include(message.content)
end
it 'applies inbox branded email layout to template messages' do
account.enable_features!(:branded_email_templates)
create(
:email_template,
:layout,
account: account,
inbox: conversation.inbox,
body: '<html><body>Template Brand {{ content_for_layout }}</body></html>'
)
template_message = create(:message, conversation: conversation, account: account, message_type: :template, content_type: :text,
content: 'Automation template response', sender: agent)
template_mail = described_class.email_reply(template_message).deliver_now
expect(template_mail.decoded).to include('Template Brand')
expect(template_mail.decoded).to include('Automation template response')
end
it 'builds messageID properly' do it 'builds messageID properly' do
expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}") expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
end end
@@ -736,6 +831,22 @@ RSpec.describe ConversationReplyMailer do
it 'sets the correct in reply to id' do it 'sets the correct in reply to id' do
expect(mail.in_reply_to).to eq("account/#{conversation.account.id}/conversation/#{conversation.uuid}@#{domain}") expect(mail.in_reply_to).to eq("account/#{conversation.account.id}/conversation/#{conversation.uuid}@#{domain}")
end end
it 'applies inbox branded email layout to conversation transcript' do
new_account.enable_features!(:branded_email_templates)
create(
:email_template,
:layout,
account: new_account,
inbox: conversation.inbox,
body: '<html><body>Transcript Brand {{ content_for_layout }}</body></html>'
)
transcript = described_class.conversation_transcript(conversation, 'customer@example.com').deliver_now
expect(transcript.decoded).to include('Transcript Brand')
expect(transcript.decoded).to include(message.content)
end
end end
end end
end end
+126
View File
@@ -0,0 +1,126 @@
require 'rails_helper'
RSpec.describe EmailTemplate do
describe 'validations' do
it 'allows the same layout name across installation, account, and inbox scopes' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, account: nil)
create(:email_template, :layout, account: account)
inbox_template = build(:email_template, :layout, account: account, inbox: inbox)
expect(inbox_template).to be_valid
end
it 'allows an account-scoped layout after an inbox-scoped layout' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, account: account, inbox: inbox)
account_template = build(:email_template, :layout, account: account)
expect(account_template).to be_valid
end
it 'allows an installation-scoped layout after account and inbox-scoped layouts' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, account: account)
create(:email_template, :layout, account: account, inbox: inbox)
installation_template = build(:email_template, :layout, account: nil)
expect(installation_template).to be_valid
end
it 'rejects duplicate installation-scoped templates' do
create(:email_template)
duplicate_template = build(:email_template)
expect(duplicate_template).not_to be_valid
expect(duplicate_template.errors[:name]).to include('has already been taken')
end
it 'rejects duplicate account-scoped templates' do
account = create(:account)
create(:email_template, account: account)
duplicate_template = build(:email_template, account: account)
expect(duplicate_template).not_to be_valid
expect(duplicate_template.errors[:name]).to include('has already been taken')
end
it 'rejects duplicate inbox-scoped templates' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, account: account, inbox: inbox)
duplicate_template = build(:email_template, account: account, inbox: inbox)
expect(duplicate_template).not_to be_valid
expect(duplicate_template.errors[:name]).to include('has already been taken')
end
it 'requires branded layouts to include content_for_layout' do
template = build(:email_template, name: EmailTemplate::BRANDED_LAYOUT_NAME, template_type: :layout, body: '<html><body>No slot</body></html>')
expect(template).not_to be_valid
expect(template.errors[:body]).to include('must include {{ content_for_layout }}')
end
it 'validates liquid syntax' do
template = build(:email_template, body: '{{ broken ')
expect(template).not_to be_valid
expect(template.errors[:body].first).to include('has invalid Liquid syntax')
end
it 'requires account to match inbox account when both are present' do
inbox = create(:inbox, :with_email)
other_account = create(:account)
template = build(:email_template, :layout, account: other_account, inbox: inbox)
expect(template).not_to be_valid
expect(template.errors[:account]).to include('must match inbox account')
end
end
describe '.branded_layout_for' do
it 'uses inbox, account, then installation fallback order' do
account = create(:account)
inbox = create(:inbox, :with_email, account: account)
create(:email_template, :layout, body: 'Global {{ content_for_layout }}')
account_template = create(:email_template, :layout, account: account, body: 'Account {{ content_for_layout }}')
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(account_template)
inbox_template = create(:email_template, :layout, account: account, inbox: inbox, body: 'Inbox {{ content_for_layout }}')
expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(inbox_template)
end
end
describe '.update_account_branded_layout!' do
it 'creates and updates the account-scoped branded layout' do
account = create(:account)
described_class.update_account_branded_layout!(account: account, body: 'Account {{ content_for_layout }}')
template = described_class.account_branded_layout_template_for(account)
expect(template.body).to eq('Account {{ content_for_layout }}')
described_class.update_account_branded_layout!(account: account, body: 'Updated {{ content_for_layout }}')
expect(template.reload.body).to eq('Updated {{ content_for_layout }}')
end
it 'clears the account-scoped branded layout for blank bodies' do
account = create(:account)
create(:email_template, :layout, account: account)
described_class.update_account_branded_layout!(account: account, body: '')
expect(described_class.account_branded_layout_template_for(account)).to be_nil
end
end
end