From 43ace1ded99fdbdb32c8b7192d54febcdfe8545a Mon Sep 17 00:00:00 2001 From: Spitap Date: Wed, 26 Mar 2025 15:08:26 +0100 Subject: [PATCH 01/11] Use RQ instead of cron - mailbox ops --- modoboa/admin/jobs.py | 105 ++++++++++++++++++ .../commands/handle_mailbox_operations.py | 103 ----------------- modoboa/admin/models/mailbox.py | 15 ++- test_project/test_project/settings.py | 3 + 4 files changed, 121 insertions(+), 105 deletions(-) create mode 100644 modoboa/admin/jobs.py delete mode 100644 modoboa/admin/management/commands/handle_mailbox_operations.py diff --git a/modoboa/admin/jobs.py b/modoboa/admin/jobs.py new file mode 100644 index 000000000..4526a5aa9 --- /dev/null +++ b/modoboa/admin/jobs.py @@ -0,0 +1,105 @@ +"""Script to register RQ jobs.""" + + +def rename_mailbox(op_id): + """Rename the mailbox folder through a RQ Job.""" + import logging + import os + + from modoboa.admin.app_settings import load_admin_settings + from modoboa.admin.models.mailbox import MailboxOperation + from modoboa.lib.sysutils import exec_cmd + from modoboa.parameters import tools as param_tools + + logger = logging.getLogger("modoboa.admin") + + load_admin_settings() + if not param_tools.get_global_parameter("handle_mailboxes"): + return + try: + operation = MailboxOperation.objects.get(pk=op_id) + except MailboxOperation.DoesNotExist: + logger.error(f"Failed to retrieve operation id {op_id} for renaming") + return + if not os.path.exists(operation.argument): + logger.error(f"Failed to rename {operation.argument}, folder not found") + operation.delete() + return + new_mail_home = operation.mailbox.mail_home + dirname = os.path.dirname(new_mail_home) + if not os.path.exists(dirname): + try: + os.makedirs(dirname) + except OSError as e: + reason = str(e).decode("utf-8") + logger.critical( + f"renaming of {operation.argument} to {new_mail_home} failed (reason: {reason})" + ) + return + code, output = exec_cmd(f"mv {operation.argument} {new_mail_home}") + if code: + logger.critical(f"Renaming of {new_mail_home} failed (reason: {output})") + return + operation.delete() + + +def delete_mailbox(op_id): + """Delete the mailbox folder through a RQ Job.""" + import logging + import os + import shutil + + from modoboa.admin.app_settings import load_admin_settings + from modoboa.admin.models.mailbox import MailboxOperation + from modoboa.parameters import tools as param_tools + + logger = logging.getLogger("modoboa.admin") + + load_admin_settings() + if not param_tools.get_global_parameter("handle_mailboxes"): + return + + try: + operation = MailboxOperation.objects.get(pk=op_id) + except MailboxOperation.DoesNotExist: + logger.error(f"Failed to retrieve operation id {op_id} for deletion") + return + + if not os.path.exists(operation.argument): + logger.error(f"Failed to delete {operation.argument}, folder not found") + operation.delete() + return + + def onerror(function, path, excinfo): + import logging + + """Handle errors.""" + logger = logging.getLogger("modoboa.admin") + logger.critical(f"delete failed (reason: {excinfo})") + operation.delete() + + shutil.rmtree(operation.argument, False, onerror) + operation.delete() + + +def handle_remaining_operation(): + import logging + + import django_rq + + from modoboa.parameters import tools as param_tools + from modoboa.admin.app_settings import load_admin_settings + from modoboa.admin.models.mailbox import MailboxOperation + + load_admin_settings() + if not param_tools.get_global_parameter("handle_mailboxes"): + return + queue = django_rq.get_queue("dovecot") + if queue.started_job_registry.count > 0 or queue.scheduled_job_registry.count > 0: + return + logger = logging.getLogger("modoboa.admin") + for ope in MailboxOperation.objects.all(): + if ope.type == "rename": + rename_mailbox(ope.pk) + elif ope.type == "delete": + delete_mailbox(ope.pk) diff --git a/modoboa/admin/management/commands/handle_mailbox_operations.py b/modoboa/admin/management/commands/handle_mailbox_operations.py deleted file mode 100644 index f6274f484..000000000 --- a/modoboa/admin/management/commands/handle_mailbox_operations.py +++ /dev/null @@ -1,103 +0,0 @@ -"""A management command to apply mailbox operations.""" - -import logging -import os -import shutil - -from django.core.management.base import BaseCommand - -from modoboa.lib.exceptions import InternalError -from modoboa.lib.sysutils import exec_cmd -from modoboa.parameters import tools as param_tools -from ...app_settings import load_admin_settings -from ...models import MailboxOperation - - -class OperationError(Exception): - """Custom exception.""" - - pass - - -class Command(BaseCommand): - """Command definition""" - - help = "Handles rename and delete operations on mailboxes" # NOQA:A003 - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.logger = logging.getLogger("modoboa.admin") - - def add_arguments(self, parser): - """Add extra arguments to command line.""" - parser.add_argument( - "--pidfile", - default="/tmp/handle_mailbox_operations.pid", - help="Path to the file that will contain the PID of this process", - ) - - def rename_mailbox(self, operation): - if not os.path.exists(operation.argument): - return - new_mail_home = operation.mailbox.mail_home - dirname = os.path.dirname(new_mail_home) - if not os.path.exists(dirname): - try: - os.makedirs(dirname) - except OSError as e: - raise OperationError(str(e)) from None - code, output = exec_cmd(f"mv {operation.argument} {new_mail_home}") - if code: - raise OperationError(output) - - def delete_mailbox(self, operation): - """Try to delete a mailbox tree on filesystem.""" - if not os.path.exists(operation.argument): - return - - def onerror(function, path, excinfo): - """Handle errors.""" - self.logger.critical(f"delete failed (reason: {excinfo})") - - shutil.rmtree(operation.argument, False, onerror) - - def check_pidfile(self, path): - """Check if this command is already running - - :param str path: path to the file containing the PID - :return: a boolean, True means we can go further - """ - if os.path.exists(path): - with open(path) as fp: - pid = fp.read().strip() - code, output = exec_cmd( - f"grep handle_mailbox_operations /proc/{pid}/cmdline" - ) - if not code: - return False - with open(path, "w") as fp: - print(os.getpid(), file=fp) - return True - - def handle(self, *args, **options): - """Command entry point.""" - load_admin_settings() - if not param_tools.get_global_parameter("handle_mailboxes"): - return - if not self.check_pidfile(options["pidfile"]): - return - for ope in MailboxOperation.objects.all(): - try: - f = getattr(self, f"{ope.type}_mailbox") - except AttributeError: - continue - try: - f(ope) - except (OperationError, InternalError) as e: - self.logger.critical( - "%s failed (reason: %s)", ope, str(e).decode("utf-8") - ) - else: - self.logger.info("%s succeed", ope) - ope.delete() - os.unlink(options["pidfile"]) diff --git a/modoboa/admin/models/mailbox.py b/modoboa/admin/models/mailbox.py index 3d667b790..113e83cfe 100644 --- a/modoboa/admin/models/mailbox.py +++ b/modoboa/admin/models/mailbox.py @@ -9,12 +9,15 @@ from django.db.models.manager import Manager from django.utils.encoding import smart_str, force_str from django.utils.translation import gettext as _, gettext_lazy +from modoboa.admin import jobs from modoboa.core.models import User from modoboa.lib import exceptions as lib_exceptions from modoboa.lib.email_utils import split_mailbox from modoboa.lib.sysutils import doveadm_cmd from modoboa.parameters import tools as param_tools +import django_rq + from .base import AdminObject from .domain import Domain from . import mixins @@ -192,9 +195,12 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject): hm = param_tools.get_global_parameter("handle_mailboxes", raise_exception=False) if not hm: return - MailboxOperation.objects.create( + mail_op = MailboxOperation.objects.create( mailbox=self, type="rename", argument=old_mail_home ) + mail_op.save() + dovecot_queue = django_rq.get_queue("dovecot") + dovecot_queue.enqueue(jobs.rename_mailbox, mail_op.pk) def rename(self, address, domain): """Rename the mailbox. @@ -223,7 +229,12 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject): hm = param_tools.get_global_parameter("handle_mailboxes", raise_exception=False) if not hm: return - MailboxOperation.objects.create(type="delete", argument=self.mail_home) + mail_op = MailboxOperation.objects.create( + type="delete", argument=self.mail_home + ) + mail_op.save() + dovecot_queue = django_rq.get_queue("dovecot") + dovecot_queue.enqueue(jobs.delete_mailbox, mail_op.pk) def set_quota(self, value=None, override_rules=False): """Set or update quota value for this mailbox. diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 70f45ea33..8d3ffb235 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -303,6 +303,9 @@ RQ_QUEUES = { "modoboa": { "URL": REDIS_URL, }, + "dovecot": { + "URL": REDIS_URL, + }, } # CACHE From 4d602582986ed669ce2d44f90522d2a6f6f63fde Mon Sep 17 00:00:00 2001 From: Adrien P Date: Thu, 17 Jul 2025 11:27:47 +0200 Subject: [PATCH 02/11] Some update --- .../core/commands/templates/settings.py.tpl | 6 ++++ modoboa/core/jobs.py | 14 ++++++++ .../core/management/commands/job_heartbeat.py | 32 +++++++++++++++++++ modoboa/core/password_hashers/base.py | 5 ++- pyproject.toml | 1 + update_deps.sh | 8 ----- 6 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 modoboa/core/jobs.py create mode 100644 modoboa/core/management/commands/job_heartbeat.py delete mode 100755 update_deps.sh diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index ad9246b76..1b58ce695 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -296,6 +296,12 @@ RQ_QUEUES = { 'DB': REDIS_QUOTA_DB, 'URL': REDIS_URL, }, + 'dovecot': { + 'HOST': REDIS_HOST, + 'PORT': REDIS_PORT, + 'DB': REDIS_QUOTA_DB, + 'URL': REDIS_URL, + }, } # CACHE diff --git a/modoboa/core/jobs.py b/modoboa/core/jobs.py new file mode 100644 index 000000000..2c8a1c621 --- /dev/null +++ b/modoboa/core/jobs.py @@ -0,0 +1,14 @@ +"""Core Jobs""" + + +def clean_log(): + import datetime + + from modoboa.core.models import Log + from modoboa.parameters import tools as param_tools + + from django.utils import timezone + + log_maximum_age = param_tools.get_global_parameter("log_maximum_age") + limit = timezone.now() - datetime.timedelta(log_maximum_age) + Log.objects.filter(date_created__lt=limit).delete() diff --git a/modoboa/core/management/commands/job_heartbeat.py b/modoboa/core/management/commands/job_heartbeat.py new file mode 100644 index 000000000..f182a92e2 --- /dev/null +++ b/modoboa/core/management/commands/job_heartbeat.py @@ -0,0 +1,32 @@ +import datetime +import logging + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from modoboa.core.models import Log +from modoboa.parameters import tools as param_tools + +JOB_IDs = ["clearsessions_r", "cleanlogs_r", "logparser_r"] +AMAVIS_IDs = ["qcleanup_r", "amnotify_r"] + + +class Command(BaseCommand): + """Command class.""" + + help = "Checks that redondants jobs are started or start them" # NOQA:A003 + + def add_arguments(self, parser): + """Add extra arguments to command line.""" + parser.add_argument( + "--queue-now", + action="store_true", + default=False, + help="Queue all redondant job now", + ) + + def check_clean_log(queue_now=False): + pass + + def handle(self, *args, **options): + self.check_clean_log(options["queue_now"]) diff --git a/modoboa/core/password_hashers/base.py b/modoboa/core/password_hashers/base.py index dcba941df..9d821dc6f 100644 --- a/modoboa/core/password_hashers/base.py +++ b/modoboa/core/password_hashers/base.py @@ -5,7 +5,10 @@ Contains weak hashers (the original ones) available with Modoboa. """ import base64 + import hashlib +from random import Random +import string from typing import Optional from django.utils.crypto import constant_time_compare @@ -49,7 +52,7 @@ class PasswordHasher(metaclass=MetaHasher): def __init__(self, target="local"): self._target = target - def _encrypt(self, clearvalue: str, salt: Optional[str] = None) -> str: # NOQA + def _encrypt(self, clearvalue: str, salt: Optional[str] = None) -> str: raise NotImplementedError def _b64encode(self, pwhash): diff --git a/pyproject.toml b/pyproject.toml index f8d57efc7..7a6deed71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ dependencies = [ # RQ "rq>=1.13.0", "django-rq", + "rq-scheduler", # Sieve "sievelib>=1.4.1", # User diff --git a/update_deps.sh b/update_deps.sh deleted file mode 100755 index 17e02ae5d..000000000 --- a/update_deps.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh - -pip-compile --output-file=requirements.txt pyproject.toml -pip-compile --extra=mysql --output-file=mysql-requirements.txt pyproject.toml -pip-compile --extra=postgresql --output-file=postgresql-requirements.txt pyproject.toml -pip-compile --extra=ldap --output-file=ldap-requirements.txt pyproject.toml -pip-compile --extra=dev --output-file=dev-requirements.txt pyproject.toml -pip-compile --extra=test --output-file=test-requirements.txt pyproject.toml From c147eb80e4ff4c0f8a2cbfe635c88e9e38469eb2 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Tue, 6 Jan 2026 15:30:53 +0100 Subject: [PATCH 03/11] Only use RQ 2.4+ and its cron capabilities. --- docker-compose.yml | 17 +++++- modoboa/admin/jobs.py | 76 ++++++------------------ modoboa/admin/models/mailbox.py | 15 +---- modoboa/core/jobs.py | 32 +++++++--- pyproject.toml | 3 +- test_project/test_project/cron_config.py | 8 +++ test_project/test_project/settings.py | 1 + 7 files changed, 68 insertions(+), 84 deletions(-) create mode 100644 test_project/test_project/cron_config.py diff --git a/docker-compose.yml b/docker-compose.yml index 738fa68e1..4b07aa5bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,22 @@ services: PYTHONPATH: ".." PYTHONUNBUFFERED: 1 command: > - sh -c "cd test_project; python3 manage.py rqworker dkim modoboa" + sh -c "cd test_project; python3 manage.py rqworker dkim modoboa dovecot" + volumes: + - .:/code + + rq-scheduler: + build: + context: . + dockerfile: docker/Dockerfile.dev + environment: + DB: "SQLITE" + DEBUG: 1 + REDIS_HOST: redis + PYTHONPATH: ".." + PYTHONUNBUFFERED: 1 + command: > + sh -c "cd test_project; python3 manage.py rqcron test_project/cron_config.py" volumes: - .:/code diff --git a/modoboa/admin/jobs.py b/modoboa/admin/jobs.py index 4526a5aa9..836940dab 100644 --- a/modoboa/admin/jobs.py +++ b/modoboa/admin/jobs.py @@ -1,26 +1,19 @@ -"""Script to register RQ jobs.""" +"""Async jobs definition.""" + +import logging +import os +import shutil + +from modoboa.admin.app_settings import load_admin_settings +from modoboa.admin.models.mailbox import MailboxOperation +from modoboa.lib.sysutils import exec_cmd +from modoboa.parameters import tools as param_tools + +logger = logging.getLogger("modoboa.jobs") -def rename_mailbox(op_id): +def rename_mailbox(operation): """Rename the mailbox folder through a RQ Job.""" - import logging - import os - - from modoboa.admin.app_settings import load_admin_settings - from modoboa.admin.models.mailbox import MailboxOperation - from modoboa.lib.sysutils import exec_cmd - from modoboa.parameters import tools as param_tools - - logger = logging.getLogger("modoboa.admin") - - load_admin_settings() - if not param_tools.get_global_parameter("handle_mailboxes"): - return - try: - operation = MailboxOperation.objects.get(pk=op_id) - except MailboxOperation.DoesNotExist: - logger.error(f"Failed to retrieve operation id {op_id} for renaming") - return if not os.path.exists(operation.argument): logger.error(f"Failed to rename {operation.argument}, folder not found") operation.delete() @@ -43,38 +36,15 @@ def rename_mailbox(op_id): operation.delete() -def delete_mailbox(op_id): +def delete_mailbox(operation): """Delete the mailbox folder through a RQ Job.""" - import logging - import os - import shutil - - from modoboa.admin.app_settings import load_admin_settings - from modoboa.admin.models.mailbox import MailboxOperation - from modoboa.parameters import tools as param_tools - - logger = logging.getLogger("modoboa.admin") - - load_admin_settings() - if not param_tools.get_global_parameter("handle_mailboxes"): - return - - try: - operation = MailboxOperation.objects.get(pk=op_id) - except MailboxOperation.DoesNotExist: - logger.error(f"Failed to retrieve operation id {op_id} for deletion") - return - if not os.path.exists(operation.argument): logger.error(f"Failed to delete {operation.argument}, folder not found") operation.delete() return def onerror(function, path, excinfo): - import logging - """Handle errors.""" - logger = logging.getLogger("modoboa.admin") logger.critical(f"delete failed (reason: {excinfo})") operation.delete() @@ -82,24 +52,12 @@ def delete_mailbox(op_id): operation.delete() -def handle_remaining_operation(): - import logging - - import django_rq - - from modoboa.parameters import tools as param_tools - from modoboa.admin.app_settings import load_admin_settings - from modoboa.admin.models.mailbox import MailboxOperation - +def handle_mailbox_operations(): load_admin_settings() if not param_tools.get_global_parameter("handle_mailboxes"): return - queue = django_rq.get_queue("dovecot") - if queue.started_job_registry.count > 0 or queue.scheduled_job_registry.count > 0: - return - logger = logging.getLogger("modoboa.admin") for ope in MailboxOperation.objects.all(): if ope.type == "rename": - rename_mailbox(ope.pk) + rename_mailbox(ope) elif ope.type == "delete": - delete_mailbox(ope.pk) + delete_mailbox(ope) diff --git a/modoboa/admin/models/mailbox.py b/modoboa/admin/models/mailbox.py index 113e83cfe..3d667b790 100644 --- a/modoboa/admin/models/mailbox.py +++ b/modoboa/admin/models/mailbox.py @@ -9,15 +9,12 @@ from django.db.models.manager import Manager from django.utils.encoding import smart_str, force_str from django.utils.translation import gettext as _, gettext_lazy -from modoboa.admin import jobs from modoboa.core.models import User from modoboa.lib import exceptions as lib_exceptions from modoboa.lib.email_utils import split_mailbox from modoboa.lib.sysutils import doveadm_cmd from modoboa.parameters import tools as param_tools -import django_rq - from .base import AdminObject from .domain import Domain from . import mixins @@ -195,12 +192,9 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject): hm = param_tools.get_global_parameter("handle_mailboxes", raise_exception=False) if not hm: return - mail_op = MailboxOperation.objects.create( + MailboxOperation.objects.create( mailbox=self, type="rename", argument=old_mail_home ) - mail_op.save() - dovecot_queue = django_rq.get_queue("dovecot") - dovecot_queue.enqueue(jobs.rename_mailbox, mail_op.pk) def rename(self, address, domain): """Rename the mailbox. @@ -229,12 +223,7 @@ class Mailbox(mixins.MessageLimitMixin, AdminObject): hm = param_tools.get_global_parameter("handle_mailboxes", raise_exception=False) if not hm: return - mail_op = MailboxOperation.objects.create( - type="delete", argument=self.mail_home - ) - mail_op.save() - dovecot_queue = django_rq.get_queue("dovecot") - dovecot_queue.enqueue(jobs.delete_mailbox, mail_op.pk) + MailboxOperation.objects.create(type="delete", argument=self.mail_home) def set_quota(self, value=None, override_rules=False): """Set or update quota value for this mailbox. diff --git a/modoboa/core/jobs.py b/modoboa/core/jobs.py index 2c8a1c621..62ecb8f83 100644 --- a/modoboa/core/jobs.py +++ b/modoboa/core/jobs.py @@ -1,14 +1,28 @@ -"""Core Jobs""" +"""Async jobs definition.""" + +import datetime +import logging + +from modoboa.core.models import Log +from modoboa.maillog.models import Maillog +from modoboa.parameters import tools as param_tools + +from django.utils import timezone + +logger = logging.getLogger("modoboa.jobs") -def clean_log(): - import datetime - - from modoboa.core.models import Log - from modoboa.parameters import tools as param_tools - - from django.utils import timezone - +def clean_logs(): log_maximum_age = param_tools.get_global_parameter("log_maximum_age") + logger.info(f"Deleting logs older than {log_maximum_age} days...") limit = timezone.now() - datetime.timedelta(log_maximum_age) Log.objects.filter(date_created__lt=limit).delete() + + message_history_maximum_age = param_tools.get_global_parameter( + "message_history_maximum_age" + ) + logger.info( + f"Deleting messages in history older than {message_history_maximum_age} days..." + ) + limit = timezone.now() - datetime.timedelta(message_history_maximum_age) + Maillog.objects.filter(date__lt=limit).delete() diff --git a/pyproject.toml b/pyproject.toml index 7a6deed71..42ef78220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,9 +70,8 @@ dependencies = [ "defusedxml>=0.6.0", "python-magic>=0.4.24", # RQ - "rq>=1.13.0", + "rq>=2.4.0", "django-rq", - "rq-scheduler", # Sieve "sievelib>=1.4.1", # User diff --git a/test_project/test_project/cron_config.py b/test_project/test_project/cron_config.py new file mode 100644 index 000000000..d98b3abdf --- /dev/null +++ b/test_project/test_project/cron_config.py @@ -0,0 +1,8 @@ +from rq.cron import register + +from modoboa.core import jobs as core_jobs +from modoboa.admin import jobs as admin_jobs + + +register(admin_jobs.handle_mailbox_operations, queue_name="dovecot", cron="* * * * *") +register(core_jobs.clean_logs, queue_name="modoboa", cron="0 0 * * *") diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 8d3ffb235..1576326df 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -385,6 +385,7 @@ LOGGING = { }, "modoboa.admin": {"handlers": ["modoboa"], "level": "INFO", "propagate": False}, "modoboa.dns": {"handlers": ["syslog"], "level": "INFO", "propagate": False}, + "modoboa.jobs": {"handlers": ["syslog"], "level": "INFO", "propagate": False}, "django.server": { "handlers": ["django.server"], "level": "INFO", From 6d890d02b2349685f28c69616a65584409d7e0f0 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Tue, 6 Jan 2026 15:46:27 +0100 Subject: [PATCH 04/11] Wrap maillog management commands with simple functions. --- modoboa/maillog/jobs.py | 11 +++++++++++ test_project/test_project/cron_config.py | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 modoboa/maillog/jobs.py diff --git a/modoboa/maillog/jobs.py b/modoboa/maillog/jobs.py new file mode 100644 index 000000000..887a2820c --- /dev/null +++ b/modoboa/maillog/jobs.py @@ -0,0 +1,11 @@ +"""Async jobs definition.""" + +from django.core.management import call_command + + +def logparser(): + call_command("logparser") + + +def update_statistics(): + call_command("update_statistics") diff --git a/test_project/test_project/cron_config.py b/test_project/test_project/cron_config.py index d98b3abdf..0df7d99ea 100644 --- a/test_project/test_project/cron_config.py +++ b/test_project/test_project/cron_config.py @@ -2,7 +2,12 @@ from rq.cron import register from modoboa.core import jobs as core_jobs from modoboa.admin import jobs as admin_jobs +from modoboa.maillog import jobs as maillog_jobs +register(core_jobs.clean_logs, queue_name="modoboa", cron="0 0 * * *") + register(admin_jobs.handle_mailbox_operations, queue_name="dovecot", cron="* * * * *") -register(core_jobs.clean_logs, queue_name="modoboa", cron="0 0 * * *") + +register(maillog_jobs.logparser, queue_name="modoboa", cron="*/5 * * * *") +register(maillog_jobs.update_statistics, queue_name="modoboa", cron="0 * * * *") From 4ca4f9ab7fa332686c4e089446b3988c65b6f75c Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Wed, 7 Jan 2026 18:20:55 +0100 Subject: [PATCH 05/11] New jobs for amavis and calendars apps. --- modoboa/amavis/jobs.py | 11 +++++++ modoboa/calendars/jobs.py | 7 ++++ modoboa/core/jobs.py | 6 ++++ .../core/management/commands/job_heartbeat.py | 32 ------------------- test_project/test_project/cron_config.py | 21 ++++++++++++ 5 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 modoboa/amavis/jobs.py create mode 100644 modoboa/calendars/jobs.py delete mode 100644 modoboa/core/management/commands/job_heartbeat.py diff --git a/modoboa/amavis/jobs.py b/modoboa/amavis/jobs.py new file mode 100644 index 000000000..a5dbc5691 --- /dev/null +++ b/modoboa/amavis/jobs.py @@ -0,0 +1,11 @@ +"""Async jobs definition.""" + +from django.core.management import call_command + + +def amnotify(): + call_command("amnotify") + + +def qcleanup(): + call_command("qcleanup") diff --git a/modoboa/calendars/jobs.py b/modoboa/calendars/jobs.py new file mode 100644 index 000000000..1af38fa81 --- /dev/null +++ b/modoboa/calendars/jobs.py @@ -0,0 +1,7 @@ +"""Async jobs definition.""" + +from django.core.management import call_command + + +def generate_rights(): + call_command("generate_rights") diff --git a/modoboa/core/jobs.py b/modoboa/core/jobs.py index 62ecb8f83..c5f64104d 100644 --- a/modoboa/core/jobs.py +++ b/modoboa/core/jobs.py @@ -3,6 +3,8 @@ import datetime import logging +from django.core.management import call_command + from modoboa.core.models import Log from modoboa.maillog.models import Maillog from modoboa.parameters import tools as param_tools @@ -26,3 +28,7 @@ def clean_logs(): ) limit = timezone.now() - datetime.timedelta(message_history_maximum_age) Maillog.objects.filter(date__lt=limit).delete() + + +def communicate_with_public_api(): + call_command("communicate_with_public_api") diff --git a/modoboa/core/management/commands/job_heartbeat.py b/modoboa/core/management/commands/job_heartbeat.py deleted file mode 100644 index f182a92e2..000000000 --- a/modoboa/core/management/commands/job_heartbeat.py +++ /dev/null @@ -1,32 +0,0 @@ -import datetime -import logging - -from django.core.management.base import BaseCommand -from django.utils import timezone - -from modoboa.core.models import Log -from modoboa.parameters import tools as param_tools - -JOB_IDs = ["clearsessions_r", "cleanlogs_r", "logparser_r"] -AMAVIS_IDs = ["qcleanup_r", "amnotify_r"] - - -class Command(BaseCommand): - """Command class.""" - - help = "Checks that redondants jobs are started or start them" # NOQA:A003 - - def add_arguments(self, parser): - """Add extra arguments to command line.""" - parser.add_argument( - "--queue-now", - action="store_true", - default=False, - help="Queue all redondant job now", - ) - - def check_clean_log(queue_now=False): - pass - - def handle(self, *args, **options): - self.check_clean_log(options["queue_now"]) diff --git a/test_project/test_project/cron_config.py b/test_project/test_project/cron_config.py index 0df7d99ea..dd88463ee 100644 --- a/test_project/test_project/cron_config.py +++ b/test_project/test_project/cron_config.py @@ -1,5 +1,11 @@ +import random + from rq.cron import register +from django.conf import settings + +from modoboa.amavis import jobs as amavis_jobs +from modoboa.calendars import jobs as calendars_jobs from modoboa.core import jobs as core_jobs from modoboa.admin import jobs as admin_jobs from modoboa.maillog import jobs as maillog_jobs @@ -7,7 +13,22 @@ from modoboa.maillog import jobs as maillog_jobs register(core_jobs.clean_logs, queue_name="modoboa", cron="0 0 * * *") +# Random start for this one to avoid a DDoS on the API... +minute = random.randint(1, 59) +hour = random.randint(0, 6) +register( + core_jobs.communicate_with_public_api, + queue_name="modoboa", + cron=f"{minute} {hour},{hour + 12} * * *", +) + register(admin_jobs.handle_mailbox_operations, queue_name="dovecot", cron="* * * * *") register(maillog_jobs.logparser, queue_name="modoboa", cron="*/5 * * * *") register(maillog_jobs.update_statistics, queue_name="modoboa", cron="0 * * * *") + +register(calendars_jobs.generate_rights, queue_name="modoboa", cron="*/2 * * * *") + +if "modoboa.amavis" in settings.MODOBOA_APPS: + register(amavis_jobs.qcleanup, queue="modoboa", cron="0 0 * * *") + register(amavis_jobs.amnotify, queue="modoboa", cron="0 12 * * *") From 4b1b9b2042389ef01af4f7f04362999511f3aacf Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 12 Jan 2026 16:43:50 +0100 Subject: [PATCH 06/11] Replaced check_mx management command by a periodic task --- modoboa/admin/api/v2/serializers.py | 1 + modoboa/admin/app_settings.py | 12 ++ .../subcommands/_mx.py => dns_checker.py} | 152 +++++------------- modoboa/admin/jobs.py | 27 +++- modoboa/admin/tests/test_mx.py | 103 ++++++------ pyproject.toml | 1 - test_project/test_project/cron_config.py | 5 +- 7 files changed, 127 insertions(+), 174 deletions(-) rename modoboa/admin/{management/commands/subcommands/_mx.py => dns_checker.py} (64%) diff --git a/modoboa/admin/api/v2/serializers.py b/modoboa/admin/api/v2/serializers.py index 532093049..618732532 100644 --- a/modoboa/admin/api/v2/serializers.py +++ b/modoboa/admin/api/v2/serializers.py @@ -223,6 +223,7 @@ class AdminGlobalParametersSerializer(serializers.Serializer): enable_dkim_checks = serializers.BooleanField(default=True) enable_dmarc_checks = serializers.BooleanField(default=True) enable_autoconfig_checks = serializers.BooleanField(default=True) + enable_dns_notifications = serializers.BooleanField(default=True) custom_dns_server = serializers.IPAddressField(allow_blank=True, allow_null=True) enable_dnsbl_checks = serializers.BooleanField(default=True) dkim_keys_storage_dir = serializers.CharField(allow_blank=True, allow_null=True) diff --git a/modoboa/admin/app_settings.py b/modoboa/admin/app_settings.py index 1ddbfad1c..fd40589c9 100644 --- a/modoboa/admin/app_settings.py +++ b/modoboa/admin/app_settings.py @@ -110,6 +110,18 @@ GLOBAL_PARAMETERS_STRUCT = collections.OrderedDict( ), }, ), + ( + "enable_dns_notifications", + { + "label": gettext_lazy( + "Notify domain admins about DNS issues" + ), + "help_text": gettext_lazy( + "Send a notification by email to domain administrators " + "when DNS issues are encountered" + ), + }, + ), ( "custom_dns_server", { diff --git a/modoboa/admin/management/commands/subcommands/_mx.py b/modoboa/admin/dns_checker.py similarity index 64% rename from modoboa/admin/management/commands/subcommands/_mx.py rename to modoboa/admin/dns_checker.py index ff1d9e890..038cd0a57 100644 --- a/modoboa/admin/management/commands/subcommands/_mx.py +++ b/modoboa/admin/dns_checker.py @@ -1,16 +1,10 @@ -"""Management command to check defined domains.""" - import ipaddress - -import gevent -from gevent import socket +import socket from django.conf import settings from django.core.mail import EmailMessage -from django.core.management.base import BaseCommand from django.template.loader import render_to_string from django.utils import timezone -from django.utils.encoding import smart_str from django.utils.functional import cached_property from django.utils.translation import gettext as _ @@ -19,10 +13,7 @@ from modoboa.dnstools import models as dns_models from modoboa.parameters import tools as param_tools -class CheckMXRecords(BaseCommand): - """Command class.""" - - help = "Check defined domains." # NOQA:A003 +class DNSChecker: @cached_property def providers(self): @@ -36,52 +27,24 @@ class CheckMXRecords(BaseCommand): """Return sender address for notifications.""" return param_tools.get_global_parameter("sender_address", app="core") + @cached_property + def config(self) -> dict: + return dict(param_tools.get_global_parameters("admin")) + @cached_property def valid_mxs(self): """Return valid MXs set in admin.""" - valid_mxs = param_tools.get_global_parameter("valid_mxs") + valid_mxs = self.config["valid_mxs"] return [ - ipaddress.ip_network(smart_str(v.strip())) - for v in valid_mxs.split() - if v.strip() + ipaddress.ip_network(str(v.strip())) for v in valid_mxs.split() if v.strip() ] - def add_arguments(self, parser): - """Add extra arguments to command.""" - parser.add_argument( - "--no-dnsbl", action="store_true", default=False, help="Skip DNSBL queries." - ) - parser.add_argument( - "--email", - type=str, - action="append", - default=[], - help="One or more email to notify", - ) - parser.add_argument( - "--skip-admin-emails", - action="store_true", - default=False, - help="Skip domain's admins email notification.", - ) - parser.add_argument( - "--domain", - type=str, - action="append", - default=[], - help="Domain name or id to update.", - ) - parser.add_argument( - "--timeout", type=int, default=3, help="Timeout used for queries." - ) - parser.add_argument("--ttl", type=int, default=7200, help="TTL for dns query.") - def query_dnsbl(self, mx_list, provider): """Check given IP against given DNSBL provider.""" results = {} for ip, mxs in mx_list.items(): try: - ip = ipaddress.ip_address(smart_str(ip)) + ip = ipaddress.ip_address(str(ip)) except ValueError: continue else: @@ -99,9 +62,9 @@ class CheckMXRecords(BaseCommand): result = False for mx in mxs: results[mx] = result - return provider, results + return results - def store_dnsbl_result(self, domain, provider, results, **options): + def store_dnsbl_result(self, domain, provider, results): """Store DNSBL provider results for domain. Return a list of alerts. @@ -142,23 +105,24 @@ class CheckMXRecords(BaseCommand): models.DNSBLResult.objects.bulk_create(to_create) return alerts - def send_alert_notifications(self, domain, alerts, subject, tpl, **options): + def send_alert_notifications( + self, domain: models.Domain, alerts: list, subject: str, tpl: str + ) -> None: """Send email notifications about given alerts.""" - emails = list(options["email"]) - if not options["skip_admin_emails"]: - emails.extend( - domain.admins.exclude(mailbox__isnull=True).values_list( - "email", flat=True - ) - ) + if not self.config["enable_dns_notifications"]: + return + emails = domain.admins.exclude(mailbox__isnull=True).values_list( + "email", flat=True + ) if not len(emails): return content = render_to_string(tpl, {"domain": domain, "alerts": alerts}) msg = EmailMessage(subject, content.strip(), self.sender, emails) msg.send() - def check_valid_mx(self, domain, mx_list, **options): - """Check that domain's MX record exist. + def check_valid_mx(self, domain: models.Domain, mx_list: list) -> None: + """ + Check that domain's MX record exist. If `valid_mx` is provided, retrieved MX records must be contained in it. @@ -212,26 +176,25 @@ class CheckMXRecords(BaseCommand): return subject = _("[modoboa] MX issue(s) for domain {}").format(domain.name) tpl = "admin/notifications/domain_invalid_mx.html" - self.send_alert_notifications(domain, alerts, subject, tpl, **options) + self.send_alert_notifications(domain, alerts, subject, tpl) + + def run(self, domain: models.Domain, ttl: int = 7200): + # Remove deprecated records first + domain.dnsblresult_set.exclude(provider__in=self.providers).delete() - def check_domain(self, domain, timeout=3, ttl=7200, **options): - """Check specified domain.""" mx_list = list(models.MXRecord.objects.get_or_create_for_domain(domain, ttl)) - if param_tools.get_global_parameter("enable_mx_checks"): - self.check_valid_mx(domain, mx_list, **options) + if self.config["enable_mx_checks"]: + self.check_valid_mx(domain, mx_list) - if param_tools.get_global_parameter("enable_spf_checks"): + if self.config["enable_spf_checks"]: dns_models.DNSRecord.objects.get_or_create_for_domain(domain, "spf", ttl) - condition = ( - param_tools.get_global_parameter("enable_dkim_checks") - and domain.dkim_public_key - ) + condition = self.config["enable_dkim_checks"] and domain.dkim_public_key if condition: dns_models.DNSRecord.objects.get_or_create_for_domain(domain, "dkim", ttl) - if param_tools.get_global_parameter("enable_dmarc_checks"): + if self.config["enable_dmarc_checks"]: dns_models.DNSRecord.objects.get_or_create_for_domain(domain, "dmarc", ttl) - if param_tools.get_global_parameter("enable_autoconfig_checks"): + if self.config["enable_autoconfig_checks"]: dns_models.DNSRecord.objects.get_or_create_for_domain( domain, "autoconfig", ttl ) @@ -239,11 +202,8 @@ class CheckMXRecords(BaseCommand): domain, "autodiscover", ttl ) - condition = ( - not param_tools.get_global_parameter("enable_dnsbl_checks") - or options["no_dnsbl"] is True - ) - if condition or not mx_list: + condition = not self.config["enable_dnsbl_checks"] or not mx_list + if condition: return mx_by_ip = {} @@ -253,44 +213,14 @@ class CheckMXRecords(BaseCommand): elif mx not in mx_by_ip[mx.address]: mx_by_ip[mx.address].append(mx) - jobs = [ - gevent.spawn(self.query_dnsbl, mx_by_ip, provider) - for provider in self.providers - ] - gevent.joinall(jobs, timeout) alerts = [] - for job in jobs: - if not job.successful(): - continue - provider, results = job.value - alerts += self.store_dnsbl_result(domain, provider, results, **options) + for provider in self.providers: + results = self.query_dnsbl(mx_by_ip, provider) + alerts += self.store_dnsbl_result(domain, provider, results) + if not alerts: return + subject = _("[modoboa] DNSBL issue(s) for domain {}").format(domain.name) tpl = "admin/notifications/domain_in_dnsbl.html" - self.send_alert_notifications(domain, alerts, subject, tpl, **options) - - def handle(self, *args, **options): - """Command entry point.""" - # Remove deprecated records first - models.DNSBLResult.objects.exclude(provider__in=self.providers).delete() - - if options["domain"]: - domains = [] - for domain in options["domain"]: - try: - if domain.isdigit(): - domains.append(models.Domain.objects.get(pk=domain)) - else: - domains.append(models.Domain.objects.get(name=domain)) - except models.Domain.DoesNotExist: - pass - else: - domains = models.Domain.objects.filter(enabled=True, enable_dns_checks=True) - - options.pop("domain") - - for domain in domains: - if domain.uses_a_reserved_tld: - continue - self.check_domain(domain, **options) + self.send_alert_notifications(domain, alerts, subject, tpl) diff --git a/modoboa/admin/jobs.py b/modoboa/admin/jobs.py index 836940dab..4080a0fb3 100644 --- a/modoboa/admin/jobs.py +++ b/modoboa/admin/jobs.py @@ -4,8 +4,14 @@ import logging import os import shutil +from django.db.models import F +from django.utils import timezone + +import django_rq + +from modoboa.admin import models from modoboa.admin.app_settings import load_admin_settings -from modoboa.admin.models.mailbox import MailboxOperation +from modoboa.admin.dns_checker import DNSChecker from modoboa.lib.sysutils import exec_cmd from modoboa.parameters import tools as param_tools @@ -56,8 +62,25 @@ def handle_mailbox_operations(): load_admin_settings() if not param_tools.get_global_parameter("handle_mailboxes"): return - for ope in MailboxOperation.objects.all(): + for ope in models.MailboxOperation.objects.all(): if ope.type == "rename": rename_mailbox(ope) elif ope.type == "delete": delete_mailbox(ope) + + +def launch_domain_dns_checks(domain_id: int): + domain = models.Domain.objects.get(id=domain_id) + DNSChecker().run(domain) + + +def handle_dns_checks(): + """Launch DNS checks for every possible domain.""" + minute = timezone.now().minute + queue = django_rq.get_queue("modoboa") + for domain in models.Domain.objects.annotate(slot=F("id") % 60).filter( + enable_dns_checks=True, slot=minute + ): + if domain.uses_a_reserved_tld: + continue + queue.enqueue(launch_domain_dns_checks, domain.id) diff --git a/modoboa/admin/tests/test_mx.py b/modoboa/admin/tests/test_mx.py index 790d4f006..6b1963555 100644 --- a/modoboa/admin/tests/test_mx.py +++ b/modoboa/admin/tests/test_mx.py @@ -3,12 +3,17 @@ from unittest import mock import dns.resolver +from rq import SimpleWorker from testfixtures import LogCapture -from django.core import mail, management +from django.core import mail from django.test import override_settings from django.utils.translation import gettext as _ +import django_rq + +from modoboa.admin import jobs +from modoboa.admin.dns_checker import DNSChecker from modoboa.core import models as core_models, factories as core_factories from modoboa.lib.tests import ModoTestCase from . import utils @@ -44,26 +49,26 @@ class MXTestCase(ModoTestCase): cls.localconfig.save() models.MXRecord.objects.all().delete() - @mock.patch("gevent.socket.gethostbyname") + @mock.patch("socket.gethostbyname") @mock.patch("socket.getaddrinfo") @mock.patch.object(dns.resolver.Resolver, "resolve") - def test_management_command( - self, mock_query, mock_getaddrinfo, mock_g_gethostbyname - ): + def test_dns_checker(self, mock_query, mock_getaddrinfo, mock_g_gethostbyname): """Check that command works fine.""" mock_query.side_effect = utils.mock_dns_query_result mock_getaddrinfo.side_effect = utils.mock_ip_query_result mock_g_gethostbyname.return_value = "1.2.3.4" + self.set_global_parameter("enable_dnsbl_checks", False) + self.assertEqual(models.MXRecord.objects.count(), 0) with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--no-dnsbl", "--ttl=0") + DNSChecker().run(self.domain, ttl=0) self.assertTrue(models.MXRecord.objects.filter(domain=self.domain).exists()) # we passed a ttl to 0. this will reset the cache this time qs = models.MXRecord.objects.filter(domain=self.domain) id_ = qs[0].id with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--no-dnsbl", "--ttl=7200") + DNSChecker().run(self.domain) qs = models.MXRecord.objects.filter(domain=self.domain) self.assertNotEqual(id_, qs[0].id) id_ = qs[0].id @@ -71,40 +76,11 @@ class MXTestCase(ModoTestCase): # assume that mxrecords ids are the same. means that we taking care of # ttl with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--no-dnsbl") + DNSChecker().run(self.domain) qs = models.MXRecord.objects.filter(domain=self.domain) self.assertEqual(id_, qs[0].id) - @mock.patch("gevent.socket.gethostbyname") - @mock.patch("socket.getaddrinfo") - @mock.patch.object(dns.resolver.Resolver, "resolve") - def test_single_domain_update( - self, mock_query, mock_getaddrinfo, mock_g_gethostbyname - ): - """Update only one domain.""" - mock_query.side_effect = utils.mock_dns_query_result - mock_getaddrinfo.side_effect = utils.mock_ip_query_result - mock_g_gethostbyname.return_value = "1.2.3.4" - with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--domain", self.domain.name) - self.assertTrue(models.MXRecord.objects.filter(domain=self.domain).exists()) - self.assertFalse( - models.MXRecord.objects.filter(domain=self.bad_domain).exists() - ) - - with LogCapture("modoboa.dns"): - management.call_command( - "modo", "check_mx", "--domain", str(self.bad_domain.pk) - ) - self.assertFalse( - models.MXRecord.objects.filter(domain=self.bad_domain).exists() - ) - self.assertEqual(len(mail.outbox), 1) - - with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--domain", "toto.com") - - @mock.patch("gevent.socket.gethostbyname") + @mock.patch("socket.gethostbyname") @mock.patch("socket.getaddrinfo") @mock.patch.object(dns.resolver.Resolver, "resolve") def test_invalid_mx(self, mock_query, mock_getaddrinfo, mock_g_gethostbyname): @@ -112,6 +88,8 @@ class MXTestCase(ModoTestCase): mock_query.side_effect = utils.mock_dns_query_result mock_getaddrinfo.side_effect = utils.mock_ip_query_result mock_g_gethostbyname.return_value = "1.2.3.4" + self.set_global_parameter("enable_dnsbl_checks", False) + domain = factories.DomainFactory(name="invalid-mx.com") # Add domain admin with mailbox mb = factories.MailboxFactory( @@ -122,14 +100,10 @@ class MXTestCase(ModoTestCase): ) domain.add_admin(mb.user) with LogCapture("modoboa.dns"): - management.call_command( - "modo", "check_mx", "--domain", domain.name, "--no-dnsbl" - ) + DNSChecker().run(domain) self.assertEqual(domain.alarms.count(), 1) with LogCapture("modoboa.dns"): - management.call_command( - "modo", "check_mx", "--domain", domain.name, "--no-dnsbl" - ) + DNSChecker().run(domain) self.assertEqual(domain.alarms.count(), 1) self.assertEqual(len(mail.outbox), 1) @@ -226,6 +200,15 @@ class DNSBLTestCase(ModoTestCase): """Create some data.""" super().setUpTestData() cls.domain = factories.DomainFactory(name="modoboa.org") + # Add domain admin with mailbox + mb = factories.MailboxFactory( + address="admin", + domain=cls.domain, + user__username="admin@modoboa.org", + user__groups=("DomainAdmins",), + ) + cls.domain.add_admin(mb.user) + factories.DomainFactory(name="does-not-exist.example.com") cls.domain2 = factories.DomainFactory( name="test.localhost" @@ -235,19 +218,21 @@ class DNSBLTestCase(ModoTestCase): ) models.DNSBLResult.objects.all().delete() - @mock.patch("gevent.socket.gethostbyname") + @mock.patch("socket.gethostbyname") @mock.patch("socket.getaddrinfo") @mock.patch.object(dns.resolver.Resolver, "resolve") - def test_management_command( - self, mock_query, mock_getaddrinfo, mock_g_gethostbyname - ): + def test_job(self, mock_query, mock_getaddrinfo, mock_g_gethostbyname): """Check that command works fine.""" mock_query.side_effect = utils.mock_dns_query_result mock_getaddrinfo.side_effect = utils.mock_ip_query_result mock_g_gethostbyname.return_value = "1.2.3.4" self.assertEqual(models.DNSBLResult.objects.count(), 0) + + queue = django_rq.get_queue("modoboa") + worker = SimpleWorker([queue], connection=queue.connection) with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx") + jobs.handle_dns_checks() + worker.work(burst=True) self.assertTrue(models.DNSBLResult.objects.filter(domain=self.domain).exists()) self.assertFalse( models.DNSBLResult.objects.filter(domain=self.domain3).exists() @@ -255,7 +240,7 @@ class DNSBLTestCase(ModoTestCase): self.assertFalse(self.domain.uses_a_reserved_tld) self.assertTrue(self.domain2.uses_a_reserved_tld) - @mock.patch("gevent.socket.gethostbyname") + @mock.patch("socket.gethostbyname") @mock.patch("socket.getaddrinfo") @mock.patch.object(dns.resolver.Resolver, "resolve") def test_notifications(self, mock_query, mock_getaddrinfo, mock_g_gethostbyname): @@ -264,10 +249,10 @@ class DNSBLTestCase(ModoTestCase): mock_getaddrinfo.side_effect = utils.mock_ip_query_result mock_g_gethostbyname.return_value = "127.0.0.4" with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--email", "user@example.test") - self.assertEqual(len(mail.outbox), 2) + DNSChecker().run(self.domain) + self.assertEqual(len(mail.outbox), 1) - @mock.patch("gevent.socket.gethostbyname") + @mock.patch("socket.gethostbyname") @mock.patch("socket.getaddrinfo") @mock.patch.object(dns.resolver.Resolver, "resolve") def test_notifications_wrong_dnsbl_response( @@ -278,10 +263,10 @@ class DNSBLTestCase(ModoTestCase): mock_getaddrinfo.side_effect = utils.mock_ip_query_result mock_g_gethostbyname.return_value = "127.255.255.254" # <--Spamhaus response when querying from an open resolver with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--email", "user@example.test") + DNSChecker().run(self.domain) self.assertEqual(len(mail.outbox), 1) - @mock.patch("gevent.socket.gethostbyname") + @mock.patch("socket.gethostbyname") @mock.patch("socket.getaddrinfo") @mock.patch.object(dns.resolver.Resolver, "resolve") def test_management_command_no_dnsbl( @@ -291,9 +276,10 @@ class DNSBLTestCase(ModoTestCase): mock_query.side_effect = utils.mock_dns_query_result mock_getaddrinfo.side_effect = utils.mock_ip_query_result mock_g_gethostbyname.return_value = "1.2.3.4" + self.set_global_parameter("enable_dnsbl_checks", False) self.assertEqual(models.DNSBLResult.objects.count(), 0) with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--no-dnsbl") + DNSChecker().run(self.domain) self.assertFalse(models.DNSBLResult.objects.filter(domain=self.domain).exists()) @@ -306,7 +292,7 @@ class DNSChecksTestCase(ModoTestCase): super().setUpTestData() cls.domain = factories.DomainFactory(name="dns-checks.com") - @mock.patch("gevent.socket.gethostbyname") + @mock.patch("socket.gethostbyname") @mock.patch("socket.getaddrinfo") @mock.patch.object(dns.resolver.Resolver, "resolve") def test_management_command( @@ -316,13 +302,14 @@ class DNSChecksTestCase(ModoTestCase): mock_query.side_effect = utils.mock_dns_query_result mock_getaddrinfo.side_effect = utils.mock_ip_query_result mock_g_gethostbyname.return_value = "1.2.3.4" + self.set_global_parameter("enable_dnsbl_checks", False) self.domain.enable_dkim = True self.domain.dkim_public_key = "XXXXX" self.domain.save(update_fields=["enable_dkim", "dkim_public_key"]) with LogCapture("modoboa.dns"): - management.call_command("modo", "check_mx", "--no-dnsbl", "--ttl=0") + DNSChecker().run(self.domain, ttl=0) self.assertIsNot(self.domain.spf_record, None) self.assertIsNot(self.domain.dkim_record, None) diff --git a/pyproject.toml b/pyproject.toml index 42ef78220..6dd12054f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,6 @@ dependencies = [ "dnspython==2.8.0", "feedparser==6.0.12", "fido2==2.0.0", - "gevent==25.9.1", "progressbar33==2.4", "python-dateutil", "cryptography", diff --git a/test_project/test_project/cron_config.py b/test_project/test_project/cron_config.py index dd88463ee..0e3ee92e2 100644 --- a/test_project/test_project/cron_config.py +++ b/test_project/test_project/cron_config.py @@ -23,6 +23,7 @@ register( ) register(admin_jobs.handle_mailbox_operations, queue_name="dovecot", cron="* * * * *") +register(admin_jobs.handle_dns_checks, queue_name="modoboa", cron="* * * * *") register(maillog_jobs.logparser, queue_name="modoboa", cron="*/5 * * * *") register(maillog_jobs.update_statistics, queue_name="modoboa", cron="0 * * * *") @@ -30,5 +31,5 @@ register(maillog_jobs.update_statistics, queue_name="modoboa", cron="0 * * * *") register(calendars_jobs.generate_rights, queue_name="modoboa", cron="*/2 * * * *") if "modoboa.amavis" in settings.MODOBOA_APPS: - register(amavis_jobs.qcleanup, queue="modoboa", cron="0 0 * * *") - register(amavis_jobs.amnotify, queue="modoboa", cron="0 12 * * *") + register(amavis_jobs.qcleanup, queue_name="modoboa", cron="0 0 * * *") + register(amavis_jobs.amnotify, queue_name="modoboa", cron="0 12 * * *") From 0fc07aa40fc1dae44625d25c41650aae8124c992 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 12 Jan 2026 16:55:14 +0100 Subject: [PATCH 07/11] Removed command definition --- modoboa/admin/management/commands/modo.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/modoboa/admin/management/commands/modo.py b/modoboa/admin/management/commands/modo.py index a5ce79284..b71e50d23 100644 --- a/modoboa/admin/management/commands/modo.py +++ b/modoboa/admin/management/commands/modo.py @@ -5,7 +5,6 @@ from django.core.management.base import BaseCommand from .subcommands._export import ExportCommand from .subcommands._import import ImportCommand from .subcommands._manage_dkim_keys import ManageDKIMKeys -from .subcommands._mx import CheckMXRecords from .subcommands._repair import Repair @@ -20,7 +19,6 @@ class Command(BaseCommand): subcommands = { "export": ExportCommand, "import": ImportCommand, - "check_mx": CheckMXRecords, "manage_dkim_keys": ManageDKIMKeys, "repair": Repair, } From 09dfeb235338c77d23f6e537f64ced441e27f445 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 12 Jan 2026 17:53:13 +0100 Subject: [PATCH 08/11] Improved tests --- modoboa/admin/tests/test_mailbox_operations.py | 8 ++++---- modoboa/admin/tests/test_mx.py | 18 +++++++++++------- pyproject.toml | 1 + 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/modoboa/admin/tests/test_mailbox_operations.py b/modoboa/admin/tests/test_mailbox_operations.py index 94a910c1c..d4152aa6f 100644 --- a/modoboa/admin/tests/test_mailbox_operations.py +++ b/modoboa/admin/tests/test_mailbox_operations.py @@ -4,10 +4,10 @@ import os import shutil from unittest import mock -from django.core.management import call_command from django.test import override_settings from django.urls import reverse +from modoboa.admin import jobs from modoboa.lib.tests import ModoAPITestCase from .. import factories, models @@ -45,7 +45,7 @@ class MailboxOperationTestCase(ModoAPITestCase): self.client.post( reverse("v2:account-delete", args=[mb.user.pk]), {}, format="json" ) - call_command("handle_mailbox_operations") + jobs.handle_mailbox_operations() self.assertFalse(models.MailboxOperation.objects.exists()) self.assertFalse(os.path.exists(mb.mail_home)) @@ -71,7 +71,7 @@ class MailboxOperationTestCase(ModoAPITestCase): self.assertEqual(response.status_code, 200) path = f"{self.workdir}/test.com/admin2" mail_home_mock.__get__ = mock.Mock(return_value=path) - call_command("handle_mailbox_operations") + jobs.handle_mailbox_operations() self.assertFalse(models.MailboxOperation.objects.exists()) self.assertTrue(os.path.exists(mb.mail_home)) @@ -84,6 +84,6 @@ class MailboxOperationTestCase(ModoAPITestCase): self.client.post( reverse("v2:domain-delete", args=[domain.pk]), {}, format="json" ) - call_command("handle_mailbox_operations") + jobs.handle_mailbox_operations() self.assertFalse(models.MailboxOperation.objects.exists()) self.assertFalse(os.path.exists(path)) diff --git a/modoboa/admin/tests/test_mx.py b/modoboa/admin/tests/test_mx.py index 6b1963555..bccd1884c 100644 --- a/modoboa/admin/tests/test_mx.py +++ b/modoboa/admin/tests/test_mx.py @@ -3,6 +3,7 @@ from unittest import mock import dns.resolver +from freezegun import freeze_time from rq import SimpleWorker from testfixtures import LogCapture @@ -209,7 +210,7 @@ class DNSBLTestCase(ModoTestCase): ) cls.domain.add_admin(mb.user) - factories.DomainFactory(name="does-not-exist.example.com") + cls.domain4 = factories.DomainFactory(name="does-not-exist.example.com") cls.domain2 = factories.DomainFactory( name="test.localhost" ) # Should not be checked @@ -230,9 +231,12 @@ class DNSBLTestCase(ModoTestCase): queue = django_rq.get_queue("modoboa") worker = SimpleWorker([queue], connection=queue.connection) - with LogCapture("modoboa.dns"): - jobs.handle_dns_checks() - worker.work(burst=True) + + for cpt in range(10): + with freeze_time(f"2026-01-12 14:00:0{cpt}"): + jobs.handle_dns_checks() + worker.work(burst=True) + self.assertTrue(models.DNSBLResult.objects.filter(domain=self.domain).exists()) self.assertFalse( models.DNSBLResult.objects.filter(domain=self.domain3).exists() @@ -259,12 +263,12 @@ class DNSBLTestCase(ModoTestCase): self, mock_query, mock_getaddrinfo, mock_g_gethostbyname ): """Check notifications.""" + self.set_global_parameter("enable_dnsbl_checks", True) mock_query.side_effect = utils.mock_dns_query_result mock_getaddrinfo.side_effect = utils.mock_ip_query_result mock_g_gethostbyname.return_value = "127.255.255.254" # <--Spamhaus response when querying from an open resolver - with LogCapture("modoboa.dns"): - DNSChecker().run(self.domain) - self.assertEqual(len(mail.outbox), 1) + DNSChecker().run(self.domain) + self.assertEqual(len(mail.outbox), 0) @mock.patch("socket.gethostbyname") @mock.patch("socket.getaddrinfo") diff --git a/pyproject.toml b/pyproject.toml index 6dd12054f..7cd9442c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,6 +98,7 @@ test = [ "factory-boy<3.4.0", "httmock==1.4.0", "testfixtures==8.3.0", + "freezegun", "tox", ] ldap = ["django-auth-ldap<6.0.0"] From ed27becef9e44e0b59f63fcd1309c1ed9b3f06d2 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Mon, 12 Jan 2026 18:14:12 +0100 Subject: [PATCH 09/11] Improved test --- modoboa/admin/tests/test_mx.py | 13 +++++++++---- test_project/.coveragerc | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/modoboa/admin/tests/test_mx.py b/modoboa/admin/tests/test_mx.py index bccd1884c..ddc563004 100644 --- a/modoboa/admin/tests/test_mx.py +++ b/modoboa/admin/tests/test_mx.py @@ -229,12 +229,17 @@ class DNSBLTestCase(ModoTestCase): mock_g_gethostbyname.return_value = "1.2.3.4" self.assertEqual(models.DNSBLResult.objects.count(), 0) + with freeze_time(f"2026-01-12 14:{self.domain.id % 60:02}"): + jobs.handle_dns_checks() + with freeze_time(f"2026-01-12 14:{self.domain2.id % 60:02}"): + jobs.handle_dns_checks() + with freeze_time(f"2026-01-12 14:{self.domain3.id % 60:02}"): + jobs.handle_dns_checks() + with freeze_time(f"2026-01-12 14:{self.domain4.id % 60:02}"): + jobs.handle_dns_checks() + queue = django_rq.get_queue("modoboa") worker = SimpleWorker([queue], connection=queue.connection) - - for cpt in range(10): - with freeze_time(f"2026-01-12 14:00:0{cpt}"): - jobs.handle_dns_checks() worker.work(burst=True) self.assertTrue(models.DNSBLResult.objects.filter(domain=self.domain).exists()) diff --git a/test_project/.coveragerc b/test_project/.coveragerc index 9440bfdd5..d341fab1d 100644 --- a/test_project/.coveragerc +++ b/test_project/.coveragerc @@ -1,5 +1,5 @@ [run] branch = True -concurrency = multiprocessing, greenlet +concurrency = multiprocessing parallel = True source = ../modoboa From 5dc1cfea8632b1f464cc3ffb0c1957598239dd9a Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Tue, 13 Jan 2026 09:44:20 +0100 Subject: [PATCH 10/11] Improved coverage. --- modoboa/amavis/tests/test_jobs.py | 18 +++++++ modoboa/amavis/tests/test_viewsets.py | 5 +- modoboa/calendars/tests.py | 3 +- modoboa/core/management/commands/cleanlogs.py | 53 ------------------- modoboa/core/tests/test_core.py | 14 ----- modoboa/core/tests/test_jobs.py | 40 ++++++++++++++ modoboa/maillog/tests/test_views.py | 9 ++-- 7 files changed, 67 insertions(+), 75 deletions(-) create mode 100644 modoboa/amavis/tests/test_jobs.py delete mode 100644 modoboa/core/management/commands/cleanlogs.py create mode 100644 modoboa/core/tests/test_jobs.py diff --git a/modoboa/amavis/tests/test_jobs.py b/modoboa/amavis/tests/test_jobs.py new file mode 100644 index 000000000..45184a3e3 --- /dev/null +++ b/modoboa/amavis/tests/test_jobs.py @@ -0,0 +1,18 @@ +"""Tests cases for RQ jobs.""" + +from modoboa.lib.tests import ModoTestCase + +from modoboa.amavis import factories, jobs, models + + +class JobsTestCase(ModoTestCase): + + databases = "__all__" + + def test_qcleanup(self): + factories.create_spam("user@test.com", rs="D") + jobs.qcleanup() + self.assertEqual(models.Quarantine.objects.count(), 0) + self.assertEqual(models.Msgs.objects.count(), 0) + self.assertEqual(models.Maddr.objects.count(), 0) + self.assertEqual(models.Msgrcpt.objects.count(), 0) diff --git a/modoboa/amavis/tests/test_viewsets.py b/modoboa/amavis/tests/test_viewsets.py index 79c71cfaa..28cfd57f3 100644 --- a/modoboa/amavis/tests/test_viewsets.py +++ b/modoboa/amavis/tests/test_viewsets.py @@ -4,14 +4,13 @@ from unittest import mock from rq import SimpleWorker from django.core import mail -from django.core.management import call_command from django.test import override_settings from django.urls import reverse import django_rq from modoboa.admin import factories as admin_factories, models as admin_models -from modoboa.amavis import factories, models +from modoboa.amavis import factories, jobs, models from modoboa.amavis.utils import smart_str from modoboa.core import models as core_models from modoboa.lib.tests import ModoAPITestCase @@ -204,7 +203,7 @@ class QuarantineViewSetTestCase(TestDataMixin, ModoAPITestCase): self.assertEqual(self.msgrcpt.rs, "p") # Send notification to admins - call_command("amnotify") + jobs.amnotify() self.assertEqual(len(mail.outbox), 1) def _test_mark_selection(self, action, status): diff --git a/modoboa/calendars/tests.py b/modoboa/calendars/tests.py index 3f00ed214..bfd657782 100644 --- a/modoboa/calendars/tests.py +++ b/modoboa/calendars/tests.py @@ -18,6 +18,7 @@ from modoboa.lib.tests import ModoAPITestCase from modoboa.admin.factories import populate_database from . import factories +from . import jobs from . import models from . import mocks @@ -106,7 +107,7 @@ class AccessRuleTestCase(ModoAPITestCase): self.assertEqual(cfg.get(section, "permissions"), "Rr") # Call a second time - management.call_command("generate_rights", verbosity=False) + jobs.generate_rights() def test_rights_file_generation_with_admin(self): self.set_global_parameter( diff --git a/modoboa/core/management/commands/cleanlogs.py b/modoboa/core/management/commands/cleanlogs.py deleted file mode 100644 index 46f3d2ac5..000000000 --- a/modoboa/core/management/commands/cleanlogs.py +++ /dev/null @@ -1,53 +0,0 @@ -import datetime -import logging - -from django.core.management.base import BaseCommand -from django.utils import timezone - -from modoboa.core.models import Log -from modoboa.maillog.models import Maillog -from modoboa.parameters import tools as param_tools - - -class Command(BaseCommand): - """Command class.""" - - help = "Log table cleanup" # NOQA:A003 - - def add_arguments(self, parser): - """Add extra arguments to command line.""" - parser.add_argument( - "--debug", action="store_true", default=False, help="Activate debug output" - ) - parser.add_argument( - "--verbose", - action="store_true", - default=False, - help="Display informational messages", - ) - - def __vprint(self, msg): - if not self.verbose: - return - print(msg) - - def handle(self, *args, **options): - if options["debug"]: - log = logging.getLogger("django.db.backends") - log.setLevel(logging.DEBUG) - log.addHandler(logging.StreamHandler()) - self.verbose = options["verbose"] - - log_maximum_age = param_tools.get_global_parameter("log_maximum_age") - self.__vprint(f"Deleting logs older than {log_maximum_age} days...") - limit = timezone.now() - datetime.timedelta(log_maximum_age) - Log.objects.filter(date_created__lt=limit).delete() - message_history_maximum_age = param_tools.get_global_parameter( - "message_history_maximum_age" - ) - self.__vprint( - f"Deleting messages in history older than {message_history_maximum_age} days..." - ) - limit = timezone.now() - datetime.timedelta(message_history_maximum_age) - Maillog.objects.filter(date__lt=limit).delete() - self.__vprint("Done.") diff --git a/modoboa/core/tests/test_core.py b/modoboa/core/tests/test_core.py index 99a448ebb..94d0bd331 100644 --- a/modoboa/core/tests/test_core.py +++ b/modoboa/core/tests/test_core.py @@ -16,7 +16,6 @@ from django.urls import reverse from django.utils import timezone from modoboa.lib.tests import ModoTestCase, SimpleModoTestCase -from modoboa.maillog import factories as maillog_factories, models as maillog_models from .. import factories, mocks, models @@ -59,19 +58,6 @@ class ManagementCommandsTestCase(SimpleModoTestCase): management.call_command("load_initial_data", "--admin-username", "modoadmin") self.assertTrue(self.client.login(username="modoadmin", password="password")) - def test_clean_logs(self): - """Run cleanlogs command.""" - management.call_command("load_initial_data", "--no-frontend") - log1 = factories.LogFactory() - factories.LogFactory() - log1.date_created -= relativedelta(years=2) - log1.save(update_fields=["date_created"]) - maillog_factories.MaillogFactory(date=timezone.now() - relativedelta(days=190)) - maillog_factories.MaillogFactory() - management.call_command("cleanlogs") - self.assertEqual(models.Log.objects.count(), 1) - self.assertEqual(maillog_models.Maillog.objects.count(), 1) - def test_init_data_non_duplicate_client_creation(self): """Test that the load_initial_data command does not create duplicates client for the frontend deployment.""" diff --git a/modoboa/core/tests/test_jobs.py b/modoboa/core/tests/test_jobs.py new file mode 100644 index 000000000..0b52c8389 --- /dev/null +++ b/modoboa/core/tests/test_jobs.py @@ -0,0 +1,40 @@ +"""Tests cases for RQ jobs.""" + +from dateutil.relativedelta import relativedelta +import httmock + +from django.core import mail +from django.core import management +from django.utils import timezone + +from modoboa.core import factories, jobs, mocks, models +from modoboa.lib.tests import SimpleModoTestCase +from modoboa.maillog import factories as maillog_factories, models as maillog_models + + +class JobsTestCase(SimpleModoTestCase): + + def test_clean_logs(self): + """Check clean_logs job.""" + management.call_command("load_initial_data", "--no-frontend") + log1 = factories.LogFactory() + factories.LogFactory() + log1.date_created -= relativedelta(years=2) + log1.save(update_fields=["date_created"]) + maillog_factories.MaillogFactory(date=timezone.now() - relativedelta(days=190)) + maillog_factories.MaillogFactory() + jobs.clean_logs() + self.assertEqual(models.Log.objects.count(), 1) + self.assertEqual(maillog_models.Maillog.objects.count(), 1) + + def test_communicate_with_public_api(self): + """Check that job starts fine.""" + with httmock.HTTMock( + mocks.modo_api_instance_search, + mocks.modo_api_instance_create, + mocks.modo_api_instance_update, + mocks.modo_api_versions, + ): + jobs.communicate_with_public_api() + self.assertEqual(models.LocalConfig.objects.first().api_pk, 100) + self.assertEqual(len(mail.outbox), 0) diff --git a/modoboa/maillog/tests/test_views.py b/modoboa/maillog/tests/test_views.py index 376bb97bd..86bd6b9ca 100644 --- a/modoboa/maillog/tests/test_views.py +++ b/modoboa/maillog/tests/test_views.py @@ -11,6 +11,7 @@ from django.test import override_settings from modoboa.admin import factories as admin_factories from modoboa.lib.tests import SimpleModoTestCase +from modoboa.maillog import jobs class RunCommandsMixin: @@ -36,14 +37,14 @@ class RunCommandsMixin: with open(path, "w") as fp: fp.write(content) self.set_global_parameter("logfile", path) - call_command("logparser") + jobs.logparser() def run_update_statistics(self, rebuild=False): """Run update_statistics command.""" - args = [] if rebuild: - args.append("--rebuild") - call_command("update_statistics", *args) + call_command("update_statistics", ["--rebuild"]) + else: + jobs.update_statistics() @override_settings(RRDTOOL_TEST_MODE=True) From 0ea42129efc9615ae5644ae274c0f1e5a3e4e2dd Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Tue, 13 Jan 2026 10:10:05 +0100 Subject: [PATCH 11/11] Automate cron_config.py file creation --- modoboa/core/commands/deploy.py | 13 ++++++++ .../commands/templates/cron_config.py.tpl | 30 +++++++++++++++++++ test_project/test_project/cron_config.py | 6 +--- 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 modoboa/core/commands/templates/cron_config.py.tpl diff --git a/modoboa/core/commands/deploy.py b/modoboa/core/commands/deploy.py index 57b662214..757f923d7 100644 --- a/modoboa/core/commands/deploy.py +++ b/modoboa/core/commands/deploy.py @@ -3,6 +3,7 @@ import getpass import os from os.path import isfile +import random import shutil import subprocess import sys @@ -259,6 +260,18 @@ class DeployCommand(Command): shutil.copyfile(f"{self._templates_dir}/urls.py.tpl", f"{path}/urls.py") os.mkdir(f"{parsed_args.name}/media") + hour = random.randint(0, 6) + tpl = self._render_template( + f"{self._templates_dir}/cron_config.py.tpl", + { + "minute": random.randint(1, 59), + "hour_start": hour, + "hour_end": hour + 12, + }, + ) + with open(f"{path}/cron_config.py", "w") as fp: + fp.write(tpl) + if isfile(f"{path}/settings.pyc"): os.unlink(f"{path}/settings.pyc") self._exec_django_command("migrate", parsed_args.name, "--noinput") diff --git a/modoboa/core/commands/templates/cron_config.py.tpl b/modoboa/core/commands/templates/cron_config.py.tpl new file mode 100644 index 000000000..611d7b349 --- /dev/null +++ b/modoboa/core/commands/templates/cron_config.py.tpl @@ -0,0 +1,30 @@ +from rq.cron import register + +from django.conf import settings + +from modoboa.amavis import jobs as amavis_jobs +from modoboa.calendars import jobs as calendars_jobs +from modoboa.core import jobs as core_jobs +from modoboa.admin import jobs as admin_jobs +from modoboa.maillog import jobs as maillog_jobs + + +register(core_jobs.clean_logs, queue_name="modoboa", cron="0 0 * * *") + +register( + core_jobs.communicate_with_public_api, + queue_name="modoboa", + cron="{{ minute }} {{ hour_start }},{{ hour_end }} * * *", +) + +register(admin_jobs.handle_mailbox_operations, queue_name="dovecot", cron="* * * * *") +register(admin_jobs.handle_dns_checks, queue_name="modoboa", cron="* * * * *") + +register(maillog_jobs.logparser, queue_name="modoboa", cron="*/5 * * * *") +register(maillog_jobs.update_statistics, queue_name="modoboa", cron="0 * * * *") + +register(calendars_jobs.generate_rights, queue_name="modoboa", cron="*/2 * * * *") + +if "modoboa.amavis" in settings.MODOBOA_APPS: + register(amavis_jobs.qcleanup, queue_name="modoboa", cron="0 0 * * *") + register(amavis_jobs.amnotify, queue_name="modoboa", cron="0 12 * * *") diff --git a/test_project/test_project/cron_config.py b/test_project/test_project/cron_config.py index 0e3ee92e2..787ee8199 100644 --- a/test_project/test_project/cron_config.py +++ b/test_project/test_project/cron_config.py @@ -1,5 +1,3 @@ -import random - from rq.cron import register from django.conf import settings @@ -14,12 +12,10 @@ from modoboa.maillog import jobs as maillog_jobs register(core_jobs.clean_logs, queue_name="modoboa", cron="0 0 * * *") # Random start for this one to avoid a DDoS on the API... -minute = random.randint(1, 59) -hour = random.randint(0, 6) register( core_jobs.communicate_with_public_api, queue_name="modoboa", - cron=f"{minute} {hour},{hour + 12} * * *", + cron="12 2,14 * * *", ) register(admin_jobs.handle_mailbox_operations, queue_name="dovecot", cron="* * * * *")