mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-27 20:55:42 +00:00
Compare commits
15
Commits
staging
...
feat/groups
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15042e5921 | ||
|
|
3959e311a0 | ||
|
|
ad86403b87 | ||
|
|
2c5718c9c9 | ||
|
|
8bc8fbdd2c | ||
|
|
f63042bbaf | ||
|
|
4828f0cf64 | ||
|
|
40488b2120 | ||
|
|
02a9f59530 | ||
|
|
0dadf49934 | ||
|
|
c145f858e2 | ||
|
|
474f1c688b | ||
|
|
7c62a57910 | ||
|
|
e756b7857e | ||
|
|
b2a152fe45 |
No files matched your search
+3
-1
@@ -78,4 +78,6 @@ refresh_images.sh
|
||||
update_diffs/
|
||||
create_cold_standby.sh
|
||||
!data/conf/nginx/mailcow_auth.conf
|
||||
data/conf/postfix/postfix-tlspol
|
||||
data/conf/postfix/postfix-tlspol
|
||||
tests/php/vendor/
|
||||
tests/php/.phpunit.cache/
|
||||
@@ -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
|
||||
|
||||
@@ -68,7 +68,6 @@ RUN addgroup -g 5000 vmail \
|
||||
perl-package-stash-xs \
|
||||
perl-par-packer \
|
||||
perl-parse-recdescent \
|
||||
perl-lockfile-simple \
|
||||
libproc2 \
|
||||
perl-readonly \
|
||||
perl-regexp-common \
|
||||
@@ -120,7 +119,7 @@ COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh
|
||||
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
||||
COPY imapsync /usr/local/bin/imapsync
|
||||
COPY imapsync_runner.pl /usr/local/bin/imapsync_runner.pl
|
||||
COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py
|
||||
COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
|
||||
COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
|
||||
COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -279,7 +280,7 @@ chmod g+rw /dev/console
|
||||
chown root:tty /dev/console
|
||||
chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
|
||||
/usr/lib/dovecot/sieve/rspamd-pipe-spam \
|
||||
/usr/local/bin/imapsync_runner.pl \
|
||||
/usr/local/bin/imapsync_runner.py \
|
||||
/usr/local/bin/imapsync \
|
||||
/usr/local/bin/trim_logs.sh \
|
||||
/usr/local/bin/sa-rules.sh \
|
||||
@@ -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
|
||||
|
||||
+6759
-3364
File diff suppressed because it is too large.
Load diff
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
|
||||
use DBI;
|
||||
use LockFile::Simple qw(lock trylock unlock);
|
||||
use Proc::ProcessTable;
|
||||
use Data::Dumper qw(Dumper);
|
||||
use IPC::Run 'run';
|
||||
use File::Temp;
|
||||
use Try::Tiny;
|
||||
use sigtrap 'handler' => \&sig_handler, qw(INT TERM KILL QUIT);
|
||||
|
||||
sub trim { my $s = shift; $s =~ s/^\s+|\s+$//g; return $s };
|
||||
my $t = Proc::ProcessTable->new;
|
||||
my $imapsync_running = grep { $_->{cmndline} =~ /imapsync\s/i } @{$t->table};
|
||||
if ($imapsync_running ge 1)
|
||||
{
|
||||
print "imapsync is active, exiting...";
|
||||
exit;
|
||||
}
|
||||
|
||||
sub qqw($) {
|
||||
my @params = ();
|
||||
my @values = split(/(?=--)/, $_[0]);
|
||||
foreach my $val (@values) {
|
||||
my @tmpparam = split(/ /, $val, 2);
|
||||
foreach my $tmpval (@tmpparam) {
|
||||
if ($tmpval ne '') {
|
||||
push @params, $tmpval;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach my $val (@params) {
|
||||
$val=trim($val);
|
||||
}
|
||||
return @params;
|
||||
}
|
||||
|
||||
$run_dir="/tmp";
|
||||
$dsn = 'DBI:mysql:database=' . $ENV{'DBNAME'} . ';mysql_socket=/var/run/mysqld/mysqld.sock';
|
||||
$lock_file = $run_dir . "/imapsync_busy";
|
||||
$lockmgr = LockFile::Simple->make(-autoclean => 1, -max => 1);
|
||||
$lockmgr->lock($lock_file) || die "can't lock ${lock_file}";
|
||||
$dbh = DBI->connect($dsn, $ENV{'DBUSER'}, $ENV{'DBPASS'}, {
|
||||
mysql_auto_reconnect => 1,
|
||||
mysql_enable_utf8mb4 => 1
|
||||
});
|
||||
$dbh->do("UPDATE imapsync SET is_running = 0");
|
||||
|
||||
sub sig_handler {
|
||||
# Send die to force exception in "run"
|
||||
die "sig_handler received signal, preparing to exit...\n";
|
||||
};
|
||||
|
||||
open my $file, '<', "/etc/sogo/sieve.creds";
|
||||
my $creds = <$file>;
|
||||
close $file;
|
||||
my ($master_user, $master_pass) = split /:/, $creds;
|
||||
my $sth = $dbh->prepare("SELECT id,
|
||||
user1,
|
||||
user2,
|
||||
host1,
|
||||
authmech1,
|
||||
password1,
|
||||
exclude,
|
||||
port1,
|
||||
enc1,
|
||||
delete2duplicates,
|
||||
maxage,
|
||||
subfolder2,
|
||||
delete1,
|
||||
delete2,
|
||||
automap,
|
||||
skipcrossduplicates,
|
||||
maxbytespersecond,
|
||||
custom_params,
|
||||
subscribeall,
|
||||
timeout1,
|
||||
timeout2,
|
||||
dry
|
||||
FROM imapsync
|
||||
WHERE active = 1
|
||||
AND is_running = 0
|
||||
AND (
|
||||
UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(last_run) > mins_interval * 60
|
||||
OR
|
||||
last_run IS NULL)
|
||||
ORDER BY last_run");
|
||||
|
||||
$sth->execute();
|
||||
my $row;
|
||||
|
||||
while ($row = $sth->fetchrow_arrayref()) {
|
||||
|
||||
$id = @$row[0];
|
||||
$user1 = @$row[1];
|
||||
$user2 = @$row[2];
|
||||
$host1 = @$row[3];
|
||||
$authmech1 = @$row[4];
|
||||
$password1 = @$row[5];
|
||||
$exclude = @$row[6];
|
||||
$port1 = @$row[7];
|
||||
$enc1 = @$row[8];
|
||||
$delete2duplicates = @$row[9];
|
||||
$maxage = @$row[10];
|
||||
$subfolder2 = @$row[11];
|
||||
$delete1 = @$row[12];
|
||||
$delete2 = @$row[13];
|
||||
$automap = @$row[14];
|
||||
$skipcrossduplicates = @$row[15];
|
||||
$maxbytespersecond = @$row[16];
|
||||
$custom_params = @$row[17];
|
||||
$subscribeall = @$row[18];
|
||||
$timeout1 = @$row[19];
|
||||
$timeout2 = @$row[20];
|
||||
$dry = @$row[21];
|
||||
|
||||
if ($enc1 eq "TLS") { $enc1 = "--tls1"; } elsif ($enc1 eq "SSL") { $enc1 = "--ssl1"; } else { undef $enc1; }
|
||||
|
||||
my $template = $run_dir . '/imapsync.XXXXXXX';
|
||||
my $passfile1 = File::Temp->new(TEMPLATE => $template);
|
||||
my $passfile2 = File::Temp->new(TEMPLATE => $template);
|
||||
|
||||
binmode( $passfile1, ":utf8" );
|
||||
|
||||
print $passfile1 "$password1\n";
|
||||
print $passfile2 trim($master_pass) . "\n";
|
||||
|
||||
my @custom_params_a = qqw($custom_params);
|
||||
my $custom_params_ref = \@custom_params_a;
|
||||
|
||||
my $generated_cmds = [ "/usr/local/bin/imapsync",
|
||||
"--tmpdir", "/tmp",
|
||||
"--nofoldersizes",
|
||||
"--addheader",
|
||||
($timeout1 le "0" ? () : ('--timeout1', $timeout1)),
|
||||
($timeout2 le "0" ? () : ('--timeout2', $timeout2)),
|
||||
($exclude eq "" ? () : ("--exclude", $exclude)),
|
||||
($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)),
|
||||
($maxage eq "0" ? () : ('--maxage', $maxage)),
|
||||
($maxbytespersecond eq "0" ? () : ('--maxbytespersecond', $maxbytespersecond)),
|
||||
($delete2duplicates ne "1" ? () : ('--delete2duplicates')),
|
||||
($subscribeall ne "1" ? () : ('--subscribeall')),
|
||||
($delete1 ne "1" ? () : ('--delete')),
|
||||
($delete2 ne "1" ? () : ('--delete2')),
|
||||
($automap ne "1" ? () : ('--automap')),
|
||||
($skipcrossduplicates ne "1" ? () : ('--skipcrossduplicates')),
|
||||
(!defined($enc1) ? () : ($enc1)),
|
||||
"--host1", $host1,
|
||||
"--user1", $user1,
|
||||
"--passfile1", $passfile1->filename,
|
||||
"--port1", $port1,
|
||||
"--host2", "localhost",
|
||||
"--user2", $user2 . '*' . trim($master_user),
|
||||
"--passfile2", $passfile2->filename,
|
||||
($dry eq "1" ? ('--dry') : ()),
|
||||
'--no-modulesversion',
|
||||
'--noreleasecheck'];
|
||||
|
||||
try {
|
||||
$is_running = $dbh->prepare("UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL WHERE id = ?");
|
||||
$is_running->bind_param( 1, ${id} );
|
||||
$is_running->execute();
|
||||
|
||||
run [@$generated_cmds, @$custom_params_ref], '&>', \my $stdout;
|
||||
|
||||
# check exit code and status
|
||||
($exit_code, $exit_status) = ($stdout =~ m/Exiting\swith\sreturn\svalue\s(\d+)\s\(([^:)]+)/);
|
||||
|
||||
$success = 0;
|
||||
if (defined $exit_code && $exit_code == 0) {
|
||||
$success = 1;
|
||||
}
|
||||
|
||||
$update = $dbh->prepare("UPDATE imapsync SET returned_text = ?, success = ?, exit_status = ? WHERE id = ?");
|
||||
$update->bind_param( 1, ${stdout} );
|
||||
$update->bind_param( 2, ${success} );
|
||||
$update->bind_param( 3, ${exit_status} );
|
||||
$update->bind_param( 4, ${id} );
|
||||
$update->execute();
|
||||
} catch {
|
||||
$update = $dbh->prepare("UPDATE imapsync SET returned_text = 'Could not start or finish imapsync', success = 0 WHERE id = ?");
|
||||
$update->bind_param( 1, ${id} );
|
||||
$update->execute();
|
||||
} finally {
|
||||
$update = $dbh->prepare("UPDATE imapsync SET last_run = NOW(), is_running = 0 WHERE id = ?");
|
||||
$update->bind_param( 1, ${id} );
|
||||
$update->execute();
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
$sth->finish();
|
||||
$dbh->disconnect();
|
||||
|
||||
$lockmgr->unlock($lock_file);
|
||||
Executable
+331
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import MySQLdb
|
||||
import MySQLdb.cursors
|
||||
|
||||
run_dir = '/tmp'
|
||||
# Max size of imapsync output stored in returned_text (the tail holds the summary)
|
||||
max_output = 16 * 1024 * 1024
|
||||
lock_file = run_dir + '/imapsync_busy.lock'
|
||||
# An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens)
|
||||
token_min_valid = 900
|
||||
# Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect
|
||||
token_sync_interval = 60
|
||||
|
||||
# Next due job: higher prio first, never-run jobs first, then oldest run
|
||||
next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage,
|
||||
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
|
||||
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
|
||||
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow
|
||||
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
|
||||
WHERE i.active = 1
|
||||
AND s.active = 1
|
||||
AND i.is_running = 0
|
||||
AND (UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(i.last_run) > i.mins_interval * 60 OR i.last_run IS NULL)
|
||||
{skip}
|
||||
ORDER BY i.prio DESC, i.last_run IS NOT NULL, i.last_run ASC, i.id ASC
|
||||
LIMIT 1"""
|
||||
# Atomic claim, a job is only started when this flips is_running 0 -> 1
|
||||
claim_sql = """UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL
|
||||
WHERE id = %s AND is_running = 0 AND active = 1"""
|
||||
# Access token: client_credentials share the source token, authorization_code use the token of
|
||||
# source + local mailbox (user2) + remote user (user1). issued/error describe the last refresh.
|
||||
source_token_sql = """SELECT oauth_access_token AS access_token, oauth_token_expires AS token_expires,
|
||||
UNIX_TIMESTAMP(oauth_last_refresh_attempt) AS issued, oauth_last_refresh_error AS error
|
||||
FROM imapsync_source WHERE id = %s"""
|
||||
user_token_sql = """SELECT access_token, token_expires, UNIX_TIMESTAMP(oauth_last_refresh_attempt) AS issued,
|
||||
last_refresh_error AS error FROM imapsync_source_oauth_token
|
||||
WHERE source_id = %s AND owner = %s AND username = %s"""
|
||||
|
||||
conn = None
|
||||
# Popen => job of running workers
|
||||
running = {}
|
||||
# Jobs released without a run (token not ready), skipped for the rest of this runner round
|
||||
deferred = set()
|
||||
stopping = False
|
||||
|
||||
def num(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def unlink(files):
|
||||
for path in files:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def imapsync_running():
|
||||
for pid in filter(str.isdigit, os.listdir('/proc')):
|
||||
try:
|
||||
with open('/proc/' + pid + '/cmdline', 'rb') as f:
|
||||
cmdline = f.read().replace(b'\0', b' ')
|
||||
except OSError:
|
||||
continue
|
||||
if re.search(rb'imapsync\s', cmdline, re.I):
|
||||
return True
|
||||
return False
|
||||
|
||||
def connect():
|
||||
return MySQLdb.connect(unix_socket='/var/run/mysqld/mysqld.sock', user=os.environ['DBUSER'],
|
||||
passwd=os.environ['DBPASS'], db=os.environ['DBNAME'], charset='utf8mb4', autocommit=True)
|
||||
|
||||
# Reconnect when the server dropped the connection (syncs can outlast wait_timeout)
|
||||
def query(sql, args=None):
|
||||
global conn
|
||||
try:
|
||||
conn.ping()
|
||||
except MySQLdb.Error:
|
||||
conn = connect()
|
||||
cur = conn.cursor(MySQLdb.cursors.DictCursor)
|
||||
cur.execute(sql, args)
|
||||
return cur
|
||||
|
||||
# Store a result and release the job
|
||||
def finish_job(job_id, text, success, exit_status):
|
||||
try:
|
||||
query("UPDATE imapsync SET returned_text = %s, success = %s, exit_status = %s, last_run = NOW(), is_running = 0 WHERE id = %s",
|
||||
(text, success, exit_status, job_id))
|
||||
except MySQLdb.Error:
|
||||
query("UPDATE imapsync SET success = 0, last_run = NOW(), is_running = 0 WHERE id = %s", (job_id,))
|
||||
|
||||
# Release a job without a run; last_run stays, so the next runner round retries it
|
||||
def defer_job(job_id, text, exit_status):
|
||||
query("UPDATE imapsync SET returned_text = %s, success = 0, exit_status = %s, is_running = 0 WHERE id = %s",
|
||||
(text, exit_status, job_id))
|
||||
deferred.add(job_id)
|
||||
|
||||
def oauth_token(j):
|
||||
if j['oauth_flow'] == 'authorization_code':
|
||||
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
|
||||
return query(source_token_sql, (j['source_id'],)).fetchone() or {}
|
||||
|
||||
# Valid for token_min_valid, or half the lifetime of a short-lived token (known when its last refresh succeeded)
|
||||
def token_ready(row):
|
||||
token, expires, issued = row.get('access_token'), row.get('token_expires'), row.get('issued')
|
||||
if not token or expires is None:
|
||||
return False
|
||||
need = token_min_valid
|
||||
if issued and not row.get('error') and expires > int(issued):
|
||||
need = min(need, (expires - int(issued)) // 2)
|
||||
return expires - time.time() >= need
|
||||
|
||||
# Write a refreshed token over the token file of a running job (atomic, same path)
|
||||
def sync_token_file(oauth):
|
||||
token = oauth_token(oauth['job']).get('access_token')
|
||||
if not token or token == oauth['token']:
|
||||
return
|
||||
fd, tmp = tempfile.mkstemp(prefix='imapsync.', dir=run_dir)
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(token + '\n')
|
||||
os.replace(tmp, oauth['file'])
|
||||
oauth['token'] = token
|
||||
except OSError:
|
||||
unlink([tmp])
|
||||
|
||||
# Max concurrent imapsync processes and global per-process bandwidth cap in bytes/s (0 = off),
|
||||
# re-read before every claim round so changes apply to long runs
|
||||
def read_settings():
|
||||
settings = {r['name']: r['value'] for r in query("SELECT name, value FROM imapsync_settings")}
|
||||
return max(num(settings.get('max_parallel')), 1), max(num(settings.get('max_bytes_per_second')), 0)
|
||||
|
||||
# Build temp files and argv for a claimed job. Returns None when the job was finished without running imapsync.
|
||||
def prepare_job(j, global_bw, files):
|
||||
def tmpfile(content):
|
||||
fd, path = tempfile.mkstemp(prefix='imapsync.', dir=run_dir)
|
||||
files.append(path)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
auth_type = j['auth_type'] or 'PLAIN'
|
||||
oauth = None
|
||||
if auth_type == 'XOAUTH2':
|
||||
row = oauth_token(j)
|
||||
if not token_ready(row):
|
||||
defer_job(j['id'], "OAuth access token for the configured source is missing or expires too soon; refresh cron will retry.",
|
||||
'OAUTH_TOKEN_NOT_READY')
|
||||
return None
|
||||
oauth = {'job': j, 'token': row['access_token'], 'file': tmpfile(row['access_token'] + '\n')}
|
||||
auth_args = ['--authmech1', 'XOAUTH2', '--oauthaccesstoken1', oauth['file']]
|
||||
else:
|
||||
auth_args = ['--passfile1', tmpfile(j['password1'] + '\n')]
|
||||
# imapsync auto-picks PLAIN, only pass --authmech1 for LOGIN/CRAM-MD5
|
||||
if auth_type != 'PLAIN':
|
||||
auth_args += ['--authmech1', auth_type]
|
||||
|
||||
# Custom params: JSON array of {o,v}, validated by the backend on write. One argv token per pair,
|
||||
# "--opt" or "--opt=value" (value glued to its option, no shell)
|
||||
custom_params = []
|
||||
try:
|
||||
pairs = json.loads(j['custom_params'] or '[]')
|
||||
except ValueError:
|
||||
pairs = []
|
||||
for p in pairs if isinstance(pairs, list) else []:
|
||||
if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']):
|
||||
continue
|
||||
value = '' if p.get('v') is None else str(p['v'])
|
||||
custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value)
|
||||
|
||||
# Effective per-process bandwidth cap (bytes/s): per-job value, capped by the global limit
|
||||
eff_bw = num(j['maxbytespersecond'])
|
||||
if global_bw > 0 and (eff_bw <= 0 or eff_bw > global_bw):
|
||||
eff_bw = global_bw
|
||||
|
||||
# --nolog: the output is stored in returned_text, imapsync's own log dir would be the (read-only) cwd
|
||||
cmd = ['/usr/local/bin/imapsync', '--tmpdir', '/tmp', '--nolog', '--nofoldersizes', '--addheader']
|
||||
if num(j['timeout1']) > 0:
|
||||
cmd += ['--timeout1', str(j['timeout1'])]
|
||||
if num(j['timeout2']) > 0:
|
||||
cmd += ['--timeout2', str(j['timeout2'])]
|
||||
if j['exclude']:
|
||||
cmd += ['--exclude', j['exclude']]
|
||||
if j['subfolder2']:
|
||||
cmd += ['--subfolder2', j['subfolder2']]
|
||||
if num(j['maxage']) != 0:
|
||||
cmd += ['--maxage', str(j['maxage'])]
|
||||
if eff_bw > 0:
|
||||
cmd += ['--maxbytespersecond', str(eff_bw)]
|
||||
for col, opt in (('delete2duplicates', '--delete2duplicates'), ('subscribeall', '--subscribeall'),
|
||||
('delete1', '--delete1'), ('delete2', '--delete2'), ('automap', '--automap'),
|
||||
('skipcrossduplicates', '--skipcrossduplicates')):
|
||||
if j[col] == 1:
|
||||
cmd.append(opt)
|
||||
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
|
||||
if enc1:
|
||||
cmd.append(enc1)
|
||||
cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])]
|
||||
cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
|
||||
if j['dry'] == 1:
|
||||
cmd.append('--dry')
|
||||
cmd += ['--no-modulesversion', '--noreleasecheck'] + custom_params
|
||||
|
||||
return {'id': j['id'], 'files': files, 'outfile': tmpfile(''), 'cmd': cmd, 'oauth': oauth}
|
||||
|
||||
# Worker: imapsync with output to a file, no inherited fds (DB socket, lock)
|
||||
def start_job(job):
|
||||
with open(job['outfile'], 'wb') as out:
|
||||
return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT)
|
||||
|
||||
# Store the result of a finished worker and remove its temp files
|
||||
def reap_job(job):
|
||||
stdout = b''
|
||||
try:
|
||||
with open(job['outfile'], 'rb') as f:
|
||||
size = os.fstat(f.fileno()).st_size
|
||||
if size > max_output:
|
||||
f.seek(size - max_output)
|
||||
stdout = b'[output truncated, last %d bytes]\n' % max_output
|
||||
stdout += f.read()
|
||||
except OSError:
|
||||
pass
|
||||
unlink(job['files'])
|
||||
text = stdout.decode('utf-8', 'replace')
|
||||
# Last match: the echoed command line at the top may contain user controlled values
|
||||
m = re.findall(r'Exiting\swith\sreturn\svalue\s(\d+)\s\(([^:)]+)', text)
|
||||
exit_code, exit_status = m[-1] if m else (None, None)
|
||||
if exit_code is None:
|
||||
text = 'Could not start or finish imapsync\n' + text
|
||||
finish_job(job['id'], text, 1 if exit_code is not None and int(exit_code) == 0 else 0, exit_status)
|
||||
|
||||
# Stop claiming new jobs, pass the signal on to the running imapsync processes
|
||||
def stop(signum, frame):
|
||||
global stopping
|
||||
stopping = True
|
||||
for proc in list(running):
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
|
||||
# Released by the kernel when the process ends, a crashed run leaves no stale lock
|
||||
lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
sys.exit("can't lock " + lock_file)
|
||||
|
||||
if imapsync_running():
|
||||
print('imapsync is active, exiting...')
|
||||
sys.exit(0)
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT):
|
||||
signal.signal(sig, stop)
|
||||
|
||||
conn = connect()
|
||||
# Stale flags from crashed runs, safe only here: under the lock and with no imapsync running
|
||||
query("UPDATE imapsync SET is_running = 0")
|
||||
|
||||
with open('/etc/sogo/sieve.creds') as f:
|
||||
master_user, master_pass = [s.strip() for s in f.readline().split(':', 1)]
|
||||
|
||||
# Worker pool: keep up to max_parallel workers busy, claim the next due job whenever a slot is free
|
||||
# (after every worker exit and at least every 10 seconds while workers run)
|
||||
last_token_sync = time.monotonic()
|
||||
try:
|
||||
while True:
|
||||
for proc, job in list(running.items()):
|
||||
if proc.poll() is not None:
|
||||
del running[proc]
|
||||
reap_job(job)
|
||||
|
||||
if time.monotonic() - last_token_sync >= token_sync_interval:
|
||||
last_token_sync = time.monotonic()
|
||||
for job in running.values():
|
||||
if job['oauth']:
|
||||
try:
|
||||
sync_token_file(job['oauth'])
|
||||
except MySQLdb.Error as e:
|
||||
print('imapsync job %d: %s' % (job['id'], e), file=sys.stderr)
|
||||
|
||||
max_parallel, global_bw = read_settings()
|
||||
# Deferred and finished jobs drop out of next_sql, only failed claims are capped
|
||||
failed_claims = 0
|
||||
while not stopping and len(running) < max_parallel and failed_claims < 100:
|
||||
skip = ('AND i.id NOT IN (%s)' % ', '.join(['%s'] * len(deferred))) if deferred else ''
|
||||
j = query(next_sql.format(skip=skip), tuple(deferred) or None).fetchone()
|
||||
if not j:
|
||||
break
|
||||
if query(claim_sql, (j['id'],)).rowcount != 1:
|
||||
failed_claims += 1
|
||||
continue
|
||||
|
||||
files = []
|
||||
try:
|
||||
job = prepare_job(j, global_bw, files)
|
||||
except Exception as e:
|
||||
print('imapsync job %d: %s' % (j['id'], e), file=sys.stderr)
|
||||
finish_job(j['id'], 'Could not start or finish imapsync', 0, None)
|
||||
job = None
|
||||
if not job:
|
||||
unlink(files)
|
||||
continue
|
||||
try:
|
||||
running[start_job(job)] = job
|
||||
except OSError as e:
|
||||
# Release the claim, the job is picked up again later
|
||||
print('imapsync job %d: %s' % (j['id'], e), file=sys.stderr)
|
||||
unlink(files)
|
||||
query("UPDATE imapsync SET is_running = 0 WHERE id = %s", (j['id'],))
|
||||
break
|
||||
|
||||
if not running:
|
||||
break
|
||||
# A worker exit ends the wait early
|
||||
for _ in range(50):
|
||||
if any(proc.poll() is not None for proc in running):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
finally:
|
||||
# Never leave password/token files behind
|
||||
for job in running.values():
|
||||
unlink(job['files'])
|
||||
@@ -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();
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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 = "imapsync OAuth refresh") {
|
||||
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';
|
||||
|
||||
// File-lock to prevent concurrent runs
|
||||
$lock_file = '/tmp/imapsync-oauth-refresh.lock';
|
||||
if (file_exists($lock_file)) {
|
||||
$pid = (int)trim(@file_get_contents($lock_file));
|
||||
if ($pid > 0 && posix_kill($pid, 0)) {
|
||||
logMsg("info", "Previous refresh still running (pid=$pid), exiting");
|
||||
exit(0);
|
||||
}
|
||||
@unlink($lock_file);
|
||||
}
|
||||
file_put_contents($lock_file, getmypid());
|
||||
|
||||
try {
|
||||
// client_credentials source tokens, unused per-user tokens (1 day grace), authorization_code per-user tokens;
|
||||
// failing tokens are retried 15 minutes after their last attempt (C2), errors are sanitized
|
||||
$imapsync = \Mailcow\Imapsync\ImapsyncModule::create($pdo, \Mailcow\Core\Context::system(), [
|
||||
'hostname' => (string)getenv('MAILCOW_HOSTNAME'),
|
||||
]);
|
||||
foreach ($imapsync->oauth->refreshDue() as $e) {
|
||||
logMsg($e['priority'], $e['message']);
|
||||
}
|
||||
} finally {
|
||||
@unlink($lock_file);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once(__DIR__ . '/../web/inc/vars.inc.php');
|
||||
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
|
||||
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
|
||||
}
|
||||
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
|
||||
|
||||
// Init database
|
||||
//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
|
||||
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
try {
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
logMsg("err", $e->getMessage());
|
||||
session_destroy();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Init Redis
|
||||
$redis = new Redis();
|
||||
try {
|
||||
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
|
||||
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
|
||||
}
|
||||
else {
|
||||
$redis->connect('redis-mailcow', 6379);
|
||||
}
|
||||
$redis->auth(getenv("REDISPASS"));
|
||||
}
|
||||
catch (Exception $e) {
|
||||
echo "Exiting: " . $e->getMessage();
|
||||
session_destroy();
|
||||
exit;
|
||||
}
|
||||
|
||||
function logMsg($priority, $message, $task = "Keycloak Sync") {
|
||||
global $redis;
|
||||
|
||||
$finalMsg = array(
|
||||
"time" => time(),
|
||||
"priority" => $priority,
|
||||
"task" => $task,
|
||||
"message" => $message
|
||||
);
|
||||
$redis->lPush('CRON_LOG', json_encode($finalMsg));
|
||||
}
|
||||
|
||||
// Load core functions first
|
||||
require_once __DIR__ . '/../web/inc/functions.inc.php';
|
||||
require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
|
||||
require_once __DIR__ . '/../web/inc/sessions.inc.php';
|
||||
require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
|
||||
require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php';
|
||||
require_once __DIR__ . '/../web/inc/functions.acl.inc.php';
|
||||
|
||||
$_SESSION['mailcow_cc_username'] = "admin";
|
||||
$_SESSION['mailcow_cc_role'] = "admin";
|
||||
$_SESSION['acl']['tls_policy'] = "1";
|
||||
$_SESSION['acl']['quarantine_notification'] = "1";
|
||||
$_SESSION['acl']['quarantine_category'] = "1";
|
||||
$_SESSION['acl']['ratelimit'] = "1";
|
||||
$_SESSION['acl']['sogo_access'] = "1";
|
||||
$_SESSION['acl']['protocol_access'] = "1";
|
||||
$_SESSION['acl']['mailbox_relayhost'] = "1";
|
||||
$_SESSION['acl']['unlimited_quota'] = "1";
|
||||
|
||||
$iam_settings = identity_provider('get');
|
||||
if ($iam_settings['authsource'] != "keycloak" || (intval($iam_settings['periodic_sync']) != 1 && intval($iam_settings['import_users']) != 1)) {
|
||||
session_destroy();
|
||||
exit;
|
||||
}
|
||||
|
||||
// Set pagination variables
|
||||
$start = 0;
|
||||
$max = 100;
|
||||
|
||||
// lock sync if already running
|
||||
$lock_file = '/tmp/iam-sync.lock';
|
||||
if (file_exists($lock_file)) {
|
||||
$lock_file_parts = explode("\n", file_get_contents($lock_file));
|
||||
$pid = $lock_file_parts[0];
|
||||
if (count($lock_file_parts) > 1){
|
||||
$last_execution = $lock_file_parts[1];
|
||||
$elapsed_time = (time() - $last_execution) / 60;
|
||||
if ($elapsed_time < intval($iam_settings['sync_interval'])) {
|
||||
logMsg("warning", "Sync not ready (".number_format((float)$elapsed_time, 2, '.', '')."min / ".$iam_settings['sync_interval']."min)");
|
||||
session_destroy();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
if (posix_kill($pid, 0)) {
|
||||
logMsg("warning", "Sync is already running");
|
||||
session_destroy();
|
||||
exit;
|
||||
} else {
|
||||
unlink($lock_file);
|
||||
}
|
||||
}
|
||||
$lock_file_handle = fopen($lock_file, 'w');
|
||||
fwrite($lock_file_handle, getmypid());
|
||||
fclose($lock_file_handle);
|
||||
|
||||
// Init Keycloak Provider
|
||||
$iam_provider = identity_provider('init');
|
||||
|
||||
// Loop until all users have been retrieved
|
||||
while (true) {
|
||||
// Get admin access token
|
||||
$admin_token = identity_provider("get-keycloak-admin-token");
|
||||
|
||||
// Make the API request to retrieve the users
|
||||
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users?first=$start&max=$max";
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer " . $admin_token
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code != 200){
|
||||
logMsg("err", "Received HTTP {$code}");
|
||||
session_destroy();
|
||||
exit;
|
||||
}
|
||||
try {
|
||||
$response = json_decode($response, true);
|
||||
} catch (Exception $e) {
|
||||
logMsg("err", $e->getMessage());
|
||||
break;
|
||||
}
|
||||
if (!is_array($response)){
|
||||
logMsg("err", "Received malformed response from keycloak api");
|
||||
break;
|
||||
}
|
||||
if (count($response) == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Process the batch of users
|
||||
foreach ($response as $user) {
|
||||
if (empty($user['email'])){
|
||||
logMsg("warning", "No email address in keycloak found for user " . $user['name']);
|
||||
continue;
|
||||
}
|
||||
|
||||
// try get mailbox user
|
||||
$stmt = $pdo->prepare("SELECT
|
||||
mailbox.*,
|
||||
domain.active AS d_active
|
||||
FROM `mailbox`
|
||||
INNER JOIN domain on mailbox.domain = domain.domain
|
||||
WHERE `kind` NOT REGEXP 'location|thing|group'
|
||||
AND `username` = :user");
|
||||
$stmt->execute(array(':user' => $user['email']));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// check if matching attribute mapping exists
|
||||
$user_template = $user['attributes']['mailcow_template'][0];
|
||||
$mapper_key = array_search($user_template, $iam_settings['mappers']);
|
||||
|
||||
$_SESSION['access_all_exception'] = '1';
|
||||
if (!$row && intval($iam_settings['import_users']) == 1){
|
||||
if ($mapper_key === false){
|
||||
if (!empty($iam_settings['default_template'])) {
|
||||
$mbox_template = $iam_settings['default_template'];
|
||||
logMsg("warning", "Using default template for user " . $user['email']);
|
||||
} else {
|
||||
logMsg("warning", "No matching attribute mapping found for user " . $user['email']);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
$mbox_template = $iam_settings['templates'][$mapper_key];
|
||||
}
|
||||
// mailbox user does not exist, create...
|
||||
logMsg("info", "Creating user " . $user['email']);
|
||||
$create_res = mailbox('add', 'mailbox_from_template', array(
|
||||
'domain' => explode('@', $user['email'])[1],
|
||||
'local_part' => explode('@', $user['email'])[0],
|
||||
'name' => $user['firstName'] . " " . $user['lastName'],
|
||||
'authsource' => 'keycloak',
|
||||
'template' => $mbox_template
|
||||
));
|
||||
if (!$create_res){
|
||||
logMsg("err", "Could not create user " . $user['email']);
|
||||
continue;
|
||||
}
|
||||
} else if ($row && intval($iam_settings['periodic_sync']) == 1 && $row['authsource'] == "keycloak") {
|
||||
if ($mapper_key === false){
|
||||
logMsg("warning", "No matching attribute mapping found for user " . $user['email']);
|
||||
continue;
|
||||
}
|
||||
$mbox_template = $iam_settings['templates'][$mapper_key];
|
||||
// mailbox user does exist, sync attribtues...
|
||||
logMsg("info", "Syncing attributes for user " . $user['email']);
|
||||
mailbox('edit', 'mailbox_from_template', array(
|
||||
'username' => $user['email'],
|
||||
'name' => $user['firstName'] . " " . $user['lastName'],
|
||||
'template' => $mbox_template
|
||||
));
|
||||
} else {
|
||||
// skip mailbox user
|
||||
logMsg("info", "Skipping user " . $user['email']);
|
||||
}
|
||||
$_SESSION['access_all_exception'] = '0';
|
||||
|
||||
sleep(0.025);
|
||||
}
|
||||
|
||||
// Update the pagination variables for the next batch
|
||||
$start += $max;
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
logMsg("info", "DONE!");
|
||||
// add last execution time to lock file
|
||||
$lock_file_handle = fopen($lock_file, 'w');
|
||||
fwrite($lock_file_handle, getmypid() . "\n" . time());
|
||||
fclose($lock_file_handle);
|
||||
session_destroy();
|
||||
@@ -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();
|
||||
@@ -9,6 +9,8 @@ $_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');
|
||||
@@ -38,10 +40,12 @@ $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']),
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
@@ -12,6 +12,7 @@ $fido2_data = fido2(array("action" => "get_friendly_names"));
|
||||
$js_minifier->add('/web/js/site/admin.js');
|
||||
$js_minifier->add('/web/js/presets/rspamd.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
$js_minifier->add('/web/js/site/identity.js');
|
||||
|
||||
// all domains
|
||||
$domains = mailbox('get', 'domains');
|
||||
@@ -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,
|
||||
@@ -118,6 +115,7 @@ $template_data = [
|
||||
'ui_themes' => customize('get', 'ui_themes'),
|
||||
'ui_theme' => customize('get', 'ui_theme'),
|
||||
'password_complexity' => password_complexity('get'),
|
||||
'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(),
|
||||
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
|
||||
'cors_settings' => $cors_settings,
|
||||
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
|
||||
|
||||
+1962
-159
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,8 @@ $_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');
|
||||
@@ -42,6 +44,7 @@ $template_data = [
|
||||
'lang_rl' => json_encode($lang['ratelimit']),
|
||||
'lang_edit' => json_encode($lang['edit']),
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
+33
-1
@@ -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,
|
||||
@@ -204,9 +227,15 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
if (isset($_GET['syncjob']) &&
|
||||
is_numeric($_GET['syncjob'])) {
|
||||
$id = $_GET["syncjob"];
|
||||
$result = mailbox('get', 'syncjob_details', $id);
|
||||
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id);
|
||||
$template = 'edit/syncjob.twig';
|
||||
}
|
||||
elseif (isset($_GET['syncjob_source']) &&
|
||||
is_numeric($_GET['syncjob_source'])) {
|
||||
$id = $_GET["syncjob_source"];
|
||||
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSource($id);
|
||||
$template = 'edit/imapsync_source.twig';
|
||||
}
|
||||
elseif (isset($_GET['filter']) &&
|
||||
is_numeric($_GET['filter'])) {
|
||||
$id = $_GET["filter"];
|
||||
@@ -225,6 +254,8 @@ else {
|
||||
$template_data['access_denied'] = true;
|
||||
}
|
||||
|
||||
$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');
|
||||
|
||||
@@ -233,5 +264,6 @@ $template_data['return_to'] = $_SESSION['return_to'];
|
||||
$template_data['lang_user'] = json_encode($lang['user']);
|
||||
$template_data['lang_admin'] = json_encode($lang['admin']);
|
||||
$template_data['lang_datatables'] = json_encode($lang['datatables']);
|
||||
$template_data['imapsync_redirect_uri'] = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri();
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
@@ -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") {
|
||||
|
||||
@@ -6,7 +6,7 @@ if (!isset($_SESSION['mailcow_cc_role'])) {
|
||||
}
|
||||
|
||||
if (isset($_GET['id']) && is_numeric($_GET['id'])) {
|
||||
if ($details = mailbox('get', 'syncjob_details', intval($_GET['id']))) {
|
||||
if ($details = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob(intval($_GET['id']))) {
|
||||
echo (empty($details['log'])) ? '-' : $details['log'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ if (array_key_exists('pending_tfa_methods', $_SESSION)) {
|
||||
if (isset($pending_tfa_authmechs['webauthn'])) {
|
||||
$pending_tfa_authmechs['webauthn'] = true;
|
||||
}
|
||||
if (!isset($pending_tfa_authmechs['webauthn'])
|
||||
if (!isset($pending_tfa_authmechs['webauthn'])
|
||||
&& isset($pending_tfa_authmechs['yubi_otp'])) {
|
||||
$pending_tfa_authmechs['yubi_otp'] = true;
|
||||
}
|
||||
if (!isset($pending_tfa_authmechs['webauthn'])
|
||||
if (!isset($pending_tfa_authmechs['webauthn'])
|
||||
&& !isset($pending_tfa_authmechs['yubi_otp'])
|
||||
&& isset($pending_tfa_authmechs['totp'])) {
|
||||
$pending_tfa_authmechs['totp'] = true;
|
||||
@@ -72,6 +72,8 @@ $globalVariables = [
|
||||
'lang_fido2' => json_encode($lang['fido2']),
|
||||
'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')) {
|
||||
|
||||
@@ -103,7 +103,7 @@ function admin($_action, $_data = null) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new))) {
|
||||
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new)) || $username_new == 'API') {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
@@ -179,6 +179,9 @@ function admin($_action, $_data = null) {
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `username` = :username_new WHERE `username` = :username");
|
||||
$stmt->execute(array(':username_new' => $username_new, ':username' => $username));
|
||||
}
|
||||
if ($username_new != $username) {
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username, $username_new);
|
||||
}
|
||||
}
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
@@ -211,6 +214,7 @@ function admin($_action, $_data = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $username,
|
||||
));
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username);
|
||||
$stmt = $pdo->prepare("DELETE FROM `domain_admins` WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':username' => $username,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -156,7 +156,7 @@ function domain_admin($_action, $_data = null) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new))) {
|
||||
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new)) || $username_new == 'API') {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
@@ -183,6 +183,9 @@ function domain_admin($_action, $_data = null) {
|
||||
':username_new' => $username_new,
|
||||
':username' => $username
|
||||
));
|
||||
if ($username_new != $username) {
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username, $username_new);
|
||||
}
|
||||
if (!empty($domains)) {
|
||||
foreach ($domains as $domain) {
|
||||
$stmt = $pdo->prepare("INSERT INTO `domain_admins` (`username`, `domain`, `created`, `active`)
|
||||
@@ -331,6 +334,8 @@ function domain_admin($_action, $_data = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $username,
|
||||
));
|
||||
// sync sources created by this account must not pass to a re-created account of the same name
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username);
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
|
||||
+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;
|
||||
|
||||
@@ -302,192 +302,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
'msg' => array('mailbox_modified', $username)
|
||||
);
|
||||
break;
|
||||
case 'syncjob':
|
||||
if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
$username = $_data['username'];
|
||||
}
|
||||
}
|
||||
elseif ($_SESSION['mailcow_cc_role'] == "user") {
|
||||
$username = $_SESSION['mailcow_cc_username'];
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'no_user_defined'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$active = intval($_data['active']);
|
||||
$subscribeall = intval($_data['subscribeall']);
|
||||
$delete2duplicates = intval($_data['delete2duplicates']);
|
||||
$delete1 = intval($_data['delete1']);
|
||||
$delete2 = intval($_data['delete2']);
|
||||
$timeout1 = intval($_data['timeout1']);
|
||||
$timeout2 = intval($_data['timeout2']);
|
||||
$skipcrossduplicates = intval($_data['skipcrossduplicates']);
|
||||
$automap = intval($_data['automap']);
|
||||
$dry = intval($_data['dry']);
|
||||
$port1 = $_data['port1'];
|
||||
$host1 = strtolower($_data['host1']);
|
||||
$password1 = $_data['password1'];
|
||||
$exclude = $_data['exclude'];
|
||||
$maxage = $_data['maxage'];
|
||||
$maxbytespersecond = $_data['maxbytespersecond'];
|
||||
$subfolder2 = $_data['subfolder2'];
|
||||
$user1 = $_data['user1'];
|
||||
$mins_interval = $_data['mins_interval'];
|
||||
$enc1 = $_data['enc1'];
|
||||
$custom_params = (empty(trim($_data['custom_params']))) ? '' : trim($_data['custom_params']);
|
||||
|
||||
// validate custom params
|
||||
foreach (explode('-', $custom_params) as $param){
|
||||
if(empty($param)) continue;
|
||||
|
||||
// extract option
|
||||
if (str_contains($param, '=')) $param = explode('=', $param)[0];
|
||||
else $param = rtrim($param, ' ');
|
||||
// remove first char if first char is -
|
||||
if ($param[0] == '-') $param = ltrim($param, $param[0]);
|
||||
|
||||
if (str_contains($param, ' ')) {
|
||||
// bad char
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'bad character SPACE'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if param is whitelisted
|
||||
if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
|
||||
// bad option
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'bad option '. $param
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (empty($subfolder2)) {
|
||||
$subfolder2 = "";
|
||||
}
|
||||
if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$maxage = "0";
|
||||
}
|
||||
if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$timeout1 = "600";
|
||||
}
|
||||
if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$timeout2 = "600";
|
||||
}
|
||||
if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) {
|
||||
$maxbytespersecond = "0";
|
||||
}
|
||||
if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// if (!is_valid_domain_name($host1)) {
|
||||
// $_SESSION['return'][] = array(
|
||||
// 'type' => 'danger',
|
||||
// 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
// 'msg' => 'access_denied'
|
||||
// );
|
||||
// return false;
|
||||
// }
|
||||
if ($enc1 != "TLS" && $enc1 != "SSL" && $enc1 != "PLAIN") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (@preg_match("/" . $exclude . "/", null) === false) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT '1' FROM `imapsync`
|
||||
WHERE `user2` = :user2 AND `user1` = :user1 AND `host1` = :host1");
|
||||
$stmt->execute(array(':user1' => $user1, ':user2' => $username, ':host1' => $host1));
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
if ($num_results != 0) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('object_exists', htmlspecialchars($host1 . ' / ' . $user1))
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `dry`, `maxage`, `subfolder2`, `host1`, `authmech1`, `user1`, `password1`, `mins_interval`, `port1`, `enc1`, `delete2duplicates`, `custom_params`, `active`)
|
||||
VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :host1, :authmech1, :user1, :password1, :mins_interval, :port1, :enc1, :delete2duplicates, :custom_params, :active)");
|
||||
$stmt->execute(array(
|
||||
':user2' => $username,
|
||||
':custom_params' => $custom_params,
|
||||
':exclude' => $exclude,
|
||||
':maxage' => $maxage,
|
||||
':delete1' => $delete1,
|
||||
':delete2' => $delete2,
|
||||
':timeout1' => $timeout1,
|
||||
':timeout2' => $timeout2,
|
||||
':automap' => $automap,
|
||||
':skipcrossduplicates' => $skipcrossduplicates,
|
||||
':maxbytespersecond' => $maxbytespersecond,
|
||||
':subscribeall' => $subscribeall,
|
||||
':dry' => $dry,
|
||||
':subfolder2' => $subfolder2,
|
||||
':host1' => $host1,
|
||||
':authmech1' => 'PLAIN',
|
||||
':user1' => $user1,
|
||||
':password1' => $password1,
|
||||
':mins_interval' => $mins_interval,
|
||||
':port1' => $port1,
|
||||
':enc1' => $enc1,
|
||||
':delete2duplicates' => $delete2duplicates,
|
||||
':active' => $active,
|
||||
));
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('mailbox_modified', $username)
|
||||
);
|
||||
break;
|
||||
case 'domain':
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -1124,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
|
||||
@@ -1157,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),
|
||||
@@ -1236,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;
|
||||
}
|
||||
@@ -1454,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);
|
||||
@@ -2280,202 +2110,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'syncjob':
|
||||
if (!is_array($_data['id'])) {
|
||||
$ids = array();
|
||||
$ids[] = $_data['id'];
|
||||
}
|
||||
else {
|
||||
$ids = $_data['id'];
|
||||
}
|
||||
if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
foreach ($ids as $id) {
|
||||
$is_now = mailbox('get', 'syncjob_details', $id, array('with_password'));
|
||||
if (!empty($is_now)) {
|
||||
$username = $is_now['user2'];
|
||||
$user1 = (!empty($_data['user1'])) ? $_data['user1'] : $is_now['user1'];
|
||||
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
||||
$last_run = (isset($_data['last_run'])) ? NULL : $is_now['last_run'];
|
||||
$success = (isset($_data['success'])) ? NULL : $is_now['success'];
|
||||
$delete2duplicates = (isset($_data['delete2duplicates'])) ? intval($_data['delete2duplicates']) : $is_now['delete2duplicates'];
|
||||
$subscribeall = (isset($_data['subscribeall'])) ? intval($_data['subscribeall']) : $is_now['subscribeall'];
|
||||
$dry = (isset($_data['dry'])) ? intval($_data['dry']) : $is_now['dry'];
|
||||
$delete1 = (isset($_data['delete1'])) ? intval($_data['delete1']) : $is_now['delete1'];
|
||||
$delete2 = (isset($_data['delete2'])) ? intval($_data['delete2']) : $is_now['delete2'];
|
||||
$automap = (isset($_data['automap'])) ? intval($_data['automap']) : $is_now['automap'];
|
||||
$skipcrossduplicates = (isset($_data['skipcrossduplicates'])) ? intval($_data['skipcrossduplicates']) : $is_now['skipcrossduplicates'];
|
||||
$port1 = (!empty($_data['port1'])) ? $_data['port1'] : $is_now['port1'];
|
||||
$password1 = (!empty($_data['password1'])) ? $_data['password1'] : $is_now['password1'];
|
||||
$host1 = (!empty($_data['host1'])) ? $_data['host1'] : $is_now['host1'];
|
||||
$subfolder2 = (isset($_data['subfolder2'])) ? $_data['subfolder2'] : $is_now['subfolder2'];
|
||||
$enc1 = (!empty($_data['enc1'])) ? $_data['enc1'] : $is_now['enc1'];
|
||||
$mins_interval = (!empty($_data['mins_interval'])) ? $_data['mins_interval'] : $is_now['mins_interval'];
|
||||
$exclude = (isset($_data['exclude'])) ? $_data['exclude'] : $is_now['exclude'];
|
||||
$custom_params = (isset($_data['custom_params'])) ? $_data['custom_params'] : $is_now['custom_params'];
|
||||
$maxage = (isset($_data['maxage']) && $_data['maxage'] != "") ? intval($_data['maxage']) : $is_now['maxage'];
|
||||
$maxbytespersecond = (isset($_data['maxbytespersecond']) && $_data['maxbytespersecond'] != "") ? intval($_data['maxbytespersecond']) : $is_now['maxbytespersecond'];
|
||||
$timeout1 = (isset($_data['timeout1']) && $_data['timeout1'] != "") ? intval($_data['timeout1']) : $is_now['timeout1'];
|
||||
$timeout2 = (isset($_data['timeout2']) && $_data['timeout2'] != "") ? intval($_data['timeout2']) : $is_now['timeout2'];
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// validate custom params
|
||||
foreach (explode('-', $custom_params) as $param){
|
||||
if(empty($param)) continue;
|
||||
|
||||
// extract option
|
||||
if (str_contains($param, '=')) $param = explode('=', $param)[0];
|
||||
else $param = rtrim($param, ' ');
|
||||
// remove first char if first char is -
|
||||
if ($param[0] == '-') $param = ltrim($param, $param[0]);
|
||||
|
||||
if (str_contains($param, ' ')) {
|
||||
// bad char
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'bad character SPACE'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if param is whitelisted
|
||||
if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
|
||||
// bad option
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'bad option '. $param
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (empty($subfolder2)) {
|
||||
$subfolder2 = "";
|
||||
}
|
||||
if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$maxage = "0";
|
||||
}
|
||||
if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$timeout1 = "600";
|
||||
}
|
||||
if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$timeout2 = "600";
|
||||
}
|
||||
if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) {
|
||||
$maxbytespersecond = "0";
|
||||
}
|
||||
if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!is_valid_domain_name($host1)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if ($enc1 != "TLS" && $enc1 != "SSL" && $enc1 != "PLAIN") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (@preg_match("/" . $exclude . "/", null) === false) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
$stmt = $pdo->prepare("UPDATE `imapsync` SET `delete1` = :delete1,
|
||||
`delete2` = :delete2,
|
||||
`automap` = :automap,
|
||||
`skipcrossduplicates` = :skipcrossduplicates,
|
||||
`maxage` = :maxage,
|
||||
`maxbytespersecond` = :maxbytespersecond,
|
||||
`subfolder2` = :subfolder2,
|
||||
`exclude` = :exclude,
|
||||
`host1` = :host1,
|
||||
`last_run` = :last_run,
|
||||
`success` = :success,
|
||||
`user1` = :user1,
|
||||
`password1` = :password1,
|
||||
`mins_interval` = :mins_interval,
|
||||
`port1` = :port1,
|
||||
`enc1` = :enc1,
|
||||
`delete2duplicates` = :delete2duplicates,
|
||||
`custom_params` = :custom_params,
|
||||
`timeout1` = :timeout1,
|
||||
`timeout2` = :timeout2,
|
||||
`subscribeall` = :subscribeall,
|
||||
`dry` = :dry,
|
||||
`active` = :active
|
||||
WHERE `id` = :id");
|
||||
$stmt->execute(array(
|
||||
':delete1' => $delete1,
|
||||
':delete2' => $delete2,
|
||||
':automap' => $automap,
|
||||
':skipcrossduplicates' => $skipcrossduplicates,
|
||||
':id' => $id,
|
||||
':exclude' => $exclude,
|
||||
':maxage' => $maxage,
|
||||
':maxbytespersecond' => $maxbytespersecond,
|
||||
':subfolder2' => $subfolder2,
|
||||
':host1' => $host1,
|
||||
':user1' => $user1,
|
||||
':password1' => $password1,
|
||||
':last_run' => $last_run,
|
||||
':success' => $success,
|
||||
':mins_interval' => $mins_interval,
|
||||
':port1' => $port1,
|
||||
':enc1' => $enc1,
|
||||
':delete2duplicates' => $delete2duplicates,
|
||||
':custom_params' => $custom_params,
|
||||
':timeout1' => $timeout1,
|
||||
':timeout2' => $timeout2,
|
||||
':subscribeall' => $subscribeall,
|
||||
':dry' => $dry,
|
||||
':active' => $active,
|
||||
));
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('mailbox_modified', $username)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'filter':
|
||||
if (!isset($_SESSION['acl']['filters']) || $_SESSION['acl']['filters'] != "1" ) {
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -2590,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
|
||||
@@ -2752,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
|
||||
@@ -2786,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',
|
||||
@@ -3167,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'];
|
||||
@@ -3389,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
|
||||
));
|
||||
@@ -3421,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
|
||||
));
|
||||
@@ -3462,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),
|
||||
@@ -3484,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
|
||||
));
|
||||
@@ -3640,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) {
|
||||
@@ -3652,6 +3307,11 @@ 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")
|
||||
->execute([
|
||||
@@ -3749,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']);
|
||||
@@ -3795,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;
|
||||
@@ -3907,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4338,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)) {
|
||||
@@ -4357,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)) {
|
||||
@@ -4603,68 +4283,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
}
|
||||
return false;
|
||||
break;
|
||||
case 'syncjob_details':
|
||||
$syncjobdetails = array();
|
||||
if (!is_numeric($_data)) {
|
||||
return false;
|
||||
}
|
||||
if (isset($_extra) && in_array('no_log', $_extra)) {
|
||||
$field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("returned_text", "password1")');
|
||||
$fields = $field_query->fetchAll(PDO::FETCH_ASSOC);
|
||||
while($field = array_shift($fields)) {
|
||||
$shown_fields[] = $field['Field'];
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ",
|
||||
`active`
|
||||
FROM `imapsync` WHERE id = :id");
|
||||
}
|
||||
elseif (isset($_extra) && in_array('with_password', $_extra)) {
|
||||
$stmt = $pdo->prepare("SELECT *,
|
||||
`active`
|
||||
FROM `imapsync` WHERE id = :id");
|
||||
}
|
||||
else {
|
||||
$field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("password1")');
|
||||
$fields = $field_query->fetchAll(PDO::FETCH_ASSOC);
|
||||
while($field = array_shift($fields)) {
|
||||
$shown_fields[] = $field['Field'];
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ",
|
||||
`active`
|
||||
FROM `imapsync` WHERE id = :id");
|
||||
}
|
||||
$stmt->execute(array(':id' => $_data));
|
||||
$syncjobdetails = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!empty($syncjobdetails['returned_text'])) {
|
||||
$syncjobdetails['log'] = $syncjobdetails['returned_text'];
|
||||
}
|
||||
else {
|
||||
$syncjobdetails['log'] = '';
|
||||
}
|
||||
unset($syncjobdetails['returned_text']);
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $syncjobdetails['user2'])) {
|
||||
return false;
|
||||
}
|
||||
return $syncjobdetails;
|
||||
break;
|
||||
case 'syncjobs':
|
||||
$syncjobdata = array();
|
||||
if (isset($_data) && filter_var($_data, FILTER_VALIDATE_EMAIL)) {
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
$_data = $_SESSION['mailcow_cc_username'];
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username");
|
||||
$stmt->execute(array(':username' => $_data));
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
while($row = array_shift($rows)) {
|
||||
$syncjobdata[] = $row['id'];
|
||||
}
|
||||
return $syncjobdata;
|
||||
break;
|
||||
case 'spam_score':
|
||||
$curl = curl_init();
|
||||
curl_setopt($curl, CURLOPT_UNIX_SOCKET_PATH, '/var/lib/rspamd/rspamd.sock');
|
||||
@@ -4877,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(
|
||||
@@ -4910,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'])) {
|
||||
@@ -5350,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) {
|
||||
@@ -5494,46 +5117,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
break;
|
||||
case 'delete':
|
||||
switch ($_type) {
|
||||
case 'syncjob':
|
||||
if (!is_array($_data['id'])) {
|
||||
$ids = array();
|
||||
$ids[] = $_data['id'];
|
||||
}
|
||||
else {
|
||||
$ids = $_data['id'];
|
||||
}
|
||||
if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
foreach ($ids as $id) {
|
||||
if (!is_numeric($id)) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT `user2` FROM `imapsync` WHERE id = :id");
|
||||
$stmt->execute(array(':id' => $id));
|
||||
$user2 = $stmt->fetch(PDO::FETCH_ASSOC)['user2'];
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $user2)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM `imapsync` WHERE `id`= :id");
|
||||
$stmt->execute(array(':id' => $id));
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('deleted_syncjob', $id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'filter':
|
||||
if (!is_array($_data['id'])) {
|
||||
$ids = array();
|
||||
@@ -5764,6 +5347,11 @@ 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,
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `domain_admins` WHERE `domain` = :domain");
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain,
|
||||
@@ -5887,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'])) {
|
||||
@@ -5907,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),
|
||||
@@ -5947,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,
|
||||
@@ -6029,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
|
||||
));
|
||||
@@ -6071,6 +5670,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $username
|
||||
));
|
||||
// Tokens, scope targets and own sync sources of the mailbox
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username);
|
||||
$stmt = $pdo->prepare("DELETE FROM `filterconf` WHERE `object` = :username");
|
||||
$stmt->execute(array(
|
||||
':username' => $username
|
||||
|
||||
+693
-65
@@ -4,7 +4,20 @@ function init_db_schema()
|
||||
try {
|
||||
global $pdo;
|
||||
|
||||
$db_version = "18082026_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(
|
||||
@@ -755,35 +1088,153 @@ function init_db_schema()
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_source" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"name" => "VARCHAR(100) NOT NULL",
|
||||
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"created_by" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"scope" => "ENUM('all','domain','user') NOT NULL DEFAULT 'user'",
|
||||
"host1" => "VARCHAR(255) NOT NULL",
|
||||
"port1" => "SMALLINT UNSIGNED NOT NULL",
|
||||
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
|
||||
"auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
|
||||
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
|
||||
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_authorize_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_userinfo_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_client_id" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_client_secret" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_scope" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_extra_params" => "TEXT DEFAULT NULL",
|
||||
"oauth_access_token" => "TEXT DEFAULT NULL",
|
||||
"oauth_token_expires" => "BIGINT UNSIGNED DEFAULT NULL",
|
||||
"oauth_last_refresh_error" => "TEXT DEFAULT NULL",
|
||||
"oauth_last_refresh_attempt" => "DATETIME 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(
|
||||
"uniq_creator_name" => array("created_by", "name")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_created_by" => array("created_by"),
|
||||
"idx_scope" => array("scope")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_source_domain" => array(
|
||||
"cols" => array(
|
||||
"source_id" => "INT NOT NULL",
|
||||
"domain" => "VARCHAR(255) NOT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("source_id", "domain")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_domain" => array("domain")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_imapsync_source_domain" => array(
|
||||
"col" => "source_id",
|
||||
"ref" => "imapsync_source.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_source_user" => array(
|
||||
"cols" => array(
|
||||
"source_id" => "INT NOT NULL",
|
||||
"username" => "VARCHAR(255) NOT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("source_id", "username")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_username" => array("username")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_imapsync_source_user" => array(
|
||||
"col" => "source_id",
|
||||
"ref" => "imapsync_source.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_source_oauth_token" => array(
|
||||
"cols" => array(
|
||||
"source_id" => "INT NOT NULL",
|
||||
"owner" => "VARCHAR(255) NOT NULL",
|
||||
"username" => "VARCHAR(255) NOT NULL",
|
||||
"access_token" => "TEXT DEFAULT NULL",
|
||||
"refresh_token" => "TEXT DEFAULT NULL",
|
||||
"token_expires" => "BIGINT UNSIGNED DEFAULT NULL",
|
||||
"last_refresh_error" => "TEXT DEFAULT NULL",
|
||||
"oauth_last_refresh_attempt" => "DATETIME NULL DEFAULT NULL",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("source_id", "owner", "username")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_owner" => array("owner")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_imapsync_source_oauth_token" => array(
|
||||
"col" => "source_id",
|
||||
"ref" => "imapsync_source.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"user2" => "VARCHAR(255) NOT NULL",
|
||||
"host1" => "VARCHAR(255) NOT NULL",
|
||||
"authmech1" => "ENUM('PLAIN','LOGIN','CRAM-MD5') DEFAULT 'PLAIN'",
|
||||
"source_id" => "INT NOT NULL",
|
||||
"regextrans2" => "VARCHAR(255) DEFAULT ''",
|
||||
"authmd51" => "TINYINT(1) NOT NULL DEFAULT 0",
|
||||
"domain2" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"subfolder2" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"user1" => "VARCHAR(255) NOT NULL",
|
||||
"password1" => "VARCHAR(255) NOT NULL",
|
||||
"password1" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"exclude" => "VARCHAR(500) NOT NULL DEFAULT ''",
|
||||
"maxage" => "SMALLINT NOT NULL DEFAULT '0'",
|
||||
"mins_interval" => "SMALLINT UNSIGNED NOT NULL DEFAULT '0'",
|
||||
"maxbytespersecond" => "VARCHAR(50) NOT NULL DEFAULT '0'",
|
||||
"port1" => "SMALLINT UNSIGNED NOT NULL",
|
||||
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
|
||||
"delete2duplicates" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"delete1" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"delete2" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"automap" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"skipcrossduplicates" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"custom_params" => "VARCHAR(512) NOT NULL DEFAULT ''",
|
||||
"custom_params" => "TEXT",
|
||||
// original legacy custom_params string, only set when options were dropped during migration (never used by the runner)
|
||||
"custom_params_legacy" => "TEXT DEFAULT NULL",
|
||||
"timeout1" => "SMALLINT NOT NULL DEFAULT '600'",
|
||||
"timeout2" => "SMALLINT NOT NULL DEFAULT '600'",
|
||||
"subscribeall" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"dry" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"is_running" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"prio" => "INT NOT NULL DEFAULT 0",
|
||||
"returned_text" => "LONGTEXT",
|
||||
"last_run" => "TIMESTAMP NULL DEFAULT NULL",
|
||||
"success" => "TINYINT(1) UNSIGNED DEFAULT NULL",
|
||||
@@ -795,6 +1246,32 @@ function init_db_schema()
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_source_id" => array("source_id"),
|
||||
"idx_prio" => array("prio")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_imapsync_source" => array(
|
||||
"col" => "source_id",
|
||||
"ref" => "imapsync_source.id",
|
||||
"delete" => "RESTRICT",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_settings" => array(
|
||||
"cols" => array(
|
||||
"name" => "VARCHAR(64) NOT NULL",
|
||||
"value" => "VARCHAR(255) NOT NULL",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("name")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
@@ -1184,11 +1661,128 @@ function init_db_schema()
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate legacy imapsync jobs (host/port/enc per job) to domain-scoped legacy sources
|
||||
// Re-entrant: only rows with source_id IS NULL are migrated, the legacy columns are kept while any are left
|
||||
if ($table == 'imapsync') {
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE 'imapsync'");
|
||||
if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) != 0) {
|
||||
$imapsync_cols = array_column($pdo->query("SHOW COLUMNS FROM `imapsync`")->fetchAll(PDO::FETCH_ASSOC), 'Field', 'Field');
|
||||
if (isset($imapsync_cols['host1'])) {
|
||||
if (!isset($imapsync_cols['source_id'])) {
|
||||
$pdo->exec("ALTER TABLE `imapsync` ADD COLUMN `source_id` INT DEFAULT NULL");
|
||||
}
|
||||
if (!isset($imapsync_cols['custom_params_legacy'])) {
|
||||
$pdo->exec("ALTER TABLE `imapsync` ADD COLUMN `custom_params_legacy` TEXT DEFAULT NULL");
|
||||
}
|
||||
// Converted JSON may not fit into the old VARCHAR(512)
|
||||
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
|
||||
$imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []);
|
||||
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
|
||||
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`)
|
||||
VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)");
|
||||
$source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)");
|
||||
$job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy,
|
||||
`dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL");
|
||||
$stmt = $pdo->query("SELECT `id`, `user2`, `host1`, `port1`, `enc1`, `custom_params` FROM `imapsync` WHERE `source_id` IS NULL");
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// Parse the legacy custom_params string like the old runner did (all tokens up to the next option are its value)
|
||||
// into pairs valid under $IMAPSYNC_OPTIONS, a token after a flag is never its value.
|
||||
// --authmech1 PLAIN|LOGIN|CRAM-MD5 becomes the source auth type, --dry the job's dry flag (never run a simulation for real).
|
||||
// Anything else that does not fit is dropped, the original string is then kept in custom_params_legacy.
|
||||
$pairs = array();
|
||||
$auth = null;
|
||||
$dry = false;
|
||||
$dropped = false;
|
||||
$tokens = preg_split('/\s+/', trim((string)$row['custom_params']), -1, PREG_SPLIT_NO_EMPTY);
|
||||
for ($i = 0, $n = count($tokens); $i < $n; $i++) {
|
||||
if ($tokens[$i][0] !== '-') {
|
||||
// stray value without option
|
||||
$dropped = true;
|
||||
continue;
|
||||
}
|
||||
@list($opt, $val) = explode('=', ltrim($tokens[$i], '-'), 2);
|
||||
$opt = strtolower($opt);
|
||||
$inline = ($val !== null);
|
||||
$rest = array();
|
||||
while ($i + 1 < $n && $tokens[$i + 1][0] !== '-') {
|
||||
$rest[] = $tokens[++$i];
|
||||
}
|
||||
$rule = ($opt === 'authmech1') ? true : ($imapsync_allow[$opt] ?? null);
|
||||
if ($opt === 'dry' || $rule === false) {
|
||||
if ($inline || !empty($rest)) $dropped = true;
|
||||
if ($opt === 'dry') {
|
||||
$dry = true;
|
||||
} elseif (!$inline) {
|
||||
$pairs[] = array('o' => $opt, 'v' => '');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// unknown option, or "--opt=a b" (the old runner cut the value at the first space)
|
||||
if ($rule === null || ($inline && !empty($rest))) {
|
||||
$dropped = true;
|
||||
continue;
|
||||
}
|
||||
$val = $inline ? $val : implode(' ', $rest);
|
||||
if ($opt === 'authmech1' && $auth === null && in_array(strtoupper($val), array('PLAIN', 'LOGIN', 'CRAM-MD5'), true)) {
|
||||
$auth = strtoupper($val);
|
||||
} elseif ($opt === 'authmech1' || trim($val) === ''
|
||||
|| (is_string($rule) && preg_match('~^(?:' . str_replace('~', '\~', $rule) . ')$~D', $val) !== 1)) {
|
||||
$dropped = true;
|
||||
} else {
|
||||
$pairs[] = array('o' => $opt, 'v' => $val);
|
||||
}
|
||||
}
|
||||
// Old runner ignored the authmech1 column, only a custom --authmech1 was effective
|
||||
$auth = $auth ?? 'PLAIN';
|
||||
$host = strtolower(trim((string)$row['host1']));
|
||||
$port = (intval($row['port1']) >= 1 && intval($row['port1']) <= 65535) ? intval($row['port1']) : 143;
|
||||
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
|
||||
$at = strrchr((string)$row['user2'], '@');
|
||||
$domain = ($at === false) ? '' : strtolower(substr($at, 1));
|
||||
// One source per (host, port, enc, auth, domain), name always unique and <= 100 chars
|
||||
$name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8);
|
||||
$source_sel->execute(array(':name' => $name));
|
||||
if (($source_id = $source_sel->fetchColumn()) === false) {
|
||||
$source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth));
|
||||
$source_sel->execute(array(':name' => $name));
|
||||
if (($source_id = $source_sel->fetchColumn()) === false) continue;
|
||||
}
|
||||
if ($domain !== '') {
|
||||
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
|
||||
}
|
||||
$job_upd->execute(array(
|
||||
':source_id' => $source_id,
|
||||
':cp' => json_encode($pairs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
':cp_legacy' => $dropped ? $row['custom_params'] : null,
|
||||
':dry' => $dry ? 1 : 0,
|
||||
':id' => $row['id']
|
||||
));
|
||||
}
|
||||
|
||||
// Never throw on leftovers: keep source_id nullable and the legacy columns, retried with the next db_version
|
||||
$pending = intval($pdo->query("SELECT COUNT(*) FROM `imapsync` WHERE `source_id` IS NULL")->fetchColumn());
|
||||
if ($pending > 0) {
|
||||
$properties['cols']['source_id'] = "INT DEFAULT NULL";
|
||||
$legacy_col_types = array(
|
||||
'host1' => "VARCHAR(255) DEFAULT NULL",
|
||||
'port1' => "SMALLINT UNSIGNED DEFAULT NULL",
|
||||
'enc1' => "ENUM('TLS','SSL','PLAIN') DEFAULT NULL",
|
||||
'authmech1' => "ENUM('PLAIN','LOGIN','CRAM-MD5') DEFAULT NULL"
|
||||
);
|
||||
foreach ($legacy_col_types as $col => $type) {
|
||||
if (isset($imapsync_cols[$col])) $properties['cols'][$col] = $type;
|
||||
}
|
||||
error_log('mailcow: imapsync migration: ' . $pending . ' sync job(s) could not be assigned to a source and will not run');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE '" . $table . "'");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
if ($num_results != 0) {
|
||||
$stmt = $pdo->prepare("SELECT CONCAT('ALTER TABLE `', `table_schema`, '`.', `table_name`, ' DROP FOREIGN KEY ', `constraint_name`, ';') AS `FKEY_DROP` FROM `information_schema`.`table_constraints`
|
||||
WHERE `constraint_type` = 'FOREIGN KEY' AND `table_name` = :table;");
|
||||
WHERE `constraint_type` = 'FOREIGN KEY' AND `table_schema` = DATABASE() AND `table_name` = :table;");
|
||||
$stmt->execute(array(':table' => $table));
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
while ($row = array_shift($rows)) {
|
||||
@@ -1349,20 +1943,29 @@ function init_db_schema()
|
||||
AND (JSON_CONTAINS_PATH(`call`, 'one', '$[2].password')
|
||||
OR JSON_CONTAINS_PATH(`call`, 'one', '$[2].password2'));");
|
||||
|
||||
// Mitigate imapsync argument injection issue
|
||||
$pdo->query("UPDATE `imapsync` SET `custom_params` = ''
|
||||
WHERE `custom_params` LIKE '%pipemess%'
|
||||
OR custom_params LIKE '%skipmess%'
|
||||
OR custom_params LIKE '%delete2foldersonly%'
|
||||
OR custom_params LIKE '%delete2foldersbutnot%'
|
||||
OR custom_params LIKE '%regexflag%'
|
||||
OR custom_params LIKE '%pipemess%'
|
||||
OR custom_params LIKE '%regextrans2%'
|
||||
OR custom_params LIKE '%maxlinelengthcmd%';");
|
||||
// Mask syncjob secrets in old log entries (data and attr arguments of the logged call)
|
||||
$mask_paths = array();
|
||||
$unmasked = array();
|
||||
foreach (array(3, 4) as $idx) {
|
||||
foreach (array('password1', 'password2', 'oauth_client_secret', 'oauth_extra_params') as $key) {
|
||||
$mask_paths[] = "'$[" . $idx . "]." . $key . "'";
|
||||
// only rows still holding a value other than the string '*' (objects/arrays/numbers included), missing paths are NULL
|
||||
$unmasked[] = "JSON_UNQUOTE(JSON_EXTRACT(`call`, '$[" . $idx . "]." . $key . "')) <> '*'";
|
||||
}
|
||||
}
|
||||
$pdo->exec("UPDATE `logs` SET `call` = JSON_REPLACE(`call`, " . implode(", '*', ", $mask_paths) . ", '*')
|
||||
WHERE JSON_VALID(`call`)
|
||||
AND (JSON_VALUE(`call`, '$[0]') = 'syncjob'
|
||||
OR JSON_VALUE(`call`, '$[0]') LIKE 'imapsync%'
|
||||
OR (JSON_VALUE(`call`, '$[0]') = 'mailbox' AND JSON_VALUE(`call`, '$[2]') = 'syncjob'))
|
||||
AND (" . implode(" OR ", $unmasked) . ");");
|
||||
|
||||
// Migrate webauthn tfa
|
||||
$stmt = $pdo->query("ALTER TABLE `tfa` MODIFY COLUMN `authmech` ENUM('yubi_otp', 'u2f', 'hotp', 'totp', 'webauthn')");
|
||||
|
||||
// Syncjobs: seed global settings
|
||||
$pdo->query("INSERT IGNORE INTO `imapsync_settings` (`name`, `value`) VALUES ('max_parallel', '1')");
|
||||
|
||||
// Inject admin if not exists
|
||||
$stmt = $pdo->query("SELECT NULL FROM `admin`");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
@@ -1530,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;
|
||||
@@ -1544,6 +2159,10 @@ function init_db_schema()
|
||||
}
|
||||
if (php_sapi_name() == "cli") {
|
||||
include '/web/inc/vars.inc.php';
|
||||
if (file_exists('/web/inc/vars.local.inc.php')) {
|
||||
include '/web/inc/vars.local.inc.php';
|
||||
}
|
||||
require_once '/web/inc/lib/vendor/autoload.php';
|
||||
include '/web/inc/functions.inc.php';
|
||||
include '/web/inc/functions.docker.inc.php';
|
||||
// $now = new DateTime();
|
||||
@@ -1561,7 +2180,7 @@ if (php_sapi_name() == "cli") {
|
||||
//PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '" . $offset . "', group_concat_max_len = 3423543543;",
|
||||
];
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
$stmt = $pdo->query("SELECT COUNT('OK') AS OK_C FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sogo_view' OR TABLE_NAME = '_sogo_static_view';");
|
||||
$stmt = $pdo->query("SELECT COUNT('OK') AS OK_C FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME = 'sogo_view' OR TABLE_NAME = '_sogo_static_view');");
|
||||
$res = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (intval($res['OK_C']) === 2) {
|
||||
// Be more precise when replacing into _sogo_static_view, col orders may change
|
||||
@@ -1581,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;
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,10 @@
|
||||
"stevenmaguire/oauth2-keycloak": "^4.0",
|
||||
"league/oauth2-client": "^2.7",
|
||||
"bacon/bacon-qr-code": "^2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Mailcow\\": "mailcow/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Core;
|
||||
|
||||
/**
|
||||
* The caller may not do this (or may not see the object). Always shown as `access_denied`.
|
||||
*/
|
||||
class AccessDenied extends UserError
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('access_denied');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Core;
|
||||
|
||||
/**
|
||||
* Collects the messages of one API call and writes them to $_SESSION['return'] in the mailcow format
|
||||
* (`type`, `log`, `msg`), which json_api.php and the UI read. This is the only class that touches the session.
|
||||
*
|
||||
* Values of the configured secret keys are replaced by '*' inside every array of the `log` part,
|
||||
* so passwords or client secrets never reach the mailcow log or an API response.
|
||||
*/
|
||||
class ApiResponse
|
||||
{
|
||||
private array $messages = [];
|
||||
|
||||
public function __construct(private array $secretKeys = [])
|
||||
{
|
||||
}
|
||||
|
||||
/** Returns true, so callers can `return $response->success(...)` */
|
||||
public function success(string|array $msg, array $log): bool
|
||||
{
|
||||
$this->add('success', $msg, $log);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns false, so callers can `return $response->danger(...)` */
|
||||
public function danger(string|array $msg, array $log): bool
|
||||
{
|
||||
$this->add('danger', $msg, $log);
|
||||
return false;
|
||||
}
|
||||
|
||||
public function error(UserError $error, array $log): bool
|
||||
{
|
||||
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
|
||||
{
|
||||
return $this->messages;
|
||||
}
|
||||
|
||||
/** Appends the collected messages to $_SESSION['return'] and starts over */
|
||||
public function flush(): void
|
||||
{
|
||||
foreach ($this->messages as $message) {
|
||||
$_SESSION['return'][] = $message;
|
||||
}
|
||||
$this->messages = [];
|
||||
}
|
||||
|
||||
private function add(string $type, string|array $msg, array $log): void
|
||||
{
|
||||
foreach ($log as $i => $part) {
|
||||
if (!is_array($part)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($this->secretKeys as $key) {
|
||||
if (isset($part[$key]) && $part[$key] !== '') {
|
||||
$log[$i][$key] = '*';
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->messages[] = ['type' => $type, 'log' => $log, 'msg' => $msg];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Core;
|
||||
|
||||
/**
|
||||
* Who is calling: role, username, ACL flags and the access-all exception of the session.
|
||||
*
|
||||
* Services get a Context instead of reading $_SESSION. Build it once per request in the entry point:
|
||||
* `Context::fromSession($_SESSION)` for web requests, `Context::system()` for cron/CLI code paths
|
||||
* that do no permission checks.
|
||||
*/
|
||||
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 ?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 ?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',
|
||||
$apiAccess
|
||||
);
|
||||
}
|
||||
|
||||
/** Caller for cron and CLI code: not a logged-in account, so every permission check fails */
|
||||
public static function system(): self
|
||||
{
|
||||
return new self('system', null);
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
}
|
||||
|
||||
public function isDomainAdmin(): bool
|
||||
{
|
||||
return $this->role === 'domainadmin';
|
||||
}
|
||||
|
||||
public function isUser(): bool
|
||||
{
|
||||
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';
|
||||
}
|
||||
|
||||
/** Bridge to the legacy hasMailboxObjectAccess() until functions.inc.php is converted */
|
||||
public function canAccessMailbox(string $mailbox): bool
|
||||
{
|
||||
return (bool)hasMailboxObjectAccess($this->username, $this->role, $mailbox);
|
||||
}
|
||||
|
||||
/** Bridge to the legacy hasDomainAccess() until functions.inc.php is converted */
|
||||
public function canAccessDomain(string $domain): bool
|
||||
{
|
||||
return (bool)hasDomainAccess($this->username, $this->role, $domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Core;
|
||||
|
||||
/**
|
||||
* Helpers for loosely typed request values. Form values arrive as strings, JSON bodies as any type,
|
||||
* and 011-api.js turns JSON-looking strings into arrays, so services must not trust the PHP type.
|
||||
*/
|
||||
class Input
|
||||
{
|
||||
/**
|
||||
* Scalar value as trimmed string; '' for null; null for arrays/objects (= invalid).
|
||||
*/
|
||||
public static function text(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if (!is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
return trim((string)$value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Integer within [$min, $max] (filter_var rules: "12" and 12 pass, "12abc" and "1.5" do not), else null.
|
||||
*/
|
||||
public static function intInRange(mixed $value, int $min, int $max): ?int
|
||||
{
|
||||
$int = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => $min, 'max_range' => $max]]);
|
||||
return ($int === false) ? null : $int;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Base class of the repositories: the PDO plus the helpers every repository needs.
|
||||
* Named parameters are passed without the colon (['id' => 5]).
|
||||
*/
|
||||
class Repository
|
||||
{
|
||||
public function __construct(protected PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/** Prepares and executes one statement */
|
||||
protected function query(string $sql, array $params = []): PDOStatement
|
||||
{
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt;
|
||||
}
|
||||
|
||||
/** Inserts one row (column => value) and returns the new id */
|
||||
protected function insertRow(string $table, array $values): int
|
||||
{
|
||||
$columns = array_keys($values);
|
||||
$this->query("INSERT INTO `$table` (`" . implode('`, `', $columns) . "`) VALUES (:" . implode(', :', $columns) . ")", $values);
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/** Updates the columns (column => value) of the row with the given `id` */
|
||||
protected function updateRow(string $table, int $id, array $values): void
|
||||
{
|
||||
$set = implode(', ', array_map(fn(string $column): string => "`$column` = :$column", array_keys($values)));
|
||||
$this->query("UPDATE `$table` SET $set WHERE `id` = :id", $values + ['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs $work in a transaction and returns its result. Inside a transaction of the caller it just runs,
|
||||
* so the caller keeps control over commit and rollback.
|
||||
*/
|
||||
protected function transaction(callable $work): mixed
|
||||
{
|
||||
if ($this->pdo->inTransaction()) {
|
||||
return $work();
|
||||
}
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$result = $work();
|
||||
$this->pdo->commit();
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Core;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* An expected error that is shown to the user (validation failed, not found, not allowed).
|
||||
*
|
||||
* Carries a lang key of the `danger` section plus optional message arguments, e.g.
|
||||
* `throw new UserError('object_exists', [$name]);`
|
||||
* Controllers turn it into an ApiResponse message; anything else that is thrown is a real failure.
|
||||
*/
|
||||
class UserError extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly string $key, public readonly array $args = [])
|
||||
{
|
||||
parent::__construct($key);
|
||||
}
|
||||
|
||||
/** The `msg` value of a mailcow message: the lang key alone, or [key, arg1, ...] when there are arguments */
|
||||
public function msg(): string|array
|
||||
{
|
||||
return empty($this->args) ? $this->key : array_merge([$this->key], array_values($this->args));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Controller;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Service\JobService;
|
||||
use Mailcow\Imapsync\Service\SourceService;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php (and the pages that show the same data).
|
||||
*
|
||||
* Write methods take the decoded request ($items = ids, $attr = attributes), loop over batch ids, turn
|
||||
* UserError into danger messages and write all messages to $_SESSION['return'] via ApiResponse.
|
||||
* They return what json_api passes to process_*_return(): false when the whole call was rejected.
|
||||
* A database error stops the call with one `mysql_error` message, like the rest of mailcow.
|
||||
* Read methods return the API data (an empty array when there is nothing the caller may see).
|
||||
* Messages are logged as ['syncjob', action, type, data] like before the conversion.
|
||||
*/
|
||||
class ImapsyncApiController
|
||||
{
|
||||
public function __construct(private SourceService $sources, private JobService $jobs, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** POST add/syncjob */
|
||||
public function addJob(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', 'job', $attr,
|
||||
fn(array $log): bool => $this->response->success(['mailbox_modified', $this->jobs->create($attr)->user2], $log));
|
||||
}
|
||||
|
||||
/** POST add/syncjob_source */
|
||||
public function addSource(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', 'source', $attr, function (array $log) use ($attr): bool {
|
||||
$source = $this->sources->create($attr);
|
||||
$this->tokenFailure($source, $log);
|
||||
return $this->response->success(['imapsync_source_added', $source->name], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/syncjob: one message per id; `id` in $attr wins over $items (array_merge like before) */
|
||||
public function editJobs(?array $items, array $attr): bool
|
||||
{
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', 'job', $data, function (array $log) use ($data): bool {
|
||||
$this->jobs->requireAcl();
|
||||
return $this->batch((array)($data['id'] ?? []), $log,
|
||||
fn(int $id): array => ['mailbox_modified', $this->jobs->update($id, $data)->user2]);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/syncjob/run_next (admin): false when no job was moved */
|
||||
public function runNext(?array $items): bool
|
||||
{
|
||||
return $this->handle('edit', 'run_next', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$outcome = $this->jobs->runNext(array_map('intval', array_values(array_filter((array)$items, 'is_numeric'))));
|
||||
foreach ($outcome['running'] as $id) {
|
||||
$this->response->danger(['imapsync_job_running', $id], $log);
|
||||
}
|
||||
return ($outcome['moved'] > 0) ? $this->response->success(['imapsync_run_next_done', $outcome['moved']], $log) : false;
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/syncjob_source: one message per id, non-numeric ids are skipped */
|
||||
public function editSources(?array $items, array $attr): bool
|
||||
{
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', 'source', $data, function (array $log) use ($data): bool {
|
||||
$this->sources->requireEditor();
|
||||
return $this->batch(array_filter((array)($data['id'] ?? []), 'is_numeric'), $log, function (int $id) use ($data, $log): array {
|
||||
$source = $this->sources->update($id, $data);
|
||||
$this->tokenFailure($source, $log);
|
||||
return ['imapsync_source_modified', $source->name];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/syncjob_source/refresh_token: the first id only */
|
||||
public function refreshSourceToken(?array $items): bool
|
||||
{
|
||||
return $this->handle('edit', 'refresh_token', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$source = $this->sources->refreshToken($this->requireId(is_array($items) ? reset($items) : null));
|
||||
return $this->response->success(['imapsync_source_token_refreshed', $source->name], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/imapsync_settings (admin) */
|
||||
public function editSettings(array $attr): bool
|
||||
{
|
||||
return $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool {
|
||||
$this->jobs->saveSettings($attr);
|
||||
return $this->response->success('max_parallel_saved', $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/syncjob: one message per id (with the id as requested) */
|
||||
public function deleteJobs(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', 'job', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$this->jobs->requireAcl();
|
||||
return $this->batch($items, $log, function (int $id, mixed $requested): array {
|
||||
$this->jobs->delete($id);
|
||||
return ['deleted_syncjob', $requested];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/syncjob_source: one message per id, non-numeric ids are skipped */
|
||||
public function deleteSources(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', 'source', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$this->sources->requireAcl();
|
||||
return $this->batch(array_filter($items, 'is_numeric'), $log,
|
||||
fn(int $id): array => ['imapsync_source_deleted', $this->sources->delete($id)->name]);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET syncjob_options */
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->jobs->customParamOptions();
|
||||
}
|
||||
|
||||
/** GET imapsync_settings (admin), also admin/system.php */
|
||||
public function getSettings(): array
|
||||
{
|
||||
try {
|
||||
return $this->jobs->getSettings()->toArray();
|
||||
} catch (UserError) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** GET syncjob_source/all */
|
||||
public function getSources(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(fn(int $id): array => $this->getSource($id), $this->sources->visibleIds())));
|
||||
}
|
||||
|
||||
/** GET syncjob_source/<id>, also edit.php */
|
||||
public function getSource(mixed $id): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$source = ($id === null) ? null : $this->sources->get($id);
|
||||
return ($source === null) ? [] : $this->sources->toArray($source);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET syncjobs/all. Admins get every job (incl. orphaned ones). Others get the jobs of $mailboxes,
|
||||
* the mailboxes the caller manages (json_api collects them with the legacy mailbox() functions
|
||||
* until functions.mailbox.inc.php is converted), in queue order.
|
||||
*/
|
||||
public function getAllJobs(?string $extra, array $mailboxes): array
|
||||
{
|
||||
if ($this->jobs->canListAll()) {
|
||||
return $this->jobRows($this->jobs->allIds(), $extra);
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($mailboxes as $mailbox) {
|
||||
$ids = array_merge($ids, $this->jobs->idsForMailbox((string)$mailbox));
|
||||
}
|
||||
$rows = $this->jobRows($ids, $extra);
|
||||
// Queue order (C4): prio desc, never-run first, least recently run, id
|
||||
usort($rows, fn(array $a, array $b): int => [$b['prio'], !empty($a['last_run']), (string)$a['last_run'], (int)$a['id']]
|
||||
<=> [$a['prio'], !empty($b['last_run']), (string)$b['last_run'], (int)$b['id']]);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** GET syncjobs/<mailbox>; without a valid mailbox the caller's own jobs */
|
||||
public function getJobs(?string $mailbox, ?string $extra): array
|
||||
{
|
||||
return $this->jobRows($this->jobs->idsForMailbox($mailbox), $extra);
|
||||
}
|
||||
|
||||
/** One job (edit.php, inc/ajax/syncjob_logs.php); [] when missing or not accessible. `no_log` in $extra skips the log. */
|
||||
public function getJob(mixed $id, ?string $extra = null): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$withLog = ($extra === null || !in_array('no_log', explode(',', $extra), true));
|
||||
$job = ($id === null) ? null : $this->jobs->get($id, $withLog);
|
||||
return ($job === null) ? [] : $job->toArray($withLog);
|
||||
}
|
||||
|
||||
/**
|
||||
* Account rename ($new) or delete ($new = null) from legacy account code that runs outside a transaction
|
||||
* (admin, domain admin, mailbox delete): a database error becomes a `mysql_error` message.
|
||||
* Inside a transaction (mailbox rename) call $module->sources->onAccountRenamed() directly instead,
|
||||
* so the caller's catch can roll back.
|
||||
*/
|
||||
public function accountChanged(string $old, ?string $new = null): void
|
||||
{
|
||||
try {
|
||||
if ($new === null) {
|
||||
$this->sources->onAccountDeleted($old);
|
||||
} else {
|
||||
$this->sources->onAccountRenamed($old, $new);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
$this->response->danger(['mysql_error', $e->getMessage()], ['imapsync_account_changed', $old, $new]);
|
||||
}
|
||||
$this->response->flush();
|
||||
}
|
||||
|
||||
/** The token fetched when saving a client_credentials source failed: the source is saved, report the error */
|
||||
private function tokenFailure(Source $source, array $log): void
|
||||
{
|
||||
if ($source->usesClientCredentials() && $source->active === 1 && $source->lastRefreshError !== null) {
|
||||
$this->response->danger(['imapsync_source_token_refresh_failed', $source->name . ' (' . $source->lastRefreshError . ')'], $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, string $type, mixed $data, callable $work): bool
|
||||
{
|
||||
$log = ['syncjob', $action, $type, $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, requested 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), $id), $log);
|
||||
} catch (UserError $e) {
|
||||
$this->response->error($e, $log);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array[] API rows of the jobs the caller may access */
|
||||
private function jobRows(array $ids, ?string $extra): array
|
||||
{
|
||||
return array_values(array_filter(array_map(fn(int $id): array => $this->getJob($id, $extra), $ids)));
|
||||
}
|
||||
|
||||
/** Integer id from request input ("12", 12); null for anything else, also "1.5" (matches no row) */
|
||||
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,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Http;
|
||||
|
||||
/**
|
||||
* HTTPS requests to OAuth provider endpoints with an address policy against SSRF (C5).
|
||||
*
|
||||
* Every endpoint must be https://, without userinfo, with a valid hostname. In strict mode (endpoints of
|
||||
* sources not created by an admin) every resolved address must be public; curl is then pinned to the checked
|
||||
* addresses (CURLOPT_RESOLVE), so a DNS change between check and request does not help an attacker.
|
||||
*
|
||||
* resolve() and send() are the only I/O and protected, so tests can replace them.
|
||||
*/
|
||||
class OAuthClient
|
||||
{
|
||||
private const MAX_URL_LENGTH = 500;
|
||||
private const MAX_ERROR_LENGTH = 200;
|
||||
|
||||
public function isAllowed(string $url, bool $strict): bool
|
||||
{
|
||||
return $this->pin($url, $strict) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends one request (POST form when $post is given, otherwise GET) and returns the decoded JSON object.
|
||||
*
|
||||
* @throws OAuthException with a sanitised message
|
||||
*/
|
||||
public function request(string $url, bool $strict, ?array $post = null, array $headers = []): array
|
||||
{
|
||||
$pin = $this->pin($url, $strict);
|
||||
if ($pin === null) {
|
||||
throw new OAuthException('endpoint not allowed');
|
||||
}
|
||||
$options = [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_PROXY => '',
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 20,
|
||||
CURLOPT_MAXFILESIZE => 1048576,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers),
|
||||
];
|
||||
if (!empty($pin)) {
|
||||
$options[CURLOPT_RESOLVE] = $pin;
|
||||
}
|
||||
if ($post !== null) {
|
||||
$options[CURLOPT_POST] = true;
|
||||
$options[CURLOPT_POSTFIELDS] = http_build_query($post);
|
||||
}
|
||||
[$body, $code] = $this->send($options);
|
||||
if ($body === false) {
|
||||
throw new OAuthException('connection failed');
|
||||
}
|
||||
$data = json_decode($body, true);
|
||||
$ok = ($code >= 200 && $code < 300);
|
||||
if ($ok && is_array($data)) {
|
||||
return $data;
|
||||
}
|
||||
throw new OAuthException($this->errorText($code, $ok, $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv4 and IPv6 addresses of a host.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function resolve(string $host): array
|
||||
{
|
||||
$ips = @gethostbynamel($host) ?: [];
|
||||
foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $record) {
|
||||
$ips[] = $record['ipv6'] ?? '';
|
||||
}
|
||||
return $ips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs curl with the given options.
|
||||
*
|
||||
* @return array{0: string|false, 1: int} response body (false on connection errors) and HTTP status
|
||||
*/
|
||||
protected function send(array $options): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, $options);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return [is_string($body) ? $body : false, $code];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the endpoint policy. Returns null when the URL is not allowed, otherwise the CURLOPT_RESOLVE
|
||||
* pin (empty when not strict).
|
||||
*/
|
||||
private function pin(string $url, bool $strict): ?array
|
||||
{
|
||||
if (strlen($url) > self::MAX_URL_LENGTH || preg_match('/[\x00-\x20\x7f]/', $url)) {
|
||||
return null;
|
||||
}
|
||||
$parts = parse_url($url);
|
||||
if (!$parts || strtolower($parts['scheme'] ?? '') !== 'https' || isset($parts['user']) || isset($parts['pass'])) {
|
||||
return null;
|
||||
}
|
||||
$host = strtolower($parts['host'] ?? '');
|
||||
if (!filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return null;
|
||||
}
|
||||
if (!$strict) {
|
||||
return [];
|
||||
}
|
||||
$ips = array_values(array_unique($this->resolve($host)));
|
||||
if (empty($ips)) {
|
||||
return null;
|
||||
}
|
||||
$pinned = [];
|
||||
foreach ($ips as $ip) {
|
||||
if (!$this->isPublic($ip)) {
|
||||
return null;
|
||||
}
|
||||
$pinned[] = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '[' . $ip . ']' : $ip;
|
||||
}
|
||||
return [$host . ':' . (int)($parts['port'] ?? 443) . ':' . implode(',', $pinned)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Public unicast address only: no private, loopback, link-local or reserved ranges, no CGNAT (100.64/10),
|
||||
* benchmarking (198.18/15) or multicast/reserved (224/3); IPv6 only global unicast 2000::/3 without
|
||||
* 6to4 (2002::/16), Teredo (2001::/32) and documentation (2001:db8::/32).
|
||||
*/
|
||||
private function isPublic(string $ip): bool
|
||||
{
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return false;
|
||||
}
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$long = ip2long($ip);
|
||||
return ($long & 0xFFC00000) != 0x64400000
|
||||
&& ($long & 0xFFFE0000) != 0xC6120000
|
||||
&& ($long & 0xE0000000) != 0xE0000000;
|
||||
}
|
||||
$hex = bin2hex((string)inet_pton($ip));
|
||||
return ($hex[0] === '2' || $hex[0] === '3')
|
||||
&& substr($hex, 0, 4) !== '2002'
|
||||
&& !in_array(substr($hex, 0, 8), ['20010000', '20010db8'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error text that is safe to store: "HTTP <code>" plus the provider's error / error_description.
|
||||
*/
|
||||
private function errorText(int $code, bool $ok, mixed $data): string
|
||||
{
|
||||
$text = 'HTTP ' . $code . ($ok ? ': malformed response' : '');
|
||||
$detail = [];
|
||||
foreach (['error', 'error_description'] as $key) {
|
||||
if (is_array($data) && isset($data[$key]) && is_scalar($data[$key]) && (string)$data[$key] !== '') {
|
||||
$detail[] = (string)$data[$key];
|
||||
}
|
||||
}
|
||||
if (!empty($detail)) {
|
||||
$text .= ': ' . implode(' - ', $detail);
|
||||
}
|
||||
return mb_substr(trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', strip_tags($text))), 0, self::MAX_ERROR_LENGTH);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Http;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A failed OAuth request. The message is safe to store and show: "HTTP <code>" plus the provider's
|
||||
* error fields, or a fixed text; never a raw response body or curl details.
|
||||
*/
|
||||
class OAuthException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync;
|
||||
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Imapsync\Controller\ImapsyncApiController;
|
||||
use Mailcow\Imapsync\Http\OAuthClient;
|
||||
use Mailcow\Imapsync\Model\CustomParams;
|
||||
use Mailcow\Imapsync\Repository\JobRepository;
|
||||
use Mailcow\Imapsync\Repository\SettingsRepository;
|
||||
use Mailcow\Imapsync\Repository\SourceRepository;
|
||||
use Mailcow\Imapsync\Repository\TokenRepository;
|
||||
use Mailcow\Imapsync\Service\JobService;
|
||||
use Mailcow\Imapsync\Service\OAuthService;
|
||||
use Mailcow\Imapsync\Service\SourceService;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the sync job module: wires repositories, services and the API controller.
|
||||
*
|
||||
* $imapsync = ImapsyncModule::create($pdo, Context::fromSession($_SESSION), [
|
||||
* 'allowlist' => CustomParams::allowlist($IMAPSYNC_OPTIONS, $IMAPSYNC_OPTIONS_DEFAULT),
|
||||
* 'hostname' => (string)getenv('MAILCOW_HOSTNAME'),
|
||||
* ]);
|
||||
* process_add_return($imapsync->api->addJob($attr));
|
||||
*
|
||||
* Procedural legacy code (json_api.php, pages, functions.*.inc.php) uses fromGlobals() instead.
|
||||
*/
|
||||
class ImapsyncModule
|
||||
{
|
||||
/** Request keys whose values never reach a log */
|
||||
private const SECRET_KEYS = ['password1', 'password2', 'oauth_client_secret', 'oauth_extra_params'];
|
||||
|
||||
public function __construct(
|
||||
public readonly SourceService $sources,
|
||||
public readonly JobService $jobs,
|
||||
public readonly OAuthService $oauth,
|
||||
public readonly ImapsyncApiController $api
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $config 'allowlist' => effective custom param allowlist (CustomParams::allowlist()),
|
||||
* 'hostname' => MAILCOW_HOSTNAME (OAuth redirect URI); both optional
|
||||
*/
|
||||
public static function create(PDO $pdo, Context $context, array $config = []): self
|
||||
{
|
||||
$sourceRepository = new SourceRepository($pdo);
|
||||
$tokenRepository = new TokenRepository($pdo);
|
||||
$oauth = new OAuthService($sourceRepository, $tokenRepository, new OAuthClient(), (string)($config['hostname'] ?? ''));
|
||||
$sources = new SourceService($sourceRepository, $tokenRepository, $oauth, $context);
|
||||
$jobs = new JobService(new JobRepository($pdo), $sources, $tokenRepository, new SettingsRepository($pdo),
|
||||
$context, (array)($config['allowlist'] ?? []));
|
||||
return new self($sources, $jobs, $oauth, new ImapsyncApiController($sources, $jobs, new ApiResponse(self::SECRET_KEYS)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy entry point for procedural callers: the one place that reads the globals ($pdo, $_SESSION,
|
||||
* $IMAPSYNC_OPTIONS / $IMAPSYNC_OPTIONS_DEFAULT from vars(.local).inc.php, MAILCOW_HOSTNAME)
|
||||
* and passes them to create(). $context overrides the session caller (e.g. Context::system()).
|
||||
*/
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [
|
||||
'allowlist' => CustomParams::allowlist((array)($GLOBALS['IMAPSYNC_OPTIONS'] ?? []),
|
||||
(array)($GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? [])),
|
||||
'hostname' => (string)getenv('MAILCOW_HOSTNAME'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Model;
|
||||
|
||||
use Mailcow\Core\UserError;
|
||||
|
||||
/**
|
||||
* Rules for the extra imapsync options of a sync job: a list of {o: option, v: value} pairs, stored as JSON
|
||||
* (`imapsync`.`custom_params`, read by the runner). Stored values are passed through unchanged.
|
||||
*
|
||||
* Allowlist rules (option name lowercase, without dashes):
|
||||
* true option requires a non-empty value
|
||||
* false flag, must not have a value
|
||||
* string PCRE (no delimiters) the whole non-empty value must match
|
||||
*/
|
||||
class CustomParams
|
||||
{
|
||||
private const MAX_PAIRS = 100;
|
||||
private const MAX_VALUE_LENGTH = 1000;
|
||||
|
||||
private static bool $warnedOldFormat = false;
|
||||
|
||||
/**
|
||||
* Validates request input (list of {o, v}) against the allowlist and returns the JSON to store.
|
||||
* Empty input means no options.
|
||||
*
|
||||
* @throws UserError imapsync_custom_params_format, imapsync_custom_param_invalid, imapsync_custom_param_value
|
||||
*/
|
||||
public static function toJson(mixed $input, array $allowlist): string
|
||||
{
|
||||
$input = ($input === null || $input === '') ? [] : $input;
|
||||
if (!is_array($input)) {
|
||||
throw new UserError('imapsync_custom_params_format');
|
||||
}
|
||||
$pairs = [];
|
||||
foreach ($input as $pair) {
|
||||
$option = is_array($pair) ? ($pair['o'] ?? '') : null;
|
||||
$value = is_array($pair) ? ($pair['v'] ?? '') : null;
|
||||
if (!is_scalar($option) || !is_scalar($value)) {
|
||||
throw new UserError('imapsync_custom_params_format');
|
||||
}
|
||||
$option = ltrim(strtolower(trim((string)$option)), '-');
|
||||
$value = str_replace("\0", '', (string)$value);
|
||||
if ($option === '' && trim($value) === '') {
|
||||
continue; // empty UI row
|
||||
}
|
||||
$value = self::checkValue($option, $value, $allowlist);
|
||||
if (strlen($value) > self::MAX_VALUE_LENGTH || count($pairs) >= self::MAX_PAIRS) {
|
||||
throw new UserError('imapsync_custom_params_format');
|
||||
}
|
||||
$pairs[] = ['o' => $option, 'v' => $value];
|
||||
}
|
||||
return json_encode($pairs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective allowlist: $IMAPSYNC_OPTIONS (after vars.local.inc.php) without the old
|
||||
* 'whitelist' key and malformed entries; the defaults when nothing valid is left.
|
||||
*/
|
||||
public static function allowlist(array $configured, array $defaults): array
|
||||
{
|
||||
$allowlist = [];
|
||||
foreach ($configured as $option => $rule) {
|
||||
$option = strtolower(ltrim(trim((string)$option), '-'));
|
||||
if ($option !== 'whitelist' && preg_match('/^[a-z][a-z0-9]*$/', $option)
|
||||
&& (is_bool($rule) || (is_string($rule) && $rule !== ''))) {
|
||||
$allowlist[$option] = $rule;
|
||||
}
|
||||
}
|
||||
if (!self::$warnedOldFormat && isset($configured['whitelist'])) {
|
||||
self::$warnedOldFormat = true;
|
||||
error_log("mailcow: \$IMAPSYNC_OPTIONS['whitelist'] (old format) is ignored, use 'option' => true|false|'regex' (see data/web/inc/vars.inc.php)");
|
||||
}
|
||||
return empty($allowlist) ? $defaults : $allowlist;
|
||||
}
|
||||
|
||||
/** The value to store for one option; throws when the allowlist rule is violated */
|
||||
private static function checkValue(string $option, string $value, array $allowlist): string
|
||||
{
|
||||
$rule = $allowlist[$option] ?? null;
|
||||
if ($option === '' || !(is_bool($rule) || is_string($rule))) {
|
||||
throw new UserError('imapsync_custom_param_invalid', [htmlspecialchars($option)]);
|
||||
}
|
||||
$empty = (trim($value) === '');
|
||||
$valid = match (true) {
|
||||
$rule === false => $empty,
|
||||
$rule === true => !$empty,
|
||||
default => !$empty && preg_match('~^(?:' . str_replace('~', '\~', $rule) . ')$~D', $value) === 1,
|
||||
};
|
||||
if (!$valid) {
|
||||
throw new UserError('imapsync_custom_param_value', [$option]);
|
||||
}
|
||||
return ($rule === false) ? '' : $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Model;
|
||||
|
||||
/**
|
||||
* A sync job (table `imapsync`): copies the remote account user1 of a source into the local mailbox user2.
|
||||
*
|
||||
* Loaded jobs also carry display data of their source and of the per-user token ($source / $token,
|
||||
* filled from the JOIN; no secrets). Switches default to off, because unchecked form checkboxes are not sent.
|
||||
*/
|
||||
class Job
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $user2 = '';
|
||||
public ?string $regextrans2 = '';
|
||||
public int $authmd51 = 0;
|
||||
public string $domain2 = '';
|
||||
public string $subfolder2 = '';
|
||||
public string $user1 = '';
|
||||
/** Remote password, never returned by toArray() */
|
||||
public string $password1 = '';
|
||||
public string $exclude = '';
|
||||
public int $maxage = 0;
|
||||
public int $minsInterval = 0;
|
||||
/** Bytes/s limit, stored as text */
|
||||
public string $maxBytesPerSecond = '0';
|
||||
public int $delete2duplicates = 0;
|
||||
public int $delete1 = 0;
|
||||
public int $delete2 = 0;
|
||||
public int $automap = 0;
|
||||
public int $skipcrossduplicates = 0;
|
||||
/** JSON list of {o, v} pairs (see CustomParams), old rows may hold other text */
|
||||
public ?string $customParams = '[]';
|
||||
public ?string $customParamsLegacy = null;
|
||||
public int $timeout1 = 600;
|
||||
public int $timeout2 = 600;
|
||||
public int $subscribeall = 0;
|
||||
public int $dry = 0;
|
||||
public int $isRunning = 0;
|
||||
public ?string $returnedText = null;
|
||||
public ?string $lastRun = null;
|
||||
public ?int $success = null;
|
||||
public ?string $exitStatus = null;
|
||||
public ?string $created = null;
|
||||
public ?string $modified = null;
|
||||
public int $active = 0;
|
||||
public int $sourceId = 0;
|
||||
public int $prio = 0;
|
||||
public ?Source $source = null;
|
||||
public ?OAuthToken $token = null;
|
||||
|
||||
/**
|
||||
* Builds a job from an `imapsync` row, optionally joined with `source_<column>` (columns of the source)
|
||||
* and `user_token_*` columns.
|
||||
*/
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$job = new self();
|
||||
$job->id = isset($row['id']) ? (int)$row['id'] : null;
|
||||
foreach (['authmd51', 'maxage', 'delete2duplicates', 'delete1', 'delete2', 'automap', 'skipcrossduplicates', 'timeout1',
|
||||
'timeout2', 'subscribeall', 'dry', 'active', 'prio'] as $column) {
|
||||
$job->$column = (int)($row[$column] ?? 0);
|
||||
}
|
||||
foreach (['user2', 'domain2', 'subfolder2', 'user1', 'password1', 'exclude'] as $column) {
|
||||
$job->$column = (string)($row[$column] ?? '');
|
||||
}
|
||||
$job->minsInterval = (int)($row['mins_interval'] ?? 0);
|
||||
$job->maxBytesPerSecond = (string)($row['maxbytespersecond'] ?? '0');
|
||||
$job->isRunning = (int)($row['is_running'] ?? 0);
|
||||
$job->success = isset($row['success']) ? (int)$row['success'] : null;
|
||||
// Nullable text columns (column => property)
|
||||
foreach (['regextrans2' => 'regextrans2', 'custom_params' => 'customParams', 'custom_params_legacy' => 'customParamsLegacy',
|
||||
'returned_text' => 'returnedText', 'last_run' => 'lastRun', 'exit_status' => 'exitStatus', 'created' => 'created',
|
||||
'modified' => 'modified'] as $column => $property) {
|
||||
$job->$property = isset($row[$column]) ? (string)$row[$column] : null;
|
||||
}
|
||||
$job->sourceId = (int)($row['source_id'] ?? 0);
|
||||
if (isset($row['source_name'])) {
|
||||
// source_id is the job's foreign key, i.e. the id of the source
|
||||
$source = [];
|
||||
foreach ($row as $key => $value) {
|
||||
if (str_starts_with($key, 'source_')) {
|
||||
$source[substr($key, 7)] = $value;
|
||||
}
|
||||
}
|
||||
$job->source = Source::fromRow($source);
|
||||
}
|
||||
if (!empty($row['user_token_exists'])) {
|
||||
$job->token = OAuthToken::fromRow(['source_id' => $job->sourceId, 'owner' => $job->user2, 'username' => $job->user1,
|
||||
'token_expires' => $row['user_token_expires'] ?? null, 'last_refresh_error' => $row['user_token_error'] ?? null]);
|
||||
}
|
||||
return $job;
|
||||
}
|
||||
|
||||
/**
|
||||
* API representation (GET syncjobs, edit page, log view). Never contains password1.
|
||||
* Without $withLog the `log` field is empty (UI tables request `no_log`).
|
||||
*/
|
||||
public function toArray(bool $withLog = true): array
|
||||
{
|
||||
$source = $this->source;
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'user2' => $this->user2,
|
||||
'regextrans2' => $this->regextrans2,
|
||||
'authmd51' => $this->authmd51,
|
||||
'domain2' => $this->domain2,
|
||||
'subfolder2' => $this->subfolder2,
|
||||
'user1' => $this->user1,
|
||||
'exclude' => $this->exclude,
|
||||
'maxage' => $this->maxage,
|
||||
'mins_interval' => $this->minsInterval,
|
||||
'maxbytespersecond' => $this->maxBytesPerSecond,
|
||||
'delete2duplicates' => $this->delete2duplicates,
|
||||
'delete1' => $this->delete1,
|
||||
'delete2' => $this->delete2,
|
||||
'automap' => $this->automap,
|
||||
'skipcrossduplicates' => $this->skipcrossduplicates,
|
||||
'custom_params' => $this->customParams,
|
||||
'timeout1' => $this->timeout1,
|
||||
'timeout2' => $this->timeout2,
|
||||
'subscribeall' => $this->subscribeall,
|
||||
'dry' => $this->dry,
|
||||
'is_running' => $this->isRunning,
|
||||
'last_run' => $this->lastRun,
|
||||
'success' => $this->success,
|
||||
'exit_status' => $this->exitStatus,
|
||||
'created' => $this->created,
|
||||
'modified' => $this->modified,
|
||||
'active' => $this->active,
|
||||
'source_id' => $this->sourceId,
|
||||
'custom_params_legacy' => $this->customParamsLegacy,
|
||||
'prio' => $this->prio,
|
||||
'source_name' => $source?->name,
|
||||
'source_description' => $source?->description,
|
||||
'source_host' => $source?->host,
|
||||
'source_port' => $source?->port,
|
||||
'source_enc' => $source?->enc,
|
||||
'source_auth_type' => $source?->authType,
|
||||
'source_created_by' => $source?->createdBy,
|
||||
'source_scope' => $source?->scope,
|
||||
'source_oauth_flow' => $source?->oauthFlow,
|
||||
'source_active' => $source?->active,
|
||||
'source_oauth_token_expires' => $source?->tokenExpires,
|
||||
'source_oauth_last_refresh_error' => $source?->lastRefreshError,
|
||||
'user_token_expires' => $this->token?->tokenExpires,
|
||||
'user_token_error' => $this->token?->lastRefreshError,
|
||||
'user_token_exists' => $this->token === null ? 0 : 1,
|
||||
'log' => ($withLog && !empty($this->returnedText)) ? $this->returnedText : '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Model;
|
||||
|
||||
/**
|
||||
* Per-user OAuth token of an authorization_code source (table `imapsync_source_oauth_token`).
|
||||
*
|
||||
* Bound to (source, owner, username): owner = local mailcow mailbox (job user2),
|
||||
* username = authenticated remote identity (job user1).
|
||||
*/
|
||||
class OAuthToken
|
||||
{
|
||||
public int $sourceId = 0;
|
||||
public string $owner = '';
|
||||
public string $username = '';
|
||||
public ?string $accessToken = null;
|
||||
public ?string $refreshToken = null;
|
||||
public ?int $tokenExpires = null;
|
||||
public ?string $lastRefreshError = null;
|
||||
/** Seconds since the last refresh attempt by the DB clock; only filled by the refresh queries */
|
||||
public ?int $refreshAttemptAge = null;
|
||||
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$token = new self();
|
||||
$token->sourceId = (int)($row['source_id'] ?? 0);
|
||||
$token->owner = (string)($row['owner'] ?? '');
|
||||
$token->username = (string)($row['username'] ?? '');
|
||||
$token->accessToken = isset($row['access_token']) ? (string)$row['access_token'] : null;
|
||||
$token->refreshToken = isset($row['refresh_token']) ? (string)$row['refresh_token'] : null;
|
||||
$token->tokenExpires = isset($row['token_expires']) ? (int)$row['token_expires'] : null;
|
||||
$token->lastRefreshError = isset($row['last_refresh_error']) ? (string)$row['last_refresh_error'] : null;
|
||||
$token->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Model;
|
||||
|
||||
/**
|
||||
* Global sync job settings (table `imapsync_settings`), read by the runner.
|
||||
*/
|
||||
class Settings
|
||||
{
|
||||
public int $maxParallel = 1;
|
||||
/** Global bandwidth limit in bytes/s, 0 = unlimited */
|
||||
public int $maxBytesPerSecond = 0;
|
||||
|
||||
/**
|
||||
* API representation (GET imapsync_settings); the UI edits the limit in KB/s.
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'max_parallel' => $this->maxParallel,
|
||||
'max_bytes_per_second' => $this->maxBytesPerSecond,
|
||||
'max_kb_per_second' => intdiv($this->maxBytesPerSecond, 1024),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Model;
|
||||
|
||||
/**
|
||||
* A reusable IMAP source (table `imapsync_source` plus its scope targets).
|
||||
*
|
||||
* Visibility is driven by `scope` and the target lists:
|
||||
* 'all' visible to everyone (admin-created only)
|
||||
* 'domain' visible to the listed domains (their domain admins and users)
|
||||
* 'user' visible to the listed users (and their domain admins); no targets = private
|
||||
* `createdBy` records the creator ('' = handed over to the admins).
|
||||
*
|
||||
* The object holds secrets (client secret, access token); toArray() never returns them.
|
||||
*/
|
||||
class Source
|
||||
{
|
||||
public const AUTH_XOAUTH2 = 'XOAUTH2';
|
||||
public const FLOW_CLIENT_CREDENTIALS = 'client_credentials';
|
||||
public const FLOW_AUTHORIZATION_CODE = 'authorization_code';
|
||||
|
||||
public ?int $id = null;
|
||||
public string $name = '';
|
||||
public string $description = '';
|
||||
public string $createdBy = '';
|
||||
public string $scope = 'user';
|
||||
public string $host = '';
|
||||
public int $port = 0;
|
||||
public ?string $enc = 'TLS';
|
||||
public ?string $authType = 'PLAIN';
|
||||
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
|
||||
public ?string $tokenEndpoint = null;
|
||||
public ?string $authorizeEndpoint = null;
|
||||
public ?string $userinfoEndpoint = null;
|
||||
public ?string $clientId = null;
|
||||
public ?string $clientSecret = null;
|
||||
public ?string $oauthScope = null;
|
||||
/** Extra OAuth parameters (sign-in or token request, see OAuthService), JSON object text */
|
||||
public ?string $extraParams = null;
|
||||
/** Shared token of a client_credentials source */
|
||||
public ?string $accessToken = null;
|
||||
public ?int $tokenExpires = null;
|
||||
public ?string $lastRefreshError = null;
|
||||
public ?string $lastRefreshAttempt = null;
|
||||
/** Seconds since lastRefreshAttempt by the DB clock; only filled by the refresh queries */
|
||||
public ?int $refreshAttemptAge = null;
|
||||
public ?string $created = null;
|
||||
public ?string $modified = null;
|
||||
public int $active = 1;
|
||||
/** @var string[] */
|
||||
public array $domains = [];
|
||||
/** @var string[] */
|
||||
public array $users = [];
|
||||
|
||||
/** Builds a source from an `imapsync_source` row (column names as in the table) */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$source = new self();
|
||||
$source->id = isset($row['id']) ? (int)$row['id'] : null;
|
||||
$source->name = (string)($row['name'] ?? '');
|
||||
$source->description = (string)($row['description'] ?? '');
|
||||
$source->createdBy = (string)($row['created_by'] ?? '');
|
||||
$source->scope = (string)($row['scope'] ?? 'user');
|
||||
$source->host = (string)($row['host1'] ?? '');
|
||||
$source->port = (int)($row['port1'] ?? 0);
|
||||
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
|
||||
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
|
||||
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
|
||||
// Nullable text columns (column => property)
|
||||
foreach (['enc1' => 'enc', 'auth_type' => 'authType', 'oauth_token_endpoint' => 'tokenEndpoint',
|
||||
'oauth_authorize_endpoint' => 'authorizeEndpoint', 'oauth_userinfo_endpoint' => 'userinfoEndpoint',
|
||||
'oauth_client_id' => 'clientId', 'oauth_client_secret' => 'clientSecret', 'oauth_scope' => 'oauthScope',
|
||||
'oauth_extra_params' => 'extraParams', 'oauth_access_token' => 'accessToken', 'oauth_last_refresh_error' => 'lastRefreshError',
|
||||
'oauth_last_refresh_attempt' => 'lastRefreshAttempt', 'created' => 'created', 'modified' => 'modified'] as $column => $property) {
|
||||
$source->$property = isset($row[$column]) ? (string)$row[$column] : null;
|
||||
}
|
||||
$source->active = (int)($row['active'] ?? 1);
|
||||
return $source;
|
||||
}
|
||||
|
||||
public function isXOAuth2(): bool
|
||||
{
|
||||
return $this->authType === self::AUTH_XOAUTH2;
|
||||
}
|
||||
|
||||
/** XOAUTH2 with one app-wide token that reaches every mailbox of the tenant */
|
||||
public function usesClientCredentials(): bool
|
||||
{
|
||||
return $this->isXOAuth2() && $this->oauthFlow === self::FLOW_CLIENT_CREDENTIALS;
|
||||
}
|
||||
|
||||
/** XOAUTH2 with a per-user token from the browser sign-in */
|
||||
public function usesAuthorizationCode(): bool
|
||||
{
|
||||
return $this->isXOAuth2() && $this->oauthFlow === self::FLOW_AUTHORIZATION_CODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* API representation (GET syncjob_source). Secrets and tokens are replaced by has_* flags;
|
||||
* extra params are only shown to callers who may edit the source.
|
||||
*/
|
||||
public function toArray(bool $canEdit, string $redirectUri): array
|
||||
{
|
||||
$data = [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'created_by' => $this->createdBy,
|
||||
'scope' => $this->scope,
|
||||
'host1' => $this->host,
|
||||
'port1' => $this->port,
|
||||
'enc1' => $this->enc,
|
||||
'auth_type' => $this->authType,
|
||||
'oauth_flow' => $this->oauthFlow,
|
||||
'oauth_token_endpoint' => $this->tokenEndpoint,
|
||||
'oauth_authorize_endpoint' => $this->authorizeEndpoint,
|
||||
'oauth_userinfo_endpoint' => $this->userinfoEndpoint,
|
||||
'oauth_client_id' => $this->clientId,
|
||||
'oauth_scope' => $this->oauthScope,
|
||||
'oauth_extra_params' => $canEdit ? $this->extraParams : null,
|
||||
'oauth_token_expires' => $this->tokenExpires,
|
||||
'oauth_last_refresh_error' => $this->lastRefreshError,
|
||||
'oauth_last_refresh_attempt' => $this->lastRefreshAttempt,
|
||||
'created' => $this->created,
|
||||
'modified' => $this->modified,
|
||||
'active' => $this->active,
|
||||
'domains' => $this->domains,
|
||||
'users' => $this->users,
|
||||
'can_edit' => $canEdit,
|
||||
'host' => $this->host,
|
||||
'port' => $this->port,
|
||||
'enc' => $this->enc,
|
||||
'has_client_secret' => !empty($this->clientSecret),
|
||||
'has_access_token' => !empty($this->accessToken),
|
||||
];
|
||||
if ($this->usesAuthorizationCode()) {
|
||||
$data['oauth_redirect_uri'] = $redirectUri;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\Imapsync\Model\Job;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for sync jobs (table `imapsync`).
|
||||
*/
|
||||
class JobRepository extends Repository
|
||||
{
|
||||
/** Job queue order (C4): prio desc, never-run first, then least recently run */
|
||||
private const QUEUE_ORDER = "ORDER BY `prio` DESC, `last_run` IS NOT NULL, `last_run` ASC, `id` ASC";
|
||||
|
||||
/** Every column except returned_text (the log, loaded on demand) */
|
||||
private const COLUMNS = ['id', 'user2', 'regextrans2', 'authmd51', 'domain2', 'subfolder2', 'user1', 'password1',
|
||||
'exclude', 'maxage', 'mins_interval', 'maxbytespersecond', 'delete2duplicates', 'delete1', 'delete2', 'automap',
|
||||
'skipcrossduplicates', 'custom_params', 'timeout1', 'timeout2', 'subscribeall', 'dry', 'is_running',
|
||||
'last_run', 'success', 'exit_status', 'created', 'modified', 'active', 'source_id', 'custom_params_legacy', 'prio'];
|
||||
|
||||
/**
|
||||
* The job with display data of its source and per-user token (C1: source, owner = user2, username = user1).
|
||||
* $withLog also loads returned_text.
|
||||
*/
|
||||
public function find(int $id, bool $withLog = true): ?Job
|
||||
{
|
||||
$columns = $withLog ? [...self::COLUMNS, 'returned_text'] : self::COLUMNS;
|
||||
$row = $this->query("SELECT i.`" . implode("`, i.`", $columns) . "`,
|
||||
s.`name` AS source_name, s.`description` AS source_description, s.`host1` AS source_host1,
|
||||
s.`port1` AS source_port1, s.`enc1` AS source_enc1, s.`auth_type` AS source_auth_type,
|
||||
s.`created_by` AS source_created_by, s.`scope` AS source_scope, s.`oauth_flow` AS source_oauth_flow,
|
||||
s.`active` AS source_active, s.`oauth_token_expires` AS source_oauth_token_expires,
|
||||
s.`oauth_last_refresh_error` AS source_oauth_last_refresh_error,
|
||||
t.`token_expires` AS user_token_expires, t.`last_refresh_error` AS user_token_error,
|
||||
CASE WHEN t.`username` IS NULL THEN 0 ELSE 1 END AS user_token_exists
|
||||
FROM `imapsync` i
|
||||
LEFT JOIN `imapsync_source` s ON i.`source_id` = s.`id`
|
||||
LEFT JOIN `imapsync_source_oauth_token` t
|
||||
ON t.`source_id` = i.`source_id` AND t.`owner` = i.`user2` AND t.`username` = i.`user1`
|
||||
WHERE i.`id` = :id", ['id' => $id])->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ? Job::fromRow($row) : null;
|
||||
}
|
||||
|
||||
/** @return int[] job ids of a local mailbox in queue order */
|
||||
public function idsForMailbox(string $user2): array
|
||||
{
|
||||
return array_map('intval', $this->query("SELECT `id` FROM `imapsync` WHERE `user2` = :user2 " . self::QUEUE_ORDER,
|
||||
['user2' => $user2])->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/** @return int[] all job ids (incl. orphaned ones) in queue order */
|
||||
public function allIds(): array
|
||||
{
|
||||
return array_map('intval', $this->query("SELECT `id` FROM `imapsync` " . self::QUEUE_ORDER)->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/** True when another job (not $exceptId) already syncs (source, user1) into user2 */
|
||||
public function tupleExists(int $sourceId, string $user2, string $user1, ?int $exceptId = null): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `imapsync`
|
||||
WHERE `user2` = :user2 AND `user1` = :user1 AND `source_id` = :source_id AND `id` != :id",
|
||||
['user2' => $user2, 'user1' => $user1, 'source_id' => $sourceId, 'id' => $exceptId ?? 0])->fetchColumn();
|
||||
}
|
||||
|
||||
public function insert(Job $job): int
|
||||
{
|
||||
return $job->id = $this->insertRow('imapsync', $this->values($job) + ['user2' => $job->user2]);
|
||||
}
|
||||
|
||||
public function update(Job $job): void
|
||||
{
|
||||
$this->updateRow('imapsync', (int)$job->id,
|
||||
$this->values($job) + ['last_run' => $job->lastRun, 'success' => $job->success, 'prio' => $job->prio]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->query("DELETE FROM `imapsync` WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves idle jobs to the front of the queue: each gets a prio above the current global max
|
||||
* (lowest current prio first) and last_run/success are cleared. The rows are locked first, so the
|
||||
* runner cannot claim a job in between (its claim UPDATE waits for this transaction).
|
||||
*
|
||||
* @param int[] $ids
|
||||
* @return array{running: int[], moved: int} ids that are running (left alone) and the number of moved jobs
|
||||
*/
|
||||
public function moveToFront(array $ids): array
|
||||
{
|
||||
return $this->transaction(function () use ($ids): array {
|
||||
$rows = $this->query("SELECT `id`, `is_running` FROM `imapsync` WHERE `id` IN (" . self::placeholders($ids) . ") FOR UPDATE",
|
||||
$ids)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$running = array_keys(array_filter($rows, fn($isRunning): bool => (int)$isRunning === 1));
|
||||
$idle = array_values(array_diff(array_keys($rows), $running));
|
||||
$moved = 0;
|
||||
if (!empty($idle)) {
|
||||
$this->pdo->exec("SET @m := (SELECT COALESCE(MAX(`prio`), 0) FROM `imapsync`)");
|
||||
$this->pdo->exec("SET @r := 0");
|
||||
$moved = $this->query("UPDATE `imapsync` SET `prio` = @m + (@r := @r + 1), `last_run` = NULL, `success` = NULL
|
||||
WHERE `id` IN (" . self::placeholders($idle) . ") AND `is_running` = 0 ORDER BY `prio` ASC, `id` ASC", $idle)->rowCount();
|
||||
}
|
||||
return ['running' => $running, 'moved' => $moved];
|
||||
});
|
||||
}
|
||||
|
||||
/** Values of the columns a user can edit (shared by insert and update) */
|
||||
private function values(Job $job): array
|
||||
{
|
||||
$values = ['mins_interval' => $job->minsInterval, 'maxbytespersecond' => $job->maxBytesPerSecond,
|
||||
'custom_params' => $job->customParams, 'source_id' => $job->sourceId];
|
||||
// Columns with a property of the same name
|
||||
foreach (['user1', 'password1', 'subfolder2', 'exclude', 'maxage', 'timeout1', 'timeout2', 'active', 'delete1', 'delete2',
|
||||
'delete2duplicates', 'automap', 'skipcrossduplicates', 'subscribeall', 'dry'] as $column) {
|
||||
$values[$column] = $job->$column;
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
private static function placeholders(array $values): string
|
||||
{
|
||||
return implode(',', array_fill(0, count($values), '?'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\Imapsync\Model\Settings;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for the global sync job settings (table `imapsync_settings`, name => value).
|
||||
*/
|
||||
class SettingsRepository extends Repository
|
||||
{
|
||||
/** Stored settings; keys that are not stored yet keep the defaults of the model */
|
||||
public function load(): Settings
|
||||
{
|
||||
$rows = $this->query("SELECT `name`, `value` FROM `imapsync_settings`")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$settings = new Settings();
|
||||
$settings->maxParallel = (int)($rows['max_parallel'] ?? $settings->maxParallel);
|
||||
$settings->maxBytesPerSecond = (int)($rows['max_bytes_per_second'] ?? $settings->maxBytesPerSecond);
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public function save(Settings $settings): void
|
||||
{
|
||||
$sql = "INSERT INTO `imapsync_settings` (`name`, `value`) VALUES (:name, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)";
|
||||
$this->query($sql, ['name' => 'max_parallel', 'value' => (string)$settings->maxParallel]);
|
||||
$this->query($sql, ['name' => 'max_bytes_per_second', 'value' => (string)$settings->maxBytesPerSecond]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Repository;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for `imapsync_source` and its target tables `imapsync_source_domain` / `imapsync_source_user`.
|
||||
* No permission decisions here except the visibility filter, which is pure SQL (see visibility()).
|
||||
*/
|
||||
class SourceRepository extends Repository
|
||||
{
|
||||
/** Full source incl. secrets and targets, without visibility filter */
|
||||
public function find(int $id): ?Source
|
||||
{
|
||||
return $this->fetch('1=1', ['id' => $id]);
|
||||
}
|
||||
|
||||
/** Like find(), but only when the source is visible to the caller */
|
||||
public function findVisible(int $id, Context $context): ?Source
|
||||
{
|
||||
[$visible, $params] = $this->visibility($context);
|
||||
return $this->fetch($visible, $params + ['id' => $id]);
|
||||
}
|
||||
|
||||
/** @return int[] ids of all sources visible to the caller, ordered by name */
|
||||
public function visibleIds(Context $context): array
|
||||
{
|
||||
[$visible, $params] = $this->visibility($context);
|
||||
return array_map('intval', $this->query("SELECT s.`id` FROM `imapsync_source` s WHERE $visible ORDER BY s.`name`, s.`id`",
|
||||
$params)->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
public function nameExists(string $name, string $createdBy, ?int $exceptId = null): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `imapsync_source` WHERE `name` = :name AND `created_by` = :created_by AND `id` != :id",
|
||||
['name' => $name, 'created_by' => $createdBy, 'id' => $exceptId ?? 0])->fetchColumn();
|
||||
}
|
||||
|
||||
/** True while a sync job references the source */
|
||||
public function isInUse(int $id): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn();
|
||||
}
|
||||
|
||||
public function isSuperAdmin(string $username): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'",
|
||||
['username' => $username])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Inserts the source with its targets and returns the new id */
|
||||
public function insert(Source $source): int
|
||||
{
|
||||
return $this->transaction(function () use ($source): int {
|
||||
$source->id = $this->insertRow('imapsync_source',
|
||||
$this->values($source) + ['created_by' => $source->createdBy, 'scope' => $source->scope]);
|
||||
$this->writeTargets($source);
|
||||
return $source->id;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the source. $writeTargets also rewrites scope + target lists, $resetTokens drops the shared
|
||||
* token and all per-user tokens of the source. All in one transaction.
|
||||
*/
|
||||
public function update(Source $source, bool $writeTargets, bool $resetTokens): void
|
||||
{
|
||||
$this->transaction(function () use ($source, $writeTargets, $resetTokens): void {
|
||||
$id = ['id' => $source->id];
|
||||
$this->updateRow('imapsync_source', (int)$source->id, $this->values($source));
|
||||
if ($writeTargets) {
|
||||
$this->query("DELETE FROM `imapsync_source_domain` WHERE `source_id` = :id", $id);
|
||||
$this->query("DELETE FROM `imapsync_source_user` WHERE `source_id` = :id", $id);
|
||||
$this->writeTargets($source);
|
||||
}
|
||||
if ($resetTokens) {
|
||||
$this->query("UPDATE `imapsync_source` SET `oauth_access_token` = NULL, `oauth_token_expires` = NULL,
|
||||
`oauth_last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NULL WHERE `id` = :id", $id);
|
||||
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE `source_id` = :id", $id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes the source; targets and per-user tokens go via FK ON DELETE CASCADE */
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->query("DELETE FROM `imapsync_source` WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
/** Deletes the source and its child rows explicitly, for callers running with FOREIGN_KEY_CHECKS = 0 (mailbox rename) */
|
||||
public function deleteWithChildren(int $id): void
|
||||
{
|
||||
foreach (['imapsync_source_oauth_token', 'imapsync_source_user', 'imapsync_source_domain'] as $table) {
|
||||
$this->query("DELETE FROM `$table` WHERE `source_id` = :id", ['id' => $id]);
|
||||
}
|
||||
$this->delete($id);
|
||||
}
|
||||
|
||||
/** @return Source[] sources created by an account (id and name only) */
|
||||
public function listCreatedBy(string $username): array
|
||||
{
|
||||
return array_map([Source::class, 'fromRow'], $this->query("SELECT `id`, `name` FROM `imapsync_source` WHERE `created_by` = :username",
|
||||
['username' => $username])->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** Hands a source over to the admins (created_by '') under the given name */
|
||||
public function handOver(int $id, string $name): void
|
||||
{
|
||||
$this->query("UPDATE `imapsync_source` SET `created_by` = '', `name` = :name WHERE `id` = :id", ['name' => $name, 'id' => $id]);
|
||||
}
|
||||
|
||||
/** Follows an account rename: creator and user targets */
|
||||
public function renameAccount(string $old, string $new): void
|
||||
{
|
||||
$this->query("UPDATE `imapsync_source` SET `created_by` = :new WHERE `created_by` = :old", ['new' => $new, 'old' => $old]);
|
||||
$this->query("UPDATE `imapsync_source_user` SET `username` = :new WHERE `username` = :old", ['new' => $new, 'old' => $old]);
|
||||
}
|
||||
|
||||
/** Removes a deleted account from all user target lists */
|
||||
public function deleteUserTarget(string $username): void
|
||||
{
|
||||
$this->query("DELETE FROM `imapsync_source_user` WHERE `username` = :username", ['username' => $username]);
|
||||
}
|
||||
|
||||
private function fetch(string $where, array $params): ?Source
|
||||
{
|
||||
$row = $this->query("SELECT s.* FROM `imapsync_source` s WHERE s.`id` = :id AND $where", $params)->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$source = Source::fromRow($row);
|
||||
$id = ['id' => $source->id];
|
||||
$source->domains = $this->query("SELECT `domain` FROM `imapsync_source_domain` WHERE `source_id` = :id ORDER BY `domain`",
|
||||
$id)->fetchAll(PDO::FETCH_COLUMN);
|
||||
$source->users = $this->query("SELECT `username` FROM `imapsync_source_user` WHERE `source_id` = :id ORDER BY `username`",
|
||||
$id)->fetchAll(PDO::FETCH_COLUMN);
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE fragment over `imapsync_source s` that limits rows to what the caller may see, and its parameters.
|
||||
* admin everything
|
||||
* domainadmin own + 'all' + sources targeting his domains or their mailboxes + sources created by them
|
||||
* user own + 'all' + sources targeting him or his domain
|
||||
*
|
||||
* @return array{0: string, 1: array}
|
||||
*/
|
||||
private function visibility(Context $context): array
|
||||
{
|
||||
$username = $context->username;
|
||||
if ($context->isAdmin()) {
|
||||
return ['1=1', []];
|
||||
}
|
||||
// Native prepares: a placeholder may appear only once, so the username is bound under one name per use
|
||||
$managed = "SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = ";
|
||||
if ($context->isDomainAdmin()) {
|
||||
return ["(s.`created_by` = :vis_self OR s.`scope` = 'all'
|
||||
OR (s.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` t
|
||||
WHERE t.`source_id` = s.`id` AND t.`domain` IN ($managed :vis_d1)))
|
||||
OR (s.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` t JOIN `mailbox` mb ON mb.`username` = t.`username`
|
||||
WHERE t.`source_id` = s.`id` AND mb.`domain` IN ($managed :vis_d2)))
|
||||
OR s.`created_by` IN (SELECT `username` FROM `mailbox` WHERE `domain` IN ($managed :vis_d3)))",
|
||||
['vis_self' => $username, 'vis_d1' => $username, 'vis_d2' => $username, 'vis_d3' => $username]];
|
||||
}
|
||||
if ($context->isUser()) {
|
||||
return ["(s.`created_by` = :vis_self OR s.`scope` = 'all'
|
||||
OR (s.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` t
|
||||
WHERE t.`source_id` = s.`id` AND t.`username` = :vis_u1))
|
||||
OR (s.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` t JOIN `mailbox` mb ON mb.`domain` = t.`domain`
|
||||
WHERE t.`source_id` = s.`id` AND mb.`username` = :vis_u2)))",
|
||||
['vis_self' => $username, 'vis_u1' => $username, 'vis_u2' => $username]];
|
||||
}
|
||||
return ['0=1', []];
|
||||
}
|
||||
|
||||
private function writeTargets(Source $source): void
|
||||
{
|
||||
foreach ($source->domains as $domain) {
|
||||
$this->query("INSERT INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)",
|
||||
['id' => $source->id, 'domain' => $domain]);
|
||||
}
|
||||
foreach ($source->users as $username) {
|
||||
$this->query("INSERT INTO `imapsync_source_user` (`source_id`, `username`) VALUES (:id, :username)",
|
||||
['id' => $source->id, 'username' => $username]);
|
||||
}
|
||||
$this->query("UPDATE `imapsync_source` SET `scope` = :scope WHERE `id` = :id", ['scope' => $source->scope, 'id' => $source->id]);
|
||||
}
|
||||
|
||||
private function values(Source $source): array
|
||||
{
|
||||
return [
|
||||
'name' => $source->name,
|
||||
'description' => $source->description,
|
||||
'host1' => $source->host,
|
||||
'port1' => $source->port,
|
||||
'enc1' => $source->enc,
|
||||
'auth_type' => $source->authType,
|
||||
'oauth_flow' => $source->oauthFlow,
|
||||
'oauth_token_endpoint' => $source->tokenEndpoint,
|
||||
'oauth_authorize_endpoint' => $source->authorizeEndpoint,
|
||||
'oauth_userinfo_endpoint' => $source->userinfoEndpoint,
|
||||
'oauth_client_id' => $source->clientId,
|
||||
'oauth_client_secret' => $source->clientSecret,
|
||||
'oauth_scope' => $source->oauthScope,
|
||||
'oauth_extra_params' => $source->extraParams,
|
||||
'active' => $source->active,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\Imapsync\Model\OAuthToken;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for OAuth tokens: the shared token of a client_credentials source (columns of `imapsync_source`)
|
||||
* and the per-user tokens of authorization_code sources (`imapsync_source_oauth_token`).
|
||||
*/
|
||||
class TokenRepository extends Repository
|
||||
{
|
||||
private const USER_TOKEN = "`source_id` = :sid AND `owner` = :owner AND `username` = :user";
|
||||
|
||||
public function exists(int $sourceId, string $owner, string $username): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `imapsync_source_oauth_token` WHERE " . self::USER_TOKEN,
|
||||
['sid' => $sourceId, 'owner' => $owner, 'user' => $username])->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the token of a finished browser sign-in. An existing refresh token is kept when the
|
||||
* provider sent none (Microsoft only sends one with offline_access).
|
||||
*/
|
||||
public function saveUserToken(OAuthToken $token): void
|
||||
{
|
||||
$this->query("INSERT INTO `imapsync_source_oauth_token`
|
||||
(`source_id`, `owner`, `username`, `access_token`, `refresh_token`, `token_expires`, `last_refresh_error`, `oauth_last_refresh_attempt`)
|
||||
VALUES (:sid, :owner, :user, :acc, :ref, :exp, NULL, NOW())
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`access_token` = VALUES(`access_token`),
|
||||
`refresh_token` = IF(VALUES(`refresh_token`) = '', `refresh_token`, VALUES(`refresh_token`)),
|
||||
`token_expires` = VALUES(`token_expires`),
|
||||
`last_refresh_error` = NULL,
|
||||
`oauth_last_refresh_attempt` = NOW()",
|
||||
$this->key($token) + ['acc' => $token->accessToken, 'ref' => (string)$token->refreshToken, 'exp' => $token->tokenExpires]);
|
||||
}
|
||||
|
||||
/** Stores a refreshed per-user token and clears its last error */
|
||||
public function storeUserRefresh(OAuthToken $token): void
|
||||
{
|
||||
$this->query("UPDATE `imapsync_source_oauth_token` SET `access_token` = :acc, `refresh_token` = :ref,
|
||||
`token_expires` = :exp, `last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NOW() WHERE " . self::USER_TOKEN,
|
||||
$this->key($token) + ['acc' => $token->accessToken, 'ref' => $token->refreshToken, 'exp' => $token->tokenExpires]);
|
||||
}
|
||||
|
||||
public function storeUserError(OAuthToken $token, string $error): void
|
||||
{
|
||||
$this->query("UPDATE `imapsync_source_oauth_token` SET `last_refresh_error` = :err, `oauth_last_refresh_attempt` = NOW()
|
||||
WHERE " . self::USER_TOKEN, $this->key($token) + ['err' => $error]);
|
||||
}
|
||||
|
||||
public function storeSourceToken(int $sourceId, string $accessToken, int $expires): void
|
||||
{
|
||||
$this->query("UPDATE `imapsync_source` SET `oauth_access_token` = :tok, `oauth_token_expires` = :exp,
|
||||
`oauth_last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NOW() WHERE `id` = :id",
|
||||
['tok' => $accessToken, 'exp' => $expires, 'id' => $sourceId]);
|
||||
}
|
||||
|
||||
public function storeSourceError(int $sourceId, string $error): void
|
||||
{
|
||||
$this->query("UPDATE `imapsync_source` SET `oauth_last_refresh_error` = :err, `oauth_last_refresh_attempt` = NOW()
|
||||
WHERE `id` = :id", ['err' => $error, 'id' => $sourceId]);
|
||||
}
|
||||
|
||||
/** @return Source[] active client_credentials sources (incl. secrets), each with refreshAttemptAge */
|
||||
public function refreshableSources(): array
|
||||
{
|
||||
return array_map([Source::class, 'fromRow'], $this->query("SELECT *,
|
||||
TIMESTAMPDIFF(SECOND, `oauth_last_refresh_attempt`, NOW()) AS `refresh_attempt_age`
|
||||
FROM `imapsync_source`
|
||||
WHERE `active` = 1 AND `auth_type` = 'XOAUTH2' AND `oauth_flow` = 'client_credentials'
|
||||
ORDER BY `id`")->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return OAuthToken[] per-user tokens with a refresh token on active authorization_code sources, with refreshAttemptAge */
|
||||
public function refreshableUserTokens(): array
|
||||
{
|
||||
return array_map([OAuthToken::class, 'fromRow'], $this->query("SELECT t.*,
|
||||
TIMESTAMPDIFF(SECOND, t.`oauth_last_refresh_attempt`, NOW()) AS `refresh_attempt_age`
|
||||
FROM `imapsync_source_oauth_token` t
|
||||
JOIN `imapsync_source` s ON s.`id` = t.`source_id`
|
||||
WHERE s.`active` = 1 AND s.`auth_type` = 'XOAUTH2' AND s.`oauth_flow` = 'authorization_code'
|
||||
AND t.`refresh_token` <> ''
|
||||
ORDER BY t.`source_id`, t.`owner`, t.`username`")->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** Drops a per-user token once no job uses (source, owner, username) anymore */
|
||||
public function deleteIfUnused(int $sourceId, string $owner, string $username): void
|
||||
{
|
||||
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE " . self::USER_TOKEN . "
|
||||
AND NOT EXISTS (SELECT 1 FROM `imapsync` WHERE `source_id` = :sid2 AND `user2` = :owner2 AND `user1` = :user2)",
|
||||
['sid' => $sourceId, 'owner' => $owner, 'user' => $username, 'sid2' => $sourceId, 'owner2' => $owner, 'user2' => $username]);
|
||||
}
|
||||
|
||||
/** Drops per-user tokens that no job uses and that are older than $seconds. Returns the number of rows. */
|
||||
public function deleteUnusedOlderThan(int $seconds): int
|
||||
{
|
||||
return $this->query("DELETE t FROM `imapsync_source_oauth_token` t
|
||||
LEFT JOIN `imapsync` i ON i.`source_id` = t.`source_id` AND i.`user2` = t.`owner` AND i.`user1` = t.`username`
|
||||
WHERE i.`id` IS NULL AND t.`created` < NOW() - INTERVAL :seconds SECOND", ['seconds' => $seconds])->rowCount();
|
||||
}
|
||||
|
||||
public function deleteByOwner(string $owner): void
|
||||
{
|
||||
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE `owner` = :owner", ['owner' => $owner]);
|
||||
}
|
||||
|
||||
public function renameOwner(string $old, string $new): void
|
||||
{
|
||||
$this->query("UPDATE `imapsync_source_oauth_token` SET `owner` = :new WHERE `owner` = :old", ['new' => $new, 'old' => $old]);
|
||||
}
|
||||
|
||||
private function key(OAuthToken $token): array
|
||||
{
|
||||
return ['sid' => $token->sourceId, 'owner' => $token->owner, 'user' => $token->username];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Model\CustomParams;
|
||||
use Mailcow\Imapsync\Model\Job;
|
||||
use Mailcow\Imapsync\Model\Settings;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Repository\JobRepository;
|
||||
use Mailcow\Imapsync\Repository\SettingsRepository;
|
||||
use Mailcow\Imapsync\Repository\TokenRepository;
|
||||
|
||||
/**
|
||||
* Use cases and rules for sync jobs and the global sync settings.
|
||||
*
|
||||
* A job belongs to its local mailbox (user2): whoever may access that mailbox may see and manage it
|
||||
* (admins also orphaned jobs). Writing needs the `syncjobs` ACL. Jobs on an authorization_code source
|
||||
* need the per-user token of (source, owner = user2, username = user1) first (C1).
|
||||
*/
|
||||
class JobService
|
||||
{
|
||||
private const FLAGS = ['active', 'delete2duplicates', 'subscribeall', 'dry', 'delete1', 'delete2', 'automap', 'skipcrossduplicates'];
|
||||
|
||||
public function __construct(private JobRepository $jobs, private SourceService $sources, private TokenRepository $tokens,
|
||||
private SettingsRepository $settings, private Context $context, private array $allowlist)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws AccessDenied without the syncjobs ACL */
|
||||
public function requireAcl(): void
|
||||
{
|
||||
if (!$this->context->hasAcl('syncjobs')) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
/** The allowed custom params (option => rule), for the job form */
|
||||
public function customParamOptions(): array
|
||||
{
|
||||
return $this->allowlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* The job when the caller may access its mailbox (incl. password1: never pass the object to the UI, use toArray()).
|
||||
* Admins may also manage orphaned jobs whose local mailbox no longer exists.
|
||||
*/
|
||||
public function get(int $id, bool $withLog = true): ?Job
|
||||
{
|
||||
$job = $this->jobs->find($id, $withLog);
|
||||
return ($job !== null && ($this->context->isAdmin() || $this->context->canAccessMailbox($job->user2))) ? $job : null;
|
||||
}
|
||||
|
||||
/** @return int[] job ids of a mailbox (default: the caller's own) in queue order; [] without access */
|
||||
public function idsForMailbox(mixed $mailbox): array
|
||||
{
|
||||
if ($mailbox !== null && filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
|
||||
if (!$this->context->canAccessMailbox((string)$mailbox)) {
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
$mailbox = $this->context->username;
|
||||
}
|
||||
return ($mailbox === null) ? [] : $this->jobs->idsForMailbox((string)$mailbox);
|
||||
}
|
||||
|
||||
/** Only admins list all jobs at once (incl. orphaned ones) */
|
||||
public function canListAll(): bool
|
||||
{
|
||||
return $this->context->isAdmin();
|
||||
}
|
||||
|
||||
/** @return int[] all job ids in queue order; [] for non-admins */
|
||||
public function allIds(): array
|
||||
{
|
||||
return $this->canListAll() ? $this->jobs->allIds() : [];
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function create(array $input): Job
|
||||
{
|
||||
$this->requireAcl();
|
||||
$job = new Job();
|
||||
$job->user2 = $this->ownerMailbox($input);
|
||||
// Add keeps its legacy parsing of these attributes (e.g. maxage '1.5' means none, on edit 1); then the edit rules apply
|
||||
$input['source_id'] = (int)($input['source_id'] ?? 0);
|
||||
$input['maxage'] = Input::intInRange($input['maxage'] ?? null, 1, 32000) ?? 0;
|
||||
$input['maxbytespersecond'] = Input::intInRange($input['maxbytespersecond'] ?? null, 1, 125000000) ?? 0;
|
||||
$this->save($job, null, $input + ['custom_params' => '']);
|
||||
return $job;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function update(int $id, array $input): Job
|
||||
{
|
||||
$this->requireAcl();
|
||||
$current = $this->get($id, false) ?? throw new AccessDenied();
|
||||
$job = clone $current;
|
||||
$this->save($job, $current, $input);
|
||||
return $job;
|
||||
}
|
||||
|
||||
/** @throws UserError access_denied when the job does not exist or the caller may not access it */
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->requireAcl();
|
||||
$job = $this->get($id, false) ?? throw new AccessDenied();
|
||||
// prio is a sparse sort key (ties allowed), so no gap needs closing
|
||||
$this->jobs->delete($id);
|
||||
$this->tokens->deleteIfUnused($job->sourceId, $job->user2, $job->user1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin only (C4): moves jobs to the front of the queue. Running jobs are left alone; the runner never resets prio.
|
||||
*
|
||||
* @param int[] $ids
|
||||
* @return array{running: int[], moved: int}
|
||||
*/
|
||||
public function runNext(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique($ids));
|
||||
if (!$this->context->isAdmin() || empty($ids)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $this->jobs->moveToFront($ids);
|
||||
}
|
||||
|
||||
/** @throws AccessDenied for non-admins */
|
||||
public function getSettings(): Settings
|
||||
{
|
||||
if (!$this->context->isAdmin()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $this->settings->load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the global settings (admin only). The UI sends the bandwidth limit in KB/s; stored as bytes/s
|
||||
* like the per-job limit.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function saveSettings(array $input): Settings
|
||||
{
|
||||
if (!$this->context->isAdmin()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$maxParallel = Input::intInRange($input['max_parallel'] ?? null, 1, 32);
|
||||
$kb = $input['max_kb_per_second'] ?? 0;
|
||||
if ($maxParallel === null) {
|
||||
throw new UserError('imapsync_max_parallel_invalid');
|
||||
}
|
||||
// 0 (unlimited) .. 10 GiB/s
|
||||
$kb = Input::intInRange(($kb === '') ? 0 : $kb, 0, 10485760) ?? throw new UserError('imapsync_max_kb_per_second_invalid');
|
||||
$settings = new Settings();
|
||||
$settings->maxParallel = $maxParallel;
|
||||
$settings->maxBytesPerSecond = $kb * 1024;
|
||||
$this->settings->save($settings);
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an add ($current = null) or edit request to $job and stores it. Attributes missing in $input keep
|
||||
* the value of $job, an empty password keeps the stored one. On edit the source is only checked when the job
|
||||
* moves to another source, so a domain admin or user can still edit or deactivate a job bound to a source
|
||||
* he cannot see.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
private function save(Job $job, ?Job $current, array $input): void
|
||||
{
|
||||
foreach (self::FLAGS as $flag) {
|
||||
if (isset($input[$flag])) {
|
||||
$job->$flag = (int)$input[$flag];
|
||||
}
|
||||
}
|
||||
foreach (['maxage', 'timeout1', 'timeout2'] as $number) {
|
||||
if (isset($input[$number]) && $input[$number] != '') {
|
||||
$job->$number = (int)$input[$number];
|
||||
}
|
||||
}
|
||||
if (isset($input['maxbytespersecond']) && $input['maxbytespersecond'] != '') {
|
||||
$job->maxBytesPerSecond = (string)(int)$input['maxbytespersecond'];
|
||||
}
|
||||
// last_run/success are reset when present; prio is a global-queue concern that only admins may change
|
||||
$job->lastRun = isset($input['last_run']) ? null : $job->lastRun;
|
||||
$job->success = isset($input['success']) ? null : $job->success;
|
||||
if (isset($input['prio']) && is_numeric($input['prio']) && $this->context->isAdmin()) {
|
||||
$job->prio = (int)$input['prio'];
|
||||
}
|
||||
// An empty user1 keeps the stored one on edit; a new job needs one
|
||||
$user1 = (isset($input['user1']) && Input::text($input['user1']) !== '') ? Input::text($input['user1']) : $job->user1;
|
||||
if ($user1 === null || mb_strlen($user1) > 255 || ($current === null && $user1 === '')) {
|
||||
throw new UserError('imapsync_user1_invalid');
|
||||
}
|
||||
$password = (isset($input['password1']) && is_scalar($input['password1']) && (string)$input['password1'] !== '')
|
||||
? (string)$input['password1'] : $job->password1;
|
||||
$sourceId = (!empty($input['source_id']) && is_scalar($input['source_id'])) ? (int)$input['source_id'] : $job->sourceId;
|
||||
$sourceChanged = ($current === null || $sourceId !== $current->sourceId);
|
||||
$tupleChanged = $sourceChanged || $user1 !== $current->user1;
|
||||
$job->user1 = $user1;
|
||||
$job->sourceId = $sourceId;
|
||||
|
||||
$source = $sourceChanged ? $this->sources->get($sourceId) : $current->source;
|
||||
if ($source === null || ($sourceChanged && $source->active !== 1)) {
|
||||
throw new UserError('imapsync_source_unavailable');
|
||||
}
|
||||
if ($source->usesClientCredentials() && $tupleChanged && !$sourceChanged && !$this->mayUse($source)) {
|
||||
// The app token reaches every mailbox of the tenant, so only callers that may use the source
|
||||
// may pick another remote mailbox (user1). Other fields stay editable.
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$job->password1 = $this->checkAuth($source, $job, $password, $tupleChanged);
|
||||
if (array_key_exists('custom_params', $input)) {
|
||||
$job->customParams = CustomParams::toJson($input['custom_params'], $this->allowlist);
|
||||
}
|
||||
$job->maxage = Input::intInRange($job->maxage, 1, 32000) ?? 0;
|
||||
$job->timeout1 = Input::intInRange($job->timeout1, 1, 32000) ?? 600;
|
||||
$job->timeout2 = Input::intInRange($job->timeout2, 1, 32000) ?? 600;
|
||||
$job->maxBytesPerSecond = (string)(Input::intInRange($job->maxBytesPerSecond, 1, 125000000) ?? 0);
|
||||
$exclude = isset($input['exclude']) ? Input::text($input['exclude']) : $job->exclude;
|
||||
$subfolder2 = isset($input['subfolder2']) ? Input::text($input['subfolder2']) : $job->subfolder2;
|
||||
$minsInterval = Input::intInRange(!empty($input['mins_interval']) ? $input['mins_interval'] : $job->minsInterval, 1, 43800);
|
||||
// exclude must be a valid regex, the interval 1..43800 minutes
|
||||
if ($exclude === null || $subfolder2 === null || $minsInterval === null || @preg_match("/" . $exclude . "/", '') === false) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$job->exclude = $exclude;
|
||||
$job->subfolder2 = $subfolder2;
|
||||
$job->minsInterval = $minsInterval;
|
||||
if ($tupleChanged && $this->jobs->tupleExists($job->sourceId, $job->user2, $job->user1, $current?->id)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($source->name . ' / ' . $job->user1)]);
|
||||
}
|
||||
if ($current === null) {
|
||||
$this->jobs->insert($job);
|
||||
return;
|
||||
}
|
||||
$this->jobs->update($job);
|
||||
// The previous (source, owner, remote user) token is dropped once no job uses it (C1)
|
||||
if ($tupleChanged) {
|
||||
$this->tokens->deleteIfUnused($current->sourceId, $current->user2, $current->user1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the caller may use the source for new jobs (sees it or may edit it) */
|
||||
private function mayUse(Source $source): bool
|
||||
{
|
||||
return $this->sources->get((int)$source->id) !== null || $this->sources->canEdit($source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local mailbox of a new job: `username` (needs access), else the caller's own mailbox (users only).
|
||||
*
|
||||
* @throws UserError access_denied, no_user_defined
|
||||
*/
|
||||
private function ownerMailbox(array $input): string
|
||||
{
|
||||
$username = $input['username'] ?? null;
|
||||
if (is_string($username) && filter_var($username, FILTER_VALIDATE_EMAIL)) {
|
||||
return $this->context->canAccessMailbox($username) ? $username : throw new AccessDenied();
|
||||
}
|
||||
return $this->context->isUser() ? (string)$this->context->username : throw new UserError('no_user_defined');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the password to store. XOAUTH2 sources carry no password (the runner uses an access token);
|
||||
* an authorization_code source needs the per-user token of (source, owner, remote user) when it is new.
|
||||
*
|
||||
* @throws UserError imapsync_source_oauth_connect_required, password_empty
|
||||
*/
|
||||
private function checkAuth(Source $source, Job $job, string $password, bool $tupleChanged): string
|
||||
{
|
||||
if (!$source->isXOAuth2()) {
|
||||
return ($password !== '') ? $password : throw new UserError('password_empty');
|
||||
}
|
||||
if ($source->usesAuthorizationCode() && $tupleChanged && !$this->tokens->exists($job->sourceId, $job->user2, $job->user1)) {
|
||||
throw new UserError('imapsync_source_oauth_connect_required');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Service;
|
||||
|
||||
use Exception;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Http\OAuthClient;
|
||||
use Mailcow\Imapsync\Http\OAuthException;
|
||||
use Mailcow\Imapsync\Model\OAuthToken;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Repository\SourceRepository;
|
||||
use Mailcow\Imapsync\Repository\TokenRepository;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* OAuth2 for XOAUTH2 sources: their OAuth settings, token requests for every grant type, the browser sign-in
|
||||
* (authorization_code) and token refresh. Needs no Context, so the refresh cron can use it; callers check
|
||||
* permissions first.
|
||||
*/
|
||||
class OAuthService
|
||||
{
|
||||
/** Tokens expiring within this many seconds are refreshed (the runner starts jobs with >= 15 min left) */
|
||||
private const REFRESH_AHEAD = 1200;
|
||||
/** A token whose last refresh failed is retried after this many seconds */
|
||||
private const RETRY_AFTER = 180;
|
||||
/** Extra params that belong to the browser sign-in; all others go to the token request */
|
||||
private const AUTHORIZE_PARAMS = ['prompt', 'access_type', 'login_hint', 'domain_hint', 'hd'];
|
||||
/** Per-user tokens that no job uses are kept this long (time to save the job after signing in) */
|
||||
private const UNUSED_TOKEN_GRACE = 86400;
|
||||
|
||||
private string $hostname;
|
||||
|
||||
public function __construct(private SourceRepository $sources, private TokenRepository $tokens, private OAuthClient $client,
|
||||
string $hostname)
|
||||
{
|
||||
$this->hostname = strtolower(trim($hostname));
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect URI to register at the provider. Only MAILCOW_HOSTNAME is supported: the popup callback
|
||||
* needs the session cookie and opener origin of the UI, so the UI must be used on that host.
|
||||
*/
|
||||
public function redirectUri(): string
|
||||
{
|
||||
return ($this->hostname === '') ? '' : 'https://' . $this->hostname . '/syncjob-oauth';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the OAuth settings of a source from request attributes: cleared for password sources, read and
|
||||
* validated for XOAUTH2 (incl. the endpoint policy C5). Missing attributes keep the value of $source,
|
||||
* an empty client secret keeps the stored one. Permission checks are the caller's job.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function applySettings(Source $source, array $input): void
|
||||
{
|
||||
if (!$source->isXOAuth2()) {
|
||||
$source->oauthFlow = Source::FLOW_CLIENT_CREDENTIALS;
|
||||
$source->tokenEndpoint = $source->authorizeEndpoint = $source->userinfoEndpoint = null;
|
||||
$source->clientId = $source->clientSecret = $source->oauthScope = $source->extraParams = null;
|
||||
return;
|
||||
}
|
||||
$source->oauthFlow = (($input['oauth_flow'] ?? $source->oauthFlow) === Source::FLOW_AUTHORIZATION_CODE)
|
||||
? Source::FLOW_AUTHORIZATION_CODE : Source::FLOW_CLIENT_CREDENTIALS;
|
||||
$source->tokenEndpoint = $this->oauthText($input, 'oauth_token_endpoint', $source->tokenEndpoint);
|
||||
$authorizeEndpoint = $this->oauthText($input, 'oauth_authorize_endpoint', $source->authorizeEndpoint);
|
||||
$userinfoEndpoint = $this->oauthText($input, 'oauth_userinfo_endpoint', $source->userinfoEndpoint);
|
||||
$source->clientId = $this->oauthText($input, 'oauth_client_id', $source->clientId);
|
||||
$source->oauthScope = $this->oauthText($input, 'oauth_scope', $source->oauthScope);
|
||||
$secret = $input['oauth_client_secret'] ?? '';
|
||||
if (!is_scalar($secret)) {
|
||||
throw new UserError('imapsync_source_oauth_invalid');
|
||||
}
|
||||
$source->clientSecret = ((string)$secret !== '') ? (string)$secret : (string)$source->clientSecret;
|
||||
$source->extraParams = $this->extraParams(array_key_exists('oauth_extra_params', $input) ? $input['oauth_extra_params'] : $source->extraParams);
|
||||
|
||||
if ($source->tokenEndpoint === '' || $source->clientId === '' || $source->clientSecret === '' || $source->oauthScope === '') {
|
||||
throw new UserError('imapsync_source_oauth_required');
|
||||
}
|
||||
if (mb_strlen($source->clientId) > 500 || mb_strlen($source->clientSecret) > 500 || mb_strlen($source->oauthScope) > 500) {
|
||||
throw new UserError('imapsync_source_oauth_invalid');
|
||||
}
|
||||
// C5, checked again before every request. The authorize endpoint is only opened in the browser,
|
||||
// but must not point users to internal hosts either.
|
||||
$endpoints = [$source->tokenEndpoint];
|
||||
if ($source->usesAuthorizationCode()) {
|
||||
$endpoints[] = $authorizeEndpoint;
|
||||
}
|
||||
if ($userinfoEndpoint !== '') {
|
||||
$endpoints[] = $userinfoEndpoint;
|
||||
}
|
||||
foreach ($endpoints as $endpoint) {
|
||||
if (!$this->endpointAllowed($endpoint, $source->createdBy)) {
|
||||
throw new UserError('imapsync_source_oauth_endpoint_invalid');
|
||||
}
|
||||
}
|
||||
$source->authorizeEndpoint = ($authorizeEndpoint === '') ? null : $authorizeEndpoint;
|
||||
$source->userinfoEndpoint = ($userinfoEndpoint === '') ? null : $userinfoEndpoint;
|
||||
}
|
||||
|
||||
/** Provider consent URL for the browser sign-in, or null when the authorize endpoint is not allowed */
|
||||
public function authorizeUrl(Source $source, string $state): ?string
|
||||
{
|
||||
$endpoint = (string)$source->authorizeEndpoint;
|
||||
if (!$this->endpointAllowed($endpoint, $source->createdBy)) {
|
||||
return null;
|
||||
}
|
||||
// select_account: never sign in silently with the account the browser already uses (often the admin's)
|
||||
$params = array_merge(['prompt' => 'select_account'], $this->extraParamsFor($source, true), ['client_id' => $source->clientId,
|
||||
'response_type' => 'code', 'redirect_uri' => $this->redirectUri(), 'response_mode' => 'query',
|
||||
'scope' => $source->oauthScope, 'state' => $state]);
|
||||
return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishes the browser sign-in: exchanges the code, finds the remote identity and stores the token
|
||||
* for the local mailbox $owner (C1). Returns the remote identity (the job's user1).
|
||||
*
|
||||
* @throws UserError token_failed (args: detail for the server log) or no_identity
|
||||
*/
|
||||
public function connectUser(Source $source, string $owner, string $code): string
|
||||
{
|
||||
try {
|
||||
$token = $this->requestToken($source, ['grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->redirectUri()]);
|
||||
} catch (OAuthException $e) {
|
||||
throw new UserError('token_failed', ['token exchange failed for source ' . $source->id . ': ' . $e->getMessage()]);
|
||||
}
|
||||
$identity = $this->identity($source, $token);
|
||||
if ($identity === null) {
|
||||
throw new UserError('no_identity');
|
||||
}
|
||||
try {
|
||||
$this->tokens->saveUserToken(OAuthToken::fromRow(['source_id' => $source->id, 'owner' => $owner, 'username' => $identity,
|
||||
'access_token' => $token['access_token'], 'refresh_token' => $token['refresh_token'] ?? '',
|
||||
'token_expires' => time() + (int)$token['expires_in']]));
|
||||
} catch (PDOException $e) {
|
||||
throw new UserError('token_failed', ['storing token failed for source ' . $source->id . ': ' . $e->getMessage()]);
|
||||
}
|
||||
return $identity;
|
||||
}
|
||||
|
||||
/** Fetches and stores a new shared token of a client_credentials source (or stores the error); returns the error or null */
|
||||
public function refreshSourceToken(Source $source): ?string
|
||||
{
|
||||
if (!$source->usesClientCredentials()) {
|
||||
return 'not refreshable';
|
||||
}
|
||||
try {
|
||||
$token = $this->requestToken($source, ['grant_type' => 'client_credentials']);
|
||||
} catch (OAuthException $e) {
|
||||
$this->tokens->storeSourceError((int)$source->id, $e->getMessage());
|
||||
return $e->getMessage();
|
||||
}
|
||||
$this->tokens->storeSourceToken((int)$source->id, $token['access_token'], time() + (int)$token['expires_in']);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Refreshes and stores a per-user token via its refresh token (or stores the error); returns the error or null */
|
||||
public function refreshUserToken(Source $source, OAuthToken $token): ?string
|
||||
{
|
||||
if (!$source->usesAuthorizationCode() || (string)$token->refreshToken === '') {
|
||||
return 'not refreshable';
|
||||
}
|
||||
try {
|
||||
$new = $this->requestToken($source, ['grant_type' => 'refresh_token', 'refresh_token' => $token->refreshToken]);
|
||||
} catch (OAuthException $e) {
|
||||
$this->tokens->storeUserError($token, $e->getMessage());
|
||||
return $e->getMessage();
|
||||
}
|
||||
$token->accessToken = $new['access_token'];
|
||||
// Some providers omit a new refresh token on refresh: keep the existing one then
|
||||
$token->refreshToken = empty($new['refresh_token']) ? $token->refreshToken : $new['refresh_token'];
|
||||
$token->tokenExpires = time() + (int)$new['expires_in'];
|
||||
$this->tokens->storeUserRefresh($token);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron run: refreshes due source tokens, drops unused per-user tokens, refreshes due per-user tokens.
|
||||
*
|
||||
* @return array<int, array{priority: string, message: string}> log entries for CRON_LOG
|
||||
*/
|
||||
public function refreshDue(): array
|
||||
{
|
||||
$log = [];
|
||||
foreach ($this->tokens->refreshableSources() as $source) {
|
||||
if ($this->isDue($source->tokenExpires, $source->lastRefreshError, $source->refreshAttemptAge)) {
|
||||
try {
|
||||
$error = $this->refreshSourceToken($source);
|
||||
} catch (Exception $e) {
|
||||
$error = get_class($e);
|
||||
}
|
||||
$label = "source '" . $source->name . "' (id=" . $source->id . ")";
|
||||
$log[] = $this->logEntry($error, "Refreshed token for " . $label, "Refresh failed for " . $label);
|
||||
}
|
||||
}
|
||||
$removed = $this->tokens->deleteUnusedOlderThan(self::UNUSED_TOKEN_GRACE);
|
||||
if ($removed > 0) {
|
||||
$log[] = ['priority' => 'info', 'message' => "Removed " . $removed . " unused user token(s)"];
|
||||
}
|
||||
foreach ($this->tokens->refreshableUserTokens() as $token) {
|
||||
if ($this->isDue($token->tokenExpires, $token->lastRefreshError, $token->refreshAttemptAge)) {
|
||||
$source = null;
|
||||
try {
|
||||
$source = $this->sources->find($token->sourceId);
|
||||
$error = ($source === null) ? 'not refreshable' : $this->refreshUserToken($source, $token);
|
||||
} catch (Exception $e) {
|
||||
$error = get_class($e);
|
||||
}
|
||||
$label = "user token '" . $token->username . "' (mailbox " . $token->owner . ") on source '"
|
||||
. ($source->name ?? '') . "' (id=" . $token->sourceId . ")";
|
||||
$log[] = $this->logEntry($error, "Refreshed " . $label, "Refresh failed for " . $label);
|
||||
}
|
||||
}
|
||||
return $log;
|
||||
}
|
||||
|
||||
/** Due when the token expires soon (or is missing) and the last failed attempt is old enough */
|
||||
private function isDue(?int $expires, ?string $lastError, ?int $attemptAge): bool
|
||||
{
|
||||
return ($expires === null || $expires < time() + self::REFRESH_AHEAD)
|
||||
&& ($lastError === null || $attemptAge === null || $attemptAge > self::RETRY_AFTER);
|
||||
}
|
||||
|
||||
private function logEntry(?string $error, string $success, string $failure): array
|
||||
{
|
||||
return ($error === null) ? ['priority' => 'info', 'message' => $success]
|
||||
: ['priority' => 'warning', 'message' => $failure . ": " . $error];
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint policy (C5). Endpoints of sources not created by an admin must resolve to public addresses;
|
||||
* admin sources have no address restriction (API key sessions run as admin under the reserved name 'API').
|
||||
*/
|
||||
private function endpointAllowed(string $url, string $createdBy): bool
|
||||
{
|
||||
return $this->client->isAllowed($url, $this->isStrict($createdBy));
|
||||
}
|
||||
|
||||
private function isStrict(string $createdBy): bool
|
||||
{
|
||||
return !($createdBy === 'API' || $this->sources->isSuperAdmin($createdBy));
|
||||
}
|
||||
|
||||
/** One OAuth text attribute from the request, or the stored value when the attribute is missing */
|
||||
private function oauthText(array $input, string $key, ?string $stored): string
|
||||
{
|
||||
$value = array_key_exists($key, $input) ? Input::text($input[$key]) : (string)$stored;
|
||||
return $value ?? throw new UserError('imapsync_source_oauth_invalid');
|
||||
}
|
||||
|
||||
/** Stored extra params of the browser sign-in ($authorize) or of the token request */
|
||||
private function extraParamsFor(Source $source, bool $authorize): array
|
||||
{
|
||||
$extra = json_decode((string)$source->extraParams, true);
|
||||
return array_filter(is_array($extra) ? $extra : [],
|
||||
fn($key): bool => in_array($key, self::AUTHORIZE_PARAMS, true) === $authorize, ARRAY_FILTER_USE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra parameters: a JSON object with scalar values, as string or as array (after 011-api.js
|
||||
* auto-parsing). Returns the JSON to store, null for none.
|
||||
*/
|
||||
private function extraParams(mixed $extra): ?string
|
||||
{
|
||||
if ($extra === null || (is_string($extra) && trim($extra) === '')) {
|
||||
$extra = [];
|
||||
} elseif (is_string($extra)) {
|
||||
$extra = json_decode($extra, true);
|
||||
}
|
||||
$bad = !is_array($extra);
|
||||
foreach ((array)$extra as $key => $value) {
|
||||
$bad = $bad || !is_string($key) || $key === '' || !(is_scalar($value) || $value === null);
|
||||
}
|
||||
$json = empty($extra) ? null : json_encode($extra, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($bad || strlen((string)$json) > 4000) {
|
||||
throw new UserError('imapsync_source_oauth_extra_invalid');
|
||||
}
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token endpoint request for every grant type. Stored extra params never override the client and grant fields.
|
||||
*
|
||||
* @throws OAuthException
|
||||
*/
|
||||
private function requestToken(Source $source, array $grant): array
|
||||
{
|
||||
$post = array_merge($this->extraParamsFor($source, false),
|
||||
['client_id' => $source->clientId, 'client_secret' => $source->clientSecret, 'scope' => $source->oauthScope], $grant);
|
||||
$token = $this->client->request((string)$source->tokenEndpoint, $this->isStrict($source->createdBy), $post);
|
||||
if (empty($token['access_token']) || !is_string($token['access_token'])) {
|
||||
throw new OAuthException('malformed token response');
|
||||
}
|
||||
if (!isset($token['expires_in']) || !is_numeric($token['expires_in'])) {
|
||||
$token['expires_in'] = 3600;
|
||||
}
|
||||
if (isset($token['refresh_token']) && !is_string($token['refresh_token'])) {
|
||||
unset($token['refresh_token']);
|
||||
}
|
||||
return $token;
|
||||
}
|
||||
|
||||
/** Remote identity: id_token claims (received directly from the token endpoint over TLS), else the userinfo endpoint */
|
||||
private function identity(Source $source, array $token): ?string
|
||||
{
|
||||
$parts = explode('.', is_string($token['id_token'] ?? null) ? $token['id_token'] : '');
|
||||
$email = (count($parts) === 3) ? $this->emailClaim(json_decode((string)base64_decode(strtr($parts[1], '-_', '+/')), true)) : null;
|
||||
if ($email !== null || empty($source->userinfoEndpoint)) {
|
||||
return $email;
|
||||
}
|
||||
try {
|
||||
return $this->emailClaim($this->client->request($source->userinfoEndpoint, $this->isStrict($source->createdBy), null,
|
||||
['Authorization: Bearer ' . $token['access_token']]));
|
||||
} catch (OAuthException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function emailClaim(mixed $claims): ?string
|
||||
{
|
||||
foreach (['email', 'preferred_username', 'upn', 'unique_name'] as $key) {
|
||||
if (is_array($claims) && !empty($claims[$key]) && is_string($claims[$key]) && filter_var($claims[$key], FILTER_VALIDATE_EMAIL)) {
|
||||
return strtolower($claims[$key]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Repository\SourceRepository;
|
||||
use Mailcow\Imapsync\Repository\TokenRepository;
|
||||
|
||||
/**
|
||||
* Use cases and rules for sync sources.
|
||||
*
|
||||
* Read: admin all; domain admin own + 'all' + sources targeting his domains/mailboxes + sources created by
|
||||
* his mailboxes; user own + 'all' + sources targeting him or his domain (see SourceRepository).
|
||||
* Edit: admin all; creator own; domain admin sources created by his mailboxes.
|
||||
* XOAUTH2 sources are created and edited by admins and domain admins only (users may use them).
|
||||
* Users and domain admins need the `syncjobs` ACL for everything.
|
||||
*/
|
||||
class SourceService
|
||||
{
|
||||
private const ENCRYPTIONS = ['TLS', 'SSL', 'PLAIN'];
|
||||
private const AUTH_TYPES = ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2'];
|
||||
private const SCOPES = ['all', 'domain', 'user'];
|
||||
|
||||
public function __construct(private SourceRepository $repo, private TokenRepository $tokens, private OAuthService $oauth,
|
||||
private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws AccessDenied without the syncjobs ACL */
|
||||
public function requireAcl(): void
|
||||
{
|
||||
if (!$this->context->hasAcl('syncjobs')) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
/** Add/edit: a logged-in admin, domain admin or user with the syncjobs ACL */
|
||||
public function requireEditor(): void
|
||||
{
|
||||
if (!($this->context->isAdmin() || $this->context->isDomainAdmin() || $this->context->isUser())) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$this->requireAcl();
|
||||
}
|
||||
|
||||
/**
|
||||
* The source when the caller may see it (incl. secrets: never pass the object to the UI, use toArray()).
|
||||
* Users and domain admins without the syncjobs ACL see no sources.
|
||||
*/
|
||||
public function get(int $id): ?Source
|
||||
{
|
||||
return $this->canRead() ? $this->repo->findVisible($id, $this->context) : null;
|
||||
}
|
||||
|
||||
/** @return int[] ids of the sources the caller may see, ordered by name */
|
||||
public function visibleIds(): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->visibleIds($this->context) : [];
|
||||
}
|
||||
|
||||
public function canEdit(Source $source): bool
|
||||
{
|
||||
if ($this->context->hasAdminRights() || $source->createdBy === $this->context->username) {
|
||||
return true;
|
||||
}
|
||||
// A domain admin may manage sources created by mailboxes of his domains
|
||||
return $this->context->isDomainAdmin() && $source->createdBy !== '' && $this->context->canAccessMailbox($source->createdBy);
|
||||
}
|
||||
|
||||
/** API representation for the caller (can_edit, extra params only for editors, redirect URI) */
|
||||
public function toArray(Source $source): array
|
||||
{
|
||||
return $source->toArray($this->canEdit($source), $this->oauth->redirectUri());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a source from request attributes (C9 names; host1/port1/enc1 are accepted as aliases).
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function create(array $input): Source
|
||||
{
|
||||
$this->requireEditor();
|
||||
$source = new Source();
|
||||
$source->createdBy = (string)$this->context->username;
|
||||
$this->applyBasics($source, $input);
|
||||
$this->applyOAuth($source, $input);
|
||||
// Scope and targets are validated before anything is written
|
||||
$this->applyScope($source, $input['scope'] ?? 'user', $input['domains'] ?? [], $input['users'] ?? []);
|
||||
if ($this->repo->nameExists($source->name, $source->createdBy)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
|
||||
}
|
||||
$this->repo->insert($source);
|
||||
$this->fetchToken($source);
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a source; attributes missing in $input keep their value.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function update(int $id, array $input): Source
|
||||
{
|
||||
$this->requireEditor();
|
||||
$current = $this->editable($id);
|
||||
$source = clone $current;
|
||||
$this->applyBasics($source, $input);
|
||||
if ($source->name !== $current->name && $this->repo->nameExists($source->name, $source->createdBy, $id)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
|
||||
}
|
||||
$this->applyOAuth($source, $input);
|
||||
// Targets only change when the request carries `scope`; client_credentials sources are always private
|
||||
$writeTargets = isset($input['scope']) || $source->usesClientCredentials();
|
||||
if ($writeTargets) {
|
||||
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
|
||||
}
|
||||
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source));
|
||||
$this->fetchToken($source);
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an unused source and returns it.
|
||||
*
|
||||
* @throws UserError access_denied, imapsync_source_in_use
|
||||
*/
|
||||
public function delete(int $id): Source
|
||||
{
|
||||
$this->requireAcl();
|
||||
$source = $this->editable($id);
|
||||
// FK ON DELETE RESTRICT prevents removal while sync jobs reference it
|
||||
if ($this->repo->isInUse($id)) {
|
||||
throw new UserError('imapsync_source_in_use', [$source->name]);
|
||||
}
|
||||
$this->repo->delete($id);
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a new shared token for a client_credentials source now.
|
||||
*
|
||||
* @throws UserError access_denied, imapsync_source_refresh_unsupported, imapsync_source_token_refresh_failed
|
||||
*/
|
||||
public function refreshToken(int $id): Source
|
||||
{
|
||||
$this->requireAcl();
|
||||
$source = $this->editable($id);
|
||||
if (!$source->usesClientCredentials()) {
|
||||
throw new UserError('imapsync_source_refresh_unsupported');
|
||||
}
|
||||
$error = $this->oauth->refreshSourceToken($source);
|
||||
if ($error !== null) {
|
||||
throw new UserError('imapsync_source_token_refresh_failed', [$source->name . ' (' . $error . ')']);
|
||||
}
|
||||
return $source;
|
||||
}
|
||||
|
||||
/** A visible, active authorization_code source the caller may sign in to (OAuth popup), else null */
|
||||
public function findConnectable(int $id): ?Source
|
||||
{
|
||||
$source = $this->get($id);
|
||||
return ($source !== null && $source->active === 1 && $source->usesAuthorizationCode()) ? $source : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account rename (mailbox, admin, domain admin): sources, user targets and token owners follow.
|
||||
* Leftover sources of an earlier account named $new are released first (see releaseSourcesOf()).
|
||||
* Runs on the caller's PDO; a PDOException is not caught, so a caller transaction can roll back.
|
||||
*/
|
||||
public function onAccountRenamed(string $old, string $new): void
|
||||
{
|
||||
if ($old === '' || $old === $new) {
|
||||
return;
|
||||
}
|
||||
$this->releaseSourcesOf($new);
|
||||
$this->repo->renameAccount($old, $new);
|
||||
$this->tokens->renameOwner($old, $new);
|
||||
}
|
||||
|
||||
/** Account deleted (after its jobs were deleted): sources released, tokens and user targets removed */
|
||||
public function onAccountDeleted(string $old): void
|
||||
{
|
||||
if ($old === '') {
|
||||
return;
|
||||
}
|
||||
$this->releaseSourcesOf($old);
|
||||
$this->tokens->deleteByOwner($old);
|
||||
$this->repo->deleteUserTarget($old);
|
||||
}
|
||||
|
||||
private function canRead(): bool
|
||||
{
|
||||
return $this->context->hasAcl('syncjobs') || $this->context->isAdmin();
|
||||
}
|
||||
|
||||
/** @throws AccessDenied when the caller cannot see or edit the source */
|
||||
private function editable(int $id): Source
|
||||
{
|
||||
$source = $this->get($id);
|
||||
if ($source === null || !$this->canEdit($source)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sources of an account are deleted when unused, otherwise handed over to the admins
|
||||
* (created_by ''), so a re-created account inherits nothing.
|
||||
*/
|
||||
private function releaseSourcesOf(string $username): void
|
||||
{
|
||||
foreach ($this->repo->listCreatedBy($username) as $source) {
|
||||
$id = (int)$source->id;
|
||||
if (!$this->repo->isInUse($id)) {
|
||||
// Explicit child deletes: the mailbox rename runs with FOREIGN_KEY_CHECKS = 0 (no cascade)
|
||||
$this->repo->deleteWithChildren($id);
|
||||
continue;
|
||||
}
|
||||
// Keep (created_by, name) unique when handing over
|
||||
$name = $this->repo->nameExists($source->name, '') ? mb_substr($source->name, 0, 80) . ' (#' . $id . ')' : $source->name;
|
||||
$this->repo->handOver($id, $name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name, description, connection and active from the request; missing attributes keep the value of $source.
|
||||
* On edit, stored host/port/enc that do not change are not validated again, so migrated legacy sources
|
||||
* stay editable (e.g. host 'imap_server.lan'). A host that only differs in case keeps the stored spelling.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
private function applyBasics(Source $source, array $input): void
|
||||
{
|
||||
$stored = ($source->id !== null);
|
||||
$name = isset($input['name']) ? Input::text($input['name']) : $source->name;
|
||||
$description = isset($input['description']) ? Input::text($input['description']) : $source->description;
|
||||
$host = $input['host'] ?? $input['host1'] ?? null;
|
||||
$host = ($host === null) ? $source->host : Input::text($host);
|
||||
if ($host !== null) {
|
||||
$host = (strtolower($host) === strtolower($source->host)) ? $source->host : strtolower($host);
|
||||
}
|
||||
$port = $input['port'] ?? $input['port1'] ?? $source->port;
|
||||
$port = is_scalar($port) ? (int)$port : 0;
|
||||
$enc = $input['enc'] ?? $input['enc1'] ?? $source->enc;
|
||||
$authType = $input['auth_type'] ?? $source->authType;
|
||||
|
||||
if ($name === null || $name === '' || mb_strlen($name) > 100) {
|
||||
throw new UserError('imapsync_source_name_invalid');
|
||||
}
|
||||
if ($description === null || mb_strlen($description) > 255) {
|
||||
throw new UserError('imapsync_source_description_invalid');
|
||||
}
|
||||
if (!($stored && $host === $source->host) && !$this->isValidHost($host)) {
|
||||
throw new UserError('imapsync_source_host_invalid');
|
||||
}
|
||||
if (!($stored && $port === $source->port) && ($port < 1 || $port > 65535)) {
|
||||
throw new UserError('imapsync_source_port_invalid');
|
||||
}
|
||||
if (!($stored && $enc === $source->enc) && !in_array($enc, self::ENCRYPTIONS, true)) {
|
||||
throw new UserError('imapsync_source_enc_invalid');
|
||||
}
|
||||
if (!in_array($authType, self::AUTH_TYPES, true)) {
|
||||
throw new UserError('imapsync_source_auth_invalid');
|
||||
}
|
||||
$source->name = $name;
|
||||
$source->description = $description;
|
||||
$source->host = $host;
|
||||
$source->port = $port;
|
||||
$source->enc = $enc;
|
||||
$source->authType = $authType;
|
||||
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
|
||||
}
|
||||
|
||||
private function isValidHost(?string $host): bool
|
||||
{
|
||||
return $host !== null && $host !== '' && strlen($host) <= 253
|
||||
&& (filter_var($host, FILTER_VALIDATE_IP) || filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME));
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function applyOAuth(Source $source, array $input): void
|
||||
{
|
||||
// XOAUTH2 sources are managed by admins and domain admins only
|
||||
if ($source->isXOAuth2() && $this->context->isUser()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$this->oauth->applySettings($source, $input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates scope and targets for the caller and sets them; nothing is written here.
|
||||
* Users' sources and client_credentials sources (one app-wide token that reaches every mailbox) are always private.
|
||||
*
|
||||
* @throws UserError access_denied, imapsync_source_scope_empty
|
||||
*/
|
||||
private function applyScope(Source $source, mixed $scope, mixed $domains, mixed $users): void
|
||||
{
|
||||
if ($source->usesClientCredentials() || $this->context->isUser()) {
|
||||
[$source->scope, $source->domains, $source->users] = ['user', [], []];
|
||||
return;
|
||||
}
|
||||
// Only admins may create globally visible sources
|
||||
if (!is_string($scope) || !in_array($scope, self::SCOPES, true) || ($scope === 'all' && !$this->context->isAdmin())) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$domains = ($scope === 'domain') ? $this->cleanList($domains) : [];
|
||||
$users = ($scope === 'user') ? $this->cleanList($users) : [];
|
||||
if ($scope === 'domain' && empty($domains)) {
|
||||
throw new UserError('imapsync_source_scope_empty');
|
||||
}
|
||||
foreach ($domains as $domain) {
|
||||
if (!$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
foreach ($users as $user) {
|
||||
if (!$this->context->canAccessMailbox($user)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
[$source->scope, $source->domains, $source->users] = [$scope, $domains, $users];
|
||||
}
|
||||
|
||||
/** @return string[] trimmed, lowercase, unique, non-empty scalar values */
|
||||
private function cleanList(mixed $list): array
|
||||
{
|
||||
$clean = [];
|
||||
foreach ((array)$list as $value) {
|
||||
if (is_scalar($value) && trim((string)$value) !== '') {
|
||||
$clean[] = strtolower(trim((string)$value));
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($clean));
|
||||
}
|
||||
|
||||
/** A saved, active client_credentials source gets its token now instead of at the next cron run (error in lastRefreshError) */
|
||||
private function fetchToken(Source $source): void
|
||||
{
|
||||
if ($source->usesClientCredentials() && $source->active === 1) {
|
||||
$source->lastRefreshError = $this->oauth->refreshSourceToken($source);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokens never follow a source to another OAuth client or IMAP server, so these values reset them */
|
||||
private static function connection(Source $source): array
|
||||
{
|
||||
return [(string)$source->authType, $source->host, $source->port, (string)$source->enc, $source->oauthFlow,
|
||||
(string)$source->tokenEndpoint, (string)$source->clientId];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# mailcow PHP classes (`Mailcow\`)
|
||||
|
||||
New backend code of the mailcow UI/API lives here as plain PHP classes, one directory per module.
|
||||
It replaces the procedural `data/web/inc/functions.*.inc.php` files step by step. There is no framework,
|
||||
no DI container and no ORM: just small classes, constructor injection and PDO.
|
||||
|
||||
The directory sits below `inc/lib/`, which nginx never serves. Classes are loaded by Composer's PSR-4
|
||||
autoloader (`"Mailcow\\": "mailcow/"` in `inc/lib/composer.json`), which every entry point already
|
||||
includes (`prerequisites.inc.php`, crons, `mailcowauth.php`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Core/ shared by all modules
|
||||
Context.php who is calling: role, username, ACL, access-all; fromSession(), system()
|
||||
UserError.php expected error with a lang key (+ args), shown as a danger message
|
||||
AccessDenied.php UserError 'access_denied'
|
||||
ApiResponse.php collects messages, writes $_SESSION['return'] (the only class that does)
|
||||
Input.php helpers for loosely typed request values
|
||||
Repository.php base class of all repositories: PDO, query(), insertRow(), updateRow(), transaction()
|
||||
<Module>/
|
||||
<Module>Module.php composition root: create(PDO, Context, array $config) wires everything;
|
||||
fromGlobals() is the legacy entry point for procedural callers
|
||||
Model/ plain typed objects (fromRow(), toArray() for the API shape), no DB
|
||||
Repository/ SQL only (prepared statements); takes and returns models
|
||||
Service/ use cases, business rules, permissions (Context), transactions via repositories
|
||||
Http/ outbound I/O (e.g. OAuth requests)
|
||||
Controller/ request in, messages out: adapter for json_api.php and pages
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- **Model:** typed public properties, `fromRow()` from a DB row, `toArray()` for the API format (keys and
|
||||
order are the API contract). Secrets stay in the model but never in `toArray()`. No getters/setters.
|
||||
- **Repository:** extends `Core\Repository`; the only place with SQL (prepared statements via `query()`).
|
||||
No permission decisions (a visibility filter that is pure SQL may take the `Context`). A method that writes
|
||||
several tables wraps them in `transaction()`, which joins a transaction the caller already runs.
|
||||
- **Service:** one use case per public method, working on single objects. Checks permissions with the
|
||||
`Context` and throws `UserError` / `AccessDenied` with a lang key. No `$_SESSION`, no `global`, no output.
|
||||
When add and edit share their rules, use one private routine for both (e.g. `JobService::save()`,
|
||||
`SourceService::applyBasics()`): missing attributes keep the current value, a new object starts with defaults.
|
||||
- **Controller:** normalises request input (batch ids, attribute merging), loops over batch ids, calls
|
||||
services, turns `UserError` into messages via `ApiResponse` and returns what `json_api.php` passes to
|
||||
`process_*_return()`. The API format (routes, JSON, message keys and order) stays as it was. Each write
|
||||
method is a few lines on top of two private helpers: `handle()` (log entry, error mapping, flush) and
|
||||
`batch()` (one message per id).
|
||||
- **View:** Twig templates, unchanged.
|
||||
|
||||
## Conventions
|
||||
|
||||
- `declare(strict_types=1);` in every file; typed properties, parameters and return types; constructor
|
||||
property promotion; `public readonly` properties instead of getters for values that never change.
|
||||
- Class files contain only declarations (no code at file level), one class per file.
|
||||
- Constructor injection. Global state (`$pdo`, `$_SESSION`, config arrays, `getenv()`) is only read at the
|
||||
entry points and passed to `<Module>Module::create()`. Procedural legacy callers (`json_api.php`, pages,
|
||||
`functions.*.inc.php`) use `<Module>Module::fromGlobals(?Context $context = null)`, the one documented
|
||||
place in the module that reads the globals, instead of repeating the config lines; entry points that build
|
||||
their own state (crons, CLI) call `create()` directly, e.g. with `Context::system()`.
|
||||
`ApiResponse::flush()` is the one class that writes `$_SESSION['return']`.
|
||||
- No interfaces until there are two implementations; no closures as injected dependencies (local callbacks
|
||||
such as `transaction(fn ...)` or the controller's `handle()` are fine); small methods.
|
||||
- Docblocks only where they add information (rules, API contract, non-obvious behaviour), not to repeat
|
||||
the signature.
|
||||
- Classes are not `final`, so PHPUnit can mock them. I/O that tests must replace sits in protected methods
|
||||
(see `Imapsync/Http/OAuthClient::send()`).
|
||||
- Messages: services throw, controllers report. The `msg` is a lang key of the matching section
|
||||
(`success` / `danger`), optionally with arguments: `new UserError('object_exists', [$name])`.
|
||||
- Logs: the `log` part of a message is `[<legacy function>, <action>, <type>, <data>]` as before; request
|
||||
keys holding secrets are masked by `ApiResponse` (configure them in the module).
|
||||
- A database error (`PDOException`) stops the call with one `mysql_error` message, like the legacy code.
|
||||
- Naming: `Mailcow\<Module>\{Model,Repository,Service,Http,Controller}\<Class>`.
|
||||
|
||||
## Converting a legacy `functions.X.inc.php`
|
||||
|
||||
1. Capture the current API behaviour (E2E runs, raw JSON responses, DB state).
|
||||
2. Build the module as above: models, repositories, services, controller, `<Module>Module::create()`.
|
||||
3. Switch the callers (`json_api.php`, pages, crons, other function files) to the module.
|
||||
4. Delete the function file and its `require` lines.
|
||||
5. Compare the API output with step 1; the format must stay the same.
|
||||
|
||||
Large files (e.g. `functions.mailbox.inc.php`) are converted one area at a time (aliases, domains, ...),
|
||||
each as its own module. Until `functions.inc.php` is converted, `Context::canAccessMailbox()` /
|
||||
`canAccessDomain()` call the legacy `hasMailboxObjectAccess()` / `hasDomainAccess()`.
|
||||
|
||||
## Development
|
||||
|
||||
- Unit tests: `tests/php/<Module>/...` (dev-only, never deployed), run with
|
||||
`helper-scripts/dev_tests/run_php_unit_tests.sh`. Services are tested with mocked repositories;
|
||||
repositories are covered by the E2E suites (MariaDB-specific SQL).
|
||||
- Autoloader: after adding the PSR-4 mapping nothing needs regenerating for new classes. If you ever run
|
||||
`composer dump-autoload` (inside the php-fpm container, `-w /web/inc/lib`), never use `-o`, `-a` or
|
||||
`--apcu`, and commit only the changed `autoload_*.php` files.
|
||||
- No dev packages in `inc/lib/composer.json`.
|
||||
- opcache runs with `validate_timestamps=0`: restart php-fpm after changing PHP files
|
||||
(`docker compose restart php-fpm-mailcow`).
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ return array(
|
||||
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
|
||||
'MatthiasMullie\\PathConverter\\' => array($vendorDir . '/matthiasmullie/path-converter/src'),
|
||||
'MatthiasMullie\\Minify\\' => array($vendorDir . '/matthiasmullie/minify/src'),
|
||||
'Mailcow\\' => array($baseDir . '/mailcow'),
|
||||
'League\\OAuth2\\Client\\' => array($vendorDir . '/league/oauth2-client/src'),
|
||||
'LdapRecord\\' => array($vendorDir . '/directorytree/ldaprecord/src'),
|
||||
'Illuminate\\Support\\' => array($vendorDir . '/illuminate/collections', $vendorDir . '/illuminate/macroable', $vendorDir . '/illuminate/conditionable'),
|
||||
|
||||
@@ -58,6 +58,7 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b
|
||||
array (
|
||||
'MatthiasMullie\\PathConverter\\' => 29,
|
||||
'MatthiasMullie\\Minify\\' => 22,
|
||||
'Mailcow\\' => 8,
|
||||
),
|
||||
'L' =>
|
||||
array (
|
||||
@@ -177,6 +178,10 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/matthiasmullie/minify/src',
|
||||
),
|
||||
'Mailcow\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/../..' . '/mailcow',
|
||||
),
|
||||
'League\\OAuth2\\Client\\' =>
|
||||
array (
|
||||
0 => __DIR__ . '/..' . '/league/oauth2-client/src',
|
||||
|
||||
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user