diff --git a/app/jobs/internal/delete_accounts_job.rb b/app/jobs/internal/delete_accounts_job.rb new file mode 100644 index 000000000..311915ed7 --- /dev/null +++ b/app/jobs/internal/delete_accounts_job.rb @@ -0,0 +1,27 @@ +class Internal::DeleteAccountsJob < ApplicationJob + queue_as :scheduled_jobs + + def perform + delete_expired_accounts + end + + private + + def delete_expired_accounts + accounts_pending_deletion.each do |account| + AccountDeletionService.new(account: account).perform + end + end + + def accounts_pending_deletion + Account.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") + .select { |account| deletion_period_expired?(account) } + end + + def deletion_period_expired?(account) + deletion_time = account.custom_attributes['marked_for_deletion_at'] + return false if deletion_time.blank? + + DateTime.parse(deletion_time) <= Time.current + end +end diff --git a/app/mailers/administrator_notifications/account_compliance_mailer.rb b/app/mailers/administrator_notifications/account_compliance_mailer.rb new file mode 100644 index 000000000..c78da7ec4 --- /dev/null +++ b/app/mailers/administrator_notifications/account_compliance_mailer.rb @@ -0,0 +1,53 @@ +class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer + def account_deleted(account) + return if instance_admin_email.blank? + + subject = subject_for(account) + meta = build_meta(account) + + send_notification(subject, to: instance_admin_email, meta: meta) + end + + private + + def build_meta(account) + deleted_users = params[:soft_deleted_users] || [] + + user_info_list = [] + deleted_users.each do |user| + user_info_list << { + 'user_id' => user[:id].to_s, + 'user_email' => user[:original_email].to_s + } + end + + { + 'instance_url' => instance_url, + 'account_id' => account.id, + 'account_name' => account.name, + 'deleted_at' => format_time(Time.current.iso8601), + 'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified', + 'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']), + 'soft_deleted_users' => user_info_list, + 'deleted_user_count' => user_info_list.size + } + end + + def format_time(time_string) + return 'not specified' if time_string.blank? + + Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z') + end + + def subject_for(account) + "Account Deletion Notice for #{account.id} - #{account.name}" + end + + def instance_admin_email + GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL'] + end + + def instance_url + ENV.fetch('FRONTEND_URL', 'not available') + end +end diff --git a/app/services/account_deletion_service.rb b/app/services/account_deletion_service.rb new file mode 100644 index 000000000..62bce601b --- /dev/null +++ b/app/services/account_deletion_service.rb @@ -0,0 +1,50 @@ +class AccountDeletionService + attr_reader :account, :soft_deleted_users + + def initialize(account:) + @account = account + @soft_deleted_users = [] + end + + def perform + Rails.logger.info("Deleting account #{account.id} - #{account.name} that was marked for deletion") + + soft_delete_orphaned_users + send_compliance_notification + DeleteObjectJob.perform_later(account) + end + + private + + def send_compliance_notification + AdministratorNotifications::AccountComplianceMailer.with( + account: account, + soft_deleted_users: soft_deleted_users + ).account_deleted(account).deliver_later + end + + def soft_delete_orphaned_users + account.users.each do |user| + # Find all account_users for this user excluding the current account + other_accounts = user.account_users.where.not(account_id: account.id).count + + # If user has no other accounts, soft delete them + next unless other_accounts.zero? + + # Soft delete user by appending -deleted.com to email + original_email = user.email + user.email = "#{original_email}-deleted.com" + user.skip_reconfirmation! + user.save! + + user_info = { + id: user.id.to_s, + original_email: original_email + } + + soft_deleted_users << user_info + + Rails.logger.info("Soft deleted user #{user.id} with email #{original_email}") + end + end +end diff --git a/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid b/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid new file mode 100644 index 000000000..636a5daa2 --- /dev/null +++ b/app/views/mailers/administrator_notifications/account_compliance_mailer/account_deleted.liquid @@ -0,0 +1,30 @@ +

Hello,

+ +

This is a notification to inform you that an account has been permanently deleted from your Chatwoot instance.

+ +

+ Chatwoot Installation: {{ meta.instance_url }}
+ Account ID: {{ meta.account_id }}
+ Account Name: {{ meta.account_name }}
+ Deleted At: {{ meta.deleted_at }}
+ Marked for Deletion at: {{ meta.marked_for_deletion_at }}
+ Deletion Reason: {{ meta.deletion_reason }} +

+ +{% if meta.deleted_user_count > 0 %} +

+ Deleted Users ({{ meta.deleted_user_count }}):
+ {% for user in meta.soft_deleted_users %} + User ID: {{ user.user_id }}, Email: {{ user.user_email }}{% unless forloop.last %}
{% endunless %} + {% endfor %} +

+{% else %} +

+ Deleted Users: None +

+{% endif %} + +

This email serves as a record for compliance purposes.

+ +

Thank you,
+Chatwoot System

\ No newline at end of file diff --git a/config/installation_config.yml b/config/installation_config.yml index 1a891c420..2365c1865 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -235,6 +235,14 @@ description: Used to notify Chatwoot about account abuses, potential threads (Should be a Discord Webhook URL) # ------- End of Chatwoot Internal Config for Self Hosted ----# +# ------- Compliance Related Config ----# +- name: CHATWOOT_INSTANCE_ADMIN_EMAIL + display_title: 'Instance Admin Email' + value: + description: 'The email of the instance administrator to receive compliance-related notifications' + locked: false +# ------- End of Compliance Related Config ----# + ## ------ Configs added for enterprise clients ------ ## - name: API_CHANNEL_NAME value: diff --git a/config/schedule.yml b/config/schedule.yml index 2747910ad..c45d395bf 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -39,3 +39,10 @@ process_stale_contacts_job: cron: '30 04 * * *' class: 'Internal::ProcessStaleContactsJob' queue: housekeeping + +# executed daily at 0100 UTC +# to delete accounts marked for deletion +delete_accounts_job: + cron: '0 1 * * *' + class: 'Internal::DeleteAccountsJob' + queue: scheduled_jobs diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb index f0b798648..2454295dc 100644 --- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb @@ -33,6 +33,6 @@ module Enterprise::SuperAdmin::AppConfigsController def internal_config_options %w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS - CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL] + CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL] end end diff --git a/spec/jobs/internal/delete_accounts_job_spec.rb b/spec/jobs/internal/delete_accounts_job_spec.rb new file mode 100644 index 000000000..514ecf6ab --- /dev/null +++ b/spec/jobs/internal/delete_accounts_job_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +RSpec.describe Internal::DeleteAccountsJob do + subject(:job) { described_class.perform_later } + + let!(:account_marked_for_deletion) { create(:account) } + let!(:future_deletion_account) { create(:account) } + let!(:active_account) { create(:account) } + let(:account_deletion_service) { instance_double(AccountDeletionService, perform: true) } + + before do + account_marked_for_deletion.update!( + custom_attributes: { + 'marked_for_deletion_at' => 1.day.ago.iso8601, + 'marked_for_deletion_reason' => 'user_requested' + } + ) + + future_deletion_account.update!( + custom_attributes: { + 'marked_for_deletion_at' => 3.days.from_now.iso8601, + 'marked_for_deletion_reason' => 'user_requested' + } + ) + + allow(AccountDeletionService).to receive(:new).and_return(account_deletion_service) + end + + it 'enqueues the job' do + expect { job }.to have_enqueued_job(described_class) + .on_queue('scheduled_jobs') + end + + describe '#perform' do + it 'calls AccountDeletionService for accounts past deletion date' do + described_class.new.perform + + expect(AccountDeletionService).to have_received(:new).with(account: account_marked_for_deletion) + expect(AccountDeletionService).not_to have_received(:new).with(account: future_deletion_account) + expect(AccountDeletionService).not_to have_received(:new).with(account: active_account) + expect(account_deletion_service).to have_received(:perform) + end + end +end diff --git a/spec/mailers/administrator_notifications/account_compliance_mailer_spec.rb b/spec/mailers/administrator_notifications/account_compliance_mailer_spec.rb new file mode 100644 index 000000000..c7a93337b --- /dev/null +++ b/spec/mailers/administrator_notifications/account_compliance_mailer_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' + +RSpec.describe AdministratorNotifications::AccountComplianceMailer do + let(:account) do + create(:account, custom_attributes: { 'marked_for_deletion_at' => 1.day.ago.iso8601, 'marked_for_deletion_reason' => 'user_requested' }) + end + let(:soft_deleted_users) do + [ + { id: 1, original_email: 'user1@example.com' }, + { id: 2, original_email: 'user2@example.com' } + ] + end + + describe 'account_deleted' do + it 'has the right subject format' do + subject = described_class.new.send(:subject_for, account) + expect(subject).to eq("Account Deletion Notice for #{account.id} - #{account.name}") + end + + it 'includes soft deleted users in meta when provided' do + mailer_instance = described_class.new + allow(mailer_instance).to receive(:params).and_return( + { soft_deleted_users: soft_deleted_users } + ) + + meta = mailer_instance.send(:build_meta, account) + + expect(meta['deleted_user_count']).to eq(2) + expect(meta['soft_deleted_users'].size).to eq(2) + expect(meta['soft_deleted_users'].first['user_id']).to eq('1') + expect(meta['soft_deleted_users'].first['user_email']).to eq('user1@example.com') + end + end +end diff --git a/spec/services/account_deletion_service_spec.rb b/spec/services/account_deletion_service_spec.rb new file mode 100644 index 000000000..6b263c5b3 --- /dev/null +++ b/spec/services/account_deletion_service_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe AccountDeletionService do + let(:account) { create(:account) } + let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) } + + describe '#perform' do + before do + allow(DeleteObjectJob).to receive(:perform_later) + allow(AdministratorNotifications::AccountComplianceMailer).to receive(:with).and_return( + instance_double(AdministratorNotifications::AccountComplianceMailer, account_deleted: mailer) + ) + end + + it 'enqueues DeleteObjectJob with the account' do + described_class.new(account: account).perform + + expect(DeleteObjectJob).to have_received(:perform_later).with(account) + end + + it 'sends a compliance notification email' do + described_class.new(account: account).perform + + expect(AdministratorNotifications::AccountComplianceMailer).to have_received(:with) do |args| + expect(args[:account]).to eq(account) + expect(args).to include(:soft_deleted_users) + end + expect(mailer).to have_received(:deliver_later) + end + + context 'when handling users' do + let(:user_with_one_account) { create(:user) } + let(:user_with_multiple_accounts) { create(:user) } + let(:second_account) { create(:account) } + + before do + create(:account_user, user: user_with_one_account, account: account) + create(:account_user, user: user_with_multiple_accounts, account: account) + create(:account_user, user: user_with_multiple_accounts, account: second_account) + end + + it 'soft deletes users who only belong to the deleted account' do + original_email = user_with_one_account.email + + described_class.new(account: account).perform + + # Reload the user to get the updated email + user_with_one_account.reload + expect(user_with_one_account.email).to eq("#{original_email}-deleted.com") + end + + it 'does not modify emails for users belonging to multiple accounts' do + original_email = user_with_multiple_accounts.email + + described_class.new(account: account).perform + + # Reload the user to get the updated email + user_with_multiple_accounts.reload + expect(user_with_multiple_accounts.email).to eq(original_email) + end + end + end +end