## 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
121 lines
4.0 KiB
Ruby
121 lines
4.0 KiB
Ruby
# == Schema Information
|
|
#
|
|
# Table name: email_templates
|
|
#
|
|
# id :bigint not null, primary key
|
|
# body :text not null
|
|
# locale :integer default("en"), not null
|
|
# name :string not null
|
|
# template_type :integer default("content")
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# account_id :integer
|
|
# inbox_id :integer
|
|
#
|
|
# Indexes
|
|
#
|
|
# 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: %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
|