about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2018-12-22 20:02:09 +0100
committerGitHub <noreply@github.com>2018-12-22 20:02:09 +0100
commit3c033c4352f8b156887cd7157b4a89c23a545838 (patch)
treefa6317223a0104abea84a10e6234a0beef316001
parent00862dcaff7cb918d29947accda1c01873a7ddeb (diff)
Add moderation warnings (#9519)
* Add moderation warnings

Replace individual routes for disabling, silencing, and suspending
a user, as well as the report update route, with a unified account
action controller that allows you to select an action (none,
disable, silence, suspend) as well as whether it should generate an
e-mail notification with optional custom text. That notification,
with the optional custom text, is saved as a warning.

Additionally, there are warning presets you can configure to save
time when performing the above.

* Use Account#local_username_and_domain
-rw-r--r--app/controllers/admin/account_actions_controller.rb36
-rw-r--r--app/controllers/admin/account_moderation_notes_controller.rb1
-rw-r--r--app/controllers/admin/accounts_controller.rb23
-rw-r--r--app/controllers/admin/reports_controller.rb79
-rw-r--r--app/controllers/admin/silences_controller.rb27
-rw-r--r--app/controllers/admin/suspensions_controller.rb60
-rw-r--r--app/controllers/admin/warning_presets_controller.rb58
-rw-r--r--app/helpers/admin/action_logs_helper.rb7
-rw-r--r--app/javascript/images/icon_flag.svg4
-rw-r--r--app/javascript/images/mailer/icon_warning.pngbin0 -> 371 bytes
-rw-r--r--app/javascript/styles/mailer.scss4
-rw-r--r--app/javascript/styles/mastodon/admin.scss4
-rw-r--r--app/mailers/user_mailer.rb12
-rw-r--r--app/models/account.rb8
-rw-r--r--app/models/account_warning.rb23
-rw-r--r--app/models/account_warning_preset.rb15
-rw-r--r--app/models/admin/account_action.rb134
-rw-r--r--app/models/concerns/account_associations.rb2
-rw-r--r--app/models/form/admin_suspension_confirmation.rb7
-rw-r--r--app/policies/account_policy.rb4
-rw-r--r--app/policies/account_warning_preset_policy.rb19
-rw-r--r--app/views/admin/account_actions/new.html.haml26
-rw-r--r--app/views/admin/account_warnings/_account_warning.html.haml6
-rw-r--r--app/views/admin/accounts/show.html.haml14
-rw-r--r--app/views/admin/reports/show.html.haml17
-rw-r--r--app/views/admin/suspensions/new.html.haml25
-rw-r--r--app/views/admin/warning_presets/edit.html.haml11
-rw-r--r--app/views/admin/warning_presets/index.html.haml30
-rw-r--r--app/views/user_mailer/warning.html.haml63
-rw-r--r--app/views/user_mailer/warning.text.erb9
-rw-r--r--app/views/user_mailer/welcome.text.erb2
-rw-r--r--config/locales/ar.yml5
-rw-r--r--config/locales/ast.yml2
-rw-r--r--config/locales/ca.yml6
-rw-r--r--config/locales/co.yml6
-rw-r--r--config/locales/cs.yml6
-rw-r--r--config/locales/cy.yml6
-rw-r--r--config/locales/da.yml6
-rw-r--r--config/locales/de.yml6
-rw-r--r--config/locales/el.yml6
-rw-r--r--config/locales/en.yml32
-rw-r--r--config/locales/eo.yml5
-rw-r--r--config/locales/es.yml6
-rw-r--r--config/locales/eu.yml6
-rw-r--r--config/locales/fa.yml6
-rw-r--r--config/locales/fr.yml6
-rw-r--r--config/locales/gl.yml6
-rw-r--r--config/locales/it.yml6
-rw-r--r--config/locales/ja.yml6
-rw-r--r--config/locales/ko.yml6
-rw-r--r--config/locales/nl.yml6
-rw-r--r--config/locales/oc.yml6
-rw-r--r--config/locales/pl.yml6
-rw-r--r--config/locales/pt-BR.yml6
-rw-r--r--config/locales/ru.yml6
-rw-r--r--config/locales/simple_form.en.yml19
-rw-r--r--config/locales/sk.yml6
-rw-r--r--config/locales/sr.yml6
-rw-r--r--config/routes.rb16
-rw-r--r--db/migrate/20181213184704_create_account_warnings.rb12
-rw-r--r--db/migrate/20181213185533_create_account_warning_presets.rb9
-rw-r--r--db/schema.rb21
-rw-r--r--spec/controllers/admin/accounts_controller_spec.rb52
-rw-r--r--spec/controllers/admin/reports_controller_spec.rb84
-rw-r--r--spec/controllers/admin/silences_controller_spec.rb33
-rw-r--r--spec/controllers/admin/suspensions_controller_spec.rb39
-rw-r--r--spec/fabricators/account_warning_fabricator.rb5
-rw-r--r--spec/fabricators/account_warning_preset_fabricator.rb3
-rw-r--r--spec/mailers/previews/user_mailer_preview.rb5
-rw-r--r--spec/models/account_warning_preset_spec.rb5
-rw-r--r--spec/models/account_warning_spec.rb5
-rw-r--r--spec/models/admin/account_action_spec.rb4
72 files changed, 682 insertions, 536 deletions
diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb
new file mode 100644
index 000000000..e847495f1
--- /dev/null
+++ b/app/controllers/admin/account_actions_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Admin
+  class AccountActionsController < BaseController
+    before_action :set_account
+
+    def new
+      @account_action  = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true)
+      @warning_presets = AccountWarningPreset.all
+    end
+
+    def create
+      account_action                 = Admin::AccountAction.new(resource_params)
+      account_action.target_account  = @account
+      account_action.current_account = current_account
+
+      account_action.save!
+
+      if account_action.with_report?
+        redirect_to admin_report_path(account_action.report)
+      else
+        redirect_to admin_account_path(@account.id)
+      end
+    end
+
+    private
+
+    def set_account
+      @account = Account.find(params[:account_id])
+    end
+
+    def resource_params
+      params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification)
+    end
+  end
+end
diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb
index 7d5b9bf52..44f6e34f8 100644
--- a/app/controllers/admin/account_moderation_notes_controller.rb
+++ b/app/controllers/admin/account_moderation_notes_controller.rb
@@ -14,6 +14,7 @@ module Admin
       else
         @account          = @account_moderation_note.target_account
         @moderation_notes = @account.targeted_moderation_notes.latest
+        @warnings         = @account.targeted_account_warnings.latest.custom
 
         render template: 'admin/accounts/show'
       end
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 771302db8..10abd1e6a 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -2,9 +2,9 @@
 
 module Admin
   class AccountsController < BaseController
-    before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :disable, :memorialize]
+    before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :memorialize]
     before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload]
-    before_action :require_local_account!, only: [:enable, :disable, :memorialize]
+    before_action :require_local_account!, only: [:enable, :memorialize]
 
     def index
       authorize :account, :index?
@@ -13,8 +13,10 @@ module Admin
 
     def show
       authorize @account, :show?
+
       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
-      @moderation_notes = @account.targeted_moderation_notes.latest
+      @moderation_notes        = @account.targeted_moderation_notes.latest
+      @warnings                = @account.targeted_account_warnings.latest.custom
     end
 
     def subscribe
@@ -43,10 +45,17 @@ module Admin
       redirect_to admin_account_path(@account.id)
     end
 
-    def disable
-      authorize @account.user, :disable?
-      @account.user.disable!
-      log_action :disable, @account.user
+    def unsilence
+      authorize @account, :unsilence?
+      @account.unsilence!
+      log_action :unsilence, @account
+      redirect_to admin_account_path(@account.id)
+    end
+
+    def unsuspend
+      authorize @account, :unsuspend?
+      @account.unsuspend!
+      log_action :unsuspend, @account
       redirect_to admin_account_path(@account.id)
     end
 
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index e97ddb9b6..f138376b2 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -13,75 +13,42 @@ module Admin
       authorize @report, :show?
 
       @report_note  = @report.notes.new
-      @report_notes = (@report.notes.latest + @report.history).sort_by(&:created_at)
+      @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
       @form         = Form::StatusBatch.new
     end
 
-    def update
+    def assign_to_self
       authorize @report, :update?
-      process_report
-
-      if @report.action_taken?
-        redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
-      else
-        redirect_to admin_report_path(@report)
-      end
+      @report.update!(assigned_account_id: current_account.id)
+      log_action :assigned_to_self, @report
+      redirect_to admin_report_path(@report)
     end
 
-    private
-
-    def process_report
-      case params[:outcome].to_s
-      when 'assign_to_self'
-        @report.update!(assigned_account_id: current_account.id)
-        log_action :assigned_to_self, @report
-      when 'unassign'
-        @report.update!(assigned_account_id: nil)
-        log_action :unassigned, @report
-      when 'reopen'
-        @report.unresolve!
-        log_action :reopen, @report
-      when 'resolve'
-        @report.resolve!(current_account)
-        log_action :resolve, @report
-      when 'disable'
-        @report.resolve!(current_account)
-        @report.target_account.user.disable!
-
-        log_action :resolve, @report
-        log_action :disable, @report.target_account.user
-
-        resolve_all_target_account_reports
-      when 'silence'
-        @report.resolve!(current_account)
-        @report.target_account.update!(silenced: true)
-
-        log_action :resolve, @report
-        log_action :silence, @report.target_account
-
-        resolve_all_target_account_reports
-      else
-        raise ActiveRecord::RecordNotFound
-      end
-
-      @report.reload
+    def unassign
+      authorize @report, :update?
+      @report.update!(assigned_account_id: nil)
+      log_action :unassigned, @report
+      redirect_to admin_report_path(@report)
     end
 
-    def resolve_all_target_account_reports
-      unresolved_reports_for_target_account.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
+    def reopen
+      authorize @report, :update?
+      @report.unresolve!
+      log_action :reopen, @report
+      redirect_to admin_report_path(@report)
     end
 
-    def unresolved_reports_for_target_account
-      Report.where(
-        target_account: @report.target_account
-      ).unresolved
+    def resolve
+      authorize @report, :update?
+      @report.resolve!(current_account)
+      log_action :resolve, @report
+      redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
     end
 
+    private
+
     def filtered_reports
-      ReportFilter.new(filter_params).results.order(id: :desc).includes(
-        :account,
-        :target_account
-      )
+      ReportFilter.new(filter_params).results.order(id: :desc).includes(:account, :target_account)
     end
 
     def filter_params
diff --git a/app/controllers/admin/silences_controller.rb b/app/controllers/admin/silences_controller.rb
deleted file mode 100644
index 4c06a9c0c..000000000
--- a/app/controllers/admin/silences_controller.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class SilencesController < BaseController
-    before_action :set_account
-
-    def create
-      authorize @account, :silence?
-      @account.update!(silenced: true)
-      log_action :silence, @account
-      redirect_to admin_accounts_path
-    end
-
-    def destroy
-      authorize @account, :unsilence?
-      @account.update!(silenced: false)
-      log_action :unsilence, @account
-      redirect_to admin_accounts_path
-    end
-
-    private
-
-    def set_account
-      @account = Account.find(params[:account_id])
-    end
-  end
-end
diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb
deleted file mode 100644
index f9bbf36fb..000000000
--- a/app/controllers/admin/suspensions_controller.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class SuspensionsController < BaseController
-    before_action :set_account
-
-    def new
-      @suspension = Form::AdminSuspensionConfirmation.new(report_id: params[:report_id])
-    end
-
-    def create
-      authorize @account, :suspend?
-
-      @suspension = Form::AdminSuspensionConfirmation.new(suspension_params)
-
-      if suspension_params[:acct] == @account.acct
-        resolve_report! if suspension_params[:report_id].present?
-        perform_suspend!
-        mark_reports_resolved!
-        redirect_to admin_accounts_path
-      else
-        flash.now[:alert] = I18n.t('admin.suspensions.bad_acct_msg')
-        render :new
-      end
-    end
-
-    def destroy
-      authorize @account, :unsuspend?
-      @account.unsuspend!
-      log_action :unsuspend, @account
-      redirect_to admin_accounts_path
-    end
-
-    private
-
-    def set_account
-      @account = Account.find(params[:account_id])
-    end
-
-    def suspension_params
-      params.require(:form_admin_suspension_confirmation).permit(:acct, :report_id)
-    end
-
-    def resolve_report!
-      report = Report.find(suspension_params[:report_id])
-      report.resolve!(current_account)
-      log_action :resolve, report
-    end
-
-    def perform_suspend!
-      @account.suspend!
-      Admin::SuspensionWorker.perform_async(@account.id)
-      log_action :suspend, @account
-    end
-
-    def mark_reports_resolved!
-      Report.where(target_account: @account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
-    end
-  end
-end
diff --git a/app/controllers/admin/warning_presets_controller.rb b/app/controllers/admin/warning_presets_controller.rb
new file mode 100644
index 000000000..37be842c5
--- /dev/null
+++ b/app/controllers/admin/warning_presets_controller.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Admin
+  class WarningPresetsController < BaseController
+    before_action :set_warning_preset, except: [:index, :create]
+
+    def index
+      authorize :account_warning_preset, :index?
+
+      @warning_presets = AccountWarningPreset.all
+      @warning_preset  = AccountWarningPreset.new
+    end
+
+    def create
+      authorize :account_warning_preset, :create?
+
+      @warning_preset = AccountWarningPreset.new(warning_preset_params)
+
+      if @warning_preset.save
+        redirect_to admin_warning_presets_path
+      else
+        @warning_presets = AccountWarningPreset.all
+        render :index
+      end
+    end
+
+    def edit
+      authorize @warning_preset, :update?
+    end
+
+    def update
+      authorize @warning_preset, :update?
+
+      if @warning_preset.update(warning_preset_params)
+        redirect_to admin_warning_presets_path
+      else
+        render :edit
+      end
+    end
+
+    def destroy
+      authorize @warning_preset, :destroy?
+
+      @warning_preset.destroy!
+      redirect_to admin_warning_presets_path
+    end
+
+    private
+
+    def set_warning_preset
+      @warning_preset = AccountWarningPreset.find(params[:id])
+    end
+
+    def warning_preset_params
+      params.require(:account_warning_preset).permit(:text)
+    end
+  end
+end
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 68cf8c75d..359d60b60 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -23,6 +23,8 @@ module Admin::ActionLogsHelper
       link_to record.domain, "https://#{record.domain}"
     when 'Status'
       link_to record.account.acct, TagManager.instance.url_for(record)
+    when 'AccountWarning'
+      link_to record.target_account.acct, admin_account_path(record.target_account_id)
     end
   end
 
@@ -34,6 +36,7 @@ module Admin::ActionLogsHelper
       link_to attributes['domain'], "https://#{attributes['domain']}"
     when 'Status'
       tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))
+
       if tmp_status.account
         link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id)
       else
@@ -81,6 +84,8 @@ module Admin::ActionLogsHelper
       'envelope'
     when 'Status'
       'pencil'
+    when 'AccountWarning'
+      'warning'
     end
   end
 
@@ -104,6 +109,6 @@ module Admin::ActionLogsHelper
   private
 
   def opposite_verbs?(log)
-    %w(DomainBlock EmailDomainBlock).include?(log.target_type)
+    %w(DomainBlock EmailDomainBlock AccountWarning).include?(log.target_type)
   end
 end
diff --git a/app/javascript/images/icon_flag.svg b/app/javascript/images/icon_flag.svg
new file mode 100644
index 000000000..3939c9d2b
--- /dev/null
+++ b/app/javascript/images/icon_flag.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+  <path d="M0 0h24v24H0z" fill="none"/>
+  <path d="M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6z"/>
+</svg>
diff --git a/app/javascript/images/mailer/icon_warning.png b/app/javascript/images/mailer/icon_warning.png
new file mode 100644
index 000000000..7baaac61c
--- /dev/null
+++ b/app/javascript/images/mailer/icon_warning.png
Binary files differdiff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
index d83bd4d96..74d1df8ed 100644
--- a/app/javascript/styles/mailer.scss
+++ b/app/javascript/styles/mailer.scss
@@ -426,6 +426,10 @@ h5 {
     background: $success-green;
   }
 
+  &.alert-icon td {
+    background: $error-red;
+  }
+
   img {
     max-width: 32px;
     width: 32px;
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index b6c771abf..e8f331932 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -542,6 +542,10 @@ a.name-tag,
     border-left-color: lighten($error-red, 12%);
   }
 
+  &.warning {
+    border-left-color: $gold-star;
+  }
+
   &__bubble {
     padding: 16px;
     padding-left: 14px;
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index aa76b4dfe..8f3a4ab3a 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -78,4 +78,16 @@ class UserMailer < Devise::Mailer
       mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject')
     end
   end
+
+  def warning(user, warning)
+    @resource = user
+    @warning  = warning
+    @instance = Rails.configuration.x.local_domain
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email,
+           subject: I18n.t("user_mailer.warning.subject.#{@warning.action}", acct: "@#{user.account.local_username_and_domain}"),
+           reply_to: Setting.site_contact_email
+    end
+  end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 5a7a9c580..16ef6c187 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -155,6 +155,14 @@ class Account < ApplicationRecord
     ResolveAccountService.new.call(acct)
   end
 
+  def silence!
+    update!(silenced: true)
+  end
+
+  def unsilence!
+    update!(silenced: false)
+  end
+
   def suspend!
     transaction do
       user&.disable! if local?
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
new file mode 100644
index 000000000..157e6c04d
--- /dev/null
+++ b/app/models/account_warning.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_warnings
+#
+#  id                :bigint(8)        not null, primary key
+#  account_id        :bigint(8)
+#  target_account_id :bigint(8)
+#  action            :integer          default("none"), not null
+#  text              :text             default(""), not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class AccountWarning < ApplicationRecord
+  enum action: %i(none disable silence suspend), _suffix: :action
+
+  belongs_to :account, inverse_of: :account_warnings
+  belongs_to :target_account, class_name: 'Account', inverse_of: :targeted_account_warnings
+
+  scope :latest, -> { order(created_at: :desc) }
+  scope :custom, -> { where.not(text: '') }
+end
diff --git a/app/models/account_warning_preset.rb b/app/models/account_warning_preset.rb
new file mode 100644
index 000000000..ba8ceabb3
--- /dev/null
+++ b/app/models/account_warning_preset.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: account_warning_presets
+#
+#  id         :bigint(8)        not null, primary key
+#  text       :text             default(""), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class AccountWarningPreset < ApplicationRecord
+  validates :text, presence: true
+end
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
new file mode 100644
index 000000000..84c3f880d
--- /dev/null
+++ b/app/models/admin/account_action.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+class Admin::AccountAction
+  include ActiveModel::Model
+  include AccountableConcern
+  include Authorization
+
+  TYPES = %w(
+    none
+    disable
+    silence
+    suspend
+  ).freeze
+
+  attr_accessor :target_account,
+                :current_account,
+                :type,
+                :text,
+                :report_id,
+                :warning_preset_id,
+                :send_email_notification
+
+  attr_reader :warning
+
+  def save!
+    ApplicationRecord.transaction do
+      process_action!
+      process_warning!
+    end
+
+    queue_email!
+    process_reports!
+  end
+
+  def report
+    @report ||= Report.find(report_id) if report_id.present?
+  end
+
+  def with_report?
+    !report.nil?
+  end
+
+  class << self
+    def types_for_account(account)
+      if account.local?
+        TYPES
+      else
+        TYPES - %w(none disable)
+      end
+    end
+  end
+
+  private
+
+  def process_action!
+    case type
+    when 'disable'
+      handle_disable!
+    when 'silence'
+      handle_silence!
+    when 'suspend'
+      handle_suspend!
+    end
+  end
+
+  def process_warning!
+    return unless warnable?
+
+    authorize(target_account, :warn?)
+
+    @warning = AccountWarning.create!(target_account: target_account,
+                                      account: current_account,
+                                      action: type,
+                                      text: text_for_warning)
+
+    # A log entry is only interesting if the warning contains
+    # custom text from someone. Otherwise it's just noise.
+    log_action(:create, warning) if warning.text.present?
+  end
+
+  def process_reports!
+    return if report_id.blank?
+
+    authorize(report, :update?)
+
+    if type == 'none'
+      log_action(:resolve, report)
+      report.resolve!(current_account)
+    else
+      Report.where(target_account: target_account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
+    end
+  end
+
+  def handle_disable!
+    authorize(target_account.user, :disable?)
+    log_action(:disable, target_account.user)
+    target_account.user&.disable!
+  end
+
+  def handle_silence!
+    authorize(target_account, :silence?)
+    log_action(:silence, target_account)
+    target_account.silence!
+  end
+
+  def handle_suspend!
+    authorize(target_account, :suspend?)
+    log_action(:suspend, target_account)
+    target_account.suspend!
+    queue_suspension_worker!
+  end
+
+  def text_for_warning
+    [warning_preset&.text, text].compact.join("\n\n")
+  end
+
+  def queue_suspension_worker!
+    Admin::SuspensionWorker.perform_async(target_account.id)
+  end
+
+  def queue_email!
+    return unless warnable?
+
+    UserMailer.warning(target_account.user, warning).deliver_later!
+  end
+
+  def warnable?
+    send_email_notification && target_account.local?
+  end
+
+  def warning_preset
+    @warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present?
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index ae50860ed..a894b5eed 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -39,6 +39,8 @@ module AccountAssociations
     # Moderation notes
     has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account
     has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
+    has_many :account_warnings, dependent: :destroy, inverse_of: :account
+    has_many :targeted_account_warnings, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
 
     # Lists (that the account is on, not owned by the account)
     has_many :list_accounts, inverse_of: :account, dependent: :destroy
diff --git a/app/models/form/admin_suspension_confirmation.rb b/app/models/form/admin_suspension_confirmation.rb
deleted file mode 100644
index c34b5b30e..000000000
--- a/app/models/form/admin_suspension_confirmation.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class Form::AdminSuspensionConfirmation
-  include ActiveModel::Model
-
-  attr_accessor :acct, :report_id
-end
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 07bae68ef..9c145979d 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -9,6 +9,10 @@ class AccountPolicy < ApplicationPolicy
     staff?
   end
 
+  def warn?
+    staff? && !record.user&.staff?
+  end
+
   def suspend?
     staff? && !record.user&.staff?
   end
diff --git a/app/policies/account_warning_preset_policy.rb b/app/policies/account_warning_preset_policy.rb
new file mode 100644
index 000000000..bccbd33ef
--- /dev/null
+++ b/app/policies/account_warning_preset_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AccountWarningPresetPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def create?
+    staff?
+  end
+
+  def update?
+    staff?
+  end
+
+  def destroy?
+    staff?
+  end
+end
diff --git a/app/views/admin/account_actions/new.html.haml b/app/views/admin/account_actions/new.html.haml
new file mode 100644
index 000000000..97286c8e5
--- /dev/null
+++ b/app/views/admin/account_actions/new.html.haml
@@ -0,0 +1,26 @@
+- content_for :page_title do
+  = t('admin.account_actions.title', acct: @account.acct)
+
+= simple_form_for @account_action, url: admin_account_action_path(@account.id) do |f|
+  = f.input :report_id, as: :hidden
+
+  .fields-group
+    = f.input :type, collection: Admin::AccountAction.types_for_account(@account), include_blank: false, wrapper: :with_block_label, label_method: ->(type) { I18n.t("simple_form.labels.admin_account_action.types.#{type}")}, hint: t('simple_form.hints.admin_account_action.type_html', acct: @account.acct)
+
+  - if @account.local?
+    %hr.spacer/
+
+    .fields-group
+      = f.input :send_email_notification, as: :boolean, wrapper: :with_label
+
+    %hr.spacer/
+
+    - unless @warning_presets.empty?
+      .fields-group
+        = f.input :warning_preset_id, collection: @warning_presets, label_method: :text, wrapper: :with_block_label
+
+    .fields-group
+      = f.input :text, as: :text, wrapper: :with_block_label, hint: t('simple_form.hints.admin_account_action.text_html', path: admin_warning_presets_path)
+
+  .actions
+    = f.button :button, t('admin.account_actions.action'), type: :submit
diff --git a/app/views/admin/account_warnings/_account_warning.html.haml b/app/views/admin/account_warnings/_account_warning.html.haml
new file mode 100644
index 000000000..8c9c9679c
--- /dev/null
+++ b/app/views/admin/account_warnings/_account_warning.html.haml
@@ -0,0 +1,6 @@
+.speech-bubble.warning
+  .speech-bubble__bubble
+    = Formatter.instance.linkify(account_warning.text)
+  .speech-bubble__owner
+    = admin_account_link_to account_warning.account
+    %time.formatted{ datetime: account_warning.created_at.iso8601 }= l account_warning.created_at
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index e9f765107..226aef732 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -64,7 +64,7 @@
               = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user)
             - else
               = t('admin.accounts.enabled')
-              = table_link_to 'lock', t('admin.accounts.disable'), disable_admin_account_path(@account.id), method: :post if can?(:disable, @account.user)
+              = 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('admin.accounts.most_recent_ip')
           %td= @account.user_current_sign_in_ip
@@ -119,18 +119,18 @@
 
   %div{ style: 'float: left' }
     - if @account.silenced?
-      = link_to t('admin.accounts.undo_silenced'), admin_account_silence_path(@account.id), method: :delete, class: 'button' if can?(:unsilence, @account)
+      = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account)
     - else
-      = link_to t('admin.accounts.silence'), admin_account_silence_path(@account.id), method: :post, class: 'button button--destructive' if can?(:silence, @account)
+      = 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?
       - 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'), admin_account_suspension_path(@account.id), method: :delete, class: 'button' if can?(:unsuspend, @account)
+      = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
     - else
-      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_suspension_path(@account.id), class: 'button button--destructive' if can?(:suspend, @account)
+      = 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)
 
 - if !@account.local? && @account.hub_url.present?
   %hr.spacer/
@@ -184,6 +184,10 @@
 
 %hr.spacer/
 
+= render @warnings
+
+%hr.spacer/
+
 = render @moderation_notes
 
 = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 3588d151d..863dada9e 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -8,13 +8,14 @@
   - if @report.unresolved?
     %div{ style: 'float: right' }
       - if @report.target_account.local?
-        = link_to t('admin.accounts.disable'), admin_report_path(@report, outcome: 'disable'), method: :put, class: 'button button--destructive'
-      = link_to t('admin.accounts.silence'), admin_report_path(@report, outcome: 'silence'), method: :put, class: 'button button--destructive'
-      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_suspension_path(@report.target_account_id, report_id: @report.id), class: 'button button--destructive'
+        = link_to t('admin.accounts.warn'), new_admin_account_action_path(@report.target_account_id, type: 'none', report_id: @report.id), class: 'button'
+        = link_to t('admin.accounts.disable'), new_admin_account_action_path(@report.target_account_id, type: 'disable', report_id: @report.id), class: 'button button--destructive'
+      = link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive'
+      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, type: 'suspend', report_id: @report.id), class: 'button button--destructive'
     %div{ style: 'float: left' }
-      = link_to t('admin.reports.mark_as_resolved'), admin_report_path(@report, outcome: 'resolve'), method: :put, class: 'button'
+      = link_to t('admin.reports.mark_as_resolved'), resolve_admin_report_path(@report), method: :post, class: 'button'
   - else
-    = link_to t('admin.reports.mark_as_unresolved'), admin_report_path(@report, outcome: 'reopen'), method: :put, class: 'button'
+    = link_to t('admin.reports.mark_as_unresolved'), reopen_admin_report_path(@report), method: :post, class: 'button'
 
 %hr.spacer
 
@@ -67,10 +68,10 @@
               = admin_account_link_to @report.assigned_account
           %td
             - if @report.assigned_account != current_user.account
-              = table_link_to 'user', t('admin.reports.assign_to_self'), admin_report_path(@report, outcome: 'assign_to_self'), method: :put
+              = table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post
           %td
             - if !@report.assigned_account.nil?
-              = table_link_to 'trash', t('admin.reports.unassign'), admin_report_path(@report, outcome: 'unassign'), method: :put
+              = table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post
 
 %hr.spacer
 
@@ -104,7 +105,7 @@
 - @report_notes.each do |item|
   - if item.is_a?(Admin::ActionLog)
     = render partial: 'action_log', locals: { action_log: item }
-  - elsif item.is_a?(ReportNote)
+  - else
     = render item
 
 = simple_form_for @report_note, url: admin_report_notes_path do |f|
diff --git a/app/views/admin/suspensions/new.html.haml b/app/views/admin/suspensions/new.html.haml
deleted file mode 100644
index f03ecacc3..000000000
--- a/app/views/admin/suspensions/new.html.haml
+++ /dev/null
@@ -1,25 +0,0 @@
-- content_for :page_title do
-  = t('admin.suspensions.title', acct: @account.acct)
-
-= simple_form_for @suspension, url: admin_account_suspension_path(@account.id), method: :post do |f|
-  %p.hint= t('admin.suspensions.warning_html')
-
-  .fields-group
-    %ul
-      %li.negative-hint
-        = number_to_human @account.statuses_count, strip_insignificant_zeros: true
-        = t('accounts.posts', count: @account.statuses_count)
-      %li.negative-hint
-        = number_to_human @account.following_count, strip_insignificant_zeros: true
-        = t('accounts.following', count: @account.following_count)
-      %li.negative-hint
-        = number_to_human @account.followers_count, strip_insignificant_zeros: true
-        = t('accounts.followers', count: @account.followers_count)
-
-  %p.hint= t('admin.suspensions.hint_html', value: content_tag(:code, @account.acct))
-
-  = f.input :acct
-  = f.input_field :report_id, as: :hidden
-
-  .actions
-    = f.button :button, t('admin.suspensions.proceed'), type: :submit, class: 'negative'
diff --git a/app/views/admin/warning_presets/edit.html.haml b/app/views/admin/warning_presets/edit.html.haml
new file mode 100644
index 000000000..9522746cd
--- /dev/null
+++ b/app/views/admin/warning_presets/edit.html.haml
@@ -0,0 +1,11 @@
+- content_for :page_title do
+  = t('admin.warning_presets.edit_preset')
+
+= simple_form_for @warning_preset, url: admin_warning_preset_path(@warning_preset) do |f|
+  = render 'shared/error_messages', object: @warning_preset
+
+  .fields-group
+    = f.input :text, wrapper: :with_block_label
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/warning_presets/index.html.haml b/app/views/admin/warning_presets/index.html.haml
new file mode 100644
index 000000000..45913ef73
--- /dev/null
+++ b/app/views/admin/warning_presets/index.html.haml
@@ -0,0 +1,30 @@
+- content_for :page_title do
+  = t('admin.warning_presets.title')
+
+- if can? :create, :account_warning_preset
+  = simple_form_for @warning_preset, url: admin_warning_presets_path do |f|
+    = render 'shared/error_messages', object: @warning_preset
+
+    .fields-group
+      = f.input :text, wrapper: :with_block_label
+
+    .actions
+      = f.button :button, t('admin.warning_presets.add_new'), type: :submit
+
+  %hr.spacer/
+
+- unless @warning_presets.empty?
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('simple_form.labels.account_warning_preset.text')
+          %th
+      %tbody
+        - @warning_presets.each do |preset|
+          %tr
+            %td
+              = Formatter.instance.linkify(preset.text)
+            %td
+              = table_link_to 'pencil', t('admin.warning_presets.edit'), edit_admin_warning_preset_path(preset)
+              = table_link_to 'trash', t('admin.warning_presets.delete'), admin_warning_preset_path(preset), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
new file mode 100644
index 000000000..c5e1f5a28
--- /dev/null
+++ b/app/views/user_mailer/warning.html.haml
@@ -0,0 +1,63 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_warning.png'), alt: ''
+
+                              %h1= t "user_mailer.warning.title.#{@warning.action}"
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              - unless @warning.none_action?
+                                %p= t "user_mailer.warning.explanation.#{@warning.action}"
+
+                              - unless @warning.text.blank?
+                                = Formatter.instance.linkify(@warning.text)
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to about_more_url do
+                                    %span= t 'user_mailer.warning.review_server_policies'
diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb
new file mode 100644
index 000000000..b4f2402cb
--- /dev/null
+++ b/app/views/user_mailer/warning.text.erb
@@ -0,0 +1,9 @@
+<%= t "user_mailer.warning.title.#{@warning.action}" %>
+
+===
+
+<% unless @warning.none_action? %>
+<%= t "user_mailer.warning.explanation.#{@warning.action}" %>
+
+<% end %>
+<%= @warning.text %>
diff --git a/app/views/user_mailer/welcome.text.erb b/app/views/user_mailer/welcome.text.erb
index 5bd0cab2a..845458c32 100644
--- a/app/views/user_mailer/welcome.text.erb
+++ b/app/views/user_mailer/welcome.text.erb
@@ -2,7 +2,7 @@
 
 ===
 
-<%= t 'user_mailer.welcome.full_handle' %> (<%= "@#{@resource.account.username}@#{@instance}" %>)
+<%= t 'user_mailer.welcome.full_handle' %> (<%= "@#{@resource.account.local_username_and_domain}" %>)
 <%= t 'user_mailer.welcome.full_handle_hint', instance: @instance %>
 
 ---
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 2fa075273..0652089d4 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -455,11 +455,6 @@ ar:
       last_delivery: آخر إيداع
       title: WebSub
       topic: الموضوع
-    suspensions:
-      bad_acct_msg: قيمة التأكيد غير متطابقة. متأكد مِن أنك بصدد تعليق الحساب الصحيح؟
-      hint_html: 'لتأكيد إجراء تعليق الحساب، يُرجى إدخال %{value} في الحقل التالي:'
-      proceed: مواصلة
-      title: تعليق الحساب %{acct}
     tags:
       accounts: الحسابات
       hidden: المخفية
diff --git a/config/locales/ast.yml b/config/locales/ast.yml
index e6c51b10e..c18c398eb 100644
--- a/config/locales/ast.yml
+++ b/config/locales/ast.yml
@@ -121,8 +121,6 @@ ast:
       failed_to_execute: Fallu al executar
     subscriptions:
       title: WebSub
-    suspensions:
-      warning_html: 'El suspender esta cuenta va desaniciar <strong>de mou irreversible</strong> los sos datos qu''inclúin:'
     title: Alministración
   admin_mailer:
     new_report:
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index 1575b32ea..b7e3cb7f6 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -439,12 +439,6 @@ ca:
       last_delivery: Últim lliurament
       title: WebSub
       topic: Tema
-    suspensions:
-      bad_acct_msg: El valor de confirmació no s'ha trobat. Estàs suspenen el compte correcte?
-      hint_html: 'Per confirmar la suspensió del compte, introdueix %{value} al camp següent:'
-      proceed: Procedeix
-      title: Suspèn %{acct}
-      warning_html: 'Suspenen aquest compte esborrarà <strong>irreversiblement</strong> les dades del compte, incloent:'
     tags:
       accounts: Comptes
       hidden: Amagat
diff --git a/config/locales/co.yml b/config/locales/co.yml
index c15f241c6..3df78f007 100644
--- a/config/locales/co.yml
+++ b/config/locales/co.yml
@@ -439,12 +439,6 @@ co:
       last_delivery: Ultima arricata
       title: WebSub
       topic: Sughjettu
-    suspensions:
-      bad_acct_msg: U valore di cunfirmazione ùn era micca curretta. Site sicuru·a di suspende u bonu contu?
-      hint_html: 'Per cunfirmà a suspensione di u contu, entrate %{value} quì sottu:'
-      proceed: Cuntinuà
-      title: Suspende %{acct}
-      warning_html: 'A suspensione di u contu sguasserà di manera <strong>irreversibile</strong> i so dati, cum''è:'
     tags:
       accounts: Conti
       hidden: Piattatu
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index d25ca6c2c..0042d5641 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -444,12 +444,6 @@ cs:
       last_delivery: Poslední doručení
       title: WebSub
       topic: Téma
-    suspensions:
-      bad_acct_msg: Hodnota pro potvrzení neodpovídá. Suspendujete správný účet?
-      hint_html: 'Pro potvrzení suspenzace účtu prosím zadejte do pole níže %{value}:'
-      proceed: Pokračovat
-      title: Suspendovat účet %{acct}
-      warning_html: 'Suspenzace tohoto účtu <strong>nenávratně</strong> smaže z tohoto účtu data, včetně:'
     tags:
       accounts: Účty
       hidden: Skryté
diff --git a/config/locales/cy.yml b/config/locales/cy.yml
index 24ae6fa10..2467d3e78 100644
--- a/config/locales/cy.yml
+++ b/config/locales/cy.yml
@@ -423,12 +423,6 @@ cy:
       last_delivery: Danfoniad diwethaf
       title: WebSub
       topic: Pwnc
-    suspensions:
-      bad_acct_msg: Nid yw'r gwerthoedd cadarnhau yn cyfateb. Ydych chi'n atal y cyfrif cywir?
-      hint_html: 'I gadarnhau atal y cyfrif, mewnbynwch %{value} yn y maes isod:'
-      proceed: Parhau
-      title: Atal %{acct}
-      warning_html: 'Mi fydd atal y cyfrif hwn yn dileu data <strong>am byth</strong> o''r cyfrif hwn, gan gynnwys:'
     title: Gweinyddiaeth
   admin_mailer:
     new_report:
diff --git a/config/locales/da.yml b/config/locales/da.yml
index c6bdc753a..202f6bfb3 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -427,12 +427,6 @@ da:
       last_delivery: Sidste levering
       title: Websub
       topic: Emne
-    suspensions:
-      bad_acct_msg: Bekræftelsværdien stemte ikke overens. Er du ved at udelukke den rigtige konto?
-      hint_html: 'For at bekræfte udelukkelsen af kontoen, indtast venligst %{value} i nedenstående felt:'
-      proceed: Fortsæt
-      title: Udeluk %{acct}
-      warning_html: 'Udelukkelse af denne konto vil <strong>uigenkaldeligt</strong> slette al data fra denne konto, hvilket indebærer:'
     title: Administration
   admin_mailer:
     new_report:
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 945d5a0ff..647897171 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -439,12 +439,6 @@ de:
       last_delivery: Letzte Zustellung
       title: WebSub
       topic: Thema
-    suspensions:
-      bad_acct_msg: Der Bestätigungswert stimmt nicht überein. Sperrst du das richtige Benutzerkonto?
-      hint_html: 'Um die Sperrung des Benutzerkontos zu genehmigen tippe %{value} in das Feld unten ein:'
-      proceed: Fortfahren
-      title: "%{acct} sperren"
-      warning_html: 'Die Sperrung des Benutzerkontos wird <strong>unwiederrufliche</strong> Schäden hervorrufen und alle Daten löschen, die folgendes beinhalten:'
     tags:
       accounts: Konten
       hidden: Versteckt
diff --git a/config/locales/el.yml b/config/locales/el.yml
index 66393f213..e03a116d0 100644
--- a/config/locales/el.yml
+++ b/config/locales/el.yml
@@ -439,12 +439,6 @@ el:
       last_delivery: Τελευταία παράδοση
       title: WebSub
       topic: Θέμα
-    suspensions:
-      bad_acct_msg: Η τιμή επιβεβαίωσης δεν ταιριάζει. Σίγουρα αναστέλλεις το σωστό λογαριασμό;
-      hint_html: 'Για να επιβεβαιώσεις την αναστολή του λογαριασμού, γράψε %{value} στο ακόλουθο πεδίο:'
-      proceed: Συνέχεια
-      title: Αναστολή %{acct}
-      warning_html: 'Αναστέλλοντας αυτό το λογαριασμό θα διαγραφούν <strong>αμετάκλητα</strong> δεδομένα του, μεταξύ των οποίων:'
     tags:
       accounts: Λογαριασμοί
       hidden: Κρυμμένες
diff --git a/config/locales/en.yml b/config/locales/en.yml
index bd0b0c3d5..bea182f0b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -70,6 +70,9 @@ en:
       moderator: Mod
     unfollow: Unfollow
   admin:
+    account_actions:
+      action: Perform action
+      title: Perform moderation action on %{acct}
     account_moderation_notes:
       create: Leave note
       created_msg: Moderation note successfully created!
@@ -173,6 +176,7 @@ en:
         assigned_to_self_report: "%{name} assigned report %{target} to themselves"
         change_email_user: "%{name} changed the e-mail address of user %{target}"
         confirm_user: "%{name} confirmed e-mail address of user %{target}"
+        create_account_warning: "%{name} sent a warning to %{target}"
         create_custom_emoji: "%{name} uploaded new emoji %{target}"
         create_domain_block: "%{name} blocked domain %{target}"
         create_email_domain_block: "%{name} blacklisted e-mail domain %{target}"
@@ -441,12 +445,6 @@ en:
       last_delivery: Last delivery
       title: WebSub
       topic: Topic
-    suspensions:
-      bad_acct_msg: The confirmation value didn't match up. Are you suspending the right account?
-      hint_html: 'To confirm the suspension of the account, please enter %{value} into the field below:'
-      proceed: Proceed
-      title: Suspend %{acct}
-      warning_html: 'Suspending this account will <strong>irreversibly</strong> delete data from this account, which includes:'
     tags:
       accounts: Accounts
       hidden: Hidden
@@ -456,6 +454,12 @@ en:
       unhide: Show in directory
       visible: Visible
     title: Administration
+    warning_presets:
+      add_new: Add new
+      delete: Delete
+      edit: Edit
+      edit_preset: Edit warning preset
+      title: Manage warning presets
   admin_mailer:
     new_report:
       body: "%{reporter} has reported %{target}"
@@ -922,6 +926,22 @@ en:
       explanation: You requested a full backup of your Mastodon account. It's now ready for download!
       subject: Your archive is ready for download
       title: Archive takeout
+    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.
+      review_server_policies: Review server policies
+      subject:
+        disable: Your account %{acct} has been frozen
+        none: Warning for %{acct}
+        silence: Your account %{acct} has been limited
+        suspend: Your account %{acct} has been suspended
+      title:
+        disable: Account frozen
+        none: Warning
+        silence: Account limited
+        suspend: Account suspended
     welcome:
       edit_profile_action: Setup profile
       edit_profile_step: You can customize your profile by uploading an avatar, header, changing your display name and more. If you’d like to review new followers before they’re allowed to follow you, you can lock your account.
diff --git a/config/locales/eo.yml b/config/locales/eo.yml
index f944b2a19..6a62f7021 100644
--- a/config/locales/eo.yml
+++ b/config/locales/eo.yml
@@ -427,11 +427,6 @@ eo:
       last_delivery: Lasta livero
       title: WebSub
       topic: Temo
-    suspensions:
-      hint_html: 'Por konformi la haltigo de la konto, bonvolu enigi %{value} en la kampo sube:'
-      proceed: Daŭrigita
-      title: Haltigi %{acct}
-      warning_html: 'Haltigi ĉi tiu konton forigos <strong>senrevene</strong> datumojn de ĉi tiu konto, inklusive de:'
     title: Administrado
   admin_mailer:
     new_report:
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 4cd1e2a38..8927c3b38 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -433,12 +433,6 @@ es:
       last_delivery: Última entrega
       title: WebSub
       topic: Tópico
-    suspensions:
-      bad_acct_msg: El valor de confirmación no cuadra. ¿Estás suspendiendo la cuenta correcta?
-      hint_html: 'Para confirmar las suspensión de la cuenta, por favor introduce %{value} en el campo de abajo:'
-      proceed: Proceder
-      title: Suspender %{acct}
-      warning_html: 'Suspender esta cuenta borrará <strong>irreversiblemente</strong> los datos de stra cuenta que incluyen:'
     title: Administración
   admin_mailer:
     new_report:
diff --git a/config/locales/eu.yml b/config/locales/eu.yml
index c96438bc3..eb148c6a2 100644
--- a/config/locales/eu.yml
+++ b/config/locales/eu.yml
@@ -435,12 +435,6 @@ eu:
       last_delivery: Azken bidalketa
       title: WebSub
       topic: Mintzagaia
-    suspensions:
-      bad_acct_msg: Berrespen balioa ez dator bat. Dagokion kontua kanporatzen ari zara?
-      hint_html: 'Kontuaren kanporatzea berresteko, sartu %{value} beheko eremuan:'
-      proceed: Jarraitu
-      title: Kanporatu %{acct}
-      warning_html: 'Kontu hau kanporatzeak <strong>behin betiko</strong> ezabatuko ditu kontu honetako datuak, hauek barne:'
     tags:
       accounts: Kontuak
       hidden: Ezkutatuta
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index 7802ca98d..a87949183 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -433,12 +433,6 @@ fa:
       last_delivery: آخرین ارسال
       title: WebSub
       topic: موضوع
-    suspensions:
-      bad_acct_msg: محتوایی که برای تأیید وارد کردید منطبق نبود. آیا دارید حساب درستی را معلق می‌کنید؟
-      hint_html: 'برای تأیید معلق‌کردن حساب، لطفاً در کادر زیر %{value} را وارد کنید:'
-      proceed: ادامه
-      title: معلق‌کردن %{acct}
-      warning_html: 'معلق‌کردن این حساب <strong>برای همیشه</strong> داده‌هایش را پاک می‌کند. داده‌هایی شامل:'
     title: مدیریت سرور
   admin_mailer:
     new_report:
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 3d33cd40c..167c942ee 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -439,12 +439,6 @@ fr:
       last_delivery: Dernière livraison
       title: WebSub
       topic: Sujet
-    suspensions:
-      bad_acct_msg: La valeur de confirmation n'a pas correspondu. Êtes-vous certain de suspendre le bon compte ?
-      hint_html: 'Pour confirmer la suspension du compte, veuillez entrer %{value} dans le champ ci-dessous :'
-      proceed: Confirmer
-      title: Suspension de %{acct}
-      warning_html: 'Suspendre ce compte effacera <strong>irréversiblement</strong> les données de ce compte, ce qui inclut :'
     tags:
       accounts: Comptes
       hidden: Masqué
diff --git a/config/locales/gl.yml b/config/locales/gl.yml
index de1557fb9..b6830fb72 100644
--- a/config/locales/gl.yml
+++ b/config/locales/gl.yml
@@ -439,12 +439,6 @@ gl:
       last_delivery: Última entrega
       title: WebSub
       topic: Asunto
-    suspensions:
-      bad_acct_msg: O valor de confirmación non é coincidente. Está a suspender a conta correcta?
-      hint_html: 'Para confirmar a suspensión da conta introduza %{value} no campo inferior:'
-      proceed: Proceder
-      title: Suspender %{acct}
-      warning_html: 'Ao suspender esta conta eliminará <strong>de xeito irreversible</strong> os datos de esta conta, que inclúe:'
     tags:
       accounts: Contas
       hidden: Ocultas
diff --git a/config/locales/it.yml b/config/locales/it.yml
index dc62b1bea..e9bf78cdf 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -429,12 +429,6 @@ it:
       confirmed: Confermato
       expires_in: Scade in
       topic: Argomento
-    suspensions:
-      bad_acct_msg: Il valore di conferma non corrisponde. Stai sospendendo l'account giusto?
-      hint_html: 'Per confermare la sospensione dell''account, inserisci %{value} nel campo qui sotto:'
-      proceed: Continua
-      title: Sospendi %{acct}
-      warning_html: 'La sospensione dell''account comporta la cancellazione <strong>irreversibile</strong> dei suoi dati, che comprendono:'
     title: Amministrazione
   application_mailer:
     notification_preferences: Cambia preferenze email
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 038131a61..292acf52f 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -441,12 +441,6 @@ ja:
       last_delivery: 最終配送
       title: WebSub
       topic: トピック
-    suspensions:
-      bad_acct_msg: 値が一致しませんでした。停止しようとしているアカウントに間違いはありませんか?
-      hint_html: 'アカウントの停止を確認するには、以下のフィールドに %{value} と入力してください:'
-      proceed: 完全に活動停止させる
-      title: "%{acct} を停止"
-      warning_html: 'このアカウントを停止すると、このアカウントから次のようなデータが<strong>不可逆的に</strong>削除されます:'
     tags:
       accounts: アカウント
       hidden: 非表示
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 40dab2d79..6bbc71e0b 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -441,12 +441,6 @@ ko:
       last_delivery: 최종 발송
       title: WebSub
       topic: 토픽
-    suspensions:
-      bad_acct_msg: 확인값이 일치하지 않습니다. 정지하려는 계정이 맞습니까?
-      hint_html: '이 계정을 정지하려면 %{value}를 아래 입력칸에 입력하세요:'
-      proceed: 완전히 정지시키기
-      title: "%{acct} 정지하기"
-      warning_html: '이 계정을 정지하면 계정의 데이터를 모두 삭제하며 <strong>되돌릴 수 없습니다</strong>. 이것은 다음을 포함합니다:'
     tags:
       accounts: 계정들
       hidden: 숨겨짐
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index d0578bc74..700217830 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -439,12 +439,6 @@ nl:
       last_delivery: Laatste bezorging
       title: WebSub
       topic: Account
-    suspensions:
-      bad_acct_msg: De bevestigingswaarde kwam niet overeen. Schort je wel het juiste account op?
-      hint_html: Vul in het veld hieronder %{value} in, om het opschorten van dit account te bevestigen.
-      proceed: Ga verder
-      title: "%{acct} opschorten"
-      warning_html: 'Door het opschorten van dit account worden gegevens van dit account <strong>permanent</strong> verwijderd, waaronder:'
     tags:
       accounts: Accounts
       hidden: Verborgen
diff --git a/config/locales/oc.yml b/config/locales/oc.yml
index 0468fac86..e647e400c 100644
--- a/config/locales/oc.yml
+++ b/config/locales/oc.yml
@@ -439,12 +439,6 @@ oc:
       last_delivery: Darrièra distribucion
       title: WebSub
       topic: Subjècte
-    suspensions:
-      bad_acct_msg: La valor de confirmacion a pas coïncidit. Sètz a suspendre lo bon compte ?
-      hint_html: 'Per confirmar la suspension del compte, picatz %{value} al camp çai-jos :'
-      proceed: Tractat
-      title: Suspension de %{acct}
-      warning_html: 'Suspendre aqueste compte suprimirà <strong>irreversiblament</strong> las donadas del compte, aquò compren :'
     tags:
       accounts: Comptes
       hidden: Amagat
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index 79ba6f9fb..7fd5df038 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -445,12 +445,6 @@ pl:
       last_delivery: Ostatnio doręczono
       title: WebSub
       topic: Temat
-    suspensions:
-      bad_acct_msg: Zawartość potwierdzenia nie zgadza się. Czy próbujesz zawiesić właściwe konto?
-      hint_html: 'Aby potwierdzić zawieszenie konta, wprowadź %{value} w poniższe pole:'
-      proceed: Przejdź
-      title: Zawieś %{acct}
-      warning_html: 'Zawieszenie konta będzie skutkowało <strong>nieodwracalnym</strong> usunięciem danych z tego konta, wliczając:'
     tags:
       accounts: Konta
       hidden: Ukryte
diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml
index 27f3b820c..e9625628b 100644
--- a/config/locales/pt-BR.yml
+++ b/config/locales/pt-BR.yml
@@ -439,12 +439,6 @@ pt-BR:
       last_delivery: Última entrega
       title: WebSub
       topic: Tópico
-    suspensions:
-      bad_acct_msg: Os valores de confirmação não correspondem. Você está suspendendo a conta certa?
-      hint_html: 'Para confirmar a suspensão da conta, por favor digite %{value} no campo abaixo:'
-      proceed: Prosseguir
-      title: Suspender %{acct}
-      warning_html: 'Suspender essa conta vai remover <strong>irreversivelmente</strong> dados dessa conta, o que inclui:'
     tags:
       accounts: Contas
       hidden: Escondido
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 9fa85b7c2..dceb41376 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -427,12 +427,6 @@ ru:
       last_delivery: Последняя доставка
       title: WebSub
       topic: Тема
-    suspensions:
-      bad_acct_msg: Не удалось найти такое число подтверждения. Вы уверены, что замораживаете нужный аккаунт?
-      hint_html: 'Чтобы подтвердить заморозку аккаунта, пожалуйста, введите %{value} в поле ниже:'
-      proceed: Продолжить
-      title: Заморозить %{acct}
-      warning_html: 'Заморозка этого аккаунта приведёт к <strong>необратимому</strong> удалению данных с этого аккаунта, включая:'
     title: Администрирование
   admin_mailer:
     new_report:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index ce6a62e87..4363c59e4 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -2,6 +2,13 @@
 en:
   simple_form:
     hints:
+      account_warning_preset:
+        text: You can use toot syntax, such as URLs, hashtags and mentions
+      admin_account_action:
+        send_email_notification: The user will receive an explanation of what happened with their account
+        text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time
+        type_html: Choose what to do with <strong>%{acct}</strong>
+        warning_preset_id: Optional. You can still add custom text to end of the preset
       defaults:
         autofollow: People who sign up through the invite will automatically follow you
         avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
@@ -40,6 +47,18 @@ en:
         fields:
           name: Label
           value: Content
+      account_warning_preset:
+        text: Preset text
+      admin_account_action:
+        send_email_notification: Notify the user per e-mail
+        text: Custom warning
+        type: Action
+        types:
+          disable: Disable
+          none: Do nothing
+          silence: Silence
+          suspend: Suspend and irreversibly delete account data
+        warning_preset_id: Use a warning preset
       defaults:
         autofollow: Invite to follow your account
         avatar: Avatar
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index 3458c699c..f44d971a4 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -444,12 +444,6 @@ sk:
       last_delivery: Posledné doručenie
       title: WebSub
       topic: Téma
-    suspensions:
-      bad_acct_msg: Hodnota pre potvrdenie sa nezhoduje. Si si istý/á že zamrazuješ ten správny účet?
-      hint_html: 'Pre potvrdenie zamrazenia účtu, prosím napíš %{value} do následujúceho políčka:'
-      proceed: Pokračuj
-      title: Zamraziť %{acct}
-      warning_html: 'Zamrazením tohto účtu budú dáta na tomto účte <strong>nenávratne</strong> zmazané, zahŕňajúc:'
     tags:
       accounts: Účty
       hidden: Skryté
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index da621a910..8dee9fdac 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -443,12 +443,6 @@ sr:
       last_delivery: Последња достава
       title: WebSub
       topic: Topic
-    suspensions:
-      bad_acct_msg: Вредност потврде се не поклапа. Да ли суспендујете прави рачун?
-      hint_html: 'Да бисте потврдили суспензију налога, унесите %{value} у поље испод:'
-      proceed: Настави
-      title: Суспендуј %{acct}
-      warning_html: 'Суспендовање овог налога ће <strong>неповратно</strong>избрисати све податке са овог налога, који укључују:'
     title: Администрација
   admin_mailer:
     new_report:
diff --git a/config/routes.rb b/config/routes.rb
index 0aba433e2..7723a08af 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -139,6 +139,7 @@ Rails.application.routes.draw do
     resources :domain_blocks, only: [:index, :new, :create, :show, :destroy]
     resources :email_domain_blocks, only: [:index, :new, :create, :destroy]
     resources :action_logs, only: [:index]
+    resources :warning_presets, except: [:new]
     resource :settings, only: [:edit, :update]
 
     resources :invites, only: [:index, :create, :destroy] do
@@ -160,7 +161,14 @@ Rails.application.routes.draw do
       end
     end
 
-    resources :reports, only: [:index, :show, :update] do
+    resources :reports, only: [:index, :show] do
+      member do
+        post :assign_to_self
+        post :unassign
+        post :reopen
+        post :resolve
+      end
+
       resources :reported_statuses, only: [:create]
     end
 
@@ -171,7 +179,8 @@ Rails.application.routes.draw do
         post :subscribe
         post :unsubscribe
         post :enable
-        post :disable
+        post :unsilence
+        post :unsuspend
         post :redownload
         post :remove_avatar
         post :remove_header
@@ -180,8 +189,7 @@ Rails.application.routes.draw do
 
       resource :change_email, only: [:show, :update]
       resource :reset, only: [:create]
-      resource :silence, only: [:create, :destroy]
-      resource :suspension, only: [:new, :create, :destroy]
+      resource :action, only: [:new, :create], controller: 'account_actions'
       resources :statuses, only: [:index, :create, :update, :destroy]
 
       resource :confirmation, only: [:create] do
diff --git a/db/migrate/20181213184704_create_account_warnings.rb b/db/migrate/20181213184704_create_account_warnings.rb
new file mode 100644
index 000000000..e768be277
--- /dev/null
+++ b/db/migrate/20181213184704_create_account_warnings.rb
@@ -0,0 +1,12 @@
+class CreateAccountWarnings < ActiveRecord::Migration[5.2]
+  def change
+    create_table :account_warnings do |t|
+      t.belongs_to :account, foreign_key: { on_delete: :nullify }
+      t.belongs_to :target_account, foreign_key: { to_table: 'accounts', on_delete: :cascade }
+      t.integer :action, null: false, default: 0
+      t.text :text, null: false, default: ''
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20181213185533_create_account_warning_presets.rb b/db/migrate/20181213185533_create_account_warning_presets.rb
new file mode 100644
index 000000000..9c81f1b5e
--- /dev/null
+++ b/db/migrate/20181213185533_create_account_warning_presets.rb
@@ -0,0 +1,9 @@
+class CreateAccountWarningPresets < ActiveRecord::Migration[5.2]
+  def change
+    create_table :account_warning_presets do |t|
+      t.text :text, null: false, default: ''
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 51ac43e1d..51a7b5e74 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: 2018_12_07_011115) do
+ActiveRecord::Schema.define(version: 2018_12_13_185533) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -76,6 +76,23 @@ ActiveRecord::Schema.define(version: 2018_12_07_011115) do
     t.index ["tag_id"], name: "index_account_tag_stats_on_tag_id", unique: true
   end
 
+  create_table "account_warning_presets", force: :cascade do |t|
+    t.text "text", default: "", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+  end
+
+  create_table "account_warnings", force: :cascade do |t|
+    t.bigint "account_id"
+    t.bigint "target_account_id"
+    t.integer "action", default: 0, null: false
+    t.text "text", default: "", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["account_id"], name: "index_account_warnings_on_account_id"
+    t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id"
+  end
+
   create_table "accounts", force: :cascade do |t|
     t.string "username", default: "", null: false
     t.string "domain"
@@ -656,6 +673,8 @@ ActiveRecord::Schema.define(version: 2018_12_07_011115) do
   add_foreign_key "account_pins", "accounts", on_delete: :cascade
   add_foreign_key "account_stats", "accounts", on_delete: :cascade
   add_foreign_key "account_tag_stats", "tags", on_delete: :cascade
+  add_foreign_key "account_warnings", "accounts", column: "target_account_id", on_delete: :cascade
+  add_foreign_key "account_warnings", "accounts", on_delete: :nullify
   add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
   add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade
   add_foreign_key "backups", "users", on_delete: :nullify
diff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb
index dbcad3c2d..a348ab3d7 100644
--- a/spec/controllers/admin/accounts_controller_spec.rb
+++ b/spec/controllers/admin/accounts_controller_spec.rb
@@ -191,58 +191,6 @@ RSpec.describe Admin::AccountsController, type: :controller do
     end
   end
 
-  describe 'POST #disable' do
-    subject { post :disable, params: { id: account.id } }
-
-    let(:current_user) { Fabricate(:user, admin: current_user_admin) }
-    let(:account) { Fabricate(:account, user: user) }
-    let(:user) { Fabricate(:user, disabled: false, admin: target_user_admin) }
-
-    context 'when user is admin' do
-      let(:current_user_admin) { true }
-
-      context 'when target user is admin' do
-        let(:target_user_admin) { true }
-
-        it 'fails to disable account' do
-          is_expected.to have_http_status :forbidden
-          expect(user.reload).not_to be_disabled
-        end
-      end
-
-      context 'when target user is not admin' do
-        let(:target_user_admin) { false }
-
-        it 'succeeds in disabling account' do
-          is_expected.to redirect_to admin_account_path(account.id)
-          expect(user.reload).to be_disabled
-        end
-      end
-    end
-
-    context 'when user is not admin' do
-      let(:current_user_admin) { false }
-
-      context 'when target user is admin' do
-        let(:target_user_admin) { true }
-
-        it 'fails to disable account' do
-          is_expected.to have_http_status :forbidden
-          expect(user.reload).not_to be_disabled
-        end
-      end
-
-      context 'when target user is not admin' do
-        let(:target_user_admin) { false }
-
-        it 'fails to disable account' do
-          is_expected.to have_http_status :forbidden
-          expect(user.reload).not_to be_disabled
-        end
-      end
-    end
-  end
-
   describe 'POST #redownload' do
     subject { post :redownload, params: { id: account.id } }
 
diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb
index bcc789c57..b428299ee 100644
--- a/spec/controllers/admin/reports_controller_spec.rb
+++ b/spec/controllers/admin/reports_controller_spec.rb
@@ -46,73 +46,37 @@ describe Admin::ReportsController do
     end
   end
 
-  describe 'PUT #update' do
-    describe 'with an unknown outcome' do
-      it 'rejects the change' do
-        report = Fabricate(:report)
-        put :update, params: { id: report, outcome: 'unknown' }
-
-        expect(response).to have_http_status(404)
-      end
-    end
-
-    describe 'with an outcome of `resolve`' do
-      it 'resolves the report' do
-        report = Fabricate(:report)
-
-        put :update, params: { id: report, outcome: 'resolve' }
-        expect(response).to redirect_to(admin_reports_path)
-        report.reload
-        expect(report.action_taken_by_account).to eq user.account
-        expect(report.action_taken).to eq true
-      end
-    end
-
-    describe 'with an outsome of `silence`' do
-      it 'silences the reported account' do
-        report = Fabricate(:report)
-
-        put :update, params: { id: report, outcome: 'silence' }
-        expect(response).to redirect_to(admin_reports_path)
-        report.reload
-        expect(report.action_taken_by_account).to eq user.account
-        expect(report.action_taken).to eq true
-        expect(report.target_account).to be_silenced
-      end
-    end
-
-    describe 'with an outsome of `reopen`' do
-      it 'reopens the report' do
-        report = Fabricate(:report)
+  describe 'POST #reopen' do
+    it 'reopens the report' do
+      report = Fabricate(:report)
 
-        put :update, params: { id: report, outcome: 'reopen' }
-        expect(response).to redirect_to(admin_report_path(report))
-        report.reload
-        expect(report.action_taken_by_account).to eq nil
-        expect(report.action_taken).to eq false
-      end
+      put :reopen, params: { id: report }
+      expect(response).to redirect_to(admin_report_path(report))
+      report.reload
+      expect(report.action_taken_by_account).to eq nil
+      expect(report.action_taken).to eq false
     end
+  end
 
-    describe 'with an outsome of `assign_to_self`' do
-      it 'reopens the report' do
-        report = Fabricate(:report)
+  describe 'POST #assign_to_self' do
+    it 'reopens the report' do
+      report = Fabricate(:report)
 
-        put :update, params: { id: report, outcome: 'assign_to_self' }
-        expect(response).to redirect_to(admin_report_path(report))
-        report.reload
-        expect(report.assigned_account).to eq user.account
-      end
+      put :assign_to_self, params: { id: report }
+      expect(response).to redirect_to(admin_report_path(report))
+      report.reload
+      expect(report.assigned_account).to eq user.account
     end
+  end
 
-    describe 'with an outsome of `unassign`' do
-      it 'reopens the report' do
-        report = Fabricate(:report)
+  describe 'POST #unassign' do
+    it 'reopens the report' do
+      report = Fabricate(:report)
 
-        put :update, params: { id: report, outcome: 'unassign' }
-        expect(response).to redirect_to(admin_report_path(report))
-        report.reload
-        expect(report.assigned_account).to eq nil
-      end
+      put :unassign, params: { id: report }
+      expect(response).to redirect_to(admin_report_path(report))
+      report.reload
+      expect(report.assigned_account).to eq nil
     end
   end
 end
diff --git a/spec/controllers/admin/silences_controller_spec.rb b/spec/controllers/admin/silences_controller_spec.rb
deleted file mode 100644
index 78560eb39..000000000
--- a/spec/controllers/admin/silences_controller_spec.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-require 'rails_helper'
-
-describe Admin::SilencesController do
-  render_views
-
-  before do
-    sign_in Fabricate(:user, admin: true), scope: :user
-  end
-
-  describe 'POST #create' do
-    it 'redirects to admin accounts page' do
-      account = Fabricate(:account, silenced: false)
-
-      post :create, params: { account_id: account.id }
-
-      account.reload
-      expect(account.silenced?).to eq true
-      expect(response).to redirect_to(admin_accounts_path)
-    end
-  end
-
-  describe 'DELETE #destroy' do
-    it 'redirects to admin accounts page' do
-      account = Fabricate(:account, silenced: true)
-
-      delete :destroy, params: { account_id: account.id }
-
-      account.reload
-      expect(account.silenced?).to eq false
-      expect(response).to redirect_to(admin_accounts_path)
-    end
-  end
-end
diff --git a/spec/controllers/admin/suspensions_controller_spec.rb b/spec/controllers/admin/suspensions_controller_spec.rb
deleted file mode 100644
index 1bc33e490..000000000
--- a/spec/controllers/admin/suspensions_controller_spec.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-require 'rails_helper'
-
-describe Admin::SuspensionsController do
-  render_views
-
-  before do
-    sign_in Fabricate(:user, admin: true), scope: :user
-  end
-
-  describe 'GET #new' do
-    it 'returns 200' do
-      get :new, params: { account_id: Fabricate(:account).id, report_id: Fabricate(:report).id }
-      expect(response).to have_http_status(200)
-    end
-  end
-
-  describe 'POST #create' do
-    it 'redirects to admin accounts page' do
-      account = Fabricate(:account, suspended: false)
-      expect(Admin::SuspensionWorker).to receive(:perform_async).with(account.id)
-
-      post :create, params: { account_id: account.id, form_admin_suspension_confirmation: { acct: account.acct } }
-
-      expect(response).to redirect_to(admin_accounts_path)
-    end
-  end
-
-  describe 'DELETE #destroy' do
-    it 'redirects to admin accounts page' do
-      account = Fabricate(:account, suspended: true)
-
-      delete :destroy, params: { account_id: account.id }
-
-      account.reload
-      expect(account.suspended?).to eq false
-      expect(response).to redirect_to(admin_accounts_path)
-    end
-  end
-end
diff --git a/spec/fabricators/account_warning_fabricator.rb b/spec/fabricators/account_warning_fabricator.rb
new file mode 100644
index 000000000..db161d446
--- /dev/null
+++ b/spec/fabricators/account_warning_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:account_warning) do
+  account        nil
+  target_account nil
+  text           "MyText"
+end
diff --git a/spec/fabricators/account_warning_preset_fabricator.rb b/spec/fabricators/account_warning_preset_fabricator.rb
new file mode 100644
index 000000000..6c0b87e7c
--- /dev/null
+++ b/spec/fabricators/account_warning_preset_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:account_warning_preset) do
+  text "MyText"
+end
diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb
index d9cdb9264..53c836494 100644
--- a/spec/mailers/previews/user_mailer_preview.rb
+++ b/spec/mailers/previews/user_mailer_preview.rb
@@ -39,4 +39,9 @@ class UserMailerPreview < ActionMailer::Preview
   def backup_ready
     UserMailer.backup_ready(User.first, Backup.first)
   end
+
+  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning
+  def warning
+    UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence))
+  end
 end
diff --git a/spec/models/account_warning_preset_spec.rb b/spec/models/account_warning_preset_spec.rb
new file mode 100644
index 000000000..a859a305f
--- /dev/null
+++ b/spec/models/account_warning_preset_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe AccountWarningPreset, type: :model do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/models/account_warning_spec.rb b/spec/models/account_warning_spec.rb
new file mode 100644
index 000000000..5286f9177
--- /dev/null
+++ b/spec/models/account_warning_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe AccountWarning, type: :model do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb
new file mode 100644
index 000000000..8c55cf4dd
--- /dev/null
+++ b/spec/models/admin/account_action_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe Admin::AccountAction, type: :model do
+end