Compare commits

..

295 Commits

Author SHA1 Message Date
FreddleSpl0it
c0be3347f8 Merge pull request #7026 from mailcow/staging
Automatic PR to nightly from 2026-01-29T09:19:39Z
2026-01-29 10:33:28 +01:00
FreddleSpl0it
caaa4a414d [Web] Fix datatables search after PR #7022 2026-01-29 10:26:44 +01:00
FreddleSpl0it
4f08c4ed7d Merge remote-tracking branch 'origin/staging' into nightly 2026-01-29 07:58:15 +01:00
FreddleSpl0it
c3d841340c [Dovecot][PHP][SOGo] Update Images 2026-01-28 11:28:36 +01:00
FreddleSpl0it
b8cd00111f Merge pull request #7007 from moregeek/feat/allow_preset_passwords
feat: allow preset of passwords via environment vars
2026-01-28 10:18:56 +01:00
FreddleSpl0it
81cda80651 Merge pull request #7021 from mailcow/feat/restrict-alias-sending
[Postfix] Configurable send permissions for alias addresses
2026-01-28 10:03:02 +01:00
FreddleSpl0it
c1d4f04c22 Merge branch 'staging' into feat/restrict-alias-sending 2026-01-28 10:02:03 +01:00
FreddleSpl0it
82276cd1ca Merge pull request #7022 from mailcow/feat/eas-dav-access
[Web] Allow admins to limit EAS and DAV access for mailbox users
2026-01-28 09:54:47 +01:00
FreddleSpl0it
56ea4302ed [Web] Allow admins to limit EAS and DAV access for mailbox users 2026-01-28 09:49:33 +01:00
FreddleSpl0it
c06112b26e [Postfix] Configurable send permissions for alias addresses 2026-01-27 09:05:51 +01:00
FreddleSpl0it
aa5a4f0998 Merge pull request #6710 from mailcow/renovate/tianon-gosu-1.x
chore(deps): update dependency tianon/gosu to v1.19
2026-01-27 08:09:31 +01:00
FreddleSpl0it
bf4f471cfd Merge pull request #6837 from mailcow/renovate/php-memcached-dev-php-memcached-3.x
chore(deps): update dependency php-memcached-dev/php-memcached to v3.4.0
2026-01-27 08:08:50 +01:00
FreddleSpl0it
978bff9dbc Merge pull request #6867 from DiscoNova/feat/possible-to-disable-logins-from-autoprotocol-domains
[Web] Disable login UI on autoprotocol domains
2026-01-27 08:08:12 +01:00
FreddleSpl0it
869d9af7dd Merge pull request #6901 from mailcow/renovate/phpredis-phpredis-6.x
chore(deps): update dependency phpredis/phpredis to v6.3.0
2026-01-27 08:05:58 +01:00
FreddleSpl0it
af10499ecb Merge pull request #6927 from mailcow/renovate/imagick-imagick-3.x
chore(deps): update dependency imagick/imagick to v3.8.1
2026-01-27 08:04:51 +01:00
FreddleSpl0it
a1a4d8ff98 Merge pull request #6947 from mailcow/renovate/krakjoe-apcu-5.x
chore(deps): update dependency krakjoe/apcu to v5.1.28
2026-01-27 08:04:24 +01:00
FreddleSpl0it
95d61e8aa2 Merge pull request #6980 from bluewalk/feat/issue-6489
Configurable displayName(s) - Fixes issue #6489
2026-01-27 08:02:20 +01:00
FreddleSpl0it
ec8dd1a54f Merge pull request #6990 from psuet/mobileconfig-with-password-complexity
fix: Password for mobileconfig that conforms to password-complexity policy
2026-01-27 07:56:35 +01:00
milkmaker
382ee34d0e [Web] Updated lang.hu-hu.json (#7020)
Co-authored-by: Sándor <me-github@sandros.hu>
2026-01-26 20:15:47 +01:00
milkmaker
0999c9e9ab Translations update from Weblate (#7014)
* [Web] Updated lang.zh-cn.json

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

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

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

---------

Co-authored-by: 雨 <luotianyi@luotianyi.me>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2026-01-23 22:02:55 +01:00
DerLinkman
0e76396f01 reuse nightly images where needed 2026-01-20 08:46:04 +01:00
DerLinkman
9bbac9f171 Merge branch 'staging' into nightly 2026-01-19 12:17:11 +01:00
Stefan Morgenthaler
c485968e7f feat: allow preset of passwords via environment vars
Signed-off-by: Stefan Morgenthaler <dev@morgenthaler.at>
2026-01-14 11:42:15 +01:00
milkmaker
e727620bd3 Translations update from Weblate (#7002)
* [Web] Updated lang.zh-cn.json

Co-authored-by: ガラスのような夢 <i@msdnicrosoft.work>

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

Co-authored-by: Monika Bark <rychert.monika@wp.pl>

---------

Co-authored-by: ガラスのような夢 <i@msdnicrosoft.work>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2026-01-07 17:23:31 +01:00
milkmaker
71fa3ecebc update postscreen_access.cidr (#6987) 2026-01-07 17:22:01 +01:00
Paul Sütterlin
70101d1187 fix: Password for mobileconfig that conforms to password-complexity policy 2026-01-01 16:57:21 +01:00
bluewalk
c060c205d3 Fixes issue #6489 2025-12-21 16:56:16 +01:00
Copilot
038b2efb75 Add MTA-STS support for alias domains (#6972)
* Initial plan

* Add MTA-STS support for alias domains

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Improve domain normalization and code style in mta-sts.php

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add error handling for idn_to_ascii in mta-sts.php

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add database error handling for alias domain query

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add ACME certificate support for MTA-STS on alias domains

Query alias_domain table to find aliases with MTA-STS enabled target domains and request certificates for mta-sts.<alias-domain> subdomains.

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* compose: bump image tag to 1.95

* Add MTA-STS DNS records display for alias domains in UI

When viewing an alias domain's DNS diagnostics, check if the target domain has MTA-STS enabled and display the required DNS records for the alias domain.

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-12-15 16:29:21 +01:00
DerLinkman
1fe4cd03e9 ui: fix global filters ui tickbox reappearing (#6966) 2025-12-12 16:01:18 +01:00
milkmaker
12e02e67ff Translations update from Weblate (#6965)
* [Web] Updated lang.fr-fr.json

Co-authored-by: Keo <contact@kbl.netlib.re>

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

Co-authored-by: Germano Pires Ferreira <germanopires@gmail.com>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

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

Co-authored-by: Monika Bark <rychert.monika@wp.pl>

---------

Co-authored-by: Keo <contact@kbl.netlib.re>
Co-authored-by: Germano Pires Ferreira <germanopires@gmail.com>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-12-12 15:21:04 +01:00
DerLinkman
b6f57dfb78 rspamd: update to 3.14.2 2025-12-12 14:06:49 +01:00
Copilot
3ebf2c2d2d Prevent duplicate/plaintext login announcement rendering (#6963)
* Initial plan

* Fix duplicate login announcement display

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-12 12:34:20 +01:00
DerLinkman
1bac6f1ee7 ofelia: revert fixed cron syntax for sa-rules download 2025-12-11 13:29:11 +01:00
DerLinkman
67e7acd6bd rspamd: upgrade to 3.14.1, trixie rebuild + bcc forwarded hosts fix (#6958)
* rspamd: fix bcc + subadress handling when using forward hosts

* rspamd: build against trixie + use version 3.14.1
2025-12-11 09:45:56 +01:00
renovate[bot]
910ce573d6 chore(deps): update peter-evans/create-pull-request action to v8 (#6953) 2025-12-10 19:48:02 +01:00
renovate[bot]
689336b3e1 chore(deps): update dependency tianon/gosu to v1.19
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:59 +00:00
renovate[bot]
01cf72cdef chore(deps): update dependency phpredis/phpredis to v6.3.0
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:54 +00:00
renovate[bot]
4cdb97c699 chore(deps): update dependency php-memcached-dev/php-memcached to v3.4.0
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:50 +00:00
renovate[bot]
1bd795a9c6 chore(deps): update dependency krakjoe/apcu to v5.1.28
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:42 +00:00
renovate[bot]
39f29e6c30 chore(deps): update dependency imagick/imagick to v3.8.1
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:38 +00:00
Ashitaka
1ab6af21e3 Merge pull request #6905 from Ashitaka57/6646-pbkdf2-sha512-verify-hash
Support for PBKDF2-SHA512 hash algorithm in verify_hash() (FreeIPA compatibility) (issue 6646)
2025-12-10 11:41:06 +01:00
DerLinkman
5d95c48e0d backup: add image prefetch function to verify latest image is used 2025-12-10 08:43:04 +01:00
DerLinkman
dbb9e474b0 pf-tlspol: upgrade to 1.8.22 (#6951)
* postfix-tlspol: upgrade to 1.8.20

* pf-tlspol: update to 1.8.22
2025-12-09 13:25:50 +01:00
Khurram Malik
f8eed8c786 fix(api): add missing break in CORS switch block causing save to hang (#6926) 2025-12-09 11:54:20 +01:00
DerLinkman
ef010aa39c Update CONTRIBUTING.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-08 15:08:25 +01:00
milkmaker
79171ea6f5 [Web] Updated lang.fr-fr.json (#6943)
Co-authored-by: Neuronnexion <support@nnx.com>
2025-12-05 14:40:45 +01:00
milkmaker
4e3294b273 [Web] Updated lang.fr-fr.json (#6941)
[Web] Updated lang.fr-fr.json

[Web] Updated lang.fr-fr.json

Co-authored-by: Neuronnexion <support@nnx.com>
2025-12-03 23:31:37 +01:00
renovate[bot]
32a6ecddb6 chore(deps): update alpine docker tag to v3.23 (#6940)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 23:30:46 +01:00
DerLinkman
f3d9833ecf Merge branch 'master' into staging 2025-12-03 16:55:12 +01:00
DerLinkman
930ca76ea7 update: moved _modules initialization and update at the beginning of update script 2025-12-03 16:54:26 +01:00
DerLinkman
9a2887cf46 core: improved docker compose version check 2025-12-03 16:27:04 +01:00
DerLinkman
9950914086 core: improved docker compose version check 2025-12-03 16:26:22 +01:00
renovate[bot]
470cfb0026 chore(deps): update actions/stale action to v10.1.1 (#6937)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 13:53:05 +01:00
milkmaker
6c106b4e4d [Web] Updated lang.fr-fr.json (#6936)
Co-authored-by: Neuronnexion <support@nnx.com>
2025-12-02 16:40:36 +01:00
milkmaker
3d6253a2b2 update postscreen_access.cidr (#6933) 2025-12-01 08:48:22 +01:00
milkmaker
b873812588 [Web] Updated lang.gr-gr.json (#6930)
Co-authored-by: ChD Computers <chdcomputers@gmail.com>
2025-11-29 13:20:02 +01:00
milkmaker
514fefd2ed Translations update from Weblate (#6924)
* [Web] Updated lang.ca-es.json

Co-authored-by: Pere Montpeó <peremontpeo@gmail.com>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

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

Co-authored-by: Chris <chrismfz@gmail.com>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.cs-cz.json

Co-authored-by: Filip Hajny <filip@hajny.net>

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

[Web] Updated lang.pl-pl.json

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

---------

Co-authored-by: Pere Montpeó <peremontpeo@gmail.com>
Co-authored-by: Chris <chrismfz@gmail.com>
Co-authored-by: Filip Hajny <filip@hajny.net>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-11-24 16:50:03 +01:00
renovate[bot]
6f9ee2d151 chore(deps): update actions/checkout action to v6 (#6920)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-21 10:55:12 +01:00
milkmaker
9832006141 Translations update from Weblate (#6916)
* [Web] Updated lang.si-si.json

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

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

Co-authored-by: Habetdin <15926758+Habetdin@users.noreply.github.com>

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

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

---------

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: Habetdin <15926758+Habetdin@users.noreply.github.com>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
Co-authored-by: Peter <magic@kthx.at>
2025-11-17 23:23:07 +01:00
Josh
0413d26855 Allow making spam aliases permanent (#6888)
* Allow making spam aliases permanent

* added german translation

* updated Spamalias Twig + Rename in Spam Alias

* compose: update image tags to align to vendor version

---------

Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-11-13 16:05:01 +01:00
Patrik Kernstock
7b29c1f304 Disable nginx server_tokens in http context (#6873) 2025-11-13 15:19:11 +01:00
Patrik Kernstock
ae3ef391ee Remove deprecated 'X-XSS-Protection' header (#6871) 2025-11-13 15:16:44 +01:00
Peter
7313f996d3 Update to trixie (#6907) 2025-11-13 15:16:00 +01:00
DerLinkman
62d16c9e56 compose: changes cronjobs to regular cron syntax + fixed sogo creds for cronjobs (#6866)
* cron: restructure cron timer to time on second (instead of random)

* dovecot: fix clearance for cron.creds file
2025-11-13 14:59:49 +01:00
DerLinkman
674b41ce08 updated the Contributing Guidelines 2025-11-12 10:16:54 +01:00
Claas Flint
1b833be760 Replace pigz with zstd for backup compression (#6897)
* Replace pigz with zstd for backup compression

This change replaces pigz (parallel gzip) with zstd (Zstandard) as the
compression algorithm for mailcow backups while maintaining full backward
compatibility with existing .tar.gz backups.

Benefits:
- Better compression ratios (12-37% improvement in tests)
- Improved compression speed with modern algorithm
- Maintains rsyncable functionality for incremental backups
- Full backward compatibility for restoring old .tar.gz backups
- Wide industry adoption and active development

Changes:
- Backup compression: pigz --rsyncable -p → zstd --rsyncable -T
- Backup decompression: pigz -d -p → zstd -d -T
- File extensions: .tar.gz → .tar.zst
- Added get_archive_info() function for intelligent format detection
- Updated backup Dockerfile to install zstd alongside pigz
- Restore function now auto-detects and handles both formats
- Updated FILE_SELECTION regex to recognize both .tar.zst and .tar.gz
- Updated comments to reflect new file extension

Backward Compatibility:
- Restore automatically detects .tar.zst (preferred) or .tar.gz (legacy)
- Existing .tar.gz backups can still be restored without issues
- pigz remains installed in backup image for legacy support
- Graceful fallback if backup file format not found

Testing:
- Added comprehensive test suite (test_backup_and_restore.sh)
- 12 automated tests covering all scenarios:
  * Backup creation (both formats)
  * Restore (both formats)
  * Format detection and priority
  * Error handling (missing files, empty dirs)
  * Content integrity verification
  * Multi-threading configuration
  * Large file compression (8.59 MB realistic data)

Test Results:
✓ zstd compression working
✓ pigz compression working (legacy)
✓ zstd decompression working
✓ pigz decompression working (backward compatible)
✓ Archive detection working
✓ Content integrity verified
✓ Format priority correct (.tar.zst preferred)
✓ Error handling for missing files
✓ Error handling for empty directories
✓ Multi-threading configuration verified
✓ Large file compression: 37.05% improvement
✓ Small file compression: 12.18% improvement

* move testing script into development folder

---------

Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-11-12 10:06:36 +01:00
DerLinkman
88adb1adf5 remove dev docker volume from upstream 2025-11-12 09:54:35 +01:00
DerLinkman
ec472f13cf sogo: removed URLDecrpytion by default, make it configurable in sogo.conf 2025-11-12 09:50:41 +01:00
milkmaker
2e1d98cc7c [Web] Updated lang.pl-pl.json (#6908)
[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-11-10 21:06:13 +01:00
milkmaker
07d7e3dc30 [Web] Updated lang.pl-pl.json (#6906)
[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-11-09 23:03:00 +01:00
milkmaker
b0f5aee628 [Web] Updated lang.pl-pl.json (#6898)
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-11-05 17:37:26 +01:00
milkmaker
d3065612fd update postscreen_access.cidr (#6886) 2025-11-03 21:07:40 +01:00
Josh
9912e41f78 [Web] Correct order of Dansk/Danish in UI (#6887) 2025-11-03 21:07:20 +01:00
milkmaker
04200c99a4 Translations update from Weblate (#6880)
* [Web] Updated lang.vi-vn.json

Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.nb-no.json

Co-authored-by: Runar Ingebrigtsen <runar@rin.no>

---------

Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
Co-authored-by: Runar Ingebrigtsen <runar@rin.no>
2025-10-27 20:00:29 +01:00
FreddleSpl0it
45666d2c4e Merge pull request #6874 from mailcow/staging
Update 2025-10a Hotfix
2025-10-24 08:27:23 +02:00
FreddleSpl0it
9a806e64ce [PHP] remove opcache.revalidate_freq 2025-10-24 08:18:49 +02:00
Markku Post
95e0608749 [Web] Disable login on autodiscover/autoconfig domains
Autodiscover and autoconfig domains (autodiscover.*, autoconfig.*) are intended solely for client autoconfiguration endpoints and should not display the mailcow login page. This change check the hostname and disables unauthenticated users from seeing the login page on those domains; HTTP 404 response is returned when necessary.
2025-10-24 06:03:40 +03:00
FreddleSpl0it
22a09b9795 [PHP] re-add opcache.revalidate_freq setting 2025-10-23 15:16:24 +02:00
FreddleSpl0it
04d5c43550 Merge pull request #6847 from patschi/disable-opcache-jit
Disable PHP opcache.jit
2025-10-23 09:32:02 +02:00
milkmaker
fbcb8cbeb9 [Web] Updated lang.vi-vn.json (#6861)
Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
2025-10-21 18:03:22 +02:00
renovate[bot]
0338a36ecf chore(deps): update alpine docker tag to v3.22 (#6417)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 18:03:02 +02:00
milkmaker
23fb5e2fca Add Vietnamese language (#6854)
* [Web] Updated lang.vi-vn.json

[Web] Added lang.vi-vn.json

Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* Add Vietnamese language

---------

Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
Co-authored-by: Peter <magic@kthx.at>
2025-10-20 18:35:00 +02:00
renovate[bot]
3507ff2773 chore(deps): update devops-infra/action-pull-request action to v1.0.2 (#6850) 2025-10-19 23:18:09 +02:00
Patrik Kernstock
a4970397f1 Disable PHP opcache.jit 2025-10-17 13:17:56 +02:00
renovate[bot]
4132f6bd48 chore(deps): update devops-infra/action-pull-request action to v1 (#6840) 2025-10-16 07:28:57 +02:00
FreddleSpl0it
e6f83853ae Merge remote-tracking branch 'origin/staging' into nightly 2025-10-15 11:17:07 +02:00
FreddleSpl0it
586b3a2ed1 Merge pull request #6838 from mailcow/staging
Update 2025-10
2025-10-15 08:11:08 +02:00
FreddleSpl0it
6af2addf3c [PHPFPM] Update Image to Version 1.94 2025-10-14 10:25:06 +02:00
FreddleSpl0it
f6eed6c441 Merge pull request #6836 from mailcow/fix/6802
[Web] Add password verification when setting recovery email
2025-10-13 12:10:57 +02:00
FreddleSpl0it
b85837c803 [Web] Add password verification when setting recovery email 2025-10-13 12:05:17 +02:00
FreddleSpl0it
653fc40d4c Merge pull request #6783 from patschi/phpfpm-moar-speeeed
Optimize phpfpm opcache: more aggressive caching, enable JIT
2025-10-13 11:43:07 +02:00
FreddleSpl0it
c17d80a6fd Merge pull request #6821 from tjmills-dev/feat/show-app-passwd-logins
Show app passwords for successful logins on user page
2025-10-13 11:41:39 +02:00
FreddleSpl0it
980bfa3aa0 Merge pull request #6696 from mailcow/renovate/krakjoe-apcu-5.x
chore(deps): update dependency krakjoe/apcu to v5.1.27
2025-10-10 14:07:24 +02:00
FreddleSpl0it
664a954393 Merge pull request #6798 from mailcow/renovate/php-pecl-mail-mailparse-3.x
chore(deps): update dependency php/pecl-mail-mailparse to v3.1.9
2025-10-10 14:07:05 +02:00
FreddleSpl0it
d5a27c4ccb Merge pull request #6830 from mailcow/feat/rspamd-3.13.2
[Rspamd] Update to 3.13.2
2025-10-10 13:10:54 +02:00
FreddleSpl0it
6a8a2e2136 Merge pull request #6829 from mailcow/feat/redis-7.4.6
[Redis] Update to Redis 7.4.6
2025-10-10 13:09:47 +02:00
FreddleSpl0it
b859a52b8e Merge pull request #6828 from mailcow/fix/6818
[Web] Fix SOGo redirection after login
2025-10-10 13:08:22 +02:00
FreddleSpl0it
10e0c42eff Merge pull request #6797 from Hobby-Student/fix/autodiscover-with-ldap-attribute-mapping
fix autodiscover when using ldap with attribute mapping templates
2025-10-10 13:07:58 +02:00
FreddleSpl0it
f47df263d7 [Rspamd] Update to 3.13.2 2025-10-10 13:04:01 +02:00
FreddleSpl0it
2642d9109e [Redis] Update to Redis 7.4.6 2025-10-10 12:48:57 +02:00
FreddleSpl0it
6708b94ebb [Web] Fix SOGo redirection after login 2025-10-10 10:05:56 +02:00
milkmaker
79cf0abc6e [Web] Updated lang.zh-cn.json (#6826)
Co-authored-by: Easton Man <me@eastonman.com>
2025-10-09 19:54:12 +02:00
Olavo Rocha Neto
7de70322d6 Update pt-br lang (#6803)
* [Web] Updated lang.si-si.json

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

* Update pt-br lang

* Complimentary adjustments

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

This reverts commit b23848e0f2.

---------

Co-authored-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-10-09 19:36:36 +02:00
DerLinkman
417835dea8 netfilter: improve logging and mark iptables-legacy as deprecated 2025-10-09 16:37:05 +02:00
Thomas Mills
3dcacc4187 Change icon to filled key 2025-10-09 11:39:24 +01:00
Thomas Mills
69f0552d4f Decrease margin size 2025-10-08 21:48:03 +01:00
Thomas Mills
c443a9400a Move flag in front of IP 2025-10-08 21:48:03 +01:00
Thomas Mills
5c9f387d94 Add margin 2025-10-08 21:48:02 +01:00
Thomas Mills
e9414d17e4 Show app password for last logins 2025-10-08 21:47:50 +01:00
FreddleSpl0it
6bfa58611e Merge pull request #6813 from mailcow/staging
Update 2025-09c
2025-10-07 11:43:15 +02:00
FreddleSpl0it
df4d3bb6e0 [Web] Fix dashboard host stats 2025-10-07 11:41:57 +02:00
FreddleSpl0it
e31b6d9a07 Merge pull request #6812 from mailcow/staging
Update 2025-09c
2025-10-07 10:47:19 +02:00
FreddleSpl0it
455ef084b4 [Web] clear old app_passwd log entries 2025-10-07 10:37:44 +02:00
FreddleSpl0it
c2948735f2 [Web] clear old app_passwd log entries 2025-10-07 10:18:07 +02:00
FreddleSpl0it
24c62b2f09 Merge pull request #6810 from mailcow/staging
Update 2025-09c
2025-10-07 10:03:47 +02:00
FreddleSpl0it
7da088c931 Merge branch 'staging' into nightly 2025-10-06 13:58:40 +02:00
FreddleSpl0it
1ef0149076 [Web] make SameSite policy and cookie name configurable via vars.local.inc 2025-10-06 11:00:03 +02:00
FreddleSpl0it
922d173540 [Web] include hostname in default website title 2025-10-06 10:58:35 +02:00
renovate[bot]
fd088cb504 chore(deps): update actions/stale action to v10.1.0 (#6806) 2025-10-04 14:13:48 +02:00
Valentin Brandl
721ee2394e Update variable name for prometheus-exporter security token (#6776)
* update variable name for prometheus-exporter security token

* update `MAILCOW_EXPORTER_TOKEN_DISABLE` variable name
2025-10-03 18:03:03 +01:00
Colin Kubon
c217be06c6 scripts: make sure /etc/docker exists (#6791) 2025-10-02 09:24:06 +02:00
Jonas
871c422ec1 Fix typos in config (#6792)
Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-10-02 09:22:35 +02:00
sdsys-ch
3cc28af607 [Helper] Fix cold-standby script to support digits and override files (#6800)
This commit fixes two bugs in the cold-standby script:

1. Support digits in COMPOSE_PROJECT_NAME
   The script was stripping digits from COMPOSE_PROJECT_NAME, while
   backup_and_restore.sh (fixed in a71d991c) correctly supports them.
   Added '0-9' to the tr character set to align behavior.

2. Support docker-compose.override.yml on remote
   Lines 172 and 287 explicitly used '-f docker-compose.yml' which
   causes Docker Compose to ignore docker-compose.override.yml even
   when present. Changed to 'cd && compose' pattern (matching line 296)
   to auto-discover override files.

   Impact: Users with custom volumes/services in override file would
   experience silent failures - volumes not created, images not pulled,
   data syncing to wrong locations.

Both fixes ensure cold-standby works correctly with standard Docker
Compose conventions and user customizations.

Co-authored-by: Christophe Neuerburg <c.neuerburg@sdsys.ch>
2025-10-02 09:21:26 +02:00
milkmaker
796e131c3a update postscreen_access.cidr (#6801) 2025-10-01 11:14:57 +02:00
renovate[bot]
dd160cd508 Update dependency php/pecl-mail-mailparse to v3.1.9
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-09-30 13:42:36 +00:00
Hobby-Student
732b321962 fix autodiscover when using ldap with attribute mapping templates 2025-09-30 14:37:19 +02:00
milkmaker
c51a769aec [Web] Updated lang.si-si.json (#6794)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-29 18:10:39 +02:00
FreddleSpl0it
45a61755a5 Merge pull request #6777 from patschi/enable-https-redirect-default
Enable HTTPS redirect by default on new setups
2025-09-29 11:56:46 +02:00
FreddleSpl0it
769c57c355 Merge pull request #6779 from patschi/remove-debug-consolelog
Remove debug console.log calls
2025-09-29 11:54:23 +02:00
FreddleSpl0it
2e7eb7c0fd Merge pull request #6780 from patschi/fix-pwcomplexity-apppasswds
Fixed password complexity check for AppPasswords creation/edit
2025-09-29 11:53:26 +02:00
FreddleSpl0it
4c83147d01 Merge pull request #6781 from patschi/pw-field-name-consistency
Rename password fields for AppPasswords same way for consistency
2025-09-29 11:52:08 +02:00
FreddleSpl0it
ca0bec4fc2 Merge pull request #6782 from patschi/fix-footer-escape
Fixed wrong footer escaping for certain characters
2025-09-29 11:45:42 +02:00
FreddleSpl0it
6f50dd17da Merge pull request #6786 from patschi/fix-sql-typo
Fix several SQL statements
2025-09-29 11:39:30 +02:00
FreddleSpl0it
4a331929d0 Merge pull request #6787 from patschi/hide-relayhosts-if-no-acl
Hide relayhosts when ACL does not allow
2025-09-29 11:38:52 +02:00
FreddleSpl0it
748bc893b6 Merge pull request #6788 from patschi/lastmodified-default-value
Show "Never" by default if no last-modified date saved
2025-09-29 11:37:52 +02:00
FreddleSpl0it
e462602ddc Merge pull request #6789 from patschi/domain-descr-readonly-when-no-acl
Make domain description field readonly when no ACL
2025-09-29 11:36:42 +02:00
milkmaker
4e0f435d12 [Web] Updated lang.si-si.json (#6793) 2025-09-28 15:12:14 +02:00
milkmaker
46f0581936 [Web] Updated lang.si-si.json (#6790)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-26 19:14:07 +02:00
Patrik Kernstock
20f04ecf6b Make domain description field readonly when no ACL 2025-09-26 17:13:24 +02:00
Patrik Kernstock
ff43799763 Show "Never" by default if no last-modified date 2025-09-26 17:02:22 +02:00
Patrik Kernstock
85ca197615 Hide relayhosts when ACL does not allow 2025-09-26 16:50:58 +02:00
Patrik Kernstock
d06d23bbaf Fix several SQL statements 2025-09-26 14:58:04 +02:00
Patrik Kernstock
702ed85dfd Fixed footer escaping 2025-09-26 14:41:19 +02:00
milkmaker
8abe74a562 [Web] Updated lang.si-si.json (#6785)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-26 10:57:32 +02:00
Patrik Kernstock
2f8a181281 Fix comments, added some comments 2025-09-26 04:16:57 +02:00
Patrik Kernstock
5c5287ca21 Fixed wrong footer escaping 2025-09-26 04:04:45 +02:00
Patrik Kernstock
83ba8d5840 Optimize opcache settings, enable JIT 2025-09-26 04:01:17 +02:00
Patrik Kernstock
ce219668cf Rename AppPasswds fields uniquely like 'add' 2025-09-26 03:37:49 +02:00
Patrik Kernstock
5b1b49a418 Fixed password complexity check for AppPasswords 2025-09-26 02:37:02 +02:00
Patrik Kernstock
8978a9ad79 Remove debug console.log() lines 2025-09-26 02:13:22 +02:00
Patrik Kernstock
5f4a4fd759 Removed new lines for consistency 2025-09-26 01:14:33 +02:00
Patrik Kernstock
171c591da4 Enable REDIRECT_HTTP=y by default 2025-09-26 01:14:23 +02:00
FreddleSpl0it
9133b9899c Merge pull request #6764 from patschi/tools-install-clear-msg
Clearer message to install required tool, e.g. jq
2025-09-25 09:00:41 +02:00
FreddleSpl0it
701c9fb1b4 Merge pull request #6772 from patschi/update-issue-template
Update GitHub's issue template
2025-09-25 08:53:18 +02:00
Patrik Kernstock
eabd22188b Re-intend checkboxes 2025-09-24 21:20:48 +02:00
Patrik Kernstock
7028619742 Update GitHub's issue template 2025-09-24 21:17:29 +02:00
Patrik Kernstock
c915bf2ee2 Add docs link to get_installed_tools() message 2025-09-24 19:06:47 +02:00
milkmaker
011edd5ac9 [Web] Updated lang.si-si.json (#6771)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-24 17:36:09 +02:00
FreddleSpl0it
7ba3de4ced Merge pull request #6767 from mailcow/fix/rename-phpsessid
[Web] Rename PHP Cookie to MCSESSID
2025-09-23 12:41:01 +02:00
FreddleSpl0it
8ead77083f [Web] Rename PHP Cookie to MCSESSID 2025-09-23 12:39:48 +02:00
FreddleSpl0it
b2774fb50b Merge pull request #6766 from mailcow/fix/samesite-cookie
[Web] set cookie SameSite attribute to Lax
2025-09-23 12:36:11 +02:00
FreddleSpl0it
4440bd46ad [Web] set cookie SameSite attribute to Lax 2025-09-23 12:24:25 +02:00
FreddleSpl0it
28985973eb [Web] Revert - allow "*" as wildcard domain 2025-09-23 10:07:33 +02:00
Christian 🦄
f2c4697ca3 Fixed typo in lang de-de (#6765) 2025-09-22 22:45:54 +01:00
Patrik Kernstock
383b5affb5 More clearer message to install required tool 2025-09-22 19:49:31 +02:00
FreddleSpl0it
ed4dcff63b [Web] allow "*" as wildcard domain 2025-09-22 14:42:14 +02:00
FreddleSpl0it
caca32bbba Merge pull request #6759 from mailcow/fix/6720
[Web] Allow wildcard subdomains for MTA-STS
2025-09-22 14:20:36 +02:00
FreddleSpl0it
d31e74c778 Merge pull request #6760 from mailcow/fix/6739
[Web] Remove Port from HTTP_HOST
2025-09-22 14:20:15 +02:00
FreddleSpl0it
6c00e29276 Merge pull request #6762 from mailcow/fix/6740
[Nginx] do not invert ENABLE_IPV6
2025-09-22 14:19:57 +02:00
FreddleSpl0it
9940c503a2 [Nginx] do not invert ENABLE_IPV6 2025-09-22 14:16:42 +02:00
FreddleSpl0it
4b2862cb3c [Web] Remove Port from HTTP_HOST 2025-09-22 14:07:17 +02:00
FreddleSpl0it
a36485f0f1 [Web] Allow wildcard subdomains for MTA-STS 2025-09-22 13:55:18 +02:00
FreddleSpl0it
78168ee80a Merge pull request #6758 from mailcow/feat/sogo-url-encryption
[SOGo][Web] SOGo URL Encryption support
2025-09-22 13:32:58 +02:00
FreddleSpl0it
610609378f [SOGo][Web] Set URL encryption key in mailcow.conf 2025-09-22 12:58:05 +02:00
FreddleSpl0it
260906e350 [SOGo][Web] Enable SOGo URL Encryption 2025-09-22 12:28:09 +02:00
milkmaker
2891bbf82a Translations update from Weblate (#6749)
* [Web] Updated lang.cs-cz.json

Co-authored-by: Filip Hajny <filip@hajny.net>

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

Co-authored-by: Edgars Andersons <Edgars+Mailcow+Weblate@gaitenis.id.lv>

---------

Co-authored-by: Filip Hajny <filip@hajny.net>
Co-authored-by: Edgars Andersons <Edgars+Mailcow+Weblate@gaitenis.id.lv>
2025-09-16 18:24:12 +02:00
milkmaker
eb26bcbc94 Translations update from Weblate (#6743)
* [Web] Updated lang.zh-cn.json

Co-authored-by: Easton Man <me@eastonman.com>

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

[Web] Updated lang.si-si.json

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

---------

Co-authored-by: Easton Man <me@eastonman.com>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-13 21:41:59 +02:00
FreddleSpl0it
bb3c2fb4fe Merge pull request #6731 from mailcow/staging
Automatic PR to nightly from 2025-09-11T07:38:50Z
2025-09-12 11:43:02 +02:00
FreddleSpl0it
ef0f366d1c Merge pull request #6738 from mailcow/staging
Update 2025-09b
2025-09-12 11:29:26 +02:00
patr_
84e230de8f [Nginx] fix: Disable IPv6 support in Nginx configuration (#6736)
Co-authored-by: patr_ <patbernh@gmail.com>
2025-09-12 11:17:18 +02:00
FreddleSpl0it
f67a12d157 Merge pull request #6726 from mailcow/fix/6135
[Web] remove unused bcc dest column from alias table
2025-09-11 13:50:25 +02:00
FreddleSpl0it
34b48eedfc Merge pull request #6727 from mailcow/fix/6423
[SOGo] Drop deprecated `sogo_update_password` sql trigger if it still exists
2025-09-11 13:50:05 +02:00
FreddleSpl0it
0d900d4fc8 [SOGo] Drop deprecated sogo_update_password sql trigger if it still exists 2025-09-11 11:01:50 +02:00
FreddleSpl0it
642ac6d02c [Web] remove unused bcc dest column from alias table 2025-09-11 10:34:35 +02:00
FreddleSpl0it
eb84847a5b Merge branch 'staging' into nightly 2025-09-11 10:26:42 +02:00
DerLinkman
4db1569c93 Squashed commit of the following:
commit 94c1a6c4e1
Author: DerLinkman <niklas.meyer@servercow.de>
Date:   Wed Sep 10 16:20:58 2025 +0200

    scripts:  ipv6_controller improvement + fix modules handling (#6722)

    * Fix subscript handling for modules

    * ipv6: detect case when link local is present

    * v6-controller: removed fixed-cidr for docker 28+
2025-09-10 16:22:19 +02:00
DerLinkman
94c1a6c4e1 scripts: ipv6_controller improvement + fix modules handling (#6722)
* Fix subscript handling for modules

* ipv6: detect case when link local is present

* v6-controller: removed fixed-cidr for docker 28+
2025-09-10 16:20:58 +02:00
FreddleSpl0it
7ce3b0faed Merge pull request #6719 from mailcow/staging
Update 2025-09
2025-09-10 11:18:17 +02:00
FreddleSpl0it
262fe04286 change MAJOR_VERSIONS 2025-08 to 2025-09 2025-09-10 11:17:34 +02:00
FreddleSpl0it
b1c088a57f Merge pull request #6718 from mailcow/staging
Update 2025-09
2025-09-10 11:05:09 +02:00
FreddleSpl0it
1c438330c6 [postfix-tlspol] build with NOOPT=1 for wider CPU compatibility 2025-09-10 10:14:37 +02:00
FreddleSpl0it
8cb25709ae [Clamd] update to 1.71 2025-09-10 08:23:22 +02:00
FreddleSpl0it
221f2989b0 Merge pull request #6698 from mailcow/6644_clamd-tmp-folder-naming-change
Changed clamavs tmp folder structure
2025-09-09 13:12:54 +02:00
FreddleSpl0it
3d05207bc7 Merge pull request #6717 from mailcow/fix/6664
[Rspamd] only recreate external_services.conf file if it was deleted
2025-09-09 12:53:03 +02:00
FreddleSpl0it
8c8497d885 [Rspamd] only recreate external_services.conf file if it was deleted 2025-09-09 12:50:19 +02:00
FreddleSpl0it
56d083ced4 Merge pull request #6682 from psuet/fix/imapsync
fix: imapsync gets correct timeouts from imapsync_runner.pl
2025-09-09 12:34:02 +02:00
FreddleSpl0it
a90b3544a7 Merge pull request #6651 from psuet/fix/php-warnings
Fix multiple PHP Warnings present in "stock" installation
2025-09-09 12:27:14 +02:00
FreddleSpl0it
08aea7fb26 Merge pull request #6716 from mailcow/fix/6610
Prevent user login if protocol access has been disabled
2025-09-09 12:16:09 +02:00
FreddleSpl0it
13f7f9830b Prevent user login if protocol access has been disabled 2025-09-09 12:11:19 +02:00
FreddleSpl0it
2f75039194 Merge pull request #6715 from mailcow/fix/version-tag
[Web] Only include mailcow_info in JS when mailcow_cc_username is set
2025-09-09 11:10:25 +02:00
FreddleSpl0it
1e192e14f4 [Web] Only include mailcow_info in JS when mailcow_cc_username is set 2025-09-09 11:09:09 +02:00
FreddleSpl0it
9cd1f931fc Merge pull request #6714 from mailcow/fix/domain-wide-footer
[RSPAMD] Add boundary if present when applying domain-wide footer
2025-09-09 10:57:18 +02:00
FreddleSpl0it
8d7235b535 [RSPAMD] Add boundary if present when applying domain-wide footer 2025-09-09 10:52:19 +02:00
FreddleSpl0it
8446abd484 Merge pull request #6713 from mailcow/feat/internal-alias
[Rspamd][Web] Internal alias support
2025-09-09 10:45:56 +02:00
FreddleSpl0it
f67c0530f5 [Rspamd][Web] Internal alias support 2025-09-09 10:37:54 +02:00
Dmitriy Alekseev
06db1d6a72 [Rspamd] Do not increment rate limit for emails from user to himself (#6706)
* [Rspamd] Do not increment rate limit for emails from user to himself

* Lowercase username and recipient address for comparison

Normalize username and recipient address comparison to lowercase.
2025-09-05 03:37:59 +02:00
renovate[bot]
81775ab4d5 chore(deps): update actions/stale action to v10 (#6708)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 18:02:11 +02:00
DerLinkman
34877ecf9c watchdog: added postfix-tlspol check (#6691) 2025-09-03 08:18:04 +02:00
milkmaker
dbde144014 update postscreen_access.cidr (#6703) 2025-09-03 08:14:14 +02:00
DerLinkman
5361a4a4ee updated sponsors in Readme.md 2025-09-01 12:32:27 +02:00
milkmaker
0997548d7f Translations update from Weblate (#6699)
* [Web] Updated lang.de-de.json

Co-authored-by: Peter <magic@kthx.at>

* [Web] Updated lang.en-gb.json

Co-authored-by: Peter <magic@kthx.at>

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

[Web] Language file updated by 'Cleanup translation files' addon

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>

---------

Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-08-29 22:38:06 +02:00
Sándor
921de02a2b Update lang.hu-hu.json (#6697)
Extended Hungarian translation
2025-08-29 18:32:17 +02:00
Peter
48e90a72dc Changed clamavs tmp folder structure 2025-08-29 18:27:34 +02:00
renovate[bot]
c0b7a98e6c chore(deps): update actions/checkout action to v5 (#6671)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-29 18:23:56 +02:00
renovate[bot]
6dc90186f9 chore(deps): update dependency krakjoe/apcu to v5.1.27
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-08-29 16:22:28 +00:00
DerLinkman
0b0a65a3f3 web: rename login placeholder for mailbox to email address (#6693) 2025-08-28 17:02:16 +02:00
DerLinkman
6c5d82c4df expanded postscreen whitelist with modern freemailers + included checks.mailcow.email 2025-08-28 14:06:17 +02:00
maxi322
5e66ffa366 watchdog: use dig instead of check_dns (#6685)
* watchdog: use dig instead of check_dns

check_dns is slower and uses more system resources,
dig wrapped in a script is a more performant approach and uses
fewer system resources

* added debug mode + compose image bump

---------

Co-authored-by: maxi322 <maxi322@users.noreply.github.com>
Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-08-28 12:56:37 +02:00
Sajjad hassanzadeh
4d88e19106 Feat/prometheus-exporter : Add prometheus exporter and grafana dashboard for mailcow. (#6314)
* add : readme for prometheus exporter configs

* add : grafana dashboard json file

* add: prometheus exporter service on docker-compose.override.yml

* migrate: doc files into docs.mailcow.email project

* add : security configs in prometheus exporter compose file

* add : explain more in my comment part in prometheus override compose file

* remove : mailcow dockerized docs

---------

Co-authored-by: Saji <saji@abrnoc>
2025-08-28 12:36:43 +02:00
DerLinkman
0cfcde673c Merge branch 'staging' into nightly 2025-08-28 10:21:38 +02:00
Paul Sütterlin
53c35493a5 fix: imapsync gets correct timeouts
Previously imapsync only attached the timeout1 / timeout2 arguments if the argument was negative (which is not even possible). Now the argument is added for every positive number.

Fixes #6590
2025-08-21 18:36:01 +00:00
FreddleSpl0it
ed5be5d7dc Merge branch 'feat/mailcow-adm' into nightly 2025-08-19 11:57:22 +02:00
FreddleSpl0it
ac90ecaf4f Merge remote-tracking branch 'origin/staging' into nightly 2025-08-19 11:51:54 +02:00
FreddleSpl0it
fed3fc9514 [Controller] Add HTTPS_PORT env var to base_url 2025-08-19 11:30:20 +02:00
FreddleSpl0it
35b9940db4 [Controller] Fix function description in SyncjobModel 2025-08-19 11:28:54 +02:00
FreddleSpl0it
ece940b000 [Controller] Fix missing password2 assignment in from_dict 2025-08-19 11:28:08 +02:00
DerLinkman
4b5fd0b50a compose: bump nginx nightly 2025-08-05 16:42:24 +02:00
DerLinkman
5aa9498f65 Merge branch 'feat/remove-ip6nat' into nightly 2025-08-05 16:41:15 +02:00
DerLinkman
690d511e54 reuse DOCKER_MAJOR Variable in ip6_controller 2025-08-05 16:37:09 +02:00
DerLinkman
e2a2b42139 Update _modules/scripts/new_options.sh
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 16:36:29 +02:00
DerLinkman
4bbda8006d Update _modules/scripts/new_options.sh
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 16:36:22 +02:00
DerLinkman
a281746958 ip6_controller: moved docker major detection upwards 2025-08-05 16:25:59 +02:00
DerLinkman
cec51b6162 improve detection of ENABLE_IPV6 2025-08-05 16:22:51 +02:00
DerLinkman
107c5d2e7d improve ENABLE_IPV6 check in nginx bootstrap 2025-08-05 16:18:29 +02:00
DerLinkman
00c025f31a Update _modules/scripts/core.sh
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 16:12:11 +02:00
DerLinkman
9b6388d0d0 Update _modules/scripts/new_options.sh
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-05 16:11:39 +02:00
DerLinkman
2f25fcad77 removed unnecessary message on every call of function 2025-08-05 16:04:10 +02:00
DerLinkman
7067e2c714 move detect_major_update func to core submodule 2025-08-05 16:04:10 +02:00
DerLinkman
9f3cdfa713 adapted removal of ACME_CONTACT for nightly 2025-08-05 16:03:26 +02:00
DerLinkman
529acf5ff6 added error handling for blank daemon.json 2025-08-05 16:03:26 +02:00
DerLinkman
0371edcf5e reintegrated module loading (update.sh) 2025-08-05 16:03:25 +02:00
DerLinkman
d20254d4ee improved _modules handling while running 2025-08-05 16:03:25 +02:00
DerLinkman
befecfc31d fixed docker version check for daemon 2025-08-05 16:03:24 +02:00
DerLinkman
004fcf092b added jq as dependancy 2025-08-05 16:03:24 +02:00
DerLinkman
a487fcd0bd fix broken EXIT_CODE var handling 2025-08-05 16:03:23 +02:00
DerLinkman
17e38a05f0 fixed/added comments for modules 2025-08-05 16:03:23 +02:00
DerLinkman
c503abfe40 fixed missing fi in update.sh 2025-08-05 16:03:22 +02:00
DerLinkman
73929db796 rewrite to scripts after testing (improved error handling) 2025-08-05 16:03:22 +02:00
DerLinkman
fb0685fa71 initial commit for script overhauls 2025-08-05 16:03:21 +02:00
DerLinkman
df36670c7c nginx: renamed DISABLE_IPv6 to ENABLE_IPV6 to align 2025-08-05 16:02:41 +02:00
DerLinkman
3f9215678d ipv6: added ipv6 detection + removed ip6 nat container 2025-08-05 16:02:41 +02:00
FreddleSpl0it
0ac0e5c252 [DockerApi] Rename DockerApi to Controller and add mailcow-adm tool 2025-08-01 15:31:50 +02:00
Paul Sütterlin
ad9b328ed5 fix: Undefined array key "pending_tfa_methods" in
/web/inc/footer.inc.php
on line
29
2025-07-26 01:12:48 +00:00
Paul Sütterlin
3d5b57889a fix: Empty App Links
The return value of the function caused a warning
in header.inc.php:42 if no additional links were set.

header.inc.php is the only caller of this function,
thus it is safe to return an empty array here.
2025-07-26 01:08:28 +00:00
Paul Sütterlin
6b8e981bdc fix: Only use HTTP_ORIGIN if it is sent. 2025-07-26 01:06:24 +00:00
DerLinkman
af61c82077 adapted removal of ACME_CONTACT for nightly 2025-07-16 09:03:50 +02:00
DerLinkman
c066273c79 Merge branch staging into nightly 2025-07-16 09:03:20 +02:00
FreddleSpl0it
527f27d249 Merge pull request #6632 from mailcow/staging
Update 2025-07
2025-07-15 07:48:37 +02:00
DerLinkman
0c3e53e3a9 Merge branch 'feat/remove-ip6nat' into nightly 2025-05-27 16:32:08 +02:00
DerLinkman
5ca10d1cde added error handling for blank daemon.json 2025-05-27 16:31:22 +02:00
DerLinkman
7907d43af7 compose: bumped nginx container tag 2025-05-27 16:24:21 +02:00
DerLinkman
d198f1d3f8 Merge branch 'feat/remove-ip6nat' into nightly 2025-05-27 16:19:42 +02:00
DerLinkman
102226723e reintegrated module loading (update.sh) 2025-05-27 16:18:55 +02:00
DerLinkman
2efaccf038 improved _modules handling while running 2025-05-27 16:16:23 +02:00
DerLinkman
aa7b6fa4a9 improved _modules handling while running 2025-05-27 16:15:02 +02:00
DerLinkman
714727a129 Merge pull request #6561 from mailcow/feat/remove-ip6nat
core: rewrote ipv6 detection and core script splitting
2025-05-27 16:10:44 +02:00
DerLinkman
4e5e264e3e Merge branch 'staging' into nightly 2025-05-27 16:09:18 +02:00
DerLinkman
267c81b42e fixed docker version check for daemon 2025-05-27 15:55:58 +02:00
DerLinkman
f2f3fbe497 added jq as dependancy 2025-05-27 15:54:58 +02:00
DerLinkman
6ba650820f fix broken EXIT_CODE var handling 2025-05-27 15:31:52 +02:00
DerLinkman
baa6286471 fixed/added comments for modules 2025-05-27 15:30:56 +02:00
DerLinkman
be8537d165 fixed missing fi in update.sh 2025-05-27 15:29:48 +02:00
DerLinkman
737fced7be rewrite to scripts after testing (improved error handling) 2025-05-27 15:26:08 +02:00
DerLinkman
5a532df8ce initial commit for script overhauls 2025-05-26 17:09:58 +02:00
DerLinkman
f8ce7a71e6 nginx: renamed DISABLE_IPv6 to ENABLE_IPV6 to align 2025-05-26 17:09:37 +02:00
DerLinkman
2e876bda9a ipv6: added ipv6 detection + removed ip6 nat container 2025-05-26 15:19:42 +02:00
FreddleSpl0it
d2e5926cce Merge pull request #6536 from mailcow/staging
Automatic PR to nightly from 2025-05-13T07:58:39Z
2025-05-13 11:22:48 +02:00
FreddleSpl0it
1994b9895b Merge pull request #6537 from mailcow/staging
Update 2025-05_2
2025-05-13 10:16:30 +02:00
FreddleSpl0it
798e6a4c00 Merge pull request #6535 from mailcow/staging
Update 2025-05
2025-05-13 09:58:32 +02:00
FreddleSpl0it
e3b576be67 Merge pull request #6475 from mailcow/staging
Automatic PR to nightly from 2025-04-09T01:26:20Z
2025-05-13 09:52:49 +02:00
FreddleSpl0it
3f493e043d Merge pull request #6468 from mailcow/staging
Update 2025-03b
2025-04-07 09:09:39 +02:00
FreddleSpl0it
0f7e359686 Merge pull request #6467 from mailcow/staging
Automatic PR to nightly from 2025-04-07T05:55:15Z
2025-04-07 09:00:11 +02:00
FreddleSpl0it
b9a0b2db6d Merge pull request #6456 from mailcow/staging
Automatic PR to nightly from 2025-04-03T11:26:19Z
2025-04-03 14:21:26 +02:00
FreddleSpl0it
93b876c473 Merge pull request #6446 from mailcow/staging
Automatic PR to nightly from 2025-04-01T13:48:33Z
2025-04-03 14:02:21 +02:00
FreddleSpl0it
2c47145dee Merge pull request #6419 from mailcow/staging
Update 2025-03a
2025-03-27 09:19:29 +01:00
FreddleSpl0it
92c2aa2023 Merge pull request #6420 from mailcow/staging
Automatic PR to nightly from 2025-03-27T05:23:47Z
2025-03-27 08:38:49 +01:00
FreddleSpl0it
9351cf24fe Merge pull request #6386 from mailcow/staging
Automatic PR to nightly from 2025-03-24T08:36:34Z
2025-03-27 07:45:25 +01:00
FreddleSpl0it
c3c68360dc Merge pull request #6391 from mailcow/staging
Update 2025-03
2025-03-25 08:10:50 +01:00
FreddleSpl0it
a632980871 Merge pull request #6336 from mailcow/staging
Update 2025-02
2025-02-27 11:48:57 +01:00
FreddleSpl0it
2d1ef41d32 Merge pull request #6335 from mailcow/staging
Update 2025-02
2025-02-27 11:05:55 +01:00
FreddleSpl0it
120366fec7 Merge pull request #6291 from mailcow/staging
Update 2025-01a
2025-02-04 13:55:30 +01:00
DerLinkman
244d4b8c4c compose: rollback clamd version until next major... accidentally pushed 2025-01-29 13:46:53 +01:00
DerLinkman
f92ddd86c5 clamd: update to 1.4.2 + build from source instead using alpine packages (#6273)
* clamd: update to 1.4.2 + build from source instead using alpine packages

* clamd: remove exposed ports from buildfile

* clamd: cleanup dockerfile
2025-01-29 09:49:04 +01:00
FreddleSpl0it
ba0349a911 Merge pull request #6256 from mailcow/staging
[Nginx] move conf.d include to end of nginx.conf
2025-01-23 14:55:38 +01:00
FreddleSpl0it
8caf09cd80 Merge pull request #6253 from mailcow/staging
2025-01
2025-01-23 12:01:38 +01:00
177 changed files with 8955 additions and 1928 deletions

View File

@@ -11,22 +11,35 @@ body:
required: true
- type: checkboxes
attributes:
label: I've found a bug and checked that ...
description: Prior to placing the issue, please check following:** *(fill out each checkbox with an `X` once done)*
label: Checklist prior issue creation
description: Prior to creating the issue...
options:
- label: ... I understand that not following the below instructions will result in immediate closure and/or deletion of my issue.
- label: I understand that failure to follow below instructions may cause this issue to be closed.
required: true
- label: ... I have understood that this bug report is dedicated for bugs, and not for support-related inquiries.
- label: I understand that vague, incomplete or inaccurate information may cause this issue to be closed.
required: true
- label: ... I have understood that answers are voluntary and community-driven, and not commercial support.
- label: I understand that this form is intended solely for reporting software bugs and not for support-related inquiries.
required: true
- label: ... I have verified that my issue has not been already answered in the past. I also checked previous [issues](https://github.com/mailcow/mailcow-dockerized/issues).
- label: I understand that all responses are voluntary and community-driven, and do not constitute commercial support.
required: true
- label: I confirm that I have reviewed previous [issues](https://github.com/mailcow/mailcow-dockerized/issues) to ensure this matter has not already been addressed.
required: true
- label: I confirm that my environment meets all [prerequisite requirements](https://docs.mailcow.email/getstarted/prerequisite-system/) as specified in the official documentation.
required: true
- type: textarea
attributes:
label: Description
description: Please provide a brief description of the bug in 1-2 sentences. If applicable, add screenshots to help explain your problem. Very useful for bugs in mailcow UI.
render: plain text
description: Please provide a brief description of the bug. If applicable, add screenshots to help explain your problem. (Very useful for bugs in mailcow UI.)
validations:
required: true
- type: textarea
attributes:
label: "Steps to reproduce:"
description: "Please describe the steps to reproduce the bug. Screenshots can be added, if helpful."
placeholder: |-
1. ...
2. ...
3. ...
validations:
required: true
- type: textarea
@@ -36,45 +49,36 @@ body:
render: plain text
validations:
required: true
- type: textarea
attributes:
label: "Steps to reproduce:"
description: "Please describe the steps to reproduce the bug. Screenshots can be added, if helpful."
render: plain text
placeholder: |-
1. ...
2. ...
3. ...
validations:
required: true
- type: markdown
attributes:
value: |
## System information
### In this stage we would kindly ask you to attach general system information about your setup.
In this stage we would kindly ask you to attach general system information about your setup.
- type: dropdown
attributes:
label: "Which branch are you using?"
description: "#### `git rev-parse --abbrev-ref HEAD`"
description: "#### Run: `git rev-parse --abbrev-ref HEAD`"
multiple: false
options:
- master
- master (stable)
- staging
- nightly
validations:
required: true
- type: dropdown
attributes:
label: "Which architecture are you using?"
description: "#### `uname -m`"
description: "#### Run: `uname -m`"
multiple: false
options:
- x86
- x86_64
- ARM64 (aarch64)
validations:
required: true
- type: input
attributes:
label: "Operating System:"
description: "#### Run: `lsb_release -ds`"
placeholder: "e.g. Ubuntu 22.04 LTS"
validations:
required: true
@@ -93,43 +97,44 @@ body:
- type: input
attributes:
label: "Virtualization technology:"
placeholder: "KVM, VMware, Xen, etc - **LXC and OpenVZ are not supported**"
description: "LXC and OpenVZ are not supported!"
placeholder: "KVM, VMware ESXi, Xen, etc"
validations:
required: true
- type: input
attributes:
label: "Docker version:"
description: "#### `docker version`"
description: "#### Run: `docker version`"
placeholder: "20.10.21"
validations:
required: true
- type: input
attributes:
label: "docker-compose version or docker compose version:"
description: "#### `docker-compose version` or `docker compose version`"
description: "#### Run: `docker-compose version` or `docker compose version`"
placeholder: "v2.12.2"
validations:
required: true
- type: input
attributes:
label: "mailcow version:"
description: "#### ```git describe --tags `git rev-list --tags --max-count=1` ```"
placeholder: "2022-08"
description: "#### Run: ```git describe --tags `git rev-list --tags --max-count=1` ```"
placeholder: "2022-08x"
validations:
required: true
- type: input
attributes:
label: "Reverse proxy:"
placeholder: "e.g. Nginx/Traefik"
placeholder: "e.g. nginx/Traefik, or none"
validations:
required: true
- type: textarea
attributes:
label: "Logs of git diff:"
description: "#### Output of `git diff origin/master`, any other changes to the code? If so, **please post them**:"
description: "#### Output of `git diff origin/master`, any other changes to the code? Sanitize if needed. If so, **please post them**:"
render: plain text
validations:
required: true
required: false
- type: textarea
attributes:
label: "Logs of iptables -L -vn:"

View File

@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Mark/Close Stale Issues and Pull Requests 🗑️
uses: actions/stale@v9.1.0
uses: actions/stale@v10.1.1
with:
repo-token: ${{ secrets.STALE_ACTION_PAT }}
days-before-stale: 60

View File

@@ -15,7 +15,7 @@ jobs:
images:
- "acme-mailcow"
- "clamd-mailcow"
- "dockerapi-mailcow"
- "controller-mailcow"
- "dovecot-mailcow"
- "netfilter-mailcow"
- "olefy-mailcow"
@@ -27,7 +27,7 @@ jobs:
- "watchdog-mailcow"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup Docker
run: |
curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh

View File

@@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run the Action
uses: devops-infra/action-pull-request@v0.6.1
uses: devops-infra/action-pull-request@v1.0.2
with:
github_token: ${{ secrets.PRTONIGHTLY_ACTION_PAT }}
title: Automatic PR to nightly from ${{ github.event.repository.updated_at}}

View File

@@ -13,7 +13,7 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

View File

@@ -15,14 +15,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Generate postscreen_access.cidr
run: |
bash helper-scripts/update_postscreen_whitelist.sh
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.mailcow_action_Update_postscreen_access_cidr_pat }}
commit-message: update postscreen_access.cidr

View File

@@ -1,11 +1,11 @@
# Contribution Guidelines
**_Last modified on 15th August 2024_**
**_Last modified on 12th November 2025_**
First of all, thank you for wanting to provide a bugfix or a new feature for the mailcow community, it's because of your help that the project can continue to grow!
As we want to keep mailcow's development structured we setup these Guidelines which helps you to create your issue/pull request accordingly.
**PLEASE NOTE, THAT WE MIGHT CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULLFIL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
**PLEASE NOTE, THAT WE WILL CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULFILL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
## Topics
@@ -27,14 +27,18 @@ However, please note the following regarding pull requests:
6. Please **ALWAYS** create the actual pull request against the staging branch and **NEVER** directly against the master branch. *If you forget to do this, our moobot will remind you to switch the branch to staging.*
7. Wait for a merge commit: It may happen that we do not accept your pull request immediately or sometimes not at all for various reasons. Please do not be disappointed if this is the case. We always endeavor to incorporate any meaningful changes from the community into the mailcow project.
8. If you are planning larger and therefore more complex pull requests, it would be advisable to first announce this in a separate issue and then start implementing it after the idea has been accepted in order to avoid unnecessary frustration and effort!
9. If your PR requires a Docker image rebuild (changes to Dockerfiles or files in data/Dockerfiles/), update the image tag in docker-compose.yml. Use the base-image versioning (e.g. ghcr.io/mailcow/sogo:5.12.4 → :5.12.5 for version bumps; append a letter for patch fixes, e.g. :5.12.4a). Follow this scheme.
---
## Issue Reporting
**_Last modified on 15th August 2024_**
**_Last modified on 12th November 2025_**
If you plan to report a issue within mailcow please read and understand the following rules:
### Security disclosures / Security-related fixes
- Security vulnerabilities and security fixes must always be reported confidentially first to the contact address specified in SECURITY.md before they are integrated, published, or publicly disclosed in issues/PRs. Please wait for a response from the specified contact to ensure coordinated and responsible disclosure.
### Issue Reporting Guidelines
1. **ONLY** use the issue tracker for bug reports or improvement requests and NOT for support questions. For support questions you can either contact the [mailcow community on Telegram](https://docs.mailcow.email/#community-support-and-chat) or the mailcow team directly in exchange for a [support fee](https://docs.mailcow.email/#commercial-support).

View File

@@ -23,9 +23,6 @@ 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://macarne.com/" target=_blank><img
src="https://avatars.githubusercontent.com/u/149550368?s=200&v=4" height="58"
/></a>
### 50$/Month Sponsors
<a href="https://github.com/vnukhr" target=_blank><img

View File

@@ -17,7 +17,13 @@ caller="${BASH_SOURCE[1]##*/}"
get_installed_tools(){
for bin in openssl curl docker git awk sha1sum grep cut jq; do
if [[ -z $(command -v ${bin}) ]]; then echo "Cannot find ${bin}, exiting..."; exit 1; fi
if [[ -z $(command -v ${bin}) ]]; then
echo "Error: Cannot find command '${bin}'. Cannot proceed."
echo "Solution: Please review system requirements and install requirements. Then, re-run the script."
echo "See System Requirements: https://docs.mailcow.email/getstarted/install/"
echo "Exiting..."
exit 1
fi
done
if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox grep detected, please install gnu grep, \"apk add --no-cache --upgrade grep\"${NC}"; exit 1; fi
@@ -32,45 +38,45 @@ get_docker_version(){
}
get_compose_type(){
if docker compose > /dev/null 2>&1; then
if docker compose version --short | grep -e "^2." -e "^v2." > /dev/null 2>&1; then
COMPOSE_VERSION=native
COMPOSE_COMMAND="docker compose"
if [[ "$caller" == "update.sh" ]]; then
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=native/' "$SCRIPT_DIR/mailcow.conf"
fi
echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
sleep 2
echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
else
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
elif docker-compose > /dev/null 2>&1; then
if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then
if docker-compose version --short | grep "^2." > /dev/null 2>&1; then
COMPOSE_VERSION=standalone
COMPOSE_COMMAND="docker-compose"
if [[ "$caller" == "update.sh" ]]; then
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=standalone/' "$SCRIPT_DIR/mailcow.conf"
fi
echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
sleep 2
echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
else
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
fi
if docker compose > /dev/null 2>&1; then
if docker compose version --short | grep -e "^[2-9]\." -e "^v[2-9]\." -e "^[1-9][0-9]\." -e "^v[1-9][0-9]\." > /dev/null 2>&1; then
COMPOSE_VERSION=native
COMPOSE_COMMAND="docker compose"
if [[ "$caller" == "update.sh" ]]; then
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=native/' "$SCRIPT_DIR/mailcow.conf"
fi
echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
sleep 2
echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
else
echo -e "\e[31mCannot find Docker Compose.\e[0m"
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
elif docker-compose > /dev/null 2>&1; then
if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then
if docker-compose version --short | grep -e "^[2-9]\." -e "^[1-9][0-9]\." > /dev/null 2>&1; then
COMPOSE_VERSION=standalone
COMPOSE_COMMAND="docker-compose"
if [[ "$caller" == "update.sh" ]]; then
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=standalone/' "$SCRIPT_DIR/mailcow.conf"
fi
echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
sleep 2
echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
else
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
fi
else
echo -e "\e[31mCannot find Docker Compose.\e[0m"
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
}
detect_bad_asn() {
@@ -185,7 +191,7 @@ detect_major_update() {
MAJOR_VERSIONS=(
"2025-02"
"2025-03"
"2025-08"
"2025-09"
)
current_version=""
@@ -221,4 +227,4 @@ detect_major_update() {
fi
fi
fi
}
}

View File

@@ -5,14 +5,65 @@
# 1) Check if the host supports IPv6
get_ipv6_support() {
if grep -qs '^1' /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null \
|| ! ip -6 route show default &>/dev/null; then
# ---- helper: probe external IPv6 connectivity without DNS ----
_probe_ipv6_connectivity() {
# Use literal, always-on IPv6 echo responders (no DNS required)
local PROBE_IPS=("2001:4860:4860::8888" "2606:4700:4700::1111")
local ip rc=1
for ip in "${PROBE_IPS[@]}"; do
if command -v ping6 &>/dev/null; then
ping6 -c1 -W2 "$ip" &>/dev/null || ping6 -c1 -w2 "$ip" &>/dev/null
rc=$?
elif command -v ping &>/dev/null; then
ping -6 -c1 -W2 "$ip" &>/dev/null || ping -6 -c1 -w2 "$ip" &>/dev/null
rc=$?
else
rc=1
fi
[[ $rc -eq 0 ]] && return 0
done
return 1
}
if [[ ! -f /proc/net/if_inet6 ]] || grep -qs '^1' /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null; then
DETECTED_IPV6=false
echo -e "${YELLOW}IPv6 not detected on host ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
else
DETECTED_IPV6=true
echo -e "IPv6 detected on host ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
echo -e "${YELLOW}IPv6 not detected on host ${LIGHT_RED}IPv6 is administratively disabled${YELLOW}.${NC}"
return
fi
if ip -6 route show default 2>/dev/null | grep -qE '^default'; then
echo -e "${YELLOW}Default IPv6 route found testing external IPv6 connectivity...${NC}"
if _probe_ipv6_connectivity; then
DETECTED_IPV6=true
echo -e "IPv6 detected on host ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
else
DETECTED_IPV6=false
echo -e "${YELLOW}Default IPv6 route present but external IPv6 connectivity failed ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
fi
return
fi
if ip -6 addr show scope global 2>/dev/null | grep -q 'inet6'; then
DETECTED_IPV6=false
echo -e "${YELLOW}Global IPv6 address present but no default route ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
return
fi
if ip -6 addr show scope link 2>/dev/null | grep -q 'inet6'; then
echo -e "${YELLOW}Only link-local IPv6 addresses found testing external IPv6 connectivity...${NC}"
if _probe_ipv6_connectivity; then
DETECTED_IPV6=true
echo -e "External IPv6 connectivity available ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
else
DETECTED_IPV6=false
echo -e "${YELLOW}Only link-local IPv6 present and no external connectivity ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
fi
return
fi
DETECTED_IPV6=false
echo -e "${YELLOW}IPv6 not detected on host ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
}
# 2) Ensure Docker daemon.json has (or create) the required IPv6 settings
@@ -21,7 +72,7 @@ docker_daemon_edit(){
DOCKER_MAJOR=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
MISSING=()
_has_kv() { grep -Eq "\"$1\"\s*:\s*$2" "$DOCKER_DAEMON_CONFIG" 2>/dev/null; }
_has_kv() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*$2" "$DOCKER_DAEMON_CONFIG" 2>/dev/null; }
if [[ -f "$DOCKER_DAEMON_CONFIG" ]]; then
@@ -38,12 +89,18 @@ docker_daemon_edit(){
fi
# Gather missing keys
! _has_kv ipv6 true && MISSING+=("ipv6: true")
! grep -Eq '"fixed-cidr-v6"\s*:\s*".+"' "$DOCKER_DAEMON_CONFIG" \
&& MISSING+=('fixed-cidr-v6: "fd00:dead:beef:c0::/80"')
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -le 27 ]]; then
! _has_kv ipv6 true && MISSING+=("ipv6: true")
# For Docker < 28, keep requiring fixed-cidr-v6 (default bridge needs it on old engines)
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
! grep -Eq '"fixed-cidr-v6"[[:space:]]*:[[:space:]]*".+"' "$DOCKER_DAEMON_CONFIG" \
&& MISSING+=('fixed-cidr-v6: "fd00:dead:beef:c0::/80"')
fi
# For Docker < 27, ip6tables needed and was tied to experimental in older releases
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
_has_kv ipv6 true && ! _has_kv ip6tables true && MISSING+=("ip6tables: true")
! _has_kv experimental true && MISSING+=("experimental: true")
! _has_kv experimental true && MISSING+=("experimental: true")
fi
# Fix if needed
@@ -60,9 +117,19 @@ docker_daemon_edit(){
cp "$DOCKER_DAEMON_CONFIG" "${DOCKER_DAEMON_CONFIG}.bak"
if command -v jq &>/dev/null; then
TMP=$(mktemp)
JQ_FILTER='.ipv6 = true | .["fixed-cidr-v6"] = "fd00:dead:beef:c0::/80"'
[[ "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]] \
&& JQ_FILTER+=' | .ip6tables = true | .experimental = true'
# Base filter: ensure ipv6 = true
JQ_FILTER='.ipv6 = true'
# Add fixed-cidr-v6 only for Docker < 28
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
JQ_FILTER+=' | .["fixed-cidr-v6"] = (.["fixed-cidr-v6"] // "fd00:dead:beef:c0::/80")'
fi
# Add ip6tables/experimental only for Docker < 27
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
JQ_FILTER+=' | .ip6tables = true | .experimental = true'
fi
jq "$JQ_FILTER" "$DOCKER_DAEMON_CONFIG" >"$TMP" && mv "$TMP" "$DOCKER_DAEMON_CONFIG"
echo -e "${LIGHT_GREEN}daemon.json updated. Restarting Docker...${NC}"
(command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart
@@ -88,6 +155,7 @@ docker_daemon_edit(){
fi
if [[ $ans =~ ^[Yy]$ ]]; then
mkdir -p "$(dirname "$DOCKER_DAEMON_CONFIG")"
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
{
@@ -97,12 +165,19 @@ docker_daemon_edit(){
"experimental": true
}
EOF
else
elif [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef:c0::/80"
}
EOF
else
# Docker 28+: ipv6 works without fixed-cidr-v6
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
{
"ipv6": true
}
EOF
fi
echo -e "${GREEN}Created $DOCKER_DAEMON_CONFIG with IPv6 settings.${NC}"
@@ -122,7 +197,7 @@ configure_ipv6() {
# detect manual override if mailcow.conf is present
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]] && grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
MANUAL_SETTING=$(grep '^ENABLE_IPV6=' "$MAILCOW_CONF" | cut -d= -f2)
elif [[ -z "$MAILCOW_CONF" ]] && [[ ! -z "${ENABLE_IPV6:-}" ]]; then
elif [[ -z "$MAILCOW_CONF" ]] && [[ -n "${ENABLE_IPV6:-}" ]]; then
MANUAL_SETTING="$ENABLE_IPV6"
else
MANUAL_SETTING=""
@@ -131,38 +206,34 @@ configure_ipv6() {
get_ipv6_support
# if user manually set it, check for mismatch
if [[ -n "$MANUAL_SETTING" ]]; then
if [[ "$MANUAL_SETTING" == "false" && "$DETECTED_IPV6" == "true" ]]; then
echo -e "${RED}ERROR: You have ENABLE_IPV6=false but your host and Docker support IPv6.${NC}"
echo -e "${RED}This can create an open relay. Please set ENABLE_IPV6=true in your mailcow.conf and re-run.${NC}"
exit 1
elif [[ "$MANUAL_SETTING" == "true" && "$DETECTED_IPV6" == "false" ]]; then
echo -e "${RED}ERROR: You have ENABLE_IPV6=true but your host does not support IPv6.${NC}"
echo -e "${RED}Please disable or fix your host/Docker IPv6 support, or set ENABLE_IPV6=false.${NC}"
exit 1
if [[ "$DETECTED_IPV6" != "true" ]]; then
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF"
fi
else
return
export IPV6_BOOL=false
fi
fi
# no manual override: proceed to set or export
if [[ "$DETECTED_IPV6" == "true" ]]; then
docker_daemon_edit
else
echo "Skipping Docker IPv6 configuration because host does not support IPv6."
echo "Make sure to check if your docker daemon.json does not include \"enable_ipv6\": true if you do not want IPv6."
echo "IPv6 configuration complete: ENABLE_IPV6=false"
sleep 2
return
fi
# now write into mailcow.conf or export
docker_daemon_edit
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
LINE="ENABLE_IPV6=$DETECTED_IPV6"
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i "s/^ENABLE_IPV6=.*/$LINE/" "$MAILCOW_CONF"
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=true/' "$MAILCOW_CONF"
else
echo "$LINE" >> "$MAILCOW_CONF"
echo "ENABLE_IPV6=true" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL="$DETECTED_IPV6"
export IPV6_BOOL=true
fi
echo "IPv6 configuration complete: ENABLE_IPV6=$DETECTED_IPV6"
echo "IPv6 configuration complete: ENABLE_IPV6=true"
}

View File

@@ -43,6 +43,7 @@ adapt_new_options() {
"ALLOW_ADMIN_EMAIL_LOGIN"
"SKIP_HTTP_VERIFICATION"
"SOGO_EXPIRE_SESSION"
"SOGO_URL_ENCRYPTION_KEY"
"REDIS_PORT"
"REDISPASS"
"DOVECOT_MASTER_USER"
@@ -94,7 +95,6 @@ adapt_new_options() {
echo '# Max log lines per service to keep in Redis logs' >> mailcow.conf
echo "LOG_LINES=9999" >> mailcow.conf
;;
IPV4_NETWORK)
echo '# Internal IPv4 /24 subnet, format n.n.n. (expands to n.n.n.0/24)' >> mailcow.conf
echo "IPV4_NETWORK=172.22.1" >> mailcow.conf
@@ -276,21 +276,22 @@ adapt_new_options() {
echo '# A COMPLETE DOCKER STACK REBUILD (compose down && compose up -d) IS NEEDED TO APPLY THIS.' >> mailcow.conf
echo ENABLE_IPV6=${IPV6_BOOL} >> mailcow.conf
;;
SKIP_CLAMD)
echo '# Skip ClamAV (clamd-mailcow) anti-virus (Rspamd will auto-detect a missing ClamAV container) - y/n' >> mailcow.conf
echo 'SKIP_CLAMD=n' >> mailcow.conf
;;
SKIP_OLEFY)
echo '# Skip Olefy (olefy-mailcow) anti-virus for Office documents (Rspamd will auto-detect a missing Olefy container) - y/n' >> mailcow.conf
echo 'SKIP_OLEFY=n' >> mailcow.conf
;;
REDISPASS)
echo "REDISPASS=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 28)" >> mailcow.conf
;;
SOGO_URL_ENCRYPTION_KEY)
echo '# SOGo URL encryption key (exactly 16 characters, limited to AZ, az, 09)' >> mailcow.conf
echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
;;
*)
echo "${option}=" >> mailcow.conf
;;

View File

@@ -48,11 +48,11 @@ if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
exec $(readlink -f "$0")
fi
log_f "Waiting for Docker API..."
until ping dockerapi -c1 > /dev/null; do
log_f "Waiting for Controller .."
until ping controller -c1 > /dev/null; do
sleep 1
done
log_f "Docker API OK"
log_f "Controller OK"
log_f "Waiting for Postfix..."
until ping postfix -c1 > /dev/null; do
@@ -246,6 +246,25 @@ while true; do
done
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
done
# Fetch alias domains where target domain has MTA-STS enabled
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
SQL_ALIAS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT ad.alias_domain FROM alias_domain ad INNER JOIN mta_sts m ON ad.target_domain = m.domain WHERE ad.active = 1 AND m.active = 1" -Bs)
if [[ $? -eq 0 ]]; then
while read alias_domain; do
if [[ -z "${alias_domain}" ]]; then
# ignore empty lines
continue
fi
# Only add mta-sts subdomain for alias domains
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
if check_domain "mta-sts.${alias_domain}"; then
VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}")
fi
fi
done <<< "${SQL_ALIAS_DOMAINS}"
fi
fi
fi
if check_domain ${MAILCOW_HOSTNAME}; then

View File

@@ -2,32 +2,32 @@
# Reading container IDs
# Wrapping as array to ensure trimmed content when calling $NGINX etc.
NGINX=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"nginx-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
DOVECOT=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"dovecot-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
POSTFIX=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
NGINX=($(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"nginx-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
DOVECOT=($(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"dovecot-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
POSTFIX=($(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
reload_nginx(){
echo "Reloading Nginx..."
NGINX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${NGINX}/exec -d '{"cmd":"reload", "task":"nginx"}' --silent -H 'Content-type: application/json' | jq -r .type)
NGINX_RELOAD_RET=$(curl -X POST --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${NGINX}/exec -d '{"cmd":"reload", "task":"nginx"}' --silent -H 'Content-type: application/json' | jq -r .type)
[[ ${NGINX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Nginx, restarting container..."; restart_container ${NGINX} ; }
}
reload_dovecot(){
echo "Reloading Dovecot..."
DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type)
DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type)
[[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; }
}
reload_postfix(){
echo "Reloading Postfix..."
POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/exec -d '{"cmd":"reload", "task":"postfix"}' --silent -H 'Content-type: application/json' | jq -r .type)
POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/exec -d '{"cmd":"reload", "task":"postfix"}' --silent -H 'Content-type: application/json' | jq -r .type)
[[ ${POSTFIX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Postfix, restarting container..."; restart_container ${POSTFIX} ; }
}
restart_container(){
for container in $*; do
echo "Restarting ${container}..."
C_REST_OUT=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${container}/restart --silent | jq -r '.msg')
C_REST_OUT=$(curl -X POST --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${container}/restart --silent | jq -r '.msg')
echo "${C_REST_OUT}"
done
}

View File

@@ -1,3 +1,3 @@
FROM debian:bookworm-slim
FROM debian:trixie-slim
RUN apt update && apt install pigz -y --no-install-recommends
RUN apt update && apt install pigz zstd -y --no-install-recommends

View File

@@ -8,7 +8,7 @@ fi
# Cleaning up garbage
echo "Cleaning up tmp files..."
rm -rf /var/lib/clamav/clamav-*.tmp
rm -rf /var/lib/clamav/tmp.*
# Prepare whitelist

View File

@@ -6,22 +6,29 @@ ARG PIP_BREAK_SYSTEM_PACKAGES=1
WORKDIR /app
RUN apk add --update --no-cache python3 \
bash \
py3-pip \
openssl \
tzdata \
py3-psutil \
py3-redis \
py3-async-timeout \
supervisor \
curl \
&& pip3 install --upgrade pip \
fastapi \
uvicorn \
aiodocker \
docker
RUN mkdir /app/modules
COPY mailcow-adm/ /app/mailcow-adm/
RUN pip3 install -r /app/mailcow-adm/requirements.txt
COPY api/ /app/api/
COPY docker-entrypoint.sh /app/
COPY main.py /app/main.py
COPY modules/ /app/modules/
COPY supervisord.conf /etc/supervisor/supervisord.conf
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
ENTRYPOINT ["/bin/sh", "/app/docker-entrypoint.sh"]
CMD ["python", "main.py"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

View File

@@ -254,8 +254,8 @@ if __name__ == '__main__':
app,
host="0.0.0.0",
port=443,
ssl_certfile="/app/dockerapi_cert.pem",
ssl_keyfile="/app/dockerapi_key.pem",
ssl_certfile="/app/controller_cert.pem",
ssl_keyfile="/app/controller_key.pem",
log_level="info",
loop="none"
)

View File

@@ -0,0 +1,9 @@
#!/bin/bash
`openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout /app/controller_key.pem \
-out /app/controller_cert.pem \
-subj /CN=controller/O=mailcow \
-addext subjectAltName=DNS:controller`
exec "$@"

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import sys
from models.AliasModel import AliasModel
from models.MailboxModel import MailboxModel
from models.SyncjobModel import SyncjobModel
from models.CalendarModel import CalendarModel
from models.MailerModel import MailerModel
from models.AddressbookModel import AddressbookModel
from models.MaildirModel import MaildirModel
from models.DomainModel import DomainModel
from models.DomainadminModel import DomainadminModel
from models.StatusModel import StatusModel
from modules.Utils import Utils
def main():
utils = Utils()
model_map = {
MailboxModel.parser_command: MailboxModel,
AliasModel.parser_command: AliasModel,
SyncjobModel.parser_command: SyncjobModel,
CalendarModel.parser_command: CalendarModel,
AddressbookModel.parser_command: AddressbookModel,
MailerModel.parser_command: MailerModel,
MaildirModel.parser_command: MaildirModel,
DomainModel.parser_command: DomainModel,
DomainadminModel.parser_command: DomainadminModel,
StatusModel.parser_command: StatusModel
}
parser = argparse.ArgumentParser(description="mailcow Admin Tool")
subparsers = parser.add_subparsers(dest="command", required=True)
for model in model_map.values():
model.add_parser(subparsers)
args = parser.parse_args()
for cmd, model_cls in model_map.items():
if args.command == cmd and model_cls.has_required_args(args):
instance = model_cls(**vars(args))
action = getattr(instance, args.object, None)
if callable(action):
res = action()
utils.pprint(res)
sys.exit(0)
parser.print_help()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,140 @@
from modules.Sogo import Sogo
from models.BaseModel import BaseModel
class AddressbookModel(BaseModel):
parser_command = "addressbook"
required_args = {
"add": [["username", "name"]],
"delete": [["username", "name"]],
"get": [["username", "name"]],
"set_acl": [["username", "name", "sharee_email", "acl"]],
"get_acl": [["username", "name"]],
"delete_acl": [["username", "name", "sharee_email"]],
"add_contact": [["username", "name", "contact_name", "contact_email", "type"]],
"delete_contact": [["username", "name", "contact_name"]],
}
def __init__(
self,
username=None,
name=None,
sharee_email=None,
acl=None,
subscribe=None,
ics=None,
contact_name=None,
contact_email=None,
type=None,
**kwargs
):
self.sogo = Sogo(username)
self.name = name
self.acl = acl
self.sharee_email = sharee_email
self.subscribe = subscribe
self.ics = ics
self.contact_name = contact_name
self.contact_email = contact_email
self.type = type
def add(self):
"""
Add a new addressbook.
:return: Response from SOGo API.
"""
return self.sogo.addAddressbook(self.name)
def set_acl(self):
"""
Set ACL for the addressbook.
:return: Response from SOGo API.
"""
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
if not addressbook_id:
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.setAddressbookACL(addressbook_id, self.sharee_email, self.acl, self.subscribe)
def delete_acl(self):
"""
Delete the addressbook ACL.
:return: Response from SOGo API.
"""
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
if not addressbook_id:
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.deleteAddressbookACL(addressbook_id, self.sharee_email)
def get_acl(self):
"""
Get the ACL for the addressbook.
:return: Response from SOGo API.
"""
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
if not addressbook_id:
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.getAddressbookACL(addressbook_id)
def add_contact(self):
"""
Add a new contact to the addressbook.
:return: Response from SOGo API.
"""
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
if not addressbook_id:
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
return None
if self.type == "card":
return self.sogo.addAddressbookContact(addressbook_id, self.contact_name, self.contact_email)
elif self.type == "list":
return self.sogo.addAddressbookContactList(addressbook_id, self.contact_name, self.contact_email)
def delete_contact(self):
"""
Delete a contact or contactlist from the addressbook.
:return: Response from SOGo API.
"""
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
if not addressbook_id:
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.deleteAddressbookItem(addressbook_id, self.contact_name)
def get(self):
"""
Retrieve addressbooks list.
:return: Response from SOGo API.
"""
return self.sogo.getAddressbookList()
def delete(self):
"""
Delete the addressbook.
:return: Response from SOGo API.
"""
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
if not addressbook_id:
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.deleteAddressbook(addressbook_id)
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Manage addressbooks (add, delete, get, set_acl, get_acl, delete_acl, add_contact, delete_contact)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, set_acl, get_acl, delete_acl, add_contact, delete_contact")
parser.add_argument("--username", required=True, help="Username of the addressbook owner (e.g. user@example.com)")
parser.add_argument("--name", help="Addressbook name")
parser.add_argument("--sharee-email", help="Email address to share the addressbook with")
parser.add_argument("--acl", help="ACL rights for the sharee (e.g. r, w, rw)")
parser.add_argument("--subscribe", action='store_true', help="Subscribe the sharee to the addressbook")
parser.add_argument("--contact-name", help="Name of the contact or contactlist to add or delete")
parser.add_argument("--contact-email", help="Email address of the contact to add")
parser.add_argument("--type", choices=["card", "list"], help="Type of contact to add: card (single contact) or list (distribution list)")

View File

@@ -0,0 +1,107 @@
from modules.Mailcow import Mailcow
from models.BaseModel import BaseModel
class AliasModel(BaseModel):
parser_command = "alias"
required_args = {
"add": [["address", "goto"]],
"delete": [["id"]],
"get": [["id"]],
"edit": [["id"]]
}
def __init__(
self,
id=None,
address=None,
goto=None,
active=None,
sogo_visible=None,
**kwargs
):
self.mailcow = Mailcow()
self.id = id
self.address = address
self.goto = goto
self.active = active
self.sogo_visible = sogo_visible
@classmethod
def from_dict(cls, data):
return cls(
address=data.get("address"),
goto=data.get("goto"),
active=data.get("active", None),
sogo_visible=data.get("sogo_visible", None)
)
def getAdd(self):
"""
Get the alias details as a dictionary for adding, sets default values.
:return: Dictionary containing alias details.
"""
alias = {
"address": self.address,
"goto": self.goto,
"active": self.active if self.active is not None else 1,
"sogo_visible": self.sogo_visible if self.sogo_visible is not None else 0
}
return {key: value for key, value in alias.items() if value is not None}
def getEdit(self):
"""
Get the alias details as a dictionary for editing, sets no default values.
:return: Dictionary containing mailbox details.
"""
alias = {
"address": self.address,
"goto": self.goto,
"active": self.active,
"sogo_visible": self.sogo_visible
}
return {key: value for key, value in alias.items() if value is not None}
def get(self):
"""
Get the mailbox details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.getAlias(self.id)
def delete(self):
"""
Get the mailbox details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.deleteAlias(self.id)
def add(self):
"""
Get the mailbox details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.addAlias(self.getAdd())
def edit(self):
"""
Get the mailbox details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.editAlias(self.id, self.getEdit())
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Manage aliases (add, delete, get, edit)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
parser.add_argument("--id", help="Alias object ID (required for get, edit, delete)")
parser.add_argument("--address", help="Alias email address (e.g. alias@example.com)")
parser.add_argument("--goto", help="Destination address(es), comma-separated (e.g. user1@example.com,user2@example.com)")
parser.add_argument("--active", choices=["1", "0"], help="Activate (1) or deactivate (0) the alias")
parser.add_argument("--sogo-visible", choices=["1", "0"], help="Show alias in SOGo addressbook (1 = yes, 0 = no)")

View File

@@ -0,0 +1,35 @@
class BaseModel:
parser_command = ""
required_args = {}
@classmethod
def has_required_args(cls, args):
"""
Validate that all required arguments are present.
"""
object_name = args.object if hasattr(args, "object") else args.get("object")
required_lists = cls.required_args.get(object_name, False)
if not required_lists:
return False
for required_set in required_lists:
result = True
for required_args in required_set:
if isinstance(args, dict):
if not args.get(required_args):
result = False
break
elif not hasattr(args, required_args):
result = False
break
if result:
break
if not result:
print(f"Required arguments for '{object_name}': {required_lists}")
return result
@classmethod
def add_parser(cls, subparsers):
pass

View File

@@ -0,0 +1,111 @@
from modules.Sogo import Sogo
from models.BaseModel import BaseModel
class CalendarModel(BaseModel):
parser_command = "calendar"
required_args = {
"add": [["username", "name"]],
"delete": [["username", "name"]],
"get": [["username"]],
"import_ics": [["username", "name", "ics"]],
"set_acl": [["username", "name", "sharee_email", "acl"]],
"get_acl": [["username", "name"]],
"delete_acl": [["username", "name", "sharee_email"]],
}
def __init__(
self,
username=None,
name=None,
sharee_email=None,
acl=None,
subscribe=None,
ics=None,
**kwargs
):
self.sogo = Sogo(username)
self.name = name
self.acl = acl
self.sharee_email = sharee_email
self.subscribe = subscribe
self.ics = ics
def add(self):
"""
Add a new calendar.
:return: Response from SOGo API.
"""
return self.sogo.addCalendar(self.name)
def delete(self):
"""
Delete a calendar.
:return: Response from SOGo API.
"""
calendar_id = self.sogo.getCalendarIdByName(self.name)
if not calendar_id:
print(f"Calendar '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.deleteCalendar(calendar_id)
def get(self):
"""
Get the calendar details.
:return: Response from SOGo API.
"""
return self.sogo.getCalendar()
def set_acl(self):
"""
Set ACL for the calendar.
:return: Response from SOGo API.
"""
calendar_id = self.sogo.getCalendarIdByName(self.name)
if not calendar_id:
print(f"Calendar '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.setCalendarACL(calendar_id, self.sharee_email, self.acl, self.subscribe)
def delete_acl(self):
"""
Delete the calendar ACL.
:return: Response from SOGo API.
"""
calendar_id = self.sogo.getCalendarIdByName(self.name)
if not calendar_id:
print(f"Calendar '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.deleteCalendarACL(calendar_id, self.sharee_email)
def get_acl(self):
"""
Get the ACL for the calendar.
:return: Response from SOGo API.
"""
calendar_id = self.sogo.getCalendarIdByName(self.name)
if not calendar_id:
print(f"Calendar '{self.name}' not found for user '{self.username}'.")
return None
return self.sogo.getCalendarACL(calendar_id)
def import_ics(self):
"""
Import a calendar from an ICS file.
:return: Response from SOGo API.
"""
return self.sogo.importCalendar(self.name, self.ics)
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Manage calendars (add, delete, get, import_ics, set_acl, get_acl, delete_acl)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, import_ics, set_acl, get_acl, delete_acl")
parser.add_argument("--username", required=True, help="Username of the calendar owner (e.g. user@example.com)")
parser.add_argument("--name", help="Calendar name")
parser.add_argument("--ics", help="Path to ICS file for import")
parser.add_argument("--sharee-email", help="Email address to share the calendar with")
parser.add_argument("--acl", help="ACL rights for the sharee (e.g. r, w, rw)")
parser.add_argument("--subscribe", action='store_true', help="Subscribe the sharee to the calendar")

View File

@@ -0,0 +1,162 @@
from modules.Mailcow import Mailcow
from models.BaseModel import BaseModel
class DomainModel(BaseModel):
parser_command = "domain"
required_args = {
"add": [["domain"]],
"delete": [["domain"]],
"get": [["domain"]],
"edit": [["domain"]]
}
def __init__(
self,
domain=None,
active=None,
aliases=None,
backupmx=None,
defquota=None,
description=None,
mailboxes=None,
maxquota=None,
quota=None,
relay_all_recipients=None,
rl_frame=None,
rl_value=None,
restart_sogo=None,
tags=None,
**kwargs
):
self.mailcow = Mailcow()
self.domain = domain
self.active = active
self.aliases = aliases
self.backupmx = backupmx
self.defquota = defquota
self.description = description
self.mailboxes = mailboxes
self.maxquota = maxquota
self.quota = quota
self.relay_all_recipients = relay_all_recipients
self.rl_frame = rl_frame
self.rl_value = rl_value
self.restart_sogo = restart_sogo
self.tags = tags
@classmethod
def from_dict(cls, data):
return cls(
domain=data.get("domain"),
active=data.get("active", None),
aliases=data.get("aliases", None),
backupmx=data.get("backupmx", None),
defquota=data.get("defquota", None),
description=data.get("description", None),
mailboxes=data.get("mailboxes", None),
maxquota=data.get("maxquota", None),
quota=data.get("quota", None),
relay_all_recipients=data.get("relay_all_recipients", None),
rl_frame=data.get("rl_frame", None),
rl_value=data.get("rl_value", None),
restart_sogo=data.get("restart_sogo", None),
tags=data.get("tags", None)
)
def getAdd(self):
"""
Get the domain details as a dictionary for adding, sets default values.
:return: Dictionary containing domain details.
"""
domain = {
"domain": self.domain,
"active": self.active if self.active is not None else 1,
"aliases": self.aliases if self.aliases is not None else 400,
"backupmx": self.backupmx if self.backupmx is not None else 0,
"defquota": self.defquota if self.defquota is not None else 3072,
"description": self.description if self.description is not None else "",
"mailboxes": self.mailboxes if self.mailboxes is not None else 10,
"maxquota": self.maxquota if self.maxquota is not None else 10240,
"quota": self.quota if self.quota is not None else 10240,
"relay_all_recipients": self.relay_all_recipients if self.relay_all_recipients is not None else 0,
"rl_frame": self.rl_frame,
"rl_value": self.rl_value,
"restart_sogo": self.restart_sogo if self.restart_sogo is not None else 0,
"tags": self.tags if self.tags is not None else []
}
return {key: value for key, value in domain.items() if value is not None}
def getEdit(self):
"""
Get the domain details as a dictionary for editing, sets no default values.
:return: Dictionary containing domain details.
"""
domain = {
"domain": self.domain,
"active": self.active,
"aliases": self.aliases,
"backupmx": self.backupmx,
"defquota": self.defquota,
"description": self.description,
"mailboxes": self.mailboxes,
"maxquota": self.maxquota,
"quota": self.quota,
"relay_all_recipients": self.relay_all_recipients,
"rl_frame": self.rl_frame,
"rl_value": self.rl_value,
"restart_sogo": self.restart_sogo,
"tags": self.tags
}
return {key: value for key, value in domain.items() if value is not None}
def get(self):
"""
Get the domain details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.getDomain(self.domain)
def delete(self):
"""
Delete the domain from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.deleteDomain(self.domain)
def add(self):
"""
Add the domain to the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.addDomain(self.getAdd())
def edit(self):
"""
Edit the domain in the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.editDomain(self.domain, self.getEdit())
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Manage domains (add, delete, get, edit)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
parser.add_argument("--domain", required=True, help="Domain name (e.g. domain.tld)")
parser.add_argument("--active", choices=["1", "0"], help="Activate (1) or deactivate (0) the domain")
parser.add_argument("--aliases", help="Number of aliases allowed for the domain")
parser.add_argument("--backupmx", choices=["1", "0"], help="Enable (1) or disable (0) backup MX")
parser.add_argument("--defquota", help="Default quota for mailboxes in MB")
parser.add_argument("--description", help="Description of the domain")
parser.add_argument("--mailboxes", help="Number of mailboxes allowed for the domain")
parser.add_argument("--maxquota", help="Maximum quota for the domain in MB")
parser.add_argument("--quota", help="Quota used by the domain in MB")
parser.add_argument("--relay-all-recipients", choices=["1", "0"], help="Relay all recipients (1 = yes, 0 = no)")
parser.add_argument("--rl-frame", help="Rate limit frame (e.g., s, m, h)")
parser.add_argument("--rl-value", help="Rate limit value")
parser.add_argument("--restart-sogo", help="Restart SOGo after changes (1 = yes, 0 = no)")
parser.add_argument("--tags", nargs="*", help="Tags for the domain")

View File

@@ -0,0 +1,106 @@
from modules.Mailcow import Mailcow
from models.BaseModel import BaseModel
class DomainadminModel(BaseModel):
parser_command = "domainadmin"
required_args = {
"add": [["username", "domains", "password"]],
"delete": [["username"]],
"get": [["username"]],
"edit": [["username"]]
}
def __init__(
self,
username=None,
domains=None,
password=None,
active=None,
**kwargs
):
self.mailcow = Mailcow()
self.username = username
self.domains = domains
self.password = password
self.password2 = password
self.active = active
@classmethod
def from_dict(cls, data):
return cls(
username=data.get("username"),
domains=data.get("domains"),
password=data.get("password"),
password2=data.get("password"),
active=data.get("active", None),
)
def getAdd(self):
"""
Get the domain admin details as a dictionary for adding, sets default values.
:return: Dictionary containing domain admin details.
"""
domainadmin = {
"username": self.username,
"domains": self.domains,
"password": self.password,
"password2": self.password2,
"active": self.active if self.active is not None else "1"
}
return {key: value for key, value in domainadmin.items() if value is not None}
def getEdit(self):
"""
Get the domain admin details as a dictionary for editing, sets no default values.
:return: Dictionary containing domain admin details.
"""
domainadmin = {
"username": self.username,
"domains": self.domains,
"password": self.password,
"password2": self.password2,
"active": self.active
}
return {key: value for key, value in domainadmin.items() if value is not None}
def get(self):
"""
Get the domain admin details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.getDomainadmin(self.username)
def delete(self):
"""
Delete the domain admin from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.deleteDomainadmin(self.username)
def add(self):
"""
Add the domain admin to the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.addDomainadmin(self.getAdd())
def edit(self):
"""
Edit the domain admin in the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.editDomainadmin(self.username, self.getEdit())
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Manage domain admins (add, delete, get, edit)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
parser.add_argument("--username", help="Username for the domain admin")
parser.add_argument("--domains", help="Comma-separated list of domains")
parser.add_argument("--password", help="Password for the domain admin")
parser.add_argument("--active", choices=["1", "0"], help="Activate (1) or deactivate (0) the domain admin")

View File

@@ -0,0 +1,164 @@
from modules.Mailcow import Mailcow
from models.BaseModel import BaseModel
class MailboxModel(BaseModel):
parser_command = "mailbox"
required_args = {
"add": [["username", "password"]],
"delete": [["username"]],
"get": [["username"]],
"edit": [["username"]]
}
def __init__(
self,
password=None,
username=None,
domain=None,
local_part=None,
active=None,
sogo_access=None,
name=None,
authsource=None,
quota=None,
force_pw_update=None,
tls_enforce_in=None,
tls_enforce_out=None,
tags=None,
sender_acl=None,
**kwargs
):
self.mailcow = Mailcow()
if username is not None and "@" in username:
self.username = username
self.local_part, self.domain = username.split("@")
else:
self.username = f"{local_part}@{domain}"
self.local_part = local_part
self.domain = domain
self.password = password
self.password2 = password
self.active = active
self.sogo_access = sogo_access
self.name = name
self.authsource = authsource
self.quota = quota
self.force_pw_update = force_pw_update
self.tls_enforce_in = tls_enforce_in
self.tls_enforce_out = tls_enforce_out
self.tags = tags
self.sender_acl = sender_acl
@classmethod
def from_dict(cls, data):
return cls(
domain=data.get("domain"),
local_part=data.get("local_part"),
password=data.get("password"),
password2=data.get("password"),
active=data.get("active", None),
sogo_access=data.get("sogo_access", None),
name=data.get("name", None),
authsource=data.get("authsource", None),
quota=data.get("quota", None),
force_pw_update=data.get("force_pw_update", None),
tls_enforce_in=data.get("tls_enforce_in", None),
tls_enforce_out=data.get("tls_enforce_out", None),
tags=data.get("tags", None),
sender_acl=data.get("sender_acl", None)
)
def getAdd(self):
"""
Get the mailbox details as a dictionary for adding, sets default values.
:return: Dictionary containing mailbox details.
"""
mailbox = {
"domain": self.domain,
"local_part": self.local_part,
"password": self.password,
"password2": self.password2,
"active": self.active if self.active is not None else 1,
"name": self.name if self.name is not None else "",
"authsource": self.authsource if self.authsource is not None else "mailcow",
"quota": self.quota if self.quota is not None else 0,
"force_pw_update": self.force_pw_update if self.force_pw_update is not None else 0,
"tls_enforce_in": self.tls_enforce_in if self.tls_enforce_in is not None else 0,
"tls_enforce_out": self.tls_enforce_out if self.tls_enforce_out is not None else 0,
"tags": self.tags if self.tags is not None else []
}
return {key: value for key, value in mailbox.items() if value is not None}
def getEdit(self):
"""
Get the mailbox details as a dictionary for editing, sets no default values.
:return: Dictionary containing mailbox details.
"""
mailbox = {
"domain": self.domain,
"local_part": self.local_part,
"password": self.password,
"password2": self.password2,
"active": self.active,
"name": self.name,
"authsource": self.authsource,
"quota": self.quota,
"force_pw_update": self.force_pw_update,
"tls_enforce_in": self.tls_enforce_in,
"tls_enforce_out": self.tls_enforce_out,
"tags": self.tags
}
return {key: value for key, value in mailbox.items() if value is not None}
def get(self):
"""
Get the mailbox details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.getMailbox(self.username)
def delete(self):
"""
Get the mailbox details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.deleteMailbox(self.username)
def add(self):
"""
Get the mailbox details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.addMailbox(self.getAdd())
def edit(self):
"""
Get the mailbox details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.editMailbox(self.username, self.getEdit())
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Manage mailboxes (add, delete, get, edit)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
parser.add_argument("--username", help="Full email address of the mailbox (e.g. user@example.com)")
parser.add_argument("--password", help="Password for the mailbox (required for add)")
parser.add_argument("--active", choices=["1", "0"], help="Activate (1) or deactivate (0) the mailbox")
parser.add_argument("--sogo-access", choices=["1", "0"], help="Redirect mailbox to SOGo after web login (1 = yes, 0 = no)")
parser.add_argument("--name", help="Display name of the mailbox owner")
parser.add_argument("--authsource", help="Authentication source (default: mailcow)")
parser.add_argument("--quota", help="Mailbox quota in bytes (0 = unlimited)")
parser.add_argument("--force-pw-update", choices=["1", "0"], help="Force password update on next login (1 = yes, 0 = no)")
parser.add_argument("--tls-enforce-in", choices=["1", "0"], help="Enforce TLS for incoming emails (1 = yes, 0 = no)")
parser.add_argument("--tls-enforce-out", choices=["1", "0"], help="Enforce TLS for outgoing emails (1 = yes, 0 = no)")
parser.add_argument("--tags", help="Comma-separated list of tags for the mailbox")
parser.add_argument("--sender-acl", help="Comma-separated list of allowed sender addresses for this mailbox")

View File

@@ -0,0 +1,67 @@
from modules.Dovecot import Dovecot
from models.BaseModel import BaseModel
class MaildirModel(BaseModel):
parser_command = "maildir"
required_args = {
"encrypt": [],
"decrypt": [],
"restore": [["username", "item"], ["list"]]
}
def __init__(
self,
username=None,
source=None,
item=None,
overwrite=None,
list=None,
**kwargs
):
self.dovecot = Dovecot()
for key, value in kwargs.items():
setattr(self, key, value)
self.username = username
self.source = source
self.item = item
self.overwrite = overwrite
self.list = list
def encrypt(self):
"""
Encrypt the maildir for the specified user or all.
:return: Response from Dovecot.
"""
return self.dovecot.encryptMaildir(self.source_dir, self.output_dir)
def decrypt(self):
"""
Decrypt the maildir for the specified user or all.
:return: Response from Dovecot.
"""
return self.dovecot.decryptMaildir(self.source_dir, self.output_dir)
def restore(self):
"""
Restore or List maildir data for the specified user.
:return: Response from Dovecot.
"""
if self.list:
return self.dovecot.listDeletedMaildirs()
return self.dovecot.restoreMaildir(self.username, self.item)
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Manage maildir (encrypt, decrypt, restore)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: encrypt, decrypt, restore")
parser.add_argument("--item", help="Item to restore")
parser.add_argument("--username", help="Username to restore the item to")
parser.add_argument("--list", action="store_true", help="List items to restore")
parser.add_argument("--source-dir", help="Path to the source maildir to import/encrypt/decrypt")
parser.add_argument("--output-dir", help="Directory to store encrypted/decrypted files inside the Dovecot container")

View File

@@ -0,0 +1,62 @@
import json
from models.BaseModel import BaseModel
from modules.Mailer import Mailer
class MailerModel(BaseModel):
parser_command = "mail"
required_args = {
"send": [["sender", "recipient", "subject", "body"]]
}
def __init__(
self,
sender=None,
recipient=None,
subject=None,
body=None,
context=None,
**kwargs
):
self.sender = sender
self.recipient = recipient
self.subject = subject
self.body = body
self.context = context
def send(self):
if self.context is not None:
try:
self.context = json.loads(self.context)
except json.JSONDecodeError as e:
return f"Invalid context JSON: {e}"
else:
self.context = {}
mailer = Mailer(
smtp_host="postfix-mailcow",
smtp_port=25,
username=self.sender,
password="",
use_tls=True
)
res = mailer.send_mail(
subject=self.subject,
from_addr=self.sender,
to_addrs=self.recipient.split(","),
template=self.body,
context=self.context
)
return res
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Send emails via SMTP"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: send")
parser.add_argument("--sender", required=True, help="Email sender address")
parser.add_argument("--recipient", required=True, help="Email recipient address (comma-separated for multiple)")
parser.add_argument("--subject", required=True, help="Email subject")
parser.add_argument("--body", required=True, help="Email body (Jinja2 template supported)")
parser.add_argument("--context", help="Context for Jinja2 template rendering (JSON format)")

View File

@@ -0,0 +1,45 @@
from modules.Mailcow import Mailcow
from models.BaseModel import BaseModel
class StatusModel(BaseModel):
parser_command = "status"
required_args = {
"version": [[]],
"vmail": [[]],
"containers": [[]]
}
def __init__(
self,
**kwargs
):
self.mailcow = Mailcow()
def version(self):
"""
Get the version of the mailcow instance.
:return: Response from the mailcow API.
"""
return self.mailcow.getStatusVersion()
def vmail(self):
"""
Get the vmail details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.getStatusVmail()
def containers(self):
"""
Get the status of containers in the mailcow instance.
:return: Response from the mailcow API.
"""
return self.mailcow.getStatusContainers()
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Get information about mailcow (version, vmail, containers)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: version, vmail, containers")

View File

@@ -0,0 +1,221 @@
from modules.Mailcow import Mailcow
from models.BaseModel import BaseModel
class SyncjobModel(BaseModel):
parser_command = "syncjob"
required_args = {
"add": [["username", "host1", "port1", "user1", "password1", "enc1"]],
"delete": [["id"]],
"get": [["username"]],
"edit": [["id"]],
"run": [["id"]]
}
def __init__(
self,
id=None,
username=None,
host1=None,
port1=None,
user1=None,
password1=None,
enc1=None,
mins_interval=None,
subfolder2=None,
maxage=None,
maxbytespersecond=None,
timeout1=None,
timeout2=None,
exclude=None,
custom_parameters=None,
delete2duplicates=None,
delete1=None,
delete2=None,
automap=None,
skipcrossduplicates=None,
subscribeall=None,
active=None,
force=None,
**kwargs
):
self.mailcow = Mailcow()
for key, value in kwargs.items():
setattr(self, key, value)
self.id = id
self.username = username
self.host1 = host1
self.port1 = port1
self.user1 = user1
self.password1 = password1
self.enc1 = enc1
self.mins_interval = mins_interval
self.subfolder2 = subfolder2
self.maxage = maxage
self.maxbytespersecond = maxbytespersecond
self.timeout1 = timeout1
self.timeout2 = timeout2
self.exclude = exclude
self.custom_parameters = custom_parameters
self.delete2duplicates = delete2duplicates
self.delete1 = delete1
self.delete2 = delete2
self.automap = automap
self.skipcrossduplicates = skipcrossduplicates
self.subscribeall = subscribeall
self.active = active
self.force = force
@classmethod
def from_dict(cls, data):
return cls(
username=data.get("username"),
host1=data.get("host1"),
port1=data.get("port1"),
user1=data.get("user1"),
password1=data.get("password1"),
enc1=data.get("enc1"),
mins_interval=data.get("mins_interval", None),
subfolder2=data.get("subfolder2", None),
maxage=data.get("maxage", None),
maxbytespersecond=data.get("maxbytespersecond", None),
timeout1=data.get("timeout1", None),
timeout2=data.get("timeout2", None),
exclude=data.get("exclude", None),
custom_parameters=data.get("custom_parameters", None),
delete2duplicates=data.get("delete2duplicates", None),
delete1=data.get("delete1", None),
delete2=data.get("delete2", None),
automap=data.get("automap", None),
skipcrossduplicates=data.get("skipcrossduplicates", None),
subscribeall=data.get("subscribeall", None),
active=data.get("active", None),
)
def getAdd(self):
"""
Get the sync job details as a dictionary for adding, sets default values.
:return: Dictionary containing sync job details.
"""
syncjob = {
"username": self.username,
"host1": self.host1,
"port1": self.port1,
"user1": self.user1,
"password1": self.password1,
"enc1": self.enc1,
"mins_interval": self.mins_interval if self.mins_interval is not None else 20,
"subfolder2": self.subfolder2 if self.subfolder2 is not None else "",
"maxage": self.maxage if self.maxage is not None else 0,
"maxbytespersecond": self.maxbytespersecond if self.maxbytespersecond is not None else 0,
"timeout1": self.timeout1 if self.timeout1 is not None else 600,
"timeout2": self.timeout2 if self.timeout2 is not None else 600,
"exclude": self.exclude if self.exclude is not None else "(?i)spam|(?i)junk",
"custom_parameters": self.custom_parameters if self.custom_parameters is not None else "",
"delete2duplicates": 1 if self.delete2duplicates else 0,
"delete1": 1 if self.delete1 else 0,
"delete2": 1 if self.delete2 else 0,
"automap": 1 if self.automap else 0,
"skipcrossduplicates": 1 if self.skipcrossduplicates else 0,
"subscribeall": 1 if self.subscribeall else 0,
"active": 1 if self.active else 0
}
return {key: value for key, value in syncjob.items() if value is not None}
def getEdit(self):
"""
Get the sync job details as a dictionary for editing, sets no default values.
:return: Dictionary containing sync job details.
"""
syncjob = {
"username": self.username,
"host1": self.host1,
"port1": self.port1,
"user1": self.user1,
"password1": self.password1,
"enc1": self.enc1,
"mins_interval": self.mins_interval,
"subfolder2": self.subfolder2,
"maxage": self.maxage,
"maxbytespersecond": self.maxbytespersecond,
"timeout1": self.timeout1,
"timeout2": self.timeout2,
"exclude": self.exclude,
"custom_parameters": self.custom_parameters,
"delete2duplicates": self.delete2duplicates,
"delete1": self.delete1,
"delete2": self.delete2,
"automap": self.automap,
"skipcrossduplicates": self.skipcrossduplicates,
"subscribeall": self.subscribeall,
"active": self.active
}
return {key: value for key, value in syncjob.items() if value is not None}
def get(self):
"""
Get the sync job details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.getSyncjob(self.username)
def delete(self):
"""
Get the sync job details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.deleteSyncjob(self.id)
def add(self):
"""
Get the sync job details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.addSyncjob(self.getAdd())
def edit(self):
"""
Get the sync job details from the mailcow API.
:return: Response from the mailcow API.
"""
return self.mailcow.editSyncjob(self.id, self.getEdit())
def run(self):
"""
Run the sync job.
:return: Response from the mailcow API.
"""
return self.mailcow.runSyncjob(self.id, force=self.force)
@classmethod
def add_parser(cls, subparsers):
parser = subparsers.add_parser(
cls.parser_command,
help="Manage sync jobs (add, delete, get, edit)"
)
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
parser.add_argument("--id", help="Syncjob object ID (required for edit, delete, run)")
parser.add_argument("--username", help="Target mailbox username (e.g. user@example.com)")
parser.add_argument("--host1", help="Source IMAP server hostname")
parser.add_argument("--port1", help="Source IMAP server port")
parser.add_argument("--user1", help="Source IMAP account username")
parser.add_argument("--password1", help="Source IMAP account password")
parser.add_argument("--enc1", choices=["PLAIN", "SSL", "TLS"], help="Encryption for source server connection")
parser.add_argument("--mins-interval", help="Sync interval in minutes (default: 20)")
parser.add_argument("--subfolder2", help="Destination subfolder (default: empty)")
parser.add_argument("--maxage", help="Maximum mail age in days (default: 0 = unlimited)")
parser.add_argument("--maxbytespersecond", help="Maximum bandwidth in bytes/sec (default: 0 = unlimited)")
parser.add_argument("--timeout1", help="Timeout for source server in seconds (default: 600)")
parser.add_argument("--timeout2", help="Timeout for destination server in seconds (default: 600)")
parser.add_argument("--exclude", help="Regex pattern to exclude folders (default: (?i)spam|(?i)junk)")
parser.add_argument("--custom-parameters", help="Additional imapsync parameters")
parser.add_argument("--delete2duplicates", choices=["1", "0"], help="Delete duplicates on destination (1 = yes, 0 = no)")
parser.add_argument("--del1", choices=["1", "0"], help="Delete mails on source after sync (1 = yes, 0 = no)")
parser.add_argument("--del2", choices=["1", "0"], help="Delete mails on destination after sync (1 = yes, 0 = no)")
parser.add_argument("--automap", choices=["1", "0"], help="Enable folder automapping (1 = yes, 0 = no)")
parser.add_argument("--skipcrossduplicates", choices=["1", "0"], help="Skip cross-account duplicates (1 = yes, 0 = no)")
parser.add_argument("--subscribeall", choices=["1", "0"], help="Subscribe to all folders (1 = yes, 0 = no)")
parser.add_argument("--active", choices=["1", "0"], help="Activate syncjob (1 = yes, 0 = no)")
parser.add_argument("--force", action="store_true", help="Force the syncjob to run even if it is not active")

View File

@@ -0,0 +1,128 @@
import docker
from docker.errors import APIError
class Docker:
def __init__(self):
self.client = docker.from_env()
def exec_command(self, container_name, cmd, user=None):
"""
Execute a command in a container by its container name.
:param container_name: The name of the container.
:param cmd: The command to execute as a list (e.g., ["ls", "-la"]).
:param user: The user to execute the command as (optional).
:return: A standardized response with status, output, and exit_code.
"""
filters = {"name": container_name}
try:
for container in self.client.containers.list(filters=filters):
exec_result = container.exec_run(cmd, user=user)
return {
"status": "success",
"exit_code": exec_result.exit_code,
"output": exec_result.output.decode("utf-8")
}
except APIError as e:
return {
"status": "error",
"exit_code": "APIError",
"output": str(e)
}
except Exception as e:
return {
"status": "error",
"exit_code": "Exception",
"output": str(e)
}
def start_container(self, container_name):
"""
Start a container by its container name.
:param container_name: The name of the container.
:return: A standardized response with status, output, and exit_code.
"""
filters = {"name": container_name}
try:
for container in self.client.containers.list(filters=filters):
container.start()
return {
"status": "success",
"exit_code": "0",
"output": f"Container '{container_name}' started successfully."
}
except APIError as e:
return {
"status": "error",
"exit_code": "APIError",
"output": str(e)
}
except Exception as e:
return {
"status": "error",
"error_type": "Exception",
"output": str(e)
}
def stop_container(self, container_name):
"""
Stop a container by its container name.
:param container_name: The name of the container.
:return: A standardized response with status, output, and exit_code.
"""
filters = {"name": container_name}
try:
for container in self.client.containers.list(filters=filters):
container.stop()
return {
"status": "success",
"exit_code": "0",
"output": f"Container '{container_name}' stopped successfully."
}
except APIError as e:
return {
"status": "error",
"exit_code": "APIError",
"output": str(e)
}
except Exception as e:
return {
"status": "error",
"exit_code": "Exception",
"output": str(e)
}
def restart_container(self, container_name):
"""
Restart a container by its container name.
:param container_name: The name of the container.
:return: A standardized response with status, output, and exit_code.
"""
filters = {"name": container_name}
try:
for container in self.client.containers.list(filters=filters):
container.restart()
return {
"status": "success",
"exit_code": "0",
"output": f"Container '{container_name}' restarted successfully."
}
except APIError as e:
return {
"status": "error",
"exit_code": "APIError",
"output": str(e)
}
except Exception as e:
return {
"status": "error",
"exit_code": "Exception",
"output": str(e)
}

View File

@@ -0,0 +1,206 @@
import os
from modules.Docker import Docker
class Dovecot:
def __init__(self):
self.docker = Docker()
def decryptMaildir(self, source_dir="/var/vmail/", output_dir=None):
"""
Decrypt files in /var/vmail using doveadm if they are encrypted.
:param output_dir: Directory inside the Dovecot container to store decrypted files, Default overwrite.
"""
private_key = "/mail_crypt/ecprivkey.pem"
public_key = "/mail_crypt/ecpubkey.pem"
if output_dir:
# Ensure the output directory exists inside the container
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c 'mkdir -p {output_dir} && chown vmail:vmail {output_dir}'")
if mkdir_result.get("status") != "success":
print(f"Error creating output directory: {mkdir_result.get('output')}")
return
find_command = [
"find", source_dir, "-type", "f", "-regextype", "egrep", "-regex", ".*S=.*W=.*"
]
try:
find_result = self.docker.exec_command("dovecot-mailcow", " ".join(find_command))
if find_result.get("status") != "success":
print(f"Error finding files: {find_result.get('output')}")
return
files = find_result.get("output", "").splitlines()
for file in files:
head_command = f"head -c7 {file}"
head_result = self.docker.exec_command("dovecot-mailcow", head_command)
if head_result.get("status") == "success" and head_result.get("output", "").strip() == "CRYPTED":
if output_dir:
# Preserve the directory structure in the output directory
relative_path = os.path.relpath(file, source_dir)
output_file = os.path.join(output_dir, relative_path)
current_path = output_dir
for part in os.path.dirname(relative_path).split(os.sep):
current_path = os.path.join(current_path, part)
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c '[ ! -d {current_path} ] && mkdir {current_path} && chown vmail:vmail {current_path}'")
if mkdir_result.get("status") != "success":
print(f"Error creating directory {current_path}: {mkdir_result.get('output')}")
continue
else:
# Overwrite the original file
output_file = file
decrypt_command = (
f"bash -c 'doveadm fs get compress lz4:1:crypt:private_key_path={private_key}:public_key_path={public_key}:posix:prefix=/ {file} > {output_file}'"
)
decrypt_result = self.docker.exec_command("dovecot-mailcow", decrypt_command)
if decrypt_result.get("status") == "success":
print(f"Decrypted {file}")
# Verify the file size and set permissions
size_check_command = f"bash -c '[ -s {output_file} ] && chmod 600 {output_file} && chown vmail:vmail {output_file} || rm -f {output_file}'"
size_check_result = self.docker.exec_command("dovecot-mailcow", size_check_command)
if size_check_result.get("status") != "success":
print(f"Error setting permissions for {output_file}: {size_check_result.get('output')}\n")
except Exception as e:
print(f"Error during decryption: {e}")
return "Done"
def encryptMaildir(self, source_dir="/var/vmail/", output_dir=None):
"""
Encrypt files in /var/vmail using doveadm if they are not already encrypted.
:param source_dir: Directory inside the Dovecot container to encrypt files.
:param output_dir: Directory inside the Dovecot container to store encrypted files, Default overwrite.
"""
private_key = "/mail_crypt/ecprivkey.pem"
public_key = "/mail_crypt/ecpubkey.pem"
if output_dir:
# Ensure the output directory exists inside the container
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"mkdir -p {output_dir}")
if mkdir_result.get("status") != "success":
print(f"Error creating output directory: {mkdir_result.get('output')}")
return
find_command = [
"find", source_dir, "-type", "f", "-regextype", "egrep", "-regex", ".*S=.*W=.*"
]
try:
find_result = self.docker.exec_command("dovecot-mailcow", " ".join(find_command))
if find_result.get("status") != "success":
print(f"Error finding files: {find_result.get('output')}")
return
files = find_result.get("output", "").splitlines()
for file in files:
head_command = f"head -c7 {file}"
head_result = self.docker.exec_command("dovecot-mailcow", head_command)
if head_result.get("status") == "success" and head_result.get("output", "").strip() != "CRYPTED":
if output_dir:
# Preserve the directory structure in the output directory
relative_path = os.path.relpath(file, source_dir)
output_file = os.path.join(output_dir, relative_path)
current_path = output_dir
for part in os.path.dirname(relative_path).split(os.sep):
current_path = os.path.join(current_path, part)
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c '[ ! -d {current_path} ] && mkdir {current_path} && chown vmail:vmail {current_path}'")
if mkdir_result.get("status") != "success":
print(f"Error creating directory {current_path}: {mkdir_result.get('output')}")
continue
else:
# Overwrite the original file
output_file = file
encrypt_command = (
f"bash -c 'doveadm fs put crypt private_key_path={private_key}:public_key_path={public_key}:posix:prefix=/ {file} {output_file}'"
)
encrypt_result = self.docker.exec_command("dovecot-mailcow", encrypt_command)
if encrypt_result.get("status") == "success":
print(f"Encrypted {file}")
# Set permissions
permissions_command = f"bash -c 'chmod 600 {output_file} && chown 5000:5000 {output_file}'"
permissions_result = self.docker.exec_command("dovecot-mailcow", permissions_command)
if permissions_result.get("status") != "success":
print(f"Error setting permissions for {output_file}: {permissions_result.get('output')}\n")
except Exception as e:
print(f"Error during encryption: {e}")
return "Done"
def listDeletedMaildirs(self, source_dir="/var/vmail/_garbage"):
"""
List deleted maildirs in the specified garbage directory.
:param source_dir: Directory to search for deleted maildirs.
:return: List of maildirs.
"""
list_command = ["bash", "-c", f"ls -la {source_dir}"]
try:
result = self.docker.exec_command("dovecot-mailcow", list_command)
if result.get("status") != "success":
print(f"Error listing deleted maildirs: {result.get('output')}")
return []
lines = result.get("output", "").splitlines()
maildirs = {}
for idx, line in enumerate(lines):
parts = line.split()
if "_" in line:
folder_name = parts[-1]
time, maildir = folder_name.split("_", 1)
if maildir.endswith("_index"):
main_item = maildir[:-6]
if main_item in maildirs:
maildirs[main_item]["has_index"] = True
else:
maildirs[maildir] = {"item": idx, "time": time, "name": maildir, "has_index": False}
return list(maildirs.values())
except Exception as e:
print(f"Error during listing deleted maildirs: {e}")
return []
def restoreMaildir(self, username, item, source_dir="/var/vmail/_garbage"):
"""
Restore a maildir item for a specific user from the deleted maildirs.
:param username: Username to restore the item to.
:param item: Item to restore (e.g., mailbox, folder).
:param source_dir: Directory containing deleted maildirs.
:return: Response from Dovecot.
"""
username_splitted = username.split("@")
maildirs = self.listDeletedMaildirs()
maildir = None
for mdir in maildirs:
if mdir["item"] == int(item):
maildir = mdir
break
if not maildir:
return {"status": "error", "message": "Maildir not found."}
restore_command = f"mv {source_dir}/{maildir['time']}_{maildir['name']} /var/vmail/{username_splitted[1]}/{username_splitted[0]}"
restore_index_command = f"mv {source_dir}/{maildir['time']}_{maildir['name']}_index /var/vmail_index/{username}"
result = self.docker.exec_command("dovecot-mailcow", ["bash", "-c", restore_command])
if result.get("status") != "success":
return {"status": "error", "message": "Failed to restore maildir."}
result = self.docker.exec_command("dovecot-mailcow", ["bash", "-c", restore_index_command])
if result.get("status") != "success":
return {"status": "error", "message": "Failed to restore maildir index."}
return "Done"

View File

@@ -0,0 +1,457 @@
import requests
import urllib3
import sys
import os
import subprocess
import tempfile
import mysql.connector
from contextlib import contextmanager
from datetime import datetime
from modules.Docker import Docker
class Mailcow:
def __init__(self):
self.apiUrl = "/api/v1"
self.ignore_ssl_errors = True
self.baseUrl = f"https://{os.getenv('IPv4_NETWORK', '172.22.1')}.247:{os.getenv('HTTPS_PORT', '443')}"
self.host = os.getenv("MAILCOW_HOSTNAME", "")
self.apiKey = ""
if self.ignore_ssl_errors:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.db_config = {
'user': os.getenv('DBUSER'),
'password': os.getenv('DBPASS'),
'database': os.getenv('DBNAME'),
'unix_socket': '/var/run/mysqld/mysqld.sock',
}
self.docker = Docker()
# API Functions
def addDomain(self, domain):
"""
Add a domain to the mailcow instance.
:param domain: Dictionary containing domain details.
:return: Response from the mailcow API.
"""
return self.post('/add/domain', domain)
def addMailbox(self, mailbox):
"""
Add a mailbox to the mailcow instance.
:param mailbox: Dictionary containing mailbox details.
:return: Response from the mailcow API.
"""
return self.post('/add/mailbox', mailbox)
def addAlias(self, alias):
"""
Add an alias to the mailcow instance.
:param alias: Dictionary containing alias details.
:return: Response from the mailcow API.
"""
return self.post('/add/alias', alias)
def addSyncjob(self, syncjob):
"""
Add a sync job to the mailcow instance.
:param syncjob: Dictionary containing sync job details.
:return: Response from the mailcow API.
"""
return self.post('/add/syncjob', syncjob)
def addDomainadmin(self, domainadmin):
"""
Add a domain admin to the mailcow instance.
:param domainadmin: Dictionary containing domain admin details.
:return: Response from the mailcow API.
"""
return self.post('/add/domain-admin', domainadmin)
def deleteDomain(self, domain):
"""
Delete a domain from the mailcow instance.
:param domain: Name of the domain to delete.
:return: Response from the mailcow API.
"""
items = [domain]
return self.post('/delete/domain', items)
def deleteAlias(self, id):
"""
Delete an alias from the mailcow instance.
:param id: ID of the alias to delete.
:return: Response from the mailcow API.
"""
items = [id]
return self.post('/delete/alias', items)
def deleteSyncjob(self, id):
"""
Delete a sync job from the mailcow instance.
:param id: ID of the sync job to delete.
:return: Response from the mailcow API.
"""
items = [id]
return self.post('/delete/syncjob', items)
def deleteMailbox(self, mailbox):
"""
Delete a mailbox from the mailcow instance.
:param mailbox: Name of the mailbox to delete.
:return: Response from the mailcow API.
"""
items = [mailbox]
return self.post('/delete/mailbox', items)
def deleteDomainadmin(self, username):
"""
Delete a domain admin from the mailcow instance.
:param username: Username of the domain admin to delete.
:return: Response from the mailcow API.
"""
items = [username]
return self.post('/delete/domain-admin', items)
def post(self, endpoint, data):
"""
Make a POST request to the mailcow API.
:param endpoint: The API endpoint to post to.
:param data: Data to be sent in the POST request.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
headers = {
"Content-Type": "application/json",
"Host": self.host
}
if self.apiKey:
headers["X-Api-Key"] = self.apiKey
response = requests.post(
url,
json=data,
headers=headers,
verify=not self.ignore_ssl_errors
)
response.raise_for_status()
return response.json()
def getDomain(self, domain):
"""
Get a domain from the mailcow instance.
:param domain: Name of the domain to get.
:return: Response from the mailcow API.
"""
return self.get(f'/get/domain/{domain}')
def getMailbox(self, username):
"""
Get a mailbox from the mailcow instance.
:param mailbox: Dictionary containing mailbox details (e.g. {"username": "user@example.com"})
:return: Response from the mailcow API.
"""
return self.get(f'/get/mailbox/{username}')
def getAlias(self, id):
"""
Get an alias from the mailcow instance.
:param alias: Dictionary containing alias details (e.g. {"address": "alias@example.com"})
:return: Response from the mailcow API.
"""
return self.get(f'/get/alias/{id}')
def getSyncjob(self, id):
"""
Get a sync job from the mailcow instance.
:param syncjob: Dictionary containing sync job details (e.g. {"id": "123"})
:return: Response from the mailcow API.
"""
return self.get(f'/get/syncjobs/{id}')
def getDomainadmin(self, username):
"""
Get a domain admin from the mailcow instance.
:param username: Username of the domain admin to get.
:return: Response from the mailcow API.
"""
return self.get(f'/get/domain-admin/{username}')
def getStatusVersion(self):
"""
Get the version of the mailcow instance.
:return: Response from the mailcow API.
"""
return self.get('/get/status/version')
def getStatusVmail(self):
"""
Get the vmail status from the mailcow instance.
:return: Response from the mailcow API.
"""
return self.get('/get/status/vmail')
def getStatusContainers(self):
"""
Get the status of containers from the mailcow instance.
:return: Response from the mailcow API.
"""
return self.get('/get/status/containers')
def get(self, endpoint, params=None):
"""
Make a GET request to the mailcow API.
:param endpoint: The API endpoint to get from.
:param params: Parameters to be sent in the GET request.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
headers = {
"Content-Type": "application/json",
"Host": self.host
}
if self.apiKey:
headers["X-Api-Key"] = self.apiKey
response = requests.get(
url,
params=params,
headers=headers,
verify=not self.ignore_ssl_errors
)
response.raise_for_status()
return response.json()
def editDomain(self, domain, attributes):
"""
Edit an existing domain in the mailcow instance.
:param domain: Name of the domain to edit
:param attributes: Dictionary containing the new domain attributes.
"""
items = [domain]
return self.edit('/edit/domain', items, attributes)
def editMailbox(self, mailbox, attributes):
"""
Edit an existing mailbox in the mailcow instance.
:param mailbox: Name of the mailbox to edit
:param attributes: Dictionary containing the new mailbox attributes.
"""
items = [mailbox]
return self.edit('/edit/mailbox', items, attributes)
def editAlias(self, alias, attributes):
"""
Edit an existing alias in the mailcow instance.
:param alias: Name of the alias to edit
:param attributes: Dictionary containing the new alias attributes.
"""
items = [alias]
return self.edit('/edit/alias', items, attributes)
def editSyncjob(self, syncjob, attributes):
"""
Edit an existing sync job in the mailcow instance.
:param syncjob: Name of the sync job to edit
:param attributes: Dictionary containing the new sync job attributes.
"""
items = [syncjob]
return self.edit('/edit/syncjob', items, attributes)
def editDomainadmin(self, username, attributes):
"""
Edit an existing domain admin in the mailcow instance.
:param username: Username of the domain admin to edit
:param attributes: Dictionary containing the new domain admin attributes.
"""
items = [username]
return self.edit('/edit/domain-admin', items, attributes)
def edit(self, endpoint, items, attributes):
"""
Make a POST request to edit items in the mailcow API.
:param items: List of items to edit.
:param attributes: Dictionary containing the new attributes for the items.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
headers = {
"Content-Type": "application/json",
"Host": self.host
}
if self.apiKey:
headers["X-Api-Key"] = self.apiKey
data = {
"items": items,
"attr": attributes
}
response = requests.post(
url,
json=data,
headers=headers,
verify=not self.ignore_ssl_errors
)
response.raise_for_status()
return response.json()
# System Functions
def runSyncjob(self, id, force=False):
"""
Run a sync job.
:param id: ID of the sync job to run.
:return: Response from the imapsync script.
"""
creds_path = "/app/sieve.creds"
conn = mysql.connector.connect(**self.db_config)
cursor = conn.cursor(dictionary=True)
with open(creds_path, 'r') as file:
master_user, master_pass = file.read().strip().split(':')
query = ("SELECT * FROM imapsync WHERE id = %s")
cursor.execute(query, (id,))
success = False
syncjob = cursor.fetchone()
if not syncjob:
cursor.close()
conn.close()
return f"Sync job with ID {id} not found."
if syncjob['active'] == 0 and not force:
cursor.close()
conn.close()
return f"Sync job with ID {id} is not active."
enc1_flag = "--tls1" if syncjob['enc1'] == "TLS" else "--ssl1" if syncjob['enc1'] == "SSL" else None
passfile1_path = f"/tmp/passfile1_{id}.txt"
passfile2_path = f"/tmp/passfile2_{id}.txt"
passfile1_cmd = [
"sh", "-c",
f"echo {syncjob['password1']} > {passfile1_path}"
]
passfile2_cmd = [
"sh", "-c",
f"echo {master_pass} > {passfile2_path}"
]
self.docker.exec_command("dovecot-mailcow", passfile1_cmd)
self.docker.exec_command("dovecot-mailcow", passfile2_cmd)
imapsync_cmd = [
"/usr/local/bin/imapsync",
"--tmpdir", "/tmp",
"--nofoldersizes",
"--addheader"
]
if int(syncjob['timeout1']) > 0:
imapsync_cmd.extend(['--timeout1', str(syncjob['timeout1'])])
if int(syncjob['timeout2']) > 0:
imapsync_cmd.extend(['--timeout2', str(syncjob['timeout2'])])
if syncjob['exclude']:
imapsync_cmd.extend(['--exclude', syncjob['exclude']])
if syncjob['subfolder2']:
imapsync_cmd.extend(['--subfolder2', syncjob['subfolder2']])
if int(syncjob['maxage']) > 0:
imapsync_cmd.extend(['--maxage', str(syncjob['maxage'])])
if int(syncjob['maxbytespersecond']) > 0:
imapsync_cmd.extend(['--maxbytespersecond', str(syncjob['maxbytespersecond'])])
if int(syncjob['delete2duplicates']) == 1:
imapsync_cmd.append("--delete2duplicates")
if int(syncjob['subscribeall']) == 1:
imapsync_cmd.append("--subscribeall")
if int(syncjob['delete1']) == 1:
imapsync_cmd.append("--delete")
if int(syncjob['delete2']) == 1:
imapsync_cmd.append("--delete2")
if int(syncjob['automap']) == 1:
imapsync_cmd.append("--automap")
if int(syncjob['skipcrossduplicates']) == 1:
imapsync_cmd.append("--skipcrossduplicates")
if enc1_flag:
imapsync_cmd.append(enc1_flag)
imapsync_cmd.extend([
"--host1", syncjob['host1'],
"--user1", syncjob['user1'],
"--passfile1", passfile1_path,
"--port1", str(syncjob['port1']),
"--host2", "localhost",
"--user2", f"{syncjob['user2']}*{master_user}",
"--passfile2", passfile2_path
])
if syncjob['dry'] == 1:
imapsync_cmd.append("--dry")
imapsync_cmd.extend([
"--no-modulesversion",
"--noreleasecheck"
])
try:
cursor.execute("UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL WHERE id = %s", (id,))
conn.commit()
result = self.docker.exec_command("dovecot-mailcow", imapsync_cmd)
print(result)
success = result['status'] == "success" and result['exit_code'] == 0
cursor.execute(
"UPDATE imapsync SET returned_text = %s, success = %s, exit_status = %s WHERE id = %s",
(result['output'], int(success), result['exit_code'], id)
)
conn.commit()
except Exception as e:
cursor.execute(
"UPDATE imapsync SET returned_text = %s, success = 0 WHERE id = %s",
(str(e), id)
)
conn.commit()
finally:
cursor.execute("UPDATE imapsync SET last_run = NOW(), is_running = 0 WHERE id = %s", (id,))
conn.commit()
delete_passfile1_cmd = [
"sh", "-c",
f"rm -f {passfile1_path}"
]
delete_passfile2_cmd = [
"sh", "-c",
f"rm -f {passfile2_path}"
]
self.docker.exec_command("dovecot-mailcow", delete_passfile1_cmd)
self.docker.exec_command("dovecot-mailcow", delete_passfile2_cmd)
cursor.close()
conn.close()
return "Sync job completed successfully." if success else "Sync job failed."

View File

@@ -0,0 +1,64 @@
import smtplib
import json
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from jinja2 import Environment, BaseLoader
class Mailer:
def __init__(self, smtp_host, smtp_port, username, password, use_tls=True):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
self.use_tls = use_tls
self.server = None
self.env = Environment(loader=BaseLoader())
def connect(self):
print("Connecting to the SMTP server...")
self.server = smtplib.SMTP(self.smtp_host, self.smtp_port)
if self.use_tls:
self.server.starttls()
print("TLS activated!")
if self.username and self.password:
self.server.login(self.username, self.password)
print("Authenticated!")
def disconnect(self):
if self.server:
try:
if self.server.sock:
self.server.quit()
except smtplib.SMTPServerDisconnected:
pass
finally:
self.server = None
def render_inline_template(self, template_string, context):
template = self.env.from_string(template_string)
return template.render(context)
def send_mail(self, subject, from_addr, to_addrs, template, context = {}):
try:
if template == "":
print("Cannot send email, template is empty!")
return "Failed: Template is empty."
body = self.render_inline_template(template, context)
msg = MIMEMultipart()
msg['From'] = from_addr
msg['To'] = ', '.join(to_addrs) if isinstance(to_addrs, list) else to_addrs
msg['Subject'] = subject
msg.attach(MIMEText(body, 'html'))
self.connect()
self.server.sendmail(from_addr, to_addrs, msg.as_string())
self.disconnect()
return f"Success: Email sent to {msg['To']}"
except Exception as e:
print(f"Error during send_mail: {type(e).__name__}: {e}")
return f"Failed: {type(e).__name__}: {e}"
finally:
self.disconnect()

View File

@@ -0,0 +1,51 @@
from jinja2 import Environment, Template
import csv
def split_at(value, sep, idx):
try:
return value.split(sep)[idx]
except Exception:
return ''
class Reader:
"""
Reader class to handle reading and processing of CSV and JSON files for mailcow.
"""
def __init__(self):
pass
def read_csv(self, file_path, delimiter=',', encoding='iso-8859-1'):
"""
Read a CSV file and return a list of dictionaries.
Each dictionary represents a row in the CSV file.
:param file_path: Path to the CSV file.
:param delimiter: Delimiter used in the CSV file (default: ',').
"""
with open(file_path, mode='r', encoding=encoding) as file:
reader = csv.DictReader(file, delimiter=delimiter)
reader.fieldnames = [h.replace(" ", "_") if h else h for h in reader.fieldnames]
return [row for row in reader]
def map_csv_data(self, data, mapping_file_path, encoding='iso-8859-1'):
"""
Map CSV data to a specific structure based on the provided Jinja2 template file.
:param data: List of dictionaries representing CSV rows.
:param mapping_file_path: Path to the Jinja2 template file.
:return: List of dictionaries with mapped data.
"""
with open(mapping_file_path, 'r', encoding=encoding) as tpl_file:
template_content = tpl_file.read()
env = Environment()
env.filters['split_at'] = split_at
template = env.from_string(template_content)
mapped_data = []
for row in data:
rendered = template.render(**row)
try:
mapped_row = eval(rendered)
except Exception:
mapped_row = rendered
mapped_data.append(mapped_row)
return mapped_data

View File

@@ -0,0 +1,512 @@
import requests
import urllib3
import os
from uuid import uuid4
from collections import defaultdict
class Sogo:
def __init__(self, username, password=""):
self.apiUrl = "/SOGo/so"
self.davUrl = "/SOGo/dav"
self.ignore_ssl_errors = True
self.baseUrl = f"https://{os.getenv('IPv4_NETWORK', '172.22.1')}.247:{os.getenv('HTTPS_PORT', '443')}"
self.host = os.getenv("MAILCOW_HOSTNAME", "")
if self.ignore_ssl_errors:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.username = username
self.password = password
def addCalendar(self, calendar_name):
"""
Add a new calendar to the sogo instance.
:param calendar_name: Name of the calendar to be created
:return: Response from the sogo API.
"""
res = self.post(f"/{self.username}/Calendar/createFolder", {
"name": calendar_name
})
try:
return res.json()
except ValueError:
return res.text
def getCalendarIdByName(self, calendar_name):
"""
Get the calendar ID by its name.
:param calendar_name: Name of the calendar to find
:return: Calendar ID if found, otherwise None.
"""
res = self.get(f"/{self.username}/Calendar/calendarslist")
try:
for calendar in res.json()["calendars"]:
if calendar['name'] == calendar_name:
return calendar['id']
except ValueError:
return None
return None
def getCalendar(self):
"""
Get calendar list.
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Calendar/calendarslist")
try:
return res.json()
except ValueError:
return res.text
def deleteCalendar(self, calendar_id):
"""
Delete a calendar.
:param calendar_id: ID of the calendar to be deleted
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Calendar/{calendar_id}/delete")
return res.status_code == 204
def importCalendar(self, calendar_name, ics_file):
"""
Import a calendar from an ICS file.
:param calendar_name: Name of the calendar to import into
:param ics_file: Path to the ICS file to import
:return: Response from SOGo API.
"""
try:
with open(ics_file, "rb") as f:
pass
except Exception as e:
print(f"Could not open ICS file '{ics_file}': {e}")
return {"status": "error", "message": str(e)}
new_calendar = self.addCalendar(calendar_name)
selected_calendar = new_calendar.json()["id"]
url = f"{self.baseUrl}{self.apiUrl}/{self.username}/Calendar/{selected_calendar}/import"
auth = (self.username, self.password)
with open(ics_file, "rb") as f:
files = {'icsFile': (ics_file, f, 'text/calendar')}
res = requests.post(
url,
files=files,
auth=auth,
verify=not self.ignore_ssl_errors
)
try:
return res.json()
except ValueError:
return res.text
return None
def setCalendarACL(self, calendar_id, sharee_email, acl="r", subscribe=False):
"""
Set CalDAV calendar permissions for a user (sharee).
:param calendar_id: ID of the calendar to share
:param sharee_email: Email of the user to share with
:param acl: "w" for write, "r" for read-only or combination "rw" for read-write
:param subscribe: True will scubscribe the sharee to the calendar
:return: None
"""
# Access rights
if acl == "" or len(acl) > 2:
return "Invalid acl level specified. Use 'w', 'r' or combinations like 'rw'."
rights = [{
"c_email": sharee_email,
"uid": sharee_email,
"userClass": "normal-user",
"rights": {
"Public": "None",
"Private": "None",
"Confidential": "None",
"canCreateObjects": 0,
"canEraseObjects": 0
}
}]
if "w" in acl:
rights[0]["rights"]["canCreateObjects"] = 1
rights[0]["rights"]["canEraseObjects"] = 1
if "r" in acl:
rights[0]["rights"]["Public"] = "Viewer"
rights[0]["rights"]["Private"] = "Viewer"
rights[0]["rights"]["Confidential"] = "Viewer"
r_add = self.get(f"/{self.username}/Calendar/{calendar_id}/addUserInAcls?uid={sharee_email}")
if r_add.status_code < 200 or r_add.status_code > 299:
try:
return r_add.json()
except ValueError:
return r_add.text
r_save = self.post(f"/{self.username}/Calendar/{calendar_id}/saveUserRights", rights)
if r_save.status_code < 200 or r_save.status_code > 299:
try:
return r_save.json()
except ValueError:
return r_save.text
if subscribe:
r_subscribe = self.get(f"/{self.username}/Calendar/{calendar_id}/subscribeUsers?uids={sharee_email}")
if r_subscribe.status_code < 200 or r_subscribe.status_code > 299:
try:
return r_subscribe.json()
except ValueError:
return r_subscribe.text
return r_save.status_code == 200
def getCalendarACL(self, calendar_id):
"""
Get CalDAV calendar permissions for a user (sharee).
:param calendar_id: ID of the calendar to get ACL from
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Calendar/{calendar_id}/acls")
try:
return res.json()
except ValueError:
return res.text
def deleteCalendarACL(self, calendar_id, sharee_email):
"""
Delete a calendar ACL for a user (sharee).
:param calendar_id: ID of the calendar to delete ACL from
:param sharee_email: Email of the user whose ACL to delete
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Calendar/{calendar_id}/removeUserFromAcls?uid={sharee_email}")
return res.status_code == 204
def addAddressbook(self, addressbook_name):
"""
Add a new addressbook to the sogo instance.
:param addressbook_name: Name of the addressbook to be created
:return: Response from the sogo API.
"""
res = self.post(f"/{self.username}/Contacts/createFolder", {
"name": addressbook_name
})
try:
return res.json()
except ValueError:
return res.text
def getAddressbookIdByName(self, addressbook_name):
"""
Get the addressbook ID by its name.
:param addressbook_name: Name of the addressbook to find
:return: Addressbook ID if found, otherwise None.
"""
res = self.get(f"/{self.username}/Contacts/addressbooksList")
try:
for addressbook in res.json()["addressbooks"]:
if addressbook['name'] == addressbook_name:
return addressbook['id']
except ValueError:
return None
return None
def deleteAddressbook(self, addressbook_id):
"""
Delete an addressbook.
:param addressbook_id: ID of the addressbook to be deleted
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/delete")
return res.status_code == 204
def getAddressbookList(self):
"""
Get addressbook list.
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Contacts/addressbooksList")
try:
return res.json()
except ValueError:
return res.text
def setAddressbookACL(self, addressbook_id, sharee_email, acl="r", subscribe=False):
"""
Set CalDAV addressbook permissions for a user (sharee).
:param addressbook_id: ID of the addressbook to share
:param sharee_email: Email of the user to share with
:param acl: "w" for write, "r" for read-only or combination "rw" for read-write
:param subscribe: True will subscribe the sharee to the addressbook
:return: None
"""
# Access rights
if acl == "" or len(acl) > 2:
print("Invalid acl level specified. Use 's', 'w', 'r' or combinations like 'rws'.")
return "Invalid acl level specified. Use 'w', 'r' or combinations like 'rw'."
rights = [{
"c_email": sharee_email,
"uid": sharee_email,
"userClass": "normal-user",
"rights": {
"canCreateObjects": 0,
"canEditObjects": 0,
"canEraseObjects": 0,
"canViewObjects": 0,
}
}]
if "w" in acl:
rights[0]["rights"]["canCreateObjects"] = 1
rights[0]["rights"]["canEditObjects"] = 1
rights[0]["rights"]["canEraseObjects"] = 1
if "r" in acl:
rights[0]["rights"]["canViewObjects"] = 1
r_add = self.get(f"/{self.username}/Contacts/{addressbook_id}/addUserInAcls?uid={sharee_email}")
if r_add.status_code < 200 or r_add.status_code > 299:
try:
return r_add.json()
except ValueError:
return r_add.text
r_save = self.post(f"/{self.username}/Contacts/{addressbook_id}/saveUserRights", rights)
if r_save.status_code < 200 or r_save.status_code > 299:
try:
return r_save.json()
except ValueError:
return r_save.text
if subscribe:
r_subscribe = self.get(f"/{self.username}/Contacts/{addressbook_id}/subscribeUsers?uids={sharee_email}")
if r_subscribe.status_code < 200 or r_subscribe.status_code > 299:
try:
return r_subscribe.json()
except ValueError:
return r_subscribe.text
return r_save.status_code == 200
def getAddressbookACL(self, addressbook_id):
"""
Get CalDAV addressbook permissions for a user (sharee).
:param addressbook_id: ID of the addressbook to get ACL from
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/acls")
try:
return res.json()
except ValueError:
return res.text
def deleteAddressbookACL(self, addressbook_id, sharee_email):
"""
Delete an addressbook ACL for a user (sharee).
:param addressbook_id: ID of the addressbook to delete ACL from
:param sharee_email: Email of the user whose ACL to delete
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/removeUserFromAcls?uid={sharee_email}")
return res.status_code == 204
def getAddressbookNewGuid(self, addressbook_id):
"""
Request a new GUID for a SOGo addressbook.
:param addressbook_id: ID of the addressbook
:return: JSON response from SOGo or None if not found
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/newguid")
try:
return res.json()
except ValueError:
return res.text
def addAddressbookContact(self, addressbook_id, contact_name, contact_email):
"""
Save a vCard as a contact in the specified addressbook.
:param addressbook_id: ID of the addressbook
:param contact_name: Name of the contact
:param contact_email: Email of the contact
:return: JSON response from SOGo or None if not found
"""
vcard_id = self.getAddressbookNewGuid(addressbook_id)
contact_data = {
"id": vcard_id["id"],
"pid": vcard_id["pid"],
"c_cn": contact_name,
"emails": [{
"type": "pref",
"value": contact_email
}],
"isNew": True,
"c_component": "vcard",
}
endpoint = f"/{self.username}/Contacts/{addressbook_id}/{vcard_id['id']}/saveAsContact"
res = self.post(endpoint, contact_data)
try:
return res.json()
except ValueError:
return res.text
def getAddressbookContacts(self, addressbook_id, contact_email=None):
"""
Get all contacts from the specified addressbook.
:param addressbook_id: ID of the addressbook
:return: JSON response with contacts or None if not found
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/view")
try:
res_json = res.json()
headers = res_json.get("headers", [])
if not headers or len(headers) < 2:
return []
field_names = headers[0]
contacts = []
for row in headers[1:]:
contact = dict(zip(field_names, row))
contacts.append(contact)
if contact_email:
contact = {}
for c in contacts:
if c["c_mail"] == contact_email or c["c_cn"] == contact_email:
contact = c
break
return contact
return contacts
except ValueError:
return res.text
def addAddressbookContactList(self, addressbook_id, contact_name, contact_email=None):
"""
Add a new contact list to the addressbook.
:param addressbook_id: ID of the addressbook
:param contact_name: Name of the contact list
:param contact_email: Comma-separated emails to include in the list
:return: Response from SOGo API.
"""
gal_domain = self.username.split("@")[-1]
vlist_id = self.getAddressbookNewGuid(addressbook_id)
contact_emails = contact_email.split(",") if contact_email else []
contacts = self.getAddressbookContacts(addressbook_id)
refs = []
for contact in contacts:
if contact['c_mail'] in contact_emails:
refs.append({
"refs": [],
"categories": [],
"c_screenname": contact.get("c_screenname", ""),
"pid": contact.get("pid", vlist_id["pid"]),
"id": contact.get("id", ""),
"notes": [""],
"empty": " ",
"hasphoto": contact.get("hasphoto", 0),
"c_cn": contact.get("c_cn", ""),
"c_uid": contact.get("c_uid", None),
"containername": contact.get("containername", f"GAL {gal_domain}"), # or your addressbook name
"sourceid": contact.get("sourceid", gal_domain),
"c_component": contact.get("c_component", "vcard"),
"c_sn": contact.get("c_sn", ""),
"c_givenname": contact.get("c_givenname", ""),
"c_name": contact.get("c_name", contact.get("id", "")),
"c_telephonenumber": contact.get("c_telephonenumber", ""),
"fn": contact.get("fn", ""),
"c_mail": contact.get("c_mail", ""),
"emails": contact.get("emails", []),
"c_o": contact.get("c_o", ""),
"reference": contact.get("id", ""),
"birthday": contact.get("birthday", "")
})
contact_data = {
"refs": refs,
"categories": [],
"c_screenname": None,
"pid": vlist_id["pid"],
"c_component": "vlist",
"notes": [""],
"empty": " ",
"isNew": True,
"id": vlist_id["id"],
"c_cn": contact_name,
"birthday": ""
}
endpoint = f"/{self.username}/Contacts/{addressbook_id}/{vlist_id['id']}/saveAsList"
res = self.post(endpoint, contact_data)
try:
return res.json()
except ValueError:
return res.text
def deleteAddressbookItem(self, addressbook_id, contact_name):
"""
Delete an addressbook item by its ID.
:param addressbook_id: ID of the addressbook item to delete
:param contact_name: Name of the contact to delete
:return: Response from SOGo API.
"""
res = self.getAddressbookContacts(addressbook_id, contact_name)
if "id" not in res:
print(f"Contact '{contact_name}' not found in addressbook '{addressbook_id}'.")
return None
res = self.post(f"/{self.username}/Contacts/{addressbook_id}/batchDelete", {
"uids": [res["id"]],
})
return res.status_code == 204
def get(self, endpoint, params=None):
"""
Make a GET request to the mailcow API.
:param endpoint: The API endpoint to get.
:param params: Optional parameters for the GET request.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}{endpoint}"
auth = (self.username, self.password)
headers = {"Host": self.host}
response = requests.get(
url,
params=params,
auth=auth,
headers=headers,
verify=not self.ignore_ssl_errors
)
return response
def post(self, endpoint, data):
"""
Make a POST request to the mailcow API.
:param endpoint: The API endpoint to post to.
:param data: Data to be sent in the POST request.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}{endpoint}"
auth = (self.username, self.password)
headers = {"Host": self.host}
response = requests.post(
url,
json=data,
auth=auth,
headers=headers,
verify=not self.ignore_ssl_errors
)
return response

View File

@@ -0,0 +1,37 @@
import json
import random
import string
class Utils:
def __init(self):
pass
def normalize_email(self, email):
replacements = {
"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss",
"Ä": "Ae", "Ö": "Oe", "Ü": "Ue"
}
for orig, repl in replacements.items():
email = email.replace(orig, repl)
return email
def generate_password(self, length=8):
chars = string.ascii_letters + string.digits
return ''.join(random.choices(chars, k=length))
def pprint(self, data=""):
"""
Pretty print a dictionary, list, or text.
If data is a text containing JSON, it will be printed in a formatted way.
"""
if isinstance(data, (dict, list)):
print(json.dumps(data, indent=2, ensure_ascii=False))
elif isinstance(data, str):
try:
json_data = json.loads(data)
print(json.dumps(json_data, indent=2, ensure_ascii=False))
except json.JSONDecodeError:
print(data)
else:
print(data)

View File

@@ -0,0 +1,4 @@
jinja2
requests
mysql-connector-python
pytest

View File

@@ -0,0 +1,94 @@
import pytest
import json
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
from models.DomainModel import DomainModel
from models.AliasModel import AliasModel
def test_model():
# Generate random alias
random_alias = f"alias_test{os.urandom(4).hex()}@mailcow.local"
# Create an instance of AliasModel
model = AliasModel(
address=random_alias,
goto="test@mailcow.local,test2@mailcow.local"
)
# Test the parser_command attribute
assert model.parser_command == "alias", "Parser command should be 'alias'"
# add Domain for testing
domain_model = DomainModel(domain="mailcow.local")
domain_model.add()
# 1. Alias add tests, should success
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[0]['type'] == "success", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 3, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[0]['msg'][0] == "alias_added", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'alias_added'\n{json.dumps(r_add, indent=2)}"
# Assign created alias ID for further tests
model.id = r_add[0]['msg'][2]
# 2. Alias add tests, should fail because the alias already exists
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[0]['msg'][0] == "is_alias_or_mailbox", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'is_alias_or_mailbox'\n{json.dumps(r_add, indent=2)}"
# 3. Alias get tests
r_get = model.get()
assert isinstance(r_get, dict), f"Expected a dict but received: {json.dumps(r_get, indent=2)}"
assert "domain" in r_get, f"'domain' key missing in response: {json.dumps(r_get, indent=2)}"
assert "goto" in r_get, f"'goto' key missing in response: {json.dumps(r_get, indent=2)}"
assert "address" in r_get, f"'address' key missing in response: {json.dumps(r_get, indent=2)}"
assert r_get['domain'] == model.address.split("@")[1], f"Wrong 'domain' received: {r_get['domain']}, expected: {model.address.split('@')[1]}\n{json.dumps(r_get, indent=2)}"
assert r_get['goto'] == model.goto, f"Wrong 'goto' received: {r_get['goto']}, expected: {model.goto}\n{json.dumps(r_get, indent=2)}"
assert r_get['address'] == model.address, f"Wrong 'address' received: {r_get['address']}, expected: {model.address}\n{json.dumps(r_get, indent=2)}"
# 4. Alias edit tests
model.goto = "test@mailcow.local"
model.active = 0
r_edit = model.edit()
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['msg'][0] == "alias_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'alias_modified'\n{json.dumps(r_edit, indent=2)}"
# 5. Alias delete tests
r_delete = model.delete()
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['msg'][0] == "alias_removed", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'alias_removed'\n{json.dumps(r_delete, indent=2)}"
# delete testing Domain
domain_model.delete()
if __name__ == "__main__":
print("Running AliasModel tests...")
test_model()
print("All tests passed!")

View File

@@ -0,0 +1,71 @@
import pytest
from models.BaseModel import BaseModel
class Args:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
def test_has_required_args():
BaseModel.required_args = {
"test_object": [["arg1"], ["arg2", "arg3"]],
}
# Test cases with Args object
args = Args(object="non_existent_object")
assert BaseModel.has_required_args(args) == False
args = Args(object="test_object")
assert BaseModel.has_required_args(args) == False
args = Args(object="test_object", arg1="value")
assert BaseModel.has_required_args(args) == True
args = Args(object="test_object", arg2="value")
assert BaseModel.has_required_args(args) == False
args = Args(object="test_object", arg3="value")
assert BaseModel.has_required_args(args) == False
args = Args(object="test_object", arg2="value", arg3="value")
assert BaseModel.has_required_args(args) == True
# Test cases with dict object
args = {"object": "non_existent_object"}
assert BaseModel.has_required_args(args) == False
args = {"object": "test_object"}
assert BaseModel.has_required_args(args) == False
args = {"object": "test_object", "arg1": "value"}
assert BaseModel.has_required_args(args) == True
args = {"object": "test_object", "arg2": "value"}
assert BaseModel.has_required_args(args) == False
args = {"object": "test_object", "arg3": "value"}
assert BaseModel.has_required_args(args) == False
args = {"object": "test_object", "arg2": "value", "arg3": "value"}
assert BaseModel.has_required_args(args) == True
BaseModel.required_args = {
"test_object": [[]],
}
# Test cases with Args object
args = Args(object="non_existent_object")
assert BaseModel.has_required_args(args) == False
args = Args(object="test_object")
assert BaseModel.has_required_args(args) == True
# Test cases with dict object
args = {"object": "non_existent_object"}
assert BaseModel.has_required_args(args) == False
args = {"object": "test_object"}
assert BaseModel.has_required_args(args) == True

View File

@@ -0,0 +1,74 @@
import pytest
import json
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
from models.DomainModel import DomainModel
def test_model():
# Create an instance of DomainModel
model = DomainModel(
domain="mailcow.local",
)
# Test the parser_command attribute
assert model.parser_command == "domain", "Parser command should be 'domain'"
# 1. Domain add tests, should success
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0 and len(r_add) >= 2, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[1], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[1]['type'] == "success", f"Wrong 'type' received: {r_add[1]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[1], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[1]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[1]['msg']) > 0 and len(r_add[1]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[1]['msg'][0] == "domain_added", f"Wrong 'msg' received: {r_add[1]['msg'][0]}, expected: 'domain_added'\n{json.dumps(r_add, indent=2)}"
# 2. Domain add tests, should fail because the domain already exists
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[0]['msg'][0] == "domain_exists", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'domain_exists'\n{json.dumps(r_add, indent=2)}"
# 3. Domain get tests
r_get = model.get()
assert isinstance(r_get, dict), f"Expected a dict but received: {json.dumps(r_get, indent=2)}"
assert "domain_name" in r_get, f"'domain_name' key missing in response: {json.dumps(r_get, indent=2)}"
assert r_get['domain_name'] == model.domain, f"Wrong 'domain_name' received: {r_get['domain_name']}, expected: {model.domain}\n{json.dumps(r_get, indent=2)}"
# 4. Domain edit tests
model.active = 0
r_edit = model.edit()
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['msg'][0] == "domain_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'domain_modified'\n{json.dumps(r_edit, indent=2)}"
# 5. Domain delete tests
r_delete = model.delete()
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['msg'][0] == "domain_removed", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'domain_removed'\n{json.dumps(r_delete, indent=2)}"
if __name__ == "__main__":
print("Running DomainModel tests...")
test_model()
print("All tests passed!")

View File

@@ -0,0 +1,89 @@
import pytest
import json
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
from models.DomainModel import DomainModel
from models.DomainadminModel import DomainadminModel
def test_model():
# Generate random domainadmin
random_username = f"dadmin_test{os.urandom(4).hex()}"
random_password = f"{os.urandom(4).hex()}"
# Create an instance of DomainadminModel
model = DomainadminModel(
username=random_username,
password=random_password,
domains="mailcow.local",
)
# Test the parser_command attribute
assert model.parser_command == "domainadmin", "Parser command should be 'domainadmin'"
# add Domain for testing
domain_model = DomainModel(domain="mailcow.local")
domain_model.add()
# 1. Domainadmin add tests, should success
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[0]['type'] == "success", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 3, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[0]['msg'][0] == "domain_admin_added", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'domain_admin_added'\n{json.dumps(r_add, indent=2)}"
# 2. Domainadmin add tests, should fail because the domainadmin already exists
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[0]['msg'][0] == "object_exists", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'object_exists'\n{json.dumps(r_add, indent=2)}"
# 3. Domainadmin get tests
r_get = model.get()
assert isinstance(r_get, dict), f"Expected a dict but received: {json.dumps(r_get, indent=2)}"
assert "selected_domains" in r_get, f"'selected_domains' key missing in response: {json.dumps(r_get, indent=2)}"
assert "username" in r_get, f"'username' key missing in response: {json.dumps(r_get, indent=2)}"
assert set(model.domains.replace(" ", "").split(",")) == set(r_get['selected_domains']), f"Wrong 'selected_domains' received: {r_get['selected_domains']}, expected: {model.domains}\n{json.dumps(r_get, indent=2)}"
assert r_get['username'] == model.username, f"Wrong 'username' received: {r_get['username']}, expected: {model.username}\n{json.dumps(r_get, indent=2)}"
# 4. Domainadmin edit tests
model.active = 0
r_edit = model.edit()
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['msg'][0] == "domain_admin_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'domain_admin_modified'\n{json.dumps(r_edit, indent=2)}"
# 5. Domainadmin delete tests
r_delete = model.delete()
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['msg'][0] == "domain_admin_removed", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'domain_admin_removed'\n{json.dumps(r_delete, indent=2)}"
# delete testing Domain
domain_model.delete()
if __name__ == "__main__":
print("Running DomainadminModel tests...")
test_model()
print("All tests passed!")

View File

@@ -0,0 +1,89 @@
import pytest
import json
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
from models.DomainModel import DomainModel
from models.MailboxModel import MailboxModel
def test_model():
# Generate random mailbox
random_username = f"mbox_test{os.urandom(4).hex()}@mailcow.local"
random_password = f"{os.urandom(4).hex()}"
# Create an instance of MailboxModel
model = MailboxModel(
username=random_username,
password=random_password
)
# Test the parser_command attribute
assert model.parser_command == "mailbox", "Parser command should be 'mailbox'"
# add Domain for testing
domain_model = DomainModel(domain="mailcow.local")
domain_model.add()
# 1. Mailbox add tests, should success
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0 and len(r_add) <= 2, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[1], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[1]['type'] == "success", f"Wrong 'type' received: {r_add[1]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[1], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[1]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[1]['msg']) > 0 and len(r_add[1]['msg']) <= 3, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[1]['msg'][0] == "mailbox_added", f"Wrong 'msg' received: {r_add[1]['msg'][0]}, expected: 'mailbox_added'\n{json.dumps(r_add, indent=2)}"
# 2. Mailbox add tests, should fail because the mailbox already exists
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[0]['msg'][0] == "object_exists", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'object_exists'\n{json.dumps(r_add, indent=2)}"
# 3. Mailbox get tests
r_get = model.get()
assert isinstance(r_get, dict), f"Expected a dict but received: {json.dumps(r_get, indent=2)}"
assert "domain" in r_get, f"'domain' key missing in response: {json.dumps(r_get, indent=2)}"
assert "local_part" in r_get, f"'local_part' key missing in response: {json.dumps(r_get, indent=2)}"
assert r_get['domain'] == model.domain, f"Wrong 'domain' received: {r_get['domain']}, expected: {model.domain}\n{json.dumps(r_get, indent=2)}"
assert r_get['local_part'] == model.local_part, f"Wrong 'local_part' received: {r_get['local_part']}, expected: {model.local_part}\n{json.dumps(r_get, indent=2)}"
# 4. Mailbox edit tests
model.active = 0
r_edit = model.edit()
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['msg'][0] == "mailbox_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'mailbox_modified'\n{json.dumps(r_edit, indent=2)}"
# 5. Mailbox delete tests
r_delete = model.delete()
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['msg'][0] == "mailbox_removed", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'mailbox_removed'\n{json.dumps(r_delete, indent=2)}"
# delete testing Domain
domain_model.delete()
if __name__ == "__main__":
print("Running MailboxModel tests...")
test_model()
print("All tests passed!")

View File

@@ -0,0 +1,39 @@
import pytest
import json
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
from models.StatusModel import StatusModel
def test_model():
# Create an instance of StatusModel
model = StatusModel()
# Test the parser_command attribute
assert model.parser_command == "status", "Parser command should be 'status'"
# 1. Status version tests
r_version = model.version()
assert isinstance(r_version, dict), f"Expected a dict but received: {json.dumps(r_version, indent=2)}"
assert "version" in r_version, f"'version' key missing in response: {json.dumps(r_version, indent=2)}"
# 2. Status vmail tests
r_vmail = model.vmail()
assert isinstance(r_vmail, dict), f"Expected a dict but received: {json.dumps(r_vmail, indent=2)}"
assert "type" in r_vmail, f"'type' key missing in response: {json.dumps(r_vmail, indent=2)}"
assert "disk" in r_vmail, f"'disk' key missing in response: {json.dumps(r_vmail, indent=2)}"
assert "used" in r_vmail, f"'used' key missing in response: {json.dumps(r_vmail, indent=2)}"
assert "total" in r_vmail, f"'total' key missing in response: {json.dumps(r_vmail, indent=2)}"
assert "used_percent" in r_vmail, f"'used_percent' key missing in response: {json.dumps(r_vmail, indent=2)}"
# 3. Status containers tests
r_containers = model.containers()
assert isinstance(r_containers, dict), f"Expected a dict but received: {json.dumps(r_containers, indent=2)}"
if __name__ == "__main__":
print("Running StatusModel tests...")
test_model()
print("All tests passed!")

View File

@@ -0,0 +1,106 @@
import pytest
import json
import sys
import os
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
from models.DomainModel import DomainModel
from models.MailboxModel import MailboxModel
from models.SyncjobModel import SyncjobModel
def test_model():
# Generate random Mailbox
random_username = f"mbox_test@mailcow.local"
random_password = f"{os.urandom(4).hex()}"
# Create an instance of SyncjobModel
model = SyncjobModel(
username=random_username,
host1="mailcow.local",
port1=993,
user1="testuser@mailcow.local",
password1="testpassword",
enc1="SSL",
)
# Test the parser_command attribute
assert model.parser_command == "syncjob", "Parser command should be 'syncjob'"
# add Domain and Mailbox for testing
domain_model = DomainModel(domain="mailcow.local")
domain_model.add()
mbox_model = MailboxModel(username=random_username, password=random_password)
mbox_model.add()
# 1. Syncjob add tests, should success
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0 and len(r_add) <= 2, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[0]['type'] == "success", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 3, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[0]['msg'][0] == "mailbox_modified", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'mailbox_modified'\n{json.dumps(r_add, indent=2)}"
# Assign created syncjob ID for further tests
model.id = r_add[0]['msg'][2]
# 2. Syncjob add tests, should fail because the syncjob already exists
r_add = model.add()
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
assert r_add[0]['msg'][0] == "object_exists", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'object_exists'\n{json.dumps(r_add, indent=2)}"
# 3. Syncjob get tests
r_get = model.get()
assert isinstance(r_get, list), f"Expected a list but received: {json.dumps(r_get, indent=2)}"
assert "user2" in r_get[0], f"'user2' key missing in response: {json.dumps(r_get, indent=2)}"
assert "host1" in r_get[0], f"'host1' key missing in response: {json.dumps(r_get, indent=2)}"
assert "port1" in r_get[0], f"'port1' key missing in response: {json.dumps(r_get, indent=2)}"
assert "user1" in r_get[0], f"'user1' key missing in response: {json.dumps(r_get, indent=2)}"
assert "enc1" in r_get[0], f"'enc1' key missing in response: {json.dumps(r_get, indent=2)}"
assert r_get[0]['user2'] == model.username, f"Wrong 'user2' received: {r_get[0]['user2']}, expected: {model.username}\n{json.dumps(r_get, indent=2)}"
assert r_get[0]['host1'] == model.host1, f"Wrong 'host1' received: {r_get[0]['host1']}, expected: {model.host1}\n{json.dumps(r_get, indent=2)}"
assert r_get[0]['port1'] == model.port1, f"Wrong 'port1' received: {r_get[0]['port1']}, expected: {model.port1}\n{json.dumps(r_get, indent=2)}"
assert r_get[0]['user1'] == model.user1, f"Wrong 'user1' received: {r_get[0]['user1']}, expected: {model.user1}\n{json.dumps(r_get, indent=2)}"
assert r_get[0]['enc1'] == model.enc1, f"Wrong 'enc1' received: {r_get[0]['enc1']}, expected: {model.enc1}\n{json.dumps(r_get, indent=2)}"
# 4. Syncjob edit tests
model.active = 1
r_edit = model.edit()
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
assert r_edit[0]['msg'][0] == "mailbox_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'mailbox_modified'\n{json.dumps(r_edit, indent=2)}"
# 5. Syncjob delete tests
r_delete = model.delete()
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
assert r_delete[0]['msg'][0] == "deleted_syncjob", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'deleted_syncjob'\n{json.dumps(r_delete, indent=2)}"
# delete testing Domain and Mailbox
mbox_model.delete()
domain_model.delete()
if __name__ == "__main__":
print("Running SyncjobModel tests...")
test_model()
print("All tests passed!")

View File

@@ -0,0 +1,8 @@
#!/bin/bash
printf "READY\n";
while read line; do
echo "Processing Event: $line" >&2;
kill -3 $(cat "/var/run/supervisord.pid")
done < /dev/stdin

View File

@@ -0,0 +1,17 @@
[supervisord]
nodaemon=true
user=root
pidfile=/var/run/supervisord.pid
[program:api]
command=python /app/api/main.py
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stderr_logfile=/dev/stderr
stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
[eventlistener:processes]
command=/usr/local/sbin/stop-supervisor.sh
events=PROCESS_STATE_STOPPED, PROCESS_STATE_EXITED, PROCESS_STATE_FATAL

View File

@@ -1,9 +0,0 @@
#!/bin/bash
`openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout /app/dockerapi_key.pem \
-out /app/dockerapi_cert.pem \
-subj /CN=dockerapi/O=mailcow \
-addext subjectAltName=DNS:dockerapi`
exec "$@"

View File

@@ -1,9 +1,9 @@
FROM alpine:3.22
FROM alpine:3.21
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
ARG GOSU_VERSION=1.17
ARG GOSU_VERSION=1.19
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8

View File

@@ -44,109 +44,90 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
else
QUOTA_TABLE=quota2replica
fi
cat <<EOF > /etc/dovecot/conf.d/12-mysql.conf
# Autogenerated by mailcow - DO NOT TOUCH!
mysql /var/run/mysqld/mysqld.sock {
dbname=${DBNAME}
user=${DBUSER}
password=${DBPASS}
ssl = no
}
EOF
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-quota.conf
# Autogenerated by mailcow
dict_map priv/quota/storage {
sql_table = ${QUOTA_TABLE}
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
map {
pattern = priv/quota/storage
table = ${QUOTA_TABLE}
username_field = username
value_field bytes {
}
value_field = bytes
}
dict_map priv/quota/messages {
sql_table = ${QUOTA_TABLE}
map {
pattern = priv/quota/messages
table = ${QUOTA_TABLE}
username_field = username
value_field messages {
}
value_field = messages
}
EOF
# Create dict used for sieve pre and postfilters
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf
# Autogenerated by mailcow
dict_map priv/sieve/name/\$script_name {
sql_table = sieve_before
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
map {
pattern = priv/sieve/name/\$script_name
table = sieve_before
username_field = username
value_field id {
}
# The script name field in the table to query
key_field script_name {
value = \$script_name
value_field = id
fields {
script_name = \$script_name
}
}
dict_map priv/sieve/data/\$id {
sql_table = sieve_before
map {
pattern = priv/sieve/data/\$id
table = sieve_before
username_field = username
value_field script_data {
}
key_field id {
value = \$id
value_field = script_data
fields {
id = \$id
}
}
EOF
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf
# Autogenerated by mailcow
dict_map priv/sieve/name/\$script_name {
sql_table = sieve_after
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
map {
pattern = priv/sieve/name/\$script_name
table = sieve_after
username_field = username
value_field id {
}
key_field script_name {
value = \$script_name
value_field = id
fields {
script_name = \$script_name
}
}
dict_map priv/sieve/data/\$id {
sql_table = sieve_after
map {
pattern = priv/sieve/data/\$id
table = sieve_after
username_field = username
value_field script_data {
}
key_field id {
value = \$id
value_field = script_data
fields {
id = \$id
}
}
EOF
if [[ "${ACL_ANYONE}" == "allow" ]]; then
echo -n "yes" > /etc/dovecot/acl_anyone
else
echo -n "no" > /etc/dovecot/acl_anyone
fi
echo -n ${ACL_ANYONE} > /etc/dovecot/acl_anyone
if [[ "${SKIP_FTS}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo -e "\e[33mDetecting SKIP_FTS=y... not enabling Flatcurve (FTS) then...\e[0m"
echo -n 'quota quota_clone acl mail_crypt mail_crypt_acl mail_log mail_compress notify lazy_expunge' > /etc/dovecot/mail_plugins
echo -n 'quota quota_clone imap_quota imap_acl acl imap_sieve mail_crypt mail_crypt_acl mail_compress notify mail_log' > /etc/dovecot/mail_plugins_imap
echo -n 'quota quota_clone sieve acl mail_crypt mail_crypt_acl mail_compress notify' > /etc/dovecot/mail_plugins_lmtp
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify listescape replication lazy_expunge' > /etc/dovecot/mail_plugins
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify listescape replication mail_log' > /etc/dovecot/mail_plugins_imap
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
else
echo -e "\e[32mDetecting SKIP_FTS=n... enabling Flatcurve (FTS)\e[0m"
echo -n 'quota quota_clone acl mail_crypt mail_crypt_acl mail_log mail_compress notify fts fts_flatcurve lazy_expunge' > /etc/dovecot/mail_plugins
echo -n 'quota quota_clone imap_quota imap_acl acl imap_sieve mail_crypt mail_crypt_acl mail_compress notify mail_log fts fts_flatcurve' > /etc/dovecot/mail_plugins_imap
echo -n 'quota quota_clone sieve acl mail_crypt mail_crypt_acl mail_compress fts fts_flatcurve notify' > /etc/dovecot/mail_plugins_lmtp
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify fts fts_flatcurve listescape replication lazy_expunge' > /etc/dovecot/mail_plugins
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify mail_log fts fts_flatcurve listescape replication' > /etc/dovecot/mail_plugins_imap
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl fts fts_flatcurve notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
fi
chmod 644 /etc/dovecot/mail_plugins /etc/dovecot/mail_plugins_imap /etc/dovecot/mail_plugins_lmtp /templates/quarantine.tpl
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
# Autogenerated by mailcow
query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%{user | domain }}/%{user | username }/Maildir:VOLATILEDIR=/var/volatile/%{user}:INDEX=/var/vmail_index/%{user}') AS mail, '%{protocol}' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule FROM mailbox WHERE username = '%{user}' AND (active = '1' OR active = '2')
driver = mysql
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%d/%n/${MAILDIR_SUB}:VOLATILEDIR=/var/volatile/%u:INDEX=/var/vmail_index/%u') AS mail, '%s' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule FROM mailbox WHERE username = '%u' AND (active = '1' OR active = '2')
iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2';
EOF
@@ -177,8 +158,8 @@ for cert_dir in /etc/ssl/mail/*/ ; do
domains=($(cat ${cert_dir}domains))
for domain in ${domains[@]}; do
echo 'local_name '${domain}' {' >> /etc/dovecot/sni.conf;
echo ' ssl_server_cert_file = '${cert_dir}'cert.pem' >> /etc/dovecot/sni.conf;
echo ' ssl_server_key_file = '${cert_dir}'key.pem' >> /etc/dovecot/sni.conf;
echo ' ssl_cert = <'${cert_dir}'cert.pem' >> /etc/dovecot/sni.conf;
echo ' ssl_key = <'${cert_dir}'key.pem' >> /etc/dovecot/sni.conf;
echo '}' >> /etc/dovecot/sni.conf;
done
done
@@ -202,13 +183,11 @@ else
fi
cat <<EOF > /etc/dovecot/shared_namespace.conf
# Autogenerated by mailcow
namespace shared {
namespace {
type = shared
separator = /
prefix = Shared/\$user/
mail_driver = maildir
mail_path = %{owner_home}${MAILDIR_SUB_SHARED}
mail_index_private_path = ~${MAILDIR_SUB_SHARED}/Shared/%{owner_user}
prefix = Shared/%%u/
location = maildir:%%h${MAILDIR_SUB_SHARED}:INDEX=~${MAILDIR_SUB_SHARED}/Shared/%%u
subscriptions = no
list = children
}
@@ -218,27 +197,24 @@ EOF
cat <<EOF > /etc/dovecot/sogo_trusted_ip.conf
# Autogenerated by mailcow
remote ${IPV4_NETWORK}.248 {
auth_allow_cleartext = yes
disable_plaintext_auth = no
}
EOF
# Create random master Password for SOGo SSO
RAND_PASS=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 32 | head -n 1)
echo -n ${RAND_PASS} > /etc/phpfpm/sogo-sso.pass
# Creating additional creds file for SOGo notify crons (calendars, etc)
echo -n ${RAND_USER}@mailcow.local:${RAND_PASS} > /etc/sogo/cron.creds
cat <<EOF > /etc/dovecot/sogo-sso.conf
# Autogenerated by mailcow
passdb static {
fields {
allow_real_nets=${IPV4_NETWORK}.248/32
}
password={plain}${RAND_PASS}
passdb {
driver = static
args = allow_nets=${IPV4_NETWORK}.248/32 password={plain}${RAND_PASS}
}
EOF
# Creating additional creds file for SOGo notify crons (calendars, etc) (dummy user, sso password)
echo -n ${RAND_USER}@mailcow.local:${RAND_PASS} > /etc/sogo/cron.creds
if [[ "${MASTER}" =~ ^([nN][oO]|[nN])+$ ]]; then
# Toggling MASTER will result in a rebuild of containers, so the quota script will be recreated
cat <<'EOF' > /usr/local/bin/quota_notify.py
@@ -260,9 +236,9 @@ fi
if [[ "${SKIP_FTS}" =~ ^([nN][oO]|[nN])+$ ]]; then
echo -e "\e[94mConfiguring FTS Settings...\e[0m"
echo -e "\e[94mSetting FTS Memory Limit (per process) to ${FTS_HEAP} MB\e[0m"
sed -i "s/vsz_limit\s*=\s*[0-9]*\s*MB*/vsz_limit=${FTS_HEAP} MB/" /etc/dovecot/conf.d/35-fts.conf
sed -i "s/vsz_limit\s*=\s*[0-9]*\s*MB*/vsz_limit=${FTS_HEAP} MB/" /etc/dovecot/conf.d/fts.conf
echo -e "\e[94mSetting FTS Process Limit to ${FTS_PROCS}\e[0m"
sed -i "s/process_limit\s*=\s*[0-9]*/process_limit=${FTS_PROCS}/" /etc/dovecot/conf.d/35-fts.conf
sed -i "s/process_limit\s*=\s*[0-9]*/process_limit=${FTS_PROCS}/" /etc/dovecot/conf.d/fts.conf
fi
# 401 is user dovecot
@@ -274,16 +250,16 @@ else
chown 401 /mail_crypt/ecprivkey.pem /mail_crypt/ecpubkey.pem
fi
# # Fix OpenSSL 3.X TLS1.0, 1.1 support (https://community.mailcow.email/d/4062-hi-all/20)
# if grep -qE 'ssl_min_protocol\s*=\s*(TLSv1|TLSv1\.1)\s*$' /etc/dovecot/dovecot.conf /etc/dovecot/extra.conf; then
# sed -i '/\[openssl_init\]/a ssl_conf = ssl_configuration' /etc/ssl/openssl.cnf
# Fix OpenSSL 3.X TLS1.0, 1.1 support (https://community.mailcow.email/d/4062-hi-all/20)
if grep -qE 'ssl_min_protocol\s*=\s*(TLSv1|TLSv1\.1)\s*$' /etc/dovecot/dovecot.conf /etc/dovecot/extra.conf; then
sed -i '/\[openssl_init\]/a ssl_conf = ssl_configuration' /etc/ssl/openssl.cnf
# echo "[ssl_configuration]" >> /etc/ssl/openssl.cnf
# echo "system_default = tls_system_default" >> /etc/ssl/openssl.cnf
# echo "[tls_system_default]" >> /etc/ssl/openssl.cnf
# echo "MinProtocol = TLSv1" >> /etc/ssl/openssl.cnf
# echo "CipherString = DEFAULT@SECLEVEL=0" >> /etc/ssl/openssl.cnf
# fi
echo "[ssl_configuration]" >> /etc/ssl/openssl.cnf
echo "system_default = tls_system_default" >> /etc/ssl/openssl.cnf
echo "[tls_system_default]" >> /etc/ssl/openssl.cnf
echo "MinProtocol = TLSv1" >> /etc/ssl/openssl.cnf
echo "CipherString = DEFAULT@SECLEVEL=0" >> /etc/ssl/openssl.cnf
fi
# Compile sieve scripts
sievec /var/vmail/sieve/global_sieve_before.sieve

View File

@@ -132,8 +132,8 @@ while ($row = $sth->fetchrow_arrayref()) {
"--tmpdir", "/tmp",
"--nofoldersizes",
"--addheader",
($timeout1 gt "0" ? () : ('--timeout1', $timeout1)),
($timeout2 gt "0" ? () : ('--timeout2', $timeout2)),
($timeout1 le "0" ? () : ('--timeout1', $timeout1)),
($timeout2 le "0" ? () : ('--timeout2', $timeout2)),
($exclude eq "" ? () : ("--exclude", $exclude)),
($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)),
($maxage eq "0" ? () : ('--maxage', $maxage)),

View File

@@ -25,11 +25,11 @@ sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules
if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then
CONTAINER_NAME=rspamd-mailcow
CONTAINER_ID=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | \
CONTAINER_ID=$(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | \
jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | \
jq -rc "select( .name | tostring | contains(\"${CONTAINER_NAME}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
if [[ ! -z ${CONTAINER_ID} ]]; then
curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
fi
fi

View File

@@ -1,6 +1,6 @@
#!/bin/sh
backend=iptables
backend=nftables
nft list table ip filter &>/dev/null
nftables_found=$?

View File

@@ -449,6 +449,11 @@ if __name__ == '__main__':
tables = NFTables(chain_name, logger)
else:
logger.logInfo('Using IPTables backend')
logger.logWarn(
"DEPRECATION: iptables-legacy is deprecated and will be removed in future releases. "
"Please switch to nftables on your host to ensure complete compatibility."
)
time.sleep(5)
tables = IPTables(chain_name, logger)
clear()

View File

@@ -1,5 +1,6 @@
import time
import json
import datetime
class Logger:
def __init__(self):
@@ -8,17 +9,28 @@ class Logger:
def set_redis(self, redis):
self.r = redis
def _format_timestamp(self):
# Local time with milliseconds
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(self, priority, message):
tolog = {}
tolog['time'] = int(round(time.time()))
tolog['priority'] = priority
tolog['message'] = message
print(message)
# build redis-friendly dict
tolog = {
'time': int(round(time.time())), # keep raw timestamp for Redis
'priority': priority,
'message': message
}
# print human-readable message with timestamp
ts = self._format_timestamp()
print(f"{ts} {priority.upper()}: {message}", flush=True)
# also push JSON to Redis if connected
if self.r is not None:
try:
self.r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
except Exception as ex:
print('Failed logging to redis: %s' % (ex))
print(f'{ts} WARN: Failed logging to redis: {ex}', flush=True)
def logWarn(self, message):
self.log('warn', message)
@@ -27,4 +39,4 @@ class Logger:
self.log('crit', message)
def logInfo(self, message):
self.log('info', message)
self.log('info', message)

View File

@@ -10,7 +10,7 @@ def includes_conf(env, template_vars):
server_name_config = f"server_name {template_vars['MAILCOW_HOSTNAME']} autodiscover.* autoconfig.* {' '.join(template_vars['ADDITIONAL_SERVER_NAMES'])};"
listen_plain_config = f"listen {template_vars['HTTP_PORT']};"
listen_ssl_config = f"listen {template_vars['HTTPS_PORT']};"
if not template_vars['ENABLE_IPV6']:
if template_vars['ENABLE_IPV6']:
listen_plain_config += f"\nlisten [::]:{template_vars['HTTP_PORT']};"
listen_ssl_config += f"\nlisten [::]:{template_vars['HTTPS_PORT']} ssl;"
listen_ssl_config += "\nhttp2 on;"

View File

@@ -3,15 +3,15 @@ FROM php:8.2-fpm-alpine3.21
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
# renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG APCU_PECL_VERSION=5.1.26
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.0
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.8
ARG MAILPARSE_PECL_VERSION=3.1.9
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG MEMCACHED_PECL_VERSION=3.3.0
ARG MEMCACHED_PECL_VERSION=3.4.0
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
ARG REDIS_PECL_VERSION=6.2.0
ARG REDIS_PECL_VERSION=6.3.0
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
ARG COMPOSER_VERSION=2.8.6

View File

@@ -32,7 +32,7 @@ session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'"
# Check mysql_upgrade (master and slave)
CONTAINER_ID=
until [[ ! -z "${CONTAINER_ID}" ]] && [[ "${CONTAINER_ID}" =~ ^[[:alnum:]]*$ ]]; do
CONTAINER_ID=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"mysql-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
CONTAINER_ID=$(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"mysql-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
echo "Could not get mysql-mailcow container id... trying again"
sleep 2
done
@@ -44,7 +44,7 @@ until [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; do
echo "Tried to upgrade MySQL and failed, giving up after ${SQL_LOOP_C} retries and starting container (oops, not good)"
break
fi
SQL_FULL_UPGRADE_RETURN=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_upgrade"}' --silent -H 'Content-type: application/json')
SQL_FULL_UPGRADE_RETURN=$(curl --silent --insecure -XPOST https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_upgrade"}' --silent -H 'Content-type: application/json')
SQL_UPGRADE_STATUS=$(echo ${SQL_FULL_UPGRADE_RETURN} | jq -r .type)
SQL_LOOP_C=$((SQL_LOOP_C+1))
echo "SQL upgrade iteration #${SQL_LOOP_C}"
@@ -69,12 +69,12 @@ done
# doing post-installation stuff, if SQL was upgraded (master and slave)
if [ ${SQL_CHANGED} -eq 1 ]; then
POSTFIX=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
POSTFIX=$(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
if [[ -z "${POSTFIX}" ]] || ! [[ "${POSTFIX}" =~ ^[[:alnum:]]*$ ]]; then
echo "Could not determine Postfix container ID, skipping Postfix restart."
else
echo "Restarting Postfix"
curl -X POST --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/restart | jq -r '.msg'
curl -X POST --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/restart | jq -r '.msg'
echo "Sleeping 5 seconds..."
sleep 5
fi
@@ -83,7 +83,7 @@ fi
# Check mysql tz import (master and slave)
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
SQL_FULL_TZINFO_IMPORT_RETURN=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_tzinfo_to_sql"}' --silent -H 'Content-type: application/json')
SQL_FULL_TZINFO_IMPORT_RETURN=$(curl --silent --insecure -XPOST https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_tzinfo_to_sql"}' --silent -H 'Content-type: application/json')
echo "MySQL mysql_tzinfo_to_sql - debug output:"
echo ${SQL_FULL_TZINFO_IMPORT_RETURN}
fi
@@ -167,7 +167,7 @@ DELIMITER //
CREATE EVENT clean_spamalias
ON SCHEDULE EVERY 1 DAY DO
BEGIN
DELETE FROM spamalias WHERE validity < UNIX_TIMESTAMP();
DELETE FROM spamalias WHERE validity < UNIX_TIMESTAMP() AND permanent = 0;
END;
//
DELIMITER ;

View File

@@ -3,7 +3,8 @@ WORKDIR /src
ENV CGO_ENABLED=0 \
GO111MODULE=on \
VERSION=1.8.14
NOOPT=1 \
VERSION=1.8.22
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
cd /src/postfix-tlspol && \

View File

@@ -329,14 +329,17 @@ query = SELECT goto FROM alias
SELECT id FROM alias
WHERE address='%s'
AND (active='1' OR active='2')
AND sender_allowed='1'
), (
SELECT id FROM alias
WHERE address='@%d'
AND (active='1' OR active='2')
AND sender_allowed='1'
)
)
)
AND active='1'
AND sender_allowed='1'
AND (domain IN
(SELECT domain FROM domain
WHERE domain='%d'
@@ -390,7 +393,7 @@ hosts = unix:/var/run/mysqld/mysqld.sock
dbname = ${DBNAME}
query = SELECT goto FROM spamalias
WHERE address='%s'
AND validity >= UNIX_TIMESTAMP()
AND (validity >= UNIX_TIMESTAMP() OR permanent != 0)
EOF
if [ ! -f /opt/postfix/conf/dns_blocklists.cf ]; then
@@ -524,4 +527,4 @@ if [[ $? != 0 ]]; then
else
postfix -c /opt/postfix/conf start
sleep 126144000
fi
fi

View File

@@ -1,9 +1,9 @@
FROM debian:bookworm-slim
FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ARG RSPAMD_VER=rspamd_3.12.1-1~6dbfca2fa
ARG CODENAME=bookworm
ARG RSPAMD_VER=rspamd_3.14.2-82~90302bc
ARG CODENAME=trixie
ENV LC_ALL=C
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -14,8 +14,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
dnsutils \
netcat-traditional \
wget \
redis-tools \
procps \
redis-tools \
procps \
nano \
lua-cjson \
&& arch=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) \

View File

@@ -86,7 +86,8 @@ if [[ "${SKIP_OLEFY}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
rm /etc/rspamd/local.d/external_services.conf
fi
else
cat <<EOF > /etc/rspamd/local.d/external_services.conf
if [[ ! -f /etc/rspamd/local.d/external_services.conf ]]; then
cat <<EOF > /etc/rspamd/local.d/external_services.conf
oletools {
# default olefy settings
servers = "olefy:10055";
@@ -100,6 +101,7 @@ oletools {
retransmits = 1;
}
EOF
fi
fi
# Provide additional lua modules

View File

@@ -6,7 +6,7 @@ ARG DEBIAN_FRONTEND=noninteractive
ARG DEBIAN_VERSION=bookworm
ARG SOGO_DEBIAN_REPOSITORY=https://packagingv2.sogo.nu/sogo-nightly-debian/
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
ARG GOSU_VERSION=1.17
ARG GOSU_VERSION=1.19
ENV LC_ALL=C
# Prerequisites

View File

@@ -24,6 +24,10 @@ while [[ "${DBV_NOW}" != "${DBV_NEW}" ]]; do
done
echo "DB schema is ${DBV_NOW}"
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DROP TRIGGER IF EXISTS sogo_update_password"
fi
# cat /dev/urandom seems to hang here occasionally and is not recommended anyway, better use openssl
RAND_PASS=$(openssl rand -base64 16 | tr -dc _A-Z-a-z-0-9)

View File

@@ -16,7 +16,6 @@ RUN apk add --update \
fcgi \
openssl \
nagios-plugins-mysql \
nagios-plugins-dns \
nagios-plugins-disk \
bind-tools \
redis \
@@ -32,9 +31,11 @@ RUN apk add --update \
tzdata \
whois \
&& curl https://raw.githubusercontent.com/mludvig/smtp-cli/v3.10/smtp-cli -o /smtp-cli \
&& chmod +x smtp-cli
&& chmod +x smtp-cli \
&& mkdir /usr/lib/mailcow
COPY watchdog.sh /watchdog.sh
COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh
COPY check_dns.sh /usr/lib/mailcow/check_dns.sh
CMD ["/watchdog.sh"]

View File

@@ -0,0 +1,39 @@
#!/bin/sh
while getopts "H:s:" opt; do
case "$opt" in
H) HOST="$OPTARG" ;;
s) SERVER="$OPTARG" ;;
*) echo "Usage: $0 -H host -s server"; exit 3 ;;
esac
done
if [ -z "$SERVER" ]; then
echo "No DNS Server provided"
exit 3
fi
if [ -z "$HOST" ]; then
echo "No host to test provided"
exit 3
fi
# run dig and measure the time it takes to run
START_TIME=$(date +%s%3N)
dig_output=$(dig +short +timeout=2 +tries=1 "$HOST" @"$SERVER" 2>/dev/null)
dig_rc=$?
dig_output_ips=$(echo "$dig_output" | grep -E '^[0-9.]+$' | sort | paste -sd ',' -)
END_TIME=$(date +%s%3N)
ELAPSED_TIME=$((END_TIME - START_TIME))
# validate and perform nagios like output and exit codes
if [ $dig_rc -ne 0 ] || [ -z "$dig_output" ]; then
echo "Domain $HOST was not found by the server"
exit 2
elif [ $dig_rc -eq 0 ]; then
echo "DNS OK: $ELAPSED_TIME ms response time. $HOST returns $dig_output_ips"
exit 0
else
echo "Unknown error"
exit 3
fi

View File

@@ -1,5 +1,10 @@
#!/bin/bash
if [ "${DEV_MODE}" != "n" ]; then
echo -e "\e[31mEnabled Debug Mode\e[0m"
set -x
fi
trap "exit" INT TERM
trap "kill 0" EXIT
@@ -195,12 +200,12 @@ get_container_ip() {
else
sleep 0.5
# get long container id for exact match
CONTAINER_ID=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring == \"${1}\") | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id"))
CONTAINER_ID=($(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring == \"${1}\") | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id"))
# returned id can have multiple elements (if scaled), shuffle for random test
CONTAINER_ID=($(printf "%s\n" "${CONTAINER_ID[@]}" | shuf))
if [[ ! -z ${CONTAINER_ID} ]]; then
for matched_container in "${CONTAINER_ID[@]}"; do
CONTAINER_IPS=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${matched_container}/json | jq -r '.NetworkSettings.Networks[].IPAddress'))
CONTAINER_IPS=($(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${matched_container}/json | jq -r '.NetworkSettings.Networks[].IPAddress'))
for ip_match in "${CONTAINER_IPS[@]}"; do
# grep will do nothing if one of these vars is empty
[[ -z ${ip_match} ]] && continue
@@ -297,7 +302,7 @@ unbound_checks() {
touch /tmp/unbound-mailcow; echo "$(tail -50 /tmp/unbound-mailcow)" > /tmp/unbound-mailcow
host_ip=$(get_container_ip unbound-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_dns -s ${host_ip} -H stackoverflow.com 2>> /tmp/unbound-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/mailcow/check_dns.sh -s ${host_ip} -H stackoverflow.com 2>> /tmp/unbound-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
DNSSEC=$(dig com +dnssec | egrep 'flags:.+ad')
if [[ -z ${DNSSEC} ]]; then
echo "DNSSEC failure" 2>> /tmp/unbound-mailcow 1>&2
@@ -445,6 +450,31 @@ postfix_checks() {
return 1
}
postfix-tlspol_checks() {
err_count=0
diff_c=0
THRESHOLD=${POSTFIX_TLSPOL_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/postfix-tlspol-mailcow; echo "$(tail -50 /tmp/postfix-tlspol-mailcow)" > /tmp/postfix-tlspol-mailcow
host_ip=$(get_container_ip postfix-tlspol-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 8642 2>> /tmp/postfix-tlspol-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Postfix TLS Policy companion" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
clamd_checks() {
err_count=0
diff_c=0
@@ -922,6 +952,18 @@ PID=$!
echo "Spawned mailq_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! postfix-tlspol_checks; then
log_msg "Postfix TLS Policy hit error limit"
echo postfix-tlspol-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned postfix-tlspol_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! dovecot_checks; then
@@ -1033,15 +1075,15 @@ while true; do
done
) &
# Monitor dockerapi
# Monitor controller
(
while true; do
while nc -z dockerapi 443; do
while nc -z controller 443; do
sleep 3
done
log_msg "Cannot find dockerapi-mailcow, waiting to recover..."
log_msg "Cannot find controller-mailcow, waiting to recover..."
kill -STOP ${BACKGROUND_TASKS[*]}
until nc -z dockerapi 443; do
until nc -z controller 443; do
sleep 3
done
kill -CONT ${BACKGROUND_TASKS[*]}
@@ -1101,12 +1143,12 @@ while true; do
elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then
kill -STOP ${BACKGROUND_TASKS[*]}
sleep 10
CONTAINER_ID=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"${com_pipe_answer}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
CONTAINER_ID=$(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"${com_pipe_answer}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
if [[ ! -z ${CONTAINER_ID} ]]; then
if [[ "${com_pipe_answer}" == "php-fpm-mailcow" ]]; then
HAS_INITDB=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/top | jq '.msg.Processes[] | contains(["php -c /usr/local/etc/php -f /web/inc/init_db.inc.php"])' | grep true)
HAS_INITDB=$(curl --silent --insecure -XPOST https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/top | jq '.msg.Processes[] | contains(["php -c /usr/local/etc/php -f /web/inc/init_db.inc.php"])' | grep true)
fi
S_RUNNING=$(($(date +%s) - $(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/json | jq .State.StartedAt | xargs -n1 date +%s -d)))
S_RUNNING=$(($(date +%s) - $(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/json | jq .State.StartedAt | xargs -n1 date +%s -d)))
if [ ${S_RUNNING} -lt 360 ]; then
log_msg "Container is running for less than 360 seconds, skipping action..."
elif [[ ! -z ${HAS_INITDB} ]]; then
@@ -1114,7 +1156,7 @@ while true; do
sleep 60
else
log_msg "Sending restart command to ${CONTAINER_ID}..."
curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
curl --silent --insecure -XPOST https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
notify_error "${com_pipe_answer}"
log_msg "Wait for restarted container to settle and continue watching..."
sleep 35

View File

@@ -80,23 +80,30 @@ if ($isSOGoRequest) {
}
if ($result === false){
// If it's a SOGo Request, don't check for protocol access
$service = ($isSOGoRequest) ? false : array($post['service'] => true);
$result = apppass_login($post['username'], $post['password'], $service, array(
if ($isSOGoRequest) {
$service = 'SOGO';
$post['service'] = 'NONE';
} else {
$service = $post['service'];
}
$result = apppass_login($post['username'], $post['password'], array(
'service' => $post['service'],
'is_internal' => true,
'remote_addr' => $post['real_rip']
));
if ($result) {
error_log('MAILCOWAUTH: App auth for user ' . $post['username']);
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $service);
}
}
if ($result === false){
// Init Identity Provider
$iam_provider = identity_provider('init');
$iam_settings = identity_provider('get');
$result = user_login($post['username'], $post['password'], array('is_internal' => true));
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
if ($result) {
error_log('MAILCOWAUTH: User auth for user ' . $post['username']);
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
}
}
@@ -105,7 +112,7 @@ if ($result) {
http_response_code(200); // OK
$return['success'] = true;
} else {
error_log("MAILCOWAUTH: Login failed for user " . $post['username']);
error_log("MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
http_response_code(401); // Unauthorized
}

View File

@@ -1,5 +1,4 @@
function auth_password_verify(request, password)
request.domain = request.auth_user:match("@(.+)") or nil
if request.domain == nil then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
end
@@ -10,10 +9,10 @@ function auth_password_verify(request, password)
https.TIMEOUT = 30
local req = {
username = request.auth_user,
username = request.user,
password = password,
real_rip = request.remote_ip,
service = request.protocol
real_rip = request.real_rip,
service = request.service
}
local req_json = json.encode(req)
local res = {}
@@ -34,6 +33,7 @@ function auth_password_verify(request, password)
-- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry,
-- even if the TTL has expired. Useful to avoid cache eviction during backend issues.
if c ~= 200 and c ~= 401 then
dovecot.i_info("HTTP request failed with " .. c .. " for user " .. request.user)
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
end
@@ -46,7 +46,7 @@ function auth_password_verify(request, password)
end
if response_json.success == true then
return dovecot.auth.PASSDB_RESULT_OK, { msg = "" }
return dovecot.auth.PASSDB_RESULT_OK, ""
end
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Failed to authenticate"
@@ -55,7 +55,3 @@ end
function auth_passdb_lookup(req)
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, ""
end
function auth_passdb_get_cache_key()
return "%{protocol}:%{user | username}\t:%{password}"
end

View File

@@ -1,3 +0,0 @@
# /etc/dovecot/conf.d/05-core.conf
# Core, single-line settings that don't fit elsewhere.
recipient_delimiter = +

View File

@@ -1,13 +0,0 @@
# /etc/dovecot/conf.d/10-logging.conf
# Logging and debug.
#mail_debug = yes
#auth_debug = yes
#log_debug = category=fts-flatcurve
log_path = syslog
log_timestamp = "%Y-%m-%d %H:%M:%S "
login_log_format_elements = "user=<%{user}> method=%{mechanism} rip=%{remote_ip} lip=%{local_ip} mpid=%{mail_pid} %{secured} session=<%{session}>"
# Mail event logging.
mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
mail_log_fields = uid box msgid size
mail_log_cached_only = yes

View File

@@ -1,10 +0,0 @@
# /etc/dovecot/conf.d/10-mail.conf
# Mail storage paths and core mail settings.
mail_home = /var/vmail/%{user | domain }/%{user | username }
mail_driver = maildir
mail_path = ~/Maildir
mail_index_path = /var/vmail_index/%{user}
mail_plugins = </etc/dovecot/mail_plugins
mail_shared_explicit_inbox = yes
mailbox_list_storage_escape_char = "\\"
mail_prefetch_count = 30

View File

@@ -1,13 +0,0 @@
# /etc/dovecot/conf.d/10-ssl.conf
# TLS/SSL settings.
ssl_min_protocol = TLSv1.2
ssl_cipher_list = ALL:!ADH:!LOW:!SSLv2:!SSLv3:!EXP:!aNULL:!eNULL:!3DES:!MD5:!PSK:!DSS:!RC4:!SEED:!IDEA:+HIGH:+MEDIUM
ssl_options = no_ticket
#ssl_dh_parameters_length = 2048
ssl_server {
prefer_ciphers = server
dh_file = /etc/ssl/mail/dhparams.pem
cert_file = /etc/ssl/mail/cert.pem
key_file = /etc/ssl/mail/key.pem
}

View File

@@ -1,3 +0,0 @@
# /etc/dovecot/conf.d/11-sql.conf
# Default SQL driver used by SQL-based dicts/userdb.
sql_driver = mysql

View File

@@ -1,8 +0,0 @@
# Autogenerated by mailcow - DO NOT TOUCH!
mysql /var/run/mysqld/mysqld.sock {
dbname=mailcow
user=mailcow
password=D8O9BIivJc7Pb2VCfpAeLbAzUOZ0
ssl = no
}

View File

@@ -1,7 +0,0 @@
# /etc/dovecot/conf.d/12-storage-attachments.conf
# External attachment storage.
fs mail_ext_attachment {
fs_driver = posix
mail_ext_attachment_path = /var/attachments
mail_ext_attachment_min_size = 128k
}

View File

@@ -1,10 +0,0 @@
# /etc/dovecot/conf.d/15-performance.conf
# Performance and mailbox tuning.
# Enable only when you do not manually touch cur/.
maildir_very_dirty_syncs = yes
# NFS examples | Only modify if using NFS!:
#mm ap_disable = yes
#mail_fsync = always
#mail_nfs_index = yes
#mail_nfs_storage = yes

View File

@@ -1,40 +0,0 @@
# /etc/dovecot/conf.d/20-auth.conf
# Authentication mechanisms, master/user separation, passdb chain, auth cache.
auth_mechanisms = plain login
auth_allow_cleartext = yes
auth_master_user_separator = *
auth_cache_verify_password_with_worker = yes
auth_cache_negative_ttl = 60s
auth_cache_ttl = 300s
auth_cache_size = 10M
auth_verbose_passwords = sha1:6
# 1) Lua password verification (blocking, return mapping).
passdb lua {
driver = lua
lua_file = /etc/dovecot/auth/passwd-verify.lua
lua_settings {
blocking=yes
result_success = return-ok
result_failure = continue
result_internalfail = continue
}
}
# 2) Master password for master user logins.
passdb master {
driver = passwd-file
passwd_file_path = /etc/dovecot/dovecot-master.passwd
master = yes
skip = authenticated
}
# 3) Mandatory return layer: empty Lua (e.g. for forced reset).
passdb empty-lua {
driver = lua
lua_file = /etc/dovecot/auth/passwd-verify.lua
lua_settings {
blocking = yes
}
}

View File

@@ -1,11 +0,0 @@
# /etc/dovecot/conf.d/20-userdb.conf
# User database chain.
userdb passwd {
driver = passwd-file
passwd_file_path = /etc/dovecot/dovecot-master.userdb
}
userdb sql {
!include /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
skip = found
}

View File

@@ -1,144 +0,0 @@
# /etc/dovecot/conf.d/25-services.conf
# All service listeners and workers.
# doveadm remote admin
# Set doveadm_password in extra.conf.
service doveadm {
inet_listener doveadm {
port = 12345
}
vsz_limit = 2048 MB
}
# dict
service dict {
unix_listener dict {
mode = 0660
user = vmail
group = vmail
}
}
# log
service log {
user = dovenull
}
# config socket
service config {
unix_listener config {
user = root
group = vmail
mode = 0660
}
}
# anvil socket
service anvil {
unix_listener anvil {
user = vmail
group = vmail
mode = 0660
}
}
# auth sockets and inet
service auth {
inet_listener auth-inet {
port = 10001
}
unix_listener auth-master {
mode = 0600
user = vmail
}
unix_listener auth-userdb {
mode = 0600
user = vmail
}
vsz_limit = 2G
}
# managesieve login
service managesieve-login {
inet_listener sieve {
port = 4190
}
inet_listener sieve_haproxy {
port = 14190
haproxy = yes
}
service_restart_request_count = 1
process_min_avail = 2
vsz_limit = 1G
}
# imap login
service imap-login {
service_restart_request_count = 1
process_min_avail = 2
process_limit = 10000
vsz_limit = 1G
user = dovenull
inet_listener imap_haproxy {
port = 10143
haproxy = yes
}
inet_listener imaps_haproxy {
port = 10993
ssl = yes
haproxy = yes
}
}
# pop3 login
service pop3-login {
service_restart_request_count = 1
process_min_avail = 1
vsz_limit = 1G
inet_listener pop3_haproxy {
port = 10110
haproxy = yes
}
inet_listener pop3s_haproxy {
port = 10995
ssl = yes
haproxy = yes
}
}
# imap worker
service imap {
executable = imap
user = vmail
vsz_limit = 1G
}
# managesieve worker
service managesieve {
process_limit = 256
}
# lmtp
service lmtp {
inet_listener lmtp-inet {
port = 24
}
user = vmail
}
# quota warning hook
service quota-warning {
executable = script /usr/local/bin/quota_notify.py
user = vmail
unix_listener quota-warning {
user = vmail
}
}
# stats
service stats {
unix_listener stats-writer {
mode = 0660
user = vmail
}
}

View File

@@ -1,17 +0,0 @@
# /etc/dovecot/conf.d/30-protocols.conf
# IMAP protocol specifics.
protocol imap {
mail_plugins = </etc/dovecot/mail_plugins_imap
imap_metadata = yes
}
# LMTP protocol specifics.
protocol lmtp {
mail_plugins = </etc/dovecot/mail_plugins_lmtp
auth_socket_path = /var/run/dovecot/auth-master
}
# ManageSieve protocol specifics.
protocol sieve {
managesieve_logout_format = bytes=%i/%o
}

View File

@@ -1,45 +0,0 @@
# mailcow FTS Flatcurve Settings, change them as you like.
# Maximum term length can be set via the 'maxlen' argument (maxlen is
# specified in bytes, not number of UTF-8 characters)
language_tokenizer_address_token_maxlen = 100
language_tokenizer_generic_algorithm = simple
language_tokenizer_generic_token_maxlen = 30
# These are not flatcurve settings, but required for Dovecot FTS. See
# Dovecot FTS Configuration link above for further information.
language en {
default = yes
language_filters = lowercase snowball english-possessive stopwords
}
language de {
language_filters = lowercase snowball stopwords
}
language es {
language_filters = lowercase snowball stopwords
}
language_tokenizers = generic email-address
fts_search_timeout = 300s
fts_autoindex = yes
# Tweak this setting if you only want to ensure big and frequent folders are indexed, not all.
fts_autoindex_max_recent_msgs = 20
fts flatcurve {
substring_search = no
}
### THIS PART WILL BE CHANGED BY MODIFYING mailcow.conf AUTOMATICALLY DURING RUNTIME! ###
service indexer-worker {
# Max amount of simultaniously running indexer jobs.
process_limit=1
# Max amount of RAM used by EACH indexer process.
vsz_limit=128 MB
}
### THIS PART WILL BE CHANGED BY MODIFYING mailcow.conf AUTOMATICALLY DURING RUNTIME! ###

View File

@@ -1,12 +0,0 @@
# /etc/dovecot/conf.d/40-acl.conf
# ACL and shared mailboxes.
imap_acl_allow_anyone = </etc/dovecot/acl_anyone
acl_sharing_map {
dict file {
path = /var/vmail/shared-mailboxes.db
}
}
acl_driver = vfile
acl_user = %{user}

View File

@@ -1,7 +0,0 @@
# /etc/dovecot/conf.d/40-attributes.conf
# User/mail attributes.
mail_attribute {
dict file {
path = /etc/dovecot/dovecot-attributes
}
}

View File

@@ -1,25 +0,0 @@
# /etc/dovecot/conf.d/50-quota.conf
# Quota configuration and notifications.
quota "User quota" {
driver = count
warning warn-95 {
quota_storage_percentage = 95
execute quota-warning {
args = 95 %{user}
}
}
warning warn-80 {
quota_storage_percentage = 80
execute quota-warning {
args = 80 %{user}
}
}
}
quota_clone {
dict proxy {
name = mysql_quota
}
}

View File

@@ -1,97 +0,0 @@
# /etc/dovecot/conf.d/60-sieve-pipeline.conf
# Complete Sieve pipeline: personal/global scripts, plugins, limits, training.
# Global before/after (file and dict)
sieve_script before {
type = before
driver = file
path = /var/vmail/sieve/global_sieve_before.sieve
}
sieve_script before2 {
type = before
driver = dict
name = active
dict proxy {
name = sieve_before
}
bin_path = /var/vmail/sieve_before_bindir/%{user}
}
sieve_script after {
type = after
driver = file
path = /var/vmail/sieve/global_sieve_after.sieve
}
sieve_script after2 {
type = after
driver = dict
name = active
dict proxy {
name = sieve_after
}
bin_path = /var/vmail/sieve_after_bindir/%{user}
}
# Personal scripts
sieve_script personal {
type = personal
driver = file
path = ~/sieve
active_path = ~/.dovecot.sieve
}
# Plugins and behavior
sieve_plugins = sieve_imapsieve sieve_extprograms
sieve_vacation_send_from_recipient = yes
sieve_redirect_envelope_from = recipient
# IMAPSieve training
imapsieve_from Junk {
sieve_script ham {
type = before
cause = copy
path = /usr/lib/dovecot/sieve/report-ham.sieve
}
}
mailbox Junk {
sieve_script spam {
type = before
cause = copy
path = /usr/lib/dovecot/sieve/report-spam.sieve
}
}
# Extprograms and extensions
sieve_pipe_bin_dir = /usr/lib/dovecot/sieve
sieve_plugins {
sieve_extprograms = yes
}
sieve_global_extensions {
vnd.dovecot.pipe = yes
vnd.dovecot.execute = yes
}
# Limits and duplicate handling
sieve_max_script_size = 1M
sieve_max_redirects = 100
sieve_max_actions = 101
sieve_quota_script_count = 0
sieve_quota_storage_size = 0
sieve_vacation_min_period = 5s
sieve_vacation_max_period = 365d
sieve_vacation_default_period = 60s
sieve_duplicate_default_period = 1m
sieve_duplicate_max_period = 7d
sieve_extensions {
vacation-seconds = yes
editheader = yes
}
# pipe sockets in /var/run/dovecot/sieve-pipe
sieve_pipe_socket_dir = sieve-pipe
# execute sockets in /var/run/dovecot/sieve-execute
sieve_execute_socket_dir = sieve-execute

View File

@@ -1,6 +0,0 @@
# /etc/dovecot/conf.d/70-crypto.conf
# Global mail-crypt keys.
crypt_global_private_key global {
crypt_private_key_file = /mail_crypt/ecprivkey.pem
}
crypt_global_public_key_file = /mail_crypt/ecpubkey.pem

View File

@@ -1,3 +0,0 @@
# /etc/dovecot/conf.d/80-compress.conf
# Compression settings.
mail_compress_write_method = lz4

View File

@@ -1,18 +0,0 @@
# /etc/dovecot/conf.d/90-dict.conf
# Dict declarations and SQL bindings.
dict_server {
dict sieve_after {
driver = sql
!include /etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf
}
dict sieve_before {
driver = sql
!include /etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf
}
dict mysql_quota {
driver = sql
!include /etc/dovecot/sql/dovecot-dict-sql-quota.conf
}
}

View File

@@ -1,7 +0,0 @@
# /etc/dovecot/conf.d/90-limits.conf
# Connection and memory limits; doveadm port.
mail_max_userip_connections = 500
imap_max_line_length = 2 M
default_client_limit = 10400
default_vsz_limit = 1024 M
doveadm_port = 12345

View File

@@ -1,22 +0,0 @@
# /etc/dovecot/conf.d/99-includes.conf
# Late includes and site-specific bits.
# Mailbox layout includes (if used)
!include /etc/dovecot/dovecot.folders.conf
# Optional replication
!include_try /etc/dovecot/mail_replica.conf
# Existing includes you already had
!include_try /etc/dovecot/sni.conf
!include_try /etc/dovecot/sogo_trusted_ip.conf
!include_try /etc/dovecot/shared_namespace.conf
!include_try /etc/dovecot/conf.d/fts.conf
# Remote auth override
remote 127.0.0.1 {
auth_allow_cleartext = yes
}
# Outbound submission target
submission_host = postfix:588

View File

@@ -0,0 +1,37 @@
# mailcow FTS Flatcurve Settings, change them as you like.
plugin {
fts_autoindex = yes
fts_autoindex_exclude = \Junk
fts_autoindex_exclude2 = \Trash
# Tweak this setting if you only want to ensure big and frequent folders are indexed, not all.
fts_autoindex_max_recent_msgs = 20
fts = flatcurve
# Maximum term length can be set via the 'maxlen' argument (maxlen is
# specified in bytes, not number of UTF-8 characters)
fts_tokenizer_email_address = maxlen=100
fts_tokenizer_generic = algorithm=simple maxlen=30
# These are not flatcurve settings, but required for Dovecot FTS. See
# Dovecot FTS Configuration link above for further information.
fts_languages = en es de
fts_tokenizers = generic email-address
# OPTIONAL: Recommended default FTS core configuration
fts_filters = normalizer-icu snowball stopwords
fts_filters_en = lowercase snowball english-possessive stopwords
fts_index_timeout = 300s
}
### THIS PART WILL BE CHANGED BY MODIFYING mailcow.conf AUTOMATICALLY DURING RUNTIME! ###
service indexer-worker {
# Max amount of simultaniously running indexer jobs.
process_limit=1
# Max amount of RAM used by EACH indexer process.
vsz_limit=128 MB
}
### THIS PART WILL BE CHANGED BY MODIFYING mailcow.conf AUTOMATICALLY DURING RUNTIME! ###

View File

@@ -1,34 +1,311 @@
# /etc/dovecot/dovecot.conf
# Base file kept minimal. All real config lives under conf.d/.
dovecot_config_version = 2.4.0
dovecot_storage_version = 2.4.0
# --------------------------------------------------------------------------
# Please create a file "extra.conf" for persistent overrides to dovecot.conf
# --------------------------------------------------------------------------
# LDAP example:
#passdb {
# args = /etc/dovecot/ldap/passdb.conf
# driver = ldap
#}
listen = *,[::]
auth_mechanisms = plain login
#mail_debug = yes
#auth_debug = yes
#log_debug = category=fts-flatcurve # Activate Logging for Flatcurve FTS Searchings
log_path = syslog
disable_plaintext_auth = yes
# Uncomment on NFS share
#mmap_disable = yes
#mail_fsync = always
#mail_nfs_index = yes
#mail_nfs_storage = yes
login_log_format_elements = "user=<%u> method=%m rip=%r lip=%l mpid=%e %c %k"
mail_home = /var/vmail/%d/%n
mail_location = maildir:~/
mail_plugins = </etc/dovecot/mail_plugins
mail_attachment_fs = crypt:set_prefix=mail_crypt_global:posix:
mail_attachment_dir = /var/attachments
mail_attachment_min_size = 128k
# Significantly speeds up very large mailboxes, but is only safe to enable if
# you do not manually modify the files in the `cur` directories in
# mailcowdockerized_vmail-vol-1.
# https://docs.mailcow.email/manual-guides/Dovecot/u_e-dovecot-performance/
maildir_very_dirty_syncs = yes
# Dovecot 2.2
#ssl_protocols = !SSLv3
# Dovecot 2.3
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes
ssl_cipher_list = ALL:!ADH:!LOW:!SSLv2:!SSLv3:!EXP:!aNULL:!eNULL:!3DES:!MD5:!PSK:!DSS:!RC4:!SEED:!IDEA:+HIGH:+MEDIUM
# Default in Dovecot 2.3
ssl_options = no_compression no_ticket
# New in Dovecot 2.3
ssl_dh = </etc/ssl/mail/dhparams.pem
# Dovecot 2.2
#ssl_dh_parameters_length = 2048
log_timestamp = "%Y-%m-%d %H:%M:%S "
recipient_delimiter = +
auth_master_user_separator = *
mail_shared_explicit_inbox = yes
mail_prefetch_count = 30
passdb {
driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
result_success = return-ok
result_failure = continue
result_internalfail = continue
}
# try a master passwd
passdb {
driver = passwd-file
args = /etc/dovecot/dovecot-master.passwd
master = yes
skip = authenticated
}
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
# a return of the following passdb is mandatory
passdb {
driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes
}
# Set doveadm_password=your-secret-password in data/conf/dovecot/extra.conf (create if missing)
service doveadm {
inet_listener {
port = 12345
}
vsz_limit=2048 MB
}
!include /etc/dovecot/dovecot.folders.conf
protocols = imap sieve lmtp pop3
service dict {
unix_listener dict {
mode = 0660
user = vmail
group = vmail
}
}
service log {
user = dovenull
}
service config {
unix_listener config {
user = root
group = vmail
mode = 0660
}
}
service auth {
inet_listener auth-inet {
port = 10001
}
unix_listener auth-master {
mode = 0600
user = vmail
}
unix_listener auth-userdb {
mode = 0600
user = vmail
}
vsz_limit = 2G
}
service managesieve-login {
inet_listener sieve {
port = 4190
}
inet_listener sieve_haproxy {
port = 14190
haproxy = yes
}
service_count = 1
process_min_avail = 2
vsz_limit = 1G
}
service imap-login {
service_count = 1
process_min_avail = 2
process_limit = 10000
vsz_limit = 1G
user = dovenull
inet_listener imap_haproxy {
port = 10143
haproxy = yes
}
inet_listener imaps_haproxy {
port = 10993
ssl = yes
haproxy = yes
}
}
service pop3-login {
service_count = 1
process_min_avail = 1
vsz_limit = 1G
inet_listener pop3_haproxy {
port = 10110
haproxy = yes
}
inet_listener pop3s_haproxy {
port = 10995
ssl = yes
haproxy = yes
}
}
service imap {
executable = imap
user = vmail
vsz_limit = 1G
}
service managesieve {
process_limit = 256
}
service lmtp {
inet_listener lmtp-inet {
port = 24
}
user = vmail
}
listen = *,[::]
ssl_cert = </etc/ssl/mail/cert.pem
ssl_key = </etc/ssl/mail/key.pem
userdb {
driver = passwd-file
args = /etc/dovecot/dovecot-master.userdb
}
userdb {
args = /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
driver = sql
skip = found
}
protocol imap {
mail_plugins = </etc/dovecot/mail_plugins_imap
imap_metadata = yes
}
mail_attribute_dict = file:%h/dovecot-attributes
protocol lmtp {
mail_plugins = </etc/dovecot/mail_plugins_lmtp
auth_socket_path = /var/run/dovecot/auth-master
}
protocol sieve {
managesieve_logout_format = bytes=%i/%o
}
plugin {
# Allow "any" or "authenticated" to be used in ACLs
acl_anyone = </etc/dovecot/acl_anyone
acl_shared_dict = file:/var/vmail/shared-mailboxes.db
acl = vfile
acl_user = %u
quota = dict:Userquota::proxy::sqlquota
quota_rule2 = Trash:storage=+100%%
sieve = /var/vmail/sieve/%u.sieve
sieve_plugins = sieve_imapsieve sieve_extprograms
sieve_vacation_send_from_recipient = yes
sieve_redirect_envelope_from = recipient
# From elsewhere to Spam folder
imapsieve_mailbox1_name = Junk
imapsieve_mailbox1_causes = COPY
imapsieve_mailbox1_before = file:/usr/lib/dovecot/sieve/report-spam.sieve
# END
# From Spam folder to elsewhere
imapsieve_mailbox2_name = *
imapsieve_mailbox2_from = Junk
imapsieve_mailbox2_causes = COPY
imapsieve_mailbox2_before = file:/usr/lib/dovecot/sieve/report-ham.sieve
# END
master_user = %u
quota_warning = storage=95%% quota-warning 95 %u
quota_warning2 = storage=80%% quota-warning 80 %u
sieve_pipe_bin_dir = /usr/lib/dovecot/sieve
sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.execute
sieve_extensions = +notify +imapflags +vacation-seconds +editheader
sieve_max_script_size = 1M
sieve_max_redirects = 100
sieve_max_actions = 101
sieve_quota_max_scripts = 0
sieve_quota_max_storage = 0
listescape_char = "\\"
sieve_vacation_min_period = 5s
sieve_vacation_max_period = 0
sieve_vacation_default_period = 60s
sieve_before = /var/vmail/sieve/global_sieve_before.sieve
sieve_before2 = dict:proxy::sieve_before;name=active;bindir=/var/vmail/sieve_before_bindir
sieve_after = dict:proxy::sieve_after;name=active;bindir=/var/vmail/sieve_after_bindir
sieve_after2 = /var/vmail/sieve/global_sieve_after.sieve
sieve_duplicate_default_period = 1m
sieve_duplicate_max_period = 7d
!include_try /etc/dovecot/conf.d/05-core.conf
!include_try /etc/dovecot/conf.d/10-logging.conf
!include_try /etc/dovecot/conf.d/10-mail.conf
!include_try /etc/dovecot/conf.d/10-ssl.conf
!include_try /etc/dovecot/conf.d/11-sql.conf
!include_try /etc/dovecot/conf.d/12-mysql.conf
!include_try /etc/dovecot/conf.d/12-storage-attachments.conf
!include_try /etc/dovecot/conf.d/15-performance.conf
!include_try /etc/dovecot/conf.d/20-auth.conf
!include_try /etc/dovecot/conf.d/20-userdb.conf
!include_try /etc/dovecot/conf.d/25-services.conf
!include_try /etc/dovecot/conf.d/30-protocols.conf
!include_try /etc/dovecot/conf.d/35-fts.conf
!include_try /etc/dovecot/conf.d/40-acl.conf
!include_try /etc/dovecot/conf.d/40-attributes.conf
!include_try /etc/dovecot/conf.d/50-quota.conf
!include_try /etc/dovecot/conf.d/60-sieve-pipeline.conf
!include_try /etc/dovecot/conf.d/70-crypto.conf
!include_try /etc/dovecot/conf.d/80-compress.conf
!include_try /etc/dovecot/conf.d/80-mail-logging.conf
!include_try /etc/dovecot/conf.d/90-limits.conf
!include_try /etc/dovecot/conf.d/90-dict.conf
!include_try /etc/dovecot/conf.d/99-includes.conf
# -- Global keys
mail_crypt_global_private_key = </mail_crypt/ecprivkey.pem
mail_crypt_global_public_key = </mail_crypt/ecpubkey.pem
mail_crypt_save_version = 2
# Last: local overrides
!include_try /etc/dovecot/extra.conf
# Enable compression while saving, lz4 Dovecot v2.3.17+
zlib_save = lz4
mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
mail_log_fields = uid box msgid size
mail_log_cached_only = yes
# Try set mail_replica
!include_try /etc/dovecot/mail_replica.conf
}
service quota-warning {
executable = script /usr/local/bin/quota_notify.py
# use some unprivileged user for executing the quota warnings
user = vmail
unix_listener quota-warning {
user = vmail
}
}
dict {
sqlquota = mysql:/etc/dovecot/sql/dovecot-dict-sql-quota.conf
sieve_after = mysql:/etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf
sieve_before = mysql:/etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf
}
remote 127.0.0.1 {
disable_plaintext_auth = no
}
submission_host = postfix:588
mail_max_userip_connections = 500
service stats {
unix_listener stats-writer {
mode = 0660
user = vmail
}
}
imap_max_line_length = 2 M
auth_cache_verify_password_with_worker = yes
auth_cache_negative_ttl = 60s
auth_cache_ttl = 300s
auth_cache_size = 10M
auth_verbose_passwords = sha1:6
service replicator {
process_min_avail = 1
}
service aggregator {
fifo_listener replication-notify-fifo {
user = vmail
}
unix_listener replication-notify {
user = vmail
}
}
service replicator {
unix_listener replicator-doveadm {
mode = 0666
}
}
replication_max_conns = 10
doveadm_port = 12345
replication_dsync_parameters = -d -l 30 -U -n INBOX
# <Includes>
!include_try /etc/dovecot/sni.conf
!include_try /etc/dovecot/sogo_trusted_ip.conf
!include_try /etc/dovecot/extra.conf
!include_try /etc/dovecot/shared_namespace.conf
!include_try /etc/dovecot/conf.d/fts.conf
# </Includes>
default_client_limit = 10400
default_vsz_limit = 1024 M

View File

@@ -1,14 +1,10 @@
namespace inbox {
inbox = yes
location =
separator = /
mailbox storage/* {
quota_storage_extra = 100M
}
mailbox "Trash" {
auto = subscribe
special_use = \Trash
quota_storage_percentage = 100
fts_autoindex = no
}
mailbox "Deleted Messages" {
special_use = \Trash
@@ -199,7 +195,6 @@ namespace inbox {
mailbox "Junk" {
auto = subscribe
special_use = \Junk
fts_autoindex = no
}
mailbox "Junk-E-Mail" {
special_use = \Junk

View File

@@ -13,6 +13,7 @@ events {
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
@@ -78,7 +79,7 @@ http {
{%endif%}
listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
{% if not DISABLE_IPv6 %}
{% if ENABLE_IPV6 %}
{% if not HTTP_REDIRECT %}
listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
{%endif%}
@@ -105,7 +106,7 @@ http {
{%endif%}
listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
{% if not DISABLE_IPv6 %}
{% if ENABLE_IPV6 %}
{% if not HTTP_REDIRECT %}
listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
{%endif%}
@@ -126,7 +127,7 @@ http {
# rspamd dynmaps:
server {
listen 8081;
{% if not DISABLE_IPv6 %}
{% if ENABLE_IPV6 %}
listen [::]:8081;
{%endif%}
index index.php index.html;
@@ -199,7 +200,7 @@ http {
{%endif%}
listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
{% if not DISABLE_IPv6 %}
{% if ENABLE_IPV6 %}
{% if not HTTP_REDIRECT %}
listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
{%endif%}

View File

@@ -14,7 +14,6 @@ ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=15768000;";
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header X-Robots-Tag none;
add_header X-Download-Options noopen;
add_header X-Frame-Options "SAMEORIGIN" always;
@@ -186,6 +185,7 @@ location ^~ /Microsoft-Server-ActiveSync {
auth_request_set $user $upstream_http_x_user;
auth_request_set $auth $upstream_http_x_auth;
auth_request_set $auth_type $upstream_http_x_auth_type;
auth_request_set $real_ip $remote_addr;
proxy_set_header x-webobjects-remote-user "$user";
proxy_set_header Authorization "$auth";
proxy_set_header x-webobjects-auth-type "$auth_type";
@@ -211,6 +211,7 @@ location ^~ /SOGo {
auth_request_set $user $upstream_http_x_user;
auth_request_set $auth $upstream_http_x_auth;
auth_request_set $auth_type $upstream_http_x_auth_type;
auth_request_set $real_ip $remote_addr;
proxy_set_header x-webobjects-remote-user "$user";
proxy_set_header Authorization "$auth";
proxy_set_header x-webobjects-auth-type "$auth_type";
@@ -233,6 +234,7 @@ location ^~ /SOGo {
auth_request_set $user $upstream_http_x_user;
auth_request_set $auth $upstream_http_x_auth;
auth_request_set $auth_type $upstream_http_x_auth_type;
auth_request_set $real_ip $remote_addr;
proxy_set_header x-webobjects-remote-user "$user";
proxy_set_header Authorization "$auth";
proxy_set_header x-webobjects-auth-type "$auth_type";

Some files were not shown because too many files have changed in this diff Show More