Browse Source

Merge pull request #6009 from mailcow/feat/pw-reset

[Web] Add a forgot password flow
FreddleSpl0it 1 year ago
parent
commit
cb9ca772b1

+ 29 - 0
data/assets/templates/pw_reset_html.tpl

@@ -0,0 +1,29 @@
+<html>
+  <head>
+  <meta name="x-apple-disable-message-reformatting" />
+  <style>
+  body {
+    font-family: Helvetica, Arial, Sans-Serif;
+  }
+  /* mobile devices */
+  @media all and (max-width: 480px) {
+    .mob {
+      display: none;
+    }    
+  }
+  </style>
+  </head>
+  <body>
+Hello {{username2}},<br><br>
+
+Somebody requested a new password for the {{hostname}} account associated with {{username}}.<br>
+<small>Date of the password reset request: {{date}}</small><br><br>
+
+You can reset your password by clicking the link below:<br>
+<a href="{{link}}">{{link}}</a><br><br>
+
+The link will be valid for the next {{token_lifetime}} minutes.<br><br>
+
+If you did not request a new password, please ignore this email.<br>
+  </body>
+</html>

+ 11 - 0
data/assets/templates/pw_reset_text.tpl

@@ -0,0 +1,11 @@
+Hello {{username2}},
+
+Somebody requested a new password for the {{hostname}} account associated with {{username}}.
+Date of the password reset request: {{date}}
+
+You can reset your password by clicking the link below:
+{{link}}
+
+The link will be valid for the next {{token_lifetime}} minutes.
+
+If you did not request a new password, please ignore this email.

+ 1 - 0
data/web/admin.php

@@ -107,6 +107,7 @@ $template_data = [
   'f2b_banlist_url' => getBaseUrl() . "/api/v1/get/fail2ban/banlist/" . $f2b_data['banlist_id'],
   'q_data' => quarantine('settings'),
   'qn_data' => quota_notification('get'),
+  'pw_reset_data' => reset_password('get_notification'),
   'rsettings_map' => file_get_contents('http://nginx:8081/settings.php'),
   'rsettings' => $rsettings,
   'rspamd_regex_maps' => $rspamd_regex_maps,

+ 424 - 15
data/web/inc/functions.inc.php

@@ -1073,13 +1073,17 @@ function update_sogo_static_view($mailbox = null) {
 function edit_user_account($_data) {
   global $lang;
   global $pdo;
+
   $_data_log = $_data;
   !isset($_data_log['user_new_pass']) ?: $_data_log['user_new_pass'] = '*';
   !isset($_data_log['user_new_pass2']) ?: $_data_log['user_new_pass2'] = '*';
   !isset($_data_log['user_old_pass']) ?: $_data_log['user_old_pass'] = '*';
+
   $username = $_SESSION['mailcow_cc_username'];
   $role = $_SESSION['mailcow_cc_role'];
   $password_old = $_data['user_old_pass'];
+  $pw_recovery_email = $_data['pw_recovery_email'];
+
   if (filter_var($username, FILTER_VALIDATE_EMAIL === false) || $role != 'user') {
     $_SESSION['return'][] =  array(
       'type' => 'danger',
@@ -1088,20 +1092,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");
-  $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");
+    $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) {
@@ -1116,8 +1124,29 @@ function edit_user_account($_data) {
       ':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");
+    $stmt->execute(array(
+      ':recovery_email' => $pw_recovery_email,
+      ':username' => $username
+    ));
   }
-  update_sogo_static_view();
+
   $_SESSION['return'][] =  array(
     'type' => 'success',
     'log' => array(__FUNCTION__, $_data_log),
@@ -2261,6 +2290,386 @@ function uuid4() {
 
   return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
 }
+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->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");
+      $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");
+      $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 get_logs($application, $lines = false) {
   if ($lines === false) {

+ 61 - 42
data/web/inc/functions.mailbox.inc.php

@@ -184,6 +184,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             'msg' => 'global_filter_written'
           );
           return true;
+        break;
         case 'filter':
           $sieve = new Sieve\SieveParser();
           if (!isset($_SESSION['acl']['filters']) || $_SESSION['acl']['filters'] != "1" ) {
@@ -1249,6 +1250,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             $_data['quarantine_notification'] = (in_array('quarantine_notification', $_data['acl'])) ? 1 : 0;
             $_data['quarantine_category'] = (in_array('quarantine_category', $_data['acl'])) ? 1 : 0;
             $_data['app_passwds'] = (in_array('app_passwds', $_data['acl'])) ? 1 : 0;
+            $_data['pw_reset'] = (in_array('pw_reset', $_data['acl'])) ? 1 : 0;
           } else {
             $_data['spam_alias'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_spam_alias']);
             $_data['tls_policy'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_tls_policy']);
@@ -1264,14 +1266,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             $_data['quarantine_notification'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_quarantine_notification']);
             $_data['quarantine_category'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_quarantine_category']);
             $_data['app_passwds'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_app_passwds']);     
+            $_data['pw_reset'] = intval($MAILBOX_DEFAULT_ATTRIBUTES['acl_pw_reset']);     
           }
 
           try {
             $stmt = $pdo->prepare("INSERT INTO `user_acl` 
               (`username`, `spam_alias`, `tls_policy`, `spam_score`, `spam_policy`, `delimiter_action`, `syncjobs`, `eas_reset`, `sogo_profile_reset`,
-                `pushover`, `quarantine`, `quarantine_attachments`, `quarantine_notification`, `quarantine_category`, `app_passwds`) 
+                `pushover`, `quarantine`, `quarantine_attachments`, `quarantine_notification`, `quarantine_category`, `app_passwds`, `pw_reset`) 
               VALUES (:username, :spam_alias, :tls_policy, :spam_score, :spam_policy, :delimiter_action, :syncjobs, :eas_reset, :sogo_profile_reset,
-                :pushover, :quarantine, :quarantine_attachments, :quarantine_notification, :quarantine_category, :app_passwds) ");
+                :pushover, :quarantine, :quarantine_attachments, :quarantine_notification, :quarantine_category, :app_passwds, :pw_reset) ");
             $stmt->execute(array(
               ':username' => $username,
               ':spam_alias' => $_data['spam_alias'],
@@ -1287,7 +1290,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               ':quarantine_attachments' => $_data['quarantine_attachments'],
               ':quarantine_notification' => $_data['quarantine_notification'],
               ':quarantine_category' => $_data['quarantine_category'],
-              ':app_passwds' => $_data['app_passwds']
+              ':app_passwds' => $_data['app_passwds'],
+              ':pw_reset' => $_data['pw_reset']
             ));
           }
           catch (PDOException $e) {
@@ -1576,6 +1580,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
             $attr['acl_quarantine_notification'] = (in_array('quarantine_notification', $_data['acl'])) ? 1 : 0;
             $attr['acl_quarantine_category'] = (in_array('quarantine_category', $_data['acl'])) ? 1 : 0;
             $attr['acl_app_passwds'] = (in_array('app_passwds', $_data['acl'])) ? 1 : 0;
+            $attr['acl_pw_reset'] = (in_array('pw_reset', $_data['acl'])) ? 1 : 0;
           } else {
             $_data['acl'] = (array)$_data['acl'];
             $attr['acl_spam_alias'] = 0;
@@ -2865,21 +2870,22 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               $_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());
+              (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());
             }
             else {
               $_SESSION['return'][] = array(
@@ -3132,31 +3138,43 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               ':address' => $username,
               ':active' => $active
             ));
-            $stmt = $pdo->prepare("UPDATE `mailbox` SET
-                `active` = :active,
-                `name`= :name,
-                `quota` = :quota_b,
-                `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)
-                  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,
-              ':relayhost' => $relayhost,
-              ':username' => $username
-            ));
+            try {
+              $stmt = $pdo->prepare("UPDATE `mailbox` SET
+                  `active` = :active,
+                  `name`= :name,
+                  `quota` = :quota_b,
+                  `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)
+                    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
+                ));
+            }
+            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;
@@ -3263,6 +3281,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
               $attr['acl_quarantine_notification'] = (in_array('quarantine_notification', $_data['acl'])) ? 1 : 0;
               $attr['acl_quarantine_category'] = (in_array('quarantine_category', $_data['acl'])) ? 1 : 0;
               $attr['acl_app_passwds'] = (in_array('app_passwds', $_data['acl'])) ? 1 : 0;
+              $attr['acl_pw_reset'] = (in_array('pw_reset', $_data['acl'])) ? 1 : 0;
             } else {    
               foreach ($is_now as $key => $value){
                 $attr[$key] = $is_now[$key];

+ 15 - 1
data/web/inc/init_db.inc.php

@@ -3,7 +3,7 @@ function init_db_schema() {
   try {
     global $pdo;
 
-    $db_version = "26022024_1433";
+    $db_version = "29072024_1000";
 
     $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
     $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -483,6 +483,7 @@ function init_db_schema() {
           "quarantine_notification" => "TINYINT(1) NOT NULL DEFAULT '1'",
           "quarantine_category" => "TINYINT(1) NOT NULL DEFAULT '1'",
           "app_passwds" => "TINYINT(1) NOT NULL DEFAULT '1'",
+          "pw_reset" => "TINYINT(1) NOT NULL DEFAULT '1'",
           ),
         "keys" => array(
           "primary" => array(
@@ -694,6 +695,19 @@ function init_db_schema() {
         ),
         "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
       ),
+      "reset_password" => array(
+        "cols" => array(
+          "username" => "VARCHAR(255) NOT NULL",
+          "token" => "VARCHAR(255) NOT NULL",
+          "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
+        ),
+        "keys" => array(
+          "primary" => array(
+            "" => array("token", "created")
+          ),
+        ),
+        "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
+      ),
       "imapsync" => array(
         "cols" => array(
           "id" => "INT NOT NULL AUTO_INCREMENT",

+ 50 - 10
data/web/inc/triggers.inc.php

@@ -10,16 +10,54 @@ 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)) {
-    $_SESSION['mailcow_cc_username'] = $_SESSION['pending_mailcow_cc_username'];
-    $_SESSION['mailcow_cc_role'] = $_SESSION['pending_mailcow_cc_role'];
-    unset($_SESSION['pending_mailcow_cc_username']);
-    unset($_SESSION['pending_mailcow_cc_role']);
-    unset($_SESSION['pending_tfa_methods']);
+    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']);
+      unset($_SESSION['pending_mailcow_cc_username']);
+      unset($_SESSION['pending_tfa_methods']);
 
-    header("Location: /user");
+      header("Location: /");
+      exit;
+    } else {
+      $_SESSION['mailcow_cc_username'] = $_SESSION['pending_mailcow_cc_username'];
+      $_SESSION['mailcow_cc_role'] = $_SESSION['pending_mailcow_cc_role'];
+      unset($_SESSION['pending_mailcow_cc_username']);
+      unset($_SESSION['pending_mailcow_cc_role']);
+      unset($_SESSION['pending_tfa_methods']);
+  
+      header("Location: /user");
+    }
   } 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']);
@@ -27,11 +65,13 @@ if (isset($_POST["verify_tfa_login"])) {
 }
 
 if (isset($_GET["cancel_tfa_login"])) {
-    unset($_SESSION['pending_mailcow_cc_username']);
-    unset($_SESSION['pending_mailcow_cc_role']);
-    unset($_SESSION['pending_tfa_methods']);
+  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']);
 
-    header("Location: /");
+  header("Location: /");
 }
 
 if (isset($_POST["quick_release"])) {

+ 6 - 0
data/web/inc/vars.inc.php

@@ -210,6 +210,12 @@ $MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format'] = 'maildir:';
 // Show last IMAP and POP3 logins
 $SHOW_LAST_LOGIN = true;
 
+// Maximum number of password reset tokens that can be generated at once per user
+$PW_RESET_TOKEN_LIMIT = 3;
+
+// Maximum time in minutes a password reset token is valid
+$PW_RESET_TOKEN_LIFETIME = 15;
+
 // UV flag handling in FIDO2/WebAuthn - defaults to false to allow iOS logins
 // true = required
 // false = preferred

+ 3 - 0
data/web/js/site/mailbox.js

@@ -380,6 +380,9 @@ $(document).ready(function() {
     if (template.acl_app_passwds == 1){
       acl.push("app_passwds");
     }
+    if (template.acl_pw_reset == 1){
+      acl.push("pw_reset");
+    }
     $('#user_acl').selectpicker('val', acl);
 
     $('#rl_value').val(template.rl_value);

+ 3 - 1
data/web/json_api.php

@@ -1973,7 +1973,6 @@ if (isset($_GET['query'])) {
         case "quota_notification_bcc":
           process_edit_return(quota_notification_bcc('edit', $attr));
         break;
-        break;
         case "mailq":
           process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr)));
         break;
@@ -2069,6 +2068,9 @@ if (isset($_GET['query'])) {
         case "cors":
           process_edit_return(cors('edit', $attr));
         break;
+        case "reset-password-notification":
+          process_edit_return(reset_password('edit_notification', $attr));
+        break;
         // return no route found if no case is matched
         default:
           http_response_code(404);

+ 26 - 0
data/web/lang/lang.de-de.json

@@ -14,6 +14,7 @@
         "prohibited": "Untersagt durch Richtlinie",
         "protocol_access": "Ändern der erlaubten Protokolle",
         "pushover": "Pushover",
+        "pw_reset": "Verwalten der E-Mail zur Passwortwiederherstellung erlauben",
         "quarantine": "Quarantäne-Aktionen",
         "quarantine_attachments": "Anhänge aus Quarantäne",
         "quarantine_category": "Ändern der Quarantäne-Benachrichtigungskategorie",
@@ -248,6 +249,11 @@
         "password_policy_numbers": "Muss eine Ziffer enthalten",
         "password_policy_special_chars": "Muss Sonderzeichen enthalten",
         "password_repeat": "Passwort wiederholen",
+        "password_reset_info": "Wenn keine E-Mail zur Passwortwiederherstellung hinterlegt ist, kann diese Funktion nicht genutzt werden.",
+        "password_reset_settings": "Einstellungen zur Passwortwiederherstellung",
+        "password_reset_tmpl_html": "HTML Vorlage",
+        "password_reset_tmpl_text": "Text Vorlage",
+        "password_settings": "Passwort Einstellungen",
         "priority": "Gewichtung",
         "private_key": "Private Key",
         "quarantine": "Quarantäne",
@@ -287,6 +293,8 @@
         "remove_row": "Entfernen",
         "reset_default": "Zurücksetzen auf Standard",
         "reset_limit": "Hash entfernen",
+        "reset_password_vars": "<code>{{link}}</code> Der generierte Passwort-Reset-Link<br><code>{{username}}</code> Die E-Mail-Adresse des Benutzers, der die Passwortzurücksetzung angefordert hat<br><code>{{username2}}</code> Die E-Mail-Adresse zur Wiederherstellung<br><code>{{date}}</code> Das Datum, an dem die Passwort-Reset-Anfrage gestellt wurde<br><code>{{token_lifetime}}</code> Die Gültigkeitsdauer des Tokens in Minuten<br><code>{{hostname}}</code> Der mailcow Hostname",
+        "restore_template": "Leer lassen, um Standard-Template wiederherzustellen.",
         "routing": "Routing",
         "rsetting_add_rule": "Regel hinzufügen",
         "rsetting_content": "Regelinhalt",
@@ -407,6 +415,7 @@
         "invalid_nexthop_authenticated": "Dieser Next Hop existiert bereits mit abweichenden Authentifizierungsdaten. Die bestehenden Authentifizierungsdaten dieses \"Next Hops\" müssen vorab angepasst werden.",
         "invalid_recipient_map_new": "Neuer Empfänger \"%s\" ist ungültig",
         "invalid_recipient_map_old": "Originaler Empfänger \"%s\" ist ungültig",
+        "invalid_reset_token": "Ungültiger Rücksetz-Token",
         "ip_list_empty": "Liste erlaubter IPs darf nicht leer sein",
         "is_alias": "%s lautet bereits eine Alias-Adresse",
         "is_alias_or_mailbox": "Eine Mailbox, ein Alias oder eine sich aus einer Alias-Domain ergebende Adresse mit dem Namen %s ist bereits vorhanden",
@@ -436,6 +445,8 @@
         "password_complexity": "Passwort entspricht nicht den Richtlinien",
         "password_empty": "Passwort darf nicht leer sein",
         "password_mismatch": "Passwort-Wiederholung stimmt nicht überein",
+        "password_reset_invalid_user": "Benutzer nicht gefunden oder keine E-Mail-Adresse zur Wiederherstellung eingerichtet",
+        "password_reset_na": "Die Passwortwiederherstellung ist momentan nicht verfügbar. Bitte wenden Sie sich an Ihren Administrator.",
         "policy_list_from_exists": "Ein Eintrag mit diesem Wert existiert bereits",
         "policy_list_from_invalid": "Eintrag hat ein ungültiges Format",
         "private_key_error": "Schlüsselfehler: %s",
@@ -444,10 +455,12 @@
         "pushover_token": "Pushover Token hat das falsche Format",
         "quota_not_0_not_numeric": "Speicherplatz muss numerisch und >= 0 sein",
         "recipient_map_entry_exists": "Eine Empfängerumschreibung für Objekt \"%s\" existiert bereits",
+        "recovery_email_failed": "E-Mail zur Wiederherstellung konnte nicht gesendet werden. Bitte wenden Sie sich an Ihren Administrator.",
         "redis_error": "Redis Fehler: %s",
         "relayhost_invalid": "Map-Eintrag %s ist ungültig",
         "release_send_failed": "Die Nachricht konnte nicht versendet werden: %s",
         "reset_f2b_regex": "Regex-Filter konnten nicht in vorgegebener Zeit zurückgesetzt werden, bitte erneut versuchen oder die Webseite neu laden.",
+        "reset_token_limit_exceeded": "Das Limit für Rücksetz-Tokens wurde überschritten. Bitte versuchen Sie es später erneut.",
         "resource_invalid": "Ressourcenname %s ist ungültig",
         "rl_timeframe": "Ratelimit-Zeitraum ist inkorrekt",
         "rspamd_ui_pw_length": "Rspamd UI-Passwort muss mindestens 6 Zeichen lang sein",
@@ -467,6 +480,7 @@
         "tls_policy_map_dest_invalid": "Ziel ist ungültig",
         "tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
         "tls_policy_map_parameter_invalid": "Parameter ist ungültig",
+        "to_invalid": "Empfänger darf nicht leer sein",
         "totp_verification_failed": "TOTP-Verifizierung fehlgeschlagen",
         "transport_dest_exists": "Transport-Maps-Ziel \"%s\" existiert bereits",
         "webauthn_verification_failed": "WebAuthn-Verifizierung fehlgeschlagen: %s",
@@ -638,6 +652,7 @@
         "nexthop": "Next Hop",
         "none_inherit": "Keine Auswahl / Erben",
         "password": "Passwort",
+        "password_recovery_email": "E-Mail zur Passwortwiederherstellung",
         "password_repeat": "Passwort wiederholen",
         "previous": "Vorherige Seite",
         "private_comment": "Privater Kommentar",
@@ -741,12 +756,19 @@
         "session_expires": "Die Sitzung wird in etwa 15 Sekunden beendet."
     },
     "login": {
+        "back_to_mailcow": "Zurück zu mailcow",
         "delayed": "Login wurde zur Sicherheit um %s Sekunde/n verzögert.",
         "fido2_webauthn": "FIDO2/WebAuthn Login",
+        "forgot_password": "> Passwort vergessen?",
+        "invalid_pass_reset_token": "Der Rücksetz-Token für das Passwort ist ungültig oder abgelaufen.<br>Bitte fordern Sie einen neuen Link zur Passwortwiederherstellung an.",
         "login": "Anmelden",
         "mobileconfig_info": "Bitte als Mailbox-Benutzer einloggen, um das Verbindungsprofil herunterzuladen.",
+        "new_password": "Neues Passwort",
+        "new_password_confirm": "Neues Passwort bestätigen",        
         "other_logins": "Key Login",
         "password": "Passwort",
+        "reset_password": "Passwort zurücksetzen",
+        "request_reset_password": "Passwortänderung anfordern",
         "username": "Benutzername"
     },
     "mailbox": {
@@ -1065,11 +1087,13 @@
         "nginx_reloaded": "Nginx wurde neu geladen",
         "object_modified": "Änderungen an Objekt %s wurden gespeichert",
         "password_policy_saved": "Passwortrichtlinie wurde erfolgreich gespeichert",
+        "password_changed_success": "Das Passwort wurde erfolgreich geändert",
         "pushover_settings_edited": "Pushover-Konfiguration gespeichert, bitte den Zugang im Anschluss verifizieren.",
         "qlearn_spam": "Nachricht-ID %s wurde als Spam gelernt und gelöscht",
         "queue_command_success": "Queue-Aufgabe erfolgreich ausgeführt",
         "recipient_map_entry_deleted": "Empfängerumschreibung mit der ID %s wurde gelöscht",
         "recipient_map_entry_saved": "Empfängerumschreibung für Objekt \"%s\" wurde gespeichert",
+        "recovery_email_sent": "Wiederherstellungs-E-Mail an %s gesendet",
         "relayhost_added": "Map-Eintrag %s wurde hinzugefügt",
         "relayhost_removed": "Map-Eintrag %s wurde entfernt",
         "reset_main_logo": "Standardgrafik wurde wiederhergestellt",
@@ -1202,6 +1226,7 @@
         "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.",
         "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>]",
@@ -1211,6 +1236,7 @@
         "pushover_title": "Notification Titel",
         "pushover_vars": "Wenn kein Sender-Filter definiert ist, werden alle E-Mails berücksichtigt.<br>Die direkte Absenderprüfung und reguläre Ausdrücke werden unabhängig voneinander geprüft, sie <b>hängen nicht voneinander ab</b> und werden der Reihe nach ausgeführt. <br>Verwendbare Variablen für Titel und Text (Datenschutzrichtlinien beachten)",
         "pushover_verify": "Verbindung verifizieren",
+        "pw_recovery_email": "E-Mail zur Passwortwiederherstellung",
         "q_add_header": "Junk-Ordner",
         "q_all": "Alle Kategorien",
         "q_reject": "Abgelehnt",

+ 26 - 0
data/web/lang/lang.en-gb.json

@@ -14,6 +14,7 @@
         "prohibited": "Prohibited by ACL",
         "protocol_access": "Change protocol access",
         "pushover": "Pushover",
+        "pw_reset": "Allow to reset mailcow user password",
         "quarantine": "Quarantine actions",
         "quarantine_attachments": "Quarantine attachments",
         "quarantine_category": "Change quarantine notification category",
@@ -256,6 +257,11 @@
         "password_policy_numbers": "Must contain at least one number",
         "password_policy_special_chars": "Must contain special characters",
         "password_repeat": "Confirmation password (repeat)",
+        "password_reset_info": "If no recovery email is provided, this function cannot be used.",
+        "password_reset_settings": "Password Recovery Settings",
+        "password_reset_tmpl_html": "HTML Template",
+        "password_reset_tmpl_text": "Text Template",
+        "password_settings": "Password Settings",
         "priority": "Priority",
         "private_key": "Private key",
         "quarantine": "Quarantine",
@@ -296,6 +302,8 @@
         "remove_row": "Remove row",
         "reset_default": "Reset to default",
         "reset_limit": "Remove hash",
+        "reset_password_vars": "<code>{{link}}</code> The generated password reset link<br><code>{{username}}</code> The mailbox name of the user who requested the password reset<br><code>{{username2}}</code> The recovery mailbox name<br><code>{{date}}</code> The date the password reset request was made<br><code>{{token_lifetime}}</code> The token lifetime in minutes<br><code>{{hostname}}</code> The mailcow hostname",
+        "restore_template": "Leave empty to restore default template.",
         "routing": "Routing",
         "rsetting_add_rule": "Add rule",
         "rsetting_content": "Rule content",
@@ -407,6 +415,7 @@
         "invalid_nexthop_authenticated": "Next hop exists with different credentials, please update the existing credentials for this next hop first.",
         "invalid_recipient_map_new": "Invalid new recipient specified: %s",
         "invalid_recipient_map_old": "Invalid original recipient specified: %s",
+        "invalid_reset_token": "Invalid reset token",
         "ip_list_empty": "List of allowed IPs cannot be empty",
         "is_alias": "%s is already known as an alias address",
         "is_alias_or_mailbox": "%s is already known as an alias, a mailbox or an alias address expanded from an alias domain.",
@@ -436,6 +445,8 @@
         "password_complexity": "Password does not meet the policy",
         "password_empty": "Password must not be empty",
         "password_mismatch": "Confirmation password does not match",
+        "password_reset_invalid_user": "Mailbox not found or no recovery email is set",
+        "password_reset_na": "The password recovery is currently unavailable. Please contact your administrator.",
         "policy_list_from_exists": "A record with given name exists",
         "policy_list_from_invalid": "Record has invalid format",
         "private_key_error": "Private key error: %s",
@@ -444,10 +455,12 @@
         "pushover_token": "Pushover token has a wrong format",
         "quota_not_0_not_numeric": "Quota must be numeric and >= 0",
         "recipient_map_entry_exists": "A Recipient map entry \"%s\" exists",
+        "recovery_email_failed": "Could not send a recovery email. Please contact your administrator.",
         "redis_error": "Redis error: %s",
         "relayhost_invalid": "Map entry %s is invalid",
         "release_send_failed": "Message could not be released: %s",
         "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",
@@ -470,6 +483,7 @@
         "tls_policy_map_dest_invalid": "Policy destination is invalid",
         "tls_policy_map_entry_exists": "A TLS policy map entry \"%s\" exists",
         "tls_policy_map_parameter_invalid": "Policy parameter is invalid",
+        "to_invalid": "Recipient must not be empty",
         "totp_verification_failed": "TOTP verification failed",
         "transport_dest_exists": "Transport destination \"%s\" exists",
         "webauthn_verification_failed": "WebAuthn verification failed: %s",
@@ -638,6 +652,7 @@
         "none_inherit": "None / Inherit",
         "nexthop": "Next hop",
         "password": "Password",
+        "password_recovery_email": "Password recovery email",
         "password_repeat": "Confirmation password (repeat)",
         "previous": "Previous page",
         "private_comment": "Private comment",
@@ -741,12 +756,19 @@
         "session_expires": "Your session will expire in about 15 seconds"
     },
     "login": {
+        "back_to_mailcow": "Back to mailcow",
         "delayed": "Login was delayed by %s seconds.",
         "fido2_webauthn": "FIDO2/WebAuthn Login",
+        "forgot_password": "> Forgot Password?",
+        "invalid_pass_reset_token": "The reset password token is invalid or has expired.<br>Please request a new password reset link.",
         "login": "Login",
         "mobileconfig_info": "Please login as mailbox user to download the requested Apple connection profile.",
+        "new_password": "New Password",
+        "new_password_confirm": "Confirm new password",
         "other_logins": "Key login",
         "password": "Password",
+        "reset_password": "Reset Password",
+        "request_reset_password": "Request password change",
         "username": "Username"
     },
     "mailbox": {
@@ -1072,11 +1094,13 @@
         "nginx_reloaded": "Nginx was reloaded",
         "object_modified": "Changes to object %s have been saved",
         "password_policy_saved": "Password policy was saved successfully",
+        "password_changed_success": "Password was successfully changed",
         "pushover_settings_edited": "Pushover settings successfully set, please verify credentials.",
         "qlearn_spam": "Message ID %s was learned as spam and deleted",
         "queue_command_success": "Queue command completed successfully",
         "recipient_map_entry_deleted": "Recipient map ID %s has been deleted",
         "recipient_map_entry_saved": "Recipient map entry \"%s\" has been saved",
+        "recovery_email_sent": "Recovery email sent to %s",
         "relayhost_added": "Map entry %s has been added",
         "relayhost_removed": "Map entry %s has been removed",
         "reset_main_logo": "Reset to default logo",
@@ -1210,6 +1234,7 @@
         "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.",
         "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>]",
@@ -1220,6 +1245,7 @@
         "pushover_sound": "Sound",
         "pushover_vars": "When no sender filter is defined, all mails will be considered.<br>Regex filters as well as exact sender checks can be defined individually and will be considered sequentially. They do not depend on each other.<br>Useable variables for text and title (please take note of data protection policies)",
         "pushover_verify": "Verify credentials",
+        "pw_recovery_email": "Password recovery email",
         "q_add_header": "Junk folder",
         "q_all": "All categories",
         "q_reject": "Rejected",

+ 31 - 0
data/web/reset-password.php

@@ -0,0 +1,31 @@
+<?php
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
+
+if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
+  header('Location: /debug');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+  header('Location: /mailbox');
+  exit();
+}
+elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+  header('Location: /user');
+  exit();
+}
+
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
+$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+$_SESSION['index_query_string'] = $_SERVER['QUERY_STRING'];
+
+if (isset($_GET['token'])) $is_reset_token_valid = reset_password("check", $_GET['token']);
+else $is_reset_token_valid = False;
+
+$template = 'reset-password.twig';
+$template_data = [
+  'is_mobileconfig' => str_contains($_SESSION['index_query_string'], 'mobileconfig'),
+  'is_reset_token_valid' => $is_reset_token_valid,
+  'reset_token' => $_GET['token']
+];
+
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';

+ 2 - 2
data/web/templates/admin.twig

@@ -22,7 +22,7 @@
         <li><button class="dropdown-item" data-bs-target="#tab-config-quarantine" aria-selected="false" aria-controls="tab-config-quarantine" role="tab" data-bs-toggle="tab">{{ lang.admin.quarantine }}</button></li>
         <li><button class="dropdown-item" data-bs-target="#tab-config-quota" aria-selected="false" aria-controls="tab-config-quota" role="tab" data-bs-toggle="tab">{{ lang.admin.quota_notifications }}</button></li>
         <li><button class="dropdown-item" data-bs-target="#tab-config-rsettings" aria-selected="false" aria-controls="tab-config-rsettings" role="tab" data-bs-toggle="tab">{{ lang.admin.rspamd_settings_map }}</button></li>
-        <li><button class="dropdown-item" data-bs-target="#tab-config-password-policy" aria-selected="false" aria-controls="tab-config-password-policy" role="tab" data-bs-toggle="tab">{{ lang.admin.password_policy }}</button></li>
+        <li><button class="dropdown-item" data-bs-target="#tab-config-password-settings" aria-selected="false" aria-controls="tab-config-password-settings" role="tab" data-bs-toggle="tab">{{ lang.admin.password_settings }}</button></li>
         <li><button class="dropdown-item" data-bs-target="#tab-config-customize" aria-selected="false" aria-controls="tab-config-customize" role="tab" data-bs-toggle="tab">{{ lang.admin.customize }}</button></li>
       </ul>
     </li>
@@ -51,7 +51,7 @@
         {% include 'admin/tab-config-quota.twig' %}
         {% include 'admin/tab-config-rsettings.twig' %}
         {% include 'admin/tab-config-customize.twig' %}
-        {% include 'admin/tab-config-password-policy.twig' %}
+        {% include 'admin/tab-config-password-settings.twig' %}
         {% include 'admin/tab-sys-mails.twig' %}
         {% include 'admin/tab-globalfilter-regex.twig' %}
       </div>

+ 0 - 40
data/web/templates/admin/tab-config-password-policy.twig

@@ -1,40 +0,0 @@
-<div class="tab-pane fade" id="tab-config-password-policy" role="tabpanel" aria-labelledby="tab-config-password-policy">
-  <div class="card mb-4">
-    <div class="card-header d-flex fs-5">
-      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-password-policy" data-bs-toggle="collapse" aria-controls="collapse-tab-config-password-policy">
-        {{ lang.admin.password_policy }}
-      </button>
-      <span class="d-none d-md-block">{{ lang.admin.password_policy }}</span>
-    </div>
-    <div id="collapse-tab-config-password-policy" class="card-body collapse" data-bs-parent="#admin-content">
-      <form class="form-horizontal" data-id="passwordpolicy" role="form" method="post">
-        {% for name, value in password_complexity %}
-          {% if name == 'length' %}
-            <div class="row mb-4">
-              <label class="control-label col-sm-3 text-sm-end" for="length">{{ lang.admin.password_length }}:</label>
-              <div class="col-sm-2">
-                <input type="number" class="form-control" min="3" max="64" name="length" id="length" value="{{ value }}" required>
-              </div>
-            </div>
-          {% else %}
-            <input type="hidden" name="{{ name }}" value="0">
-            <div class="row mb-2">
-              <div class="offset-sm-3 col-sm-9">
-                <label>
-                  <input type="checkbox" class="form-check-input" name="{{ name }}" id="{{ name }}" value="1" {% if value == 1 %}checked{% endif %}> {{ lang.admin['password_policy_'~name] }}
-                </label>
-              </div>
-            </div>
-          {% endif %}
-        {% endfor %}
-        <div class="row mt-4 mb-2">
-          <div class="offset-sm-3 col-sm-9">
-            <div class="btn-group">
-              <button class="btn btn-sm d-block d-sm-inline btn-success" data-item="passwordpolicy" data-action="edit_selected" data-id="passwordpolicy" data-api-url='edit/passwordpolicy' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
-            </div>
-          </div>
-        </div>
-      </form>
-    </div>
-  </div>
-</div>

+ 102 - 0
data/web/templates/admin/tab-config-password-settings.twig

@@ -0,0 +1,102 @@
+<div class="tab-pane fade" id="tab-config-password-settings" role="tabpanel" aria-labelledby="tab-config-password-settings">
+  <div class="card mb-4">
+    <div class="card-header d-flex fs-5">
+      <button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-password-settings" data-bs-toggle="collapse" aria-controls="collapse-tab-config-password-settings">
+        {{ lang.admin.password_settings }}
+      </button>
+      <span class="d-none d-md-block">{{ lang.admin.password_settings }}</span>
+    </div>
+    <div id="collapse-tab-config-password-settings" class="card-body collapse" data-bs-parent="#admin-content">
+      <form class="form-horizontal" data-id="passwordpolicy" role="form" method="post">
+        <div class="row">
+          <div class="col-sm-12">
+            <legend>
+              {{ lang.admin.password_policy }}
+            </legend>
+            <hr />
+          </div>
+        </div>
+        {% for name, value in password_complexity %}
+          {% if name == 'length' %}
+            <div class="row mb-4">
+              <label class="control-label col-sm-3 text-sm-end" for="length">{{ lang.admin.password_length }}:</label>
+              <div class="col-sm-2">
+                <input type="number" class="form-control" min="3" max="64" name="length" id="length" value="{{ value }}" required>
+              </div>
+            </div>
+          {% else %}
+            <input type="hidden" name="{{ name }}" value="0">
+            <div class="row mb-2">
+              <div class="offset-sm-3 col-sm-9">
+                <label>
+                  <input type="checkbox" class="form-check-input" name="{{ name }}" id="{{ name }}" value="1" {% if value == 1 %}checked{% endif %}> {{ lang.admin['password_policy_'~name] }}
+                </label>
+              </div>
+            </div>
+          {% endif %}
+        {% endfor %}
+        <div class="row mt-4 mb-2">
+          <div class="offset-sm-3 col-sm-9">
+            <div class="btn-group">
+              <button class="btn btn-sm d-block d-sm-inline btn-success" data-item="passwordpolicy" data-action="edit_selected" data-id="passwordpolicy" data-api-url='edit/passwordpolicy' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
+            </div>
+          </div>
+        </div>
+      </form>
+
+      <form class="form" role="form" data-id="pw_reset_notification" method="post" style="margin-top: 50px;">
+        <div class="row">
+          <div class="col-sm-12">
+            <legend>
+              {{ lang.admin.password_reset_settings }}
+            </legend>
+            <hr />
+            <small>{{ lang.admin.reset_password_vars|raw }}</small><br><br>
+          </div>
+        </div>
+        <div class="row mb-4">
+          <div class="col-sm-6">
+            <div>
+              <label for="pw_reset_from">{{ lang.admin.quota_notification_sender }}:</label>
+              <input type="email" class="form-control" id="pw_reset_from" name="from" value="{{ pw_reset_data.from }}">
+            </div>
+          </div>
+          <div class="col-sm-6">
+            <div>
+              <label for="pw_reset_subject">{{ lang.admin.quota_notification_subject }}:</label>
+              <input type="text" class="form-control" id="pw_reset_subject" name="subject" value="{{ pw_reset_data.subject }}">
+            </div>
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-12" data-bs-target="#text_template" style="cursor:pointer" unselectable="on" data-bs-toggle="collapse">
+            <span class="d-block"><i style="font-size:10pt;" class="bi bi-plus-square"></i> {{ lang.admin.password_reset_tmpl_text }}</span>
+            <small>{{ lang.admin.restore_template }}</small>
+          </div>
+          <div id="text_template" class="col-12 collapse">
+            <textarea autocorrect="off" spellcheck="false" autocapitalize="none" class="form-control textarea-code mb-2" rows="20" name="text_tmpl">{{ pw_reset_data.text_tmpl|raw }}</textarea>
+          </div>
+          <div class="col-12 mt-3" data-bs-target="#html_template" style="cursor:pointer" unselectable="on" data-bs-toggle="collapse">
+            <span class="d-block"><i style="font-size:10pt;" class="bi bi-plus-square"></i> {{ lang.admin.password_reset_tmpl_html }}</span>
+            <small>{{ lang.admin.restore_template }}</small>
+          </div>
+          <div id="html_template" class="col-12 collapse">
+            <textarea autocorrect="off" spellcheck="false" autocapitalize="none" class="form-control textarea-code" rows="20" name="html_tmpl">{{ pw_reset_data.html_tmpl|raw }}</textarea>
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-sm-10">
+            <div>
+              <br>
+              <a type="button" class="btn btn-sm d-block d-sm-inline btn-success" data-action="edit_selected"
+                 data-item="pw_reset_notification"
+                 data-id="pw_reset_notification"
+                 data-api-url='edit/reset-password-notification'
+                 data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.user.save_changes }}</a>
+            </div>
+          </div>
+        </div>
+      </form>
+    </div>
+  </div>
+</div>

+ 1 - 0
data/web/templates/edit/mailbox-templates.twig

@@ -112,6 +112,7 @@
           <option value="quarantine_notification" {% if template.attributes.acl_quarantine_notification == '1' %} selected{% endif %}>{{ lang.acl["quarantine_notification"] }}</option>
           <option value="quarantine_category" {% if template.attributes.acl_quarantine_category == '1' %} selected{% endif %}>{{ lang.acl["quarantine_category"] }}</option>
           <option value="app_passwds" {% if template.attributes.acl_app_passwds == '1' %} selected{% endif %}>{{ lang.acl["app_passwds"] }}</option>
+          <option value="pw_reset" {% if template.attributes.acl_pw_reset == '1' %} selected{% endif %}>{{ lang.acl["pw_reset"] }}</option>
         </select>
       </div>
     </div>

+ 7 - 0
data/web/templates/edit/mailbox.twig

@@ -203,6 +203,13 @@
                       <input type="password" data-pwgen-field="true" class="form-control" name="password2" autocomplete="new-password">
                     </div>
                   </div>
+                  <div class="row mb-4">
+                    <label class="control-label col-sm-2" for="pw_recovery_email">{{ lang.edit.password_recovery_email }}</label>
+                    <div class="col-sm-10">
+                      <input type="email" class="form-control" name="pw_recovery_email" value="{{ result.attributes.recovery_email }}">
+                      <small class="text-muted">{{ lang.admin.password_reset_info }}</small>
+                    </div>
+                  </div>
                   <div data-acl="{{ acl.extend_sender_acl }}" class="row mb-4">
                     <label class="control-label col-sm-2" for="extended_sender_acl">{{ lang.edit.extended_sender_acl }}</label>
                     <div class="col-sm-10">

+ 3 - 0
data/web/templates/index.twig

@@ -63,6 +63,9 @@
             {% endif %}
           </div>
         </form>
+        <div class="mt-3 mb-4">
+          <a href="/reset-password">{{ lang.login.forgot_password }}</a>
+        </div>
         {% if login_delay %}
         <p><div class="my-4 alert alert-info">{{ lang.login.delayed|format(login_delay) }}</b></div></p>
         {% endif %}

+ 2 - 0
data/web/templates/modals/mailbox.twig

@@ -149,6 +149,7 @@
                   <option value="quarantine_notification" selected>{{ lang.acl["quarantine_notification"] }}</option>
                   <option value="quarantine_category" selected>{{ lang.acl["quarantine_category"] }}</option>
                   <option value="app_passwds" selected>{{ lang.acl["app_passwds"] }}</option>
+                  <option value="pw_reset" selected>{{ lang.acl["pw_reset"] }}</option>
               </select>
             </div>
           </div>
@@ -318,6 +319,7 @@
                 <option value="quarantine_notification" selected>{{ lang.acl["quarantine_notification"] }}</option>
                 <option value="quarantine_category" selected>{{ lang.acl["quarantine_category"] }}</option>
                 <option value="app_passwds" selected>{{ lang.acl["app_passwds"] }}</option>
+                <option value="pw_reset" selected>{{ lang.acl["pw_reset"] }}</option>
               </select>
             </div>
           </div>

+ 27 - 0
data/web/templates/modals/user.twig

@@ -309,6 +309,33 @@
     </div>
   </div>
 </div><!-- pw change modal -->
+<!-- pw recovery email modal -->
+<div class="modal fade" id="pwRecoveryEmailModal" tabindex="-1" role="dialog" aria-labelledby="pwRecoveryEmailModalLabel">
+  <div class="modal-dialog modal-lg" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h3 class="modal-title">{{ lang.user.pw_recovery_email }}</h3>
+        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
+      </div>
+      <div class="modal-body">
+        <form class="form-horizontal" data-cached-form="false" data-id="pw_recovery_change" role="form" method="post" autocomplete="off">
+          <div class="row mb-4">
+            <label class="control-label col-sm-3" for="pw_recovery_email">{{ lang.user.email }}</label>
+            <div class="col-sm-9">
+              <input type="email" class="form-control" name="pw_recovery_email" value="{{ mailboxdata.attributes.recovery_email }}">
+              <small class="text-muted">{{ lang.user.password_reset_info }}</small>
+            </div>
+          </div>
+          <div class="row">
+            <div class="offset-sm-3 col-sm-9">
+              <button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="pw_recovery_change" data-item="null" data-api-url='edit/self' data-api-attr='{}' href="#">{{ lang.user.save }}</button>
+            </div>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</div><!-- pw recovery email modal -->
 <!-- temp alias modal -->
 <div class="modal fade" id="tempAliasModal" tabindex="-1" role="dialog" aria-labelledby="tempAliasModalLabel">
   <div class="modal-dialog modal-lg" role="document">

+ 57 - 0
data/web/templates/reset-password.twig

@@ -0,0 +1,57 @@
+{% extends 'base.twig' %}
+
+{% block navbar %}{% endblock %}
+
+{% block content %}
+<div class="row mb-4" style="margin-top: 60px">
+  <div class="col-12 col-md-7 col-lg-6 col-xl-5 ms-auto me-auto">
+    <div class="card">
+      <div class="card-header d-flex align-items-center">
+        <i class="bi bi-person-fill me-2"></i> {{ lang.login.reset_password }}
+        <div class="ms-auto form-check form-switch my-auto d-flex align-items-center">
+          <label class="form-check-label"><i class="bi bi-moon-fill"></i></label>
+          <input class="form-check-input ms-2" type="checkbox" id="dark-mode-toggle">
+        </div>
+      </div>
+      <div class="card-body">
+        <div class="text-center mailcow-logo mb-4">
+          <img class="main-logo" src="{{ logo|default('/img/cow_mailcow.svg') }}" alt="mailcow">
+          <img class="main-logo-dark" src="{{ logo_dark|default('/img/cow_mailcow.svg') }}" alt="mailcow-logo-dark">
+        </div>
+        <legend>{{ ui_texts.main_name|raw }}</legend><hr />
+
+        {% if is_reset_token_valid %}
+        <form method="post" autofill="off">
+          <input type="hidden" name="token" value="{{ reset_token }}" />
+          <input type="password" autocorrect="off" autocapitalize="none" class="form-control mb-2" name="new_password" placeholder="{{ lang.login.new_password }}" />
+          <input type="password" autocorrect="off" autocapitalize="none" class="form-control mb-2" name="new_password2" placeholder="{{ lang.login.new_password_confirm }}" />
+
+          <small id="mismatch_alert" class="text-danger d-none">{{ lang.login.password_mismatch }}</small>
+          <div class="d-flex justify-content-end mt-4" style="position: relative">
+            <button type="submit" class="btn btn-xs-lg d-block d-sm-inline btn-success" name="pw_reset">{{ lang.login.reset_password }}</button>
+          </div>
+        </form>
+        {% elseif reset_token is null %}
+        <form method="post" autofill="off">
+          <input type="text" autocorrect="off" autocapitalize="none" class="form-control mb-2" name="username" placeholder="{{ lang.login.username }}" />
+
+          <div class="d-flex justify-content-end mt-4" style="position: relative">
+            <button type="submit" class="btn btn-xs-lg d-block d-sm-inline btn-success" name="pw_reset_request">{{ lang.login.request_reset_password }}</button>
+          </div>
+        </form>
+        {% else %}
+        <p class="text-center">{{ lang.login.invalid_pass_reset_token|raw }}</p>
+        <a href="/">{{ lang.login.back_to_mailcow }}</a>
+        {% endif %}
+
+
+      </div>
+    </div>
+  </div>
+</div>
+
+<script type='text/javascript'>
+  var csrf_token = '{{ csrf_token }}';
+  var mailcow_cc_username = '{{ mailcow_cc_username }}';
+</script>
+{% endblock %}

+ 1 - 0
data/web/templates/user/tab-user-auth.twig

@@ -50,6 +50,7 @@
           <p>{{ mailboxdata.quota_used|formatBytes(2) }} / {% if mailboxdata.quota == 0 %}∞{% else %}{{ mailboxdata.quota|formatBytes(2) }}{% endif %}<br>{{ mailboxdata.messages }} {{ lang.user.messages }}</p>
           <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>