## 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
135 lines
4.1 KiB
Ruby
135 lines
4.1 KiB
Ruby
# Code is heavily inspired by panaromic gem
|
|
# https://github.com/andreapavoni/panoramic
|
|
# We will try to find layouts and content from database
|
|
# layout will be rendered with erb and other content in html format
|
|
# Further processing in liquid is implemented in mailers
|
|
|
|
# NOTE: rails resolver looks for templates in cache first
|
|
# which we don't want to happen here
|
|
# so we are overriding find_all method in action view resolver
|
|
# If anything breaks - look into rails : actionview/lib/action_view/template/resolver.rb
|
|
|
|
class ::EmailTemplates::DbResolverService < ActionView::Resolver
|
|
require 'singleton'
|
|
include Singleton
|
|
|
|
# Instantiate Resolver by passing a model.
|
|
def self.using(model, options = {})
|
|
class_variable_set(:@@model, model)
|
|
class_variable_set(:@@resolver_options, options)
|
|
instance
|
|
end
|
|
|
|
# Since rails picks up files from cache. lets override the method
|
|
# Normalizes the arguments and passes it on to find_templates.
|
|
# rubocop:disable Metrics/ParameterLists
|
|
def find_all(name, prefix = nil, partial = false, details = {}, key = nil, locals = [])
|
|
locals = locals.map(&:to_s).sort!.freeze
|
|
_find_all(name, prefix, partial, details, key, locals)
|
|
end
|
|
# rubocop:enable Metrics/ParameterLists
|
|
|
|
# the function has to accept(name, prefix, partial, details, locals = [])
|
|
# details contain local info which we can leverage in future
|
|
# cause of codeclimate issue with 4 args, relying on (*args)
|
|
def find_templates(name, prefix, partial, *args)
|
|
@template_name = name
|
|
@template_type = prefix.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?
|
|
|
|
path = build_path(prefix)
|
|
handler = ActionView::Template.registered_template_handler(:liquid)
|
|
|
|
template_details = {
|
|
locals: [],
|
|
format: Mime['html'].to_sym,
|
|
virtual_path: virtual_path(path, partial)
|
|
}
|
|
|
|
[ActionView::Template.new(@db_template.body, "DB Template - #{@db_template.id}", handler, **template_details)]
|
|
end
|
|
|
|
private
|
|
|
|
def find_db_template
|
|
find_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?
|
|
|
|
find_template_for(@@model.where(account: Current.account, inbox: nil))
|
|
end
|
|
|
|
def find_installation_template
|
|
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
|
|
def build_path(prefix)
|
|
prefix.present? ? "#{prefix}/#{@template_name}" : @template_name
|
|
end
|
|
|
|
# returns a path depending if its a partial or template
|
|
# params path: path/to/file.ext partial: true/false
|
|
# the function appends _to make the file name _file.ext if partial: true
|
|
def virtual_path(path, partial)
|
|
return path unless partial
|
|
|
|
if (index = path.rindex('/'))
|
|
path.insert(index + 1, '_')
|
|
else
|
|
"_#{path}"
|
|
end
|
|
end
|
|
end
|