diff --git a/data/Dockerfiles/dockerapi/modules/DockerApi.py b/data/Dockerfiles/dockerapi/modules/DockerApi.py index 4701cbf51..af76c5377 100644 --- a/data/Dockerfiles/dockerapi/modules/DockerApi.py +++ b/data/Dockerfiles/dockerapi/modules/DockerApi.py @@ -412,6 +412,63 @@ class DockerApi: sogo_return = container.exec_run(["/bin/bash", "-c", f"sogo-tool rename-user '{old_username}' '{new_username}'"], user='sogo') return self.exec_run_handler('generic', sogo_return) + # api call: container_post - post_action: exec - cmd: sogo - task: subscribe + # Subscribes users to a folder of the owner, or unsubscribes them; values are passed as environment and + # arguments, never through the shell + def container_post__exec__sogo__subscribe(self, request_json, **kwargs): + if 'container_id' in kwargs: + filters = {"id": kwargs['container_id']} + elif 'container_name' in kwargs: + filters = {"name": kwargs['container_name']} + + for container in self.sync_docker_client.containers.list(filters=filters): + owner = str(request_json.get('owner', '')) + folder = str(request_json.get('folder', '')) + users = [str(user) for user in request_json.get('users', []) if str(user)] + if not owner or not users or not re.match(r'^(Calendar|Contacts)/[A-Za-z0-9._-]+$', folder): + return Response(content=json.dumps({ 'type': 'danger', 'msg': 'owner, folder and users required' }, indent=4), media_type="application/json") + script = 'for user in "$@"; do sogo-tool manage-acl "$SOGO_ACTION" "$SOGO_OWNER" "$SOGO_FOLDER" "$user" || exit 1; done' + subscribe_return = container.exec_run(["/bin/bash", "-c", script, "subscribe"] + users, user='sogo', + environment={ 'SOGO_ACTION': 'unsubscribe' if request_json.get('unsubscribe') else 'subscribe', 'SOGO_OWNER': owner, 'SOGO_FOLDER': folder }) + return self.exec_run_handler('generic', subscribe_return) + # api call: container_post - post_action: exec - cmd: sogo - task: init_user + # Creates the personal folders of users that never logged in to SOGo (e.g. shared mailboxes) + def container_post__exec__sogo__init_user(self, request_json, **kwargs): + if 'container_id' in kwargs: + filters = {"id": kwargs['container_id']} + elif 'container_name' in kwargs: + filters = {"name": kwargs['container_name']} + + for container in self.sync_docker_client.containers.list(filters=filters): + users = [str(user) for user in request_json.get('users', []) if str(user)] + if not users: + return Response(content=json.dumps({ 'type': 'danger', 'msg': 'users required' }, indent=4), media_type="application/json") + init_return = container.exec_run(["/bin/bash", "-c", 'for user in "$@"; do sogo-tool init-user "$user" || exit 1; done', "init_user"] + users, user='sogo') + return self.exec_run_handler('generic', init_return) + # api call: container_post - post_action: exec - cmd: doveadm - task: subscribe_shared + # Subscribes users to every folder of a mailbox shared with them (Shared//...), or unsubscribes them + def container_post__exec__doveadm__subscribe_shared(self, request_json, **kwargs): + if 'container_id' in kwargs: + filters = {"id": kwargs['container_id']} + elif 'container_name' in kwargs: + filters = {"name": kwargs['container_name']} + + for container in self.sync_docker_client.containers.list(filters=filters): + owner = str(request_json.get('owner', '')) + users = [str(user) for user in request_json.get('users', []) if str(user)] + if not owner or not users or any(c in owner for c in '/*%'): + return Response(content=json.dumps({ 'type': 'danger', 'msg': 'owner and users required' }, indent=4), media_type="application/json") + if request_json.get('unsubscribe'): + # Without access the user no longer sees the folders, their names come from the owner + script = ('set -o pipefail; for user in "$@"; do doveadm mailbox list -u "$SUB_OWNER" | while IFS= read -r folder; do ' + 'case "$folder" in Shared|Shared/*) continue;; esac; doveadm mailbox unsubscribe -u "$user" "Shared/$SUB_OWNER/$folder" || exit 1; ' + 'done || exit 1; done') + else: + # No visible folder means no access yet (ACL, auth cache): fail, so the subscription is retried + script = ('for user in "$@"; do folders=$(doveadm mailbox list -u "$user" "Shared/$SUB_OWNER/*") && [ -n "$folders" ] || exit 1; ' + 'while IFS= read -r folder; do doveadm mailbox subscribe -u "$user" "$folder" || exit 1; done <<< "$folders"; done') + subscribe_return = container.exec_run(["/bin/bash", "-c", script, "subscribe_shared"] + users, environment={ 'SUB_OWNER': owner }) + return self.exec_run_handler('generic', subscribe_return) # api call: container_post - post_action: exec - cmd: doveadm - task: get_acl def container_post__exec__doveadm__get_acl(self, request_json, **kwargs): if 'container_id' in kwargs: @@ -439,7 +496,8 @@ class DockerApi: if len(acls) >= 2: for acl in acls[1:]: user_id, rights = acl.split(maxsplit=1) - user_id = user_id.split('=')[1] + # user= as plain name, other identifiers (group=...) keep their type + user_id = user_id.split('=', 1)[1] if user_id.startswith('user=') else user_id mailbox_seen.append(mailbox) formatted_acls.append({ 'user': id, 'id': user_id, 'mailbox': mailbox, 'rights': rights.split() }) elif "Shared" in shared_folder and "/" in shared_folder: @@ -476,7 +534,7 @@ class DockerApi: id = request_json['id'].replace("'", "'\\''") if user and mailbox and id: - acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' 'user={id}'"]) + acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' '{self.acl_identifier(id)}'"]) return self.exec_run_handler('generic', acl_delete_return) # api call: container_post - post_action: exec - cmd: doveadm - task: set_acl def container_post__exec__doveadm__set_acl(self, request_json, **kwargs): @@ -510,8 +568,50 @@ class DockerApi: rights += right + " " if user and mailbox and id and rights: - acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' 'user={id}' {rights}"]) + acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' '{self.acl_identifier(id)}' {rights}"]) return self.exec_run_handler('generic', acl_set_return) + # api call: container_post - post_action: exec - cmd: doveadm - task: set_acl_all + # Sets the rights of an identifier on every folder of a mailbox, no rights = delete; values are passed as + # environment, never through the shell + def container_post__exec__doveadm__set_acl_all(self, request_json, **kwargs): + if 'container_id' in kwargs: + filters = {"id": kwargs['container_id']} + elif 'container_name' in kwargs: + filters = {"name": kwargs['container_name']} + + for container in self.sync_docker_client.containers.list(filters=filters): + user = request_json.get('user', '') + id = self.acl_identifier(request_json.get('id', '')) + rights = [right.lower() for right in request_json.get('rights', []) if right.lower() in self.acl_rights] + if not user or id in ('user=', 'group='): + return Response(content=json.dumps({ 'type': 'danger', 'msg': 'user and id required' }, indent=4), media_type="application/json") + action = 'doveadm acl set -u "$ACL_USER" "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" "$folder" "$ACL_ID"' + script = ('set -o pipefail; doveadm mailbox list -u "$ACL_USER" | while IFS= read -r folder; do ' + 'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"') + acl_return = container.exec_run(["/bin/bash", "-c", script], + environment={ 'ACL_USER': user, 'ACL_ID': id, 'ACL_RIGHTS': ' '.join(rights) }) + return self.exec_run_handler('generic', acl_return) + # api call: container_post - post_action: exec - cmd: doveadm - task: auth_cache_flush + # Drops cached userdb results (e.g. acl_groups) of the given users, all users if none are given + def container_post__exec__doveadm__auth_cache_flush(self, request_json, **kwargs): + if 'container_id' in kwargs: + filters = {"id": kwargs['container_id']} + elif 'container_name' in kwargs: + filters = {"name": kwargs['container_name']} + + for container in self.sync_docker_client.containers.list(filters=filters): + users = [str(user) for user in request_json.get('users', []) if str(user)] + flush_return = container.exec_run(["doveadm", "auth", "cache", "flush"] + users) + return self.exec_run_handler('generic', flush_return) + + # Rights doveadm acl set accepts from the API + acl_rights = ["admin", "create", "delete", "expunge", "insert", "lookup", "post", "read", "write", "write-deleted", "write-seen"] + + # ACL identifier for doveadm: "user=" for a plain name, "user=..." / "group=..." as given + def acl_identifier(self, id): + if id.startswith('user=') or id.startswith('group='): + return id + return 'user=' + id # Collect host stats diff --git a/data/Dockerfiles/dovecot/docker-entrypoint.sh b/data/Dockerfiles/dovecot/docker-entrypoint.sh index d5175e96d..0a66275f6 100755 --- a/data/Dockerfiles/dovecot/docker-entrypoint.sh +++ b/data/Dockerfiles/dovecot/docker-entrypoint.sh @@ -127,7 +127,8 @@ cat < /etc/dovecot/sql/dovecot-dict-sql-userdb.conf # Autogenerated by mailcow driver = mysql connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}" -user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%d/%n/${MAILDIR_SUB}:VOLATILEDIR=/var/volatile/%u:INDEX=/var/vmail_index/%u') AS mail, '%s' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule FROM mailbox WHERE username = '%u' AND (active = '1' OR active = '2') +# acl_groups: active mailbox groups of the user, IMAP grants are ACLs on group=mcg_ +user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%d/%n/${MAILDIR_SUB}:VOLATILEDIR=/var/volatile/%u:INDEX=/var/vmail_index/%u') AS mail, '%s' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule, (SELECT GROUP_CONCAT(CONCAT('mcg_', gm.group_id)) FROM mailbox_group_member gm JOIN mailbox_group g ON g.id = gm.group_id AND g.active = 1 WHERE gm.username = mailbox.username) AS acl_groups FROM mailbox WHERE username = '%u' AND (active = '1' OR active = '2') iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2'; EOF @@ -304,6 +305,15 @@ IMAPSYNC_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DB # Envsubst maildir_gc echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh +# Wait for updated schema (DB_VERSION from docker-compose.yml), the userdb query reads tables the init_db of php-fpm creates on updates +DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN) +while [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; do + echo "Waiting for schema update..." + sleep 5 + DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN) +done +echo "DB schema is ${DBV_NOW}" + # GUID generation while [[ ${VERSIONS_OK} != 'OK' ]]; do if [[ ! -z $(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = \"${DBNAME}\" AND TABLE_NAME = 'versions'") ]]; then diff --git a/data/Dockerfiles/sogo/bootstrap-sogo.sh b/data/Dockerfiles/sogo/bootstrap-sogo.sh index 1b2cd2ace..19ef60a92 100755 --- a/data/Dockerfiles/sogo/bootstrap-sogo.sh +++ b/data/Dockerfiles/sogo/bootstrap-sogo.sh @@ -13,13 +13,11 @@ do sleep 3 done -# Wait for updated schema +# Wait for updated schema (DB_VERSION from docker-compose.yml) DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN) -DBV_NEW=$(grep -oE '\$db_version = .*;' init_db.inc.php | sed 's/$db_version = //g;s/;//g' | cut -d \" -f2) -while [[ "${DBV_NOW}" != "${DBV_NEW}" ]]; do +while [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; do echo "Waiting for schema update..." DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN) - DBV_NEW=$(grep -oE '\$db_version = .*;' init_db.inc.php | sed 's/$db_version = //g;s/;//g' | cut -d \" -f2) sleep 5 done echo "DB schema is ${DBV_NOW}" diff --git a/data/conf/phpfpm/crons/group-grants.php b/data/conf/phpfpm/crons/group-grants.php new file mode 100644 index 000000000..6083fa81b --- /dev/null +++ b/data/conf/phpfpm/crons/group-grants.php @@ -0,0 +1,65 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; +try { + $pdo = new PDO($dsn, $database_user, $database_pass, $opt); +} catch (PDOException $e) { + fwrite(STDERR, "DB connect failed: " . $e->getMessage() . "\n"); + exit(1); +} + +$redis = new Redis(); +try { + if (!empty(getenv('REDIS_SLAVEOF_IP'))) { + $redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT')); + } else { + $redis->connect('redis-mailcow', 6379); + } + $redis->auth(getenv("REDISPASS")); +} catch (Exception $e) { + fwrite(STDERR, "Redis connect failed: " . $e->getMessage() . "\n"); + exit(1); +} + +function logMsg($priority, $message, $task = "Group Shares") { + global $redis; + $redis->lPush('CRON_LOG', json_encode(array( + "time" => time(), + "priority" => $priority, + "task" => $task, + "message" => $message, + ))); +} + +require_once __DIR__ . '/../web/inc/functions.inc.php'; +require_once __DIR__ . '/../web/inc/functions.docker.inc.php'; + +// One run at a time, released when the process ends +$lock = fopen('/tmp/group-grants.lock', 'c'); +if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { + exit(0); +} + +$sharing = \Mailcow\MailboxGroup\MailboxGroupModule::create($pdo, \Mailcow\Core\Context::system())->sharing; + +// Every minute: subscriptions of changed groups; failed ones are retried and logged by the full run +if ((int)date('i') % 15 !== 0) { + $sharing->syncSubscriptions(); + exit(0); +} + +// Every 15 minutes: IMAP ACLs on new top-level folders and failed grants, SOGo grants of owners that opened SOGo meanwhile +foreach ($sharing->reconcile() as $warning) { + logMsg("warning", $warning[0] . ": " . html_entity_decode(implode(", ", array_slice($warning, 1)))); +} diff --git a/data/conf/phpfpm/crons/identity-sync.php b/data/conf/phpfpm/crons/identity-sync.php new file mode 100644 index 000000000..11c55bff2 --- /dev/null +++ b/data/conf/phpfpm/crons/identity-sync.php @@ -0,0 +1,95 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; +try { + $pdo = new PDO($dsn, $database_user, $database_pass, $opt); +} catch (PDOException $e) { + fwrite(STDERR, "DB connect failed: " . $e->getMessage() . "\n"); + exit(1); +} + +$redis = new Redis(); +try { + if (!empty(getenv('REDIS_SLAVEOF_IP'))) { + $redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT')); + } else { + $redis->connect('redis-mailcow', 6379); + } + $redis->auth(getenv("REDISPASS")); +} catch (Exception $e) { + fwrite(STDERR, "Redis connect failed: " . $e->getMessage() . "\n"); + exit(1); +} + +function logMsg($priority, $message, $task = "Identity Sync") { + global $redis; + $redis->lPush('CRON_LOG', json_encode(array( + "time" => time(), + "priority" => $priority, + "task" => $task, + "message" => $message, + ))); +} + +require_once __DIR__ . '/../web/inc/functions.inc.php'; +require_once __DIR__ . '/../web/inc/functions.auth.inc.php'; +require_once __DIR__ . '/../web/inc/sessions.inc.php'; +require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php'; +require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php'; +require_once __DIR__ . '/../web/inc/functions.acl.inc.php'; + +// The legacy mailbox functions run as admin +$_SESSION['mailcow_cc_username'] = "admin"; +$_SESSION['mailcow_cc_role'] = "admin"; +$_SESSION['acl']['tls_policy'] = "1"; +$_SESSION['acl']['quarantine_notification'] = "1"; +$_SESSION['acl']['quarantine_category'] = "1"; +$_SESSION['acl']['ratelimit'] = "1"; +$_SESSION['acl']['sogo_access'] = "1"; +$_SESSION['acl']['protocol_access'] = "1"; +$_SESSION['acl']['mailbox_relayhost'] = "1"; +$_SESSION['acl']['unlimited_quota'] = "1"; + +// One run at a time, released when the process ends +$lock = fopen('/tmp/identity-sync.lock', 'c'); +if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { + exit(0); +} + +// Users, offboarding and groups of Keycloak or LDAP; requested runs first, else every sync_interval minutes. +// The IdP is only contacted when a run is due. +$iam_settings = identity_provider('get'); +$identity = \Mailcow\Identity\IdentityModule::create($pdo, \Mailcow\Core\Context::system(), $iam_settings, function () { + return identity_provider('init'); +}); +$run = $identity->sync->runDue(); +if ($run !== null) { + $summary = $run['summary']; + if ($run['status'] == 'error') { + logMsg("err", ucfirst($run['mode']) . " failed: " . $summary['error']); + } else { + $counts = array(); + foreach ($summary['counts'] as $key => $count) { + if ($count > 0) $counts[] = $key . "=" . $count; + } + logMsg(($run['status'] == 'blocked') ? "warning" : "info", ucfirst($run['mode']) . " " . $run['status'] . ": " . (empty($counts) ? "no changes" : implode(", ", $counts))); + foreach ($summary['details']['conflicts'] as $conflict) { + logMsg("warning", $conflict); + } + foreach ($summary['errors'] ?? array() as $error) { + logMsg("err", $error); + } + } +} +session_destroy(); diff --git a/data/conf/phpfpm/crons/keycloak-sync.php b/data/conf/phpfpm/crons/keycloak-sync.php deleted file mode 100644 index f09a47d79..000000000 --- a/data/conf/phpfpm/crons/keycloak-sync.php +++ /dev/null @@ -1,231 +0,0 @@ - PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, -]; -try { - $pdo = new PDO($dsn, $database_user, $database_pass, $opt); -} -catch (PDOException $e) { - logMsg("err", $e->getMessage()); - session_destroy(); - exit; -} - -// Init Redis -$redis = new Redis(); -try { - if (!empty(getenv('REDIS_SLAVEOF_IP'))) { - $redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT')); - } - else { - $redis->connect('redis-mailcow', 6379); - } - $redis->auth(getenv("REDISPASS")); -} -catch (Exception $e) { - echo "Exiting: " . $e->getMessage(); - session_destroy(); - exit; -} - -function logMsg($priority, $message, $task = "Keycloak Sync") { - global $redis; - - $finalMsg = array( - "time" => time(), - "priority" => $priority, - "task" => $task, - "message" => $message - ); - $redis->lPush('CRON_LOG', json_encode($finalMsg)); -} - -// Load core functions first -require_once __DIR__ . '/../web/inc/functions.inc.php'; -require_once __DIR__ . '/../web/inc/functions.auth.inc.php'; -require_once __DIR__ . '/../web/inc/sessions.inc.php'; -require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php'; -require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php'; -require_once __DIR__ . '/../web/inc/functions.acl.inc.php'; - -$_SESSION['mailcow_cc_username'] = "admin"; -$_SESSION['mailcow_cc_role'] = "admin"; -$_SESSION['acl']['tls_policy'] = "1"; -$_SESSION['acl']['quarantine_notification'] = "1"; -$_SESSION['acl']['quarantine_category'] = "1"; -$_SESSION['acl']['ratelimit'] = "1"; -$_SESSION['acl']['sogo_access'] = "1"; -$_SESSION['acl']['protocol_access'] = "1"; -$_SESSION['acl']['mailbox_relayhost'] = "1"; -$_SESSION['acl']['unlimited_quota'] = "1"; - -$iam_settings = identity_provider('get'); -if ($iam_settings['authsource'] != "keycloak" || (intval($iam_settings['periodic_sync']) != 1 && intval($iam_settings['import_users']) != 1)) { - session_destroy(); - exit; -} - -// Set pagination variables -$start = 0; -$max = 100; - -// lock sync if already running -$lock_file = '/tmp/iam-sync.lock'; -if (file_exists($lock_file)) { - $lock_file_parts = explode("\n", file_get_contents($lock_file)); - $pid = $lock_file_parts[0]; - if (count($lock_file_parts) > 1){ - $last_execution = $lock_file_parts[1]; - $elapsed_time = (time() - $last_execution) / 60; - if ($elapsed_time < intval($iam_settings['sync_interval'])) { - logMsg("warning", "Sync not ready (".number_format((float)$elapsed_time, 2, '.', '')."min / ".$iam_settings['sync_interval']."min)"); - session_destroy(); - exit; - } - } - - if (posix_kill($pid, 0)) { - logMsg("warning", "Sync is already running"); - session_destroy(); - exit; - } else { - unlink($lock_file); - } -} -$lock_file_handle = fopen($lock_file, 'w'); -fwrite($lock_file_handle, getmypid()); -fclose($lock_file_handle); - -// Init Keycloak Provider -$iam_provider = identity_provider('init'); - -// Loop until all users have been retrieved -while (true) { - // Get admin access token - $admin_token = identity_provider("get-keycloak-admin-token"); - - // Make the API request to retrieve the users - $url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users?first=$start&max=$max"; - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - "Content-Type: application/json", - "Authorization: Bearer " . $admin_token - ]); - $response = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($code != 200){ - logMsg("err", "Received HTTP {$code}"); - session_destroy(); - exit; - } - try { - $response = json_decode($response, true); - } catch (Exception $e) { - logMsg("err", $e->getMessage()); - break; - } - if (!is_array($response)){ - logMsg("err", "Received malformed response from keycloak api"); - break; - } - if (count($response) == 0) { - break; - } - - // Process the batch of users - foreach ($response as $user) { - if (empty($user['email'])){ - logMsg("warning", "No email address in keycloak found for user " . $user['name']); - continue; - } - - // try get mailbox user - $stmt = $pdo->prepare("SELECT - mailbox.*, - domain.active AS d_active - FROM `mailbox` - INNER JOIN domain on mailbox.domain = domain.domain - WHERE `kind` NOT REGEXP 'location|thing|group' - AND `username` = :user"); - $stmt->execute(array(':user' => $user['email'])); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - // check if matching attribute mapping exists - $user_template = $user['attributes']['mailcow_template'][0]; - $mapper_key = array_search($user_template, $iam_settings['mappers']); - - $_SESSION['access_all_exception'] = '1'; - if (!$row && intval($iam_settings['import_users']) == 1){ - if ($mapper_key === false){ - if (!empty($iam_settings['default_template'])) { - $mbox_template = $iam_settings['default_template']; - logMsg("warning", "Using default template for user " . $user['email']); - } else { - logMsg("warning", "No matching attribute mapping found for user " . $user['email']); - continue; - } - } else { - $mbox_template = $iam_settings['templates'][$mapper_key]; - } - // mailbox user does not exist, create... - logMsg("info", "Creating user " . $user['email']); - $create_res = mailbox('add', 'mailbox_from_template', array( - 'domain' => explode('@', $user['email'])[1], - 'local_part' => explode('@', $user['email'])[0], - 'name' => $user['firstName'] . " " . $user['lastName'], - 'authsource' => 'keycloak', - 'template' => $mbox_template - )); - if (!$create_res){ - logMsg("err", "Could not create user " . $user['email']); - continue; - } - } else if ($row && intval($iam_settings['periodic_sync']) == 1 && $row['authsource'] == "keycloak") { - if ($mapper_key === false){ - logMsg("warning", "No matching attribute mapping found for user " . $user['email']); - continue; - } - $mbox_template = $iam_settings['templates'][$mapper_key]; - // mailbox user does exist, sync attribtues... - logMsg("info", "Syncing attributes for user " . $user['email']); - mailbox('edit', 'mailbox_from_template', array( - 'username' => $user['email'], - 'name' => $user['firstName'] . " " . $user['lastName'], - 'template' => $mbox_template - )); - } else { - // skip mailbox user - logMsg("info", "Skipping user " . $user['email']); - } - $_SESSION['access_all_exception'] = '0'; - - sleep(0.025); - } - - // Update the pagination variables for the next batch - $start += $max; - sleep(1); -} - -logMsg("info", "DONE!"); -// add last execution time to lock file -$lock_file_handle = fopen($lock_file, 'w'); -fwrite($lock_file_handle, getmypid() . "\n" . time()); -fclose($lock_file_handle); -session_destroy(); diff --git a/data/conf/phpfpm/crons/ldap-sync.php b/data/conf/phpfpm/crons/ldap-sync.php deleted file mode 100644 index 32026c071..000000000 --- a/data/conf/phpfpm/crons/ldap-sync.php +++ /dev/null @@ -1,198 +0,0 @@ - PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, -]; -try { - $pdo = new PDO($dsn, $database_user, $database_pass, $opt); -} -catch (PDOException $e) { - logMsg("err", $e->getMessage()); - session_destroy(); - exit; -} - -// Init Redis -$redis = new Redis(); -try { - if (!empty(getenv('REDIS_SLAVEOF_IP'))) { - $redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT')); - } - else { - $redis->connect('redis-mailcow', 6379); - } - $redis->auth(getenv("REDISPASS")); -} -catch (Exception $e) { - echo "Exiting: " . $e->getMessage(); - session_destroy(); - exit; -} - -function logMsg($priority, $message, $task = "LDAP Sync") { - global $redis; - - $finalMsg = array( - "time" => time(), - "priority" => $priority, - "task" => $task, - "message" => $message - ); - $redis->lPush('CRON_LOG', json_encode($finalMsg)); -} - -// Load core functions first -require_once __DIR__ . '/../web/inc/functions.inc.php'; -require_once __DIR__ . '/../web/inc/functions.auth.inc.php'; -require_once __DIR__ . '/../web/inc/sessions.inc.php'; -require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php'; -require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php'; -require_once __DIR__ . '/../web/inc/functions.acl.inc.php'; - -$_SESSION['mailcow_cc_username'] = "admin"; -$_SESSION['mailcow_cc_role'] = "admin"; -$_SESSION['acl']['tls_policy'] = "1"; -$_SESSION['acl']['quarantine_notification'] = "1"; -$_SESSION['acl']['quarantine_category'] = "1"; -$_SESSION['acl']['ratelimit'] = "1"; -$_SESSION['acl']['sogo_access'] = "1"; -$_SESSION['acl']['protocol_access'] = "1"; -$_SESSION['acl']['mailbox_relayhost'] = "1"; -$_SESSION['acl']['unlimited_quota'] = "1"; - -$iam_settings = identity_provider('get'); -if ($iam_settings['authsource'] != "ldap" || (intval($iam_settings['periodic_sync']) != 1 && intval($iam_settings['import_users']) != 1)) { - session_destroy(); - exit; -} - -// Set pagination variables -$start = 0; -$max = 100; - -// lock sync if already running -$lock_file = '/tmp/iam-sync.lock'; -if (file_exists($lock_file)) { - $lock_file_parts = explode("\n", file_get_contents($lock_file)); - $pid = $lock_file_parts[0]; - if (count($lock_file_parts) > 1){ - $last_execution = $lock_file_parts[1]; - $elapsed_time = (time() - $last_execution) / 60; - if ($elapsed_time < intval($iam_settings['sync_interval'])) { - logMsg("warning", "Sync not ready (".number_format((float)$elapsed_time, 2, '.', '')."min / ".$iam_settings['sync_interval']."min)"); - session_destroy(); - exit; - } - } - - if (posix_kill($pid, 0)) { - logMsg("warning", "Sync is already running"); - session_destroy(); - exit; - } else { - unlink($lock_file); - } -} -$lock_file_handle = fopen($lock_file, 'w'); -fwrite($lock_file_handle, getmypid()); -fclose($lock_file_handle); - -// Init Provider -$iam_provider = identity_provider('init'); - -// Get ldap users -$ldap_query = $iam_provider->query(); -if (!empty($iam_settings['filter'])) { - $ldap_query = $ldap_query->rawFilter($iam_settings['filter']); -} -$response = $ldap_query->where($iam_settings['username_field'], "*") - ->where($iam_settings['attribute_field'], "*") - ->select([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname']) - ->paginate($max); - -// Process the users -foreach ($response as $user) { - // try get mailbox user - $stmt = $pdo->prepare("SELECT - mailbox.*, - domain.active AS d_active - FROM `mailbox` - INNER JOIN domain on mailbox.domain = domain.domain - WHERE `kind` NOT REGEXP 'location|thing|group' - AND `username` = :user"); - $stmt->execute(array(':user' => $user[$iam_settings['username_field']][0])); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - - // check if matching attribute mapping exists - $user_template = $user[$iam_settings['attribute_field']][0]; - $mapper_key = array_search($user_template, $iam_settings['mappers']); - - if (empty($user[$iam_settings['username_field']][0])){ - logMsg("warning", "Skipping user " . $user['displayname'][0] . " due to empty LDAP ". $iam_settings['username_field'] . " property."); - continue; - } - - $_SESSION['access_all_exception'] = '1'; - if (!$row && intval($iam_settings['import_users']) == 1){ - if ($mapper_key === false){ - if (!empty($iam_settings['default_template'])) { - $mbox_template = $iam_settings['default_template']; - } else { - logMsg("warning", "No matching attribute mapping found for user " . $user[$iam_settings['username_field']][0]); - continue; - } - } else { - $mbox_template = $iam_settings['templates'][$mapper_key]; - } - // mailbox user does not exist, create... - logMsg("info", "Creating user " . $user[$iam_settings['username_field']][0]); - $create_res = mailbox('add', 'mailbox_from_template', array( - 'domain' => explode('@', $user[$iam_settings['username_field']][0])[1], - 'local_part' => explode('@', $user[$iam_settings['username_field']][0])[0], - 'name' => $user['displayname'][0], - 'authsource' => 'ldap', - 'template' => $mbox_template - )); - if (!$create_res){ - logMsg("err", "Could not create user " . $user[$iam_settings['username_field']][0]); - continue; - } - } else if ($row && intval($iam_settings['periodic_sync']) == 1 && $row['authsource'] == "ldap") { - if ($mapper_key === false){ - logMsg("warning", "No matching attribute mapping found for user " . $user[$iam_settings['username_field']][0]); - continue; - } - $mbox_template = $iam_settings['templates'][$mapper_key]; - // mailbox user does exist, sync attribtues... - logMsg("info", "Syncing attributes for user " . $user[$iam_settings['username_field']][0]); - mailbox('edit', 'mailbox_from_template', array( - 'username' => $user[$iam_settings['username_field']][0], - 'name' => $user['displayname'][0], - 'template' => $mbox_template - )); - } else { - // skip mailbox user - logMsg("info", "Skipping user " . $user[$iam_settings['username_field']][0]); - } - $_SESSION['access_all_exception'] = '0'; - - sleep(0.025); -} - -logMsg("info", "DONE!"); -// add last execution time to lock file -$lock_file_handle = fopen($lock_file, 'w'); -fwrite($lock_file_handle, getmypid() . "\n" . time()); -fclose($lock_file_handle); -session_destroy(); diff --git a/data/web/admin/mailbox.php b/data/web/admin/mailbox.php index a79c68c1a..05530e99e 100644 --- a/data/web/admin/mailbox.php +++ b/data/web/admin/mailbox.php @@ -10,6 +10,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $js_minifier->add('/web/js/site/imapsync.js'); +$js_minifier->add('/web/js/site/groups.js'); $js_minifier->add('/web/js/site/mailbox.js'); $js_minifier->add('/web/js/presets/sieveMailbox.js'); $js_minifier->add('/web/js/site/pwgen.js'); @@ -39,6 +40,7 @@ $template_data = [ 'global_filters' => mailbox('get', 'global_filter_details'), 'domains' => $domains, 'mailboxes' => $mailboxes, + 'mbox_templates' => mailbox('get', 'mailbox_templates'), 'lang_mailbox' => json_encode($lang['mailbox']), 'lang_rl' => json_encode($lang['ratelimit']), 'lang_edit' => json_encode($lang['edit']), diff --git a/data/web/admin/system.php b/data/web/admin/system.php index 326a8a6af..8353a8d2d 100644 --- a/data/web/admin/system.php +++ b/data/web/admin/system.php @@ -12,6 +12,7 @@ $fido2_data = fido2(array("action" => "get_friendly_names")); $js_minifier->add('/web/js/site/admin.js'); $js_minifier->add('/web/js/presets/rspamd.js'); $js_minifier->add('/web/js/site/pwgen.js'); +$js_minifier->add('/web/js/site/identity.js'); // all domains $domains = mailbox('get', 'domains'); diff --git a/data/web/domainadmin/mailbox.php b/data/web/domainadmin/mailbox.php index cc1f5bc1b..4b99579a0 100644 --- a/data/web/domainadmin/mailbox.php +++ b/data/web/domainadmin/mailbox.php @@ -10,6 +10,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $js_minifier->add('/web/js/site/imapsync.js'); +$js_minifier->add('/web/js/site/groups.js'); $js_minifier->add('/web/js/site/mailbox.js'); $js_minifier->add('/web/js/presets/sieveMailbox.js'); $js_minifier->add('/web/js/site/pwgen.js'); diff --git a/data/web/edit.php b/data/web/edit.php index 4594ae3c8..eceaf6f75 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -99,6 +99,22 @@ if (isset($_SESSION['mailcow_cc_role'])) { $template = 'edit/oauth2client.twig'; $template_data = ['oauth2client' => $oauth2client]; } + elseif (isset($_GET['mailbox-group']) && + is_numeric($_GET['mailbox-group'])) { + $result = \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getGroup($_GET['mailbox-group']); + $template = 'edit/mailbox-group.twig'; + // Member candidates: mailboxes of the domains the caller manages + $mailboxes = []; + foreach (mailbox('get', 'domains') as $domain) { + foreach (mailbox('get', 'mailboxes', $domain) as $mailbox) { + $mailboxes[] = $mailbox; + } + } + $template_data = [ + 'mailboxes' => $mailboxes, + 'mbox_templates' => ($_SESSION['mailcow_cc_role'] == 'admin') ? mailbox('get', 'mailbox_templates') : [], + ]; + } elseif (isset($_GET['api-key']) && is_numeric($_GET['api-key'])) { $result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']); @@ -141,6 +157,8 @@ if (isset($_SESSION['mailcow_cc_role'])) { 'get_tls_policy' => $get_tls_policy, 'rlyhosts' => $rlyhosts, 'sender_acl_handles' => mailbox('get', 'sender_acl_handles', $mailbox), + 'mailbox_groups' => \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getMemberships($mailbox), + 'identity_link' => \Mailcow\Identity\IdentityModule::fromGlobals()->api->getLink($mailbox), 'user_acls' => acl('get', 'user', $mailbox), 'mailbox_details' => $result, 'iam_settings' => $iam_settings, @@ -237,6 +255,7 @@ else { } $js_minifier->add('/web/js/site/imapsync.js'); +$js_minifier->add('/web/js/site/groups.js'); $js_minifier->add('/web/js/site/edit.js'); $js_minifier->add('/web/js/site/pwgen.js'); diff --git a/data/web/inc/footer.inc.php b/data/web/inc/footer.inc.php index 01dc2c532..41cd022bb 100644 --- a/data/web/inc/footer.inc.php +++ b/data/web/inc/footer.inc.php @@ -73,6 +73,7 @@ $globalVariables = [ 'lang_success' => json_encode($lang['success']), 'lang_danger' => json_encode($lang['danger']), 'lang_syncjobs' => json_encode($lang['syncjobs']), + 'lang_groups' => json_encode($lang['groups']), 'docker_timeout' => $DOCKER_TIMEOUT, 'session_lifetime' => (int)$SESSION_LIFETIME, 'csrf_token' => $_SESSION['CSRF']['TOKEN'], diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php index e5a303f9b..a507bbfa4 100644 --- a/data/web/inc/functions.auth.inc.php +++ b/data/web/inc/functions.auth.inc.php @@ -262,12 +262,20 @@ function user_login($user, $pass, $extra = null){ // check if user has access to service (imap, smtp, pop3, sieve) if service is set $row['attributes'] = json_decode($row['attributes'], true); + // Shared mailboxes have no login of their own, members open them through group shares + if (($row['attributes']['shared'] ?? '0') == '1') { + return false; + } if ($extra['service'] != 'NONE') { $key = strtolower($extra['service']) . "_access"; if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { return false; } } + // IdP accounts: refuse inactive mailboxes and domains before asking the IdP (no template side effects) + if ($row['authsource'] != 'mailcow' && ($row['active'] != '1' || $row['d_active'] != '1')) { + return false; + } switch ($row['authsource']) { case 'keycloak': // user authsource is keycloak, try using via rest flow @@ -462,6 +470,7 @@ function apppass_login($user, $pass, $extra = null){ AND `mailbox`.`active` = '1' AND `domain`.`active` = '1' AND `app_passwd`.`active` = '1' + AND COALESCE(JSON_UNQUOTE(JSON_VALUE(`mailbox`.`attributes`, '$.shared')), '0') != '1' AND `app_passwd`.`mailbox` = :user" ); // fetch password data @@ -525,7 +534,11 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ 'Authorization: Bearer ' . $admin_token, 'Content-Type: application/json' )); - $user_res = json_decode(curl_exec($curl), true)[0]; + if (intval($iam_settings['ignore_ssl_error']) == 1) { + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0); + } + $users = json_decode(curl_exec($curl), true); $code = curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); if ($code != 200) { @@ -536,6 +549,14 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ ); return false; } + // exact=true can still return several users with that email + if (!is_array($users) || count($users) != 1) { + return false; + } + $user_res = $users[0]; + if (empty($user_res['enabled'])) { + return false; + } if (!isset($user_res['attributes']['mailcow_password']) || !is_array($user_res['attributes']['mailcow_password'])){ $_SESSION['return'][] = array( 'type' => 'danger', @@ -560,18 +581,28 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ } // get mapped template - $user_template = $user_res['attributes']['mailcow_template'][0]; - $mapper_key = array_search($user_template, $iam_settings['mappers']); + $name = trim(($user_res['firstName'] ?? '') . ' ' . ($user_res['lastName'] ?? '')); + $idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp; + $mapped_template = $idp_login->mappedTemplate($user_res['attributes']['mailcow_template'] ?? array()); if (!$create) { + if (!$idp_login->verifyLink('keycloak', $user, (string)($user_res['id'] ?? ''))) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $user, '*', 'The mailbox is linked to another IdP user'), + 'msg' => 'generic_server_error' + ); + return false; + } // login success - if ($mapper_key !== false) { + $update_template = $idp_login->templateFor($user, $mapped_template); + if ($update_template !== null) { // update user $_SESSION['access_all_exception'] = '1'; mailbox('edit', 'mailbox_from_template', array( 'username' => $user, - 'name' => $user_res['name'], - 'template' => $iam_settings['templates'][$mapper_key] + 'name' => $name, + 'template' => $update_template )); $_SESSION['access_all_exception'] = '0'; } @@ -590,7 +621,7 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ return false; } // check if matching attribute exist - if (empty($iam_settings['mappers']) || !$user_template || $mapper_key === false) { + if ($mapped_template === null) { if (!empty($iam_settings['default_template'])) { $mbox_template = $iam_settings['default_template']; } else { @@ -602,7 +633,16 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ return false; } } else { - $mbox_template = $iam_settings['templates'][$mapper_key]; + $mbox_template = $mapped_template; + } + + if (!$idp_login->mayCreate('keycloak', $user, (string)($user_res['id'] ?? ''))) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $user, '*', 'The IdP user is linked to another mailbox'), + 'msg' => 'generic_server_error' + ); + return false; } // create mailbox @@ -610,7 +650,7 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ $create_res = mailbox('add', 'mailbox_from_template', array( 'domain' => explode('@', $user)[1], 'local_part' => explode('@', $user)[0], - 'name' => $user_res['name'], + 'name' => $name, 'authsource' => 'keycloak', 'template' => $mbox_template )); @@ -624,6 +664,7 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){ ); return false; } + $idp_login->storeLink('keycloak', $user, (string)($user_res['id'] ?? '')); return 'user'; } @@ -655,7 +696,8 @@ function ldap_mbox_login($user, $pass, $extra = null){ $ldap_query = $ldap_query->rawFilter($iam_settings['filter']); } $ldap_query = $ldap_query->where($iam_settings['username_field'], '=', $user) - ->select([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn']); + ->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'], + \Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? ''))))); $user_res = $ldap_query->firstOrFail(); } catch (Exception $e) { @@ -684,18 +726,28 @@ function ldap_mbox_login($user, $pass, $extra = null){ } // get mapped template - $user_template = $user_res[$iam_settings['attribute_field']][0]; - $mapper_key = array_search($user_template, $iam_settings['mappers']); + $idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp; + $mapped_template = $idp_login->mappedTemplate($user_res[$iam_settings['attribute_field']] ?? array()); + $external_id = \Mailcow\Identity\Directory\LdapDirectory::entryId($user_res, $iam_settings['id_attribute'] ?? ''); if (!$create) { + if (!$idp_login->verifyLink('ldap', $user, $external_id)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $user, '*', 'The mailbox is linked to another IdP user'), + 'msg' => 'generic_server_error' + ); + return false; + } // login success - if ($mapper_key !== false) { + $update_template = $idp_login->templateFor($user, $mapped_template); + if ($update_template !== null) { // update user $_SESSION['access_all_exception'] = '1'; mailbox('edit', 'mailbox_from_template', array( 'username' => $user, 'name' => $user_res['displayname'][0], - 'template' => $iam_settings['templates'][$mapper_key] + 'template' => $update_template )); $_SESSION['access_all_exception'] = '0'; } @@ -714,7 +766,7 @@ function ldap_mbox_login($user, $pass, $extra = null){ return false; } // check if matching attribute exist - if (empty($iam_settings['mappers']) || !$user_template || $mapper_key === false) { + if ($mapped_template === null) { if (!empty($iam_settings['default_template'])) { $mbox_template = $iam_settings['default_template']; } else { @@ -726,7 +778,16 @@ function ldap_mbox_login($user, $pass, $extra = null){ return false; } } else { - $mbox_template = $iam_settings['templates'][$mapper_key]; + $mbox_template = $mapped_template; + } + + if (!$idp_login->mayCreate('ldap', $user, $external_id)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $user, '*', 'The IdP user is linked to another mailbox'), + 'msg' => 'generic_server_error' + ); + return false; } // create mailbox @@ -748,6 +809,7 @@ function ldap_mbox_login($user, $pass, $extra = null){ ); return false; } + $idp_login->storeLink('ldap', $user, $external_id); return 'user'; } diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 286a5bdf7..660cd67f0 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2360,6 +2360,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $data_log = $_data; if (isset($data_log['client_secret'])) $data_log['client_secret'] = '*'; if (isset($data_log['access_token'])) $data_log['access_token'] = '*'; + if (isset($data_log['bindpass'])) $data_log['bindpass'] = '*'; switch ($_action) { case 'get': @@ -2370,9 +2371,12 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { foreach($rows as $row){ switch ($row["key"]) { case "redirect_url_extra": + $settings[$row["key"]] = json_decode($row["value"]); + break; case "mappers": case "templates": - $settings[$row["key"]] = json_decode($row["value"]); + // Parallel lists; older rows can be JSON objects, decode as arrays to keep the keys aligned + $settings[$row["key"]] = (array)json_decode($row["value"], true); break; case "use_ssl": case "use_tls": @@ -2455,6 +2459,13 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $_data['ignore_ssl_error'] = isset($_data['ignore_ssl_error']) ? boolval($_data['ignore_ssl_error']) : false; $_data['login_provisioning'] = isset($_data['login_provisioning']) ? boolval($_data['login_provisioning']) : false; + // Identity sync: offboarding and groups (Keycloak and LDAP) + $sync_settings = array('offboarding', 'offboarding_limit', 'group_sync', 'group_autocreate', 'group_default_domain'); + $_data['offboarding'] = isset($_data['offboarding']) ? intval($_data['offboarding']) : 0; + $_data['offboarding_limit'] = isset($_data['offboarding_limit']) && is_numeric($_data['offboarding_limit']) ? min(100, max(0, intval($_data['offboarding_limit']))) : 10; + $_data['group_sync'] = isset($_data['group_sync']) ? intval($_data['group_sync']) : 0; + $_data['group_autocreate'] = isset($_data['group_autocreate']) ? intval($_data['group_autocreate']) : 0; + $_data['group_default_domain'] = isset($_data['group_default_domain']) ? strtolower(trim($_data['group_default_domain'])) : ""; switch ($_data['authsource']) { case "keycloak": $_data['server_url'] = (!empty($_data['server_url'])) ? rtrim($_data['server_url'], '/') : null; @@ -2463,7 +2474,8 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $_data['import_users'] = isset($_data['import_users']) ? intval($_data['import_users']) : 0; $_data['sync_interval'] = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15; $_data['sync_interval'] = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval']; - $required_settings = array('authsource', 'server_url', 'realm', 'client_id', 'client_secret', 'redirect_url', 'version', 'mailpassword_flow', 'periodic_sync', 'import_users', 'sync_interval', 'ignore_ssl_error', 'login_provisioning'); + $_data['group_paths'] = isset($_data['group_paths']) ? trim($_data['group_paths']) : ""; + $required_settings = array_merge(array('authsource', 'server_url', 'realm', 'client_id', 'client_secret', 'redirect_url', 'version', 'mailpassword_flow', 'periodic_sync', 'import_users', 'sync_interval', 'ignore_ssl_error', 'login_provisioning', 'group_paths'), $sync_settings); break; case "generic-oidc": $_data['authorize_url'] = (!empty($_data['authorize_url'])) ? $_data['authorize_url'] : null; @@ -2484,7 +2496,14 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $_data['use_tls'] = isset($_data['use_tls']) && !$_data['use_ssl'] ? boolval($_data['use_tls']) : false; $_data['sync_interval'] = (!empty($_data['sync_interval'])) ? intval($_data['sync_interval']) : 15; $_data['sync_interval'] = $_data['sync_interval'] < 1 ? 1 : $_data['sync_interval']; - $required_settings = array('authsource', 'host', 'port', 'basedn', 'username_field', 'filter', 'attribute_field', 'binddn', 'bindpass', 'periodic_sync', 'import_users', 'sync_interval', 'use_ssl', 'use_tls', 'ignore_ssl_error', 'login_provisioning'); + $_data['id_attribute'] = isset($_data['id_attribute']) ? strtolower(trim($_data['id_attribute'])) : ""; + $_data['disabled_filter'] = isset($_data['disabled_filter']) ? trim($_data['disabled_filter']) : ""; + $_data['group_basedn'] = isset($_data['group_basedn']) ? trim($_data['group_basedn']) : ""; + $_data['group_filter'] = isset($_data['group_filter']) ? trim($_data['group_filter']) : ""; + $_data['group_name_attribute'] = (!empty($_data['group_name_attribute'])) ? strtolower(trim($_data['group_name_attribute'])) : "cn"; + $_data['group_member_mode'] = isset($_data['group_member_mode']) && in_array($_data['group_member_mode'], array('memberof', 'member', 'ad_chain')) ? $_data['group_member_mode'] : "memberof"; + $_data['group_member_attribute'] = (!empty($_data['group_member_attribute'])) ? strtolower(trim($_data['group_member_attribute'])) : "member"; + $required_settings = array_merge(array('authsource', 'host', 'port', 'basedn', 'username_field', 'filter', 'attribute_field', 'binddn', 'bindpass', 'periodic_sync', 'import_users', 'sync_interval', 'use_ssl', 'use_tls', 'ignore_ssl_error', 'login_provisioning', 'id_attribute', 'disabled_filter', 'group_basedn', 'group_filter', 'group_name_attribute', 'group_member_mode', 'group_member_attribute'), $sync_settings); break; } @@ -2506,6 +2525,10 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $stmt->bindParam(':value', $_data[$setting]); $stmt->execute(); } + // Stored LDAP ids of another id attribute never match again, the mailboxes are linked by email again + if ($_data['authsource'] == 'ldap' && ($iam_settings['id_attribute'] ?? '') != $_data['id_attribute']) { + $pdo->query("UPDATE `identity_link` SET `external_id` = NULL WHERE `provider` = 'ldap'"); + } $pdo->commit(); // add redirect_url_extra @@ -2533,19 +2556,26 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $_data['mappers'] = (!is_array($_data['mappers'])) ? array($_data['mappers']) : $_data['mappers']; $_data['templates'] = (!is_array($_data['templates'])) ? array($_data['templates']) : $_data['templates']; - $mappers = array_filter($_data['mappers']); - $templates = array_filter($_data['templates']); - if (count($mappers) == count($templates)){ - $mappers = json_encode($mappers); - $templates = json_encode($templates); - - $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES ('mappers', :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);"); - $stmt->bindParam(':value', $mappers); - $stmt->execute(); - $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES ('templates', :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);"); - $stmt->bindParam(':value', $templates); - $stmt->execute(); + // Keep mapper/template pairs together, incomplete rows are dropped + $mappers = array(); + $templates = array(); + $template_values = array_values($_data['templates']); + foreach (array_values($_data['mappers']) as $i => $mapper) { + if ((string)$mapper === '' || (string)($template_values[$i] ?? '') === '') { + continue; + } + $mappers[] = $mapper; + $templates[] = $template_values[$i]; } + $mappers = json_encode($mappers); + $templates = json_encode($templates); + + $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES ('mappers', :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);"); + $stmt->bindParam(':value', $mappers); + $stmt->execute(); + $stmt = $pdo->prepare("INSERT INTO identity_provider (`key`, `value`) VALUES ('templates', :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`);"); + $stmt->bindParam(':value', $templates); + $stmt->execute(); } // delete old access_token @@ -2670,6 +2700,8 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { } $stmt = $pdo->query("DELETE FROM identity_provider;"); + $stmt = $pdo->query("DELETE FROM identity_link;"); + $stmt = $pdo->query("DELETE FROM identity_sync_run;"); $_SESSION['return'][] = array( 'type' => 'success', @@ -2790,8 +2822,8 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { } // get mapped template - $user_template = $info['mailcow_template']; - $mapper_key = array_search($user_template, $iam_settings['mappers']); + $idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp; + $mapped_template = $idp_login->mappedTemplate($info['mailcow_template'] ?? array()); // token valid, get mailbox $stmt = $pdo->prepare("SELECT @@ -2813,13 +2845,23 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { ); return false; } - if ($mapper_key !== false) { + if (!$idp_login->verifyLink($iam_settings['authsource'], $info['email'], (string)($info['sub'] ?? ''))) { + clear_session(); + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $info['email'], 'The mailbox is linked to another IdP user'), + 'msg' => 'login_failed' + ); + return false; + } + $update_template = $idp_login->templateFor($info['email'], $mapped_template); + if ($update_template !== null) { // update user $_SESSION['access_all_exception'] = '1'; mailbox('edit', 'mailbox_from_template', array( 'username' => $info['email'], 'name' => $info['name'], - 'template' => $iam_settings['templates'][$mapper_key] + 'template' => $update_template )); $_SESSION['access_all_exception'] = '0'; @@ -2834,11 +2876,11 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { $stmt->execute(array(':user' => $info['email'])); $row = $stmt->fetch(PDO::FETCH_ASSOC); } - if ($row['active'] != 1 || $row['d_active'] != 1) { + if ($row['active'] != 1 || $row['d_active'] != 1 || (json_decode($row['attributes'], true)['shared'] ?? '0') == '1') { clear_session(); $_SESSION['return'][] = array( 'type' => 'danger', - 'log' => array(__FUNCTION__, $info['email'], 'Domain or mailbox is inactive'), + 'log' => array(__FUNCTION__, $info['email'], 'Domain or mailbox is inactive or shared'), 'msg' => 'login_failed' ); return false; @@ -2864,7 +2906,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { return false; } - if (empty($iam_settings['mappers']) || empty($user_template) || $mapper_key === false){ + if ($mapped_template === null){ if (!empty($iam_settings['default_template'])) { $mbox_template = $iam_settings['default_template']; } else { @@ -2877,7 +2919,17 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { return false; } } else { - $mbox_template = $iam_settings['templates'][$mapper_key]; + $mbox_template = $mapped_template; + } + + if (!$idp_login->mayCreate($iam_settings['authsource'], $info['email'], (string)($info['sub'] ?? ''))) { + clear_session(); + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $info['email'], 'The IdP user is linked to another mailbox'), + 'msg' => 'login_failed' + ); + return false; } // create mailbox @@ -2899,6 +2951,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { ); return false; } + $idp_login->storeLink($iam_settings['authsource'], $info['email'], (string)($info['sub'] ?? '')); // double check if mailbox and domain is active $stmt = $pdo->prepare("SELECT * FROM `mailbox` @@ -2955,6 +3008,26 @@ function identity_provider($_action = null, $_data = null, $_extra = null) { return false; } + // The mailbox may have been deleted or deactivated since the login + $stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox` + INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain` + WHERE `kind` NOT REGEXP 'location|thing|group' + AND `mailbox`.`active` = '1' + AND `domain`.`active` = '1' + AND COALESCE(JSON_UNQUOTE(JSON_VALUE(`mailbox`.`attributes`, '$.shared')), '0') != '1' + AND `username` = :user"); + $stmt->execute(array(':user' => $info['email'])); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row || !in_array($row['authsource'], array('keycloak', 'generic-oidc'))) { + clear_session(); + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $info['email'], 'Mailbox or domain is inactive or not of type OIDC'), + 'msg' => 'refresh_login_failed' + ); + return false; + } + set_user_loggedin_session($info['email']); $_SESSION['iam_token'] = $plain_token; $_SESSION['iam_refresh_token'] = $plain_refreshtoken; diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 645e96a4d..121d43a0c 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -938,6 +938,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $eas_access = (isset($_data['eas_access'])) ? intval($_data['eas_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['eas_access']); $dav_access = (isset($_data['dav_access'])) ? intval($_data['dav_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['dav_access']); $relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 0; + // Shared mailbox: no login of its own, members open it through group shares + $shared = (isset($_data['shared'])) ? intval($_data['shared']) : 0; $quarantine_notification = (isset($_data['quarantine_notification'])) ? strval($_data['quarantine_notification']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']); $quarantine_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']); // Validate quarantine_category @@ -971,6 +973,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { 'eas_access' => strval($eas_access), 'dav_access' => strval($dav_access), 'relayhost' => strval($relayhost), + 'shared' => strval($shared), 'passwd_update' => time(), 'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']), 'quarantine_notification' => strval($quarantine_notification), @@ -1050,7 +1053,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ); return false; } - if ($authsource == 'mailcow'){ + if ($authsource == 'mailcow' && $shared == 1 && empty($password) && empty($password2)) { + // Nobody logs in to a shared mailbox, a random password keeps it unusable + $password_hashed = hash_password(bin2hex(random_bytes(32))); + } + elseif ($authsource == 'mailcow'){ if (password_check($password, $password2) !== true) { return false; } @@ -1268,7 +1275,16 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } } - return mailbox('add', 'mailbox', $mailbox_attributes); + $result = mailbox('add', 'mailbox', $mailbox_attributes); + if ($result !== false) { + // Remember the applied template, see edit mailbox_from_template + $stmt = $pdo->prepare("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.template', :template) WHERE `username` = :username"); + $stmt->execute(array( + ':template' => $_data['template'], + ':username' => strtolower(trim($_data['local_part'])) . '@' . idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46) + )); + } + return $result; break; case 'mta_sts': $domain = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46); @@ -2208,6 +2224,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ); continue; } + if (!empty($is_now['group_id'])) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => array('alias_group_managed', htmlspecialchars($is_now['address'])) + ); + continue; + } if ($_data['expand_alias'] === true || $_data['expand_alias'] == 1) { $stmt = $pdo->prepare("SELECT `address` FROM `alias` WHERE `address` = :address @@ -2370,7 +2394,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { // Delete from sender_acl to prevent duplicates $stmt = $pdo->prepare("DELETE FROM `sender_acl` WHERE `logged_in_as` = :goto AND - `send_as` = :address"); + `send_as` = :address AND + `group_id` IS NULL"); $stmt->execute(array( ':goto' => $goto, ':address' => $address @@ -2404,6 +2429,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':active' => $active, ':id' => $is_now['id'] )); + if ($address != $is_now['address']) { + // Send-as grants of groups follow the address + \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressRenamed($is_now['address'], $address); + } } $_SESSION['return'][] = array( 'type' => 'success', @@ -2785,6 +2814,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { (int)$eas_access = (isset($_data['eas_access']) && hasACLAccess("protocol_access")) ? intval($_data['eas_access']) : intval($is_now['attributes']['eas_access']); (int)$dav_access = (isset($_data['dav_access']) && hasACLAccess("protocol_access")) ? intval($_data['dav_access']) : intval($is_now['attributes']['dav_access']); (int)$relayhost = (isset($_data['relayhost']) && hasACLAccess("mailbox_relayhost")) ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']); + (int)$shared = (isset($_data['shared'])) ? intval($_data['shared']) : intval($is_now['attributes']['shared'] ?? 0); (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']; @@ -3007,7 +3037,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $sender_acl_merged = array_merge($sender_acl_domain_admin, $sender_acl_admin); // If merged array still contains "*", set it as only value !in_array('*', $sender_acl_merged) ?: $sender_acl_merged = array('*'); - $stmt = $pdo->prepare("DELETE FROM `sender_acl` WHERE `external` = 0 AND `logged_in_as` = :username"); + $stmt = $pdo->prepare("DELETE FROM `sender_acl` WHERE `external` = 0 AND `group_id` IS NULL AND `logged_in_as` = :username"); $stmt->execute(array( ':username' => $username )); @@ -3039,7 +3069,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } } else { - $stmt = $pdo->prepare("DELETE FROM `sender_acl` WHERE `external` = 0 AND `logged_in_as` = :username"); + $stmt = $pdo->prepare("DELETE FROM `sender_acl` WHERE `external` = 0 AND `group_id` IS NULL AND `logged_in_as` = :username"); $stmt->execute(array( ':username' => $username )); @@ -3080,6 +3110,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { `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`, '$.shared', :shared), `attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access), `attributes` = JSON_SET(`attributes`, '$.eas_access', :eas_access), `attributes` = JSON_SET(`attributes`, '$.dav_access', :dav_access), @@ -3102,6 +3133,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ':dav_access' => $dav_access, ':recovery_email' => $pw_recovery_email, ':relayhost' => $relayhost, + ':shared' => strval($shared), ':username' => $username, ':authsource' => $authsource )); @@ -3258,7 +3290,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { "imapsync" => ['user2'], 'bcc_maps' => ['local_dest', 'bcc_dest'], 'recipient_maps' => ['old_dest', 'new_dest'], - 'sender_acl' => ['logged_in_as', 'send_as'] + 'sender_acl' => ['logged_in_as', 'send_as'], + 'identity_link' => ['username'], + 'mailbox_group_imap_grant' => ['owner'], + 'mailbox_group_sogo_grant' => ['owner'], + 'mailbox_group_sogo_acl' => ['c_uid'], + 'mailbox_group_subscription' => ['username', 'owner'] ]; foreach ($tables as $table => $columns) { foreach ($columns as $column) { @@ -3272,6 +3309,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { // Sync sources created by / targeting the mailbox and its OAuth tokens (owner); a PDOException rolls back below \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username); + // Group memberships and send-as grants follow, group aliases are rebuilt + \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->hooks->onMailboxRenamed($old_username, $new_username); // Update c_uid, c_name and mail in _sogo_static_view table $pdo->prepare("UPDATE _sogo_static_view SET c_uid = :new_username, c_name = :new_username2, mail = :new_username3 WHERE c_uid = :old_username") @@ -3370,10 +3409,27 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $attribute_hash = sha1(json_encode($mbox_template_data["attributes"])); $is_now = mailbox('get', 'mailbox_details', $_data['username']); $name = ltrim(rtrim($_data['name'], '>'), '<'); - if ($is_now['attributes']['attribute_hash'] == $attribute_hash && $is_now['name'] == $name) + // Last applied template, null for mailboxes created before it was recorded + $applied_template = $is_now['attributes']['template'] ?? null; + $stmt_template = $pdo->prepare("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.template', :template) WHERE `username` = :username"); + if ($is_now['attributes']['attribute_hash'] == $attribute_hash && $is_now['name'] == $name) { + if ($applied_template === null) { + $stmt_template->execute(array(':template' => $_data['template'], ':username' => $_data['username'])); + } return true; + } $mbox_template_data = json_decode($mbox_template_data["attributes"], true); + // Any manual edit clears attribute_hash: re-applying the same template must not undo a manual (de)activation + if ($applied_template === null || $applied_template === $_data['template']) { + unset($mbox_template_data['active']); + } + // Login disabled by the IdP sync is only lifted by the sync + $stmt = $pdo->prepare("SELECT `idp_disabled` FROM `identity_link` WHERE `username` = :username"); + $stmt->execute(array(':username' => $_data['username'])); + if (intval($stmt->fetchColumn()) == 1) { + unset($mbox_template_data['active']); + } $mbox_template_data['attribute_hash'] = $attribute_hash; $mbox_template_data['name'] = $name; $quarantine_attributes = array('username' => $_data['username']); @@ -3416,6 +3472,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { if ($result === false) return $result; $result = acl('edit', 'user', $acl_attributes); if ($result === false) return $result; + $stmt_template->execute(array(':template' => $_data['template'], ':username' => $_data['username'])); $_SESSION['return'] = array(); return true; @@ -3528,6 +3585,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ":template" => $_data["template"] , ":attributes" => json_encode($attr) )); + // Mailboxes with this effective group template get the new settings + \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->templateChanged((int)$id); } @@ -3959,7 +4018,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { } // Return array $data['sender_acl_domains/addresses']['ro'] with read-only objects // Return array $data['sender_acl_domains/addresses']['rw'] with read-write objects (can be deleted) - $stmt = $pdo->prepare("SELECT REPLACE(`send_as`, '@', '') AS `send_as` FROM `sender_acl` WHERE `logged_in_as` = :logged_in_as AND `external` = '0' AND (`send_as` LIKE '@%' OR `send_as` = '*')"); + $stmt = $pdo->prepare("SELECT REPLACE(`send_as`, '@', '') AS `send_as` FROM `sender_acl` WHERE `logged_in_as` = :logged_in_as AND `external` = '0' AND `group_id` IS NULL AND (`send_as` LIKE '@%' OR `send_as` = '*')"); $stmt->execute(array(':logged_in_as' => $_data)); $domain_rows = $stmt->fetchAll(PDO::FETCH_ASSOC); while ($domain_row = array_shift($domain_rows)) { @@ -3978,7 +4037,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $data['sender_acl_domains']['rw'][] = $domain_row['send_as']; } } - $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :logged_in_as AND `external` = '0' AND (`send_as` NOT LIKE '@%' AND `send_as` != '*')"); + $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :logged_in_as AND `external` = '0' AND `group_id` IS NULL AND (`send_as` NOT LIKE '@%' AND `send_as` != '*')"); $stmt->execute(array(':logged_in_as' => $_data)); $address_rows = $stmt->fetchAll(PDO::FETCH_ASSOC); while ($address_row = array_shift($address_rows)) { @@ -4436,7 +4495,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { `sogo_visible`, `sender_allowed`, `created`, - `modified` + `modified`, + `group_id`, + (SELECT `name` FROM `mailbox_group` WHERE `mailbox_group`.`id` = `alias`.`group_id`) AS `group_name` FROM `alias` WHERE (`id` = :id OR `address` = :address) AND `address` != `goto`"); $stmt->execute(array( @@ -4469,6 +4530,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $aliasdata['sogo_visible'] = $row['sogo_visible']; $aliasdata['sogo_visible_int'] = $row['sogo_visible']; $aliasdata['sender_allowed'] = $row['sender_allowed']; + // Distribution address of a mailbox group, read-only here + $aliasdata['group_id'] = $row['group_id']; + $aliasdata['group_name'] = $row['group_name']; $aliasdata['created'] = $row['created']; $aliasdata['modified'] = $row['modified']; if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $aliasdata['domain'])) { @@ -4909,7 +4973,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $mailboxdata['is_relayed'] = $row['backupmx']; // Internal send-as ACL for this mailbox, mirrors the sender_acl field accepted by edit/mailbox $mailboxdata['sender_acl'] = array(); - $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `external` = '0'"); + $stmt = $pdo->prepare("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :username AND `external` = '0' AND `group_id` IS NULL"); $stmt->execute(array(':username' => $_data)); $sender_acl_rows = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($sender_acl_rows as $sender_acl_row) { @@ -5283,6 +5347,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':domain' => $domain, )); + \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->domainDeleted($domain); $stmt = $pdo->prepare("DELETE FROM `imapsync_source_domain` WHERE `domain` = :domain"); $stmt->execute(array( ':domain' => $domain, @@ -5410,6 +5475,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { ); continue; } + if (!empty($alias_data['group_id'])) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => array('alias_group_managed', htmlspecialchars($alias_data['address'])) + ); + continue; + } // Track affected mailboxes for SOGo update (capture before deletion) if (!empty($alias_data['goto'])) { @@ -5430,6 +5503,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':alias_address' => $alias_data['address'] )); + \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressDeleted($alias_data['address']); $_SESSION['return'][] = array( 'type' => 'success', 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), @@ -5470,6 +5544,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':alias_domain' => $alias_domain, )); + \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->domainDeleted($alias_domain); $stmt = $pdo->prepare("DELETE FROM `alias` WHERE `domain` = :alias_domain"); $stmt->execute(array( ':alias_domain' => $alias_domain, @@ -5552,7 +5627,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { 'msg' => 'Could not move maildir to garbage collector: variables local_part and/or domain empty' ); } - $stmt = $pdo->prepare("DELETE FROM `alias` WHERE `goto` = :username"); + \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->mailboxDeleted($username); + $stmt = $pdo->prepare("DELETE FROM `alias` WHERE `goto` = :username AND `group_id` IS NULL"); $stmt->execute(array( ':username' => $username )); diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 02a876a86..2afb086b3 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,20 @@ function init_db_schema() try { global $pdo; - $db_version = "22102026_1200"; + // Defined once as DB_VERSION in docker-compose.yml + $db_version = (string)getenv('DB_VERSION'); + if ($db_version === '') { + if (php_sapi_name() == "cli") { + echo "DB initialization failed: DB_VERSION is not set" . PHP_EOL; + } else { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__), + 'msg' => 'Database not initialized: DB_VERSION is not set.' + ); + } + return false; + } $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -174,34 +187,6 @@ function init_db_schema() ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), - "alias" => array( - "cols" => array( - "id" => "INT NOT NULL AUTO_INCREMENT", - "address" => "VARCHAR(255) NOT NULL", - "goto" => "TEXT NOT NULL", - "domain" => "VARCHAR(255) NOT NULL", - "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", - "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", - "private_comment" => "TEXT", - "public_comment" => "TEXT", - "sogo_visible" => "TINYINT(1) NOT NULL DEFAULT '1'", - "internal" => "TINYINT(1) NOT NULL DEFAULT '0'", - "sender_allowed" => "TINYINT(1) NOT NULL DEFAULT '1'", - "active" => "TINYINT(1) NOT NULL DEFAULT '1'" - ), - "keys" => array( - "primary" => array( - "" => array("id") - ), - "unique" => array( - "address" => array("address") - ), - "key" => array( - "domain" => array("domain") - ) - ), - "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" - ), "api" => array( "cols" => array( "id" => "INT NOT NULL AUTO_INCREMENT", @@ -226,20 +211,6 @@ function init_db_schema() ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), - "sender_acl" => array( - "cols" => array( - "id" => "INT NOT NULL AUTO_INCREMENT", - "logged_in_as" => "VARCHAR(255) NOT NULL", - "send_as" => "VARCHAR(255) NOT NULL", - "external" => "TINYINT(1) NOT NULL DEFAULT '0'" - ), - "keys" => array( - "primary" => array( - "" => array("id") - ) - ), - "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" - ), "templates" => array( "cols" => array( "id" => "INT NOT NULL AUTO_INCREMENT", @@ -418,6 +389,360 @@ function init_db_schema() ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), + "mailbox_group" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "domain" => "VARCHAR(255) NOT NULL", + "name" => "VARCHAR(100) NOT NULL", + "description" => "VARCHAR(255) NOT NULL DEFAULT ''", + // distribution address, maintained as alias row with group_id + "address" => "VARCHAR(255) NULL DEFAULT NULL", + // members may send as the address + "sender_allowed" => "TINYINT(1) NOT NULL DEFAULT '0'", + "sogo_visible" => "TINYINT(1) NOT NULL DEFAULT '0'", + "internal" => "TINYINT(1) NOT NULL DEFAULT '0'", + "template_id" => "INT NULL DEFAULT NULL", + // highest priority wins when a mailbox is in several groups with a template + "priority" => "INT NOT NULL DEFAULT '0'", + // IdP group id: set = memberships are synced from the IdP + "idp_ref" => "VARCHAR(255) NULL DEFAULT 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") + ), + "unique" => array( + "domain_name" => array("domain", "name"), + "address" => array("address"), + "idp_ref" => array("idp_ref") + ), + "fkey" => array( + "fk_mailbox_group_domain" => array( + "col" => "domain", + "ref" => "domain.domain", + "delete" => "CASCADE", + "update" => "NO ACTION" + ), + "fk_mailbox_group_template" => array( + "col" => "template_id", + "ref" => "templates.id", + "delete" => "SET NULL", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "mailbox_group_member" => array( + "cols" => array( + "group_id" => "INT NOT NULL", + "username" => "VARCHAR(255) NOT NULL", + "source" => "ENUM('manual', 'idp') NOT NULL DEFAULT 'manual'", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)" + ), + "keys" => array( + "primary" => array( + "" => array("group_id", "username") + ), + "key" => array( + "username" => array("username") + ), + "fkey" => array( + "fk_mailbox_group_member_group" => array( + "col" => "group_id", + "ref" => "mailbox_group.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ), + "fk_mailbox_group_member_mailbox" => array( + "col" => "username", + "ref" => "mailbox.username", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "mailbox_group_send_as" => array( + "cols" => array( + "group_id" => "INT NOT NULL", + // address or @domain the members may send as + "address" => "VARCHAR(255) NOT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("group_id", "address") + ), + "key" => array( + "address" => array("address") + ), + "fkey" => array( + "fk_mailbox_group_send_as_group" => array( + "col" => "group_id", + "ref" => "mailbox_group.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "mailbox_group_imap_grant" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "group_id" => "INT NOT NULL", + // mailbox whose folders the members may access + "owner" => "VARCHAR(255) NOT NULL", + "rights" => "ENUM('read', 'write', 'full') NOT NULL DEFAULT 'read'", + // 1 = the group-grants cron subscribes the members to the folders (once per member) + "subscribe" => "TINYINT(1) NOT NULL DEFAULT '0'", + // removing = the ACL is deleted in Dovecot, then the row + "state" => "ENUM('pending', 'applied', 'failed', 'removing') NOT NULL DEFAULT 'pending'", + "last_error" => "TEXT", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "group_owner" => array("group_id", "owner") + ), + "key" => array( + "owner" => array("owner") + ), + "fkey" => array( + "fk_mailbox_group_imap_grant_group" => array( + "col" => "group_id", + "ref" => "mailbox_group.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ), + "fk_mailbox_group_imap_grant_owner" => array( + "col" => "owner", + "ref" => "mailbox.username", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "mailbox_group_sogo_grant" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "group_id" => "INT NOT NULL", + "owner" => "VARCHAR(255) NOT NULL", + "folder_type" => "ENUM('Calendar', 'Contacts') NOT NULL DEFAULT 'Calendar'", + "folder" => "VARCHAR(255) NOT NULL DEFAULT 'personal'", + "access" => "ENUM('read', 'write') NOT NULL DEFAULT 'read'", + // 1 = the group-grants cron subscribes the members to the folder (once per member) + "subscribe" => "TINYINT(1) NOT NULL DEFAULT '0'", + // pending = the owner has not opened SOGo yet, the folder does not exist + "state" => "ENUM('pending', 'applied', 'failed') NOT NULL DEFAULT 'pending'", + "last_error" => "TEXT", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "group_folder" => array("group_id", "owner", "folder_type", "folder") + ), + "key" => array( + "owner" => array("owner") + ), + "fkey" => array( + "fk_mailbox_group_sogo_grant_group" => array( + "col" => "group_id", + "ref" => "mailbox_group.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ), + "fk_mailbox_group_sogo_grant_owner" => array( + "col" => "owner", + "ref" => "mailbox.username", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + // sogo_acl rows written for a grant, so only those are ever removed again. No FK: rows of grants + // removed by a cascade stay until their sogo_acl rows are deleted (GroupRepository::cleanupSogoAcl()) + "mailbox_group_sogo_acl" => array( + "cols" => array( + "acl_id" => "INT NOT NULL", + "grant_id" => "INT NOT NULL", + "c_uid" => "VARCHAR(255) NOT NULL", + "c_role" => "VARCHAR(80) NOT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("acl_id") + ), + "key" => array( + "grant_id" => array("grant_id") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + // IMAP and SOGo subscriptions made by the group-grants cron. No FK: rows of grants removed by a cascade stay + // until the members are unsubscribed again + "mailbox_group_subscription" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "kind" => "ENUM('imap', 'sogo') NOT NULL", + // mailbox_group_imap_grant.id or mailbox_group_sogo_grant.id + "grant_id" => "INT NOT NULL", + "username" => "VARCHAR(255) NOT NULL", + "owner" => "VARCHAR(255) NOT NULL", + // SOGo: Calendar/ or Contacts/, IMAP: empty (all folders of the owner) + "folder" => "VARCHAR(255) NOT NULL DEFAULT ''", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "grant_username" => array("kind", "grant_id", "username") + ), + "key" => array( + "username" => array("username") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "identity_link" => array( + "cols" => array( + "username" => "VARCHAR(255) NOT NULL", + "provider" => "VARCHAR(20) NOT NULL", + // Keycloak user id (= OIDC sub), LDAP objectGUID/entryUUID/... or dn:, OIDC sub; NULL = not linked yet + "external_id" => "VARCHAR(255) NULL DEFAULT NULL", + // 1 = the sync set the mailbox to login disabled (active = 2) + "idp_disabled" => "TINYINT(1) NOT NULL DEFAULT '0'", + // first complete sync run that did not list the user + "missing_since" => "DATETIME NULL DEFAULT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP" + ), + "keys" => array( + "primary" => array( + "" => array("username") + ), + "unique" => array( + "provider_external_id" => array("provider", "external_id") + ), + "fkey" => array( + "fk_identity_link_mailbox" => array( + "col" => "username", + "ref" => "mailbox.username", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "identity_sync_run" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "provider" => "VARCHAR(20) NOT NULL DEFAULT ''", + // run = scheduled or requested, preview = dry run, force = without the deactivation limit + "mode" => "ENUM('run', 'preview', 'force') NOT NULL DEFAULT 'run'", + "status" => "ENUM('pending', 'running', 'ok', 'blocked', 'error') NOT NULL DEFAULT 'pending'", + "requested_by" => "VARCHAR(255) NOT NULL DEFAULT ''", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "started" => "DATETIME NULL DEFAULT NULL", + "finished" => "DATETIME NULL DEFAULT NULL", + "summary" => "MEDIUMTEXT" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "key" => array( + "status" => array("status") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + // alias and sender_acl come after mailbox_group (foreign keys) + "alias" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "address" => "VARCHAR(255) NOT NULL", + "goto" => "TEXT NOT NULL", + "domain" => "VARCHAR(255) NOT NULL", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", + "private_comment" => "TEXT", + "public_comment" => "TEXT", + "sogo_visible" => "TINYINT(1) NOT NULL DEFAULT '1'", + "internal" => "TINYINT(1) NOT NULL DEFAULT '0'", + "sender_allowed" => "TINYINT(1) NOT NULL DEFAULT '1'", + "active" => "TINYINT(1) NOT NULL DEFAULT '1'", + // set = distribution address of a mailbox group, read-only in the alias UI + "group_id" => "INT NULL DEFAULT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "address" => array("address"), + "group_id" => array("group_id") + ), + "key" => array( + "domain" => array("domain") + ), + "fkey" => array( + "fk_alias_mailbox_group" => array( + "col" => "group_id", + "ref" => "mailbox_group.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + "sender_acl" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "logged_in_as" => "VARCHAR(255) NOT NULL", + "send_as" => "VARCHAR(255) NOT NULL", + "external" => "TINYINT(1) NOT NULL DEFAULT '0'", + // set = granted through a mailbox group, rebuilt by the group module + "group_id" => "INT NULL DEFAULT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "key" => array( + "group_id" => array("group_id") + ), + "fkey" => array( + "fk_sender_acl_mailbox_group" => array( + "col" => "group_id", + "ref" => "mailbox_group.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), "sieve_filters" => array( "cols" => array( "id" => "INT NOT NULL AUTO_INCREMENT", @@ -727,7 +1052,8 @@ function init_db_schema() "mailbox_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'", "domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'", "domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'", - "alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'" + "alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'", + "mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'" ), "keys" => array( "primary" => array( @@ -1807,6 +2133,18 @@ function init_db_schema() // fill quarantine.qhash $pdo->query("UPDATE `quarantine` SET `qhash` = SHA2(CONCAT(`id`, `qid`), 256) WHERE ISNULL(`qhash`)"); + // Group rows that lost their group_id (init_db of an older version ran) belong to their group again, so the + // group can update its alias and revokes send-as rights of former members. An active group without rows of its + // own for a grant only exists after such a downgrade; manual rows next to group rows stay manual. + $pdo->query("UPDATE `alias` a JOIN `mailbox_group` g ON g.`address` = a.`address` SET a.`group_id` = g.`id` WHERE a.`group_id` IS NULL"); + $pdo->query("UPDATE `sender_acl` s + JOIN `mailbox_group_member` m ON m.`username` = s.`logged_in_as` + JOIN `mailbox_group` g ON g.`id` = m.`group_id` AND g.`active` = 1 + JOIN `mailbox_group_send_as` ga ON ga.`group_id` = m.`group_id` AND ga.`address` = s.`send_as` + SET s.`group_id` = m.`group_id` + WHERE s.`group_id` IS NULL AND s.`external` = 0 + AND NOT EXISTS (SELECT 1 FROM (SELECT `group_id` FROM `sender_acl` WHERE `group_id` IS NOT NULL) own WHERE own.`group_id` = m.`group_id`)"); + } catch (PDOException $e) { if (php_sapi_name() == "cli") { echo "DB initialization failed: " . print_r($e, true) . PHP_EOL; diff --git a/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php b/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php index 73a518db3..d7b82a91d 100644 --- a/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php +++ b/data/web/inc/lib/mailcow/ApiKey/Service/ApiKeyService.php @@ -30,9 +30,7 @@ class ApiKeyService /** @throws AccessDenied unless an admin (login or read-write API key) */ public function requireAdmin(): void { - if (!$this->context->isAdmin() || $this->context->isReadOnlyApi()) { - throw new AccessDenied(); - } + $this->context->requireAdmin(); } /** @return ApiKey[] all keys, service keys first; [] for other callers */ @@ -146,7 +144,7 @@ class ApiKeyService private function canRead(): bool { - return $this->context->isAdmin() && !$this->context->isReadOnlyApi(); + return $this->context->hasAdminRights() && !$this->context->isReadOnlyApi(); } /** @throws AccessDenied when missing, UserError api_key_service_locked for service keys */ diff --git a/data/web/inc/lib/mailcow/Auth/AuthModule.php b/data/web/inc/lib/mailcow/Auth/AuthModule.php new file mode 100644 index 000000000..751ad0fe5 --- /dev/null +++ b/data/web/inc/lib/mailcow/Auth/AuthModule.php @@ -0,0 +1,36 @@ +settings, $identity->links, MailboxGroupModule::create($pdo, $context)->templates)); + } + + /** Logins run before a session exists, so the default context is the system */ + public static function fromGlobals(?Context $context = null): self + { + $settings = $GLOBALS['iam_settings'] ?? null; + return self::create($GLOBALS['pdo'], $context ?? Context::system(), is_array($settings) ? $settings : identity_provider('get')); + } +} diff --git a/data/web/inc/lib/mailcow/Auth/Service/IdpLoginService.php b/data/web/inc/lib/mailcow/Auth/Service/IdpLoginService.php new file mode 100644 index 000000000..bc9833398 --- /dev/null +++ b/data/web/inc/lib/mailcow/Auth/Service/IdpLoginService.php @@ -0,0 +1,70 @@ +settings->mappedTemplate((array)$values); + } + + /** Template for the login of an existing mailbox: its effective group template, else the mapped one */ + public function templateFor(string $username, ?string $mapped): ?string + { + try { + return $this->templates->templateForLogin($username, $mapped); + } catch (Throwable $e) { + error_log('mailcow: group template lookup failed for ' . $username . ': ' . $e->getMessage()); + return $mapped; + } + } + + /** The IdP user may open the mailbox, an unlinked mailbox gets linked */ + public function verifyLink(string $provider, string $username, string $externalId): bool + { + try { + return $this->links->verify($provider, $username, $externalId); + } catch (Throwable $e) { + error_log('mailcow: IdP link check failed for ' . $username . ': ' . $e->getMessage()); + return false; + } + } + + /** Before a mailbox is created on login: the IdP user must not own another mailbox */ + public function mayCreate(string $provider, string $username, string $externalId): bool + { + try { + return $this->links->mayCreate($provider, $username, $externalId); + } catch (Throwable $e) { + error_log('mailcow: IdP link check failed for ' . $username . ': ' . $e->getMessage()); + return false; + } + } + + /** Links a mailbox created on login */ + public function storeLink(string $provider, string $username, string $externalId): void + { + try { + $this->links->link($provider, $username, $externalId); + } catch (Throwable $e) { + error_log('mailcow: IdP link could not be stored for ' . $username . ': ' . $e->getMessage()); + } + } +} diff --git a/data/web/inc/lib/mailcow/Core/ApiResponse.php b/data/web/inc/lib/mailcow/Core/ApiResponse.php index 81af09e7f..3caf069d7 100644 --- a/data/web/inc/lib/mailcow/Core/ApiResponse.php +++ b/data/web/inc/lib/mailcow/Core/ApiResponse.php @@ -38,6 +38,12 @@ class ApiResponse return $this->danger($error->msg(), $log); } + /** Something went partly wrong, but the call itself succeeded */ + public function warning(string|array $msg, array $log): void + { + $this->add('warning', $msg, $log); + } + /** Messages collected since the last flush() */ public function messages(): array { diff --git a/data/web/inc/lib/mailcow/Core/Context.php b/data/web/inc/lib/mailcow/Core/Context.php index 86f69e113..e3336af0b 100644 --- a/data/web/inc/lib/mailcow/Core/Context.php +++ b/data/web/inc/lib/mailcow/Core/Context.php @@ -70,6 +70,24 @@ class Context return $this->apiAccess === 'ro'; } + /** An admin, or a legacy call that may ignore ownership (access_all_exception) */ + public function hasAdminRights(): bool + { + return $this->isAdmin() || $this->accessAll; + } + + /** + * Admin rights; a write also needs more than a read-only API key. + * + * @throws AccessDenied + */ + public function requireAdmin(bool $write = true): void + { + if (!$this->hasAdminRights() || ($write && $this->isReadOnlyApi())) { + throw new AccessDenied(); + } + } + public function hasAcl(string $name): bool { return isset($this->acl[$name]) && $this->acl[$name] == '1'; diff --git a/data/web/inc/lib/mailcow/Dovecot/DovecotModule.php b/data/web/inc/lib/mailcow/Dovecot/DovecotModule.php new file mode 100644 index 000000000..ae516d909 --- /dev/null +++ b/data/web/inc/lib/mailcow/Dovecot/DovecotModule.php @@ -0,0 +1,22 @@ +, group=) on every folder of the owner; no rights + * remove it. + * + * @return ?string null on success, else the error of dockerapi + */ + public function setAclAll(string $owner, string $identifier, array $rights): ?string + { + return self::error($this->exec(['cmd' => 'doveadm', 'task' => 'set_acl_all', 'user' => $owner, 'id' => $identifier, + 'rights' => array_values($rights)])); + } + + /** + * Subscribes the users to every folder of the owner they see under Shared//, or unsubscribes them. + * + * @return ?string null on success, else the error of dockerapi + */ + public function subscribeShared(string $owner, array $usernames, bool $unsubscribe = false): ?string + { + return self::error($this->exec(['cmd' => 'doveadm', 'task' => 'subscribe_shared', 'owner' => $owner, + 'users' => array_values($usernames), 'unsubscribe' => $unsubscribe])); + } + + /** Drops the cached userdb results, so changed userdb fields apply at the next login instead of after auth_cache_ttl */ + public function flushAuthCache(array $usernames): void + { + if (!empty($usernames)) { + $this->exec(['cmd' => 'doveadm', 'task' => 'auth_cache_flush', 'users' => array_values($usernames)]); + } + } + + protected function exec(array $fields): mixed + { + return docker('post', 'dovecot-mailcow', 'exec', $fields); + } + + private static function error(mixed $response): ?string + { + $result = json_decode((string)$response, true); + return (($result['type'] ?? '') === 'success') ? null : (string)($result['msg'] ?? 'dockerapi did not answer'); + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Controller/IdentityApiController.php b/data/web/inc/lib/mailcow/Identity/Controller/IdentityApiController.php new file mode 100644 index 000000000..f69051dbd --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Controller/IdentityApiController.php @@ -0,0 +1,72 @@ +status->status(); + } catch (UserError) { + return []; + } + } + + /** POST edit/identity-provider-sync: attr mode = preview|run|force */ + public function requestRun(array $attr): bool + { + return $this->handle('edit', 'sync', $attr, function (array $log) use ($attr): bool { + $this->status->requestRun($attr['mode'] ?? 'run'); + return $this->response->success('iam_sync_requested', $log); + }); + } + + /** GET identity-link/ */ + public function getLink(string $username): array + { + return $this->status->link($username) ?? []; + } + + /** POST delete/identity-link: usernames */ + public function unlink(array $items): bool + { + return $this->handle('delete', 'link', $items, function (array $log) use ($items): bool { + foreach ($items as $username) { + $this->status->unlink((string)$username); + $this->response->success(['iam_link_removed', htmlspecialchars((string)$username)], $log); + } + return true; + }); + } + + private function handle(string $action, string $object, mixed $data, callable $work): bool + { + $log = ['identity_provider', $action, $object, $data]; + try { + $result = $work($log); + } catch (UserError $e) { + $result = $this->response->error($e, $log); + } catch (PDOException $e) { + $result = $this->response->danger(['mysql_error', $e->getMessage()], $log); + } + $this->response->flush(); + return $result; + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Directory/Directory.php b/data/web/inc/lib/mailcow/Identity/Directory/Directory.php new file mode 100644 index 000000000..0951045b1 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Directory/Directory.php @@ -0,0 +1,21 @@ +paged('/users', ['briefRepresentation' => 'false']) as $row) { + if (isset($row['id'])) { + $user = self::user($row); + $users[$user->id] = $user; + } + } + return new Listing($users, $withGroups ? $this->groups($users) : null); + } + + /** A user of the admin REST API */ + public static function user(array $row): DirectoryUser + { + $email = (isset($row['email']) && $row['email'] !== '') ? strtolower((string)$row['email']) : null; + return new DirectoryUser((string)$row['id'], $email, trim(($row['firstName'] ?? '') . ' ' . ($row['lastName'] ?? '')), + !empty($row['enabled']), array_map('strval', (array)($row['attributes']['mailcow_template'] ?? []))); + } + + /** + * @param array $users + * @return array selected groups, members limited to listed users + */ + private function groups(array $users): array + { + $groups = []; + foreach ($this->paged('/groups', ['briefRepresentation' => 'false']) as $row) { + $this->collect($row, $groups); + } + foreach ($groups as $id => $group) { + foreach ($this->paged('/groups/' . rawurlencode((string)$id) . '/members', ['briefRepresentation' => 'true']) as $member) { + if (isset($member['id'], $users[$member['id']])) { + $group->memberIds[] = (string)$member['id']; + } + } + } + return $groups; + } + + /** Adds the group and its subgroups that match the configured paths (Keycloak >= 23 lists children separately) */ + private function collect(array $row, array &$groups): void + { + $path = (string)($row['path'] ?? '/' . ($row['name'] ?? '')); + if ($this->selected($path)) { + $mail = $row['attributes']['mail'][0] ?? null; + $groups[(string)$row['id']] = new DirectoryGroup((string)$row['id'], (string)($row['name'] ?? $path), + is_string($mail) && $mail !== '' ? strtolower($mail) : null); + } + $children = $row['subGroups'] ?? []; + if (empty($children) && (int)($row['subGroupCount'] ?? 0) > 0) { + $children = $this->paged('/groups/' . rawurlencode((string)$row['id']) . '/children', ['briefRepresentation' => 'false']); + } + foreach ($children as $child) { + $this->collect($child, $groups); + } + } + + /** '/a' exactly, '/a/*' every group below /a, '/*' every group */ + private function selected(string $path): bool + { + foreach ($this->settings->groupPaths as $pattern) { + if ($pattern === $path || (str_ends_with($pattern, '/*') && str_starts_with($path, substr($pattern, 0, -1)))) { + return true; + } + } + return false; + } + + /** @return array[] all rows of a paged list endpoint */ + private function paged(string $path, array $query): array + { + $rows = []; + for ($first = 0; ; $first += self::PAGE) { + $page = array_values($this->client->get($path, $query + ['first' => $first, 'max' => self::PAGE])); + $rows = array_merge($rows, $page); + if (count($page) < self::PAGE) { + return $rows; + } + } + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Directory/LdapDirectory.php b/data/web/inc/lib/mailcow/Identity/Directory/LdapDirectory.php new file mode 100644 index 000000000..5ebab7eb8 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Directory/LdapDirectory.php @@ -0,0 +1,240 @@ +settings; + $userFilter = $this->userFilter(); + $disabled = []; + if ($s->disabledFilter !== '') { + foreach ($this->search($s->basedn, '(&' . $userFilter . self::wrap($s->disabledFilter) . ')', ['dn']) as $entry) { + $disabled[strtolower((string)$entry['dn'])] = true; + } + } + $attributes = array_merge([$s->usernameField, 'displayname', 'cn', 'useraccountcontrol'], + ($s->attributeField !== '') ? [$s->attributeField] : [], + self::idAttributes($s->idAttribute), + ($withGroups && $s->groupMemberMode === 'memberof') ? ['memberof'] : []); + $users = []; + $memberOf = []; + foreach ($this->search($s->basedn, $userFilter, array_values(array_unique($attributes))) as $entry) { + $dn = strtolower((string)$entry['dn']); + $email = self::first($entry, $s->usernameField); + $uac = self::first($entry, 'useraccountcontrol'); + $user = new DirectoryUser(self::entryId($entry, $s->idAttribute), + ($email === null) ? null : strtolower($email), + self::first($entry, 'displayname') ?? self::first($entry, 'cn') ?? '', + !isset($disabled[$dn]) && !($uac !== null && ((int)$uac & self::UAC_DISABLED)), + ($s->attributeField !== '') ? self::values($entry, $s->attributeField) : [], + $dn); + $users[$user->id] = $user; + $memberOf[$user->id] = array_map('strtolower', self::values($entry, 'memberof')); + } + return new Listing($users, $withGroups ? $this->groups($users, $memberOf, $userFilter) : null); + } + + /** Attributes entryId() reads */ + public static function idAttributes(string $configured): array + { + return ($configured !== '') ? [$configured] : self::ID_ATTRIBUTES; + } + + /** + * Stable id of an entry: the configured attribute, else objectGUID (as GUID string), entryUUID, nsUniqueId, + * ipaUniqueID, else the DN. Also used at login, with an entry of an LdapRecord raw query. + */ + public static function entryId(array $entry, string $configured = ''): string + { + foreach (self::idAttributes($configured) as $attribute) { + $value = self::first($entry, $attribute); + if ($value !== null && $value !== '') { + return ($attribute === 'objectguid' && strlen($value) === 16) ? self::guid($value) : strtolower($value); + } + } + return 'dn:' . strtolower((string)(self::first($entry, 'dn') ?? '')); + } + + /** + * @param array $users + * @param array $memberOf user id => lowercase group DNs + * @return array + */ + private function groups(array $users, array $memberOf, string $userFilter): array + { + $s = $this->settings; + if ($s->groupFilter === '') { + throw new DirectoryException('The LDAP group filter is empty'); + } + $userIds = []; + foreach ($users as $user) { + $userIds[$user->dn] = $user->id; + } + $attributes = array_merge([$s->groupNameAttribute, 'mail'], self::ID_ATTRIBUTES, + ($s->groupMemberMode === 'member') ? [$s->groupMemberAttribute] : []); + $groups = []; + $groupIds = []; + foreach ($this->search(($s->groupBasedn !== '') ? $s->groupBasedn : $s->basedn, self::wrap($s->groupFilter), + array_values(array_unique($attributes))) as $entry) { + $dn = strtolower((string)$entry['dn']); + $mail = self::first($entry, 'mail'); + $group = new DirectoryGroup(self::entryId($entry), self::first($entry, $s->groupNameAttribute) ?? $dn, + ($mail === null) ? null : strtolower($mail)); + if ($s->groupMemberMode === 'member') { + foreach (array_keys($entry) as $key) { + // AD returns large member lists in ranges; a partial list would remove members + if (is_string($key) && str_starts_with($key, $s->groupMemberAttribute . ';range=')) { + throw new DirectoryException('LDAP returned a ranged member list for ' . $dn . ', use the memberOf or AD mode'); + } + } + foreach (self::values($entry, $s->groupMemberAttribute) as $memberDn) { + if (isset($userIds[strtolower($memberDn)])) { + $group->memberIds[] = $userIds[strtolower($memberDn)]; + } + } + } + $groups[$group->id] = $group; + $groupIds[$dn] = $group->id; + } + if ($s->groupMemberMode === 'memberof') { + foreach ($memberOf as $userId => $dns) { + foreach ($dns as $dn) { + if (isset($groupIds[$dn])) { + $groups[$groupIds[$dn]]->memberIds[] = (string)$userId; + } + } + } + } elseif ($s->groupMemberMode === 'ad_chain') { + foreach ($groupIds as $dn => $groupId) { + $filter = '(&' . $userFilter . '(memberOf:' . self::AD_IN_CHAIN . ':=' . self::escape($dn) . '))'; + foreach ($this->search($s->basedn, $filter, ['dn']) as $entry) { + $userDn = strtolower((string)$entry['dn']); + if (isset($userIds[$userDn])) { + $groups[$groupId]->memberIds[] = $userIds[$userDn]; + } + } + } + } + foreach ($groups as $group) { + $group->memberIds = array_values(array_unique($group->memberIds)); + } + return $groups; + } + + /** Users of the configured filter that have the username attribute */ + private function userFilter(): string + { + $filter = ($this->settings->filter !== '') ? self::wrap($this->settings->filter) : '(objectClass=*)'; + return '(&' . $filter . '(' . $this->settings->usernameField . '=*))'; + } + + /** + * Paged search that fails instead of returning a partial result. + * + * @return array[] entries as returned by ldap_get_entries() + * @throws DirectoryException + */ + protected function search(string $base, string $filter, array $attributes): array + { + if ($this->connection === null) { + throw new DirectoryException('No connection to the LDAP server'); + } + $link = $this->connection->getLdapConnection()->getConnection(); + $entries = []; + $cookie = ''; + $paged = true; + while (true) { + $controls = $paged ? [['oid' => LDAP_CONTROL_PAGEDRESULTS, 'iscritical' => true, + 'value' => ['size' => self::PAGE, 'cookie' => $cookie]]] : []; + $result = @ldap_search($link, $base, $filter, $attributes, 0, 0, 0, LDAP_DEREF_NEVER, $controls); + if ($result === false) { + throw new DirectoryException('LDAP search failed: ' . ldap_error($link)); + } + $responseControls = []; + if (!ldap_parse_result($link, $result, $code, $matched, $message, $referrals, $responseControls)) { + throw new DirectoryException('LDAP result could not be read: ' . ldap_error($link)); + } + // 12 = unavailableCriticalExtension: no paging on this server, search once without it + if ($code === 12 && $paged && $cookie === '') { + $paged = false; + continue; + } + if ($code !== 0) { + throw new DirectoryException('LDAP search incomplete (code ' . $code . '): ' . (($message !== '') ? $message : ldap_err2str($code))); + } + $page = ldap_get_entries($link, $result); + for ($i = 0; $i < (int)($page['count'] ?? 0); $i++) { + $entries[] = $page[$i]; + } + $cookie = $paged ? (string)($responseControls[LDAP_CONTROL_PAGEDRESULTS]['value']['cookie'] ?? '') : ''; + if ($cookie === '') { + return $entries; + } + } + } + + /** First value of an attribute: ldap_get_entries() format or LdapRecord attributes */ + private static function first(array $entry, string $attribute): ?string + { + $values = self::values($entry, $attribute); + return $values[0] ?? null; + } + + /** @return string[] */ + private static function values(array $entry, string $attribute): array + { + $raw = $entry[$attribute] ?? $entry[strtolower($attribute)] ?? null; + if (!is_array($raw)) { + return is_scalar($raw) ? [(string)$raw] : []; + } + unset($raw['count']); + return array_values(array_map('strval', array_filter($raw, 'is_scalar'))); + } + + private static function wrap(string $filter): string + { + return str_starts_with($filter, '(') ? $filter : '(' . $filter . ')'; + } + + /** RFC 4515 value escaping */ + private static function escape(string $value): string + { + return str_replace(['\\', '*', '(', ')', "\0"], ['\\5c', '\\2a', '\\28', '\\29', '\\00'], $value); + } + + /** Binary objectGUID as the usual GUID string */ + private static function guid(string $binary): string + { + $hex = bin2hex($binary); + return substr($hex, 6, 2) . substr($hex, 4, 2) . substr($hex, 2, 2) . substr($hex, 0, 2) . '-' + . substr($hex, 10, 2) . substr($hex, 8, 2) . '-' . substr($hex, 14, 2) . substr($hex, 12, 2) . '-' + . substr($hex, 16, 4) . '-' . substr($hex, 20, 12); + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Http/KeycloakClient.php b/data/web/inc/lib/mailcow/Identity/Http/KeycloakClient.php new file mode 100644 index 000000000..c60b2fb02 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Http/KeycloakClient.php @@ -0,0 +1,92 @@ +, decoded JSON. A rejected token is renewed once. + * + * @throws DirectoryException + */ + public function get(string $path, array $query = []): array + { + $url = $this->serverUrl . '/admin/realms/' . rawurlencode($this->realm) . $path . (empty($query) ? '' : '?' . http_build_query($query)); + for ($attempt = 0; $attempt < 2; $attempt++) { + $this->token ??= $this->fetchToken(); + [$code, $body] = $this->send('GET', $url, ['Authorization: Bearer ' . $this->token, 'Accept: application/json'], null); + if ($code === 401 && $attempt === 0) { + $this->token = null; + continue; + } + if ($code !== 200) { + throw new DirectoryException('Keycloak returned HTTP ' . $code . ' for ' . $path); + } + $data = json_decode($body, true); + if (!is_array($data)) { + throw new DirectoryException('Keycloak returned invalid JSON for ' . $path); + } + return $data; + } + throw new DirectoryException('Keycloak rejected the access token of the mailcow client'); + } + + /** @throws DirectoryException */ + private function fetchToken(): string + { + [$code, $body] = $this->send('POST', $this->serverUrl . '/realms/' . rawurlencode($this->realm) . '/protocol/openid-connect/token', + ['Content-Type: application/x-www-form-urlencoded'], + http_build_query(['grant_type' => 'client_credentials', 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret])); + $data = json_decode($body, true); + if ($code !== 200 || !is_array($data) || empty($data['access_token'])) { + throw new DirectoryException('Keycloak token request failed (HTTP ' . $code . ')'); + } + return (string)$data['access_token']; + } + + /** + * @return array{0: int, 1: string} HTTP status and body + * @throws DirectoryException on transport errors + */ + protected function send(string $method, string $url, array $headers, ?string $body): array + { + $curl = curl_init($url); + curl_setopt_array($curl, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => $headers, + ]); + if ($method === 'POST') { + curl_setopt($curl, CURLOPT_POST, true); + curl_setopt($curl, CURLOPT_POSTFIELDS, (string)$body); + } + if ($this->ignoreSslError) { + curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0); + } + $response = curl_exec($curl); + $code = (int)curl_getinfo($curl, CURLINFO_HTTP_CODE); + $error = curl_error($curl); + curl_close($curl); + if ($response === false) { + throw new DirectoryException('Keycloak request failed: ' . $error); + } + return [$code, (string)$response]; + } +} diff --git a/data/web/inc/lib/mailcow/Identity/IdentityModule.php b/data/web/inc/lib/mailcow/Identity/IdentityModule.php new file mode 100644 index 000000000..04effbeb5 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/IdentityModule.php @@ -0,0 +1,85 @@ + identity_provider('init'))->sync->runDue(); // cron + * IdentityModule::fromGlobals()->api->getLink($username); // edit page + * + * Logins use the links through Mailcow\Auth (AuthModule::fromGlobals()->idp). + */ +class IdentityModule +{ + public function __construct( + public readonly Settings $settings, + public readonly SyncService $sync, + public readonly LinkService $links, + public readonly StatusService $status, + public readonly IdentityApiController $api + ) { + } + + /** + * @param array $settings identity_provider('get') + * @param ?Closure $ldap returns the bound LDAP connection (identity_provider('init')), called when a run starts + */ + public static function create(PDO $pdo, Context $context, array $settings, ?Closure $ldap = null): self + { + $config = Settings::fromArray($settings); + $repo = new IdentityRepository($pdo); + $runs = new RunRepository($pdo); + $groups = MailboxGroupModule::create($pdo, Context::system()); + $status = new StatusService($config, $repo, $runs, $context); + $directory = fn(): ?Directory => self::directory($config, $ldap); + return new self($config, + new SyncService($config, $directory, $repo, $runs, new SyncPlanner(), $groups->idp, $groups->templates, new LegacyAccounts(), + SogoModule::create()->sogo), + new LinkService($repo), $status, new IdentityApiController($status, new ApiResponse())); + } + + /** Legacy entry point for procedural callers: reads $pdo, $iam_settings and $_SESSION; $context overrides the session caller */ + public static function fromGlobals(?Context $context = null): self + { + $settings = $GLOBALS['iam_settings'] ?? null; + return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), + is_array($settings) ? $settings : identity_provider('get'), fn(): mixed => $GLOBALS['iam_provider'] ?? null); + } + + private static function directory(Settings $settings, ?Closure $ldap): ?Directory + { + if ($settings->provider === Settings::KEYCLOAK) { + return new KeycloakDirectory(new KeycloakClient($settings->serverUrl, $settings->realm, $settings->clientId, + $settings->clientSecret, $settings->ignoreSslError), $settings); + } + if ($settings->provider === Settings::LDAP) { + $connection = ($ldap === null) ? null : $ldap(); + return new LdapDirectory(($connection instanceof Connection) ? $connection : null, $settings); + } + return null; + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Legacy/LegacyAccounts.php b/data/web/inc/lib/mailcow/Identity/Legacy/LegacyAccounts.php new file mode 100644 index 000000000..4b79ad9da --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Legacy/LegacyAccounts.php @@ -0,0 +1,30 @@ +asSystem(fn(): mixed => $this->callCreate($email, $name, $authsource, $template)); + } + + protected function callCreate(string $email, string $name, string $authsource, string $template): mixed + { + [$local, $domain] = explode('@', $email, 2); + return mailbox('add', 'mailbox_from_template', ['domain' => $domain, 'local_part' => $local, 'name' => $name, + 'authsource' => $authsource, 'template' => $template]); + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Model/Account.php b/data/web/inc/lib/mailcow/Identity/Model/Account.php new file mode 100644 index 000000000..ce9e3231d --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Model/Account.php @@ -0,0 +1,27 @@ + $users by id + * @param ?array $groups by id, null when groups were not listed + */ + public function __construct(public readonly array $users, public readonly ?array $groups = null) + { + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Model/Settings.php b/data/web/inc/lib/mailcow/Identity/Model/Settings.php new file mode 100644 index 000000000..29ca20403 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Model/Settings.php @@ -0,0 +1,118 @@ +provider = (string)($s['authsource'] ?? ''); + $settings->periodicSync = self::flag($s['periodic_sync'] ?? 0); + $settings->importUsers = self::flag($s['import_users'] ?? 0); + $settings->syncInterval = max(1, (int)($s['sync_interval'] ?? 15)); + $settings->offboarding = self::flag($s['offboarding'] ?? 0); + $settings->offboardingLimit = min(100, max(0, (int)($s['offboarding_limit'] ?? 10))); + $settings->groupSync = self::flag($s['group_sync'] ?? 0); + $settings->groupAutocreate = self::flag($s['group_autocreate'] ?? 1); + $settings->groupDefaultDomain = strtolower(trim((string)($s['group_default_domain'] ?? ''))); + $settings->defaultTemplate = (string)($s['default_template'] ?? ''); + $settings->mappers = array_map('strval', (array)($s['mappers'] ?? [])); + $settings->templates = array_map('strval', (array)($s['templates'] ?? [])); + $settings->serverUrl = rtrim((string)($s['server_url'] ?? ''), '/'); + $settings->realm = (string)($s['realm'] ?? ''); + $settings->clientId = (string)($s['client_id'] ?? ''); + $settings->clientSecret = (string)($s['client_secret'] ?? ''); + $settings->ignoreSslError = self::flag($s['ignore_ssl_error'] ?? 0); + $settings->groupPaths = array_values(array_filter(array_map('trim', explode(',', (string)($s['group_paths'] ?? ''))), 'strlen')); + $settings->basedn = (string)($s['basedn'] ?? ''); + $settings->filter = trim((string)($s['filter'] ?? '')); + $settings->usernameField = strtolower((string)(($s['username_field'] ?? '') ?: 'mail')); + $settings->attributeField = strtolower(trim((string)($s['attribute_field'] ?? ''))); + $settings->idAttribute = strtolower(trim((string)($s['id_attribute'] ?? ''))); + $settings->disabledFilter = trim((string)($s['disabled_filter'] ?? '')); + $settings->groupBasedn = trim((string)($s['group_basedn'] ?? '')); + $settings->groupFilter = trim((string)($s['group_filter'] ?? '')); + $settings->groupNameAttribute = strtolower((string)(($s['group_name_attribute'] ?? '') ?: 'cn')); + $mode = (string)($s['group_member_mode'] ?? 'memberof'); + $settings->groupMemberMode = in_array($mode, self::MEMBER_MODES, true) ? $mode : 'memberof'; + $settings->groupMemberAttribute = strtolower((string)(($s['group_member_attribute'] ?? '') ?: 'member')); + return $settings; + } + + /** Only Keycloak and LDAP can be listed by the sync */ + public function canSync(): bool + { + return in_array($this->provider, [self::KEYCLOAK, self::LDAP], true); + } + + public function syncEnabled(): bool + { + return $this->canSync() && ($this->periodicSync || $this->importUsers || $this->offboarding || $this->groupSync); + } + + /** Template of the first mapping (in configured order) matching one of the values, null if none */ + public function mappedTemplate(array $values): ?string + { + $values = array_map('strval', array_filter($values, 'is_scalar')); + foreach ($this->mappers as $key => $mapper) { + if (in_array($mapper, $values, true) && ($this->templates[$key] ?? '') !== '') { + return $this->templates[$key]; + } + } + return null; + } + + private static function flag(mixed $value): bool + { + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Model/State.php b/data/web/inc/lib/mailcow/Identity/Model/State.php new file mode 100644 index 000000000..ce52e07db --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Model/State.php @@ -0,0 +1,30 @@ + $accounts mailboxes with the provider as authsource, by username + * @param array $mailboxes username => authsource of every mailbox + * @param string[] $domains mailcow domains + * @param array[] $groups mailbox groups {id, domain, name, idp_ref, template, priority, + * active, members: username => source} + * @param array $effectiveTemplates username => effective group template + * @param array $templateHashes template name => attribute hash + */ + public function __construct( + public readonly array $accounts, + public readonly array $mailboxes = [], + public readonly array $domains = [], + public readonly array $groups = [], + public readonly array $effectiveTemplates = [], + public readonly array $templateHashes = [] + ) { + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Model/SyncPlan.php b/data/web/inc/lib/mailcow/Identity/Model/SyncPlan.php new file mode 100644 index 000000000..2dbc8e796 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Model/SyncPlan.php @@ -0,0 +1,73 @@ + username => external id (adopted by email or relinked) */ + public array $links = []; + /** @var array[] {email, name, template, id}: mailboxes to create */ + public array $creates = []; + /** @var string[] usernames set to login disabled (active = 2) */ + public array $deactivate = []; + /** @var string[] usernames the sync had disabled and the IdP enabled again (active = 1) */ + public array $restore = []; + /** @var string[] usernames whose idp_disabled marker is dropped, active stays (an admin changed it) */ + public array $release = []; + /** @var string[] usernames not listed for the first time (missing_since is set) */ + public array $missing = []; + /** @var string[] usernames listed again (missing_since is cleared) */ + public array $found = []; + /** @var array[] {username, name, template}: existing mailboxes whose name/template is synced; template = legacy mapping */ + public array $updates = []; + /** @var array username => {name, template}: IdP name and legacy mapping of every listed mailbox */ + public array $profiles = []; + /** @var array[] {ref, name, domain, mail}: groups to create for IdP groups */ + public array $groupCreates = []; + /** @var array group id => new name */ + public array $groupRenames = []; + /** @var array group ref => usernames of its IdP members */ + public array $memberships = []; + /** @var int[] linked groups no longer listed; their IdP members are removed */ + public array $orphanedGroups = []; + /** Number of IdP memberships the plan removes (for the guard) */ + public int $membershipRemovals = 0; + /** @var string[] situations an admin has to resolve */ + public array $conflicts = []; + /** Users without email, not in a mailcow domain or without template (not created) */ + public int $skipped = 0; + /** The guard stopped deactivations and membership removals */ + public bool $blocked = false; + + /** Stored with the run and shown in the UI */ + public function summary(): array + { + $summary = ['counts' => [], 'details' => []]; + foreach (['links' => 'links', 'creates' => 'creates', 'deactivate' => 'deactivate', 'restore' => 'restore', + 'release' => 'release', 'missing' => 'missing', 'found' => 'found', 'updates' => 'updates', 'group_creates' => 'groupCreates', + 'group_renames' => 'groupRenames', 'memberships' => 'memberships', 'orphaned_groups' => 'orphanedGroups', + 'conflicts' => 'conflicts'] as $key => $kind) { + $summary['counts'][$key] = count($this->$kind); + } + $summary['counts']['membership_removals'] = $this->membershipRemovals; + $summary['counts']['skipped'] = $this->skipped; + $summary['details'] = [ + 'creates' => array_slice(array_column($this->creates, 'email'), 0, self::DETAILS), + 'deactivate' => array_slice($this->deactivate, 0, self::DETAILS), + 'restore' => array_slice($this->restore, 0, self::DETAILS), + 'missing' => array_slice($this->missing, 0, self::DETAILS), + 'group_creates' => array_slice(array_column($this->groupCreates, 'name'), 0, self::DETAILS), + 'conflicts' => array_slice($this->conflicts, 0, self::DETAILS), + ]; + $summary['blocked'] = $this->blocked; + return $summary; + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Repository/IdentityRepository.php b/data/web/inc/lib/mailcow/Identity/Repository/IdentityRepository.php new file mode 100644 index 000000000..8beecc3e4 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Repository/IdentityRepository.php @@ -0,0 +1,130 @@ + mailboxes with the provider as authsource, by username */ + public function accounts(string $provider): array + { + $rows = $this->query("SELECT m.`username`, m.`name`, m.`active`, + JSON_UNQUOTE(JSON_EXTRACT(m.`attributes`, '$.attribute_hash')) AS `attribute_hash`, + l.`external_id`, l.`idp_disabled`, l.`missing_since` + FROM `mailbox` m + LEFT JOIN `identity_link` l ON l.`username` = m.`username` AND l.`provider` = :p1 + WHERE m.`authsource` = :p2 AND m.`kind` = ''", ['p1' => $provider, 'p2' => $provider])->fetchAll(PDO::FETCH_ASSOC); + $accounts = []; + foreach ($rows as $row) { + $accounts[(string)$row['username']] = new Account((string)$row['username'], (string)$row['name'], (int)$row['active'], + isset($row['external_id']) ? (string)$row['external_id'] : null, (int)($row['idp_disabled'] ?? 0) === 1, + isset($row['missing_since']) ? (string)$row['missing_since'] : null, (string)($row['attribute_hash'] ?? '')); + } + return $accounts; + } + + /** @return array{accounts: int, linked: int, idp_disabled: int, missing: int} mailboxes of the provider */ + public function stats(string $provider): array + { + $row = $this->query("SELECT COUNT(*) AS `accounts`, COUNT(l.`external_id`) AS `linked`, + COALESCE(SUM(l.`idp_disabled`), 0) AS `idp_disabled`, COUNT(l.`missing_since`) AS `missing` + FROM `mailbox` m LEFT JOIN `identity_link` l ON l.`username` = m.`username` AND l.`provider` = :p1 + WHERE m.`authsource` = :p2 AND m.`kind` = ''", ['p1' => $provider, 'p2' => $provider])->fetch(PDO::FETCH_ASSOC); + return array_map('intval', $row); + } + + /** @return array username => authsource of every mailbox (incl. resources) */ + public function mailboxes(): array + { + return $this->query("SELECT `username`, `authsource` FROM `mailbox`")->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** @return string[] mailcow domains (no alias domains) */ + public function domains(): array + { + return $this->query("SELECT `domain` FROM `domain`")->fetchAll(PDO::FETCH_COLUMN); + } + + /** @return array template name => hash as stored by mailbox_from_template in attributes.attribute_hash */ + public function templateHashes(): array + { + $hashes = []; + foreach ($this->query("SELECT `template`, `attributes` FROM `templates` WHERE `type` = 'mailbox'")->fetchAll(PDO::FETCH_ASSOC) as $row) { + $hashes[(string)$row['template']] = sha1(json_encode($row['attributes'])); + } + return $hashes; + } + + /** @return ?array{provider: string, external_id: string, idp_disabled: int, missing_since: ?string} */ + public function findLink(string $username): ?array + { + $row = $this->query("SELECT `provider`, `external_id`, `idp_disabled`, `missing_since` FROM `identity_link` WHERE `username` = :username", + ['username' => $username])->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + /** Mailbox linked to the IdP user; links of mailboxes that no longer use the provider do not count */ + public function usernameOf(string $provider, string $externalId): ?string + { + $username = $this->query("SELECT l.`username` FROM `identity_link` l + JOIN `mailbox` m ON m.`username` = l.`username` AND m.`authsource` = l.`provider` + WHERE l.`provider` = :provider AND l.`external_id` = :id", ['provider' => $provider, 'id' => $externalId])->fetchColumn(); + return ($username === false) ? null : (string)$username; + } + + /** + * Links the mailbox to the IdP user; an older link of the mailbox is replaced. Callers make sure the IdP user + * is not linked to another mailbox of the provider (usernameOf()); stale links of the id are dropped. + */ + public function link(string $username, string $provider, string $externalId): void + { + $this->transaction(function () use ($username, $provider, $externalId): void { + $this->query("DELETE FROM `identity_link` WHERE `provider` = :provider AND `external_id` = :id AND `username` != :username", + ['provider' => $provider, 'id' => $externalId, 'username' => $username]); + $this->query("INSERT INTO `identity_link` (`username`, `provider`, `external_id`) VALUES (:username, :provider, :id) + ON DUPLICATE KEY UPDATE `provider` = VALUES(`provider`), `external_id` = VALUES(`external_id`), `missing_since` = NULL", + ['username' => $username, 'provider' => $provider, 'id' => $externalId]); + }); + } + + public function unlink(string $username): void + { + $this->query("DELETE FROM `identity_link` WHERE `username` = :username", ['username' => $username]); + } + + /** Marks the IdP mailbox as not listed (now) or listed again; unlinked mailboxes get a link row without id */ + public function setMissing(string $username, string $provider, bool $missing): void + { + $this->query("INSERT INTO `identity_link` (`username`, `provider`, `missing_since`) VALUES (:username, :provider, " + . ($missing ? "NOW()" : "NULL") . ") ON DUPLICATE KEY UPDATE `missing_since` = VALUES(`missing_since`)", + ['username' => $username, 'provider' => $provider]); + } + + /** + * Sets mailbox.active like the mailbox edit does (incl. the mailbox's own alias row) and records whether the + * IdP sync caused it. + */ + public function setActive(string $username, string $provider, int $active, bool $idpDisabled): void + { + $this->transaction(function () use ($username, $provider, $active, $idpDisabled): void { + $this->query("UPDATE `mailbox` SET `active` = :active WHERE `username` = :username", ['active' => $active, 'username' => $username]); + $this->query("UPDATE `alias` SET `active` = :active WHERE `address` = :username", ['active' => $active, 'username' => $username]); + $this->setIdpDisabled($username, $provider, $idpDisabled); + }); + } + + public function setIdpDisabled(string $username, string $provider, bool $idpDisabled): void + { + $this->query("INSERT INTO `identity_link` (`username`, `provider`, `idp_disabled`) VALUES (:username, :provider, :flag) + ON DUPLICATE KEY UPDATE `idp_disabled` = VALUES(`idp_disabled`)", + ['username' => $username, 'provider' => $provider, 'flag' => $idpDisabled ? 1 : 0]); + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Repository/RunRepository.php b/data/web/inc/lib/mailcow/Identity/Repository/RunRepository.php new file mode 100644 index 000000000..ba2e0ed25 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Repository/RunRepository.php @@ -0,0 +1,81 @@ +query("SELECT `id` FROM `identity_sync_run` WHERE `status` = 'pending' AND `mode` = :mode", + ['mode' => $mode])->fetchColumn(); + if ($id !== false) { + return (int)$id; + } + return $this->insertRow('identity_sync_run', ['provider' => $provider, 'mode' => $mode, 'status' => 'pending', + 'requested_by' => $requestedBy]); + } + + /** @return ?array oldest pending request */ + public function nextPending(): ?array + { + $row = $this->query("SELECT * FROM `identity_sync_run` WHERE `status` = 'pending' ORDER BY `id` LIMIT 1")->fetch(PDO::FETCH_ASSOC); + return $row ?: null; + } + + /** A scheduled run (not requested by anyone) */ + public function create(string $provider, string $mode): int + { + return $this->insertRow('identity_sync_run', ['provider' => $provider, 'mode' => $mode, 'status' => 'pending', 'requested_by' => '']); + } + + public function start(int $id): void + { + $this->query("UPDATE `identity_sync_run` SET `status` = 'running', `started` = NOW() WHERE `id` = :id", ['id' => $id]); + } + + public function finish(int $id, string $status, array $summary): void + { + $this->query("UPDATE `identity_sync_run` SET `status` = :status, `finished` = NOW(), `summary` = :summary WHERE `id` = :id", + ['status' => $status, 'summary' => json_encode($summary), 'id' => $id]); + $this->query("DELETE FROM `identity_sync_run` WHERE `id` NOT IN (SELECT `id` FROM (SELECT `id` FROM `identity_sync_run` + ORDER BY `id` DESC LIMIT " . self::KEEP . ") AS `keep`)"); + } + + /** Seconds since the last scheduled run started, null if there was none */ + public function secondsSinceScheduled(): ?int + { + $seconds = $this->query("SELECT TIMESTAMPDIFF(SECOND, MAX(`started`), NOW()) FROM `identity_sync_run` + WHERE `requested_by` = '' AND `mode` = 'run'")->fetchColumn(); + return ($seconds === false || $seconds === null) ? null : (int)$seconds; + } + + /** Runs left in status running by a crashed cron are closed, so they do not hide new runs */ + public function failStale(int $minutes): void + { + $this->query("UPDATE `identity_sync_run` SET `status` = 'error', `finished` = NOW(), + `summary` = JSON_OBJECT('error', 'The sync run did not finish') + WHERE `status` = 'running' AND `started` < NOW() - INTERVAL :minutes MINUTE", ['minutes' => $minutes]); + } + + /** @return array[] newest first, summary decoded */ + public function recent(int $limit = 10): array + { + $rows = $this->query("SELECT * FROM `identity_sync_run` ORDER BY `id` DESC LIMIT " . max(1, $limit))->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as &$row) { + $row['id'] = (int)$row['id']; + $row['summary'] = ($row['summary'] === null) ? null : json_decode((string)$row['summary'], true); + } + return $rows; + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Service/LinkService.php b/data/web/inc/lib/mailcow/Identity/Service/LinkService.php new file mode 100644 index 000000000..6a136f15c --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Service/LinkService.php @@ -0,0 +1,51 @@ +repo->findLink($username); + if ($link !== null && $link['provider'] === $provider && $link['external_id'] !== null) { + return hash_equals((string)$link['external_id'], $externalId); + } + if (!$this->mayCreate($provider, $username, $externalId)) { + return false; + } + $this->repo->link($username, $provider, $externalId); + return true; + } + + /** The IdP user owns no other mailbox (checked before a mailbox is created on login) */ + public function mayCreate(string $provider, string $username, string $externalId): bool + { + $owner = ($externalId === '') ? null : $this->repo->usernameOf($provider, $externalId); + return $externalId !== '' && ($owner === null || $owner === $username); + } + + /** Links a mailbox created on login */ + public function link(string $provider, string $username, string $externalId): void + { + if ($externalId !== '') { + $this->repo->link($username, $provider, $externalId); + } + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Service/StatusService.php b/data/web/inc/lib/mailcow/Identity/Service/StatusService.php new file mode 100644 index 000000000..12118a879 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Service/StatusService.php @@ -0,0 +1,80 @@ +context->requireAdmin(false); + return [ + 'provider' => $this->settings->provider, + 'can_sync' => $this->settings->canSync(), + 'sync_enabled' => $this->settings->syncEnabled(), + 'counts' => $this->repo->stats($this->settings->provider), + 'runs' => $this->runs->recent(), + ]; + } + + /** + * Queues a run for the cron: preview (dry run), run, or force (run without the change limit). + * + * @throws UserError + */ + public function requestRun(mixed $mode): int + { + $this->context->requireAdmin(); + $mode = Input::text($mode); + if (!in_array($mode, self::MODES, true)) { + throw new UserError('iam_sync_mode_invalid'); + } + if (!$this->settings->canSync()) { + throw new UserError('iam_sync_unavailable'); + } + return $this->runs->request($this->settings->provider, $mode, (string)$this->context->username); + } + + /** Link of a mailbox for the mailbox edit page, null if none or not allowed */ + public function link(string $username): ?array + { + $username = strtolower(trim($username)); + if (!($this->context->hasAdminRights() || ($this->context->isDomainAdmin() && $this->context->canAccessMailbox($username)))) { + return null; + } + return $this->repo->findLink($username); + } + + /** + * Drops the link, the mailbox is linked again by email (next run or login). Resolves conflicts, e.g. when an + * address was given to another IdP user. + * + * @throws AccessDenied + */ + public function unlink(string $username): void + { + $this->context->requireAdmin(); + $this->repo->unlink(strtolower(trim($username))); + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Service/SyncPlanner.php b/data/web/inc/lib/mailcow/Identity/Service/SyncPlanner.php new file mode 100644 index 000000000..9870be7c4 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Service/SyncPlanner.php @@ -0,0 +1,311 @@ +accounts as $username => $account) { + if ($account->externalId !== null) { + $owners[$account->externalId] = (string)$username; + } + } + /** @var array $matched username => listed user */ + $matched = []; + /** @var array $candidates email => user to create, template still open */ + $candidates = []; + $duplicates = []; + foreach ($listing->users as $user) { + if (isset($owners[$user->id])) { + $username = $owners[$user->id]; + if ($user->email !== null && $user->email !== $username) { + self::conflict($plan, $username . ' is linked to an IdP user whose email is now ' . $user->email); + } + $matched[$username] = $user; + } elseif ($user->email === null) { + $plan->skipped++; + } elseif (isset($state->accounts[$user->email])) { + $account = $state->accounts[$user->email]; + if ($account->externalId !== null || isset($plan->links[$user->email])) { + self::conflict($plan, $user->email . ' is linked to another IdP user'); + continue; + } + $plan->links[$user->email] = $user->id; + $matched[$user->email] = $user; + } elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled + || !in_array(substr((string)strrchr($user->email, '@'), 1), $state->domains, true)) { + // other authsource, import off, disabled or no mailcow domain + $plan->skipped++; + } elseif (isset($candidates[$user->email]) || isset($duplicates[$user->email])) { + self::conflict($plan, 'Several IdP users have the email ' . $user->email); + unset($candidates[$user->email]); + $duplicates[$user->email] = true; + } else { + $candidates[$user->email] = $user; + } + } + $groups = ($settings->groupSync && $listing->groups !== null) ? $listing->groups : null; + $this->planCreates($settings, $candidates, $groups ?? [], $state, $plan); + $usernames = []; + foreach ($matched as $username => $user) { + $usernames[$user->id] = (string)$username; + } + foreach ($plan->creates as $create) { + $usernames[$create['id']] = $create['email']; + } + if ($groups !== null) { + $this->planGroups($settings, $groups, $state, $usernames, $matched, $plan); + } + $this->planStates($settings, $state, $matched, $plan); + if ($settings->periodicSync) { + $this->planUpdates($settings, $state, $matched, $plan); + } + $active = count(array_filter($state->accounts, fn(Account $a): bool => $a->active === 1)); + $memberships = array_sum(array_map(fn(array $g): int => count(self::idpMembers($g)), $state->groups)); + if (!$force && (count($plan->deactivate) > self::limit($active, $settings->offboardingLimit) + || $plan->membershipRemovals > self::limit($memberships, $settings->offboardingLimit))) { + $plan->blocked = true; + } + return $plan; + } + + /** + * @param array $groups + * @param array $usernames user id => username of matched and new users + */ + private function planGroups(Settings $settings, array $groups, State $state, array $usernames, array $matched, SyncPlan $plan): void + { + $linked = []; + $names = []; + foreach ($state->groups as $group) { + if ($group['idp_ref'] !== null) { + $linked[(string)$group['idp_ref']] = $group; + } + $names[$group['domain']][mb_strtolower($group['name'])] = (int)$group['id']; + } + foreach ($groups as $ref => $directoryGroup) { + $ref = (string)$ref; + $members = []; + foreach ($directoryGroup->memberIds as $id) { + if (isset($usernames[$id])) { + $members[] = $usernames[$id]; + } + } + $current = $linked[$ref] ?? null; + if ($current === null) { + if (!$settings->groupAutocreate) { + continue; + } + $domain = self::groupDomain($settings, $directoryGroup, $state->domains); + if ($domain === null) { + self::conflict($plan, 'IdP group ' . $directoryGroup->name . ' has no mailcow domain'); + continue; + } + if (isset($names[$domain][mb_strtolower($directoryGroup->name)])) { + self::conflict($plan, 'A group ' . $directoryGroup->name . ' exists in ' . $domain . ' and is not linked to the IdP group'); + continue; + } + $names[$domain][mb_strtolower($directoryGroup->name)] = 0; + $plan->groupCreates[] = ['ref' => $ref, 'name' => $directoryGroup->name, 'domain' => $domain, 'mail' => $directoryGroup->mail]; + $plan->memberships[$ref] = self::sorted($members); + continue; + } + $idpMembers = self::idpMembers($current); + // Linked users missing in the listing keep their memberships until they are listed again + foreach ($idpMembers as $username) { + if (isset($state->accounts[$username]) && !isset($matched[$username])) { + $members[] = $username; + } + } + $members = self::sorted($members); + $removed = array_diff($idpMembers, $members); + if (!empty($removed) || !empty(array_diff($members, array_map('strval', array_keys($current['members']))))) { + $plan->memberships[$ref] = $members; + $plan->membershipRemovals += count($removed); + } + if ($directoryGroup->name !== $current['name']) { + $taken = $names[$current['domain']][mb_strtolower($directoryGroup->name)] ?? null; + if ($taken !== null && $taken !== (int)$current['id']) { + self::conflict($plan, 'IdP group ' . $current['name'] . ' was renamed to ' . $directoryGroup->name . ', which exists in ' . $current['domain']); + } else { + $plan->groupRenames[(int)$current['id']] = $directoryGroup->name; + $names[$current['domain']][mb_strtolower($directoryGroup->name)] = (int)$current['id']; + } + } + } + foreach ($linked as $ref => $group) { + if (!isset($groups[$ref])) { + $plan->orphanedGroups[] = (int)$group['id']; + $plan->membershipRemovals += count(self::idpMembers($group)); + } + } + } + + /** + * Template of a new mailbox: the template of its highest-priority linked group (as in TemplateService), else + * the attribute mapping, else the default template. + * + * @param array $candidates + * @param array $groups listed groups, [] without group sync + */ + private function planCreates(Settings $settings, array $candidates, array $groups, State $state, SyncPlan $plan): void + { + $linked = []; + foreach ($state->groups as $group) { + if ($group['idp_ref'] !== null && $group['template'] !== null && $group['active'] === 1) { + $linked[(string)$group['idp_ref']] = $group; + } + } + $groupsOf = []; + foreach ($groups as $ref => $directoryGroup) { + if (isset($linked[(string)$ref])) { + foreach ($directoryGroup->memberIds as $id) { + $groupsOf[$id][] = $linked[(string)$ref]; + } + } + } + foreach ($candidates as $email => $user) { + $best = null; + foreach ($groupsOf[$user->id] ?? [] as $group) { + if ($best === null || $group['priority'] > $best['priority'] || ($group['priority'] === $best['priority'] && $group['id'] < $best['id'])) { + $best = $group; + } + } + $template = $best['template'] ?? $settings->mappedTemplate($user->templateValues) + ?? (($settings->defaultTemplate !== '') ? $settings->defaultTemplate : null); + if ($template === null) { + self::conflict($plan, 'No template matches ' . $email); + continue; + } + $plan->creates[] = ['email' => (string)$email, 'name' => self::name($user->name, ''), 'template' => $template, 'id' => $user->id]; + } + } + + /** @param array $matched */ + private function planStates(Settings $settings, State $state, array $matched, SyncPlan $plan): void + { + foreach ($state->accounts as $username => $account) { + $username = (string)$username; + $user = $matched[$username] ?? null; + if ($user === null && $account->missingSince === null) { + $plan->missing[] = $username; + continue; + } + if ($user !== null && $account->missingSince !== null) { + $plan->found[] = $username; + } + if (!$settings->offboarding) { + continue; + } + // Second complete run without the user counts as removed from the IdP + $disabled = ($user === null) || !$user->enabled; + if ($disabled && !$account->idpDisabled && $account->active === 1) { + $plan->deactivate[] = $username; + } elseif (!$disabled && $account->idpDisabled) { + if ($account->active === 2) { + $plan->restore[] = $username; + } else { + $plan->release[] = $username; + } + } + } + } + + /** + * Name and template of linked mailboxes, like the IdP login does. The planned template is the legacy mapping; + * SyncService applies the effective template again after the group changes of the run. + * + * @param array $matched + */ + private function planUpdates(Settings $settings, State $state, array $matched, SyncPlan $plan): void + { + foreach ($matched as $username => $user) { + $account = $state->accounts[$username]; + $legacy = $settings->mappedTemplate($user->templateValues); + $plan->profiles[$username] = ['name' => self::name($user->name, $account->name), 'template' => $legacy]; + $template = $state->effectiveTemplates[$username] ?? $legacy; + if ($template === null) { + continue; + } + if (!isset($state->templateHashes[$template])) { + self::conflict($plan, 'Template ' . $template . ' does not exist'); + continue; + } + $name = self::name($user->name, $account->name); + if ($name !== $account->name || $state->templateHashes[$template] !== $account->attributeHash) { + $plan->updates[] = ['username' => (string)$username, 'name' => $name, 'template' => $legacy]; + } + } + } + + /** Domain for a new group: the domain of its mail if it is a mailcow domain, else the default group domain */ + private static function groupDomain(Settings $settings, DirectoryGroup $group, array $domains): ?string + { + $domain = ($group->mail !== null) ? substr((string)strrchr($group->mail, '@'), 1) : ''; + if (in_array($domain, $domains, true)) { + return $domain; + } + return in_array($settings->groupDefaultDomain, $domains, true) ? $settings->groupDefaultDomain : null; + } + + /** Mailbox name as mailbox_from_template stores it; an empty IdP name keeps the current one */ + private static function name(string $name, string $current): string + { + $name = ltrim(rtrim(trim($name), '>'), '<'); + return ($name === '') ? $current : $name; + } + + private static function limit(int $base, int $percent): int + { + return max(self::GUARD_MIN, (int)ceil($base * $percent / 100)); + } + + /** @return string[] members of a state group with source idp */ + private static function idpMembers(array $group): array + { + return array_map('strval', array_keys(array_filter($group['members'], fn(string $source): bool => $source === 'idp'))); + } + + private static function sorted(array $usernames): array + { + $usernames = array_values(array_unique(array_map('strval', $usernames))); + sort($usernames); + return $usernames; + } + + private static function conflict(SyncPlan $plan, string $message): void + { + if (!in_array($message, $plan->conflicts, true)) { + $plan->conflicts[] = $message; + } + } +} diff --git a/data/web/inc/lib/mailcow/Identity/Service/SyncService.php b/data/web/inc/lib/mailcow/Identity/Service/SyncService.php new file mode 100644 index 000000000..e953edee8 --- /dev/null +++ b/data/web/inc/lib/mailcow/Identity/Service/SyncService.php @@ -0,0 +1,231 @@ +runs->failStale(self::STALE_MINUTES); + $run = $this->runs->nextPending(); + if ($run === null) { + if (!$this->settings->syncEnabled()) { + return null; + } + $since = $this->runs->secondsSinceScheduled(); + if ($since !== null && $since < $this->settings->syncInterval * 60) { + return null; + } + $run = ['id' => $this->runs->create($this->settings->provider, 'run'), 'mode' => 'run']; + } + return $this->execute((int)$run['id'], (string)$run['mode']); + } + + /** @return array{id: int, mode: string, status: string, summary: array} */ + public function execute(int $id, string $mode): array + { + $this->runs->start($id); + try { + $directory = $this->settings->canSync() ? ($this->directory)() : null; + if ($directory === null) { + throw new DirectoryException('The identity provider cannot be synced'); + } + $listing = $directory->listAll($this->settings->groupSync); + $plan = $this->planner->plan($this->settings, $listing, $this->state(), $mode === 'force'); + $summary = $plan->summary(); + if ($mode === 'preview') { + $status = 'ok'; + } elseif ($plan->blocked) { + $status = 'blocked'; + } else { + [$errors, $updated] = $this->apply($plan); + $summary['counts']['updates'] = $updated; + $summary['counts']['errors'] = count($errors); + $summary['errors'] = array_slice($errors, 0, self::DETAILS); + $status = 'ok'; + } + } catch (DirectoryException $e) { + $status = 'error'; + $summary = ['error' => $e->getMessage()]; + } catch (Throwable $e) { + $this->runs->finish($id, 'error', ['error' => 'The sync run failed: ' . $e->getMessage(), 'mode' => $mode]); + throw $e; + } + $summary['mode'] = $mode; + $this->runs->finish($id, $status, $summary); + return ['id' => $id, 'mode' => $mode, 'status' => $status, 'summary' => $summary]; + } + + private function state(): State + { + $accounts = $this->repo->accounts($this->settings->provider); + return new State($accounts, $this->repo->mailboxes(), $this->repo->domains(), + $this->settings->groupSync ? $this->groups->groups() : [], + $this->settings->periodicSync ? $this->templates->effective(array_keys($accounts)) : [], + $this->repo->templateHashes()); + } + + /** @return array{0: string[], 1: int} errors of single changes (the rest is still applied), updated mailboxes */ + private function apply(SyncPlan $plan): array + { + $provider = $this->settings->provider; + $errors = []; + foreach ($plan->links as $username => $externalId) { + $this->repo->link((string)$username, $provider, $externalId); + } + foreach ($plan->found as $username) { + $this->repo->setMissing($username, $provider, false); + } + foreach ($plan->missing as $username) { + $this->repo->setMissing($username, $provider, true); + } + foreach ($plan->creates as $create) { + $error = $this->legacy->createMailbox($create['email'], $create['name'], $provider, $create['template']); + if ($error !== null) { + $errors[] = $create['email'] . ': ' . $error; + continue; + } + $this->repo->link($create['email'], $provider, $create['id']); + } + foreach ($plan->deactivate as $username) { + $this->repo->setActive($username, $provider, 2, true); + } + foreach ($plan->restore as $username) { + $this->repo->setActive($username, $provider, 1, false); + } + foreach ($plan->release as $username) { + $this->repo->setIdpDisabled($username, $provider, false); + } + $this->sogo->refreshStaticView(array_merge($plan->deactivate, $plan->restore)); + $changed = []; + $errors = array_merge($errors, $this->applyGroups($plan, $changed)); + [$updateErrors, $updated] = $this->applyUpdates($plan, $changed); + return [array_merge($errors, $updateErrors), $updated]; + } + + /** + * @param string[] $changed collects the members that joined or left a group + * @return string[] + */ + private function applyGroups(SyncPlan $plan, array &$changed): array + { + $errors = []; + foreach ($plan->groupCreates as $create) { + try { + foreach ($this->groups->createFromIdp($create['ref'], $create['name'], $create['domain'], $create['mail']) as $warning) { + $errors[] = $create['name'] . ': ' . $warning[0]; + } + } catch (UserError $e) { + $errors[] = $create['name'] . ': ' . $e->key; + } + } + foreach ($plan->groupRenames as $id => $name) { + try { + $this->groups->renameFromIdp((int)$id, $name); + } catch (UserError $e) { + $errors[] = $name . ': ' . $e->key; + } + } + foreach ($plan->memberships as $ref => $usernames) { + $group = $this->groups->findByIdpRef((string)$ref); + if ($group !== null) { + $this->groupResult($group->name, $this->groups->syncGroupMembers((int)$group->id, $usernames), $errors, $changed); + } + } + foreach ($plan->orphanedGroups as $id) { + $this->groupResult((string)$id, $this->groups->syncGroupMembers($id, []), $errors, $changed); + } + return $errors; + } + + /** + * Name and template updates after the group changes: planned updates and members that joined or left a group + * in this run (e.g. the legacy mapping applies again after leaving a template group). The effective template + * is resolved and compared again. + * + * @return array{0: string[], 1: int} errors, updated mailboxes + */ + private function applyUpdates(SyncPlan $plan, array $changed): array + { + $candidates = array_values(array_unique(array_merge(array_column($plan->updates, 'username'), + array_intersect($changed, array_keys($plan->profiles))))); + if (empty($candidates)) { + return [[], 0]; + } + $errors = []; + $updated = 0; + $accounts = $this->repo->accounts($this->settings->provider); + $hashes = $this->repo->templateHashes(); + $effective = $this->templates->effective($candidates); + foreach ($candidates as $username) { + $profile = $plan->profiles[$username] ?? null; + $account = $accounts[$username] ?? null; + $template = $effective[$username] ?? $profile['template'] ?? null; + if ($profile === null || $account === null || $template === null + || ($profile['name'] === $account->name && ($hashes[$template] ?? null) === $account->attributeHash)) { + continue; + } + $error = $this->legacy->applyTemplate($username, $profile['name'], $template); + if ($error !== null) { + $errors[] = $username . ': ' . $error; + } else { + $updated++; + } + } + return [$errors, $updated]; + } + + private function groupResult(string $group, array $result, array &$errors, array &$changed): void + { + foreach ($result['warnings'] as $warning) { + $errors[] = $group . ': ' . $warning[0]; + } + array_push($changed, ...$result['added'], ...$result['removed']); + } +} diff --git a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php index b3b70a8b0..c2a9b6d6d 100644 --- a/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php +++ b/data/web/inc/lib/mailcow/Imapsync/Service/SourceService.php @@ -66,7 +66,7 @@ class SourceService public function canEdit(Source $source): bool { - if ($this->context->accessAll || $this->context->isAdmin() || $source->createdBy === $this->context->username) { + if ($this->context->hasAdminRights() || $source->createdBy === $this->context->username) { return true; } // A domain admin may manage sources created by mailboxes of his domains diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Controller/MailboxGroupApiController.php b/data/web/inc/lib/mailcow/MailboxGroup/Controller/MailboxGroupApiController.php new file mode 100644 index 000000000..86533ca31 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Controller/MailboxGroupApiController.php @@ -0,0 +1,173 @@ +handle('add', $attr, function (array $log) use ($attr): bool { + $group = $this->groups->create($attr); + $this->warnings($group->warnings, $log); + return $this->response->success(['group_added', htmlspecialchars($group->name)], $log); + }); + } + + /** POST edit/mailbox-group: one message per id */ + public function editGroups(?array $items, array $attr): bool + { + $data = array_merge(['id' => $items], $attr); + return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool { + $this->groups->requireEditor(); + return $this->batch((array)($data['id'] ?? []), $log, function (int $id) use ($attr, $log): array { + $group = $this->groups->update($id, $attr); + $this->warnings($group->warnings, $log); + return ['group_modified', htmlspecialchars($group->name)]; + }); + }); + } + + /** POST delete/mailbox-group: one message per id */ + public function deleteGroups(array $items): bool + { + return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool { + $this->groups->requireEditor(); + return $this->batch($items, $log, function (int $id) use ($log): array { + $group = $this->groups->delete($id); + $this->warnings($group->warnings, $log); + return ['group_removed', htmlspecialchars($group->name)]; + }); + }); + } + + /** GET mailbox-group/all[/] */ + public function getGroups(?string $domains = null): array + { + $list = array_values(array_filter(array_map('trim', explode(',', (string)$domains)), 'strlen')); + return array_values(array_filter(array_map(fn(int $id): array => $this->getGroup($id), $this->groups->visibleIds($list)))); + } + + /** GET mailbox-group/, also edit.php */ + public function getGroup(mixed $id): array + { + $id = $this->toId($id); + $group = ($id === null) ? null : $this->groups->get($id); + return ($group === null) ? [] : $this->groups->toArray($group); + } + + /** GET mailbox-group/member/: groups of a mailbox (mailbox edit page, user page) */ + public function getMemberships(string $username): array + { + return $this->groups->membershipsOf($username); + } + + public function mailboxDeleted(string $username): void + { + $this->hook('mailbox_deleted', $username, fn() => $this->hooks->onMailboxDeleted($username)); + } + + public function domainDeleted(string $domain): void + { + $this->hook('domain_deleted', $domain, fn() => $this->hooks->onDomainDeleted($domain)); + } + + public function addressDeleted(string $address): void + { + $this->hook('address_deleted', $address, fn() => $this->hooks->onAddressDeleted($address)); + } + + public function addressRenamed(string $old, string $new): void + { + $this->hook('address_renamed', [$old, $new], fn() => $this->hooks->onAddressRenamed($old, $new)); + } + + /** A mailbox template was edited: apply it where it is the effective group template */ + public function templateChanged(int $templateId): void + { + $this->hook('template_changed', $templateId, function () use ($templateId): void { + $this->warnings($this->templates->onTemplateChanged($templateId), ['mailbox_group', 'edit', 'template', $templateId]); + }); + } + + private function warnings(array $warnings, array $log): void + { + foreach ($warnings as $msg) { + $this->response->warning($msg, $log); + } + } + + /** + * Runs one write call: $work gets the log entry and returns the result for process_*_return(). + * A UserError becomes a danger message, a database error stops the call with one `mysql_error` message. + */ + private function handle(string $action, mixed $data, callable $work): bool + { + $log = ['mailbox_group', $action, 'group', $data]; + try { + $result = $work($log); + } catch (UserError $e) { + $result = $this->response->error($e, $log); + } catch (PDOException $e) { + $result = $this->response->danger(['mysql_error', $e->getMessage()], $log); + } + $this->response->flush(); + return $result; + } + + private function hook(string $action, mixed $data, callable $work): void + { + try { + $work(); + } catch (PDOException $e) { + $this->response->danger(['mysql_error', $e->getMessage()], ['mailbox_group', $action, 'hook', $data]); + } + $this->response->flush(); + } + + /** One message per id: the success message returned by $work(id), or the UserError */ + private function batch(array $ids, array $log, callable $work): bool + { + foreach ($ids as $id) { + try { + $this->response->success($work($this->requireId($id)), $log); + } catch (UserError $e) { + $this->response->error($e, $log); + } + } + return true; + } + + /** Integer id from request input ("12", 12); null for anything else */ + private function toId(mixed $id): ?int + { + return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null; + } + + /** @throws AccessDenied for ids that cannot exist */ + private function requireId(mixed $id): int + { + return $this->toId($id) ?? throw new AccessDenied(); + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Legacy/LegacyMailbox.php b/data/web/inc/lib/mailcow/MailboxGroup/Legacy/LegacyMailbox.php new file mode 100644 index 000000000..ac2e7157f --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Legacy/LegacyMailbox.php @@ -0,0 +1,59 @@ +asSystem(fn(): mixed => $this->callTemplate($username, $name, $template)); + } + + /** + * Runs a legacy call as system caller (access_all_exception); the caller's session messages are kept. + * + * @return ?string null unless the call returned false, else the message key of the legacy error + */ + protected function asSystem(callable $call): ?string + { + $hasReturn = array_key_exists('return', $_SESSION ?? []); + $return = $_SESSION['return'] ?? null; + $accessAll = $_SESSION['access_all_exception'] ?? null; + $_SESSION['return'] = []; + $_SESSION['access_all_exception'] = '1'; + try { + if ($call() !== false) { + return null; + } + foreach ($_SESSION['return'] as $message) { + if (($message['type'] ?? '') === 'danger') { + return (string)(is_array($message['msg']) ? $message['msg'][0] : $message['msg']); + } + } + return 'generic_server_error'; + } finally { + if ($hasReturn) { + $_SESSION['return'] = $return; + } else { + unset($_SESSION['return']); + } + $_SESSION['access_all_exception'] = $accessAll ?? '0'; + } + } + + protected function callTemplate(string $username, string $name, string $template): mixed + { + return mailbox('edit', 'mailbox_from_template', ['username' => $username, 'name' => $name, 'template' => $template]); + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/MailboxGroupModule.php b/data/web/inc/lib/mailcow/MailboxGroup/MailboxGroupModule.php new file mode 100644 index 000000000..d7d1fd66c --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/MailboxGroupModule.php @@ -0,0 +1,62 @@ +api->addGroup($attr)); + * MailboxGroupModule::fromGlobals(Context::system())->templates->templateForLogin($username, $legacyTemplate); + * MailboxGroupModule::create($pdo, Context::system())->sharing->reconcile(); // cron + */ +class MailboxGroupModule +{ + public function __construct( + public readonly GroupService $groups, + public readonly TemplateService $templates, + public readonly AccountHooks $hooks, + public readonly IdpSyncService $idp, + public readonly SharingService $sharing, + public readonly MailboxGroupApiController $api + ) { + } + + public static function create(PDO $pdo, Context $context): self + { + $repo = new GroupRepository($pdo); + $lookup = new LookupRepository($pdo); + $dovecot = DovecotModule::create()->dovecot; + $sogo = SogoModule::create()->sogo; + $templates = new TemplateService($lookup, new LegacyMailbox()); + $effects = new ChangeEffects($templates, $sogo, $repo, $dovecot); + $sharing = new SharingService($repo, $dovecot, $sogo, $effects); + $groups = new GroupService($repo, $lookup, $templates, $effects, $sharing, $context); + $hooks = new AccountHooks($repo, $effects); + return new self($groups, $templates, $hooks, new IdpSyncService($repo, $lookup, $effects), $sharing, + new MailboxGroupApiController($groups, $hooks, $templates, new ApiResponse())); + } + + /** Legacy entry point for procedural callers: reads $pdo and $_SESSION; $context overrides the session caller */ + public static function fromGlobals(?Context $context = null): self + { + return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? [])); + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Model/Group.php b/data/web/inc/lib/mailcow/MailboxGroup/Model/Group.php new file mode 100644 index 000000000..90a5b54e7 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Model/Group.php @@ -0,0 +1,108 @@ + username => source */ + public array $members = []; + /** @var string[] addresses or @domain */ + public array $sendAs = []; + /** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_) */ + public array $imapGrants = []; + /** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */ + public array $sogoGrants = []; + /** @var array[] warnings of the last write (msg values), not stored */ + public array $warnings = []; + + /** Builds a group from a `mailbox_group` row (`template_name` optional) */ + public static function fromRow(array $row): self + { + $group = new self(); + $group->id = isset($row['id']) ? (int)$row['id'] : null; + $group->domain = (string)($row['domain'] ?? ''); + $group->name = (string)($row['name'] ?? ''); + $group->description = (string)($row['description'] ?? ''); + $group->address = isset($row['address']) ? (string)$row['address'] : null; + $group->senderAllowed = (int)($row['sender_allowed'] ?? 0); + $group->sogoVisible = (int)($row['sogo_visible'] ?? 0); + $group->internal = (int)($row['internal'] ?? 0); + $group->templateId = isset($row['template_id']) ? (int)$row['template_id'] : null; + $group->template = isset($row['template_name']) ? (string)$row['template_name'] : null; + $group->priority = (int)($row['priority'] ?? 0); + $group->idpRef = isset($row['idp_ref']) ? (string)$row['idp_ref'] : null; + $group->active = (int)($row['active'] ?? 1); + $group->created = isset($row['created']) ? (string)$row['created'] : null; + $group->modified = isset($row['modified']) ? (string)$row['modified'] : null; + return $group; + } + + /** @return string[] */ + public function usernames(): array + { + return array_keys($this->members); + } + + /** API representation (GET mailbox-group) */ + public function toArray(bool $canEdit): array + { + $members = []; + foreach ($this->members as $username => $source) { + $members[] = ['username' => (string)$username, 'source' => $source]; + } + return [ + 'id' => $this->id, + 'domain' => $this->domain, + 'name' => $this->name, + 'description' => $this->description, + 'address' => $this->address, + 'sender_allowed' => $this->senderAllowed, + 'sogo_visible' => $this->sogoVisible, + 'internal' => $this->internal, + 'template_id' => $this->templateId, + 'template' => $this->template, + 'priority' => $this->priority, + 'idp_ref' => $this->idpRef, + 'members' => $members, + 'send_as' => $this->sendAs, + 'imap_grants' => $this->imapGrants, + 'sogo_grants' => $this->sogoGrants, + 'active' => $this->active, + 'created' => $this->created, + 'modified' => $this->modified, + 'can_edit' => $canEdit, + ]; + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Repository/GroupRepository.php b/data/web/inc/lib/mailcow/MailboxGroup/Repository/GroupRepository.php new file mode 100644 index 000000000..f414ea2cd --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Repository/GroupRepository.php @@ -0,0 +1,649 @@ + [ + 'read' => ['PublicViewer', 'ConfidentialViewer', 'PrivateDAndTViewer'], + 'write' => ['PublicModifier', 'ConfidentialModifier', 'PrivateDAndTViewer', 'ObjectCreator', 'ObjectEraser'], + ], + 'Contacts' => [ + 'read' => ['ObjectViewer'], + 'write' => ['ObjectViewer', 'ObjectCreator', 'ObjectEditor', 'ObjectEraser'], + ], + ]; + + /** Grant table, folder and extra "access gone" condition of the subscriptions per kind */ + private const SUBSCRIPTION_GRANTS = [ + 'imap' => ['table' => 'mailbox_group_imap_grant', 'folder' => "''", 'gone' => " OR g.`state` = 'removing'", 'valid' => " AND g2.`state` != 'removing'"], + 'sogo' => ['table' => 'mailbox_group_sogo_grant', 'folder' => "CONCAT(g.`folder_type`, '/', g.`folder`)", 'gone' => '', 'valid' => ''], + ]; + + /** Side effects of the writes since the last takeEffects(): members whose acl_groups changed, sogo_acl changed */ + private array $effects = ['members' => [], 'sogo_acl' => false]; + /** Members removed by writeMembers(), rebuild() decides whether Dovecot needs to know */ + private array $removedMembers = []; + + public function find(int $id): ?Group + { + return $this->fetch('1=1', ['id' => $id]); + } + + /** Like find(), but only when the group is visible to the caller */ + public function findVisible(int $id, Context $context): ?Group + { + [$visible, $params] = $this->visibility($context); + return $this->fetch($visible, $params + ['id' => $id]); + } + + public function findByIdpRef(string $ref): ?Group + { + $id = $this->query("SELECT `id` FROM `mailbox_group` WHERE `idp_ref` = :ref", ['ref' => $ref])->fetchColumn(); + return ($id === false) ? null : $this->find((int)$id); + } + + /** + * @param string[] $domains limit to these domains, [] = all visible + * @return int[] ids of the groups visible to the caller, ordered by domain and name + */ + public function visibleIds(Context $context, array $domains = []): array + { + [$visible, $params] = $this->visibility($context); + $sql = "SELECT g.`id` FROM `mailbox_group` g WHERE $visible"; + if (!empty($domains)) { + $names = []; + foreach (array_values($domains) as $i => $domain) { + $names[] = ':dom' . $i; + $params['dom' . $i] = $domain; + } + $sql .= " AND g.`domain` IN (" . implode(', ', $names) . ")"; + } + return array_map('intval', $this->query($sql . " ORDER BY g.`domain`, g.`name`", $params)->fetchAll(PDO::FETCH_COLUMN)); + } + + public function nameExists(string $domain, string $name, ?int $exceptId = null): bool + { + return (bool)$this->query("SELECT 1 FROM `mailbox_group` WHERE `domain` = :domain AND `name` = :name AND `id` != :id", + ['domain' => $domain, 'name' => $name, 'id' => $exceptId ?? 0])->fetchColumn(); + } + + /** @return string[] usernames whose SOGo identities changed */ + public function insert(Group $group): array + { + return $this->transaction(function () use ($group): array { + $group->id = $this->insertRow('mailbox_group', $this->values($group)); + $this->writeMembers($group); + $this->writeSendAs($group); + $this->writeGrants($group); + return $this->rebuild((int)$group->id); + }); + } + + /** @return string[] usernames whose SOGo identities changed */ + public function update(Group $group): array + { + return $this->transaction(function () use ($group): array { + $this->updateRow('mailbox_group', (int)$group->id, $this->values($group)); + $this->writeMembers($group); + $this->writeSendAs($group); + $this->writeGrants($group); + return $this->rebuild((int)$group->id); + }); + } + + /** + * Deletes the group; members, grants and the derived alias/sender_acl rows go via FK cascade. + * + * @return string[] usernames whose SOGo identities changed + */ + public function delete(int $id): array + { + return $this->transaction(function () use ($id): array { + $before = $this->aliasState($id); + $this->query("DELETE FROM `mailbox_group` WHERE `id` = :id", ['id' => $id]); + $this->cleanupSogoAcl(); + return $before['members']; + }); + } + + /** + * Rewrites the rows derived from the group: the alias row of its address (goto = members, inactive while the + * group is inactive or empty, so the address bounces but stays reserved) and one sender_acl row per member + * and send-as grant, and the sogo_acl rows of its SOGo grants. Idempotent, joins the caller's transaction. + * $sogo = false keeps the sogo_acl rows (mailbox rename: sogo-tool rename-user updates them afterwards). + * + * @return string[] usernames whose SOGo identities changed + */ + public function rebuild(int $id, bool $sogo = true): array + { + return $this->transaction(function () use ($id, $sogo): array { + $group = $this->query("SELECT * FROM `mailbox_group` WHERE `id` = :id FOR UPDATE", ['id' => $id])->fetch(PDO::FETCH_ASSOC); + if (!$group) { + return []; + } + $before = $this->aliasState($id); + $members = $this->query("SELECT `username` FROM `mailbox_group_member` WHERE `group_id` = :id ORDER BY `username`", + ['id' => $id])->fetchAll(PDO::FETCH_COLUMN); + $visible = false; + if ($group['address'] === null) { + $this->query("DELETE FROM `alias` WHERE `group_id` = :id", ['id' => $id]); + } else { + $values = [ + 'address' => $group['address'], + 'goto' => empty($members) ? 'null@localhost' : implode(',', $members), + 'domain' => $group['domain'], + 'public_comment' => $group['description'], + 'sender_allowed' => (int)$group['sender_allowed'], + // SOGo identities only make sense when the members may send as the address + 'sogo_visible' => ($group['sogo_visible'] == 1 && $group['sender_allowed'] == 1) ? 1 : 0, + 'internal' => (int)$group['internal'], + 'active' => ($group['active'] == 1 && !empty($members)) ? 1 : 0, + ]; + // Never insert by address: an existing foreign row must not be taken over + $aliasId = $this->query("SELECT `id` FROM `alias` WHERE `group_id` = :id", ['id' => $id])->fetchColumn(); + if ($aliasId === false) { + $this->insertRow('alias', $values + ['group_id' => $id]); + } else { + $this->updateRow('alias', (int)$aliasId, $values); + } + $visible = ($values['active'] === 1 && $values['sogo_visible'] === 1); + } + $this->query("DELETE FROM `sender_acl` WHERE `group_id` = :id", ['id' => $id]); + if ($group['active'] == 1) { + $this->query("INSERT INTO `sender_acl` (`logged_in_as`, `send_as`, `external`, `group_id`) + SELECT m.`username`, s.`address`, 0, m.`group_id` FROM `mailbox_group_member` m + JOIN `mailbox_group_send_as` s ON s.`group_id` = m.`group_id` + WHERE m.`group_id` = :id AND m.`username` != s.`address`", ['id' => $id]); + } + if ($sogo) { + $this->rebuildSogoAcl($id, $group['active'] == 1, $members); + } + // Dovecot caches acl_groups (userdb) of the members, incl. the ones that just left + if ($this->query("SELECT 1 FROM `mailbox_group_imap_grant` WHERE `group_id` = :id LIMIT 1", ['id' => $id])->fetchColumn()) { + $this->effects['members'] = array_merge($this->effects['members'], $members, $this->removedMembers); + } + $this->removedMembers = []; + $after = $visible ? $members : []; + // Same visible address: only joined and left members are affected + if ($before['members'] !== [] && $visible && $before['address'] === $group['address']) { + return array_values(array_merge(array_diff($before['members'], $after), array_diff($after, $before['members']))); + } + return array_values(array_unique(array_merge($before['members'], $after))); + }); + } + + /** + * Every group as the IdP sync plans with it: {id, domain, name, idp_ref, template, priority, active, + * members: username => source} + * + * @return array[] + */ + public function syncState(): array + { + $members = []; + foreach ($this->query("SELECT `group_id`, `username`, `source` FROM `mailbox_group_member`")->fetchAll(PDO::FETCH_ASSOC) as $row) { + $members[(int)$row['group_id']][(string)$row['username']] = (string)$row['source']; + } + $groups = []; + foreach ($this->query("SELECT g.`id`, g.`domain`, g.`name`, g.`idp_ref`, t.`template`, g.`priority`, g.`active` + FROM `mailbox_group` g LEFT JOIN `templates` t ON t.`id` = g.`template_id` ORDER BY g.`id`")->fetchAll(PDO::FETCH_ASSOC) as $row) { + $groups[] = [ + 'id' => (int)$row['id'], + 'domain' => (string)$row['domain'], + 'name' => (string)$row['name'], + 'idp_ref' => isset($row['idp_ref']) ? (string)$row['idp_ref'] : null, + 'template' => isset($row['template']) ? (string)$row['template'] : null, + 'priority' => (int)$row['priority'], + 'active' => (int)$row['active'], + 'members' => $members[(int)$row['id']] ?? [], + ]; + } + return $groups; + } + + /** @return int[] groups with this member, a send-as grant on this address or a SOGo grant on this mailbox */ + public function groupIdsOfAccount(string $address): array + { + return array_map('intval', $this->query("SELECT `group_id` FROM `mailbox_group_member` WHERE `username` = :a1 + UNION SELECT `group_id` FROM `mailbox_group_send_as` WHERE `address` = :a2 + UNION SELECT `group_id` FROM `mailbox_group_sogo_grant` WHERE `owner` = :a3", + ['a1' => $address, 'a2' => $address, 'a3' => $address])->fetchAll(PDO::FETCH_COLUMN)); + } + + /** @return int[] groups with send-as grants on addresses of the domain (incl. @domain) */ + public function groupIdsWithSendAsIn(string $domain): array + { + return array_map('intval', $this->query("SELECT DISTINCT `group_id` FROM `mailbox_group_send_as` + WHERE SUBSTRING_INDEX(`address`, '@', -1) = :domain", ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN)); + } + + /** + * A mailbox is deleted: its memberships and the grants on its address go, the groups are rebuilt. + * + * @param int[] $ids groups of the account (groupIdsOfAccount()) + * @return string[] usernames whose SOGo identities changed + */ + public function removeAccount(string $username, array $ids): array + { + return $this->transaction(function () use ($username, $ids): array { + $this->query("DELETE FROM `mailbox_group_member` WHERE `username` = :username", ['username' => $username]); + $this->query("DELETE FROM `mailbox_group_send_as` WHERE `address` = :username", ['username' => $username]); + return $this->rebuildAll($ids); + }); + } + + /** An alias was deleted: grants on its address go */ + public function removeSendAs(string $address, array $ids): array + { + return $this->transaction(function () use ($address, $ids): array { + $this->query("DELETE FROM `mailbox_group_send_as` WHERE `address` = :address", ['address' => $address]); + return $this->rebuildAll($ids); + }); + } + + /** A domain or alias domain is deleted: grants on its addresses go */ + public function removeSendAsIn(string $domain, array $ids): array + { + return $this->transaction(function () use ($domain, $ids): array { + $this->query("DELETE FROM `mailbox_group_send_as` WHERE SUBSTRING_INDEX(`address`, '@', -1) = :domain", ['domain' => $domain]); + return $this->rebuildAll($ids); + }); + } + + /** + * Account or alias address renamed: members and grants follow. Explicit updates, because the mailbox rename + * runs with FOREIGN_KEY_CHECKS = 0; joins the caller's transaction. + */ + public function renameAccount(string $old, string $new, array $ids): array + { + return $this->transaction(function () use ($old, $new, $ids): array { + $this->query("UPDATE `mailbox_group_member` SET `username` = :new WHERE `username` = :old", ['new' => $new, 'old' => $old]); + $this->query("UPDATE `mailbox_group_send_as` SET `address` = :new WHERE `address` = :old", ['new' => $new, 'old' => $old]); + return $this->rebuildAll($ids, false); + }); + } + + /** @return int[] groups with SOGo grants */ + public function groupIdsWithSogoGrants(): array + { + return array_map('intval', $this->query("SELECT DISTINCT `group_id` FROM `mailbox_group_sogo_grant`")->fetchAll(PDO::FETCH_COLUMN)); + } + + /** + * IMAP grants Dovecot has to apply: pending, failed and removing ones, with $all also applied ones (new + * top-level folders); of one group or all. + * + * @return array[] rows of mailbox_group_imap_grant + */ + public function imapGrantsToApply(?int $groupId, bool $all): array + { + $where = $all ? '1=1' : "`state` != 'applied'"; + $params = []; + if ($groupId !== null) { + $where .= " AND `group_id` = :id"; + $params['id'] = $groupId; + } + return $this->query("SELECT * FROM `mailbox_group_imap_grant` WHERE $where ORDER BY `id`", $params)->fetchAll(PDO::FETCH_ASSOC); + } + + public function setImapState(int $grantId, string $state, ?string $error): void + { + $this->query("UPDATE `mailbox_group_imap_grant` SET `state` = :state, `last_error` = :error WHERE `id` = :id", + ['state' => $state, 'error' => $error, 'id' => $grantId]); + } + + public function deleteImapGrant(int $grantId): void + { + $this->query("DELETE FROM `mailbox_group_imap_grant` WHERE `id` = :id", ['id' => $grantId]); + } + + /** + * Deletes the sogo_acl rows of grants that are gone, e.g. by FK cascades of groups, owners and domains. + * Joins the caller's transaction. + */ + public function cleanupSogoAcl(): void + { + $this->transaction(function (): void { + $orphans = $this->query("SELECT l.`acl_id` FROM `mailbox_group_sogo_acl` l + LEFT JOIN `mailbox_group_sogo_grant` g ON g.`id` = l.`grant_id` + WHERE g.`id` IS NULL")->fetchAll(PDO::FETCH_COLUMN); + foreach ($orphans as $aclId) { + $this->deleteSogoAcl((int)$aclId); + } + }); + } + + /** + * Members to subscribe to grants with `subscribe` (kind imap or sogo): applied grants of active groups, members + * without a subscription by mailcow yet (a member that removes it keeps it removed). + * + * @return array[] {grant_id, owner, folder, usernames} + */ + public function missingSubscriptions(string $kind): array + { + $config = self::SUBSCRIPTION_GRANTS[$kind]; + $grants = []; + foreach ($this->query("SELECT g.`id`, g.`owner`, {$config['folder']} AS `folder`, m.`username` + FROM `{$config['table']}` g + JOIN `mailbox_group` mg ON mg.`id` = g.`group_id` AND mg.`active` = 1 + JOIN `mailbox_group_member` m ON m.`group_id` = g.`group_id` AND m.`username` != g.`owner` + LEFT JOIN `mailbox_group_subscription` s ON s.`kind` = :kind AND s.`grant_id` = g.`id` AND s.`username` = m.`username` + WHERE g.`subscribe` = 1 AND g.`state` = 'applied' AND s.`id` IS NULL + ORDER BY g.`id`, m.`username`", ['kind' => $kind])->fetchAll(PDO::FETCH_ASSOC) as $row) { + $grants[$row['id']] ??= ['grant_id' => (int)$row['id'], 'owner' => (string)$row['owner'], 'folder' => (string)$row['folder'], 'usernames' => []]; + $grants[$row['id']]['usernames'][] = (string)$row['username']; + } + return array_values($grants); + } + + /** + * Subscriptions by mailcow whose member lost the access: grant, membership or group gone or inactive. + * `covered`: another group still grants the member the same folder, so the subscription stays. + * + * @return array[] rows of mailbox_group_subscription with member_exists, owner_exists, covered + */ + public function staleSubscriptions(string $kind): array + { + $config = self::SUBSCRIPTION_GRANTS[$kind]; + return $this->query("SELECT s.`id`, s.`username`, s.`owner`, s.`folder`, + EXISTS(SELECT 1 FROM `mailbox` WHERE `username` = s.`username`) AS `member_exists`, + EXISTS(SELECT 1 FROM `mailbox` WHERE `username` = s.`owner`) AS `owner_exists`, + EXISTS(SELECT 1 FROM `mailbox_group_subscription` s2 + JOIN `{$config['table']}` g2 ON g2.`id` = s2.`grant_id`{$config['valid']} + JOIN `mailbox_group` mg2 ON mg2.`id` = g2.`group_id` AND mg2.`active` = 1 + JOIN `mailbox_group_member` m2 ON m2.`group_id` = g2.`group_id` AND m2.`username` = s2.`username` + WHERE s2.`kind` = s.`kind` AND s2.`id` != s.`id` AND s2.`username` = s.`username` AND s2.`owner` = s.`owner` + AND s2.`folder` = s.`folder`) AS `covered` + FROM `mailbox_group_subscription` s + LEFT JOIN `{$config['table']}` g ON g.`id` = s.`grant_id` + LEFT JOIN `mailbox_group` mg ON mg.`id` = g.`group_id` AND mg.`active` = 1 + LEFT JOIN `mailbox_group_member` m ON m.`group_id` = g.`group_id` AND m.`username` = s.`username` + WHERE s.`kind` = :kind AND (g.`id` IS NULL OR mg.`id` IS NULL OR m.`username` IS NULL{$config['gone']}) + ORDER BY s.`id`", ['kind' => $kind])->fetchAll(PDO::FETCH_ASSOC); + } + + public function addSubscriptions(string $kind, int $grantId, string $owner, string $folder, array $usernames): void + { + foreach ($usernames as $username) { + $this->query("INSERT IGNORE INTO `mailbox_group_subscription` (`kind`, `grant_id`, `username`, `owner`, `folder`) + VALUES (:kind, :grant, :username, :owner, :folder)", + ['kind' => $kind, 'grant' => $grantId, 'username' => $username, 'owner' => $owner, 'folder' => $folder]); + } + } + + public function deleteSubscription(int $id): void + { + $this->query("DELETE FROM `mailbox_group_subscription` WHERE `id` = :id", ['id' => $id]); + } + + /** + * Owners of pending SOGo grants on their personal folder that SOGo knows (active mailboxes), of one group or all. + * Their folders do not exist because they never logged in to SOGo, e.g. shared mailboxes. + * + * @return string[] + */ + public function pendingSogoOwners(?int $groupId): array + { + $params = []; + $where = ''; + if ($groupId !== null) { + $where = " AND g.`group_id` = :id"; + $params['id'] = $groupId; + } + return $this->query("SELECT DISTINCT g.`owner` FROM `mailbox_group_sogo_grant` g + JOIN `mailbox` mb ON mb.`username` = g.`owner` AND mb.`active` = '1' AND mb.`kind` = '' + WHERE g.`state` = 'pending' AND g.`folder` = 'personal'$where ORDER BY g.`owner`", $params)->fetchAll(PDO::FETCH_COLUMN); + } + + /** + * Side effects of the writes since the last call, for ChangeEffects after the commit. + * + * @return array{members: string[], sogo_acl: bool} + */ + public function takeEffects(): array + { + $effects = $this->effects; + $effects['members'] = array_values(array_unique($effects['members'])); + $this->effects = ['members' => [], 'sogo_acl' => false]; + return $effects; + } + + /** @return string[] */ + private function rebuildAll(array $ids, bool $sogo = true): array + { + $users = []; + foreach (array_unique($ids) as $id) { + $users = array_merge($users, $this->rebuild((int)$id, $sogo)); + } + return array_values(array_unique($users)); + } + + /** + * sogo_acl rows of the group's SOGo grants: one row per member (except the owner) and role while the group is + * active. Only rows of the ledger are ever deleted, so shares made in SOGo stay. A grant on a folder that does + * not exist yet (the owner never opened SOGo) stays pending. + */ + private function rebuildSogoAcl(int $id, bool $active, array $members): void + { + $this->cleanupSogoAcl(); + foreach ($this->query("SELECT * FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id", ['id' => $id])->fetchAll(PDO::FETCH_ASSOC) as $grant) { + $object = '/' . $grant['owner'] . '/' . $grant['folder_type'] . '/' . $grant['folder']; + $folderId = $this->query("SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path` = :path", + ['path' => '/Users' . $object])->fetchColumn(); + $wanted = []; + if ($folderId !== false && $active) { + foreach ($members as $username) { + foreach (($username === $grant['owner']) ? [] : self::SOGO_ROLES[$grant['folder_type']][$grant['access']] as $role) { + $wanted[$username . '|' . $role] = [$username, $role]; + } + } + } + $rows = $this->query("SELECT l.`acl_id`, l.`c_uid`, l.`c_role`, a.`c_folder_id`, a.`c_object` FROM `mailbox_group_sogo_acl` l + LEFT JOIN `sogo_acl` a ON a.`id` = l.`acl_id` + WHERE l.`grant_id` = :id", ['id' => $grant['id']])->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $key = $row['c_uid'] . '|' . $row['c_role']; + if (isset($wanted[$key]) && (string)$row['c_folder_id'] === (string)$folderId && $row['c_object'] === $object) { + unset($wanted[$key]); + continue; + } + $this->deleteSogoAcl((int)$row['acl_id']); + } + foreach ($wanted as [$username, $role]) { + $aclId = $this->insertRow('sogo_acl', ['c_folder_id' => $folderId, 'c_object' => $object, 'c_uid' => $username, 'c_role' => $role]); + $this->query("INSERT INTO `mailbox_group_sogo_acl` (`acl_id`, `grant_id`, `c_uid`, `c_role`) VALUES (:acl, :grant, :uid, :role)", + ['acl' => $aclId, 'grant' => $grant['id'], 'uid' => $username, 'role' => $role]); + $this->effects['sogo_acl'] = true; + } + $state = ($folderId === false) ? 'pending' : 'applied'; + if ($grant['state'] !== $state) { + $this->query("UPDATE `mailbox_group_sogo_grant` SET `state` = :state, `last_error` = :error WHERE `id` = :id", + ['state' => $state, 'error' => ($folderId === false) ? 'folder_missing' : null, 'id' => $grant['id']]); + } + } + } + + private function deleteSogoAcl(int $aclId): void + { + $this->query("DELETE FROM `sogo_acl` WHERE `id` = :id", ['id' => $aclId]); + $this->query("DELETE FROM `mailbox_group_sogo_acl` WHERE `acl_id` = :id", ['id' => $aclId]); + $this->effects['sogo_acl'] = true; + } + + private function fetch(string $where, array $params): ?Group + { + $row = $this->query("SELECT g.*, t.`template` AS `template_name` FROM `mailbox_group` g + LEFT JOIN `templates` t ON t.`id` = g.`template_id` + WHERE g.`id` = :id AND $where", $params)->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + $group = Group::fromRow($row); + $id = ['id' => $group->id]; + $group->members = $this->query("SELECT `username`, `source` FROM `mailbox_group_member` WHERE `group_id` = :id ORDER BY `username`", + $id)->fetchAll(PDO::FETCH_KEY_PAIR); + $group->sendAs = $this->query("SELECT `address` FROM `mailbox_group_send_as` WHERE `group_id` = :id ORDER BY `address`", + $id)->fetchAll(PDO::FETCH_COLUMN); + $group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]), + $this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant` + WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC)); + $group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]), + $this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error` + FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC)); + return $group; + } + + /** Members (username => source) as in the model; changed sources are updated, created stays */ + private function writeMembers(Group $group): void + { + $id = (int)$group->id; + $stored = $this->query("SELECT `username`, `source` FROM `mailbox_group_member` WHERE `group_id` = :id", ['id' => $id]) + ->fetchAll(PDO::FETCH_KEY_PAIR); + foreach (array_diff_key($stored, $group->members) as $username => $source) { + $this->query("DELETE FROM `mailbox_group_member` WHERE `group_id` = :id AND `username` = :username", + ['id' => $id, 'username' => $username]); + $this->removedMembers[] = (string)$username; + } + foreach ($group->members as $username => $source) { + if (!isset($stored[$username])) { + $this->query("INSERT INTO `mailbox_group_member` (`group_id`, `username`, `source`) VALUES (:id, :username, :source)", + ['id' => $id, 'username' => $username, 'source' => $source]); + } elseif ($stored[$username] !== $source) { + $this->query("UPDATE `mailbox_group_member` SET `source` = :source WHERE `group_id` = :id AND `username` = :username", + ['source' => $source, 'id' => $id, 'username' => $username]); + } + } + } + + /** + * IMAP grants: new ones are pending, removed ones are set to removing until Dovecot dropped the ACL + * (SharingService). SOGo grants are replaced; rebuild() maintains their sogo_acl rows. + */ + private function writeGrants(Group $group): void + { + $id = (int)$group->id; + $stored = []; + foreach ($this->query("SELECT `id`, `owner`, `rights`, `subscribe`, `state` FROM `mailbox_group_imap_grant` WHERE `group_id` = :id", + ['id' => $id])->fetchAll(PDO::FETCH_ASSOC) as $row) { + $stored[(string)$row['owner']] = $row; + } + $wanted = []; + foreach ($group->imapGrants as $grant) { + $wanted[(string)$grant['owner']] = $grant; + } + foreach ($stored as $owner => $row) { + if (!isset($wanted[$owner]) && $row['state'] !== 'removing') { + $this->setImapState((int)$row['id'], 'removing', null); + } + } + foreach ($wanted as $owner => $grant) { + $row = $stored[$owner] ?? null; + $subscribe = (int)($grant['subscribe'] ?? 0); + if ($row === null) { + $this->insertRow('mailbox_group_imap_grant', ['group_id' => $id, 'owner' => $owner, 'rights' => $grant['rights'], + 'subscribe' => $subscribe, 'state' => 'pending']); + continue; + } + if ($row['rights'] !== $grant['rights'] || $row['state'] === 'removing') { + $this->query("UPDATE `mailbox_group_imap_grant` SET `rights` = :rights, `state` = 'pending', `last_error` = NULL WHERE `id` = :id", + ['rights' => $grant['rights'], 'id' => $row['id']]); + } + if ((int)$row['subscribe'] !== $subscribe) { + $this->query("UPDATE `mailbox_group_imap_grant` SET `subscribe` = :subscribe WHERE `id` = :id", + ['subscribe' => $subscribe, 'id' => $row['id']]); + } + } + $stored = []; + foreach ($this->query("SELECT `id`, `owner`, `folder_type`, `folder`, `access`, `subscribe` FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id", + ['id' => $id])->fetchAll(PDO::FETCH_ASSOC) as $row) { + $stored[$row['owner'] . '|' . $row['folder_type'] . '|' . $row['folder']] = $row; + } + $wanted = []; + foreach ($group->sogoGrants as $grant) { + $wanted[$grant['owner'] . '|' . $grant['type'] . '|' . $grant['folder']] = $grant; + } + foreach (array_diff_key($stored, $wanted) as $row) { + $this->query("DELETE FROM `mailbox_group_sogo_grant` WHERE `id` = :id", ['id' => $row['id']]); + } + foreach ($wanted as $key => $grant) { + $subscribe = (int)($grant['subscribe'] ?? 0); + if (!isset($stored[$key])) { + $this->insertRow('mailbox_group_sogo_grant', ['group_id' => $id, 'owner' => $grant['owner'], 'folder_type' => $grant['type'], + 'folder' => $grant['folder'], 'access' => $grant['access'], 'subscribe' => $subscribe, 'state' => 'pending']); + } elseif ($stored[$key]['access'] !== $grant['access'] || (int)$stored[$key]['subscribe'] !== $subscribe) { + $this->query("UPDATE `mailbox_group_sogo_grant` SET `access` = :access, `subscribe` = :subscribe WHERE `id` = :id", + ['access' => $grant['access'], 'subscribe' => $subscribe, 'id' => $stored[$key]['id']]); + } + } + } + + private function writeSendAs(Group $group): void + { + $id = ['id' => $group->id]; + $this->query("DELETE FROM `mailbox_group_send_as` WHERE `group_id` = :id", $id); + foreach ($group->sendAs as $address) { + $this->query("INSERT INTO `mailbox_group_send_as` (`group_id`, `address`) VALUES (:id, :address)", $id + ['address' => $address]); + } + } + + /** + * The group's alias row as SOGo sees it: visible = active and sogo_visible (see the grouped_mail_aliases view). + * + * @return array{address: ?string, members: string[]} members only when visible + */ + private function aliasState(int $id): array + { + $row = $this->query("SELECT `address`, `goto`, `active`, `sogo_visible` FROM `alias` WHERE `group_id` = :id", + ['id' => $id])->fetch(PDO::FETCH_ASSOC); + $visible = $row && $row['active'] == 1 && $row['sogo_visible'] == 1; + return ['address' => $row['address'] ?? null, 'members' => $visible ? explode(',', (string)$row['goto']) : []]; + } + + /** + * WHERE fragment over `mailbox_group g` that limits rows to what the caller may see, and its parameters. + * + * @return array{0: string, 1: array} + */ + private function visibility(Context $context): array + { + if ($context->hasAdminRights()) { + return ['1=1', []]; + } + if ($context->isDomainAdmin()) { + return ["g.`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :vis_da)", + ['vis_da' => $context->username]]; + } + return ['0=1', []]; + } + + private function values(Group $group): array + { + return [ + 'domain' => $group->domain, + 'name' => $group->name, + 'description' => $group->description, + 'address' => $group->address, + 'sender_allowed' => $group->senderAllowed, + 'sogo_visible' => $group->sogoVisible, + 'internal' => $group->internal, + 'template_id' => $group->templateId, + 'priority' => $group->priority, + 'idp_ref' => $group->idpRef, + 'active' => $group->active, + ]; + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Repository/LookupRepository.php b/data/web/inc/lib/mailcow/MailboxGroup/Repository/LookupRepository.php new file mode 100644 index 000000000..58749098f --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Repository/LookupRepository.php @@ -0,0 +1,148 @@ +query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn(); + } + + public function isDomainOrAliasDomain(string $domain): bool + { + return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :d1 UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d2", + ['d1' => $domain, 'd2' => $domain])->fetchColumn(); + } + + /** An alias or mailbox address (mailboxes have an alias row of their own) */ + public function aliasExists(string $address): bool + { + return (bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address", ['address' => $address])->fetchColumn(); + } + + /** Used by an alias other than the group's own row, by a mailbox or resource, or as spam alias */ + public function addressTaken(string $address, ?int $groupId): bool + { + return (bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :a1 AND (`group_id` IS NULL OR `group_id` != :id) + UNION SELECT 1 FROM `mailbox` WHERE `username` = :a2 + UNION SELECT 1 FROM `spamalias` WHERE `address` = :a3", + ['a1' => $address, 'id' => $groupId ?? 0, 'a2' => $address, 'a3' => $address])->fetchColumn(); + } + + /** Aliases the domain may still create (same count as mailbox('get', 'domain_details')) */ + public function aliasesLeft(string $domain): int + { + $max = $this->query("SELECT `aliases` FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn(); + $used = $this->query("SELECT COUNT(`address`) FROM `alias` + WHERE (`domain` = :d1 OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :d2)) + AND `address` NOT IN (SELECT `username` FROM `mailbox`)", ['d1' => $domain, 'd2' => $domain])->fetchColumn(); + return (int)$max - (int)$used; + } + + /** + * @param string[] $usernames + * @return string[] the given usernames that are regular mailboxes (no resources) + */ + public function regularMailboxes(array $usernames): array + { + if (empty($usernames)) { + return []; + } + [$in, $params] = self::in('u', $usernames); + return $this->query("SELECT `username` FROM `mailbox` WHERE `kind` = '' AND `username` IN ($in)", $params)->fetchAll(PDO::FETCH_COLUMN); + } + + /** Name of a mailbox template, null if there is none with this id */ + public function templateName(int $id): ?string + { + $name = $this->query("SELECT `template` FROM `templates` WHERE `id` = :id AND `type` = 'mailbox'", ['id' => $id])->fetchColumn(); + return ($name === false) ? null : (string)$name; + } + + /** @return array username => full name */ + public function mailboxNames(array $usernames): array + { + if (empty($usernames)) { + return []; + } + [$in, $params] = self::in('u', $usernames); + return $this->query("SELECT `username`, `name` FROM `mailbox` WHERE `username` IN ($in)", $params)->fetchAll(PDO::FETCH_KEY_PAIR); + } + + /** + * Template of the active group with the highest priority (lowest id on ties) per member. + * + * @return array username => template name, null without group template + */ + public function effectiveTemplates(array $usernames): array + { + $result = array_fill_keys(array_map('strval', $usernames), null); + if (empty($usernames)) { + return $result; + } + [$in, $params] = self::in('u', $usernames); + $rows = $this->query("SELECT m.`username`, t.`template` FROM `mailbox_group_member` m + JOIN `mailbox_group` g ON g.`id` = m.`group_id` AND g.`active` = '1' + JOIN `templates` t ON t.`id` = g.`template_id` AND t.`type` = 'mailbox' + WHERE m.`username` IN ($in) + ORDER BY m.`username`, g.`priority` DESC, g.`id`", $params)->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $result[$row['username']] ??= $row['template']; + } + return $result; + } + + /** @return string[] members of active groups bound to the template */ + public function membersWithTemplate(int $templateId): array + { + return $this->query("SELECT DISTINCT m.`username` FROM `mailbox_group_member` m + JOIN `mailbox_group` g ON g.`id` = m.`group_id` AND g.`active` = '1' + WHERE g.`template_id` = :id", ['id' => $templateId])->fetchAll(PDO::FETCH_COLUMN); + } + + /** Groups of a mailbox with its membership source */ + public function membershipsOf(string $username): array + { + return $this->query("SELECT g.`id`, g.`domain`, g.`name`, g.`address`, g.`sender_allowed`, g.`active`, g.`priority`, + t.`template`, m.`source` FROM `mailbox_group_member` m + JOIN `mailbox_group` g ON g.`id` = m.`group_id` + LEFT JOIN `templates` t ON t.`id` = g.`template_id` + WHERE m.`username` = :username ORDER BY g.`domain`, g.`name`", ['username' => $username])->fetchAll(PDO::FETCH_ASSOC); + } + + /** @return string[] addresses a mailbox may send as through its active groups */ + public function sendAsOf(string $username): array + { + return $this->query("SELECT `send_as` FROM `sender_acl` WHERE `logged_in_as` = :u1 AND `group_id` IS NOT NULL + UNION SELECT g.`address` FROM `mailbox_group_member` m JOIN `mailbox_group` g ON g.`id` = m.`group_id` + WHERE m.`username` = :u2 AND g.`active` = '1' AND g.`sender_allowed` = '1' AND g.`address` IS NOT NULL + ORDER BY 1", ['u1' => $username, 'u2' => $username])->fetchAll(PDO::FETCH_COLUMN); + } + + /** + * IN list with one placeholder per value (native prepares allow a placeholder only once). + * + * @return array{0: string, 1: array} + */ + private static function in(string $prefix, array $values): array + { + $names = []; + $params = []; + foreach (array_values($values) as $i => $value) { + $names[] = ':' . $prefix . $i; + $params[$prefix . $i] = (string)$value; + } + return [implode(', ', $names), $params]; + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Service/AccountHooks.php b/data/web/inc/lib/mailcow/MailboxGroup/Service/AccountHooks.php new file mode 100644 index 000000000..2f805d3f1 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Service/AccountHooks.php @@ -0,0 +1,70 @@ +repo->groupIdsOfAccount($username); + if (!empty($ids)) { + // The deleted mailbox itself needs no SOGo refresh + $this->effects->apply(array_diff($this->repo->removeAccount($username, $ids), [$username]), []); + } + } + + /** + * Inside the legacy mailbox rename transaction (FOREIGN_KEY_CHECKS = 0): members and grants follow. + * Must not catch, so the caller can roll back. The legacy rename refreshes SOGo itself. + */ + public function onMailboxRenamed(string $old, string $new): void + { + $ids = $this->repo->groupIdsOfAccount($old); + if (!empty($ids)) { + $this->repo->renameAccount($old, $new, $ids); + } + } + + /** + * After a domain or alias domain was deleted: grants on its addresses go. Its groups went via FK cascade, + * the sogo_acl rows of their SOGo grants go here. + */ + public function onDomainDeleted(string $domain): void + { + $ids = $this->repo->groupIdsWithSendAsIn($domain); + $users = empty($ids) ? [] : $this->repo->removeSendAsIn($domain, $ids); + $this->repo->cleanupSogoAcl(); + $this->effects->apply($users, []); + } + + /** An alias was deleted: grants on its address go */ + public function onAddressDeleted(string $address): void + { + $ids = $this->repo->groupIdsOfAccount($address); + if (!empty($ids)) { + $this->effects->apply($this->repo->removeSendAs($address, $ids), []); + } + } + + /** An alias address changed: grants follow */ + public function onAddressRenamed(string $old, string $new): void + { + $ids = $this->repo->groupIdsOfAccount($old); + if (!empty($ids) && $old !== $new) { + $this->effects->apply($this->repo->renameAccount($old, $new, $ids), []); + } + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Service/ChangeEffects.php b/data/web/inc/lib/mailcow/MailboxGroup/Service/ChangeEffects.php new file mode 100644 index 000000000..5a975c810 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Service/ChangeEffects.php @@ -0,0 +1,40 @@ +templates->effective($usernames); + } + + /** @return array[] warnings (msg values) */ + public function apply(array $sogoUsers, array $snapshot): array + { + $this->sogo->refreshStaticView($sogoUsers); + $effects = $this->repo->takeEffects(); + if ($effects['sogo_acl']) { + $this->sogo->flushCache(); + } + $this->dovecot->flushAuthCache($effects['members']); + return $this->templates->applyChanged($snapshot); + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Service/GroupService.php b/data/web/inc/lib/mailcow/MailboxGroup/Service/GroupService.php new file mode 100644 index 000000000..3780a1b40 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Service/GroupService.php @@ -0,0 +1,485 @@ +context->hasAdminRights() + || ($this->context->isDomainAdmin() && $this->context->hasAcl('mailbox_groups')); + } + + /** @throws AccessDenied unless the caller may edit groups at all */ + public function requireEditor(): void + { + if (!$this->canRead() || $this->context->isReadOnlyApi()) { + throw new AccessDenied(); + } + } + + public function get(int $id): ?Group + { + return $this->canRead() ? $this->repo->findVisible($id, $this->context) : null; + } + + /** @return int[] */ + public function visibleIds(array $domains = []): array + { + return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : []; + } + + public function canEdit(Group $group): bool + { + if ($this->context->isReadOnlyApi() || !$this->canRead()) { + return false; + } + if ($this->context->hasAdminRights()) { + return true; + } + if (!$this->context->canAccessDomain($group->domain)) { + return false; + } + // Grants on foreign domains or mailboxes make the group read-only for a domain admin + foreach ($group->sendAs as $address) { + if (!$this->context->canAccessDomain(self::domainOf($address))) { + return false; + } + } + foreach (array_merge($group->imapGrants, $group->sogoGrants) as $grant) { + if (!$this->context->canAccessMailbox((string)$grant['owner'])) { + return false; + } + } + return true; + } + + public function toArray(Group $group): array + { + return $group->toArray($this->canEdit($group)); + } + + /** @throws UserError */ + public function create(array $input): Group + { + $this->requireEditor(); + $group = new Group(); + $group->domain = $this->domainFor($input['domain'] ?? null); + $this->apply($group, $input, null); + $snapshot = $this->effects->snapshot($group->usernames()); + $sogo = $this->repo->insert($group); + $group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot), + $this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id)); + return $group; + } + + /** + * Updates a group; attributes missing in $input keep their value. `reapply_template` (admin) applies the + * effective templates to all members again. + * + * @throws UserError + */ + public function update(int $id, array $input): Group + { + $this->requireEditor(); + $current = $this->editable($id); + $group = clone $current; + $this->apply($group, $input, $current); + $snapshot = $this->effects->snapshot(array_merge($current->usernames(), $group->usernames())); + $sogo = $this->repo->update($group); + $group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot), + $this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id)); + if ($this->context->hasAdminRights() && self::flag($input['reapply_template'] ?? 0) === 1) { + $group->warnings = array_merge($group->warnings, $this->templates->applyTo($group->usernames())); + } + return $group; + } + + /** + * Deletes a group and returns it. Members keep their settings; another group's template may now apply. + * + * @throws UserError + */ + public function delete(int $id): Group + { + $this->requireEditor(); + $group = $this->editable($id); + $snapshot = $this->effects->snapshot($group->usernames()); + $this->sharing->removeImap($group); + $sogo = $this->repo->delete($id); + $group->warnings = $this->effects->apply($sogo, $snapshot); + return $group; + } + + /** + * Groups of a mailbox, its effective group template and the addresses it may send as through groups. + * For the mailbox itself and for admins/domain admins with access to it. + */ + public function membershipsOf(string $username): array + { + $username = strtolower(trim($username)); + $own = ($this->context->isUser() && $this->context->username === $username); + if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) { + return []; + } + return [ + 'groups' => $this->lookup->membershipsOf($username), + 'template' => $this->templates->effective([$username])[$username] ?? null, + 'send_as' => $this->lookup->sendAsOf($username), + ]; + } + + /** @throws AccessDenied when the caller cannot see or edit the group */ + private function editable(int $id): Group + { + $group = $this->get($id); + if ($group === null || !$this->canEdit($group)) { + throw new AccessDenied(); + } + return $group; + } + + /** @throws UserError domain_not_found, access_denied */ + private function domainFor(mixed $value): string + { + $domain = Input::text($value); + $domain = ($domain === null || $domain === '') ? '' : (string)idn_to_ascii(strtolower($domain), 0, INTL_IDNA_VARIANT_UTS46); + if ($domain === '' || !$this->lookup->isDomain($domain)) { + throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]); + } + if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) { + throw new AccessDenied(); + } + return $domain; + } + + /** + * Add/edit rules shared by create() and update(): attributes missing in $input keep the value of $group, + * a new group starts with the model defaults. Everything is validated before anything is written. + * + * @throws UserError + */ + private function apply(Group $group, array $input, ?Group $current): void + { + $name = isset($input['name']) ? Input::text($input['name']) : $group->name; + if ($name === null || $name === '' || mb_strlen($name) > 100) { + throw new UserError('group_name_invalid'); + } + $description = isset($input['description']) ? Input::text($input['description']) : $group->description; + if ($description === null || mb_strlen($description) > 255) { + throw new UserError('group_description_invalid'); + } + if ($this->repo->nameExists($group->domain, $name, $group->id)) { + throw new UserError('object_exists', [htmlspecialchars($name)]); + } + $group->name = $name; + $group->description = $description; + foreach (['sender_allowed' => 'senderAllowed', 'sogo_visible' => 'sogoVisible', 'internal' => 'internal', 'active' => 'active'] as $key => $property) { + if (isset($input[$key])) { + $group->$property = self::flag($input[$key]); + } + } + $this->applyAddress($group, $input, $current); + $this->applyMembers($group, $input); + $this->applySendAs($group, $input); + $this->applyGrants($group, $input); + if ($this->context->hasAdminRights()) { + $this->applyAdminFields($group, $input); + } + } + + /** @throws UserError */ + private function applyAddress(Group $group, array $input, ?Group $current): void + { + if (array_key_exists('address', $input)) { + $value = Input::text($input['address']); + } elseif (array_key_exists('local_part', $input)) { + $local = Input::text($input['local_part']); + $value = ($local === null || $local === '') ? $local : $local . '@' . $group->domain; + } else { + return; + } + if ($value === '') { + $group->address = null; + return; + } + $address = ($value === null) ? null : self::normalizeAddress($value); + if ($address === null || self::domainOf($address) !== $group->domain) { + throw new UserError('group_address_invalid', [htmlspecialchars((string)$value)]); + } + $group->address = $address; + if ($address === $current?->address) { + return; + } + if ($this->lookup->addressTaken($address, $group->id)) { + throw new UserError('is_alias_or_mailbox', [htmlspecialchars($address)]); + } + // A group with an address counts like an alias of the domain + if ($current?->address === null && $this->lookup->aliasesLeft($group->domain) < 1) { + throw new UserError('max_alias_exceeded'); + } + } + + /** + * `members` replaces the manual members the caller may access, `add_members` / `remove_members` change them. + * + * @throws UserError group_member_invalid, group_member_idp, access_denied + */ + private function applyMembers(Group $group, array $input): void + { + $members = $group->members; + $add = []; + if (array_key_exists('members', $input)) { + $add = self::cleanList($input['members']); + foreach ($members as $username => $source) { + if ($source === Group::SOURCE_MANUAL && !in_array((string)$username, $add, true) && $this->context->canAccessMailbox((string)$username)) { + unset($members[$username]); + } + } + } + $add = array_merge($add, self::cleanList($input['add_members'] ?? [])); + foreach (self::cleanList($input['remove_members'] ?? []) as $username) { + if (!isset($members[$username])) { + continue; + } + if ($members[$username] === Group::SOURCE_IDP) { + throw new UserError('group_member_idp', [htmlspecialchars($username)]); + } + if (!$this->context->canAccessMailbox($username)) { + throw new AccessDenied(); + } + unset($members[$username]); + } + $new = array_values(array_diff(array_unique($add), array_map('strval', array_keys($members)))); + $regular = $this->lookup->regularMailboxes($new); + foreach ($new as $username) { + if (!in_array($username, $regular, true)) { + throw new UserError('group_member_invalid', [htmlspecialchars($username)]); + } + if (!$this->context->canAccessMailbox($username)) { + throw new AccessDenied(); + } + $members[$username] = Group::SOURCE_MANUAL; + } + ksort($members); + $group->members = $members; + } + + /** + * Addresses (mailboxes or aliases) and @domain the members may send as; the editor needs access to their domains. + * + * @throws UserError group_send_as_invalid, access_denied + */ + private function applySendAs(Group $group, array $input): void + { + if (!array_key_exists('send_as', $input)) { + return; + } + $sendAs = []; + foreach (self::cleanList($input['send_as']) as $value) { + if (str_starts_with($value, '@')) { + $domain = (string)idn_to_ascii(substr($value, 1), 0, INTL_IDNA_VARIANT_UTS46); + $address = '@' . $domain; + $valid = ($domain !== '' && $this->lookup->isDomainOrAliasDomain($domain)); + } else { + $address = self::normalizeAddress($value); + // The group's own address is covered by sender_allowed + $valid = ($address !== null && $address !== $group->address && $this->lookup->aliasExists($address)); + } + if (!$valid) { + throw new UserError('group_send_as_invalid', [htmlspecialchars($value)]); + } + if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf((string)$address))) { + throw new AccessDenied(); + } + $sendAs[] = (string)$address; + } + $sendAs = array_values(array_unique($sendAs)); + sort($sendAs); + $group->sendAs = $sendAs; + } + + /** + * `imap_grants` [{owner, rights, subscribe}] and `sogo_grants` [{owner, type, folder, access, subscribe}] replace + * the grants; the editor needs access to every owner mailbox. + * + * @throws UserError group_grant_invalid, access_denied + */ + private function applyGrants(Group $group, array $input): void + { + if (array_key_exists('imap_grants', $input)) { + $grants = []; + foreach (self::grantList($input['imap_grants']) as $grant) { + $owner = $this->grantOwner($grant); + $rights = Input::text($grant['rights'] ?? 'read'); + if (!isset(SharingService::IMAP_RIGHTS[$rights])) { + throw new UserError('group_grant_invalid', [htmlspecialchars($owner)]); + } + $grants[$owner] = ['owner' => $owner, 'rights' => $rights, 'subscribe' => self::flag($grant['subscribe'] ?? 0)]; + } + ksort($grants); + $group->imapGrants = array_values($grants); + } + if (array_key_exists('sogo_grants', $input)) { + $grants = []; + foreach (self::grantList($input['sogo_grants']) as $grant) { + $owner = $this->grantOwner($grant); + $type = Input::text($grant['type'] ?? 'Calendar'); + $folder = Input::text($grant['folder'] ?? '') ?: 'personal'; + $access = Input::text($grant['access'] ?? 'read'); + if (!isset(GroupRepository::SOGO_ROLES[$type][$access]) || !preg_match('/^[A-Za-z0-9._-]{1,100}$/', $folder)) { + throw new UserError('group_grant_invalid', [htmlspecialchars($owner)]); + } + $grants[$owner . '|' . $type . '|' . $folder] = ['owner' => $owner, 'type' => $type, 'folder' => $folder, 'access' => $access, + 'subscribe' => self::flag($grant['subscribe'] ?? 0)]; + } + ksort($grants); + $group->sogoGrants = array_values($grants); + } + } + + /** @throws UserError group_grant_invalid, access_denied */ + private function grantOwner(array $grant): string + { + $owner = strtolower((string)Input::text($grant['owner'] ?? '')); + if ($owner === '' || empty($this->lookup->regularMailboxes([$owner]))) { + throw new UserError('group_grant_invalid', [htmlspecialchars($owner)]); + } + if (!$this->context->canAccessMailbox($owner)) { + throw new AccessDenied(); + } + return $owner; + } + + /** + * Grant objects of a JSON body or of a form field (JSON string) + * + * @throws UserError group_grant_invalid + */ + private static function grantList(mixed $value): array + { + if (is_string($value)) { + $value = ($value === '') ? [] : json_decode($value, true); + } + if (!is_array($value)) { + throw new UserError('group_grant_invalid', ['']); + } + foreach ($value as $grant) { + if (!is_array($grant)) { + throw new UserError('group_grant_invalid', ['']); + } + } + return array_values($value); + } + + /** @throws UserError template_id_invalid, group_priority_invalid */ + private function applyAdminFields(Group $group, array $input): void + { + if (array_key_exists('template_id', $input)) { + $id = $input['template_id']; + if ($id === null || $id === '' || $id === '0' || $id === 0) { + $group->templateId = null; + $group->template = null; + } else { + $templateId = Input::intInRange($id, 1, PHP_INT_MAX); + $name = ($templateId === null) ? null : $this->lookup->templateName($templateId); + if ($name === null) { + throw new UserError('template_id_invalid', [htmlspecialchars((string)Input::text($id))]); + } + $group->templateId = $templateId; + $group->template = $name; + } + } + if (isset($input['priority'])) { + $priority = Input::intInRange($input['priority'], -1000000, 1000000); + if ($priority === null) { + throw new UserError('group_priority_invalid'); + } + $group->priority = $priority; + } + if (array_key_exists('idp_ref', $input)) { + $ref = Input::text($input['idp_ref']); + if ($ref === null || mb_strlen($ref) > 255) { + throw new AccessDenied(); + } + $group->idpRef = ($ref === '') ? null : $ref; + } + } + + /** @return array[] */ + private function sizeWarnings(Group $group): array + { + if ($group->address !== null && count($group->members) > self::MAX_RECIPIENTS) { + return [['group_too_many_members', htmlspecialchars($group->name), self::MAX_RECIPIENTS]]; + } + return []; + } + + /** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */ + private static function flag(mixed $value): int + { + if (is_array($value)) { + $value = end($value); + } + return filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 1 : 0; + } + + /** @return string[] trimmed, lowercase, unique entries of an array or of a string separated by spaces, commas, semicolons or newlines */ + private static function cleanList(mixed $value): array + { + $values = is_array($value) ? $value : preg_split('/[\s,;]+/', is_scalar($value) ? (string)$value : ''); + $clean = []; + foreach ($values as $item) { + if (is_scalar($item) && trim((string)$item) !== '') { + $clean[] = strtolower(trim((string)$item)); + } + } + return array_values(array_unique($clean)); + } + + /** Lowercase address with ASCII (IDN) domain, null if invalid */ + private static function normalizeAddress(string $value): ?string + { + $value = strtolower(trim($value)); + $at = strrpos($value, '@'); + if ($at === false || $at === 0) { + return null; + } + $domain = idn_to_ascii(substr($value, $at + 1), 0, INTL_IDNA_VARIANT_UTS46); + $address = substr($value, 0, $at) . '@' . $domain; + return ($domain !== false && filter_var($address, FILTER_VALIDATE_EMAIL)) ? $address : null; + } + + private static function domainOf(string $address): string + { + return substr($address, strrpos($address, '@') + 1); + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Service/IdpSyncService.php b/data/web/inc/lib/mailcow/MailboxGroup/Service/IdpSyncService.php new file mode 100644 index 000000000..1f2711068 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Service/IdpSyncService.php @@ -0,0 +1,124 @@ +repo->findByIdpRef($ref); + } + + /** @return array[] every group with its members, see GroupRepository::syncState() */ + public function groups(): array + { + return $this->repo->syncState(); + } + + /** + * Creates the group of an IdP group, members follow with syncGroupMembers(). The group mail becomes the + * distribution address when it is in the group's domain and free. + * + * @return array[] warnings (msg values) + * @throws UserError + */ + public function createFromIdp(string $ref, string $name, string $domain, ?string $mail): array + { + $name = $this->validName($domain, $name, null); + if (!$this->lookup->isDomain($domain)) { + throw new UserError('domain_not_found', [htmlspecialchars($domain)]); + } + $group = new Group(); + $group->domain = $domain; + $group->name = $name; + $group->idpRef = $ref; + $warnings = []; + if ($mail !== null && filter_var($mail, FILTER_VALIDATE_EMAIL) && str_ends_with($mail, '@' . $domain)) { + if ($this->lookup->addressTaken($mail, null) || $this->lookup->aliasesLeft($domain) < 1) { + $warnings[] = ['group_idp_address_skipped', htmlspecialchars($mail), htmlspecialchars($name)]; + } else { + $group->address = $mail; + } + } + $this->repo->insert($group); + return $warnings; + } + + /** @throws UserError */ + public function renameFromIdp(int $groupId, string $name): void + { + $group = $this->repo->find($groupId); + if ($group === null) { + return; + } + $group->name = $this->validName($group->domain, $name, $groupId); + $this->repo->update($group); + } + + /** + * Sets the IdP members of a group. Usernames that are no regular mailbox are skipped; a manual member that + * the IdP also lists stays manual. + * + * @return array{added: string[], removed: string[], skipped: string[], warnings: array[]} + */ + public function syncGroupMembers(int $groupId, array $usernames): array + { + $result = ['added' => [], 'removed' => [], 'skipped' => [], 'warnings' => []]; + $group = $this->repo->find($groupId); + if ($group === null) { + return $result; + } + $wanted = array_values(array_unique(array_map(fn(mixed $u): string => strtolower(trim((string)$u)), $usernames))); + $regular = $this->lookup->regularMailboxes($wanted); + $result['skipped'] = array_values(array_diff($wanted, $regular)); + $members = $group->members; + foreach ($members as $username => $source) { + if ($source === Group::SOURCE_IDP && !in_array((string)$username, $regular, true)) { + unset($members[$username]); + $result['removed'][] = (string)$username; + } + } + foreach ($regular as $username) { + if (!isset($members[$username])) { + $members[$username] = Group::SOURCE_IDP; + $result['added'][] = $username; + } + } + if (empty($result['added']) && empty($result['removed'])) { + return $result; + } + ksort($members); + $snapshot = $this->effects->snapshot(array_merge($group->usernames(), array_keys($members))); + $group->members = $members; + $result['warnings'] = $this->effects->apply($this->repo->update($group), $snapshot); + return $result; + } + + /** @throws UserError */ + private function validName(string $domain, string $name, ?int $groupId): string + { + $name = trim($name); + if ($name === '' || mb_strlen($name) > 100) { + throw new UserError('group_name_invalid'); + } + if ($this->repo->nameExists($domain, $name, $groupId)) { + throw new UserError('object_exists', [htmlspecialchars($name)]); + } + return $name; + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Service/SharingService.php b/data/web/inc/lib/mailcow/MailboxGroup/Service/SharingService.php new file mode 100644 index 000000000..1453c3851 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Service/SharingService.php @@ -0,0 +1,169 @@ + on every folder of the owner; members get the group through + * the userdb field acl_groups, so membership changes need no ACL change. Dovecot is reached through dockerapi + * after the commit; subfolders created later inherit the ACL, new top-level folders and failed grants are + * handled by reconcile() (cron). + * SOGo: sogo_acl rows per member, maintained by GroupRepository::rebuild(); missing personal folders of owners that + * never logged in to SOGo (shared mailboxes) are created with sogo-tool init-user. + * Grants with `subscribe` are subscribed for each member once by syncSubscriptions(); a member that removes the + * subscription keeps it removed, members that lose the access are unsubscribed. + */ +class SharingService +{ + /** Dovecot rights per grant level */ + public const IMAP_RIGHTS = [ + 'read' => ['lookup', 'read', 'write-seen'], + 'write' => ['lookup', 'read', 'write-seen', 'write', 'write-deleted', 'insert', 'post', 'expunge', 'create'], + 'full' => ['lookup', 'read', 'write-seen', 'write', 'write-deleted', 'insert', 'post', 'expunge', 'create', 'delete'], + ]; + + public function __construct(private GroupRepository $repo, private DovecotService $dovecot, private SogoService $sogo, + private ChangeEffects $effects) + { + } + + /** Group name in the userdb field acl_groups (dovecot docker-entrypoint.sh) */ + public static function aclGroup(int $groupId): string + { + return 'mcg_' . $groupId; + } + + /** + * Applies the IMAP grants Dovecot does not have yet (pending, failed, removing); $all also applies the applied + * ones again. Of one group or all. + * + * @return array[] warnings (msg values) + */ + public function applyImap(?int $groupId, bool $all = false): array + { + $warnings = []; + foreach ($this->repo->imapGrantsToApply($groupId, $all) as $grant) { + $removing = ($grant['state'] === 'removing'); + $error = $this->dovecot->setAclAll((string)$grant['owner'], 'group=' . self::aclGroup((int)$grant['group_id']), + $removing ? [] : self::IMAP_RIGHTS[$grant['rights']]); + if ($error === null && $removing) { + $this->repo->deleteImapGrant((int)$grant['id']); + continue; + } + if ($error === null) { + if ($grant['state'] !== 'applied' || $grant['last_error'] !== null) { + $this->repo->setImapState((int)$grant['id'], 'applied', null); + } + continue; + } + $this->repo->setImapState((int)$grant['id'], $removing ? 'removing' : 'failed', $error); + $warnings[] = ['group_imap_grant_failed', htmlspecialchars((string)$grant['owner'])]; + } + return $warnings; + } + + /** After a group write: creates missing SOGo folders of its grant owners and applies the pending grants to them */ + public function applySogo(int $groupId): array + { + $warnings = $this->initSogoFolders($groupId); + // null: no grant waits for a folder, warnings: the folders could not be created + if ($warnings === null || $warnings !== []) { + return $warnings ?? []; + } + return $this->effects->apply($this->repo->rebuild($groupId), []); + } + + /** Before a group is deleted: its Dovecot ACLs go (best effort, the ACL of a deleted group grants nothing) */ + public function removeImap(Group $group): void + { + foreach ($group->imapGrants as $grant) { + $this->dovecot->setAclAll((string)$grant['owner'], 'group=' . self::aclGroup((int)$group->id), []); + } + } + + /** + * Cron: applies all IMAP grants again (new top-level folders, failed grants), rebuilds the SOGo grants + * (owners that opened SOGo meanwhile, rows of removed grants) and syncs the SOGo subscriptions. + * + * @return array[] warnings (msg values) + */ + public function reconcile(): array + { + $warnings = array_merge($this->applyImap(null, true), $this->initSogoFolders(null) ?? []); + $users = []; + foreach ($this->repo->groupIdsWithSogoGrants() as $id) { + $users = array_merge($users, $this->repo->rebuild($id)); + } + $this->repo->cleanupSogoAcl(); + $warnings = array_merge($warnings, $this->effects->apply(array_values(array_unique($users)), [])); + return array_merge($warnings, $this->syncSubscriptions()); + } + + /** + * Unsubscribes members that lost the access to a folder mailcow subscribed them to, then subscribes the members + * of grants with `subscribe` that have no subscription by mailcow yet. Cheap without pending changes, the cron + * runs it every minute. + * + * @return array[] warnings (msg values) + */ + public function syncSubscriptions(): array + { + $warnings = []; + $sogoChanged = false; + foreach (['imap', 'sogo'] as $kind) { + foreach ($this->repo->staleSubscriptions($kind) as $row) { + // A deleted member has no subscriptions left, a deleted owner no folders; another group may still grant it + if ($row['member_exists'] && $row['owner_exists'] && !$row['covered']) { + if ($this->subscribe($kind, (string)$row['owner'], (string)$row['folder'], [(string)$row['username']], true) !== null) { + $warnings[] = ['group_subscription_failed', htmlspecialchars((string)$row['username'])]; + continue; + } + $sogoChanged = $sogoChanged || ($kind === 'sogo'); + } + $this->repo->deleteSubscription((int)$row['id']); + } + foreach ($this->repo->missingSubscriptions($kind) as $grant) { + if ($this->subscribe($kind, $grant['owner'], $grant['folder'], $grant['usernames']) !== null) { + $warnings[] = ['group_subscription_failed', htmlspecialchars(implode(', ', $grant['usernames']))]; + continue; + } + $this->repo->addSubscriptions($kind, $grant['grant_id'], $grant['owner'], $grant['folder'], $grant['usernames']); + $sogoChanged = $sogoChanged || ($kind === 'sogo'); + } + } + if ($sogoChanged) { + $this->sogo->flushCache(); + } + return $warnings; + } + + /** @return ?string null on success, else the error */ + private function subscribe(string $kind, string $owner, string $folder, array $usernames, bool $unsubscribe = false): ?string + { + return ($kind === 'imap') ? $this->dovecot->subscribeShared($owner, $usernames, $unsubscribe) + : $this->sogo->subscribe($owner, $folder, $usernames, $unsubscribe); + } + + /** + * Creates the personal SOGo folders of grant owners that never logged in to SOGo, so their pending grants can + * be applied. + * + * @return ?array[] warnings, null if no grant was waiting for a folder + */ + private function initSogoFolders(?int $groupId): ?array + { + $owners = $this->repo->pendingSogoOwners($groupId); + if (empty($owners)) { + return null; + } + return ($this->sogo->initUsers($owners) === null) ? [] : [['group_sogo_init_failed', htmlspecialchars(implode(', ', $owners))]]; + } +} diff --git a/data/web/inc/lib/mailcow/MailboxGroup/Service/TemplateService.php b/data/web/inc/lib/mailcow/MailboxGroup/Service/TemplateService.php new file mode 100644 index 000000000..af8f0f028 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailboxGroup/Service/TemplateService.php @@ -0,0 +1,83 @@ + username => effective group template */ + public function effective(array $usernames): array + { + return $this->lookup->effectiveTemplates(array_values(array_unique($usernames))); + } + + /** + * Applies the effective template to the mailboxes whose effective template differs from $before + * (a snapshot of effective() taken before the change). + * + * @return array[] warnings (msg values) + */ + public function applyChanged(array $before): array + { + $changed = []; + foreach ($this->effective(array_keys($before)) as $username => $template) { + if ($template !== null && $template !== $before[$username]) { + $changed[$username] = $template; + } + } + return $this->apply($changed); + } + + /** Applies the effective templates again, e.g. after manual changes (admin action) */ + public function applyTo(array $usernames): array + { + return $this->apply(array_filter($this->effective($usernames), fn(?string $template): bool => $template !== null)); + } + + /** A template was edited: apply it to the mailboxes it is the effective template of */ + public function onTemplateChanged(int $templateId): array + { + $name = $this->lookup->templateName($templateId); + if ($name === null) { + return []; + } + return $this->apply(array_filter($this->effective($this->lookup->membersWithTemplate($templateId)), + fn(?string $template): bool => $template === $name)); + } + + /** Template for IdP logins and syncs: the effective group template, else the legacy attribute mapping */ + public function templateForLogin(string $username, ?string $legacy): ?string + { + return $this->effective([$username])[$username] ?? $legacy; + } + + /** + * @param array $templates username => template + * @return array[] warnings (msg values) + */ + private function apply(array $templates): array + { + $warnings = []; + $names = $this->lookup->mailboxNames(array_keys($templates)); + foreach ($templates as $username => $template) { + $error = $this->legacy->applyTemplate((string)$username, (string)($names[$username] ?? ''), $template); + if ($error !== null) { + $warnings[] = ['group_template_failed', htmlspecialchars($template), htmlspecialchars((string)$username)]; + } + } + return $warnings; + } +} diff --git a/data/web/inc/lib/mailcow/README.md b/data/web/inc/lib/mailcow/README.md index 7bf2fb2e7..4eb89aaa6 100644 --- a/data/web/inc/lib/mailcow/README.md +++ b/data/web/inc/lib/mailcow/README.md @@ -30,6 +30,15 @@ Core/ shared by all modules Example: `Imapsync/` (sync jobs, sync sources, OAuth). Use it as the template for new modules. `ApiKey/` (API keys incl. the service keys of mailcow.conf) is a small module with the same layout. +`MailboxGroup/` (mailbox groups) keeps its own tables as source of truth and maintains rows the legacy code and +Postfix read (`alias`, `sender_acl`, tagged with `group_id`); calls into procedural code go through `Legacy/`. +Shares become Dovecot ACLs on `group=mcg_` (userdb `acl_groups`) and `sogo_acl` rows tracked in a ledger. +`Dovecot/` (doveadm through dockerapi: ACLs, subscriptions, auth cache) and `Sogo/` (user view, cache, sogo-tool +through dockerapi) are used by other modules instead of calling `docker()` or the SOGo functions themselves. +`Identity/` (IdP links, sync runs, offboarding) lists Keycloak/LDAP through `Directory/` adapters, decides in the +side-effect free `SyncPlanner` and is run by the `identity-sync.php` cron. +`Auth/` (logins) holds for now the IdP login steps the legacy login flows call (link checks fail closed, group +template before the attribute mapping); the auth logic of the function files moves here step by step. ## Layers and rules diff --git a/data/web/inc/lib/mailcow/Sogo/Service/SogoService.php b/data/web/inc/lib/mailcow/Sogo/Service/SogoService.php new file mode 100644 index 000000000..530031470 --- /dev/null +++ b/data/web/inc/lib/mailcow/Sogo/Service/SogoService.php @@ -0,0 +1,80 @@ + self::SINGLE_UPDATES) { + $this->updateStaticView(null); + return; + } + foreach ($usernames as $username) { + $this->updateStaticView($username); + } + } + + /** SOGo caches ACLs and user settings in memcached */ + public function flushCache(): void + { + $this->flushMemcached(); + } + + /** + * Subscribes the users to a folder of the owner (sogo-tool manage-acl), or unsubscribes them. + * + * @param string $folder Calendar/ or Contacts/ + * @return ?string null on success, else the error of dockerapi + */ + public function subscribe(string $owner, string $folder, array $usernames, bool $unsubscribe = false): ?string + { + return self::error($this->exec(['cmd' => 'sogo', 'task' => 'subscribe', 'owner' => $owner, 'folder' => $folder, + 'users' => array_values($usernames), 'unsubscribe' => $unsubscribe])); + } + + /** + * Creates the personal folders of users that never logged in to SOGo (sogo-tool init-user). + * + * @return ?string null on success, else the error of dockerapi + */ + public function initUsers(array $usernames): ?string + { + return self::error($this->exec(['cmd' => 'sogo', 'task' => 'init_user', 'users' => array_values($usernames)])); + } + + protected function updateStaticView(?string $username): void + { + update_sogo_static_view($username); + } + + protected function flushMemcached(): void + { + flush_memcached(); + } + + protected function exec(array $fields): mixed + { + return docker('post', 'sogo-mailcow', 'exec', $fields); + } + + private static function error(mixed $response): ?string + { + $result = json_decode((string)$response, true); + return (($result['type'] ?? '') === 'success') ? null : (string)($result['msg'] ?? 'dockerapi did not answer'); + } +} diff --git a/data/web/inc/lib/mailcow/Sogo/SogoModule.php b/data/web/inc/lib/mailcow/Sogo/SogoModule.php new file mode 100644 index 000000000..346aaf488 --- /dev/null +++ b/data/web/inc/lib/mailcow/Sogo/SogoModule.php @@ -0,0 +1,22 @@ + ' : '') + esc(m.username); + }).join('
'); + if (members.length > 3) list += '
+' + (members.length - 3) + ''; + return '' + members.length + '
' + list; + } + + function addressCell(item) { + if (!item.address) return '-'; + var flags = []; + if (item.sender_allowed == 1) flags.push(''); + if (item.internal == 1) flags.push(''); + return esc(item.address) + (flags.length ? ' ' + flags.join(' ') : ''); + } + + function drawTable() { + if ($.fn.DataTable.isDataTable('#mailbox_group_table')) { + $('#mailbox_group_table').DataTable().columns.adjust().responsive.recalc(); + return; + } + $('#mailbox_group_table').DataTable({ + responsive: true, + processing: true, + serverSide: false, + stateSave: true, + pageLength: pagination_size, + dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + "tr" + + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", + language: lang_datatables, + order: [[3, 'asc']], + ajax: { + type: "GET", + url: "/api/v1/get/mailbox-group/all", + dataSrc: function(json) { + if (!Array.isArray(json)) return []; + $.each(json, function(i, item) { + item.chkbox = item.can_edit ? + '' : + ''; + item.name_display = esc(item.name) + (item.idp_ref ? ' ' + esc(L('idp')) + '' : ''); + if (item.description) item.name_display += '
' + esc(item.description) + '
'; + item.address_display = addressCell(item); + item.members_display = membersCell(item.members); + item.send_as_display = (item.send_as || []).map(esc).join('
') || '-'; + item.template_display = item.template ? esc(item.template) + ' (' + esc(item.priority) + ')' : '-'; + item.action = ''; + }); + return json; + }, + error: function() { + mailcow_alert_box(L('list_error'), 'danger'); + } + }, + columns: [ + { + // placeholder, so checkbox will not block child row toggle + title: '', + data: null, + searchable: false, + orderable: false, + defaultContent: '', + responsivePriority: 1 + }, + { title: '', data: 'chkbox', searchable: false, orderable: false, defaultContent: '', responsivePriority: 2 }, + { title: 'ID', data: 'id', defaultContent: '', responsivePriority: 6 }, + { title: L('name'), data: 'name_display', defaultContent: '', responsivePriority: 2 }, + { title: L('domain'), data: 'domain', defaultContent: '', responsivePriority: 5 }, + { title: L('address'), data: 'address_display', defaultContent: '', responsivePriority: 3 }, + { title: L('members'), data: 'members_display', defaultContent: '', responsivePriority: 4 }, + { title: L('send_as'), data: 'send_as_display', defaultContent: '', responsivePriority: 7 }, + { title: L('template'), data: 'template_display', defaultContent: '', responsivePriority: 8 }, + { + title: lang.active, + data: 'active', + defaultContent: '', + responsivePriority: 6, + render: function(d) { return d == 1 ? '' : ''; } + }, + { + title: lang.action, + data: 'action', + orderable: false, + className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right', + responsivePriority: 3, + defaultContent: '' + } + ] + }); + } + + // Share rows (edit page, add modal) are kept as JSON in the hidden form field of their table (imap_grants, sogo_grants) + function syncGrants($table) { + var grants = []; + $table.find('tr.group-grant').each(function() { + var grant = {}; + $(this).find('[data-grant]').each(function() { + grant[$(this).data('grant')] = $(this).is(':checkbox') ? ($(this).is(':checked') ? 1 : 0) : $(this).val(); + }); + if (grant.owner) grants.push(grant); + }); + $table.closest('form').find('input[name="' + $table.data('field') + '"]').val(JSON.stringify(grants)); + } + + $(document).ready(function() { + // @domain suffix of the address in the add modal + $('.mailbox-group-domain-select').on('change', function() { + $(this).closest('form').find('.mailbox-group-domain').text($(this).val()); + }); + $(document).on('change', '.group-grants [data-grant]', function() { + syncGrants($(this).closest('.group-grants')); + }); + $(document).on('click', '.group-grant-add', function(e) { + e.preventDefault(); + var $table = $('#' + $(this).data('table')); + var $body = $table.children('tbody'); + if (!$body.length) $body = $('').appendTo($table); + var $row = $($('#' + $(this).data('table') + '_template').html()).appendTo($body); + $row.find('select').selectpicker({'styleBase': 'btn btn-xs-lg', 'noneSelectedText': lang_footer.nothing_selected}); + syncGrants($table); + }); + $(document).on('click', '.group-grant-del', function(e) { + e.preventDefault(); + var $table = $(this).closest('.group-grants'); + $(this).closest('tr').remove(); + syncGrants($table); + }); + }); + + return { + drawTable: drawTable, + L: L + }; +})(jQuery); diff --git a/data/web/js/site/identity.js b/data/web/js/site/identity.js new file mode 100644 index 000000000..475db5d1b --- /dev/null +++ b/data/web/js/site/identity.js @@ -0,0 +1,104 @@ +// Identity provider sync: status card and run requests (admin system page, identity provider tab). +// Runs are executed by the identity-sync cron (every minute); the card polls until the requested run is done. +var mailcowIdentity = (function($) { + 'use strict'; + + var POLL_MS = 5000; + var POLL_MAX = 36; + var polls = 0; + var timer = null; + var STATUS_CLASS = {ok: 'bg-success', blocked: 'bg-warning text-dark', error: 'bg-danger', pending: 'bg-secondary', running: 'bg-info text-dark'}; + // summary counts in display order + var COUNTS = ['creates', 'links', 'deactivate', 'restore', 'release', 'missing', 'found', 'updates', 'group_creates', 'group_renames', + 'memberships', 'orphaned_groups', 'membership_removals', 'conflicts', 'errors', 'skipped']; + var DETAILS = ['conflicts', 'errors', 'deactivate', 'restore', 'missing', 'creates', 'group_creates']; + + // admin.twig injects the admin lang section as `lang` + function L(key) { + var src = (typeof lang_admin !== 'undefined') ? lang_admin : ((typeof lang !== 'undefined') ? lang : {}); + return (src[key] !== undefined) ? src[key] : key; + } + function esc(s) { + return escapeHtml(s === null || s === undefined ? '' : String(s)); + } + + function badge(label, value, cls) { + return '' + esc(label) + ': ' + esc(value) + ''; + } + + function changes(run) { + var s = run.summary; + if (!s) return ''; + if (s.error) return '' + esc(s.error) + ''; + var counts = s.counts || {}; + var html = ''; + $.each(COUNTS, function(i, key) { + if (counts[key] > 0) html += badge(L('iam_sync_count_' + key), counts[key], (key === 'errors' || key === 'conflicts') ? 'bg-danger' : null); + }); + if (html === '') html = '' + esc(L('iam_sync_no_changes')) + ''; + var details = $.extend({}, s.details || {}, {errors: s.errors || []}); + var lists = ''; + $.each(DETAILS, function(i, key) { + var items = details[key] || []; + if (!items.length) return; + lists += '
' + esc(L('iam_sync_count_' + key)) + '
    ' + + $.map(items, function(item) { return '
  • ' + esc(item) + '
  • '; }).join('') + '
'; + }); + if (lists !== '') html += '
' + esc(L('iam_sync_details')) + '' + lists + '
'; + return html; + } + + function render(status) { + var c = status.counts || {}; + var counts = badge(L('iam_sync_accounts'), c.accounts || 0) + badge(L('iam_sync_linked'), c.linked || 0) + + badge(L('iam_sync_idp_disabled'), c.idp_disabled || 0, c.idp_disabled ? 'bg-warning text-dark' : null) + + badge(L('iam_sync_missing'), c.missing || 0, c.missing ? 'bg-warning text-dark' : null); + if (!status.sync_enabled) counts += '
' + esc(L('iam_sync_off')) + '
'; + $('#iam_sync_counts').html(counts); + var rows = $.map(status.runs || [], function(run) { + var state = '' + esc(L('iam_sync_status_' + run.status)) + ''; + return '' + esc(run.started || run.created) + '' + + '' + esc(L('iam_sync_mode_' + run.mode)) + (run.requested_by ? '
' + esc(run.requested_by) + '
' : '') + '' + + '' + state + '' + changes(run) + ''; + }); + $('#iam_sync_runs tbody').html(rows.length ? rows.join('') : '' + esc(L('iam_sync_none')) + ''); + return status.runs && status.runs.length && (status.runs[0].status === 'pending' || status.runs[0].status === 'running'); + } + + function load() { + clearTimeout(timer); + $.get('/api/v1/get/identity-provider/sync-status', function(status) { + if (render(status || {}) && polls++ < POLL_MAX) timer = setTimeout(load, POLL_MS); + }, 'json'); + } + + function request(mode) { + $.ajax({ + type: 'POST', + dataType: 'json', + url: '/api/v1/edit/identity-provider-sync', + data: {items: JSON.stringify(['sync']), attr: JSON.stringify({mode: mode}), csrf_token: csrf_token}, + success: function(res) { + var msg = (res && res[0]) ? res[0] : {type: 'danger', msg: 'generic_server_error'}; + var key = $.isArray(msg.msg) ? msg.msg[0] : msg.msg; + var text = (msg.type === 'success' ? lang_success : lang_danger)[key] || key; + mailcow_alert_box(text, msg.type === 'success' ? 'success' : 'danger'); + polls = 0; + load(); + } + }); + } + + $(document).ready(function() { + if (!$('#iam_sync_status').length) return; + load(); + $(document).on('click', '.iam_sync_request', function(e) { + e.preventDefault(); + var mode = $(this).data('mode'); + if (mode === 'force' && !confirm(L('iam_sync_force_confirm'))) return; + request(mode); + }); + }); + + return {load: load}; +})(jQuery); diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index e073650d3..32de2a8d1 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -120,6 +120,10 @@ $(document).ready(function() { } }); }); + // Shared mailboxes need no password, the server sets a random one + $('#add_mailbox_shared').on('change', function() { + $(this).closest('form').find('input[name="password"], input[name="password2"]').prop('required', !this.checked); + }); // @Open Mailbox add modal $('#addMailboxModal').on('show.bs.modal', function(e) { $.ajax({ @@ -996,6 +1000,9 @@ jQuery(function($){ 'style="min-width:2em;width:' + item.percent_in_use + '%">' + item.percent_in_use + '%' + '' }; item.username = escapeHtml(item.username); + if (item.attributes && item.attributes.shared == '1') { + item.username += ' ' + escapeHtml(lang.shared_mailbox) + ''; + } item.name = escapeHtml(item.name); if (Array.isArray(item.tags)){ @@ -1926,6 +1933,12 @@ jQuery(function($){ ' ' + lang.remove + '' + ''; item.chkbox = ''; + if (item.group_id) { + // Distribution address of a group: changed through the group + item.action = ' ' + + escapeHtml((lang.groups && lang.groups.managed_alias ? lang.groups.managed_alias : '%s').replace('%s', item.group_name)) + ''; + item.chkbox = ''; + } item.goto = escapeHtml(item.goto.replace(/,/g, " ")); if (item.public_comment !== null) { item.public_comment = escapeHtml(item.public_comment); @@ -2479,6 +2492,7 @@ jQuery(function($){ onVisible("[id^=aliasdomain_table]", () => draw_aliasdomain_table()); onVisible("[id^=sync_job_table]", () => draw_sync_job_table()); onVisible("[id^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(role === 'admin' || acl_data.syncjobs === 1)); + onVisible("[id^=mailbox_group_table]", () => mailcowGroups.drawTable()); onVisible("[id^=filter_table]", () => draw_filter_table()); onVisible("[id^=bcc_table]", () => draw_bcc_table()); onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table()); diff --git a/data/web/json_api.php b/data/web/json_api.php index d1b9807ea..3a6ad2659 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -258,6 +258,9 @@ if (isset($_GET['query'])) { case "api-key": process_add_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->addKey($attr)); break; + case "mailbox-group": + process_add_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addGroup($attr)); + break; case "domain": switch ($object) { case "template": @@ -820,6 +823,22 @@ if (isset($_GET['query'])) { } break; + case "mailbox-group": + switch ($object) { + case "all": + process_get_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getGroups($extra)); + break; + + case "member": + process_get_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getMemberships((string)$extra)); + break; + + default: + process_get_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getGroup($object)); + break; + } + break; + case "logs": switch ($object) { case "dovecot": @@ -1527,12 +1546,17 @@ if (isset($_GET['query'])) { process_get_return($score); break; case "identity-provider": - if($_SESSION['mailcow_cc_role'] === 'admin') { + if ($object == "sync-status") { + process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getStatus()); + } elseif($_SESSION['mailcow_cc_role'] === 'admin') { process_get_return($iam_settings); } else { process_get_return(null); } break; + case "identity-link": + process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getLink((string)$object)); + break; break; // return no route found if no case is matched default: @@ -1726,6 +1750,9 @@ if (isset($_GET['query'])) { case "api-key": process_delete_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->deleteKeys($items)); break; + case "mailbox-group": + process_delete_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->deleteGroups($items)); + break; case "app-passwd": process_delete_return(app_passwd('delete', array('id' => $items))); break; @@ -1825,6 +1852,9 @@ if (isset($_GET['query'])) { case "identity-provider": process_delete_return(identity_provider('delete')); break; + case "identity-link": + process_delete_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->unlink($items)); + break; // return no route found if no case is matched default: http_response_code(404); @@ -1894,6 +1924,9 @@ if (isset($_GET['query'])) { case "api-key": process_edit_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->editKeys($items, $attr)); break; + case "mailbox-group": + process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr)); + break; case "recipient_map": process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr))); break; @@ -2081,6 +2114,9 @@ if (isset($_GET['query'])) { case "identity-provider-test": process_edit_return(identity_provider('test', $attr)); break; + case "identity-provider-sync": + process_edit_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->requestRun($attr)); + break; case "cors": process_edit_return(cors('edit', $attr)); break; diff --git a/data/web/templates/admin/iam-redirect-urls.twig b/data/web/templates/admin/iam-redirect-urls.twig new file mode 100644 index 000000000..8a4756af8 --- /dev/null +++ b/data/web/templates/admin/iam-redirect-urls.twig @@ -0,0 +1,41 @@ +{# Redirect URLs of the OIDC forms (iam_form = keycloak|generic); row markup is used by addRedirectUrlRow() in admin.js #} +
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+ + {% for key, url in iam_settings.redirect_url_extra %} +
+
+
+ +
+
+ +
+
+
+ {% endfor %} +
+
+
+ +
+
+ +
+
+
+
diff --git a/data/web/templates/admin/iam-sync-settings.twig b/data/web/templates/admin/iam-sync-settings.twig new file mode 100644 index 000000000..bbd88d411 --- /dev/null +++ b/data/web/templates/admin/iam-sync-settings.twig @@ -0,0 +1,153 @@ +{# Sync and group settings of the Keycloak and LDAP forms (iam_form = keycloak|ldap) #} + {{ lang.admin.iam_section_sync }}
+ {% if iam_form == 'keycloak' %} +
+
+ {{ lang.admin.iam_extra_permission|raw }} +
+
+ {% endif %} +
+
+ + +
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ +
+
+ {{ lang.admin.iam_section_groups }}
+
+
+ + +
+
+
+ +
+
+
+ {% if iam_form == 'keycloak' %} +
+
+ + +
+
+ +
+
+ {% else %} +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ +
+
+ {% endif %} +
+
+ + +
+
+
+ +
+
+
+
+
+ + +
+
+ +
+
diff --git a/data/web/templates/admin/iam-sync-status.twig b/data/web/templates/admin/iam-sync-status.twig new file mode 100644 index 000000000..f3ebd0f13 --- /dev/null +++ b/data/web/templates/admin/iam-sync-status.twig @@ -0,0 +1,30 @@ +{# Status of the identity sync (Keycloak and LDAP), filled by js/site/identity.js #} +
+
+ {{ lang.admin.iam_sync_status }} +
+ + + +
+
+
+

{{ lang.admin.iam_sync_status_info }}

+
+
+ + + + + + + + + + + + +
{{ lang.admin.iam_sync_started }}{{ lang.admin.iam_sync_mode }}{{ lang.admin.iam_sync_result }}{{ lang.admin.iam_sync_changes }}
{{ lang.admin.iam_sync_none }}
+
+
+
diff --git a/data/web/templates/admin/iam-template-mapping.twig b/data/web/templates/admin/iam-template-mapping.twig new file mode 100644 index 000000000..1b646aa71 --- /dev/null +++ b/data/web/templates/admin/iam-template-mapping.twig @@ -0,0 +1,72 @@ +{# Default template and attribute mapping of all IdP forms (iam_form = keycloak|generic|ldap); row markup is used by + addAttributeMappingRow() in admin.js #} +
+
+ + +
+
+ +
+
+
+
+ + +
+
+
+ {{ lang.user.attribute }} + {{ lang.mailbox.template }} +
+ +
+
+
+
+
+ + + {% for key, role in iam_settings.mappers %} +
+
+
+ +
+
+ +
+
+ +
+
+
+ {% endfor %} +
+
+
+ +
+
+ +
+
+ +
+
+
+
diff --git a/data/web/templates/admin/tab-config-identity-provider.twig b/data/web/templates/admin/tab-config-identity-provider.twig index 4572d7fb5..c40b8305c 100644 --- a/data/web/templates/admin/tab-config-identity-provider.twig +++ b/data/web/templates/admin/tab-config-identity-provider.twig @@ -10,7 +10,7 @@

{{ lang.admin.iam_description|raw }}

- +
+ {{ lang.admin.iam_section_connection }}
- +
- +
@@ -43,6 +45,14 @@
+
+
+ +
+
+ +
+
@@ -62,215 +72,62 @@
-
-
- -
-
-
- - - -
- -
-
-
-
-
- - {% for key, url in iam_settings.redirect_url_extra %} -
-
-
- -
-
- -
-
-
- {% endfor %} -
-
-
- -
-
- -
-
-
-
-
-
- -
-
- -
-
-
-
- -
-
-
- {{ lang.user.attribute }} - {{ lang.mailbox.template }} -
- -
-
-
-
-
- - -
-
-
- - {{ lang.admin.iam_default_template}} -
-
- -
-
-
-
- {% for key, role in iam_settings.mappers %} -
-
-
- -
-
- -
-
- -
-
-
- {% endfor %} -
-
-
- -
-
- -
-
- -
-
-
-
-
-
-
- {{ lang.admin.iam_extra_permission|raw }} -
-
-
-
- -
-
-
- -
-

- - {{ lang.admin.iam_auth_flow_info|raw }} - -

-
-
-
+
+
+
+ +
+
+ {{ lang.admin.iam_section_login }}
+ {% include 'admin/iam-redirect-urls.twig' with {'iam_form': 'keycloak'} %}
+ + +
+
+
+ +
+
+
+
+
+
-
+
- +
+ {{ lang.admin.iam_section_templates }}
+ {% include 'admin/iam-template-mapping.twig' with {'iam_form': 'keycloak'} %} + {% include 'admin/iam-sync-settings.twig' with {'iam_form': 'keycloak'} %} +
-
- -
-
-
- -
-
-
-
-
- -
-
-
- -
-
-
-
-
- -
-
- -
-
-
-
- - -
+
+ {# Generic OIDC #}
+ {{ lang.admin.iam_section_connection }}
@@ -315,46 +172,6 @@
-
- -
-
-
- - - -
- -
-
-
-
-
- - {% for key, url in iam_settings.redirect_url_extra %} -
-
-
- -
-
- -
-
-
- {% endfor %} -
-
-
- -
-
- -
-
-
-
-
@@ -362,125 +179,57 @@
-
-
- -
-
-
- {{ lang.user.attribute }} - {{ lang.mailbox.template }} -
- -
-
-
-
-
- - -
-
-
- - {{ lang.admin.iam_default_template}} -
-
- -
-
-
-
- {% for key, role in iam_settings.mappers %} -
-
-
- -
-
- -
-
- -
-
-
- {% endfor %} -
-
-
- -
-
- -
-
- -
-
-
-
-
+
-
+
+
+ +
+
+ {{ lang.admin.iam_section_login }}
+ {% include 'admin/iam-redirect-urls.twig' with {'iam_form': 'generic'} %} +
+
-
+
- +
-
+ {{ lang.admin.iam_section_templates }}
+ {% include 'admin/iam-template-mapping.twig' with {'iam_form': 'generic'} %} +
+
-
- - -
+
+ {# LDAP #}
+ {{ lang.admin.iam_section_connection }}
-
- +
+
@@ -488,96 +237,44 @@
- +
- - + +
-
-
- -
+
+ {# use_ssl and use_tls exclude each other, the select sets both #} + + +
-
- - -
-
-
- -
-
-
-
-
+
-
-
- -
-
- -
-
-
-
- -
-
- -
-
-
-
- -
-
- -
-
-
-
- -
-
- -
-
- +
-
+
@@ -589,132 +286,91 @@
-
- -
-
-
- {{ lang.user.attribute }} - {{ lang.mailbox.template }} -
- -
-
+
+
-
- - -
-
-
- - {{ lang.admin.iam_default_template }} -
-
- -
-
-
+ {{ lang.admin.iam_section_users }}
+
+
+
- {% for key, role in iam_settings.mappers %} -
-
-
- -
-
- -
-
- -
-
-
- {% endfor %} -
-
-
- -
-
- -
-
- -
-
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+ {{ lang.admin.iam_section_login }}
+
+
+
-
+
- +
+ {{ lang.admin.iam_section_templates }}
- -
-
-
- -
-
-
-
-
- -
-
-
- -
-
-
-
-
- + +
- +
-
+ {% include 'admin/iam-template-mapping.twig' with {'iam_form': 'ldap'} %} + {% include 'admin/iam-sync-settings.twig' with {'iam_form': 'ldap'} %} +
+
-
- - -
+
+ {% if iam_settings.authsource == 'keycloak' or iam_settings.authsource == 'ldap' %} + {% include 'admin/iam-sync-status.twig' %} + {% endif %}
diff --git a/data/web/templates/edit.twig b/data/web/templates/edit.twig index a5c2cc154..d210370b7 100644 --- a/data/web/templates/edit.twig +++ b/data/web/templates/edit.twig @@ -27,6 +27,7 @@ var lang_admin = {{ lang_admin|raw }}; var lang_datatables = {{ lang_datatables|raw }}; var lang_syncjobs = {{ lang_syncjobs|raw }}; + var lang_groups = {{ lang_groups|raw }}; var csrf_token = '{{ csrf_token }}'; var pagination_size = Math.trunc('{{ pagination_size }}'); var table_for_domain = '{{ domain }}'; diff --git a/data/web/templates/edit/mailbox-group-grant.twig b/data/web/templates/edit/mailbox-group-grant.twig new file mode 100644 index 000000000..5c9c21f39 --- /dev/null +++ b/data/web/templates/edit/mailbox-group-grant.twig @@ -0,0 +1,49 @@ +{# One share row of the group edit page: kind = imap|sogo, grant = {} for the add template #} +{% set level_key = kind == 'imap' ? 'rights' : 'access' %} +{# new IMAP shares are subscribed by default, like automapping in Exchange #} +{% set subscribed = grant.owner is defined ? grant.subscribe|default(0) == 1 : kind == 'imap' %} + + + + + {% if kind == 'sogo' %} + + + + + + + {% endif %} + + + + +
+ +
+ + + {% if grant.state %} + {{ attribute(lang.groups, 'grant_state_' ~ grant.state) }} + {% endif %} + + + {% if not ro %} + + {% endif %} + + diff --git a/data/web/templates/edit/mailbox-group-grants.twig b/data/web/templates/edit/mailbox-group-grants.twig new file mode 100644 index 000000000..46fea6cb5 --- /dev/null +++ b/data/web/templates/edit/mailbox-group-grants.twig @@ -0,0 +1,24 @@ +{# Shares of a group (edit page, add modal): kind = imap|sogo, grants, id = table id, ro; rows go to the hidden JSON field #} +{% set field = kind ~ '_grants' %} +
+ +
+ {% if not ro %} + + {% endif %} + + + {% for grant in grants %} + {% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': grant} %} + {% endfor %} + +
+ {% if not ro %} + + + {% endif %} + {{ attribute(lang.groups, field ~ '_hint') }} +
+
diff --git a/data/web/templates/edit/mailbox-group.twig b/data/web/templates/edit/mailbox-group.twig new file mode 100644 index 000000000..d886e743c --- /dev/null +++ b/data/web/templates/edit/mailbox-group.twig @@ -0,0 +1,142 @@ +{% extends 'edit.twig' %} + +{% block inner_content %} +{% if result %} +{% set ro = not result.can_edit %} +{% set editable_members = [] %} +{% set fixed_members = [] %} +{% for member in result.members %} + {% if member.source == 'idp' or member.username not in mailboxes %} + {% set fixed_members = fixed_members|merge([member]) %} + {% else %} + {% set editable_members = editable_members|merge([member.username]) %} + {% endif %} +{% endfor %} +

{{ lang.groups.group }}: {{ result.name }}

+{% if result.idp_ref %} + +{% endif %} +
+ + + + + + {% if not ro %} + {# an empty selection still sends the field, so all editable members can be removed #} + + {% endif %} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + {{ lang.groups.address_hint }} +
+
+
+ +
+ + {% if fixed_members %} +
+ {% for member in fixed_members %} + {% if member.source == 'idp' %} {% endif %}{{ member.username }} + {% endfor %} +
+ {% endif %} +
+
+
+ +
+ + {{ lang.groups.send_as_hint }} +
+
+ {% include 'edit/mailbox-group-grants.twig' with {'kind': 'imap', 'grants': result.imap_grants, 'id': 'group_imap_grants'} %} + {% if not skip_sogo %} + {% include 'edit/mailbox-group-grants.twig' with {'kind': 'sogo', 'grants': result.sogo_grants, 'id': 'group_sogo_grants'} %} + {% endif %} +
+
+
+ +
+ {% if not skip_sogo %} +
+ +
+ {% endif %} +
+ +
+
+
+
+ + {% if mailcow_cc_role == 'admin' %} +
+ +
+ +
+ +
+
+
+ +
+ {{ lang.groups.template_hint }} +
+ {% else %} +
+ +
+ {% endif %} +
+
+
+
+ +
+
+
+ {% if not ro %} +
+
+ +
+
+ {% endif %} +
+{% else %} + {{ parent() }} +{% endif %} +{% endblock %} diff --git a/data/web/templates/edit/mailbox.twig b/data/web/templates/edit/mailbox.twig index db258fe49..9f56ac56c 100644 --- a/data/web/templates/edit/mailbox.twig +++ b/data/web/templates/edit/mailbox.twig @@ -24,6 +24,7 @@
+ @@ -46,6 +47,20 @@
+ {% if result.authsource != 'mailcow' and identity_link %} +
+ +
+ {% if identity_link.external_id %}{{ identity_link.external_id }}{% else %}{{ lang.edit.iam_not_linked }}{% endif %} + {% if identity_link.idp_disabled == 1 %}{{ lang.edit.iam_idp_disabled }}{% endif %} + {% if identity_link.missing_since %}{{ lang.edit.iam_missing_since }} {{ identity_link.missing_since }}{% endif %} + {% if mailcow_cc_role == 'admin' and identity_link.external_id %} + {{ lang.edit.iam_unlink }} + {% endif %} + {{ lang.edit.iam_link_info }} +
+
+ {% endif %}
@@ -121,6 +136,22 @@ {{ lang.edit.sender_acl_info|raw }}
+ {% if mailbox_groups.groups %} +
+ +
+ {% for group in mailbox_groups.groups %} + {% if group.source == 'idp' %} {% endif %}{{ group.name }}{% if group.address %} <{{ group.address }}>{% endif %} + {% endfor %} + {% if mailbox_groups.template %} + {{ lang.groups.effective_template }}: {{ mailbox_groups.template }} + {% endif %} + {% if mailbox_groups.send_as %} + {{ lang.groups.send_as_groups }}: {{ mailbox_groups.send_as|join(', ') }} + {% endif %} +
+
+ {% endif %} {% if acl.mailbox_relayhost == '1' %}
@@ -310,6 +341,14 @@
+
+
+
+ + {{ lang.mailbox.shared_mailbox_info }} +
+
+
diff --git a/data/web/templates/mailbox.twig b/data/web/templates/mailbox.twig index f330a76b9..6a6cbe58d 100644 --- a/data/web/templates/mailbox.twig +++ b/data/web/templates/mailbox.twig @@ -33,6 +33,9 @@
  • + {% if acl.mailbox_groups == 1 %} + + {% endif %} @@ -53,6 +56,9 @@ {% include 'mailbox/tab-resources.twig' %} {% include 'mailbox/tab-domain-aliases.twig' %} {% include 'mailbox/tab-mbox-aliases.twig' %} + {% if acl.mailbox_groups == 1 %} + {% include 'mailbox/tab-groups.twig' %} + {% endif %} {% include 'mailbox/tab-syncjobs.twig' %} {% include 'mailbox/tab-filters.twig' %} {% include 'mailbox/tab-bcc.twig' %} @@ -68,6 +74,7 @@ var acl = '{{ acl_json|raw }}'; var lang = {{ lang_mailbox|raw }}; lang.syncjobs = {{ lang_syncjobs|raw }}; + lang.groups = {{ lang_groups|raw }}; var lang_rl = {{ lang_rl|raw }}; var lang_edit = {{ lang_edit|raw }}; var lang_datatables = {{ lang_datatables|raw }}; diff --git a/data/web/templates/mailbox/tab-groups.twig b/data/web/templates/mailbox/tab-groups.twig new file mode 100644 index 000000000..408453ca7 --- /dev/null +++ b/data/web/templates/mailbox/tab-groups.twig @@ -0,0 +1,30 @@ +
    +
    +
    + + {{ lang.groups.groups }} +
    + +
    +
    + +
    +
    diff --git a/data/web/templates/modals/mailbox.twig b/data/web/templates/modals/mailbox.twig index db61054e7..0a886957c 100644 --- a/data/web/templates/modals/mailbox.twig +++ b/data/web/templates/modals/mailbox.twig @@ -197,6 +197,14 @@
    +
    +
    +
    + + {{ lang.mailbox.shared_mailbox_info }} +
    +
    +
    @@ -1351,3 +1359,6 @@
    {% include "modals/imapsync_source.twig" %} +{% if acl.mailbox_groups == 1 %} +{% include "modals/mailbox_group.twig" %} +{% endif %} diff --git a/data/web/templates/modals/mailbox_group.twig b/data/web/templates/modals/mailbox_group.twig new file mode 100644 index 000000000..f29bd76d4 --- /dev/null +++ b/data/web/templates/modals/mailbox_group.twig @@ -0,0 +1,119 @@ + + diff --git a/data/web/templates/user/tab-user-details.twig b/data/web/templates/user/tab-user-details.twig index 62842fc9b..32761c167 100644 --- a/data/web/templates/user/tab-user-details.twig +++ b/data/web/templates/user/tab-user-details.twig @@ -61,6 +61,18 @@

    {% if not user_get_alias_details.aliases_send_as_all %}{% else %}{{ user_get_alias_details.aliases_send_as_all }}{% endif %}

    +
    +
    {{ lang.groups.memberships }}:
    +
    +

    + {% for group in mailbox_groups.groups %} + {{ group.name }}{% if group.address %} <{{ group.address }}>{% endif %}{% if not loop.last %}
    {% endif %} + {% else %} + + {% endfor %} +

    +
    +
    {{ lang.user.is_catch_all }}:
    diff --git a/data/web/user.php b/data/web/user.php index c176b8655..e9e10c72a 100644 --- a/data/web/user.php +++ b/data/web/user.php @@ -64,6 +64,7 @@ $template_data = [ 'mailboxdata' => $mailboxdata, 'clientconfigstr' => $clientconfigstr, 'user_get_alias_details' => $user_get_alias_details, + 'mailbox_groups' => \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getMemberships($username), 'get_tagging_options' => mailbox('get', 'delimiter_action', $username), 'get_tls_policy' => mailbox('get', 'tls_policy', $username), 'quarantine_notification' => mailbox('get', 'quarantine_notification', $username), diff --git a/docker-compose.yml b/docker-compose.yml index 2d94177fa..fda28e8ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,6 @@ +# Version of the database schema +x-db-version: &db_version DB_VERSION=26102026_1200 + services: unbound-mailcow: @@ -150,6 +153,7 @@ services: dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: + - *db_version - REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-} - REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-} - REDISPASS=${REDISPASS} @@ -188,12 +192,12 @@ services: restart: always labels: ofelia.enabled: "true" - ofelia.job-exec.phpfpm_keycloak_sync.schedule: "0 * * * * *" - ofelia.job-exec.phpfpm_keycloak_sync.no-overlap: "true" - ofelia.job-exec.phpfpm_keycloak_sync.command: "/bin/bash -c \"php /crons/keycloak-sync.php || exit 0\"" - ofelia.job-exec.phpfpm_ldap_sync.schedule: "0 * * * * *" - ofelia.job-exec.phpfpm_ldap_sync.no-overlap: "true" - ofelia.job-exec.phpfpm_ldap_sync.command: "/bin/bash -c \"php /crons/ldap-sync.php || exit 0\"" + ofelia.job-exec.phpfpm_identity_sync.schedule: "0 * * * * *" + ofelia.job-exec.phpfpm_identity_sync.no-overlap: "true" + ofelia.job-exec.phpfpm_identity_sync.command: "/bin/bash -c \"php /crons/identity-sync.php || exit 0\"" + ofelia.job-exec.phpfpm_group_grants.schedule: "0 * * * * *" + ofelia.job-exec.phpfpm_group_grants.no-overlap: "true" + ofelia.job-exec.phpfpm_group_grants.command: "/bin/bash -c \"php /crons/group-grants.php || exit 0\"" ofelia.job-exec.phpfpm_imapsync_oauth_refresh.schedule: "0 * * * * *" ofelia.job-exec.phpfpm_imapsync_oauth_refresh.no-overlap: "true" ofelia.job-exec.phpfpm_imapsync_oauth_refresh.command: "/bin/bash -c \"php /crons/imapsync-oauth-refresh.php || exit 0\"" @@ -205,6 +209,7 @@ services: sogo-mailcow: image: ghcr.io/mailcow/sogo:5.12.12-1 environment: + - *db_version - DBNAME=${DBNAME} - DBUSER=${DBUSER} - DBPASS=${DBPASS} @@ -227,7 +232,6 @@ services: volumes: - ./data/hooks/sogo:/hooks:Z - ./data/conf/sogo/:/etc/sogo/:z - - ./data/web/inc/init_db.inc.php:/init_db.inc.php:z - ./data/conf/sogo/custom-favicon.ico:/usr/local/lib/GNUstep/SOGo/WebServerResources/img/sogo.ico:z - ./data/conf/sogo/custom-shortlogo.svg:/usr/local/lib/GNUstep/SOGo/WebServerResources/img/sogo-compact.svg:z - ./data/conf/sogo/custom-fulllogo.svg:/usr/local/lib/GNUstep/SOGo/WebServerResources/img/sogo-full.svg:z @@ -278,6 +282,7 @@ services: - rspamd-vol-1:/var/lib/rspamd - mysql-socket-vol-1:/var/run/mysqld/:z environment: + - *db_version - DOVECOT_MASTER_USER=${DOVECOT_MASTER_USER:-} - DOVECOT_MASTER_PASS=${DOVECOT_MASTER_PASS:-} - MAILCOW_REPLICA_IP=${MAILCOW_REPLICA_IP:-}