feat: automate account deletion (#11406)

- Automate the deletion of accounts that have requested deletion via
account settings.
- Add a Sidekiq job that runs daily to find accounts that have requested
deletion and have passed the 7-day window.
- This job deletes the account and then soft-deletes users if they do
not belong to any other account.
- This job also sends an email to the Chatwoot instance admin for
compliance purposes.
- The Chatwoot instance admin's email is configurable via the
`CHATWOOT_INSTANCE_ADMIN_EMAIL` global config.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Vishnu Narayanan
2025-05-23 12:58:13 +05:30
committed by GitHub
parent 72c5671e09
commit d40a59f7fa
10 changed files with 317 additions and 1 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,30 @@
<p>Hello,</p>
<p>This is a notification to inform you that an account has been permanently deleted from your Chatwoot instance.</p>
<p>
<strong>Chatwoot Installation:</strong> {{ meta.instance_url }}<br>
<strong>Account ID:</strong> {{ meta.account_id }}<br>
<strong>Account Name:</strong> {{ meta.account_name }}<br>
<strong>Deleted At:</strong> {{ meta.deleted_at }}<br>
<strong>Marked for Deletion at:</strong> {{ meta.marked_for_deletion_at }}<br>
<strong>Deletion Reason:</strong> {{ meta.deletion_reason }}
</p>
{% if meta.deleted_user_count > 0 %}
<p>
<strong>Deleted Users ({{ meta.deleted_user_count }}):</strong><br>
{% for user in meta.soft_deleted_users %}
User ID: {{ user.user_id }}, Email: {{ user.user_email }}{% unless forloop.last %}<br>{% endunless %}
{% endfor %}
</p>
{% else %}
<p>
<strong>Deleted Users:</strong> None
</p>
{% endif %}
<p>This email serves as a record for compliance purposes.</p>
<p>Thank you,<br>
Chatwoot System</p>

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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