diff --git a/data/conf/dovecot/dovecot.conf b/data/conf/dovecot/dovecot.conf index f07c0e19..01243091 100644 --- a/data/conf/dovecot/dovecot.conf +++ b/data/conf/dovecot/dovecot.conf @@ -45,12 +45,25 @@ recipient_delimiter = + auth_master_user_separator = * mail_shared_explicit_inbox = yes mail_prefetch_count = 30 +# try a master passwd passdb { driver = passwd-file args = /etc/dovecot/dovecot-master.passwd master = yes pass = yes + result_failure = continue + result_internalfail = continue } +# try an app passwd +passdb { + args = /etc/dovecot/sql/dovecot-dict-sql-app-passdb.conf + driver = sql + pass = yes + result_failure = continue + result_internalfail = continue +} +# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail +# a return of the following passdb is mandatory passdb { args = /etc/dovecot/sql/dovecot-dict-sql-passdb.conf driver = sql diff --git a/data/web/admin.php b/data/web/admin.php index 5b401243..855894a7 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -98,6 +98,7 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC

: - : + :

diff --git a/data/web/edit.php b/data/web/edit.php index bf730c84..4556917d 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -1314,6 +1314,54 @@ if (isset($_SESSION['mailcow_cc_role'])) { +

App

+
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ + + 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + else { + $username = $_data['username']; + } + } + else { + $username = $_SESSION['mailcow_cc_username']; + } + switch ($_action) { + case 'add': + $name = trim($_data['name']); + $password = $_data['password']; + $password2 = $_data['password2']; + $active = intval($_data['active']); + $domain = mailbox('get', 'mailbox_details', $username)['domain']; + if (empty($domain)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'access_denied' + ); + return false; + } + if (!empty($password) && !empty($password2)) { + if (!preg_match('/' . $GLOBALS['PASSWD_REGEP'] . '/', $password)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'password_complexity' + ); + return false; + } + if ($password != $password2) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'password_mismatch' + ); + return false; + } + $password_hashed = hash_password($password); + } + if (empty($name)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'app_name_empty' + ); + return false; + } + try { + $stmt = $pdo->prepare("INSERT INTO `app_passwd` (`name`, `mailbox`, `domain`, `password`, `active`) + VALUES (:name, :mailbox, :domain, :password, :active)"); + $stmt->execute(array( + ':name' => $name, + ':mailbox' => $mailbox, + ':domain' => $domain, + ':password' => $password, + ':active' => $active + )); + } + catch (PDOException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('mysql_error', $e) + ); + return false; + } + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => 'app_passwd_added' + ); + break; + case 'edit': + $ids = (array)$_data['id']; + foreach ($ids as $id) { + $is_now = app_passwd('details', $id); + if (!empty($is_now)) { + $name = (!empty($_data['name'])) ? $_data['name'] : $is_now['name']; + $password = (!empty($_data['password'])) ? $_data['password'] : null; + $password2 = (!empty($_data['password2'])) ? $_data['password2'] : null; + $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active_int']; + } + else { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('settings_map_invalid', $id) + ); + continue; + } + $name = trim($name); + if (!empty($password) && !empty($password2)) { + if (!preg_match('/' . $GLOBALS['PASSWD_REGEP'] . '/', $password)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => 'password_complexity' + ); + continue; + } + if ($password != $password2) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => 'password_mismatch' + ); + continue; + } + $password_hashed = hash_password($password); + $stmt = $pdo->prepare("UPDATE `app_passwd` SET + `password` = :password_hashed + WHERE `mailbox` = :username AND `id` = :id"); + $stmt->execute(array( + ':password_hashed' => $password_hashed, + ':username' => $username, + ':id' => $id + )); + } + try { + $stmt = $pdo->prepare("UPDATE `app_passwd` SET + `name` = :name, + `mailbox` = :username, + `active` = :active + WHERE `id` = :id"); + $stmt->execute(array( + ':name' => $name, + ':username' => $username, + ':active' => $active, + ':id' => $id + )); + } + catch (PDOException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('mysql_error', $e) + ); + continue; + } + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('object_modified', htmlspecialchars($ids)) + ); + } + break; + case 'delete': + $ids = (array)$_data['id']; + foreach ($ids as $id) { + try { + $stmt = $pdo->prepare("DELETE FROM `app_passwd` WHERE `id`= :id AND `mailbox`= :username"); + $stmt->execute(array(':id' => $id, ':username' => $username)); + } + catch (PDOException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('mysql_error', $e) + ); + return false; + } + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $_action, $_data_log), + 'msg' => array('app_passwd_removed', htmlspecialchars($id)) + ); + } + break; + case 'get': + $app_passwds = array(); + $stmt = $pdo->prepare("SELECT `id`, `name` FROM `app_passwd` WHERE `mailbox` = :username"); + $stmt->execute(array(':username' => $username)); + $app_passwds = $stmt->fetchAll(PDO::FETCH_ASSOC); + return $app_passwds; + break; + case 'details': + $app_passwd_data = array(); + $stmt = $pdo->prepare("SELECT `id`, + `name`, + `mailbox`, + `domain`, + `created`, + `modified`, + `active` AS `active_int`, + CASE `active` WHEN 1 THEN '".$lang['mailbox']['yes']."' ELSE '".$lang['mailbox']['no']."' END AS `active` + FROM `app_passwd` + WHERE `id` = :id + AND `mailbox` = :username"); + $stmt->execute(array(':id' => $_data, ':username' => $username)); + $app_passwd_data = $stmt->fetch(PDO::FETCH_ASSOC); + return $app_passwd_data; + break; + } +} \ No newline at end of file diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index e3a568b4..6c7239c8 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1260,17 +1260,20 @@ function license($action, $data = null) { $_SESSION['gal']['valid'] = "true"; $_SESSION['gal']['c'] = $json_return['c']; $_SESSION['gal']['s'] = $json_return['s']; - } + $_SESSION['gal']['m'] = str_repeat('🐄', substr_count($json_return['m'], 'o')); + } elseif ($json_return['response'] === "invalid") { $_SESSION['gal']['valid'] = "false"; $_SESSION['gal']['c'] = $lang['mailbox']['no']; $_SESSION['gal']['s'] = $lang['mailbox']['no']; + $_SESSION['gal']['m'] = $lang['mailbox']['no']; } } else { $_SESSION['gal']['valid'] = "false"; $_SESSION['gal']['c'] = $lang['danger']['temp_error']; $_SESSION['gal']['s'] = $lang['danger']['temp_error']; + $_SESSION['gal']['m'] = $lang['danger']['temp_error']; } try { // json_encode needs "true"/"false" instead of true/false, to not encode it to 0 or 1 diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 64c6d328..cd3f1956 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -3,7 +3,7 @@ function init_db_schema() { try { global $pdo; - $db_version = "06112019_1840"; + $db_version = "01122019_0755"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -321,6 +321,37 @@ function init_db_schema() { ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), + "app_passwd" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "name" => "VARCHAR(255) NOT NULL", + "mailbox" => "VARCHAR(255) NOT NULL", + "domain" => "VARCHAR(255) NOT NULL", + "password" => "VARCHAR(255) NOT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", + "active" => "TINYINT(1) NOT NULL DEFAULT '1'" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "key" => array( + "mailbox" => array("mailbox"), + "password" => array("password"), + "domain" => array("domain"), + ), + "fkey" => array( + "fk_username_app_passwd" => array( + "col" => "mailbox", + "ref" => "mailbox.username", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), "user_acl" => array( "cols" => array( "username" => "VARCHAR(255) NOT NULL", @@ -335,6 +366,7 @@ function init_db_schema() { "quarantine" => "TINYINT(1) NOT NULL DEFAULT '1'", "quarantine_attachments" => "TINYINT(1) NOT NULL DEFAULT '1'", "quarantine_notification" => "TINYINT(1) NOT NULL DEFAULT '1'", + "app_passwds" => "TINYINT(1) NOT NULL DEFAULT '1'", ), "keys" => array( "primary" => array( @@ -475,6 +507,7 @@ function init_db_schema() { "quarantine" => "TINYINT(1) NOT NULL DEFAULT '1'", "login_as" => "TINYINT(1) NOT NULL DEFAULT '1'", "sogo_access" => "TINYINT(1) NOT NULL DEFAULT '1'", + "app_passwds" => "TINYINT(1) NOT NULL DEFAULT '1'", "bcc_maps" => "TINYINT(1) NOT NULL DEFAULT '1'", "filters" => "TINYINT(1) NOT NULL DEFAULT '1'", "ratelimit" => "TINYINT(1) NOT NULL DEFAULT '1'", diff --git a/data/web/inc/prerequisites.inc.php b/data/web/inc/prerequisites.inc.php index d303a91b..cf26da6b 100644 --- a/data/web/inc/prerequisites.inc.php +++ b/data/web/inc/prerequisites.inc.php @@ -205,6 +205,7 @@ if(file_exists($langFile)) { require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.acl.inc.php'; +require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.app_passwd.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.mailbox.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.customize.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.address_rewriting.inc.php'; diff --git a/data/web/js/site/user.js b/data/web/js/site/user.js index a976dd85..cabc595b 100644 --- a/data/web/js/site/user.js +++ b/data/web/js/site/user.js @@ -156,6 +156,51 @@ jQuery(function($){ "toggleSelector": "table tbody span.footable-toggle" }); } + function draw_app_passwd_table() { + ft_apppasswd_table = FooTable.init('#app_passwd_table', { + "columns": [ + {"name":"chkbox","title":"","style":{"maxWidth":"60px","width":"60px","text-align":"center"},"filterable": false,"sortable": false,"type":"html"}, + {"sorted": true,"name":"id","title":"ID","style":{"maxWidth":"60px","width":"60px","text-align":"center"}}, + {"name":"name","title":lang.app_name}, + {"name":"active","filterable": false,"style":{"maxWidth":"70px","width":"70px"},"title":lang.active}, + {"name":"action","filterable": false,"sortable": false,"style":{"text-align":"right","maxWidth":"180px","width":"180px"},"type":"html","title":lang.action,"breakpoints":"xs sm"} + ], + "empty": lang.empty, + "rows": $.ajax({ + dataType: 'json', + url: '/api/v1/get/app-passwd/all', + jsonp: false, + error: function () { + console.log('Cannot draw app passwd table'); + }, + success: function (data) { + $.each(data, function (i, item) { + if (acl_data.app_passwds === 1) { + item.action = '
' + + ' ' + lang.edit + '' + + ' ' + lang.remove + '' + + '
'; + item.chkbox = ''; + } + else { + item.action = '-'; + item.chkbox = ''; + } + }); + } + }), + "paging": { + "enabled": true, + "limit": 5, + "size": pagination_size + }, + "state": {"enabled": true}, + "sorting": { + "enabled": true + }, + "toggleSelector": "table tbody span.footable-toggle" + }); + } function draw_wl_policy_mailbox_table() { ft_wl_policy_mailbox_table = FooTable.init('#wl_policy_mailbox_table', { "columns": [ @@ -244,6 +289,7 @@ jQuery(function($){ }) draw_sync_job_table(); + draw_app_passwd_table(); draw_tla_table(); draw_wl_policy_mailbox_table(); draw_bl_policy_mailbox_table(); diff --git a/data/web/json_api.php b/data/web/json_api.php index e2a7ef62..de2d31d3 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -206,6 +206,9 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u case "tls-policy-map": process_add_return(tls_policy_maps('add', $attr)); break; + case "app-passwd": + process_add_return(app_passwd('add', $attr)); + break; // return no route found if no case is matched default: http_response_code(404); @@ -282,6 +285,33 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u } break; + case "app-passwd": + switch ($object) { + case "all": + $app_passwds = app_passwd('get'); + if (!empty($app_passwds)) { + foreach ($app_passwds as $app_passwd) { + if ($details = app_passwd('details', $app_passwd['id'])) { + $data[] = $details; + } + else { + continue; + } + } + process_get_return($data); + } + else { + echo '{}'; + } + break; + + default: + $data = app_passwd('details', $object); + process_get_return($data); + break; + } + break; + case "mailq": switch ($object) { case "all": @@ -1121,6 +1151,9 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u case "oauth2-client": process_delete_return(oauth2('delete', 'client', array('id' => $items))); break; + case "app-passwd": + process_delete_return(app_passwd('delete', array('id' => $items))); + break; case "relayhost": process_delete_return(relayhost('delete', array('id' => $items))); break; @@ -1249,6 +1282,9 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u case "recipient_map": process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr))); break; + case "app-passwd": + process_edit_return(app_passwd('edit', array_merge(array('id' => $items), $attr))); + break; case "tls-policy-map": process_edit_return(tls_policy_maps('edit', array_merge(array('id' => $items), $attr))); break; diff --git a/data/web/lang/lang.de.json b/data/web/lang/lang.de.json index b68cacae..f0081224 100644 --- a/data/web/lang/lang.de.json +++ b/data/web/lang/lang.de.json @@ -56,7 +56,9 @@ "bcc_exists": "Ein BCC Map Eintrag %s existiert bereits als Typ %s", "private_key_error": "Schlüsselfehler: %s", "map_content_empty": "Inhalt darf nicht leer sein", + "app_name_empty": "App Name darf nicht leer sein", "settings_map_invalid": "Regel ID %s ist ungültig", + "app_passwd_id_invalid": "App Passwort ID %s ist ungültig", "global_map_invalid": "Rspamd Map %s ist ungültig", "global_map_write_error": "Kann globale Map ID %s nicht schreiben: %s", "invalid_host": "Ungültiger Host: %s", @@ -144,7 +146,9 @@ "bcc_edited": "BCC Map Eintrag %s wurde geändert", "bcc_deleted": "BCC Map Einträge gelöscht: %s", "settings_map_added": "Regel wurde gespeichert", + "app_passwd_added": "App Password wurde gespeichert", "settings_map_removed": "Regeln wurden entfernt: %s", + "app_passwd_removed": "App Passwort ID %s wurde entfernt", "saved_settings": "Regel wurde gespeichert", "dkim_removed": "DKIM-Key %s wurde entfernt", "dkim_added": "DKIM-Key %s wurde hinzugefügt", @@ -212,6 +216,10 @@ "session_ua": "Formular-Token ungültig: User-Agent-Validierungsfehler" }, "user": { + "create_app_passwd": "Erstelle App Passwort", + "app_passwds": "App Passwörter", + "app_name": "App Name", + "app_hint": "App Passwörter sind alternative Passwörter für den IMAP und SMTP Login am Mailserver. Der Benutzername bleibt unverändert.
SOGo (und damit ActiveSync) ist mit diesem Kennwort nicht verwendbar.", "loading": "Lade...", "force_pw_update": "Das Passwort für diesen Benutzer muss geändert werden, damit die Zugriffssperre auf die Groupwarekomponenten wieder freigeschaltet wird.", "active_sieve": "Aktiver Filter", @@ -224,8 +232,10 @@ "change_password": "Passwort ändern", "client_configuration": "Konfigurationsanleitungen für E-Mail-Programme und Smartphones anzeigen", "new_password": "Neues Passwort", + "password": "Passwort", "save_changes": "Änderungen speichern", "password_now": "Aktuelles Passwort (Änderungen bestätigen)", + "password_repeat": "Passwort (Wiederholung)", "new_password_repeat": "Neues Passwort (Wiederholung)", "new_password_description": "Mindestanforderung: 6 Zeichen lang, Buchstaben und Zahlen.", "spam_aliases": "Temporäre E-Mail Aliasse", @@ -475,6 +485,7 @@ "validate_license_now": "GUID erneut verifizieren", "customer_id": "Kunde", "service_id": "Service", + "sal_level": "Moo-Level", "lookup_mx": "Ziel gegen MX prüfen (etwa .outlook.com, um alle Ziele mit MX *.outlook.com zu routen)", "transport_dest_format": "Syntax: example.org, .example.org, *, box@example.org (mehrere Werte getrennt durch Komma einzugeben)", "rspamd_global_filters_agree": "Ich werde vorsichtig sein!", @@ -745,6 +756,8 @@ "generate": "generieren", "syncjob": "Syncjob hinzufügen", "syncjob_hint": "Passwörter werden unverschlüsselt abgelegt!", + "app_password": "App Passwort hinzufügen", + "app_name": "App Name", "hostname": "Host", "destination": "Ziel", "nexthop": "Next Hop", @@ -824,7 +837,8 @@ "unlimited_quota": "Unendliche Quota für Mailboxen", "extend_sender_acl": "Eingabe externer Absenderadressen erlauben", "prohibited": "Untersagt durch Richtlinie", - "sogo_access": "Verwalten des SOGo Zugriffsrechts erlauben" + "sogo_access": "Verwalten des SOGo Zugriffsrechts erlauben", + "app_passwds": "App Passwörter verwalten" }, "login": { "username": "Benutzername", diff --git a/data/web/lang/lang.en.json b/data/web/lang/lang.en.json index ee77b62a..911024ca 100644 --- a/data/web/lang/lang.en.json +++ b/data/web/lang/lang.en.json @@ -56,7 +56,9 @@ "bcc_exists": "A BCC map %s exists for type %s", "private_key_error": "Private key error: %s", "map_content_empty": "Map content cannot be empty", + "app_name_empty": "App name cannot be empty", "settings_map_invalid": "Settings map ID %s invalid", + "app_passwd_id_invalid": "App password ID %s invalid", "global_map_invalid": "Global map ID %s invalid", "global_map_write_error": "Could not write global map ID %s: %s", "invalid_host": "Invalid host specified: %s", @@ -144,7 +146,9 @@ "bcc_edited": "BCC map entry %s edited", "bcc_deleted": "BCC map entries deleted: %s", "settings_map_added": "Added settings map entry", + "app_passwd_added": "Added new app password", "settings_map_removed": "Removed settings map ID %s", + "app_passwd_removed": "Removed app password ID %s", "saved_settings": "Saved settings", "db_init_complete": "Database initialization completed", "dkim_removed": "DKIM key %s has been removed", @@ -212,6 +216,10 @@ "ip_invalid": "Skipped invalid IP: %s" }, "user": { + "create_app_passwd": "Create app password", + "app_passwds": "App passwords", + "app_name": "App name", + "app_hint": "App passwords are alternative passwords for your IMAP and SMTP login. The username remains unchanged.
SOGo (including ActiveSync) is not available through app passwords.", "loading": "Loading...", "force_pw_update": "You must set a new password to be able to access groupware related services.", "active_sieve": "Active filter", @@ -224,9 +232,11 @@ "change_password": "Change password", "client_configuration": "Show configuration guides for email clients and smartphones", "new_password": "New password", + "password": "password", "save_changes": "Save changes", "password_now": "Current password (confirm changes)", "new_password_repeat": "Confirmation password (repeat)", + "password_repeat": "Password (repeat)", "new_password_description": "Requirement: 6 characters long, letters and numbers.", "spam_aliases": "Temporary email aliases", "alias": "Alias", @@ -487,6 +497,7 @@ "validate_license_now": "Validate GUID against license server", "customer_id": "Customer ID", "service_id": "Service ID", + "sal_level": "Moo level", "lookup_mx": "Match destination against MX (.outlook.com to route all mail targeted to a MX *.outlook.com over this hop)", "transport_dest_format": "Syntax: example.org, .example.org, *, box@example.org (multiple values can be comma-separated)", "rspamd_global_filters_agree": "I will be careful!", @@ -748,6 +759,8 @@ "destination": "Destination", "nexthop": "Next hop", "port": "Port", + "app_name": "App name", + "app_password": "Add app password", "username": "Username", "enc_method": "Encryption method", "mins_interval": "Polling interval (minutes)", @@ -824,6 +837,7 @@ "extend_sender_acl": "Allow to extend sender ACL by external addresses", "prohibited": "Prohibited by ACL", "sogo_access": "Allow management of SOGo access" + "app_passwds": "Manage app passwords" }, "login": { "username": "Username", diff --git a/data/web/modals/user.php b/data/web/modals/user.php index b89b8ac0..5dec66e5 100644 --- a/data/web/modals/user.php +++ b/data/web/modals/user.php @@ -162,6 +162,52 @@ if (!isset($_SESSION['mailcow_cc_role'])) { + + + + +
+

+
+
+
+
+ + + + +
+
+
+