[Web][Dovecot][SOGo][DockerApi] Add mailbox groups, shared mailboxes and identity provider sync

This commit is contained in:
FreddleSpl0it committed 2026-10-27 10:01:33 +01:00
1 parent 8bc8fbdd2c
commit 2c5718c9c9
82 files changed
+6488 -1027

No files matched your search

+103 -3
View File
@@ -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/<owner>/...), 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=<name> 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=<name>" 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
+11 -1
View File
@@ -127,7 +127,8 @@ cat <<EOF > /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_<id>
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
+2 -4
View File
@@ -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}"
+65
View File
@@ -0,0 +1,65 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => 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))));
}
+95
View File
@@ -0,0 +1,95 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => 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();
-231
View File
@@ -1,231 +0,0 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
// Init database
//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => 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();
-198
View File
@@ -1,198 +0,0 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
// Init database
//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => 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();
+2
View File
@@ -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']),
+1
View File
@@ -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');
+1
View File
@@ -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');
+19
View File
@@ -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');
+1
View File
@@ -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'],
+78 -16
View File
@@ -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';
}
+96 -23
View File
@@ -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;
+88 -12
View File
@@ -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
));
+382 -44
View File
@@ -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/<id> or Contacts/<id>, 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:<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;
@@ -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 */
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Mailcow\Auth;
use Mailcow\Auth\Service\IdpLoginService;
use Mailcow\Core\Context;
use Mailcow\Identity\IdentityModule;
use Mailcow\MailboxGroup\MailboxGroupModule;
use PDO;
/**
* Logins. For now the IdP login steps of the legacy flows (keycloak_mbox_login_rest(), ldap_mbox_login(),
* identity_provider('verify-sso')); the auth logic of the function files moves here step by step.
*/
class AuthModule
{
public function __construct(public readonly IdpLoginService $idp)
{
}
/** @param array $iamSettings identity provider settings as identity_provider('get') returns them */
public static function create(PDO $pdo, Context $context, array $iamSettings): self
{
$identity = IdentityModule::create($pdo, $context, $iamSettings);
return new self(new IdpLoginService($identity->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'));
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Mailcow\Auth\Service;
use Mailcow\Identity\Model\Settings;
use Mailcow\Identity\Service\LinkService;
use Mailcow\MailboxGroup\Service\TemplateService;
use Throwable;
/**
* Steps of an IdP login (Keycloak REST, LDAP bind, OIDC) the legacy login flows call. The link checks fail
* closed: an error denies the login.
*/
class IdpLoginService
{
public function __construct(private Settings $settings, private LinkService $links, private TemplateService $templates)
{
}
/** Template of the first attribute mapping matching one of the IdP attribute values, null if none */
public function mappedTemplate(mixed $values): ?string
{
return $this->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());
}
}
}
@@ -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
{
+18
View File
@@ -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';
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mailcow\Dovecot;
use Mailcow\Dovecot\Service\DovecotService;
/**
* Dovecot through dockerapi (ACLs, subscriptions, auth cache). Modules use it instead of calling docker().
*/
class DovecotModule
{
public function __construct(public readonly DovecotService $dovecot)
{
}
public static function create(): self
{
return new self(new DovecotService());
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Mailcow\Dovecot\Service;
/**
* doveadm in the dovecot container through dockerapi. The docker() call sits in a protected method so tests can
* replace it.
*/
class DovecotService
{
/**
* Sets the rights of an ACL identifier (user=<name>, group=<name>) 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/<owner>/, 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');
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Controller;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use Mailcow\Identity\Service\StatusService;
use PDOException;
/**
* HTTP API adapter for json_api.php (routes `identity-provider/sync-status`, `identity-provider-sync`,
* `identity-link`). Messages are logged as ['identity_provider', action, object, data].
*/
class IdentityApiController
{
public function __construct(private StatusService $status, private ApiResponse $response)
{
}
/** GET identity-provider/sync-status */
public function getStatus(): array
{
try {
return $this->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/<username> */
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;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Directory;
use Mailcow\Identity\Model\Listing;
/**
* Lists users (and groups) of an IdP for the sync. Implementations: KeycloakDirectory, LdapDirectory.
*/
interface Directory
{
/**
* The complete listing. Anything that could make it incomplete (HTTP or LDAP errors, size limits, ranged
* attributes) throws, because the sync treats users that are not listed as removed.
*
* @throws DirectoryException
*/
public function listAll(bool $withGroups): Listing;
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Directory;
use RuntimeException;
/**
* The IdP could not be listed completely; the sync run stops without writing anything.
*/
class DirectoryException extends RuntimeException
{
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Directory;
use Mailcow\Identity\Http\KeycloakClient;
use Mailcow\Identity\Model\DirectoryGroup;
use Mailcow\Identity\Model\DirectoryUser;
use Mailcow\Identity\Model\Listing;
use Mailcow\Identity\Model\Settings;
/**
* Keycloak realm users and the groups selected by their path. Only direct group members count.
*/
class KeycloakDirectory implements Directory
{
private const PAGE = 100;
public function __construct(private KeycloakClient $client, private Settings $settings)
{
}
public function listAll(bool $withGroups): Listing
{
$users = [];
foreach ($this->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<string, DirectoryUser> $users
* @return array<string, DirectoryGroup> 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;
}
}
}
}
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Directory;
use LdapRecord\Connection;
use Mailcow\Identity\Model\DirectoryGroup;
use Mailcow\Identity\Model\DirectoryUser;
use Mailcow\Identity\Model\Listing;
use Mailcow\Identity\Model\Settings;
/**
* LDAP / Active Directory users and the groups matching the group filter.
*
* Searches run on the raw connection with paged results and check the result code of every page, because
* LdapRecord's paginator stops silently on size limits. Group members come from memberOf (default), from the
* member attribute of the groups, or from the AD in-chain rule (nested groups).
*/
class LdapDirectory implements Directory
{
private const PAGE = 500;
private const ID_ATTRIBUTES = ['objectguid', 'entryuuid', 'nsuniqueid', 'ipauniqueid'];
private const AD_IN_CHAIN = '1.2.840.113556.1.4.1941';
/** userAccountControl: ACCOUNTDISABLE */
private const UAC_DISABLED = 0x2;
public function __construct(private ?Connection $connection, private Settings $settings)
{
}
public function listAll(bool $withGroups): Listing
{
$s = $this->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<string, DirectoryUser> $users
* @param array<string, string[]> $memberOf user id => lowercase group DNs
* @return array<string, DirectoryGroup>
*/
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);
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Http;
use Mailcow\Identity\Directory\DirectoryException;
/**
* Admin REST API of a Keycloak realm with the client_credentials token of the mailcow client
* (service account roles: view-users, query-groups). The HTTP call sits in send() so tests can replace it.
*/
class KeycloakClient
{
private ?string $token = null;
public function __construct(private string $serverUrl, private string $realm, private string $clientId,
private string $clientSecret, private bool $ignoreSslError = false)
{
}
/**
* GET below /admin/realms/<realm>, 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];
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity;
use Closure;
use LdapRecord\Connection;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use Mailcow\Identity\Controller\IdentityApiController;
use Mailcow\Identity\Directory\Directory;
use Mailcow\Identity\Directory\KeycloakDirectory;
use Mailcow\Identity\Directory\LdapDirectory;
use Mailcow\Identity\Http\KeycloakClient;
use Mailcow\Identity\Legacy\LegacyAccounts;
use Mailcow\Identity\Model\Settings;
use Mailcow\Identity\Repository\IdentityRepository;
use Mailcow\Identity\Repository\RunRepository;
use Mailcow\Identity\Service\LinkService;
use Mailcow\Identity\Service\StatusService;
use Mailcow\Identity\Service\SyncPlanner;
use Mailcow\Identity\Service\SyncService;
use Mailcow\MailboxGroup\MailboxGroupModule;
use Mailcow\Sogo\SogoModule;
use PDO;
/**
* Composition root of the identity module (IdP links, sync, offboarding).
*
* IdentityModule::create($pdo, Context::system(), identity_provider('get'), fn() => 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;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Legacy;
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
/**
* Bridge to the procedural mailbox code for the IdP sync: creating mailboxes, applying templates.
*/
class LegacyAccounts extends LegacyMailbox
{
/**
* Creates a mailbox of the IdP from a template, like the IdP login does.
*
* @return ?string null on success, else the message key of the legacy error
*/
public function createMailbox(string $email, string $name, string $authsource, string $template): ?string
{
return $this->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]);
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Model;
/**
* A mailbox with the authsource of the configured IdP and its link (table `identity_link`).
*/
class Account
{
/**
* @param int $active mailbox.active: 1 active, 2 login disabled (still receives mail), 0 inactive
* @param bool $idpDisabled the sync set active = 2, so it may undo it
* @param ?string $missingSince first complete run that did not list the user
*/
public function __construct(
public readonly string $username,
public readonly string $name,
public readonly int $active,
public readonly ?string $externalId = null,
public readonly bool $idpDisabled = false,
public readonly ?string $missingSince = null,
public readonly string $attributeHash = ''
) {
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Model;
/**
* A selected IdP group with the ids of its member users.
*/
class DirectoryGroup
{
/**
* @param ?string $mail group address in the IdP, decides the domain of an auto-created group
* @param string[] $memberIds ids of the member users (DirectoryUser::$id)
*/
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly ?string $mail = null,
public array $memberIds = []
) {
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Model;
/**
* A user as listed by the IdP. `id` is stable across renames (Keycloak user id, LDAP objectGUID/entryUUID/...).
*/
class DirectoryUser
{
/**
* @param ?string $email lowercase, null if the user has none
* @param string[] $templateValues values of the template attribute (legacy mapping)
* @param string $dn LDAP only, lowercase, used to resolve group members
*/
public function __construct(
public readonly string $id,
public readonly ?string $email,
public readonly string $name,
public readonly bool $enabled,
public readonly array $templateValues = [],
public readonly string $dn = ''
) {
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Model;
/**
* A complete listing of the IdP. Directories never return partial listings, they throw DirectoryException,
* so a user missing here is really gone (or out of the filter).
*/
class Listing
{
/**
* @param array<string, DirectoryUser> $users by id
* @param ?array<string, DirectoryGroup> $groups by id, null when groups were not listed
*/
public function __construct(public readonly array $users, public readonly ?array $groups = null)
{
}
}
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Model;
/**
* Identity provider settings (table `identity_provider`, key/value, see identity_provider('get')) with defaults.
*/
class Settings
{
public const KEYCLOAK = 'keycloak';
public const LDAP = 'ldap';
public const GENERIC_OIDC = 'generic-oidc';
public const MEMBER_MODES = ['memberof', 'member', 'ad_chain'];
public string $provider = '';
public bool $periodicSync = false;
public bool $importUsers = false;
/** Minutes between scheduled runs */
public int $syncInterval = 15;
/** Users disabled or removed in the IdP get login disabled (mailbox.active = 2) */
public bool $offboarding = false;
/** Max. share (percent) of active IdP mailboxes one run may deactivate, at least 5 mailboxes */
public int $offboardingLimit = 10;
public bool $groupSync = false;
public bool $groupAutocreate = true;
public string $groupDefaultDomain = '';
public string $defaultTemplate = '';
/** @var string[] attribute value per mapping, keys aligned with $templates */
public array $mappers = [];
/** @var string[] */
public array $templates = [];
public string $serverUrl = '';
public string $realm = '';
public string $clientId = '';
public string $clientSecret = '';
public bool $ignoreSslError = false;
/** @var string[] Keycloak group paths: '/path', '/path/*' (subtree) or '/*' (all) */
public array $groupPaths = [];
public string $basedn = '';
public string $filter = '';
public string $usernameField = 'mail';
public string $attributeField = '';
/** LDAP attribute with the stable user id, '' = objectGUID, entryUUID, nsUniqueId, ipaUniqueID, else the DN */
public string $idAttribute = '';
/** LDAP filter matching disabled users (besides AD userAccountControl) */
public string $disabledFilter = '';
public string $groupBasedn = '';
public string $groupFilter = '';
public string $groupNameAttribute = 'cn';
public string $groupMemberMode = 'memberof';
public string $groupMemberAttribute = 'member';
public static function fromArray(array $s): self
{
$settings = new self();
$settings->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);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Model;
/**
* What mailcow knows before a sync run, read by SyncService and planned against by SyncPlanner.
*/
class State
{
/**
* @param array<string, Account> $accounts mailboxes with the provider as authsource, by username
* @param array<string, string> $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<string, ?string> $effectiveTemplates username => effective group template
* @param array<string, string> $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 = []
) {
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Model;
/**
* What one sync run changes. Built by SyncPlanner from a listing and the current state, applied by SyncService.
*/
class SyncPlan
{
/** Details per kind kept in the stored summary */
private const DETAILS = 50;
/** @var array<string, string> 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<string, 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<int, string> group id => new name */
public array $groupRenames = [];
/** @var array<string, string[]> 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;
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Repository;
use Mailcow\Core\Repository;
use Mailcow\Identity\Model\Account;
use PDO;
/**
* SQL for IdP mailboxes and their links (table `identity_link`).
*/
class IdentityRepository extends Repository
{
/** @return array<string, Account> 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<string, string> 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<string, string> 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]);
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Repository;
use Mailcow\Core\Repository;
use PDO;
/**
* Sync runs (table `identity_sync_run`): requests from the UI (status pending), the run itself and its summary.
*/
class RunRepository extends Repository
{
private const KEEP = 30;
/** Queues a run for the cron; an identical pending request is reused */
public function request(string $provider, string $mode, string $requestedBy): int
{
$id = $this->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;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Service;
use Mailcow\Identity\Repository\IdentityRepository;
/**
* Links of mailboxes to IdP users, checked at IdP logins (REST, LDAP bind, OIDC). Mailboxes are found by email,
* so the IdP user id has to match the link: an address taken over by another IdP user must not open the old
* mailbox. Unlinked mailboxes are linked on their first login.
*/
class LinkService
{
public function __construct(private IdentityRepository $repo)
{
}
/** The IdP user may log in to the mailbox; links the mailbox when it has no link yet */
public function verify(string $provider, string $username, string $externalId): bool
{
if ($externalId === '') {
return false;
}
$link = $this->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);
}
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Service;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Identity\Model\Settings;
use Mailcow\Identity\Repository\IdentityRepository;
use Mailcow\Identity\Repository\RunRepository;
/**
* Admin use cases of the sync: status, run requests (executed by the cron) and links of mailboxes.
* Admins only, read-only API keys may read. Domain admins may read the link of mailboxes they manage.
*/
class StatusService
{
public const MODES = ['preview', 'run', 'force'];
public function __construct(private Settings $settings, private IdentityRepository $repo, private RunRepository $runs,
private Context $context)
{
}
/** @throws AccessDenied */
public function status(): array
{
$this->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)));
}
}
@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Service;
use Mailcow\Identity\Model\Account;
use Mailcow\Identity\Model\DirectoryGroup;
use Mailcow\Identity\Model\DirectoryUser;
use Mailcow\Identity\Model\Listing;
use Mailcow\Identity\Model\Settings;
use Mailcow\Identity\Model\State;
use Mailcow\Identity\Model\SyncPlan;
/**
* Decides what a sync run changes, without side effects.
*
* Users: linked mailboxes are found by IdP user id, unlinked ones are adopted by email. A mailbox linked to
* another IdP user, or an IdP user whose email moved away from its mailbox, is a conflict; nothing is
* relinked or created for it.
* Offboarding: a user disabled in the IdP, or not listed by two consecutive complete runs, gets login disabled
* (active = 2), once per IdP disable. Only those mailboxes are activated again when the IdP enables
* the user; manual changes of active always win.
* Groups: IdP groups map to mailbox groups by idp_ref; unknown ones are created in the domain of their mail,
* else in the default group domain. Linked users that are not listed keep their IdP memberships.
* Guard: more deactivations or membership removals than max(5, limit % of active IdP mailboxes resp. IdP
* memberships) block the run unless forced.
*/
class SyncPlanner
{
private const GUARD_MIN = 5;
public function plan(Settings $settings, Listing $listing, State $state, bool $force = false): SyncPlan
{
$plan = new SyncPlan();
$owners = [];
foreach ($state->accounts as $username => $account) {
if ($account->externalId !== null) {
$owners[$account->externalId] = (string)$username;
}
}
/** @var array<string, DirectoryUser> $matched username => listed user */
$matched = [];
/** @var array<string, DirectoryUser> $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<string, DirectoryGroup> $groups
* @param array<string, string> $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<string, DirectoryUser> $candidates
* @param array<string, DirectoryGroup> $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<string, DirectoryUser> $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<string, DirectoryUser> $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;
}
}
}
@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Mailcow\Identity\Service;
use Closure;
use Mailcow\Core\UserError;
use Mailcow\Identity\Directory\Directory;
use Mailcow\Identity\Directory\DirectoryException;
use Mailcow\Identity\Legacy\LegacyAccounts;
use Mailcow\Identity\Model\Settings;
use Mailcow\Identity\Model\State;
use Mailcow\Identity\Model\SyncPlan;
use Mailcow\Identity\Repository\IdentityRepository;
use Mailcow\Identity\Repository\RunRepository;
use Mailcow\MailboxGroup\Service\IdpSyncService;
use Mailcow\MailboxGroup\Service\TemplateService;
use Mailcow\Sogo\Service\SogoService;
use Throwable;
/**
* Sync runs of the IdP (cron only): list the directory, plan, apply. A listing error aborts the run before
* anything is written; a blocked or preview run writes nothing but the run summary.
* The directory is created only when a run is due, so idle cron calls never connect to the IdP.
*/
class SyncService
{
/** A run still `running` after this many minutes crashed */
private const STALE_MINUTES = 60;
/** Errors kept in the run summary */
private const DETAILS = 50;
public function __construct(
private Settings $settings,
/** @var Closure(): ?Directory */
private Closure $directory,
private IdentityRepository $repo,
private RunRepository $runs,
private SyncPlanner $planner,
private IdpSyncService $groups,
private TemplateService $templates,
private LegacyAccounts $legacy,
private SogoService $sogo
) {
}
/**
* Runs the oldest requested run, else a scheduled run when the sync interval is over.
*
* @return ?array the finished run (id, mode, status, summary), null if nothing was due
*/
public function runDue(): ?array
{
$this->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']);
}
}
@@ -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
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Controller;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use Mailcow\MailboxGroup\Model\Group;
use Mailcow\MailboxGroup\Service\AccountHooks;
use Mailcow\MailboxGroup\Service\GroupService;
use Mailcow\MailboxGroup\Service\TemplateService;
use PDOException;
/**
* HTTP API adapter for json_api.php and edit.php (routes `mailbox-group`), plus the hook adapters the legacy
* code calls outside a transaction (a database error becomes a `mysql_error` message).
* Messages are logged as ['mailbox_group', action, 'group', data].
*/
class MailboxGroupApiController
{
public function __construct(private GroupService $groups, private AccountHooks $hooks, private TemplateService $templates,
private ApiResponse $response)
{
}
/** POST add/mailbox-group */
public function addGroup(array $attr): bool
{
return $this->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[/<domain,domain>] */
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/<id>, 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/<username>: 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();
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Legacy;
/**
* Bridge to the procedural mailbox code. The global calls sit in protected methods so tests can replace them.
*/
class LegacyMailbox
{
/**
* Applies a mailbox template like the IdP code does: as system caller, with the current name, so only the
* template attributes change.
*
* @return ?string null on success, else the message key of the legacy error
*/
public function applyTemplate(string $username, string $name, string $template): ?string
{
return $this->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]);
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use Mailcow\Dovecot\DovecotModule;
use Mailcow\MailboxGroup\Controller\MailboxGroupApiController;
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
use Mailcow\MailboxGroup\Service\AccountHooks;
use Mailcow\MailboxGroup\Service\ChangeEffects;
use Mailcow\MailboxGroup\Service\GroupService;
use Mailcow\MailboxGroup\Service\IdpSyncService;
use Mailcow\MailboxGroup\Service\SharingService;
use Mailcow\MailboxGroup\Service\TemplateService;
use Mailcow\Sogo\SogoModule;
use PDO;
/**
* Composition root of the mailbox group module.
*
* process_add_return(MailboxGroupModule::fromGlobals()->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 ?? []));
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Model;
/**
* A mailbox group (table `mailbox_group`) with its members and send-as grants.
*
* The group tables are the source of truth. GroupRepository::rebuild() derives the rows other services read:
* the alias row of the distribution address and the sender_acl rows of the grants, both tagged with `group_id`.
* Not to be confused with SOGo resources of kind 'group' (`mailbox.kind`).
*/
class Group
{
public const SOURCE_MANUAL = 'manual';
public const SOURCE_IDP = 'idp';
public ?int $id = null;
public string $domain = '';
public string $name = '';
public string $description = '';
/** Distribution address, null = group only used for grants and templates */
public ?string $address = null;
/** Members may send as the address */
public int $senderAllowed = 0;
public int $sogoVisible = 0;
/** Only senders of the domain may write to the address */
public int $internal = 0;
public ?int $templateId = null;
/** Template name, read only */
public ?string $template = null;
public int $priority = 0;
/** IdP group id, set = IdP memberships are synced */
public ?string $idpRef = null;
public int $active = 1;
public ?string $created = null;
public ?string $modified = null;
/** @var array<string, string> 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_<id>) */
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,
];
}
}
@@ -0,0 +1,649 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Repository;
use Mailcow\Core\Context;
use Mailcow\Core\Repository;
use Mailcow\MailboxGroup\Model\Group;
use PDO;
/**
* SQL for `mailbox_group`, its members and send-as grants, and the rows derived from them (rebuild()).
* Every write rebuilds the derived rows in the same transaction, so readers never see a half-applied group.
*/
class GroupRepository extends Repository
{
/** SOGo roles per folder type and access of a SOGo grant */
public const SOGO_ROLES = [
'Calendar' => [
'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,
];
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Repository;
use Mailcow\Core\Repository;
use PDO;
/**
* Read-only queries on tables of the legacy code (domains, mailboxes, aliases, templates) and the group
* memberships the template logic needs.
*/
class LookupRepository extends Repository
{
/** A primary domain (groups never belong to alias domains) */
public function isDomain(string $domain): bool
{
return (bool)$this->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<string, string> 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<string, ?string> 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];
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Service;
use Mailcow\MailboxGroup\Repository\GroupRepository;
/**
* Keeps groups consistent with account changes made by the legacy code (functions.mailbox.inc.php).
* No permission checks: the legacy caller already checked them.
*/
class AccountHooks
{
public function __construct(private GroupRepository $repo, private ChangeEffects $effects)
{
}
/** Before a mailbox is deleted: its memberships and the grants on its address go */
public function onMailboxDeleted(string $username): void
{
$ids = $this->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), []);
}
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Service;
use Mailcow\Dovecot\Service\DovecotService;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\Sogo\Service\SogoService;
/**
* What follows a committed group write: SOGo identities of the affected members, group templates and the caches
* of SOGo (ACLs) and Dovecot (acl_groups). Runs after the commit; failures become warnings and never roll the
* write back.
*/
class ChangeEffects
{
public function __construct(private TemplateService $templates, private SogoService $sogo, private GroupRepository $repo,
private DovecotService $dovecot)
{
}
/** Effective templates of the mailboxes a write may touch, taken before the write */
public function snapshot(array $usernames): array
{
return $this->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);
}
}
@@ -0,0 +1,485 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Service;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\MailboxGroup\Model\Group;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
/**
* Use cases and rules for mailbox groups.
*
* Read: admins all groups (also read-only API keys); domain admins with the `mailbox_groups` ACL the groups of
* their domains; users only their own memberships (membershipsOf()).
* Edit: admins; domain admins with the ACL for groups of their domains, as long as every send-as grant of the
* group is inside their domains. Template, priority and IdP link are set by admins only.
* Members: regular mailboxes the editor may access. IdP members and members outside the editor's scope are kept
* when the member list is replaced; IdP members can only be removed by the IdP sync.
*/
class GroupService
{
/** Postfix expands at most this many recipients per address (virtual_alias_expansion_limit) */
private const MAX_RECIPIENTS = 1000;
public function __construct(private GroupRepository $repo, private LookupRepository $lookup, private TemplateService $templates,
private ChangeEffects $effects, private SharingService $sharing, private Context $context)
{
}
public function canRead(): bool
{
return $this->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);
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Service;
use Mailcow\Core\UserError;
use Mailcow\MailboxGroup\Model\Group;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\MailboxGroup\Repository\LookupRepository;
/**
* Entry points for the IdP sync: system caller, no permission checks, never routed in json_api.
* Only memberships with source 'idp' are managed here; manual memberships stay.
*/
class IdpSyncService
{
public function __construct(private GroupRepository $repo, private LookupRepository $lookup, private ChangeEffects $effects)
{
}
public function findByIdpRef(string $ref): ?Group
{
return $this->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;
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Service;
use Mailcow\Dovecot\Service\DovecotService;
use Mailcow\MailboxGroup\Model\Group;
use Mailcow\MailboxGroup\Repository\GroupRepository;
use Mailcow\Sogo\Service\SogoService;
/**
* Mailbox access of group members (shared mailboxes, calendars, address books).
*
* IMAP: a grant is the Dovecot ACL group=mcg_<id> 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))]];
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Mailcow\MailboxGroup\Service;
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
use Mailcow\MailboxGroup\Repository\LookupRepository;
/**
* Group templates: the effective template of a mailbox is the one of its active group with the highest
* priority. It is applied only when it changes, so manual settings of a mailbox stay until then.
* A mailbox that leaves all groups keeps its settings.
*/
class TemplateService
{
public function __construct(private LookupRepository $lookup, private LegacyMailbox $legacy)
{
}
/** @return array<string, ?string> 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<string, string> $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;
}
}
+9
View File
@@ -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_<id>` (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
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Mailcow\Sogo\Service;
/**
* SOGo: its user view, its cache and sogo-tool in the sogo container through dockerapi. The legacy calls sit in
* protected methods so tests can replace them.
*/
class SogoService
{
/** More changed mailboxes than this: one full view rebuild instead of single updates */
private const SINGLE_UPDATES = 25;
/** Refreshes the SOGo identities (visible aliases, send-as addresses) of the given mailboxes */
public function refreshStaticView(array $usernames): void
{
$usernames = array_values(array_unique($usernames));
if (empty($usernames)) {
return;
}
if (count($usernames) > 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/<id> or Contacts/<id>
* @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');
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mailcow\Sogo;
use Mailcow\Sogo\Service\SogoService;
/**
* SOGo (user view, cache, sogo-tool through dockerapi). Modules use it instead of the legacy SOGo functions.
*/
class SogoModule
{
public function __construct(public readonly SogoService $sogo)
{
}
public static function create(): self
{
return new self(new SogoService());
}
}
+6
View File
@@ -934,6 +934,12 @@ jQuery(function($){
$('#keycloak_settings').addClass('d-none');
}
});
// LDAP: one select for the excluding use_ssl and use_tls (select only, bootstrap-select copies the class to its wrapper)
$('select.iam_ldap_encryption').on('change', function() {
var $form = $(this).closest('form');
$form.find('input[name="use_ssl"]').val(this.value === 'ssl' ? 1 : 0);
$form.find('input[name="use_tls"]').val(this.value === 'tls' ? 1 : 0);
});
function addAttributeMappingRow(list_id, del_class, e) {
e.preventDefault();
+152
View File
@@ -0,0 +1,152 @@
// Mailbox groups (admin/domainadmin mailbox page, group edit page). Loaded via $js_minifier.
var mailcowGroups = (function($) {
'use strict';
// Lang strings: lang.groups (mailbox page) or lang_groups (edit page), '' if missing
function L(key) {
var src = (typeof lang_groups !== 'undefined' && lang_groups) ? lang_groups :
((typeof lang !== 'undefined' && lang && lang.groups) ? lang.groups : {});
return (src[key] !== undefined && src[key] !== null) ? src[key] : '';
}
function esc(s) {
return escapeHtml(s === null || s === undefined ? '' : s);
}
function membersCell(members) {
if (!Array.isArray(members) || members.length === 0) return '-';
var list = members.slice(0, 3).map(function(m) {
return (m.source === 'idp' ? '<i class="bi bi-cloud" title="' + esc(L('idp_member')) + '"></i> ' : '') + esc(m.username);
}).join('<br>');
if (members.length > 3) list += '<br><span class="text-muted">+' + (members.length - 3) + '</span>';
return '<span class="badge bg-info">' + members.length + '</span><br>' + list;
}
function addressCell(item) {
if (!item.address) return '-';
var flags = [];
if (item.sender_allowed == 1) flags.push('<i class="bi bi-send" title="' + esc(L('sender_allowed')) + '"></i>');
if (item.internal == 1) flags.push('<i class="bi bi-building-lock" title="' + esc(L('internal')) + '"></i>');
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 ?
'<input type="checkbox" class="form-check-input" data-id="mailbox_group" name="multi_select" value="' + encodeURIComponent(item.id) + '" />' :
'<input type="checkbox" class="form-check-input" disabled />';
item.name_display = esc(item.name) + (item.idp_ref ? ' <span class="badge bg-info">' + esc(L('idp')) + '</span>' : '');
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
item.address_display = addressCell(item);
item.members_display = membersCell(item.members);
item.send_as_display = (item.send_as || []).map(esc).join('<br>') || '-';
item.template_display = item.template ? esc(item.template) + ' <span class="text-muted">(' + esc(item.priority) + ')</span>' : '-';
item.action = '<div class="btn-group">' +
'<a href="/edit/mailbox-group/' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + esc(item.can_edit ? lang.edit : L('view')) + '</a>' +
(item.can_edit ? '<a href="#" data-action="delete_selected" data-id="single-mailbox_group" data-api-url="delete/mailbox-group" data-item="' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + esc(lang.remove) + '</a>' : '') +
'</div>';
});
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 ? '<i class="bi bi-check-lg"></i>' : '<i class="bi bi-x-lg"></i>'; }
},
{
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 = $('<tbody>').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);
+104
View File
@@ -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 '<span class="badge ' + (cls || 'bg-light text-dark border') + ' me-1 mb-1">' + esc(label) + ': ' + esc(value) + '</span>';
}
function changes(run) {
var s = run.summary;
if (!s) return '';
if (s.error) return '<span class="text-danger">' + esc(s.error) + '</span>';
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 = '<span class="text-muted">' + esc(L('iam_sync_no_changes')) + '</span>';
var details = $.extend({}, s.details || {}, {errors: s.errors || []});
var lists = '';
$.each(DETAILS, function(i, key) {
var items = details[key] || [];
if (!items.length) return;
lists += '<div class="mt-1"><b>' + esc(L('iam_sync_count_' + key)) + '</b><ul class="mb-1">' +
$.map(items, function(item) { return '<li>' + esc(item) + '</li>'; }).join('') + '</ul></div>';
});
if (lists !== '') html += '<details class="small mt-1"><summary>' + esc(L('iam_sync_details')) + '</summary>' + lists + '</details>';
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 += '<div class="text-muted small mt-1">' + esc(L('iam_sync_off')) + '</div>';
$('#iam_sync_counts').html(counts);
var rows = $.map(status.runs || [], function(run) {
var state = '<span class="badge ' + (STATUS_CLASS[run.status] || 'bg-secondary') + '">' + esc(L('iam_sync_status_' + run.status)) + '</span>';
return '<tr><td class="text-nowrap">' + esc(run.started || run.created) + '</td>' +
'<td>' + esc(L('iam_sync_mode_' + run.mode)) + (run.requested_by ? '<div class="small text-muted">' + esc(run.requested_by) + '</div>' : '') + '</td>' +
'<td>' + state + '</td><td>' + changes(run) + '</td></tr>';
});
$('#iam_sync_runs tbody').html(rows.length ? rows.join('') : '<tr><td colspan="4" class="text-muted">' + esc(L('iam_sync_none')) + '</td></tr>');
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);
+14
View File
@@ -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 + '%' + '</div></div>'
};
item.username = escapeHtml(item.username);
if (item.attributes && item.attributes.shared == '1') {
item.username += ' <span class="badge bg-secondary">' + escapeHtml(lang.shared_mailbox) + '</span>';
}
item.name = escapeHtml(item.name);
if (Array.isArray(item.tags)){
@@ -1926,6 +1933,12 @@ jQuery(function($){
'<a href="#" data-action="delete_selected" data-id="single-alias" data-api-url="delete/alias" data-item="' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
'</div>';
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="alias" name="multi_select" value="' + encodeURIComponent(item.id) + '" />';
if (item.group_id) {
// Distribution address of a group: changed through the group
item.action = '<a href="/edit/mailbox-group/' + encodeURIComponent(item.group_id) + '" class="btn btn-sm btn-xs-lg btn-secondary"><i class="bi bi-people"></i> ' +
escapeHtml((lang.groups && lang.groups.managed_alias ? lang.groups.managed_alias : '%s').replace('%s', item.group_name)) + '</a>';
item.chkbox = '<input type="checkbox" class="form-check-input" disabled />';
}
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());
+37 -1
View File
@@ -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;
@@ -0,0 +1,41 @@
{# Redirect URLs of the OIDC forms (iam_form = keycloak|generic); row markup is used by addRedirectUrlRow() in admin.js #}
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_redirect_url }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<div class="row mx-0 align-items-center">
<span class="col-10 p-0 pe-2">
<input type="text" class="form-control" name="redirect_url" value="{{ iam_settings.redirect_url }}" required>
</span>
<div class="col-2 p-0 d-flex">
<button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_redirect_add_{{ iam_form }}" title="{{ lang.admin.iam_redirect_url_add }}"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-2" id="iam_{{ iam_form }}_redirect_list">
<input type="hidden" name="redirect_url_extra" value="">
{% for key, url in iam_settings.redirect_url_extra %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row mx-0">
<div class="col-10 p-0 pe-2">
<input type="text" class="form-control me-2" name="redirect_url_extra" value="{{ iam_settings.redirect_url_extra[key] }}">
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_{{ iam_form }}_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row mx-0">
<div class="col-10 p-0 pe-2">
<input type="text" class="form-control me-2" name="redirect_url_extra" value="">
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_{{ iam_form }}_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,153 @@
{# Sync and group settings of the Keycloak and LDAP forms (iam_form = keycloak|ldap) #}
<legend class="fs-5 mt-4"><i class="bi bi-arrow-repeat"></i> {{ lang.admin.iam_section_sync }}</legend><hr>
{% if iam_form == 'keycloak' %}
<div class="row mb-2">
<div class="offset-md-3 col-12 col-md-9">
<small class="text-muted">{{ lang.admin.iam_extra_permission|raw }}</small>
</div>
</div>
{% endif %}
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_import_users_info }}"></i>
<label class="control-label">{{ lang.admin.iam_import_users }}</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="import_users" value="1" {% if iam_settings.import_users == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_periodic_full_sync_info }}"></i>
<label class="control-label">{{ lang.admin.iam_periodic_full_sync }}</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="periodic_sync" value="1" {% if iam_settings.periodic_sync == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_offboarding_info }}"></i>
<label class="control-label">{{ lang.admin.iam_offboarding }}</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="offboarding" value="1" {% if iam_settings.offboarding == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_sync_interval }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input class="form-control" type="number" min="1" name="sync_interval" style="width: 80px;" value="{{ iam_settings.sync_interval ?: 15 }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_offboarding_limit_info }}"></i>
<label class="control-label">{{ lang.admin.iam_offboarding_limit }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input class="form-control" type="number" min="0" max="100" name="offboarding_limit" style="width: 80px;" value="{{ iam_settings.offboarding_limit ?? 10 }}">
</div>
</div>
<legend class="fs-5 mt-4"><i class="bi bi-people"></i> {{ lang.admin.iam_section_groups }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_group_sync_info }}"></i>
<label class="control-label">{{ lang.admin.iam_group_sync }}</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="group_sync" value="1" {% if iam_settings.group_sync == 1 %}checked{% endif %}>
</div>
</div>
</div>
{% if iam_form == 'keycloak' %}
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_group_paths_info }}"></i>
<label class="control-label" for="iam_{{ iam_form }}_group_paths">{{ lang.admin.iam_group_paths }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="/mailcow/*" id="iam_{{ iam_form }}_group_paths" name="group_paths" value="{{ iam_settings.group_paths }}">
</div>
</div>
{% else %}
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_{{ iam_form }}_group_basedn">{{ lang.admin.iam_group_basedn }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="{{ lang.admin.iam_basedn }}" id="iam_{{ iam_form }}_group_basedn" name="group_basedn" value="{{ iam_settings.group_basedn }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_{{ iam_form }}_group_filter">{{ lang.admin.iam_group_filter }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="(&amp;(objectClass=group)(cn=mailcow-*))" id="iam_{{ iam_form }}_group_filter" name="group_filter" value="{{ iam_settings.group_filter }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_{{ iam_form }}_group_name_attribute">{{ lang.admin.iam_group_name_attribute }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="cn" id="iam_{{ iam_form }}_group_name_attribute" name="group_name_attribute" value="{{ iam_settings.group_name_attribute }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_ldap_group_member_mode">{{ lang.admin.iam_group_member_mode }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<select id="iam_ldap_group_member_mode" name="group_member_mode" class="form-control">
{% for mode in ['memberof', 'member', 'ad_chain'] %}
<option value="{{ mode }}" {% if (iam_settings.group_member_mode ?: 'memberof') == mode %}selected{% endif %}>{{ attribute(lang.admin, 'iam_group_member_mode_' ~ mode) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_group_member_attribute_info }}"></i>
<label class="control-label" for="iam_{{ iam_form }}_group_member_attribute">{{ lang.admin.iam_group_member_attribute }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="member" id="iam_{{ iam_form }}_group_member_attribute" name="group_member_attribute" value="{{ iam_settings.group_member_attribute }}">
</div>
</div>
{% endif %}
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_group_autocreate_info }}"></i>
<label class="control-label">{{ lang.admin.iam_group_autocreate }}</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="group_autocreate" value="1" {% if iam_settings.group_autocreate is not defined or iam_settings.group_autocreate == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_group_default_domain_info }}"></i>
<label class="control-label">{{ lang.admin.iam_group_default_domain }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<select data-live-search="true" name="group_default_domain" class="form-control" title="-- {{ lang.admin.iam_group_default_domain }} --">
<option value="" {% if not iam_settings.group_default_domain %}selected{% endif %}>-- {{ lang.admin.iam_group_default_domain }} --</option>
{% for domain in domains %}
<option {% if domain == iam_settings.group_default_domain %}selected{% endif %}>{{ domain }}</option>
{% endfor %}
</select>
</div>
</div>
@@ -0,0 +1,30 @@
{# Status of the identity sync (Keycloak and LDAP), filled by js/site/identity.js #}
<div class="card mt-4" id="iam_sync_status">
<div class="card-header d-flex flex-wrap align-items-center">
<span class="me-2">{{ lang.admin.iam_sync_status }}</span>
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-secondary iam_sync_request" data-mode="preview"><i class="bi bi-eye"></i> {{ lang.admin.iam_sync_preview }}</button>
<button class="btn btn-sm btn-primary iam_sync_request" data-mode="run"><i class="bi bi-play"></i> {{ lang.admin.iam_sync_run }}</button>
<button class="btn btn-sm btn-danger iam_sync_request" data-mode="force"><i class="bi bi-exclamation-triangle"></i> {{ lang.admin.iam_sync_force }}</button>
</div>
</div>
<div class="card-body">
<p class="text-muted"><small>{{ lang.admin.iam_sync_status_info }}</small></p>
<div id="iam_sync_counts" class="mb-3"></div>
<div class="table-responsive">
<table class="table table-sm table-striped mb-0" id="iam_sync_runs">
<thead>
<tr>
<th>{{ lang.admin.iam_sync_started }}</th>
<th>{{ lang.admin.iam_sync_mode }}</th>
<th>{{ lang.admin.iam_sync_result }}</th>
<th>{{ lang.admin.iam_sync_changes }}</th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-muted">{{ lang.admin.iam_sync_none }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -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 #}
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_default_template_description }}"></i>
<label class="control-label">{{ lang.admin.iam_default_template }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<select data-live-search="true" name="default_template" class="form-control" title="-- {{ lang.mailbox.template }} --">
<option value="" {% if not iam_settings.default_template %}selected{% endif %}>-- {{ lang.mailbox.template }} --</option>
{% for mbox_template in mbox_templates %}
<option {% if mbox_template.template == iam_settings.default_template %}selected{% endif %}>{{ mbox_template.template }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_mapping_info }}"></i>
<label class="control-label">{{ lang.admin.iam_mapping }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<div class="row mx-0 align-items-center">
<span class="col-5 p-0 pe-2 text-muted">{{ lang.user.attribute }}</span>
<span class="col-5 p-0 pe-2 text-muted">{{ lang.mailbox.template }}</span>
<div class="col-2 p-0 d-flex">
<button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_rolemap_add_{{ iam_form }}" title="{{ lang.admin.iam_mapping_add }}"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-2" id="iam_{{ iam_form }}_mapping_list">
<input type="hidden" name="mappers" value="">
<input type="hidden" name="templates" value="">
{% for key, role in iam_settings.mappers %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row mx-0">
<div class="col-5 p-0 pe-2">
<input type="text" class="form-control me-2" name="mappers" value="{{ iam_settings.mappers[key] }}" required>
</div>
<div class="col-5 p-0 pe-2">
<select data-live-search="true" name="templates" class="form-control" title="-- {{ lang.mailbox.template }} --" required>
{% for mbox_template in mbox_templates %}
<option{% if mbox_template.template == iam_settings.templates[key] %} selected{% endif %}>{{ mbox_template.template }}</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_{{ iam_form }}_rolemap_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row mx-0">
<div class="col-5 p-0 pe-2">
<input type="text" class="form-control me-2" name="mappers" value="">
</div>
<div class="col-5 p-0 pe-2">
<select data-live-search="true" name="templates" class="form-control" title="-- {{ lang.mailbox.template }} --">
<option value="" selected>-- {{ lang.mailbox.template }} --</option>
{% for mbox_template in mbox_templates %}
<option>{{ mbox_template.template }}</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_{{ iam_form }}_rolemap_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
@@ -10,7 +10,7 @@
<p class="offset-sm-3 mb-4">{{ lang.admin.iam_description|raw }}</p>
<div class="row mb-4">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_realm">{{ lang.admin.iam }}:</label>
<label class="control-label" for="iam_provider">{{ lang.admin.iam }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<select
@@ -24,15 +24,17 @@
</select>
</div>
</div>
{# Keycloak #}
<div id="keycloak_settings" class="{% if iam_settings.authsource and iam_settings.authsource != 'keycloak' %}d-none{% endif %}">
<form class="form-horizontal" autocapitalize="none" data-id="iam_keycloak" autocorrect="off" role="form" method="post">
<input type="hidden" name="authsource" value="keycloak">
<legend class="fs-5 mt-2"><i class="bi bi-plug"></i> {{ lang.admin.iam_section_connection }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_keycloak_url">{{ lang.admin.iam_server_url }}:</label>
<label class="control-label" for="iam_keycloak_url">{{ lang.admin.iam_server_url }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" id="iam_keycloak_url" name="server_url" value="{{ iam_settings.server_url }}" required>
<input type="text" class="form-control" id="iam_keycloak_url" name="server_url" value="{{ iam_settings.server_url }}" placeholder="https://auth.example.org" required>
</div>
</div>
<div class="row mb-2">
@@ -43,6 +45,14 @@
<input type="text" class="form-control" id="iam_keycloak_realm" name="realm" value="{{ iam_settings.realm }}" required>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_keycloak_version">{{ lang.admin.iam_version }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" id="iam_keycloak_version" name="version" value="{{ iam_settings.version }}" placeholder="26.0.0" required>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_keycloak_clientid">{{ lang.admin.iam_client_id }}:</label>
@@ -62,215 +72,62 @@
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_redirect_url }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<div class="row px-2 align-items-center">
<span class="col-10 p-0 pe-2">
<input type="text" class="form-control" name="redirect_url" value="{{ iam_settings.redirect_url }}" required>
</span>
<div class="col-2 p-0 d-flex">
<button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_redirect_add_keycloak"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-2" id="iam_keycloak_redirect_list">
<input type="hidden" name="redirect_url_extra" value="">
{% for key, url in iam_settings.redirect_url_extra %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-10 p-0 pe-2">
<input type="text" class="form-control me-2" name="redirect_url_extra" value="{{ iam_settings.redirect_url_extra[key] }}">
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_keycloak_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-10 p-0 pe-2">
<input type="text" class="form-control me-2" name="redirect_url_extra" value="">
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_keycloak_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_keycloak_version">{{ lang.admin.iam_version }}:</label>
</div>
<div class="col-sm-4">
<input type="text" class="form-control" id="iam_keycloak_version" name="version" value="{{ iam_settings.version }}" required>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_mapping }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<div class="row px-2 align-items-center">
<span class="col-5 p-0 pe-2">{{ lang.user.attribute }}</span>
<span class="col-5 p-0 pe-2">{{ lang.mailbox.template }}</span>
<div class="col-2 p-0 d-flex">
<button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_rolemap_add_keycloak"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-2" id="iam_keycloak_mapping_list">
<input type="hidden" name="mappers" value="">
<input type="hidden" name="templates" value="">
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_default_template_description }}"></i>
<span>{{ lang.admin.iam_default_template}}</span>
</div>
<div class="col-5 p-0 pe-2 align-content-end">
<select data-live-search="true" name="default_template" class="form-control" title="-- {{ lang.mailbox.template }} --">
<option value="" {% if not iam_settings.default_template %}selected{% endif %}>-- {{ lang.mailbox.template }} --</option>
{% for mbox_template in mbox_templates %}
<option {% if mbox_template.template == iam_settings.default_template %}selected{% endif %}>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex"></div>
</div>
</div>
{% for key, role in iam_settings.mappers %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<input type="text" class="form-control me-2" name="mappers" value="{{ iam_settings.mappers[key] }}" required>
</div>
<div class="col-5 p-0 pe-2">
<select data-live-search="true" name="templates" class="form-control" title="-- {{ lang.mailbox.template }} --" required>
{% for mbox_template in mbox_templates %}
<option{% if mbox_template.template == iam_settings.templates[key] %} selected{% endif %}>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_keycloak_rolemap_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<input type="text" class="form-control me-2" name="mappers" value="">
</div>
<div class="col-5 p-0 pe-2">
<select data-live-search="true" name="templates" class="form-control" title="-- {{ lang.mailbox.template }} --">
<option value="" selected>-- {{ lang.mailbox.template }} --</option>
{% for mbox_template in mbox_templates %}
<option>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_keycloak_rolemap_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-2 mt-4">
<div class="col-md-3 d-flex align-items-center justify-content-md-end"></div>
<div class="col-12 col-md-9">
<span>{{ lang.admin.iam_extra_permission|raw }}</span>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-start justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_rest_flow }}</label>
</div>
<div class="col-12 col-md-9">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="mailpassword_flow" value="1" {% if iam_settings.mailpassword_flow == 1 %}checked{% endif %}>
</div>
<p class="text-muted">
<small>
{{ lang.admin.iam_auth_flow_info|raw }}
</small>
</p>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.ignore_ssl_error }}</label>
</div>
<div class="col-12 col-md-9">
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="ignore_ssl_error" value="1" {% if iam_settings.ignore_ssl_error == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="offset-md-3 col-12 col-md-9">
<button class="btn btn-sm d-block d-sm-inline btn-secondary iam_test_connection" data-id="iam_keycloak"><i class="bi bi-play"></i> {{ lang.admin.iam_test_connection }}</button>
</div>
</div>
<legend class="fs-5 mt-4"><i class="bi bi-box-arrow-in-right"></i> {{ lang.admin.iam_section_login }}</legend><hr>
{% include 'admin/iam-redirect-urls.twig' with {'iam_form': 'keycloak'} %}
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_auth_flow_info }}"></i>
<label class="control-label">{{ lang.admin.iam_rest_flow }}</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="mailpassword_flow" value="1" {% if iam_settings.mailpassword_flow == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_login_provisioning_info }}"></i>
<label class="control-label">{{ lang.admin.iam_login_provisioning }}</label>
</div>
<div class="col-12 col-md-9">
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="login_provisioning" value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
<input class="form-check-input" type="checkbox" role="switch" name="login_provisioning" value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
</div>
</div>
</div>
<legend class="fs-5 mt-4"><i class="bi bi-file-earmark-text"></i> {{ lang.admin.iam_section_templates }}</legend><hr>
{% include 'admin/iam-template-mapping.twig' with {'iam_form': 'keycloak'} %}
{% include 'admin/iam-sync-settings.twig' with {'iam_form': 'keycloak'} %}
<hr class="mt-4">
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_periodic_full_sync }}</label>
</div>
<div class="col-12 col-md-9">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="periodic_sync" value="1" {% if iam_settings.periodic_sync == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_import_users }}</label>
</div>
<div class="col-12 col-md-9">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="import_users" value="1" {% if iam_settings.import_users == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_sync_interval }}</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input class="form-control" type="number" min="1" name="sync_interval" style="width: 80px;" {% if iam_settings.sync_interval %}value="{{ iam_settings.sync_interval }}"{% else %}value="15"{% endif %}>
</div>
</div>
<div class="row mt-4 mb-2">
<div class="offset-md-3 col-12 col-md-9 d-flex flex-wrap">
<div class="btn-group mb-2">
<button class="btn btn-sm d-block d-sm-inline btn-secondary iam_test_connection iam_test_connection" data-id="iam_keycloak"><i class="bi bi-play"></i> {{ lang.admin.iam_test_connection }}</button>
<button class="btn btn-sm d-block d-sm-inline btn-success" data-item="identity-provider" data-action="edit_selected" data-id="iam_keycloak" data-api-url='edit/identity-provider' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
<button class="btn btn-sm d-block d-sm-inline btn-success mb-2" data-item="identity-provider" data-action="edit_selected" data-id="iam_keycloak" data-api-url='edit/identity-provider' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-sm d-block d-sm-inline btn-danger ms-auto mb-2" data-item="identity-provider" data-action="delete_selected" data-id="iam_keycloak" data-api-url='delete/identity-provider'><i class="bi bi-trash"></i> {{ lang.mailbox.remove }}</button>
</div>
</div>
</form>
</div>
{# Generic OIDC #}
<div id="generic_oidc_settings" class="{% if not iam_settings.authsource or iam_settings.authsource != 'generic-oidc' %}d-none{% endif %}">
<form class="form-horizontal" autocapitalize="none" data-id="iam_generic" autocorrect="off" role="form" method="post">
<input type="hidden" name="authsource" value="generic-oidc">
<legend class="fs-5 mt-2"><i class="bi bi-plug"></i> {{ lang.admin.iam_section_connection }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_authorize_url">{{ lang.admin.iam_authorize_url }}:</label>
@@ -315,46 +172,6 @@
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_redirect_url }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<div class="row px-2 align-items-center">
<span class="col-10 p-0 pe-2">
<input type="text" class="form-control" name="redirect_url" value="{{ iam_settings.redirect_url }}" required>
</span>
<div class="col-2 p-0 d-flex">
<button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_redirect_add_generic"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-2" id="iam_generic_redirect_list">
<input type="hidden" name="redirect_url_extra" value="">
{% for key, url in iam_settings.redirect_url_extra %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-10 p-0 pe-2">
<input type="text" class="form-control me-2" name="redirect_url_extra" value="{{ iam_settings.redirect_url_extra[key] }}">
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_generic_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-10 p-0 pe-2">
<input type="text" class="form-control me-2" name="redirect_url_extra" value="">
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_generic_redirect_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_client_scopes">{{ lang.admin.iam_client_scopes }}:</label>
</div>
@@ -362,125 +179,57 @@
<input type="text" placeholder="openid profile email mailcow_template" class="form-control" id="iam_client_scopes" name="client_scopes" value="{{ iam_settings.client_scopes }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_mapping }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<div class="row px-2 align-items-center">
<span class="col-5 p-0 pe-2">{{ lang.user.attribute }}</span>
<span class="col-5 p-0 pe-2">{{ lang.mailbox.template }}</span>
<div class="col-2 p-0 d-flex">
<button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_rolemap_add_generic"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-2" id="iam_generic_mapping_list">
<input type="hidden" name="mappers" value="">
<input type="hidden" name="templates" value="">
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_default_template_description }}"></i>
<span>{{ lang.admin.iam_default_template}}</span>
</div>
<div class="col-5 p-0 pe-2 align-content-end">
<select data-live-search="true" name="default_template" class="form-control" title="-- {{ lang.mailbox.template }} --">
<option value="" {% if not iam_settings.default_template %}selected{% endif %}>-- {{ lang.mailbox.template }} --</option>
{% for mbox_template in mbox_templates %}
<option {% if mbox_template.template == iam_settings.default_template %}selected{% endif %}>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex"></div>
</div>
</div>
{% for key, role in iam_settings.mappers %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<input type="text" class="form-control me-2" name="mappers" value="{{ iam_settings.mappers[key] }}" required>
</div>
<div class="col-5 p-0 pe-2">
<select data-live-search="true" name="templates" class="form-control" title="-- {{ lang.mailbox.template }} --" required>
{% for mbox_template in mbox_templates %}
<option{% if mbox_template.template == iam_settings.templates[key] %} selected{% endif %}>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_generic_rolemap_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<input type="text" class="form-control me-2" name="mappers" value="">
</div>
<div class="col-5 p-0 pe-2">
<select data-live-search="true" name="templates" class="form-control" title="-- {{ lang.mailbox.template }} --">
<option value="" selected>-- {{ lang.mailbox.template }} --</option>
{% for mbox_template in mbox_templates %}
<option>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_generic_rolemap_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.ignore_ssl_error }}</label>
</div>
<div class="col-12 col-md-9">
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="ignore_ssl_error" value="1" {% if iam_settings.ignore_ssl_error == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-4">
<div class="row mb-2">
<div class="offset-md-3 col-12 col-md-9">
<button class="btn btn-sm d-block d-sm-inline btn-secondary iam_test_connection" data-id="iam_generic"><i class="bi bi-play"></i> {{ lang.admin.iam_test_connection }}</button>
</div>
</div>
<legend class="fs-5 mt-4"><i class="bi bi-box-arrow-in-right"></i> {{ lang.admin.iam_section_login }}</legend><hr>
{% include 'admin/iam-redirect-urls.twig' with {'iam_form': 'generic'} %}
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_login_provisioning_info }}"></i>
<label class="control-label">{{ lang.admin.iam_login_provisioning }}</label>
</div>
<div class="col-12 col-md-9">
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="login_provisioning" value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
<input class="form-check-input" type="checkbox" role="switch" name="login_provisioning" value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mt-4 mb-2">
<legend class="fs-5 mt-4"><i class="bi bi-file-earmark-text"></i> {{ lang.admin.iam_section_templates }}</legend><hr>
{% include 'admin/iam-template-mapping.twig' with {'iam_form': 'generic'} %}
<hr class="mt-4">
<div class="row mb-2">
<div class="offset-md-3 col-12 col-md-9 d-flex flex-wrap">
<div class="btn-group mb-2">
<button class="btn btn-sm d-block d-sm-inline btn-secondary iam_test_connection" data-id="iam_generic"><i class="bi bi-play"></i> {{ lang.admin.iam_test_connection }}</button>
<button class="btn btn-sm d-block d-sm-inline btn-success" data-item="identity-provider" data-action="edit_selected" data-id="iam_generic" data-api-url='edit/identity-provider' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
<button class="btn btn-sm d-block d-sm-inline btn-success mb-2" data-item="identity-provider" data-action="edit_selected" data-id="iam_generic" data-api-url='edit/identity-provider' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-sm d-block d-sm-inline btn-danger ms-auto mb-2" data-item="identity-provider" data-action="delete_selected" data-id="iam_generic" data-api-url='delete/identity-provider'><i class="bi bi-trash"></i> {{ lang.mailbox.remove }}</button>
</div>
</div>
</form>
</div>
{# LDAP #}
<div id="ldap_settings" class="{% if not iam_settings.authsource or iam_settings.authsource != 'ldap' %}d-none{% endif %}">
<form class="form-horizontal" autocapitalize="none" data-id="iam_ldap" autocorrect="off" role="form" method="post">
<input type="hidden" name="authsource" value="ldap">
<legend class="fs-5 mt-2"><i class="bi bi-plug"></i> {{ lang.admin.iam_section_connection }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_host_info }}"></i>
<label class="control-label" for="iam_ldap_host">{{ lang.admin.iam_host }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4 d-flex">
<input type="text" class="form-control" id="iam_ldap_host" name="host" value="{{ iam_settings.host }}" required>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" id="iam_ldap_host" name="host" value="{{ iam_settings.host }}" placeholder="ldap1.example.org, ldap2.example.org" required>
</div>
</div>
<div class="row mb-2">
@@ -488,96 +237,44 @@
<label class="control-label" for="iam_ldap_port">{{ lang.admin.iam_port }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="number" class="form-control" id="iam_ldap_port" name="port" value="{{ iam_settings.port }}" required>
<input type="number" class="form-control" id="iam_ldap_port" name="port" value="{{ iam_settings.port }}" placeholder="389" required>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_use_ssl_info }}"></i>
<label class="control-label">{{ lang.admin.iam_use_ssl }}</label>
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_encryption_info }}"></i>
<label class="control-label" for="iam_ldap_encryption">{{ lang.admin.iam_encryption }}:</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
role="switch"
id="use_ssl"
name="use_ssl"
value="1"
onchange="if(this.checked) document.getElementById('use_tls').checked = false"
{% if iam_settings.use_ssl == 1 %}checked{% endif %}>
</div>
<div class="col-12 col-md-9 col-lg-4">
{# use_ssl and use_tls exclude each other, the select sets both #}
<input type="hidden" name="use_ssl" value="{{ iam_settings.use_ssl ? 1 : 0 }}">
<input type="hidden" name="use_tls" value="{{ iam_settings.use_tls ? 1 : 0 }}">
<select id="iam_ldap_encryption" class="form-control iam_ldap_encryption">
<option value="none" {% if not iam_settings.use_ssl and not iam_settings.use_tls %}selected{% endif %}>{{ lang.admin.iam_encryption_none }}</option>
<option value="tls" {% if iam_settings.use_tls and not iam_settings.use_ssl %}selected{% endif %}>StartTLS</option>
<option value="ssl" {% if iam_settings.use_ssl %}selected{% endif %}>SSL (LDAPS)</option>
</select>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_use_tls_info }}"></i>
<label class="control-label">{{ lang.admin.iam_use_tls }}</label>
</div>
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
role="switch"
id="use_tls"
name="use_tls"
value="1"
onchange="if(this.checked) document.getElementById('use_ssl').checked = false"
{% if iam_settings.use_tls == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.ignore_ssl_error }}</label>
</div>
<div class="col-12 col-md-9">
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="ignore_ssl_error" value="1" {% if iam_settings.ignore_ssl_error == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_ldap_basedn">{{ lang.admin.iam_basedn }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" id="iam_ldap_basedn" name="basedn" value="{{ iam_settings.basedn }}" required>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_ldap_username_field">{{ lang.admin.iam_username_field }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="mail" id="iam_ldap_username_field" name="username_field" value="{{ iam_settings.username_field }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_ldap_filter">{{ lang.admin.filter }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="" id="iam_ldap_filter" name="filter" value="{{ iam_settings.filter }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_ldap_attribute_field">{{ lang.admin.iam_attribute_field }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" id="iam_ldap_attribute_field" name="attribute_field" value="{{ iam_settings.attribute_field }}" required>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_ldap_binddn">{{ lang.admin.iam_binddn }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" id="iam_ldap_binddn" name="binddn" value="{{ iam_settings.binddn }}" required>
<input type="text" class="form-control" id="iam_ldap_binddn" name="binddn" value="{{ iam_settings.binddn }}" placeholder="cn=mailcow,ou=services,dc=example,dc=org" required>
</div>
</div>
<div class="row mb-4">
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_ldap_bindpass">{{ lang.admin.iam_bindpass }}:</label>
</div>
@@ -589,132 +286,91 @@
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_mapping }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<div class="row px-2 align-items-center">
<span class="col-5 p-0 pe-2">{{ lang.user.attribute }}</span>
<span class="col-5 p-0 pe-2">{{ lang.mailbox.template }}</span>
<div class="col-2 p-0 d-flex">
<button class="btn btn-sm d-block d-sm-inline btn-secondary ms-auto iam_rolemap_add_ldap"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
<div class="offset-md-3 col-12 col-md-9">
<button class="btn btn-sm d-block d-sm-inline btn-secondary iam_test_connection" data-id="iam_ldap"><i class="bi bi-play"></i> {{ lang.admin.iam_test_connection }}</button>
</div>
</div>
<div class="row mb-2" id="iam_ldap_mapping_list">
<input type="hidden" name="mappers" value="">
<input type="hidden" name="templates" value="">
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_default_template_description }}"></i>
<span>{{ lang.admin.iam_default_template }}</span>
</div>
<div class="col-5 p-0 pe-2 align-content-end">
<select data-live-search="true" name="default_template" class="form-control" title="-- {{ lang.mailbox.template }} --">
<option value="" {% if not iam_settings.default_template %}selected{% endif %}>-- {{ lang.mailbox.template }} --</option>
{% for mbox_template in mbox_templates %}
<option {% if mbox_template.template == iam_settings.default_template %}selected{% endif %}>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex"></div>
</div>
<legend class="fs-5 mt-4"><i class="bi bi-person-lines-fill"></i> {{ lang.admin.iam_section_users }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label" for="iam_ldap_basedn">{{ lang.admin.iam_basedn }}:</label>
</div>
{% for key, role in iam_settings.mappers %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<input type="text" class="form-control me-2" name="mappers" value="{{ iam_settings.mappers[key] }}" required>
</div>
<div class="col-5 p-0 pe-2">
<select data-live-search="true" name="templates" class="form-control" title="-- {{ lang.mailbox.template }} --" required>
{% for mbox_template in mbox_templates %}
<option{% if mbox_template.template == iam_settings.templates[key] %} selected{% endif %}>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_ldap_rolemap_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
{% endfor %}
<div class="offset-md-3 col-12 col-md-9 col-lg-4 mb-2">
<div class="row px-2">
<div class="col-5 p-0 pe-2">
<input type="text" class="form-control me-2" name="mappers" value="">
</div>
<div class="col-5 p-0 pe-2">
<select data-live-search="true" name="templates" class="form-control" title="-- {{ lang.mailbox.template }} --">
<option value="" selected>-- {{ lang.mailbox.template }} --</option>
{% for mbox_template in mbox_templates %}
<option>
{{ mbox_template.template }}
</option>
{% endfor %}
</select>
</div>
<div class="col-2 p-0 d-flex">
<button class="iam_ldap_rolemap_del btn btn-sm d-block d-sm-inline btn-secondary ms-auto"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" id="iam_ldap_basedn" name="basedn" value="{{ iam_settings.basedn }}" placeholder="ou=people,dc=example,dc=org" required>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_filter_info }}"></i>
<label class="control-label" for="iam_ldap_filter">{{ lang.admin.filter }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="(objectClass=person)" id="iam_ldap_filter" name="filter" value="{{ iam_settings.filter }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_username_field_info }}"></i>
<label class="control-label" for="iam_ldap_username_field">{{ lang.admin.iam_username_field }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="mail" id="iam_ldap_username_field" name="username_field" value="{{ iam_settings.username_field }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_id_attribute_info }}"></i>
<label class="control-label" for="iam_ldap_id_attribute">{{ lang.admin.iam_id_attribute }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="objectGUID, entryUUID" id="iam_ldap_id_attribute" name="id_attribute" value="{{ iam_settings.id_attribute }}">
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_disabled_filter_info }}"></i>
<label class="control-label" for="iam_ldap_disabled_filter">{{ lang.admin.iam_disabled_filter }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input type="text" class="form-control" placeholder="(employeeType=inactive)" id="iam_ldap_disabled_filter" name="disabled_filter" value="{{ iam_settings.disabled_filter }}">
</div>
</div>
<legend class="fs-5 mt-4"><i class="bi bi-box-arrow-in-right"></i> {{ lang.admin.iam_section_login }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_login_provisioning_info }}"></i>
<label class="control-label">{{ lang.admin.iam_login_provisioning }}</label>
</div>
<div class="col-12 col-md-9">
<div class="col-12 col-md-9 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="login_provisioning" value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
<input class="form-check-input" type="checkbox" role="switch" name="login_provisioning" value="1" {% if iam_settings.login_provisioning == 1 %}checked{% endif %}>
</div>
</div>
</div>
<legend class="fs-5 mt-4"><i class="bi bi-file-earmark-text"></i> {{ lang.admin.iam_section_templates }}</legend><hr>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_periodic_full_sync }}</label>
</div>
<div class="col-12 col-md-9">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="periodic_sync" value="1" {% if iam_settings.periodic_sync == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_import_users }}</label>
</div>
<div class="col-12 col-md-9">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="import_users" value="1" {% if iam_settings.import_users == 1 %}checked{% endif %}>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
<label class="control-label">{{ lang.admin.iam_sync_interval }}</label>
<i style="font-size: 16px; cursor: pointer;" class="bi bi-patch-question-fill mx-2 ms-0" data-bs-toggle="tooltip" data-bs-html="true" data-bs-placement="bottom" title="{{ lang.admin.iam_attribute_field_info }}"></i>
<label class="control-label" for="iam_ldap_attribute_field">{{ lang.admin.iam_attribute_field }}:</label>
</div>
<div class="col-12 col-md-9 col-lg-4">
<input class="form-control" type="number" min="1" name="sync_interval" style="width: 80px;" {% if iam_settings.sync_interval %}value="{{ iam_settings.sync_interval }}"{% else %}value="15"{% endif %}>
<input type="text" class="form-control" id="iam_ldap_attribute_field" name="attribute_field" value="{{ iam_settings.attribute_field }}" placeholder="departmentNumber">
</div>
</div>
<div class="row mt-4 mb-2">
{% include 'admin/iam-template-mapping.twig' with {'iam_form': 'ldap'} %}
{% include 'admin/iam-sync-settings.twig' with {'iam_form': 'ldap'} %}
<hr class="mt-4">
<div class="row mb-2">
<div class="offset-md-3 col-12 col-md-9 d-flex flex-wrap">
<div class="btn-group mb-2">
<button class="btn btn-sm d-block d-sm-inline btn-secondary iam_test_connection iam_test_connection" data-id="iam_ldap"><i class="bi bi-play"></i> {{ lang.admin.iam_test_connection }}</button>
<button class="btn btn-sm d-block d-sm-inline btn-success" data-item="identity-provider" data-action="edit_selected" data-id="iam_ldap" data-api-url='edit/identity-provider' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
<button class="btn btn-sm d-block d-sm-inline btn-success mb-2" data-item="identity-provider" data-action="edit_selected" data-id="iam_ldap" data-api-url='edit/identity-provider' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-sm d-block d-sm-inline btn-danger ms-auto mb-2" data-item="identity-provider" data-action="delete_selected" data-id="iam_ldap" data-api-url='delete/identity-provider'><i class="bi bi-trash"></i> {{ lang.mailbox.remove }}</button>
</div>
</div>
</form>
</div>
{% if iam_settings.authsource == 'keycloak' or iam_settings.authsource == 'ldap' %}
{% include 'admin/iam-sync-status.twig' %}
{% endif %}
</div>
</div>
</div>
+1
View File
@@ -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 }}';
@@ -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' %}
<tr class="group-grant">
<td class="{{ kind == 'imap' ? 'w-50' : 'w-25' }}">
<select data-grant="owner" data-live-search="true" data-width="100%" class="form-control" title="{{ lang.groups.grant_owner }}"{% if ro %} disabled{% endif %}>
{% for mailbox in mailboxes %}
<option{% if mailbox == grant.owner %} selected{% endif %}>{{ mailbox }}</option>
{% endfor %}
{% if grant.owner and grant.owner not in mailboxes %}
<option selected>{{ grant.owner }}</option>
{% endif %}
</select>
</td>
{% if kind == 'sogo' %}
<td>
<select data-grant="type" data-width="100%" class="form-control"{% if ro %} disabled{% endif %}>
<option value="Calendar"{% if grant.type == 'Calendar' %} selected{% endif %}>{{ lang.groups.type_calendar }}</option>
<option value="Contacts"{% if grant.type == 'Contacts' %} selected{% endif %}>{{ lang.groups.type_contacts }}</option>
</select>
</td>
<td class="w-25">
<input type="text" data-grant="folder" class="form-control" value="{{ grant.folder ?? 'personal' }}" maxlength="100" title="{{ lang.groups.grant_folder }}"{% if ro %} disabled{% endif %}>
</td>
{% endif %}
<td class="{{ kind == 'imap' ? 'w-25' : '' }}">
<select data-grant="{{ level_key }}" data-width="100%" class="form-control"{% if ro %} disabled{% endif %}>
{% for level in (kind == 'imap' ? ['read', 'write', 'full'] : ['read', 'write']) %}
<option value="{{ level }}"{% if level == attribute(grant, level_key)|default('read') %} selected{% endif %}>{{ attribute(lang.groups, 'rights_' ~ level) }}</option>
{% endfor %}
</select>
</td>
<td class="text-nowrap">
<div class="form-check mb-0" title="{{ attribute(lang.groups, 'grant_subscribe_' ~ kind ~ '_hint') }}">
<label><input type="checkbox" class="form-check-input" data-grant="subscribe" value="1"{% if subscribed %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.groups.grant_subscribe }}</label>
</div>
</td>
<td>
{% if grant.state %}
<span class="badge {{ grant.state == 'applied' ? 'bg-success' : (grant.state == 'failed' ? 'bg-danger' : 'bg-secondary') }}"{% if grant.last_error %} title="{{ grant.last_error }}"{% endif %}>{{ attribute(lang.groups, 'grant_state_' ~ grant.state) }}</span>
{% endif %}
</td>
<td class="text-end">
{% if not ro %}
<button class="btn btn-sm btn-secondary group-grant-del" title="{{ lang.mailbox.remove }}"><i class="bi bi-x-lg"></i></button>
{% endif %}
</td>
</tr>
@@ -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' %}
<div class="row mb-2">
<label class="control-label col-sm-2{{ label_class|default('') }}">{{ attribute(lang.groups, field) }}</label>
<div class="col-sm-10">
{% if not ro %}
<input type="hidden" name="{{ field }}" value="{{ grants|json_encode }}">
{% endif %}
<table class="table table-sm align-middle group-grants mb-1" id="{{ id }}" data-field="{{ field }}">
<tbody>
{% for grant in grants %}
{% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': grant} %}
{% endfor %}
</tbody>
</table>
{% if not ro %}
<template id="{{ id }}_template">
{% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': {}} %}
</template>
<button class="btn btn-sm btn-secondary group-grant-add" data-table="{{ id }}"><i class="bi bi-plus-lg"></i> {{ lang.groups.grant_add }}</button>
{% endif %}
<small class="text-muted d-block">{{ attribute(lang.groups, field ~ '_hint') }}</small>
</div>
</div>
+142
View File
@@ -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 %}
<h4>{{ lang.groups.group }}: {{ result.name }}</h4>
{% if result.idp_ref %}
<div class="alert alert-secondary" role="alert"><span class="badge bg-info">{{ lang.groups.idp }}</span> {{ lang.groups.idp_ref }}: <code>{{ result.idp_ref }}</code></div>
{% endif %}
<form data-id="edit_mailbox_group" class="form-horizontal" role="form" method="post" autocapitalize="none" autocorrect="off">
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="sender_allowed">
<input type="hidden" value="0" name="sogo_visible">
<input type="hidden" value="0" name="internal">
<input type="hidden" value="0" name="reapply_template">
{% if not ro %}
{# an empty selection still sends the field, so all editable members can be removed #}
<input type="hidden" value="" name="members">
{% endif %}
<div class="row mb-2">
<label class="control-label col-sm-2" for="group_domain">{{ lang.groups.domain }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="group_domain" value="{{ result.domain }}" disabled>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="group_name">{{ lang.groups.name }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="name" id="group_name" value="{{ result.name }}" maxlength="100" required{% if ro %} disabled{% endif %}>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="group_description">{{ lang.groups.description }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="description" id="group_description" value="{{ result.description }}" maxlength="255"{% if ro %} disabled{% endif %}>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="group_address">{{ lang.groups.address }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="address" id="group_address" value="{{ result.address }}" placeholder="name@{{ result.domain }}"{% if ro %} disabled{% endif %}>
<small class="text-muted">{{ lang.groups.address_hint }}</small>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="group_members">{{ lang.groups.members }}</label>
<div class="col-sm-10">
<select data-live-search="true" data-width="100%" name="members" id="group_members" class="form-control" multiple{% if ro %} disabled{% endif %}>
{% for mailbox in mailboxes %}
<option{% if mailbox in editable_members %} selected{% endif %}>{{ mailbox }}</option>
{% endfor %}
</select>
{% if fixed_members %}
<div class="mt-2">
{% for member in fixed_members %}
<span class="badge bg-secondary"{% if member.source == 'idp' %} title="{{ lang.groups.idp_member }}"{% endif %}>{% if member.source == 'idp' %}<i class="bi bi-cloud"></i> {% endif %}{{ member.username }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="group_send_as">{{ lang.groups.send_as }}</label>
<div class="col-sm-10">
<textarea class="form-control" rows="3" name="send_as" id="group_send_as"{% if ro %} disabled{% endif %}>{{ result.send_as|join("\n") }}</textarea>
<small class="text-muted">{{ lang.groups.send_as_hint }}</small>
</div>
</div>
{% 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 %}
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="sender_allowed"{% if result.sender_allowed == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.groups.sender_allowed }}</label>
</div>
{% if not skip_sogo %}
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="sogo_visible"{% if result.sogo_visible == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.groups.sogo_visible }}</label>
</div>
{% endif %}
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="internal"{% if result.internal == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.groups.internal }}</label>
</div>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="group_template">{{ lang.groups.template }}</label>
{% if mailcow_cc_role == 'admin' %}
<div class="col-sm-6">
<select name="template_id" id="group_template" class="form-control">
<option value="">{{ lang.groups.template_none }}</option>
{% for template in mbox_templates %}
<option value="{{ template.id }}"{% if template.id == result.template_id %} selected{% endif %}>{{ template.template }}</option>
{% endfor %}
</select>
</div>
<label class="control-label col-sm-2" for="group_priority">{{ lang.groups.priority }}</label>
<div class="col-sm-2">
<input type="number" class="form-control" name="priority" id="group_priority" value="{{ result.priority }}">
</div>
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="reapply_template"> {{ lang.groups.reapply_template }}</label>
</div>
<small class="text-muted">{{ lang.groups.template_hint }}</small>
</div>
{% else %}
<div class="col-sm-10">
<input type="text" class="form-control" id="group_template" value="{% if result.template %}{{ result.template }} ({{ lang.groups.priority }} {{ result.priority }}){% else %}{{ lang.groups.template_none }}{% endif %}" disabled>
</div>
{% endif %}
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="active"{% if result.active == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.groups.active }}</label>
</div>
</div>
</div>
{% if not ro %}
<div class="row">
<div class="offset-sm-2 col-sm-10">
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="edit_mailbox_group" data-item="{{ result.id }}" data-api-url='edit/mailbox-group' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
</div>
{% endif %}
</form>
{% else %}
{{ parent() }}
{% endif %}
{% endblock %}
+39
View File
@@ -24,6 +24,7 @@
<form class="form-horizontal" data-id="editmailbox" role="form" method="post">
<input type="hidden" value="default" name="sender_acl">
<input type="hidden" value="0" name="force_pw_update">
<input type="hidden" value="0" name="shared">
<input type="hidden" value="0" name="force_tfa">
<input type="hidden" value="0" name="sogo_access">
<input type="hidden" value="0" name="protocol_access">
@@ -46,6 +47,20 @@
</select>
</div>
</div>
{% if result.authsource != 'mailcow' and identity_link %}
<div class="row mb-2">
<label class="control-label col-sm-2">{{ lang.edit.iam_link }}</label>
<div class="col-sm-10">
{% if identity_link.external_id %}<code>{{ identity_link.external_id }}</code>{% else %}<span class="text-muted">{{ lang.edit.iam_not_linked }}</span>{% endif %}
{% if identity_link.idp_disabled == 1 %}<span class="badge bg-warning text-dark">{{ lang.edit.iam_idp_disabled }}</span>{% endif %}
{% if identity_link.missing_since %}<span class="badge bg-danger">{{ lang.edit.iam_missing_since }} {{ identity_link.missing_since }}</span>{% endif %}
{% if mailcow_cc_role == 'admin' and identity_link.external_id %}
<a href="#" class="btn btn-sm btn-secondary ms-2" data-action="delete_selected" data-id="identity_link" data-item="{{ mailbox }}" data-api-url="delete/identity-link"><i class="bi bi-link-45deg"></i> {{ lang.edit.iam_unlink }}</a>
{% endif %}
<small class="text-muted d-block">{{ lang.edit.iam_link_info }}</small>
</div>
</div>
{% endif %}
<div class="row mb-2">
<label class="control-label col-sm-2" for="name">{{ lang.edit.full_name }}</label>
<div class="col-sm-10">
@@ -121,6 +136,22 @@
<small class="text-muted d-block">{{ lang.edit.sender_acl_info|raw }}</small>
</div>
</div>
{% if mailbox_groups.groups %}
<div class="row mb-2">
<label class="control-label col-sm-2">{{ lang.groups.memberships }}</label>
<div class="col-sm-10">
{% for group in mailbox_groups.groups %}
<a href="/edit/mailbox-group/{{ group.id }}" class="badge bg-secondary text-decoration-none">{% if group.source == 'idp' %}<i class="bi bi-cloud" title="{{ lang.groups.idp_member }}"></i> {% endif %}{{ group.name }}{% if group.address %} &lt;{{ group.address }}&gt;{% endif %}</a>
{% endfor %}
{% if mailbox_groups.template %}
<small class="text-muted d-block">{{ lang.groups.effective_template }}: {{ mailbox_groups.template }}</small>
{% endif %}
{% if mailbox_groups.send_as %}
<small class="text-muted d-block">{{ lang.groups.send_as_groups }}: {{ mailbox_groups.send_as|join(', ') }}</small>
{% endif %}
</div>
</div>
{% endif %}
{% if acl.mailbox_relayhost == '1' %}
<div class="row mb-2">
<label class="control-label col-sm-2" for="relayhost">{{ lang.edit.relayhost }}</label>
@@ -310,6 +341,14 @@
</select>
</div>
</div>
<div class="row mt-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="shared"{% if result.attributes.shared == '1' %} checked{% endif %}> {{ lang.mailbox.shared_mailbox }}</label>
<small class="text-muted">{{ lang.mailbox.shared_mailbox_info }}</small>
</div>
</div>
</div>
<div class="row mt-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
+7
View File
@@ -33,6 +33,9 @@
<li role="presentation"><button class="dropdown-item" aria-selected="false" aria-controls="tab-domain-aliases" role="tab" data-bs-toggle="tab" data-bs-target="#tab-domain-aliases">{{ lang.mailbox.domain_aliases }}</button></li>
</ul>
</li>
{% if acl.mailbox_groups == 1 %}
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-groups" role="tab" data-bs-toggle="tab" data-bs-target="#tab-groups">{{ lang.groups.groups }}</button></li>
{% endif %}
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#tab-syncjobs">{{ lang.syncjobs.sync_jobs }}</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-filters" role="tab" data-bs-toggle="tab" data-bs-target="#tab-filters">{{ lang.mailbox.filters }}</button></li>
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-bcc" role="tab" data-bs-toggle="tab" data-bs-target="#tab-bcc">{{ lang.mailbox.address_rewriting }}</button></li>
@@ -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 }};
@@ -0,0 +1,30 @@
<div class="tab-pane fade" id="tab-groups" role="tabpanel" aria-labelledby="tab-groups">
<div class="card mb-4">
<div class="card-header d-flex fs-5">
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-groups" data-bs-toggle="collapse" aria-controls="collapse-tab-groups">
{{ lang.groups.groups }} <span class="badge bg-info table-lines"></span>
</button>
<span class="d-none d-md-block">{{ lang.groups.groups }} <span class="badge bg-info table-lines"></span></span>
<div class="btn-group ms-auto d-flex">
<button class="btn btn-xs btn-secondary refresh_table" data-table="mailbox_group_table">{{ lang.admin.refresh }}</button>
</div>
</div>
<div id="collapse-tab-groups" class="card-body collapse" data-bs-parent="#mail-content">
<p class="text-muted">{{ lang.groups.info }}</p>
<table id="mailbox_group_table" class="table table-striped dt-responsive w-100"></table>
<div class="mass-actions-mailbox mt-4">
<div class="btn-group">
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" id="toggle_multi_select_all" data-id="mailbox_group" href="#"><i class="bi bi-check-all"></i> {{ lang.mailbox.toggle_all }}</a>
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" data-action="edit_selected" data-id="mailbox_group" data-api-url='edit/mailbox-group' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
<li><a class="dropdown-item" data-action="edit_selected" data-id="mailbox_group" data-api-url='edit/mailbox-group' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" data-action="delete_selected" data-id="mailbox_group" data-api-url='delete/mailbox-group' href="#">{{ lang.mailbox.remove }}</a></li>
</ul>
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addMailboxGroupModal"><i class="bi bi-plus-lg"></i> {{ lang.groups.add }}</a>
</div>
</div>
</div>
</div>
</div>
+11
View File
@@ -197,6 +197,14 @@
</select>
</div>
</div>
<div class="row">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="shared" id="add_mailbox_shared"> {{ lang.mailbox.shared_mailbox }}</label>
<small class="text-muted">{{ lang.mailbox.shared_mailbox_info }}</small>
</div>
</div>
</div>
<div class="row">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
@@ -1351,3 +1359,6 @@
</div><!-- DNS info modal -->
{% include "modals/imapsync_source.twig" %}
{% if acl.mailbox_groups == 1 %}
{% include "modals/mailbox_group.twig" %}
{% endif %}
@@ -0,0 +1,119 @@
<!-- add mailbox group modal -->
<div class="modal fade" id="addMailboxGroupModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">{{ lang.groups.add }}</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form class="form-horizontal" role="form" data-id="add_mailbox_group" autocapitalize="none" autocorrect="off">
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="sender_allowed">
<input type="hidden" value="0" name="sogo_visible">
<input type="hidden" value="0" name="internal">
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_group_domain">{{ lang.groups.domain }}</label>
<div class="col-sm-10">
<select data-live-search="true" name="domain" id="add_group_domain" class="form-control mailbox-group-domain-select" required>
{% for domain in domains %}
<option>{{ domain }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_group_name">{{ lang.groups.name }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="name" id="add_group_name" maxlength="100" required>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_group_description">{{ lang.groups.description }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="description" id="add_group_description" maxlength="255">
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_group_local_part">{{ lang.groups.address }}</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" class="form-control" name="local_part" id="add_group_local_part">
<span class="input-group-text">@<span class="mailbox-group-domain">{{ domains|first }}</span></span>
</div>
<small class="text-muted">{{ lang.groups.address_hint }}</small>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_group_members">{{ lang.groups.members }}</label>
<div class="col-sm-10">
<select data-live-search="true" data-width="100%" name="members" id="add_group_members" class="form-control" multiple>
{% for mailbox in mailboxes %}
<option>{{ mailbox }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_group_send_as">{{ lang.groups.send_as }}</label>
<div class="col-sm-10">
<textarea class="form-control" rows="3" name="send_as" id="add_group_send_as"></textarea>
<small class="text-muted">{{ lang.groups.send_as_hint }}</small>
</div>
</div>
{% include 'edit/mailbox-group-grants.twig' with {'kind': 'imap', 'grants': [], 'id': 'add_group_imap_grants', 'ro': false, 'label_class': ' text-sm-end'} %}
{% if not skip_sogo %}
{% include 'edit/mailbox-group-grants.twig' with {'kind': 'sogo', 'grants': [], 'id': 'add_group_sogo_grants', 'ro': false, 'label_class': ' text-sm-end'} %}
{% endif %}
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="sender_allowed"> {{ lang.groups.sender_allowed }}</label>
</div>
{% if not skip_sogo %}
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="sogo_visible"> {{ lang.groups.sogo_visible }}</label>
</div>
{% endif %}
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="internal"> {{ lang.groups.internal }}</label>
</div>
</div>
</div>
{% if mailcow_cc_role == 'admin' %}
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_group_template">{{ lang.groups.template }}</label>
<div class="col-sm-6">
<select name="template_id" id="add_group_template" class="form-control">
<option value="">{{ lang.groups.template_none }}</option>
{% for template in mbox_templates %}
<option value="{{ template.id }}">{{ template.template }}</option>
{% endfor %}
</select>
</div>
<label class="control-label col-sm-2 text-sm-end" for="add_group_priority">{{ lang.groups.priority }}</label>
<div class="col-sm-2">
<input type="number" class="form-control" name="priority" id="add_group_priority" value="0">
</div>
<div class="offset-sm-2 col-sm-10">
<small class="text-muted">{{ lang.groups.template_hint }}</small>
</div>
</div>
{% endif %}
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="active" checked> {{ lang.groups.active }}</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="add_item" data-id="add_mailbox_group" data-api-url='add/mailbox-group' data-api-attr='{}' href="#">{{ lang.admin.add }}</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div><!-- add mailbox group modal -->
@@ -61,6 +61,18 @@
<p>{% if not user_get_alias_details.aliases_send_as_all %}<i class="bi bi-x-lg"></i>{% else %}{{ user_get_alias_details.aliases_send_as_all }}{% endif %}</p>
</div>
</div>
<div class="row">
<div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4"><i class="bi bi-people"></i> {{ lang.groups.memberships }}:</div>
<div class="col-sm-8 col-md-9 col-12">
<p>
{% for group in mailbox_groups.groups %}
{{ group.name }}{% if group.address %} &lt;{{ group.address }}&gt;{% endif %}{% if not loop.last %}<br>{% endif %}
{% else %}
<i class="bi bi-x-lg"></i>
{% endfor %}
</p>
</div>
</div>
<div class="row">
<div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4">{{ lang.user.is_catch_all }}:</div>
<div class="col-sm-8 col-md-9 col-12">
+1
View File
@@ -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),
+12 -7
View File
@@ -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:-}