feat(platform): Add email channel migration endpoint for bulk OAuth channel creation (#13902)

Adds a Platform API endpoint that allows migrating existing Google and
Microsoft email channels (with OAuth credentials) into Chatwoot without
requiring end-users to re-authenticate. This enables customers who lack
Rails console access to programmatically migrate email channels from
legacy systems.
    
 ### How to test

1. Create a Platform App and grant it permissible access to a target
account
2. `POST /platform/api/v1/accounts/:account_id/email_channel_migrations`
with a payload like:
  ```json
  {
    "migrations": [
      {
        "email": "support@example.com",
        "provider": "google",
        "provider_config": {
          "access_token": "...",
          "refresh_token": "...",
          "expires_on": "..."
        },
        "inbox_name": "Migrated Support"
      }
    ]
  }
```
  3. Verify channels are created with correct provider, provider_config, and IMAP defaults
  4. Verify partial failures (e.g. duplicate email) don't roll back other migrations in the batch
  5. Verify unauthenticated and non-permissible requests return 401

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Muhsin Keloth
2026-03-26 02:58:08 +04:00
committed by GitHub
parent 608be1036b
commit 250650dd7a
3 changed files with 368 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :validate_feature_flag
before_action :validate_params
def create
results = migrate_email_channels
render json: { results: results }, status: :ok
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def validate_feature_flag
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
end
def validate_params
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
return unless migration_params.size > MAX_MIGRATIONS
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
status: :unprocessable_entity
end
def migrate_email_channels
migration_params.map { |entry| migrate_single(entry) }
end
MAX_MIGRATIONS = 25
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
def migrate_single(entry)
validate_provider!(entry[:provider])
ActiveRecord::Base.transaction do
channel = create_channel(entry)
inbox = create_inbox(channel, entry)
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
end
rescue StandardError => e
{ email: entry[:email], status: 'error', message: e.message }
end
def create_channel(entry)
Channel::Email.create!(
account_id: @account.id,
email: entry[:email],
provider: entry[:provider],
provider_config: entry[:provider_config]&.to_h,
imap_enabled: entry.fetch(:imap_enabled, true),
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
imap_port: entry[:imap_port] || 993,
imap_login: entry[:imap_login] || entry[:email],
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
)
end
def create_inbox(channel, entry)
@account.inboxes.create!(
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
channel: channel
)
end
def validate_provider!(provider)
return if SUPPORTED_PROVIDERS.include?(provider)
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
end
def default_imap_address(provider)
case provider
when 'google' then 'imap.gmail.com'
when 'microsoft' then 'outlook.office365.com'
else ''
end
end
def migration_params
params.permit(migrations: [
:email, :provider, :inbox_name,
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
{ provider_config: {} }
])[:migrations]
end
end

View File

@@ -507,6 +507,7 @@ Rails.application.routes.draw do
delete :destroy
end
end
resources :email_channel_migrations, only: [:create]
end
end
end

View File

@@ -0,0 +1,266 @@
require 'rails_helper'
RSpec.describe 'Platform Email Channel Migrations API', type: :request do
let!(:account) { create(:account) }
let(:platform_app) { create(:platform_app) }
let(:base_url) { "/platform/api/v1/accounts/#{account.id}/email_channel_migrations" }
let(:headers) { { api_access_token: platform_app.access_token.token } }
let(:google_provider_config) do
{ access_token: 'ya29.test-access-token', refresh_token: '1//test-refresh-token', expires_on: 1.hour.from_now.to_s }
end
let(:valid_migration_params) do
{
migrations: [
{
email: 'support@example.com',
provider: 'google',
provider_config: google_provider_config,
inbox_name: 'Migrated Support'
}
]
}
end
before do
create(:platform_app_permissible, platform_app: platform_app, permissible: account)
end
describe 'POST /platform/api/v1/accounts/:account_id/email_channel_migrations' do
context 'when unauthenticated' do
it 'returns unauthorized without token' do
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
post base_url, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
it 'returns unauthorized with invalid token' do
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
post base_url, params: valid_migration_params, headers: { api_access_token: 'invalid' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
context 'when account is not permissible' do
let(:other_account) { create(:account) }
let(:other_url) { "/platform/api/v1/accounts/#{other_account.id}/email_channel_migrations" }
it 'returns unauthorized' do
with_modified_env EMAIL_CHANNEL_MIGRATION: other_account.id.to_s do
post other_url, params: valid_migration_params, headers: headers, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
context 'when account is not in allowed list' do
it 'returns forbidden' do
with_modified_env EMAIL_CHANNEL_MIGRATION: '' do
post base_url, params: valid_migration_params, headers: headers, as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('Email channel migration is not enabled')
end
end
end
context 'when authenticated with permissible account' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
it 'creates a google email channel and inbox' do
expect do
post base_url, params: valid_migration_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(1).and change(Inbox, :count).by(1)
expect(response).to have_http_status(:ok)
result = response.parsed_body['results'].first
expect(result['status']).to eq('success')
expect(result['email']).to eq('support@example.com')
expect(result['inbox_id']).to be_present
expect(result['channel_id']).to be_present
end
it 'sets correct google channel attributes' do
post base_url, params: valid_migration_params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.provider).to eq('google')
expect(channel.imap_enabled).to be(true)
expect(channel.imap_address).to eq('imap.gmail.com')
expect(channel.imap_port).to eq(993)
expect(channel.imap_login).to eq('support@example.com')
expect(channel.provider_config['refresh_token']).to eq('1//test-refresh-token')
end
it 'sets correct inbox attributes' do
post base_url, params: valid_migration_params, headers: headers, as: :json
inbox = Inbox.find(response.parsed_body['results'].first['inbox_id'])
expect(inbox.name).to eq('Migrated Support')
expect(inbox.account_id).to eq(account.id)
end
it 'creates a microsoft email channel with correct defaults' do
params = {
migrations: [
{
email: 'support@outlook.com',
provider: 'microsoft',
provider_config: { access_token: 'test', refresh_token: 'test', expires_on: 1.hour.from_now.to_s }
}
]
}
post base_url, params: params, headers: headers, as: :json
expect(response).to have_http_status(:ok)
result = response.parsed_body['results'].first
channel = Channel::Email.find(result['channel_id'])
expect(channel.provider).to eq('microsoft')
expect(channel.imap_address).to eq('outlook.office365.com')
end
it 'uses default inbox name when not provided' do
params = { migrations: [{ email: 'test@example.com', provider: 'google', provider_config: google_provider_config }] }
post base_url, params: params, headers: headers, as: :json
inbox = Inbox.find(response.parsed_body['results'].first['inbox_id'])
expect(inbox.name).to eq('Migrated Google: test@example.com')
end
it 'defaults imap_login to email address' do
post base_url, params: valid_migration_params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.imap_login).to eq('support@example.com')
end
it 'allows overriding imap settings' do
params = {
migrations: [
{
email: 'custom@example.com',
provider: 'google',
provider_config: google_provider_config,
imap_address: 'custom.imap.server.com',
imap_port: 143,
imap_login: 'custom-login@example.com',
imap_enable_ssl: false
}
]
}
post base_url, params: params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.imap_address).to eq('custom.imap.server.com')
expect(channel.imap_port).to eq(143)
expect(channel.imap_login).to eq('custom-login@example.com')
expect(channel.imap_enable_ssl).to be(false)
end
end
context 'when migrating multiple channels' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
let(:bulk_params) do
{
migrations: [
{ email: 'first@example.com', provider: 'google', provider_config: google_provider_config },
{ email: 'second@example.com', provider: 'google', provider_config: google_provider_config },
{ email: 'third@example.com', provider: 'microsoft',
provider_config: { access_token: 'test', refresh_token: 'test', expires_on: 1.hour.from_now.to_s } }
]
}
end
it 'creates all channels and inboxes' do
expect do
post base_url, params: bulk_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(3).and change(Inbox, :count).by(3)
results = response.parsed_body['results']
expect(results.map { |r| r['status'] }).to all(eq('success'))
expect(results.map { |r| r['email'] }).to match_array(%w[first@example.com second@example.com third@example.com])
end
it 'continues processing when one migration fails' do
create(:channel_email, email: 'first@example.com', account: account)
expect do
post base_url, params: bulk_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(2).and change(Inbox, :count).by(2)
results = response.parsed_body['results']
failed = results.find { |r| r['email'] == 'first@example.com' }
succeeded = results.reject { |r| r['email'] == 'first@example.com' }
expect(failed['status']).to eq('error')
expect(failed['message']).to include('Email has already been taken')
expect(succeeded.map { |r| r['status'] }).to all(eq('success'))
end
end
context 'when params are invalid' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
it 'returns unprocessable entity when migrations param is missing' do
post base_url, params: {}, headers: headers, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns unprocessable entity when migrations exceed max batch size' do
params = {
migrations: Array.new(26) { |i| { email: "user#{i}@example.com", provider: 'google', provider_config: google_provider_config } }
}
post base_url, params: params, headers: headers, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('Too many migrations')
end
it 'returns error for unsupported provider' do
params = {
migrations: [{ email: 'test@example.com', provider: 'Yahoo', provider_config: google_provider_config }]
}
post base_url, params: params, headers: headers, as: :json
result = response.parsed_body['results'].first
expect(result['status']).to eq('error')
expect(result['message']).to include("Unsupported provider 'Yahoo'")
end
it 'returns error for duplicate email' do
create(:channel_email, email: 'existing@example.com', account: account)
params = {
migrations: [{ email: 'existing@example.com', provider: 'google', provider_config: google_provider_config }]
}
post base_url, params: params, headers: headers, as: :json
result = response.parsed_body['results'].first
expect(result['status']).to eq('error')
expect(result['message']).to include('Email has already been taken')
end
end
end
end