diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index d4ddcbaab3..d707578cae 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -81,9 +81,6 @@ Rails/WhereExists:
     - 'app/lib/delivery_failure_tracker.rb'
     - 'app/lib/feed_manager.rb'
     - 'app/lib/suspicious_sign_in_detector.rb'
-    - 'app/models/poll.rb'
-    - 'app/models/session_activation.rb'
-    - 'app/models/status.rb'
     - 'app/policies/status_policy.rb'
     - 'app/serializers/rest/announcement_serializer.rb'
     - 'app/workers/move_worker.rb'
diff --git a/app/controllers/api/v1/annual_reports_controller.rb b/app/controllers/api/v1/annual_reports_controller.rb
new file mode 100644
index 0000000000..9bc8e68ac2
--- /dev/null
+++ b/app/controllers/api/v1/annual_reports_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Api::V1::AnnualReportsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
+  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
+  before_action :require_user!
+  before_action :set_annual_report, except: :index
+
+  def index
+    with_read_replica do
+      @presenter = AnnualReportsPresenter.new(GeneratedAnnualReport.where(account_id: current_account.id).pending)
+      @relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id)
+    end
+
+    render json: @presenter,
+           serializer: REST::AnnualReportsSerializer,
+           relationships: @relationships
+  end
+
+  def read
+    @annual_report.view!
+    render_empty
+  end
+
+  private
+
+  def set_annual_report
+    @annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id])
+  end
+end
diff --git a/app/lib/annual_report.rb b/app/lib/annual_report.rb
new file mode 100644
index 0000000000..cf4297f2a4
--- /dev/null
+++ b/app/lib/annual_report.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class AnnualReport
+  include DatabaseHelper
+
+  SOURCES = [
+    AnnualReport::Archetype,
+    AnnualReport::TypeDistribution,
+    AnnualReport::TopStatuses,
+    AnnualReport::MostUsedApps,
+    AnnualReport::CommonlyInteractedWithAccounts,
+    AnnualReport::TimeSeries,
+    AnnualReport::TopHashtags,
+    AnnualReport::MostRebloggedAccounts,
+    AnnualReport::Percentiles,
+  ].freeze
+
+  SCHEMA = 1
+
+  def initialize(account, year)
+    @account = account
+    @year = year
+  end
+
+  def generate
+    return if GeneratedAnnualReport.exists?(account: @account, year: @year)
+
+    GeneratedAnnualReport.create(
+      account: @account,
+      year: @year,
+      schema_version: SCHEMA,
+      data: data
+    )
+  end
+
+  private
+
+  def data
+    with_read_replica do
+      SOURCES.each_with_object({}) { |klass, hsh| hsh.merge!(klass.new(@account, @year).generate) }
+    end
+  end
+end
diff --git a/app/lib/annual_report/archetype.rb b/app/lib/annual_report/archetype.rb
new file mode 100644
index 0000000000..ea9ef366df
--- /dev/null
+++ b/app/lib/annual_report/archetype.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class AnnualReport::Archetype < AnnualReport::Source
+  # Average number of posts (including replies and reblogs) made by
+  # each active user in a single year (2023)
+  AVERAGE_PER_YEAR = 113
+
+  def generate
+    {
+      archetype: archetype,
+    }
+  end
+
+  private
+
+  def archetype
+    if (standalone_count + replies_count + reblogs_count) < AVERAGE_PER_YEAR
+      :lurker
+    elsif reblogs_count > (standalone_count * 2)
+      :booster
+    elsif polls_count > (standalone_count * 0.1) # standalone_count includes posts with polls
+      :pollster
+    elsif replies_count > (standalone_count * 2)
+      :replier
+    else
+      :oracle
+    end
+  end
+
+  def polls_count
+    @polls_count ||= base_scope.where.not(poll_id: nil).count
+  end
+
+  def reblogs_count
+    @reblogs_count ||= base_scope.where.not(reblog_of_id: nil).count
+  end
+
+  def replies_count
+    @replies_count ||= base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count
+  end
+
+  def standalone_count
+    @standalone_count ||= base_scope.without_replies.without_reblogs.count
+  end
+
+  def base_scope
+    @account.statuses.where(id: year_as_snowflake_range)
+  end
+end
diff --git a/app/lib/annual_report/commonly_interacted_with_accounts.rb b/app/lib/annual_report/commonly_interacted_with_accounts.rb
new file mode 100644
index 0000000000..af5e854c22
--- /dev/null
+++ b/app/lib/annual_report/commonly_interacted_with_accounts.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source
+  SET_SIZE = 40
+
+  def generate
+    {
+      commonly_interacted_with_accounts: commonly_interacted_with_accounts.map do |(account_id, count)|
+                                           {
+                                             account_id: account_id,
+                                             count: count,
+                                           }
+                                         end,
+    }
+  end
+
+  private
+
+  def commonly_interacted_with_accounts
+    @account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(in_reply_to_account_id: @account.id).group(:in_reply_to_account_id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('in_reply_to_account_id, count(*) AS total'))
+  end
+end
diff --git a/app/lib/annual_report/most_reblogged_accounts.rb b/app/lib/annual_report/most_reblogged_accounts.rb
new file mode 100644
index 0000000000..e3e8a7c90b
--- /dev/null
+++ b/app/lib/annual_report/most_reblogged_accounts.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AnnualReport::MostRebloggedAccounts < AnnualReport::Source
+  SET_SIZE = 10
+
+  def generate
+    {
+      most_reblogged_accounts: most_reblogged_accounts.map do |(account_id, count)|
+                                 {
+                                   account_id: account_id,
+                                   count: count,
+                                 }
+                               end,
+    }
+  end
+
+  private
+
+  def most_reblogged_accounts
+    @account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(reblog_of_id: nil).joins(reblog: :account).group('accounts.id').having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('accounts.id, count(*) as total'))
+  end
+end
diff --git a/app/lib/annual_report/most_used_apps.rb b/app/lib/annual_report/most_used_apps.rb
new file mode 100644
index 0000000000..85ff1ff86e
--- /dev/null
+++ b/app/lib/annual_report/most_used_apps.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AnnualReport::MostUsedApps < AnnualReport::Source
+  SET_SIZE = 10
+
+  def generate
+    {
+      most_used_apps: most_used_apps.map do |(name, count)|
+                        {
+                          name: name,
+                          count: count,
+                        }
+                      end,
+    }
+  end
+
+  private
+
+  def most_used_apps
+    @account.statuses.reorder(nil).where(id: year_as_snowflake_range).joins(:application).group('oauth_applications.name').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('oauth_applications.name, count(*) as total'))
+  end
+end
diff --git a/app/lib/annual_report/percentiles.rb b/app/lib/annual_report/percentiles.rb
new file mode 100644
index 0000000000..9fe4698ee5
--- /dev/null
+++ b/app/lib/annual_report/percentiles.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class AnnualReport::Percentiles < AnnualReport::Source
+  def generate
+    {
+      percentiles: {
+        followers: (total_with_fewer_followers / (total_with_any_followers + 1.0)) * 100,
+        statuses: (total_with_fewer_statuses / (total_with_any_statuses + 1.0)) * 100,
+      },
+    }
+  end
+
+  private
+
+  def followers_gained
+    @followers_gained ||= @account.passive_relationships.where("date_part('year', follows.created_at) = ?", @year).count
+  end
+
+  def statuses_created
+    @statuses_created ||= @account.statuses.where(id: year_as_snowflake_range).count
+  end
+
+  def total_with_fewer_followers
+    @total_with_fewer_followers ||= Follow.find_by_sql([<<~SQL.squish, { year: @year, comparison: followers_gained }]).first.total
+      WITH tmp0 AS (
+        SELECT follows.target_account_id
+        FROM follows
+        INNER JOIN accounts ON accounts.id = follows.target_account_id
+        WHERE date_part('year', follows.created_at) = :year
+          AND accounts.domain IS NULL
+        GROUP BY follows.target_account_id
+        HAVING COUNT(*) < :comparison
+      )
+      SELECT count(*) AS total
+      FROM tmp0
+    SQL
+  end
+
+  def total_with_fewer_statuses
+    @total_with_fewer_statuses ||= Status.find_by_sql([<<~SQL.squish, { comparison: statuses_created, min_id: year_as_snowflake_range.first, max_id: year_as_snowflake_range.last }]).first.total
+      WITH tmp0 AS (
+        SELECT statuses.account_id
+        FROM statuses
+        INNER JOIN accounts ON accounts.id = statuses.account_id
+        WHERE statuses.id BETWEEN :min_id AND :max_id
+          AND accounts.domain IS NULL
+        GROUP BY statuses.account_id
+        HAVING count(*) < :comparison
+      )
+      SELECT count(*) AS total
+      FROM tmp0
+    SQL
+  end
+
+  def total_with_any_followers
+    @total_with_any_followers ||= Follow.where("date_part('year', follows.created_at) = ?", @year).joins(:target_account).merge(Account.local).count('distinct follows.target_account_id')
+  end
+
+  def total_with_any_statuses
+    @total_with_any_statuses ||= Status.where(id: year_as_snowflake_range).joins(:account).merge(Account.local).count('distinct statuses.account_id')
+  end
+end
diff --git a/app/lib/annual_report/source.rb b/app/lib/annual_report/source.rb
new file mode 100644
index 0000000000..1ccb622676
--- /dev/null
+++ b/app/lib/annual_report/source.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class AnnualReport::Source
+  attr_reader :account, :year
+
+  def initialize(account, year)
+    @account = account
+    @year = year
+  end
+
+  protected
+
+  def year_as_snowflake_range
+    (Mastodon::Snowflake.id_at(DateTime.new(year, 1, 1))..Mastodon::Snowflake.id_at(DateTime.new(year, 12, 31)))
+  end
+end
diff --git a/app/lib/annual_report/time_series.rb b/app/lib/annual_report/time_series.rb
new file mode 100644
index 0000000000..a144bac0d1
--- /dev/null
+++ b/app/lib/annual_report/time_series.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class AnnualReport::TimeSeries < AnnualReport::Source
+  def generate
+    {
+      time_series: (1..12).map do |month|
+                     {
+                       month: month,
+                       statuses: statuses_per_month[month] || 0,
+                       following: following_per_month[month] || 0,
+                       followers: followers_per_month[month] || 0,
+                     }
+                   end,
+    }
+  end
+
+  private
+
+  def statuses_per_month
+    @statuses_per_month ||= @account.statuses.reorder(nil).where(id: year_as_snowflake_range).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
+  end
+
+  def following_per_month
+    @following_per_month ||= @account.active_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
+  end
+
+  def followers_per_month
+    @followers_per_month ||= @account.passive_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
+  end
+end
diff --git a/app/lib/annual_report/top_hashtags.rb b/app/lib/annual_report/top_hashtags.rb
new file mode 100644
index 0000000000..488dacb1b4
--- /dev/null
+++ b/app/lib/annual_report/top_hashtags.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AnnualReport::TopHashtags < AnnualReport::Source
+  SET_SIZE = 40
+
+  def generate
+    {
+      top_hashtags: top_hashtags.map do |(name, count)|
+                      {
+                        name: name,
+                        count: count,
+                      }
+                    end,
+    }
+  end
+
+  private
+
+  def top_hashtags
+    Tag.joins(:statuses).where(statuses: { id: @account.statuses.where(id: year_as_snowflake_range).reorder(nil).select(:id) }).group(:id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('COALESCE(tags.display_name, tags.name), count(*) AS total'))
+  end
+end
diff --git a/app/lib/annual_report/top_statuses.rb b/app/lib/annual_report/top_statuses.rb
new file mode 100644
index 0000000000..112e5591ce
--- /dev/null
+++ b/app/lib/annual_report/top_statuses.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class AnnualReport::TopStatuses < AnnualReport::Source
+  def generate
+    top_reblogs = base_scope.order(reblogs_count: :desc).first&.id
+    top_favourites = base_scope.where.not(id: top_reblogs).order(favourites_count: :desc).first&.id
+    top_replies = base_scope.where.not(id: [top_reblogs, top_favourites]).order(replies_count: :desc).first&.id
+
+    {
+      top_statuses: {
+        by_reblogs: top_reblogs,
+        by_favourites: top_favourites,
+        by_replies: top_replies,
+      },
+    }
+  end
+
+  def base_scope
+    @account.statuses.with_public_visibility.joins(:status_stat).where(id: year_as_snowflake_range).reorder(nil)
+  end
+end
diff --git a/app/lib/annual_report/type_distribution.rb b/app/lib/annual_report/type_distribution.rb
new file mode 100644
index 0000000000..fc12a6f1f4
--- /dev/null
+++ b/app/lib/annual_report/type_distribution.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class AnnualReport::TypeDistribution < AnnualReport::Source
+  def generate
+    {
+      type_distribution: {
+        total: base_scope.count,
+        reblogs: base_scope.where.not(reblog_of_id: nil).count,
+        replies: base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count,
+        standalone: base_scope.without_replies.without_reblogs.count,
+      },
+    }
+  end
+
+  private
+
+  def base_scope
+    @account.statuses.where(id: year_as_snowflake_range)
+  end
+end
diff --git a/app/lib/vacuum/media_attachments_vacuum.rb b/app/lib/vacuum/media_attachments_vacuum.rb
index ab7ea4092f..e558195290 100644
--- a/app/lib/vacuum/media_attachments_vacuum.rb
+++ b/app/lib/vacuum/media_attachments_vacuum.rb
@@ -27,11 +27,17 @@ class Vacuum::MediaAttachmentsVacuum
   end
 
   def media_attachments_past_retention_period
-    MediaAttachment.remote.cached.where(MediaAttachment.arel_table[:created_at].lt(@retention_period.ago)).where(MediaAttachment.arel_table[:updated_at].lt(@retention_period.ago))
+    MediaAttachment
+      .remote
+      .cached
+      .created_before(@retention_period.ago)
+      .updated_before(@retention_period.ago)
   end
 
   def orphaned_media_attachments
-    MediaAttachment.unattached.where(MediaAttachment.arel_table[:created_at].lt(TTL.ago))
+    MediaAttachment
+      .unattached
+      .created_before(TTL.ago)
   end
 
   def retention_period?
diff --git a/app/models/account_summary.rb b/app/models/account_summary.rb
index 0d8835b83c..2a21d09a8b 100644
--- a/app/models/account_summary.rb
+++ b/app/models/account_summary.rb
@@ -12,9 +12,11 @@
 class AccountSummary < ApplicationRecord
   self.primary_key = :account_id
 
+  has_many :follow_recommendation_suppressions, primary_key: :account_id, foreign_key: :account_id, inverse_of: false
+
   scope :safe, -> { where(sensitive: false) }
   scope :localized, ->(locale) { where(language: locale) }
-  scope :filtered, -> { joins(arel_table.join(FollowRecommendationSuppression.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:account_id].eq(FollowRecommendationSuppression.arel_table[:account_id])).join_sources).where(FollowRecommendationSuppression.arel_table[:id].eq(nil)) }
+  scope :filtered, -> { where.missing(:follow_recommendation_suppressions) }
 
   def self.refresh
     Scenic.database.refresh_materialized_view(table_name, concurrently: false, cascade: false)
diff --git a/app/models/generated_annual_report.rb b/app/models/generated_annual_report.rb
new file mode 100644
index 0000000000..43c97d7108
--- /dev/null
+++ b/app/models/generated_annual_report.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: generated_annual_reports
+#
+#  id             :bigint(8)        not null, primary key
+#  account_id     :bigint(8)        not null
+#  year           :integer          not null
+#  data           :jsonb            not null
+#  schema_version :integer          not null
+#  viewed_at      :datetime
+#  created_at     :datetime         not null
+#  updated_at     :datetime         not null
+#
+
+class GeneratedAnnualReport < ApplicationRecord
+  belongs_to :account
+
+  scope :pending, -> { where(viewed_at: nil) }
+
+  def viewed?
+    viewed_at.present?
+  end
+
+  def view!
+    update!(viewed_at: Time.now.utc)
+  end
+
+  def account_ids
+    data['most_reblogged_accounts'].pluck('account_id') + data['commonly_interacted_with_accounts'].pluck('account_id')
+  end
+
+  def status_ids
+    data['top_statuses'].values
+  end
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 5af4ec46ed..90eda3dc8d 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -204,12 +204,14 @@ class MediaAttachment < ApplicationRecord
   validates :file, presence: true, if: :local?
   validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? }
 
-  scope :attached,   -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
-  scope :cached,     -> { remote.where.not(file_file_name: nil) }
-  scope :local,      -> { where(remote_url: '') }
-  scope :ordered,    -> { order(id: :asc) }
-  scope :remote,     -> { where.not(remote_url: '') }
+  scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
+  scope :cached, -> { remote.where.not(file_file_name: nil) }
+  scope :created_before, ->(value) { where(arel_table[:created_at].lt(value)) }
+  scope :local, -> { where(remote_url: '') }
+  scope :ordered, -> { order(id: :asc) }
+  scope :remote, -> { where.not(remote_url: '') }
   scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
+  scope :updated_before, ->(value) { where(arel_table[:updated_at].lt(value)) }
 
   attr_accessor :skip_download
 
diff --git a/app/models/poll.rb b/app/models/poll.rb
index 37149c3d86..cc4184f80a 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -57,7 +57,7 @@ class Poll < ApplicationRecord
   end
 
   def voted?(account)
-    account.id == account_id || votes.where(account: account).exists?
+    account.id == account_id || votes.exists?(account: account)
   end
 
   def own_votes(account)
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 7f5f0d9a9a..c67180d3ba 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -41,7 +41,7 @@ class SessionActivation < ApplicationRecord
 
   class << self
     def active?(id)
-      id && where(session_id: id).exists?
+      id && exists?(session_id: id)
     end
 
     def activate(**options)
diff --git a/app/models/status.rb b/app/models/status.rb
index 7fc3f8c5df..18c9176b3a 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -270,7 +270,7 @@ class Status < ApplicationRecord
   end
 
   def reported?
-    @reported ||= Report.where(target_account: account).unresolved.where('? = ANY(status_ids)', id).exists?
+    @reported ||= Report.where(target_account: account).unresolved.exists?(['? = ANY(status_ids)', id])
   end
 
   def emojis
diff --git a/app/presenters/annual_reports_presenter.rb b/app/presenters/annual_reports_presenter.rb
new file mode 100644
index 0000000000..001e1d37b0
--- /dev/null
+++ b/app/presenters/annual_reports_presenter.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class AnnualReportsPresenter
+  alias read_attribute_for_serialization send
+
+  attr_reader :annual_reports
+
+  def initialize(annual_reports)
+    @annual_reports = annual_reports
+  end
+
+  def accounts
+    @accounts ||= Account.where(id: @annual_reports.flat_map(&:account_ids)).includes(:account_stat, :moved_to_account, user: :role)
+  end
+
+  def statuses
+    @statuses ||= Status.where(id: @annual_reports.flat_map(&:status_ids)).with_includes
+  end
+
+  def self.model_name
+    @model_name ||= ActiveModel::Name.new(self)
+  end
+end
diff --git a/app/serializers/rest/annual_report_serializer.rb b/app/serializers/rest/annual_report_serializer.rb
new file mode 100644
index 0000000000..1fb5ddb5c1
--- /dev/null
+++ b/app/serializers/rest/annual_report_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::AnnualReportSerializer < ActiveModel::Serializer
+  attributes :year, :data, :schema_version
+end
diff --git a/app/serializers/rest/annual_reports_serializer.rb b/app/serializers/rest/annual_reports_serializer.rb
new file mode 100644
index 0000000000..ea9572be1b
--- /dev/null
+++ b/app/serializers/rest/annual_reports_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class REST::AnnualReportsSerializer < ActiveModel::Serializer
+  has_many :annual_reports, serializer: REST::AnnualReportSerializer
+  has_many :accounts, serializer: REST::AccountSerializer
+  has_many :statuses, serializer: REST::StatusSerializer
+end
diff --git a/app/workers/generate_annual_report_worker.rb b/app/workers/generate_annual_report_worker.rb
new file mode 100644
index 0000000000..7094c1ab9c
--- /dev/null
+++ b/app/workers/generate_annual_report_worker.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class GenerateAnnualReportWorker
+  include Sidekiq::Worker
+
+  def perform(account_id, year)
+    AnnualReport.new(Account.find(account_id), year).generate
+  rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordNotUnique
+    true
+  end
+end
diff --git a/app/workers/scheduler/indexing_scheduler.rb b/app/workers/scheduler/indexing_scheduler.rb
index 5c985e25a0..f52d0141d4 100644
--- a/app/workers/scheduler/indexing_scheduler.rb
+++ b/app/workers/scheduler/indexing_scheduler.rb
@@ -24,6 +24,8 @@ class Scheduler::IndexingScheduler
     end
   end
 
+  private
+
   def indexes
     [AccountsIndex, TagsIndex, PublicStatusesIndex, StatusesIndex]
   end
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index 3c8c643fe7..e6d653c674 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -1934,6 +1934,7 @@ ar:
     go_to_sso_account_settings: انتقل إلى إعدادات حساب مزود الهوية الخاص بك
     invalid_otp_token: رمز المصادقة بخطوتين غير صالح
     otp_lost_help_html: إن فقدتَهُما ، يمكنك الاتصال بـ %{email}
+    rate_limited: عدد محاولات التحقق كثير جدًا، يرجى المحاولة مرة أخرى لاحقًا.
     seamless_external_login: لقد قمت بتسجيل الدخول عبر خدمة خارجية، إنّ إعدادات الكلمة السرية و البريد الإلكتروني غير متوفرة.
     signed_in_as: 'تم تسجيل دخولك بصفة:'
   verification:
diff --git a/config/locales/bg.yml b/config/locales/bg.yml
index c3eaa7e4c2..b9a3135448 100644
--- a/config/locales/bg.yml
+++ b/config/locales/bg.yml
@@ -1793,6 +1793,7 @@ bg:
     failed_2fa:
       details: 'Ето подробности на опита за влизане:'
       explanation: Някой се опита да влезе в акаунта ви, но предостави невалиден втори фактор за удостоверяване.
+      further_actions_html: Ако не бяхте вие, то препоръчваме да направите %{action} незабавно, тъй като може да се злепостави.
       subject: Неуспешен втори фактор за удостоверяване
       title: Провал на втория фактор за удостоверяване
     suspicious_sign_in:
diff --git a/config/locales/da.yml b/config/locales/da.yml
index 58fd723aef..d92d001905 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -1790,6 +1790,12 @@ da:
       extra: Sikkerhedskopien kan nu downloades!
       subject: Dit arkiv er klar til download
       title: Arkiv download
+    failed_2fa:
+      details: 'Her er detaljerne om login-forsøget:'
+      explanation: Nogen har forsøgt at logge ind på kontoen, men har angivet en ugyldig anden godkendelsesfaktor.
+      further_actions_html: Var dette ikke dig, anbefales det straks at %{action}, da den kan være kompromitteret.
+      subject: Anden faktor godkendelsesfejl
+      title: Fejlede på anden faktor godkendelse
     suspicious_sign_in:
       change_password: ændrer din adgangskode
       details: 'Her er nogle detaljer om login-forsøget:'
diff --git a/config/locales/devise.ru.yml b/config/locales/devise.ru.yml
index ccbd13438d..9dd418f2cd 100644
--- a/config/locales/devise.ru.yml
+++ b/config/locales/devise.ru.yml
@@ -47,14 +47,19 @@ ru:
         subject: 'Mastodon: Инструкция по сбросу пароля'
         title: Сброс пароля
       two_factor_disabled:
+        explanation: Вход в систему теперь возможен только с использованием адреса электронной почты и пароля.
         subject: 'Mastodon: Двухфакторная авторизация отключена'
+        subtitle: Двухфакторная аутентификация для вашей учетной записи была отключена.
         title: 2ФА отключена
       two_factor_enabled:
+        explanation: Для входа в систему потребуется токен, сгенерированный сопряженным приложением TOTP.
         subject: 'Mastodon: Настроена двухфакторная авторизация'
+        subtitle: Для вашей учетной записи была включена двухфакторная аутентификация.
         title: 2ФА включена
       two_factor_recovery_codes_changed:
         explanation: Предыдущие резервные коды были аннулированы и созданы новые.
         subject: 'Mastodon: Резервные коды двуфакторной авторизации обновлены'
+        subtitle: Предыдущие коды восстановления были аннулированы и сгенерированы новые.
         title: Коды восстановления 2FA изменены
       unlock_instructions:
         subject: 'Mastodon: Инструкция по разблокировке'
@@ -68,9 +73,13 @@ ru:
           subject: 'Мастодон: Ключ Безопасности удален'
           title: Один из ваших защитных ключей был удален
       webauthn_disabled:
+        explanation: Аутентификация с помощью ключей безопасности была отключена для вашей учетной записи.
+        extra: Теперь вход в систему возможен только с использованием токена, сгенерированного сопряженным приложением TOTP.
         subject: 'Мастодон: Аутентификация с ключами безопасности отключена'
         title: Ключи безопасности отключены
       webauthn_enabled:
+        explanation: Для вашей учетной записи включена аутентификация по ключу безопасности.
+        extra: Теперь ваш ключ безопасности можно использовать для входа в систему.
         subject: 'Мастодон: Включена аутентификация по ключу безопасности'
         title: Ключи безопасности включены
     omniauth_callbacks:
diff --git a/config/locales/devise.sq.yml b/config/locales/devise.sq.yml
index 7cea2f8e2e..32136a0baa 100644
--- a/config/locales/devise.sq.yml
+++ b/config/locales/devise.sq.yml
@@ -47,14 +47,19 @@ sq:
         subject: 'Mastodon: Udhëzime ricaktimi fjalëkalimi'
         title: Ricaktim fjalëkalimi
       two_factor_disabled:
+        explanation: Hyrja tanimë është e mundshme duke përdorur vetëm adresë email dhe fjalëkalim.
         subject: 'Mastodon: U çaktivizua mirëfilltësimi dyfaktorësh'
+        subtitle: Mirëfilltësimi dyfaktorësh për llogarinë tuaj është çaktivizuar.
         title: 2FA u çaktivizua
       two_factor_enabled:
+        explanation: Për të kryer hyrjen do të kërkohet doemos një token i prodhuar nga aplikacioni TOTP i çiftuar.
         subject: 'Mastodon: U aktivizua mirëfilltësimi dyfaktorësh'
+        subtitle: Për llogarinë tuaj është aktivizuar mirëfilltësmi dyfaktorësh.
         title: 2FA u aktivizua
       two_factor_recovery_codes_changed:
         explanation: Kodet e dikurshëm të rikthimit janë bërë të pavlefshëm dhe janë prodhuar të rinj.
         subject: 'Mastodon: U riprodhuan kode rikthimi dyfaktorësh'
+        subtitle: Kodet e dikurshëm të rikthimit janë bërë të pavlefshëm dhe janë prodhuar të rinj.
         title: Kodet e rikthimit 2FA u ndryshuan
       unlock_instructions:
         subject: 'Mastodon: Udhëzime shkyçjeje'
@@ -68,9 +73,13 @@ sq:
           subject: 'Mastodon: Fshirje kyçi sigurie'
           title: Një nga kyçet tuaj të sigurisë është fshirë
       webauthn_disabled:
+        explanation: Mirëfilltësimi me kyçe sigurie është çaktivizuar për llogarinë tuaj.
+        extra: Hyrjet tani janë të mundshme vetëm duke përdorur token-in e prodhuar nga aplikacioni TOTP i çiftuar.
         subject: 'Mastodon: U çaktivizua mirëfilltësimi me kyçe sigurie'
         title: U çaktivizuan kyçe sigurie
       webauthn_enabled:
+        explanation: Mirëfilltësimi me kyçe sigurie është aktivizuar për këtë llogari.
+        extra: Kyçi juaj i sigurisë tanimë mund të përdoret për hyrje.
         subject: 'Mastodon: U aktivizua mirëfilltësim me kyçe sigurie'
         title: U aktivizuan kyçe sigurie
     omniauth_callbacks:
diff --git a/config/locales/es-MX.yml b/config/locales/es-MX.yml
index 040d8a9d3c..b84fb7cf96 100644
--- a/config/locales/es-MX.yml
+++ b/config/locales/es-MX.yml
@@ -1792,6 +1792,10 @@ es-MX:
       title: Descargar archivo
     failed_2fa:
       details: 'Estos son los detalles del intento de inicio de sesión:'
+      explanation: Alguien ha intentado iniciar sesión en tu cuenta pero proporcionó un segundo factor de autenticación inválido.
+      further_actions_html: Si no fuiste tú, se recomienda %{action} inmediatamente ya que puede estar comprometido.
+      subject: Fallo de autenticación de segundo factor
+      title: Falló la autenticación de segundo factor
     suspicious_sign_in:
       change_password: cambies tu contraseña
       details: 'Aquí están los detalles del inicio de sesión:'
diff --git a/config/locales/es.yml b/config/locales/es.yml
index ffe3eb5b00..95816d6bcb 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -1792,6 +1792,10 @@ es:
       title: Descargar archivo
     failed_2fa:
       details: 'Estos son los detalles del intento de inicio de sesión:'
+      explanation: Alguien ha intentado iniciar sesión en tu cuenta pero proporcionó un segundo factor de autenticación inválido.
+      further_actions_html: Si no fuiste tú, se recomienda %{action} inmediatamente ya que puede estar comprometida.
+      subject: Fallo de autenticación del segundo factor
+      title: Fallo en la autenticación del segundo factor
     suspicious_sign_in:
       change_password: cambies tu contraseña
       details: 'Aquí están los detalles del inicio de sesión:'
diff --git a/config/locales/fy.yml b/config/locales/fy.yml
index f861bc3e4a..c59ad72725 100644
--- a/config/locales/fy.yml
+++ b/config/locales/fy.yml
@@ -1790,6 +1790,12 @@ fy:
       extra: It stiet no klear om download te wurden!
       subject: Jo argyf stiet klear om download te wurden
       title: Argyf ophelje
+    failed_2fa:
+      details: 'Hjir binne de details fan de oanmeldbesykjen:'
+      explanation: Ien hat probearre om oan te melden op jo account, mar hat in ûnjildige twaddeferifikaasjefaktor opjûn.
+      further_actions_html: As jo dit net wiene, rekommandearje wy jo oan daliks %{action}, omdat it kompromitearre wêze kin.
+      subject: Twaddefaktorautentikaasjeflater
+      title: Twastapsferifikaasje mislearre
     suspicious_sign_in:
       change_password: wizigje jo wachtwurd
       details: 'Hjir binne de details fan oanmeldbesykjen:'
diff --git a/config/locales/gl.yml b/config/locales/gl.yml
index 3c43a4e23d..087ed2ec76 100644
--- a/config/locales/gl.yml
+++ b/config/locales/gl.yml
@@ -1790,6 +1790,12 @@ gl:
       extra: Está preparada para descargala!
       subject: O teu ficheiro xa está preparado para descargar
       title: Leve o ficheiro
+    failed_2fa:
+      details: 'Detalles do intento de acceso:'
+      explanation: Alguén intentou acceder á túa conta mais fíxoo cun segundo factor de autenticación non válido.
+      further_actions_html: Se non foches ti, recomendámosche %{action} inmediatamente xa que a conta podería estar en risco.
+      subject: Fallo co segundo factor de autenticación
+      title: Fallou o segundo factor de autenticación
     suspicious_sign_in:
       change_password: cambia o teu contrasinal
       details: 'Estos son os detalles do acceso:'
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 2644275c37..24edbdc75e 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -439,6 +439,7 @@ ru:
       view: Посмотреть доменные блокировки
     email_domain_blocks:
       add_new: Добавить новую
+      allow_registrations_with_approval: Разрешить регистрацию с одобрением
       attempts_over_week:
         few: "%{count} попытки за последнюю неделю"
         many: "%{count} попыток за последнюю неделю"
@@ -1659,6 +1660,7 @@ ru:
       unknown_browser: Неизвестный браузер
       weibo: Weibo
     current_session: Текущая сессия
+    date: Дата
     description: "%{browser} на %{platform}"
     explanation: Здесь отображаются все браузеры, с которых выполнен вход в вашу учётную запись. Авторизованные приложения находятся в секции «Приложения».
     ip: IP
@@ -1837,16 +1839,27 @@ ru:
     webauthn: Ключи безопасности
   user_mailer:
     appeal_approved:
+      action: Настройки аккаунта
       explanation: Апелляция на разблокировку против вашей учетной записи %{strike_date}, которую вы подали на %{appeal_date}, была одобрена. Ваша учетная запись снова на хорошем счету.
       subject: Ваше обжалование от %{date} была одобрено
+      subtitle: Ваш аккаунт снова с хорошей репутацией.
       title: Обжалование одобрено
     appeal_rejected:
       explanation: Апелляция на разблокировку против вашей учетной записи %{strike_date}, которую вы подали на %{appeal_date}, была одобрена. Ваша учетная запись восстановлена.
       subject: Ваше обжалование от %{date} отклонено
+      subtitle: Ваша апелляция отклонена.
       title: Обжалование отклонено
     backup_ready:
+      explanation: Вы запросили полное резервное копирование вашей учетной записи Mastodon.
+      extra: Теперь он готов к загрузке!
       subject: Ваш архив готов к загрузке
       title: Архив ваших данных готов
+    failed_2fa:
+      details: 'Вот подробности попытки регистрации:'
+      explanation: Кто-то пытался войти в вашу учетную запись, но указал неверный второй фактор аутентификации.
+      further_actions_html: Если это не вы, мы рекомендуем %{action} немедленно принять меры, так как он может быть скомпрометирован.
+      subject: Сбой двухфакторной аутентификации
+      title: Сбой двухфакторной аутентификации
     suspicious_sign_in:
       change_password: сменить пароль
       details: 'Подробности о новом входе:'
@@ -1900,6 +1913,7 @@ ru:
     go_to_sso_account_settings: Перейти к настройкам сторонних аккаунтов учетной записи
     invalid_otp_token: Введен неверный код двухфакторной аутентификации
     otp_lost_help_html: Если Вы потеряли доступ к обоим, свяжитесь с %{email}
+    rate_limited: Слишком много попыток аутентификации, повторите попытку позже.
     seamless_external_login: Вы залогинены через сторонний сервис, поэтому настройки e-mail и пароля недоступны.
     signed_in_as: 'Выполнен вход под именем:'
   verification:
diff --git a/config/locales/simple_form.sk.yml b/config/locales/simple_form.sk.yml
index e13a05835f..614812a3a9 100644
--- a/config/locales/simple_form.sk.yml
+++ b/config/locales/simple_form.sk.yml
@@ -60,6 +60,7 @@ sk:
         fields:
           name: Označenie
           value: Obsah
+        unlocked: Automaticky prijímaj nových nasledovateľov
       account_alias:
         acct: Adresa starého účtu
       account_migration:
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index c639bbe1a6..e83ae348f6 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -430,6 +430,7 @@ sk:
       dashboard:
         instance_accounts_dimension: Najsledovanejšie účty
         instance_accounts_measure: uložené účty
+        instance_followers_measure: naši nasledovatelia tam
         instance_follows_measure: ich sledovatelia tu
         instance_languages_dimension: Najpopulárnejšie jazyky
         instance_media_attachments_measure: uložené mediálne prílohy
@@ -1257,6 +1258,8 @@ sk:
       extra: Teraz je pripravená na stiahnutie!
       subject: Tvoj archív je pripravený na stiahnutie
       title: Odber archívu
+    failed_2fa:
+      details: 'Tu sú podrobnosti o pokuse o prihlásenie:'
     warning:
       subject:
         disable: Tvoj účet %{acct} bol zamrazený
diff --git a/config/locales/sq.yml b/config/locales/sq.yml
index 1693db7f31..d6e6925c70 100644
--- a/config/locales/sq.yml
+++ b/config/locales/sq.yml
@@ -1604,6 +1604,7 @@ sq:
       unknown_browser: Shfletues i Panjohur
       weibo: Weibo
     current_session: Sesioni i tanishëm
+    date: Datë
     description: "%{browser} në %{platform}"
     explanation: Këta janë shfletuesit e përdorur tani për hyrje te llogaria juaj Mastodon.
     ip: IP
@@ -1770,16 +1771,27 @@ sq:
     webauthn: Kyçe sigurie
   user_mailer:
     appeal_approved:
+      action: Rregullime Llogarie
       explanation: Apelimi i paralajmërimit kundër llogarisë tuaj më %{strike_date}, të cilin e parashtruar më %{appeal_date} është miratuar. Llogaria juaj është sërish në pozita të mira.
       subject: Apelimi juaj i datës %{date} u miratua
+      subtitle: Llogaria juaj edhe një herë është e shëndetshme.
       title: Apelimi u miratua
     appeal_rejected:
       explanation: Apelimi i paralajmërimit kundër llogarisë tuaj më %{strike_date}, të cilin e parashtruar më %{appeal_date}, u hodh poshtë.
       subject: Apelimi juaj prej %{date} është hedhur poshtë
+      subtitle: Apelimi juaj është hedhur poshtë.
       title: Apelimi u hodh poshtë
     backup_ready:
+      explanation: Kërkuat një kopjeruajtje të plotë të llogarisë tuaj Mastodon.
+      extra: Tani është gati për shkarkim!
       subject: Arkivi juaj është gati për shkarkim
       title: Marrje arkivi me vete
+    failed_2fa:
+      details: 'Ja hollësitë e përpjekjes për hyrje:'
+      explanation: Dikush ka provuar të hyjë në llogarinë tuaj, por dha faktor të dytë mirëfilltësimi.
+      further_actions_html: Nëse s’qetë ju, rekomandojmë të %{action} menjëherë, ngaqë mund të jetë komprometua.
+      subject: Dështim faktori të dytë mirëfilltësimesh
+      title: Dështoi mirëfilltësimi me faktor të dytë
     suspicious_sign_in:
       change_password: ndryshoni fjalëkalimin tuaj
       details: 'Ja hollësitë për hyrjen:'
@@ -1833,6 +1845,7 @@ sq:
     go_to_sso_account_settings: Kaloni te rregullime llogarie te shërbimi juaj i identitetit
     invalid_otp_token: Kod dyfaktorësh i pavlefshëm
     otp_lost_help_html: Nëse humbët hyrjen te të dy, mund të lidheni me %{email}
+    rate_limited: Shumë përpjekje mirëfilltësimi, riprovoni më vonë.
     seamless_external_login: Jeni futur përmes një shërbimi të jashtëm, ndaj s’ka rregullime fjalëkalimi dhe email.
     signed_in_as: 'I futur si:'
   verification:
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index fa84d2a96d..2b5b5ad45b 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -1791,7 +1791,7 @@ tr:
       subject: Arşiviniz indirilmeye hazır
       title: Arşiv paketlemesi
     failed_2fa:
-      details: 'Oturum açma denemesinin ayrıntıları şöyledir:'
+      details: 'İşte oturum açma girişiminin ayrıntıları:'
       explanation: Birisi hesabınızda oturum açmaya çalıştı ancak hatalı bir iki aşamalı doğrulama kodu kullandı.
       further_actions_html: Eğer bu kişi siz değilseniz, hemen %{action} yapmanızı öneriyoruz çünkü hesabınız ifşa olmuş olabilir.
       subject: İki aşamalı doğrulama başarısızlığı
diff --git a/config/locales/vi.yml b/config/locales/vi.yml
index 3817b18f07..1ece72e154 100644
--- a/config/locales/vi.yml
+++ b/config/locales/vi.yml
@@ -1758,6 +1758,12 @@ vi:
       extra: Hiện nó đã sẵn sàng tải xuống!
       subject: Dữ liệu cá nhân của bạn đã sẵn sàng để tải về
       title: Nhận dữ liệu cá nhân
+    failed_2fa:
+      details: 'Chi tiết thông tin đăng nhập:'
+      explanation: Ai đó đã cố đăng nhập vào tài khoản của bạn nhưng cung cấp yếu tố xác thực thứ hai không hợp lệ.
+      further_actions_html: Nếu không phải bạn, hãy lập tức %{action} vì có thể có rủi ro.
+      subject: Xác minh hai bước thất bại
+      title: Xác minh hai bước thất bại
     suspicious_sign_in:
       change_password: đổi mật khẩu của bạn
       details: 'Chi tiết thông tin đăng nhập:'
diff --git a/config/routes/api.rb b/config/routes/api.rb
index f4e4b204ad..2e79ecf46f 100644
--- a/config/routes/api.rb
+++ b/config/routes/api.rb
@@ -52,6 +52,12 @@ namespace :api, format: false do
     resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
     resources :preferences, only: [:index]
 
+    resources :annual_reports, only: [:index] do
+      member do
+        post :read
+      end
+    end
+
     resources :announcements, only: [:index] do
       scope module: :announcements do
         resources :reactions, only: [:update, :destroy]
diff --git a/db/migrate/20240111033014_create_generated_annual_reports.rb b/db/migrate/20240111033014_create_generated_annual_reports.rb
new file mode 100644
index 0000000000..2a755fb14e
--- /dev/null
+++ b/db/migrate/20240111033014_create_generated_annual_reports.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class CreateGeneratedAnnualReports < ActiveRecord::Migration[7.1]
+  def change
+    create_table :generated_annual_reports do |t|
+      t.belongs_to :account, null: false, foreign_key: { on_cascade: :delete }, index: false
+      t.integer :year, null: false
+      t.jsonb :data, null: false
+      t.integer :schema_version, null: false
+      t.datetime :viewed_at
+
+      t.timestamps
+    end
+
+    add_index :generated_annual_reports, [:account_id, :year], unique: true
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 18d51c8bf1..e4642b4296 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[7.1].define(version: 2024_01_09_103012) do
+ActiveRecord::Schema[7.1].define(version: 2024_01_11_033014) do
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
 
@@ -516,6 +516,17 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_09_103012) do
     t.index ["target_account_id"], name: "index_follows_on_target_account_id"
   end
 
+  create_table "generated_annual_reports", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.integer "year", null: false
+    t.jsonb "data", null: false
+    t.integer "schema_version", null: false
+    t.datetime "viewed_at"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["account_id", "year"], name: "index_generated_annual_reports_on_account_id_and_year", unique: true
+  end
+
   create_table "identities", force: :cascade do |t|
     t.string "provider", default: "", null: false
     t.string "uid", default: "", null: false
@@ -1229,6 +1240,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_09_103012) do
   add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
   add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
   add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
+  add_foreign_key "generated_annual_reports", "accounts"
   add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade
   add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
   add_foreign_key "invites", "users", on_delete: :cascade
diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb
index 7acf3f9b77..48d76e0288 100644
--- a/lib/mastodon/cli/statuses.rb
+++ b/lib/mastodon/cli/statuses.rb
@@ -120,7 +120,7 @@ module Mastodon::CLI
 
       say('Beginning removal of now-orphaned media attachments to free up disk space...')
 
-      scope     = MediaAttachment.unattached.where('created_at < ?', options[:days].pred.days.ago)
+      scope     = MediaAttachment.unattached.created_before(options[:days].pred.days.ago)
       processed = 0
       removed   = 0
       progress  = create_progress_bar(scope.count)
diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb
index db1e8777f7..f8e014be2f 100644
--- a/spec/controllers/api/base_controller_spec.rb
+++ b/spec/controllers/api/base_controller_spec.rb
@@ -12,7 +12,7 @@ describe Api::BaseController do
       head 200
     end
 
-    def error
+    def failure
       FakeService.new
     end
   end
@@ -30,7 +30,7 @@ describe Api::BaseController do
 
     it 'does not protect from forgery' do
       ActionController::Base.allow_forgery_protection = true
-      post 'success'
+      post :success
       expect(response).to have_http_status(200)
     end
   end
@@ -50,47 +50,55 @@ describe Api::BaseController do
 
     it 'returns http forbidden for unconfirmed accounts' do
       user.update(confirmed_at: nil)
-      post 'success'
+      post :success
       expect(response).to have_http_status(403)
     end
 
     it 'returns http forbidden for pending accounts' do
       user.update(approved: false)
-      post 'success'
+      post :success
       expect(response).to have_http_status(403)
     end
 
     it 'returns http forbidden for disabled accounts' do
       user.update(disabled: true)
-      post 'success'
+      post :success
       expect(response).to have_http_status(403)
     end
 
     it 'returns http forbidden for suspended accounts' do
       user.account.suspend!
-      post 'success'
+      post :success
       expect(response).to have_http_status(403)
     end
   end
 
   describe 'error handling' do
     before do
-      routes.draw { get 'error' => 'api/base#error' }
+      routes.draw { get 'failure' => 'api/base#failure' }
     end
 
     {
       ActiveRecord::RecordInvalid => 422,
-      Mastodon::ValidationError => 422,
       ActiveRecord::RecordNotFound => 404,
-      Mastodon::UnexpectedResponseError => 503,
+      ActiveRecord::RecordNotUnique => 422,
+      Date::Error => 422,
       HTTP::Error => 503,
-      OpenSSL::SSL::SSLError => 503,
+      Mastodon::InvalidParameterError => 400,
       Mastodon::NotPermittedError => 403,
+      Mastodon::RaceConditionError => 503,
+      Mastodon::RateLimitExceededError => 429,
+      Mastodon::UnexpectedResponseError => 503,
+      Mastodon::ValidationError => 422,
+      OpenSSL::SSL::SSLError => 503,
+      Seahorse::Client::NetworkingError => 503,
+      Stoplight::Error::RedLight => 503,
     }.each do |error, code|
       it "Handles error class of #{error}" do
         allow(FakeService).to receive(:new).and_raise(error)
 
-        get 'error'
+        get :failure
+
         expect(response).to have_http_status(code)
         expect(FakeService).to have_received(:new)
       end