NobGit
public nobgit read

NobMail

Based on mailcow: dockerized

Languages

Repository composition by tracked source files.

PHP
PHP 49% JavaScript 35% HTML 9% CSS 4% Shell 2% Python 1% Lua 0% Perl 0% Ruby 0% SCSS 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/nobgit/nobmail.git
ssh://[email protected]:2222/orgs/nobgit/nobmail.git

Commit

Merge remote-tracking branch 'origin/staging' into nightly

962ac39e
FreddleSpl0it <[email protected]> 1 year, 11 months ago
.github/PULL_REQUEST_TEMPLATE.md                   |  38 ++
 CONTRIBUTING.md                                    |  33 +-
 data/assets/templates/pw_reset_html.tpl            |  29 ++
 data/assets/templates/pw_reset_text.tpl            |  11 +
 data/web/admin.php                                 |   1 +
 data/web/inc/functions.inc.php                     | 438 ++++++++++++++++++++-
 data/web/inc/functions.mailbox.inc.php             | 115 +++---
 data/web/inc/init_db.inc.php                       |  16 +-
 data/web/inc/triggers.inc.php                      |  60 ++-
 data/web/inc/vars.inc.php                          |   6 +
 data/web/js/site/mailbox.js                        |   3 +
 data/web/json_api.php                              |   4 +-
 data/web/lang/lang.de-de.json                      |  26 ++
 data/web/lang/lang.en-gb.json                      |  26 ++
 data/web/reset-password.php                        |  31 ++
 data/web/templates/admin.twig                      |   4 +-
 .../admin/tab-config-password-policy.twig          |  40 --
 .../admin/tab-config-password-settings.twig        | 102 +++++
 data/web/templates/edit/mailbox-templates.twig     |   1 +
 data/web/templates/edit/mailbox.twig               |   7 +
 data/web/templates/index.twig                      |   3 +
 data/web/templates/modals/mailbox.twig             |   2 +
 data/web/templates/modals/user.twig                |  27 ++
 data/web/templates/reset-password.twig             |  57 +++
 data/web/templates/user/tab-user-auth.twig         |   5 +-
 25 files changed, 958 insertions(+), 127 deletions(-)
 create mode 100644 .github/PULL_REQUEST_TEMPLATE.md
 create mode 100644 data/assets/templates/pw_reset_html.tpl
 create mode 100644 data/assets/templates/pw_reset_text.tpl
 create mode 100644 data/web/reset-password.php
 delete mode 100644 data/web/templates/admin/tab-config-password-policy.twig
 create mode 100644 data/web/templates/admin/tab-config-password-settings.twig
 create mode 100644 data/web/templates/reset-password.twig

Diff

diff --cc data/web/inc/functions.inc.php
index 4d63e04c,25d08b9f..97f97a42
--- a/data/web/inc/functions.inc.php
+++ b/data/web/inc/functions.inc.php
@@@ -918,20 -1092,24 +922,24 @@@ function edit_user_account($_data) 
      );
      return false;
    }
-   $stmt = $pdo->prepare("SELECT `password` FROM `mailbox`
-       WHERE `kind` NOT REGEXP 'location|thing|group'
-         AND `username` = :user AND authsource = 'mailcow'");
-   $stmt->execute(array(':user' => $username));
-   $row = $stmt->fetch(PDO::FETCH_ASSOC);
-   if (!verify_hash($row['password'], $password_old)) {
-     $_SESSION['return'][] =  array(
-       'type' => 'danger',
-       'log' => array(__FUNCTION__, $_data_log),
-       'msg' => 'access_denied'
-     );
-     return false;
-   }
-   if (!empty($_data['user_new_pass']) && !empty($_data['user_new_pass2'])) {
+ 
+   // edit password
+   if (!empty($password_old) && !empty($_data['user_new_pass']) && !empty($_data['user_new_pass2'])) {
+     $stmt = $pdo->prepare("SELECT `password` FROM `mailbox`
+         WHERE `kind` NOT REGEXP 'location|thing|group'
 -          AND `username` = :user");
++          AND `username` = :user AND authsource = 'mailcow'");
+     $stmt->execute(array(':user' => $username));
+     $row = $stmt->fetch(PDO::FETCH_ASSOC);
+   
+     if (!verify_hash($row['password'], $password_old)) {
+       $_SESSION['return'][] =  array(
+         'type' => 'danger',
+         'log' => array(__FUNCTION__, $_data_log),
+         'msg' => 'access_denied'
+       );
+       return false;
+     }
+   
      $password_new = $_data['user_new_pass'];
      $password_new2  = $_data['user_new_pass2'];
      if (password_check($password_new, $password_new2) !== true) {
@@@ -946,8 -1124,29 +954,29 @@@
        ':password_hashed' => $password_hashed,
        ':username' => $username
      ));
+   
+     update_sogo_static_view();
+   }
+   // edit password recovery email
+   elseif (isset($pw_recovery_email)) {
+     if (!isset($_SESSION['acl']['pw_reset']) || $_SESSION['acl']['pw_reset'] != "1" ) {
+       $_SESSION['return'][] = array(
+         'type' => 'danger',
+         'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+         'msg' => 'access_denied'
+       );
+       return false;
+     }
+ 
+     $pw_recovery_email = (!filter_var($pw_recovery_email, FILTER_VALIDATE_EMAIL)) ? '' : $pw_recovery_email;
+     $stmt = $pdo->prepare("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email)
 -      WHERE `username` = :username");
++      WHERE `username` = :username AND authsource = 'mailcow'");
+     $stmt->execute(array(
+       ':recovery_email' => $pw_recovery_email,
+       ':username' => $username
+     ));
    }
-   update_sogo_static_view();
+ 
    $_SESSION['return'][] =  array(
      'type' => 'success',
      'log' => array(__FUNCTION__, $_data_log),
@@@ -2097,573 -2290,387 +2126,952 @@@ function uuid4() 
  
    return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
  }
 +function identity_provider($_action = null, $_data = null, $_extra = null) {
 +  global $pdo;
 +  global $iam_provider;
 +
 +  $data_log = $_data;
 +  if (isset($data_log['client_secret'])) $data_log['client_secret'] = '*';
 +  if (isset($data_log['access_token'])) $data_log['access_token'] = '*';
 +
 +  switch ($_action) {
 +    case NULL:
 +      if ($iam_provider) {
 +        return $iam_provider;
 +      } else {
 +        $iam_provider = identity_provider("init");
 +        return $iam_provider;
 +      }
 +    break;
 +    case 'get':
 +      $settings = array();
 +      $stmt = $pdo->prepare("SELECT * FROM `identity_provider`;");
 +      $stmt->execute();
 +      $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
 +      foreach($rows as $row){
 +        switch ($row["key"]) {
 +          case "mappers":
 +          case "templates":
 +            $settings[$row["key"]] = json_decode($row["value"]);
 +          break;
 +          case "use_ssl":
 +          case "use_tls":
 +          case "ignore_ssl_errors":
 +            $settings[$row["key"]] = boolval($row["value"]);
 +          break;
 +          default:
 +            $settings[$row["key"]] = $row["value"];
 +          break;
 +        }
 +      }
 +      // return default client_scopes for generic-oidc if none is set
 +      if ($settings["authsource"] == "generic-oidc" && empty($settings["client_scopes"])){
 +        $settings["client_scopes"] = "openid profile email";
 +      }
 +      if ($_extra['hide_sensitive']){
 +        $settings['client_secret'] = '';
 +        $settings['access_token'] = '';
 +      }
 +      // return default ldap options
 +      if ($settings["authsource"] == "ldap"){
 +        $settings['use_ssl'] = !isset($settings['use_ssl']) ? false : $settings['use_ssl'];
 +        $settings['use_tls'] = !isset($settings['use_tls']) ? false : $settings['use_tls'];
 +        $settings['ignore_ssl_errors'] = !isset($settings['ignore_ssl_errors']) ? false : $settings['ignore_ssl_errors'];
 +      }
 +      return $settings;
 +    break;
 +    case 'edit':
 +      if ($_SESSION['mailcow_cc_role'] != "admin") {
 +        $_SESSION['return'][] = array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $_action, $_data),
 +          'msg' => 'access_denied'
 +        );
 +        return false;
 +      }
 +      if (!isset($_data['authsource'])){
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $_action, $data_log),
 +          'msg' => array('required_data_missing', '')
 +        );
 +        return false;
 +      }
 +
 +      $available_authsources = array(
 +        "keycloak",
 +        "generic-oidc",
 +        "ldap"
 +      );
 +      $_data['authsource'] = strtolower($_data['authsource']);
 +      if (!in_array($_data['authsource'], $available_authsources)){
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $_action, $data_log),
 +          'msg' => array('invalid_authsource', $setting)
 +        );
 +        return false;
 +      }
 +
 +      $stmt = $pdo->prepare("SELECT * FROM `mailbox`
 +          WHERE `authsource` != 'mailcow'
 +          AND `authsource` IS NOT NULL
 +          AND `authsource` != :authsource");  
 +      $stmt->execute(array(':authsource' => $_data['authsource']));
 +      $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
 +      if ($rows) {
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $_action, $data_log),
 +          'msg' => array('authsource_in_use', $setting)
 +        );
 +        return false;
 +      }
 +
 +      switch ($_data['authsource']) {
 +        case "keycloak":
 +          $_data['server_url']        = (!empty($_data['server_url'])) ? rtrim($_data['server_url'], '/') : null;
 +          $_data['mailpassword_flow'] = isset($_data['mailpassword_flow']) ? intval($_data['mailpassword_flow']) : 0;
 +          $_data['periodic_sync']     = isset($_data['periodic_sync']) ? intval($_data['periodic_sync']) : 0;
 +          $_data['import_users']      = isset($_data['import_users']) ? intval($_data['import_users']) : 0;
 +          $_data['sync_interval']     = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15;
 +          $_data['sync_interval']     = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval'];
 +          $required_settings          = array('authsource', 'server_url', 'realm', 'client_id', 'client_secret', 'redirect_url', 'version', 'mailpassword_flow', 'periodic_sync', 'import_users', 'sync_interval');
 +        break;
 +        case "generic-oidc":
 +          $_data['authorize_url']     = (!empty($_data['authorize_url'])) ? $_data['authorize_url'] : null;
 +          $_data['token_url']         = (!empty($_data['token_url'])) ? $_data['token_url'] : null;
 +          $_data['userinfo_url']      = (!empty($_data['userinfo_url'])) ? $_data['userinfo_url'] : null;
 +          $_data['client_scopes']     = (!empty($_data['client_scopes'])) ? $_data['client_scopes'] : "openid profile email";
 +          $required_settings          = array('authsource', 'authorize_url', 'token_url', 'client_id', 'client_secret', 'redirect_url', 'userinfo_url', 'client_scopes');
 +        break;
 +        case "ldap":
 +          $_data['port']              = (!empty($_data['port'])) ? intval($_data['port']) : 389;
 +          $_data['username_field']    = (!empty($_data['username_field'])) ? $_data['username_field'] : "mail";
 +          $_data['filter']            = (!empty($_data['filter'])) ? $_data['filter'] : "";
 +          $_data['periodic_sync']     = isset($_data['periodic_sync']) ? intval($_data['periodic_sync']) : 0;
 +          $_data['import_users']      = isset($_data['import_users']) ? intval($_data['import_users']) : 0;
 +          $_data['use_ssl']           = isset($_data['use_ssl']) ? boolval($_data['use_ssl']) : false;
 +          $_data['use_tls']           = isset($_data['use_tls']) && !$_data['use_ssl'] ? boolval($_data['use_tls']) : false;
 +          $_data['ignore_ssl_error']  = isset($_data['ignore_ssl_error']) ? boolval($_data['ignore_ssl_error']) : false;
 +          $_data['sync_interval']     = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15;
 +          $_data['sync_interval']     = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval'];
 +          $required_settings          = array('authsource', 'host', 'port', 'basedn', 'username_field', 'filter', 'attribute_field', 'binddn', 'bindpass', 'periodic_sync', 'import_users', 'sync_interval', 'use_ssl', 'use_tls', 'ignore_ssl_error');
 +        break;
 +      }
 +      
 +      $pdo->beginTransaction();
 +      $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES (:key, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);");
 +      // add connection settings
 +      foreach($required_settings as $setting){
 +        if (!isset($_data[$setting])){
 +          $_SESSION['return'][] =  array(
 +            'type' => 'danger',
 +            'log' => array(__FUNCTION__, $_action, $data_log),
 +            'msg' => array('required_data_missing', $setting)
 +          );
 +          $pdo->rollback();
 +          return false;
 +        }
 +
 +        $stmt->bindParam(':key', $setting);
 +        $stmt->bindParam(':value', $_data[$setting]);
 +        $stmt->execute();
 +      }
 +      $pdo->commit();
 +
 +      // add mappers
 +      if ($_data['mappers'] && $_data['templates']){
 +        $_data['mappers'] = (!is_array($_data['mappers'])) ? array($_data['mappers']) : $_data['mappers'];
 +        $_data['templates'] = (!is_array($_data['templates'])) ? array($_data['templates']) : $_data['templates'];
 +
 +        $mappers = array_filter($_data['mappers']);
 +        $templates = array_filter($_data['templates']);
 +        if (count($mappers) == count($templates)){
 +          $mappers = json_encode($mappers);
 +          $templates = json_encode($templates);
 +
 +          $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES ('mappers', :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);");
 +          $stmt->bindParam(':value', $mappers);
 +          $stmt->execute();
 +          $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES ('templates', :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);");
 +          $stmt->bindParam(':value', $templates);
 +          $stmt->execute();
 +        }
 +      }
 +
 +      // delete old access_token
 +      $stmt = $pdo->query("INSERT INTO identity_provider (`key`, `value`) VALUES ('access_token', '') ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);");
 +
 +      $_SESSION['return'][] =  array(
 +        'type' => 'success',
 +        'log' => array(__FUNCTION__, $_action, $data_log),
 +        'msg' => array('object_modified', '')
 +      );
 +      return true;
 +    break;
 +    case 'test':
 +      if ($_SESSION['mailcow_cc_role'] != "admin") {
 +        $_SESSION['return'][] = array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $_action, $_data),
 +          'msg' => 'access_denied'
 +        );
 +        return false;
 +      }
 +
 +      switch ($_data['authsource']) {
 +        case 'keycloak':
 +        case 'generic-oidc':
 +          if ($_data['authsource'] == 'keycloak') {
 +            $url = "{$_data['server_url']}/realms/{$_data['realm']}/protocol/openid-connect/token";
 +          } else {
 +            $url = $_data['token_url'];
 +          }
 +          $req = http_build_query(array(
 +            'grant_type'    => 'client_credentials',
 +            'client_id'     => $_data['client_id'],
 +            'client_secret' => $_data['client_secret']
 +          ));
 +          $curl = curl_init();
 +          curl_setopt($curl, CURLOPT_URL, $url);
 +          curl_setopt($curl, CURLOPT_TIMEOUT, 7);
 +          curl_setopt($curl, CURLOPT_POST, 1);
 +          curl_setopt($curl, CURLOPT_POSTFIELDS, $req);
 +          curl_setopt($curl, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
 +          curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
 +          $res = curl_exec($curl);
 +          $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
 +          curl_close ($curl);
 +          
 +          if ($code != 200) {
 +            return false;
 +          }
 +        break;
 +        case 'ldap':
 +          if (!$_data['host'] || !$_data['port'] || !$_data['basedn'] ||
 +            !$_data['binddn'] || !$_data['bindpass']){
 +              return false;
 +          }
 +          $_data['use_ssl'] = isset($_data['use_ssl']) ? boolval($_data['use_ssl']) : false;
 +          $_data['use_tls'] = isset($_data['use_tls']) && !$_data['use_ssl'] ? boolval($_data['use_tls']) : false;
 +          $_data['ignore_ssl_error'] = isset($_data['ignore_ssl_error']) ? boolval($_data['ignore_ssl_error']) : false;
 +          $options = array();
 +          if ($_data['ignore_ssl_error']) {
 +            $options['LDAP_OPT_X_TLS_REQUIRE_CERT'] = "LDAP_OPT_X_TLS_NEVER";
 +          }
 +          $provider = new \LdapRecord\Connection([
 +            'hosts'                     => [$_data['host']],
 +            'port'                      => $_data['port'],
 +            'base_dn'                   => $_data['basedn'],
 +            'username'                  => $_data['binddn'],
 +            'password'                  => $_data['bindpass'],
 +            'use_ssl'                   => $_data['use_ssl'],
 +            'use_tls'                   => $_data['use_tls'],
 +            'options'                   => $options
 +          ]);
 +          try {
 +            $provider->connect();
 +          } catch (TypeError $e) {
 +            return false;
 +          } catch (\LdapRecord\Auth\BindException $e) {
 +            return false;
 +          }
 +        break;
 +      }
 +
 +      return true;
 +    break;
 +    case "delete":
 +      if ($_SESSION['mailcow_cc_role'] != "admin") {
 +        $_SESSION['return'][] = array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $_action, $_data),
 +          'msg' => 'access_denied'
 +        );
 +        return false;
 +      }
 +      
 +      $stmt = $pdo->query("SELECT * FROM `mailbox`
 +          WHERE `authsource` != 'mailcow'
 +          AND `authsource` IS NOT NULL");
 +      $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
 +      if ($rows) {
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $_action, $data_log),
 +          'msg' => array('authsource_in_use', $setting)
 +        );
 +        return false;
 +      }
 +
 +      $stmt = $pdo->query("DELETE FROM identity_provider;");
 +
 +      $_SESSION['return'][] =  array(
 +        'type' => 'success',
 +        'log' => array(__FUNCTION__, $_action, $data_log),
 +        'msg' => array('item_deleted', '')
 +      );
 +      return true;
 +    break;
 +    case "init":
 +      $iam_settings = identity_provider('get');
 +      $provider = null;
 +
 +      switch ($iam_settings['authsource']) {
 +        case "keycloak":
 +          if ($iam_settings['server_url'] && $iam_settings['realm'] && $iam_settings['client_id'] &&
 +            $iam_settings['client_secret'] && $iam_settings['redirect_url'] && $iam_settings['version']){
 +            $provider = new Stevenmaguire\OAuth2\Client\Provider\Keycloak([
 +              'authServerUrl'         => $iam_settings['server_url'],
 +              'realm'                 => $iam_settings['realm'],
 +              'clientId'              => $iam_settings['client_id'],
 +              'clientSecret'          => $iam_settings['client_secret'],
 +              'redirectUri'           => $iam_settings['redirect_url'],
 +              'version'               => $iam_settings['version'],                            
 +              // 'encryptionAlgorithm'   => 'RS256',                             // optional
 +              // 'encryptionKeyPath'     => '../key.pem'                         // optional
 +              // 'encryptionKey'         => 'contents_of_key_or_certificate'     // optional
 +            ]);
 +          }
 +        break;
 +        case "generic-oidc":
 +          if ($iam_settings['client_id'] && $iam_settings['client_secret'] && $iam_settings['redirect_url'] &&
 +            $iam_settings['authorize_url'] && $iam_settings['token_url'] && $iam_settings['userinfo_url']){
 +            $provider = new \League\OAuth2\Client\Provider\GenericProvider([
 +              'clientId'                => $iam_settings['client_id'],
 +              'clientSecret'            => $iam_settings['client_secret'],
 +              'redirectUri'             => $iam_settings['redirect_url'],
 +              'urlAuthorize'            => $iam_settings['authorize_url'],
 +              'urlAccessToken'          => $iam_settings['token_url'],
 +              'urlResourceOwnerDetails' => $iam_settings['userinfo_url'],
 +              'scopes'                  => $iam_settings['client_scopes']
 +            ]);
 +          }
 +        break;
 +        case "ldap":
 +          if ($iam_settings['host'] && $iam_settings['port'] && $iam_settings['basedn'] &&
 +            $iam_settings['binddn'] && $iam_settings['bindpass']){
 +            $options = array();
 +            if ($iam_settings['ignore_ssl_error']) {
 +              $options['LDAP_OPT_X_TLS_REQUIRE_CERT'] = "LDAP_OPT_X_TLS_NEVER";
 +            }
 +            $provider = new \LdapRecord\Connection([
 +              'hosts'                     => [$iam_settings['host']],
 +              'port'                      => $iam_settings['port'],
 +              'base_dn'                   => $iam_settings['basedn'],
 +              'username'                  => $iam_settings['binddn'],
 +              'password'                  => $iam_settings['bindpass'],
 +              'use_ssl'                   => $iam_settings['use_ssl'],
 +              'use_tls'                   => $iam_settings['use_tls'],
 +              'options'                   => $options
 +            ]);
 +            try {
 +              $provider->connect();
 +            } catch (Throwable $e) {
 +              $provider = null;
 +            }
 +          }
 +        break;
 +      }
 +
 +      return $provider;
 +    break;
 +    case "verify-sso":
 +      $provider = $_data['iam_provider'];
 +      $iam_settings = identity_provider('get');
 +      if ($iam_settings['authsource'] != 'keycloak' && $iam_settings['authsource'] != 'generic-oidc'){
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__),
 +          'msg' => array('login_failed', "no OIDC provider configured")
 +        );
 +        return false;
 +      }
 +    
 +      try {
 +        $token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);
 +        $_SESSION['iam_token'] = $token->getToken();
 +        $_SESSION['iam_refresh_token'] = $token->getRefreshToken();
 +        $info = $provider->getResourceOwner($token)->toArray();
 +      } catch (Throwable $e) {
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__),
 +          'msg' => array('login_failed', $e->getMessage())
 +        );
 +        return false;
 +      }
 +      // check if email address is given
 +      if (empty($info['email'])) return false;
 +    
 +      // token valid, get mailbox
 +      $stmt = $pdo->prepare("SELECT * FROM `mailbox`
 +        INNER JOIN domain on mailbox.domain = domain.domain
 +        WHERE `kind` NOT REGEXP 'location|thing|group'
 +          AND `mailbox`.`active`='1'
 +          AND `domain`.`active`='1'
 +          AND `username` = :user
 +          AND (`authsource`='keycloak' OR `authsource`='generic-oidc')");
 +      $stmt->execute(array(':user' => $info['email']));
 +      $row = $stmt->fetch(PDO::FETCH_ASSOC);
 +      if ($row){
 +        // success
 +        set_user_loggedin_session($info['email']);
 +        $_SESSION['return'][] =  array(
 +          'type' => 'success',
 +          'log' => array(__FUNCTION__, $_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role']),
 +          'msg' => array('logged_in_as', $_SESSION['mailcow_cc_username'])
 +        );
 +        return true;
 +      }
 +
 +      // get mapped template, if not set return false
 +      // also return false if no mappers were defined
 +      $user_template = $info['mailcow_template'];
 +      if (empty($iam_settings['mappers']) || empty($user_template)){
 +        clear_session();  
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $info['email']),
 +          'msg' => array('login_failed', 'empty attribute mapping or missing template attribute')
 +        );
 +        return false;
 +      }
 +
 +      // check if matching attribute exist
 +      $mapper_key = array_search($user_template, $iam_settings['mappers']);
 +      if ($mapper_key === false) {
 +        clear_session();  
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $info['email']),
 +          'msg' => array('login_failed', 'specified template not found')
 +        );
 +        return false;
 +      }
 +
 +      // create mailbox
 +      $create_res = mailbox('add', 'mailbox_from_template', array(
 +        'domain' => explode('@', $info['email'])[1],
 +        'local_part' => explode('@', $info['email'])[0],
 +        'name' => $info['firstName'] . " " . $info['lastName'],
 +        'authsource' => $iam_settings['authsource'],
 +        'template' => $iam_settings['templates'][$mapper_key]
 +      ));
 +      if (!$create_res){
 +        clear_session();  
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $info['email']),
 +          'msg' => array('login_failed', 'mailbox creation failed')
 +        );
 +        return false;
 +      }
 +    
 +      set_user_loggedin_session($info['email']);
 +      $_SESSION['return'][] =  array(
 +        'type' => 'success',
 +        'log' => array(__FUNCTION__, $_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role']),
 +        'msg' => array('logged_in_as', $_SESSION['mailcow_cc_username'])
 +      );
 +      return true;
 +    break;
 +    case "refresh-token":
 +      $provider = $_data['iam_provider'];
 +
 +      try {
 +        $token = $provider->getAccessToken('refresh_token', ['refresh_token' => $_SESSION['iam_refresh_token']]);
 +        $_SESSION['iam_token'] = $token->getToken();
 +        $_SESSION['iam_refresh_token'] = $token->getRefreshToken();
 +        $info = $provider->getResourceOwner($token)->toArray();
 +      } catch (Throwable $e) {
 +        clear_session();  
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__),
 +          'msg' => array('refresh_login_failed', $e->getMessage())
 +        );
 +        return false;
 +      }
 +
 +      if (empty($info['email'])){
 +        clear_session();  
 +        $_SESSION['return'][] =  array(
 +          'type' => 'danger',
 +          'log' => array(__FUNCTION__, $_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role']),
 +          'msg' => 'refresh_login_failed'
 +        );
 +        return false;
 +      }
 +    
 +      $_SESSION['mailcow_cc_username'] = $info['email'];
 +      $_SESSION['mailcow_cc_role'] = "user";
 +      return true;
 +    break;
 +    case "get-redirect":
 +      $iam_settings = identity_provider('get');
 +      if ($iam_settings['authsource'] != 'keycloak' && $iam_settings['authsource'] != 'generic-oidc') 
 +        return false;
 +      $provider = $_data['iam_provider'];
 +      $authUrl = $provider->getAuthorizationUrl();
 +      $_SESSION['oauth2state'] = $provider->getState();
 +      return $authUrl;
 +    break;
 +    case "get-keycloak-admin-token":
 +      // get access_token for service account of mailcow client
 +      $iam_settings = identity_provider('get');
 +      if ($iam_settings['authsource'] !== 'keycloak') return false;
 +      if (isset($iam_settings['access_token'])) {
 +        // check if access_token is valid
 +        $url = "{$iam_settings['server_url']}/realms/{$iam_settings['realm']}/protocol/openid-connect/token/introspect";
 +        $req = http_build_query(array(
 +          'token'    => $iam_settings['access_token'],
 +          'client_id'     => $iam_settings['client_id'],
 +          'client_secret' => $iam_settings['client_secret']
 +        ));
 +        $curl = curl_init();
 +        curl_setopt($curl, CURLOPT_URL, $url);
 +        curl_setopt($curl, CURLOPT_TIMEOUT, 7);
 +        curl_setopt($curl, CURLOPT_POST, 1);
 +        curl_setopt($curl, CURLOPT_POSTFIELDS, $req);
 +        curl_setopt($curl, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
 +        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
 +        curl_setopt($curl, CURLOPT_TIMEOUT, 5);
 +        $res = json_decode(curl_exec($curl), true);
 +        $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
 +        curl_close ($curl);
 +        if ($code == 200 && $res['active'] == true) {
 +          // token is valid
 +          return $iam_settings['access_token'];
 +        }
 +      }
 +
 +      $url = "{$iam_settings['server_url']}/realms/{$iam_settings['realm']}/protocol/openid-connect/token";
 +      $req = http_build_query(array(
 +        'grant_type'    => 'client_credentials',
 +        'client_id'     => $iam_settings['client_id'],
 +        'client_secret' => $iam_settings['client_secret']
 +      ));
 +      $curl = curl_init();
 +      curl_setopt($curl, CURLOPT_URL, $url);
 +      curl_setopt($curl, CURLOPT_TIMEOUT, 7);
 +      curl_setopt($curl, CURLOPT_POST, 1);
 +      curl_setopt($curl, CURLOPT_POSTFIELDS, $req);
 +      curl_setopt($curl, CURLOPT_HTTPHEADER, array('Content-Type: application/x-www-form-urlencoded'));
 +      curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
 +      curl_setopt($curl, CURLOPT_TIMEOUT, 5);
 +      $res = json_decode(curl_exec($curl), true);
 +      $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
 +      curl_close ($curl);
 +      if ($code != 200) {
 +        return false;
 +      }
 +      
 +      $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES (:key, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);");
 +      $stmt->execute(array(
 +        ':key' => 'access_token',
 +        ':value' => $res['access_token']
 +      ));
 +      return $res['access_token'];
 +    break;
 +  }
 +}
+ function reset_password($action, $data = null) {
+   global $pdo;
+   global $redis;
+   global $mailcow_hostname;
+   global $PW_RESET_TOKEN_LIMIT;
+   global $PW_RESET_TOKEN_LIFETIME;
+ 
+ 	$_data_log = $data;
+   if (isset($_data_log['new_password'])) $_data_log['new_password'] = '*';
+   if (isset($_data_log['new_password2'])) $_data_log['new_password2'] = '*';
+ 
+   switch ($action) {
+     case 'check':
+       $token = $data;
+ 
 -      $stmt = $pdo->prepare("SELECT `t1`.`username` FROM `reset_password` AS `t1` JOIN `mailbox` AS `t2` ON `t1`.`username` = `t2`.`username` WHERE `t1`.`token` = :token AND `t1`.`created` > DATE_SUB(NOW(), INTERVAL :lifetime MINUTE) AND `t2`.`active` = 1;");
++      $stmt = $pdo->prepare("SELECT `t1`.`username` FROM `reset_password` AS `t1` JOIN `mailbox` AS `t2` ON `t1`.`username` = `t2`.`username` WHERE `t1`.`token` = :token AND `t1`.`created` > DATE_SUB(NOW(), INTERVAL :lifetime MINUTE) AND `t2`.`active` = 1 AND `t2`.`authsource` = 'mailcow';");
+       $stmt->execute(array(
+         ':token' => preg_replace('/[^a-zA-Z0-9-]/', '', $token),
+         ':lifetime' => $PW_RESET_TOKEN_LIFETIME
+       ));
+       $return = $stmt->fetch(PDO::FETCH_ASSOC);
+       return empty($return['username']) ? false : $return['username'];
+     break;
+     case 'issue':
+       $username = $data;
+       
+       // perform cleanup
+       $stmt = $pdo->prepare("DELETE FROM `reset_password` WHERE created < DATE_SUB(NOW(), INTERVAL :lifetime MINUTE);");
+       $stmt->execute(array(':lifetime' => $PW_RESET_TOKEN_LIFETIME));
+ 
+       if (filter_var($username, FILTER_VALIDATE_EMAIL) === false) {
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => 'access_denied'
+         );
+         return false;
+       }
+ 
+       $pw_reset_notification = reset_password('get_notification', 'raw');
+       if (!$pw_reset_notification) return false;
+       if (empty($pw_reset_notification['from']) || empty($pw_reset_notification['subject'])) {
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => 'password_reset_na'
+         );
+         return false;
+       }
+ 
+       $stmt = $pdo->prepare("SELECT * FROM `mailbox`
 -        WHERE `username` = :username");
++        WHERE `username` = :username AND authsource = 'mailcow'");
+       $stmt->execute(array(':username' => $username));
+       $mailbox_data = $stmt->fetch(PDO::FETCH_ASSOC);
+ 
+       if (empty($mailbox_data)) {
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => 'password_reset_invalid_user'
+         );
+         return false;
+       }
+ 
+       $mailbox_attr = json_decode($mailbox_data['attributes'], true);
+       if (empty($mailbox_attr['recovery_email']) || filter_var($mailbox_attr['recovery_email'], FILTER_VALIDATE_EMAIL) === false) {
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => "password_reset_invalid_user"
+         );
+         return false;
+       }
+ 
+       $stmt = $pdo->prepare("SELECT * FROM `reset_password`
+         WHERE `username` = :username");
+       $stmt->execute(array(':username' => $username));
+       $generated_token_count = count($stmt->fetchAll(PDO::FETCH_ASSOC));
+       if ($generated_token_count >= $PW_RESET_TOKEN_LIMIT) {
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => "reset_token_limit_exceeded"
+         );
+         return false;
+       }
+ 
+       $token = implode('-', array(
+         strtoupper(bin2hex(random_bytes(3))),
+         strtoupper(bin2hex(random_bytes(3))),
+         strtoupper(bin2hex(random_bytes(3))),
+         strtoupper(bin2hex(random_bytes(3))),
+         strtoupper(bin2hex(random_bytes(3)))
+       ));
+ 
+       $stmt = $pdo->prepare("INSERT INTO `reset_password` (`username`, `token`)
+         VALUES (:username, :token)");
+       $stmt->execute(array(
+         ':username' => $username,
+         ':token' => $token
+       ));
+ 
+       $reset_link = getBaseURL() . "/reset-password?token=" . $token;
+ 
+       $request_date = new DateTime();
+       $locale_date = locale_get_default();
+       $date_formatter = new IntlDateFormatter(
+         $locale_date, 
+         IntlDateFormatter::FULL, 
+         IntlDateFormatter::FULL
+       );
+       $formatted_request_date = $date_formatter->format($request_date);
+ 
+       // set template vars
+       // subject
+       $pw_reset_notification['subject'] = str_replace('{{hostname}}', $mailcow_hostname, $pw_reset_notification['subject']);
+       $pw_reset_notification['subject'] = str_replace('{{link}}', $reset_link, $pw_reset_notification['subject']);
+       $pw_reset_notification['subject'] = str_replace('{{username}}', $username, $pw_reset_notification['subject']);
+       $pw_reset_notification['subject'] = str_replace('{{username2}}', $mailbox_attr['recovery_email'], $pw_reset_notification['subject']);
+       $pw_reset_notification['subject'] = str_replace('{{date}}', $formatted_request_date, $pw_reset_notification['subject']);
+       $pw_reset_notification['subject'] = str_replace('{{token_lifetime}}', $PW_RESET_TOKEN_LIFETIME, $pw_reset_notification['subject']);
+       // text
+       $pw_reset_notification['text_tmpl'] = str_replace('{{hostname}}', $mailcow_hostname, $pw_reset_notification['text_tmpl']);
+       $pw_reset_notification['text_tmpl'] = str_replace('{{link}}', $reset_link, $pw_reset_notification['text_tmpl']);
+       $pw_reset_notification['text_tmpl'] = str_replace('{{username}}', $username, $pw_reset_notification['text_tmpl']);
+       $pw_reset_notification['text_tmpl'] = str_replace('{{username2}}', $mailbox_attr['recovery_email'], $pw_reset_notification['text_tmpl']);
+       $pw_reset_notification['text_tmpl'] = str_replace('{{date}}', $formatted_request_date, $pw_reset_notification['text_tmpl']);
+       $pw_reset_notification['text_tmpl'] = str_replace('{{token_lifetime}}', $PW_RESET_TOKEN_LIFETIME, $pw_reset_notification['text_tmpl']);
+       // html
+       $pw_reset_notification['html_tmpl'] = str_replace('{{hostname}}', $mailcow_hostname, $pw_reset_notification['html_tmpl']);
+       $pw_reset_notification['html_tmpl'] = str_replace('{{link}}', $reset_link, $pw_reset_notification['html_tmpl']);
+       $pw_reset_notification['html_tmpl'] = str_replace('{{username}}', $username, $pw_reset_notification['html_tmpl']);
+       $pw_reset_notification['html_tmpl'] = str_replace('{{username2}}', $mailbox_attr['recovery_email'], $pw_reset_notification['html_tmpl']);
+       $pw_reset_notification['html_tmpl'] = str_replace('{{date}}', $formatted_request_date, $pw_reset_notification['html_tmpl']);
+       $pw_reset_notification['html_tmpl'] = str_replace('{{token_lifetime}}', $PW_RESET_TOKEN_LIFETIME, $pw_reset_notification['html_tmpl']);
+ 
+ 
+       $email_sent = reset_password('send_mail', array(
+         "from" => $pw_reset_notification['from'],
+         "to" => $mailbox_attr['recovery_email'],
+         "subject" => $pw_reset_notification['subject'],
+         "text" => $pw_reset_notification['text_tmpl'],
+         "html" => $pw_reset_notification['html_tmpl']
+       ));
+ 
+       if (!$email_sent){
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => "recovery_email_failed"
+         );
+         return false;
+       }
+ 
+       list($localPart, $domainPart) = explode('@', $mailbox_attr['recovery_email']);
+       if (strlen($localPart) > 1) {
+         $maskedLocalPart = $localPart[0] . str_repeat('*', strlen($localPart) - 1);
+       } else {
+         $maskedLocalPart = "*";
+       }
+       $_SESSION['return'][] = array(
+         'type' => 'success',
+         'log' => array(__FUNCTION__, $action, $_data_log),
+         'msg' => array("recovery_email_sent", $maskedLocalPart . '@' . $domainPart)
+       );
+       return array(
+         "username" => $username,
+         "issue" => "success"
+       );
+     break;
+     case 'reset':
+       $token = $data['token'];
+       $new_password = $data['new_password'];
+       $new_password2 = $data['new_password2'];
+       $username = $data['username'];
+       $check_tfa = $data['check_tfa'];
+ 
+       if (!$username || !$token) {
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => 'invalid_reset_token'
+         );
+         return false;
+       }
+ 
+       # check new password
+       if (!password_check($new_password, $new_password2)) {
+         return false;
+       }
+ 
+       if ($check_tfa){
+         // check for tfa authenticators
+         $authenticators = get_tfa($username);
+         if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
+           $_SESSION['pending_mailcow_cc_username'] = $username;
+           $_SESSION['pending_pw_reset_token'] = $token;
+           $_SESSION['pending_pw_new_password'] = $new_password;
+           $_SESSION['pending_tfa_methods'] = $authenticators['additional'];
+           $_SESSION['return'][] =  array(
+             'type' => 'info',
+             'log' => array(__FUNCTION__, $user, '*'),
+             'msg' => 'awaiting_tfa_confirmation'
+           );
+           return false;
+         }
+       }
+ 
+       # set new password
+       $password_hashed = hash_password($new_password);
+       $stmt = $pdo->prepare("UPDATE `mailbox` SET
+         `password` = :password_hashed,
+         `attributes` = JSON_SET(`attributes`, '$.passwd_update', NOW())
 -        WHERE `username` = :username");
++        WHERE `username` = :username AND authsource = 'mailcow'");
+       $stmt->execute(array(
+         ':password_hashed' => $password_hashed,
+         ':username' => $username
+       ));
+ 
+       // perform cleanup
+       $stmt = $pdo->prepare("DELETE FROM `reset_password` WHERE `username` = :username;");
+       $stmt->execute(array(
+         ':username' => $username
+       ));
+    
+       $_SESSION['return'][] = array(
+         'type' => 'success',
+         'log' => array(__FUNCTION__, $action, $_data_log),
+         'msg' => 'password_changed_success'
+       );
+       return true;
+     break;
+     case 'get_notification':
+       $type = $data;
+ 
+       try {
+         $settings['from'] = $redis->Get('PW_RESET_FROM');
+         $settings['subject'] = $redis->Get('PW_RESET_SUBJ');
+         $settings['html_tmpl'] = $redis->Get('PW_RESET_HTML');
+         $settings['text_tmpl'] = $redis->Get('PW_RESET_TEXT');
+         if (empty($settings['html_tmpl']) && empty($settings['text_tmpl'])) {
+           $settings['html_tmpl'] = file_get_contents("/tpls/pw_reset_html.tpl");
+           $settings['text_tmpl'] = file_get_contents("/tpls/pw_reset_text.tpl");
+         }
+ 
+         if ($type != "raw") {
+           $settings['html_tmpl'] = htmlspecialchars($settings['html_tmpl']);
+           $settings['text_tmpl'] = htmlspecialchars($settings['text_tmpl']);
+         }
+       }
+       catch (RedisException $e) {
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => array('redis_error', $e)
+         );
+         return false;
+       }
+ 
+       return $settings;
+     break;
+     case 'send_mail':
+       $from = $data['from'];
+       $to = $data['to'];
+       $text = $data['text'];
+       $html = $data['html'];
+       $subject = $data['subject'];
+     
+       if (!filter_var($from, FILTER_VALIDATE_EMAIL)) {
+         $_SESSION['return'][] =  array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => 'from_invalid'
+         );
+         return false;
+       }
+       if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
+         $_SESSION['return'][] =  array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => 'to_invalid'
+         );
+         return false;
+       }
+       if (empty($subject)) {
+         $_SESSION['return'][] =  array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => 'subject_empty'
+         );
+         return false;
+       }
+       if (empty($text)) {
+         $_SESSION['return'][] =  array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => 'text_empty'
+         );
+         return false;
+       }
+     
+       ini_set('max_execution_time', 0);
+       ini_set('max_input_time', 0);
+       $mail = new PHPMailer;
+       $mail->Timeout = 10;
+       $mail->SMTPOptions = array(
+         'ssl' => array(
+           'verify_peer' => false,
+           'verify_peer_name' => false,
+           'allow_self_signed' => true
+         )
+       );
+       $mail->isSMTP();
+       $mail->Host = 'postfix-mailcow';
+       $mail->SMTPAuth = false;
+       $mail->Port = 25;
+       $mail->setFrom($from);
+       $mail->Subject = $subject;
+       $mail->CharSet ="UTF-8";
+       if (!empty($html)) {
+         $mail->Body = $html;
+         $mail->AltBody = $text;
+       }
+       else {
+         $mail->Body = $text;
+       }
+       $mail->XMailer = 'MooMail';
+       $mail->AddAddress($to);
+       if (!$mail->send()) {
+         return false;
+       }
+       $mail->ClearAllRecipients();
+     
+       return true;
+     break;
+   }
+ 
+   if ($_SESSION['mailcow_cc_role'] != "admin") {
+     $_SESSION['return'][] = array(
+       'type' => 'danger',
+       'log' => array(__FUNCTION__, $action, $_data_log),
+       'msg' => 'access_denied'
+     );
+     return false;
+   }
+ 
+   switch ($action) {
+     case 'edit_notification':
+       $subject = $data['subject'];
+       $from = preg_replace('/[\x00-\x1F\x80-\xFF]/', '', $data['from']);
+ 
+       $from = (!filter_var($from, FILTER_VALIDATE_EMAIL)) ? "" : $from;
+       $subject = (empty($subject)) ? "" : $subject;
+       $text = (empty($data['text_tmpl'])) ? "" : $data['text_tmpl'];
+       $html = (empty($data['html_tmpl'])) ? "" : $data['html_tmpl'];
+ 
+       try {
+         $redis->Set('PW_RESET_FROM', $from);
+         $redis->Set('PW_RESET_SUBJ', $subject);
+         $redis->Set('PW_RESET_HTML', $html);
+         $redis->Set('PW_RESET_TEXT', $text);
+       }
+       catch (RedisException $e) {
+         $_SESSION['return'][] = array(
+           'type' => 'danger',
+           'log' => array(__FUNCTION__, $action, $_data_log),
+           'msg' => array('redis_error', $e)
+         );
+         return false;
+       }
+ 
+       $_SESSION['return'][] = array(
+         'type' => 'success',
+         'log' => array(__FUNCTION__, $action, $_data_log),
+         'msg' => 'saved_settings'
+       );
+     break;
+   }
+ }
 -
 -
 +function clear_session(){
 +  session_regenerate_id(true);
 +  session_unset();
 +  session_destroy();
 +  session_write_close();
 +}
 +function set_user_loggedin_session($user) {
 +  $_SESSION['mailcow_cc_username'] = $user;
 +  $_SESSION['mailcow_cc_role'] = 'user';
 +  $sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
 +  $_SESSION['sogo-sso-user-allowed'][] = $user;
 +  $_SESSION['sogo-sso-pass'] = $sogo_sso_pass;
 +  unset($_SESSION['pending_mailcow_cc_username']);
 +  unset($_SESSION['pending_mailcow_cc_role']);
 +  unset($_SESSION['pending_tfa_methods']);
 +}
  function get_logs($application, $lines = false) {
    if ($lines === false) {
      $lines = $GLOBALS['LOG_LINES'] - 1;
diff --cc data/web/inc/functions.mailbox.inc.php
index 09951bed,c927ce49..c0517d84
--- a/data/web/inc/functions.mailbox.inc.php
+++ b/data/web/inc/functions.mailbox.inc.php
@@@ -2927,26 -2870,22 +2932,27 @@@ function mailbox($_action, $_type, $_da
                $_data['sieve_access'] = (in_array('sieve', $_data['protocol_access'])) ? 1 : 0;
              }
              if (!empty($is_now)) {
-               $active     = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
+               $active               = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
                (int)$force_pw_update = (isset($_data['force_pw_update'])) ? intval($_data['force_pw_update']) : intval($is_now['attributes']['force_pw_update']);
-               (int)$sogo_access = (isset($_data['sogo_access']) && isset($_SESSION['acl']['sogo_access']) && $_SESSION['acl']['sogo_access'] == "1") ? intval($_data['sogo_access']) : intval($is_now['attributes']['sogo_access']);
-               (int)$imap_access = (isset($_data['imap_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['imap_access']) : intval($is_now['attributes']['imap_access']);
-               (int)$pop3_access = (isset($_data['pop3_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['pop3_access']) : intval($is_now['attributes']['pop3_access']);
-               (int)$smtp_access = (isset($_data['smtp_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['smtp_access']) : intval($is_now['attributes']['smtp_access']);
-               (int)$sieve_access = (isset($_data['sieve_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['sieve_access']) : intval($is_now['attributes']['sieve_access']);
-               (int)$relayhost = (isset($_data['relayhost']) && isset($_SESSION['acl']['mailbox_relayhost']) && $_SESSION['acl']['mailbox_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']);
-               (int)$quota_m = (isset_has_content($_data['quota'])) ? intval($_data['quota']) : ($is_now['quota'] / 1048576);
-               $name           = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name'];
-               $domain         = $is_now['domain'];
-               $quota_b        = $quota_m * 1048576;
-               $password       = (!empty($_data['password'])) ? $_data['password'] : null;
-               $password2      = (!empty($_data['password2'])) ? $_data['password2'] : null;
-               $tags           = (is_array($_data['tags']) ? $_data['tags'] : array());
-               $attribute_hash = (!empty($_data['attribute_hash'])) ? $_data['attribute_hash'] : '';
-               $authsource     = $is_now['authsource'];
+               (int)$sogo_access     = (isset($_data['sogo_access']) && isset($_SESSION['acl']['sogo_access']) && $_SESSION['acl']['sogo_access'] == "1") ? intval($_data['sogo_access']) : intval($is_now['attributes']['sogo_access']);
+               (int)$imap_access     = (isset($_data['imap_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['imap_access']) : intval($is_now['attributes']['imap_access']);
+               (int)$pop3_access     = (isset($_data['pop3_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['pop3_access']) : intval($is_now['attributes']['pop3_access']);
+               (int)$smtp_access     = (isset($_data['smtp_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['smtp_access']) : intval($is_now['attributes']['smtp_access']);
+               (int)$sieve_access    = (isset($_data['sieve_access']) && isset($_SESSION['acl']['protocol_access']) && $_SESSION['acl']['protocol_access'] == "1") ? intval($_data['sieve_access']) : intval($is_now['attributes']['sieve_access']);
+               (int)$relayhost       = (isset($_data['relayhost']) && isset($_SESSION['acl']['mailbox_relayhost']) && $_SESSION['acl']['mailbox_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']);
+               (int)$quota_m         = (isset_has_content($_data['quota'])) ? intval($_data['quota']) : ($is_now['quota'] / 1048576);
+               $name                 = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name'];
+               $domain               = $is_now['domain'];
+               $quota_b              = $quota_m * 1048576;
+               $password             = (!empty($_data['password'])) ? $_data['password'] : null;
+               $password2            = (!empty($_data['password2'])) ? $_data['password2'] : null;
 -              $pw_recovery_email     = (isset($_data['pw_recovery_email'])) ? $_data['pw_recovery_email'] : $is_now['attributes']['recovery_email'];
+               $tags                 = (is_array($_data['tags']) ? $_data['tags'] : array());
++              $attribute_hash       = (!empty($_data['attribute_hash'])) ? $_data['attribute_hash'] : '';
++              $authsource           = $is_now['authsource'];
 +              if (in_array($_data['authsource'], array('mailcow', 'keycloak', 'generic-oidc', 'ldap'))){
 +                $authsource = $_data['authsource'];
 +              }
++              $pw_recovery_email    = (isset($_data['pw_recovery_email']) && $authsource == 'mailcow') ? $_data['pw_recovery_email'] : $is_now['attributes']['recovery_email'];
              }
              else {
                $_SESSION['return'][] = array(
@@@ -3199,35 -3138,43 +3205,47 @@@
                ':address' => $username,
                ':active' => $active
              ));
-             $stmt = $pdo->prepare("UPDATE `mailbox` SET
-                 `active` = :active,
-                 `name`= :name,
-                 `quota` = :quota_b,
-                 `authsource` = :authsource,
-                 `attributes` = JSON_SET(`attributes`, '$.force_pw_update', :force_pw_update),
-                 `attributes` = JSON_SET(`attributes`, '$.sogo_access', :sogo_access),
-                 `attributes` = JSON_SET(`attributes`, '$.imap_access', :imap_access),
-                 `attributes` = JSON_SET(`attributes`, '$.sieve_access', :sieve_access),
-                 `attributes` = JSON_SET(`attributes`, '$.pop3_access', :pop3_access),
-                 `attributes` = JSON_SET(`attributes`, '$.relayhost', :relayhost),
-                 `attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
-                 `attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash)
-                   WHERE `username` = :username");
-             $stmt->execute(array(
-               ':active' => $active,
-               ':name' => $name,
-               ':quota_b' => $quota_b,
-               ':attribute_hash' => $attribute_hash,
-               ':force_pw_update' => $force_pw_update,
-               ':sogo_access' => $sogo_access,
-               ':imap_access' => $imap_access,
-               ':pop3_access' => $pop3_access,
-               ':sieve_access' => $sieve_access,
-               ':smtp_access' => $smtp_access,
-               ':relayhost' => $relayhost,
-               ':username' => $username,
-               ':authsource' => $authsource
-             ));
+             try {
+               $stmt = $pdo->prepare("UPDATE `mailbox` SET
+                   `active` = :active,
+                   `name`= :name,
+                   `quota` = :quota_b,
++                  `authsource` = :authsource,
+                   `attributes` = JSON_SET(`attributes`, '$.force_pw_update', :force_pw_update),
+                   `attributes` = JSON_SET(`attributes`, '$.sogo_access', :sogo_access),
+                   `attributes` = JSON_SET(`attributes`, '$.imap_access', :imap_access),
+                   `attributes` = JSON_SET(`attributes`, '$.sieve_access', :sieve_access),
+                   `attributes` = JSON_SET(`attributes`, '$.pop3_access', :pop3_access),
+                   `attributes` = JSON_SET(`attributes`, '$.relayhost', :relayhost),
+                   `attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
 -                  `attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email)
++                  `attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email),
++                  `attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash)
+                     WHERE `username` = :username");
 -                $stmt->execute(array(
 -                  ':active' => $active,
 -                  ':name' => $name,
 -                  ':quota_b' => $quota_b,
 -                  ':force_pw_update' => $force_pw_update,
 -                  ':sogo_access' => $sogo_access,
 -                  ':imap_access' => $imap_access,
 -                  ':pop3_access' => $pop3_access,
 -                  ':sieve_access' => $sieve_access,
 -                  ':smtp_access' => $smtp_access,
 -                  ':recovery_email' => $pw_recovery_email,
 -                  ':relayhost' => $relayhost,
 -                  ':username' => $username
 -                ));
++              $stmt->execute(array(
++                ':active' => $active,
++                ':name' => $name,
++                ':quota_b' => $quota_b,
++                ':attribute_hash' => $attribute_hash,
++                ':force_pw_update' => $force_pw_update,
++                ':sogo_access' => $sogo_access,
++                ':imap_access' => $imap_access,
++                ':pop3_access' => $pop3_access,
++                ':sieve_access' => $sieve_access,
++                ':smtp_access' => $smtp_access,
++                ':recovery_email' => $pw_recovery_email,
++                ':relayhost' => $relayhost,
++                ':username' => $username,
++                ':authsource' => $authsource
++              ));
+             }
+             catch (PDOException $e) {
+               $_SESSION['return'][] = array(
+                 'type' => 'danger',
+                 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
+                 'msg' => $e->getMessage()
+               );
+               return false;
+             }
              // save tags
              foreach($tags as $index => $tag){
                if (empty($tag)) continue;
diff --cc data/web/inc/init_db.inc.php
index 1dd47fec,8c4951d5..fd196f9e
--- a/data/web/inc/init_db.inc.php
+++ b/data/web/inc/init_db.inc.php
@@@ -3,7 -3,7 +3,7 @@@ function init_db_schema() 
    try {
      global $pdo;
  
-     $db_version = "31072024_1012";
 -    $db_version = "29072024_1000";
++    $db_version = "15082024_1212";
  
      $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
      $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
diff --cc data/web/inc/triggers.inc.php
index 30198778,5c625e41..8fa5dcd9
--- a/data/web/inc/triggers.inc.php
+++ b/data/web/inc/triggers.inc.php
@@@ -38,45 -10,58 +38,81 @@@ if (!empty($_GET['sso_token'])) 
    }
  }
  
+ if (isset($_POST["pw_reset_request"]) && !empty($_POST['username'])) {
+   reset_password("issue", $_POST['username']);
+   header("Location: /");
+   exit;
+ }
+ if (isset($_POST["pw_reset"])) {
+   $username = reset_password("check", $_POST['token']);
+   $reset_result = reset_password("reset", array(
+     'new_password' => $_POST['new_password'], 
+     'new_password2' => $_POST['new_password2'],
+     'token' => $_POST['token'],
+     'username' => $username,
+     'check_tfa' => True
+   ));
+ 
+   if ($reset_result){
+     header("Location: /");
+     exit;
+   }
+ }
  if (isset($_POST["verify_tfa_login"])) {
    if (verify_tfa_login($_SESSION['pending_mailcow_cc_username'], $_POST)) {
 -    if (isset($_SESSION['pending_mailcow_cc_username']) && isset($_SESSION['pending_pw_reset_token']) && isset($_SESSION['pending_pw_new_password'])) {
 -      reset_password("reset", array(
 -        'new_password' => $_SESSION['pending_pw_new_password'],
 -        'new_password2' => $_SESSION['pending_pw_new_password'],
 -        'token' => $_SESSION['pending_pw_reset_token'],
 -        'username' => $_SESSION['pending_mailcow_cc_username']
 -      ));
 -      unset($_SESSION['pending_pw_reset_token']);
 -      unset($_SESSION['pending_pw_new_password']);
 +    if ($_SESSION['pending_mailcow_cc_role'] == "admin") {
 +      $_SESSION['mailcow_cc_username'] = $_SESSION['pending_mailcow_cc_username'];
 +      $_SESSION['mailcow_cc_role'] = "admin";
        unset($_SESSION['pending_mailcow_cc_username']);
 +      unset($_SESSION['pending_mailcow_cc_role']);
        unset($_SESSION['pending_tfa_methods']);
 -
 -      header("Location: /");
 -      exit;
 -    } else {
 +      
 +		  header("Location: /debug");
 +      die();
 +    }
 +    elseif ($_SESSION['pending_mailcow_cc_role'] == "domainadmin") {
        $_SESSION['mailcow_cc_username'] = $_SESSION['pending_mailcow_cc_username'];
 -      $_SESSION['mailcow_cc_role'] = $_SESSION['pending_mailcow_cc_role'];
 +      $_SESSION['mailcow_cc_role'] = "domainadmin";
        unset($_SESSION['pending_mailcow_cc_username']);
        unset($_SESSION['pending_mailcow_cc_role']);
        unset($_SESSION['pending_tfa_methods']);
 +      
 +		  header("Location: /mailbox");
 +      die();
 +    }
 +    elseif ($_SESSION['pending_mailcow_cc_role'] == "user") {
-       set_user_loggedin_session($_SESSION['pending_mailcow_cc_username']);
-       $user_details = mailbox("get", "mailbox_details", $_SESSION['mailcow_cc_username']);
-       $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
-       if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual) {
-         header("Location: /SOGo/so/{$_SESSION['mailcow_cc_username']}");
++      if (isset($_SESSION['pending_pw_reset_token']) && isset($_SESSION['pending_pw_new_password'])) {
++        reset_password("reset", array(
++          'new_password' => $_SESSION['pending_pw_new_password'],
++          'new_password2' => $_SESSION['pending_pw_new_password'],
++          'token' => $_SESSION['pending_pw_reset_token'],
++          'username' => $_SESSION['pending_mailcow_cc_username']
++        ));
++        unset($_SESSION['pending_pw_reset_token']);
++        unset($_SESSION['pending_pw_new_password']);
++        unset($_SESSION['pending_mailcow_cc_username']);
++        unset($_SESSION['pending_tfa_methods']);
+   
 -      header("Location: /user");
++        header("Location: /");
 +        die();
 +      } else {
-         header("Location: /user");
-         die();
++        set_user_loggedin_session($_SESSION['pending_mailcow_cc_username']);
++        $user_details = mailbox("get", "mailbox_details", $_SESSION['mailcow_cc_username']);
++        $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
++        if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual) {
++          header("Location: /SOGo/so/{$_SESSION['mailcow_cc_username']}");
++          die();
++        } else {
++          header("Location: /user");
++          die();
++        }
 +      }
      }
 -  } else {
 -    unset($_SESSION['pending_pw_reset_token']);
 -    unset($_SESSION['pending_pw_new_password']);
 -    unset($_SESSION['pending_mailcow_cc_username']);
 -    unset($_SESSION['pending_mailcow_cc_role']);
 -    unset($_SESSION['pending_tfa_methods']);
    }
 +
 +  unset($_SESSION['pending_mailcow_cc_username']);
 +  unset($_SESSION['pending_mailcow_cc_role']);
 +  unset($_SESSION['pending_tfa_methods']);
  }
  
  if (isset($_GET["cancel_tfa_login"])) {
diff --cc data/web/lang/lang.de-de.json
index 821c1551,189774ee..ddbbc342
--- a/data/web/lang/lang.de-de.json
+++ b/data/web/lang/lang.de-de.json
@@@ -1206,7 -1226,7 +1230,8 @@@
          "password": "Passwort",
          "password_now": "Aktuelles Passwort (Änderungen bestätigen)",
          "password_repeat": "Passwort (Wiederholung)",
+         "password_reset_info": "Wenn keine E-Mail zur Passwortwiederherstellung hinterlegt ist, kann diese Funktion nicht genutzt werden.",
 +        "protocols": "Protokolle",
          "pushover_evaluate_x_prio": "Hohe Priorität eskalieren [<code>X-Priority: 1</code>]",
          "pushover_info": "Push-Benachrichtungen werden angewendet auf alle nicht-Spam Nachrichten zugestellt an <b>%s</b>, einschließlich Alias-Adressen (shared, non-shared, tagged).",
          "pushover_only_x_prio": "Nur Mail mit hoher Priorität berücksichtigen [<code>X-Priority: 1</code>]",
diff --cc data/web/lang/lang.en-gb.json
index aac2abc8,60044180..36d886f7
--- a/data/web/lang/lang.en-gb.json
+++ b/data/web/lang/lang.en-gb.json
@@@ -485,8 -459,8 +497,9 @@@
          "redis_error": "Redis error: %s",
          "relayhost_invalid": "Map entry %s is invalid",
          "release_send_failed": "Message could not be released: %s",
 +        "required_data_missing": "Required data %s is missing",
          "reset_f2b_regex": "Regex filter could not be reset in time, please try again or wait a few more seconds and reload the website.",
+         "reset_token_limit_exceeded": "Reset token limit has been exceeded. Please try again later.",
          "resource_invalid": "Resource name %s is invalid",
          "rl_timeframe": "Rate limit time frame is incorrect",
          "rspamd_ui_pw_length": "Rspamd UI password should be at least 6 chars long",
@@@ -1253,7 -1234,7 +1277,8 @@@
          "password": "Password",
          "password_now": "Current password (confirm changes)",
          "password_repeat": "Password (repeat)",
+         "password_reset_info": "If no email for password recovery is provided, this function cannot be used.",
 +        "protocols": "Protocols",
          "pushover_evaluate_x_prio": "Escalate high priority mail [<code>X-Priority: 1</code>]",
          "pushover_info": "Push notification settings will apply to all clean (non-spam) mail delivered to <b>%s</b> including aliases (shared, non-shared, tagged).",
          "pushover_only_x_prio": "Only consider high priority mail [<code>X-Priority: 1</code>]",
diff --cc data/web/templates/user/tab-user-auth.twig
index 424d5e94,24c9eb48..0f30d333
--- a/data/web/templates/user/tab-user-auth.twig
+++ b/data/web/templates/user/tab-user-auth.twig
@@@ -43,168 -47,119 +43,171 @@@
                {{ mailboxdata.percent_in_use }}%
              </div>
            </div>
 -          <p>{{ mailboxdata.quota_used|formatBytes(2) }} / {% if mailboxdata.quota == 0 %}∞{% else %}{{ mailboxdata.quota|formatBytes(2) }}{% endif %}<br>{{ mailboxdata.messages }} {{ lang.user.messages }}</p>
 +          
 +          <div class="row">
 +            <div class="col-12 col-md-3 d-flex">
 +              <span class="mt-2 w-100 text-md-end">{{ lang.user.protocols }}:</span>
 +            </div>
 +            <div class="col-12 col-md-9 d-flex">
 +              <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill m-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.user.direct_protocol_access|raw }}"></i>
 +              <div class="d-flex flex-wrap">
 +              {% if mailboxdata.attributes.imap_access == 1 %}<div class="badge fs-6 bg-success m-2">IMAP <i class="bi bi-check-lg"></i></div>{% else %}<div class="badge fs-6 bg-danger m-2">IMAP <i class="bi bi-x-lg"></i></div>{% endif %}
 +              {% if mailboxdata.attributes.smtp_access == 1 %}<div class="badge fs-6 bg-success m-2">SMTP <i class="bi bi-check-lg"></i></div>{% else %}<div class="badge fs-6 bg-danger m-2">SMTP <i class="bi bi-x-lg"></i></div>{% endif %}
 +              {% if mailboxdata.attributes.sieve_access == 1 %}<div class="badge fs-6 bg-success m-2">Sieve <i class="bi bi-check-lg"></i></div>{% else %}<div class="badge fs-6 bg-danger m-2">Sieve <i class="bi bi-x-lg"></i></div>{% endif %}
 +              {% if mailboxdata.attributes.pop3_access == 1 %}<div class="badge fs-6 bg-success m-2">POP3 <i class="bi bi-check-lg"></i></div>{% else %}<div class="badge fs-6 bg-danger m-2">POP3 <i class="bi bi-x-lg"></i></div>{% endif %}
 +              </div>
 +            </div>
 +          </div>
 +          <div class="row mt-2">
 +            <div class="col-12 col-md-3 d-flex">
 +              <span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}:</span>
 +            </div>
 +            <div class="col-12 col-md-8">
 +              <div class="d-flex">
 +                <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill me-2" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.user.apple_connection_profile_mailonly }}"></i> 
 +                <a href="/mobileconfig.php?only_email">{{ lang.user.email }} <small>[IMAP, SMTP]</small></a>
 +              </div> 
 +              {% if not skip_sogo %}
 +              <div class="d-flex">
 +                <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill me-2" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.user.apple_connection_profile_complete }}"></i>  
 +                <a href="/mobileconfig.php">{{ lang.user.email_and_dav }} <small>[IMAP, SMTP, Cal/CardDAV]</small></a>
 +              </div>
 +              {% endif %}
 +            </div>
 +          </div>
 +          <div class="row mt-2">
 +            <div class="col-12 col-md-3 d-flex">
 +              <span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}<br>{{ lang.user.with_app_password }}:</span>
 +            </div>
 +            <div class="col-12 col-md-9">
 +              <div class="d-flex">
 +                <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill me-2" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.user.apple_connection_profile_mailonly }} {{ lang.user.apple_connection_profile_with_app_password }}"></i> 
 +                <a href="/mobileconfig.php?only_email&amp;app_password">{{ lang.user.email }} <small>[IMAP, SMTP]</small></a>
 +              </div>
 +              {% if not skip_sogo %}
 +              <div class="d-flex">
 +                <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill me-2" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.user.apple_connection_profile_complete }} {{ lang.user.apple_connection_profile_with_app_password }}"></i> 
 +                <a href="/mobileconfig.php?app_password">{{ lang.user.email_and_dav }} <small>[IMAP, SMTP, Cal/CardDAV]</small></a>
 +              </div>
 +              {% endif %}
 +            </div>
 +          </div>
 +          <div class="row">
 +            <div class="col-12 d-flex flex-column text-center align-items-center mt-4">
 +              <a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a>
 +              <a class="mt-2" href="#userFilterModal" data-bs-toggle="modal">{{ lang.user.show_sieve_filters }}</a>
 +            </div>
 +          </div>
 +
 +          <legend class="mt-4">{{ lang.user.authentication }}</legend>
            <hr>
 -          <p><a href="#pwChangeModal" data-bs-toggle="modal"><i class="bi bi-pencil-fill"></i> {{ lang.user.change_password }}</a></p>
 -          {% if acl.pw_reset == 1 %}<p><a href="#pwRecoveryEmailModal" data-bs-toggle="modal"><i class="bi bi-pencil-fill"></i> {{ lang.user.pw_recovery_email }}</a></p>{% endif %}
 -        </div>
 -      </div>
 -      <hr>
 -      {# TFA #}
 -      <div class="row">
 -        <div class="col-sm-3 col-xs-5 text-right">{{ lang.tfa.tfa }}:</div>
 -        <div class="col-sm-9 col-xs-7">
 -          <p id="tfa_pretty">{{ tfa_data.pretty }}</p>
 -          {% include 'tfa_keys.twig' %}
 -          <br>
 -        </div>
 -      </div>
 -      <div class="row">
 -        <div class="col-sm-3 col-xs-5 text-right">{{ lang.tfa.set_tfa }}:</div>
 -        <div class="col-sm-9 col-xs-7">
 -          <select data-style="btn btn-sm dropdown-toggle bs-placeholder btn-secondary" data-width="fit" id="selectTFA" class="selectpicker" title="{{ lang.tfa.select }}">
 -            <option value="yubi_otp">{{ lang.tfa.yubi_otp }}</option>
 -            <option value="webauthn">{{ lang.tfa.webauthn }}</option>
 -            <option value="totp">{{ lang.tfa.totp }}</option>
 -            <option value="none">{{ lang.tfa.none }}</option>
 -          </select>
 -        </div>
 -      </div>
 -      <hr>
 -      {# FIDO2 #}
 -      <div class="row">
 -        <div class="col-sm-3 col-12 text-sm-end text-start">
 -          <p><i class="bi bi-shield-fill-check"></i> {{ lang.fido2.fido2_auth }}</p>
 -        </div>
 -      </div>
 -      <div class="row">
 -        <div class="col-sm-3 col-12 text-sm-end text-start mb-4">
 -          {{ lang.fido2.known_ids }}:
 -        </div>
 -        <div class="col-sm-9 col-12">
 -          <div class="table-responsive">
 -            <table class="table table-striped table-hover table-condensed" id="fido2_keys">
 -              <tr>
 -                <th>ID</th>
 -                <th style="min-width:240px;text-align: right">{{ lang.admin.action }}</th>
 -              </tr>
 -              {% include 'fido2.twig' %}
 -            </table>
 +          {# Password Change #}
 +          {% if mailboxdata.authsource == "mailcow" %}
 +          <div class="row">
 +            <div class="col-12 col-md-3 d-flex"></div>
-             <div class="col-12 col-md-9 d-flex flex-wrap justify-content-center justify-content-sm-start">
++            <div class="col-12 col-md-9 d-flex flex-wrap">
 +              <a class="btn btn-secondary" href="#pwChangeModal" data-bs-toggle="modal"><i class="bi bi-pencil-fill"></i> {{ lang.user.change_password }}</a>
++              {% if acl.pw_reset == 1 %}
++              <a class="btn btn-secondary ms-4" href="#pwRecoveryEmailModal" data-bs-toggle="modal"><i class="bi bi-pencil-fill"></i> {{ lang.user.pw_recovery_email }}</a></p>
++              {% endif %}
 +            </div>
            </div>
 -          <br>
 -        </div>
 -      </div>
 -      <div class="row">
 -        <div class="offset-sm-3 col-sm-9">
 -          <div class="btn-group nowrap">
 -            <button class="btn btn-sm btn-primary d-block d-sm-inline" id="register-fido2">{{ lang.fido2.set_fido2 }}</button>
 -            <button type="button" class="btn btn-sm btn-xs-lg btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></button>
 -            <ul class="dropdown-menu">
 -              <li><a class="dropdown-item" href="#" id="register-fido2-touchid"><i class="bi bi-apple"></i> {{ lang.fido2.set_fido2_touchid }}</a></li>
 -            </ul>
 +          {% endif %}
 +          {# TFA #}
 +          {% if mailboxdata.authsource == "mailcow" or mailboxdata.authsource == "ldap" %}
 +          <div class="row mt-4">
 +            <div class="col-12 col-md-3 d-flex">
 +              <span class="mt-2 w-100 text-md-end">{{ lang.tfa.tfa }}:</span>
 +            </div>
 +            <div class="col-12 col-md-9 d-flex flex-wrap justify-content-center justify-content-sm-start">
 +              <span id="tfa_pretty">{{ tfa_data.pretty }}</span>
 +              {% include 'tfa_keys.twig' %}
 +            </div>
 +          </div>
 +          <div class="row mt-2 mb-4">
 +            <div class="col-12 col-md-3 d-flex">
 +              <span class="mt-2 w-100 text-md-end">{{ lang.tfa.set_tfa }}:</span>
 +            </div>
 +            <div class="col-12 col-md-9 d-flex flex-wrap align-items-center justify-content-center justify-content-sm-start">
 +              <i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill m-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.user.tfa_info|raw }}"></i>
 +              <select data-style="ms-2 btn btn-sm dropdown-toggle bs-placeholder btn-secondary" data-width="fit" id="selectTFA" class="selectpicker" title="{{ lang.tfa.select }}">
 +                <option value="yubi_otp">{{ lang.tfa.yubi_otp }}</option>
 +                <option value="webauthn">{{ lang.tfa.webauthn }}</option>
 +                <option value="totp">{{ lang.tfa.totp }}</option>
 +                <option value="none">{{ lang.tfa.none }}</option>
 +              </select>
 +            </div>
            </div>
 -        </div>
 -      </div>
 -      <br>
 -      <div class="row" id="status-fido2">
 -        <div class="col-sm-3 col-5 text-end">{{ lang.fido2.register_status }}:</div>
 -        <div class="col-sm-9 col-7">
 -          <div id="fido2-alerts">-</div>
 -        </div>
 -        <br>
 -      </div>
 -      <hr>
 -      <div class="row">
 -        <div class="col-md-3 col-12 text-sm-end text-start mb-4"><i class="bi bi-file-earmark-text"></i> {{ lang.user.apple_connection_profile }}:</div>
 -        <div class="col-md-9 col-12">
 -          <p><i class="bi bi-file-earmark-post"></i> <a href="/mobileconfig.php?only_email">{{ lang.user.email }}</a> <small>IMAP, SMTP</small></p>
 -          <p class="text-muted">{{ lang.user.apple_connection_profile_mailonly }}</p>
 -          {% if not skip_sogo %}
 -          <p><i class="bi bi-file-earmark-post"></i> <a href="/mobileconfig.php">{{ lang.user.email_and_dav }}</a> <small>IMAP, SMTP, Cal/CardDAV</small></p>
 -          <p class="text-muted">{{ lang.user.apple_connection_profile_complete }}</p>
            {% endif %}
 -        </div>
 -      </div>
 -      <div class="row">
 -        <div class="col-md-3 col-12 text-sm-end text-start mb-4"><i class="bi bi-file-earmark-text"></i> {{ lang.user.apple_connection_profile }}<br class="d-none d-lg-block" />{{ lang.user.with_app_password }}:</div>
 -        <div class="col-md-9 col-12">
 -          <p><i class="bi bi-file-earmark-post"></i> <a href="/mobileconfig.php?only_email&amp;app_password">{{ lang.user.email }}</a> <small>IMAP, SMTP</small></p>
 -          <p class="text-muted">{{ lang.user.apple_connection_profile_mailonly }}<br /> {{ lang.user.apple_connection_profile_with_app_password }}</p>
 -          {% if not skip_sogo %}
 -          <p><i class="bi bi-file-earmark-post"></i> <a href="/mobileconfig.php?app_password">{{ lang.user.email_and_dav }}</a> <small>IMAP, SMTP, Cal/CardDAV</small></p>
 -          <p class="text-muted">{{ lang.user.apple_connection_profile_complete }}<br /> {{ lang.user.apple_connection_profile_with_app_password }}</p>
 +          {# FIDO2 #}
 +          {% if mailboxdata.authsource == "mailcow" %}
 +          <div class="row mt-4">
 +            <div class="col-sm-3 col-12 text-sm-end text-start">
 +              <p><i class="bi bi-shield-fill-check"></i> {{ lang.fido2.fido2_auth }}</p>
 +            </div>
 +          </div>
 +          <div class="row">
 +            <div class="col-sm-3 col-12 text-sm-end text-start mb-4">
 +              {{ lang.fido2.known_ids }}:
 +            </div>
 +            <div class="col-sm-9 col-12">
 +              <div class="table-responsive">
 +                <table class="table table-striped table-hover table-condensed" id="fido2_keys">
 +                  <tr>
 +                    <th>ID</th>
 +                    <th style="min-width:240px;text-align: right">{{ lang.admin.action }}</th>
 +                  </tr>
 +                  {% include 'fido2.twig' %}
 +                </table>
 +              </div>
 +              <br>
 +            </div>
 +          </div>
 +          <div class="row">
 +            <div class="offset-sm-3 col-sm-9">
 +              <div class="btn-group nowrap">
 +                <button class="btn btn-sm btn-primary d-block d-sm-inline" id="register-fido2">{{ lang.fido2.set_fido2 }}</button>
 +                <button type="button" class="btn btn-sm btn-xs-lg btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></button>
 +                <ul class="dropdown-menu">
 +                  <li><a class="dropdown-item" href="#" id="register-fido2-touchid"><i class="bi bi-apple"></i> {{ lang.fido2.set_fido2_touchid }}</a></li>
 +                </ul>
 +              </div>
 +            </div>
 +          </div>
 +          <br>
 +          <div class="row" id="status-fido2">
 +            <div class="col-sm-3 col-5 text-end">{{ lang.fido2.register_status }}:</div>
 +            <div class="col-sm-9 col-7">
 +              <div id="fido2-alerts">-</div>
 +            </div>
 +            <br>
 +          </div>
            {% endif %}
          </div>
 -      </div>
 -      <hr>
 -      <div class="row">
 -        <div class="offset-sm-3 col-sm-9">
 -          <p><a target="_blank" href="https://docs.mailcow.email/client/client/#{{ clientconfigstr }}">[{{ lang.user.client_configuration }}]</a></p>
 -          <p><a href="#userFilterModal" data-bs-toggle="modal">[{{ lang.user.show_sieve_filters }}]</a></p>
 +        <div class="ms-auto col-xl-3 col-lg-5 col-md-12 col-12 d-flex flex-column well flex-grow-1">
 +          <legend class="d-flex">
 +            <span>{{ lang.user.recent_successful_connections }}</span>
 +            <div id="spinner-last-login" class="ms-auto my-auto spinner-border spinner-border-sm d-none" role="status">
 +              <span class="visually-hidden">Loading...</span>
 +            </div>
 +          </legend>
            <hr>
 -          <h4 class="recent-login-success">{{ lang.user.recent_successful_connections }}</h4>
 -          <div class="dropdown mt-2">
 -            <button class="btn btn-secondary btn-xs btn-xs-lg dropdown-toggle" type="button" id="history_sasl_days" data-bs-toggle="dropdown">{{ lang.user.login_history }}</button>
 -            <ul class="dropdown-menu">
 -              <li class="login-history" data-days="1"><a class="dropdown-item" href="#">1 {{ lang.user.day }}</a></li>
 -              <li class="login-history" data-days="7"><a class="dropdown-item active" href="#">1 {{ lang.user.week }}</a></li>
 -              <li class="login-history" data-days="14"><a class="dropdown-item" href="#">2 {{ lang.user.weeks }}</a></li>
 -              <li class="login-history" data-days="31"><a class="dropdown-item" href="#">1 {{ lang.user.month }}</a></li>
 -            </ul>
 +          <h6 class="last-ui-login"></h6>
 +          <div class="d-flex">
 +            <span class="clear-last-logins mt-auto mb-2">
 +              {{ lang.user.clear_recent_successful_connections }}
 +            </span>
 +            <div class="dropdown mt-4 mb-2 ms-auto">
 +              <button class="btn btn-secondary btn-xs btn-xs-lg dropdown-toggle" type="button" id="history_sasl_days" data-bs-toggle="dropdown">{{ lang.user.login_history }}</button>
 +              <ul class="dropdown-menu">
 +                <li class="login-history" data-days="1"><a class="dropdown-item" href="#">1 {{ lang.user.day }}</a></li>
 +                <li class="login-history" data-days="7"><a class="dropdown-item active" href="#">1 {{ lang.user.week }}</a></li>
 +                <li class="login-history" data-days="14"><a class="dropdown-item" href="#">2 {{ lang.user.weeks }}</a></li>
 +                <li class="login-history" data-days="31"><a class="dropdown-item" href="#">1 {{ lang.user.month }}</a></li>
 +              </ul>
 +            </div>
            </div>
 -          <div class="last-login mt-4" id="recent-logins"></div>
 -          <span class="clear-last-logins mt-2">
 -            {{ lang.user.clear_recent_successful_connections }}
 -          </span>
 +          <ul class="list-group last-sasl-login overflow-auto" style="flex: 1 1 0; min-height: 400px;"></ul>
          </div>
        </div>
      </div>