diff --git a/deactivate_users.info.yml b/deactivate_users.info.yml
index 6db85ff..9635c8b 100644
--- a/deactivate_users.info.yml
+++ b/deactivate_users.info.yml
@@ -1,7 +1,7 @@
 name: Deactivate Inactive Users
 type: module
 description: "Block accounts that have been inactive for a number of days."
-core_version_requirement: ^9 || ^10 || ^11
+core_version_requirement: ^10.1 || ^11 || ^12
 dependencies:
   - token:token
 configure: deactivate_users.admin_settings
diff --git a/deactivate_users.install b/deactivate_users.install
index f3de5bf..c193e7e 100644
--- a/deactivate_users.install
+++ b/deactivate_users.install
@@ -5,6 +5,7 @@
  * Update hooks for deactivate_users module.
  */
 
+use Drupal\Component\Utility\DeprecationHelper;
 use Drupal\Core\Utility\UpdateException;
 
 /**
@@ -14,7 +15,14 @@ use Drupal\Core\Utility\UpdateException;
  */
 function deactivate_users_update_8001(&$sandbox) {
   $config_factory = \Drupal::configFactory();
-  $config_factory->getEditable('deactivate_users.settings')
+  DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => $config_factory->getEditable('deactivate_users.settings')
+    ->set('notify_email.enabled', 1)
+    ->set('notify_email.subject', '[site:name] Account Expiring Soon')
+    ->set('deactivated_email.enabled', 1)
+    ->set('deactivated_email.from_address', '')
+    ->set('deactivated_email.subject', '[site:name] Account Expired')
+    ->set('deactivated_email.message', 'Your account has been deactivated for inactivity.')
+    ->set('timeout.changed_record', 86400)->save(), fn() => $config_factory->getEditable('deactivate_users.settings')
     ->set('notify_email.enabled', 1)
     ->set('notify_email.subject', '[site:name] Account Expiring Soon')
     ->set('deactivated_email.enabled', 1)
@@ -22,7 +30,7 @@ function deactivate_users_update_8001(&$sandbox) {
     ->set('deactivated_email.subject', '[site:name] Account Expired')
     ->set('deactivated_email.message', 'Your account has been deactivated for inactivity.')
     ->set('timeout.changed_record', 86400)
-    ->save(TRUE);
+    ->save(TRUE));
 }
 
 /**
@@ -32,9 +40,10 @@ function deactivate_users_update_8001(&$sandbox) {
  */
 function deactivate_users_update_8002(&$sandbox) {
   $config_factory = \Drupal::configFactory();
-  $config_factory->getEditable('deactivate_users.settings')
+  DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.4.0', fn() => $config_factory->getEditable('deactivate_users.settings')
+    ->set('minimum_warning_time_days', 0)->save(), fn() => $config_factory->getEditable('deactivate_users.settings')
     ->set('minimum_warning_time_days', 0)
-    ->save(TRUE);
+    ->save(TRUE));
 }
 
 /**
diff --git a/deactivate_users.module b/deactivate_users.module
index 4355879..67ca428 100644
--- a/deactivate_users.module
+++ b/deactivate_users.module
@@ -5,10 +5,10 @@
  * Hook implementations for deactivate_users module.
  */
 
+use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\deactivate_users\Hook\DeactivateUsersHooks;
 use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\Url;
-use Drupal\deactivate_users\Entity\AccountStatusRecord;
-use Drupal\deactivate_users\Event\UserDeactivatedEvent;
 use Drupal\user\Entity\User;
 
 /**
@@ -17,311 +17,41 @@ use Drupal\user\Entity\User;
  * Block users after <inactive_timeout> seconds have elapsed since their last
  * access, unless their account was changed within a grace period.
  */
+#[LegacyHook]
 function deactivate_users_cron() {
-  // Load deactivate_users config variables.
-  $config = \Drupal::config('deactivate_users.settings');
-  if (!$config->get('enabled')) {
-    return;
-  }
-
-  // Get a few services.
-  $mail_service = \Drupal::service('plugin.manager.mail');
-  $logger = \Drupal::logger('deactivate_users');
-  $event_dispatcher = \Drupal::service('event_dispatcher');
-
-  // Use the request time for "now".
-  $now = \Drupal::time()->getRequestTime();
-
-  // Get the last time an email burst was sent for each days threshold.
-  $last_sent_timestamps = \Drupal::state()->get('deactivate_users.last_emails_sent_timestamps', []);
-  $first_sent_timestamp = \Drupal::state()->get('deactivate_users.first_sent_timestamp', $now);
-  $minimum_warning_time = $config->get('minimum_warning_time_days', 0) * 86400;
-
-  // Get some offsets.
-  $inactive_offset = $config->get('timeout.inactive') * 86400;
-  $grace_period = $config->get('timeout.grace_period') * 86400;
-
-  // Don't block if we haven't sent warnings for enough days.
-  if ($now >= $first_sent_timestamp + $minimum_warning_time) {
-    // Step 1: Block expired users.
-    //
-    // Block a user if it has been more than timeout inactive days + grace
-    // period since their last access.  Ignore users with no access timestamp.
-    $inactive_limit = $now - ($inactive_offset + $grace_period);
-    $recent_timestamp = $now - $config->get('timeout.changed_record');
-
-    $inactive_uids = \Drupal::entityQuery('user')
-      ->condition('status', 1)
-      ->condition('changed', $recent_timestamp, '<')
-      ->condition('access', $inactive_limit, '<')
-      ->condition('access', 0, '>')
-      ->accessCheck()
-      ->execute() ?? [];
-
-    $never_logged_in_uids = \Drupal::entityQuery('user')
-      ->condition('status', 1)
-      ->condition('changed', $recent_timestamp, '<')
-      ->condition('created', $inactive_limit, '<')
-      ->condition('access', 0, '=')
-      ->accessCheck()
-      ->execute() ?? [];
-
-    $users = User::loadMultiple(array_merge($inactive_uids, $never_logged_in_uids));
-    foreach ($users as $user) {
-      // Despite the conditions in both queries above, blocked users are still
-      // returned in the result sets.
-      if ($user->isBlocked()) {
-        continue;
-      }
-      $user_has_email = !empty($user->getEmail());
-      $log_context = [
-        '@username' => $user->getDisplayName(),
-        '@mail' => $user->getEmail() ?? 'No User Email',
-      ];
-      $user->block();
-      $user->save();
-      $logger->notice('Deactivated @username (@mail).', $log_context);
-
-      if ($config->get('deactivated_email.enabled') && $user_has_email) {
-        $mail_service->mail('deactivate_users', 'deactivate_user', $user->getEmail(), $user->getPreferredLangcode(), ['uid' => $user->id()]);
-      }
-      else {
-        $logger->warning('Failed to notify @username of account deactivation: user has no email address.', $log_context);
-      }
-      // Notify other modules the user has been deactivated.
-      $event_dispatcher->dispatch(new UserDeactivatedEvent($user), UserDeactivatedEvent::EVENT_NAME);
-    }
-  }
-
-  // STEP 2: Send notifications to those that are still active.
-  // Get and sort email notification intervals; e.g. ["5", "10", "20"].
-  $notify_days = array_unique(array_filter(array_map('trim', explode(',', $config->get('notify_email.days')))), SORT_NUMERIC);
-
-  // @todo prevent someone from getting multiple emails in one cron run.
-  $sent_uids = [];
-  foreach ($notify_days as $day) {
-    $offset = $inactive_offset - ($day * 86400);
-    $last_sent = $last_sent_timestamps[$day] ?? 0;
-
-    // Load users that have been inactive at least n days, and have not yet seen
-    // the notification for this day.  This should send notifications to users
-    // that have crossed the threshold since the last cron run.  Ignore users
-    // with no access time.
-    // @todo add a last_notified field to the user record to handle users that
-    // are unblocked with a long inactivity.
-    $uids = \Drupal::entityQuery('user')
-      ->condition('status', 1)
-      ->condition('access', $now - $offset, '<')
-      ->condition('access', $last_sent - $offset, '>=')
-      ->condition('access', 0, '>')
-      ->accessCheck()
-      ->execute() ?? [];
-
-    $uids = array_diff($uids, $sent_uids);
-    $sent_uids = array_merge($sent_uids, $uids);
-    foreach (User::loadMultiple($uids) as $uid => $user) {
-      // Despite the conditions in the query above, blocked users are still
-      // returned in the result sets.
-      if ($user->isBlocked()) {
-        continue;
-      }
-      $user_has_email = !empty($user->getEmail());
-      $log_context = [
-        '@username' => $user->getDisplayName(),
-        '@mail' => $user->getEmail() ?? 'No User Email',
-      ];
-      // Send the email.
-      if ($config->get('deactivated_email.enabled')) {
-        if ($user_has_email) {
-          $mail = $mail_service->mail('deactivate_users', 'notify_user', $user->getEmail(), $user->getPreferredLangcode(), ['uid' => $uid]);
-          // Audit logging.
-          if (empty($mail['result'])) {
-            $logger->error('Failed to send account expiration notification email to: @mail', $log_context);
-          }
-          elseif ($config->get('log_notifications')) {
-            $logger->notice('Notified @username (@mail) of pending deactivation.', $log_context);
-          }
-        }
-        else {
-          $logger->warning('Failed to notify @username of pending deactivation: user has no email address.', $log_context);
-        }
-      }
-    }
-    $last_sent_timestamps[$day] = $now;
-    \Drupal::state()->set('deactivate_users.last_emails_sent_timestamps', $last_sent_timestamps);
-    \Drupal::state()->set('deactivate_users.first_sent_timestamp', $first_sent_timestamp);
-  }
+  \Drupal::service(DeactivateUsersHooks::class)->cron();
 }
 
 /**
  * Implements hook_token_info().
  */
+#[LegacyHook]
 function deactivate_users_token_info() {
-  $info = [];
-  $info['tokens']['user']['expire-timeout'] = [
-    'name' => t('User Access Timeout'),
-    'description' => t('Days left before blocking based on last access.'),
-    'days' => [
-      'name' => t('User Access Timeout in Days'),
-      'description' => ('Days left before blocking based on last access.'),
-    ],
-  ];
-  $info['tokens']['user']['unblock-link'] = [
-    'name' => t('Unblock Link'),
-    'description' => t('A link to unblock a user account.'),
-  ];
-  return $info;
+  return \Drupal::service(DeactivateUsersHooks::class)->tokenInfo();
 }
 
 /**
  * Implements hook_tokens().
  */
+#[LegacyHook]
 function deactivate_users_tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
-
-  $now = \Drupal::time()->getRequestTime();
-  $config = \Drupal::config('deactivate_users.settings');
-  $timeout = $config->get('timeout.inactive');
-
-  $first_sent_timestamp = \Drupal::state()->get('deactivate_users.first_sent_timestamp', $now);
-  $minimum_warning_time = $config->get('minimum_warning_time_days') * 86400;
-  $minimum_warning_timestamp = $first_sent_timestamp + $minimum_warning_time;
-
-  $replacements = [];
-  if ($type === 'user' && isset($data['user'])) {
-    // Get the number of days for the user to timeout and become
-    // inactive/blocked.
-    $user = $data['user'];
-    // Use the latter of the user's accessed and created timestamps, to account
-    // for users who have never logged in.
-    $expiration_timestamp = max($user->getLastAccessedTime(), $user->getCreatedTime()) + $timeout * 86400;
-    // Use the latter of the minimum warning timestamp and the user's expiration
-    // timestamp.
-    $days_remaining = (int) ((max($expiration_timestamp, $minimum_warning_timestamp) - $now) / 86400);
-
-    foreach ($tokens as $name => $token) {
-      switch ($name) {
-        case 'expire-timeout':
-        case 'expire-timeout:days':
-          $replacements[$token] = $days_remaining < 0 ? 0 : $days_remaining;
-          break;
-
-        case 'unblock-link':
-          $replacements[$token] = deactivate_users_generate_unblock_link($user);
-          break;
-      }
-    }
-  }
-  // Return the replacements.
-  return $replacements;
+  return \Drupal::service(DeactivateUsersHooks::class)->tokens($type, $tokens, $data, $options, $bubbleable_metadata);
 }
 
 /**
  * Implements hook_mail().
  */
+#[LegacyHook]
 function deactivate_users_mail($key, &$message, $params) {
-
-  switch ($key) {
-    case 'notify_user':
-      $from_config = 'notify_email.from_address';
-      $subject_config = 'notify_email.subject';
-      $body_config = 'notify_email.message';
-      break;
-
-    case 'deactivate_user':
-      $from_config = 'deactivated_email.from_address';
-      $subject_config = 'deactivated_email.subject';
-      $body_config = 'deactivated_email.message';
-      break;
-
-    case 'unblock_email':
-      $from_config = 'unblock_email.from_address';
-      $subject_config = 'unblock_email.subject';
-      $body_config = 'unblock_email.message';
-      break;
-
-    // Not a mail key we recognize, do nothing.
-    default:
-      return;
-  }
-
-  // User data.
-  $user = User::load($params['uid']);
-
-  // Get the site's email address, for use later if no address supplied in
-  // config.
-  $site_wide_email_address = \Drupal::config('system.site')->get('mail');
-
-  // Set the language.
-  $language_manager = \Drupal::languageManager();
-  $langcode = $message['langcode'];
-  $language = $language_manager->getLanguage($user->getPreferredLangcode());
-  $original_language = $language_manager->getConfigOverrideLanguage();
-  $language_manager->setConfigOverrideLanguage($language);
-
-  // Prep for calling the token service.
-  $token_service = \Drupal::token();
-  $token_data = ['user' => $user];
-  $token_options = [
-    'langcode' => $langcode,
-    'callback' => 'user_mail_tokens',
-    'clear' => TRUE,
-  ];
-
-  // Generate the email.
-  $config = \Drupal::config('deactivate_users.settings');
-  $message['from'] = !empty($config->get($from_config)) ? $config->get($from_config) : $site_wide_email_address;
-  $message['subject'] = $token_service->replace($config->get($subject_config), $token_data, $token_options);
-  $message['body'] = [$token_service->replace($config->get($body_config), $token_data, $token_options)];
-
-  // Need to alter the headers to actually get the From to change.
-  $message['headers']['From'] = $message['from'];
-  $message['headers']['Sender'] = $message['from'];
-
-  // Reset the language back.
-  $language_manager->setConfigOverrideLanguage($original_language);
+  \Drupal::service(DeactivateUsersHooks::class)->mail($key, $message, $params);
 }
 
 /**
  * Implements hook_user_presave().
  */
+#[LegacyHook]
 function deactivate_users_user_presave(User $user) {
-  // If this isn't a new user and their status is being changed, record it.
-  if (!$user->isNew() && ($user->original->isBlocked() != $user->isBlocked())) {
-
-    $uid = $user->id();
-    $current_user_uid = \Drupal::currentUser()->id();
-    $action = $user->isBlocked() ? "block" : "active";
-    $method = !empty($current_user_uid) ? 'by user' : 'by system';
-    $description = '';
-
-    switch ($method) {
-      case 'by system':
-        if ($action === 'block') {
-          $description = 'user blocked by system, likely due to inactivity';
-        }
-        else {
-          $description = 'user unblocked by system';
-        }
-        break;
-
-      case 'by user':
-        if ($action === 'block') {
-          $description = 'user blocked by other user';
-        }
-        else {
-          $description = 'user unblocked by other user';
-        }
-        break;
-    }
-
-    AccountStatusRecord::create([
-      'uid' => $uid,
-      'action' => $action,
-      'method' => $method,
-      'by_uid' => $current_user_uid,
-      'description' => $description,
-    ])->save();
-  }
+  \Drupal::service(DeactivateUsersHooks::class)->userPresave($user);
 
 }
 
diff --git a/deactivate_users.services.yml b/deactivate_users.services.yml
new file mode 100644
index 0000000..d95e746
--- /dev/null
+++ b/deactivate_users.services.yml
@@ -0,0 +1,5 @@
+
+services:
+  Drupal\deactivate_users\Hook\DeactivateUsersHooks:
+    class: Drupal\deactivate_users\Hook\DeactivateUsersHooks
+    autowire: true
diff --git a/src/Hook/DeactivateUsersHooks.php b/src/Hook/DeactivateUsersHooks.php
new file mode 100644
index 0000000..c307d39
--- /dev/null
+++ b/src/Hook/DeactivateUsersHooks.php
@@ -0,0 +1,299 @@
+<?php
+
+namespace Drupal\deactivate_users\Hook;
+
+use Drupal\deactivate_users\Entity\AccountStatusRecord;
+use Drupal\Component\Utility\DeprecationHelper;
+use Drupal\Core\Render\BubbleableMetadata;
+use Drupal\deactivate_users\Event\UserDeactivatedEvent;
+use Drupal\user\Entity\User;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Hook implementations for deactivate_users.
+ */
+class DeactivateUsersHooks {
+  use StringTranslationTrait;
+
+  /**
+   * Implements hook_cron().
+   *
+   * Block users after <inactive_timeout> seconds have elapsed since their last
+   * access, unless their account was changed within a grace period.
+   */
+  #[Hook('cron')]
+  public static function cron() {
+    // Load deactivate_users config variables.
+    $config = \Drupal::config('deactivate_users.settings');
+    if (!$config->get('enabled')) {
+      return;
+    }
+    // Get a few services.
+    $mail_service = \Drupal::service('plugin.manager.mail');
+    $logger = \Drupal::logger('deactivate_users');
+    $event_dispatcher = \Drupal::service('event_dispatcher');
+    // Use the request time for "now".
+    $now = \Drupal::time()->getRequestTime();
+    // Get the last time an email burst was sent for each days threshold.
+    $last_sent_timestamps = \Drupal::state()->get('deactivate_users.last_emails_sent_timestamps', []);
+    $first_sent_timestamp = \Drupal::state()->get('deactivate_users.first_sent_timestamp', $now);
+    $minimum_warning_time = $config->get('minimum_warning_time_days', 0) * 86400;
+    // Get some offsets.
+    $inactive_offset = $config->get('timeout.inactive') * 86400;
+    $grace_period = $config->get('timeout.grace_period') * 86400;
+    // Don't block if we haven't sent warnings for enough days.
+    if ($now >= $first_sent_timestamp + $minimum_warning_time) {
+      // Step 1: Block expired users.
+      //
+      // Block a user if it has been more than timeout inactive days + grace
+      // period since their last access.  Ignore users with no access timestamp.
+      $inactive_limit = $now - ($inactive_offset + $grace_period);
+      $recent_timestamp = $now - $config->get('timeout.changed_record');
+      $inactive_uids = \Drupal::entityQuery('user')->condition('status', 1)->condition('changed', $recent_timestamp, '<')->condition('access', $inactive_limit, '<')->condition('access', 0, '>')->accessCheck()->execute() ?? [];
+      $never_logged_in_uids = \Drupal::entityQuery('user')->condition('status', 1)->condition('changed', $recent_timestamp, '<')->condition('created', $inactive_limit, '<')->condition('access', 0, '=')->accessCheck()->execute() ?? [];
+      $users = User::loadMultiple(array_merge($inactive_uids, $never_logged_in_uids));
+      foreach ($users as $user) {
+        // Despite the conditions in both queries above, blocked users are still
+        // returned in the result sets.
+        if ($user->isBlocked()) {
+          continue;
+        }
+        $user_has_email = !empty($user->getEmail());
+        $log_context = [
+          '@username' => $user->getDisplayName(),
+          '@mail' => $user->getEmail() ?? 'No User Email',
+        ];
+        $user->block();
+        $user->save();
+        $logger->notice('Deactivated @username (@mail).', $log_context);
+        if ($config->get('deactivated_email.enabled') && $user_has_email) {
+          $mail_service->mail('deactivate_users', 'deactivate_user', $user->getEmail(), $user->getPreferredLangcode(), [
+            'uid' => $user->id(),
+          ]);
+        }
+        else {
+          $logger->warning('Failed to notify @username of account deactivation: user has no email address.', $log_context);
+        }
+        // Notify other modules the user has been deactivated.
+        $event_dispatcher->dispatch(new UserDeactivatedEvent($user), UserDeactivatedEvent::EVENT_NAME);
+      }
+    }
+    // STEP 2: Send notifications to those that are still active.
+    // Get and sort email notification intervals; e.g. ["5", "10", "20"].
+    $notify_days = array_unique(array_filter(array_map('trim', explode(',', $config->get('notify_email.days')))), SORT_NUMERIC);
+    // @todo prevent someone from getting multiple emails in one cron run.
+    $sent_uids = [];
+    foreach ($notify_days as $day) {
+      $offset = $inactive_offset - $day * 86400;
+      $last_sent = $last_sent_timestamps[$day] ?? 0;
+      // Load users that have been inactive at least n days, and have not yet seen
+      // the notification for this day.  This should send notifications to users
+      // that have crossed the threshold since the last cron run.  Ignore users
+      // with no access time.
+      // @todo add a last_notified field to the user record to handle users that
+      // are unblocked with a long inactivity.
+      $uids = \Drupal::entityQuery('user')->condition('status', 1)->condition('access', $now - $offset, '<')->condition('access', $last_sent - $offset, '>=')->condition('access', 0, '>')->accessCheck()->execute() ?? [];
+      $uids = array_diff($uids, $sent_uids);
+      $sent_uids = array_merge($sent_uids, $uids);
+      foreach (User::loadMultiple($uids) as $uid => $user) {
+        // Despite the conditions in the query above, blocked users are still
+        // returned in the result sets.
+        if ($user->isBlocked()) {
+          continue;
+        }
+        $user_has_email = !empty($user->getEmail());
+        $log_context = [
+          '@username' => $user->getDisplayName(),
+          '@mail' => $user->getEmail() ?? 'No User Email',
+        ];
+        // Send the email.
+        if ($config->get('deactivated_email.enabled')) {
+          if ($user_has_email) {
+            $mail = $mail_service->mail('deactivate_users', 'notify_user', $user->getEmail(), $user->getPreferredLangcode(), [
+              'uid' => $uid,
+            ]);
+            // Audit logging.
+            if (empty($mail['result'])) {
+              $logger->error('Failed to send account expiration notification email to: @mail', $log_context);
+            }
+            elseif ($config->get('log_notifications')) {
+              $logger->notice('Notified @username (@mail) of pending deactivation.', $log_context);
+            }
+          }
+          else {
+            $logger->warning('Failed to notify @username of pending deactivation: user has no email address.', $log_context);
+          }
+        }
+      }
+      $last_sent_timestamps[$day] = $now;
+      \Drupal::state()->set('deactivate_users.last_emails_sent_timestamps', $last_sent_timestamps);
+      \Drupal::state()->set('deactivate_users.first_sent_timestamp', $first_sent_timestamp);
+    }
+  }
+
+  /**
+   * Implements hook_token_info().
+   */
+  #[Hook('token_info')]
+  public function tokenInfo() {
+    $info = [];
+    $info['tokens']['user']['expire-timeout'] = [
+      'name' => $this->t('User Access Timeout'),
+      'description' => $this->t('Days left before blocking based on last access.'),
+      'days' => [
+        'name' => $this->t('User Access Timeout in Days'),
+        'description' => 'Days left before blocking based on last access.',
+      ],
+    ];
+    $info['tokens']['user']['unblock-link'] = [
+      'name' => $this->t('Unblock Link'),
+      'description' => $this->t('A link to unblock a user account.'),
+    ];
+    return $info;
+  }
+
+  /**
+   * Implements hook_tokens().
+   */
+  #[Hook('tokens')]
+  public static function tokens($type, $tokens, array $data, array $options, BubbleableMetadata $bubbleable_metadata) {
+    $now = \Drupal::time()->getRequestTime();
+    $config = \Drupal::config('deactivate_users.settings');
+    $timeout = $config->get('timeout.inactive');
+    $first_sent_timestamp = \Drupal::state()->get('deactivate_users.first_sent_timestamp', $now);
+    $minimum_warning_time = $config->get('minimum_warning_time_days') * 86400;
+    $minimum_warning_timestamp = $first_sent_timestamp + $minimum_warning_time;
+    $replacements = [];
+    if ($type === 'user' && isset($data['user'])) {
+      // Get the number of days for the user to timeout and become
+      // inactive/blocked.
+      $user = $data['user'];
+      // Use the latter of the user's accessed and created timestamps, to account
+      // for users who have never logged in.
+      $expiration_timestamp = max($user->getLastAccessedTime(), $user->getCreatedTime()) + $timeout * 86400;
+      // Use the latter of the minimum warning timestamp and the user's expiration
+      // timestamp.
+      $days_remaining = (int) ((max($expiration_timestamp, $minimum_warning_timestamp) - $now) / 86400);
+      foreach ($tokens as $name => $token) {
+        switch ($name) {
+          case 'expire-timeout':
+          case 'expire-timeout:days':
+            $replacements[$token] = $days_remaining < 0 ? 0 : $days_remaining;
+            break;
+
+          case 'unblock-link':
+            $replacements[$token] = deactivate_users_generate_unblock_link($user);
+            break;
+        }
+      }
+    }
+    // Return the replacements.
+    return $replacements;
+  }
+
+  /**
+   * Implements hook_mail().
+   */
+  #[Hook('mail')]
+  public static function mail($key, &$message, $params) {
+    switch ($key) {
+      case 'notify_user':
+        $from_config = 'notify_email.from_address';
+        $subject_config = 'notify_email.subject';
+        $body_config = 'notify_email.message';
+        break;
+
+      case 'deactivate_user':
+        $from_config = 'deactivated_email.from_address';
+        $subject_config = 'deactivated_email.subject';
+        $body_config = 'deactivated_email.message';
+        break;
+
+      case 'unblock_email':
+        $from_config = 'unblock_email.from_address';
+        $subject_config = 'unblock_email.subject';
+        $body_config = 'unblock_email.message';
+        break;
+
+      // Not a mail key we recognize, do nothing.
+      default:
+        return;
+    }
+    // User data.
+    $user = User::load($params['uid']);
+    // Get the site's email address, for use later if no address supplied in
+    // config.
+    $site_wide_email_address = \Drupal::config('system.site')->get('mail');
+    // Set the language.
+    $language_manager = \Drupal::languageManager();
+    $langcode = $message['langcode'];
+    $language = $language_manager->getLanguage($user->getPreferredLangcode());
+    $original_language = $language_manager->getConfigOverrideLanguage();
+    $language_manager->setConfigOverrideLanguage($language);
+    // Prep for calling the token service.
+    $token_service = \Drupal::token();
+    $token_data = [
+      'user' => $user,
+    ];
+    $token_options = [
+      'langcode' => $langcode,
+      'callback' => 'user_mail_tokens',
+      'clear' => TRUE,
+    ];
+    // Generate the email.
+    $config = \Drupal::config('deactivate_users.settings');
+    $message['from'] = !empty($config->get($from_config)) ? $config->get($from_config) : $site_wide_email_address;
+    $message['subject'] = $token_service->replace($config->get($subject_config), $token_data, $token_options);
+    $message['body'] = [
+      $token_service->replace($config->get($body_config), $token_data, $token_options),
+    ];
+    // Need to alter the headers to actually get the From to change.
+    $message['headers']['From'] = $message['from'];
+    $message['headers']['Sender'] = $message['from'];
+    // Reset the language back.
+    $language_manager->setConfigOverrideLanguage($original_language);
+  }
+
+  /**
+   * Implements hook_user_presave().
+   */
+  #[Hook('user_presave')]
+  public static function userPresave(User $user) {
+    // If this isn't a new user and their status is being changed, record it.
+    if (!$user->isNew() && DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.2.0', fn() => $user->getOriginal(), fn() => $user->original)->isBlocked() != $user->isBlocked()) {
+      $uid = $user->id();
+      $current_user_uid = \Drupal::currentUser()->id();
+      $action = $user->isBlocked() ? "block" : "active";
+      $method = !empty($current_user_uid) ? 'by user' : 'by system';
+      $description = '';
+      switch ($method) {
+        case 'by system':
+          if ($action === 'block') {
+            $description = 'user blocked by system, likely due to inactivity';
+          }
+          else {
+            $description = 'user unblocked by system';
+          }
+          break;
+
+        case 'by user':
+          if ($action === 'block') {
+            $description = 'user blocked by other user';
+          }
+          else {
+            $description = 'user unblocked by other user';
+          }
+          break;
+      }
+      AccountStatusRecord::create([
+        'uid' => $uid,
+        'action' => $action,
+        'method' => $method,
+        'by_uid' => $current_user_uid,
+        'description' => $description,
+      ])->save();
+    }
+  }
+
+}
diff --git a/tests/src/Traits/TestLoggerTrait.php b/tests/src/Traits/TestLoggerTrait.php
index f88e45d..790c49a 100644
--- a/tests/src/Traits/TestLoggerTrait.php
+++ b/tests/src/Traits/TestLoggerTrait.php
@@ -2,6 +2,9 @@
 
 namespace Drupal\Tests\deactivate_users\Traits;
 
+use Drupal\Component\Utility\DeprecationHelper;
+use Drupal\Core\Database\Statement\FetchAs;
+
 /**
  * A trait to find log messages in the watchdog table.
  */
@@ -68,13 +71,19 @@ trait TestLoggerTrait {
    */
   protected function hasLogMessage($channel, $level, $raw_message, $context = []): bool {
     $message = strtr($raw_message, $context);
-    $records = \Drupal::database()->select('watchdog', 'w')
+    $records = DeprecationHelper::backwardsCompatibleCall(\Drupal::VERSION, '11.2.0', fn() => \Drupal::database()->select('watchdog', 'w')
+      ->fields('w', ['type', 'message', 'variables', 'severity', 'timestamp'])
+      ->condition('w.type', $channel)
+      ->condition('w.severity', $level)
+      ->condition('w.timestamp', $this->logResetTimestamp, '>')
+      ->execute()
+      ->fetchAll(FetchAs::Associative), fn() => \Drupal::database()->select('watchdog', 'w')
       ->fields('w', ['type', 'message', 'variables', 'severity', 'timestamp'])
       ->condition('w.type', $channel)
       ->condition('w.severity', $level)
       ->condition('w.timestamp', $this->logResetTimestamp, '>')
       ->execute()
-      ->fetchAll(\PDO::FETCH_ASSOC);
+      ->fetchAll(\PDO::FETCH_ASSOC));
 
     foreach ($records as $rec) {
       $formatted = strtr($rec['message'], unserialize($rec['variables'], ['allowed_classes' => FALSE]));
