diff --git a/Civi/Token/TokenCompatSubscriber.php b/Civi/Token/TokenCompatSubscriber.php
index 3133df6eb3e4da10b1ada4f1136dd1d33f6f4139..92e20b780680f0d3c9ad2a39cca84cf1a9a4bb96 100644
--- a/Civi/Token/TokenCompatSubscriber.php
+++ b/Civi/Token/TokenCompatSubscriber.php
@@ -298,48 +298,135 @@ class TokenCompatSubscriber implements EventSubscriberInterface {
    * Load token data.
    *
    * @param \Civi\Token\Event\TokenValueEvent $e
+   *
    * @throws TokenException
+   * @throws \CRM_Core_Exception
    */
   public function onEvaluate(TokenValueEvent $e) {
-    // For reasons unknown, replaceHookTokens used to require a pre-computed list of
-    // hook *categories* (aka entities aka namespaces). We cache
-    // this in the TokenProcessor's context but can likely remove it now.
-
-    $e->getTokenProcessor()->context['hookTokenCategories'] = \CRM_Utils_Token::getTokenCategories();
-
     $messageTokens = $e->getTokenProcessor()->getMessageTokens()['contact'] ?? [];
+    if (empty($messageTokens)) {
+      return;
+    }
+    $this->fieldMetadata = (array) civicrm_api4('Contact', 'getfields', ['checkPermissions' => FALSE], 'name');
 
     foreach ($e->getRows() as $row) {
-      if (empty($row->context['contactId'])) {
+      if (empty($row->context['contactId']) && empty($row->context['contact'])) {
         continue;
       }
 
       unset($swapLocale);
       $swapLocale = empty($row->context['locale']) ? NULL : \CRM_Utils_AutoClean::swapLocale($row->context['locale']);
 
-      /** @var int $contactId */
-      $contactId = $row->context['contactId'];
       if (empty($row->context['contact'])) {
-        $contact = $this->getContact($contactId, $messageTokens);
+        $row->context['contact'] = $this->getContact($row->context['contactId'], $messageTokens);
       }
-      else {
-        $contact = $row->context['contact'];
+
+      foreach ($messageTokens as $token) {
+        if ($token === 'checksum') {
+          $cs = \CRM_Contact_BAO_Contact_Utils::generateChecksum($row->context['contactId'],
+            NULL,
+            NULL,
+            $row->context['hash'] ?? NULL
+          );
+          $row->format('text/html')
+            ->tokens('contact', $token, "cs={$cs}");
+        }
+        elseif (!empty($row->context['contact'][$token]) &&
+          $this->isDateField($token)
+        ) {
+          // Handle dates here, for now. Standardise with other token entities next round
+          $row->format('text/plain')->tokens('contact', $token, \CRM_Utils_Date::customFormat($row->context['contact'][$token]));
+        }
+        elseif (
+          ($row->context['contact'][$token] ?? '') == 0
+          && $this->isBooleanField($token)) {
+          // Note this will be the default behaviour once we fetch with apiv4.
+          $row->format('text/plain')->tokens('contact', $token, '');
+        }
+        elseif ($token === 'signature_html') {
+          $row->format('text/html')->tokens('contact', $token, html_entity_decode($row->context['contact'][$token]));
+        }
+        else {
+          $row->format('text/html')
+            ->tokens('contact', $token, $row->context['contact'][$token] ?? '');
+        }
       }
-      $row->context('contact', $contact);
     }
   }
 
+  /**
+   * Is the given field a boolean field.
+   *
+   * @param string $fieldName
+   *
+   * @return bool
+   */
+  public function isBooleanField(string $fieldName): bool {
+    // no metadata for these 2 non-standard fields
+    // @todo - fix to api v4 & have metadata for all fields. Migrate contact_is_deleted
+    // to {contact.is_deleted}. on hold feels like a token that exists by
+    // accident & could go.... since it's not from the main entity.
+    if (in_array($fieldName, ['contact_is_deleted', 'on_hold'])) {
+      return TRUE;
+    }
+    if (empty($this->getFieldMetadata()[$fieldName])) {
+      return FALSE;
+    }
+    return $this->getFieldMetadata()[$fieldName]['data_type'] === 'Boolean';
+  }
+
+  /**
+   * Is the given field a date field.
+   *
+   * @param string $fieldName
+   *
+   * @return bool
+   */
+  public function isDateField(string $fieldName): bool {
+    if (empty($this->getFieldMetadata()[$fieldName])) {
+      return FALSE;
+    }
+    return in_array($this->getFieldMetadata()[$fieldName]['data_type'], ['Timestamp', 'Date'], TRUE);
+  }
+
+  /**
+   * Get the metadata for the available fields.
+   *
+   * @return array
+   */
+  protected function getFieldMetadata(): array {
+    if (empty($this->fieldMetadata)) {
+      try {
+        // Tests fail without checkPermissions = FALSE
+        $this->fieldMetadata = (array) civicrm_api4('Contact', 'getfields', ['checkPermissions' => FALSE], 'name');
+      }
+      catch (\API_Exception $e) {
+        $this->fieldMetadata = [];
+      }
+    }
+    return $this->fieldMetadata;
+  }
+
   /**
    * Apply the various CRM_Utils_Token helpers.
    *
    * @param \Civi\Token\Event\TokenRenderEvent $e
+   *
+   * @throws \CRM_Core_Exception
    */
-  public function onRender(TokenRenderEvent $e) {
+  public function onRender(TokenRenderEvent $e): void {
     $isHtml = ($e->message['format'] === 'text/html');
     $useSmarty = !empty($e->context['smarty']);
 
     if (!empty($e->context['contact'])) {
-      \CRM_Utils_Token::replaceGreetingTokens($e->string, $e->context['contact'], $e->context['contact']['contact_id'] ?? $e->context['contactId'], NULL, $useSmarty);
+      // @todo - remove this - it simply removes the last unresolved tokens before
+      // they break smarty.
+      // historically it was only called when context['contact'] so that is
+      // retained but it only works because it's almost always true.
+      $remainingTokens = array_keys(\CRM_Utils_Token::getTokens($e->string));
+      if (!empty($remainingTokens)) {
+        $e->string = \CRM_Utils_Token::replaceHookTokens($e->string, $e->context['contact'], $remainingTokens);
+      }
     }
 
     if ($useSmarty) {
@@ -374,8 +461,12 @@ class TokenCompatSubscriber implements EventSubscriberInterface {
     $mappedFields = [
       'email_greeting' => 'email_greeting_display',
       'postal_greeting' => 'postal_greeting_display',
-      'addressee' => 'address_display',
+      'addressee' => 'addressee_display',
     ];
+    if (!empty($returnProperties['checksum'])) {
+      $returnProperties['hash'] = 1;
+    }
+
     foreach ($mappedFields as $tokenName => $realName) {
       if (in_array($tokenName, $requiredFields, TRUE)) {
         $returnProperties[$realName] = 1;
diff --git a/Civi/Token/TokenProcessor.php b/Civi/Token/TokenProcessor.php
index 5411a74fecc80a27a15da702904691b985c18fb1..3bad2d891878c5bf08c94870906c4d27f8f7d2ba 100644
--- a/Civi/Token/TokenProcessor.php
+++ b/Civi/Token/TokenProcessor.php
@@ -383,7 +383,7 @@ class TokenProcessor {
     // Regex examples: '{foo.bar}', '{foo.bar|whiz}'
     // Regex counter-examples: '{foobar}', '{foo bar}', '{$foo.bar}', '{$foo.bar|whiz}', '{foo.bar|whiz{bang}}'
     // Key observations: Civi tokens MUST have a `.` and MUST NOT have a `$`. Civi filters MUST NOT have `{}`s or `$`s.
-    $tokRegex = '([\w]+)\.([\w:]+)';
+    $tokRegex = '([\w]+)\.([\w:\.]+)';
     $filterRegex = '(\w+)';
     $event->string = preg_replace_callback(";\{$tokRegex(?:\|$filterRegex)?\};", $getToken, $message['string']);
     $this->dispatcher->dispatch('civi.token.render', $event);
diff --git a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php
index 049653d0f4f401c66291fd7ee55feea57f7800b2..ed303f27341fbef855367105c481ad6838aec3fc 100644
--- a/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php
+++ b/tests/phpunit/CRM/Core/BAO/MessageTemplateTest.php
@@ -360,6 +360,16 @@ emo
 ';
     $expected .= $this->getExpectedContactOutput($address['id'], $tokenData, $messageContent['html']);
     $this->assertEquals($expected, $messageContent['html']);
+    $textDifferences = [
+      '<p>',
+      '</p>',
+      '<a href="http://civicrm.org" ',
+      'target="_blank">',
+      '</a>',
+    ];
+    foreach ($textDifferences as $html) {
+      $expected = str_replace($html, '', $expected);
+    }
     $this->assertEquals($expected, $messageContent['text']);
     $checksum_position = strpos($messageContent['subject'], 'cs=');
     $this->assertTrue($checksum_position !== FALSE);
@@ -768,12 +778,12 @@ state_province:TX
 country:United States
 phone:123-456
 phone_ext:77
-phone_type_id:
+phone_type_id:2
 phone_type:Mobile
 email:anthony_anderson@civicrm.org
 on_hold:
 signature_text:Yours sincerely
-signature_html:&lt;p&gt;Yours&lt;/p&gt;
+signature_html:<p>Yours</p>
 im_provider:1
 im:IM Screen Name
 openid:OpenID
diff --git a/tests/phpunit/CRM/Core/FormTest.php b/tests/phpunit/CRM/Core/FormTest.php
index d6f3fb2e85975a6ebd9a7d3595f77f7f48a6451b..0041d19ac39be487b4104f2b757fdba25fe3913f 100644
--- a/tests/phpunit/CRM/Core/FormTest.php
+++ b/tests/phpunit/CRM/Core/FormTest.php
@@ -62,7 +62,7 @@ class CRM_Core_FormTest extends CiviUnitTestCase {
     ];
   }
 
-  public function testNewPriceField() {
+  public function testNewPriceField(): void {
     $this->createLoggedInUser();
 
     $priceSetId = $this->callAPISuccess('PriceSet', 'create', [