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,50 @@
|
||||
class AddInboxScopeToEmailTemplates < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
add_column :email_templates, :inbox_id, :integer
|
||||
add_index :email_templates, :inbox_id, name: 'index_email_templates_on_inbox_id'
|
||||
|
||||
remove_index :email_templates, name: 'index_email_templates_on_name_and_account_id'
|
||||
ensure_no_duplicate_installation_templates!
|
||||
add_index :email_templates,
|
||||
[:name, :template_type, :locale],
|
||||
unique: true,
|
||||
where: 'account_id IS NULL AND inbox_id IS NULL',
|
||||
name: 'index_email_templates_on_installation_scope'
|
||||
add_index :email_templates,
|
||||
[:account_id, :name, :template_type, :locale],
|
||||
unique: true,
|
||||
where: 'account_id IS NOT NULL AND inbox_id IS NULL',
|
||||
name: 'index_email_templates_on_account_scope'
|
||||
add_index :email_templates,
|
||||
[:inbox_id, :name, :template_type, :locale],
|
||||
unique: true,
|
||||
where: 'inbox_id IS NOT NULL',
|
||||
name: 'index_email_templates_on_inbox_scope'
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :email_templates, name: 'index_email_templates_on_inbox_scope'
|
||||
remove_index :email_templates, name: 'index_email_templates_on_account_scope'
|
||||
remove_index :email_templates, name: 'index_email_templates_on_installation_scope'
|
||||
remove_index :email_templates, name: 'index_email_templates_on_inbox_id'
|
||||
|
||||
add_index :email_templates, [:name, :account_id], unique: true, name: 'index_email_templates_on_name_and_account_id'
|
||||
remove_column :email_templates, :inbox_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_no_duplicate_installation_templates!
|
||||
duplicates = select_values <<~SQL.squish
|
||||
SELECT CONCAT(name, '/', template_type, '/', locale)
|
||||
FROM email_templates
|
||||
WHERE account_id IS NULL
|
||||
GROUP BY name, template_type, locale
|
||||
HAVING COUNT(*) > 1
|
||||
SQL
|
||||
return if duplicates.empty?
|
||||
|
||||
raise ActiveRecord::IrreversibleMigration,
|
||||
"Duplicate installation email templates must be resolved before migrating: #{duplicates.join(', ')}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class RepurposeInsertArticleInReplyForBrandedEmailTemplates < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
Account.feature_branded_email_templates.find_each(batch_size: 100) do |account|
|
||||
account.disable_features(:branded_email_templates)
|
||||
account.save!(validate: false)
|
||||
end
|
||||
|
||||
remove_stale_default_feature
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def remove_stale_default_feature
|
||||
config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
|
||||
return if config&.value.blank?
|
||||
|
||||
config.value = config.value.reject { |feature| feature['name'] == 'insert_article_in_reply' }
|
||||
config.save!
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
end
|
||||
@@ -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|
|
||||
|
||||
Reference in New Issue
Block a user