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:
@@ -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
|
||||||
@@ -507,6 +507,7 @@ Rails.application.routes.draw do
|
|||||||
delete :destroy
|
delete :destroy
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resources :email_channel_migrations, only: [:create]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user