## 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
228 lines
7.3 KiB
Ruby
228 lines
7.3 KiB
Ruby
class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|
include Api::V1::InboxesHelper
|
|
before_action :fetch_inbox, except: [:index, :create]
|
|
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
|
# we are already handling the authorization in fetch inbox
|
|
before_action :check_authorization, except: [:show]
|
|
|
|
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
|
|
|
def index
|
|
@inboxes = policy_scope(Current.account.inboxes)
|
|
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
|
|
.order_by_name
|
|
end
|
|
|
|
def show; end
|
|
|
|
# Deprecated: This API will be removed in 2.7.0
|
|
def assignable_agents
|
|
@assignable_agents = @inbox.assignable_agents
|
|
end
|
|
|
|
def campaigns
|
|
@campaigns = @inbox.campaigns
|
|
end
|
|
|
|
def avatar
|
|
@inbox.avatar.attachment.destroy! if @inbox.avatar.attached?
|
|
head :ok
|
|
end
|
|
|
|
def create
|
|
ActiveRecord::Base.transaction do
|
|
channel = create_channel
|
|
@inbox = Current.account.inboxes.build(
|
|
{
|
|
name: inbox_name(channel),
|
|
channel: channel
|
|
}.merge(
|
|
permitted_params.except(:channel)
|
|
)
|
|
)
|
|
@inbox.save!
|
|
end
|
|
end
|
|
|
|
def update
|
|
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
|
|
@agent_bot = @inbox.agent_bot
|
|
end
|
|
|
|
def set_agent_bot
|
|
if @agent_bot
|
|
agent_bot_inbox = @inbox.agent_bot_inbox || AgentBotInbox.new(inbox: @inbox)
|
|
agent_bot_inbox.agent_bot = @agent_bot
|
|
agent_bot_inbox.save!
|
|
elsif @inbox.agent_bot_inbox.present?
|
|
@inbox.agent_bot_inbox.destroy!
|
|
end
|
|
head :ok
|
|
end
|
|
|
|
def reset_secret
|
|
return head :not_found unless @inbox.api?
|
|
|
|
@inbox.channel.reset_secret!
|
|
end
|
|
|
|
def destroy
|
|
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
|
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
|
end
|
|
|
|
private
|
|
|
|
def fetch_inbox
|
|
@inbox = Current.account.inboxes.find(params[:id])
|
|
authorize @inbox, :show?
|
|
end
|
|
|
|
def fetch_agent_bot
|
|
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
|
|
end
|
|
|
|
def create_channel
|
|
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
|
|
|
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
|
|
end
|
|
|
|
def allowed_channel_types
|
|
%w[web_widget api email line telegram whatsapp sms]
|
|
end
|
|
|
|
def update_inbox_working_hours
|
|
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
|
|
end
|
|
|
|
def update_channel
|
|
channel_attributes = get_channel_attributes(@inbox.channel_type)
|
|
return if permitted_params(channel_attributes)[:channel].blank?
|
|
|
|
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
|
|
|
|
reauthorize_and_update_channel(channel_attributes)
|
|
update_channel_feature_flags
|
|
end
|
|
|
|
def channel_update_required?
|
|
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
|
|
end
|
|
|
|
def validate_and_update_email_channel(channel_attributes)
|
|
validate_email_channel(channel_attributes)
|
|
rescue StandardError => e
|
|
render json: { message: e }, status: :unprocessable_entity and return
|
|
end
|
|
|
|
def reauthorize_and_update_channel(channel_attributes)
|
|
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
|
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
|
end
|
|
|
|
def update_channel_feature_flags
|
|
return unless @inbox.web_widget?
|
|
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
|
|
|
|
@inbox.channel.selected_feature_flags = permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel][:selected_feature_flags]
|
|
@inbox.channel.save!
|
|
end
|
|
|
|
def format_csat_config(config)
|
|
formatted = {
|
|
'display_type' => config['display_type'] || 'emoji',
|
|
'message' => config['message'] || '',
|
|
:survey_rules => {
|
|
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
|
'values' => config.dig('survey_rules', 'values') || []
|
|
},
|
|
'button_text' => config['button_text'] || 'Please rate us',
|
|
'language' => config['language'] || 'en'
|
|
}
|
|
format_template_config(config, formatted)
|
|
formatted
|
|
end
|
|
|
|
def format_template_config(config, formatted)
|
|
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,
|
|
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
|
{ csat_config: [:display_type, :message, :button_text, :language,
|
|
{ survey_rules: [:operator, { values: [] }],
|
|
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
|
|
end
|
|
|
|
def permitted_params(channel_attributes = [])
|
|
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
|
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
|
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
|
end
|
|
|
|
def channel_type_from_params
|
|
{
|
|
'web_widget' => Channel::WebWidget,
|
|
'api' => Channel::Api,
|
|
'email' => Channel::Email,
|
|
'line' => Channel::Line,
|
|
'telegram' => Channel::Telegram,
|
|
'whatsapp' => Channel::Whatsapp,
|
|
'sms' => Channel::Sms
|
|
}[permitted_params[:channel][:type]]
|
|
end
|
|
|
|
def get_channel_attributes(channel_type)
|
|
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
|
end
|
|
end
|
|
|
|
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|