mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-27 20:55:42 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15042e5921 | ||
|
|
3959e311a0 | ||
|
|
ad86403b87 | ||
|
|
2c5718c9c9 | ||
|
|
8bc8fbdd2c | ||
|
|
f63042bbaf | ||
|
|
4828f0cf64 | ||
|
|
40488b2120 | ||
|
|
02a9f59530 | ||
|
|
0dadf49934 |
No files matched your search
@@ -111,16 +111,16 @@ adapt_new_options() {
|
||||
echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf
|
||||
;;
|
||||
API_KEY)
|
||||
echo '# Create or override API key for web UI' >> mailcow.conf
|
||||
echo '# Read-write service API key, shown read-only in the web UI' >> mailcow.conf
|
||||
echo "#API_KEY=" >> mailcow.conf
|
||||
;;
|
||||
API_KEY_READ_ONLY)
|
||||
echo '# Create or override read-only API key for web UI' >> mailcow.conf
|
||||
echo '# Read-only service API key, shown read-only in the web UI' >> mailcow.conf
|
||||
echo "#API_KEY_READ_ONLY=" >> mailcow.conf
|
||||
;;
|
||||
API_ALLOW_FROM)
|
||||
echo '# Must be set for API_KEY to be active' >> mailcow.conf
|
||||
echo '# IPs only, no networks (networks can be set via UI)' >> mailcow.conf
|
||||
echo '# Must be set for API_KEY and API_KEY_READ_ONLY to be active' >> mailcow.conf
|
||||
echo '# Comma separated list of IPs/networks' >> mailcow.conf
|
||||
echo "#API_ALLOW_FROM=" >> mailcow.conf
|
||||
;;
|
||||
SNAT_TO_SOURCE)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
function array_by_comma { local IFS=","; echo "$*"; }
|
||||
|
||||
# Wait for containers
|
||||
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
|
||||
echo "Waiting for SQL..."
|
||||
@@ -132,33 +130,6 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# Set API options if env vars are not empty
|
||||
if [[ ${API_ALLOW_FROM} != "invalid" ]] && [[ ! -z ${API_ALLOW_FROM} ]]; then
|
||||
IFS=',' read -r -a API_ALLOW_FROM_ARR <<< "${API_ALLOW_FROM}"
|
||||
declare -a VALIDATED_API_ALLOW_FROM_ARR
|
||||
REGEX_IP6='^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$'
|
||||
REGEX_IP4='^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/([0-9]|[1-2][0-9]|3[0-2]))?$'
|
||||
for IP in "${API_ALLOW_FROM_ARR[@]}"; do
|
||||
if [[ ${IP} =~ ${REGEX_IP6} ]] || [[ ${IP} =~ ${REGEX_IP4} ]]; then
|
||||
VALIDATED_API_ALLOW_FROM_ARR+=("${IP}")
|
||||
fi
|
||||
done
|
||||
VALIDATED_IPS=$(array_by_comma ${VALIDATED_API_ALLOW_FROM_ARR[*]})
|
||||
if [[ ! -z ${VALIDATED_IPS} ]]; then
|
||||
if [[ ${API_KEY} != "invalid" ]] && [[ ! -z ${API_KEY} ]]; then
|
||||
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
|
||||
DELETE FROM api WHERE access = 'rw';
|
||||
INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY}", "1", "${VALIDATED_IPS}", "rw");
|
||||
EOF
|
||||
fi
|
||||
if [[ ${API_KEY_READ_ONLY} != "invalid" ]] && [[ ! -z ${API_KEY_READ_ONLY} ]]; then
|
||||
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
|
||||
DELETE FROM api WHERE access = 'ro';
|
||||
INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY_READ_ONLY}", "1", "${VALIDATED_IPS}", "ro");
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create events (master only, STATUS for event on slave will be SLAVESIDE_DISABLED)
|
||||
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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']),
|
||||
|
||||
@@ -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');
|
||||
@@ -93,10 +94,6 @@ $template_data = [
|
||||
'tfa_id' => @$_SESSION['tfa_id'],
|
||||
'fido2_cid' => @$_SESSION['fido2_cid'],
|
||||
'fido2_data' => $fido2_data,
|
||||
'api' => [
|
||||
'ro' => admin_api('ro', 'get'),
|
||||
'rw' => admin_api('rw', 'get'),
|
||||
],
|
||||
'dkim_domains' => $dkim_domains,
|
||||
'dkim_domains_with_keys' => $dkim_domains_with_keys,
|
||||
'dkim_blind_domains' => $dkim_blind_domains,
|
||||
|
||||
+958
-1
File diff suppressed because it is too large.
Load diff
@@ -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');
|
||||
|
||||
@@ -99,6 +99,27 @@ 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']);
|
||||
$template = 'edit/api-key.twig';
|
||||
}
|
||||
elseif (isset($_GET['aliasdomain']) &&
|
||||
is_valid_domain_name(html_entity_decode(rawurldecode($_GET["aliasdomain"]))) &&
|
||||
!empty($_GET["aliasdomain"])) {
|
||||
@@ -136,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,
|
||||
@@ -232,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');
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'admin') {
|
||||
exit();
|
||||
}
|
||||
// Read-only API keys must not control containers
|
||||
if (!empty($_SESSION['mailcow_cc_api']) && ($_SESSION['mailcow_cc_api_access'] ?? '') != 'rw') {
|
||||
exit();
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'])) {
|
||||
if ($_GET['action'] == "start") {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -193,6 +193,10 @@ function acl($_action, $_scope = null, $_data = null, $_extra = null) {
|
||||
while ($row = array_shift($acl_all)) {
|
||||
$acl['acl'][$row['Field']] = 1;
|
||||
}
|
||||
// Read-only API keys must not log in as other accounts (SOGo SSO, dual login)
|
||||
if (!empty($_SESSION['mailcow_cc_api']) && ($_SESSION['mailcow_cc_api_access'] ?? '') != 'rw') {
|
||||
$acl['acl']['login_as'] = 0;
|
||||
}
|
||||
}
|
||||
elseif ($_SESSION['mailcow_cc_role'] == 'domainadmin' ||
|
||||
(isset($_SESSION["dual-login"]["role"]) && $_SESSION["dual-login"]["role"] == 'domainadmin')) {
|
||||
|
||||
@@ -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
-145
@@ -2054,128 +2054,6 @@ function verify_tfa_login($username, $_data) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
function admin_api($access, $action, $data = null) {
|
||||
global $pdo;
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if ($access !== "ro" && $access !== "rw") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__),
|
||||
'msg' => 'invalid access type'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if ($action == "edit") {
|
||||
$active = (!empty($data['active'])) ? 1 : 0;
|
||||
$skip_ip_check = (isset($data['skip_ip_check'])) ? 1 : 0;
|
||||
$allow_from = array();
|
||||
if (isset($data['allow_from'])) {
|
||||
$allow_from = array_map('trim', preg_split( "/( |,|;|\n)/", $data['allow_from']));
|
||||
}
|
||||
foreach ($allow_from as $key => $val) {
|
||||
if (empty($val)) {
|
||||
unset($allow_from[$key]);
|
||||
continue;
|
||||
}
|
||||
if (valid_network($val) !== true) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'warning',
|
||||
'log' => array(__FUNCTION__, $data),
|
||||
'msg' => array('ip_invalid', htmlspecialchars($allow_from[$key]))
|
||||
);
|
||||
unset($allow_from[$key]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$allow_from = implode(',', array_unique(array_filter($allow_from)));
|
||||
if (empty($allow_from) && $skip_ip_check == 0) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $data),
|
||||
'msg' => 'ip_list_empty'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$api_key = implode('-', array(
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3)))
|
||||
));
|
||||
$stmt = $pdo->query("SELECT `api_key` FROM `api` WHERE `access` = '" . $access . "'");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
if (empty($num_results)) {
|
||||
$stmt = $pdo->prepare("INSERT INTO `api` (`api_key`, `skip_ip_check`, `active`, `allow_from`, `access`)
|
||||
VALUES (:api_key, :skip_ip_check, :active, :allow_from, :access);");
|
||||
$stmt->execute(array(
|
||||
':api_key' => $api_key,
|
||||
':skip_ip_check' => $skip_ip_check,
|
||||
':active' => $active,
|
||||
':allow_from' => $allow_from,
|
||||
':access' => $access
|
||||
));
|
||||
}
|
||||
else {
|
||||
if ($skip_ip_check == 0) {
|
||||
$stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check,
|
||||
`active` = :active,
|
||||
`allow_from` = :allow_from
|
||||
WHERE `access` = :access;");
|
||||
$stmt->execute(array(
|
||||
':active' => $active,
|
||||
':skip_ip_check' => $skip_ip_check,
|
||||
':allow_from' => $allow_from,
|
||||
':access' => $access
|
||||
));
|
||||
}
|
||||
else {
|
||||
$stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check,
|
||||
`active` = :active
|
||||
WHERE `access` = :access;");
|
||||
$stmt->execute(array(
|
||||
':active' => $active,
|
||||
':skip_ip_check' => $skip_ip_check,
|
||||
':access' => $access
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($action == "regen_key") {
|
||||
$api_key = implode('-', array(
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3))),
|
||||
strtoupper(bin2hex(random_bytes(3)))
|
||||
));
|
||||
$stmt = $pdo->prepare("UPDATE `api` SET `api_key` = :api_key WHERE `access` = :access");
|
||||
$stmt->execute(array(
|
||||
':api_key' => $api_key,
|
||||
':access' => $access
|
||||
));
|
||||
}
|
||||
elseif ($action == "get") {
|
||||
$stmt = $pdo->query("SELECT * FROM `api` WHERE `access` = '" . $access . "'");
|
||||
$apidata = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if ($apidata !== false) {
|
||||
$apidata['allow_from'] = str_replace(',', PHP_EOL, $apidata['allow_from']);
|
||||
}
|
||||
return $apidata;
|
||||
}
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $data),
|
||||
'msg' => 'admin_api_modified'
|
||||
);
|
||||
}
|
||||
function license($action, $data = null) {
|
||||
global $pdo;
|
||||
global $redis;
|
||||
@@ -2482,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':
|
||||
@@ -2492,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":
|
||||
@@ -2577,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;
|
||||
@@ -2585,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;
|
||||
@@ -2606,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;
|
||||
}
|
||||
|
||||
@@ -2628,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
|
||||
@@ -2655,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
|
||||
@@ -2792,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',
|
||||
@@ -2912,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
|
||||
@@ -2935,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';
|
||||
|
||||
@@ -2956,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;
|
||||
@@ -2986,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 {
|
||||
@@ -2999,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
|
||||
@@ -3021,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`
|
||||
@@ -3077,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;
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
+401
-47
@@ -4,7 +4,20 @@ function init_db_schema()
|
||||
try {
|
||||
global $pdo;
|
||||
|
||||
$db_version = "16102026_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,19 +187,18 @@ function init_db_schema()
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"alias" => array(
|
||||
"api" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"address" => "VARCHAR(255) NOT NULL",
|
||||
"goto" => "TEXT NOT NULL",
|
||||
"domain" => "VARCHAR(255) NOT NULL",
|
||||
"api_key" => "VARCHAR(255) NOT NULL",
|
||||
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"allow_from" => "VARCHAR(512) NOT NULL",
|
||||
"skip_ip_check" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"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'",
|
||||
"modified" => "DATETIME ON UPDATE NOW(0)",
|
||||
"access" => "ENUM('ro', 'rw') NOT NULL DEFAULT 'rw'",
|
||||
// 1 = defined in mailcow.conf, read-only in UI and API
|
||||
"service" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
@@ -194,41 +206,7 @@ function init_db_schema()
|
||||
"" => 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(
|
||||
"api_key" => "VARCHAR(255) NOT NULL",
|
||||
"allow_from" => "VARCHAR(512) NOT NULL",
|
||||
"skip_ip_check" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE NOW(0)",
|
||||
"access" => "ENUM('ro', 'rw') NOT NULL DEFAULT 'rw'",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("api_key")
|
||||
),
|
||||
),
|
||||
"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")
|
||||
"api_key" => array("api_key")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
@@ -411,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",
|
||||
@@ -720,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(
|
||||
@@ -1800,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;
|
||||
@@ -1855,4 +2200,13 @@ if (php_sapi_name() == "cli") {
|
||||
// Dunno
|
||||
}
|
||||
init_db_schema();
|
||||
// Service API keys from mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM)
|
||||
try {
|
||||
$api_keys = \Mailcow\ApiKey\ApiKeyModule::create($pdo, \Mailcow\Core\Context::system())->keys;
|
||||
foreach ($api_keys->syncServiceKeys(getenv()) as $warning) {
|
||||
echo "API service keys: " . $warning . PHP_EOL;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo "API service keys: sync failed: " . $e->getMessage() . PHP_EOL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey;
|
||||
|
||||
use Mailcow\ApiKey\Controller\ApiKeyApiController;
|
||||
use Mailcow\ApiKey\Repository\ApiKeyRepository;
|
||||
use Mailcow\ApiKey\Service\ApiKeyService;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the API key module.
|
||||
*
|
||||
* process_add_return(ApiKeyModule::fromGlobals()->api->addKey($attr));
|
||||
* ApiKeyModule::create($pdo, Context::system())->keys->syncServiceKeys(getenv()); // php-fpm start
|
||||
*/
|
||||
class ApiKeyModule
|
||||
{
|
||||
/** Request keys whose values never reach a log */
|
||||
private const SECRET_KEYS = ['api_key'];
|
||||
|
||||
public function __construct(public readonly ApiKeyService $keys, public readonly ApiKeyApiController $api)
|
||||
{
|
||||
}
|
||||
|
||||
public static function create(PDO $pdo, Context $context): self
|
||||
{
|
||||
$keys = new ApiKeyService(new ApiKeyRepository($pdo), $context);
|
||||
return new self($keys, new ApiKeyApiController($keys, new ApiResponse(self::SECRET_KEYS)));
|
||||
}
|
||||
|
||||
/** 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,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey\Controller;
|
||||
|
||||
use Mailcow\ApiKey\Model\ApiKey;
|
||||
use Mailcow\ApiKey\Service\ApiKeyService;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php and edit.php (routes `api-key`).
|
||||
*
|
||||
* Write methods return what json_api passes to process_*_return() and write their messages via ApiResponse.
|
||||
* Messages are logged as ['api', action, 'key', data].
|
||||
*/
|
||||
class ApiKeyApiController
|
||||
{
|
||||
public function __construct(private ApiKeyService $keys, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** POST add/api-key */
|
||||
public function addKey(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', $attr,
|
||||
fn(array $log): bool => $this->response->success(['api_key_added', self::label($this->keys->create($attr))], $log));
|
||||
}
|
||||
|
||||
/** POST edit/api-key: one message per id */
|
||||
public function editKeys(?array $items, array $attr): bool
|
||||
{
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
|
||||
$this->keys->requireAdmin();
|
||||
return $this->batch((array)($data['id'] ?? []), $log,
|
||||
fn(int $id): array => ['api_key_modified', self::label($this->keys->update($id, $attr))]);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/api-key: one message per id */
|
||||
public function deleteKeys(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$this->keys->requireAdmin();
|
||||
return $this->batch($items, $log, fn(int $id): array => ['api_key_deleted', self::label($this->keys->delete($id))]);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET api-key/all */
|
||||
public function getKeys(): array
|
||||
{
|
||||
return array_map(fn(ApiKey $key): array => $key->toArray(), $this->keys->all());
|
||||
}
|
||||
|
||||
/** GET api-key/<id>, also edit.php */
|
||||
public function getKey(mixed $id): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$key = ($id === null) ? null : $this->keys->get($id);
|
||||
return ($key === null) ? [] : $key->toArray();
|
||||
}
|
||||
|
||||
/** Name of a key in messages: its description, else its id */
|
||||
private static function label(ApiKey $key): string
|
||||
{
|
||||
return htmlspecialchars($key->description !== '' ? $key->description : (string)$key->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = ['api', $action, 'key', $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;
|
||||
}
|
||||
|
||||
/** 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,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey\Model;
|
||||
|
||||
/**
|
||||
* An API key (table `api`), sent by clients in the X-API-Key header (see sessions.inc.php).
|
||||
*
|
||||
* Unlike other secrets the key is part of toArray(): admins copy it from the UI. ApiKeyService limits
|
||||
* read access to admins (not with a read-only API key).
|
||||
*/
|
||||
class ApiKey
|
||||
{
|
||||
public const ACCESS_RO = 'ro';
|
||||
public const ACCESS_RW = 'rw';
|
||||
|
||||
public ?int $id = null;
|
||||
public string $key = '';
|
||||
public string $description = '';
|
||||
public string $access = self::ACCESS_RO;
|
||||
/** @var string[] IPs and CIDR networks the key may be used from */
|
||||
public array $allowFrom = [];
|
||||
public int $skipIpCheck = 0;
|
||||
/** 1 = service key from mailcow.conf: shown, but neither editable nor deletable in UI and API */
|
||||
public int $service = 0;
|
||||
public int $active = 1;
|
||||
public ?string $created = null;
|
||||
public ?string $modified = null;
|
||||
|
||||
/** Builds a key from an `api` row (column names as in the table) */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$key = new self();
|
||||
$key->id = isset($row['id']) ? (int)$row['id'] : null;
|
||||
$key->key = (string)($row['api_key'] ?? '');
|
||||
$key->description = (string)($row['description'] ?? '');
|
||||
$key->access = (string)($row['access'] ?? self::ACCESS_RO);
|
||||
$key->allowFrom = array_values(array_filter(array_map('trim', explode(',', (string)($row['allow_from'] ?? ''))), 'strlen'));
|
||||
$key->skipIpCheck = (int)($row['skip_ip_check'] ?? 0);
|
||||
$key->service = (int)($row['service'] ?? 0);
|
||||
$key->active = (int)($row['active'] ?? 1);
|
||||
$key->created = isset($row['created']) ? (string)$row['created'] : null;
|
||||
$key->modified = isset($row['modified']) ? (string)$row['modified'] : null;
|
||||
return $key;
|
||||
}
|
||||
|
||||
/** API representation (GET api-key) */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'description' => $this->description,
|
||||
'access' => $this->access,
|
||||
'api_key' => $this->key,
|
||||
'allow_from' => $this->allowFrom,
|
||||
'skip_ip_check' => $this->skipIpCheck,
|
||||
'service' => $this->service,
|
||||
'active' => $this->active,
|
||||
'created' => $this->created,
|
||||
'modified' => $this->modified,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey\Repository;
|
||||
|
||||
use Mailcow\ApiKey\Model\ApiKey;
|
||||
use Mailcow\Core\Repository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for the API keys (table `api`).
|
||||
*/
|
||||
class ApiKeyRepository extends Repository
|
||||
{
|
||||
/** @return ApiKey[] service keys first, then by id */
|
||||
public function all(): array
|
||||
{
|
||||
return array_map([ApiKey::class, 'fromRow'],
|
||||
$this->query("SELECT * FROM `api` ORDER BY `service` DESC, `id`")->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function find(int $id): ?ApiKey
|
||||
{
|
||||
$row = $this->query("SELECT * FROM `api` WHERE `id` = :id", ['id' => $id])->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ? ApiKey::fromRow($row) : null;
|
||||
}
|
||||
|
||||
/** Inserts the key and returns the new id */
|
||||
public function insert(ApiKey $key): int
|
||||
{
|
||||
$key->id = $this->insertRow('api', $this->values($key) + ['service' => $key->service]);
|
||||
return $key->id;
|
||||
}
|
||||
|
||||
public function update(ApiKey $key): void
|
||||
{
|
||||
$this->updateRow('api', (int)$key->id, $this->values($key));
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->query("DELETE FROM `api` WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the service keys match $keys in one transaction: other service keys are deleted,
|
||||
* an existing row with the same key (also a UI key) becomes the service key.
|
||||
*
|
||||
* @param ApiKey[] $keys
|
||||
*/
|
||||
public function syncService(array $keys): void
|
||||
{
|
||||
$this->transaction(function () use ($keys): void {
|
||||
$params = [];
|
||||
foreach (array_values($keys) as $i => $key) {
|
||||
$params['k' . $i] = $key->key;
|
||||
}
|
||||
$keep = empty($params) ? '' : " AND `api_key` NOT IN (:" . implode(', :', array_keys($params)) . ")";
|
||||
$this->query("DELETE FROM `api` WHERE `service` = '1'" . $keep, $params);
|
||||
// No INSERT ... ON DUPLICATE KEY UPDATE: it uses up an id on every php-fpm start
|
||||
foreach ($keys as $key) {
|
||||
$id = $this->query("SELECT `id` FROM `api` WHERE `api_key` = :api_key", ['api_key' => $key->key])->fetchColumn();
|
||||
if ($id === false) {
|
||||
$this->insertRow('api', $this->values($key) + ['service' => 1]);
|
||||
} else {
|
||||
$this->updateRow('api', (int)$id, $this->values($key) + ['service' => 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function values(ApiKey $key): array
|
||||
{
|
||||
return [
|
||||
'api_key' => $key->key,
|
||||
'description' => $key->description,
|
||||
'allow_from' => implode(',', $key->allowFrom),
|
||||
'skip_ip_check' => $key->skipIpCheck,
|
||||
'access' => $key->access,
|
||||
'active' => $key->active,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\ApiKey\Service;
|
||||
|
||||
use Mailcow\ApiKey\Model\ApiKey;
|
||||
use Mailcow\ApiKey\Repository\ApiKeyRepository;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
|
||||
/**
|
||||
* Use cases and rules for API keys.
|
||||
*
|
||||
* Admins only. A read-only API key may not even read the keys, since they include read-write keys.
|
||||
* Service keys are defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) and mirrored
|
||||
* into the table on every php-fpm start (syncServiceKeys()). UI and API show them, but cannot change them.
|
||||
*/
|
||||
class ApiKeyService
|
||||
{
|
||||
/** mailcow.conf variable => access of the service key it defines */
|
||||
public const SERVICE_KEYS = ['API_KEY' => ApiKey::ACCESS_RW, 'API_KEY_READ_ONLY' => ApiKey::ACCESS_RO];
|
||||
|
||||
public function __construct(private ApiKeyRepository $repo, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws AccessDenied unless an admin (login or read-write API key) */
|
||||
public function requireAdmin(): void
|
||||
{
|
||||
$this->context->requireAdmin();
|
||||
}
|
||||
|
||||
/** @return ApiKey[] all keys, service keys first; [] for other callers */
|
||||
public function all(): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->all() : [];
|
||||
}
|
||||
|
||||
public function get(int $id): ?ApiKey
|
||||
{
|
||||
return $this->canRead() ? $this->repo->find($id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a key with a random value; `access` is required.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function create(array $input): ApiKey
|
||||
{
|
||||
$this->requireAdmin();
|
||||
$key = new ApiKey();
|
||||
$key->key = self::generate();
|
||||
$this->apply($key, $input);
|
||||
$this->repo->insert($key);
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a key; attributes missing in $input keep their value, `regen_key` issues a new key value.
|
||||
*
|
||||
* @throws UserError access_denied, api_key_service_locked, validation errors
|
||||
*/
|
||||
public function update(int $id, array $input): ApiKey
|
||||
{
|
||||
$this->requireAdmin();
|
||||
$key = $this->editable($id);
|
||||
$this->apply($key, $input);
|
||||
if (self::flag($input['regen_key'] ?? 0) === 1) {
|
||||
$key->key = self::generate();
|
||||
}
|
||||
$this->repo->update($key);
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a key and returns it.
|
||||
*
|
||||
* @throws UserError access_denied, api_key_service_locked
|
||||
*/
|
||||
public function delete(int $id): ApiKey
|
||||
{
|
||||
$this->requireAdmin();
|
||||
$key = $this->editable($id);
|
||||
$this->repo->delete($id);
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the service keys of mailcow.conf into the table. Runs on php-fpm start (init_db CLI, master only),
|
||||
* so there is no permission check. $env holds the container environment (getenv()).
|
||||
* Without a valid API_ALLOW_FROM no service key is active, like before. Service keys that were removed from
|
||||
* mailcow.conf are deleted; a UI key with the same value becomes the service key.
|
||||
*
|
||||
* @return string[] warnings for the container log
|
||||
*/
|
||||
public function syncServiceKeys(array $env): array
|
||||
{
|
||||
$warnings = [];
|
||||
$allowFrom = [];
|
||||
foreach (self::split($env['API_ALLOW_FROM'] ?? '') as $network) {
|
||||
// 'invalid' is the docker-compose default for an unset variable
|
||||
if ($network === 'invalid') {
|
||||
continue;
|
||||
}
|
||||
if (self::isNetwork($network)) {
|
||||
$allowFrom[] = $network;
|
||||
} else {
|
||||
$warnings[] = 'API_ALLOW_FROM: skipped invalid IP/network ' . $network;
|
||||
}
|
||||
}
|
||||
$keys = [];
|
||||
foreach (self::SERVICE_KEYS as $var => $access) {
|
||||
$value = trim((string)($env[$var] ?? ''));
|
||||
if ($value === '' || $value === 'invalid') {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^[a-zA-Z0-9-]+$/', $value)) {
|
||||
$warnings[] = $var . ': invalid characters (allowed: a-z, A-Z, 0-9, -), key ignored';
|
||||
continue;
|
||||
}
|
||||
if (empty($allowFrom)) {
|
||||
$warnings[] = $var . ': API_ALLOW_FROM contains no valid IP/network, key ignored';
|
||||
continue;
|
||||
}
|
||||
if (isset($keys[$value])) {
|
||||
$warnings[] = $var . ': same value as ' . $keys[$value]->description . ', key ignored';
|
||||
continue;
|
||||
}
|
||||
$key = new ApiKey();
|
||||
$key->key = $value;
|
||||
$key->description = $var;
|
||||
$key->access = $access;
|
||||
$key->allowFrom = $allowFrom;
|
||||
$key->service = 1;
|
||||
$keys[$value] = $key;
|
||||
}
|
||||
$this->repo->syncService(array_values($keys));
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
private function canRead(): bool
|
||||
{
|
||||
return $this->context->hasAdminRights() && !$this->context->isReadOnlyApi();
|
||||
}
|
||||
|
||||
/** @throws AccessDenied when missing, UserError api_key_service_locked for service keys */
|
||||
private function editable(int $id): ApiKey
|
||||
{
|
||||
$key = $this->repo->find($id) ?? throw new AccessDenied();
|
||||
if ($key->service === 1) {
|
||||
throw new UserError('api_key_service_locked', [$key->description]);
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/edit rules: attributes missing in $input keep the current value, a new key starts with the
|
||||
* model defaults. Without skip_ip_check the key needs at least one IP/network.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
private function apply(ApiKey $key, array $input): void
|
||||
{
|
||||
if (isset($input['description'])) {
|
||||
$description = Input::text($input['description']);
|
||||
if ($description === null || mb_strlen($description) > 255) {
|
||||
throw new UserError('api_key_description_invalid');
|
||||
}
|
||||
$key->description = $description;
|
||||
}
|
||||
if (isset($input['access']) || $key->id === null) {
|
||||
$access = $input['access'] ?? null;
|
||||
if (!in_array($access, [ApiKey::ACCESS_RO, ApiKey::ACCESS_RW], true)) {
|
||||
throw new UserError('api_key_access_invalid');
|
||||
}
|
||||
$key->access = $access;
|
||||
}
|
||||
if (isset($input['allow_from'])) {
|
||||
$key->allowFrom = self::networks($input['allow_from']);
|
||||
}
|
||||
if (isset($input['skip_ip_check'])) {
|
||||
$key->skipIpCheck = self::flag($input['skip_ip_check']);
|
||||
}
|
||||
if (isset($input['active'])) {
|
||||
$key->active = self::flag($input['active']);
|
||||
}
|
||||
if ($key->skipIpCheck === 0 && empty($key->allowFrom)) {
|
||||
throw new UserError('ip_list_empty');
|
||||
}
|
||||
}
|
||||
|
||||
/** New random key in the format of the legacy UI: five groups of six hex digits */
|
||||
private static function generate(): string
|
||||
{
|
||||
return implode('-', array_map(fn(): string => strtoupper(bin2hex(random_bytes(3))), range(1, 5)));
|
||||
}
|
||||
|
||||
/** 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[] validated IPs/networks
|
||||
* @throws UserError api_key_network_invalid for the first invalid entry
|
||||
*/
|
||||
private static function networks(mixed $value): array
|
||||
{
|
||||
$networks = self::split($value);
|
||||
foreach ($networks as $network) {
|
||||
if (!self::isNetwork($network)) {
|
||||
throw new UserError('api_key_network_invalid', [htmlspecialchars($network)]);
|
||||
}
|
||||
}
|
||||
return $networks;
|
||||
}
|
||||
|
||||
/** @return string[] unique entries of a list: array or string separated by spaces, commas, semicolons or newlines */
|
||||
private static function split(mixed $value): array
|
||||
{
|
||||
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', is_scalar($value) ? (string)$value : '');
|
||||
$values = array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values);
|
||||
return array_values(array_unique(array_filter($values, 'strlen')));
|
||||
}
|
||||
|
||||
/** IPv4/IPv6 address or CIDR network */
|
||||
private static function isNetwork(string $value): bool
|
||||
{
|
||||
[$ip, $prefix] = array_pad(explode('/', $value, 2), 2, null);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
if ($prefix === null) {
|
||||
return true;
|
||||
}
|
||||
$max = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 32 : 128;
|
||||
return ctype_digit($prefix) && (int)$prefix <= $max;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -14,25 +14,32 @@ namespace Mailcow\Core;
|
||||
class Context
|
||||
{
|
||||
/**
|
||||
* @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins)
|
||||
* @param bool $accessAll the session may ignore object ownership (`access_all_exception`)
|
||||
* @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins)
|
||||
* @param bool $accessAll the session may ignore object ownership (`access_all_exception`)
|
||||
* @param ?string $apiAccess 'ro' / 'rw' when the request authenticated with an X-API-Key, else null
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $role,
|
||||
public readonly ?string $username,
|
||||
private readonly array $acl = [],
|
||||
public readonly bool $accessAll = false
|
||||
public readonly bool $accessAll = false,
|
||||
public readonly ?string $apiAccess = null
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromSession(array $session): self
|
||||
{
|
||||
$username = $session['mailcow_cc_username'] ?? null;
|
||||
$apiAccess = null;
|
||||
if (($session['mailcow_cc_api'] ?? false) === true) {
|
||||
$apiAccess = (($session['mailcow_cc_api_access'] ?? null) === 'rw') ? 'rw' : 'ro';
|
||||
}
|
||||
return new self(
|
||||
(string)($session['mailcow_cc_role'] ?? ''),
|
||||
is_scalar($username) ? (string)$username : null,
|
||||
is_array($session['acl'] ?? null) ? $session['acl'] : [],
|
||||
($session['access_all_exception'] ?? null) == '1'
|
||||
($session['access_all_exception'] ?? null) == '1',
|
||||
$apiAccess
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +64,30 @@ class Context
|
||||
return $this->role === 'user';
|
||||
}
|
||||
|
||||
/** Request with a read-only API key (role admin, but no write access) */
|
||||
public function isReadOnlyApi(): bool
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,16 @@ 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());
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,9 @@ if (!empty($_SERVER['HTTP_X_API_KEY'])) {
|
||||
else {
|
||||
$_SESSION['mailcow_cc_api_access'] = 'ro';
|
||||
}
|
||||
// Bind the session to this key, see the check below
|
||||
$_SESSION['mailcow_cc_api_key_id'] = $api_return['id'];
|
||||
$_SESSION['mailcow_cc_api_key_hash'] = hash('sha256', $api_return['api_key']);
|
||||
}
|
||||
else {
|
||||
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for API_USER by " . $_SERVER['REMOTE_ADDR']);
|
||||
@@ -92,6 +95,26 @@ if (!empty($_SERVER['HTTP_X_API_KEY'])) {
|
||||
exit();
|
||||
}
|
||||
}
|
||||
elseif (!empty($_SESSION['mailcow_cc_api'])) {
|
||||
// API session without header: end it when its key was deleted, deactivated, regenerated or its access changed
|
||||
if (!isset($pdo)) {
|
||||
// sogo-auth.php (nginx auth_request) loads no prerequisites
|
||||
$pdo = new PDO($database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name, $database_user, $database_pass, array(
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
));
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT `api_key`, `access` FROM `api` WHERE `id` = :id AND `active` = '1';");
|
||||
$stmt->execute(array(':id' => intval($_SESSION['mailcow_cc_api_key_id'] ?? 0)));
|
||||
$api_return = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (empty($api_return['api_key']) ||
|
||||
!hash_equals((string)($_SESSION['mailcow_cc_api_key_hash'] ?? ''), hash('sha256', $api_return['api_key'])) ||
|
||||
$api_return['access'] != ($_SESSION['mailcow_cc_api_access'] ?? '')) {
|
||||
session_unset();
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle logouts
|
||||
if (isset($_POST["logout"])) {
|
||||
|
||||
@@ -99,22 +99,6 @@ if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == "admi
|
||||
if (isset($_POST["license_validate_now"])) {
|
||||
license('verify');
|
||||
}
|
||||
if (isset($_POST["admin_api"])) {
|
||||
if (isset($_POST["admin_api"]["ro"])) {
|
||||
admin_api('ro', 'edit', $_POST);
|
||||
}
|
||||
elseif (isset($_POST["admin_api"]["rw"])) {
|
||||
admin_api('rw', 'edit', $_POST);
|
||||
}
|
||||
}
|
||||
if (isset($_POST["admin_api_regen_key"])) {
|
||||
if (isset($_POST["admin_api_regen_key"]["ro"])) {
|
||||
admin_api('ro', 'regen_key', $_POST);
|
||||
}
|
||||
elseif (isset($_POST["admin_api_regen_key"]["rw"])) {
|
||||
admin_api('rw', 'regen_key', $_POST);
|
||||
}
|
||||
}
|
||||
if (isset($_POST["rspamd_ui"])) {
|
||||
rspamd_ui('edit', $_POST);
|
||||
}
|
||||
|
||||
+131
-17
@@ -219,6 +219,93 @@ jQuery(function($){
|
||||
]
|
||||
});
|
||||
}
|
||||
function draw_api_keys() {
|
||||
// just recalc width if instance already exists
|
||||
if ($.fn.DataTable.isDataTable('#apikeystable') ) {
|
||||
$('#apikeystable').DataTable().columns.adjust().responsive.recalc();
|
||||
return;
|
||||
}
|
||||
|
||||
$('#apikeystable').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,
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: "/api/v1/get/api-key/all",
|
||||
dataSrc: function(data){
|
||||
return process_table_data(data, 'apikeystable');
|
||||
}
|
||||
},
|
||||
createdRow: function(row, data) {
|
||||
// service keys from mailcow.conf are read-only
|
||||
if (data.service == 1) $(row).addClass('opacity-75');
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
// placeholder, so checkbox will not block child row toggle
|
||||
title: '',
|
||||
data: null,
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
data: 'chkbox',
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
data: 'id',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.description,
|
||||
data: 'desc',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.access,
|
||||
data: 'access_type',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.api_key,
|
||||
data: 'key',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.api_networks,
|
||||
data: 'networks',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.active,
|
||||
data: 'active',
|
||||
defaultContent: '',
|
||||
render: function (data, type) {
|
||||
if(data == 1) return '<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>';
|
||||
else return '<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: lang.action,
|
||||
data: 'action',
|
||||
className: 'dt-sm-head-hidden dt-text-right',
|
||||
defaultContent: ''
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
function draw_admins() {
|
||||
// just recalc width if instance already exists
|
||||
if ($.fn.DataTable.isDataTable('#adminstable') ) {
|
||||
@@ -574,6 +661,36 @@ jQuery(function($){
|
||||
item.grant_types = 'refresh_token password authorization_code';
|
||||
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="oauth2_clients" name="multi_select" value="' + item.id + '" />';
|
||||
});
|
||||
} else if (table == 'apikeystable') {
|
||||
$.each(data, function (i, item) {
|
||||
item.desc = escapeHtml(item.description);
|
||||
item.access_type = (item.access == 'rw') ?
|
||||
'<span class="badge bg-warning text-dark">' + lang.api_read_write + '</span>' :
|
||||
'<span class="badge bg-info">' + lang.api_read_only + '</span>';
|
||||
item.key = '<code>' + escapeHtml(item.api_key) + '</code> ' +
|
||||
'<a href="#" class="api-key-copy" data-key="' + escapeHtml(item.api_key) + '" title="' + lang.copy_to_clipboard + '"><i class="bi bi-clipboard"></i></a>';
|
||||
if (item.skip_ip_check == 1) {
|
||||
item.networks = '<span class="text-muted">' + lang.api_skip_ip_check + '</span>';
|
||||
} else {
|
||||
item.networks = $.map(item.allow_from, function(n) { return escapeHtml(n); }).join('<br>');
|
||||
}
|
||||
if (item.service == 1) {
|
||||
// service keys are shown, but cannot be changed here
|
||||
item.desc = '<i class="bi bi-lock-fill" title="' + lang.api_service_key + '"></i> ' + item.desc +
|
||||
' <span class="badge bg-secondary">mailcow.conf</span>';
|
||||
item.chkbox = '<input type="checkbox" class="form-check-input" disabled />';
|
||||
item.action = '<div class="btn-group">' +
|
||||
'<a class="btn btn-xs btn-xs-lg btn-xs-half btn-secondary disabled" aria-disabled="true"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
|
||||
'<a class="btn btn-xs btn-xs-lg btn-xs-half btn-danger disabled" aria-disabled="true"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
|
||||
'</div>';
|
||||
} else {
|
||||
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="api_keys" name="multi_select" value="' + item.id + '" />';
|
||||
item.action = '<div class="btn-group">' +
|
||||
'<a href="/edit/api-key/' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
|
||||
'<a href="#" data-action="delete_selected" data-id="single-api-key" data-api-url="delete/api-key" data-item="' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
|
||||
'</div>';
|
||||
}
|
||||
});
|
||||
} else if (table == 'domainadminstable') {
|
||||
$.each(data, function (i, item) {
|
||||
item.selected_domains = escapeHtml(item.selected_domains);
|
||||
@@ -621,6 +738,7 @@ jQuery(function($){
|
||||
onVisible("[id^=adminstable]", () => draw_admins());
|
||||
onVisible("[id^=domainadminstable]", () => draw_domain_admins());
|
||||
onVisible("[id^=oauth2clientstable]", () => draw_oauth2_clients());
|
||||
onVisible("[id^=apikeystable]", () => draw_api_keys());
|
||||
onVisible("[id^=forwardinghoststable]", () => draw_fwd_hosts());
|
||||
onVisible("[id^=relayhoststable]", () => draw_relayhosts());
|
||||
onVisible("[id^=transportstable]", () => draw_transport_maps());
|
||||
@@ -630,24 +748,14 @@ jQuery(function($){
|
||||
event.stopPropagation();
|
||||
})
|
||||
|
||||
// API IP check toggle
|
||||
$("#skip_ip_check_ro").click(function( event ) {
|
||||
$("#skip_ip_check_ro").not(this).prop('checked', false);
|
||||
if ($("#skip_ip_check_ro:checked").length > 0) {
|
||||
$('#allow_from_ro').prop('disabled', true);
|
||||
}
|
||||
else {
|
||||
$("#allow_from_ro").removeAttr('disabled');
|
||||
}
|
||||
// API key: no allowed networks without IP check
|
||||
$(".api-skip-ip-check").change(function() {
|
||||
$($(this).data('target')).prop('disabled', this.checked);
|
||||
});
|
||||
$("#skip_ip_check_rw").click(function( event ) {
|
||||
$("#skip_ip_check_rw").not(this).prop('checked', false);
|
||||
if ($("#skip_ip_check_rw:checked").length > 0) {
|
||||
$('#allow_from_rw').prop('disabled', true);
|
||||
}
|
||||
else {
|
||||
$("#allow_from_rw").removeAttr('disabled');
|
||||
}
|
||||
$(document).on('click', '.api-key-copy', function(e) {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText($(this).attr('data-key'));
|
||||
mailcow_alert_box(lang.copy_to_clipboard, 'success');
|
||||
});
|
||||
// Relayhost
|
||||
$('#testRelayhostModal').on('show.bs.modal', function (e) {
|
||||
@@ -826,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();
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ $(document).ready(function() {
|
||||
if ($("form[data-id='editalias'] .goto_checkbox:checked").length > 0) {
|
||||
$('#textarea_alias_goto').prop('disabled', true);
|
||||
}
|
||||
// API key
|
||||
$(".api-skip-ip-check").change(function() {
|
||||
$($(this).data('target')).prop('disabled', this.checked);
|
||||
});
|
||||
$(".api-key-copy").click(function() {
|
||||
navigator.clipboard.writeText($(this).attr('data-key'));
|
||||
mailcow_alert_box(lang_admin.copy_to_clipboard, 'success');
|
||||
});
|
||||
$(".api-key-regen").click(function() { return confirm(lang_admin.ays); });
|
||||
|
||||
$("#mailbox-password-warning-close").click(function( event ) {
|
||||
$('#mailbox-passwd-hidden-info').addClass('hidden');
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
|
||||
+58
-1
@@ -255,6 +255,12 @@ if (isset($_GET['query'])) {
|
||||
case "oauth2-client":
|
||||
process_add_return(oauth2('add', 'client', $attr));
|
||||
break;
|
||||
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":
|
||||
@@ -805,6 +811,34 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "api-key":
|
||||
switch ($object) {
|
||||
case "all":
|
||||
process_get_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKeys());
|
||||
break;
|
||||
|
||||
default:
|
||||
process_get_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($object));
|
||||
break;
|
||||
}
|
||||
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":
|
||||
@@ -1512,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:
|
||||
@@ -1708,6 +1747,12 @@ if (isset($_GET['query'])) {
|
||||
case "oauth2-client":
|
||||
process_delete_return(oauth2('delete', 'client', array('id' => $items)));
|
||||
break;
|
||||
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;
|
||||
@@ -1807,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);
|
||||
@@ -1873,6 +1921,12 @@ if (isset($_GET['query'])) {
|
||||
case "oauth2-client":
|
||||
process_edit_return(oauth2('edit', 'client', array_merge(array('id' => $items), $attr)));
|
||||
break;
|
||||
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;
|
||||
@@ -2060,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;
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
"admin": {
|
||||
"access": "Достъп",
|
||||
"action": "Действие",
|
||||
"activate_api": "Активиране на API",
|
||||
"activate_send": "Активиране на бутона за изпращане",
|
||||
"active": "Активен",
|
||||
"active_rspamd_settings_map": "Активна карта с настройки",
|
||||
@@ -1051,7 +1050,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL за обект %s е запазен",
|
||||
"admin_added": "Администраторът %s е добавен",
|
||||
"admin_api_modified": "Промените в API са запазени",
|
||||
"admin_modified": "Промените в администратора са запазени",
|
||||
"admin_removed": "Администраторът %s е премахнат",
|
||||
"alias_added": "Адресът на псевдонима %s (%d) е добавен",
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
"admin": {
|
||||
"access": "Accés",
|
||||
"action": "Acció",
|
||||
"activate_api": "Activar API",
|
||||
"active": "Actiu",
|
||||
"add": "Afegir",
|
||||
"add_domain_admin": "Afegir Administrador del dominio",
|
||||
|
||||
@@ -102,7 +102,6 @@
|
||||
"admin": {
|
||||
"access": "Přístupy",
|
||||
"action": "Akce",
|
||||
"activate_api": "Zapnout API",
|
||||
"activate_send": "Povolit tlačítko \"Odeslat\"",
|
||||
"active": "Aktivní",
|
||||
"active_rspamd_settings_map": "Aktivní nastavení",
|
||||
@@ -1087,7 +1086,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL pro objekt %s uloženo",
|
||||
"admin_added": "Správce %s přidán",
|
||||
"admin_api_modified": "Změna API uložena",
|
||||
"admin_modified": "Změny správce uloženy",
|
||||
"admin_removed": "Správce %s odebrán",
|
||||
"alias_added": "Byl přidán alias %s (%d)",
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"admin": {
|
||||
"access": "Adgang",
|
||||
"action": "Handling",
|
||||
"activate_api": "Aktivér API",
|
||||
"activate_send": "Aktivér send knap",
|
||||
"active": "Aktiv",
|
||||
"active_rspamd_settings_map": "Kort over aktive indstillinger",
|
||||
@@ -799,7 +798,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL til objekt %s gemt",
|
||||
"admin_added": "Administrator %s er tilføjet",
|
||||
"admin_api_modified": "Ændringer i API er gemt",
|
||||
"admin_modified": "Ændringer til administrator er gemt",
|
||||
"admin_removed": "Administrator %s er blevet fjernet",
|
||||
"alias_added": "Alias-adresse %s (%d) er tilføjet",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"extend_sender_acl": "Eingabe externer Absenderadressen erlauben",
|
||||
"filters": "Filter",
|
||||
"login_as": "Einloggen als Mailbox-Benutzer",
|
||||
"mailbox_groups": "Gruppen",
|
||||
"mailbox_relayhost": "Relayhost für eine Mailbox setzen",
|
||||
"prohibited": "Untersagt durch Richtlinie",
|
||||
"protocol_access": "Ändern der erlaubten Protokolle",
|
||||
@@ -103,7 +104,6 @@
|
||||
"admin": {
|
||||
"access": "Zugang",
|
||||
"action": "Aktion",
|
||||
"activate_api": "API aktivieren",
|
||||
"activate_send": "Senden-Button freischalten",
|
||||
"active": "Aktiv",
|
||||
"active_rspamd_settings_map": "Derzeit aktive Settings Map",
|
||||
@@ -128,6 +128,11 @@
|
||||
"api_allow_from": "IP-Adressen oder Netzwerke (CIDR Notation) für Zugriff auf API",
|
||||
"api_info": "Die API befindet sich noch in Entwicklung, die Dokumentation kann unter <a href=\"/api\">/api</a> abgerufen werden.",
|
||||
"api_key": "API-Key",
|
||||
"api_key_add": "API-Key hinzufügen",
|
||||
"api_keys": "API-Keys",
|
||||
"api_networks": "Erlaubte IPs/Netzwerke",
|
||||
"api_service_key": "Service-Key aus der mailcow.conf, nur dort änderbar",
|
||||
"api_service_key_info": "Dieser API-Key ist in der mailcow.conf definiert (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) und kann nur dort geändert werden.",
|
||||
"api_skip_ip_check": "IP-Check für API nicht ausführen",
|
||||
"app_hide": "Für Login verstecken",
|
||||
"app_links": "App-Links",
|
||||
@@ -145,7 +150,93 @@
|
||||
"credentials_transport_warning": "<b>Warnung</b>: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Next Hop.",
|
||||
"customer_id": "Kunde",
|
||||
"customize": "UI-Anpassung",
|
||||
"iam_attribute_field_info": "Optional. LDAP-Attribut, dessen Wert in der Zuordnung unten die Vorlage wählt.",
|
||||
"iam_disabled_filter": "Filter für deaktivierte Benutzer",
|
||||
"iam_disabled_filter_info": "LDAP-Filter für deaktivierte Benutzer. Active-Directory-Konten mit ACCOUNTDISABLE in userAccountControl gelten immer als deaktiviert.",
|
||||
"iam_encryption": "Verschlüsselung",
|
||||
"iam_encryption_info": "StartTLS nutzt den normalen Port (389). SSL (LDAPS) nutzt Port 636, Port 389 wird automatisch umgestellt.",
|
||||
"iam_encryption_none": "Keine",
|
||||
"iam_filter_info": "LDAP-Filter für die Benutzer, die sich anmelden dürfen und synchronisiert werden, z. B. <code>(objectClass=person)</code>.",
|
||||
"iam_group_autocreate": "Gruppen anlegen",
|
||||
"iam_group_autocreate_info": "Legt für jede IdP-Gruppe ohne Gegenstück eine Gruppe an. Die Mail-Adresse der Gruppe wird ihre Adresse, wenn sie frei ist.",
|
||||
"iam_group_basedn": "Gruppen-Base-DN",
|
||||
"iam_group_default_domain": "Standard-Domain für Gruppen",
|
||||
"iam_group_default_domain_info": "Domain neuer Gruppen, deren Mail-Adresse in keiner mailcow-Domain liegt.",
|
||||
"iam_group_filter": "Gruppenfilter",
|
||||
"iam_group_member_attribute": "Mitglieder-Attribut",
|
||||
"iam_group_member_attribute_info": "Nur für \"Mitglieder-Attribut der Gruppen\", z. B. member oder uniqueMember.",
|
||||
"iam_group_member_mode": "Mitgliedschaften",
|
||||
"iam_group_member_mode_ad_chain": "Active Directory inkl. verschachtelter Gruppen",
|
||||
"iam_group_member_mode_member": "Mitglieder-Attribut der Gruppen",
|
||||
"iam_group_member_mode_memberof": "memberOf der Benutzer",
|
||||
"iam_group_name_attribute": "Attribut für Gruppennamen",
|
||||
"iam_group_paths": "Gruppenpfade",
|
||||
"iam_group_paths_info": "Kommagetrennte Keycloak-Gruppenpfade: <code>/Vertrieb</code> oder <code>/mailcow/*</code> für alle Gruppen unter /mailcow. Der mailcow-Client braucht die Berechtigung <code>query-groups</code>.",
|
||||
"iam_group_sync": "Gruppen-Sync",
|
||||
"iam_group_sync_info": "Überträgt die Mitglieder von IdP-Gruppen in Gruppen (Mailboxen → Gruppen). In mailcow hinzugefügte Mitglieder bleiben.",
|
||||
"iam_id_attribute": "ID-Attribut",
|
||||
"iam_id_attribute_info": "Stabile Benutzer-ID. Leer = objectGUID, entryUUID, nsUniqueId oder ipaUniqueID. Eine Änderung verknüpft alle Mailboxen erneut über die E-Mail-Adresse.",
|
||||
"iam_import_users_info": "Legt Postfächer für alle IdP-Benutzer mit passender Vorlage an, ohne auf ihre erste Anmeldung zu warten.",
|
||||
"iam_login_provisioning_info": "Legt das Postfach bei der ersten Anmeldung an, mit passender Vorlage oder der Standard-Vorlage.",
|
||||
"iam_mapping_add": "Zuordnung hinzufügen",
|
||||
"iam_mapping_info": "Der Attributwert eines Benutzers wählt die Vorlage (Keycloak und OIDC: <code>mailcow_template</code>). Vorlagen von Gruppen haben Vorrang.",
|
||||
"iam_offboarding": "Offboarding",
|
||||
"iam_offboarding_info": "Benutzer, die im IdP deaktiviert sind oder in zwei vollständigen Sync-Läufen fehlen, können sich nicht mehr anmelden. Ihre Mailbox empfängt weiter E-Mails. Nur vom Sync gesperrte Mailboxen werden wieder freigegeben.",
|
||||
"iam_offboarding_limit": "Änderungslimit (%)",
|
||||
"iam_offboarding_limit_info": "Sync-Läufe, die mehr Mailboxen sperren oder mehr Gruppenmitgliedschaften entfernen würden als dieser Anteil (mindestens 5), werden blockiert.",
|
||||
"iam_periodic_full_sync_info": "Übernimmt Name und Vorlage bestehender Postfächer aus dem IdP.",
|
||||
"iam_redirect_url_add": "Redirect-URL hinzufügen",
|
||||
"iam_section_connection": "Verbindung",
|
||||
"iam_section_groups": "Gruppen",
|
||||
"iam_section_login": "Anmeldung",
|
||||
"iam_section_sync": "Synchronisation",
|
||||
"iam_section_templates": "Postfach-Vorlagen",
|
||||
"iam_section_users": "Benutzer",
|
||||
"iam_sync_accounts": "IdP-Mailboxen",
|
||||
"iam_sync_changes": "Änderungen",
|
||||
"iam_sync_count_conflicts": "Konflikte",
|
||||
"iam_sync_count_creates": "Angelegt",
|
||||
"iam_sync_count_deactivate": "Login gesperrt",
|
||||
"iam_sync_count_errors": "Fehler",
|
||||
"iam_sync_count_found": "Wieder gefunden",
|
||||
"iam_sync_count_group_creates": "Gruppen angelegt",
|
||||
"iam_sync_count_group_renames": "Gruppen umbenannt",
|
||||
"iam_sync_count_links": "Verknüpft",
|
||||
"iam_sync_count_membership_removals": "Mitgliedschaften entfernt",
|
||||
"iam_sync_count_memberships": "Gruppen mit neuen Mitgliedern",
|
||||
"iam_sync_count_missing": "Fehlend",
|
||||
"iam_sync_count_orphaned_groups": "Gruppen nicht im IdP",
|
||||
"iam_sync_count_release": "Freigegeben",
|
||||
"iam_sync_count_restore": "Login entsperrt",
|
||||
"iam_sync_count_skipped": "Übersprungen",
|
||||
"iam_sync_count_updates": "Aktualisiert",
|
||||
"iam_sync_details": "Details",
|
||||
"iam_sync_force": "Ohne Limit ausführen",
|
||||
"iam_sync_force_confirm": "Sync ohne Änderungslimit ausführen? Dabei können Mailboxen gesperrt und Gruppenmitgliedschaften entfernt werden.",
|
||||
"iam_sync_idp_disabled": "Vom IdP gesperrt",
|
||||
"iam_sync_linked": "Verknüpft",
|
||||
"iam_sync_missing": "Fehlen im IdP",
|
||||
"iam_sync_mode": "Modus",
|
||||
"iam_sync_mode_force": "Ohne Limit",
|
||||
"iam_sync_mode_preview": "Vorschau",
|
||||
"iam_sync_mode_run": "Lauf",
|
||||
"iam_sync_no_changes": "Keine Änderungen",
|
||||
"iam_sync_none": "Noch keine Sync-Läufe.",
|
||||
"iam_sync_off": "Der Sync ist aus. Aktiviere Import, periodischen Sync, Offboarding oder Gruppen-Sync.",
|
||||
"iam_sync_preview": "Vorschau",
|
||||
"iam_sync_result": "Ergebnis",
|
||||
"iam_sync_run": "Jetzt ausführen",
|
||||
"iam_sync_started": "Gestartet",
|
||||
"iam_sync_status": "Sync-Status",
|
||||
"iam_sync_status_blocked": "Blockiert",
|
||||
"iam_sync_status_error": "Fehler",
|
||||
"iam_sync_status_info": "Läufe starten innerhalb einer Minute. Eine Vorschau zeigt die Änderungen, ohne sie auszuführen.",
|
||||
"iam_sync_status_ok": "OK",
|
||||
"iam_sync_status_pending": "Wartet",
|
||||
"iam_sync_status_running": "Läuft",
|
||||
"iam_username_field_info": "Attribut mit der E-Mail-Adresse, sie wird zum Postfachnamen. Standard: mail.",
|
||||
"login_page": "Login-Seite",
|
||||
"description": "Beschreibung",
|
||||
"destination": "Ziel",
|
||||
"dkim_add_key": "ARC/DKIM-Key hinzufügen",
|
||||
"dkim_domains_selector": "Selector",
|
||||
@@ -212,7 +303,7 @@
|
||||
"iam_default_template": "Standardvorlage",
|
||||
"iam_default_template_description": "Falls für einen Benutzer kein Template hinterlegt ist, wird die Standardvorlage zum Erstellen der Mailbox verwendet, jedoch nicht zum Aktualisieren der Mailbox.",
|
||||
"iam_description": "Konfiguriere einen externen Identity Provider für die Authentifizierung<br>Die Mailboxen der Benutzer werden bei ihrer ersten Anmeldung automatisch erstellt, vorausgesetzt, dass ein Attribut Mapping festgelegt wurde.",
|
||||
"iam_extra_permission": "Damit die folgenden Einstellungen funktionieren, benötigt der mailcow Client in Keycloak ein <code>Service-Konto</code> und die Berechtigung <code>view-users</code>.",
|
||||
"iam_extra_permission": "Damit die folgenden Einstellungen funktionieren, benötigt der mailcow Client in Keycloak ein <code>Service-Konto</code> und die Berechtigung <code>view-users</code> (Gruppen-Sync: zusätzlich <code>query-groups</code>).",
|
||||
"iam_host": "Host",
|
||||
"iam_host_info": "Gib einen oder mehrere LDAP-Hosts ein, getrennt durch Kommas.",
|
||||
"iam_import_users": "Importiere Benutzer",
|
||||
@@ -415,9 +506,14 @@
|
||||
"alias_domain_invalid": "Alias-Domain %s ist ungültig",
|
||||
"alias_empty": "Alias-Adresse darf nicht leer sein",
|
||||
"alias_goto_identical": "Alias- und Ziel-Adresse dürfen nicht identisch sein",
|
||||
"alias_group_managed": "Alias %s gehört zu einer Gruppe und kann nur dort geändert werden",
|
||||
"alias_invalid": "Alias-Adresse %s ist ungültig",
|
||||
"aliasd_targetd_identical": "Alias-Domain darf nicht gleich Ziel-Domain sein: %s",
|
||||
"aliases_in_use": "Maximale Anzahl an Aliassen muss größer oder gleich %d sein",
|
||||
"api_key_access_invalid": "Ungültige API-Zugriffsart, erlaubt sind ro und rw",
|
||||
"api_key_description_invalid": "Die Beschreibung des API-Keys darf höchstens 255 Zeichen lang sein",
|
||||
"api_key_network_invalid": "Ungültige IP-Adresse oder ungültiges Netzwerk: %s",
|
||||
"api_key_service_locked": "API-Key %s ist in der mailcow.conf definiert und kann nur dort geändert werden",
|
||||
"app_name_empty": "App-Name darf nicht leer sein",
|
||||
"app_passwd_id_invalid": "App-Passwort ID %s ist ungültig",
|
||||
"authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.",
|
||||
@@ -452,7 +548,17 @@
|
||||
"global_map_write_error": "Kann globale Map ID %s nicht schreiben: %s",
|
||||
"goto_empty": "Eine Alias-Adresse muss auf mindestens eine gültige Ziel-Adresse zeigen",
|
||||
"goto_invalid": "Ziel-Adresse %s ist ungültig",
|
||||
"group_address_invalid": "Gruppenadresse %s ist ungültig oder liegt nicht in der Domain der Gruppe",
|
||||
"group_description_invalid": "Die Beschreibung der Gruppe darf höchstens 255 Zeichen lang sein",
|
||||
"group_grant_invalid": "Ungültige Freigabe für Mailbox %s",
|
||||
"group_member_idp": "%s ist über den Identity Provider Mitglied und kann nur dort entfernt werden",
|
||||
"group_member_invalid": "%s ist kein Postfach und kann nicht Mitglied einer Gruppe werden",
|
||||
"group_name_invalid": "Der Gruppenname ist ungültig (1-100 Zeichen)",
|
||||
"group_priority_invalid": "Die Priorität muss eine Zahl sein",
|
||||
"group_send_as_invalid": "Send-as-Adresse %s ist ungültig oder unbekannt",
|
||||
"ham_learn_error": "Ham-Lernfehler: %s",
|
||||
"iam_sync_mode_invalid": "Ungültiger Sync-Modus",
|
||||
"iam_sync_unavailable": "Der Identity Provider kann nicht synchronisiert werden (nur Keycloak und LDAP)",
|
||||
"iam_test_connection": "Verbindung fehlgeschlagen",
|
||||
"imagick_exception": "Fataler Bildverarbeitungsfehler",
|
||||
"img_dimensions_exceeded": "Grafik überschreitet die maximale Bildgröße",
|
||||
@@ -514,6 +620,7 @@
|
||||
"recipient_map_entry_exists": "Eine Empfängerumschreibung für Objekt \"%s\" existiert bereits",
|
||||
"recovery_email_failed": "E-Mail zur Wiederherstellung konnte nicht gesendet werden. Bitte wenden Sie sich an Ihren Administrator.",
|
||||
"redis_error": "Redis Fehler: %s",
|
||||
"refresh_login_failed": "Die Sitzung konnte nicht verlängert werden. Bitte melden Sie sich erneut an.",
|
||||
"relayhost_invalid": "Map-Eintrag %s ist ungültig",
|
||||
"release_send_failed": "Die Nachricht konnte nicht versendet werden: %s",
|
||||
"reset_f2b_regex": "Regex-Filter konnten nicht in vorgegebener Zeit zurückgesetzt werden, bitte erneut versuchen oder die Webseite neu laden.",
|
||||
@@ -711,6 +818,12 @@
|
||||
"generate": "generieren",
|
||||
"grant_types": "Grant-types",
|
||||
"hostname": "Servername",
|
||||
"iam_idp_disabled": "Login vom IdP-Sync gesperrt",
|
||||
"iam_link": "IdP-Verknüpfung",
|
||||
"iam_link_info": "Die Mailbox gehört zu diesem IdP-Benutzer. Löse die Verknüpfung, wenn die Adresse an einen anderen IdP-Benutzer vergeben wurde; sie wird dann erneut über die E-Mail-Adresse verknüpft.",
|
||||
"iam_missing_since": "Fehlt im IdP seit",
|
||||
"iam_not_linked": "Noch nicht verknüpft",
|
||||
"iam_unlink": "Verknüpfung lösen",
|
||||
"inactive": "Inaktiv",
|
||||
"internal": "Intern",
|
||||
"internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.",
|
||||
@@ -828,6 +941,57 @@
|
||||
"restarting_container": "Container wird neugestartet, bitte warten...",
|
||||
"hibp_check": "Gegen haveibeenpwned.com prüfen"
|
||||
},
|
||||
"groups": {
|
||||
"active": "Aktiv",
|
||||
"add": "Gruppe hinzufügen",
|
||||
"address": "Gruppenadresse",
|
||||
"address_hint": "Optional. Mails an diese Adresse gehen an alle Mitglieder.",
|
||||
"description": "Beschreibung",
|
||||
"domain": "Domain",
|
||||
"effective_template": "Template aus Gruppen",
|
||||
"grant_add": "Hinzufügen",
|
||||
"grant_folder": "Ordner-ID in SOGo, personal = Standardordner",
|
||||
"grant_owner": "Mailbox",
|
||||
"grant_state_applied": "Aktiv",
|
||||
"grant_state_failed": "Fehlgeschlagen",
|
||||
"grant_state_pending": "Ausstehend",
|
||||
"grant_subscribe": "Abonnieren",
|
||||
"grant_subscribe_imap_hint": "Abonniert die Ordner der Mailbox innerhalb einer Minute bei jedem Mitglied, damit Mailprogramme sie anzeigen. Einmalig: Wer sie abbestellt, behält das so",
|
||||
"grant_subscribe_sogo_hint": "Fügt den Ordner innerhalb einer Minute bei jedem Mitglied in SOGo hinzu, einmalig: Wer ihn entfernt, bekommt ihn nicht erneut",
|
||||
"group": "Gruppe",
|
||||
"groups": "Gruppen",
|
||||
"idp": "IdP",
|
||||
"idp_member": "Vom Identity Provider synchronisiert, nur dort änderbar",
|
||||
"idp_ref": "IdP-Gruppe",
|
||||
"imap_grants": "Postfachzugriff",
|
||||
"imap_grants_hint": "Mitglieder öffnen diese Mailboxen in ihrem Mailprogramm (Ordner Shared/<mailbox>). Lesen: lesen und als gelesen markieren. Schreiben: zusätzlich ablegen, verschieben und löschen. Vollzugriff: zusätzlich Ordner löschen. Team-Postfächer ohne eigenen Login als geteiltes Postfach markieren.",
|
||||
"info": "Gruppen bündeln Postfächer. Eine Gruppe kann eine eigene Adresse haben: ein Alias, der automatisch an alle Mitglieder zustellt (in Exchange: Verteilerliste). Mitglieder können als andere Adressen senden, auf freigegebene Postfächer, Kalender und Adressbücher zugreifen und ein Postfach-Template erhalten. Vom Identity Provider synchronisierte Mitglieder sind mit IdP markiert.",
|
||||
"internal": "Nur Mails von Absendern der Domain annehmen",
|
||||
"list_error": "Gruppen konnten nicht geladen werden",
|
||||
"managed_alias": "Wird von Gruppe %s verwaltet",
|
||||
"members": "Mitglieder",
|
||||
"memberships": "Gruppenmitgliedschaften",
|
||||
"name": "Name",
|
||||
"no_memberships": "In keiner Gruppe",
|
||||
"priority": "Priorität",
|
||||
"reapply_template": "Template erneut auf alle Mitglieder anwenden",
|
||||
"rights_full": "Vollzugriff",
|
||||
"rights_read": "Lesen",
|
||||
"rights_write": "Schreiben",
|
||||
"send_as": "Senden als",
|
||||
"send_as_groups": "Darf senden als (über Gruppen)",
|
||||
"send_as_hint": "Adressen (Postfach oder Alias) oder @domain, als die die Mitglieder senden dürfen, eine pro Zeile.",
|
||||
"sender_allowed": "Mitglieder dürfen als Gruppenadresse senden",
|
||||
"sogo_grants": "Kalender und Adressbücher",
|
||||
"sogo_grants_hint": "Mitglieder erhalten Zugriff auf den SOGo-Ordner der Mailbox. Mit \"Abonnieren\" erscheint er automatisch in ihrem SOGo, sonst abonnieren sie ihn selbst. Ausstehend: Die Mailbox hat SOGo noch nicht geöffnet.",
|
||||
"sogo_visible": "Gruppenadresse in SOGo als Identität anzeigen",
|
||||
"template": "Mailbox-Template",
|
||||
"template_hint": "Wird auf alle Mitglieder angewendet. Ist ein Postfach in mehreren Gruppen, gilt das Template der Gruppe mit der höchsten Priorität.",
|
||||
"template_none": "Kein Template",
|
||||
"type_calendar": "Kalender",
|
||||
"type_contacts": "Adressbuch",
|
||||
"view": "Ansehen"
|
||||
},
|
||||
"header": {
|
||||
"administration": "Server-Konfiguration",
|
||||
"apps": "Apps",
|
||||
@@ -989,6 +1153,8 @@
|
||||
"running": "In Ausführung",
|
||||
"set_postfilter": "Als Postfilter markieren",
|
||||
"set_prefilter": "Als Prefilter markieren",
|
||||
"shared_mailbox": "Geteiltes Postfach",
|
||||
"shared_mailbox_info": "Kein eigener Login (IMAP, SMTP, SOGo, ActiveSync, App-Passwörter, mailcow-UI). Es empfängt Mails, Gruppenmitglieder öffnen es über Freigaben. Kein Passwort nötig.",
|
||||
"sieve_info": "Es können mehrere Filter pro Benutzer existieren, aber nur ein Filter eines Typs (Pre-/Postfilter) kann gleichzeitig aktiv sein.<br>\r\nDie Ausführung erfolgt in nachstehender Reihenfolge. Ein fehlgeschlagenes Script sowie der Befehl \"keep;\" stoppen die weitere Verarbeitung <b>nicht</b>. Änderungen an globalen Sieve-Filtern bewirken einen Neustart von Dovecot.<br><br>Global sieve prefilter • Prefilter • User scripts • Postfilter • Global sieve postfilter",
|
||||
"sieve_preset_1": "E-Mails mit potenziell gefährlichen Dateitypen abweisen",
|
||||
"sieve_preset_2": "E-Mail eines bestimmten Absenders immer als gelesen markieren",
|
||||
@@ -1122,7 +1288,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL für Objekt %s wurde gesetzt",
|
||||
"admin_added": "Administrator %s wurde angelegt",
|
||||
"admin_api_modified": "Änderungen an API wurden gespeichert",
|
||||
"admin_modified": "Änderungen am Administrator wurden gespeichert",
|
||||
"admin_removed": "Administrator %s wurde entfernt",
|
||||
"alias_added": "Alias-Adresse %s (%d) wurde angelegt",
|
||||
@@ -1131,6 +1296,9 @@
|
||||
"alias_removed": "Alias-Adresse %s wurde entfernt",
|
||||
"aliasd_added": "Alias-Domain %s wurde angelegt",
|
||||
"aliasd_modified": "Änderungen an Alias-Domain %s wurden gespeichert",
|
||||
"api_key_added": "API-Key %s wurde hinzugefügt",
|
||||
"api_key_deleted": "API-Key %s wurde entfernt",
|
||||
"api_key_modified": "Änderungen an API-Key %s wurden gespeichert",
|
||||
"app_links": "Änderungen an App-Links wurden gespeichert",
|
||||
"app_passwd_added": "App-Passwort wurde gespeichert",
|
||||
"app_passwd_removed": "App-Passwort-ID %s wurde entfernt",
|
||||
@@ -1162,7 +1330,12 @@
|
||||
"forwarding_host_added": "Weiterleitungs-Host %s wurde hinzugefügt",
|
||||
"forwarding_host_removed": "Weiterleitungs-Host %s wurde entfernt",
|
||||
"global_filter_written": "Filterdatei wurde erfolgreich geschrieben",
|
||||
"group_added": "Gruppe %s wurde hinzugefügt",
|
||||
"group_modified": "Änderungen an Gruppe %s wurden gespeichert",
|
||||
"group_removed": "Gruppe %s wurde entfernt",
|
||||
"hash_deleted": "Hash wurde gelöscht",
|
||||
"iam_link_removed": "IdP-Verknüpfung von %s gelöst",
|
||||
"iam_sync_requested": "Sync-Lauf eingereiht, er startet innerhalb einer Minute",
|
||||
"iam_test_connection": "Verbindung erfolgreich",
|
||||
"ip_check_opt_in_modified": "IP Check wurde erfolgreich gespeichert",
|
||||
"item_deleted": "Objekt %s wurde entfernt",
|
||||
@@ -1543,6 +1716,11 @@
|
||||
"domain_added_sogo_failed": "Domain wurde hinzugefügt, aber SOGo konnte nicht neugestartet werden",
|
||||
"dovecot_restart_failed": "Dovecot wurde nicht erfolgreich neu gestartet, bitte prüfen Sie die Logs.",
|
||||
"fuzzy_learn_error": "Fuzzy-Lernfehler: %s",
|
||||
"group_imap_grant_failed": "Der Postfachzugriff auf %s konnte in Dovecot noch nicht gesetzt werden, er wird alle 15 Minuten erneut versucht",
|
||||
"group_sogo_init_failed": "Die SOGo-Ordner von %s konnten noch nicht angelegt werden, es wird alle 15 Minuten erneut versucht",
|
||||
"group_subscription_failed": "Abonnement für %s fehlgeschlagen, es wird automatisch erneut versucht",
|
||||
"group_template_failed": "Template %s konnte nicht auf %s angewendet werden",
|
||||
"group_too_many_members": "Gruppe %s hat mehr als %s Mitglieder. Postfix stellt Mails an eine Adresse höchstens an so viele Empfänger zu.",
|
||||
"hash_not_found": "Hash nicht gefunden. Möglicherweise wurde dieser bereits gelöscht.",
|
||||
"ip_invalid": "Ungültige IP übersprungen: %s",
|
||||
"is_not_primary_alias": "Überspringe nicht-primären Alias %s",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"extend_sender_acl": "Allow to extend sender ACL by external addresses",
|
||||
"filters": "Filters",
|
||||
"login_as": "Login as mailbox user",
|
||||
"mailbox_groups": "Groups",
|
||||
"mailbox_relayhost": "Change relayhost for a mailbox",
|
||||
"prohibited": "Prohibited by ACL",
|
||||
"protocol_access": "Change protocol access",
|
||||
@@ -103,7 +104,6 @@
|
||||
"admin": {
|
||||
"access": "Access",
|
||||
"action": "Action",
|
||||
"activate_api": "Activate API",
|
||||
"activate_send": "Activate send button",
|
||||
"active": "Active",
|
||||
"active_rspamd_settings_map": "Active settings map",
|
||||
@@ -130,8 +130,13 @@
|
||||
"api_allow_from": "Allow API access from these IPs/CIDR network notations",
|
||||
"api_info": "The API is a work in progress. The documentation can be found at <a href=\"/api\">/api</a>",
|
||||
"api_key": "API key",
|
||||
"api_key_add": "Add API key",
|
||||
"api_keys": "API keys",
|
||||
"api_networks": "Allowed IPs/networks",
|
||||
"api_read_only": "Read-Only Access",
|
||||
"api_read_write": "Read-Write Access",
|
||||
"api_service_key": "Service key from mailcow.conf, can only be changed there",
|
||||
"api_service_key_info": "This API key is defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) and can only be changed there.",
|
||||
"api_skip_ip_check": "Skip IP check for API",
|
||||
"app_hide": "Hide for login",
|
||||
"app_links": "App links",
|
||||
@@ -142,6 +147,91 @@
|
||||
"ays": "Are you sure you want to proceed?",
|
||||
"ban_list_info": "See a list of banned IPs below: <b>network (remaining ban time) - [actions]</b>.<br />IPs queued to be unbanned will be removed from the active ban list within a few seconds.<br />Red labels indicate active permanent bans by denylisting.",
|
||||
"change_logo": "Change logo",
|
||||
"iam_attribute_field_info": "Optional. LDAP attribute whose value selects the template in the mapping below.",
|
||||
"iam_disabled_filter": "Disabled filter",
|
||||
"iam_disabled_filter_info": "LDAP filter for disabled users. Active Directory accounts with ACCOUNTDISABLE in userAccountControl always count as disabled.",
|
||||
"iam_encryption": "Encryption",
|
||||
"iam_encryption_info": "StartTLS uses the normal port (389). SSL (LDAPS) uses port 636, port 389 is changed automatically.",
|
||||
"iam_encryption_none": "None",
|
||||
"iam_filter_info": "LDAP filter for the users that may log in and are synced, e.g. <code>(objectClass=person)</code>.",
|
||||
"iam_group_autocreate": "Create groups",
|
||||
"iam_group_autocreate_info": "Creates a mailbox group for every IdP group that has none yet. The group mail becomes its address if it is free.",
|
||||
"iam_group_basedn": "Group Base DN",
|
||||
"iam_group_default_domain": "Default group domain",
|
||||
"iam_group_default_domain_info": "Domain of new groups whose mail is not in a mailcow domain.",
|
||||
"iam_group_filter": "Group filter",
|
||||
"iam_group_member_attribute": "Member attribute",
|
||||
"iam_group_member_attribute_info": "Only for \"Member attribute of the groups\", e.g. member or uniqueMember.",
|
||||
"iam_group_member_mode": "Memberships",
|
||||
"iam_group_member_mode_ad_chain": "Active Directory incl. nested groups",
|
||||
"iam_group_member_mode_member": "Member attribute of the groups",
|
||||
"iam_group_member_mode_memberof": "memberOf of the users",
|
||||
"iam_group_name_attribute": "Group name attribute",
|
||||
"iam_group_paths": "Group paths",
|
||||
"iam_group_paths_info": "Comma-separated Keycloak group paths: <code>/Sales</code> or <code>/mailcow/*</code> for all groups below /mailcow. The mailcow client needs the <code>query-groups</code> permission.",
|
||||
"iam_group_sync": "Group sync",
|
||||
"iam_group_sync_info": "Syncs the members of IdP groups to mailbox groups (Mailboxes → Groups). Members added in mailcow stay.",
|
||||
"iam_id_attribute": "ID attribute",
|
||||
"iam_id_attribute_info": "Stable user ID. Empty = objectGUID, entryUUID, nsUniqueId or ipaUniqueID. Changing it links all mailboxes again by email.",
|
||||
"iam_import_users_info": "Creates mailboxes for all users of the IdP with a matching template, without waiting for their first login.",
|
||||
"iam_login_provisioning_info": "Creates the mailbox at the first login of a user, with a matching or the default template.",
|
||||
"iam_mapping_add": "Add mapping",
|
||||
"iam_mapping_info": "The attribute value of a user selects the template (Keycloak and OIDC: <code>mailcow_template</code>). Templates of groups take precedence.",
|
||||
"iam_offboarding": "Offboarding",
|
||||
"iam_offboarding_info": "Users disabled in the IdP or missing in two complete sync runs cannot log in anymore. Their mailbox still receives mail. Only mailboxes disabled by the sync are enabled again.",
|
||||
"iam_offboarding_limit": "Change limit (%)",
|
||||
"iam_offboarding_limit_info": "Sync runs that would disable more mailboxes or remove more group memberships than this share (at least 5) are blocked.",
|
||||
"iam_periodic_full_sync_info": "Updates name and template of existing mailboxes from the IdP.",
|
||||
"iam_redirect_url_add": "Add redirect URL",
|
||||
"iam_section_connection": "Connection",
|
||||
"iam_section_groups": "Groups",
|
||||
"iam_section_login": "Login",
|
||||
"iam_section_sync": "Synchronization",
|
||||
"iam_section_templates": "Mailbox templates",
|
||||
"iam_section_users": "Users",
|
||||
"iam_sync_accounts": "IdP mailboxes",
|
||||
"iam_sync_changes": "Changes",
|
||||
"iam_sync_count_conflicts": "Conflicts",
|
||||
"iam_sync_count_creates": "Created",
|
||||
"iam_sync_count_deactivate": "Login disabled",
|
||||
"iam_sync_count_errors": "Errors",
|
||||
"iam_sync_count_found": "Found again",
|
||||
"iam_sync_count_group_creates": "Groups created",
|
||||
"iam_sync_count_group_renames": "Groups renamed",
|
||||
"iam_sync_count_links": "Linked",
|
||||
"iam_sync_count_membership_removals": "Memberships removed",
|
||||
"iam_sync_count_memberships": "Groups with new members",
|
||||
"iam_sync_count_missing": "Missing",
|
||||
"iam_sync_count_orphaned_groups": "Groups not in IdP",
|
||||
"iam_sync_count_release": "Released",
|
||||
"iam_sync_count_restore": "Login enabled",
|
||||
"iam_sync_count_skipped": "Skipped",
|
||||
"iam_sync_count_updates": "Updated",
|
||||
"iam_sync_details": "Details",
|
||||
"iam_sync_force": "Run without limit",
|
||||
"iam_sync_force_confirm": "Run the sync without the change limit? Mailboxes may be disabled and group memberships removed.",
|
||||
"iam_sync_idp_disabled": "Disabled by IdP",
|
||||
"iam_sync_linked": "Linked",
|
||||
"iam_sync_missing": "Missing in IdP",
|
||||
"iam_sync_mode": "Mode",
|
||||
"iam_sync_mode_force": "Without limit",
|
||||
"iam_sync_mode_preview": "Preview",
|
||||
"iam_sync_mode_run": "Run",
|
||||
"iam_sync_no_changes": "No changes",
|
||||
"iam_sync_none": "No sync runs yet.",
|
||||
"iam_sync_off": "The sync is off. Enable import, periodic sync, offboarding or group sync.",
|
||||
"iam_sync_preview": "Preview",
|
||||
"iam_sync_result": "Result",
|
||||
"iam_sync_run": "Run now",
|
||||
"iam_sync_started": "Started",
|
||||
"iam_sync_status": "Sync status",
|
||||
"iam_sync_status_blocked": "Blocked",
|
||||
"iam_sync_status_error": "Error",
|
||||
"iam_sync_status_info": "Runs start within a minute. A preview shows the changes without applying them.",
|
||||
"iam_sync_status_ok": "OK",
|
||||
"iam_sync_status_pending": "Queued",
|
||||
"iam_sync_status_running": "Running",
|
||||
"iam_username_field_info": "Attribute with the email address, it becomes the mailbox name. Default: mail.",
|
||||
"logo_normal_label": "Normal",
|
||||
"logo_dark_label": "Inverted for dark mode",
|
||||
"configuration": "Configuration",
|
||||
@@ -152,6 +242,7 @@
|
||||
"customer_id": "Customer ID",
|
||||
"customize": "Customize",
|
||||
"login_page": "Login Page",
|
||||
"description": "Description",
|
||||
"destination": "Destination",
|
||||
"dkim_add_key": "Add ARC/DKIM key",
|
||||
"dkim_domains_selector": "Selector",
|
||||
@@ -219,7 +310,7 @@
|
||||
"iam_default_template": "Default Template",
|
||||
"iam_default_template_description": "If no template is assigned to a user, the default template will be used for creating the mailbox, but not for updating the mailbox.",
|
||||
"iam_description": "Configure an external Provider for Authentication<br>User's mailboxes will be automatically created upon their first login, provided that an attribute mapping has been set.",
|
||||
"iam_extra_permission": "For the following settings to work, the mailcow client in Keycloak needs a <code>Service account</code> and the permission to <code>view-users</code>.",
|
||||
"iam_extra_permission": "For the following settings to work, the mailcow client in Keycloak needs a <code>Service account</code> and the permission to <code>view-users</code> (group sync: also <code>query-groups</code>).",
|
||||
"iam_host": "Host",
|
||||
"iam_host_info": "Enter one or more LDAP hosts, separated by commas.",
|
||||
"iam_import_users": "Import Users",
|
||||
@@ -412,6 +503,21 @@
|
||||
},
|
||||
"danger": {
|
||||
"access_denied": "Access denied or invalid form data",
|
||||
"alias_group_managed": "Alias %s belongs to a group and can only be changed there",
|
||||
"api_key_access_invalid": "Invalid API access type, allowed values are ro and rw",
|
||||
"api_key_description_invalid": "API key description must not exceed 255 characters",
|
||||
"api_key_network_invalid": "Invalid IP address or network: %s",
|
||||
"api_key_service_locked": "API key %s is defined in mailcow.conf and can only be changed there",
|
||||
"group_address_invalid": "Group address %s is invalid or not in the domain of the group",
|
||||
"group_description_invalid": "The group description must not exceed 255 characters",
|
||||
"group_grant_invalid": "Invalid share for mailbox %s",
|
||||
"group_member_idp": "%s is a member through the identity provider and can only be removed there",
|
||||
"group_member_invalid": "%s is not a mailbox and cannot be a group member",
|
||||
"group_name_invalid": "Group name is invalid (1-100 characters)",
|
||||
"group_priority_invalid": "The priority must be a number",
|
||||
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
|
||||
"iam_sync_mode_invalid": "Invalid sync mode",
|
||||
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
|
||||
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
|
||||
"alias_domain_invalid": "Alias domain %s is invalid",
|
||||
"alias_empty": "Alias address must not be empty",
|
||||
@@ -515,6 +621,7 @@
|
||||
"recipient_map_entry_exists": "A Recipient map entry \"%s\" exists",
|
||||
"recovery_email_failed": "Could not send a recovery email. Please contact your administrator.",
|
||||
"redis_error": "Redis error: %s",
|
||||
"refresh_login_failed": "The session could not be refreshed. Please log in again.",
|
||||
"relayhost_invalid": "Map entry %s is invalid",
|
||||
"release_send_failed": "Message could not be released: %s",
|
||||
"required_data_missing": "Required data %s is missing",
|
||||
@@ -711,6 +818,12 @@
|
||||
"generate": "generate",
|
||||
"grant_types": "Grant types",
|
||||
"hostname": "Hostname",
|
||||
"iam_idp_disabled": "Login disabled by the IdP sync",
|
||||
"iam_link": "IdP link",
|
||||
"iam_link_info": "The mailbox is bound to this IdP user. Unlink it when the address was given to another IdP user; it is linked again by email.",
|
||||
"iam_missing_since": "Missing in the IdP since",
|
||||
"iam_not_linked": "Not linked yet",
|
||||
"iam_unlink": "Unlink",
|
||||
"inactive": "Inactive",
|
||||
"internal": "Internal",
|
||||
"internal_info": "Internal aliases are only accessible from the own domain or alias domains.",
|
||||
@@ -828,6 +941,57 @@
|
||||
"restart_now": "Restart now",
|
||||
"restarting_container": "Restarting container, this may take a while"
|
||||
},
|
||||
"groups": {
|
||||
"active": "Active",
|
||||
"add": "Add group",
|
||||
"address": "Group address",
|
||||
"address_hint": "Optional. Mail to this address is delivered to all members.",
|
||||
"description": "Description",
|
||||
"domain": "Domain",
|
||||
"effective_template": "Template from groups",
|
||||
"grant_add": "Add",
|
||||
"grant_folder": "Folder ID in SOGo, personal = default folder",
|
||||
"grant_owner": "Mailbox",
|
||||
"grant_state_applied": "Active",
|
||||
"grant_state_failed": "Failed",
|
||||
"grant_state_pending": "Pending",
|
||||
"grant_subscribe": "Subscribe",
|
||||
"grant_subscribe_imap_hint": "Subscribes the folders of the mailbox for every member within a minute, so mail clients show them. Once: a member who unsubscribes keeps it that way",
|
||||
"grant_subscribe_sogo_hint": "Adds the folder to the SOGo of every member within a minute, once: a member who removes it keeps it removed",
|
||||
"group": "Group",
|
||||
"groups": "Groups",
|
||||
"idp": "IdP",
|
||||
"idp_member": "Synced from the identity provider, can only be changed there",
|
||||
"idp_ref": "IdP group",
|
||||
"imap_grants": "Mailbox access",
|
||||
"imap_grants_hint": "Members open these mailboxes in their mail client (folder Shared/<mailbox>). Read: read and mark as read. Write: also file, move and delete mails. Full access: also delete folders. For team mailboxes without a login of their own, mark the mailbox as shared mailbox.",
|
||||
"info": "Groups bundle mailboxes. A group can have its own address: an alias that delivers to all members automatically (in Exchange: distribution list). Members can send as other addresses, access shared mailboxes, calendars and address books and get a mailbox template. Members synced from the identity provider are marked IdP.",
|
||||
"internal": "Only accept mail from senders of the domain",
|
||||
"list_error": "Groups could not be loaded",
|
||||
"managed_alias": "Managed by group %s",
|
||||
"members": "Members",
|
||||
"memberships": "Group memberships",
|
||||
"name": "Name",
|
||||
"no_memberships": "Not a member of any group",
|
||||
"priority": "Priority",
|
||||
"reapply_template": "Apply the template to all members again",
|
||||
"rights_full": "Full access",
|
||||
"rights_read": "Read",
|
||||
"rights_write": "Write",
|
||||
"send_as": "Send as",
|
||||
"send_as_groups": "May send as (through groups)",
|
||||
"send_as_hint": "Addresses (mailbox or alias) or @domain the members may send as, one per line.",
|
||||
"sender_allowed": "Members may send as the group address",
|
||||
"sogo_grants": "Calendars and address books",
|
||||
"sogo_grants_hint": "Members get access to the SOGo folder of the mailbox. With \"Subscribe\" it appears in their SOGo automatically, otherwise they subscribe to it themselves. Pending: the mailbox has not opened SOGo yet.",
|
||||
"sogo_visible": "Show the group address as identity in SOGo",
|
||||
"template": "Mailbox template",
|
||||
"template_hint": "Applied to all members. A mailbox in several groups gets the template of the group with the highest priority.",
|
||||
"template_none": "No template",
|
||||
"type_calendar": "Calendar",
|
||||
"type_contacts": "Address book",
|
||||
"view": "View"
|
||||
},
|
||||
"header": {
|
||||
"administration": "Configuration & Details",
|
||||
"apps": "Apps",
|
||||
@@ -998,6 +1162,8 @@
|
||||
"sender": "Sender",
|
||||
"set_postfilter": "Mark as postfilter",
|
||||
"set_prefilter": "Mark as prefilter",
|
||||
"shared_mailbox": "Shared mailbox",
|
||||
"shared_mailbox_info": "No login of its own (IMAP, SMTP, SOGo, ActiveSync, app passwords, mailcow UI). It receives mail, members of groups open it through shares. No password needed.",
|
||||
"sieve_info": "You can store multiple filters per user, but only one prefilter and one postfilter can be active at the same time.<br>\r\nEach filter will be processed in the described order. Neither a failed script nor an issued \"keep;\" will stop processing of further scripts. Changes to global sieve scripts will trigger a restart of Dovecot.<br><br>Global sieve prefilter • Prefilter • User scripts • Postfilter • Global sieve postfilter",
|
||||
"sieve_preset_1": "Discard mail with probable dangerous file types",
|
||||
"sieve_preset_2": "Always mark the e-mail of a specific sender as seen",
|
||||
@@ -1129,7 +1295,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL for object %s saved",
|
||||
"admin_added": "Administrator %s has been added",
|
||||
"admin_api_modified": "Changes to API have been saved",
|
||||
"admin_modified": "Changes to administrator have been saved",
|
||||
"admin_removed": "Administrator %s has been removed",
|
||||
"alias_added": "Alias address %s (%d) has been added",
|
||||
@@ -1138,6 +1303,9 @@
|
||||
"alias_removed": "Alias %s has been removed",
|
||||
"aliasd_added": "Added alias domain %s",
|
||||
"aliasd_modified": "Changes to alias domain %s have been saved",
|
||||
"api_key_added": "API key %s has been added",
|
||||
"api_key_deleted": "API key %s has been deleted",
|
||||
"api_key_modified": "Changes to API key %s have been saved",
|
||||
"app_links": "Saved changes to app links",
|
||||
"app_passwd_added": "Added new app password",
|
||||
"app_passwd_removed": "Removed app password ID %s",
|
||||
@@ -1169,7 +1337,12 @@
|
||||
"forwarding_host_added": "Forwarding host %s has been added",
|
||||
"forwarding_host_removed": "Forwarding host %s has been removed",
|
||||
"global_filter_written": "Filter was successfully written to file",
|
||||
"group_added": "Group %s has been added",
|
||||
"group_modified": "Changes to group %s have been saved",
|
||||
"group_removed": "Group %s has been removed",
|
||||
"hash_deleted": "Hash deleted",
|
||||
"iam_link_removed": "IdP link of %s removed",
|
||||
"iam_sync_requested": "Sync run queued, it starts within a minute",
|
||||
"iam_test_connection": "Connection successful",
|
||||
"ip_check_opt_in_modified": "IP check was saved successfully",
|
||||
"item_deleted": "Item %s successfully deleted",
|
||||
@@ -1550,6 +1723,11 @@
|
||||
"domain_added_sogo_failed": "Added domain but failed to restart SOGo, please check your server logs.",
|
||||
"dovecot_restart_failed": "Dovecot failed to restart, please check the logs",
|
||||
"fuzzy_learn_error": "Fuzzy hash learn error: %s",
|
||||
"group_imap_grant_failed": "Mailbox access to %s could not be set in Dovecot yet, it is retried every 15 minutes",
|
||||
"group_sogo_init_failed": "The SOGo folders of %s could not be created yet, it is retried every 15 minutes",
|
||||
"group_subscription_failed": "Subscription for %s failed, it is retried automatically",
|
||||
"group_template_failed": "Template %s could not be applied to %s",
|
||||
"group_too_many_members": "Group %s has more than %s members. Postfix delivers mail to an address to at most that many recipients.",
|
||||
"hash_not_found": "Hash not found or already deleted",
|
||||
"ip_invalid": "Skipped invalid IP: %s",
|
||||
"is_not_primary_alias": "Skipped non-primary alias %s",
|
||||
|
||||
+58
-262
@@ -17,7 +17,7 @@
|
||||
"spam_policy": "Lista de bloqueo/desbloqueo",
|
||||
"spam_score": "Puntuación de spam",
|
||||
"syncjobs": "Trabajos de sincronización",
|
||||
"tls_policy": "Política de TLS",
|
||||
"tls_policy": "Póliza de TLS",
|
||||
"unlimited_quota": "Cuota ilimitada para buzones",
|
||||
"app_passwds": "Gestionar las contraseñas de aplicaciones",
|
||||
"domain_desc": "Cambiar descripción del dominio",
|
||||
@@ -28,8 +28,7 @@
|
||||
"pw_reset": "Permitir el restablecimiento de la contraseña del usuario mailcow",
|
||||
"sogo_access": "Permitir la gestión del acceso a SOGo",
|
||||
"mailbox_relayhost": "Cambiar el host de reenvío para un buzón",
|
||||
"smtp_ip_access": "Cambiar hosts permitidos para SMTP",
|
||||
"alias_external_goto": "Permitir alias con dominios de destino externos"
|
||||
"smtp_ip_access": "Cambiar hosts permitidos para SMTP"
|
||||
},
|
||||
"add": {
|
||||
"activate_filter_warn": "Todos los demás filtros se desactivarán cuando este filtro se active.",
|
||||
@@ -71,8 +70,8 @@
|
||||
"relay_all": "Retransmitir todos los destinatarios",
|
||||
"relay_all_info": "↪ Si se elige <b>no</b> retransmitir todos los destinatarios, será necesario agregar un buzón \"ciego\" por cada destinatario que deba ser retransmitido.",
|
||||
"relay_domain": "Retransmitir este dominio",
|
||||
"select": "Seleccione...",
|
||||
"select_domain": "Elija primero un dominio",
|
||||
"select": "Por favor selecciona...",
|
||||
"select_domain": "Por favor elige un dominio primero",
|
||||
"sieve_desc": "Descripción",
|
||||
"sieve_type": "Tipo de filtro",
|
||||
"target_address": "Direcciones destino:",
|
||||
@@ -94,14 +93,11 @@
|
||||
"domain_matches_hostname": "El dominio %s coincide con el nombre de host",
|
||||
"relay_unknown_only": "Reenviar sólo los buzones no existentes. Los buzones existentes se entregarán localmente.",
|
||||
"relayhost_wrapped_tls_info": "Por favor, no utilice puertos con TLS (habitualmente, el puerto 465). <br>Utilice cualquier puerto no cifrado y emita STARTTLS. Se puede crear una política para imponer TLS en \"TLS policy maps\".",
|
||||
"tags": "Etiquetas",
|
||||
"internal_info": "Los alias internos solo son accesibles desde el propio dominio o sus dominios alias.",
|
||||
"sender_allowed": "Permitir enviar como este alias"
|
||||
"tags": "Etiquetas"
|
||||
},
|
||||
"admin": {
|
||||
"access": "Acceso",
|
||||
"action": "Acción",
|
||||
"activate_api": "Activar API",
|
||||
"activate_send": "Activar botón de envío",
|
||||
"active": "Activo",
|
||||
"active_rspamd_settings_map": "Reglas de ajustes activos",
|
||||
@@ -159,7 +155,7 @@
|
||||
"f2b_max_ban_time": "Max tiempo de restricción (s)",
|
||||
"f2b_netban_ipv4": "Tamaño de subred IPv4 para aplicar la restricción (8-32)",
|
||||
"f2b_netban_ipv6": "Tamaño de subred IPv6 para aplicar la restricción (8-128)",
|
||||
"f2b_parameters": "Parámetros de Fail2ban",
|
||||
"f2b_parameters": "Parametros Fail2ban",
|
||||
"f2b_retry_window": "Ventana de tiempo entre reintentos",
|
||||
"f2b_whitelist": "Redes y hosts en lista de desbloqueo",
|
||||
"filter_table": "Filtrar tabla",
|
||||
@@ -178,7 +174,7 @@
|
||||
"include_exclude_info": "Por defecto - sin selección - <b>todos los buzones</b> son notificados.",
|
||||
"includes": "Incluye a estos destinatarios",
|
||||
"link": "Enlace",
|
||||
"loading": "Espere, por favor...",
|
||||
"loading": "Espera por favor...",
|
||||
"logo_info": "La imagen se escalará a una altura de 40 px para la barra de navegación superior y un máx. de ancho de 250 px para la página de inicio. Un gráfico escalable es muy recomendable.",
|
||||
"main_name": "Nombre \"mailcow UI\"",
|
||||
"merged_vars_hint": "Las filas grises fueron incorporadas de <code>vars.(local.)inc.php</code> y no son modificables.",
|
||||
@@ -269,7 +265,7 @@
|
||||
"password_length": "Longitud de la contraseña",
|
||||
"quicklink_text": "Mostrar u ocultar enlaces rápidos a otras páginas de inicio bajo el formulario de inicio de sesión",
|
||||
"password_policy_lowerupper": "Debe contener caracteres en minúsculas y mayúsculas",
|
||||
"rspamd_global_filters_regex": "Sus nombres indican su propósito. Todo el contenido debe constar de expresiones regulares válidas con el formato \"/patrón/opciones\" (por ejemplo, <code>/.+@domain\\.tld/i</code>).<br>\n Si bien se llevan a cabo comprobaciones básicas de cada expresión regular, la funcionalidad de Rspamd puede verse inutilizada, si no consigue interpretar correctamente la sintaxis utilizada.<br>\n Rspamd intentará leer el contenido del mapa cuando este se modifique. En caso de problemas, <a href=\"\" data-toggle=\"modal\" data-container=\"rspamd-mailcow\" data-target=\"#RestartContainer\">reinicie Rspamd</a> para forzar una recarga del mapa.<br>Los elementos incluidos en listas de bloqueo se excluyen de la cuarentena.",
|
||||
"rspamd_global_filters_regex": "Sus nombres indican su propósito. Todo el contenido debe constar de expresiones regulares válidas con el formato \"/patrón/opciones\" (por ejemplo, <code>/.+@domain\\.tld/i</code>).<br>\n Si bien se llevan a cabo comprobaciones básicas de cada expresión regular, la funcionalidad de Rspamd puede verse inutilizada, si no consigue interpretar correctamente la sintaxis utilizada.<br>\n Rspamd intentará leer el contenido del mapa cuando éste se modifique. En caso de de problemas, <a href=\"\" data-toggle=\"modal\" data-container=\"rspamd-mailcow\" data-target=\"#RestartContainer\">reinicie Rspamd</a> para forzar una recarga del mapa.<br>Los elementos incluidos en listas de bloqueo se excluyen de la cuarentena.",
|
||||
"iam_use_ssl_info": "Si se habilita SSL y el puerto se establece en el 389, se cambiará automáticamente al 636.",
|
||||
"iam_login_provisioning": "Crear usuarios automáticamente al iniciar sesión",
|
||||
"iam_periodic_full_sync": "Sincronización completa periódica",
|
||||
@@ -388,20 +384,7 @@
|
||||
"user_quicklink": "Ocultar enlace rápido a página de inicio de sesión de usuario",
|
||||
"validate_license_now": "Validar el GUID contra el servidor de licencias",
|
||||
"verify": "Verificar",
|
||||
"yes": "✓",
|
||||
"iam_auth_flow_info": "Además del Authorization Code Flow (Standard Flow en Keycloak), que se usa para el inicio de sesión único (SSO), mailcow también admite autenticación con credenciales directas. El Mailpassword Flow intenta validar las credenciales del usuario mediante la API REST de administración de Keycloak. mailcow obtiene la contraseña cifrada del atributo <code>mailcow_password</code>, que está mapeado en Keycloak.",
|
||||
"iam_extra_permission": "Para que los siguientes ajustes funcionen, el cliente de mailcow en Keycloak necesita una <code>Service account</code> y el permiso <code>view-users</code>.",
|
||||
"iam_bindpass": "Contraseña de enlace (bind)",
|
||||
"iam_rest_flow": "Mailpassword Flow",
|
||||
"iam_binddn": "Bind DN",
|
||||
"oauth2_info": "La implementación de OAuth2 admite el tipo de concesión \"Authorization Code\" y emite tokens de actualización.<br>\r\nEl servidor también emite automáticamente nuevos tokens de actualización después de usar uno.<br><br>\r\n• El alcance predeterminado es <i>profile</i>. Solo los usuarios de buzones pueden autenticarse mediante OAuth2. Si se omite el parámetro scope, se usa <i>profile</i>.<br>\r\n• El cliente debe enviar el parámetro <i>state</i> como parte de la solicitud de autorización.<br><br>\r\nRutas para las solicitudes a la API de OAuth2: <br>\r\n<ul>\r\n <li>Endpoint de autorización: <code>/oauth/authorize</code></li>\r\n <li>Endpoint de tokens: <code>/oauth/token</code></li>\r\n <li>Página de recursos: <code>/oauth/profile</code></li>\r\n</ul>\r\nRegenerar el secreto del cliente no invalida los códigos de autorización existentes, pero estos no podrán renovar su token.<br><br>\r\nRevocar los tokens de los clientes termina de inmediato todas las sesiones activas. Todos los clientes deberán volver a autenticarse.",
|
||||
"rate_name": "Nombre del límite",
|
||||
"ui_header_announcement_active": "Activar anuncio",
|
||||
"ui_theme": "Tema",
|
||||
"ui_theme_dark": "Incluye una variante en modo oscuro",
|
||||
"ui_theme_default": "Predeterminado",
|
||||
"ui_theme_info": "Los temas son carpetas en <code>data/web/css/themes/<name>/</code> con los siguientes archivos:<ul class=\"mb-0\"><li><code>theme.css</code> (obligatorio)</li><li><code>dark.css</code> (opcional, modo oscuro)</li><li><code>bootstrap.css</code> (opcional, reemplaza el tema base de Bootstrap)</li><li><code>theme.json</code> (opcional, <code>name</code> y <code>description</code>)</li></ul>",
|
||||
"ui_theme_none": "No se encontraron temas."
|
||||
"yes": "✓"
|
||||
},
|
||||
"danger": {
|
||||
"access_denied": "Acceso denegado o datos del formulario inválidos",
|
||||
@@ -448,7 +431,7 @@
|
||||
"map_content_empty": "Contenido no puede estar vacío",
|
||||
"max_mailbox_exceeded": "Máx. de buzones superado (%d de %d)",
|
||||
"max_quota_in_use": "Cuota del buzón debe ser mayor o igual a %d MiB",
|
||||
"mysql_error": "Error de MySQL: %s",
|
||||
"mysql_error": "MySQL error: %s",
|
||||
"network_host_invalid": "Red o host inválido: %s",
|
||||
"next_hop_interferes": "%s interfiere con el siguiente destino %s",
|
||||
"next_hop_interferes_any": "Siguiente destino existente interfiere con %s",
|
||||
@@ -463,7 +446,7 @@
|
||||
"private_key_error": "Error en la llave privada: %s",
|
||||
"quota_not_0_not_numeric": "Cuota debe ser numérica y >= 0",
|
||||
"recipient_map_entry_exists": "Una regla de destinatario \"%s\" existe",
|
||||
"redis_error": "Error de Redis: %s",
|
||||
"redis_error": "Redis error: %s",
|
||||
"relayhost_invalid": "relayhost %s es invalido",
|
||||
"release_send_failed": "El mensaje no pudo ser liberado: %s",
|
||||
"rl_timeframe": "Marco de tiempo del límite de velocidad esta incorrecto",
|
||||
@@ -472,14 +455,14 @@
|
||||
"sender_acl_invalid": "Valor %s del ACL del remitente es inválido",
|
||||
"set_acl_failed": "Error al establecer el ACL",
|
||||
"settings_map_invalid": "Ajustes de ID %s invalidos",
|
||||
"sieve_error": "Error del analizador de Sieve: %s",
|
||||
"sieve_error": "Sieve parser error: %s",
|
||||
"spam_learn_error": "Error aprendiendo muestra: %s",
|
||||
"subject_empty": "Asunto no puede estar vacío",
|
||||
"target_domain_invalid": "El dominio \"goto\" es inválido",
|
||||
"targetd_not_found": "Dominio destino no encontrado",
|
||||
"text_empty": "Texto no puede estar vacío",
|
||||
"tls_policy_map_entry_exists": "La regla de política de TLS \"%s\" ya existe",
|
||||
"tls_policy_map_parameter_invalid": "El parámetro de la política no es válido.",
|
||||
"tls_policy_map_entry_exists": "Regla de póliza de TLS \"%s\" existe",
|
||||
"tls_policy_map_parameter_invalid": "El parámetro de póliza no es válido.",
|
||||
"totp_verification_failed": "Verificación TOTP fallida",
|
||||
"transport_dest_exists": "Destino de la regla de transporte ya existe",
|
||||
"webauthn_verification_failed": "Verificación WebAuthn fallida: %s",
|
||||
@@ -542,12 +525,7 @@
|
||||
"tfa_token_invalid": "Token de autenticación de doble factor no válido",
|
||||
"to_invalid": "El destinatario no puede quedar en blanco",
|
||||
"webauthn_authenticator_failed": "No se ha localizado el autenticador seleccionado",
|
||||
"webauthn_username_failed": "El autenticador seleccionado pertenece a otra cuenta",
|
||||
"tfa_removal_blocked": "No se puede quitar la autenticación de dos factores porque es obligatoria para su cuenta.",
|
||||
"external_goto_denied": "La dirección de destino externa %s no está permitida",
|
||||
"max_age_invalid": "La antigüedad máxima %s no es válida",
|
||||
"quarantine_category_invalid": "La categoría de cuarentena debe ser una de: add_header, reject, all",
|
||||
"ui_theme_invalid": "Tema no encontrado"
|
||||
"webauthn_username_failed": "El autenticador seleccionado pertenece a otra cuenta"
|
||||
},
|
||||
"debug": {
|
||||
"containers_info": "Información de los contenedores",
|
||||
@@ -557,11 +535,11 @@
|
||||
"log_info": "<p>Los <b>logs en memoria</b> son recopilados en listas de Redis y recortados a LOG_LINES (%d) cada minuto para prevenir sobrecarga en el sistema.\r\n <br>Los logs en memoria no están destinados a ser persistentes. Todas las aplicaciones que logean a la memoria, también logean en el daemon de Docker y, por lo tanto, en el controlador de registro predeterminado.\r\n El log en memoria se debe utilizar para analizar problemas menores con los contenedores.</p>\r\n <p>Los <b>logs externos</b> se recopilan a través de la API de la aplicación dada.</p>\r\n <p>Los <b>logs estáticos</b> son principalmente registros de actividad, que no están registrados en Dockerd pero que aún deben ser persistentes (excepto los registros de API).</p>",
|
||||
"logs": "Logs",
|
||||
"restart_container": "Reiniciar",
|
||||
"docs": "Documentación",
|
||||
"docs": "Docs",
|
||||
"last_modified": "Última modificación",
|
||||
"size": "Tamaño",
|
||||
"started_at": "Iniciado el",
|
||||
"uptime": "Tiempo en funcionamiento",
|
||||
"uptime": "Uptime",
|
||||
"static_logs": "Logs estáticos",
|
||||
"system_containers": "Sistema y Contenedores",
|
||||
"show_ip": "Mostrar IP pública",
|
||||
@@ -618,8 +596,8 @@
|
||||
"full_name": "Nombre completo",
|
||||
"gal": "Lista global de direcciones (GAL)",
|
||||
"gal_info": "El GAL contiene todos los objetos de un dominio y no puede ser editado por ningún usuario. Falta información de disponibilidad en SOGo, si está desactivada. <b>Reinicia SOGo para aplicar los cambios.</b>",
|
||||
"grant_types": "Tipos de concesión",
|
||||
"hostname": "Nombre de host",
|
||||
"grant_types": "Grant types",
|
||||
"hostname": "Hostname",
|
||||
"inactive": "Inactivo",
|
||||
"kind": "Tipo",
|
||||
"last_modified": "Última modificación",
|
||||
@@ -634,9 +612,9 @@
|
||||
"password_repeat": "Confirmación de contraseña (repetir):",
|
||||
"previous": "Página anterior",
|
||||
"quota_mb": "Cuota (MiB)",
|
||||
"redirect_uri": "URL de redirección/callback",
|
||||
"redirect_uri": "Redirect/Callback URL",
|
||||
"relay_all": "Retransmitir todos los destinatarios",
|
||||
"relay_all_info": "<small>Si elige <b>no</b> retransmitir a todos los destinatarios, deberá agregar un buzón \"ciego\" por cada destinatario que deba ser retransmitido.</small>",
|
||||
"relay_all_info": "<small>Si eliges <b>no</b> retransmitir a todos los destinatarios, necesitas agregar un buzón \"ciego\" por cada destinatario que debe ser retransmitido.</small>",
|
||||
"relay_domain": "Dominio de retransmisión",
|
||||
"relayhost": "Ruta saliente",
|
||||
"remove": "Eliminar",
|
||||
@@ -708,36 +686,12 @@
|
||||
"spam_alias": "Crear o modificar alias temporales (con caducidad)",
|
||||
"spam_filter": "Filtro de spam",
|
||||
"spam_policy": "Añadir o eliminar elementos de la lista de bloqueo/desbloqueo",
|
||||
"spam_score": "Establecer una puntuación de spam personalizada",
|
||||
"domain_footer_info_vars": {
|
||||
"auth_user": "{= auth_user =} - Nombre de usuario autenticado indicado por un MTA",
|
||||
"from_user": "{= from_user =} - Parte de usuario del remitente del sobre, p. ej. para \"moo@mailcow.tld\" devuelve \"moo\"",
|
||||
"from_name": "{= from_name =} - Nombre del remitente del sobre, p. ej. para \"Mailcow <moo@mailcow.tld>\" devuelve \"Mailcow\"",
|
||||
"from_addr": "{= from_addr =} - Dirección del remitente del sobre",
|
||||
"from_domain": "{= from_domain =} - Dominio del remitente del sobre",
|
||||
"custom": "{= foo =} - Si el buzón tiene el atributo personalizado \"foo\" con el valor \"bar\", devuelve \"bar\""
|
||||
},
|
||||
"domain_footer_plain": "Pie de página en texto plano",
|
||||
"internal_info": "Los alias internos solo son accesibles desde el propio dominio o sus dominios alias.",
|
||||
"sender_allowed": "Permitir enviar como este alias",
|
||||
"sender_allowed_info": "Si se desactiva, este alias solo puede recibir correo. Use la ACL de remitentes para anularlo y dar permiso de envío a buzones concretos.",
|
||||
"mta_sts": "MTA-STS",
|
||||
"mta_sts_info": "<a href='https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_MTA_Strict_Transport_Security' target='_blank'>MTA-STS</a> es un estándar que obliga a que la entrega de correo entre servidores use TLS con certificados válidos. <br>Se usa cuando <a target='_blank' href='https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities'>DANE</a> no es posible por falta de DNSSEC o porque no es compatible.<br><b>Nota</b>: si el dominio receptor admite DANE con DNSSEC, DANE <b>siempre</b> tiene preferencia; MTA-STS solo actúa como alternativa.",
|
||||
"mta_sts_version_info": "Define la versión del estándar MTA-STS; actualmente solo es válida <code>STSv1</code>.",
|
||||
"mta_sts_mode_info": "Hay tres modos para elegir:<ul><li><em>testing</em>: la política solo se supervisa, las infracciones no tienen efecto.</li><li><em>enforce</em>: la política se aplica estrictamente y se rechazan las conexiones sin TLS válido.</li><li><em>none</em>: la política se publica pero no se aplica.</li></ul>",
|
||||
"mta_sts_max_age": "Antigüedad máxima",
|
||||
"mta_sts_max_age_info": "Tiempo en segundos durante el cual los servidores receptores pueden guardar esta política en caché antes de volver a obtenerla.",
|
||||
"mta_sts_mx_info": "Permite enviar solo a los nombres de servidor de correo indicados explícitamente; el MTA emisor comprueba que el nombre del registro MX coincida con la lista de la política y solo entrega con un certificado TLS válido (protege contra ataques MITM).",
|
||||
"mta_sts_mx_notice": "Se pueden indicar varios servidores MX (separados por comas).",
|
||||
"mta_sts_active_info": "Si se desmarca, la política MTA-STS no se publicará y no se solicitará ningún certificado para el subdominio mta-sts mediante ACME.",
|
||||
"pushover_only_x_prio": "Considerar solo el correo de alta prioridad [<code>X-Priority: 1</code>]",
|
||||
"pushover_sender_regex": "Considerar la siguiente expresión regular de remitente",
|
||||
"pushover_vars": "Si no se define ningún filtro de remitente, se considerarán todos los correos.<br>Los filtros por expresión regular y las comprobaciones exactas de remitente pueden definirse por separado y se evalúan en orden. No dependen entre sí.<br>Variables utilizables para el texto y el título (tenga en cuenta las normas de protección de datos)"
|
||||
"spam_score": "Establecer una puntuación de spam personalizada"
|
||||
},
|
||||
"footer": {
|
||||
"hibp_nok": "¡Se encontró una coincidencia: esta es una contraseña <b>no segura</b>, elija otra!",
|
||||
"hibp_nok": "¡Se encontró coincidencia - esta es una contraseña <b>no segura</b>, selecciona otra!",
|
||||
"hibp_ok": "No se encontraron coincidencias",
|
||||
"loading": "Espere, por favor...",
|
||||
"loading": "Espera por favor...",
|
||||
"restart_now": "Reiniciar ahora",
|
||||
"restart_container": "Reiniciar contenedor",
|
||||
"restart_container_info": "<b>Importante:</b> Un reinicio limpio puede llevar un tiempo. Por favor, espere a que finalice.",
|
||||
@@ -752,7 +706,7 @@
|
||||
"header": {
|
||||
"administration": "Administración",
|
||||
"debug": "Información",
|
||||
"email": "Correo electrónico",
|
||||
"email": "E-Mail",
|
||||
"mailcow_config": "Configuración",
|
||||
"quarantine": "Cuarentena",
|
||||
"restart_sogo": "Reiniciar SOGo",
|
||||
@@ -798,12 +752,12 @@
|
||||
"add_bcc_entry": "Añadir regla de BCC",
|
||||
"add_domain": "Agregar dominio",
|
||||
"add_domain_alias": "Agregar alias de dominio",
|
||||
"add_domain_record_first": "Agregue primero un dominio",
|
||||
"add_domain_record_first": "Agrega un dominio primero",
|
||||
"add_filter": "Añadir filtro",
|
||||
"add_mailbox": "Agregar buzón",
|
||||
"add_recipient_map_entry": "Añadir regla de destinatario",
|
||||
"add_resource": "Añadir recurso",
|
||||
"add_tls_policy_map": "Agregar regla de política de TLS",
|
||||
"add_tls_policy_map": "Añadir regla de póliza de TLS",
|
||||
"address_rewriting": "Reescritura de direcciones",
|
||||
"alias": "Alias",
|
||||
"aliases": "Alias",
|
||||
@@ -881,20 +835,20 @@
|
||||
"running": "En marcha",
|
||||
"set_postfilter": "Marcar como posfiltro",
|
||||
"set_prefilter": "Marcar como prefiltro",
|
||||
"sieve_info": "Puede almacenar varios filtros por usuario, pero solo un prefiltro y un postfiltro pueden estar activos al mismo tiempo.<br>\r\nCada filtro será procesado en el orden descrito. Ni un script fallido ni un \"keep;\" emitido detendrá el procesamiento de otros scripts.<br>\r\n<a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_before\" target=\"_blank\">Prefiltro sieve global</a> → Prefiltro → Scripts del usuario → Posfiltro → <a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_after\" target=\"_blank\">Posfiltro sieve global</a>",
|
||||
"status": "Estado",
|
||||
"sieve_info": "Puedes almacenar múltiples filtros por usuario, pero solo un prefiltro y un postfiltro pueden estar activos al mismo tiempo.<br>\r\nCada filtro será procesado en el orden descrito. Ni un script fallido ni un \"keep;\" emitido detendrá el procesamiento de otros scripts.<br>\r\n<a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_before\" target=\"_blank\">Prefiltro sieve global</a> → Prefiltro → Scripts del usuario → Posfiltro → <a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_after\" target=\"_blank\">Posfiltro sieve global</a>",
|
||||
"status": "Status",
|
||||
"target_address": "Dirección destino",
|
||||
"target_domain": "Dominio destino",
|
||||
"tls_enforce_in": "Forzar TLS entrance",
|
||||
"tls_enforce_out": "Forzar TLS saliente",
|
||||
"tls_map_dest": "Destino",
|
||||
"tls_map_dest_info": "Ejemplos: example.org, .example.org, [mail.example.org]:25",
|
||||
"tls_map_parameters": "Parámetros",
|
||||
"tls_map_parameters_info": "Vacío o parámetros, por ejemplo: protocols=!SSLv2 ciphers=medium exclude=3DES",
|
||||
"tls_map_policy": "Política",
|
||||
"tls_map_parameters": "Parametros",
|
||||
"tls_map_parameters_info": "Vacío o parametros, por ejemplo: protocols=!SSLv2 ciphers=medium exclude=3DES",
|
||||
"tls_map_policy": "Póliza",
|
||||
"tls_policy_maps": "Políticas de TLS",
|
||||
"tls_policy_maps_info": "Este mapa de políticas anula las reglas de transporte TLS salientes, independientemente de la configuración de la política TLS de los usuarios.<br>\r\n Consulte <a href=\"http://www.postfix.org/postconf.5.html#smtp_tls_policy_maps\" target=\"_blank\">la documentación de \"smtp_tls_policy_maps\"</a> para más información.",
|
||||
"tls_policy_maps_long": "Forzar política de TLS saliente",
|
||||
"tls_policy_maps_info": "Esta póliza anula las reglas de transporte TLS salientes independientemente de la configuración de la política TLS de los usuarios.<br>\r\n Revisa <a href=\"http://www.postfix.org/postconf.5.html#smtp_tls_policy_maps\" target=\"_blank\">the \"smtp_tls_policy_maps\" docs</a> para mas información.",
|
||||
"tls_policy_maps_long": "Forzar póliza de TLS saliente",
|
||||
"toggle_all": "Selecionar todo",
|
||||
"username": "Nombre de usuario",
|
||||
"waiting": "Esperando",
|
||||
@@ -949,12 +903,7 @@
|
||||
"table_size_show_n": "Mostrar %s elementos",
|
||||
"templates": "Plantillas",
|
||||
"template": "Plantilla",
|
||||
"yes": "✓",
|
||||
"add_alias_expand": "Extender el alias a los dominios alias",
|
||||
"alias_domain_backupmx": "Dominio alias inactivo para el dominio de retransmisión",
|
||||
"catch_all": "Recoge-todo (catch-all)",
|
||||
"force_tfa": "2FA",
|
||||
"last_run_reset": "Programar la siguiente"
|
||||
"yes": "✓"
|
||||
},
|
||||
"oauth2": {
|
||||
"access_denied": "Inicie sesión como propietario del buzón para otorgar acceso a través de OAuth2.",
|
||||
@@ -979,8 +928,8 @@
|
||||
"medium_danger": "Peligro medio",
|
||||
"neutral_danger": "Neutral",
|
||||
"qhandler_success": "Solicitud enviada con éxito al sistema, puede cerrar la ventana",
|
||||
"qid": "QID de Rspamd",
|
||||
"qitem": "Elemento en cuarentena",
|
||||
"qid": "Rspamd QID",
|
||||
"qitem": "Quarantine item",
|
||||
"quarantine": "Cuarentena",
|
||||
"quick_actions": "Acciones",
|
||||
"rcpt": "Destinatario",
|
||||
@@ -1002,29 +951,14 @@
|
||||
"download_eml": "Descargar (.eml)",
|
||||
"info": "Información",
|
||||
"junk_folder": "Carpeta de correo no deseado",
|
||||
"notified": "Notificado",
|
||||
"qinfo": "El sistema de cuarentena guarda en la base de datos el correo rechazado (al remitente <em>no</em> se le dará la impresión de que el correo fue entregado) y también el correo que se entrega como copia en la carpeta de correo no deseado de un buzón.\r\n <br>\"Aprender como spam y eliminar\" aprende un mensaje como spam mediante el teorema de Bayes y calcula hashes difusos para rechazar mensajes similares en el futuro.\r\n <br>Tenga en cuenta que aprender varios mensajes puede llevar tiempo, según su sistema.<br>Los elementos de la lista de bloqueo se excluyen de la cuarentena.",
|
||||
"quick_delete_link": "Abrir enlace de eliminación rápida",
|
||||
"quick_release_link": "Abrir enlace de liberación rápida",
|
||||
"rewrite_subject": "Reescribir asunto",
|
||||
"sender_header": "Remitente (encabezado \"From\")",
|
||||
"settings_info": "Cantidad máxima de elementos en cuarentena (por buzón): %s<br>Tamaño máximo de correo: %s MiB"
|
||||
"notified": "Notificado"
|
||||
},
|
||||
"queue": {
|
||||
"queue_manager": "Administrador de cola",
|
||||
"flush": "Vaciar la cola",
|
||||
"info": "La cola de correo contiene todos los correos que esperan ser entregados. Si un correo permanece mucho tiempo en la cola, el sistema lo elimina automáticamente.<br>El mensaje de error de cada correo indica por qué no se pudo entregar.",
|
||||
"legend": "Acciones de la cola de correo:",
|
||||
"deliver_mail_legend": "Intenta volver a entregar los correos seleccionados.",
|
||||
"hold_mail_legend": "Retiene los correos seleccionados. (Impide nuevos intentos de entrega)",
|
||||
"show_message": "Mostrar mensaje",
|
||||
"unban": "desbloquear cola",
|
||||
"unhold_mail": "Liberar",
|
||||
"unhold_mail_legend": "Libera los correos seleccionados para su entrega. (Requiere que hayan sido retenidos antes)"
|
||||
"queue_manager": "Administrador de cola"
|
||||
},
|
||||
"start": {
|
||||
"help": "Mostrar/Ocultar panel de ayuda",
|
||||
"imap_smtp_server_auth_info": "Utilice su dirección de correo completa y el mecanismo de autenticación PLAIN.<br>\r\nSus datos de inicio de sesión se cifran gracias al cifrado obligatorio del servidor"
|
||||
"imap_smtp_server_auth_info": "Por favor utiliza tu dirección de correo completa y el mecanismo de autenticación PLAIN.<br>\r\nTus datos para iniciar sesión serán cifrados por el cifrado obligatorio del servidor"
|
||||
},
|
||||
"success": {
|
||||
"acl_saved": "ACL para el objeto %s guardado",
|
||||
@@ -1051,8 +985,8 @@
|
||||
"domain_admin_removed": "Administrador del dominio %s removido",
|
||||
"domain_modified": "Cambios al dominio %s guardados",
|
||||
"domain_removed": "Dominio %s removido",
|
||||
"forwarding_host_added": "Se agregó el host de reenvío %s",
|
||||
"forwarding_host_removed": "Se eliminó el host de reenvío %s",
|
||||
"forwarding_host_added": "Forwarding host %s has been added",
|
||||
"forwarding_host_removed": "Forwarding host %s has been removed",
|
||||
"logged_in_as": "Conectado como %s",
|
||||
"mailbox_added": "Buzón %s agregado",
|
||||
"mailbox_modified": "Cambios al buzón %s guardados",
|
||||
@@ -1070,86 +1004,31 @@
|
||||
"saved_settings": "Ajustes guardados",
|
||||
"settings_map_added": "Ajustes agregados",
|
||||
"settings_map_removed": "Ajustes del ID %s removidos",
|
||||
"tls_policy_map_entry_deleted": "Se eliminó la regla de política de TLS con ID %s",
|
||||
"tls_policy_map_entry_saved": "Se guardó la regla de política de TLS \"%s\"",
|
||||
"tls_policy_map_entry_deleted": "Regla de póliza de TLS con ID %s ha sido elimindada",
|
||||
"tls_policy_map_entry_saved": "Regla de póliza de TLS \"%s\" ha sido guardada",
|
||||
"verified_totp_login": "Inicio de sesión TOTP verificado",
|
||||
"verified_webauthn_login": "Inicio de sesión WebAuthn verificado",
|
||||
"verified_yotp_login": "Inicio de sesión Yubico OTP verificado",
|
||||
"admin_added": "Se agregó el administrador %s",
|
||||
"admin_api_modified": "Se guardaron los cambios en la API",
|
||||
"admin_removed": "Se eliminó el administrador %s",
|
||||
"app_links": "Se guardaron los cambios en los enlaces de aplicaciones",
|
||||
"app_passwd_added": "Se agregó una nueva contraseña de aplicación",
|
||||
"app_passwd_removed": "Se eliminó la contraseña de aplicación con ID %s",
|
||||
"cors_headers_edited": "Se guardó la configuración de CORS",
|
||||
"custom_login_modified": "La personalización del inicio de sesión se guardó correctamente",
|
||||
"domain_add_dkim_available": "Ya existía una clave DKIM",
|
||||
"dkim_duplicated": "La clave DKIM del dominio %s se copió a %s",
|
||||
"domain_footer_modified": "Se guardaron los cambios en el pie de página del dominio %s",
|
||||
"dovecot_restart_success": "Dovecot se reinició correctamente",
|
||||
"eas_reset": "Se restablecieron los dispositivos ActiveSync del usuario %s",
|
||||
"f2b_banlist_refreshed": "El ID de la lista de bloqueo se actualizó correctamente.",
|
||||
"f2b_modified": "Se guardaron los cambios en los parámetros de Fail2ban",
|
||||
"global_filter_written": "El filtro se escribió correctamente en el archivo",
|
||||
"hash_deleted": "Hash eliminado",
|
||||
"iam_test_connection": "Conexión correcta",
|
||||
"ip_check_opt_in_modified": "La comprobación de IP se guardó correctamente",
|
||||
"item_deleted": "Elemento %s eliminado correctamente",
|
||||
"item_released": "Elemento %s liberado",
|
||||
"items_deleted": "Elemento %s eliminado correctamente",
|
||||
"items_released": "Se liberaron los elementos seleccionados",
|
||||
"learned_ham": "El ID %s se aprendió correctamente como correo legítimo (ham)",
|
||||
"license_modified": "Se guardaron los cambios en la licencia",
|
||||
"mailbox_renamed": "El buzón se renombró de %s a %s",
|
||||
"nginx_reloaded": "Nginx se recargó",
|
||||
"object_modified": "Se guardaron los cambios en el objeto %s",
|
||||
"password_policy_saved": "La política de contraseñas se guardó correctamente",
|
||||
"password_changed_success": "La contraseña se cambió correctamente",
|
||||
"pushover_settings_edited": "La configuración de Pushover se guardó; verifique las credenciales.",
|
||||
"recovery_email_sent": "Se envió el correo de recuperación a %s",
|
||||
"reset_main_logo": "Se restableció el logo predeterminado",
|
||||
"resource_removed": "Se eliminó el recurso %s",
|
||||
"sogo_profile_reset": "Se restableció el perfil de SOGo del usuario %s",
|
||||
"template_added": "Se agregó la plantilla %s",
|
||||
"template_modified": "Se guardaron los cambios en la plantilla %s",
|
||||
"template_removed": "Se eliminó la plantilla con ID %s",
|
||||
"ui_texts": "Se guardaron los cambios en los textos de la interfaz",
|
||||
"ui_theme_modified": "El tema se guardó correctamente",
|
||||
"upload_success": "Archivo subido correctamente",
|
||||
"verified_fido2_login": "Inicio de sesión FIDO2 verificado"
|
||||
"verified_yotp_login": "Inicio de sesión Yubico OTP verificado"
|
||||
},
|
||||
"tfa": {
|
||||
"api_register": "%s utiliza la API en la nube de Yubico. Obtenga una clave de API para su llave <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">aquí</a>.",
|
||||
"api_register": "%s utiliza la API de la nube de Yubico. Por favor, obtén una clave API para tu llave <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">aquí</a>.",
|
||||
"confirm": "Confirmar",
|
||||
"confirm_totp_token": "Confirme los cambios ingresando el token generado",
|
||||
"confirm_totp_token": "Por favor confirma los cambios ingresando el token generado",
|
||||
"delete_tfa": "Desactivar TFA",
|
||||
"disable_tfa": "Desactivar TFA hasta el próximo inicio de sesión exitoso",
|
||||
"enter_qr_code": "Su código TOTP si su dispositivo no puede escanear códigos QR",
|
||||
"key_id": "Un identificador para su YubiKey",
|
||||
"key_id_totp": "Un identificador para su llave",
|
||||
"key_id": "Un identificador para tu YubiKey",
|
||||
"key_id_totp": "Un identificador para tu llave",
|
||||
"none": "Desactivar",
|
||||
"scan_qr_code": "Escanee el siguiente código con su aplicación de autenticador o ingrese el código manualmente.",
|
||||
"select": "Seleccione",
|
||||
"select": "Selecciona",
|
||||
"set_tfa": "Establecer el método de autenticación de dos factores",
|
||||
"tfa": "Autenticación de dos factores",
|
||||
"totp": "OTP basado en tiempo (Google Authenticator, Authy, etc.)",
|
||||
"webauthn": "Autenticación WebAuthn",
|
||||
"waiting_usb_auth": "<i>Esperando al dispositivo USB...</i><br><br>Toque el botón en su dispositivo USB WebAuthn ahora.",
|
||||
"waiting_usb_register": "<i>Esperando al dispositivo USB....</i><br><br>Ingrese su contraseña arriba y confirme su registro WebAuthn tocando el botón en su dispositivo USB WebAuthn.",
|
||||
"yubi_otp": "Yubico OTP",
|
||||
"authenticators": "Autenticadores",
|
||||
"force_tfa": "Exigir configurar 2FA al iniciar sesión",
|
||||
"force_tfa_info": "El usuario deberá configurar la autenticación de dos factores antes de acceder al panel.",
|
||||
"setup_title": "Autenticación de dos factores obligatoria",
|
||||
"setup_required": "Su cuenta requiere autenticación de dos factores. Configure un método de 2FA para continuar.",
|
||||
"cancel_setup": "Cancelar y cerrar sesión",
|
||||
"error_code": "Código de error",
|
||||
"init_webauthn": "Inicializando, espere...",
|
||||
"reload_retry": "- (recargue el navegador si el error persiste)",
|
||||
"start_webauthn_validation": "Iniciar validación",
|
||||
"tfa_token_invalid": "Token de 2FA no válido",
|
||||
"u2f_deprecated": "Parece que su llave se registró con el método obsoleto U2F. Desactivaremos la autenticación de dos factores y eliminaremos su llave.",
|
||||
"u2f_deprecated_important": "Registre su llave en el panel de administración con el nuevo método WebAuthn."
|
||||
"yubi_otp": "Yubico OTP"
|
||||
},
|
||||
"syncjobs": {
|
||||
"sync_jobs": "Trabajos de sincronización",
|
||||
@@ -1188,7 +1067,7 @@
|
||||
"alias": "Alias",
|
||||
"alias_create_random": "Generar alias aleatorio",
|
||||
"alias_extend_all": "Extender alias por 1 hora",
|
||||
"alias_full_date": "d/m/Y, H:i:s T",
|
||||
"alias_full_date": "d.m.Y, H:i:s T",
|
||||
"alias_remove_all": "Eliminar todos los alias",
|
||||
"alias_select_validity": "Periodo de validez",
|
||||
"alias_time_left": "Tiempo restante",
|
||||
@@ -1238,7 +1117,7 @@
|
||||
"spamfilter_bl": "Lista negra",
|
||||
"spamfilter_bl_desc": "Direcciones en la lista negra <b>siempre</b> clasificarán como spam. Se puede usar comodines. Filtro solo aplica a los alias directos (alias con un solo buzón de destino) excluyendo los alias catch-all.",
|
||||
"spamfilter_default_score": "Valores predeterminados:",
|
||||
"spamfilter_green": "Verde: este mensaje no es spam",
|
||||
"spamfilter_green": "Verde: éste mensaje no es spam",
|
||||
"spamfilter_hint": "El primer valor representa la \"calificación baja de spam\", el segundo representa la \"calificación alta de spam\".",
|
||||
"spamfilter_red": "Rojo: Este mensaje es spam, será rechazado por el servidor y enviado a la quarantena (si esta configurada)",
|
||||
"spamfilter_table_action": "Acción",
|
||||
@@ -1248,8 +1127,8 @@
|
||||
"spamfilter_table_rule": "Regla",
|
||||
"spamfilter_wl": "Lista blanca",
|
||||
"spamfilter_wl_desc": "Direcciones en la lista blanca <b>nunca</b> clasificarán como spam. Se puede usar comodines. Filtro solo aplica a los alias directos (alias con un solo buzón de destino) excluyendo los alias catch-all.",
|
||||
"spamfilter_yellow": "Amarillo: este mensaje puede ser spam, será etiquetado como spam y movido a su carpeta de correo no deseado",
|
||||
"status": "Estado",
|
||||
"spamfilter_yellow": "Amarillo: éste mensaje puede ser spam, será etiquetado como spam y trasladado a tu carpeta de correo no deseado",
|
||||
"status": "Status",
|
||||
"tag_handling": "Establecer manejo para el correo etiquetado",
|
||||
"tag_help_example": "Ejemplo de una dirección email etiquetada: mi<b>+Facebook</b>@ejemplo.org",
|
||||
"tag_help_explain": "En subcarpeta: una nueva subcarpeta llamada como la etiqueta será creada debajo de INBOX (\"INBOX/Facebook\").<br>\r\nEn asunto: los nombres de las etiquetas serán añadidos al asunto de los correos, ejemplo: \"[Facebook] Mis Noticias\".",
|
||||
@@ -1268,89 +1147,13 @@
|
||||
"weeks": "Semanas",
|
||||
"with_app_password": "con contraseña de aplicación",
|
||||
"year": "año",
|
||||
"years": "años",
|
||||
"active_sieve": "Filtro activo",
|
||||
"advanced_settings": "Configuración avanzada",
|
||||
"app_hint": "Las contraseñas de aplicación son contraseñas alternativas para iniciar sesión en IMAP, SMTP, CalDAV, CardDAV y EAS. El nombre de usuario no cambia. El webmail SOGo no está disponible con contraseñas de aplicación.",
|
||||
"allowed_protocols": "Protocolos permitidos",
|
||||
"app_name": "Nombre de la aplicación",
|
||||
"app_passwds": "Contraseñas de aplicación",
|
||||
"apple_connection_profile": "Perfil de conexión de Apple",
|
||||
"apple_connection_profile_complete": "Este perfil de conexión incluye los parámetros de IMAP y SMTP, y las rutas de CalDAV (calendarios) y CardDAV (contactos) para un dispositivo Apple.",
|
||||
"apple_connection_profile_mailonly": "Este perfil de conexión incluye los parámetros de configuración de IMAP y SMTP para un dispositivo Apple.",
|
||||
"apple_connection_profile_with_app_password": "Se genera una nueva contraseña de aplicación y se agrega al perfil, para que no haga falta escribir ninguna contraseña al configurar el dispositivo. No comparta el archivo: da acceso completo a su buzón.",
|
||||
"attribute": "Atributo",
|
||||
"authentication": "Autenticación",
|
||||
"change_password_hint_app_passwords": "Su cuenta tiene %d contraseñas de aplicación que no se cambiarán. Para administrarlas, vaya a la pestaña Contraseñas de aplicación.",
|
||||
"clear_recent_successful_connections": "Borrar las conexiones correctas registradas",
|
||||
"client_configuration": "Mostrar guías de configuración para clientes de correo y teléfonos",
|
||||
"create_app_passwd": "Crear contraseña de aplicación",
|
||||
"delete_ays": "Confirme la eliminación.",
|
||||
"direct_protocol_access": "Este usuario de buzón tiene <b>acceso directo y externo</b> a los siguientes protocolos y aplicaciones. Su administrador controla este ajuste. Puede crear contraseñas de aplicación para dar acceso a protocolos y aplicaciones concretos.<br>El botón \"Webmail\" ofrece inicio de sesión único en SOGo y siempre está disponible.",
|
||||
"email": "Correo electrónico",
|
||||
"email_and_dav": "Correo, calendarios y contactos",
|
||||
"empty": "Sin resultados",
|
||||
"fido2_webauthn": "FIDO2/WebAuthn",
|
||||
"force_pw_update": "<b>Debe</b> establecer una nueva contraseña para acceder a los servicios de groupware.",
|
||||
"from": "de",
|
||||
"generate": "generar",
|
||||
"in_use": "En uso",
|
||||
"last_mail_login": "Último inicio de sesión de correo",
|
||||
"last_pw_change": "Último cambio de contraseña",
|
||||
"last_ui_login": "Último inicio de sesión en la interfaz",
|
||||
"loading": "Cargando...",
|
||||
"login_history": "Historial de inicios de sesión",
|
||||
"mailbox": "Buzón",
|
||||
"mailbox_general": "General",
|
||||
"mailbox_settings": "Configuración",
|
||||
"messages": "mensajes",
|
||||
"month": "mes",
|
||||
"months": "meses",
|
||||
"no_active_filter": "No hay ningún filtro activo",
|
||||
"no_last_login": "No hay información del último inicio de sesión en la interfaz",
|
||||
"open_logs": "Abrir registros",
|
||||
"open_webmail_sso": "Webmail",
|
||||
"overview": "Resumen",
|
||||
"password": "Contraseña",
|
||||
"pw_update_required": "Su cuenta requiere un cambio de contraseña. Establezca una nueva contraseña para continuar.",
|
||||
"password_repeat": "Contraseña (repetir)",
|
||||
"password_reset_info": "Si no se indica un correo de recuperación de contraseña, esta función no se puede usar.",
|
||||
"protocols": "Protocolos",
|
||||
"pushover_evaluate_x_prio": "Escalar el correo de alta prioridad [<code>X-Priority: 1</code>]",
|
||||
"pushover_info": "La configuración de notificaciones push se aplicará a todo el correo limpio (no spam) entregado a <b>%s</b>, incluidos los alias (compartidos, no compartidos, etiquetados).",
|
||||
"pushover_only_x_prio": "Considerar solo el correo de alta prioridad [<code>X-Priority: 1</code>]",
|
||||
"pushover_sender_array": "Considerar las siguientes direcciones de remitente <small>(separadas por comas)</small>",
|
||||
"pushover_sender_regex": "Filtrar remitentes con la siguiente expresión regular",
|
||||
"pushover_text": "Texto de la notificación",
|
||||
"pushover_title": "Título de la notificación",
|
||||
"pushover_sound": "Sonido",
|
||||
"pushover_vars": "Si no se define ningún filtro de remitente, se considerarán todos los correos.<br>Los filtros por expresión regular y las comprobaciones exactas de remitente pueden definirse por separado y se evalúan en orden. No dependen entre sí.<br>Variables utilizables para el texto y el título (tenga en cuenta las normas de protección de datos)",
|
||||
"pushover_verify": "Verificar credenciales",
|
||||
"pw_recovery_email": "Correo de recuperación de contraseña",
|
||||
"q_add_header": "Carpeta de correo no deseado",
|
||||
"quarantine_category_info": "La categoría de notificación \"Rechazado\" incluye el correo que fue rechazado, mientras que \"Carpeta de correo no deseado\" avisa al usuario de los correos que se movieron a esa carpeta.",
|
||||
"recent_successful_connections": "Conexiones correctas registradas",
|
||||
"sender_acl_disabled": "<span class=\"badge fs-6 bg-danger\">La comprobación de remitente está desactivada</span>",
|
||||
"show_sieve_filters": "Mostrar el filtro Sieve activo del usuario",
|
||||
"sogo_profile_reset_help": "Esto destruirá el perfil de SOGo del usuario y <b>eliminará de forma irrecuperable todos sus contactos y datos de calendario</b>.",
|
||||
"spam_score_reset": "Restablecer al valor predeterminado del servidor",
|
||||
"spamfilter_table_domain_policy": "n/d (política del dominio)",
|
||||
"syncjob_check_log": "Revisar registro",
|
||||
"syncjob_EX_OK": "Correcto",
|
||||
"syncjob_EXIT_OVERQUOTA": "El buzón de destino superó su cuota",
|
||||
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Usuario o contraseña incorrectos",
|
||||
"tfa_info": "La autenticación de dos factores ayuda a proteger su cuenta. Si la activa, necesitará contraseñas de aplicación para iniciar sesión en aplicaciones o servicios que no admiten la autenticación de dos factores (p. ej., clientes de correo)."
|
||||
"years": "años"
|
||||
},
|
||||
"warning": {
|
||||
"domain_added_sogo_failed": "Se agregó el dominio, pero no se pudo reiniciar SOGo; revise los registros del servidor.",
|
||||
"domain_added_sogo_failed": "Se agregó el dominio pero no se pudo reiniciar SOGo, revisa los logs del servidor.",
|
||||
"fuzzy_learn_error": "Error aprendiendo hash: %s",
|
||||
"ip_invalid": "IP inválida omitida: %s",
|
||||
"cannot_delete_self": "No se puede eliminar el usuario conectado",
|
||||
"dovecot_restart_failed": "No se pudo reiniciar Dovecot, revise los registros",
|
||||
"is_not_primary_alias": "Se omitió el alias no principal %s",
|
||||
"quota_exceeded_scope": "Se superó la cuota del dominio: en este dominio solo se pueden crear buzones ilimitados.",
|
||||
"session_token": "Token de formulario no válido: los tokens no coinciden",
|
||||
"session_ua": "Token de formulario no válido: error de validación del User-Agent"
|
||||
"cannot_delete_self": "No se puede eliminar el usuario conectado"
|
||||
},
|
||||
"datatables": {
|
||||
"collapse_all": "Contraer todo",
|
||||
@@ -1391,12 +1194,5 @@
|
||||
"register_status": "Estado de registro",
|
||||
"rename": "Renombrar",
|
||||
"start_fido2_validation": "Iniciar validación FIDO2"
|
||||
},
|
||||
"ratelimit": {
|
||||
"disabled": "Desactivado",
|
||||
"second": "mensajes / segundo",
|
||||
"minute": "mensajes / minuto",
|
||||
"hour": "mensajes / hora",
|
||||
"day": "mensajes / día"
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,6 @@
|
||||
"admin": {
|
||||
"access": "Hallinta",
|
||||
"action": "Toiminnot",
|
||||
"activate_api": "Aktivoi API",
|
||||
"activate_send": "Aktivoi Lähetä-painike",
|
||||
"active": "Aktiivinen",
|
||||
"active_rspamd_settings_map": "Aktiiviset asetukset",
|
||||
@@ -668,7 +667,6 @@
|
||||
"success": {
|
||||
"acl_saved": "Objektin ACL %s tallenettu",
|
||||
"admin_added": "Järjestelmänvalvoja %s on lisätty",
|
||||
"admin_api_modified": "API-muutokset on tallennettu",
|
||||
"admin_modified": "Järjestelmänvalvojan muutokset on tallennettu",
|
||||
"admin_removed": "Ylläpitäjä %s on poistettu",
|
||||
"alias_added": "Alias osoite %s (%d) on lisätty",
|
||||
|
||||
@@ -102,7 +102,6 @@
|
||||
"admin": {
|
||||
"access": "Accès",
|
||||
"action": "Action",
|
||||
"activate_api": "Activer l'API",
|
||||
"activate_send": "Activer le bouton d'envoi",
|
||||
"active": "Actif",
|
||||
"active_rspamd_settings_map": "Paramètres de mappage actifs",
|
||||
@@ -1043,7 +1042,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL (Access Control List) pour l'objet %s enregistré",
|
||||
"admin_added": "Administrateur %s a été ajouté",
|
||||
"admin_api_modified": "Les modifications apportées à l’API ont été enregistrées",
|
||||
"admin_modified": "Les modifications apportées à l’administrateur ont été enregistrées",
|
||||
"admin_removed": "Administrateur %s a été effacé",
|
||||
"alias_added": "L'adresse alias %s (%d) a été ajoutée",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"upload": "Feltöltés",
|
||||
"username": "Felhasználónév",
|
||||
"verify": "Ellenőrzés",
|
||||
"activate_api": "API aktiválása",
|
||||
"activate_send": "Küldés gomb aktiválása",
|
||||
"add": "Hozzáad",
|
||||
"active": "Aktív",
|
||||
@@ -727,7 +726,6 @@
|
||||
},
|
||||
"success": {
|
||||
"admin_added": "Adminisztrátor %s hozzáadva",
|
||||
"admin_api_modified": "APImódosítások mentve",
|
||||
"admin_modified": "Adminisztrátor módosításai mentve",
|
||||
"admin_removed": "Adminisztrátor %s eltávolítva",
|
||||
"alias_added": "Alias cím %s (%d) hozzáadva",
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"admin": {
|
||||
"access": "Accedi",
|
||||
"action": "Azione",
|
||||
"activate_api": "Attiva API",
|
||||
"activate_send": "Attiva bottone di invio",
|
||||
"active": "Attiva",
|
||||
"active_rspamd_settings_map": "Mappa delle impostazioni attive",
|
||||
@@ -979,7 +978,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL for object %s saved",
|
||||
"admin_added": "L'amministratore %s è stato aggiunto",
|
||||
"admin_api_modified": "Le modifiche alle API sono state salvate",
|
||||
"admin_modified": "I cambiamenti all'amministratore sono stati salvati",
|
||||
"admin_removed": "L'amministratore %s è stato rimosso",
|
||||
"alias_added": "Uno o più alias sono stati correttamente aggiunti",
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
"admin": {
|
||||
"access": "アクセス",
|
||||
"action": "アクション",
|
||||
"activate_api": "APIを有効化",
|
||||
"activate_send": "送信ボタンを有効化",
|
||||
"active": "アクティブ",
|
||||
"active_rspamd_settings_map": "アクティブな設定マップ",
|
||||
@@ -1002,7 +1001,6 @@
|
||||
"success": {
|
||||
"acl_saved": "オブジェクト %s のACLを保存しました",
|
||||
"admin_added": "管理者 %s が追加されました",
|
||||
"admin_api_modified": "APIの変更が保存されました",
|
||||
"admin_modified": "管理者の変更が保存されました",
|
||||
"admin_removed": "管理者 %s が削除されました",
|
||||
"alias_added": "エイリアスアドレス %s (%d) が追加されました",
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"admin": {
|
||||
"access": "접근",
|
||||
"action": "동작",
|
||||
"activate_api": "API 활성화",
|
||||
"activate_send": "발신 버튼 활성화",
|
||||
"active": "활성화",
|
||||
"active_rspamd_settings_map": "설정 맵 활성화",
|
||||
@@ -854,7 +853,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL for object %s saved",
|
||||
"admin_added": "Administrator %s has been added",
|
||||
"admin_api_modified": "Changes to API have been saved",
|
||||
"admin_modified": "Changes to administrator have been saved",
|
||||
"admin_removed": "Administrator %s has been removed",
|
||||
"alias_added": "Alias address %s (%d) has been added",
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"admin": {
|
||||
"access": "Pieeja",
|
||||
"action": "Rīcība",
|
||||
"activate_api": "Aktivizēt API",
|
||||
"active": "Aktīvs",
|
||||
"add": "Pievienot",
|
||||
"add_domain_admin": "Pievienot domēna pārvaldītāju",
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"access": "Tilgang",
|
||||
"add_domain_admin": "Legg til domeneadministrator",
|
||||
"add_forwarding_host": "Legg til videresendingsvert",
|
||||
"activate_api": "Aktivér API",
|
||||
"add_relayhost_hint": "Vennligst vær oppmerksom på at autentiseringsdata, om noe, vil bli lagret i klartekst.",
|
||||
"app_links": "App-lenker",
|
||||
"app_name": "App-navn",
|
||||
|
||||
@@ -100,7 +100,6 @@
|
||||
"admin": {
|
||||
"access": "Toegang",
|
||||
"action": "Handeling",
|
||||
"activate_api": "Activeer API",
|
||||
"activate_send": "Bevestig bovenstaande gegevens",
|
||||
"active": "Actief",
|
||||
"active_rspamd_settings_map": "Huidige instellingen",
|
||||
@@ -1035,7 +1034,6 @@
|
||||
"success": {
|
||||
"acl_saved": "Toegangscontrole voor object %s is opgeslagen",
|
||||
"admin_added": "Administrator %s is toegevoegd",
|
||||
"admin_api_modified": "Wijzigingen aan de API zijn opgeslagen",
|
||||
"admin_modified": "Wijzigingen aan administrator zijn opgeslagen",
|
||||
"admin_removed": "Administrator %s is verwijderd",
|
||||
"alias_added": "Aliasadres %s (%d) is toegevoegd",
|
||||
|
||||
@@ -153,7 +153,6 @@
|
||||
"time": "Czas",
|
||||
"unchanged_if_empty": "W przypadku braku zmian, nie wypełniaj",
|
||||
"username": "Nazwa użytkownika",
|
||||
"activate_api": "Aktywuj API",
|
||||
"activate_send": "Aktywuj przycisk wysyłania",
|
||||
"active_rspamd_settings_map": "Aktywne ustawienia mapy",
|
||||
"add_admin": "Dodaj administratora",
|
||||
@@ -919,7 +918,6 @@
|
||||
"verified_yotp_login": "Zweryfikowany login Yubico OTP",
|
||||
"acl_saved": "ACL dla obiektu %s zapisany",
|
||||
"admin_added": "Administrator %s został dodany",
|
||||
"admin_api_modified": "Zmiany w API zostały zapisane",
|
||||
"admin_removed": "Administrator %s został usunięty",
|
||||
"app_links": "Zapisane zmiany w linkach aplikacji",
|
||||
"app_passwd_added": "Dodano nowe hasło aplikacji",
|
||||
|
||||
@@ -102,7 +102,6 @@
|
||||
"admin": {
|
||||
"access": "Acesso",
|
||||
"action": "Ação",
|
||||
"activate_api": "Ativar API",
|
||||
"activate_send": "Ativar botão de envio",
|
||||
"active": "Ativo",
|
||||
"active_rspamd_settings_map": "Mapa de configurações ativas",
|
||||
@@ -1092,7 +1091,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL para o objeto %s salvo",
|
||||
"admin_added": "O administrador %s foi adicionado",
|
||||
"admin_api_modified": "As alterações na API foram salvas",
|
||||
"admin_modified": "As alterações no administrador foram salvas",
|
||||
"admin_removed": "O administrador %s foi removido",
|
||||
"alias_added": "O endereço de alias %s (%d) foi adicionado",
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"customize": "Personalizar",
|
||||
"destination": "Destino",
|
||||
"dkim_from": "De",
|
||||
"activate_api": "Ativar API",
|
||||
"add_admin": "Adicionar administrador",
|
||||
"admins": "Administradores",
|
||||
"advanced_settings": "Configurações avançadas",
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"admin": {
|
||||
"access": "Acces",
|
||||
"action": "Acțiune",
|
||||
"activate_api": "Activează API",
|
||||
"activate_send": "Activează butonul de trimitere",
|
||||
"active": "Activ",
|
||||
"active_rspamd_settings_map": "Harta setărilor active",
|
||||
@@ -889,7 +888,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL pentru obiectul %s a fost salvat",
|
||||
"admin_added": "Administratorul %s a fost adăugat",
|
||||
"admin_api_modified": "Modificările API-ului au fost salvate",
|
||||
"admin_modified": "Modificările administratorului au fost salvate",
|
||||
"admin_removed": "Administratorul %s a fost eliminat",
|
||||
"alias_added": "Adresa alias %s a fost adăugată",
|
||||
|
||||
@@ -102,7 +102,6 @@
|
||||
"admin": {
|
||||
"access": "Настройки доступа",
|
||||
"action": "Действия",
|
||||
"activate_api": "Включить API",
|
||||
"activate_send": "Активировать кнопку отправки",
|
||||
"active": "Активный",
|
||||
"active_rspamd_settings_map": "Отобразить текущие правила",
|
||||
@@ -1088,7 +1087,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL для %s сохранено",
|
||||
"admin_added": "Администратор %s добавлен",
|
||||
"admin_api_modified": "Изменения в API сохранены",
|
||||
"admin_modified": "Изменения администратора сохранены",
|
||||
"admin_removed": "Администратор %s удалён",
|
||||
"alias_added": "Псевдоним %s (%d) добавлен",
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"admin": {
|
||||
"access": "Dostop",
|
||||
"action": "Dejanje",
|
||||
"activate_api": "Aktiviraj API",
|
||||
"activate_send": "Aktiviraj gumb \"Pošlji\"",
|
||||
"active": "Aktivno",
|
||||
"active_rspamd_settings_map": "Aktivna preslikava nastavitev",
|
||||
@@ -1100,7 +1099,6 @@
|
||||
"relayhost_removed": "Vnos na zemljevidu %s je bil odstranjen",
|
||||
"acl_saved": "ACL za objekt %s shranjen",
|
||||
"admin_added": "Dodan je bil skrbnik %s",
|
||||
"admin_api_modified": "Spremembe API-ja so bile shranjene",
|
||||
"admin_modified": "Spremembe skrbnika so bile shranjene",
|
||||
"admin_removed": "Administrator %s je bil odstranjen",
|
||||
"alias_domain_removed": "Vzdevek domene %s je bil odstranjen",
|
||||
|
||||
@@ -98,7 +98,6 @@
|
||||
"admin": {
|
||||
"access": "Prístup",
|
||||
"action": "Akcia",
|
||||
"activate_api": "Aktivovať API",
|
||||
"activate_send": "Aktivovať tlačidlo odoslania",
|
||||
"active": "Aktívny",
|
||||
"active_rspamd_settings_map": "Aktívne nastavenia",
|
||||
@@ -935,7 +934,6 @@
|
||||
"success": {
|
||||
"acl_saved": "ACL pre objekt %s uložený",
|
||||
"admin_added": "Administrátor %s pridaný",
|
||||
"admin_api_modified": "Zmeny v API uložené",
|
||||
"admin_modified": "Zmeny v administrácii uložené",
|
||||
"admin_removed": "Administrátor %s odstránený ",
|
||||
"alias_added": "Alias adresa %s (%d) pridaná",
|
||||
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user