diff --git a/typo3/sysext/core/Classes/Mail/FluidEmail.php b/typo3/sysext/core/Classes/Mail/FluidEmail.php
index def7cdb93991a1582380001e19dbcd26115e7270..b35884316a6c493f38fdd10823b206ade87bc8e1 100644
--- a/typo3/sysext/core/Classes/Mail/FluidEmail.php
+++ b/typo3/sysext/core/Classes/Mail/FluidEmail.php
@@ -146,6 +146,15 @@ class FluidEmail extends Email
         if (in_array(static::FORMAT_PLAIN, $this->format, true)) {
             $this->text(trim($this->renderContent('txt')));
         }
+
+        $subjectFromTemplate = $this->view->renderSection(
+            'Subject',
+            $this->view->getRenderingContext()->getVariableProvider()->getAll(),
+            true
+        );
+        if (!empty($subjectFromTemplate)) {
+            $this->subject($subjectFromTemplate);
+        }
     }
 
     protected function renderContent(string $format): string
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-90411-HTML-basedWorkspaceNotificationEmailsOnStageChange.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-90411-HTML-basedWorkspaceNotificationEmailsOnStageChange.rst
new file mode 100644
index 0000000000000000000000000000000000000000..0be1aa3393c5fa53c8e9668d9efb61b29cd3fe3a
--- /dev/null
+++ b/typo3/sysext/core/Documentation/Changelog/master/Feature-90411-HTML-basedWorkspaceNotificationEmailsOnStageChange.rst
@@ -0,0 +1,53 @@
+.. include:: ../../Includes.txt
+
+==========================================================================
+Feature: #90411 - HTML-based workspace notification emails on stage change
+==========================================================================
+
+See :issue:`90411`
+
+Description
+===========
+
+When inside workspaces, it is possible to notify affected (or all)
+users belonging to that workspace to send out an email when
+items have been moved to the next stage in the workflow process.
+
+These emails have been limited in the past due to marker-based templating and plain-text only.
+
+The emails have been reworked and migrated to Fluid-based templated
+emails, allowing for administrators to customize the contents of
+these emails.
+
+The following TSconfig options have been added:
+
+.. code-block:: typoscript
+
+   # defines whether a preview link should be generated and populating
+   # the sys_preview database. A new variable {previewLink}
+   # is then available within the templated email
+   tx_workspaces.emails.stageChangeNotification.generatePreviewLink = 0
+
+   # path where to look for templates / layouts / partials
+   tx_workspaces.emails.layoutRootPaths.100 = EXT:myproject/...
+   tx_workspaces.emails.partialRootPaths.100 = EXT:myproject/...
+   tx_workspaces.emails.templateRootPaths.100 = EXT:myproject/...   
+   tx_workspaces.emails.format = html/text/both
+
+The template name is always called `StageChangeNotification`.
+
+It is still possible to use the existing plain-text variant
+by setting the format to "text" and using the previous email
+contents, if applicable. It is however recommended to make use
+of the Fluid-based variables to make output more efficient.
+
+The old TSconfig options have been superseded for defining the template via XLF labels.
+
+
+Impact
+======
+
+Stage Change Notification emails are now sent as HTML+text by
+default with the email template given in `EXT:workspaces/Resources/Private/Templates/Emails/StageChangeNotification`.
+
+.. index:: TSConfig, ext:workspaces
\ No newline at end of file
diff --git a/typo3/sysext/workspaces/Classes/Hook/DataHandlerHook.php b/typo3/sysext/workspaces/Classes/Hook/DataHandlerHook.php
index 25e585757eb42b24a0199835090d0bcbf1a6326f..e123fd8c35f49405d673fbcabab2be36869c24eb 100644
--- a/typo3/sysext/workspaces/Classes/Hook/DataHandlerHook.php
+++ b/typo3/sysext/workspaces/Classes/Hook/DataHandlerHook.php
@@ -16,7 +16,6 @@ namespace TYPO3\CMS\Workspaces\Hook;
 
 use Doctrine\DBAL\DBALException;
 use Doctrine\DBAL\Platforms\SQLServerPlatform;
-use Symfony\Component\Mime\Address;
 use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Core\Cache\CacheManager;
 use TYPO3\CMS\Core\Core\Environment;
@@ -29,8 +28,6 @@ use TYPO3\CMS\Core\Database\RelationHandler;
 use TYPO3\CMS\Core\DataHandling\DataHandler;
 use TYPO3\CMS\Core\DataHandling\PlaceholderShadowColumnsResolver;
 use TYPO3\CMS\Core\Localization\LanguageService;
-use TYPO3\CMS\Core\Mail\MailMessage;
-use TYPO3\CMS\Core\Service\MarkerBasedTemplateService;
 use TYPO3\CMS\Core\SysLog\Action as SystemLogGenericAction;
 use TYPO3\CMS\Core\SysLog\Action\Database as DatabaseAction;
 use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
@@ -39,7 +36,7 @@ use TYPO3\CMS\Core\Utility\ArrayUtility;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Versioning\VersionState;
 use TYPO3\CMS\Workspaces\DataHandler\CommandMap;
-use TYPO3\CMS\Workspaces\Preview\PreviewUriBuilder;
+use TYPO3\CMS\Workspaces\Notification\StageChangeNotification;
 use TYPO3\CMS\Workspaces\Service\StagesService;
 use TYPO3\CMS\Workspaces\Service\WorkspaceService;
 
@@ -53,7 +50,6 @@ class DataHandlerHook
     /**
      * For accumulating information about workspace stages raised
      * on elements so a single mail is sent as notification.
-     * previously called "accumulateForNotifEmail" in DataHandler
      *
      * @var array
      */
@@ -100,8 +96,8 @@ class DataHandlerHook
         }
         $commandIsProcessed = true;
         $action = (string)$value['action'];
-        $comment = !empty($value['comment']) ? $value['comment'] : '';
-        $notificationAlternativeRecipients = is_array($value['notificationAlternativeRecipients'] ?? null) ? $value['notificationAlternativeRecipients'] : [];
+        $comment = $value['comment'] ?: '';
+        $notificationAlternativeRecipients = $value['notificationAlternativeRecipients'] ?? [];
         switch ($action) {
             case 'new':
                 $dataHandler->versionizeRecord($table, $id, $value['label']);
@@ -149,10 +145,14 @@ class DataHandlerHook
      */
     public function processCmdmap_afterFinish(DataHandler $dataHandler)
     {
-        // Empty accumulation array:
-        foreach ($this->notificationEmailInfo as $notifItem) {
-            $this->notifyStageChange($notifItem['shared'][0], $notifItem['shared'][1], implode(', ', $notifItem['elements']), 0, $notifItem['shared'][2], $dataHandler, $notifItem['alternativeRecipients']);
-        }
+        // Empty accumulation array
+        $emailNotificationService = GeneralUtility::makeInstance(StageChangeNotification::class);
+        $this->sendStageChangeNotification(
+            $this->notificationEmailInfo,
+            $emailNotificationService,
+            $dataHandler
+        );
+
         // Reset notification array
         $this->notificationEmailInfo = [];
         // Reset remapped IDs
@@ -161,6 +161,38 @@ class DataHandlerHook
         $this->flushWorkspaceCacheEntriesByWorkspaceId((int)$dataHandler->BE_USER->workspace);
     }
 
+    protected function sendStageChangeNotification(
+        array $accumulatedNotificationInformation,
+        StageChangeNotification $notificationService,
+        DataHandler $dataHandler
+    ): void {
+        foreach ($accumulatedNotificationInformation as $groupedNotificationInformation) {
+            $emails = (array)$groupedNotificationInformation['recipients'];
+            if (empty($emails)) {
+                continue;
+            }
+            $workspaceRec = BackendUtility::getRecord('sys_workspace', $groupedNotificationInformation['shared'][0]);
+            if (!is_array($workspaceRec)) {
+                continue;
+            }
+            $notificationService->notifyStageChange(
+                $workspaceRec,
+                (int)$groupedNotificationInformation['shared'][1],
+                $groupedNotificationInformation['elements'],
+                $groupedNotificationInformation['shared'][2],
+                $emails,
+                $dataHandler->BE_USER
+            );
+
+            if ($dataHandler->enableLogging) {
+                [$elementTable, $elementUid] = reset($groupedNotificationInformation['elements']);
+                $propertyArray = $dataHandler->getRecordProperties($elementTable, $elementUid);
+                $pid = $propertyArray['pid'];
+                $dataHandler->log($elementTable, $elementUid, SystemLogGenericAction::UNDEFINED, 0, SystemLogErrorClassification::MESSAGE, 'Notification email for stage change was sent to "' . implode('", "', $emails) . '"', -1, [], $dataHandler->eventPid($elementTable, $elementUid, $pid));
+            }
+        }
+    }
+
     /**
      * hook that is called when an element shall get deleted
      *
@@ -454,246 +486,6 @@ class DataHandlerHook
         }
     }
 
-    /****************************
-     *****  Notifications  ******
-     ****************************/
-    /**
-     * Send an email notification to users in workspace
-     *
-     * @param array $stat Workspace access array from \TYPO3\CMS\Core\Authentication\BackendUserAuthentication::checkWorkspace()
-     * @param int $stageId New Stage number: 0 = editing, 1= just ready for review, 10 = ready for publication, -1 = rejected!
-     * @param string $table Table name of element (or list of element names if $id is zero)
-     * @param int $id Record uid of element (if zero, then $table is used as reference to element(s) alone)
-     * @param string $comment User comment sent along with action
-     * @param DataHandler $dataHandler DataHandler object
-     * @param array $notificationAlternativeRecipients List of recipients to notify instead of be_users selected by sys_workspace, list is generated by workspace extension module
-     */
-    protected function notifyStageChange(array $stat, $stageId, $table, $id, $comment, DataHandler $dataHandler, array $notificationAlternativeRecipients = []): void
-    {
-        $workspaceRec = BackendUtility::getRecord('sys_workspace', $stat['uid']);
-        // So, if $id is not set, then $table is taken to be the complete element name!
-        $elementName = $id ? $table . ':' . $id : $table;
-        if (!is_array($workspaceRec)) {
-            return;
-        }
-
-        // Get the new stage title
-        $stageService = GeneralUtility::makeInstance(StagesService::class);
-        $newStage = $stageService->getStageTitle((int)$stageId);
-        if (empty($notificationAlternativeRecipients)) {
-            // Compile list of recipients:
-            $emails = [];
-            switch ((int)$stat['stagechg_notification']) {
-                // Notify users of next stage only
-                case 1:
-                    switch ((int)$stageId) {
-                        case 1:
-                        case 10:
-                            $emails = $this->getEmailsForStageChangeNotification($workspaceRec['adminusers'], true);
-                            break;
-                        case -1:
-                            // List of elements to reject:
-                            $allElements = explode(',', $elementName);
-                            // Traverse them, and find the history of each
-                            foreach ($allElements as $elRef) {
-                                [$eTable, $eUid] = explode(':', $elRef);
-
-                                $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
-                                    ->getQueryBuilderForTable('sys_log');
-
-                                $queryBuilder->getRestrictions()->removeAll();
-
-                                $result = $queryBuilder
-                                    ->select('log_data', 'tstamp', 'userid')
-                                    ->from('sys_log')
-                                    ->where(
-                                        $queryBuilder->expr()->eq(
-                                            'action',
-                                            $queryBuilder->createNamedParameter(6, \PDO::PARAM_INT)
-                                        ),
-                                        $queryBuilder->expr()->eq(
-                                            'details_nr',
-                                            $queryBuilder->createNamedParameter(30, \PDO::PARAM_INT)
-                                        ),
-                                        $queryBuilder->expr()->eq(
-                                            'tablename',
-                                            $queryBuilder->createNamedParameter($eTable, \PDO::PARAM_STR)
-                                        ),
-                                        $queryBuilder->expr()->eq(
-                                            'recuid',
-                                            $queryBuilder->createNamedParameter($eUid, \PDO::PARAM_INT)
-                                        )
-                                    )
-                                    ->orderBy('uid', 'DESC')
-                                    ->execute();
-
-                                // Find all implicated since the last stage-raise from editing to review:
-                                while ($dat = $result->fetch()) {
-                                    $data = unserialize($dat['log_data']);
-                                    $emails = $this->getEmailsForStageChangeNotification($dat['userid'], true) + $emails;
-                                    if ($data['stage'] == 1) {
-                                        break;
-                                    }
-                                }
-                            }
-                            break;
-                        case 0:
-                            $emails = $this->getEmailsForStageChangeNotification($workspaceRec['members']);
-                            break;
-                        default:
-                            $emails = $this->getEmailsForStageChangeNotification($workspaceRec['adminusers'], true);
-                    }
-                    break;
-                // Notify all users at all changes
-                case 10:
-                    $emails = $this->getEmailsForStageChangeNotification($workspaceRec['adminusers'], true);
-                    $emails = $this->getEmailsForStageChangeNotification($workspaceRec['members']) + $emails;
-                    break;
-                default:
-                    // Do nothing
-            }
-        } else {
-            $emails = $notificationAlternativeRecipients;
-        }
-        // prepare and then send the emails
-        if (!empty($emails)) {
-            $previewUriBuilder = GeneralUtility::makeInstance(PreviewUriBuilder::class);
-            // Path to record is found:
-            [$elementTable, $elementUid] = explode(':', $elementName);
-            $elementUid = (int)$elementUid;
-            $elementRecord = BackendUtility::getRecord($elementTable, $elementUid);
-            $recordTitle = BackendUtility::getRecordTitle($elementTable, $elementRecord);
-            if ($elementTable === 'pages') {
-                $pageUid = $elementUid;
-            } else {
-                BackendUtility::fixVersioningPid($elementTable, $elementRecord);
-                $pageUid = ($elementUid = $elementRecord['pid']);
-            }
-
-            // new way, options are
-            // pageTSconfig: tx_version.workspaces.stageNotificationEmail.subject
-            // userTSconfig: page.tx_version.workspaces.stageNotificationEmail.subject
-            $pageTsConfig = BackendUtility::getPagesTSconfig($pageUid);
-            $emailConfig = $pageTsConfig['tx_version.']['workspaces.']['stageNotificationEmail.'];
-            $markers = [
-                '###RECORD_TITLE###' => $recordTitle,
-                '###RECORD_PATH###' => BackendUtility::getRecordPath($elementUid, '', 20),
-                '###SITE_NAME###' => $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'],
-                '###SITE_URL###' => GeneralUtility::getIndpEnv('TYPO3_SITE_URL') . TYPO3_mainDir,
-                '###WORKSPACE_TITLE###' => $workspaceRec['title'],
-                '###WORKSPACE_UID###' => $workspaceRec['uid'],
-                '###ELEMENT_NAME###' => $elementName,
-                '###NEXT_STAGE###' => $newStage,
-                '###COMMENT###' => $comment,
-                // See: #30212 - keep both markers for compatibility
-                '###USER_REALNAME###' => $dataHandler->BE_USER->user['realName'],
-                '###USER_FULLNAME###' => $dataHandler->BE_USER->user['realName'],
-                '###USER_USERNAME###' => $dataHandler->BE_USER->user['username']
-            ];
-            // only generate the link if the marker is in the template - prevents database from getting to much entries
-            if (GeneralUtility::isFirstPartOfStr($emailConfig['message'], 'LLL:')) {
-                $tempEmailMessage = $this->getLanguageService()->sL($emailConfig['message']);
-            } else {
-                $tempEmailMessage = $emailConfig['message'];
-            }
-            if (strpos($tempEmailMessage, '###PREVIEW_LINK###') !== false) {
-                $markers['###PREVIEW_LINK###'] = $previewUriBuilder->buildUriForPage((int)$elementUid, 0);
-            }
-            unset($tempEmailMessage);
-
-            $markers['###SPLITTED_PREVIEW_LINK###'] = (string)$previewUriBuilder->buildUriForWorkspaceSplitPreview((int)$elementUid);
-            // Hook for preprocessing of the content for formmails:
-            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/version/class.tx_version_tcemain.php']['notifyStageChange-postModifyMarkers'] ?? [] as $className) {
-                $_procObj = GeneralUtility::makeInstance($className);
-                $markers = $_procObj->postModifyMarkers($markers, $this);
-            }
-            // send an email to each individual user, to ensure the
-            // multilanguage version of the email
-            $emailRecipients = [];
-            // an array of language objects that are needed
-            // for emails with different languages
-            $languageObjects = [
-                $this->getLanguageService()->lang => $this->getLanguageService()
-            ];
-            // loop through each recipient and send the email
-            foreach ($emails as $recipientData) {
-                // don't send an email twice
-                if (isset($emailRecipients[$recipientData['email']])) {
-                    continue;
-                }
-                $emailSubject = $emailConfig['subject'];
-                $emailMessage = $emailConfig['message'];
-                $emailRecipients[$recipientData['email']] = $recipientData['email'];
-                // check if the email needs to be localized
-                // in the users' language
-                if (GeneralUtility::isFirstPartOfStr($emailSubject, 'LLL:') || GeneralUtility::isFirstPartOfStr($emailMessage, 'LLL:')) {
-                    $recipientLanguage = $recipientData['lang'] ?: 'default';
-                    if (!isset($languageObjects[$recipientLanguage])) {
-                        // a LANG object in this language hasn't been
-                        // instantiated yet, so this is done here
-                        $languageObject = GeneralUtility::makeInstance(LanguageService::class);
-                        $languageObject->init($recipientLanguage);
-                        $languageObjects[$recipientLanguage] = $languageObject;
-                    } else {
-                        $languageObject = $languageObjects[$recipientLanguage];
-                    }
-                    if (GeneralUtility::isFirstPartOfStr($emailSubject, 'LLL:')) {
-                        $emailSubject = $languageObject->sL($emailSubject);
-                    }
-                    if (GeneralUtility::isFirstPartOfStr($emailMessage, 'LLL:')) {
-                        $emailMessage = $languageObject->sL($emailMessage);
-                    }
-                }
-                $templateService = GeneralUtility::makeInstance(MarkerBasedTemplateService::class);
-                $emailSubject = $templateService->substituteMarkerArray($emailSubject, $markers, '', true, true);
-                $emailMessage = $templateService->substituteMarkerArray($emailMessage, $markers, '', true, true);
-                // Send an email to the recipient
-                $mail = GeneralUtility::makeInstance(MailMessage::class);
-                $recipient = new Address($recipientData['email'], $recipientData['realName']);
-                $mail->to($recipient)
-                    ->subject($emailSubject)
-                    ->html($emailMessage);
-                $mail->send();
-            }
-            $emailRecipients = implode(',', $emailRecipients);
-            if ($dataHandler->enableLogging) {
-                $propertyArray = $dataHandler->getRecordProperties($table, $id);
-                $pid = $propertyArray['pid'];
-                $dataHandler->log($table, $id, SystemLogGenericAction::UNDEFINED, 0, SystemLogErrorClassification::MESSAGE, 'Notification email for stage change was sent to "' . $emailRecipients . '"', -1, [], $dataHandler->eventPid($table, $id, $pid));
-            }
-        }
-    }
-
-    /**
-     * Return be_users that should be notified on stage change from input list.
-     * previously called notifyStageChange_getEmails() in DataHandler
-     *
-     * @param string $listOfUsers List of backend users, on the form "be_users_10,be_users_2" or "10,2" in case noTablePrefix is set.
-     * @param bool $noTablePrefix If set, the input list are integers and not strings.
-     * @return array Array of emails
-     */
-    protected function getEmailsForStageChangeNotification($listOfUsers, bool $noTablePrefix = false): array
-    {
-        $users = GeneralUtility::trimExplode(',', $listOfUsers, true);
-        $emails = [];
-        foreach ($users as $userIdent) {
-            $table = '';
-            if ($noTablePrefix) {
-                $id = (int)$userIdent;
-            } else {
-                [$table, $id] = GeneralUtility::revExplode('_', $userIdent, 2);
-            }
-            if ($table === 'be_users' || $noTablePrefix) {
-                if ($userRecord = BackendUtility::getRecord('be_users', $id, 'uid,email,lang,realName', BackendUtility::BEenableFields('be_users'))) {
-                    if (trim($userRecord['email']) !== '') {
-                        $emails[$id] = $userRecord;
-                    }
-                }
-            }
-        }
-        return $emails;
-    }
-
     /****************************
      *****  Stage Changes  ******
      ****************************/
@@ -713,7 +505,7 @@ class DataHandlerHook
             $dataHandler->newlog('Attempt to set stage for record failed: ' . $errorCode, SystemLogErrorClassification::USER_ERROR);
         } elseif ($dataHandler->checkRecordUpdateAccess($table, $id)) {
             $record = BackendUtility::getRecord($table, $id);
-            $stat = $dataHandler->BE_USER->checkWorkspace($record['t3ver_wsid']);
+            $workspaceInfo = $dataHandler->BE_USER->checkWorkspace($record['t3ver_wsid']);
             // check if the user is allowed to the current stage, so it's also allowed to send to next stage
             if ($dataHandler->BE_USER->workspaceCheckStageForCurrent($record['t3ver_stage'])) {
                 // Set stage of record:
@@ -734,10 +526,10 @@ class DataHandlerHook
                 }
                 // TEMPORARY, except 6-30 as action/detail number which is observed elsewhere!
                 $dataHandler->log($table, $id, DatabaseAction::UPDATE, 0, SystemLogErrorClassification::MESSAGE, 'Stage raised...', 30, ['comment' => $comment, 'stage' => $stageId]);
-                if ((int)$stat['stagechg_notification'] > 0) {
-                    $this->notificationEmailInfo[$stat['uid'] . ':' . $stageId . ':' . $comment]['shared'] = [$stat, $stageId, $comment];
-                    $this->notificationEmailInfo[$stat['uid'] . ':' . $stageId . ':' . $comment]['elements'][] = $table . ':' . $id;
-                    $this->notificationEmailInfo[$stat['uid'] . ':' . $stageId . ':' . $comment]['alternativeRecipients'] = $notificationAlternativeRecipients;
+                if ((int)$workspaceInfo['stagechg_notification'] > 0) {
+                    $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['shared'] = [$workspaceInfo, $stageId, $comment];
+                    $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['elements'][] = [$table, $id];
+                    $this->notificationEmailInfo[$workspaceInfo['uid'] . ':' . $stageId . ':' . $comment]['recipients'] = $notificationAlternativeRecipients;
                 }
             } else {
                 $dataHandler->newlog('The member user tried to set a stage value "' . $stageId . '" that was not allowed', SystemLogErrorClassification::USER_ERROR);
@@ -1038,8 +830,8 @@ class DataHandlerHook
             $stageId = StagesService::STAGE_PUBLISH_EXECUTE_ID;
             $notificationEmailInfoKey = $wsAccess['uid'] . ':' . $stageId . ':' . $comment;
             $this->notificationEmailInfo[$notificationEmailInfoKey]['shared'] = [$wsAccess, $stageId, $comment];
-            $this->notificationEmailInfo[$notificationEmailInfoKey]['elements'][] = $table . ':' . $id;
-            $this->notificationEmailInfo[$notificationEmailInfoKey]['alternativeRecipients'] = $notificationAlternativeRecipients;
+            $this->notificationEmailInfo[$notificationEmailInfoKey]['elements'][] = [$table, $id];
+            $this->notificationEmailInfo[$notificationEmailInfoKey]['recipients'] = $notificationAlternativeRecipients;
             // Write to log with stageId -20 (STAGE_PUBLISH_EXECUTE_ID)
             if ($dataHandler->enableLogging) {
                 $propArr = $dataHandler->getRecordProperties($table, $id);
diff --git a/typo3/sysext/workspaces/Classes/Notification/StageChangeNotification.php b/typo3/sysext/workspaces/Classes/Notification/StageChangeNotification.php
new file mode 100644
index 0000000000000000000000000000000000000000..eb5fd8a64730c92c3ad3f8346dc9fc23195fb5cd
--- /dev/null
+++ b/typo3/sysext/workspaces/Classes/Notification/StageChangeNotification.php
@@ -0,0 +1,153 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Workspaces\Notification;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ServerRequestInterface;
+use Symfony\Component\Mime\Address;
+use TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Mail\FluidEmail;
+use TYPO3\CMS\Core\Mail\Mailer;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Fluid\View\TemplatePaths;
+use TYPO3\CMS\Workspaces\Preview\PreviewUriBuilder;
+use TYPO3\CMS\Workspaces\Service\StagesService;
+
+/**
+ * Responsible for sending out emails when one or multiple records have been changed / sent to the next stage.
+ *
+ * Relevant options are "tx_workspaces.emails.*" via userTS / pageTS.
+ *
+ * @internal This is a concrete implementation of sending out emails, and not part of the public TYPO3 Core API
+ */
+class StageChangeNotification
+{
+    /**
+     * @var StagesService
+     */
+    protected $stagesService;
+
+    /**
+     * @var PreviewUriBuilder
+     */
+    protected $previewUriBuilder;
+
+    /**
+     * @var Mailer
+     */
+    protected $mailer;
+
+    public function __construct()
+    {
+        $this->stagesService = GeneralUtility::makeInstance(StagesService::class);
+        $this->previewUriBuilder = GeneralUtility::makeInstance(PreviewUriBuilder::class);
+        $this->mailer = GeneralUtility::makeInstance(Mailer::class);
+    }
+
+    /**
+     * Send an email notification to users in workspace in multiple languages, depending on each BE users' langauge
+     * preference.
+     *
+     * @param array $workspaceRecord
+     * @param int $stageId Next Stage Number
+     * @param array $affectedElements List of element names (table / uid pairs)
+     * @param string $comment User comment sent along with action
+     * @param array $recipients List of recipients to notify, list is generated by workspace extension module
+     * @param BackendUserAuthentication $currentUser
+     */
+    public function notifyStageChange(array $workspaceRecord, int $stageId, array $affectedElements, string $comment, array $recipients, BackendUserAuthentication $currentUser): void
+    {
+        [$elementTable, $elementUid] = reset($affectedElements);
+        $elementUid = (int)$elementUid;
+        $elementRecord = BackendUtility::getRecord($elementTable, $elementUid);
+        $recordTitle = BackendUtility::getRecordTitle($elementTable, $elementRecord);
+        $pageUid = $this->findFirstPageId($elementTable, $elementUid, $elementRecord);
+
+        $emailConfig = BackendUtility::getPagesTSconfig($pageUid)['tx_workspaces.']['emails.'] ?? [];
+        $emailConfig = GeneralUtility::removeDotsFromTS($emailConfig);
+        $viewPlaceholders = [
+            'pageId' => $pageUid,
+            'workspace' => $workspaceRecord,
+            'rootLine' => BackendUtility::getRecordPath($pageUid, '', 20),
+            'currentUser' => $currentUser->user,
+            'additionalMessage' => $comment,
+            'recordTitle' => $recordTitle,
+            'affectedElements' => $affectedElements,
+            'nextStage' => $this->stagesService->getStageTitle($stageId),
+            'comparisonView' => (string)$this->previewUriBuilder->buildUriForWorkspaceSplitPreview($pageUid)
+        ];
+
+        if ($emailConfig['stageChangeNotification']['generatePreviewLink']) {
+            $viewPlaceholders['previewLink'] = $this->previewUriBuilder->buildUriForPage($pageUid, 0);
+        }
+
+        $sentEmails = [];
+        foreach ($recipients as $recipientData) {
+            // don't send an email twice
+            if (in_array($recipientData['email'], $sentEmails, true)) {
+                continue;
+            }
+            $sentEmails[] = $recipientData['email'];
+            $this->sendEmail($recipientData, $emailConfig, $viewPlaceholders);
+        }
+    }
+
+    /**
+     * As it is possible that multiple elements are sent out, or multiple pages, the first "real" page ID is found.
+     *
+     * @param string $elementTable the table of the first element found
+     * @param int $elementUid the uid of the first element in the list
+     * @param array $elementRecord the full record
+     * @return int the corresponding page ID
+     */
+    protected function findFirstPageId(string $elementTable, int $elementUid, array $elementRecord): int
+    {
+        if ($elementTable === 'pages') {
+            return $elementUid;
+        }
+        BackendUtility::fixVersioningPid($elementTable, $elementRecord);
+        return (int)$elementRecord['pid'];
+    }
+
+    /**
+     * Send one email to a specific person, apply multi-language possibilities for sending this email out.
+     *
+     * @param array $recipientData
+     * @param array $emailConfig
+     * @param array $variablesForView
+     */
+    protected function sendEmail(array $recipientData, array $emailConfig, array $variablesForView): void
+    {
+        $templatePaths = new TemplatePaths(array_replace_recursive($GLOBALS['TYPO3_CONF_VARS']['MAIL'], $emailConfig));
+        $emailObject = GeneralUtility::makeInstance(FluidEmail::class, $templatePaths);
+        $emailObject
+            ->to(new Address($recipientData['email'], $recipientData['realName'] ?? ''))
+            // Will be overridden by the template
+            ->subject('TYPO3 Workspaces: Stage Change')
+            ->setTemplate('StageChangeNotification')
+            ->assignMultiple($variablesForView)
+            ->assign('language', $recipientData['lang'] ?? 'default');
+
+        // Injecting normalized params
+        if ($GLOBALS['TYPO3_REQUEST'] instanceof ServerRequestInterface) {
+            $emailObject->setRequest($GLOBALS['TYPO3_REQUEST']);
+        }
+        if ($emailConfig['format']) {
+            $emailObject->format($emailConfig['format']);
+        }
+        $this->mailer->send($emailObject);
+    }
+}
diff --git a/typo3/sysext/workspaces/Classes/Service/StagesService.php b/typo3/sysext/workspaces/Classes/Service/StagesService.php
index cbff406126c3942d7dea8619c9b8666ad5e0d9bd..765aee629b9692b7ce888a6b69795d3c88f3d621 100644
--- a/typo3/sysext/workspaces/Classes/Service/StagesService.php
+++ b/typo3/sysext/workspaces/Classes/Service/StagesService.php
@@ -36,9 +36,6 @@ class StagesService implements SingletonInterface
     // ready to publish stage
     const STAGE_PUBLISH_ID = -10;
     const STAGE_EDIT_ID = 0;
-    const MODE_NOTIFY_SOMEONE = 0;
-    const MODE_NOTIFY_ALL = 1;
-    const MODE_NOTIFY_ALL_STRICT = 2;
 
     /**
      * Path to the locallang file
diff --git a/typo3/sysext/workspaces/Resources/Private/Language/locallang_emails.xlf b/typo3/sysext/workspaces/Resources/Private/Language/locallang_emails.xlf
deleted file mode 100644
index 4914aa499ebffd73872a91e1e90773677b589f21..0000000000000000000000000000000000000000
--- a/typo3/sysext/workspaces/Resources/Private/Language/locallang_emails.xlf
+++ /dev/null
@@ -1,23 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<xliff version="1.0" xmlns:t3="http://typo3.org/schemas/xliff">
-	<file t3:id="1415815010" source-language="en" datatype="plaintext" original="EXT:workspaces/Resources/Private/Language/locallang_emails.xlf" date="2011-10-17T20:22:37Z" product-name="workspaces">
-		<header/>
-		<body>
-			<trans-unit id="subject" resname="subject">
-				<source>TYPO3 Workspace Note: Stage Change for ###ELEMENT_NAME###</source>
-			</trans-unit>
-			<trans-unit id="message" resname="message" xml:space="preserve">
-				<source>At the TYPO3 site "###SITE_NAME###" (###SITE_URL###)
-in workspace "###WORKSPACE_TITLE###" (###WORKSPACE_UID###)
-the stage has changed for the element(s) "###RECORD_TITLE###" (###ELEMENT_NAME###) at location "###RECORD_PATH###" in the page tree:
-
-=&gt; ###NEXT_STAGE###
-
-User Comment:
-"###COMMENT###"
-
-State was changed by ###USER_FULLNAME### (username: ###USER_USERNAME###)</source>
-			</trans-unit>
-		</body>
-	</file>
-</xliff>
diff --git a/typo3/sysext/workspaces/Resources/Private/Templates/Email/StageChangeNotification.html b/typo3/sysext/workspaces/Resources/Private/Templates/Email/StageChangeNotification.html
new file mode 100644
index 0000000000000000000000000000000000000000..37f220927dec5232615fc258dba92c89d856e7b0
--- /dev/null
+++ b/typo3/sysext/workspaces/Resources/Private/Templates/Email/StageChangeNotification.html
@@ -0,0 +1,35 @@
+<f:layout name="SystemEmail" />
+<f:section name="Subject">There are new changes in workspace "{workspace.title}"</f:section>
+<f:section name="Title">{affectedElements -> f:count()} items sent to stage "{nextStage}" in workspace "{workspace.title}"</f:section>
+<f:section name="Main">
+    <p>
+        At the TYPO3 site "{typo3.sitename}" in workspace "{workspace.title}" ({workspace.uid})
+        the stage has changed to "{nextStage}" for the element(s):
+    </p>
+    <ul>
+        <f:for each="{affectedElements}" as="element">
+            <li>{element.0}:{element.1}</li>
+        </f:for>
+    </ul>
+
+    <p>
+        The first entry was "{recordTitle}" at location <code>"{rootLine}"</code> in the page tree.
+    </p>
+    <p>
+        See the changes in the <a href="{comparisonView}">comparison view</a>.
+    </p>
+
+    <f:if condition="{previewLink}">
+        <p><a href="{previewLink}" rel="noopener">See a preview of the changed page.</a>
+    </f:if>
+
+    <p>
+        The stage was changed by <strong>{currentUser.realName}</strong> ({currentUser.username}).
+    </p>
+
+    <f:if condition="{additionalMessage}">
+        <p>&nbsp;</p>
+        <h3>Additional comment</h3>
+        <p><f:format.nl2br>{additionalMessage}</f:format.nl2br></p>
+    </f:if>
+</f:section>
diff --git a/typo3/sysext/workspaces/Resources/Private/Templates/Email/StageChangeNotification.txt b/typo3/sysext/workspaces/Resources/Private/Templates/Email/StageChangeNotification.txt
new file mode 100644
index 0000000000000000000000000000000000000000..60ff0a1a2f0e2619a6a014a1c4816f38ccf7673f
--- /dev/null
+++ b/typo3/sysext/workspaces/Resources/Private/Templates/Email/StageChangeNotification.txt
@@ -0,0 +1,26 @@
+<f:layout name="SystemEmail" />
+<f:section name="Subject">There are new changes in workspace "{workspace.title}"</f:section>
+<f:section name="Title">{affectedElements -> f:count()} items sent to stage "{nextStage}" in workspace "{workspace.title}"</f:section>
+<f:section name="Main">
+At the TYPO3 site "{typo3.sitename}" in workspace "{workspace.title}" ({workspace.uid}) the stage has changed to "{nextStage}" for the element(s):
+
+<f:for each="{affectedElements}" as="element">
+ *{element.0}:{element.1}
+</f:for>
+
+The first entry was "{recordTitle}" at location "{rootLine}" in the page tree.
+
+See the changes in the comparison view {comparisonView}.
+<f:if condition="{previewLink}">
+See a preview of the changed page here {previewLink}
+</f:if>
+
+The stage was changed by <strong>{currentUser.realName}</strong> ({currentUser.username}).
+
+<f:if condition="{additionalMessage}">
+
+Additional comment:
+
+<f:format.nl2br>{additionalMessage}</f:format.nl2br>
+</f:if>
+</f:section>
diff --git a/typo3/sysext/workspaces/ext_localconf.php b/typo3/sysext/workspaces/ext_localconf.php
index 2fed93507488c2abd01b335b376e58e4cb908a0b..f8ec77a8be60a9cc6e0a08e29ef0051cef000d2f 100644
--- a/typo3/sysext/workspaces/ext_localconf.php
+++ b/typo3/sysext/workspaces/ext_localconf.php
@@ -3,9 +3,11 @@ defined('TYPO3_MODE') or die();
 
 // add default notification options to every page
 \TYPO3\CMS\Core\Utility\ExtensionManagementUtility::addPageTSConfig('
-tx_version.workspaces.stageNotificationEmail.subject = LLL:EXT:workspaces/Resources/Private/Language/locallang_emails.xlf:subject
-tx_version.workspaces.stageNotificationEmail.message = LLL:EXT:workspaces/Resources/Private/Language/locallang_emails.xlf:message
-# tx_version.workspaces.stageNotificationEmail.additionalHeaders =
+tx_workspaces.emails.stageChangeNotification.generatePreviewLink = 0
+tx_workspaces.emails.layoutRootPaths.90 = EXT:workspaces/Resources/Private/Layouts/
+tx_workspaces.emails.partialRootPaths.90 = EXT:workspaces/Resources/Private/Partials/
+tx_workspaces.emails.templateRootPaths.90 = EXT:workspaces/Resources/Private/Templates/Email/
+tx_workspaces.emails.format = html
 ');
 
 // register the hook to actually do the work within DataHandler