From c420edce58f03b1f3d43932dcea968991935c89e Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Fri, 17 Jul 2026 15:25:55 +0530 Subject: [PATCH] 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.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": "
Account Brand
{{ content_for_layout }}
Account footer
" } ``` 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": "
Inbox Brand
{{ content_for_layout }}
Inbox footer
" } ``` 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 --- .../branded_email_layouts_controller.rb | 28 +++ .../api/v1/accounts/inboxes_controller.rb | 47 ++++- app/drops/inbox_drop.rb | 14 ++ app/mailers/conversation_reply_mailer.rb | 18 ++ .../inbox_branded_email_layoutable.rb | 36 ++++ app/models/email_template.rb | 96 ++++++++- app/models/inbox.rb | 2 + .../email_templates/db_resolver_service.rb | 61 +++++- .../branded_email_layouts/show.json.jbuilder | 1 + .../update.json.jbuilder | 1 + .../v1/accounts/inboxes/show.json.jbuilder | 2 +- .../v1/accounts/inboxes/update.json.jbuilder | 2 +- app/views/api/v1/models/_inbox.json.jbuilder | 4 + config/features.yml | 5 +- config/routes.rb | 1 + ...0000_add_inbox_scope_to_email_templates.rb | 50 +++++ ...le_in_reply_for_branded_email_templates.rb | 21 ++ db/schema.rb | 6 +- lib/current.rb | 2 + .../branded_email_layouts_controller_spec.rb | 152 +++++++++++++++ .../v1/accounts/inboxes_controller_spec.rb | 183 ++++++++++++++++++ spec/factories/email_template.rb | 7 + .../db_resolver_service_spec.rb | 69 ++++++- .../mailers/conversation_reply_mailer_spec.rb | 111 +++++++++++ spec/models/email_template_spec.rb | 126 ++++++++++++ 25 files changed, 1023 insertions(+), 22 deletions(-) create mode 100644 app/controllers/api/v1/accounts/branded_email_layouts_controller.rb create mode 100644 app/models/concerns/inbox_branded_email_layoutable.rb create mode 100644 app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder create mode 100644 app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder create mode 100644 db/migrate/20260706000000_add_inbox_scope_to_email_templates.rb create mode 100644 db/migrate/20260706000001_repurpose_insert_article_in_reply_for_branded_email_templates.rb create mode 100644 spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb create mode 100644 spec/models/email_template_spec.rb diff --git a/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb new file mode 100644 index 000000000..54f976b79 --- /dev/null +++ b/app/controllers/api/v1/accounts/branded_email_layouts_controller.rb @@ -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') diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index bb9ca2a70..822fce7db 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -45,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def 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? + continue_update = false + + ActiveRecord::Base.transaction do + continue_update = update_branded_email_layout + 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 def agent_bot @@ -155,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController formatted['template'] = config['template'] if config['template'].present? 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 [: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, diff --git a/app/drops/inbox_drop.rb b/app/drops/inbox_drop.rb index 563916d5f..29698fb4b 100644 --- a/app/drops/inbox_drop.rb +++ b/app/drops/inbox_drop.rb @@ -2,4 +2,18 @@ class InboxDrop < BaseDrop def name @obj.try(:name) 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 diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index 20e194fed..22b176421 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -69,6 +69,8 @@ class ConversationReplyMailer < ApplicationMailer @agent = @conversation.assignee @inbox = @conversation.inbox @channel = @inbox.channel + Current.account = @account + Current.inbox = @inbox end def should_use_conversation_email_address? @@ -200,8 +202,24 @@ class ConversationReplyMailer < ApplicationMailer end def choose_layout + return 'mailer/base' if branded_email_layout_action? return false if action_name == 'reply_without_summary' || action_name == 'email_reply' 'mailer/base' 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 diff --git a/app/models/concerns/inbox_branded_email_layoutable.rb b/app/models/concerns/inbox_branded_email_layoutable.rb new file mode 100644 index 000000000..196ada3e2 --- /dev/null +++ b/app/models/concerns/inbox_branded_email_layoutable.rb @@ -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 diff --git a/app/models/email_template.rb b/app/models/email_template.rb index 57db7a94c..dd7f20f6e 100644 --- a/app/models/email_template.rb +++ b/app/models/email_template.rb @@ -10,19 +10,111 @@ # created_at :datetime not null # updated_at :datetime not null # account_id :integer +# inbox_id :integer # # 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 + 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 :template_type, { layout: 0, content: 1 } 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 = {}) ::EmailTemplates::DbResolverService.using self, options 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 diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 1b0cfc587..69f41180a 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -45,6 +45,7 @@ class Inbox < ApplicationRecord include OutOfOffisable include AccountCacheRevalidator include InboxAgentAvailability + include InboxBrandedEmailLayoutable # Not allowing characters: validates :name, presence: true @@ -67,6 +68,7 @@ class Inbox < ApplicationRecord has_many :members, through: :inbox_members, source: :user has_many :conversations, 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 :assignment_policy, through: :inbox_assignment_policy diff --git a/app/services/email_templates/db_resolver_service.rb b/app/services/email_templates/db_resolver_service.rb index 42222fe75..0a4621aa9 100644 --- a/app/services/email_templates/db_resolver_service.rb +++ b/app/services/email_templates/db_resolver_service.rb @@ -29,12 +29,14 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver end # rubocop:enable Metrics/ParameterLists - # the function has to accept(name, prefix, partial, _details, _locals = []) - # _details contain local info which we can leverage in future + # the function has to accept(name, prefix, partial, details, locals = []) + # details contain local info which we can leverage in future # cause of codeclimate issue with 4 args, relying on (*args) - def find_templates(name, prefix, partial, *_args) + def find_templates(name, prefix, partial, *args) @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 return [] if @db_template.blank? @@ -54,17 +56,62 @@ class ::EmailTemplates::DbResolverService < ActionView::Resolver private 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 def find_account_template 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 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 # Build path with eventual prefix diff --git a/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder b/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder new file mode 100644 index 000000000..7a30a0b14 --- /dev/null +++ b/app/views/api/v1/accounts/branded_email_layouts/show.json.jbuilder @@ -0,0 +1 @@ +json.branded_email_layout @branded_email_layout diff --git a/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder b/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder new file mode 100644 index 000000000..7a30a0b14 --- /dev/null +++ b/app/views/api/v1/accounts/branded_email_layouts/update.json.jbuilder @@ -0,0 +1 @@ +json.branded_email_layout @branded_email_layout diff --git a/app/views/api/v1/accounts/inboxes/show.json.jbuilder b/app/views/api/v1/accounts/inboxes/show.json.jbuilder index 2ad94ff82..4837c8c02 100644 --- a/app/views/api/v1/accounts/inboxes/show.json.jbuilder +++ b/app/views/api/v1/accounts/inboxes/show.json.jbuilder @@ -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 diff --git a/app/views/api/v1/accounts/inboxes/update.json.jbuilder b/app/views/api/v1/accounts/inboxes/update.json.jbuilder index 2ad94ff82..4837c8c02 100644 --- a/app/views/api/v1/accounts/inboxes/update.json.jbuilder +++ b/app/views/api/v1/accounts/inboxes/update.json.jbuilder @@ -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 diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 677e4fe9e..216f7db76 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -81,6 +81,10 @@ if resource.email? json.email resource.channel.try(:email) 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? + 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 if Current.account_user&.administrator? diff --git a/config/features.yml b/config/features.yml index b231246f5..88c193ee7 100644 --- a/config/features.yml +++ b/config/features.yml @@ -120,10 +120,9 @@ display_name: Message Reply To enabled: false deprecated: true -- name: insert_article_in_reply - display_name: Insert Article in Reply +- name: branded_email_templates + display_name: Branded Email Templates enabled: false - deprecated: true - name: inbox_view display_name: Inbox View enabled: false diff --git a/config/routes.rb b/config/routes.rb index 1481c14c7..dfdc23727 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -267,6 +267,7 @@ Rails.application.routes.draw do resources :custom_attribute_definitions, 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 get :assignable_agents, on: :member get :campaigns, on: :member diff --git a/db/migrate/20260706000000_add_inbox_scope_to_email_templates.rb b/db/migrate/20260706000000_add_inbox_scope_to_email_templates.rb new file mode 100644 index 000000000..3e6925b60 --- /dev/null +++ b/db/migrate/20260706000000_add_inbox_scope_to_email_templates.rb @@ -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 diff --git a/db/migrate/20260706000001_repurpose_insert_article_in_reply_for_branded_email_templates.rb b/db/migrate/20260706000001_repurpose_insert_article_in_reply_for_branded_email_templates.rb new file mode 100644 index 000000000..b11c96cb4 --- /dev/null +++ b/db/migrate/20260706000001_repurpose_insert_article_in_reply_for_branded_email_templates.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 14b634c30..a3dfbda46 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -987,7 +987,11 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do t.integer "locale", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["name", "account_id"], name: "index_email_templates_on_name_and_account_id", unique: true + t.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 create_table "folders", force: :cascade do |t| diff --git a/lib/current.rb b/lib/current.rb index 3376099f8..2c3dfa61d 100644 --- a/lib/current.rb +++ b/lib/current.rb @@ -4,6 +4,7 @@ module Current thread_mattr_accessor :account_user thread_mattr_accessor :executed_by thread_mattr_accessor :contact + thread_mattr_accessor :inbox def self.reset Current.user = nil @@ -11,5 +12,6 @@ module Current Current.account_user = nil Current.executed_by = nil Current.contact = nil + Current.inbox = nil end end diff --git a/spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb b/spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb new file mode 100644 index 000000000..a2c2571b0 --- /dev/null +++ b/spec/controllers/api/v1/accounts/branded_email_layouts_controller_spec.rb @@ -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) { '
Brand
{{ content_for_layout }}' } + + 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: 'No slot' }, + 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: '{{ content_for_layout }} {{ broken ' }, + 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 diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index 0fd6ad7bf..6a8055a04 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -36,6 +36,18 @@ RSpec.describe 'Inboxes API', type: :request do expect(JSON.parse(response.body, symbolize_names: true)[:payload].size).to eq(2) 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: '{{ content_for_layout }} Branded') + + 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 get "/api/v1/accounts/#{account.id}/inboxes", headers: agent.create_new_auth_token, @@ -161,8 +173,10 @@ RSpec.describe 'Inboxes API', type: :request do end 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_inbox = create(:inbox, channel: email_channel, account: account) + create(:email_template, :layout, account: account, inbox: email_inbox, body: '{{ content_for_layout }} Branded') imap_connection = double 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_login]).to eq('test@test.com') + expect(data[:branded_email_layout]).to eq('{{ content_for_layout }} Branded') + 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: '{{ content_for_layout }} Branded') + + 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: '{{ content_for_layout }} Branded') + + 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 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') 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 = '
Brand
{{ content_for_layout }}' + + 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: '{{ content_for_layout }} Branded' }, + 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: '{{ content_for_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(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: '{{ content_for_layout }}' }, + 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: 'No slot' }, + 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: '{{ content_for_layout }} {{ broken ' }, + 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 twilio_sms_channel = create(:channel_twilio_sms, account: account) twilio_sms_inbox = create(:inbox, channel: twilio_sms_channel, account: account) diff --git a/spec/factories/email_template.rb b/spec/factories/email_template.rb index 394eb6590..3830e7e13 100644 --- a/spec/factories/email_template.rb +++ b/spec/factories/email_template.rb @@ -1,5 +1,12 @@ FactoryBot.define do factory :email_template do name { 'MyString' } + body { 'Email template body' } + + trait :layout do + name { EmailTemplate::BRANDED_LAYOUT_NAME } + template_type { :layout } + body { '{{ content_for_layout }}' } + end end end diff --git a/spec/lib/email_templates/db_resolver_service_spec.rb b/spec/lib/email_templates/db_resolver_service_spec.rb index 56c8a648b..cc8ddd0b2 100644 --- a/spec/lib/email_templates/db_resolver_service_spec.rb +++ b/spec/lib/email_templates/db_resolver_service_spec.rb @@ -4,6 +4,10 @@ describe EmailTemplates::DbResolverService do subject(:resolver) { described_class.using(EmailTemplate, {}) } describe '#find_templates' do + after do + Current.reset + end + context 'when template does not exist in db' do it 'return empty array' do expect(resolver.find_templates('test', '', false, [])).to eq([]) @@ -53,7 +57,6 @@ describe EmailTemplates::DbResolverService do "DB Template - #{account_template.id}", handler, **template_details ).inspect ) - Current.account = nil end 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 ).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 diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index df4c4f41f..576ca882a 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -137,6 +137,34 @@ RSpec.describe ConversationReplyMailer do 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: 'Account Brand {{ content_for_layout }}') + + 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: 'Installation Brand {{ content_for_layout }}') + + expect(mail.decoded).not_to include('Installation Brand') + expect(mail.decoded).to include(outgoing_message.content) + end + end + context 'with references header' do 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') } @@ -243,6 +271,73 @@ RSpec.describe ConversationReplyMailer do expect(mail.decoded).to include message.content end + it 'does not apply branded email layout when feature is disabled' do + create( + :email_template, + :layout, + account: account, + inbox: conversation.inbox, + body: 'Inbox Brand {{ content_for_layout }}' + ) + + 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: [ + '
{{ inbox.business_name }}
', + '{{ content_for_layout }}', + '{{ agent.email }}', + '' + ].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: 'Account Brand {{ content_for_layout }}' + ) + + 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: 'Template Brand {{ content_for_layout }}' + ) + 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 expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}") end @@ -736,6 +831,22 @@ RSpec.describe ConversationReplyMailer 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}") 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: 'Transcript Brand {{ content_for_layout }}' + ) + + 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 diff --git a/spec/models/email_template_spec.rb b/spec/models/email_template_spec.rb new file mode 100644 index 000000000..8cc9e667f --- /dev/null +++ b/spec/models/email_template_spec.rb @@ -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: 'No slot') + + 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