diff --git a/app/controllers/platform/api/v1/email_channel_migrations_controller.rb b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb new file mode 100644 index 000000000..3e9e8defd --- /dev/null +++ b/app/controllers/platform/api/v1/email_channel_migrations_controller.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 5fe70bdca..9d442600e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -507,6 +507,7 @@ Rails.application.routes.draw do delete :destroy end end + resources :email_channel_migrations, only: [:create] end end end diff --git a/spec/controllers/platform/api/v1/email_channel_migrations_controller_spec.rb b/spec/controllers/platform/api/v1/email_channel_migrations_controller_spec.rb new file mode 100644 index 000000000..949c5da4e --- /dev/null +++ b/spec/controllers/platform/api/v1/email_channel_migrations_controller_spec.rb @@ -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