about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2022-02-14 21:27:53 +0100
committerGitHub <noreply@github.com>2022-02-14 21:27:53 +0100
commit564efd06515edc524a8a1cdf7a3d8a7d9a376c04 (patch)
treea0d0a8ec693e06ef67ef25ec22128da291c318d1 /app
parent5be705e1e0e3c05486c6069a7c8387c123a6d405 (diff)
Add appeals (#17364)
* Add appeals

* Add ability to reject appeals and ability to browse pending appeals in admin UI

* Add strikes to account page in settings

* Various fixes and improvements

- Add separate notification setting for appeals, separate from reports
- Fix style of links in report/strike header
- Change approving an appeal to not restore statuses (due to federation complexities)
- Change style of successfully appealed strikes on account settings page
- Change account settings page to only show unappealed or recently appealed strikes

* Change appealed_at to overruled_at

* Fix missing method error
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/accounts_controller.rb4
-rw-r--r--app/controllers/admin/dashboard_controller.rb1
-rw-r--r--app/controllers/admin/disputes/appeals_controller.rb40
-rw-r--r--app/controllers/auth/registrations_controller.rb11
-rw-r--r--app/controllers/disputes/appeals_controller.rb25
-rw-r--r--app/controllers/disputes/base_controller.rb18
-rw-r--r--app/controllers/disputes/strikes_controller.rb17
-rw-r--r--app/helpers/admin/account_moderation_notes_helper.rb4
-rw-r--r--app/helpers/admin/action_logs_helper.rb2
-rw-r--r--app/helpers/admin/trends/statuses_helper.rb20
-rw-r--r--app/javascript/styles/mastodon/admin.scss89
-rw-r--r--app/mailers/admin_mailer.rb10
-rw-r--r--app/mailers/user_mailer.rb20
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/account_filter.rb2
-rw-r--r--app/models/account_warning.rb8
-rw-r--r--app/models/admin/action_log_filter.rb2
-rw-r--r--app/models/admin/appeal_filter.rb49
-rw-r--r--app/models/appeal.rb58
-rw-r--r--app/models/user.rb4
-rw-r--r--app/policies/account_warning_policy.rb17
-rw-r--r--app/policies/appeal_policy.rb13
-rw-r--r--app/services/appeal_service.rb28
-rw-r--r--app/services/approve_appeal_service.rb74
-rw-r--r--app/views/admin/account_moderation_notes/_account_moderation_note.html.haml7
-rw-r--r--app/views/admin/account_warnings/_account_warning.html.haml30
-rw-r--r--app/views/admin/accounts/show.html.haml21
-rw-r--r--app/views/admin/dashboard/index.html.haml3
-rw-r--r--app/views/admin/disputes/appeals/_appeal.html.haml21
-rw-r--r--app/views/admin/disputes/appeals/index.html.haml22
-rw-r--r--app/views/admin/report_notes/_report_note.html.haml2
-rw-r--r--app/views/admin/reports/show.html.haml2
-rw-r--r--app/views/admin_mailer/new_appeal.text.erb9
-rw-r--r--app/views/auth/registrations/_account_warning.html.haml20
-rw-r--r--app/views/auth/registrations/_status.html.haml31
-rw-r--r--app/views/disputes/strikes/show.html.haml127
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml1
-rw-r--r--app/views/statuses/_detailed_status.html.haml2
-rw-r--r--app/views/user_mailer/appeal_approved.html.haml59
-rw-r--r--app/views/user_mailer/appeal_approved.text.erb7
-rw-r--r--app/views/user_mailer/appeal_rejected.html.haml59
-rw-r--r--app/views/user_mailer/appeal_rejected.text.erb7
-rw-r--r--app/views/user_mailer/warning.html.haml6
43 files changed, 901 insertions, 55 deletions
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index e7f56e243..e0ae71b9f 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -28,7 +28,7 @@ module Admin
       @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.strikes.custom.latest
+      @warnings                = @account.strikes.includes(:target_account, :account, :appeal).latest
       @domain_block            = DomainBlock.rule_for(@account.domain)
     end
 
@@ -146,7 +146,7 @@ module Admin
     end
 
     def filter_params
-      params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS)
+      params.slice(:page, *AccountFilter::KEYS).permit(:page, *AccountFilter::KEYS)
     end
 
     def form_account_batch_params
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index f0a935411..e376baab2 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -8,6 +8,7 @@ module Admin
       @pending_users_count   = User.pending.count
       @pending_reports_count = Report.unresolved.count
       @pending_tags_count    = Tag.pending_review.count
+      @pending_appeals_count = Appeal.pending.count
     end
 
     private
diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb
new file mode 100644
index 000000000..32e5e2f6f
--- /dev/null
+++ b/app/controllers/admin/disputes/appeals_controller.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class Admin::Disputes::AppealsController < Admin::BaseController
+  before_action :set_appeal, except: :index
+
+  def index
+    authorize :appeal, :index?
+
+    @appeals = filtered_appeals.page(params[:page])
+  end
+
+  def approve
+    authorize @appeal, :approve?
+    log_action :approve, @appeal
+    ApproveAppealService.new.call(@appeal, current_account)
+    redirect_to disputes_strike_path(@appeal.strike)
+  end
+
+  def reject
+    authorize @appeal, :approve?
+    log_action :reject, @appeal
+    @appeal.reject!(current_account)
+    UserMailer.appeal_rejected(@appeal.account.user, @appeal)
+    redirect_to disputes_strike_path(@appeal.strike)
+  end
+
+  private
+
+  def filtered_appeals
+    Admin::AppealFilter.new(filter_params.with_defaults(status: 'pending')).results.includes(strike: :account)
+  end
+
+  def filter_params
+    params.slice(:page, *Admin::AppealFilter::KEYS).permit(:page, *Admin::AppealFilter::KEYS)
+  end
+
+  def set_appeal
+    @appeal = Appeal.find(params[:id])
+  end
+end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index f37e906fd..3b025838b 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -9,6 +9,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   before_action :check_enabled_registrations, only: [:new, :create]
   before_action :configure_sign_up_params, only: [:create]
   before_action :set_sessions, only: [:edit, :update]
+  before_action :set_strikes, only: [:edit, :update]
   before_action :set_instance_presenter, only: [:new, :create, :update]
   before_action :set_body_classes, only: [:new, :create, :edit, :update]
   before_action :require_not_suspended!, only: [:update]
@@ -111,8 +112,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def set_invite
-    invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
-    @invite = invite&.valid_for_use? ? invite : nil
+    @invite = begin
+      invite = Invite.find_by(code: invite_code) if invite_code.present?
+      invite if invite&.valid_for_use?
+    end
   end
 
   def determine_layout
@@ -123,6 +126,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     @sessions = current_user.session_activations
   end
 
+  def set_strikes
+    @strikes = current_account.strikes.active.latest
+  end
+
   def require_not_suspended!
     forbidden if current_account.suspended?
   end
diff --git a/app/controllers/disputes/appeals_controller.rb b/app/controllers/disputes/appeals_controller.rb
new file mode 100644
index 000000000..15367c879
--- /dev/null
+++ b/app/controllers/disputes/appeals_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Disputes::AppealsController < Disputes::BaseController
+  before_action :set_strike
+
+  def create
+    authorize @strike, :appeal?
+
+    @appeal = AppealService.new.call(@strike, appeal_params[:text])
+
+    redirect_to disputes_strike_path(@strike), notice: I18n.t('disputes.strikes.appealed_msg')
+  rescue ActiveRecord::RecordInvalid
+    render template: 'disputes/strikes/show'
+  end
+
+  private
+
+  def set_strike
+    @strike = current_account.strikes.find(params[:strike_id])
+  end
+
+  def appeal_params
+    params.require(:appeal).permit(:text)
+  end
+end
diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb
new file mode 100644
index 000000000..865146b5c
--- /dev/null
+++ b/app/controllers/disputes/base_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Disputes::BaseController < ApplicationController
+  include Authorization
+
+  layout 'admin'
+
+  skip_before_action :require_functional!
+
+  before_action :set_body_classes
+  before_action :authenticate_user!
+
+  private
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
+end
diff --git a/app/controllers/disputes/strikes_controller.rb b/app/controllers/disputes/strikes_controller.rb
new file mode 100644
index 000000000..d41c5c727
--- /dev/null
+++ b/app/controllers/disputes/strikes_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Disputes::StrikesController < Disputes::BaseController
+  before_action :set_strike
+
+  def show
+    authorize @strike, :show?
+
+    @appeal = @strike.appeal || @strike.build_appeal
+  end
+
+  private
+
+  def set_strike
+    @strike = AccountWarning.find(params[:id])
+  end
+end
diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb
index 40b2a5289..2f08538ca 100644
--- a/app/helpers/admin/account_moderation_notes_helper.rb
+++ b/app/helpers/admin/account_moderation_notes_helper.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 module Admin::AccountModerationNotesHelper
-  def admin_account_link_to(account)
+  def admin_account_link_to(account, path: nil)
     return if account.nil?
 
-    link_to admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do
+    link_to path || admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do
       safe_join([
                   image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'),
                   content_tag(:span, account.acct, class: 'username'),
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index f3aa4be4f..47eeeaac3 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -33,6 +33,8 @@ module Admin::ActionLogsHelper
       "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
     when 'Instance'
       record.domain
+    when 'Appeal'
+      link_to record.account.acct, disputes_strike_path(record.strike)
     end
   end
 
diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb
new file mode 100644
index 000000000..d16e3dd12
--- /dev/null
+++ b/app/helpers/admin/trends/statuses_helper.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Admin::Trends::StatusesHelper
+  def one_line_preview(status)
+    text = begin
+      if status.local?
+        status.text.split("\n").first
+      else
+        Nokogiri::HTML(status.text).css('html > body > *').first&.text
+      end
+    end
+
+    return '' if text.blank?
+
+    html = Formatter.instance.send(:encode, text)
+    html = Formatter.instance.send(:encode_custom_emojis, html, status.emojis, prefers_autoplay?)
+
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+end
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 546de1640..f5741bd50 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -578,12 +578,16 @@ body,
 }
 
 .log-entry {
+  display: block;
   line-height: 20px;
   padding: 15px;
   padding-left: 15px * 2 + 40px;
   background: $ui-base-color;
   border-bottom: 1px solid darken($ui-base-color, 8%);
   position: relative;
+  text-decoration: none;
+  color: $darker-text-color;
+  font-size: 14px;
 
   &:first-child {
     border-top-left-radius: 4px;
@@ -596,15 +600,12 @@ body,
     border-bottom: 0;
   }
 
-  &:hover {
+  &:hover,
+  &:focus,
+  &:active {
     background: lighten($ui-base-color, 4%);
   }
 
-  &__header {
-    color: $darker-text-color;
-    font-size: 14px;
-  }
-
   &__avatar {
     position: absolute;
     left: 15px;
@@ -640,6 +641,18 @@ body,
       text-decoration: underline;
     }
   }
+
+  &--inactive {
+    .log-entry__title {
+      text-decoration: line-through;
+    }
+
+    a,
+    .username,
+    .target {
+      color: $darker-text-color;
+    }
+  }
 }
 
 a.name-tag,
@@ -1175,6 +1188,17 @@ a.sparkline {
         font-weight: 600;
         padding: 4px 0;
       }
+
+      a {
+        color: $ui-highlight-color;
+        text-decoration: none;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
     }
 
     &--horizontal {
@@ -1451,3 +1475,56 @@ a.sparkline {
     }
   }
 }
+
+.strike-card {
+  padding: 15px;
+  border-radius: 4px;
+  background: $ui-base-color;
+  font-size: 15px;
+  line-height: 20px;
+  word-wrap: break-word;
+  font-weight: 400;
+  color: $primary-text-color;
+
+  p {
+    margin-bottom: 20px;
+    unicode-bidi: plaintext;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  &__statuses-list {
+    border-radius: 4px;
+    border: 1px solid darken($ui-base-color, 8%);
+    font-size: 13px;
+    line-height: 18px;
+    overflow: hidden;
+
+    &__item {
+      padding: 16px;
+      background: lighten($ui-base-color, 2%);
+      border-bottom: 1px solid darken($ui-base-color, 8%);
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__meta {
+        color: $darker-text-color;
+      }
+
+      a {
+        color: inherit;
+        text-decoration: none;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
+    }
+  }
+}
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index b23bd1296..a9d00c000 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -15,6 +15,16 @@ class AdminMailer < ApplicationMailer
     end
   end
 
+  def new_appeal(recipient, appeal)
+    @appeal   = appeal
+    @me       = recipient
+    @instance = Rails.configuration.x.local_domain
+
+    locale_for_account(@me) do
+      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_appeal.subject', instance: @instance, username: @appeal.account.username)
+    end
+  end
+
   def new_pending_account(recipient, user)
     @account  = user.account
     @me       = recipient
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 5221a4892..583c948b0 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -173,6 +173,26 @@ class UserMailer < Devise::Mailer
     end
   end
 
+  def appeal_approved(user, appeal)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @appeal   = appeal
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('user_mailer.appeal_approved.subject', date: l(@appeal.created_at))
+    end
+  end
+
+  def appeal_rejected(user, appeal)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @appeal   = appeal
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('user_mailer.appeal_rejected.subject', date: l(@appeal.created_at))
+    end
+  end
+
   def sign_in_token(user, remote_ip, user_agent, timestamp)
     @resource   = user
     @instance   = Rails.configuration.x.local_domain
diff --git a/app/models/account.rb b/app/models/account.rb
index 771cc0b1b..2ad45feda 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -270,6 +270,10 @@ class Account < ApplicationRecord
     true
   end
 
+  def previous_strikes_count
+    strikes.where(overruled_at: nil).count
+  end
+
   def keypair
     @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
   end
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index 86b7f5f41..9da1522dd 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -24,6 +24,8 @@ class AccountFilter
     scope = Account.includes(:account_stat, user: [:ips, :invite_request]).without_instance_actor.reorder(nil)
 
     params.each do |key, value|
+      next if key.to_s == 'page'
+
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
 
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
index fc0d988fd..05d01942d 100644
--- a/app/models/account_warning.rb
+++ b/app/models/account_warning.rb
@@ -12,6 +12,7 @@
 #  updated_at        :datetime         not null
 #  report_id         :bigint(8)
 #  status_ids        :string           is an Array
+#  overruled_at      :datetime
 #
 
 class AccountWarning < ApplicationRecord
@@ -28,12 +29,17 @@ class AccountWarning < ApplicationRecord
   belongs_to :target_account, class_name: 'Account', inverse_of: :strikes
   belongs_to :report, optional: true
 
-  has_one :appeal, dependent: :destroy
+  has_one :appeal, dependent: :destroy, inverse_of: :strike
 
   scope :latest, -> { order(id: :desc) }
   scope :custom, -> { where.not(text: '') }
+  scope :active, -> { where(overruled_at: nil).or(where('account_warnings.overruled_at >= ?', 30.days.ago)) }
 
   def statuses
     Status.with_discarded.where(id: status_ids || [])
   end
+
+  def overruled?
+    overruled_at.present?
+  end
 end
diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb
index 12136223b..0f2f712a2 100644
--- a/app/models/admin/action_log_filter.rb
+++ b/app/models/admin/action_log_filter.rb
@@ -8,6 +8,8 @@ class Admin::ActionLogFilter
   ).freeze
 
   ACTION_TYPE_MAP = {
+    approve_appeal: { target_type: 'Appeal', action: 'approve' }.freeze,
+    reject_appeal: { target_type: 'Appeal', action: 'reject' }.freeze,
     assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze,
     change_email_user: { target_type: 'User', action: 'change_email' }.freeze,
     confirm_user: { target_type: 'User', action: 'confirm' }.freeze,
diff --git a/app/models/admin/appeal_filter.rb b/app/models/admin/appeal_filter.rb
new file mode 100644
index 000000000..b163d2e56
--- /dev/null
+++ b/app/models/admin/appeal_filter.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class Admin::AppealFilter
+  KEYS = %i(
+    status
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = Appeal.order(id: :desc)
+
+    params.each do |key, value|
+      next if %w(page).include?(key.to_s)
+
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'status'
+      status_scope(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+
+  def status_scope(value)
+    case value
+    when 'approved'
+      Appeal.approved
+    when 'rejected'
+      Appeal.rejected
+    when 'pending'
+      Appeal.pending
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
+end
diff --git a/app/models/appeal.rb b/app/models/appeal.rb
new file mode 100644
index 000000000..46f35ae37
--- /dev/null
+++ b/app/models/appeal.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: appeals
+#
+#  id                     :bigint(8)        not null, primary key
+#  account_id             :bigint(8)        not null
+#  account_warning_id     :bigint(8)        not null
+#  text                   :text             default(""), not null
+#  approved_at            :datetime
+#  approved_by_account_id :bigint(8)
+#  rejected_at            :datetime
+#  rejected_by_account_id :bigint(8)
+#  created_at             :datetime         not null
+#  updated_at             :datetime         not null
+#
+class Appeal < ApplicationRecord
+  belongs_to :account
+  belongs_to :strike, class_name: 'AccountWarning', foreign_key: 'account_warning_id'
+  belongs_to :approved_by_account, class_name: 'Account', optional: true
+  belongs_to :rejected_by_account, class_name: 'Account', optional: true
+
+  validates :text, presence: true, length: { maximum: 2_000 }
+  validates :account_warning_id, uniqueness: true
+
+  validate :validate_time_frame, on: :create
+
+  scope :approved, -> { where.not(approved_at: nil) }
+  scope :rejected, -> { where.not(rejected_at: nil) }
+  scope :pending, -> { where(approved_at: nil, rejected_at: nil) }
+
+  def pending?
+    !approved? && !rejected?
+  end
+
+  def approved?
+    approved_at.present?
+  end
+
+  def rejected?
+    rejected_at.present?
+  end
+
+  def approve!(current_account)
+    update!(approved_at: Time.now.utc, approved_by_account: current_account)
+  end
+
+  def reject!(current_account)
+    update!(rejected_at: Time.now.utc, rejected_by_account: current_account)
+  end
+
+  private
+
+  def validate_time_frame
+    errors.add(:base, I18n.t('strikes.errors.too_late')) if Time.now.utc > (strike.created_at + 20.days)
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index fd1d7049a..517254a91 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -265,6 +265,10 @@ class User < ApplicationRecord
     settings.notification_emails['pending_account']
   end
 
+  def allows_appeal_emails?
+    settings.notification_emails['appeal']
+  end
+
   def allows_trending_tag_emails?
     settings.notification_emails['trending_tag']
   end
diff --git a/app/policies/account_warning_policy.rb b/app/policies/account_warning_policy.rb
new file mode 100644
index 000000000..6b92da475
--- /dev/null
+++ b/app/policies/account_warning_policy.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AccountWarningPolicy < ApplicationPolicy
+  def show?
+    target? || staff?
+  end
+
+  def appeal?
+    target?
+  end
+
+  private
+
+  def target?
+    record.target_account_id == current_account&.id
+  end
+end
diff --git a/app/policies/appeal_policy.rb b/app/policies/appeal_policy.rb
new file mode 100644
index 000000000..a25187172
--- /dev/null
+++ b/app/policies/appeal_policy.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AppealPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def approve?
+    record.pending? && staff?
+  end
+
+  alias reject? approve?
+end
diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb
new file mode 100644
index 000000000..1397c50f5
--- /dev/null
+++ b/app/services/appeal_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class AppealService < BaseService
+  def call(strike, text)
+    @strike = strike
+    @text   = text
+
+    create_appeal!
+    notify_staff!
+
+    @appeal
+  end
+
+  private
+
+  def create_appeal!
+    @appeal = @strike.create_appeal!(
+      text: @text,
+      account: @strike.target_account
+    )
+  end
+
+  def notify_staff!
+    User.staff.includes(:account).each do |u|
+      AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails?
+    end
+  end
+end
diff --git a/app/services/approve_appeal_service.rb b/app/services/approve_appeal_service.rb
new file mode 100644
index 000000000..f76bf8943
--- /dev/null
+++ b/app/services/approve_appeal_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+class ApproveAppealService < BaseService
+  def call(appeal, current_account)
+    @appeal          = appeal
+    @strike          = appeal.strike
+    @current_account = current_account
+
+    ApplicationRecord.transaction do
+      undo_strike_action!
+      mark_strike_as_appealed!
+    end
+
+    queue_workers!
+    notify_target_account!
+  end
+
+  private
+
+  def target_account
+    @strike.target_account
+  end
+
+  def undo_strike_action!
+    case @strike.action
+    when 'disable'
+      undo_disable!
+    when 'delete_statuses'
+      undo_delete_statuses!
+    when 'sensitive'
+      undo_sensitive!
+    when 'silence'
+      undo_silence!
+    when 'suspend'
+      undo_suspend!
+    end
+  end
+
+  def mark_strike_as_appealed!
+    @appeal.approve!(@current_account)
+    @strike.touch(:overruled_at)
+  end
+
+  def undo_disable!
+    target_account.user.enable!
+  end
+
+  def undo_delete_statuses!
+    # Cannot be undone
+  end
+
+  def undo_sensitive!
+    target_account.unsensitize!
+  end
+
+  def undo_silence!
+    target_account.unsilence!
+  end
+
+  def undo_suspend!
+    target_account.unsuspend!
+  end
+
+  def queue_workers!
+    case @strike.action
+    when 'suspend'
+      Admin::UnsuspensionWorker.perform_async(target_account.id)
+    end
+  end
+
+  def notify_target_account!
+    UserMailer.appeal_approved(target_account.user, @appeal).deliver_later
+  end
+end
diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
deleted file mode 100644
index 432fb79a6..000000000
--- a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.speech-bubble
-  .speech-bubble__bubble
-    = simple_format(h(account_moderation_note.content))
-  .speech-bubble__owner
-    = admin_account_link_to account_moderation_note.account
-    %time.formatted{ datetime: account_moderation_note.created_at.iso8601 }= l account_moderation_note.created_at
-    = table_link_to 'trash', t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note)
diff --git a/app/views/admin/account_warnings/_account_warning.html.haml b/app/views/admin/account_warnings/_account_warning.html.haml
index 8c9c9679c..ef23c3b77 100644
--- a/app/views/admin/account_warnings/_account_warning.html.haml
+++ b/app/views/admin/account_warnings/_account_warning.html.haml
@@ -1,6 +1,24 @@
-.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
+= link_to disputes_strike_path(account_warning), class: ['log-entry', account_warning.overruled? && 'log-entry--inactive'] do
+  .log-entry__header
+    .log-entry__avatar
+      = image_tag account_warning.target_account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
+    .log-entry__content
+      .log-entry__title
+        = t(account_warning.action, scope: 'admin.strikes.actions', name: content_tag(:span, account_warning.account.username, class: 'username'), target: content_tag(:span, account_warning.target_account.acct, class: 'target')).html_safe
+      .log-entry__timestamp
+        %time.formatted{ datetime: account_warning.created_at.iso8601 }
+          = l(account_warning.created_at)
+
+        - if account_warning.report_id.present?
+          ·
+          = t('admin.reports.title', id: account_warning.report_id)
+
+        - if account_warning.overruled?
+          ·
+          %span.positive-hint= t('admin.strikes.appeal_approved')
+        - elsif account_warning.appeal&.pending?
+          ·
+          %span.warning-hint= t('admin.strikes.appeal_pending')
+        - elsif account_warning.appeal&.rejected?
+          ·
+          %span.negative-hint= t('admin.strikes.appeal_rejected')
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index f3853d629..9a1f07a06 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -246,18 +246,29 @@
   %hr.spacer/
 
   - unless @warnings.empty?
-    = render @warnings
+
+    %h3= t 'admin.accounts.previous_strikes'
+
+    %p= t('admin.accounts.previous_strikes_description_html', count: @account.previous_strikes_count)
+
+    .account-strikes
+      = render @warnings
 
     %hr.spacer/
 
-  = render @moderation_notes
+  %h3= t 'admin.reports.notes.title'
 
-  = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
-    = render 'shared/error_messages', object: @account_moderation_note
+  %p= t 'admin.reports.notes_description_html'
 
-    = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
+  .report-notes
+    = render partial: 'admin/report_notes/report_note', collection: @moderation_notes
+
+  = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
     = f.hidden_field :target_account_id
 
+    .field-group
+      = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
+
     .actions
       = f.button :button, t('admin.account_moderation_notes.create'), type: :submit
 
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 4b581f5ea..59b75e0e1 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -46,6 +46,9 @@
       %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
       = fa_icon 'chevron-right fw'
 
+    = link_to admin_disputes_appeals_path(status: 'pending'), class: 'dashboard__quick-access' do
+      %span= t('admin.dashboard.pending_appeals_html', count: @pending_appeals_count)
+      = fa_icon 'chevron-right fw'
   .dashboard__item
     = react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources')
 
diff --git a/app/views/admin/disputes/appeals/_appeal.html.haml b/app/views/admin/disputes/appeals/_appeal.html.haml
new file mode 100644
index 000000000..02b8777e1
--- /dev/null
+++ b/app/views/admin/disputes/appeals/_appeal.html.haml
@@ -0,0 +1,21 @@
+= link_to disputes_strike_path(appeal.strike), class: ['log-entry', appeal.approved? && 'log-entry--inactive'] do
+  .log-entry__header
+    .log-entry__avatar
+      = image_tag appeal.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
+    .log-entry__content
+      .log-entry__title
+        = t(appeal.strike.action, scope: 'admin.strikes.actions', name: content_tag(:span, appeal.strike.account.username, class: 'username'), target: content_tag(:span, appeal.account.acct, class: 'target')).html_safe
+      .log-entry__timestamp
+        %time.formatted{ datetime: appeal.strike.created_at.iso8601 }
+          = l(appeal.strike.created_at)
+
+        - if appeal.strike.report_id.present?
+          ·
+          = t('admin.reports.title', id: appeal.strike.report_id)
+        ·
+        - if appeal.approved?
+          %span.positive-hint= t('admin.strikes.appeal_approved')
+        - elsif appeal.rejected?
+          %span.negative-hint= t('admin.strikes.appeal_rejected')
+        - else
+          %span.warning-hint= t('admin.strikes.appeal_pending')
diff --git a/app/views/admin/disputes/appeals/index.html.haml b/app/views/admin/disputes/appeals/index.html.haml
new file mode 100644
index 000000000..dd6a6f403
--- /dev/null
+++ b/app/views/admin/disputes/appeals/index.html.haml
@@ -0,0 +1,22 @@
+- content_for :page_title do
+  = t('admin.disputes.appeals.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+.filters
+  .filter-subset
+    %strong= t('admin.tags.review')
+    %ul
+      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Appeal.pending.count})"], ' '), status: 'pending'
+      %li= filter_link_to t('admin.trends.approved'), status: 'approved'
+      %li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
+
+- if @appeals.empty?
+  %div.muted-hint.center-text
+    = t 'admin.disputes.appeals.empty'
+- else
+  .announcements-list
+    = render partial: 'appeal', collection: @appeals
+
+= paginate @appeals
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index 428b6cf59..f9d57c2ae 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -3,7 +3,7 @@
 
   .report-notes__item__header
     %span.username
-      = link_to display_name(report_note.account), admin_account_path(report_note.account_id)
+      = link_to report_note.account.username, admin_account_path(report_note.account_id)
     %time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
       - if report_note.created_at.today?
         = t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time))
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 02c46e384..e53c180e5 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -57,7 +57,7 @@
         .report-header__details__item__header
           %strong= t('admin.accounts.strikes')
         .report-header__details__item__content
-          = @report.target_account.strikes.count
+          = @report.target_account.previous_strikes_count
 
   .report-header__details
     .report-header__details__item
diff --git a/app/views/admin_mailer/new_appeal.text.erb b/app/views/admin_mailer/new_appeal.text.erb
new file mode 100644
index 000000000..db4529eb7
--- /dev/null
+++ b/app/views/admin_mailer/new_appeal.text.erb
@@ -0,0 +1,9 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_appeal.body', target: @appeal.account.username, action_taken_by: @appeal.strike.account.username, date: l(@appeal.strike.created_at), type: t(@appeal.strike.action, scope: 'admin_mailer.new_appeal.actions')) %>
+
+> <%= raw word_wrap(@appeal.text, break_sequence: "\n> ") %>
+
+<%= raw t('admin_mailer.new_appeal.next_steps') %>
+
+<%= raw t('application_mailer.view')%> <%= disputes_strike_url(@appeal.strike) %>
diff --git a/app/views/auth/registrations/_account_warning.html.haml b/app/views/auth/registrations/_account_warning.html.haml
new file mode 100644
index 000000000..40e7e1296
--- /dev/null
+++ b/app/views/auth/registrations/_account_warning.html.haml
@@ -0,0 +1,20 @@
+= link_to disputes_strike_path(account_warning), class: 'log-entry' do
+  .log-entry__header
+    .log-entry__avatar
+      .indicator-icon{ class: account_warning.overruled? ? 'success' : 'failure' }
+        = fa_icon 'warning'
+    .log-entry__content
+      .log-entry__title
+        = t('disputes.strikes.title', action: t(account_warning.action, scope: 'disputes.strikes.title_actions'), date: l(account_warning.created_at.to_date))
+      .log-entry__timestamp
+        %time.formatted{ datetime: account_warning.created_at.iso8601 }= l(account_warning.created_at)
+
+        - if account_warning.overruled?
+          ·
+          %span.positive-hint= t('disputes.strikes.your_appeal_approved')
+        - elsif account_warning.appeal&.pending?
+          ·
+          %span.warning-hint= t('disputes.strikes.your_appeal_pending')
+        - elsif account_warning.appeal&.rejected?
+          ·
+          %span.negative-hint= t('disputes.strikes.your_appeal_rejected')
diff --git a/app/views/auth/registrations/_status.html.haml b/app/views/auth/registrations/_status.html.haml
index 47112dae0..3546510b2 100644
--- a/app/views/auth/registrations/_status.html.haml
+++ b/app/views/auth/registrations/_status.html.haml
@@ -1,22 +1,17 @@
+- if !@user.confirmed?
+  .flash-message.warning
+    = t('auth.status.confirming')
+    = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
+- elsif !@user.approved?
+  .flash-message.warning
+    = t('auth.status.pending')
+- elsif @user.account.moved_to_account_id.present?
+  .flash-message.warning
+    = t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct)
+    = link_to t('migrations.cancel'), settings_migration_path
+
 %h3= t('auth.status.account_status')
 
-.simple_form
-  %p.hint
-    - if @user.account.suspended?
-      %span.negative-hint= t('user_mailer.warning.explanation.suspend')
-    - elsif @user.disabled?
-      %span.negative-hint= t('user_mailer.warning.explanation.disable')
-    - elsif @user.account.silenced?
-      %span.warning-hint= t('user_mailer.warning.explanation.silence')
-    - elsif !@user.confirmed?
-      %span.warning-hint= t('auth.status.confirming')
-      = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
-    - elsif !@user.approved?
-      %span.warning-hint= t('auth.status.pending')
-    - elsif @user.account.moved_to_account_id.present?
-      %span.positive-hint= t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct)
-      = link_to t('migrations.cancel'), settings_migration_path
-    - else
-      %span.positive-hint= t('auth.status.functional')
+= render partial: 'account_warning', collection: @strikes
 
 %hr.spacer/
diff --git a/app/views/disputes/strikes/show.html.haml b/app/views/disputes/strikes/show.html.haml
new file mode 100644
index 000000000..3dcb19016
--- /dev/null
+++ b/app/views/disputes/strikes/show.html.haml
@@ -0,0 +1,127 @@
+- content_for :page_title do
+  = t('disputes.strikes.title', action: t(@strike.action, scope: 'disputes.strikes.title_actions'), date: l(@strike.created_at.to_date))
+
+- content_for :heading_actions do
+  - if @appeal.persisted?
+    = link_to t('admin.accounts.approve'), approve_admin_disputes_appeal_path(@appeal), method: :post, class: 'button' if can?(:approve, @appeal)
+    = link_to t('admin.accounts.reject'), reject_admin_disputes_appeal_path(@appeal), method: :post, class: 'button button--destructive' if can?(:reject, @appeal)
+
+- if @strike.overruled?
+  %p.hint
+    %span.positive-hint
+      = fa_icon 'check'
+      = ' '
+      = t 'disputes.strikes.appeal_approved'
+- elsif @appeal.persisted? && @appeal.rejected?
+  %p.hint
+    %span.negative-hint
+      = fa_icon 'times'
+      = ' '
+      = t 'disputes.strikes.appeal_rejected'
+
+.report-header
+  .report-header__card
+    .strike-card
+      - unless @strike.none_action?
+        %p= t "user_mailer.warning.explanation.#{@strike.action}"
+
+      - unless @strike.text.blank?
+        = Formatter.instance.linkify(@strike.text)
+
+      - if @strike.report && !@strike.report.other?
+        %p
+          %strong= t('user_mailer.warning.reason')
+          = t("user_mailer.warning.categories.#{@strike.report.category}")
+
+        - if @strike.report.violation? && @strike.report.rule_ids.present?
+          %ul.rules-list
+            - @strike.report.rules.each do |rule|
+              %li= rule.text
+
+      - if @strike.status_ids.present? && !@strike.status_ids.empty?
+        %p
+          %strong= t('user_mailer.warning.statuses')
+
+        .strike-card__statuses-list
+          - status_map = @strike.statuses.includes(:application, :media_attachments).index_by(&:id)
+
+          - @strike.status_ids.each do |status_id|
+            .strike-card__statuses-list__item
+              - if (status = status_map[status_id.to_i])
+                .one-liner
+                  = link_to short_account_status_url(@strike.target_account, status_id), class: 'emojify' do
+                    = one_line_preview(status)
+
+                    - status.media_attachments.each do |media_attachment|
+                      %abbr{ title: media_attachment.description }
+                        = fa_icon 'link'
+                        = media_attachment.file_file_name
+                .strike-card__statuses-list__item__meta
+                  %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+                  ·
+                  = status.application.name
+              - else
+                .one-liner= t('disputes.strikes.status', id: status_id)
+                .strike-card__statuses-list__item__meta
+                  = t('disputes.strikes.status_removed')
+
+  .report-header__details
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('disputes.strikes.created_at')
+      .report-header__details__item__content
+        %time.formatted{ datetime: @strike.created_at.iso8601, title: l(@strike.created_at) }= l(@strike.created_at)
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('disputes.strikes.recipient')
+      .report-header__details__item__content
+        = admin_account_link_to @strike.target_account, path: can?(:show, @strike.target_account) ? admin_account_path(@strike.target_account_id) : ActivityPub::TagManager.instance.url_for(@strike.target_account)
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('disputes.strikes.action_taken')
+      .report-header__details__item__content
+        - if @strike.overruled?
+          %del= t(@strike.action, scope: 'user_mailer.warning.title')
+        - else
+          = t(@strike.action, scope: 'user_mailer.warning.title')
+    - if @strike.report && can?(:show, @strike.report)
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('disputes.strikes.associated_report')
+        .report-header__details__item__content
+          = link_to t('admin.reports.report', id: @strike.report.id), admin_report_path(@strike.report)
+    - if @appeal.persisted?
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('disputes.strikes.appeal_submitted_at')
+        .report-header__details__item__content
+          %time.formatted{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) }= l(@appeal.created_at)
+%hr.spacer/
+
+- if @appeal.persisted?
+  %h3= t('disputes.strikes.appeal')
+
+  .report-notes
+    .report-notes__item
+      = image_tag @appeal.account.avatar.url, class: 'report-notes__item__avatar'
+
+      .report-notes__item__header
+        %span.username
+          = link_to @appeal.account.username, can?(:show, @appeal.account) ? admin_account_path(@appeal.account_id) : short_account_url(@appeal.account)
+        %time{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) }
+          - if @appeal.created_at.today?
+            = t('admin.report_notes.today_at', time: l(@appeal.created_at, format: :time))
+          - else
+            = l @appeal.created_at.to_date
+
+      .report-notes__item__content
+        = simple_format(h(@appeal.text))
+- elsif can?(:appeal, @strike)
+  %h3= t('disputes.strikes.appeals.submit')
+
+  = simple_form_for(@appeal, url: disputes_strike_appeal_path(@strike)) do |f|
+    .fields-group
+      = f.input :text, wrapper: :with_label, input_html: { maxlength: 500 }
+
+    .actions
+      = f.button :button, t('disputes.strikes.appeals.submit'), type: :submit
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index d7cc1ed5d..223e5d740 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -21,6 +21,7 @@
 
       - if current_user.staff?
         = ff.input :report, as: :boolean, wrapper: :with_label
+        = ff.input :appeal, as: :boolean, wrapper: :with_label
         = ff.input :pending_account, as: :boolean, wrapper: :with_label
         = ff.input :trending_tag, as: :boolean, wrapper: :with_label
 
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index cd5ed52af..1922f53ce 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -49,7 +49,7 @@
     %span.detailed-status__visibility-icon
       = visibility_icon status
     ·
-    - if status.application && @account.user&.setting_show_application
+    - if status.application && status.account.user&.setting_show_application
       - if status.application.website.blank?
         %strong.detailed-status__application= status.application.name
       - else
diff --git a/app/views/user_mailer/appeal_approved.html.haml b/app/views/user_mailer/appeal_approved.html.haml
new file mode 100644
index 000000000..962cab2e2
--- /dev/null
+++ b/app/views/user_mailer/appeal_approved.html.haml
@@ -0,0 +1,59 @@
+%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{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_done.png'), alt: ''
+
+                              %h1= t 'user_mailer.appeal_approved.title'
+
+%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
+                              %p= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at)
+
+%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 root_url do
+                                    %span= t 'user_mailer.appeal_approved.action'
diff --git a/app/views/user_mailer/appeal_approved.text.erb b/app/views/user_mailer/appeal_approved.text.erb
new file mode 100644
index 000000000..290fa24c3
--- /dev/null
+++ b/app/views/user_mailer/appeal_approved.text.erb
@@ -0,0 +1,7 @@
+<%= t 'user_mailer.appeal_approved.title' %>
+
+===
+
+<%= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %>
+
+=> <%= root_url %>
diff --git a/app/views/user_mailer/appeal_rejected.html.haml b/app/views/user_mailer/appeal_rejected.html.haml
new file mode 100644
index 000000000..75cd9d023
--- /dev/null
+++ b/app/views/user_mailer/appeal_rejected.html.haml
@@ -0,0 +1,59 @@
+%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{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_warning.png'), alt: ''
+
+                              %h1= t 'user_mailer.appeal_rejected.title'
+
+%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
+                              %p= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at)
+
+%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 root_url do
+                                    %span= t 'user_mailer.appeal_approved.action'
diff --git a/app/views/user_mailer/appeal_rejected.text.erb b/app/views/user_mailer/appeal_rejected.text.erb
new file mode 100644
index 000000000..f47a76818
--- /dev/null
+++ b/app/views/user_mailer/appeal_rejected.text.erb
@@ -0,0 +1,7 @@
+<%= t 'user_mailer.appeal_rejected.title' %>
+
+===
+
+<%= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %>
+
+=> <%= root_url %>
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index bda1fef6c..b308e18f7 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -77,8 +77,8 @@
                             %tbody
                               %tr
                                 %td.button-primary
-                                  = link_to about_more_url do
-                                    %span= t 'user_mailer.warning.review_server_policies'
+                                  = link_to disputes_strike_url(@warning) do
+                                    %span= t 'user_mailer.warning.appeal'
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
@@ -95,4 +95,4 @@
                         %tbody
                           %tr
                             %td.column-cell.text-center
-                              %p= t 'user_mailer.warning.get_in_touch', instance: @instance
+                              %p= t 'user_mailer.warning.appeal_description', instance: @instance