diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 8af4242ba..ddb94d5ca 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -90,7 +90,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def account_params
-    params.permit(:username, :email, :password, :agreement, :locale, :reason)
+    params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone)
   end
 
   def check_enabled_registrations
diff --git a/app/controllers/settings/preferences/base_controller.rb b/app/controllers/settings/preferences/base_controller.rb
index faf778a7e..c1f8b4989 100644
--- a/app/controllers/settings/preferences/base_controller.rb
+++ b/app/controllers/settings/preferences/base_controller.rb
@@ -19,6 +19,6 @@ class Settings::Preferences::BaseController < Settings::BaseController
   end
 
   def user_params
-    params.require(:user).permit(:locale, chosen_languages: [], settings_attributes: UserSettings.keys)
+    params.require(:user).permit(:locale, :time_zone, chosen_languages: [], settings_attributes: UserSettings.keys)
   end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 1d24a7ec8..8a606fd2a 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -140,6 +140,7 @@ class Account < ApplicationRecord
            :locale,
            :shows_application?,
            :prefers_noindex?,
+           :time_zone,
            to: :user,
            prefix: true,
            allow_nil: true
diff --git a/app/models/user.rb b/app/models/user.rb
index b903344be..5ee14bbda 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -40,6 +40,7 @@
 #  sign_up_ip                :inet
 #  role_id                   :bigint(8)
 #  settings                  :text
+#  time_zone                 :string
 #
 
 class User < ApplicationRecord
@@ -99,6 +100,7 @@ class User < ApplicationRecord
   validates_with BlacklistedEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
   validates_with EmailMxValidator, if: :validate_email_dns?
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
+  validates :time_zone, inclusion: { in: ActiveSupport::TimeZone.all.map { |tz| tz.tzinfo.name } }, allow_blank: true
 
   # Honeypot/anti-spam fields
   attr_accessor :registration_form_time, :website, :confirm_password
diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb
index 3833327bb..94547b61b 100644
--- a/app/services/app_sign_up_service.rb
+++ b/app/services/app_sign_up_service.rb
@@ -35,7 +35,7 @@ class AppSignUpService < BaseService
   end
 
   def user_params
-    @params.slice(:email, :password, :agreement, :locale)
+    @params.slice(:email, :password, :agreement, :locale, :time_zone)
   end
 
   def account_params
diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml
index fd65039ae..d8aa38b11 100644
--- a/app/views/notification_mailer/_status.html.haml
+++ b/app/views/notification_mailer/_status.html.haml
@@ -42,4 +42,4 @@
                                         = link_to a.remote_url, a.remote_url
 
                               %p.status-footer
-                                = link_to l(status.created_at), web_url("@#{status.account.pretty_acct}/#{status.id}")
+                                = link_to l(status.created_at.in_time_zone(time_zone)), web_url("@#{status.account.pretty_acct}/#{status.id}")
diff --git a/app/views/notification_mailer/favourite.html.haml b/app/views/notification_mailer/favourite.html.haml
index 4ec89172d..325f0aff5 100644
--- a/app/views/notification_mailer/favourite.html.haml
+++ b/app/views/notification_mailer/favourite.html.haml
@@ -22,7 +22,7 @@
                               %h1= t 'notification_mailer.favourite.title'
                               %p.lead= t('notification_mailer.favourite.body', name: @account.pretty_acct)
 
-= render 'status', status: @status
+= render 'status', status: @status, time_zone: @me.user_time_zone
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
diff --git a/app/views/notification_mailer/mention.html.haml b/app/views/notification_mailer/mention.html.haml
index 4ae9bb7b0..e830644c3 100644
--- a/app/views/notification_mailer/mention.html.haml
+++ b/app/views/notification_mailer/mention.html.haml
@@ -22,7 +22,7 @@
                               %h1= t 'notification_mailer.mention.title'
                               %p.lead= t('notification_mailer.mention.body', name: @status.account.pretty_acct)
 
-= render 'status', status: @status
+= render 'status', status: @status, time_zone: @me.user_time_zone
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
diff --git a/app/views/notification_mailer/reblog.html.haml b/app/views/notification_mailer/reblog.html.haml
index f805c79f0..e4f944123 100644
--- a/app/views/notification_mailer/reblog.html.haml
+++ b/app/views/notification_mailer/reblog.html.haml
@@ -22,7 +22,7 @@
                               %h1= t 'notification_mailer.reblog.title'
                               %p.lead= t('notification_mailer.reblog.body', name: @account.pretty_acct)
 
-= render 'status', status: @status
+= render 'status', status: @status, time_zone: @me.user_time_zone
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index af61df71b..ce3a30c5e 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -9,8 +9,11 @@
     .fields-group.fields-row__column.fields-row__column-6
       = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| native_locale_name(locale) }, selected: I18n.locale, hint: false
     .fields-group.fields-row__column.fields-row__column-6
-      = f.simple_fields_for :settings, current_user.settings do |ff|
-        = ff.input :theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_theme'), include_blank: false, hint: false
+      = f.input :time_zone, wrapper: :with_label, collection: ActiveSupport::TimeZone.all.map { |tz| ["(GMT#{tz.formatted_offset}) #{tz.name}", tz.tzinfo.name] }, hint: false
+
+  .fields-group
+    = f.simple_fields_for :settings, current_user.settings do |ff|
+      = ff.input :theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_theme'), include_blank: false, hint: false
 
   - unless I18n.locale == :en
     .flash-message.translation-prompt
diff --git a/app/views/user_mailer/appeal_approved.html.haml b/app/views/user_mailer/appeal_approved.html.haml
index 962cab2e2..d62789a06 100644
--- a/app/views/user_mailer/appeal_approved.html.haml
+++ b/app/views/user_mailer/appeal_approved.html.haml
@@ -36,7 +36,7 @@
                         %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)
+                              %p= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at.in_time_zone(@resource.time_zone)), strike_date: l(@appeal.strike.created_at.in_time_zone(@resource.time_zone))
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
diff --git a/app/views/user_mailer/appeal_approved.text.erb b/app/views/user_mailer/appeal_approved.text.erb
index 290fa24c3..99596605a 100644
--- a/app/views/user_mailer/appeal_approved.text.erb
+++ b/app/views/user_mailer/appeal_approved.text.erb
@@ -2,6 +2,6 @@
 
 ===
 
-<%= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %>
+<%= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at.in_time_zone(@resource.time_zone)), strike_date: l(@appeal.strike.created_at.in_time_zone(@resource.time_zone)) %>
 
 => <%= root_url %>
diff --git a/app/views/user_mailer/appeal_rejected.html.haml b/app/views/user_mailer/appeal_rejected.html.haml
index c316a73fb..ae60775b0 100644
--- a/app/views/user_mailer/appeal_rejected.html.haml
+++ b/app/views/user_mailer/appeal_rejected.html.haml
@@ -36,7 +36,7 @@
                         %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)
+                              %p= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at.in_time_zone(@resource.time_zone)), strike_date: l(@appeal.strike.created_at.in_time_zone(@resource.time_zone))
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
diff --git a/app/views/user_mailer/appeal_rejected.text.erb b/app/views/user_mailer/appeal_rejected.text.erb
index f47a76818..3c9377718 100644
--- a/app/views/user_mailer/appeal_rejected.text.erb
+++ b/app/views/user_mailer/appeal_rejected.text.erb
@@ -2,6 +2,6 @@
 
 ===
 
-<%= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %>
+<%= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at.in_time_zone(@resource.time_zone)), strike_date: l(@appeal.strike.created_at.in_time_zone(@resource.time_zone)) %>
 
 => <%= root_url %>
diff --git a/app/views/user_mailer/suspicious_sign_in.html.haml b/app/views/user_mailer/suspicious_sign_in.html.haml
index e4ad500c3..6ebba3fa5 100644
--- a/app/views/user_mailer/suspicious_sign_in.html.haml
+++ b/app/views/user_mailer/suspicious_sign_in.html.haml
@@ -47,7 +47,7 @@
                                 %strong= "#{t('sessions.browser')}:"
                                 %span{ title: @user_agent }= t 'sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: @detection.id.to_s), platform: t("sessions.platforms.#{@detection.platform.id}", default: @detection.platform.id.to_s)
                                 %br/
-                                = l(@timestamp)
+                                = l(@timestamp.in_time_zone(@resource.time_zone))
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
diff --git a/app/views/user_mailer/suspicious_sign_in.text.erb b/app/views/user_mailer/suspicious_sign_in.text.erb
index 7d2ca28e8..956071e77 100644
--- a/app/views/user_mailer/suspicious_sign_in.text.erb
+++ b/app/views/user_mailer/suspicious_sign_in.text.erb
@@ -8,7 +8,7 @@
 
 <%= t('sessions.ip') %>: <%= @remote_ip %>
 <%= t('sessions.browser') %>: <%= t('sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")) %>
-<%= l(@timestamp) %>
+<%= l(@timestamp.in_time_zone(@resource.time_zone)) %>
 
 <%= t 'user_mailer.suspicious_sign_in.further_actions_html', action: t('user_mailer.suspicious_sign_in.change_password') %>
 
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index b9422e950..9cb73b0fe 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -58,7 +58,7 @@
 
 - unless @statuses.empty?
   - @statuses.each_with_index do |status, i|
-    = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true
+    = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true, time_zone: @resource.time_zone
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index f92e66e55..330c8732f 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -297,6 +297,7 @@ en:
         usable: Allow posts to use this hashtag
       user:
         role: Role
+        time_zone: Time zone
       user_role:
         color: Badge color
         highlighted: Display role as badge on user profiles
diff --git a/db/migrate/20230605085711_add_time_zone_to_users.rb b/db/migrate/20230605085711_add_time_zone_to_users.rb
new file mode 100644
index 000000000..fc6c0b091
--- /dev/null
+++ b/db/migrate/20230605085711_add_time_zone_to_users.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddTimeZoneToUsers < ActiveRecord::Migration[6.1]
+  def change
+    add_column :users, :time_zone, :string
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 28d8d8390..9866b1014 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2023_06_05_085710) do
+ActiveRecord::Schema.define(version: 2023_06_05_085711) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -1088,6 +1088,7 @@ ActiveRecord::Schema.define(version: 2023_06_05_085710) do
     t.boolean "skip_sign_in_token"
     t.bigint "role_id"
     t.text "settings"
+    t.string "time_zone"
     t.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)"