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