about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/admin/accounts_controller.rb31
-rw-r--r--app/controllers/api/base_controller.rb4
-rw-r--r--app/controllers/api/v1/admin/accounts_controller.rb9
-rw-r--r--app/controllers/settings/deletes_controller.rb2
-rw-r--r--app/lib/activitypub/activity/delete.rb2
-rw-r--r--app/mailers/notification_mailer.rb16
-rw-r--r--app/mailers/user_mailer.rb28
-rw-r--r--app/models/account.rb9
-rw-r--r--app/models/account_deletion_request.rb20
-rw-r--r--app/models/admin/account_action.rb2
-rw-r--r--app/models/concerns/account_associations.rb3
-rw-r--r--app/models/form/account_batch.rb2
-rw-r--r--app/models/invite.rb2
-rw-r--r--app/models/user.rb4
-rw-r--r--app/policies/account_policy.rb4
-rw-r--r--app/services/after_unallow_domain_service.rb2
-rw-r--r--app/services/block_domain_service.rb2
-rw-r--r--app/services/delete_account_service.rb180
-rw-r--r--app/services/suspend_account_service.rb183
-rw-r--r--app/services/unsuspend_account_service.rb52
-rw-r--r--app/views/admin/accounts/show.html.haml114
-rw-r--r--app/workers/account_deletion_worker.rb13
-rw-r--r--app/workers/admin/account_deletion_worker.rb13
-rw-r--r--app/workers/admin/suspension_worker.rb6
-rw-r--r--app/workers/admin/unsuspension_worker.rb13
-rw-r--r--app/workers/scheduler/user_cleanup_scheduler.rb13
-rw-r--r--config/locales/en.yml31
-rw-r--r--config/locales/simple_form.en.yml8
-rw-r--r--config/routes.rb4
-rw-r--r--db/migrate/20200908193330_create_account_deletion_requests.rb8
-rw-r--r--db/schema.rb10
-rw-r--r--lib/mastodon/accounts_cli.rb4
-rw-r--r--lib/mastodon/domains_cli.rb2
-rw-r--r--spec/controllers/auth/registrations_controller_spec.rb3
-rw-r--r--spec/controllers/concerns/export_controller_concern_spec.rb1
-rw-r--r--spec/fabricators/account_deletion_request_fabricator.rb3
-rw-r--r--spec/models/account_deletion_request_spec.rb4
-rw-r--r--spec/models/invite_spec.rb2
-rw-r--r--spec/services/delete_account_service_spec.rb (renamed from spec/services/suspend_account_service_spec.rb)2
39 files changed, 529 insertions, 282 deletions
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 7b1783542..b9b75727d 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -2,7 +2,7 @@
 
 module Admin
   class AccountsController < BaseController
-    before_action :set_account, only: [:show, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject]
+    before_action :set_account, except: [:index]
     before_action :require_remote_account!, only: [:redownload]
     before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
 
@@ -14,49 +14,58 @@ module Admin
     def show
       authorize @account, :show?
 
+      @deletion_request        = @account.deletion_request
       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
       @moderation_notes        = @account.targeted_moderation_notes.latest
       @warnings                = @account.targeted_account_warnings.latest.custom
+      @domain_block            = DomainBlock.rule_for(@account.domain)
     end
 
     def memorialize
       authorize @account, :memorialize?
       @account.memorialize!
       log_action :memorialize, @account
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.memorialized_msg', username: @account.acct)
     end
 
     def enable
       authorize @account.user, :enable?
       @account.user.enable!
       log_action :enable, @account.user
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.enabled_msg', username: @account.acct)
     end
 
     def approve
       authorize @account.user, :approve?
       @account.user.approve!
-      redirect_to admin_pending_accounts_path
+      redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
     end
 
     def reject
       authorize @account.user, :reject?
-      SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false)
-      redirect_to admin_pending_accounts_path
+      DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
+      redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
+    end
+
+    def destroy
+      authorize @account, :destroy?
+      Admin::AccountDeletionWorker.perform_async(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct)
     end
 
     def unsilence
       authorize @account, :unsilence?
       @account.unsilence!
       log_action :unsilence, @account
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsilenced_msg', username: @account.acct)
     end
 
     def unsuspend
       authorize @account, :unsuspend?
       @account.unsuspend!
+      Admin::UnsuspensionWorker.perform_async(@account.id)
       log_action :unsuspend, @account
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsuspended_msg', username: @account.acct)
     end
 
     def redownload
@@ -65,7 +74,7 @@ module Admin
       @account.update!(last_webfingered_at: nil)
       ResolveAccountService.new.call(@account)
 
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.redownloaded_msg', username: @account.acct)
     end
 
     def remove_avatar
@@ -76,7 +85,7 @@ module Admin
 
       log_action :remove_avatar, @account.user
 
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_avatar_msg', username: @account.acct)
     end
 
     def remove_header
@@ -87,7 +96,7 @@ module Admin
 
       log_action :remove_header, @account.user
 
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct)
     end
 
     private
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 467225547..e962c4e97 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -96,12 +96,12 @@ class Api::BaseController < ApplicationController
   def require_user!
     if !current_user
       render json: { error: 'This method requires an authenticated user' }, status: 422
-    elsif current_user.disabled?
-      render json: { error: 'Your login is currently disabled' }, status: 403
     elsif !current_user.confirmed?
       render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
     elsif !current_user.approved?
       render json: { error: 'Your login is currently pending approval' }, status: 403
+    elsif !current_user.functional?
+      render json: { error: 'Your login is currently disabled' }, status: 403
     else
       set_user_activity
     end
diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb
index 24c7fbef1..3af572f25 100644
--- a/app/controllers/api/v1/admin/accounts_controller.rb
+++ b/app/controllers/api/v1/admin/accounts_controller.rb
@@ -58,7 +58,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController
 
   def reject
     authorize @account.user, :reject?
-    SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false)
+    DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
+    render json: @account, serializer: REST::Admin::AccountSerializer
+  end
+
+  def destroy
+    authorize @account, :destroy?
+    Admin::AccountDeletionWorker.perform_async(@account.id)
     render json: @account, serializer: REST::Admin::AccountSerializer
   end
 
@@ -72,6 +78,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
   def unsuspend
     authorize @account, :unsuspend?
     @account.unsuspend!
+    Admin::UnsuspensionWorker.perform_async(@account.id)
     log_action :unsuspend, @account
     render json: @account, serializer: REST::Admin::AccountSerializer
   end
diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb
index 7d4844e60..f96c83b80 100644
--- a/app/controllers/settings/deletes_controller.rb
+++ b/app/controllers/settings/deletes_controller.rb
@@ -43,7 +43,7 @@ class Settings::DeletesController < Settings::BaseController
 
   def destroy_account!
     current_account.suspend!
-    Admin::SuspensionWorker.perform_async(current_user.account_id, true)
+    AccountDeletionWorker.perform_async(current_user.account_id)
     sign_out
   end
 end
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index dc9ff580c..09b9e5e0e 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -13,7 +13,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
 
   def delete_person
     lock_or_return("delete_in_progress:#{@account.id}") do
-      SuspendAccountService.new.call(@account, reserve_username: false)
+      DeleteAccountService.new.call(@account, reserve_username: false)
     end
   end
 
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index 9d8a7886c..54db892cc 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -10,7 +10,7 @@ class NotificationMailer < ApplicationMailer
     @me     = recipient
     @status = notification.target_status
 
-    return if @me.user.disabled? || @status.nil?
+    return unless @me.user.functional? && @status.present?
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
@@ -22,7 +22,7 @@ class NotificationMailer < ApplicationMailer
     @me      = recipient
     @account = notification.from_account
 
-    return if @me.user.disabled?
+    return unless @me.user.functional?
 
     locale_for_account(@me) do
       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
@@ -34,7 +34,7 @@ class NotificationMailer < ApplicationMailer
     @account = notification.from_account
     @status  = notification.target_status
 
-    return if @me.user.disabled? || @status.nil?
+    return unless @me.user.functional? && @status.present?
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
@@ -47,7 +47,7 @@ class NotificationMailer < ApplicationMailer
     @account = notification.from_account
     @status  = notification.target_status
 
-    return if @me.user.disabled? || @status.nil?
+    return unless @me.user.functional? && @status.present?
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
@@ -59,7 +59,7 @@ class NotificationMailer < ApplicationMailer
     @me      = recipient
     @account = notification.from_account
 
-    return if @me.user.disabled?
+    return unless @me.user.functional?
 
     locale_for_account(@me) do
       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
@@ -67,7 +67,7 @@ class NotificationMailer < ApplicationMailer
   end
 
   def digest(recipient, **opts)
-    return if recipient.user.disabled?
+    return unless recipient.user.functional?
 
     @me                  = recipient
     @since               = opts[:since] || [@me.user.last_emailed_at, (@me.user.current_sign_in_at + 1.day)].compact.max
@@ -88,8 +88,10 @@ class NotificationMailer < ApplicationMailer
 
   def thread_by_conversation(conversation)
     return if conversation.nil?
+
     msg_id = "<conversation-#{conversation.id}.#{conversation.created_at.strftime('%Y-%m-%d')}@#{Rails.configuration.x.local_domain}>"
+
     headers['In-Reply-To'] = msg_id
-    headers['References'] = msg_id
+    headers['References']  = msg_id
   end
 end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index b55768551..95996ba3f 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -15,7 +15,7 @@ class UserMailer < Devise::Mailer
     @token    = token
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.unconfirmed_email.presence || @resource.email,
@@ -29,7 +29,7 @@ class UserMailer < Devise::Mailer
     @token    = token
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject')
@@ -40,7 +40,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject')
@@ -51,7 +51,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.email_changed.subject')
@@ -62,7 +62,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject')
@@ -73,7 +73,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject')
@@ -84,7 +84,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject')
@@ -95,7 +95,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject')
@@ -106,7 +106,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject')
@@ -118,7 +118,7 @@ class UserMailer < Devise::Mailer
     @instance = Rails.configuration.x.local_domain
     @webauthn_credential = webauthn_credential
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject')
@@ -130,7 +130,7 @@ class UserMailer < Devise::Mailer
     @instance = Rails.configuration.x.local_domain
     @webauthn_credential = webauthn_credential
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject')
@@ -141,7 +141,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject')
@@ -153,7 +153,7 @@ class UserMailer < Devise::Mailer
     @instance = Rails.configuration.x.local_domain
     @backup   = backup
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject')
@@ -181,7 +181,7 @@ class UserMailer < Devise::Mailer
     @detection  = Browser.new(user_agent)
     @timestamp  = timestamp.to_time.utc
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email,
diff --git a/app/models/account.rb b/app/models/account.rb
index 6b7ebda9e..5acc8d621 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -222,23 +222,20 @@ class Account < ApplicationRecord
 
   def suspend!(date = Time.now.utc)
     transaction do
-      user&.disable! if local?
+      create_deletion_request!
       update!(suspended_at: date)
     end
   end
 
   def unsuspend!
     transaction do
-      user&.enable! if local?
+      deletion_request&.destroy!
       update!(suspended_at: nil)
     end
   end
 
   def memorialize!
-    transaction do
-      user&.disable! if local?
-      update!(memorial: true)
-    end
+    update!(memorial: true)
   end
 
   def sign?
diff --git a/app/models/account_deletion_request.rb b/app/models/account_deletion_request.rb
new file mode 100644
index 000000000..7d0c346cc
--- /dev/null
+++ b/app/models/account_deletion_request.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: account_deletion_requests
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+class AccountDeletionRequest < ApplicationRecord
+  DELAY_TO_DELETION = 30.days.freeze
+
+  belongs_to :account
+
+  def due_at
+    created_at + DELAY_TO_DELETION
+  end
+end
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index 9edd152f5..c4ac09520 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -134,7 +134,7 @@ class Admin::AccountAction
   end
 
   def process_email!
-    UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable?
+    UserMailer.warning(target_account.user, warning, status_ids).deliver_later! if warnable?
   end
 
   def warnable?
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index cca3a17fa..98849f8fc 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -60,5 +60,8 @@ module AccountAssociations
     # Hashtags
     has_and_belongs_to_many :tags
     has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
+
+    # Account deletion requests
+    has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
   end
 end
diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb
index 0b285fde9..7b9e40f68 100644
--- a/app/models/form/account_batch.rb
+++ b/app/models/form/account_batch.rb
@@ -69,6 +69,6 @@ class Form::AccountBatch
     records = accounts.includes(:user)
 
     records.each { |account| authorize(account.user, :reject?) }
-           .each { |account| SuspendAccountService.new.call(account, reserve_email: false, reserve_username: false) }
+           .each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) }
   end
 end
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 29d25eae8..7ea4e2f98 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -28,7 +28,7 @@ class Invite < ApplicationRecord
   before_validation :set_code
 
   def valid_for_use?
-    (max_uses.nil? || uses < max_uses) && !expired? && !(user.nil? || user.disabled?)
+    (max_uses.nil? || uses < max_uses) && !expired? && user&.functional?
   end
 
   private
diff --git a/app/models/user.rb b/app/models/user.rb
index dbee08988..6b21d6ed6 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -168,7 +168,7 @@ class User < ApplicationRecord
   end
 
   def active_for_authentication?
-    true
+    !account.memorial?
   end
 
   def suspicious_sign_in?(ip)
@@ -176,7 +176,7 @@ class User < ApplicationRecord
   end
 
   def functional?
-    confirmed? && approved? && !disabled? && !account.suspended? && account.moved_to_account_id.nil?
+    confirmed? && approved? && !disabled? && !account.suspended? && !account.memorial? && account.moved_to_account_id.nil?
   end
 
   def unconfirmed_or_pending?
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 9c145979d..1b105e92a 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -17,6 +17,10 @@ class AccountPolicy < ApplicationPolicy
     staff? && !record.user&.staff?
   end
 
+  def destroy?
+    record.suspended? && record.deletion_request.present? && admin?
+  end
+
   def unsuspend?
     staff?
   end
diff --git a/app/services/after_unallow_domain_service.rb b/app/services/after_unallow_domain_service.rb
index ccd0b8ae9..d3008a105 100644
--- a/app/services/after_unallow_domain_service.rb
+++ b/app/services/after_unallow_domain_service.rb
@@ -3,7 +3,7 @@
 class AfterUnallowDomainService < BaseService
   def call(domain)
     Account.where(domain: domain).find_each do |account|
-      SuspendAccountService.new.call(account, reserve_username: false)
+      DeleteAccountService.new.call(account, reserve_username: false)
     end
   end
 end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index dc23ef8d8..1cf3382b3 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -36,7 +36,7 @@ class BlockDomainService < BaseService
   def suspend_accounts!
     blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at)
     blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account|
-      SuspendAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
+      DeleteAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
     end
   end
 
diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb
new file mode 100644
index 000000000..15bdd13e3
--- /dev/null
+++ b/app/services/delete_account_service.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+class DeleteAccountService < BaseService
+  include Payloadable
+
+  ASSOCIATIONS_ON_SUSPEND = %w(
+    account_pins
+    active_relationships
+    block_relationships
+    blocked_by_relationships
+    conversation_mutes
+    conversations
+    custom_filters
+    domain_blocks
+    favourites
+    follow_requests
+    list_accounts
+    mute_relationships
+    muted_by_relationships
+    notifications
+    owned_lists
+    passive_relationships
+    report_notes
+    scheduled_statuses
+    status_pins
+  ).freeze
+
+  ASSOCIATIONS_ON_DESTROY = %w(
+    reports
+    targeted_moderation_notes
+    targeted_reports
+  ).freeze
+
+  # Suspend or remove an account and remove as much of its data
+  # as possible. If it's a local account and it has not been confirmed
+  # or never been approved, then side effects are skipped and both
+  # the user and account records are removed fully. Otherwise,
+  # it is controlled by options.
+  # @param [Account]
+  # @param [Hash] options
+  # @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts
+  # @option [Boolean] :reserve_username Keep account record
+  # @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
+  # @option [Time]    :suspended_at Only applicable when :reserve_username is true
+  def call(account, **options)
+    @account = account
+    @options = { reserve_username: true, reserve_email: true }.merge(options)
+
+    if @account.local? && @account.user_unconfirmed_or_pending?
+      @options[:reserve_email]     = false
+      @options[:reserve_username]  = false
+      @options[:skip_side_effects] = true
+    end
+
+    reject_follows!
+    purge_user!
+    purge_profile!
+    purge_content!
+    fulfill_deletion_request!
+  end
+
+  private
+
+  def reject_follows!
+    return if @account.local? || !@account.activitypub?
+
+    ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
+      [build_reject_json(follow), follow.target_account_id, follow.account.inbox_url]
+    end
+  end
+
+  def purge_user!
+    return if !@account.local? || @account.user.nil?
+
+    if @options[:reserve_email]
+      @account.user.disable!
+      @account.user.invites.where(uses: 0).destroy_all
+    else
+      @account.user.destroy
+    end
+  end
+
+  def purge_content!
+    distribute_delete_actor! if @account.local? && !@options[:skip_side_effects]
+
+    @account.statuses.reorder(nil).find_in_batches do |statuses|
+      statuses.reject! { |status| reported_status_ids.include?(status.id) } if @options[:reserve_username]
+      BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:skip_side_effects])
+    end
+
+    @account.media_attachments.reorder(nil).find_each do |media_attachment|
+      next if @options[:reserve_username] && reported_status_ids.include?(media_attachment.status_id)
+
+      media_attachment.destroy
+    end
+
+    @account.polls.reorder(nil).find_each do |poll|
+      next if @options[:reserve_username] && reported_status_ids.include?(poll.status_id)
+
+      poll.destroy
+    end
+
+    associations_for_destruction.each do |association_name|
+      destroy_all(@account.public_send(association_name))
+    end
+
+    @account.destroy unless @options[:reserve_username]
+  end
+
+  def purge_profile!
+    # If the account is going to be destroyed
+    # there is no point wasting time updating
+    # its values first
+
+    return unless @options[:reserve_username]
+
+    @account.silenced_at      = nil
+    @account.suspended_at     = @options[:suspended_at] || Time.now.utc
+    @account.locked           = false
+    @account.memorial         = false
+    @account.discoverable     = false
+    @account.display_name     = ''
+    @account.note             = ''
+    @account.fields           = []
+    @account.statuses_count   = 0
+    @account.followers_count  = 0
+    @account.following_count  = 0
+    @account.moved_to_account = nil
+    @account.trust_level      = :untrusted
+    @account.avatar.destroy
+    @account.header.destroy
+    @account.save!
+  end
+
+  def fulfill_deletion_request!
+    @account.deletion_request&.destroy
+  end
+
+  def destroy_all(association)
+    association.in_batches.destroy_all
+  end
+
+  def distribute_delete_actor!
+    ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
+      [delete_actor_json, @account.id, inbox_url]
+    end
+
+    ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
+      [delete_actor_json, @account.id, inbox_url]
+    end
+  end
+
+  def delete_actor_json
+    @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
+  end
+
+  def build_reject_json(follow)
+    Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
+  end
+
+  def delivery_inboxes
+    @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
+  end
+
+  def low_priority_delivery_inboxes
+    Account.inboxes - delivery_inboxes
+  end
+
+  def reported_status_ids
+    @reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq
+  end
+
+  def associations_for_destruction
+    if @options[:reserve_username]
+      ASSOCIATIONS_ON_SUSPEND
+    else
+      ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY
+    end
+  end
+end
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index ecc893931..5a079c3ac 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -1,175 +1,52 @@
 # frozen_string_literal: true
 
 class SuspendAccountService < BaseService
-  include Payloadable
-
-  ASSOCIATIONS_ON_SUSPEND = %w(
-    account_pins
-    active_relationships
-    block_relationships
-    blocked_by_relationships
-    conversation_mutes
-    conversations
-    custom_filters
-    domain_blocks
-    favourites
-    follow_requests
-    list_accounts
-    mute_relationships
-    muted_by_relationships
-    notifications
-    owned_lists
-    passive_relationships
-    report_notes
-    scheduled_statuses
-    status_pins
-  ).freeze
-
-  ASSOCIATIONS_ON_DESTROY = %w(
-    reports
-    targeted_moderation_notes
-    targeted_reports
-  ).freeze
-
-  # Suspend or remove an account and remove as much of its data
-  # as possible. If it's a local account and it has not been confirmed
-  # or never been approved, then side effects are skipped and both
-  # the user and account records are removed fully. Otherwise,
-  # it is controlled by options.
-  # @param [Account]
-  # @param [Hash] options
-  # @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts
-  # @option [Boolean] :reserve_username Keep account record
-  # @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
-  # @option [Time]    :suspended_at Only applicable when :reserve_username is true
-  def call(account, **options)
+  def call(account)
     @account = account
-    @options = { reserve_username: true, reserve_email: true }.merge(options)
-
-    if @account.local? && @account.user_unconfirmed_or_pending?
-      @options[:reserve_email]     = false
-      @options[:reserve_username]  = false
-      @options[:skip_side_effects] = true
-    end
 
-    reject_follows!
-    purge_user!
-    purge_profile!
-    purge_content!
+    suspend!
+    unmerge_from_home_timelines!
+    unmerge_from_list_timelines!
+    privatize_media_attachments!
   end
 
   private
 
-  def reject_follows!
-    return if @account.local? || !@account.activitypub?
-
-    ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
-      [build_reject_json(follow), follow.target_account_id, follow.account.inbox_url]
-    end
+  def suspend!
+    @account.suspend! unless @account.suspended?
   end
 
-  def purge_user!
-    return if !@account.local? || @account.user.nil?
-
-    if @options[:reserve_email]
-      @account.user.disable!
-      @account.user.invites.where(uses: 0).destroy_all
-    else
-      @account.user.destroy
+  def unmerge_from_home_timelines!
+    @account.followers_for_local_distribution.find_each do |follower|
+      FeedManager.instance.unmerge_from_timeline(@account, follower)
     end
   end
 
-  def purge_content!
-    distribute_delete_actor! if @account.local? && !@options[:skip_side_effects]
-
-    @account.statuses.reorder(nil).find_in_batches do |statuses|
-      statuses.reject! { |status| reported_status_ids.include?(status.id) } if @options[:reserve_username]
-      BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:skip_side_effects])
+  def unmerge_from_list_timelines!
+    @account.lists_for_local_distribution.find_each do |list|
+      FeedManager.instance.unmerge_from_list(@account, list)
     end
-
-    @account.media_attachments.reorder(nil).find_each do |media_attachment|
-      next if @options[:reserve_username] && reported_status_ids.include?(media_attachment.status_id)
-
-      media_attachment.destroy
-    end
-
-    @account.polls.reorder(nil).find_each do |poll|
-      next if @options[:reserve_username] && reported_status_ids.include?(poll.status_id)
-
-      poll.destroy
-    end
-
-    associations_for_destruction.each do |association_name|
-      destroy_all(@account.public_send(association_name))
-    end
-
-    @account.destroy unless @options[:reserve_username]
   end
 
-  def purge_profile!
-    # If the account is going to be destroyed
-    # there is no point wasting time updating
-    # its values first
-
-    return unless @options[:reserve_username]
+  def privatize_media_attachments!
+    attachment_names = MediaAttachment.attachment_definitions.keys
 
-    @account.silenced_at      = nil
-    @account.suspended_at     = @options[:suspended_at] || Time.now.utc
-    @account.locked           = false
-    @account.memorial         = false
-    @account.discoverable     = false
-    @account.display_name     = ''
-    @account.note             = ''
-    @account.fields           = []
-    @account.statuses_count   = 0
-    @account.followers_count  = 0
-    @account.following_count  = 0
-    @account.moved_to_account = nil
-    @account.trust_level      = :untrusted
-    @account.avatar.destroy
-    @account.header.destroy
-    @account.save!
-  end
-
-  def destroy_all(association)
-    association.in_batches.destroy_all
-  end
-
-  def distribute_delete_actor!
-    ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
-      [delete_actor_json, @account.id, inbox_url]
-    end
-
-    ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
-      [delete_actor_json, @account.id, inbox_url]
-    end
-  end
-
-  def delete_actor_json
-    @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
-  end
-
-  def build_reject_json(follow)
-    Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
-  end
-
-  def delivery_inboxes
-    @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
-  end
-
-  def low_priority_delivery_inboxes
-    Account.inboxes - delivery_inboxes
-  end
-
-  def reported_status_ids
-    @reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq
-  end
+    @account.media_attachments.find_each do |media_attachment|
+      attachment_names.each do |attachment_name|
+        attachment = media_attachment.public_send(attachment_name)
+        styles     = [:original] | attachment.styles.keys
 
-  def associations_for_destruction
-    if @options[:reserve_username]
-      ASSOCIATIONS_ON_SUSPEND
-    else
-      ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY
+        styles.each do |style|
+          case Paperclip::Attachment.default_options[:storage]
+          when :s3
+            attachment.s3_object(style).acl.put(:private)
+          when :fog
+            # Not supported
+          when :filesystem
+            FileUtils.chmod(0o600 & ~File.umask, attachment.path(style))
+          end
+        end
+      end
     end
   end
 end
diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb
new file mode 100644
index 000000000..3e731ddd9
--- /dev/null
+++ b/app/services/unsuspend_account_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class UnsuspendAccountService < BaseService
+  def call(account)
+    @account = account
+
+    unsuspend!
+    merge_into_home_timelines!
+    merge_into_list_timelines!
+    publish_media_attachments!
+  end
+
+  private
+
+  def unsuspend!
+    @account.unsuspend! if @account.suspended?
+  end
+
+  def merge_into_home_timelines!
+    @account.followers_for_local_distribution.find_each do |follower|
+      FeedManager.instance.merge_into_timeline(@account, follower)
+    end
+  end
+
+  def merge_into_list_timelines!
+    @account.lists_for_local_distribution.find_each do |list|
+      FeedManager.instance.merge_into_list(@account, list)
+    end
+  end
+
+  def publish_media_attachments!
+    attachment_names = MediaAttachment.attachment_definitions.keys
+
+    @account.media_attachments.find_each do |media_attachment|
+      attachment_names.each do |attachment_name|
+        attachment = media_attachment.public_send(attachment_name)
+        styles     = [:original] | attachment.styles.keys
+
+        styles.each do |style|
+          case Paperclip::Attachment.default_options[:storage]
+          when :s3
+            attachment.s3_object(style).acl.put(Paperclip::Attachment.default_options[:s3_permissions])
+          when :fog
+            # Not supported
+          when :filesystem
+            FileUtils.chmod(0o666 & ~File.umask, attachment.path(style))
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index e6461aad0..2c48692b7 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -56,19 +56,21 @@
     = link_to admin_action_logs_path(target_account_id: @account.id) do
       .dashboard__counters__text
         - if @account.local? && @account.user.nil?
-          %span.neutral= t('admin.accounts.deleted')
+          = t('admin.accounts.deleted')
+        - elsif @account.memorial?
+          = t('admin.accounts.memorialized')
         - elsif @account.suspended?
-          %span.red= t('admin.accounts.suspended')
+          = t('admin.accounts.suspended')
         - elsif @account.silenced?
-          %span.red= t('admin.accounts.silenced')
+          = t('admin.accounts.silenced')
         - elsif @account.local? && @account.user&.disabled?
-          %span.red= t('admin.accounts.disabled')
+          = t('admin.accounts.disabled')
         - elsif @account.local? && !@account.user&.confirmed?
-          %span.neutral= t('admin.accounts.confirming')
+          = t('admin.accounts.confirming')
         - elsif @account.local? && !@account.user_approved?
-          %span.neutral= t('admin.accounts.pending')
+          = t('admin.accounts.pending')
         - else
-          %span.neutral= t('admin.accounts.no_limits_imposed')
+          = t('admin.accounts.no_limits_imposed')
       .dashboard__counters__label= t 'admin.accounts.login_status'
 
 - unless @account.local? && @account.user.nil?
@@ -123,19 +125,6 @@
             %td= table_link_to 'refresh', t('admin.accounts.resend_confirmation.send'), resend_admin_account_confirmation_path(@account.id), method: :post if can?(:confirm, @account.user)
 
           %tr
-            %th= t('admin.accounts.login_status')
-            %td
-              - if @account.user&.disabled?
-                = t('admin.accounts.disabled')
-              - else
-                = t('admin.accounts.enabled')
-            %td
-              - if @account.user&.disabled?
-                = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user)
-              - elsif @account.user_approved?
-                = table_link_to 'lock', t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable') if can?(:disable, @account.user)
-
-          %tr
             %th= t('simple_form.labels.defaults.locale')
             %td= @account.user_locale
             %td
@@ -172,49 +161,62 @@
             %td
               = @account.inbox_url
               = fa_icon DeliveryFailureTracker.available?(@account.inbox_url) ? 'check' : 'times'
+            %td
+              = table_link_to 'search', @domain_block.present? ? t('admin.domain_blocks.view') : t('admin.accounts.view_domain'), admin_instance_path(@account.domain)
           %tr
             %th= t('admin.accounts.shared_inbox_url')
             %td
               = @account.shared_inbox_url
               = fa_icon DeliveryFailureTracker.available?(@account.shared_inbox_url) ? 'check': 'times'
+            %td
+              - if @domain_block.nil?
+                = table_link_to 'ban', t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain)
+
+  - if @account.suspended?
+    %hr.spacer/
+
+    %p.muted-hint= @deletion_request.present? ? t('admin.accounts.suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.suspension_irreversible')
+
+    = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
 
-  %div.action-buttons
-    %div
-      - if @account.local? && @account.user_approved?
-        = link_to t('admin.accounts.warn'), new_admin_account_action_path(@account.id, type: 'none'), class: 'button' if can?(:warn, @account)
-      - if @account.silenced?
-        = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account)
-      - elsif !@account.local? || @account.user_approved?
-        = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button button--destructive' if can?(:silence, @account)
-
-      - if @account.local?
-        - if @account.user_pending?
-          = link_to t('admin.accounts.approve'), approve_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:approve, @account.user)
-          = link_to t('admin.accounts.reject'), reject_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:reject, @account.user)
-
-        - unless @account.user_confirmed?
-          = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user)
-
-      - if @account.suspended?
-        = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
-      - elsif !@account.local? || @account.user_approved?
-        = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account)
-
-      - unless @account.local?
-        - if DomainBlock.rule_for(@account.domain)
-          = link_to t('admin.domain_blocks.view'), admin_instance_path(@account.domain), class: 'button'
+    - if @deletion_request.present?
+      = link_to t('admin.accounts.delete'), admin_account_path(@account.id), method: :destroy, class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, @account)
+  - else
+    %div.action-buttons
+      %div
+        - if @account.local? && @account.user_approved?
+          = link_to t('admin.accounts.warn'), new_admin_account_action_path(@account.id, type: 'none'), class: 'button' if can?(:warn, @account)
+
+          - if @account.user_disabled?
+            = link_to t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post, class: 'button' if can?(:enable, @account.user)
+          - else
+            = link_to t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable'), class: 'button' if can?(:disable, @account.user)
+
+        - if @account.silenced?
+          = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account)
+        - elsif !@account.local? || @account.user_approved?
+          = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button' if can?(:silence, @account)
+
+        - if @account.local?
+          - if @account.user_pending?
+            = link_to t('admin.accounts.approve'), approve_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:approve, @account.user)
+            = link_to t('admin.accounts.reject'), reject_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:reject, @account.user)
+
+          - unless @account.user_confirmed?
+            = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user)
+
+        - if !@account.local? || @account.user_approved?
+          = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button' if can?(:suspend, @account)
+
+      %div
+        - if @account.local?
+          = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user)
+          - if @account.user&.otp_required_for_login?
+            = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user)
+          - if !@account.memorial? && @account.user_approved?
+            = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account)
         - else
-          = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain), class: 'button button--destructive'
-
-    %div
-      - if @account.local?
-        = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user)
-        - if @account.user&.otp_required_for_login?
-          = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user)
-        - if !@account.memorial? && @account.user_approved?
-          = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account)
-      - else
-        = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account)
+          = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account)
 
   %hr.spacer/
 
diff --git a/app/workers/account_deletion_worker.rb b/app/workers/account_deletion_worker.rb
new file mode 100644
index 000000000..0f6be71e1
--- /dev/null
+++ b/app/workers/account_deletion_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AccountDeletionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(account_id)
+    DeleteAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: false)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/admin/account_deletion_worker.rb b/app/workers/admin/account_deletion_worker.rb
new file mode 100644
index 000000000..82f269ad6
--- /dev/null
+++ b/app/workers/admin/account_deletion_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Admin::AccountDeletionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(account_id)
+    DeleteAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: true)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/admin/suspension_worker.rb b/app/workers/admin/suspension_worker.rb
index 83c815efd..35c570336 100644
--- a/app/workers/admin/suspension_worker.rb
+++ b/app/workers/admin/suspension_worker.rb
@@ -5,7 +5,9 @@ class Admin::SuspensionWorker
 
   sidekiq_options queue: 'pull'
 
-  def perform(account_id, remove_user = false)
-    SuspendAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: !remove_user)
+  def perform(account_id)
+    SuspendAccountService.new.call(Account.find(account_id))
+  rescue ActiveRecord::RecordNotFound
+    true
   end
 end
diff --git a/app/workers/admin/unsuspension_worker.rb b/app/workers/admin/unsuspension_worker.rb
new file mode 100644
index 000000000..7cb2349b1
--- /dev/null
+++ b/app/workers/admin/unsuspension_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Admin::UnsuspensionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(account_id)
+    UnsuspendAccountService.new.call(Account.find(account_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index 6113edde1..8571b59e1 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -6,9 +6,22 @@ class Scheduler::UserCleanupScheduler
   sidekiq_options lock: :until_executed, retry: 0
 
   def perform
+    clean_unconfirmed_accounts!
+    clean_suspended_accounts!
+  end
+
+  private
+
+  def clean_unconfirmed_accounts!
     User.where('confirmed_at is NULL AND confirmation_sent_at <= ?', 2.days.ago).reorder(nil).find_in_batches do |batch|
       Account.where(id: batch.map(&:account_id)).delete_all
       User.where(id: batch.map(&:id)).delete_all
     end
   end
+
+  def clean_suspended_accounts!
+    AccountDeletionRequest.where('created_at <= ?', AccountDeletionRequest::DELAY_TO_DELETION.ago).reorder(nil).find_each do |deletion_request|
+      Admin::AccountDeletionWorker.perform_async(deletion_request.account_id)
+    end
+  end
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ab96074fd..427b2c3fc 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -98,6 +98,7 @@ en:
       add_email_domain_block: Block e-mail domain
       approve: Approve
       approve_all: Approve all
+      approved_msg: Successfully approved %{username}'s sign-up application
       are_you_sure: Are you sure?
       avatar: Avatar
       by_domain: Domain
@@ -111,18 +112,21 @@ en:
       confirm: Confirm
       confirmed: Confirmed
       confirming: Confirming
+      delete: Delete data
       deleted: Deleted
       demote: Demote
-      disable: Disable
+      destroyed_msg: "%{username}'s data is now queued to be deleted imminently"
+      disable: Freeze
       disable_two_factor_authentication: Disable 2FA
-      disabled: Disabled
+      disabled: Frozen
       display_name: Display name
       domain: Domain
       edit: Edit
       email: Email
       email_status: Email status
-      enable: Enable
+      enable: Unfreeze
       enabled: Enabled
+      enabled_msg: Successfully unfroze %{username}'s account
       followers: Followers
       follows: Follows
       header: Header
@@ -138,6 +142,8 @@ en:
       login_status: Login status
       media_attachments: Media attachments
       memorialize: Turn into memoriam
+      memorialized: Memorialized
+      memorialized_msg: Successfully turned %{username} into a memorial account
       moderation:
         active: Active
         all: All
@@ -158,10 +164,14 @@ en:
       public: Public
       push_subscription_expires: PuSH subscription expires
       redownload: Refresh profile
+      redownloaded_msg: Successfully refreshed %{username}'s profile from origin
       reject: Reject
       reject_all: Reject all
+      rejected_msg: Successfully rejected %{username}'s sign-up application
       remove_avatar: Remove avatar
       remove_header: Remove header
+      removed_avatar_msg: Successfully removed %{username}'s avatar image
+      removed_header_msg: Successfully removed %{username}'s header image
       resend_confirmation:
         already_confirmed: This user is already confirmed
         send: Resend confirmation email
@@ -182,18 +192,23 @@ en:
       show:
         created_reports: Made reports
         targeted_reports: Reported by others
-      silence: Silence
-      silenced: Silenced
+      silence: Limit
+      silenced: Limited
       statuses: Statuses
       subscribe: Subscribe
       suspended: Suspended
+      suspension_irreversible: The data of this account has been irreversibly deleted. You can unsuspend the account to make it usable but it will not recover any data it previously had.
+      suspension_reversible_hint_html: The account has been suspended, and the data will be fully removed on %{date}. Until then, the account can be restored without any ill effects. If you wish to remove all of the account's data immediately, you can do so below.
       time_in_queue: Waiting in queue %{time}
       title: Accounts
       unconfirmed_email: Unconfirmed email
       undo_silenced: Undo silence
       undo_suspension: Undo suspension
+      unsilenced_msg: Successfully unlimited %{username}'s account
       unsubscribe: Unsubscribe
+      unsuspended_msg: Successfully unsuspended %{username}'s account
       username: Username
+      view_domain: View summary for domain
       warn: Warn
       web: Web
       whitelisted: Allowed for federation
@@ -1304,9 +1319,9 @@ en:
       title: Sign in attempt
     warning:
       explanation:
-        disable: While your account is frozen, your account data remains intact, but you cannot perform any actions until it is unlocked.
-        silence: While your account is limited, only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follow you.
-        suspend: Your account has been suspended, and all of your toots and your uploaded media files have been irreversibly removed from this server, and servers where you had followers.
+        disable: You can no longer login to your account or use it in any other way, but your profile and other data remains intact.
+        silence: You can still use your account but only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follow you.
+        suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed, but we will retain some data to prevent you from evading the suspension.
       get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}.
       review_server_policies: Review server policies
       statuses: 'Specifically, for:'
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 4ab0d1871..910e77ec2 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -90,10 +90,10 @@ en:
         text: Custom warning
         type: Action
         types:
-          disable: Disable login
-          none: Do nothing
-          silence: Silence
-          suspend: Suspend and irreversibly delete account data
+          disable: Freeze
+          none: Send a warning
+          silence: Limit
+          suspend: Suspend
         warning_preset_id: Use a warning preset
       announcement:
         all_day: All-day event
diff --git a/config/routes.rb b/config/routes.rb
index c281a86e3..8d9bc317b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -232,7 +232,7 @@ Rails.application.routes.draw do
 
     resources :report_notes, only: [:create, :destroy]
 
-    resources :accounts, only: [:index, :show] do
+    resources :accounts, only: [:index, :show, :destroy] do
       member do
         post :enable
         post :unsilence
@@ -466,7 +466,7 @@ Rails.application.routes.draw do
       end
 
       namespace :admin do
-        resources :accounts, only: [:index, :show] do
+        resources :accounts, only: [:index, :show, :destroy] do
           member do
             post :enable
             post :unsilence
diff --git a/db/migrate/20200908193330_create_account_deletion_requests.rb b/db/migrate/20200908193330_create_account_deletion_requests.rb
new file mode 100644
index 000000000..e03183ae4
--- /dev/null
+++ b/db/migrate/20200908193330_create_account_deletion_requests.rb
@@ -0,0 +1,8 @@
+class CreateAccountDeletionRequests < ActiveRecord::Migration[5.2]
+  def change
+    create_table :account_deletion_requests do |t|
+      t.references :account, foreign_key: { on_delete: :cascade }
+      t.timestamps
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e37aae962..038e39130 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_06_30_190544) do
+ActiveRecord::Schema.define(version: 2020_09_08_193330) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -36,6 +36,13 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do
     t.index ["conversation_id"], name: "index_account_conversations_on_conversation_id"
   end
 
+  create_table "account_deletion_requests", force: :cascade do |t|
+    t.bigint "account_id"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["account_id"], name: "index_account_deletion_requests_on_account_id"
+  end
+
   create_table "account_domain_blocks", force: :cascade do |t|
     t.string "domain"
     t.datetime "created_at", null: false
@@ -926,6 +933,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) do
   add_foreign_key "account_aliases", "accounts", on_delete: :cascade
   add_foreign_key "account_conversations", "accounts", on_delete: :cascade
   add_foreign_key "account_conversations", "conversations", on_delete: :cascade
+  add_foreign_key "account_deletion_requests", "accounts", on_delete: :cascade
   add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade
   add_foreign_key "account_identity_proofs", "accounts", on_delete: :cascade
   add_foreign_key "account_migrations", "accounts", column: "target_account_id", on_delete: :nullify
diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb
index 8c91c3013..8f9279a3c 100644
--- a/lib/mastodon/accounts_cli.rb
+++ b/lib/mastodon/accounts_cli.rb
@@ -87,7 +87,7 @@ module Mastodon
           say('Use --force to reattach it anyway and delete the other user')
           return
         elsif account.user.present?
-          account.user.destroy!
+          DeleteAccountService.new.call(account, reserve_email: false)
         end
       end
 
@@ -192,7 +192,7 @@ module Mastodon
       end
 
       say("Deleting user with #{account.statuses_count} statuses, this might take a while...")
-      SuspendAccountService.new.call(account, reserve_email: false)
+      DeleteAccountService.new.call(account, reserve_email: false)
       say('OK', :green)
     end
 
diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb
index 558737c27..5433ddd9d 100644
--- a/lib/mastodon/domains_cli.rb
+++ b/lib/mastodon/domains_cli.rb
@@ -42,7 +42,7 @@ module Mastodon
       end
 
       processed, = parallelize_with_progress(scope) do |account|
-        SuspendAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
+        DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
       end
 
       DomainBlock.where(domain: domains).destroy_all unless options[:dry_run]
diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb
index c2e9f33a8..bef822763 100644
--- a/spec/controllers/auth/registrations_controller_spec.rb
+++ b/spec/controllers/auth/registrations_controller_spec.rb
@@ -199,9 +199,10 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
       end
 
       subject do
+        inviter = Fabricate(:user, confirmed_at: 2.days.ago)
         Setting.registrations_mode = 'approved'
         request.headers["Accept-Language"] = accept_language
-        invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
+        invite = Fabricate(:invite, user: inviter, max_uses: nil, expires_at: 1.hour.from_now)
         post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code, agreement: 'true' } }
       end
 
diff --git a/spec/controllers/concerns/export_controller_concern_spec.rb b/spec/controllers/concerns/export_controller_concern_spec.rb
index e5861c801..fce129bee 100644
--- a/spec/controllers/concerns/export_controller_concern_spec.rb
+++ b/spec/controllers/concerns/export_controller_concern_spec.rb
@@ -5,6 +5,7 @@ require 'rails_helper'
 describe ApplicationController, type: :controller do
   controller do
     include ExportControllerConcern
+
     def index
       send_export_file
     end
diff --git a/spec/fabricators/account_deletion_request_fabricator.rb b/spec/fabricators/account_deletion_request_fabricator.rb
new file mode 100644
index 000000000..08a82ba3c
--- /dev/null
+++ b/spec/fabricators/account_deletion_request_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:account_deletion_request) do
+  account
+end
diff --git a/spec/models/account_deletion_request_spec.rb b/spec/models/account_deletion_request_spec.rb
new file mode 100644
index 000000000..afaecbe22
--- /dev/null
+++ b/spec/models/account_deletion_request_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe AccountDeletionRequest, type: :model do
+end
diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb
index 30abfb86b..b0596c561 100644
--- a/spec/models/invite_spec.rb
+++ b/spec/models/invite_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Invite, type: :model do
 
     it 'returns false when invite creator has been disabled' do
       invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
-      SuspendAccountService.new.call(invite.user.account)
+      invite.user.account.suspend!
       expect(invite.valid_for_use?).to be false
     end
   end
diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/delete_account_service_spec.rb
index 32726d763..d208b25b8 100644
--- a/spec/services/suspend_account_service_spec.rb
+++ b/spec/services/delete_account_service_spec.rb
@@ -1,6 +1,6 @@
 require 'rails_helper'
 
-RSpec.describe SuspendAccountService, type: :service do
+RSpec.describe DeleteAccountService, type: :service do
   describe '#call on local account' do
     before do
       stub_request(:post, "https://alice.com/inbox").to_return(status: 201)