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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
+21
@@ -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
@@ -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|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user