mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-22 10:48:13 +00:00
Compare commits
200
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
474f1c688b | ||
|
|
7c62a57910 | ||
|
|
e756b7857e | ||
|
|
b2a152fe45 | ||
|
|
036607b4bf | ||
|
|
2bad774bcc | ||
|
|
d833b9696f | ||
|
|
7d41484d2a | ||
|
|
9e4c7ef038 | ||
|
|
1f04122eca | ||
|
|
015fd04a37 | ||
|
|
ca8546fa4a | ||
|
|
6f6edfb85e | ||
|
|
6c877fc0ac | ||
|
|
4a29767a31 | ||
|
|
cd29426933 | ||
|
|
37be306f1c | ||
|
|
a351017455 | ||
|
|
887b8a3594 | ||
|
|
571c1b5b29 | ||
|
|
8cd231c9b9 | ||
|
|
74facc4b5b | ||
|
|
ff6329d01b | ||
|
|
e8209d6504 | ||
|
|
626d006a3d | ||
|
|
e88c734d0a | ||
|
|
86bba266f6 | ||
|
|
e3ed8d2fc2 | ||
|
|
88d92261e4 | ||
|
|
017d0c7ff0 | ||
|
|
a28fa19044 | ||
|
|
fa2ad14fc4 | ||
|
|
a480a96e10 | ||
|
|
42d387ca42 | ||
|
|
fe4e675754 | ||
|
|
b05f59b705 | ||
|
|
c4d0ca56d2 | ||
|
|
7ec61fde8a | ||
|
|
d80b15b06c | ||
|
|
ed4fe41d7a | ||
|
|
6b9d875773 | ||
|
|
4d5b9d1c80 | ||
|
|
8abf453e1c | ||
|
|
8bd9e12a73 | ||
|
|
c665d430de | ||
|
|
2195a674ff | ||
|
|
089c44aee1 | ||
|
|
6644699e65 | ||
|
|
91d3b7246a | ||
|
|
b362c7105d | ||
|
|
555e7ed706 | ||
|
|
59e2a9ac77 | ||
|
|
feed5ad183 | ||
|
|
f1f7a9800b | ||
|
|
8c85044781 | ||
|
|
8fb32e4b59 | ||
|
|
b5fa926bc0 | ||
|
|
d1a2f4e168 | ||
|
|
f7a536d634 | ||
|
|
8b456a33e7 | ||
|
|
54da526d41 | ||
|
|
50be483c39 | ||
|
|
d64c923aca | ||
|
|
2d5f166ba8 | ||
|
|
94b5a623ab | ||
|
|
e406ecd461 | ||
|
|
68a95c3af3 | ||
|
|
bd037d5644 | ||
|
|
7036dbf4f8 | ||
|
|
2f10a4633f | ||
|
|
36c70db86c | ||
|
|
9cd16f0001 | ||
|
|
f4961c4023 | ||
|
|
38de21592c | ||
|
|
f44bd2f36a | ||
|
|
95a77f2dcb | ||
|
|
e856510fb2 | ||
|
|
d51d06d716 | ||
|
|
54170d075a | ||
|
|
b4bb1a625b | ||
|
|
2e5a29bf69 | ||
|
|
e245ac04d9 | ||
|
|
c877fdf0a5 | ||
|
|
fea38c8e1b | ||
|
|
cc9af65852 | ||
|
|
92cc8bec90 | ||
|
|
145745329e | ||
|
|
8e72d22c56 | ||
|
|
ddd76d99cd | ||
|
|
8c9524a2fe | ||
|
|
2ebd32d2ee | ||
|
|
4b62af9d02 | ||
|
|
90ca1bf25a | ||
|
|
14772c3a20 | ||
|
|
e5d0ed8c4c | ||
|
|
c17cc8a792 | ||
|
|
4cba448671 | ||
|
|
7dab6c63d1 | ||
|
|
5408bc809d | ||
|
|
bd5c18b145 | ||
|
|
0f06c8e563 | ||
|
|
fffe1aec3d | ||
|
|
6f7fee49cd | ||
|
|
c1d75cf808 | ||
|
|
473fe2885d | ||
|
|
31e1550668 | ||
|
|
315f55bc65 | ||
|
|
e696ee2f6c | ||
|
|
d65aa11870 | ||
|
|
f6630e5b43 | ||
|
|
98e26c5603 | ||
|
|
3098caebbc | ||
|
|
94e23a3cd3 | ||
|
|
1840074991 | ||
|
|
99768e16f5 | ||
|
|
1f8b4679cc | ||
|
|
9e723e942d | ||
|
|
47e4552b44 | ||
|
|
ddcce811c1 | ||
|
|
7e6c36dce1 | ||
|
|
fa154c71f3 | ||
|
|
83a045ed3e | ||
|
|
064817ac70 | ||
|
|
2bd7a24b8c | ||
|
|
ecc2462e4c | ||
|
|
2d8db72d46 | ||
|
|
277a307fb9 | ||
|
|
d455555621 | ||
|
|
9ea2ff1dff | ||
|
|
24cc369d84 | ||
|
|
5f5367f2f9 | ||
|
|
03c31f825a | ||
|
|
c0050e8836 | ||
|
|
15891960f7 | ||
|
|
cce02e2b15 | ||
|
|
0fafda696b | ||
|
|
843db5854c | ||
|
|
23e4e4f373 | ||
|
|
175878f8f1 | ||
|
|
3fcda21c4e | ||
|
|
eac1bf02fc | ||
|
|
a7f2b2145f | ||
|
|
faac56a611 | ||
|
|
be37bc5a68 | ||
|
|
b227c76156 | ||
|
|
db4e3b4d54 | ||
|
|
ffbc37a00c | ||
|
|
e6fa0c5099 | ||
|
|
ce3ceaf5b8 | ||
|
|
efc2e76159 | ||
|
|
7d17715e2e | ||
|
|
aed440bcb3 | ||
|
|
4f2348631a | ||
|
|
da586682f6 | ||
|
|
22ae1f5363 | ||
|
|
c9cf8d7f38 | ||
|
|
cbd3d8b9bc | ||
|
|
f553f38635 | ||
|
|
07373907e3 | ||
|
|
104af58628 | ||
|
|
4ddcee28e4 | ||
|
|
b0d16bbcee | ||
|
|
9175e5f086 | ||
|
|
ff3d571054 | ||
|
|
a8e945f3da | ||
|
|
33547d1d73 | ||
|
|
539c32d99c | ||
|
|
d42c3823c4 | ||
|
|
8ead057ce9 | ||
|
|
886dbcc419 | ||
|
|
b0873edb6a | ||
|
|
694d751915 | ||
|
|
dc15994d40 | ||
|
|
ec24825280 | ||
|
|
08278a8be9 | ||
|
|
5a00b5124b | ||
|
|
8c039f694f | ||
|
|
95bf46c1e4 | ||
|
|
edde35156d | ||
|
|
84e3c32f13 | ||
|
|
ecb848493b | ||
|
|
8a65b9d1c6 | ||
|
|
ed9264fd2a | ||
|
|
7817dda43f | ||
|
|
018e292854 | ||
|
|
127fb1e8f5 | ||
|
|
09f09cb850 | ||
|
|
d4bf377a96 | ||
|
|
abd6fe8c79 | ||
|
|
5f8382ef44 | ||
|
|
03eccd4e42 | ||
|
|
1da8d1c894 | ||
|
|
d1feebf164 | ||
|
|
293b885a85 | ||
|
|
1e0850193a | ||
|
|
33acf56526 | ||
|
|
bea9ad7e8f | ||
|
|
e7ea3aa608 | ||
|
|
5888e248c3 | ||
|
|
2e176339ba |
No files matched your search
@@ -14,7 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Mark/Close Stale Issues and Pull Requests 🗑️
|
||||
uses: actions/stale@v10.2.0
|
||||
uses: actions/stale@v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.STALE_ACTION_PAT }}
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- "watchdog-mailcow"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup Docker
|
||||
run: |
|
||||
curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh
|
||||
|
||||
@@ -8,11 +8,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Run the Action
|
||||
uses: devops-infra/action-pull-request@v1.0.2
|
||||
uses: devops-infra/action-pull-request@v1.4.0
|
||||
with:
|
||||
github_token: ${{ secrets.PRTONIGHTLY_ACTION_PAT }}
|
||||
title: Automatic PR to nightly from ${{ github.event.repository.updated_at}}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Generate postscreen_access.cidr
|
||||
run: |
|
||||
|
||||
+5
-1
@@ -64,6 +64,8 @@ data/web/templates/cache/*
|
||||
!data/web/templates/cache/.gitkeep
|
||||
data/web/.well-known/acme-challenge
|
||||
data/web/css/build/0081-custom-mailcow.css
|
||||
data/web/css/themes/*/
|
||||
!data/web/css/themes/servercow/
|
||||
data/web/inc/vars.local.inc.php
|
||||
data/web/inc/app_info.inc.php
|
||||
data/web/nextcloud*/
|
||||
@@ -76,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/
|
||||
@@ -23,10 +23,15 @@ A big thank you to everyone supporting us on GitHub Sponsors—your contribution
|
||||
<a href="https://www.maehdros.com/" target=_blank><img
|
||||
src="https://avatars.githubusercontent.com/u/173894712" height="58"
|
||||
/></a>
|
||||
<a href="https://garske-systems.de/" target=_blank><img
|
||||
src="https://garske-systems.de/img/logo_min.png" height="58"
|
||||
/></a>
|
||||
|
||||
### 50$/Month Sponsors
|
||||
<a href="https://github.com/vnukhr" target=_blank><img
|
||||
src="https://avatars.githubusercontent.com/u/7805987?s=52&v=4" height="58"
|
||||
/></a><a href="https://github.com/humiico" target=_blank><img
|
||||
src="https://avatars.githubusercontent.com/u/110134705?s=52&v=4" height="58"
|
||||
/></a>
|
||||
|
||||
## Info, documentation and support
|
||||
|
||||
@@ -249,14 +249,45 @@ while true; do
|
||||
done <<< "${SQL_DOMAINS}"
|
||||
|
||||
if [[ ${ONLY_MAILCOW_HOSTNAME} != "y" ]]; then
|
||||
# Fetch all domains with an active MTA-STS policy once.
|
||||
unset MTA_STS_ACTIVE_DOMAINS
|
||||
declare -A MTA_STS_ACTIVE_DOMAINS
|
||||
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
|
||||
SQL_MTA_STS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM mta_sts WHERE active = 1" -Bs)
|
||||
if [[ $? -eq 0 ]]; then
|
||||
while read mta_sts_domain; do
|
||||
if [[ -z "${mta_sts_domain}" ]]; then
|
||||
# ignore empty lines
|
||||
continue
|
||||
fi
|
||||
MTA_STS_ACTIVE_DOMAINS["${mta_sts_domain}"]=1
|
||||
done <<< "${SQL_MTA_STS_DOMAINS}"
|
||||
fi
|
||||
fi
|
||||
for SQL_DOMAIN in "${SQL_DOMAIN_ARR[@]}"; do
|
||||
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
|
||||
if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
|
||||
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}")
|
||||
fi
|
||||
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
|
||||
|
||||
# Skip mta-sts subdomain unless MTA-STS is enabled (active) for this domain
|
||||
if [[ "${SUBDOMAIN}" == "mta-sts" && -z "${MTA_STS_ACTIVE_DOMAINS[${SQL_DOMAIN}]}" ]]; then
|
||||
log_f "MTA-STS is not enabled for ${SQL_DOMAIN} - skipping mta-sts subdomain certificate"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Skip if subdomain matches MAILCOW_HOSTNAME
|
||||
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
|
||||
continue
|
||||
fi
|
||||
# Skip if subdomain is covered by a wildcard in ADDITIONAL_SAN
|
||||
if is_covered_by_wildcard "${FULL_SUBDOMAIN}"; then
|
||||
log_f "Subdomain '${FULL_SUBDOMAIN}' is covered by wildcard - skipping explicit subdomain"
|
||||
continue
|
||||
fi
|
||||
# Validate and add subdomain
|
||||
if check_domain "${FULL_SUBDOMAIN}"; then
|
||||
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${FULL_SUBDOMAIN}")
|
||||
fi
|
||||
done
|
||||
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
|
||||
@@ -273,7 +304,10 @@ while true; do
|
||||
fi
|
||||
# Only add mta-sts subdomain for alias domains
|
||||
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||
if check_domain "mta-sts.${alias_domain}"; then
|
||||
# Skip if mta-sts subdomain is covered by a wildcard
|
||||
if is_covered_by_wildcard "mta-sts.${alias_domain}"; then
|
||||
log_f "Alias domain mta-sts subdomain 'mta-sts.${alias_domain}' is covered by wildcard - skipping"
|
||||
elif check_domain "mta-sts.${alias_domain}"; then
|
||||
VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}")
|
||||
fi
|
||||
fi
|
||||
@@ -308,13 +342,31 @@ while true; do
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if MAILCOW_HOSTNAME is covered by a wildcard in ADDITIONAL_SAN
|
||||
MAILCOW_HOSTNAME_COVERED=0
|
||||
if [[ ! -z ${VALIDATED_MAILCOW_HOSTNAME} ]]; then
|
||||
if is_covered_by_wildcard "${VALIDATED_MAILCOW_HOSTNAME}"; then
|
||||
MAILCOW_PARENT_DOMAIN=$(echo ${VALIDATED_MAILCOW_HOSTNAME} | cut -d. -f2-)
|
||||
log_f "MAILCOW_HOSTNAME '${VALIDATED_MAILCOW_HOSTNAME}' is covered by wildcard '*.${MAILCOW_PARENT_DOMAIN}' - skipping explicit hostname"
|
||||
MAILCOW_HOSTNAME_COVERED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Unique domains for server certificate
|
||||
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
|
||||
# create certificate for server name and fqdn SANs only
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
|
||||
SERVER_SAN_VALIDATED=($(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
else
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
fi
|
||||
else
|
||||
# create certificate for all domains, including all subdomains from other domains [*]
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
|
||||
SERVER_SAN_VALIDATED=($(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
else
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
fi
|
||||
fi
|
||||
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
|
||||
CERT_NAME=${SERVER_SAN_VALIDATED[0]}
|
||||
|
||||
@@ -135,3 +135,32 @@ verify_challenge_path(){
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if a domain is covered by a wildcard (*.example.com) in ADDITIONAL_SAN
|
||||
# Usage: is_covered_by_wildcard "subdomain.example.com"
|
||||
# Returns: 0 if covered, 1 if not covered
|
||||
# Note: Only returns 0 (covered) when DNS-01 challenge is enabled,
|
||||
# as wildcards cannot be validated with HTTP-01 challenge
|
||||
is_covered_by_wildcard() {
|
||||
local DOMAIN=$1
|
||||
|
||||
# Only skip if DNS challenge is enabled (wildcards require DNS-01)
|
||||
if [[ ${ACME_DNS_CHALLENGE} != "y" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Return early if no ADDITIONAL_SAN is set
|
||||
if [[ -z ${ADDITIONAL_SAN} ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract parent domain (e.g., mail.example.com -> example.com)
|
||||
local PARENT_DOMAIN=$(echo ${DOMAIN} | cut -d. -f2-)
|
||||
|
||||
# Check if ADDITIONAL_SAN contains a wildcard for this parent domain
|
||||
if [[ "${ADDITIONAL_SAN}" == *"*.${PARENT_DOMAIN}"* ]]; then
|
||||
return 0 # Covered by wildcard
|
||||
fi
|
||||
|
||||
return 1 # Not covered
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM alpine:3.21 AS builder
|
||||
FROM alpine:3.24 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
ENV CLAMD_VERSION=1.4.2
|
||||
ENV CLAMD_VERSION=1.4.6
|
||||
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --update --no-cache \
|
||||
@@ -68,7 +68,7 @@ RUN wget -P /src https://www.clamav.net/downloads/production/clamav-${CLAMD_VERS
|
||||
"/clamav/etc/clamav/clamav-milter.conf.sample" > "/clamav/etc/clamav/clamav-milter.conf" || exit 1
|
||||
|
||||
|
||||
FROM alpine:3.21
|
||||
FROM alpine:3.24
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
|
||||
@@ -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 \
|
||||
@@ -84,7 +83,6 @@ RUN addgroup -g 5000 vmail \
|
||||
perl-test-warn \
|
||||
perl-try-tiny \
|
||||
perl-unicode-string \
|
||||
perl-proc-processtable \
|
||||
perl-app-cpanminus \
|
||||
procps \
|
||||
python3 \
|
||||
@@ -120,7 +118,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
|
||||
|
||||
@@ -279,7 +279,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 \
|
||||
|
||||
@@ -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
+274
@@ -0,0 +1,274 @@
|
||||
#!/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'
|
||||
|
||||
# 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, s.oauth_access_token, s.oauth_token_expires
|
||||
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)
|
||||
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"""
|
||||
# Token of authorization_code sources: source + local mailbox (user2) + remote user (user1)
|
||||
token_sql = """SELECT access_token, token_expires FROM imapsync_source_oauth_token
|
||||
WHERE source_id = %s AND owner = %s AND username = %s"""
|
||||
|
||||
conn = None
|
||||
# Popen => job of running workers
|
||||
running = {}
|
||||
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,))
|
||||
|
||||
# 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'
|
||||
if auth_type == 'XOAUTH2':
|
||||
# authorization_code: token of this local mailbox + remote user, client_credentials: shared source token
|
||||
token, expires = j['oauth_access_token'], j['oauth_token_expires']
|
||||
if j['oauth_flow'] == 'authorization_code':
|
||||
row = query(token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
|
||||
token, expires = row.get('access_token'), row.get('token_expires')
|
||||
# Skip sync if the cached token is missing or about to expire (<2 min)
|
||||
if not token or expires is None or expires - time.time() < 120:
|
||||
finish_job(j['id'], "OAuth access token for the configured source is missing or expires too soon; refresh cron will retry.",
|
||||
0, 'OAUTH_TOKEN_NOT_READY')
|
||||
return None
|
||||
auth_args = ['--authmech1', 'XOAUTH2', '--oauthaccesstoken1', tmpfile(token + '\n')]
|
||||
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
|
||||
|
||||
cmd = ['/usr/local/bin/imapsync', '--tmpdir', '/tmp', '--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', '--delete'), ('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}
|
||||
|
||||
# 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)
|
||||
try:
|
||||
while True:
|
||||
for proc, job in list(running.items()):
|
||||
if proc.poll() is not None:
|
||||
del running[proc]
|
||||
reap_job(job)
|
||||
|
||||
max_parallel, global_bw = read_settings()
|
||||
attempts = 0
|
||||
while not stopping and len(running) < max_parallel and attempts < 100:
|
||||
attempts += 1
|
||||
j = query(next_sql).fetchone()
|
||||
if not j:
|
||||
break
|
||||
if query(claim_sql, (j['id'],)).rowcount != 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'])
|
||||
@@ -47,7 +47,7 @@ try:
|
||||
if max_score == "":
|
||||
max_score = 9999.0
|
||||
|
||||
def query_mysql(query, headers = True, update = False):
|
||||
def query_mysql(query, params = None, headers = True, update = False):
|
||||
while True:
|
||||
try:
|
||||
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
||||
@@ -57,7 +57,10 @@ try:
|
||||
else:
|
||||
break
|
||||
cur = cnx.cursor()
|
||||
cur.execute(query)
|
||||
if params:
|
||||
cur.execute(query, params)
|
||||
else:
|
||||
cur.execute(query)
|
||||
if not update:
|
||||
result = []
|
||||
columns = tuple( [d[0] for d in cur.description] )
|
||||
@@ -76,7 +79,7 @@ try:
|
||||
|
||||
def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
|
||||
if category == "add_header": category = "add header"
|
||||
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category))
|
||||
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = %s AND score < %s AND (action = %s OR "all" = %s)', (rcpt, max_score, category, category))
|
||||
print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
|
||||
if len(meta_query) == 0:
|
||||
return
|
||||
@@ -130,7 +133,7 @@ try:
|
||||
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
|
||||
server.quit()
|
||||
for res in meta_query:
|
||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%d"' % (res['id']), update = True)
|
||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = %s', (res['id'],), update = True)
|
||||
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
|
||||
break
|
||||
except Exception as ex:
|
||||
@@ -138,7 +141,7 @@ try:
|
||||
print('%s' % (ex))
|
||||
time.sleep(3)
|
||||
|
||||
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %f AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt' % (max_score))
|
||||
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %s AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt', (max_score,))
|
||||
|
||||
for record in records:
|
||||
attrs = ''
|
||||
@@ -156,7 +159,7 @@ try:
|
||||
except Exception as ex:
|
||||
print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
|
||||
last_notification = 0
|
||||
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = "%s"' % (record['rcpt']))
|
||||
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = %s', (record['rcpt'],))
|
||||
attrs = attrs_json[0]['attributes']
|
||||
if isinstance(attrs, str):
|
||||
# if attr is str then just load it
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM nginx:alpine
|
||||
FROM nginx:1.30.6-alpine
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM php:8.2-fpm-alpine3.21
|
||||
FROM php:8.2-fpm-alpine3.24
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -7,13 +7,13 @@ ARG APCU_PECL_VERSION=5.1.28
|
||||
# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG IMAGICK_PECL_VERSION=3.8.1
|
||||
# renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG MAILPARSE_PECL_VERSION=3.1.9
|
||||
ARG MAILPARSE_PECL_VERSION=3.2.0
|
||||
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG MEMCACHED_PECL_VERSION=3.4.0
|
||||
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG REDIS_PECL_VERSION=6.3.0
|
||||
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG COMPOSER_VERSION=2.9.5
|
||||
ARG COMPOSER_VERSION=2.10.2
|
||||
|
||||
RUN apk add -U --no-cache autoconf \
|
||||
aspell-dev \
|
||||
@@ -47,6 +47,7 @@ RUN apk add -U --no-cache autoconf \
|
||||
libressl-dev \
|
||||
librsvg \
|
||||
libtool \
|
||||
libwebp \
|
||||
libwebp-dev \
|
||||
libxml2-dev \
|
||||
libxpm \
|
||||
@@ -63,13 +64,16 @@ RUN apk add -U --no-cache autoconf \
|
||||
samba-client \
|
||||
zlib-dev \
|
||||
tzdata \
|
||||
&& pecl install APCu-${APCU_PECL_VERSION} \
|
||||
&& pecl install imagick-${IMAGICK_PECL_VERSION} \
|
||||
&& pecl install mailparse-${MAILPARSE_PECL_VERSION} \
|
||||
&& pecl install memcached-${MEMCACHED_PECL_VERSION} \
|
||||
&& pecl install redis-${REDIS_PECL_VERSION} \
|
||||
&& docker-php-ext-enable apcu imagick memcached mailparse redis \
|
||||
&& pecl clear-cache \
|
||||
&& docker-php-source extract \
|
||||
# PECL extensions from their GitHub tags (pecl.php.net is not reliable enough for builds)
|
||||
&& mkdir -p /usr/src/php/ext/apcu /usr/src/php/ext/imagick /usr/src/php/ext/mailparse /usr/src/php/ext/memcached /usr/src/php/ext/redis \
|
||||
&& curl -fsSL https://github.com/krakjoe/apcu/archive/refs/tags/v${APCU_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/apcu \
|
||||
&& curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/${IMAGICK_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/imagick \
|
||||
&& sed -i "s/@PACKAGE_VERSION@/${IMAGICK_PECL_VERSION}/" /usr/src/php/ext/imagick/php_imagick.h \
|
||||
&& curl -fsSL https://github.com/php/pecl-mail-mailparse/archive/refs/tags/v${MAILPARSE_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/mailparse \
|
||||
&& curl -fsSL https://github.com/php-memcached-dev/php-memcached/archive/refs/tags/v${MEMCACHED_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/memcached \
|
||||
&& curl -fsSL https://github.com/phpredis/phpredis/archive/refs/tags/${REDIS_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/redis \
|
||||
&& docker-php-ext-install -j 4 apcu imagick mailparse memcached redis \
|
||||
&& docker-php-ext-configure intl \
|
||||
&& docker-php-ext-configure exif \
|
||||
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
||||
@@ -83,6 +87,7 @@ RUN apk add -U --no-cache autoconf \
|
||||
&& curl --silent --show-error https://getcomposer.org/installer | php -- --version=${COMPOSER_VERSION} \
|
||||
&& mv composer.phar /usr/local/bin/composer \
|
||||
&& chmod +x /usr/local/bin/composer \
|
||||
&& docker-php-source delete \
|
||||
&& apk del --purge autoconf \
|
||||
aspell-dev \
|
||||
cyrus-sasl-dev \
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
FROM golang:1.26-trixie AS builder
|
||||
WORKDIR /src
|
||||
|
||||
ENV CGO_ENABLED=0 \
|
||||
GO111MODULE=on \
|
||||
NOOPT=1 \
|
||||
VERSION=1.8.22
|
||||
VERSION=1.14.0
|
||||
|
||||
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
|
||||
cd /src/postfix-tlspol && \
|
||||
scripts/build.sh build-only
|
||||
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:trixie-slim
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@version: 3.38
|
||||
@version: 4.8
|
||||
@include "scl.conf"
|
||||
options {
|
||||
chain_hostnames(off);
|
||||
@@ -7,7 +7,7 @@ options {
|
||||
dns_cache(no);
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats_freq(0);
|
||||
stats(freq(0));
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_src {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@version: 3.38
|
||||
@version: 4.8
|
||||
@include "scl.conf"
|
||||
options {
|
||||
chain_hostnames(off);
|
||||
@@ -7,7 +7,7 @@ options {
|
||||
dns_cache(no);
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats_freq(0);
|
||||
stats(freq(0));
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_src {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:trixie-slim
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@version: 3.38
|
||||
@version: 4.8
|
||||
@include "scl.conf"
|
||||
options {
|
||||
chain_hostnames(off);
|
||||
@@ -7,7 +7,7 @@ options {
|
||||
dns_cache(no);
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats_freq(0);
|
||||
stats(freq(0));
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_src {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@version: 3.38
|
||||
@version: 4.8
|
||||
@include "scl.conf"
|
||||
options {
|
||||
chain_hostnames(off);
|
||||
@@ -7,7 +7,7 @@ options {
|
||||
dns_cache(no);
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats_freq(0);
|
||||
stats(freq(0));
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_src {
|
||||
|
||||
@@ -2,7 +2,7 @@ FROM debian:trixie-slim
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG RSPAMD_VER=rspamd_3.14.3-1~236eb65
|
||||
ARG RSPAMD_VER=rspamd_4.2.1-1~b7a16ae
|
||||
ARG CODENAME=trixie
|
||||
ENV LC_ALL=C
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SOGo built from source to enable security patch application
|
||||
# Repository: https://github.com/Alinto/sogo
|
||||
# Version: SOGo-5.12.4
|
||||
# Version: SOGo-5.12.12
|
||||
#
|
||||
# Applied security patches:
|
||||
# -
|
||||
@@ -12,8 +12,8 @@ FROM debian:bookworm
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG SOGO_VERSION=SOGo-5.12.5
|
||||
ARG SOPE_VERSION=SOPE-5.12.5
|
||||
ARG SOGO_VERSION=SOGo-5.12.12
|
||||
ARG SOPE_VERSION=SOPE-5.12.12
|
||||
# Security patches to apply (space-separated commit hashes)
|
||||
ARG SOGO_SECURITY_PATCHES=""
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
@@ -26,6 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
gobjc \
|
||||
pkg-config \
|
||||
gnustep-make \
|
||||
gnustep-base-runtime \
|
||||
libgnustep-base-dev \
|
||||
@@ -40,6 +41,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev \
|
||||
libzip-dev \
|
||||
libytnef0-dev \
|
||||
libwbxml2-dev \
|
||||
curl \
|
||||
ca-certificates \
|
||||
# Runtime dependencies
|
||||
@@ -68,6 +70,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libcurl4 \
|
||||
libzip4 \
|
||||
libytnef0 \
|
||||
libwbxml2-1 \
|
||||
# Download gosu
|
||||
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
|
||||
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
|
||||
@@ -97,6 +100,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& ./configure --disable-debug --disable-strip \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& cd /tmp/sogo/ActiveSync \
|
||||
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
|
||||
&& make -j$(nproc) install \
|
||||
&& cd / \
|
||||
&& rm -rf /tmp/sogo \
|
||||
# Strip binaries
|
||||
@@ -146,8 +152,8 @@ RUN echo "/usr/lib64" > /etc/ld.so.conf.d/sogo.conf \
|
||||
# Create sogo user and group
|
||||
RUN groupadd -r -g 999 sogo \
|
||||
&& useradd -r -u 999 -g sogo -d /var/lib/sogo -s /bin/bash -c "SOGo Daemon" sogo \
|
||||
&& mkdir -p /var/lib/sogo /var/run/sogo /var/log/sogo \
|
||||
&& chown -R sogo:sogo /var/lib/sogo /var/run/sogo /var/log/sogo
|
||||
&& mkdir -p /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo \
|
||||
&& chown -R sogo:sogo /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo
|
||||
|
||||
# Create symlinks for SOGo binaries
|
||||
RUN ln -s /usr/local/sbin/sogod /usr/sbin/sogod \
|
||||
|
||||
@@ -2,11 +2,15 @@ FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# install unbound from alpine:edge to get security patches
|
||||
# minimum version is pinned
|
||||
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main "unbound>=1.26.1-r0"
|
||||
|
||||
# install other packages from regular alpine stable repo
|
||||
RUN apk add --update --no-cache \
|
||||
curl \
|
||||
bind-tools \
|
||||
coreutils \
|
||||
unbound \
|
||||
bash \
|
||||
openssl \
|
||||
drill \
|
||||
|
||||
@@ -37,5 +37,6 @@ RUN apk add --update \
|
||||
COPY watchdog.sh /watchdog.sh
|
||||
COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh
|
||||
COPY check_dns.sh /usr/lib/mailcow/check_dns.sh
|
||||
COPY client.cnf /etc/my.cnf.d/client.cnf
|
||||
|
||||
CMD ["/watchdog.sh"]
|
||||
@@ -0,0 +1,3 @@
|
||||
[client]
|
||||
ssl = false
|
||||
ssl-verify-server-cert = false
|
||||
@@ -38,7 +38,7 @@ if [[ ! -p /tmp/com_pipe ]]; then
|
||||
fi
|
||||
|
||||
# Wait for containers
|
||||
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
|
||||
while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
|
||||
echo "Waiting for SQL..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -359,8 +359,8 @@ mysql_checks() {
|
||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
||||
touch /tmp/mysql-mailcow; echo "$(tail -50 /tmp/mysql-mailcow)" > /tmp/mysql-mailcow
|
||||
err_c_cur=${err_count}
|
||||
/usr/lib/nagios/plugins/check_mysql -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
||||
/usr/lib/nagios/plugins/check_mysql_query -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} -q "SELECT COUNT(*) FROM information_schema.tables" 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
||||
/usr/lib/nagios/plugins/check_mysql -f /etc/my.cnf.d/client.cnf -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
||||
/usr/lib/nagios/plugins/check_mysql_query -f /etc/my.cnf.d/client.cnf -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} -q "SELECT COUNT(*) FROM information_schema.tables" 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
||||
progress "MySQL/MariaDB" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
||||
@@ -384,7 +384,7 @@ mysql_repl_checks() {
|
||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
||||
touch /tmp/mysql_repl_checks; echo "$(tail -50 /tmp/mysql_repl_checks)" > /tmp/mysql_repl_checks
|
||||
err_c_cur=${err_count}
|
||||
/usr/lib/nagios/plugins/check_mysql_slavestatus.sh -S /var/run/mysqld/mysqld.sock -u root -p ${DBROOT} 2>> /tmp/mysql_repl_checks 1>&2; err_count=$(( ${err_count} + $? ))
|
||||
/usr/lib/nagios/plugins/check_mysql_slavestatus.sh -o /etc/my.cnf.d/client.cnf -S /var/run/mysqld/mysqld.sock -u root -p ${DBROOT} 2>> /tmp/mysql_repl_checks 1>&2; err_count=$(( ${err_count} + $? ))
|
||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
||||
progress "MySQL/MariaDB replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
||||
|
||||
@@ -32,8 +32,17 @@ function auth_password_verify(request, password)
|
||||
-- Returning PASSDB_RESULT_PASSWORD_MISMATCH will reset the user's auth cache entry.
|
||||
-- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry,
|
||||
-- even if the TTL has expired. Useful to avoid cache eviction during backend issues.
|
||||
|
||||
-- On a network-level failure (nginx unreachable, DNS failure, timeout) https.request
|
||||
-- returns nil plus an error string, so c is not a numeric HTTP status code. Treat this
|
||||
-- as a backend outage and keep the cache entry, rather than wiping it as a mismatch.
|
||||
if type(c) ~= "number" then
|
||||
dovecot.i_info("HTTP request to auth backend failed with " .. tostring(c) .. " for user " .. request.user)
|
||||
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unreachable"
|
||||
end
|
||||
|
||||
if c ~= 200 and c ~= 401 then
|
||||
dovecot.i_info("HTTP request failed with " .. c .. " for user " .. request.user)
|
||||
dovecot.i_info("HTTP request failed with " .. tostring(c) .. " for user " .. request.user)
|
||||
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
|
||||
end
|
||||
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
# global_sieve_before script
|
||||
# global_sieve_before -> user sieve_before (mailcow UI) -> user sieve_after (mailcow UI) -> global_sieve_after
|
||||
|
||||
require ["mailbox", "fileinto"];
|
||||
|
||||
if header :contains ["Chat-Version"] [""] {
|
||||
if mailboxexists "DeltaChat" {
|
||||
fileinto "DeltaChat";
|
||||
} else {
|
||||
fileinto :create "DeltaChat";
|
||||
}
|
||||
stop;
|
||||
}
|
||||
@@ -42,12 +42,19 @@ http {
|
||||
https https;
|
||||
}
|
||||
|
||||
map $server_port $sogo_auth_internal {
|
||||
65510 "1";
|
||||
default "";
|
||||
}
|
||||
|
||||
{% if HTTP_REDIRECT %}
|
||||
# HTTP to HTTPS redirect
|
||||
server {
|
||||
root /web;
|
||||
listen {{ HTTP_PORT }} default_server;
|
||||
{% if ENABLE_IPV6 %}
|
||||
listen [::]:{{ HTTP_PORT }} default_server;
|
||||
{%endif%}
|
||||
|
||||
server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
|
||||
|
||||
|
||||
@@ -116,6 +116,9 @@ location ~ \.php$ {
|
||||
include /etc/nginx/fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
fastcgi_param HTTP_X_REAL_IP $remote_addr;
|
||||
# trusted internal-auth marker; empty for external clients (see nginx.conf map)
|
||||
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
|
||||
fastcgi_read_timeout 3600;
|
||||
fastcgi_send_timeout 3600;
|
||||
}
|
||||
|
||||
@@ -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,3 +1,5 @@
|
||||
max_execution_time = 3600
|
||||
max_input_time = 3600
|
||||
memory_limit = 512M
|
||||
; not used by mailcow, disabled as hardening (PHP security release 2026-10-21)
|
||||
session.upload_progress.enabled = Off
|
||||
@@ -37,7 +37,7 @@ postscreen_access_list = permit_mynetworks,
|
||||
cidr:/opt/postfix/conf/postscreen_access.cidr,
|
||||
tcp:127.0.0.1:10027
|
||||
postscreen_bare_newline_enable = no
|
||||
postscreen_blacklist_action = drop
|
||||
postscreen_denylist_action = drop
|
||||
postscreen_cache_cleanup_interval = 24h
|
||||
postscreen_cache_map = proxy:btree:$data_directory/postscreen_cache
|
||||
postscreen_dnsbl_action = enforce
|
||||
@@ -107,8 +107,6 @@ smtpd_sender_restrictions = reject_authenticated_sender_login_mismatch,
|
||||
reject_unknown_sender_domain
|
||||
smtpd_soft_error_limit = 3
|
||||
smtpd_tls_auth_only = yes
|
||||
smtpd_tls_dh1024_param_file = /etc/ssl/mail/dhparams.pem
|
||||
smtpd_tls_eecdh_grade = auto
|
||||
smtpd_tls_exclude_ciphers = ECDHE-RSA-RC4-SHA, RC4, aNULL, DES-CBC3-SHA, ECDHE-RSA-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA
|
||||
smtpd_tls_loglevel = 1
|
||||
|
||||
@@ -152,7 +150,7 @@ smtp_sasl_auth_enable = yes
|
||||
smtp_sasl_password_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_sender_dependent.cf
|
||||
smtp_sasl_security_options =
|
||||
smtp_sasl_mechanism_filter = plain, login
|
||||
smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERY
|
||||
smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERYwithTLSRPT
|
||||
smtp_header_checks = pcre:/opt/postfix/conf/anonymize_headers.pcre
|
||||
mail_name = Postcow
|
||||
# local_transport map catches local destinations and prevents routing local dests when the next map would route "*"
|
||||
|
||||
@@ -29,7 +29,6 @@ smtps inet n - n - - smtpd
|
||||
# TLS protocol can be modified by setting submission_smtpd_tls_mandatory_protocols in extra.cf
|
||||
submission inet n - n - - smtpd
|
||||
-o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject
|
||||
-o smtpd_enforce_tls=yes
|
||||
-o smtpd_tls_security_level=encrypt
|
||||
-o smtpd_tls_mandatory_protocols=$submission_smtpd_tls_mandatory_protocols
|
||||
-o tls_preempt_cipherlist=yes
|
||||
@@ -38,7 +37,6 @@ submission inet n - n - - smtpd
|
||||
10587 inet n - n - - smtpd
|
||||
-o smtpd_upstream_proxy_protocol=haproxy
|
||||
-o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject
|
||||
-o smtpd_enforce_tls=yes
|
||||
-o smtpd_tls_security_level=encrypt
|
||||
-o smtpd_tls_mandatory_protocols=$submission_smtpd_tls_mandatory_protocols
|
||||
-o tls_preempt_cipherlist=yes
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Whitelist generated by Postwhite v3.4 on Sun Mar 1 00:29:01 UTC 2026
|
||||
# Whitelist generated by Postwhite v3.4 on Thu Oct 1 00:35:20 UTC 2026
|
||||
# https://github.com/stevejenkins/postwhite/
|
||||
# 2174 total rules
|
||||
# 2246 total rules
|
||||
2a00:1450:4000::/36 permit
|
||||
2a00:1450:4864::/56 permit
|
||||
2a01:111:f400::/48 permit
|
||||
2a01:111:f403:2800::/53 permit
|
||||
2a01:111:f403:8000::/51 permit
|
||||
@@ -28,11 +29,15 @@
|
||||
2a01:b747:3004:200::/56 permit
|
||||
2a01:b747:3005:200::/56 permit
|
||||
2a01:b747:3006:200::/56 permit
|
||||
2a01:b747:3007::/56 permit
|
||||
2a02:a60:0:5::/64 permit
|
||||
2a0f:f640::/56 permit
|
||||
2c0f:fb50:4000::/36 permit
|
||||
2c0f:fb50:4864::/56 permit
|
||||
2.207.151.32/27 permit
|
||||
2.207.151.53 permit
|
||||
2.207.217.30 permit
|
||||
2.207.223.160/27 permit
|
||||
3.64.237.68 permit
|
||||
3.65.3.180 permit
|
||||
3.70.123.177 permit
|
||||
@@ -53,13 +58,14 @@
|
||||
8.25.196.0/23 permit
|
||||
8.36.116.0/24 permit
|
||||
8.39.54.0/23 permit
|
||||
8.39.54.240/29 permit
|
||||
8.39.54.250/31 permit
|
||||
8.39.144.0/24 permit
|
||||
8.40.222.0/23 permit
|
||||
8.40.222.240/29 permit
|
||||
8.40.222.250/31 permit
|
||||
8.47.10.9 permit
|
||||
12.130.86.238 permit
|
||||
13.107.213.51 permit
|
||||
13.107.246.51 permit
|
||||
13.108.16.0/20 permit
|
||||
13.110.208.0/21 permit
|
||||
13.110.209.0/24 permit
|
||||
@@ -119,7 +125,6 @@
|
||||
23.249.208.0/20 permit
|
||||
23.251.224.0/19 permit
|
||||
23.253.141.0/24 permit
|
||||
23.253.182.0/23 permit
|
||||
23.253.182.103 permit
|
||||
23.253.183.145 permit
|
||||
23.253.183.146 permit
|
||||
@@ -141,24 +146,7 @@
|
||||
27.123.206.80/28 permit
|
||||
31.47.251.17 permit
|
||||
31.186.239.0/24 permit
|
||||
34.2.64.0/22 permit
|
||||
34.2.68.0/23 permit
|
||||
34.2.70.0/23 permit
|
||||
34.2.71.64/26 permit
|
||||
34.2.72.0/22 permit
|
||||
34.2.75.0/26 permit
|
||||
34.2.78.0/23 permit
|
||||
34.2.80.0/23 permit
|
||||
34.2.82.0/23 permit
|
||||
34.2.84.0/24 permit
|
||||
34.2.84.64/26 permit
|
||||
34.2.85.0/24 permit
|
||||
34.2.85.64/26 permit
|
||||
34.2.86.0/23 permit
|
||||
34.2.88.0/23 permit
|
||||
34.2.90.0/23 permit
|
||||
34.2.92.0/23 permit
|
||||
34.2.94.0/23 permit
|
||||
34.2.64.0/19 permit
|
||||
34.70.158.162 permit
|
||||
34.74.74.140 permit
|
||||
34.83.159.189 permit
|
||||
@@ -185,6 +173,7 @@
|
||||
35.205.92.9 permit
|
||||
35.228.216.85 permit
|
||||
35.242.169.159 permit
|
||||
37.187.220.204 permit
|
||||
37.188.97.188 permit
|
||||
37.218.248.47 permit
|
||||
37.218.249.47 permit
|
||||
@@ -258,8 +247,8 @@
|
||||
46.228.39.112/31 permit
|
||||
46.243.88.174 permit
|
||||
46.243.88.175 permit
|
||||
46.243.88.176 permit
|
||||
46.243.88.177 permit
|
||||
46.243.88.176/30 permit
|
||||
46.243.95.96/27 permit
|
||||
46.243.95.179 permit
|
||||
46.243.95.180 permit
|
||||
50.16.246.183 permit
|
||||
@@ -277,6 +266,8 @@
|
||||
50.56.130.221 permit
|
||||
50.56.130.222 permit
|
||||
50.112.246.219 permit
|
||||
50.152.146.25 permit
|
||||
51.83.17.38 permit
|
||||
52.1.14.157 permit
|
||||
52.5.230.59 permit
|
||||
52.6.74.205 permit
|
||||
@@ -367,7 +358,9 @@
|
||||
64.127.115.252 permit
|
||||
64.132.88.0/23 permit
|
||||
64.132.92.0/24 permit
|
||||
64.181.136.241 permit
|
||||
64.181.194.190 permit
|
||||
64.181.213.254 permit
|
||||
64.207.219.7 permit
|
||||
64.207.219.8 permit
|
||||
64.207.219.9 permit
|
||||
@@ -453,7 +446,11 @@
|
||||
66.218.75.252/31 permit
|
||||
66.218.75.254 permit
|
||||
66.220.144.128/25 permit
|
||||
66.220.144.178 permit
|
||||
66.220.144.179 permit
|
||||
66.220.155.0/24 permit
|
||||
66.220.155.178 permit
|
||||
66.220.155.179 permit
|
||||
66.220.157.0/25 permit
|
||||
66.231.80.0/20 permit
|
||||
66.240.227.0/24 permit
|
||||
@@ -518,6 +515,8 @@
|
||||
69.162.98.0/24 permit
|
||||
69.169.224.0/20 permit
|
||||
69.171.232.0/24 permit
|
||||
69.171.232.180 permit
|
||||
69.171.232.181 permit
|
||||
69.171.244.0/23 permit
|
||||
70.42.149.35 permit
|
||||
72.3.185.0/24 permit
|
||||
@@ -639,6 +638,8 @@
|
||||
77.238.189.148/30 permit
|
||||
79.135.106.0/24 permit
|
||||
79.135.107.0/24 permit
|
||||
80.225.160.128/25 permit
|
||||
81.88.38.0/23 permit
|
||||
81.169.146.243 permit
|
||||
81.169.146.245 permit
|
||||
81.169.146.246 permit
|
||||
@@ -657,6 +658,9 @@
|
||||
82.165.159.45 permit
|
||||
82.165.159.130 permit
|
||||
82.165.159.131 permit
|
||||
84.8.68.0/25 permit
|
||||
84.8.192.128/25 permit
|
||||
84.8.224.128/25 permit
|
||||
85.9.206.169 permit
|
||||
85.9.210.45 permit
|
||||
85.158.136.0/21 permit
|
||||
@@ -720,6 +724,7 @@
|
||||
96.43.148.64/28 permit
|
||||
96.43.148.64/31 permit
|
||||
96.43.151.64/28 permit
|
||||
98.77.0.0/16 permit
|
||||
98.97.248.0/21 permit
|
||||
98.136.44.181 permit
|
||||
98.136.44.182/31 permit
|
||||
@@ -1215,8 +1220,6 @@
|
||||
104.43.243.237 permit
|
||||
104.44.112.128/25 permit
|
||||
104.47.0.0/17 permit
|
||||
104.130.96.0/28 permit
|
||||
104.130.122.0/23 permit
|
||||
106.10.144.64/27 permit
|
||||
106.10.144.100/31 permit
|
||||
106.10.144.103 permit
|
||||
@@ -1369,6 +1372,7 @@
|
||||
117.120.16.0/21 permit
|
||||
119.42.242.52/31 permit
|
||||
119.42.242.156 permit
|
||||
121.244.91.31 permit
|
||||
121.244.91.48 permit
|
||||
121.244.91.52 permit
|
||||
122.15.156.182 permit
|
||||
@@ -1408,6 +1412,20 @@
|
||||
129.146.88.28 permit
|
||||
129.146.147.105 permit
|
||||
129.146.236.58 permit
|
||||
129.148.135.0/25 permit
|
||||
129.148.148.0/25 permit
|
||||
129.148.164.0/25 permit
|
||||
129.148.180.0/25 permit
|
||||
129.148.215.0/25 permit
|
||||
129.149.6.0/25 permit
|
||||
129.149.22.0/25 permit
|
||||
129.149.38.0/25 permit
|
||||
129.149.52.0/25 permit
|
||||
129.149.68.0/25 permit
|
||||
129.149.84.0/25 permit
|
||||
129.149.100.0/25 permit
|
||||
129.149.118.0/25 permit
|
||||
129.149.126.0/25 permit
|
||||
129.151.67.221 permit
|
||||
129.153.62.216 permit
|
||||
129.153.104.71 permit
|
||||
@@ -1420,18 +1438,21 @@
|
||||
129.159.22.159 permit
|
||||
129.159.87.137 permit
|
||||
129.213.195.191 permit
|
||||
130.35.116.0/25 permit
|
||||
130.61.9.72 permit
|
||||
130.162.39.83 permit
|
||||
130.162.194.23 permit
|
||||
130.248.172.0/24 permit
|
||||
130.248.173.0/24 permit
|
||||
131.186.12.0/25 permit
|
||||
131.253.30.0/24 permit
|
||||
131.253.121.0/26 permit
|
||||
132.145.13.209 permit
|
||||
132.226.26.225 permit
|
||||
132.226.49.32 permit
|
||||
132.226.56.24 permit
|
||||
134.128.64.0/19 permit
|
||||
134.128.96.0/19 permit
|
||||
134.98.248.128/25 permit
|
||||
134.128.64.0/18 permit
|
||||
134.170.27.8 permit
|
||||
134.170.113.0/26 permit
|
||||
134.170.141.64/26 permit
|
||||
@@ -1460,6 +1481,10 @@
|
||||
136.147.182.0/24 permit
|
||||
136.147.224.0/20 permit
|
||||
136.179.50.206 permit
|
||||
136.248.224.128/25 permit
|
||||
136.248.232.128/25 permit
|
||||
138.1.108.0/25 permit
|
||||
138.1.170.0/24 permit
|
||||
139.60.152.0/22 permit
|
||||
139.138.35.44 permit
|
||||
139.138.46.121 permit
|
||||
@@ -1468,6 +1493,7 @@
|
||||
139.138.57.55 permit
|
||||
139.138.58.119 permit
|
||||
139.167.79.86 permit
|
||||
139.177.108.0/25 permit
|
||||
139.180.17.0/24 permit
|
||||
140.238.148.191 permit
|
||||
141.148.55.217 permit
|
||||
@@ -1498,14 +1524,14 @@
|
||||
146.20.14.105 permit
|
||||
146.20.14.106 permit
|
||||
146.20.14.107 permit
|
||||
146.20.112.0/26 permit
|
||||
146.20.113.0/24 permit
|
||||
146.20.191.0/24 permit
|
||||
146.20.215.0/24 permit
|
||||
146.20.215.182 permit
|
||||
146.88.28.0/24 permit
|
||||
146.148.116.76 permit
|
||||
147.154.32.0/25 permit
|
||||
147.154.63.0/24 permit
|
||||
147.154.126.0/24 permit
|
||||
147.154.191.0/24 permit
|
||||
147.243.1.47 permit
|
||||
147.243.1.48 permit
|
||||
147.243.1.153 permit
|
||||
@@ -1513,16 +1539,23 @@
|
||||
147.243.128.26 permit
|
||||
148.105.0.0/16 permit
|
||||
148.105.8.0/21 permit
|
||||
148.116.32.128/25 permit
|
||||
149.72.0.0/16 permit
|
||||
149.72.234.184 permit
|
||||
149.72.248.236 permit
|
||||
149.97.173.180 permit
|
||||
149.118.160.128/25 permit
|
||||
150.136.21.199 permit
|
||||
150.171.109.178 permit
|
||||
150.230.98.160 permit
|
||||
151.145.38.14 permit
|
||||
152.67.105.195 permit
|
||||
152.69.165.251 permit
|
||||
152.69.200.236 permit
|
||||
152.70.155.126 permit
|
||||
155.248.135.128/25 permit
|
||||
155.248.140.0/25 permit
|
||||
155.248.148.0/25 permit
|
||||
155.248.208.51 permit
|
||||
155.248.220.138 permit
|
||||
155.248.234.149 permit
|
||||
@@ -1531,10 +1564,16 @@
|
||||
157.58.30.128/25 permit
|
||||
157.58.196.96/29 permit
|
||||
157.58.249.3 permit
|
||||
157.137.32.128/25 permit
|
||||
157.137.96.128/25 permit
|
||||
157.151.208.65 permit
|
||||
157.255.1.64/29 permit
|
||||
158.101.211.207 permit
|
||||
158.247.16.0/20 permit
|
||||
158.247.100.0/25 permit
|
||||
159.13.4.0/25 permit
|
||||
159.13.33.181 permit
|
||||
159.26.176.0/20 permit
|
||||
159.92.154.0/24 permit
|
||||
159.92.155.0/24 permit
|
||||
159.92.157.0/24 permit
|
||||
@@ -1562,12 +1601,15 @@
|
||||
159.183.121.182 permit
|
||||
159.183.129.172 permit
|
||||
160.1.62.192 permit
|
||||
161.33.94.110 permit
|
||||
161.33.228.244 permit
|
||||
161.38.192.0/20 permit
|
||||
161.38.204.0/22 permit
|
||||
161.71.32.0/19 permit
|
||||
161.71.64.0/20 permit
|
||||
162.88.4.0/23 permit
|
||||
162.88.8.0/24 permit
|
||||
162.88.24.0/23 permit
|
||||
162.88.24.0/24 permit
|
||||
162.88.25.0/24 permit
|
||||
162.88.36.0/24 permit
|
||||
@@ -1585,11 +1627,13 @@
|
||||
164.152.23.32 permit
|
||||
164.152.25.241 permit
|
||||
164.177.132.168/30 permit
|
||||
165.1.100.0/25 permit
|
||||
165.173.128.0/24 permit
|
||||
165.173.180.1 permit
|
||||
165.173.180.0/24 permit
|
||||
165.173.180.250/31 permit
|
||||
165.173.182.0/24 permit
|
||||
165.173.182.250/31 permit
|
||||
166.78.68.0/22 permit
|
||||
165.173.189.205 permit
|
||||
166.78.68.221 permit
|
||||
166.78.69.169 permit
|
||||
166.78.69.170 permit
|
||||
@@ -1607,9 +1651,12 @@
|
||||
167.89.75.126 permit
|
||||
167.89.75.136 permit
|
||||
167.89.75.164 permit
|
||||
167.89.101.2 permit
|
||||
167.89.101.192/28 permit
|
||||
167.220.67.232/29 permit
|
||||
168.107.248.128/25 permit
|
||||
168.110.160.128/25 permit
|
||||
168.110.248.128/25 permit
|
||||
168.129.184.128/25 permit
|
||||
168.129.248.128/25 permit
|
||||
168.138.5.36 permit
|
||||
168.138.73.51 permit
|
||||
168.138.77.31 permit
|
||||
@@ -1626,6 +1673,7 @@
|
||||
169.148.144.0/25 permit
|
||||
169.148.144.10 permit
|
||||
169.148.146.0/23 permit
|
||||
169.148.174.10 permit
|
||||
169.148.175.3 permit
|
||||
169.148.179.3 permit
|
||||
169.148.188.0/24 permit
|
||||
@@ -1644,6 +1692,7 @@
|
||||
173.203.81.39 permit
|
||||
173.224.161.128/25 permit
|
||||
173.224.165.0/26 permit
|
||||
173.224.166.48/28 permit
|
||||
174.36.84.8/29 permit
|
||||
174.36.84.16/29 permit
|
||||
174.36.84.32/29 permit
|
||||
@@ -1658,6 +1707,8 @@
|
||||
175.41.215.51 permit
|
||||
176.32.105.0/24 permit
|
||||
176.32.127.0/24 permit
|
||||
176.94.34.32/27 permit
|
||||
176.94.34.64/27 permit
|
||||
178.236.10.128/26 permit
|
||||
182.50.76.0/22 permit
|
||||
182.50.78.64/28 permit
|
||||
@@ -1739,26 +1790,36 @@
|
||||
188.125.85.234/31 permit
|
||||
188.125.85.236/31 permit
|
||||
188.125.85.238 permit
|
||||
188.165.51.139 permit
|
||||
188.172.128.0/20 permit
|
||||
192.0.64.0/18 permit
|
||||
192.9.166.184 permit
|
||||
192.18.139.154 permit
|
||||
192.18.145.36 permit
|
||||
192.18.152.58 permit
|
||||
192.22.32.128/25 permit
|
||||
192.22.96.128/25 permit
|
||||
192.22.160.128/25 permit
|
||||
192.22.224.128/25 permit
|
||||
192.28.128.0/18 permit
|
||||
192.29.24.0/25 permit
|
||||
192.29.44.0/25 permit
|
||||
192.29.72.0/25 permit
|
||||
192.29.88.0/25 permit
|
||||
192.29.103.128/25 permit
|
||||
192.29.134.0/25 permit
|
||||
192.29.151.128/25 permit
|
||||
192.29.172.0/25 permit
|
||||
192.29.178.0/25 permit
|
||||
192.29.200.0/25 permit
|
||||
192.29.216.0/25 permit
|
||||
192.29.232.0/25 permit
|
||||
192.29.248.0/25 permit
|
||||
192.30.252.0/22 permit
|
||||
192.161.144.0/20 permit
|
||||
192.162.87.0/24 permit
|
||||
192.237.158.0/23 permit
|
||||
192.237.159.42 permit
|
||||
192.237.159.43 permit
|
||||
192.254.112.0/20 permit
|
||||
192.254.112.60 permit
|
||||
192.254.112.98/31 permit
|
||||
192.254.113.10 permit
|
||||
192.254.113.101 permit
|
||||
192.254.114.176 permit
|
||||
193.109.254.0/23 permit
|
||||
193.122.128.100 permit
|
||||
193.123.56.63 permit
|
||||
@@ -1798,7 +1859,6 @@
|
||||
198.21.0.0/21 permit
|
||||
198.37.144.0/20 permit
|
||||
198.37.152.186 permit
|
||||
198.61.254.0/23 permit
|
||||
198.61.254.21 permit
|
||||
198.61.254.231 permit
|
||||
198.178.234.57 permit
|
||||
@@ -1885,6 +1945,7 @@
|
||||
204.14.232.0/21 permit
|
||||
204.14.232.64/28 permit
|
||||
204.14.234.64/28 permit
|
||||
204.75.18.128/27 permit
|
||||
204.75.142.0/24 permit
|
||||
204.92.114.187 permit
|
||||
204.92.114.203 permit
|
||||
@@ -1892,7 +1953,9 @@
|
||||
204.141.32.0/23 permit
|
||||
204.141.42.0/23 permit
|
||||
204.216.164.202 permit
|
||||
204.220.160.0/21 permit
|
||||
204.220.90.0/23 permit
|
||||
204.220.92.0/22 permit
|
||||
204.220.160.0/20 permit
|
||||
204.220.168.0/21 permit
|
||||
204.220.176.0/20 permit
|
||||
204.220.181.105 permit
|
||||
@@ -1927,6 +1990,9 @@
|
||||
207.211.30.128/25 permit
|
||||
207.211.31.0/25 permit
|
||||
207.211.41.113 permit
|
||||
207.211.132.0/25 permit
|
||||
207.211.147.87 permit
|
||||
207.211.159.110 permit
|
||||
207.218.90.0/24 permit
|
||||
207.218.90.122 permit
|
||||
207.250.68.0/24 permit
|
||||
@@ -1934,6 +2000,8 @@
|
||||
208.43.21.28/30 permit
|
||||
208.43.21.64/29 permit
|
||||
208.43.21.72/30 permit
|
||||
208.56.9.224 permit
|
||||
208.56.13.196 permit
|
||||
208.64.132.0/22 permit
|
||||
208.71.40.63 permit
|
||||
208.71.40.64/31 permit
|
||||
@@ -1960,6 +2028,7 @@
|
||||
208.71.42.214 permit
|
||||
208.72.249.240/29 permit
|
||||
208.75.120.0/22 permit
|
||||
208.76.62.0/23 permit
|
||||
208.76.62.0/24 permit
|
||||
208.76.63.0/24 permit
|
||||
208.82.237.96/29 permit
|
||||
@@ -1973,7 +2042,6 @@
|
||||
209.43.22.0/28 permit
|
||||
209.46.117.168 permit
|
||||
209.46.117.179 permit
|
||||
209.61.151.0/24 permit
|
||||
209.61.151.236 permit
|
||||
209.61.151.249 permit
|
||||
209.61.151.251 permit
|
||||
@@ -2120,6 +2188,7 @@
|
||||
216.136.168.80/28 permit
|
||||
216.139.64.0/19 permit
|
||||
216.145.221.0/24 permit
|
||||
216.146.32.0/23 permit
|
||||
216.146.32.0/24 permit
|
||||
216.146.33.0/24 permit
|
||||
216.198.0.0/18 permit
|
||||
@@ -2140,6 +2209,7 @@
|
||||
223.165.120.0/23 permit
|
||||
2001:0868:0100:0600::/64 permit
|
||||
2001:4860:4000::/36 permit
|
||||
2001:4860:4864::/56 permit
|
||||
2001:748:100:40::2:0/112 permit
|
||||
2001:748:400:1300::3 permit
|
||||
2001:748:400:1300::4 permit
|
||||
@@ -2157,10 +2227,13 @@
|
||||
2001:748:400:3301::3 permit
|
||||
2001:748:400:3301::4 permit
|
||||
2404:6800:4000::/36 permit
|
||||
2404:6800:4864::/56 permit
|
||||
2603:1061:14:1d0::1 permit
|
||||
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
|
||||
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
|
||||
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
|
||||
2607:f8b0:4000::/36 permit
|
||||
2607:f8b0:4864::/56 permit
|
||||
2620:109:c003:104::/64 permit
|
||||
2620:109:c003:104::215 permit
|
||||
2620:109:c006:104::/64 permit
|
||||
@@ -2172,8 +2245,7 @@
|
||||
2620:10d:c09c:400::8:1 permit
|
||||
2620:119:50c0:207::/64 permit
|
||||
2620:119:50c0:207::215 permit
|
||||
2620:1ec:46::51 permit
|
||||
2620:1ec:bdf::51 permit
|
||||
2800:3f0:4000::/36 permit
|
||||
2800:3f0:4864::/56 permit
|
||||
49.12.4.251 permit # checks.mailcow.email
|
||||
2a01:4f8:c17:7906::10 permit # checks.mailcow.email
|
||||
@@ -3,8 +3,7 @@ rules {
|
||||
backend = "http";
|
||||
url = "http://nginx:9081/pipe.php";
|
||||
selector = "reject_no_global_bl";
|
||||
formatter = "default";
|
||||
meta_headers = true;
|
||||
formatter = "multipart";
|
||||
}
|
||||
RLINFO {
|
||||
backend = "http";
|
||||
@@ -16,8 +15,7 @@ rules {
|
||||
backend = "http";
|
||||
url = "http://nginx:9081/pushover.php";
|
||||
selector = "mailcow_rcpt";
|
||||
formatter = "json";
|
||||
meta_headers = true;
|
||||
formatter = "multipart";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -856,6 +856,8 @@ rspamd_config:register_symbol({
|
||||
|
||||
local seen_cte
|
||||
local newline_s = newline(task)
|
||||
-- use the CTE add_text_footer actually applied
|
||||
local new_cte = rewrite.new_cte or 'quoted-printable'
|
||||
|
||||
local function rewrite_ct_cb(name, hdr)
|
||||
if rewrite.need_rewrite_ct then
|
||||
@@ -877,13 +879,13 @@ rspamd_config:register_symbol({
|
||||
return
|
||||
elseif name:lower() == 'content-transfer-encoding' then
|
||||
out[#out + 1] = string.format('%s: %s',
|
||||
'Content-Transfer-Encoding', 'quoted-printable')
|
||||
'Content-Transfer-Encoding', new_cte)
|
||||
-- update Content-Transfer-Encoding header
|
||||
task:set_milter_reply({
|
||||
remove_headers = {['Content-Transfer-Encoding'] = 0},
|
||||
})
|
||||
task:set_milter_reply({
|
||||
add_headers = {['Content-Transfer-Encoding'] = 'quoted-printable'}
|
||||
add_headers = {['Content-Transfer-Encoding'] = new_cte}
|
||||
})
|
||||
seen_cte = true
|
||||
return
|
||||
@@ -895,7 +897,7 @@ rspamd_config:register_symbol({
|
||||
task:headers_foreach(rewrite_ct_cb, {full = true})
|
||||
|
||||
if not seen_cte and rewrite.need_rewrite_ct then
|
||||
out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', 'quoted-printable')
|
||||
out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', new_cte)
|
||||
end
|
||||
|
||||
-- End of headers
|
||||
@@ -914,7 +916,7 @@ rspamd_config:register_symbol({
|
||||
out_parts[#out_parts + 1] = o
|
||||
out_parts[#out_parts + 1] = newline_s
|
||||
else
|
||||
local removePrefix = "--\x0D\x0AContent-Type"
|
||||
local removePrefix = "--\x0D\x0AContent-"
|
||||
if string.lower(string.sub(tostring(o[1]), 1, string.len(removePrefix))) == string.lower(removePrefix) then
|
||||
o[1] = string.sub(tostring(o[1]), string.len("--\x0D\x0A") + 1)
|
||||
end
|
||||
|
||||
@@ -32,47 +32,42 @@ function parse_email($email) {
|
||||
$a = strrpos($email, '@');
|
||||
return array('local' => substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1));
|
||||
}
|
||||
if (!function_exists('getallheaders')) {
|
||||
function getallheaders() {
|
||||
if (!is_array($_SERVER)) {
|
||||
return array();
|
||||
}
|
||||
$headers = array();
|
||||
foreach ($_SERVER as $name => $value) {
|
||||
if (substr($name, 0, 5) == 'HTTP_') {
|
||||
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
// rspamd metadata_exporter (multipart formatter):
|
||||
// - $_POST['metadata'] JSON with the rspamd metadata
|
||||
// - $_FILES['message'] raw RFC822 message
|
||||
if (empty($_POST['metadata']) || !isset($_FILES['message']) || $_FILES['message']['error'] !== UPLOAD_ERR_OK) {
|
||||
error_log("QUARANTINE: missing multipart parts from rspamd" . PHP_EOL);
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw_data_content = file_get_contents('php://input');
|
||||
$meta = json_decode($_POST['metadata'], true);
|
||||
if (!is_array($meta)) {
|
||||
error_log("QUARANTINE: cannot decode metadata JSON" . PHP_EOL);
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$raw_data_content = file_get_contents($_FILES['message']['tmp_name']);
|
||||
$raw_data = mb_convert_encoding($raw_data_content, 'HTML-ENTITIES', "UTF-8");
|
||||
$headers = getallheaders();
|
||||
$raw_size = (int)$_FILES['message']['size'];
|
||||
|
||||
$qid = $headers['X-Rspamd-Qid'];
|
||||
$fuzzy = $headers['X-Rspamd-Fuzzy'];
|
||||
$subject = iconv_mime_decode($headers['X-Rspamd-Subject']);
|
||||
$score = $headers['X-Rspamd-Score'];
|
||||
$rcpts = $headers['X-Rspamd-Rcpt'];
|
||||
$user = $headers['X-Rspamd-User'];
|
||||
$ip = $headers['X-Rspamd-Ip'];
|
||||
$action = $headers['X-Rspamd-Action'];
|
||||
$sender = $headers['X-Rspamd-From'];
|
||||
$symbols = $headers['X-Rspamd-Symbols'];
|
||||
|
||||
$raw_size = (int)$_SERVER['CONTENT_LENGTH'];
|
||||
$qid = $meta['qid'] ?? 'unknown';
|
||||
$subject = $meta['subject'] ?? '';
|
||||
$score = $meta['score'] ?? 0;
|
||||
$rcpts = $meta['rcpt'] ?? array();
|
||||
$user = $meta['user'] ?? 'unknown';
|
||||
$ip = $meta['ip'] ?? 'unknown';
|
||||
$action = $meta['action'] ?? 'no action';
|
||||
$sender = $meta['from'] ?? '';
|
||||
$symbols = json_encode($meta['symbols'] ?? array());
|
||||
$fuzzy = json_encode(is_array($meta['fuzzy'] ?? null) ? $meta['fuzzy'] : array());
|
||||
|
||||
if (empty($sender)) {
|
||||
error_log("QUARANTINE: Unknown sender, assuming empty-env-from@localhost" . PHP_EOL);
|
||||
$sender = 'empty-env-from@localhost';
|
||||
}
|
||||
|
||||
if ($fuzzy == 'unknown') {
|
||||
$fuzzy = '[]';
|
||||
}
|
||||
|
||||
try {
|
||||
$max_size = (int)$redis->Get('Q_MAX_SIZE');
|
||||
if (($max_size * 1048576) < $raw_size) {
|
||||
@@ -94,7 +89,7 @@ catch (RedisException $e) {
|
||||
$rcpt_final_mailboxes = array();
|
||||
|
||||
// Loop through all rcpts
|
||||
foreach (json_decode($rcpts, true) as $rcpt) {
|
||||
foreach ($rcpts as $rcpt) {
|
||||
// Remove tag
|
||||
$rcpt = preg_replace('/^(.*?)\+.*(@.*)$/', '$1$2', $rcpt);
|
||||
|
||||
@@ -158,12 +153,12 @@ foreach (json_decode($rcpts, true) as $rcpt) {
|
||||
|
||||
// Loop through all found gotos
|
||||
foreach ($gotos_array as $index => &$goto) {
|
||||
error_log("RCPT RESOVLER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
|
||||
$stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :goto AND (`active`= '1' OR `active`= '2');");
|
||||
$stmt->execute(array(':goto' => $goto));
|
||||
$username = $stmt->fetch(PDO::FETCH_ASSOC)['username'];
|
||||
if (!empty($username)) {
|
||||
error_log("RCPT RESOVLER: http pipe: mailbox found: " . $username . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: mailbox found: " . $username . PHP_EOL);
|
||||
// Current goto is a mailbox, save to rcpt_final_mailboxes if not a duplicate
|
||||
if (!in_array($username, $rcpt_final_mailboxes)) {
|
||||
$rcpt_final_mailboxes[] = $username;
|
||||
@@ -172,21 +167,21 @@ foreach (json_decode($rcpts, true) as $rcpt) {
|
||||
else {
|
||||
$parsed_goto = parse_email($goto);
|
||||
if (!$redis->hGet('DOMAIN_MAP', $parsed_goto['domain'])) {
|
||||
error_log("RCPT RESOVLER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
|
||||
error_log("RCPT RESOLVER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
|
||||
}
|
||||
else {
|
||||
$stmt = $pdo->prepare("SELECT `goto` FROM `alias` WHERE `address` = :goto AND `active` = '1'");
|
||||
$stmt->execute(array(':goto' => $goto));
|
||||
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['goto'];
|
||||
if ($goto_branch) {
|
||||
error_log("RCPT RESOVLER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
|
||||
$goto_branch_array = explode(',', $goto_branch);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
|
||||
$stmt->execute(array(':domain' => $parsed_goto['domain']));
|
||||
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
|
||||
if ($goto_branch) {
|
||||
error_log("RCPT RESOVLER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
|
||||
$goto_branch_array = array($parsed_goto['local'] . '@' . $goto_branch);
|
||||
}
|
||||
}
|
||||
@@ -208,11 +203,11 @@ foreach (json_decode($rcpts, true) as $rcpt) {
|
||||
// Force exit if loop cannot be solved
|
||||
// Postfix does not allow for alias loops, so this should never happen.
|
||||
$loop_c++;
|
||||
error_log("RCPT RESOVLER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
|
||||
}
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
error_log("RCPT RESOVLER: " . $e->getMessage() . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: " . $e->getMessage() . PHP_EOL);
|
||||
http_response_code(502);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -32,50 +32,46 @@ function parse_email($email) {
|
||||
$a = strrpos($email, '@');
|
||||
return array('local' => substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1));
|
||||
}
|
||||
if (!function_exists('getallheaders')) {
|
||||
function getallheaders() {
|
||||
if (!is_array($_SERVER)) {
|
||||
return array();
|
||||
}
|
||||
$headers = array();
|
||||
foreach ($_SERVER as $name => $value) {
|
||||
if (substr($name, 0, 5) == 'HTTP_') {
|
||||
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
|
||||
}
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
// rspamd metadata_exporter (multipart formatter): metadata JSON arrives as $_POST['metadata'].
|
||||
if (empty($_POST['metadata'])) {
|
||||
error_log("NOTIFY: missing metadata part from rspamd" . PHP_EOL);
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$headers = getallheaders();
|
||||
$json_body = json_decode(file_get_contents('php://input'));
|
||||
$meta = json_decode($_POST['metadata'], true);
|
||||
if (!is_array($meta)) {
|
||||
error_log("NOTIFY: cannot decode metadata JSON" . PHP_EOL);
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
$qid = $headers['X-Rspamd-Qid'];
|
||||
$rcpts = $headers['X-Rspamd-Rcpt'];
|
||||
$sender = $headers['X-Rspamd-From'];
|
||||
$ip = $headers['X-Rspamd-Ip'];
|
||||
$subject = iconv_mime_decode($headers['X-Rspamd-Subject']);
|
||||
$messageid= $json_body->message_id;
|
||||
$qid = $meta['qid'] ?? 'unknown';
|
||||
$rcpts = $meta['rcpt'] ?? array();
|
||||
$sender = $meta['from'] ?? '';
|
||||
$ip = $meta['ip'] ?? 'unknown';
|
||||
$subject = $meta['subject'] ?? '';
|
||||
$messageid= $meta['message_id'] ?? '';
|
||||
$priority = 0;
|
||||
|
||||
$symbols_array = json_decode($headers['X-Rspamd-Symbols'], true);
|
||||
$symbols_array = $meta['symbols'] ?? array();
|
||||
if (is_array($symbols_array)) {
|
||||
foreach ($symbols_array as $symbol) {
|
||||
if ($symbol['name'] == 'HAS_X_PRIO_ONE') {
|
||||
if (($symbol['name'] ?? null) == 'HAS_X_PRIO_ONE') {
|
||||
$priority = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$sender_address = $json_body->header_from[0];
|
||||
$sender_address = $meta['header_from'][0] ?? '';
|
||||
$sender_name = '-';
|
||||
if (preg_match('/(?<name>.*?)<(?<address>.*?)>/i', $sender_address, $matches)) {
|
||||
$sender_address = $matches['address'];
|
||||
$sender_name = trim($matches['name'], '"\' ');
|
||||
}
|
||||
|
||||
$to_address = $json_body->header_to[0];
|
||||
$to_address = $meta['header_to'][0] ?? '';
|
||||
$to_name = '-';
|
||||
if (preg_match('/(?<name>.*?)<(?<address>.*?)>/i', $to_address, $matches)) {
|
||||
$to_address = $matches['address'];
|
||||
@@ -85,7 +81,7 @@ if (preg_match('/(?<name>.*?)<(?<address>.*?)>/i', $to_address, $matches)) {
|
||||
$rcpt_final_mailboxes = array();
|
||||
|
||||
// Loop through all rcpts
|
||||
foreach (json_decode($rcpts, true) as $rcpt) {
|
||||
foreach ($rcpts as $rcpt) {
|
||||
// Remove tag
|
||||
$rcpt = preg_replace('/^(.*?)\+.*(@.*)$/', '$1$2', $rcpt);
|
||||
|
||||
@@ -143,12 +139,12 @@ foreach (json_decode($rcpts, true) as $rcpt) {
|
||||
|
||||
// Loop through all found gotos
|
||||
foreach ($gotos_array as $index => &$goto) {
|
||||
error_log("RCPT RESOVLER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
|
||||
$stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :goto AND (`active`= '1' OR `active`= '2');");
|
||||
$stmt->execute(array(':goto' => $goto));
|
||||
$username = $stmt->fetch(PDO::FETCH_ASSOC)['username'];
|
||||
if (!empty($username)) {
|
||||
error_log("RCPT RESOVLER: http pipe: mailbox found: " . $username . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: mailbox found: " . $username . PHP_EOL);
|
||||
// Current goto is a mailbox, save to rcpt_final_mailboxes if not a duplicate
|
||||
if (!in_array($username, $rcpt_final_mailboxes)) {
|
||||
$rcpt_final_mailboxes[] = $username;
|
||||
@@ -157,21 +153,21 @@ foreach (json_decode($rcpts, true) as $rcpt) {
|
||||
else {
|
||||
$parsed_goto = parse_email($goto);
|
||||
if (!$redis->hGet('DOMAIN_MAP', $parsed_goto['domain'])) {
|
||||
error_log("RCPT RESOVLER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
|
||||
error_log("RCPT RESOLVER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
|
||||
}
|
||||
else {
|
||||
$stmt = $pdo->prepare("SELECT `goto` FROM `alias` WHERE `address` = :goto AND `active` = '1'");
|
||||
$stmt->execute(array(':goto' => $goto));
|
||||
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['goto'];
|
||||
if ($goto_branch) {
|
||||
error_log("RCPT RESOVLER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
|
||||
$goto_branch_array = explode(',', $goto_branch);
|
||||
} else {
|
||||
$stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
|
||||
$stmt->execute(array(':domain' => $parsed_goto['domain']));
|
||||
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
|
||||
if ($goto_branch) {
|
||||
error_log("RCPT RESOVLER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
|
||||
$goto_branch_array = array($parsed_goto['local'] . '@' . $goto_branch);
|
||||
}
|
||||
}
|
||||
@@ -193,11 +189,11 @@ foreach (json_decode($rcpts, true) as $rcpt) {
|
||||
// Force exit if loop cannot be solved
|
||||
// Postfix does not allow for alias loops, so this should never happen.
|
||||
$loop_c++;
|
||||
error_log("RCPT RESOVLER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
|
||||
}
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
error_log("RCPT RESOVLER: " . $e->getMessage() . PHP_EOL);
|
||||
error_log("RCPT RESOLVER: " . $e->getMessage() . PHP_EOL);
|
||||
http_response_code(502);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.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 +43,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';
|
||||
@@ -115,7 +115,10 @@ $template_data = [
|
||||
'logo_dark_specs' => customize('get', 'main_logo_dark_specs'),
|
||||
'ip_check' => customize('get', 'ip_check'),
|
||||
'custom_login' => customize('get', 'custom_login'),
|
||||
'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',
|
||||
|
||||
+1033
-161
File diff suppressed because it is too large.
Load diff
@@ -1,10 +1,10 @@
|
||||
/*!
|
||||
* Bootstrap-select v1.14.0-beta2 (https://developer.snapappointments.com/bootstrap-select)
|
||||
*
|
||||
* Copyright 2012-2021 SnapAppointments, LLC
|
||||
* Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE)
|
||||
*/
|
||||
|
||||
/*!
|
||||
* Bootstrap-select v1.14.0-beta3 (https://developer.snapappointments.com/bootstrap-select)
|
||||
*
|
||||
* Copyright 2012-2022 SnapAppointments, LLC
|
||||
* Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE)
|
||||
*/
|
||||
|
||||
@-webkit-keyframes bs-notify-fadeOut {
|
||||
0% {
|
||||
opacity: 0.9;
|
||||
@@ -35,8 +35,7 @@ select.selectpicker {
|
||||
display: none !important;
|
||||
}
|
||||
.bootstrap-select {
|
||||
width: 220px \0;
|
||||
/*IE9 and below*/
|
||||
width: 220px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.bootstrap-select > .dropdown-toggle {
|
||||
|
||||
@@ -17,15 +17,15 @@ table.dataTable>tbody>tr.child ul.dtr-details>li {
|
||||
}
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover,
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover {
|
||||
background-color: #5e5e5e;
|
||||
background-color: var(--mc-dtr-hover-bg);
|
||||
}
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before,
|
||||
table.dataTable td.dt-control:before {
|
||||
background-color: #979797 !important;
|
||||
border: 1.5px solid #616161 !important;
|
||||
background-color: var(--mc-dtr-bg) !important;
|
||||
border: 1.5px solid var(--mc-dtr-border) !important;
|
||||
border-radius: 2px !important;
|
||||
color: #fff;
|
||||
color: var(--mc-dtr-color);
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
line-height: 1.25em;
|
||||
@@ -37,7 +37,7 @@ table.dataTable td.dt-control:before {
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before,
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before,
|
||||
table.dataTable td.dt-control:before {
|
||||
background-color: #979797 !important;
|
||||
background-color: var(--mc-dtr-active-bg) !important;
|
||||
}
|
||||
table.dataTable.table-striped>tbody>tr>td {
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -34,11 +34,44 @@
|
||||
url('/fonts/noto-sans-v12-latin_greek_cyrillic-700italic.woff') format('woff');
|
||||
}
|
||||
|
||||
/* theme tokens, override in css/themes/<name>/theme.css */
|
||||
:root {
|
||||
--mc-font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
--mc-font-size: 10.5pt;
|
||||
--mc-body-bg: #fbfbfb;
|
||||
--mc-primary: #0F688D;
|
||||
--mc-primary-border: #0d526d;
|
||||
--mc-info: #148DBC;
|
||||
--mc-info-border: #127ea8;
|
||||
--mc-border: #dfdfdf;
|
||||
--mc-surface: #fff;
|
||||
--mc-surface-alt: #f7f7f7;
|
||||
--mc-muted: #959595;
|
||||
--mc-hr: #ccc;
|
||||
--mc-progress-bg: #d5d5d5;
|
||||
--mc-placeholder: rgb(137 137 137);
|
||||
--mc-btn-outline-color: #000000;
|
||||
--mc-btn-outline-border: #cfcfcf;
|
||||
--mc-btn-outline-hover-bg: #f0f0f0;
|
||||
--mc-btn-outline-active-bg: #f0f0f0;
|
||||
--mc-btn-light-active-color: #fff;
|
||||
--mc-btn-light-active-bg: #555;
|
||||
--mc-btn-light-active-border: #4d4d4d;
|
||||
--mc-dtr-bg: #979797;
|
||||
--mc-dtr-border: #616161;
|
||||
--mc-dtr-color: #fff;
|
||||
--mc-dtr-hover-bg: #5e5e5e;
|
||||
--mc-dtr-active-bg: #979797;
|
||||
--mc-login-user: #28b62c;
|
||||
--mc-login-domainadmin: #ffc107;
|
||||
--mc-login-admin: #ff4136;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fbfbfb;
|
||||
background-color: var(--mc-body-bg);
|
||||
}
|
||||
#maxmsgsize { min-width: 80px; }
|
||||
#slider1 .slider-selection {
|
||||
@@ -86,17 +119,17 @@ body {
|
||||
max-height: 1000px
|
||||
}
|
||||
.nav-tabs .nav-link, .nav-tabs .nav-link.disabled, .nav-tabs .nav-link.disabled:hover, .nav-tabs .nav-link.disabled:focus {
|
||||
border-color: #dfdfdf;
|
||||
border-color: var(--mc-border);
|
||||
}
|
||||
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link {
|
||||
border-color: #dfdfdf;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
border-color: var(--mc-border);
|
||||
border-bottom: 1px solid var(--mc-surface);
|
||||
}
|
||||
.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {
|
||||
border-color: #dfdfdf;
|
||||
border-color: var(--mc-border);
|
||||
}
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid #dfdfdf;
|
||||
border-bottom: 1px solid var(--mc-border);
|
||||
}
|
||||
.bi {
|
||||
display: inline-block;
|
||||
@@ -164,13 +197,13 @@ body.modal-open {
|
||||
padding-right: inherit !important;
|
||||
}
|
||||
body {
|
||||
font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
font-size: 10.5pt;
|
||||
font-family: var(--mc-font-family);
|
||||
font-size: var(--mc-font-size);
|
||||
line-height: 1.5;
|
||||
}
|
||||
html {
|
||||
font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
font-size: 10.5pt;
|
||||
font-family: var(--mc-font-family);
|
||||
font-size: var(--mc-font-size);
|
||||
line-height: 1.5;
|
||||
}
|
||||
#mailcow-alert {
|
||||
@@ -206,7 +239,7 @@ legend {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.overlay {
|
||||
background: #fff;
|
||||
background: var(--mc-surface);
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
@@ -215,9 +248,6 @@ legend {
|
||||
.bootstrap-select.btn-group .no-results {
|
||||
display: none;
|
||||
}
|
||||
.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
|
||||
color: rgb(197, 197, 197) !important;
|
||||
}
|
||||
.haveibeenpwned {
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
@@ -239,7 +269,7 @@ legend {
|
||||
.footer {
|
||||
margin-top: 27px;
|
||||
margin-bottom: 20px;
|
||||
color: #959595;
|
||||
color: var(--mc-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -337,7 +367,7 @@ code {
|
||||
overflow-x: auto!important;
|
||||
}
|
||||
.well {
|
||||
border: 1px solid #dfdfdf;
|
||||
border: 1px solid var(--mc-border);
|
||||
background-color: #f9f9f9;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -356,47 +386,47 @@ button[aria-expanded='true'] > .caret {
|
||||
}
|
||||
|
||||
.list-group-details {
|
||||
background: #fff;
|
||||
background: var(--mc-surface);
|
||||
}
|
||||
.list-group-header {
|
||||
background: #f7f7f7;
|
||||
background: var(--mc-surface-alt);
|
||||
}
|
||||
|
||||
|
||||
.bg-primary, .alert-primary, .btn-primary {
|
||||
background-color: #0F688D !important;
|
||||
border-color: #0d526d !important;
|
||||
background-color: var(--mc-primary) !important;
|
||||
border-color: var(--mc-primary-border) !important;
|
||||
}
|
||||
.bg-info, .alert-info, .btn-info {
|
||||
background-color: #148DBC !important;
|
||||
border-color: #127ea8 !important;
|
||||
background-color: var(--mc-info) !important;
|
||||
border-color: var(--mc-info-border) !important;
|
||||
}
|
||||
|
||||
.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
|
||||
color: rgb(137 137 137)!important;
|
||||
color: var(--mc-placeholder) !important;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 16px;
|
||||
background-color: #d5d5d5;
|
||||
background-color: var(--mc-progress-bg);
|
||||
}
|
||||
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: #f0f0f0;
|
||||
background-color: var(--mc-btn-outline-hover-bg);
|
||||
}
|
||||
.btn.btn-outline-secondary {
|
||||
color: #000000 !important;
|
||||
border-color: #cfcfcf !important;
|
||||
color: var(--mc-btn-outline-color) !important;
|
||||
border-color: var(--mc-btn-outline-border) !important;
|
||||
}
|
||||
.btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show {
|
||||
background-color: #f0f0f0 !important;
|
||||
background-color: var(--mc-btn-outline-active-bg) !important;
|
||||
}
|
||||
.btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle {
|
||||
color: #fff;
|
||||
background-color: #555;
|
||||
color: var(--mc-btn-light-active-color);
|
||||
background-color: var(--mc-btn-light-active-bg);
|
||||
background-image: none;
|
||||
border-color: #4d4d4d;
|
||||
border-color: var(--mc-btn-light-active-border);
|
||||
}
|
||||
.btn-check:checked+.btn-light:focus, .btn-check:active+.btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, .show>.btn-light.dropdown-toggle:focus,
|
||||
.btn-check:focus+.btn-light, .btn-light:focus {
|
||||
@@ -412,6 +442,26 @@ button[aria-expanded='true'] > .caret {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* role marker on login pages, header icons differ too (color blindness) */
|
||||
.login-card::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 5px;
|
||||
background: var(--mc-login-color);
|
||||
border-radius: inherit;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
.login-user {
|
||||
--mc-login-color: var(--mc-login-user);
|
||||
}
|
||||
.login-domainadmin {
|
||||
--mc-login-color: var(--mc-login-domainadmin);
|
||||
}
|
||||
.login-admin {
|
||||
--mc-login-color: var(--mc-login-admin);
|
||||
}
|
||||
|
||||
.hr-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -423,7 +473,7 @@ button[aria-expanded='true'] > .caret {
|
||||
.hr-title::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
border-bottom: 1px solid #ccc;
|
||||
border-bottom: 1px solid var(--mc-hr);
|
||||
}
|
||||
|
||||
.hr-title:not(:empty)::before {
|
||||
|
||||
+3750
-2935
File diff suppressed because it is too large.
Load diff
@@ -1,62 +1,103 @@
|
||||
/* theme tokens, override in css/themes/<name>/dark.css */
|
||||
:root {
|
||||
--mc-body-bg: #1c1c1e;
|
||||
--mc-body-color: #f2f2f7;
|
||||
--mc-surface: #2c2c2e;
|
||||
--mc-surface-alt: #282828;
|
||||
--mc-text: #ccc;
|
||||
--mc-text-soft: #bbb;
|
||||
--mc-muted: #8e8e93;
|
||||
--mc-border: #555;
|
||||
--mc-tab-border: #444444;
|
||||
--mc-link: #6fc7e9;
|
||||
--mc-link-hover: #3daedb;
|
||||
--mc-btn-bg: #5e5e5e;
|
||||
--mc-btn-border: #4c4c4e;
|
||||
--mc-input-bg: #424242;
|
||||
--mc-input-color: #e2e2e2;
|
||||
--mc-input-border: #999;
|
||||
--mc-dropdown-bg: #424242;
|
||||
--mc-table-bg: #3a3a3a;
|
||||
--mc-table-stripe: #282828;
|
||||
--mc-table-stripe-row: #424242;
|
||||
--mc-table-hover: #343434;
|
||||
--mc-table-border: #5c5c5c;
|
||||
--mc-progress-bg: #555;
|
||||
--mc-placeholder: #d4d4d4;
|
||||
--mc-btn-outline-color: #e0e0e0;
|
||||
--mc-btn-outline-border: #7a7a7a;
|
||||
--mc-btn-outline-hover-bg: #5c5c5c;
|
||||
--mc-btn-outline-active-bg: #7a7a7a;
|
||||
--mc-btn-light-active-color: #f2f2f7;
|
||||
--mc-btn-light-active-bg: #242424;
|
||||
--mc-btn-light-active-border: #1c1c1e;
|
||||
--mc-btn-dark-bg: #242424;
|
||||
--mc-input-disabled-bg: #6e6e6e;
|
||||
--mc-input-disabled-color: #a8a8a8;
|
||||
--mc-list-header-bg: #444;
|
||||
--mc-list-details-bg: #555;
|
||||
--mc-chip-bg: #444;
|
||||
--mc-table-child-bg: #414141;
|
||||
--mc-dtr-bg: #7a7a7a;
|
||||
--mc-dtr-border: #5c5c5c;
|
||||
--mc-dtr-color: #e0e0e0;
|
||||
--mc-dtr-hover-bg: #7a7a7a;
|
||||
--mc-dtr-active-bg: #949494;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #1c1c1e;
|
||||
color: #f2f2f7;
|
||||
background-color: var(--mc-body-bg);
|
||||
color: var(--mc-body-color);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #2c2c2e;
|
||||
background-color: #2c2c2e;
|
||||
border: 1px solid var(--mc-surface);
|
||||
background-color: var(--mc-surface);
|
||||
}
|
||||
|
||||
legend {
|
||||
color: #f2f2f7;
|
||||
color: var(--mc-body-color);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
color: #8e8e93;
|
||||
background-color: #1c1c1e;
|
||||
color: var(--mc-muted);
|
||||
background-color: var(--mc-body-bg);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
--bs-card-color: #bbb;
|
||||
--bs-card-color: var(--mc-text-soft);
|
||||
}
|
||||
|
||||
.btn-secondary, .paginate_button, .page-link, .btn-light {
|
||||
color: #f2f2f7 !important;
|
||||
background-color: #5e5e5e !important;
|
||||
border-color: #4c4c4e !important;
|
||||
color: var(--mc-body-color) !important;
|
||||
background-color: var(--mc-btn-bg) !important;
|
||||
border-color: var(--mc-btn-border) !important;
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
color: #f2f2f7 !important;
|
||||
background-color: #242424 !important;
|
||||
border-color: #1c1c1e !important;
|
||||
}
|
||||
|
||||
.btn-secondary:focus, .btn-secondary:hover, .btn-group.open .dropdown-toggle.btn-secondary {
|
||||
background-color: #444444;
|
||||
border-color: #4c4c4e !important;
|
||||
color: #f2f2f7;
|
||||
color: var(--mc-body-color) !important;
|
||||
background-color: var(--mc-btn-dark-bg) !important;
|
||||
border-color: var(--mc-body-bg) !important;
|
||||
}
|
||||
|
||||
.btn-check:checked+.btn-secondary, .btn-check:active+.btn-secondary, .btn-secondary:active, .btn-secondary.active, .show>.btn-secondary.dropdown-toggle {
|
||||
border-color: #5e5e5e !important;
|
||||
border-color: var(--mc-btn-bg) !important;
|
||||
}
|
||||
|
||||
.alert-secondary {
|
||||
color: #f2f2f7 !important;
|
||||
background-color: #5e5e5e !important;
|
||||
border-color: #4c4c4e !important;
|
||||
color: var(--mc-body-color) !important;
|
||||
background-color: var(--mc-btn-bg) !important;
|
||||
border-color: var(--mc-btn-border) !important;
|
||||
}
|
||||
|
||||
.bg-secondary {
|
||||
color: #f2f2f7 !important;
|
||||
background-color: #5e5e5e !important;
|
||||
color: var(--mc-body-color) !important;
|
||||
background-color: var(--mc-btn-bg) !important;
|
||||
}
|
||||
|
||||
.alert-secondary, .alert-secondary a, .alert-secondary .alert-link {
|
||||
color: #f2f2f7;
|
||||
color: var(--mc-body-color);
|
||||
}
|
||||
|
||||
.page-item.active .page-link {
|
||||
@@ -65,18 +106,18 @@ legend {
|
||||
}
|
||||
|
||||
.btn-secondary:focus, .btn-secondary:hover, .btn-group.open .dropdown-toggle.btn-secondary {
|
||||
background-color: #5e5e5e;
|
||||
border-color: #4c4c4e !important;
|
||||
color: #f2f2f7;
|
||||
background-color: var(--mc-btn-bg);
|
||||
border-color: var(--mc-btn-border) !important;
|
||||
color: var(--mc-body-color);
|
||||
}
|
||||
|
||||
.btn-secondary:disabled, .btn-secondary.disabled {
|
||||
border-color: #5e5e5e !important;
|
||||
border-color: var(--mc-btn-bg) !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
--bs-modal-color: #bbb;
|
||||
background-color: #2c2c2e;
|
||||
--bs-modal-color: var(--mc-text-soft);
|
||||
background-color: var(--mc-surface);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -84,7 +125,7 @@ legend {
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
color: #bbb;
|
||||
color: var(--mc-text-soft);
|
||||
}
|
||||
|
||||
.modal .btn-close {
|
||||
@@ -92,12 +133,12 @@ legend {
|
||||
}
|
||||
|
||||
.navbar.bg-light {
|
||||
background-color: #1c1c1e !important;
|
||||
border-color: #2c2c2e;
|
||||
background-color: var(--mc-body-bg) !important;
|
||||
border-color: var(--mc-surface);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #8e8e93 !important;
|
||||
color: var(--mc-muted) !important;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link {
|
||||
@@ -105,23 +146,23 @@ legend {
|
||||
}
|
||||
|
||||
.nav-tabs, .nav-tabs .nav-link {
|
||||
border-color: #444444 !important;
|
||||
border-color: var(--mc-tab-border) !important;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus, .nav-tabs .nav-link.active {
|
||||
border-bottom-color: #1c1c1e !important;
|
||||
border-bottom-color: var(--mc-body-bg) !important;
|
||||
}
|
||||
|
||||
.card .nav-tabs .nav-link:not(.disabled):hover, .card .nav-tabs .nav-link:not(.disabled):focus, .card .nav-tabs .nav-link.active {
|
||||
border-bottom-color: #2c2c2e !important;
|
||||
border-bottom-color: var(--mc-surface) !important;
|
||||
}
|
||||
|
||||
.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
|
||||
color: #f2f2f7 !important;
|
||||
color: var(--mc-body-color) !important;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background-color: #424242;
|
||||
background-color: var(--mc-dropdown-bg);
|
||||
border: 1px solid #282828;
|
||||
}
|
||||
.dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover {
|
||||
@@ -132,79 +173,79 @@ legend {
|
||||
color: #fff;
|
||||
}
|
||||
.bootstrap-select>.dropdown-toggle.bs-placeholder, .bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
|
||||
color: #d4d4d4 !important;
|
||||
color: var(--mc-placeholder) !important;
|
||||
}
|
||||
tbody tr {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:focus, .navbar-default .navbar-nav>.open>a:hover {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
.navbar-default .navbar-nav>.active>a, .navbar-default .navbar-nav>.active>a:focus, .navbar-default .navbar-nav>.active>a:hover {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
.list-group-item {
|
||||
background-color: #282828;
|
||||
border: 1px solid #555;
|
||||
background-color: var(--mc-surface-alt);
|
||||
border: 1px solid var(--mc-border);
|
||||
}
|
||||
.table-striped>tbody>tr:nth-of-type(odd) {
|
||||
background-color: #424242;
|
||||
background-color: var(--mc-table-stripe-row);
|
||||
}
|
||||
table.dataTable>tbody>tr.child ul.dtr-details>li {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
.label.label-last-login {
|
||||
color: #ccc !important;
|
||||
color: var(--mc-text) !important;
|
||||
background-color: #555 !important;
|
||||
}
|
||||
.progress {
|
||||
background-color: #555;
|
||||
background-color: var(--mc-progress-bg);
|
||||
}
|
||||
.h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
div.numberedtextarea-number {
|
||||
color: #999;
|
||||
}
|
||||
.well {
|
||||
border: 1px solid #555;
|
||||
background-color: #282828;
|
||||
border: 1px solid var(--mc-border);
|
||||
background-color: var(--mc-surface-alt);
|
||||
}
|
||||
pre {
|
||||
color: #ccc;
|
||||
background-color: #282828;
|
||||
border: 1px solid #555;
|
||||
color: var(--mc-text);
|
||||
background-color: var(--mc-surface-alt);
|
||||
border: 1px solid var(--mc-border);
|
||||
}
|
||||
.form-control {
|
||||
background-color: transparent;
|
||||
}
|
||||
input.form-control, textarea.form-control {
|
||||
color: #e2e2e2 !important;
|
||||
background-color: #424242 !important;
|
||||
border: 1px solid #999;
|
||||
color: var(--mc-input-color) !important;
|
||||
background-color: var(--mc-input-bg) !important;
|
||||
border: 1px solid var(--mc-input-border);
|
||||
}
|
||||
input.form-control:focus, textarea.form-control {
|
||||
background-color: #424242 !important;
|
||||
background-color: var(--mc-input-bg) !important;
|
||||
}
|
||||
input.form-control:disabled, textarea.form-disabled {
|
||||
color: #a8a8a8 !important;
|
||||
background-color: #6e6e6e !important;
|
||||
color: var(--mc-input-disabled-color) !important;
|
||||
background-color: var(--mc-input-disabled-bg) !important;
|
||||
}
|
||||
.input-group-addon {
|
||||
color: #ccc;
|
||||
background-color: #424242 !important;
|
||||
border: 1px solid #999;
|
||||
color: var(--mc-text);
|
||||
background-color: var(--mc-input-bg) !important;
|
||||
border: 1px solid var(--mc-input-border);
|
||||
}
|
||||
.input-group-text {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
.dropdown-item {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
color: #616161 !important;
|
||||
@@ -214,9 +255,9 @@ input.form-control:disabled, textarea.form-disabled {
|
||||
background-color: #007aff;
|
||||
}
|
||||
.form-select {
|
||||
color: #e2e2e2!important;
|
||||
background-color: #424242!important;
|
||||
border: 1px solid #999;
|
||||
color: var(--mc-input-color)!important;
|
||||
background-color: var(--mc-input-bg)!important;
|
||||
border: 1px solid var(--mc-input-border);
|
||||
}
|
||||
|
||||
.responsive-tabs .card-header button[data-bs-toggle="collapse"] {
|
||||
@@ -240,15 +281,15 @@ input.form-control:disabled, textarea.form-disabled {
|
||||
|
||||
|
||||
a {
|
||||
color: #6fc7e9;
|
||||
color: var(--mc-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
color: #3daedb;
|
||||
color: var(--mc-link-hover);
|
||||
}
|
||||
|
||||
.breadcrumb-item.active {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
|
||||
|
||||
@@ -273,11 +314,11 @@ a:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
.accordion-button {
|
||||
color: #bbb;
|
||||
color: var(--mc-text-soft);
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
.accordion-button:not(.collapsed) {
|
||||
color: #6fc7e9;
|
||||
color: var(--mc-link);
|
||||
background-color: #2c2c2c;
|
||||
}
|
||||
.accordion-button::after {
|
||||
@@ -300,15 +341,15 @@ a:hover {
|
||||
}
|
||||
|
||||
.tag-box {
|
||||
background-color: #282828;
|
||||
border: 1px solid #555;
|
||||
background-color: var(--mc-surface-alt);
|
||||
border: 1px solid var(--mc-border);
|
||||
}
|
||||
.tag-input {
|
||||
color: #fff;
|
||||
background-color: #282828;
|
||||
background-color: var(--mc-surface-alt);
|
||||
}
|
||||
.tag-add {
|
||||
color: #ccc;
|
||||
color: var(--mc-text);
|
||||
}
|
||||
.tag-add:hover {
|
||||
color: #d1d1d1;
|
||||
@@ -318,19 +359,19 @@ a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
.btn-outline-secondary:hover {
|
||||
background-color: #5c5c5c;
|
||||
background-color: var(--mc-btn-outline-hover-bg);
|
||||
}
|
||||
.btn.btn-outline-secondary {
|
||||
color: #e0e0e0 !important;
|
||||
border-color: #7a7a7a !important;
|
||||
color: var(--mc-btn-outline-color) !important;
|
||||
border-color: var(--mc-btn-outline-border) !important;
|
||||
}
|
||||
.btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show {
|
||||
background-color: #7a7a7a !important;
|
||||
background-color: var(--mc-btn-outline-active-bg) !important;
|
||||
}
|
||||
.btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle {
|
||||
color: #f2f2f7 !important;
|
||||
background-color: #242424 !important;
|
||||
border-color: #1c1c1e !important;
|
||||
color: var(--mc-btn-light-active-color) !important;
|
||||
background-color: var(--mc-btn-light-active-bg) !important;
|
||||
border-color: var(--mc-btn-light-active-border) !important;
|
||||
}
|
||||
.btn-input-missing,
|
||||
.btn-input-missing:hover,
|
||||
@@ -348,16 +389,16 @@ a:hover {
|
||||
}
|
||||
|
||||
.list-group-details {
|
||||
background: #555;
|
||||
background: var(--mc-list-details-bg);
|
||||
}
|
||||
.list-group-header {
|
||||
background: #444;
|
||||
background: var(--mc-list-header-bg);
|
||||
}
|
||||
|
||||
span.mail-address-item {
|
||||
background-color: #444;
|
||||
background-color: var(--mc-chip-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid #555;
|
||||
border: 1px solid var(--mc-border);
|
||||
padding: 2px 7px;
|
||||
display: inline-block;
|
||||
margin: 2px 6px 2px 0;
|
||||
@@ -365,52 +406,52 @@ span.mail-address-item {
|
||||
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover,
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover {
|
||||
background-color: #7a7a7a !important;
|
||||
background-color: var(--mc-dtr-hover-bg) !important;
|
||||
}
|
||||
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before {
|
||||
background-color: #7a7a7a !important;
|
||||
border: 1.5px solid #5c5c5c !important;
|
||||
color: #e0e0e0 !important;
|
||||
background-color: var(--mc-dtr-bg) !important;
|
||||
border: 1.5px solid var(--mc-dtr-border) !important;
|
||||
color: var(--mc-dtr-color) !important;
|
||||
}
|
||||
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before,
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before {
|
||||
background-color: #949494;
|
||||
background-color: var(--mc-dtr-active-bg);
|
||||
}
|
||||
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>td.child,
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>th.child,
|
||||
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dataTables_empty {
|
||||
background-color: #414141;
|
||||
background-color: var(--mc-table-child-bg);
|
||||
}
|
||||
|
||||
table.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
|
||||
color: #ccc !important;
|
||||
color: var(--mc-text) !important;
|
||||
}
|
||||
|
||||
.table-secondary {
|
||||
--bs-table-bg: #282828;
|
||||
--bs-table-striped-bg: #343434;
|
||||
--bs-table-striped-color: #f2f2f7;
|
||||
--bs-table-striped-color: var(--mc-body-color);
|
||||
--bs-table-active-bg: #4c4c4c;
|
||||
--bs-table-active-color: #f2f2f7;
|
||||
--bs-table-active-color: var(--mc-body-color);
|
||||
--bs-table-hover-bg: #3a3a3a;
|
||||
--bs-table-hover-color: #f2f2f7;
|
||||
color: #ccc;
|
||||
--bs-table-hover-color: var(--mc-body-color);
|
||||
color: var(--mc-text);
|
||||
border-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.table-light {
|
||||
--bs-table-bg: #3a3a3a;
|
||||
--bs-table-striped-bg: #444444;
|
||||
--bs-table-striped-color: #f2f2f7;
|
||||
--bs-table-striped-color: var(--mc-body-color);
|
||||
--bs-table-active-bg: #5c5c5c;
|
||||
--bs-table-active-color: #f2f2f7;
|
||||
--bs-table-active-color: var(--mc-body-color);
|
||||
--bs-table-hover-bg: #4c4c4c;
|
||||
--bs-table-hover-color: #f2f2f7;
|
||||
color: #ccc;
|
||||
--bs-table-hover-color: var(--mc-body-color);
|
||||
color: var(--mc-text);
|
||||
border-color: #4c4c4c;
|
||||
}
|
||||
|
||||
@@ -430,23 +471,23 @@ table.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
|
||||
|
||||
.table-striped>tbody>tr:nth-of-type(odd)>td,
|
||||
.table-striped>tbody>tr:nth-of-type(odd)>th {
|
||||
background-color: #282828;
|
||||
background-color: var(--mc-table-stripe);
|
||||
}
|
||||
|
||||
.table-hover>tbody>tr:hover {
|
||||
background-color: #343434;
|
||||
background-color: var(--mc-table-hover);
|
||||
}
|
||||
|
||||
.table>:not(caption)>*>* {
|
||||
border-color: #5c5c5c;
|
||||
--bs-table-color-state:#bbb;
|
||||
--bs-table-bg: #3a3a3a;
|
||||
border-color: var(--mc-table-border);
|
||||
--bs-table-color-state:var(--mc-text-soft);
|
||||
--bs-table-bg: var(--mc-table-bg);
|
||||
}
|
||||
.text-muted {
|
||||
--bs-secondary-color: #8e8e93;
|
||||
--bs-secondary-color: var(--mc-muted);
|
||||
}
|
||||
input::placeholder {
|
||||
color: #8e8e93 !important;
|
||||
color: var(--mc-muted) !important;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/* servercow theme, dark mode, bundled after mailcow core dark mode */
|
||||
|
||||
:root {
|
||||
--sc-brown-hover: #806b55;
|
||||
--sc-brown-active: #8a745c;
|
||||
--sc-brown-soft: rgba(201, 180, 154, .18);
|
||||
--sc-brown-tint: rgba(201, 180, 154, .08);
|
||||
--sc-link: #c9b49a;
|
||||
--sc-danger-text: #ff8a8a;
|
||||
--sc-ink: #ece7e1;
|
||||
--sc-muted: #a89f94;
|
||||
--sc-placeholder: #8a8177;
|
||||
--sc-bg: #171513;
|
||||
--sc-surface: #211e1b;
|
||||
--sc-cap: #2a2622;
|
||||
--sc-addon: #2a2622;
|
||||
--sc-line: #463f38;
|
||||
--sc-divider: #34302b;
|
||||
--sc-cap-line: #3d3732;
|
||||
--sc-stripe: rgba(255, 255, 255, .025);
|
||||
--sc-hover: rgba(201, 180, 154, .08);
|
||||
--sc-focus-border: #8a7864;
|
||||
--sc-focus: rgba(201, 180, 154, .25);
|
||||
--sc-shadow: 0 0 10px 0 rgba(0, 0, 0, .5);
|
||||
--bs-body-color: #ece7e1;
|
||||
--bs-body-bg: #171513;
|
||||
/* core dark tokens, see css/themes/mailcow-darkmode.css */
|
||||
--mc-body-bg: var(--sc-bg);
|
||||
--mc-body-color: var(--sc-ink);
|
||||
--mc-surface: var(--sc-surface);
|
||||
--mc-surface-alt: var(--sc-cap);
|
||||
--mc-text: var(--sc-ink);
|
||||
--mc-text-soft: var(--sc-ink);
|
||||
--mc-muted: var(--sc-muted);
|
||||
--mc-border: var(--sc-divider);
|
||||
--mc-tab-border: var(--sc-divider);
|
||||
--mc-link: var(--sc-link);
|
||||
--mc-link-hover: #e0cfba;
|
||||
--mc-btn-bg: var(--sc-cap);
|
||||
--mc-btn-border: var(--sc-line);
|
||||
--mc-input-bg: var(--sc-bg);
|
||||
--mc-input-color: var(--sc-ink);
|
||||
--mc-input-border: var(--sc-line);
|
||||
--mc-dropdown-bg: var(--sc-surface);
|
||||
--mc-table-bg: transparent;
|
||||
--mc-table-stripe: var(--sc-stripe);
|
||||
--mc-table-stripe-row: transparent;
|
||||
--mc-table-hover: var(--sc-hover);
|
||||
--mc-table-border: var(--sc-divider);
|
||||
--mc-progress-bg: var(--sc-cap);
|
||||
--mc-placeholder: var(--sc-placeholder);
|
||||
--mc-hr: var(--sc-divider);
|
||||
--mc-btn-outline-color: var(--sc-ink);
|
||||
--mc-btn-outline-border: var(--sc-line);
|
||||
--mc-btn-outline-hover-bg: var(--sc-brown-tint);
|
||||
--mc-btn-outline-active-bg: var(--sc-brown);
|
||||
--mc-btn-light-active-color: #fff;
|
||||
--mc-btn-light-active-bg: var(--sc-brown);
|
||||
--mc-btn-light-active-border: var(--sc-brown);
|
||||
--mc-btn-dark-bg: var(--sc-brown);
|
||||
--mc-input-disabled-bg: var(--sc-cap);
|
||||
--mc-input-disabled-color: var(--sc-muted);
|
||||
--mc-list-header-bg: var(--sc-cap);
|
||||
--mc-list-details-bg: var(--sc-surface);
|
||||
--mc-chip-bg: var(--sc-cap);
|
||||
--mc-table-child-bg: var(--sc-surface);
|
||||
--mc-dtr-bg: var(--sc-cap);
|
||||
--mc-dtr-border: var(--sc-line);
|
||||
--mc-dtr-color: var(--sc-link);
|
||||
--mc-dtr-hover-bg: var(--sc-hover);
|
||||
--mc-dtr-active-bg: var(--sc-brown-soft);
|
||||
}
|
||||
|
||||
/* navbar */
|
||||
.navbar.bg-light {
|
||||
background-color: var(--sc-surface) !important;
|
||||
border-bottom-color: var(--sc-green);
|
||||
}
|
||||
.navbar-light .navbar-nav .nav-link {
|
||||
color: var(--sc-ink) !important;
|
||||
}
|
||||
.navbar-light .navbar-nav .nav-link:hover,
|
||||
.navbar-light .navbar-nav .nav-link:focus,
|
||||
.navbar-light .navbar-nav .nav-link.active,
|
||||
.navbar-light .navbar-nav .show > .nav-link {
|
||||
color: var(--sc-link) !important;
|
||||
}
|
||||
.dropdown-menu {
|
||||
border-color: var(--sc-cap-line);
|
||||
}
|
||||
.dropdown-item:hover, .dropdown-item:focus {
|
||||
color: var(--sc-link) !important;
|
||||
}
|
||||
|
||||
/* cards and modals */
|
||||
.card-header {
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-cap);
|
||||
border-color: var(--sc-cap-line);
|
||||
}
|
||||
.modal-content {
|
||||
background-color: var(--sc-surface);
|
||||
}
|
||||
.modal-header {
|
||||
border-color: var(--sc-cap-line);
|
||||
}
|
||||
.input-group-text {
|
||||
background-color: var(--sc-addon);
|
||||
}
|
||||
.form-control::file-selector-button {
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-cap);
|
||||
border-color: var(--sc-line);
|
||||
}
|
||||
.form-control:hover:not(:disabled):not([readonly])::file-selector-button {
|
||||
color: var(--sc-link);
|
||||
background-color: var(--sc-line);
|
||||
}
|
||||
.form-check-input:not(:checked) {
|
||||
background-color: var(--sc-bg);
|
||||
border-color: var(--sc-line);
|
||||
}
|
||||
kbd {
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-cap);
|
||||
}
|
||||
|
||||
/* buttons */
|
||||
.btn-secondary:hover, .btn-secondary:focus, .btn-light:hover, .btn-light:focus, .page-link:hover {
|
||||
color: var(--sc-link) !important;
|
||||
background-color: var(--sc-line) !important;
|
||||
}
|
||||
.btn-check:checked + .btn-secondary, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle,
|
||||
.page-item.active .page-link {
|
||||
color: #fff !important;
|
||||
background-color: var(--sc-brown) !important;
|
||||
border-color: var(--sc-brown) !important;
|
||||
}
|
||||
|
||||
/* tabs */
|
||||
.nav-tabs .nav-link {
|
||||
color: var(--sc-ink) !important;
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
.nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus,
|
||||
.card .nav-tabs .nav-link:not(.disabled):hover, .card .nav-tabs .nav-link:not(.disabled):focus {
|
||||
color: var(--sc-link) !important;
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
.nav-tabs .nav-link.active, .card .nav-tabs .nav-link.active {
|
||||
color: var(--sc-link) !important;
|
||||
border-bottom-color: var(--sc-link) !important;
|
||||
}
|
||||
|
||||
/* semantic text */
|
||||
.text-success {
|
||||
color: var(--sc-green) !important;
|
||||
}
|
||||
.text-warning {
|
||||
color: var(--sc-warning) !important;
|
||||
}
|
||||
|
||||
/* alerts, opaque dark tints (also used for floating notifications) */
|
||||
.alert-primary {
|
||||
color: #e6d9c9 !important;
|
||||
background-color: #3c342b !important;
|
||||
border-color: #5e4f3e !important;
|
||||
}
|
||||
.alert-info {
|
||||
color: #cfe9f6 !important;
|
||||
background-color: #2f3334 !important;
|
||||
border-color: #44535a !important;
|
||||
}
|
||||
.alert-success {
|
||||
color: #cdeea0;
|
||||
background-color: #2e3419;
|
||||
border-color: #405515;
|
||||
}
|
||||
.alert-danger {
|
||||
color: #f9c4c4;
|
||||
background-color: #402a28;
|
||||
border-color: #693a38;
|
||||
}
|
||||
.alert-warning {
|
||||
color: #f5d496;
|
||||
background-color: #3c2f19;
|
||||
border-color: #654815;
|
||||
}
|
||||
.alert-secondary, .alert-light {
|
||||
color: var(--sc-ink) !important;
|
||||
background-color: var(--sc-cap) !important;
|
||||
border-color: var(--sc-divider) !important;
|
||||
}
|
||||
|
||||
.alert .btn-close {
|
||||
filter: invert(1) grayscale(100%) brightness(200%);
|
||||
}
|
||||
|
||||
/* slider */
|
||||
.noUi-target {
|
||||
background: var(--sc-bg);
|
||||
border-color: var(--sc-line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.noUi-handle {
|
||||
background: var(--sc-cap);
|
||||
border-color: var(--sc-line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.noUi-handle::before, .noUi-handle::after {
|
||||
background: var(--sc-line);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,584 @@
|
||||
/* servercow theme for mailcow, based on the servercow customer portal */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local(''),
|
||||
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-regular.woff2') format('woff2'),
|
||||
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-regular.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local(''),
|
||||
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-italic.woff2') format('woff2'),
|
||||
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-italic.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: local(''),
|
||||
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-500.woff2') format('woff2'),
|
||||
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-500.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: local(''),
|
||||
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-600.woff2') format('woff2'),
|
||||
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-600.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Roboto Slab';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local(''),
|
||||
url('/css/themes/servercow/fonts/roboto-slab-v36-cyrillic_greek_latin-regular.woff2') format('woff2'),
|
||||
url('/css/themes/servercow/fonts/roboto-slab-v36-cyrillic_greek_latin-regular.woff') format('woff');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Roboto Slab';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: local(''),
|
||||
url('/css/themes/servercow/fonts/roboto-slab-v36-cyrillic_greek_latin-500.woff2') format('woff2'),
|
||||
url('/css/themes/servercow/fonts/roboto-slab-v36-cyrillic_greek_latin-500.woff') format('woff');
|
||||
}
|
||||
|
||||
/* tokens */
|
||||
:root {
|
||||
--sc-brown: #6F5D49;
|
||||
--sc-brown-hover: #5e4f3e;
|
||||
--sc-brown-active: #594a3a;
|
||||
--sc-brown-soft: rgba(111, 93, 73, .2);
|
||||
--sc-brown-tint: rgba(111, 93, 73, .08);
|
||||
--sc-link: #6F5D49;
|
||||
--sc-green: #8AD608;
|
||||
--sc-blue: #96D0EC;
|
||||
--sc-warning: #e3950b;
|
||||
--sc-danger: #ef6f6f;
|
||||
--sc-danger-hover: #e85e5e;
|
||||
--sc-danger-text: #c43c3c;
|
||||
--sc-ink: #212529;
|
||||
--sc-muted: #6c757d;
|
||||
--sc-placeholder: #6c757d;
|
||||
--sc-bg: #F6F8F8;
|
||||
--sc-surface: #ffffff;
|
||||
--sc-cap: #f7f7f7;
|
||||
--sc-addon: #e9ecef;
|
||||
--sc-line: #ced4da;
|
||||
--sc-divider: #dee2e6;
|
||||
--sc-cap-line: rgba(0, 0, 0, .175);
|
||||
--sc-stripe: rgba(111, 93, 73, .04);
|
||||
--sc-hover: rgba(111, 93, 73, .08);
|
||||
--sc-focus-border: #b7aea4;
|
||||
--sc-focus: rgba(111, 93, 73, .25);
|
||||
--sc-shadow: 0 0 10px 0 rgba(134, 134, 134, .3);
|
||||
--sc-font: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--sc-font-head: 'Roboto Slab', Georgia, serif;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-bg: #F6F8F8;
|
||||
--bs-body-font-family: var(--sc-font);
|
||||
/* core tokens, see css/build/014-mailcow.css */
|
||||
--mc-font-family: var(--sc-font);
|
||||
--mc-font-size: 10pt;
|
||||
--mc-body-bg: var(--sc-bg);
|
||||
--mc-primary: var(--sc-brown);
|
||||
--mc-primary-border: var(--sc-brown);
|
||||
--mc-info: var(--sc-blue);
|
||||
--mc-info-border: var(--sc-blue);
|
||||
--mc-border: var(--sc-divider);
|
||||
--mc-surface: var(--sc-surface);
|
||||
--mc-surface-alt: var(--sc-cap);
|
||||
--mc-muted: var(--sc-muted);
|
||||
--mc-hr: var(--sc-divider);
|
||||
--mc-progress-bg: var(--sc-addon);
|
||||
--mc-placeholder: var(--sc-placeholder);
|
||||
--mc-btn-outline-color: var(--sc-ink);
|
||||
--mc-btn-outline-border: var(--sc-line);
|
||||
--mc-btn-outline-hover-bg: var(--sc-brown-tint);
|
||||
--mc-btn-outline-active-bg: var(--sc-brown);
|
||||
--mc-btn-light-active-color: #fff;
|
||||
--mc-btn-light-active-bg: var(--sc-brown);
|
||||
--mc-btn-light-active-border: var(--sc-brown);
|
||||
--mc-dtr-bg: var(--sc-surface);
|
||||
--mc-dtr-border: var(--sc-line);
|
||||
--mc-dtr-color: var(--sc-brown);
|
||||
--mc-dtr-hover-bg: var(--sc-brown-tint);
|
||||
--mc-dtr-active-bg: var(--sc-brown-soft);
|
||||
--mc-login-user: var(--sc-green);
|
||||
--mc-login-domainadmin: #f5c400;
|
||||
--mc-login-admin: var(--sc-danger);
|
||||
}
|
||||
|
||||
/* base */
|
||||
body {
|
||||
color: var(--sc-ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6, legend, .modal-title {
|
||||
font-family: var(--sc-font-head);
|
||||
font-weight: 500;
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
a {
|
||||
color: var(--sc-link);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--sc-brown-hover);
|
||||
}
|
||||
.text-muted, .text-secondary {
|
||||
color: var(--sc-muted) !important;
|
||||
}
|
||||
::selection {
|
||||
background: var(--sc-brown-soft);
|
||||
}
|
||||
hr {
|
||||
border-color: var(--sc-divider);
|
||||
opacity: 1;
|
||||
}
|
||||
::placeholder {
|
||||
color: var(--sc-placeholder) !important;
|
||||
}
|
||||
|
||||
/* navbar, white with green line */
|
||||
.navbar.bg-light {
|
||||
background-color: var(--sc-surface) !important;
|
||||
border: 0;
|
||||
border-bottom: 5px solid var(--sc-green);
|
||||
}
|
||||
.navbar-light .navbar-nav .nav-link {
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
.navbar-light .navbar-nav .nav-link:hover,
|
||||
.navbar-light .navbar-nav .nav-link:focus,
|
||||
.navbar-light .navbar-nav .nav-link.active,
|
||||
.navbar-light .navbar-nav .show > .nav-link {
|
||||
color: var(--sc-brown);
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.navbar-nav .nav-link {
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
.dropdown-menu {
|
||||
border: 1px solid var(--sc-cap-line);
|
||||
border-radius: 0;
|
||||
box-shadow: var(--sc-shadow);
|
||||
}
|
||||
.dropdown-item {
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
.dropdown-item:hover, .dropdown-item:focus {
|
||||
color: var(--sc-brown) !important;
|
||||
background-color: var(--sc-brown-soft);
|
||||
}
|
||||
.dropdown-item.active, .dropdown-item:active {
|
||||
color: #fff;
|
||||
background-color: var(--sc-brown);
|
||||
}
|
||||
|
||||
/* cards, white with soft shadow */
|
||||
.card {
|
||||
border: 0 !important;
|
||||
border-radius: 0;
|
||||
background-color: var(--sc-surface);
|
||||
box-shadow: var(--sc-shadow);
|
||||
}
|
||||
.card-header, .card-header:first-child {
|
||||
font-family: var(--sc-font-head);
|
||||
font-weight: 500;
|
||||
font-size: 1.1rem;
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-cap);
|
||||
border-bottom: 1px solid var(--sc-cap-line);
|
||||
border-radius: 0;
|
||||
padding: .5rem 1rem;
|
||||
}
|
||||
.card-header .btn, .card-header .form-control, .card-header .form-select {
|
||||
font-family: var(--sc-font);
|
||||
font-size: .875rem;
|
||||
}
|
||||
.card-footer {
|
||||
background-color: var(--sc-cap);
|
||||
border-top: 1px solid var(--sc-cap-line);
|
||||
border-radius: 0;
|
||||
}
|
||||
.card .card {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--sc-divider) !important;
|
||||
}
|
||||
/* colored headers become a top line */
|
||||
.card:has(> .card-header.bg-danger) {
|
||||
border-top: 4px solid var(--sc-danger) !important;
|
||||
}
|
||||
.card-header.bg-danger, .card-header.bg-success, .card-header.bg-primary, .card-header.bg-info {
|
||||
color: var(--sc-ink) !important;
|
||||
background-color: var(--sc-cap) !important;
|
||||
}
|
||||
.list-group-item {
|
||||
border-color: var(--sc-divider);
|
||||
}
|
||||
|
||||
/* modals */
|
||||
.modal-content {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, .2);
|
||||
}
|
||||
.modal-header {
|
||||
background-color: var(--sc-cap);
|
||||
border-bottom: 1px solid var(--sc-cap-line);
|
||||
border-radius: 0;
|
||||
}
|
||||
.modal-footer {
|
||||
border-top: 1px solid var(--sc-divider);
|
||||
}
|
||||
|
||||
/* buttons, flat and square */
|
||||
.btn {
|
||||
border-width: 1px;
|
||||
border-radius: 0;
|
||||
font-weight: 400;
|
||||
transition: none;
|
||||
}
|
||||
.btn:not(.disabled):hover,
|
||||
.btn:not(.disabled):active {
|
||||
margin-top: 0;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.btn:focus, .btn-check:focus + .btn {
|
||||
box-shadow: 0 0 0 .2rem var(--sc-focus);
|
||||
}
|
||||
.btn-primary, .btn-primary:hover, .btn-primary:focus, .btn-primary:active {
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
|
||||
background-color: var(--sc-brown-hover) !important;
|
||||
border-color: var(--sc-brown-active) !important;
|
||||
}
|
||||
/* main call to action, portal primary */
|
||||
.btn-success, .btn-success:not(.disabled):hover, .btn-success:not(.disabled):active, .btn-success:focus,
|
||||
.btn-success:disabled, .btn-success.disabled {
|
||||
color: #fff;
|
||||
background-color: var(--sc-brown);
|
||||
border-color: var(--sc-brown);
|
||||
}
|
||||
.btn-success:hover, .btn-success:active, .btn-success:focus {
|
||||
background-color: var(--sc-brown-hover);
|
||||
border-color: var(--sc-brown-active);
|
||||
}
|
||||
.btn-danger, .btn-danger:not(.disabled):hover, .btn-danger:not(.disabled):active, .btn-danger:focus,
|
||||
.btn-danger:disabled, .btn-danger.disabled {
|
||||
color: #000;
|
||||
background-color: var(--sc-danger);
|
||||
border-color: var(--sc-danger);
|
||||
}
|
||||
.btn-danger:hover, .btn-danger:active, .btn-danger:focus {
|
||||
background-color: var(--sc-danger-hover);
|
||||
border-color: var(--sc-danger-hover);
|
||||
}
|
||||
.btn-warning, .btn-warning:not(.disabled):hover, .btn-warning:not(.disabled):active, .btn-warning:focus {
|
||||
color: #000;
|
||||
background-color: var(--sc-warning);
|
||||
border-color: var(--sc-warning);
|
||||
}
|
||||
.bg-info, .alert-info, .btn-info, .badge.bg-info > a {
|
||||
color: #000 !important;
|
||||
}
|
||||
.btn-secondary, .btn-secondary:not(.disabled):hover, .btn-secondary:not(.disabled):active, .btn-secondary:focus,
|
||||
.btn-light, .btn-light:not(.disabled):hover, .btn-light:not(.disabled):active, .btn-light:focus {
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-surface);
|
||||
border-color: var(--sc-line);
|
||||
}
|
||||
.btn-secondary:hover, .btn-secondary:active, .btn-secondary:focus,
|
||||
.btn-light:hover, .btn-light:focus {
|
||||
color: var(--sc-brown);
|
||||
background-color: var(--sc-brown-tint);
|
||||
border-color: var(--sc-line);
|
||||
}
|
||||
.btn-check:checked + .btn-secondary, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle {
|
||||
color: #fff;
|
||||
background-color: var(--sc-brown);
|
||||
border-color: var(--sc-brown);
|
||||
}
|
||||
.btn-check:checked + .btn-outline-secondary, .btn-outline-secondary.active {
|
||||
color: #fff !important;
|
||||
border-color: var(--sc-brown) !important;
|
||||
}
|
||||
.btn-outline-primary {
|
||||
color: var(--sc-brown);
|
||||
border-color: var(--sc-brown);
|
||||
}
|
||||
.btn-outline-primary:hover {
|
||||
color: #fff;
|
||||
background-color: var(--sc-brown);
|
||||
border-color: var(--sc-brown);
|
||||
}
|
||||
/* active toggle, e.g. tagging options */
|
||||
.btn-dark, .btn-dark:hover, .btn-dark:focus, .btn-dark:active {
|
||||
color: #fff;
|
||||
background-color: var(--sc-brown);
|
||||
border-color: var(--sc-brown);
|
||||
}
|
||||
.btn-xs, .btn-group-xs > .btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* forms */
|
||||
.form-control, .form-select {
|
||||
border-color: var(--sc-line);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-surface);
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
border-color: var(--sc-focus-border);
|
||||
box-shadow: 0 0 0 .25rem var(--sc-focus);
|
||||
}
|
||||
.form-control::file-selector-button {
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-addon);
|
||||
border-color: var(--sc-line);
|
||||
}
|
||||
.form-control:hover:not(:disabled):not([readonly])::file-selector-button {
|
||||
color: var(--sc-brown);
|
||||
background-color: var(--sc-brown-soft);
|
||||
}
|
||||
.input-group-text {
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-addon);
|
||||
border-color: var(--sc-line);
|
||||
border-radius: 0;
|
||||
}
|
||||
.input-group > :not(:first-child), .input-group > :not(:last-child) {
|
||||
border-radius: 0;
|
||||
}
|
||||
.form-check-input {
|
||||
border-color: var(--sc-line);
|
||||
}
|
||||
.form-check-input:checked {
|
||||
background-color: var(--sc-brown);
|
||||
border-color: var(--sc-brown);
|
||||
}
|
||||
.form-check-input:focus {
|
||||
border-color: var(--sc-focus-border);
|
||||
box-shadow: 0 0 0 .25rem var(--sc-focus);
|
||||
}
|
||||
.bootstrap-select > .dropdown-toggle {
|
||||
border-radius: 0;
|
||||
}
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder:hover,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder:focus,
|
||||
.bootstrap-select > .dropdown-toggle.bs-placeholder:active {
|
||||
color: var(--mc-placeholder);
|
||||
}
|
||||
.bootstrap-select .dropdown-menu li a.active, .bootstrap-select .dropdown-menu li a:active {
|
||||
color: #fff;
|
||||
background-color: var(--sc-brown);
|
||||
}
|
||||
div.numberedtextarea-number {
|
||||
color: var(--sc-muted);
|
||||
}
|
||||
|
||||
/* nav tabs */
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid var(--sc-divider);
|
||||
}
|
||||
.nav-tabs .nav-link,
|
||||
.nav-tabs .nav-link.disabled, .nav-tabs .nav-link.disabled:hover, .nav-tabs .nav-link.disabled:focus {
|
||||
margin-top: 0;
|
||||
color: var(--sc-ink);
|
||||
border: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
}
|
||||
.nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus {
|
||||
padding-bottom: .5rem;
|
||||
color: var(--sc-brown);
|
||||
}
|
||||
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link,
|
||||
.nav-tabs .nav-link:not(.disabled).active {
|
||||
padding-bottom: .5rem;
|
||||
color: var(--sc-brown);
|
||||
background: none;
|
||||
border-bottom: 3px solid var(--sc-brown);
|
||||
}
|
||||
.nav-pills .nav-link {
|
||||
border-radius: 0;
|
||||
color: var(--sc-ink);
|
||||
}
|
||||
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
|
||||
background-color: var(--sc-brown);
|
||||
}
|
||||
|
||||
/* tables */
|
||||
.table {
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-striped-bg: var(--sc-stripe);
|
||||
--bs-table-striped-color: var(--sc-ink);
|
||||
--bs-table-hover-color: var(--sc-ink);
|
||||
--bs-table-hover-bg: var(--sc-hover);
|
||||
color: var(--sc-ink);
|
||||
border-color: var(--sc-divider);
|
||||
}
|
||||
.table > thead > tr > th, table.dataTable thead th {
|
||||
color: var(--sc-ink);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--sc-line);
|
||||
}
|
||||
table.dataTable.table-striped > tbody > tr.odd > * {
|
||||
box-shadow: inset 0 0 0 9999px var(--sc-stripe);
|
||||
}
|
||||
table.dataTable.table-hover > tbody > tr:hover > * {
|
||||
box-shadow: inset 0 0 0 9999px var(--sc-hover);
|
||||
}
|
||||
.table > :not(:first-child) {
|
||||
border-top: 0;
|
||||
}
|
||||
.dataTables_wrapper .dataTables_filter input,
|
||||
.dataTables_wrapper .dataTables_length select {
|
||||
border: 1px solid var(--sc-line);
|
||||
border-radius: 0;
|
||||
}
|
||||
.pagination > li > a, .pagination > li > span, .pagination .page-link,
|
||||
.pagination > li > a:hover, .pagination > li > a:focus, .pagination > li > a:active {
|
||||
top: 0;
|
||||
color: var(--sc-brown);
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
background-color: var(--sc-surface);
|
||||
border: 1px solid var(--sc-divider);
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.pagination > li > a:hover, .pagination > li > a:focus {
|
||||
background-color: var(--sc-brown-tint);
|
||||
}
|
||||
.pagination > .disabled > a, .pagination > .disabled > a:hover, .pagination > .disabled > a:active,
|
||||
.page-item.disabled .page-link {
|
||||
color: var(--sc-muted);
|
||||
background-color: var(--sc-surface);
|
||||
border-width: 1px;
|
||||
border-color: var(--sc-divider);
|
||||
}
|
||||
.page-item.active .page-link {
|
||||
color: #fff;
|
||||
background-color: var(--sc-brown);
|
||||
border-color: var(--sc-brown);
|
||||
}
|
||||
.breadcrumb {
|
||||
border: 1px solid var(--sc-divider);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* alerts like the portal */
|
||||
.alert {
|
||||
border: 1px solid;
|
||||
border-radius: 0;
|
||||
}
|
||||
.alert-primary {
|
||||
color: #43382c !important;
|
||||
background-color: #e2dfdb !important;
|
||||
border-color: #d4cec8 !important;
|
||||
}
|
||||
.alert-info {
|
||||
color: #3c535e !important;
|
||||
background-color: #eaf6fb !important;
|
||||
border-color: #e0f1f9 !important;
|
||||
}
|
||||
.alert-success {
|
||||
color: #375603;
|
||||
background-color: #e8f7ce;
|
||||
border-color: #dcf3b5;
|
||||
}
|
||||
.alert-danger {
|
||||
color: #8f4343;
|
||||
background-color: #fce2e2;
|
||||
border-color: #fad4d4;
|
||||
}
|
||||
.alert-warning {
|
||||
color: #885907;
|
||||
background-color: #f9eace;
|
||||
border-color: #f7dfb6;
|
||||
}
|
||||
.alert-secondary, .alert-light {
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-cap);
|
||||
border-color: var(--sc-divider);
|
||||
}
|
||||
.alert .alert-link {
|
||||
color: inherit;
|
||||
}
|
||||
.alert .btn-close {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
/* badges and semantic colors, readable text on top */
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
border-radius: 0;
|
||||
}
|
||||
.bg-success {
|
||||
color: #000 !important;
|
||||
background-color: var(--sc-green) !important;
|
||||
}
|
||||
.bg-warning {
|
||||
color: #000 !important;
|
||||
background-color: var(--sc-warning) !important;
|
||||
}
|
||||
.bg-danger {
|
||||
color: #000 !important;
|
||||
background-color: var(--sc-danger) !important;
|
||||
}
|
||||
.badge.bg-secondary, .badge.bg-light {
|
||||
color: var(--sc-ink);
|
||||
background-color: var(--sc-addon) !important;
|
||||
}
|
||||
.text-success {
|
||||
color: #4f8a0b !important;
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--sc-danger-text) !important;
|
||||
}
|
||||
.text-warning {
|
||||
color: #9a6100 !important;
|
||||
}
|
||||
.progress {
|
||||
border-radius: 0;
|
||||
}
|
||||
.progress-bar {
|
||||
color: #000;
|
||||
background-color: var(--sc-green);
|
||||
}
|
||||
.progress-bar.bg-danger, .progress-bar-danger {
|
||||
color: #000;
|
||||
background-color: var(--sc-danger) !important;
|
||||
}
|
||||
.progress-bar-warning {
|
||||
color: #000;
|
||||
background-color: var(--sc-warning) !important;
|
||||
}
|
||||
|
||||
|
||||
/* footer */
|
||||
.footer, .footer a {
|
||||
color: var(--sc-muted);
|
||||
}
|
||||
.footer hr {
|
||||
border-color: var(--sc-divider);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "servercow",
|
||||
"description": "Based on the servercow theme"
|
||||
}
|
||||
@@ -9,6 +9,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.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 +43,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';
|
||||
+9
-1
@@ -204,9 +204,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 +231,7 @@ else {
|
||||
$template_data['access_denied'] = true;
|
||||
}
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/edit.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
|
||||
@@ -233,5 +240,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';
|
||||
@@ -380,13 +380,13 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
|
||||
else {
|
||||
$state = state_nomatch;
|
||||
}
|
||||
$state .= '<br />' . $current[$data_field[$current['type']]];
|
||||
$state .= '<br />' . htmlspecialchars($current[$data_field[$current['type']]]);
|
||||
}
|
||||
if ($current['type'] == 'TXT' &&
|
||||
stripos($current['txt'], 'v=dmarc') === 0 &&
|
||||
$record[2] == $dmarc_link) {
|
||||
$current['txt'] = str_replace(' ', '', $current['txt']);
|
||||
$state = $current[$data_field[$current['type']]] . state_optional;
|
||||
$state = htmlspecialchars($current[$data_field[$current['type']]]) . state_optional;
|
||||
}
|
||||
elseif ($current['type'] == 'TXT' &&
|
||||
stripos($current['txt'], 'v=spf') === 0 &&
|
||||
@@ -396,7 +396,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
|
||||
if (in_array($ip, $rslt) && in_array(expand_ipv6($ip6), $rslt)) {
|
||||
$state = state_good;
|
||||
}
|
||||
$state .= '<br />' . $current[$data_field[$current['type']]] . state_optional;
|
||||
$state .= '<br />' . htmlspecialchars($current[$data_field[$current['type']]]) . state_optional;
|
||||
}
|
||||
elseif ($current['type'] == 'TXT' &&
|
||||
stripos($current['txt'], 'v=dkim') === 0 &&
|
||||
@@ -426,7 +426,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
|
||||
if ($state == state_nomatch) {
|
||||
$state = array();
|
||||
foreach ($currents as $current) {
|
||||
$state[] = $current[$data_field[$current['type']]];
|
||||
$state[] = htmlspecialchars($current[$data_field[$current['type']]]);
|
||||
}
|
||||
$state = implode('<br />', $state);
|
||||
}
|
||||
@@ -436,12 +436,22 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
|
||||
<td>%s</td>
|
||||
<td class="dns-found">%s</td>
|
||||
<td class="dns-recommended">%s</td>
|
||||
</tr>', $record[0], $record[1], $record[2], $state);
|
||||
</tr>', htmlspecialchars($record[0]), htmlspecialchars($record[1]), $record[2], $state);
|
||||
$record[3] = explode('<br />', $state);
|
||||
}
|
||||
|
||||
unset($record);
|
||||
|
||||
// Make a hostname RHS absolute so it is not read relative to $ORIGIN.
|
||||
// Already-absolute names, the SRV root target "." and IP addresses are left alone.
|
||||
$absolutize = function($host) {
|
||||
$host = trim($host);
|
||||
if ($host === '' || $host === '.' || substr($host, -1) === '.' || filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
return $host;
|
||||
}
|
||||
return $host . '.';
|
||||
};
|
||||
|
||||
$dns_data = sprintf("\$ORIGIN %s.\n", $domain);
|
||||
foreach ($records as $record) {
|
||||
if ($domain == substr($record[0], -strlen($domain))) {
|
||||
@@ -462,22 +472,32 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
|
||||
$val = str_replace(state_optional, '', $val);
|
||||
$val = str_replace(state_good, '', $val);
|
||||
if (strlen($val) > 0) {
|
||||
// these are all TXT values, their RHS is a character string, not a name
|
||||
$vals[] = sprintf("%s\tIN\t%s\t%s\n", $label, $record[1], $val);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($record[1] == 'MX' || $record[1] == 'CNAME') {
|
||||
$val = $absolutize($val);
|
||||
}
|
||||
elseif ($record[1] == 'SRV') {
|
||||
// format here is "target port"; only the target is a name
|
||||
$parts = explode(' ', $val, 2);
|
||||
$parts[0] = $absolutize($parts[0]);
|
||||
$val = implode(' ', $parts);
|
||||
}
|
||||
$vals[] = sprintf("%s\tIN\t%s\t%s\n", $label, $record[1], $val);
|
||||
}
|
||||
|
||||
foreach ($vals as $val) {
|
||||
$dns_data .= str_replace($domain, $domain . '.', $val);
|
||||
$dns_data .= $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
</table>
|
||||
<a id='download-zonefile' class="btn btn-sm btn-secondary visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline mb-4" style="margin-top:10px" data-zonefile="<?=base64_encode($dns_data);?>" download='<?=$_GET['domain'];?>.txt' type='text/csv'>Download</a>
|
||||
<a id='download-zonefile' class="btn btn-sm btn-secondary visible-xs-block visible-sm-inline visible-md-inline visible-lg-inline mb-4" style="margin-top:10px" data-zonefile="<?=base64_encode($dns_data);?>" download='<?=htmlspecialchars($_GET['domain']);?>.txt' type='text/csv'>Download</a>
|
||||
<script>
|
||||
var zonefile_dl_link = document.getElementById('download-zonefile');
|
||||
var zonefile = atob(zonefile_dl_link.getAttribute('data-zonefile'));
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Admins only
|
||||
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(array('status' => 'error', 'message' => 'access denied'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$owner = $GLOBALS['MAILCOW_GIT_OWNER'];
|
||||
$repo = $GLOBALS['MAILCOW_GIT_REPO'];
|
||||
$current = $GLOBALS['MAILCOW_GIT_VERSION'];
|
||||
|
||||
// Cache key is bound to the running version, so an update invalidates it naturally
|
||||
$cache_key = 'MAILCOW_UPDATE_CHECK/' . $current;
|
||||
|
||||
// Serve cached result if present (one GitHub call per TTL, not one per page load)
|
||||
$cached = $redis->get($cache_key);
|
||||
if ($cached !== false) {
|
||||
echo $cached;
|
||||
exit;
|
||||
}
|
||||
|
||||
// GitHub requires a User-Agent and rate-limits unauthenticated requests to 60/h per IP
|
||||
function github_get($url) {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, array(
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_USERAGENT => 'mailcow-update-check',
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => array('Accept: application/vnd.github+json'),
|
||||
));
|
||||
$body = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($body === false || $code !== 200) return false;
|
||||
$json = json_decode($body, true);
|
||||
return is_array($json) ? $json : false;
|
||||
}
|
||||
|
||||
$latest = github_get("https://api.github.com/repos/{$owner}/{$repo}/releases/latest");
|
||||
$release = github_get("https://api.github.com/repos/{$owner}/{$repo}/releases/tags/{$current}");
|
||||
|
||||
// Any failure (rate limit, offline, unexpected payload) -> honest error, cached briefly
|
||||
if ($latest === false || $release === false ||
|
||||
empty($latest['tag_name']) || empty($latest['created_at']) || empty($release['created_at'])) {
|
||||
$result = json_encode(array('status' => 'error', 'message' => 'could not reach GitHub API'));
|
||||
$redis->setex($cache_key, 300, $result);
|
||||
echo $result;
|
||||
exit;
|
||||
}
|
||||
|
||||
$date_latest = strtotime($latest['created_at']);
|
||||
$date_current = strtotime($release['created_at']);
|
||||
|
||||
if ($date_latest <= $date_current) {
|
||||
$result = json_encode(array('status' => 'no_update'));
|
||||
} else {
|
||||
$result = json_encode(array('status' => 'update_available', 'latest_tag' => $latest['tag_name']));
|
||||
}
|
||||
|
||||
// Cache the positive result for an hour
|
||||
$redis->setex($cache_key, 3600, $result);
|
||||
echo $result;
|
||||
@@ -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,7 @@ $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']),
|
||||
'docker_timeout' => $DOCKER_TIMEOUT,
|
||||
'session_lifetime' => (int)$SESSION_LIFETIME,
|
||||
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -287,6 +287,8 @@ function user_login($user, $pass, $extra = null){
|
||||
return false;
|
||||
}
|
||||
|
||||
$row['attributes'] = json_decode($row['attributes'], true);
|
||||
|
||||
// check for tfa authenticators
|
||||
$authenticators = get_tfa($user);
|
||||
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
|
||||
@@ -343,6 +345,8 @@ function user_login($user, $pass, $extra = null){
|
||||
return false;
|
||||
}
|
||||
|
||||
$row['attributes'] = json_decode($row['attributes'], true);
|
||||
|
||||
// check for tfa authenticators
|
||||
$authenticators = get_tfa($user);
|
||||
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
|
||||
|
||||
@@ -233,6 +233,38 @@ function customize($_action, $_item, $_data = null) {
|
||||
'msg' => 'custom_login_modified'
|
||||
);
|
||||
break;
|
||||
case 'ui_theme':
|
||||
$ui_theme = is_array($_data['ui_theme']) ? (string)reset($_data['ui_theme']) : (string)$_data['ui_theme'];
|
||||
if ($ui_theme !== '' && !array_key_exists($ui_theme, customize('get', 'ui_themes'))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_item, $_data),
|
||||
'msg' => 'ui_theme_invalid'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if ($ui_theme === '') {
|
||||
$redis->del('UI_THEME');
|
||||
}
|
||||
else {
|
||||
$redis->set('UI_THEME', $ui_theme);
|
||||
}
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_item, $_data),
|
||||
'msg' => array('redis_error', $e)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_item, $_data),
|
||||
'msg' => 'ui_theme_modified'
|
||||
);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
@@ -402,6 +434,42 @@ function customize($_action, $_item, $_data = null) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'ui_themes':
|
||||
// theme folders in css/themes/<name>/ with a theme.css
|
||||
$themes = array();
|
||||
foreach (glob('/web/css/themes/*/theme.css') as $theme_css) {
|
||||
$theme_dir = dirname($theme_css);
|
||||
$theme = basename($theme_dir);
|
||||
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $theme)) {
|
||||
continue;
|
||||
}
|
||||
$meta = json_decode(@file_get_contents($theme_dir . '/theme.json'), true);
|
||||
$themes[$theme] = array(
|
||||
'name' => (!empty($meta['name'])) ? $meta['name'] : $theme,
|
||||
'description' => (!empty($meta['description'])) ? $meta['description'] : '',
|
||||
'dark' => file_exists($theme_dir . '/dark.css')
|
||||
);
|
||||
}
|
||||
ksort($themes);
|
||||
return $themes;
|
||||
break;
|
||||
case 'ui_theme':
|
||||
try {
|
||||
$ui_theme = $redis->get('UI_THEME');
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_item, $_data),
|
||||
'msg' => array('redis_error', $e)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (empty($ui_theme) || !preg_match('/^[a-zA-Z0-9_-]+$/', $ui_theme) || !file_exists('/web/css/themes/' . $ui_theme . '/theme.css')) {
|
||||
return '';
|
||||
}
|
||||
return $ui_theme;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
@@ -429,14 +434,25 @@ function domain_admin_sso($_action, $_data) {
|
||||
|
||||
switch ($_action) {
|
||||
case 'check':
|
||||
$token = $_data;
|
||||
$token = preg_replace('/[^a-zA-Z0-9-]/', '', $_data);
|
||||
|
||||
$stmt = $pdo->prepare("SELECT `t1`.`username` FROM `da_sso` AS `t1` JOIN `admin` AS `t2` ON `t1`.`username` = `t2`.`username` WHERE `t1`.`token` = :token AND `t1`.`created` > DATE_SUB(NOW(), INTERVAL '30' SECOND) AND `t2`.`active` = 1 AND `t2`.`superadmin` = 0;");
|
||||
$stmt->execute(array(
|
||||
':token' => preg_replace('/[^a-zA-Z0-9-]/', '', $token)
|
||||
':token' => $token
|
||||
));
|
||||
$return = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
return empty($return['username']) ? false : $return['username'];
|
||||
if (empty($return['username'])) {
|
||||
return false;
|
||||
}
|
||||
// single-use: consume the token; if a concurrent request already used it, deny
|
||||
$del = $pdo->prepare("DELETE FROM `da_sso` WHERE `token` = :token");
|
||||
$del->execute(array(
|
||||
':token' => $token
|
||||
));
|
||||
if ($del->rowCount() < 1) {
|
||||
return false;
|
||||
}
|
||||
return $return['username'];
|
||||
case 'issue':
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
$_SESSION['return'][] = array(
|
||||
|
||||
@@ -108,6 +108,14 @@ function fwdhost($_action, $_data = null) {
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$hosts = (array)$_data['forwardinghost'];
|
||||
foreach ($hosts as $host) {
|
||||
try {
|
||||
|
||||
@@ -53,6 +53,39 @@ function valid_network($network) {
|
||||
function valid_hostname($hostname) {
|
||||
return filter_var($hostname, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME);
|
||||
}
|
||||
// Validates a browser-style Origin: scheme://host[:port], no path/query/fragment/credentials
|
||||
function valid_origin($origin) {
|
||||
$parts = parse_url($origin);
|
||||
if ($parts === false || !isset($parts['scheme']) || !isset($parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
if (!in_array(strtolower($parts['scheme']), array('http', 'https'), true)) {
|
||||
return false;
|
||||
}
|
||||
if (isset($parts['user']) || isset($parts['pass']) || isset($parts['path']) || isset($parts['query']) || isset($parts['fragment'])) {
|
||||
return false;
|
||||
}
|
||||
$host = $parts['host'];
|
||||
$host_without_brackets = trim($host, '[]');
|
||||
if (!valid_hostname($host_without_brackets) && !filter_var($host_without_brackets, FILTER_VALIDATE_IP)) {
|
||||
return false;
|
||||
}
|
||||
// Reject anything that doesn't round-trip to the exact same origin (e.g. stray trailing slash)
|
||||
$port = isset($parts['port']) ? ':' . $parts['port'] : '';
|
||||
$rebuilt = strtolower($parts['scheme']) . '://' . strtolower($host) . $port;
|
||||
return strtolower($origin) === $rebuilt;
|
||||
}
|
||||
// Bring an origin into the exact form a browser sends it (scheme://host[:port], lowercase).
|
||||
function normalize_cors_origin($origin) {
|
||||
$origin = trim($origin);
|
||||
if ($origin === '*') {
|
||||
return '*';
|
||||
}
|
||||
if ($origin !== '' && !preg_match('~^[a-z][a-z0-9+.-]*://~i', $origin)) {
|
||||
$origin = 'https://' . $origin;
|
||||
}
|
||||
return valid_origin($origin) ? strtolower($origin) : false;
|
||||
}
|
||||
// Thanks to https://stackoverflow.com/a/49373789
|
||||
// Validates exact ip matches and ip-in-cidr, ipv4 and ipv6
|
||||
function ip_acl($ip, $networks) {
|
||||
@@ -558,7 +591,7 @@ function logger($_data = false) {
|
||||
$type = $return['type'];
|
||||
$msg = null;
|
||||
if (isset($return['msg'])) {
|
||||
$msg = json_encode($return['msg'], JSON_UNESCAPED_UNICODE);
|
||||
$msg = json_encode($return['msg'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
}
|
||||
$call = null;
|
||||
if (isset($return['log'])) {
|
||||
@@ -600,6 +633,18 @@ function logger($_data = false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
function is_local_mailcow_domain($domain) {
|
||||
// True if domain is a locally managed, active primary or alias domain
|
||||
global $pdo;
|
||||
$domain = idn_to_ascii($domain, 0, INTL_IDNA_VARIANT_UTS46);
|
||||
if (empty($domain)) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT 1 FROM `domain` WHERE `domain` = :d AND `active` = 1
|
||||
UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d2 AND `active` = 1 LIMIT 1");
|
||||
$stmt->execute(array(':d' => $domain, ':d2' => $domain));
|
||||
return (bool)$stmt->fetchColumn();
|
||||
}
|
||||
function hasDomainAccess($username, $role, $domain) {
|
||||
global $pdo;
|
||||
if (empty($domain) || !is_valid_domain_name($domain)) {
|
||||
@@ -2283,10 +2328,13 @@ function cors($action, $data = null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed_origins = isset($data['allowed_origins']) ? $data['allowed_origins'] : array($_SERVER['SERVER_NAME']);
|
||||
$allowed_origins = isset($data['allowed_origins']) ? $data['allowed_origins'] : array(getBaseURL());
|
||||
$allowed_origins = !is_array($allowed_origins) ? array_filter(array_map('trim', explode("\n", $allowed_origins))) : $allowed_origins;
|
||||
foreach ($allowed_origins as $origin) {
|
||||
if (!filter_var($origin, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) && $origin != '*') {
|
||||
foreach ($allowed_origins as &$origin) {
|
||||
if ($origin === '*') {
|
||||
continue;
|
||||
}
|
||||
if (!valid_origin($origin)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $action, $data),
|
||||
@@ -2294,7 +2342,10 @@ function cors($action, $data = null) {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// browsers always send a lowercase scheme/host in the Origin header, so normalize to match
|
||||
$origin = strtolower($origin);
|
||||
}
|
||||
unset($origin);
|
||||
|
||||
$allowed_methods = isset($data['allowed_methods']) ? $data['allowed_methods'] : array('GET', 'POST', 'PUT', 'DELETE');
|
||||
$allowed_methods = !is_array($allowed_methods) ? array_map('trim', preg_split( "/( |,|;|\n)/", $allowed_methods)) : $allowed_methods;
|
||||
@@ -2342,26 +2393,35 @@ function cors($action, $data = null) {
|
||||
);
|
||||
}
|
||||
|
||||
$cors_settings = !$cors_settings ? array('allowed_origins' => $_SERVER['SERVER_NAME'], 'allowed_methods' => 'GET, POST, PUT, DELETE') : $cors_settings;
|
||||
$cors_settings['allowed_origins'] = empty($cors_settings['allowed_origins']) ? $_SERVER['SERVER_NAME'] : $cors_settings['allowed_origins'];
|
||||
$cors_settings = !$cors_settings ? array('allowed_origins' => getBaseURL(), 'allowed_methods' => 'GET, POST, PUT, DELETE') : $cors_settings;
|
||||
$cors_settings['allowed_origins'] = empty($cors_settings['allowed_origins']) ? getBaseURL() : $cors_settings['allowed_origins'];
|
||||
$cors_settings['allowed_methods'] = empty($cors_settings['allowed_methods']) ? 'GET, POST, PUT, DELETE, OPTION' : $cors_settings['allowed_methods'];
|
||||
|
||||
return $cors_settings;
|
||||
break;
|
||||
case "set_headers":
|
||||
$cors_settings = cors('get');
|
||||
// normalize the stored list; it may still hold bare hostnames written before origins were validated as origins
|
||||
$allowed_origins = array_filter(array_map('normalize_cors_origin', explode(',', $cors_settings['allowed_origins'])));
|
||||
$origin = isset($_SERVER['HTTP_ORIGIN']) ? normalize_cors_origin($_SERVER['HTTP_ORIGIN']) : false;
|
||||
// check if requested origin is in allowed origins
|
||||
$allowed_origins = explode(', ', $cors_settings['allowed_origins']);
|
||||
$cors_settings['allowed_origins'] = $allowed_origins[0];
|
||||
if (in_array('*', $allowed_origins)){
|
||||
$cors_settings['allowed_origins'] = '*';
|
||||
} else if (array_key_exists('HTTP_ORIGIN', $_SERVER) && in_array($_SERVER['HTTP_ORIGIN'], $allowed_origins)) {
|
||||
$cors_settings['allowed_origins'] = $_SERVER['HTTP_ORIGIN'];
|
||||
$allow_origin = null;
|
||||
if (in_array('*', $allowed_origins, true)) {
|
||||
$allow_origin = '*';
|
||||
} else if ($origin !== false && in_array($origin, $allowed_origins, true)) {
|
||||
$allow_origin = $origin;
|
||||
}
|
||||
// always allow OPTIONS for preflight request
|
||||
$cors_settings["allowed_methods"] = empty($cors_settings["allowed_methods"]) ? 'OPTIONS' : $cors_settings["allowed_methods"] . ', ' . 'OPTIONS';
|
||||
|
||||
header('Access-Control-Allow-Origin: ' . $cors_settings['allowed_origins']);
|
||||
// a disallowed origin gets no Access-Control-Allow-Origin at all; echoing a different origin than the requesting one tells a browser nothing
|
||||
if ($allow_origin !== null) {
|
||||
header('Access-Control-Allow-Origin: ' . $allow_origin);
|
||||
}
|
||||
if ($allow_origin !== '*') {
|
||||
// the response depends on the request origin, keep caches from mixing them up
|
||||
header('Vary: Origin', false);
|
||||
}
|
||||
header('Access-Control-Allow-Methods: '. $cors_settings['allowed_methods']);
|
||||
header('Access-Control-Allow-Headers: Accept, Content-Type, X-Api-Key, Origin');
|
||||
|
||||
@@ -3503,7 +3563,16 @@ function protect_route($allowed_roles = ['admin', 'domainadmin', 'user'], $redir
|
||||
if (isset($redirects['unauthenticated'])) {
|
||||
header('Location: ' . $redirects['unauthenticated']);
|
||||
} else {
|
||||
header('Location: /');
|
||||
// Send a deep link to the login page for its area instead of the user login at /,
|
||||
// e.g. /admin/dashboard -> /admin rather than /
|
||||
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
|
||||
if (strpos($request_uri, '/admin/') === 0) {
|
||||
header('Location: /admin');
|
||||
} elseif (strpos($request_uri, '/domainadmin/') === 0) {
|
||||
header('Location: /domainadmin');
|
||||
} else {
|
||||
header('Location: /');
|
||||
}
|
||||
}
|
||||
exit();
|
||||
}
|
||||
|
||||
@@ -41,13 +41,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
else {
|
||||
$username = $_SESSION['mailcow_cc_username'];
|
||||
}
|
||||
if (isset($_data["validity"]) && !filter_var($_data["validity"], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 87600)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'validity_missing'
|
||||
);
|
||||
return false;
|
||||
if (isset($_data["validity"])) {
|
||||
if (!filter_var($_data["validity"], FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 87600)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'validity_missing'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Default to 1 yr
|
||||
@@ -60,7 +62,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$permanent = 0;
|
||||
}
|
||||
$domain = $_data['domain'];
|
||||
$description = $_data['description'];
|
||||
// spamalias.description is NOT NULL, an absent description must not become a null insert
|
||||
$description = $_data['description'] ?? '';
|
||||
$valid_domains[] = mailbox('get', 'mailbox_details', $username)['domain'];
|
||||
$valid_alias_domains = user_get_alias_details($username)['alias_domains'];
|
||||
if (!empty($valid_alias_domains)) {
|
||||
@@ -299,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(
|
||||
@@ -747,6 +564,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$goto_domain = idn_to_ascii(substr(strstr($goto, '@'), 1), 0, INTL_IDNA_VARIANT_UTS46);
|
||||
$goto_local_part = strstr($goto, '@', true);
|
||||
$goto = $goto_local_part.'@'.$goto_domain;
|
||||
// Deny external goto domains: global switch (all roles) overrides the per-DA ACL
|
||||
if (($GLOBALS['ALIAS_DISABLE_EXTERNAL_DOMAINS'] === true ||
|
||||
(isset($_SESSION['acl']['alias_external_goto']) && $_SESSION['acl']['alias_external_goto'] != "1")) &&
|
||||
!is_local_mailcow_domain($goto_domain)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('external_goto_denied', htmlspecialchars($goto))
|
||||
);
|
||||
unset($gotos[$i]);
|
||||
continue;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT `username` FROM `mailbox`
|
||||
WHERE `kind` REGEXP 'location|thing|group'
|
||||
AND `username`= :goto");
|
||||
@@ -1111,10 +940,21 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 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
|
||||
if (!in_array($quarantine_category, array('add_header', 'reject', 'all'))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'quarantine_category_invalid'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$quota_b = ($quota_m * 1048576);
|
||||
$attribute_hash = (!empty($_data['attribute_hash'])) ? $_data['attribute_hash'] : '';
|
||||
if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap'))){
|
||||
$force_pw_update = 0;
|
||||
}
|
||||
if ($authsource == 'generic-oidc'){
|
||||
$force_tfa = 0;
|
||||
}
|
||||
$mailbox_attrs = json_encode(
|
||||
@@ -1731,6 +1571,15 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$attr["tagged_mail_handler"] = (!empty($_data['tagged_mail_handler'])) ? $_data['tagged_mail_handler'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['tagged_mail_handler']);
|
||||
$attr["quarantine_notification"] = (!empty($_data['quarantine_notification'])) ? $_data['quarantine_notification'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']);
|
||||
$attr["quarantine_category"] = (!empty($_data['quarantine_category'])) ? $_data['quarantine_category'] : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
|
||||
// Validate quarantine_category
|
||||
if (!in_array($attr["quarantine_category"], array('add_header', 'reject', 'all'))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_extra),
|
||||
'msg' => 'quarantine_category_invalid'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$attr["rl_frame"] = (!empty($_data['rl_frame'])) ? $_data['rl_frame'] : "s";
|
||||
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : "";
|
||||
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
|
||||
@@ -2060,6 +1909,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
return false;
|
||||
}
|
||||
foreach ($usernames as $username) {
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $username)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if ($_data['spam_score'] == "default") {
|
||||
$stmt = $pdo->prepare("DELETE FROM `filterconf` WHERE `object` = :username
|
||||
AND (`option` = 'lowspamlevel' OR `option` = 'highspamlevel')");
|
||||
@@ -2237,202 +2094,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(
|
||||
@@ -2684,6 +2345,19 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
unset($gotos[$i]);
|
||||
continue;
|
||||
}
|
||||
// Deny external goto domains: global switch (all roles) overrides the per-DA ACL
|
||||
$goto_domain = idn_to_ascii(substr(strstr($goto, '@'), 1), 0, INTL_IDNA_VARIANT_UTS46);
|
||||
if (($GLOBALS['ALIAS_DISABLE_EXTERNAL_DOMAINS'] === true ||
|
||||
(isset($_SESSION['acl']['alias_external_goto']) && $_SESSION['acl']['alias_external_goto'] != "1")) &&
|
||||
!is_local_mailcow_domain($goto_domain)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('external_goto_denied', htmlspecialchars($goto))
|
||||
);
|
||||
unset($gotos[$i]);
|
||||
continue;
|
||||
}
|
||||
if ($goto == $address) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
@@ -3126,6 +2800,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
}
|
||||
if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap'))){
|
||||
$force_pw_update = 0;
|
||||
}
|
||||
if ($authsource == 'generic-oidc'){
|
||||
$force_tfa = 0;
|
||||
}
|
||||
$pw_recovery_email = (isset($_data['pw_recovery_email']) && $authsource == 'mailcow') ? $_data['pw_recovery_email'] : $is_now['attributes']['recovery_email'];
|
||||
@@ -3475,7 +3151,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
// Track affected mailboxes for SOGo update
|
||||
$update_sogo_mailboxes[] = $username;
|
||||
}
|
||||
return true;
|
||||
break;
|
||||
case 'mailbox_rename':
|
||||
$domain = $_data['domain'];
|
||||
@@ -3595,6 +3270,9 @@ 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);
|
||||
|
||||
// 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([
|
||||
@@ -3786,9 +3464,19 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$attr["tagged_mail_handler"] = (!empty($_data['tagged_mail_handler'])) ? $_data['tagged_mail_handler'] : $is_now['tagged_mail_handler'];
|
||||
$attr["quarantine_notification"] = (!empty($_data['quarantine_notification'])) ? $_data['quarantine_notification'] : $is_now['quarantine_notification'];
|
||||
$attr["quarantine_category"] = (!empty($_data['quarantine_category'])) ? $_data['quarantine_category'] : $is_now['quarantine_category'];
|
||||
// Validate quarantine_category
|
||||
if (!in_array($attr["quarantine_category"], array('add_header', 'reject', 'all'))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_extra),
|
||||
'msg' => 'quarantine_category_invalid'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
$attr["rl_frame"] = (!empty($_data['rl_frame'])) ? $_data['rl_frame'] : $is_now['rl_frame'];
|
||||
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : $is_now['rl_value'];
|
||||
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : $is_now['force_pw_update'];
|
||||
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : $is_now['force_tfa'];
|
||||
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : $is_now['sogo_access'];
|
||||
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : $is_now['active'];
|
||||
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : $is_now['tls_enforce_in'];
|
||||
@@ -4536,68 +4224,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');
|
||||
@@ -5281,6 +4907,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$mailboxdata['rl_scope'] = 'domain';
|
||||
}
|
||||
$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->execute(array(':username' => $_data));
|
||||
$sender_acl_rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($sender_acl_rows as $sender_acl_row) {
|
||||
$mailboxdata['sender_acl'][] = $sender_acl_row['send_as'];
|
||||
}
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT `tag_name`
|
||||
FROM `tags_mailbox` WHERE `username`= :username");
|
||||
@@ -5419,46 +5053,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();
|
||||
@@ -5608,8 +5202,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $username
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . $username . "/%' OR `c_uid` = :username");
|
||||
// bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match)
|
||||
$c_object_like = '%/' . addcslashes($username, '\\%_') . '/%';
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username");
|
||||
$stmt->execute(array(
|
||||
':c_object_like' => $c_object_like,
|
||||
':username' => $username
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)");
|
||||
@@ -5686,6 +5283,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$stmt->execute(array(
|
||||
':domain' => $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,
|
||||
@@ -5993,6 +5594,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
|
||||
@@ -6005,8 +5608,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $username
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . str_replace('%', '\%', $username) . "/%' OR `c_uid` = :username");
|
||||
// bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match)
|
||||
$c_object_like = '%/' . addcslashes($username, '\\%_') . '/%';
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username");
|
||||
$stmt->execute(array(
|
||||
':c_object_like' => $c_object_like,
|
||||
':username' => $username
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)");
|
||||
@@ -6088,7 +5694,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
// Track affected mailboxes for SOGo update
|
||||
$update_sogo_mailboxes[] = $username;
|
||||
}
|
||||
return true;
|
||||
break;
|
||||
case 'mailbox_templates':
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
@@ -6163,8 +5768,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $name
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE '%/" . $name . "/%' OR `c_uid` = :username");
|
||||
// bind LIKE pattern + escape LIKE wildcards so the value matches literally (no SQLi, no over-match)
|
||||
$c_object_like = '%/' . addcslashes($name, '\\%_') . '/%';
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_acl` WHERE `c_object` LIKE :c_object_like OR `c_uid` = :username");
|
||||
$stmt->execute(array(
|
||||
':c_object_like' => $c_object_like,
|
||||
':username' => $name
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `sogo_store` WHERE `c_folder_id` IN (SELECT `c_folder_id` FROM `sogo_folder_info` WHERE `c_path2` = :username)");
|
||||
|
||||
@@ -22,7 +22,7 @@ function quarantine($_action, $_data = null) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT `id` FROM `quarantine` LEFT OUTER JOIN `user_acl` ON `user_acl`.`username` = `rcpt`
|
||||
WHERE `qhash` = :hash
|
||||
WHERE `qhash` = :hash
|
||||
AND user_acl.quarantine = 1
|
||||
AND rcpt IN (SELECT username FROM mailbox)');
|
||||
$stmt->execute(array(':hash' => $hash));
|
||||
@@ -65,7 +65,7 @@ function quarantine($_action, $_data = null) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT `id` FROM `quarantine` LEFT OUTER JOIN `user_acl` ON `user_acl`.`username` = `rcpt`
|
||||
WHERE `qhash` = :hash
|
||||
WHERE `qhash` = :hash
|
||||
AND `user_acl`.`quarantine` = 1
|
||||
AND `username` IN (SELECT `username` FROM `mailbox`)');
|
||||
$stmt->execute(array(':hash' => $hash));
|
||||
@@ -170,6 +170,8 @@ function quarantine($_action, $_data = null) {
|
||||
}
|
||||
elseif ($release_format == 'raw') {
|
||||
$detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']);
|
||||
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
||||
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
|
||||
$postfix_talk = array(
|
||||
array('220', 'HELO quarantine' . chr(10)),
|
||||
array('250', 'MAIL FROM: ' . $sender . chr(10)),
|
||||
@@ -180,7 +182,7 @@ function quarantine($_action, $_data = null) {
|
||||
array('221', '')
|
||||
);
|
||||
// Thanks to https://stackoverflow.com/questions/6632399/given-an-email-as-raw-text-how-can-i-send-it-using-php
|
||||
$smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1);
|
||||
$smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1);
|
||||
if (!$smtp_connection) {
|
||||
logger(array('return' => array(
|
||||
array(
|
||||
@@ -192,7 +194,7 @@ function quarantine($_action, $_data = null) {
|
||||
return false;
|
||||
}
|
||||
for ($i=0; $i < count($postfix_talk); $i++) {
|
||||
$smtp_resource = fgets($smtp_connection, 256);
|
||||
$smtp_resource = fgets($smtp_connection, 256);
|
||||
if (substr($smtp_resource, 0, 3) !== $postfix_talk[$i][0]) {
|
||||
$ret = substr($smtp_resource, 0, 3);
|
||||
$ret = (empty($ret)) ? '-' : $ret;
|
||||
@@ -465,17 +467,19 @@ function quarantine($_action, $_data = null) {
|
||||
}
|
||||
elseif ($release_format == 'raw') {
|
||||
$row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']);
|
||||
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
||||
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
|
||||
$postfix_talk = array(
|
||||
array('220', 'HELO quarantine' . chr(10)),
|
||||
array('250', 'MAIL FROM: ' . $sender . chr(10)),
|
||||
array('250', 'RCPT TO: ' . $row['rcpt'] . chr(10)),
|
||||
array('250', 'DATA' . chr(10)),
|
||||
array('354', str_replace("\n.", '', $row['msg']) . chr(10) . '.' . chr(10)),
|
||||
array('354', $row['msg'] . chr(10) . '.' . chr(10)),
|
||||
array('250', 'QUIT' . chr(10)),
|
||||
array('221', '')
|
||||
);
|
||||
// Thanks to https://stackoverflow.com/questions/6632399/given-an-email-as-raw-text-how-can-i-send-it-using-php
|
||||
$smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1);
|
||||
$smtp_connection = fsockopen($postfix, 590, $errno, $errstr, 1);
|
||||
if (!$smtp_connection) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'warning',
|
||||
@@ -485,7 +489,7 @@ function quarantine($_action, $_data = null) {
|
||||
return false;
|
||||
}
|
||||
for ($i=0; $i < count($postfix_talk); $i++) {
|
||||
$smtp_resource = fgets($smtp_connection, 256);
|
||||
$smtp_resource = fgets($smtp_connection, 256);
|
||||
if (substr($smtp_resource, 0, 3) !== $postfix_talk[$i][0]) {
|
||||
$ret = substr($smtp_resource, 0, 3);
|
||||
$ret = (empty($ret)) ? '-' : $ret;
|
||||
@@ -833,7 +837,7 @@ function quarantine($_action, $_data = null) {
|
||||
)));
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT * FROM `quarantine` WHERE `qhash` = :hash');
|
||||
$stmt = $pdo->prepare('SELECT * FROM `quarantine` WHERE `qhash` = :hash');
|
||||
$stmt->execute(array(':hash' => $hash));
|
||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
break;
|
||||
|
||||
@@ -22,6 +22,9 @@ if (preg_match("/debug/i", $_SERVER['REQUEST_URI'])) {
|
||||
if ($_SERVER['REQUEST_URI'] == '/') {
|
||||
$css_minifier->add('/web/css/site/index.css');
|
||||
}
|
||||
if (file_exists('/web/css/themes/'.$UI_THEME.'/theme.css')) {
|
||||
$css_minifier->add('/web/css/themes/'.$UI_THEME.'/theme.css');
|
||||
}
|
||||
|
||||
$hash = $css_minifier->getDataHash();
|
||||
$CSSPath = '/tmp/' . $hash . '.css';
|
||||
@@ -30,6 +33,17 @@ if(!file_exists($CSSPath)) {
|
||||
cleanupCSS($hash);
|
||||
}
|
||||
|
||||
// dark mode bundle, core dark mode + theme dark.css
|
||||
$dark_minifier = new CSSminifierExtended('/web/css/themes/mailcow-darkmode.css');
|
||||
if (file_exists('/web/css/themes/'.$UI_THEME.'/dark.css')) {
|
||||
$dark_minifier->add('/web/css/themes/'.$UI_THEME.'/dark.css');
|
||||
}
|
||||
$DarkCSSPath = '/tmp/' . $hash . '-' . $dark_minifier->getDataHash() . '.css';
|
||||
if(!file_exists($DarkCSSPath)) {
|
||||
$dark_minifier->minify($DarkCSSPath);
|
||||
}
|
||||
$dark_css_path = '/cache/' . basename($DarkCSSPath);
|
||||
|
||||
$mailcow_apps_processed = $MAILCOW_APPS;
|
||||
$app_links = customize('get', 'app_links');
|
||||
$app_links_processed = $app_links;
|
||||
@@ -77,6 +91,7 @@ $globalVariables = [
|
||||
'dual_login' => @$_SESSION['dual-login'],
|
||||
'ui_texts' => $UI_TEXTS,
|
||||
'css_path' => '/cache/'.basename($CSSPath),
|
||||
'dark_css_path' => $dark_css_path,
|
||||
'logo' => customize('get', 'main_logo'),
|
||||
'logo_dark' => customize('get', 'main_logo_dark'),
|
||||
'available_languages' => $AVAILABLE_LANGUAGES,
|
||||
@@ -89,7 +104,7 @@ $globalVariables = [
|
||||
'app_links' => $app_links,
|
||||
'app_links_processed' => $app_links_processed,
|
||||
'is_root_uri' => (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) == '/'),
|
||||
'uri' => $_SERVER['REQUEST_URI'],
|
||||
'uri' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) ?: '/',
|
||||
];
|
||||
|
||||
foreach ($globalVariables as $globalVariableName => $globalVariableValue) {
|
||||
|
||||
+297
-20
@@ -4,7 +4,7 @@ function init_db_schema()
|
||||
try {
|
||||
global $pdo;
|
||||
|
||||
$db_version = "19022026_1220";
|
||||
$db_version = "16102026_1200";
|
||||
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
@@ -719,7 +719,8 @@ function init_db_schema()
|
||||
"alias_domains" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"mailbox_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
@@ -754,35 +755,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",
|
||||
@@ -794,6 +913,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"
|
||||
@@ -1183,11 +1328,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)) {
|
||||
@@ -1348,20 +1610,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));
|
||||
@@ -1464,6 +1735,8 @@ function init_db_schema()
|
||||
"pop3_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['pop3_access']),
|
||||
"smtp_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['smtp_access']),
|
||||
"sieve_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['sieve_access']),
|
||||
"eas_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['eas_access']),
|
||||
"dav_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['dav_access']),
|
||||
"acl_spam_alias" => 1,
|
||||
"acl_tls_policy" => 1,
|
||||
"acl_spam_score" => 1,
|
||||
@@ -1541,6 +1814,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();
|
||||
@@ -1558,7 +1835,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
|
||||
|
||||
@@ -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,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,70 @@
|
||||
<?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);
|
||||
}
|
||||
|
||||
/** 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,76 @@
|
||||
<?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`)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $role,
|
||||
public readonly ?string $username,
|
||||
private readonly array $acl = [],
|
||||
public readonly bool $accessAll = false
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromSession(array $session): self
|
||||
{
|
||||
$username = $session['mailcow_cc_username'] ?? null;
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
/** 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';
|
||||
}
|
||||
|
||||
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,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Imapsync\Controller;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
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,
|
||||
fn(array $log): bool => $this->response->success(['imapsync_source_added', $this->sources->create($attr)->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,
|
||||
fn(int $id): array => ['imapsync_source_modified', $this->sources->update($id, $data)->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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 token request parameters, 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), '?'));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user