Compare commits

..
Author SHA1 Message Date
FreddleSpl0it 474f1c688b [Web] Add imapsync translations 2026-10-22 10:09:42 +02:00
FreddleSpl0it 7c62a57910 [Web][Dovecot] Rework imapsync: sync sources, OAuth2 (XOAUTH2), worker pool, Python runner 2026-10-22 10:07:52 +02:00
FreddleSpl0it e756b7857e [Web][Tests] Add PHPUnit setup and imapsync module tests 2026-10-22 10:07:52 +02:00
FreddleSpl0it b2a152fe45 [Web] Add Mailcow\ class structure and imapsync module classes 2026-10-22 10:07:43 +02:00
FreddleSpl0it 036607b4bf Merge pull request #7534 from mailcow/feat/php-patch
[PHP] Update base to Alpine 3.24, build PECL extensions from GitHub
2026-10-21 19:29:43 +02:00
FreddleSpl0it 2bad774bcc [PHP] Update to 8.2.35 2026-10-21 16:28:17 +02:00
FreddleSpl0it d833b9696f Merge pull request #7535 from mailcow/feat/nginx-patch
[Nginx] Update to 1.30.6
2026-10-21 14:14:41 +02:00
FreddleSpl0it 7d41484d2a Merge pull request #7536 from mailcow/feat/sogo-patch
[SOGo] Update to 5.12.12
2026-10-21 14:14:13 +02:00
FreddleSpl0it 9e4c7ef038 [SOGo] Update to 5.12.12 2026-10-21 14:08:15 +02:00
FreddleSpl0it 1f04122eca [Nginx] Update to 1.30.6 2026-10-21 13:41:43 +02:00
FreddleSpl0it 015fd04a37 [PHP] Update base to Alpine 3.24, build PECL extensions from GitHub 2026-10-21 11:06:14 +02:00
milkmakerandSchwindelhub ca8546fa4a [Web] Updated lang.de-de.json (#7527)
Co-authored-by: Schwindelhub <github@netbla.de>
2026-10-20 20:34:32 +02:00
milkmakerandMatjaž Tekavec 6f6edfb85e [Web] Updated lang.si-si.json (#7525)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2026-10-19 18:40:37 +02:00
FreddleSpl0it 6c877fc0ac Merge pull request #7520 from mailcow/feat/update-ui
Feat/update UI
2026-10-15 15:31:18 +02:00
FreddleSpl0it 4a29767a31 [Web] Add role color marker and icons to login pages 2026-10-15 15:23:29 +02:00
FreddleSpl0it cd29426933 [Web] Fix minor customize and theme ui issues 2026-10-15 15:15:42 +02:00
FreddleSpl0it 37be306f1c [Web] change default theme label 2026-10-15 15:10:49 +02:00
FreddleSpl0it a351017455 [Web] Update bootstrap-select css to 1.14.0-beta3 2026-10-15 14:47:19 +02:00
FreddleSpl0it 887b8a3594 [Web] Add servercow theme 2026-10-15 14:46:57 +02:00
FreddleSpl0it 571c1b5b29 [Web] Update Bootstrap to 5.3.8, add theme support 2026-10-15 14:43:06 +02:00
FreddleSpl0it 8cd231c9b9 Merge pull request #7505 from mailcow/feat/rspamd-4.2.1
[Rspamd] Update to 4.2.1
2026-10-06 10:02:49 +02:00
FreddleSpl0it 74facc4b5b [Rspamd] update to 4.2.1 2026-10-06 09:41:51 +02:00
FreddleSpl0it ff6329d01b [Rspamd] Fix CTE and boundary prefix in domain wide footer 2026-10-06 09:41:34 +02:00
FreddleSpl0it e8209d6504 Merge pull request #7504 from ralfbergs/fix/typo-resovler
Fixed typo (changing RESOVLER to RESOLVER).
2026-10-06 09:21:33 +02:00
FreddleSpl0it 626d006a3d Merge pull request #7484 from oidipos/feat/tlspol
[postfix-tlspol] bump to v1.14.0
2026-10-06 09:20:47 +02:00
Ralf Bergs e88c734d0a Fixed typo (changing RESOVLER to RESOLVER). 2026-10-03 12:30:32 +02:00
milkmaker 86bba266f6 update postscreen_access.cidr (#7494) 2026-10-01 08:03:05 +02:00
oidipos e3ed8d2fc2 bump postfix-tlspol to v1.14.0 2026-09-25 21:23:58 +02:00
FreddleSpl0it 88d92261e4 Merge pull request #7432 from mailcow/renovate/redis-7.x
Update redis Docker tag to v7.4.11
2026-09-21 09:32:35 +02:00
FreddleSpl0it 017d0c7ff0 Merge pull request #7475 from mailcow/feat/sogo-5.12.11
[SOGo] Update to 5.12.11
2026-09-21 08:46:32 +02:00
FreddleSpl0it a28fa19044 [SOGo] Update to 5.12.11 2026-09-21 08:43:16 +02:00
FreddleSpl0it fa2ad14fc4 Merge pull request #7473 from mailcow/feat/unbound-1.26.1
[Unbound] Update to 1.26.1
2026-09-21 08:29:13 +02:00
FreddleSpl0it a480a96e10 [Unbound] Update to 1.26.1 2026-09-21 08:23:03 +02:00
renovate[bot] 42d387ca42 Update redis Docker tag to v7.4.11
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-09-15 16:52:39 +00:00
milkmakerandStefano fe4e675754 [Web] Updated lang.it-it.json (#7460)
Co-authored-by: Stefano <stefano.vassena@gmail.com>
2026-09-09 22:28:28 +02:00
b05f59b705 Translations update from Weblate (#7457)
* [Web] Updated lang.si-si.json

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.zh-cn.json

Co-authored-by: 雨 <luotianyi@luotianyi.me>

---------

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: 雨 <luotianyi@luotianyi.me>
2026-09-07 21:38:12 +02:00
milkmaker c4d0ca56d2 update postscreen_access.cidr (#7450) 2026-09-01 13:07:54 +02:00
7ec61fde8a Translations update from Weblate (#7431)
* [Web] Updated lang.it-it.json

Co-authored-by: Claudio <cb@bastard.it>

* [Web] Updated lang.si-si.json

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>

---------

Co-authored-by: Claudio <cb@bastard.it>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2026-08-19 20:23:04 +02:00
FreddleSpl0it d80b15b06c Merge pull request #7376 from smpaz7467/fix/extended-sender-acl-authsource
[Web] show external sender addresses regardless of authsource
2026-08-18 14:41:20 +02:00
FreddleSpl0it ed4fe41d7a Merge pull request #7349 from smpaz7467/fix/login-redirect-deeplink
[Web] redirect deep links to the matching login page
2026-08-18 14:37:32 +02:00
FreddleSpl0it 6b9d875773 Merge pull request #7347 from smpaz7467/fix/dns-zonefile-absolute-rhs
[Web] use absolute RHS names in generated DNS zonefile
2026-08-18 14:30:50 +02:00
FreddleSpl0it 4d5b9d1c80 Merge pull request #7342 from FrauJulian/feat/show-password
[Web] Add show/hide password toggle on login pages
2026-08-18 14:25:22 +02:00
FreddleSpl0it 8abf453e1c Merge pull request #7428 from mailcow/feat/forbid-external-alias
[Web] Add ACL + global switch to disable external alias goto
2026-08-18 14:13:39 +02:00
FreddleSpl0it 8bd9e12a73 [Web] Add ACL + global switch to disable external alias goto 2026-08-18 14:07:37 +02:00
FreddleSpl0it c665d430de Merge pull request #7344 from smpaz7467/fix/forced-pw-change-raw-lang-key
[Web] translate password errors in the forced password change modal
2026-08-18 13:31:35 +02:00
FreddleSpl0it 2195a674ff Merge pull request #7290 from wryfi/7150-fix-passwd-verify
Fix #7150: passwd-verify.lua wipes auth cache during any nginx outage
2026-08-18 13:27:01 +02:00
FreddleSpl0it 089c44aee1 Merge pull request #7334 from DerLinkman/feat/postfix-tlspol-update
postfix-tlspol: upgrade to trixie + 1.11.0 update
2026-08-18 13:20:16 +02:00
FreddleSpl0it 6644699e65 Merge pull request #7426 from mailcow/fix/web-hardening
[Web] Minor hardening across web UI and nginx
2026-08-18 09:10:09 +02:00
FreddleSpl0it 91d3b7246a [Web] Minor hardening across web UI and nginx 2026-08-18 09:08:17 +02:00
FreddleSpl0it b362c7105d Merge pull request #7425 from mailcow/feat/redis-7.4.10
[Redis] Update to 7.4.10
2026-08-18 08:14:21 +02:00
FreddleSpl0it 555e7ed706 [Redis] Update to 7.4.10 2026-08-18 08:12:32 +02:00
FreddleSpl0it 59e2a9ac77 Merge pull request #7423 from mailcow/fix/7418
[Dovecot] Remove legacy DeltaChat auto-filing sieve rule
2026-08-17 08:41:18 +02:00
FreddleSpl0it feed5ad183 [Dovecot] Remove legacy DeltaChat auto-filing sieve rule 2026-08-17 08:35:53 +02:00
FreddleSpl0it f1f7a9800b Merge pull request #7422 from mailcow/feat/sogo-5.12.10
[SOGo] Update to 5.12.10
2026-08-17 08:24:10 +02:00
FreddleSpl0it 8c85044781 [SOGo] Update to 5.12.10 2026-08-17 08:20:45 +02:00
FreddleSpl0it 8fb32e4b59 Merge pull request #7415 from mailcow/feat/clamd-1.4.6
[Clamd] Update to 1.4.6
2026-08-13 13:59:13 +02:00
FreddleSpl0it b5fa926bc0 [Clamd] Update to 1.4.6 2026-08-13 13:56:49 +02:00
d1a2f4e168 Translations update from Weblate (#7400)
* [Web] Updated lang.si-si.json

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.pt-br.json

Co-authored-by: André Glazastov <andre@glazastov.com>

---------

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: André Glazastov <andre@glazastov.com>
2026-08-04 18:37:38 +02:00
milkmaker f7a536d634 update postscreen_access.cidr (#7394) 2026-08-04 18:35:48 +02:00
Maximal Benedikt 8b456a33e7 Merge pull request #7392 from mailcow/update-sponsoring
update README.md sponsors
2026-07-30 14:21:39 +02:00
MaximalBenedikt 54da526d41 update README.md sponsors 2026-07-30 14:11:32 +02:00
FreddleSpl0it 50be483c39 Merge pull request #7390 from mailcow/fix/6859
[ACME] Skip mta-sts certificate request when MTA-STS is not active for a domain
2026-07-30 10:44:24 +02:00
FreddleSpl0it d64c923aca [ACME] Skip mta-sts certificate request when MTA-STS is not active for a domain 2026-07-30 10:33:55 +02:00
FreddleSpl0it 2d5f166ba8 Merge pull request #7389 from mailcow/fix/default-mbox-template
[Web] Create default mailbox template with eas and dav access
2026-07-30 09:23:24 +02:00
FreddleSpl0it 94b5a623ab Merge pull request #7388 from mailcow/fix/7329
[Web] Move mailcow update check to server side
2026-07-30 09:13:19 +02:00
FreddleSpl0it e406ecd461 Merge pull request #7387 from mailcow/fix/mfk25
Hardening mailcow
2026-07-30 09:07:10 +02:00
FreddleSpl0it 68a95c3af3 Merge pull request #7386 from mailcow/feat/rspamd-4.1.4
[Rspamd] update to 4.1.4
2026-07-30 08:59:04 +02:00
FreddleSpl0it bd037d5644 [Nginx] Set image tag to 1.30.4-1 2026-07-30 08:51:21 +02:00
FreddleSpl0it 7036dbf4f8 [Rspamd] update to 4.1.4 2026-07-30 08:50:18 +02:00
FreddleSpl0it 2f10a4633f Merge pull request #7385 from mailcow/fix/cors
[Web] harden CORS origin matching and add Vary: Origin
2026-07-30 08:41:01 +02:00
FreddleSpl0it 36c70db86c [Web] harden CORS origin matching and add Vary: Origin 2026-07-30 08:37:18 +02:00
Stephen Ritz 9cd16f0001 [Web] show external sender addresses regardless of authsource
The extended_sender_acl field sat inside the
`{% if not result.authsource or result.authsource == 'mailcow' %}` block
that hides the local password fields for mailboxes authenticating against
an external identity provider. As a result the "External sender addresses"
input was not rendered at all for keycloak / generic-oidc / ldap mailboxes,
even for a full admin, so those addresses could neither be reviewed nor
edited in the UI while the underlying sender_acl rows stayed active.

Extended sender ACLs are unrelated to local password management. Move the
field out of that block; it stays gated by acl.extend_sender_acl as intended.

Fixes #7365
2026-07-28 07:12:17 -07:00
FreddleSpl0it f4961c4023 Merge pull request #7333 from fallmo/fix/cors-settings-validation
fix: cors allowed origins settings validation
2026-07-28 15:35:29 +02:00
FreddleSpl0it 38de21592c Merge pull request #7358 from SYNLINQ/staging
Fix nginx CVE-2026-42533
2026-07-28 14:40:30 +02:00
FreddleSpl0it f44bd2f36a [Web] document sender_acl in get/mailbox API examples 2026-07-28 14:19:45 +02:00
FreddleSpl0it 95a77f2dcb Merge pull request #7348 from smpaz7467/fix/api-get-mailbox-sender-acl
[Web] return sender_acl in get/mailbox API
2026-07-28 14:12:57 +02:00
FreddleSpl0it e856510fb2 Merge pull request #7345 from smpaz7467/fix/time-limited-alias-api
[Web] fix add/time_limited_alias silently discarding requests and validity
2026-07-28 13:49:37 +02:00
FreddleSpl0it d51d06d716 Merge pull request #7343 from smpaz7467/fix/nginx-ipv6-default-server
[Nginx] only bind IPv6 default_server when ENABLE_IPV6 is set
2026-07-28 13:35:17 +02:00
FreddleSpl0it 54170d075a [Web] Move mailcow update check to server side 2026-07-28 13:32:18 +02:00
renovate[bot] b4bb1a625b Update actions/stale action to v11 (#7375) 2026-07-28 11:07:20 +02:00
FreddleSpl0it 2e5a29bf69 Merge pull request #7367 from oidipos/fix/quarantine-subject
fix: restore subject display in quarantine overview
2026-07-28 10:54:23 +02:00
FreddleSpl0it e245ac04d9 [Web] enforce tenant boundary for SOGo SSO 2026-07-28 09:21:29 +02:00
oidipos c877fdf0a5 fix: remove MIME decoding of JSON encoded subject
Since moving to rspamd's multipart metadata_exporter,
`subject` is a JSON encoded UTF-8 string and must not be MIME decoded.
2026-07-24 21:03:00 +02:00
FreddleSpl0it fea38c8e1b [Web] escape mailbox name 2026-07-24 11:16:39 +02:00
FreddleSpl0it cc9af65852 [Web] remove domain admin sso token after use 2026-07-24 10:26:23 +02:00
FreddleSpl0it 92cc8bec90 [Web] Use parameterized LIKE for sogo_acl deletion 2026-07-23 15:29:41 +02:00
FreddleSpl0it 145745329e [Web] add dot stuffing for quarantine raw release 2026-07-23 15:01:44 +02:00
FreddleSpl0it 8e72d22c56 [Web] Escape rspamd_history and rllog 2026-07-23 11:17:55 +02:00
FreddleSpl0it ddd76d99cd [Web] Add sogo_auth_internal nginx marker for sogo-auth.php 2026-07-23 10:10:29 +02:00
SYNLINQ 8c9524a2fe Update docker-compose.yml
reference patched nginx image
2026-07-20 21:32:57 +02:00
SYNLINQ 2ebd32d2ee Update Dockerfile
bump nginx version to 1.30.4
2026-07-20 21:31:57 +02:00
FreddleSpl0it 4b62af9d02 update README.md sponsors 2026-07-15 08:29:46 +02:00
Stephen RitzandClaude Opus 4.8 90ca1bf25a [Web] redirect deep links to the matching login page
An unauthenticated request to a deep link such as /admin/dashboard was
redirected to /, the user login, instead of the admin login. protect_route
always sent unauthenticated visitors to /.

Pick the login page from the request path: /admin/* redirects to /admin,
/domainadmin/* to /domainadmin, everything else to / as before.

Fixes #7284

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:39:19 -07:00
Stephen RitzandClaude Opus 4.8 14772c3a20 [Web] return sender_acl in get/mailbox API
sender_acl can be set through edit/mailbox but was never returned by
get/mailbox, so an API client could not read back what it had written,
and get/mailbox/all / get/mailbox/{mailbox} both omitted it.

Add the mailbox's internal send-as ACL (the sender_acl table rows with
external = 0) to mailbox_details as sender_acl, an array of send_as
values, mirroring the field edit/mailbox accepts. It is added in the same
block as the other detailed fields, so the lightweight get/mailbox/reduced
endpoint is unaffected.

Fixes #7011

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:36:40 -07:00
Stephen RitzandClaude Opus 4.8 e5d0ed8c4c [Web] use absolute RHS names in generated DNS zonefile
The DNS overview "Download" produces a $ORIGIN zonefile, but the
right-hand side of MX, CNAME and SRV records was emitted as a relative
name. A target such as mail.example.net is then read relative to the
origin and expands to mail.example.net.example.org., which is wrong.

The only prior attempt at making names absolute was
str_replace($domain, $domain . '.', ...), which appended a dot only to
targets that happened to contain the origin domain, so cross-domain
targets stayed relative. That same replace also corrupted any TXT value
containing the origin (e.g. a DMARC rua=mailto:x@example.org became
...@example.org.).

Absolutize the RHS per record type at export time only: MX and CNAME
targets, and the SRV target token, get a trailing dot; ports, the SRV
root target ".", IP addresses and TXT character strings are left as is.
The records used for the on-page DNS validation are untouched, so
matching against dns_get_record() output still works.

Fixes #6984

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:33:46 -07:00
Stephen RitzandClaude Opus 4.8 c17cc8a792 [Web] fix add/time_limited_alias silently discarding requests and validity
Three defects in add/time_limited_alias:

The description was read as $_data['description'] without a guard. When a
client omits it, null is bound to spamalias.description, which is TEXT NOT
NULL, so the insert raises a PDOException. The global exception handler is
terminal, so process_add_return() never echoes anything and the caller sees
HTTP 200 with an empty body while no alias was created. Default it to an
empty string instead.

The validity guard used a single condition whose else branch also caught the
success case, so every valid validity was overwritten with the 8760 hour
default and the parameter did nothing. Only invalid values were rejected.
Nest the range check so a valid value survives.

The OpenAPI spec documented only username and domain, while the code also
reads description, validity and permanent. Spec driven clients therefore
could not construct a working request. Document all three.

spamalias.description is the only NOT NULL description column in the schema,
which is why the same unguarded read in add/domain and add/resource does not
fail, both of those columns are nullable.

This does not change the generic exception handling. A database error is
still swallowed into an empty HTTP 200, and the message the handler builds
carries the raw PDOException, so surfacing it to API clients would need
sanitising first. That is left for a separate change.

Refs #7287

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:23:31 -07:00
Stephen RitzandClaude Opus 4.8 4cba448671 [Web] translate password errors in the forced password change modal
The forced password change modal posts to the JSON API and renders
data[0].msg directly. The API returns raw language keys rather than
translated strings, so a user who fails the complexity policy is shown
the literal text "password_complexity" instead of a message.

Every other password form renders errors through alertbox_log_parser(),
which resolves the key against $lang. The modal is the only one that
talks to the API directly, and it never resolved the key.

Resolve it against lang_danger, which base.twig already exposes for this
purpose. This also covers password_mismatch, password_empty and
access_denied, and applies to the admin path as well. Unknown keys still
fall through unchanged.

The lookup is guarded with hasOwnProperty because msg is attacker-
independent but dynamic: a bare lang_danger[msg] would resolve inherited
Object.prototype members such as "constructor" to a function, which
jQuery's .text() would then invoke as a callback.

Language files are untouched: prerequisites.inc.php loads lang.en-gb.json
as the base and merges the active locale over it, so locales that lack the
key inherit the English string.

Fixes #7301

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:11:17 -07:00
Stephen RitzandClaude Opus 4.8 7dab6c63d1 [Nginx] only bind IPv6 default_server when ENABLE_IPV6 is set
The HTTP-to-HTTPS redirect server block bound `listen [::]:{{ HTTP_PORT }}
default_server` unconditionally, while every other IPv6 listen directive in
this template is guarded by `{% if ENABLE_IPV6 %}`. On hosts where IPv6 is
disabled at the kernel level, nginx cannot bind `::` and fails to start.

This only triggers when HTTP_REDIRECT is enabled and ENABLE_IPV6 is false,
which is why it survives testing with ENABLE_IPV6=false alone.

Guard the directive like the other six IPv6 listeners in the template.

Refs #7296

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:54:28 -07:00
Paul Seebach 5408bc809d [Web] Use input-group-text for password toggle button
Replaces btn-outline-secondary with input-group-text so the toggle
button border matches the surrounding input group consistently.
2026-07-14 21:20:58 +02:00
Paul Seebach bd5c18b145 [Web] Add show/hide password toggle on login pages
Adds an eye icon button to the password field on the user, admin, and
domain admin login pages. Clicking it toggles between hidden and visible
password text, using Bootstrap Icons (bi-eye / bi-eye-slash).

Closes #6893
2026-07-14 21:20:58 +02:00
DerLinkman 0f06c8e563 postfix: prepare TLSRPT compatibility for tlspol 2026-07-13 19:34:58 +02:00
DerLinkman fffe1aec3d postfix-tlspol: upgrade to trixie + 1.11.0 update 2026-07-13 19:16:10 +02:00
Mohamed Fall 6f7fee49cd fix: cors allowed origins settings validation 2026-07-13 15:29:48 +00:00
FreddleSpl0it c1d75cf808 Merge pull request #7312 from mailcow/renovate/composer-composer-2.x
Update dependency composer/composer to v2.10.2
2026-07-13 09:40:58 +02:00
FreddleSpl0it 473fe2885d Merge pull request #7326 from ralfbergs/fix/quarantine-text-refining
Refined wording for displaying of active settings on quarantine page.
2026-07-13 09:40:24 +02:00
FreddleSpl0it 31e1550668 [Postfix] change postscreen blacklist_action to denylist_action 2026-07-13 08:45:56 +02:00
FreddleSpl0it 315f55bc65 Merge pull request #7323 from DerLinkman/feat/postfix-deb13
postfix: migrate from bookworm to trixie
2026-07-13 08:23:57 +02:00
Ralf Bergs e696ee2f6c Refined wording for displaying of active settings on quarantine page. 2026-07-12 17:22:15 +02:00
renovate[bot] d65aa11870 Update devops-infra/action-pull-request action to v1.4.0 (#7321)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 15:20:18 +02:00
renovate[bot] f6630e5b43 Update actions/stale action to v10.4.0 (#7322)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 15:19:49 +02:00
DerLinkman 98e26c5603 compose: upgrade Postfix 2026-07-10 18:41:58 +02:00
DerLinkman 3098caebbc migrate postfix to trixie 2026-07-10 18:29:45 +02:00
renovate[bot] 94e23a3cd3 Update dependency composer/composer to v2.10.2
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-07-01 09:32:23 +00:00
milkmaker 1840074991 update postscreen_access.cidr (#7311) 2026-07-01 11:31:58 +02:00
FreddleSpl0it 99768e16f5 Merge pull request #7305 from mailcow/feat/nginx-1.30.3
[Nignx] Update to 1.30.3
2026-06-25 12:20:25 +02:00
FreddleSpl0it 1f8b4679cc [Nignx] Update to 1.30.3 2026-06-25 12:17:37 +02:00
9e723e942d Translations update from Weblate (#7302)
* [Web] Updated lang.fr-fr.json

Co-authored-by: Romain Ayme <ayme.romain@hotmail.fr>

* [Web] Updated lang.si-si.json

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>

---------

Co-authored-by: Romain Ayme <ayme.romain@hotmail.fr>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2026-06-20 13:57:15 +02:00
renovate[bot] 47e4552b44 Update actions/checkout action to v7 (#7300) 2026-06-19 08:34:02 +02:00
Chris Haumesser ddcce811c1 Fixes #7150 (Dovecot passwd-verify.lua wipes auth cache during any nginx restart or unavailability)
- distinguishes between text and numeric responses from nginx
  - prevents auth cache from being invalidated due to nginx unavailability
2026-06-12 16:32:22 -06:00
renovate[bot] 7e6c36dce1 Update alpine Docker tag to v3.24 (#7280) 2026-06-11 11:35:03 +02:00
FreddleSpl0it fa154c71f3 [PHP] Rebuild Image 2026-06-11 10:29:01 +02:00
FreddleSpl0it 83a045ed3e Merge pull request #7193 from mailcow/renovate/composer-composer-2.x
Update dependency composer/composer to v2.10.1
2026-06-11 10:07:19 +02:00
FreddleSpl0it 064817ac70 Merge pull request #7189 from mailcow/renovate/php-pecl-mail-mailparse-3.x
Update dependency php/pecl-mail-mailparse to v3.2.0
2026-06-11 10:06:49 +02:00
FreddleSpl0it 2bd7a24b8c Merge pull request #7212 from mailcow/mkuron-patch-mobileconfig
Escape generated password in mobileconfig
2026-06-11 10:05:17 +02:00
FreddleSpl0it ecc2462e4c Merge pull request #7267 from goodygh/7249-update-sogo
[SOGo] Update to 5.12.9
2026-06-11 10:04:30 +02:00
FreddleSpl0it 2d8db72d46 Merge pull request #7275 from Snafu/fix/admin-mailbox-tfa-missing
Fix force_tfa not available in mailbox template #7216
2026-06-11 10:03:39 +02:00
FreddleSpl0it 277a307fb9 [Web] Fix refresh SOGo view on mailbox deletion 2026-06-11 09:55:32 +02:00
FreddleSpl0it d455555621 Merge pull request #7277 from ibobgunardi/bobi/mailcow-7136-sogo-active-refresh
Refresh SOGo view after mailbox activation
2026-06-11 09:53:30 +02:00
FreddleSpl0it 9ea2ff1dff Merge pull request #7283 from mailcow/feat/update-metadata-exporter
[Rspamd] Migrate metadata_exporter to multipart formatter
2026-06-11 09:40:34 +02:00
FreddleSpl0it 24cc369d84 [Rspamd] Migrate metadata_exporter to multipart formatter 2026-06-11 09:34:27 +02:00
FreddleSpl0it 5f5367f2f9 Merge pull request #7172 from mailcow/dragoangel-patch-4
Update RSPAMD version to 4.1.0 in Dockerfile
2026-06-11 09:04:19 +02:00
milkmaker 03c31f825a update postscreen_access.cidr (#7269) 2026-06-09 12:20:53 +02:00
renovate[bot] c0050e8836 Update devops-infra/action-pull-request action to v1.3.0 (#7272) 2026-06-09 12:19:09 +02:00
Dmytro Alieksieiev 15891960f7 Update RSPAMD version to 4.1.0 2026-06-09 01:19:06 +02:00
Bobby cce02e2b15 Refresh SOGo view after mailbox activation 2026-06-08 00:01:43 +07:00
Snafu 0fafda696b Fix force_tfa not available in mailbox template #7216 2026-06-07 17:03:52 +02:00
renovate[bot] 843db5854c Update dependency composer/composer to v2.10.1
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-06-04 11:04:42 +00:00
goodygh 23e4e4f373 [SOGo] Update to 5.12.9 2026-05-29 01:39:38 +02:00
goodygh 175878f8f1 ui, fail2ban fix german ban_list_info translation (#7265) 2026-05-28 22:20:26 +02:00
milkmakerandPhu D. Nguyen 3fcda21c4e [Web] Updated lang.vi-vn.json (#7263)
Co-authored-by: Phu D. Nguyen <sillycat@duck.com>
2026-05-27 17:46:42 +02:00
milkmakerandEdgars Andersons eac1bf02fc [Web] Updated lang.lv-lv.json (#7262)
Co-authored-by: Edgars Andersons <Edgars+Mailcow+Weblate@gaitenis.id.lv>
2026-05-26 22:02:05 +02:00
renovate[bot] a7f2b2145f Update devops-infra/action-pull-request action to v1.2.1 (#7258)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 22:00:43 +02:00
FreddleSpl0it faac56a611 [Unbound] Change version tag 2026-05-26 10:43:25 +02:00
FreddleSpl0it be37bc5a68 Merge pull request #7252 from SYNLINQ/staging
fix unbound CVE-2026-33278
2026-05-26 10:41:52 +02:00
FreddleSpl0it b227c76156 Merge pull request #7259 from mailcow/feat/nginx-1.30.2
[Nginx] Update to 1.30.2
2026-05-26 10:37:57 +02:00
FreddleSpl0it db4e3b4d54 [Nginx] Update to 1.30.2 2026-05-26 10:36:39 +02:00
Michael Kuron ffbc37a00c Escape generated password in mobileconfig
Escape ampersand, less than, greater than to avoid generating invalid XML.

Fixes #7171
2026-05-24 11:52:12 +02:00
renovate[bot] e6fa0c5099 Update devops-infra/action-pull-request action to v1.2.0 (#7254)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 22:10:51 +02:00
renovate[bot] ce3ceaf5b8 Update devops-infra/action-pull-request action to v1.1.3 (#7253)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 08:36:46 +02:00
SYNLINQ efc2e76159 Update docker-compose.yml
bump unbound version
2026-05-22 16:27:43 +02:00
SYNLINQ 7d17715e2e Update Dockerfile
install unbound package from alpine:edge
2026-05-22 16:26:47 +02:00
renovate[bot] aed440bcb3 Update devops-infra/action-pull-request action to v1.1.2 (#7247)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 21:09:43 +02:00
milkmakerandAdrian-Kozien 4f2348631a [Web] Updated lang.pl-pl.json (#7245)
Co-authored-by: Adrian-Kozien <adrian@kozien.net>
2026-05-21 17:16:34 +02:00
renovate[bot] da586682f6 Update actions/stale action to v10.3.0 (#7242)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 10:04:58 +02:00
FreddleSpl0it 22ae1f5363 Merge pull request #7241 from mailcow/fix/quarantinetable
escape HTML in quarantine table
2026-05-20 18:02:19 +02:00
FreddleSpl0it c9cf8d7f38 Merge pull request #7240 from mailcow/feat/nginx1.30.1
[Nginx] Update to 1.30.1
2026-05-20 17:58:29 +02:00
FreddleSpl0it cbd3d8b9bc escape HTML in quarantine table 2026-05-20 17:53:47 +02:00
FreddleSpl0it f553f38635 [Nginx] Update to 1.30.1 2026-05-20 17:24:41 +02:00
renovate[bot] 07373907e3 Update devops-infra/action-pull-request action to v1.1.1 (#7234) 2026-05-18 08:38:18 +02:00
renovate[bot] 104af58628 Update dependency php/pecl-mail-mailparse to v3.2.0
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-05-14 18:33:03 +00:00
Jahongir Qurbonov 4ddcee28e4 Add Uzbek language (#7224) 2026-05-13 16:42:38 +02:00
milkmakerandEdson Wolf b0d16bbcee [Web] Updated lang.pt-br.json (#7228)
Co-authored-by: Edson Wolf <edsonwolf@vivaldi.net>
2026-05-13 16:42:03 +02:00
FreddleSpl0it 9175e5f086 Merge pull request #7226 from mailcow/feat/sogo-5.12.8
[SOGo] Update to 5.12.8
2026-05-13 10:34:50 +02:00
FreddleSpl0it ff3d571054 [SOGo] Update to 5.12.8 2026-05-13 10:31:23 +02:00
FreddleSpl0it a8e945f3da Merge pull request #7220 from mailcow/fix/06052026
[Web] escape HTML in sieve filter edit view and queue manager
2026-05-12 08:36:36 +02:00
FreddleSpl0it 33547d1d73 [Web] escape HTML in sieve filter edit view and queue manager 2026-05-12 08:31:51 +02:00
539c32d99c Translations update from Weblate (#7218)
* [Web] Updated lang.az-az.json

Co-authored-by: Nemoralis <nemoralis@duck.com>

* [Web] Updated lang.nl-nl.json

Co-authored-by: Tom18314 <tomstokmans5@gmail.com>

* [Web] Updated lang.pl-pl.json

Co-authored-by: pmuch78 <pawel@prolin.pl>

---------

Co-authored-by: Nemoralis <nemoralis@duck.com>
Co-authored-by: Tom18314 <tomstokmans5@gmail.com>
Co-authored-by: pmuch78 <pawel@prolin.pl>
2026-05-08 16:46:10 +02:00
milkmaker d42c3823c4 update postscreen_access.cidr (#7209) 2026-05-02 19:15:14 +02:00
FreddleSpl0it 8ead057ce9 [Web] Create default mailbox template with eas and dav access 2026-04-15 09:33:23 +02:00
886dbcc419 Translations update from Weblate (#7190)
* [Web] Updated lang.az-az.json

[Web] Added lang.az-az.json

Co-authored-by: Nemoralis <nemoralis@duck.com>
Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.si-si.json

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>

* [Web] Updated lang.pt-br.json

Co-authored-by: André Ribas <andre@ribassu.com>

---------

Co-authored-by: Nemoralis <nemoralis@duck.com>
Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: André Ribas <andre@ribassu.com>
2026-04-11 21:25:03 +02:00
Dmitriy Alekseev b0873edb6a Update RSPAMD version in Dockerfile 2026-04-06 17:28:32 +02:00
Dmitriy Alekseev 694d751915 Update RSPAMD version in Dockerfile 2026-04-06 17:28:14 +02:00
milkmaker dc15994d40 update postscreen_access.cidr (#7177) 2026-04-02 11:50:05 +02:00
FreddleSpl0it ec24825280 Merge pull request #7173 from mailcow/fix/escaping
[Web][Dovecot] Improve input validation and escaping
2026-03-31 09:45:23 +02:00
Dmitriy Alekseev 08278a8be9 Update RSPAMD version to 4.0.0 in Dockerfile 2026-03-30 23:51:05 +02:00
FreddleSpl0it 5a00b5124b [Web][Dovecot] Add parameterized queries and input validation for quarantine_category 2026-03-29 12:08:45 +02:00
FreddleSpl0it 8c039f694f Improve template URI escaping and parameter handling 2026-03-19 12:48:43 +01:00
FreddleSpl0it 95bf46c1e4 escape HTML in autodiscover logs 2026-03-19 12:44:50 +01:00
FreddleSpl0it edde35156d escape HTML in qitem details 2026-03-19 12:44:30 +01:00
FreddleSpl0it 84e3c32f13 escape HTML in last logins 2026-03-19 12:44:00 +01:00
FreddleSpl0it ecb848493b add missing object-level access control 2026-03-19 12:42:45 +01:00
FreddleSpl0it 8a65b9d1c6 add missing access control 2026-03-19 12:41:47 +01:00
FreddleSpl0it ed9264fd2a [Web] Allow force_tfa for LDAP and Keycloak users 2026-03-13 14:13:25 +01:00
195 changed files with 18903 additions and 6023 deletions

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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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}}
+1 -1
View File
@@ -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
View File
@@ -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/
+5
View File
@@ -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
+21
View File
@@ -249,12 +249,33 @@ 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
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
+3 -3
View File
@@ -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>"
+1 -3
View File
@@ -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 \
-196
View File
@@ -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);
+274
View File
@@ -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 -1
View File
@@ -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
+15 -10
View File
@@ -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 \
+3 -3
View File
@@ -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 -1
View File
@@ -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 {
+2 -2
View File
@@ -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 -1
View File
@@ -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
+3 -3
View File
@@ -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>.*)$
+5 -1
View File
@@ -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 \
+10 -1
View File
@@ -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
-10
View File
@@ -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;
}
+7
View File
@@ -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);
}
+2
View 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
+2 -4
View File
@@ -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 "*"
-2
View File
@@ -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
+120 -48
View File
@@ -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";
}
}
+6 -4
View File
@@ -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
+34 -39
View File
@@ -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;
}
+29 -33
View File
@@ -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;
}
+2
View File
@@ -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';
+3
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+8 -9
View File
@@ -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 {
+5 -5
View File
@@ -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;
+82 -32
View File
@@ -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 {
File diff suppressed because it is too large. Load diff
+155 -114
View File
@@ -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 {
+212
View File
@@ -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.
+584
View File
@@ -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);
}
+4
View File
@@ -0,0 +1,4 @@
{
"name": "servercow",
"description": "Based on the servercow theme"
}
+2
View File
@@ -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
View File
@@ -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';
+27 -7
View File
@@ -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'));
+1 -1
View File
@@ -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'];
}
}
+67
View File
@@ -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;
+3 -2
View File
@@ -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'],
+5 -1
View File
@@ -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,
+68
View File
@@ -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;
}
+20 -4
View File
@@ -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(
+8
View File
@@ -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 {
+83 -14
View File
@@ -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();
}
+105 -497
View File
@@ -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)");
+12 -8
View File
@@ -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;
+16 -1
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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];
}
}
+76
View File
@@ -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);
}
}
+35
View File
@@ -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), '?'));
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\Settings;
use PDO;
/**
* SQL for the global sync job settings (table `imapsync_settings`, name => value).
*/
class SettingsRepository extends Repository
{
/** Stored settings; keys that are not stored yet keep the defaults of the model */
public function load(): Settings
{
$rows = $this->query("SELECT `name`, `value` FROM `imapsync_settings`")->fetchAll(PDO::FETCH_KEY_PAIR);
$settings = new Settings();
$settings->maxParallel = (int)($rows['max_parallel'] ?? $settings->maxParallel);
$settings->maxBytesPerSecond = (int)($rows['max_bytes_per_second'] ?? $settings->maxBytesPerSecond);
return $settings;
}
public function save(Settings $settings): void
{
$sql = "INSERT INTO `imapsync_settings` (`name`, `value`) VALUES (:name, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)";
$this->query($sql, ['name' => 'max_parallel', 'value' => (string)$settings->maxParallel]);
$this->query($sql, ['name' => 'max_bytes_per_second', 'value' => (string)$settings->maxBytesPerSecond]);
}
}
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Context;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\Source;
use PDO;
/**
* SQL for `imapsync_source` and its target tables `imapsync_source_domain` / `imapsync_source_user`.
* No permission decisions here except the visibility filter, which is pure SQL (see visibility()).
*/
class SourceRepository extends Repository
{
/** Full source incl. secrets and targets, without visibility filter */
public function find(int $id): ?Source
{
return $this->fetch('1=1', ['id' => $id]);
}
/** Like find(), but only when the source is visible to the caller */
public function findVisible(int $id, Context $context): ?Source
{
[$visible, $params] = $this->visibility($context);
return $this->fetch($visible, $params + ['id' => $id]);
}
/** @return int[] ids of all sources visible to the caller, ordered by name */
public function visibleIds(Context $context): array
{
[$visible, $params] = $this->visibility($context);
return array_map('intval', $this->query("SELECT s.`id` FROM `imapsync_source` s WHERE $visible ORDER BY s.`name`, s.`id`",
$params)->fetchAll(PDO::FETCH_COLUMN));
}
public function nameExists(string $name, string $createdBy, ?int $exceptId = null): bool
{
return (bool)$this->query("SELECT 1 FROM `imapsync_source` WHERE `name` = :name AND `created_by` = :created_by AND `id` != :id",
['name' => $name, 'created_by' => $createdBy, 'id' => $exceptId ?? 0])->fetchColumn();
}
/** True while a sync job references the source */
public function isInUse(int $id): bool
{
return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn();
}
public function isSuperAdmin(string $username): bool
{
return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'",
['username' => $username])->fetchColumn();
}
/** Inserts the source with its targets and returns the new id */
public function insert(Source $source): int
{
return $this->transaction(function () use ($source): int {
$source->id = $this->insertRow('imapsync_source',
$this->values($source) + ['created_by' => $source->createdBy, 'scope' => $source->scope]);
$this->writeTargets($source);
return $source->id;
});
}
/**
* Updates the source. $writeTargets also rewrites scope + target lists, $resetTokens drops the shared
* token and all per-user tokens of the source. All in one transaction.
*/
public function update(Source $source, bool $writeTargets, bool $resetTokens): void
{
$this->transaction(function () use ($source, $writeTargets, $resetTokens): void {
$id = ['id' => $source->id];
$this->updateRow('imapsync_source', (int)$source->id, $this->values($source));
if ($writeTargets) {
$this->query("DELETE FROM `imapsync_source_domain` WHERE `source_id` = :id", $id);
$this->query("DELETE FROM `imapsync_source_user` WHERE `source_id` = :id", $id);
$this->writeTargets($source);
}
if ($resetTokens) {
$this->query("UPDATE `imapsync_source` SET `oauth_access_token` = NULL, `oauth_token_expires` = NULL,
`oauth_last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NULL WHERE `id` = :id", $id);
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE `source_id` = :id", $id);
}
});
}
/** Deletes the source; targets and per-user tokens go via FK ON DELETE CASCADE */
public function delete(int $id): void
{
$this->query("DELETE FROM `imapsync_source` WHERE `id` = :id", ['id' => $id]);
}
/** Deletes the source and its child rows explicitly, for callers running with FOREIGN_KEY_CHECKS = 0 (mailbox rename) */
public function deleteWithChildren(int $id): void
{
foreach (['imapsync_source_oauth_token', 'imapsync_source_user', 'imapsync_source_domain'] as $table) {
$this->query("DELETE FROM `$table` WHERE `source_id` = :id", ['id' => $id]);
}
$this->delete($id);
}
/** @return Source[] sources created by an account (id and name only) */
public function listCreatedBy(string $username): array
{
return array_map([Source::class, 'fromRow'], $this->query("SELECT `id`, `name` FROM `imapsync_source` WHERE `created_by` = :username",
['username' => $username])->fetchAll(PDO::FETCH_ASSOC));
}
/** Hands a source over to the admins (created_by '') under the given name */
public function handOver(int $id, string $name): void
{
$this->query("UPDATE `imapsync_source` SET `created_by` = '', `name` = :name WHERE `id` = :id", ['name' => $name, 'id' => $id]);
}
/** Follows an account rename: creator and user targets */
public function renameAccount(string $old, string $new): void
{
$this->query("UPDATE `imapsync_source` SET `created_by` = :new WHERE `created_by` = :old", ['new' => $new, 'old' => $old]);
$this->query("UPDATE `imapsync_source_user` SET `username` = :new WHERE `username` = :old", ['new' => $new, 'old' => $old]);
}
/** Removes a deleted account from all user target lists */
public function deleteUserTarget(string $username): void
{
$this->query("DELETE FROM `imapsync_source_user` WHERE `username` = :username", ['username' => $username]);
}
private function fetch(string $where, array $params): ?Source
{
$row = $this->query("SELECT s.* FROM `imapsync_source` s WHERE s.`id` = :id AND $where", $params)->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$source = Source::fromRow($row);
$id = ['id' => $source->id];
$source->domains = $this->query("SELECT `domain` FROM `imapsync_source_domain` WHERE `source_id` = :id ORDER BY `domain`",
$id)->fetchAll(PDO::FETCH_COLUMN);
$source->users = $this->query("SELECT `username` FROM `imapsync_source_user` WHERE `source_id` = :id ORDER BY `username`",
$id)->fetchAll(PDO::FETCH_COLUMN);
return $source;
}
/**
* WHERE fragment over `imapsync_source s` that limits rows to what the caller may see, and its parameters.
* admin everything
* domainadmin own + 'all' + sources targeting his domains or their mailboxes + sources created by them
* user own + 'all' + sources targeting him or his domain
*
* @return array{0: string, 1: array}
*/
private function visibility(Context $context): array
{
$username = $context->username;
if ($context->isAdmin()) {
return ['1=1', []];
}
// Native prepares: a placeholder may appear only once, so the username is bound under one name per use
$managed = "SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = ";
if ($context->isDomainAdmin()) {
return ["(s.`created_by` = :vis_self OR s.`scope` = 'all'
OR (s.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` t
WHERE t.`source_id` = s.`id` AND t.`domain` IN ($managed :vis_d1)))
OR (s.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` t JOIN `mailbox` mb ON mb.`username` = t.`username`
WHERE t.`source_id` = s.`id` AND mb.`domain` IN ($managed :vis_d2)))
OR s.`created_by` IN (SELECT `username` FROM `mailbox` WHERE `domain` IN ($managed :vis_d3)))",
['vis_self' => $username, 'vis_d1' => $username, 'vis_d2' => $username, 'vis_d3' => $username]];
}
if ($context->isUser()) {
return ["(s.`created_by` = :vis_self OR s.`scope` = 'all'
OR (s.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` t
WHERE t.`source_id` = s.`id` AND t.`username` = :vis_u1))
OR (s.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` t JOIN `mailbox` mb ON mb.`domain` = t.`domain`
WHERE t.`source_id` = s.`id` AND mb.`username` = :vis_u2)))",
['vis_self' => $username, 'vis_u1' => $username, 'vis_u2' => $username]];
}
return ['0=1', []];
}
private function writeTargets(Source $source): void
{
foreach ($source->domains as $domain) {
$this->query("INSERT INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)",
['id' => $source->id, 'domain' => $domain]);
}
foreach ($source->users as $username) {
$this->query("INSERT INTO `imapsync_source_user` (`source_id`, `username`) VALUES (:id, :username)",
['id' => $source->id, 'username' => $username]);
}
$this->query("UPDATE `imapsync_source` SET `scope` = :scope WHERE `id` = :id", ['scope' => $source->scope, 'id' => $source->id]);
}
private function values(Source $source): array
{
return [
'name' => $source->name,
'description' => $source->description,
'host1' => $source->host,
'port1' => $source->port,
'enc1' => $source->enc,
'auth_type' => $source->authType,
'oauth_flow' => $source->oauthFlow,
'oauth_token_endpoint' => $source->tokenEndpoint,
'oauth_authorize_endpoint' => $source->authorizeEndpoint,
'oauth_userinfo_endpoint' => $source->userinfoEndpoint,
'oauth_client_id' => $source->clientId,
'oauth_client_secret' => $source->clientSecret,
'oauth_scope' => $source->oauthScope,
'oauth_extra_params' => $source->extraParams,
'active' => $source->active,
];
}
}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\OAuthToken;
use Mailcow\Imapsync\Model\Source;
use PDO;
/**
* SQL for OAuth tokens: the shared token of a client_credentials source (columns of `imapsync_source`)
* and the per-user tokens of authorization_code sources (`imapsync_source_oauth_token`).
*/
class TokenRepository extends Repository
{
private const USER_TOKEN = "`source_id` = :sid AND `owner` = :owner AND `username` = :user";
public function exists(int $sourceId, string $owner, string $username): bool
{
return (bool)$this->query("SELECT 1 FROM `imapsync_source_oauth_token` WHERE " . self::USER_TOKEN,
['sid' => $sourceId, 'owner' => $owner, 'user' => $username])->fetchColumn();
}
/**
* Stores the token of a finished browser sign-in. An existing refresh token is kept when the
* provider sent none (Microsoft only sends one with offline_access).
*/
public function saveUserToken(OAuthToken $token): void
{
$this->query("INSERT INTO `imapsync_source_oauth_token`
(`source_id`, `owner`, `username`, `access_token`, `refresh_token`, `token_expires`, `last_refresh_error`, `oauth_last_refresh_attempt`)
VALUES (:sid, :owner, :user, :acc, :ref, :exp, NULL, NOW())
ON DUPLICATE KEY UPDATE
`access_token` = VALUES(`access_token`),
`refresh_token` = IF(VALUES(`refresh_token`) = '', `refresh_token`, VALUES(`refresh_token`)),
`token_expires` = VALUES(`token_expires`),
`last_refresh_error` = NULL,
`oauth_last_refresh_attempt` = NOW()",
$this->key($token) + ['acc' => $token->accessToken, 'ref' => (string)$token->refreshToken, 'exp' => $token->tokenExpires]);
}
/** Stores a refreshed per-user token and clears its last error */
public function storeUserRefresh(OAuthToken $token): void
{
$this->query("UPDATE `imapsync_source_oauth_token` SET `access_token` = :acc, `refresh_token` = :ref,
`token_expires` = :exp, `last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NOW() WHERE " . self::USER_TOKEN,
$this->key($token) + ['acc' => $token->accessToken, 'ref' => $token->refreshToken, 'exp' => $token->tokenExpires]);
}
public function storeUserError(OAuthToken $token, string $error): void
{
$this->query("UPDATE `imapsync_source_oauth_token` SET `last_refresh_error` = :err, `oauth_last_refresh_attempt` = NOW()
WHERE " . self::USER_TOKEN, $this->key($token) + ['err' => $error]);
}
public function storeSourceToken(int $sourceId, string $accessToken, int $expires): void
{
$this->query("UPDATE `imapsync_source` SET `oauth_access_token` = :tok, `oauth_token_expires` = :exp,
`oauth_last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NOW() WHERE `id` = :id",
['tok' => $accessToken, 'exp' => $expires, 'id' => $sourceId]);
}
public function storeSourceError(int $sourceId, string $error): void
{
$this->query("UPDATE `imapsync_source` SET `oauth_last_refresh_error` = :err, `oauth_last_refresh_attempt` = NOW()
WHERE `id` = :id", ['err' => $error, 'id' => $sourceId]);
}
/** @return Source[] active client_credentials sources (incl. secrets), each with refreshAttemptAge */
public function refreshableSources(): array
{
return array_map([Source::class, 'fromRow'], $this->query("SELECT *,
TIMESTAMPDIFF(SECOND, `oauth_last_refresh_attempt`, NOW()) AS `refresh_attempt_age`
FROM `imapsync_source`
WHERE `active` = 1 AND `auth_type` = 'XOAUTH2' AND `oauth_flow` = 'client_credentials'
ORDER BY `id`")->fetchAll(PDO::FETCH_ASSOC));
}
/** @return OAuthToken[] per-user tokens with a refresh token on active authorization_code sources, with refreshAttemptAge */
public function refreshableUserTokens(): array
{
return array_map([OAuthToken::class, 'fromRow'], $this->query("SELECT t.*,
TIMESTAMPDIFF(SECOND, t.`oauth_last_refresh_attempt`, NOW()) AS `refresh_attempt_age`
FROM `imapsync_source_oauth_token` t
JOIN `imapsync_source` s ON s.`id` = t.`source_id`
WHERE s.`active` = 1 AND s.`auth_type` = 'XOAUTH2' AND s.`oauth_flow` = 'authorization_code'
AND t.`refresh_token` <> ''
ORDER BY t.`source_id`, t.`owner`, t.`username`")->fetchAll(PDO::FETCH_ASSOC));
}
/** Drops a per-user token once no job uses (source, owner, username) anymore */
public function deleteIfUnused(int $sourceId, string $owner, string $username): void
{
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE " . self::USER_TOKEN . "
AND NOT EXISTS (SELECT 1 FROM `imapsync` WHERE `source_id` = :sid2 AND `user2` = :owner2 AND `user1` = :user2)",
['sid' => $sourceId, 'owner' => $owner, 'user' => $username, 'sid2' => $sourceId, 'owner2' => $owner, 'user2' => $username]);
}
/** Drops per-user tokens that no job uses and that are older than $seconds. Returns the number of rows. */
public function deleteUnusedOlderThan(int $seconds): int
{
return $this->query("DELETE t FROM `imapsync_source_oauth_token` t
LEFT JOIN `imapsync` i ON i.`source_id` = t.`source_id` AND i.`user2` = t.`owner` AND i.`user1` = t.`username`
WHERE i.`id` IS NULL AND t.`created` < NOW() - INTERVAL :seconds SECOND", ['seconds' => $seconds])->rowCount();
}
public function deleteByOwner(string $owner): void
{
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE `owner` = :owner", ['owner' => $owner]);
}
public function renameOwner(string $old, string $new): void
{
$this->query("UPDATE `imapsync_source_oauth_token` SET `owner` = :new WHERE `owner` = :old", ['new' => $new, 'old' => $old]);
}
private function key(OAuthToken $token): array
{
return ['sid' => $token->sourceId, 'owner' => $token->owner, 'user' => $token->username];
}
}
@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Service;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Model\CustomParams;
use Mailcow\Imapsync\Model\Job;
use Mailcow\Imapsync\Model\Settings;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Repository\JobRepository;
use Mailcow\Imapsync\Repository\SettingsRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
/**
* Use cases and rules for sync jobs and the global sync settings.
*
* A job belongs to its local mailbox (user2): whoever may access that mailbox may see and manage it
* (admins also orphaned jobs). Writing needs the `syncjobs` ACL. Jobs on an authorization_code source
* need the per-user token of (source, owner = user2, username = user1) first (C1).
*/
class JobService
{
private const FLAGS = ['active', 'delete2duplicates', 'subscribeall', 'dry', 'delete1', 'delete2', 'automap', 'skipcrossduplicates'];
public function __construct(private JobRepository $jobs, private SourceService $sources, private TokenRepository $tokens,
private SettingsRepository $settings, private Context $context, private array $allowlist)
{
}
/** @throws AccessDenied without the syncjobs ACL */
public function requireAcl(): void
{
if (!$this->context->hasAcl('syncjobs')) {
throw new AccessDenied();
}
}
/** The allowed custom params (option => rule), for the job form */
public function customParamOptions(): array
{
return $this->allowlist;
}
/**
* The job when the caller may access its mailbox (incl. password1: never pass the object to the UI, use toArray()).
* Admins may also manage orphaned jobs whose local mailbox no longer exists.
*/
public function get(int $id, bool $withLog = true): ?Job
{
$job = $this->jobs->find($id, $withLog);
return ($job !== null && ($this->context->isAdmin() || $this->context->canAccessMailbox($job->user2))) ? $job : null;
}
/** @return int[] job ids of a mailbox (default: the caller's own) in queue order; [] without access */
public function idsForMailbox(mixed $mailbox): array
{
if ($mailbox !== null && filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
if (!$this->context->canAccessMailbox((string)$mailbox)) {
return [];
}
} else {
$mailbox = $this->context->username;
}
return ($mailbox === null) ? [] : $this->jobs->idsForMailbox((string)$mailbox);
}
/** Only admins list all jobs at once (incl. orphaned ones) */
public function canListAll(): bool
{
return $this->context->isAdmin();
}
/** @return int[] all job ids in queue order; [] for non-admins */
public function allIds(): array
{
return $this->canListAll() ? $this->jobs->allIds() : [];
}
/** @throws UserError */
public function create(array $input): Job
{
$this->requireAcl();
$job = new Job();
$job->user2 = $this->ownerMailbox($input);
// Add keeps its legacy parsing of these attributes (e.g. maxage '1.5' means none, on edit 1); then the edit rules apply
$input['source_id'] = (int)($input['source_id'] ?? 0);
$input['maxage'] = Input::intInRange($input['maxage'] ?? null, 1, 32000) ?? 0;
$input['maxbytespersecond'] = Input::intInRange($input['maxbytespersecond'] ?? null, 1, 125000000) ?? 0;
$this->save($job, null, $input + ['custom_params' => '']);
return $job;
}
/** @throws UserError */
public function update(int $id, array $input): Job
{
$this->requireAcl();
$current = $this->get($id, false) ?? throw new AccessDenied();
$job = clone $current;
$this->save($job, $current, $input);
return $job;
}
/** @throws UserError access_denied when the job does not exist or the caller may not access it */
public function delete(int $id): void
{
$this->requireAcl();
$job = $this->get($id, false) ?? throw new AccessDenied();
// prio is a sparse sort key (ties allowed), so no gap needs closing
$this->jobs->delete($id);
$this->tokens->deleteIfUnused($job->sourceId, $job->user2, $job->user1);
}
/**
* Admin only (C4): moves jobs to the front of the queue. Running jobs are left alone; the runner never resets prio.
*
* @param int[] $ids
* @return array{running: int[], moved: int}
*/
public function runNext(array $ids): array
{
$ids = array_values(array_unique($ids));
if (!$this->context->isAdmin() || empty($ids)) {
throw new AccessDenied();
}
return $this->jobs->moveToFront($ids);
}
/** @throws AccessDenied for non-admins */
public function getSettings(): Settings
{
if (!$this->context->isAdmin()) {
throw new AccessDenied();
}
return $this->settings->load();
}
/**
* Saves the global settings (admin only). The UI sends the bandwidth limit in KB/s; stored as bytes/s
* like the per-job limit.
*
* @throws UserError
*/
public function saveSettings(array $input): Settings
{
if (!$this->context->isAdmin()) {
throw new AccessDenied();
}
$maxParallel = Input::intInRange($input['max_parallel'] ?? null, 1, 32);
$kb = $input['max_kb_per_second'] ?? 0;
if ($maxParallel === null) {
throw new UserError('imapsync_max_parallel_invalid');
}
// 0 (unlimited) .. 10 GiB/s
$kb = Input::intInRange(($kb === '') ? 0 : $kb, 0, 10485760) ?? throw new UserError('imapsync_max_kb_per_second_invalid');
$settings = new Settings();
$settings->maxParallel = $maxParallel;
$settings->maxBytesPerSecond = $kb * 1024;
$this->settings->save($settings);
return $settings;
}
/**
* Applies an add ($current = null) or edit request to $job and stores it. Attributes missing in $input keep
* the value of $job, an empty password keeps the stored one. On edit the source is only checked when the job
* moves to another source, so a domain admin or user can still edit or deactivate a job bound to a source
* he cannot see.
*
* @throws UserError
*/
private function save(Job $job, ?Job $current, array $input): void
{
foreach (self::FLAGS as $flag) {
if (isset($input[$flag])) {
$job->$flag = (int)$input[$flag];
}
}
foreach (['maxage', 'timeout1', 'timeout2'] as $number) {
if (isset($input[$number]) && $input[$number] != '') {
$job->$number = (int)$input[$number];
}
}
if (isset($input['maxbytespersecond']) && $input['maxbytespersecond'] != '') {
$job->maxBytesPerSecond = (string)(int)$input['maxbytespersecond'];
}
// last_run/success are reset when present; prio is a global-queue concern that only admins may change
$job->lastRun = isset($input['last_run']) ? null : $job->lastRun;
$job->success = isset($input['success']) ? null : $job->success;
if (isset($input['prio']) && is_numeric($input['prio']) && $this->context->isAdmin()) {
$job->prio = (int)$input['prio'];
}
// An empty user1 keeps the stored one on edit; a new job needs one
$user1 = (isset($input['user1']) && Input::text($input['user1']) !== '') ? Input::text($input['user1']) : $job->user1;
if ($user1 === null || mb_strlen($user1) > 255 || ($current === null && $user1 === '')) {
throw new UserError('imapsync_user1_invalid');
}
$password = (isset($input['password1']) && is_scalar($input['password1']) && (string)$input['password1'] !== '')
? (string)$input['password1'] : $job->password1;
$sourceId = (!empty($input['source_id']) && is_scalar($input['source_id'])) ? (int)$input['source_id'] : $job->sourceId;
$sourceChanged = ($current === null || $sourceId !== $current->sourceId);
$tupleChanged = $sourceChanged || $user1 !== $current->user1;
$job->user1 = $user1;
$job->sourceId = $sourceId;
$source = $sourceChanged ? $this->sources->get($sourceId) : $current->source;
if ($source === null || ($sourceChanged && $source->active !== 1)) {
throw new UserError('imapsync_source_unavailable');
}
if ($source->usesClientCredentials() && $tupleChanged && !$sourceChanged && !$this->mayUse($source)) {
// The app token reaches every mailbox of the tenant, so only callers that may use the source
// may pick another remote mailbox (user1). Other fields stay editable.
throw new AccessDenied();
}
$job->password1 = $this->checkAuth($source, $job, $password, $tupleChanged);
if (array_key_exists('custom_params', $input)) {
$job->customParams = CustomParams::toJson($input['custom_params'], $this->allowlist);
}
$job->maxage = Input::intInRange($job->maxage, 1, 32000) ?? 0;
$job->timeout1 = Input::intInRange($job->timeout1, 1, 32000) ?? 600;
$job->timeout2 = Input::intInRange($job->timeout2, 1, 32000) ?? 600;
$job->maxBytesPerSecond = (string)(Input::intInRange($job->maxBytesPerSecond, 1, 125000000) ?? 0);
$exclude = isset($input['exclude']) ? Input::text($input['exclude']) : $job->exclude;
$subfolder2 = isset($input['subfolder2']) ? Input::text($input['subfolder2']) : $job->subfolder2;
$minsInterval = Input::intInRange(!empty($input['mins_interval']) ? $input['mins_interval'] : $job->minsInterval, 1, 43800);
// exclude must be a valid regex, the interval 1..43800 minutes
if ($exclude === null || $subfolder2 === null || $minsInterval === null || @preg_match("/" . $exclude . "/", '') === false) {
throw new AccessDenied();
}
$job->exclude = $exclude;
$job->subfolder2 = $subfolder2;
$job->minsInterval = $minsInterval;
if ($tupleChanged && $this->jobs->tupleExists($job->sourceId, $job->user2, $job->user1, $current?->id)) {
throw new UserError('object_exists', [htmlspecialchars($source->name . ' / ' . $job->user1)]);
}
if ($current === null) {
$this->jobs->insert($job);
return;
}
$this->jobs->update($job);
// The previous (source, owner, remote user) token is dropped once no job uses it (C1)
if ($tupleChanged) {
$this->tokens->deleteIfUnused($current->sourceId, $current->user2, $current->user1);
}
}
/** Whether the caller may use the source for new jobs (sees it or may edit it) */
private function mayUse(Source $source): bool
{
return $this->sources->get((int)$source->id) !== null || $this->sources->canEdit($source);
}
/**
* Local mailbox of a new job: `username` (needs access), else the caller's own mailbox (users only).
*
* @throws UserError access_denied, no_user_defined
*/
private function ownerMailbox(array $input): string
{
$username = $input['username'] ?? null;
if (is_string($username) && filter_var($username, FILTER_VALIDATE_EMAIL)) {
return $this->context->canAccessMailbox($username) ? $username : throw new AccessDenied();
}
return $this->context->isUser() ? (string)$this->context->username : throw new UserError('no_user_defined');
}
/**
* Returns the password to store. XOAUTH2 sources carry no password (the runner uses an access token);
* an authorization_code source needs the per-user token of (source, owner, remote user) when it is new.
*
* @throws UserError imapsync_source_oauth_connect_required, password_empty
*/
private function checkAuth(Source $source, Job $job, string $password, bool $tupleChanged): string
{
if (!$source->isXOAuth2()) {
return ($password !== '') ? $password : throw new UserError('password_empty');
}
if ($source->usesAuthorizationCode() && $tupleChanged && !$this->tokens->exists($job->sourceId, $job->user2, $job->user1)) {
throw new UserError('imapsync_source_oauth_connect_required');
}
return '';
}
}
@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Service;
use Exception;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Http\OAuthClient;
use Mailcow\Imapsync\Http\OAuthException;
use Mailcow\Imapsync\Model\OAuthToken;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Repository\SourceRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
use PDOException;
/**
* OAuth2 for XOAUTH2 sources: their OAuth settings, token requests for every grant type, the browser sign-in
* (authorization_code) and token refresh. Needs no Context, so the refresh cron can use it; callers check
* permissions first.
*/
class OAuthService
{
/** Tokens expiring within this many seconds are refreshed */
private const REFRESH_AHEAD = 300;
/** A token whose last refresh failed is retried after this many seconds */
private const RETRY_AFTER = 900;
/** Per-user tokens that no job uses are kept this long (time to save the job after signing in) */
private const UNUSED_TOKEN_GRACE = 86400;
private string $hostname;
public function __construct(private SourceRepository $sources, private TokenRepository $tokens, private OAuthClient $client,
string $hostname)
{
$this->hostname = strtolower(trim($hostname));
}
/**
* Redirect URI to register at the provider. Only MAILCOW_HOSTNAME is supported: the popup callback
* needs the session cookie and opener origin of the UI, so the UI must be used on that host.
*/
public function redirectUri(): string
{
return ($this->hostname === '') ? '' : 'https://' . $this->hostname . '/syncjob-oauth';
}
/**
* Sets the OAuth settings of a source from request attributes: cleared for password sources, read and
* validated for XOAUTH2 (incl. the endpoint policy C5). Missing attributes keep the value of $source,
* an empty client secret keeps the stored one. Permission checks are the caller's job.
*
* @throws UserError
*/
public function applySettings(Source $source, array $input): void
{
if (!$source->isXOAuth2()) {
$source->oauthFlow = Source::FLOW_CLIENT_CREDENTIALS;
$source->tokenEndpoint = $source->authorizeEndpoint = $source->userinfoEndpoint = null;
$source->clientId = $source->clientSecret = $source->oauthScope = $source->extraParams = null;
return;
}
$source->oauthFlow = (($input['oauth_flow'] ?? $source->oauthFlow) === Source::FLOW_AUTHORIZATION_CODE)
? Source::FLOW_AUTHORIZATION_CODE : Source::FLOW_CLIENT_CREDENTIALS;
$source->tokenEndpoint = $this->oauthText($input, 'oauth_token_endpoint', $source->tokenEndpoint);
$authorizeEndpoint = $this->oauthText($input, 'oauth_authorize_endpoint', $source->authorizeEndpoint);
$userinfoEndpoint = $this->oauthText($input, 'oauth_userinfo_endpoint', $source->userinfoEndpoint);
$source->clientId = $this->oauthText($input, 'oauth_client_id', $source->clientId);
$source->oauthScope = $this->oauthText($input, 'oauth_scope', $source->oauthScope);
$secret = $input['oauth_client_secret'] ?? '';
if (!is_scalar($secret)) {
throw new UserError('imapsync_source_oauth_invalid');
}
$source->clientSecret = ((string)$secret !== '') ? (string)$secret : (string)$source->clientSecret;
$source->extraParams = $this->extraParams(array_key_exists('oauth_extra_params', $input) ? $input['oauth_extra_params'] : $source->extraParams);
if ($source->tokenEndpoint === '' || $source->clientId === '' || $source->clientSecret === '' || $source->oauthScope === '') {
throw new UserError('imapsync_source_oauth_required');
}
if (mb_strlen($source->clientId) > 500 || mb_strlen($source->clientSecret) > 500 || mb_strlen($source->oauthScope) > 500) {
throw new UserError('imapsync_source_oauth_invalid');
}
// C5, checked again before every request. The authorize endpoint is only opened in the browser,
// but must not point users to internal hosts either.
$endpoints = [$source->tokenEndpoint];
if ($source->usesAuthorizationCode()) {
$endpoints[] = $authorizeEndpoint;
}
if ($userinfoEndpoint !== '') {
$endpoints[] = $userinfoEndpoint;
}
foreach ($endpoints as $endpoint) {
if (!$this->endpointAllowed($endpoint, $source->createdBy)) {
throw new UserError('imapsync_source_oauth_endpoint_invalid');
}
}
$source->authorizeEndpoint = ($authorizeEndpoint === '') ? null : $authorizeEndpoint;
$source->userinfoEndpoint = ($userinfoEndpoint === '') ? null : $userinfoEndpoint;
}
/** Provider consent URL for the browser sign-in, or null when the authorize endpoint is not allowed */
public function authorizeUrl(Source $source, string $state): ?string
{
$endpoint = (string)$source->authorizeEndpoint;
if (!$this->endpointAllowed($endpoint, $source->createdBy)) {
return null;
}
return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query(['client_id' => $source->clientId,
'response_type' => 'code', 'redirect_uri' => $this->redirectUri(), 'response_mode' => 'query',
'scope' => $source->oauthScope, 'state' => $state]);
}
/**
* Finishes the browser sign-in: exchanges the code, finds the remote identity and stores the token
* for the local mailbox $owner (C1). Returns the remote identity (the job's user1).
*
* @throws UserError token_failed (args: detail for the server log) or no_identity
*/
public function connectUser(Source $source, string $owner, string $code): string
{
try {
$token = $this->requestToken($source, ['grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->redirectUri()]);
} catch (OAuthException $e) {
throw new UserError('token_failed', ['token exchange failed for source ' . $source->id . ': ' . $e->getMessage()]);
}
$identity = $this->identity($source, $token);
if ($identity === null) {
throw new UserError('no_identity');
}
try {
$this->tokens->saveUserToken(OAuthToken::fromRow(['source_id' => $source->id, 'owner' => $owner, 'username' => $identity,
'access_token' => $token['access_token'], 'refresh_token' => $token['refresh_token'] ?? '',
'token_expires' => time() + (int)$token['expires_in']]));
} catch (PDOException $e) {
throw new UserError('token_failed', ['storing token failed for source ' . $source->id . ': ' . $e->getMessage()]);
}
return $identity;
}
/** Fetches and stores a new shared token of a client_credentials source (or stores the error); returns the error or null */
public function refreshSourceToken(Source $source): ?string
{
if (!$source->usesClientCredentials()) {
return 'not refreshable';
}
try {
$token = $this->requestToken($source, ['grant_type' => 'client_credentials']);
} catch (OAuthException $e) {
$this->tokens->storeSourceError((int)$source->id, $e->getMessage());
return $e->getMessage();
}
$this->tokens->storeSourceToken((int)$source->id, $token['access_token'], time() + (int)$token['expires_in']);
return null;
}
/** Refreshes and stores a per-user token via its refresh token (or stores the error); returns the error or null */
public function refreshUserToken(Source $source, OAuthToken $token): ?string
{
if (!$source->usesAuthorizationCode() || (string)$token->refreshToken === '') {
return 'not refreshable';
}
try {
$new = $this->requestToken($source, ['grant_type' => 'refresh_token', 'refresh_token' => $token->refreshToken]);
} catch (OAuthException $e) {
$this->tokens->storeUserError($token, $e->getMessage());
return $e->getMessage();
}
$token->accessToken = $new['access_token'];
// Some providers omit a new refresh token on refresh: keep the existing one then
$token->refreshToken = empty($new['refresh_token']) ? $token->refreshToken : $new['refresh_token'];
$token->tokenExpires = time() + (int)$new['expires_in'];
$this->tokens->storeUserRefresh($token);
return null;
}
/**
* Cron run: refreshes due source tokens, drops unused per-user tokens, refreshes due per-user tokens.
*
* @return array<int, array{priority: string, message: string}> log entries for CRON_LOG
*/
public function refreshDue(): array
{
$log = [];
foreach ($this->tokens->refreshableSources() as $source) {
if ($this->isDue($source->tokenExpires, $source->lastRefreshError, $source->refreshAttemptAge)) {
try {
$error = $this->refreshSourceToken($source);
} catch (Exception $e) {
$error = get_class($e);
}
$label = "source '" . $source->name . "' (id=" . $source->id . ")";
$log[] = $this->logEntry($error, "Refreshed token for " . $label, "Refresh failed for " . $label);
}
}
$removed = $this->tokens->deleteUnusedOlderThan(self::UNUSED_TOKEN_GRACE);
if ($removed > 0) {
$log[] = ['priority' => 'info', 'message' => "Removed " . $removed . " unused user token(s)"];
}
foreach ($this->tokens->refreshableUserTokens() as $token) {
if ($this->isDue($token->tokenExpires, $token->lastRefreshError, $token->refreshAttemptAge)) {
$source = null;
try {
$source = $this->sources->find($token->sourceId);
$error = ($source === null) ? 'not refreshable' : $this->refreshUserToken($source, $token);
} catch (Exception $e) {
$error = get_class($e);
}
$label = "user token '" . $token->username . "' (mailbox " . $token->owner . ") on source '"
. ($source->name ?? '') . "' (id=" . $token->sourceId . ")";
$log[] = $this->logEntry($error, "Refreshed " . $label, "Refresh failed for " . $label);
}
}
return $log;
}
/** Due when the token expires soon (or is missing) and the last failed attempt is old enough */
private function isDue(?int $expires, ?string $lastError, ?int $attemptAge): bool
{
return ($expires === null || $expires < time() + self::REFRESH_AHEAD)
&& ($lastError === null || $attemptAge === null || $attemptAge > self::RETRY_AFTER);
}
private function logEntry(?string $error, string $success, string $failure): array
{
return ($error === null) ? ['priority' => 'info', 'message' => $success]
: ['priority' => 'warning', 'message' => $failure . ": " . $error];
}
/**
* Endpoint policy (C5). Endpoints of sources not created by an admin must resolve to public addresses;
* admin sources have no address restriction (API key sessions run as admin under the reserved name 'API').
*/
private function endpointAllowed(string $url, string $createdBy): bool
{
return $this->client->isAllowed($url, $this->isStrict($createdBy));
}
private function isStrict(string $createdBy): bool
{
return !($createdBy === 'API' || $this->sources->isSuperAdmin($createdBy));
}
/** One OAuth text attribute from the request, or the stored value when the attribute is missing */
private function oauthText(array $input, string $key, ?string $stored): string
{
$value = array_key_exists($key, $input) ? Input::text($input[$key]) : (string)$stored;
return $value ?? throw new UserError('imapsync_source_oauth_invalid');
}
/**
* Extra token request parameters: a JSON object with scalar values, as string or as array
* (after 011-api.js auto-parsing). Returns the JSON to store, null for none.
*/
private function extraParams(mixed $extra): ?string
{
if ($extra === null || (is_string($extra) && trim($extra) === '')) {
$extra = [];
} elseif (is_string($extra)) {
$extra = json_decode($extra, true);
}
$bad = !is_array($extra);
foreach ((array)$extra as $key => $value) {
$bad = $bad || !is_string($key) || $key === '' || !(is_scalar($value) || $value === null);
}
$json = empty($extra) ? null : json_encode($extra, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($bad || strlen((string)$json) > 4000) {
throw new UserError('imapsync_source_oauth_extra_invalid');
}
return $json;
}
/**
* Token endpoint request for every grant type. Stored extra params never override the client and grant fields.
*
* @throws OAuthException
*/
private function requestToken(Source $source, array $grant): array
{
$extra = json_decode((string)$source->extraParams, true);
$post = array_merge(is_array($extra) ? $extra : [],
['client_id' => $source->clientId, 'client_secret' => $source->clientSecret, 'scope' => $source->oauthScope], $grant);
$token = $this->client->request((string)$source->tokenEndpoint, $this->isStrict($source->createdBy), $post);
if (empty($token['access_token']) || !is_string($token['access_token'])) {
throw new OAuthException('malformed token response');
}
if (!isset($token['expires_in']) || !is_numeric($token['expires_in'])) {
$token['expires_in'] = 3600;
}
if (isset($token['refresh_token']) && !is_string($token['refresh_token'])) {
unset($token['refresh_token']);
}
return $token;
}
/** Remote identity: id_token claims (received directly from the token endpoint over TLS), else the userinfo endpoint */
private function identity(Source $source, array $token): ?string
{
$parts = explode('.', is_string($token['id_token'] ?? null) ? $token['id_token'] : '');
$email = (count($parts) === 3) ? $this->emailClaim(json_decode((string)base64_decode(strtr($parts[1], '-_', '+/')), true)) : null;
if ($email !== null || empty($source->userinfoEndpoint)) {
return $email;
}
try {
return $this->emailClaim($this->client->request($source->userinfoEndpoint, $this->isStrict($source->createdBy), null,
['Authorization: Bearer ' . $token['access_token']]));
} catch (OAuthException $e) {
return null;
}
}
private function emailClaim(mixed $claims): ?string
{
foreach (['email', 'preferred_username', 'upn', 'unique_name'] as $key) {
if (is_array($claims) && !empty($claims[$key]) && is_string($claims[$key]) && filter_var($claims[$key], FILTER_VALIDATE_EMAIL)) {
return strtolower($claims[$key]);
}
}
return null;
}
}
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff. Show more