Merge pull request #3550 from modoboa/feature/sieve_autoreply

Use sieve vacation plugin to manage auto-replies.
This commit is contained in:
Antoine Nguyen
2025-06-11 15:08:02 +02:00
committed by GitHub
45 changed files with 304 additions and 924 deletions
-28
View File
@@ -644,31 +644,3 @@ Hash the file using the following command::
Finally, reload postfix::
$ service postfix reload
************************
Auto-reply using Postfix
************************
The user that executes the autoreply script needs to access
:file:`settings.py`. You must apply proper permissions on this file. For
example, if :file:`settings.py` belongs to ``www-data:www-data``, you can add
the ``vmail`` user to the ``www-data`` group and set the read permission
for the group.
To make Postfix use this feature, you need to update your
configuration file as follows:
``/etc/postfix/master.cf``::
autoreply unix - n n - - pipe
flags= user=vmail:<group> argv=python <modoboa_site>/manage.py autoreply $sender $mailbox
Replace ``<driver>`` by the name of the database you
use. ``<modoboa_site>`` is the path of your Modoboa instance.
.. note::
Auto-reply messages are just sent once per sender for a
pre-defined time period. By default, this period is equal to 1 day
(86400s), you can adjust this value by modifying the **Automatic
reply timeout** parameter available in the online panel.
+48
View File
@@ -148,6 +148,54 @@ Rebuild Virtual Environment
Specific instructions
*********************
Version 2.4.0
=============
Legacy web interface has been removed and all extensions have been
integrated to the core Modoboa package.
Pre update
----------
- If you are using Postegresql (default option with the installer),
run as a root user:
.. sourcecode:: bash
> sudo -u postgres -i psql -d "modoboa" -c "UPDATE django_content_type SET app_label='autoreply' WHERE app_label='postfix_autoreply'"
> sudo -u postgres -i psql -d "modoboa" -c "UPDATE django_migrations SET app='autoreply' WHERE app='postfix_autoreply'"
- If you are using Mysql, run as a root user:
.. sourcecode:: bash
> echo "UPDATE django_content_type SET app_label='autoreply' WHERE app_label='postfix_autoreply'" | mysql -u root -p modoboa
> echo "UPDATE django_migrations SET app='autoreply' WHERE app='postfix_autoreply'" | mysql -u root -p modoboa
Required changes to :file:`settings.py`
---------------------------------------
- Remove ``AjaxLoginRedirect`` from ``MIDDLEWARE`` variable:
.. sourcecode:: python
MIDDLEWARE = (
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'x_forwarded_for.middleware.XForwardedForMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'modoboa.core.middleware.LocalConfigMiddleware',
'modoboa.lib.middleware.CommonExceptionCatcher',
'modoboa.lib.middleware.RequestCatcherMiddleware',
)
Version 2.3.3
=============
+1 -1
View File
@@ -25,7 +25,7 @@ FROM base AS run
COPY docker/doveadm /usr/local/bin
RUN chmod +x /usr/local/bin/doveadm
COPY --from=build $VIRTUAL_ENV $VIRTUAL_ENV
RUN apk add --no-cache --update openssl librrd libxslt libjpeg libressl git rrdtool libmagic sudo \
RUN apk add --no-cache --update openssl librrd libxslt libjpeg libressl git rrdtool libmagic sudo sqlite \
&& rm -rf /var/cache/apk/*
RUN mkdir /code
WORKDIR /code
@@ -20,7 +20,11 @@
<label class="m-label">{{ $gettext('Content') }}</label>
<v-textarea
v-model="form.content"
:hint="$gettext('The content of your answer. You can use the following variables, which will be automatically replaced by the appropriate value: %(name)s, %(fromdate)s, %(untildate)s')"
:hint="
$gettext(
'The content of your answer. You can use the following variables, which will be automatically replaced by the appropriate value: %(name)s, %(fromdate)s, %(untildate)s'
)
"
persistent-hint
variant="outlined"
rows="3"
@@ -78,15 +82,26 @@ async function submit() {
return
}
loading.value = true
accountApi.setARMessage(form.value).then(() => {
busStore.displayNotification({ msg: $gettext('Auto-reply message updated') })
loading.value = false
}).catch(() => {
loading.value = false
})
if (form.value.fromdate === '') {
form.value.fromdate = null
}
if (form.value.untildate === '') {
form.value.untildate = null
}
accountApi
.setARMessage(form.value)
.then(() => {
busStore.displayNotification({
msg: $gettext('Auto-reply message updated'),
})
loading.value = false
})
.catch(() => {
loading.value = false
})
}
accountApi.getARMessage().then(resp => {
accountApi.getARMessage().then((resp) => {
form.value = resp.data
if (form.value.fromdate) {
form.value.fromdate = form.value.fromdate.slice(0, -4)
@@ -4,8 +4,6 @@ from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils.encoding import smart_str
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from modoboa.admin import models
from modoboa.core.models import User
@@ -90,15 +88,6 @@ def sometimes_mailbox_have_no_alias(**options):
)
@known_problem
def postfix_autoreply_old_contenttypes(**options):
"""Sometime old postfix autoreply contenttypes might be present."""
for ct in ContentType.objects.filter(app_label="postfix_autoreply"):
result = Permission.objects.filter(content_type=ct).delete()
ct.delete()
log(f"Removed content type {ct.name} and {result[0]} associated permssions")
class Repair(BaseCommand):
"""Command class."""
+3
View File
@@ -0,0 +1,3 @@
"""Away message editor for Modoboa."""
default_app_config = "modoboa.autoreply.apps.AutoreplyConfig"
@@ -5,14 +5,14 @@ from django.utils.translation import gettext as _
from rest_framework import serializers
from modoboa.postfix_autoreply import models
from modoboa.autoreply import models
class PostfixAutoreplySettingsSerializer(serializers.Serializer):
class AutoreplySettingsSerializer(serializers.Serializer):
"""A serializer for global parameters."""
# General
autoreplies_timeout = serializers.IntegerField(default=86400)
tracking_period = serializers.IntegerField(default=7)
default_subject = serializers.CharField(default="I'm off")
default_content = serializers.CharField(
default="""I'm currently off. I'll answer as soon as I come back.
@@ -22,6 +22,11 @@ Best regards,
"""
)
def validate_tracking_period(self, value):
if value < 1:
raise serializers.ValidationError(_("Value can't be less than 1 day"))
return value
class ARMessageSerializer(serializers.ModelSerializer):
"""A serializer for ARmessage."""
@@ -46,6 +51,16 @@ class ARMessageSerializer(serializers.ModelSerializer):
)
return data
def create(self, validated_data):
instance = super().create(validated_data)
instance.manage_sieve_rule(self.context["request"])
return instance
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
instance.manage_sieve_rule(self.context["request"])
return instance
class AccountARMessageSerializer(ARMessageSerializer):
"""Dedicated serializer for account operations."""
@@ -6,10 +6,11 @@ from dateutil.relativedelta import relativedelta
from modoboa.admin import factories as admin_factories
from modoboa.core.models import User
from modoboa.lib.tests import ModoAPITestCase
from modoboa.postfix_autoreply import factories, models
from modoboa.sievefilters.api.v2.tests import PatcherMixin
from modoboa.autoreply import factories, models
class ARMessageViewSetTestCase(ModoAPITestCase):
class ARMessageViewSetTestCase(PatcherMixin, ModoAPITestCase):
"""API test case."""
@classmethod
@@ -21,6 +22,23 @@ class ARMessageViewSetTestCase(ModoAPITestCase):
cls.arm = factories.ARmessageFactory(mbox=cls.account.mailbox)
cls.arm2 = factories.ARmessageFactory(mbox=cls.account2.mailbox)
def test_update_global_settings(self):
data = {
"tracking_period": 7,
"default_subject": "Je suis parti",
"default_content": "Je reviendrai peut-être",
}
url = reverse("v2:parameter-global-detail", args=["autoreply"])
resp = self.client.put(url, data, format="json")
self.assertEqual(resp.status_code, 200)
data["tracking_period"] = 0
resp = self.client.put(url, data, format="json")
self.assertEqual(resp.status_code, 400)
self.assertEqual(
resp.json()["tracking_period"][0], "Value can't be less than 1 day"
)
def test_retrieve_armessage(self):
url = reverse("api:armessage-list")
response = self.client.get(url)
@@ -124,7 +142,7 @@ class ARMessageViewSetTestCase(ModoAPITestCase):
)
class AccountARMessageViewSetTestCase(ModoAPITestCase):
class AccountARMessageViewSetTestCase(PatcherMixin, ModoAPITestCase):
"""API test case."""
@classmethod
@@ -136,7 +154,7 @@ class AccountARMessageViewSetTestCase(ModoAPITestCase):
def test_get_armessage(self):
url = reverse("api:account_armessage-armessage")
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.status_code, 403)
self.client.logout()
self.client.force_login(self.account)
self.assertFalse(
@@ -7,9 +7,10 @@ from rest_framework.decorators import action
from modoboa.admin import models as admin_models
from modoboa.lib.email_utils import split_mailbox
from modoboa.lib.viewsets import HasMailbox
from modoboa.parameters import tools as param_tools
from modoboa.postfix_autoreply import models
from modoboa.autoreply import models
from . import serializers
@@ -61,7 +62,10 @@ class ARMessageViewSet(
class AccountARMessageViewSet(viewsets.ViewSet):
"""A viewset dedicated to connected account."""
permission_classes = (permissions.IsAuthenticated,)
permission_classes = (
permissions.IsAuthenticated,
HasMailbox,
)
serializer_class = serializers.AccountARMessageSerializer
@extend_schema(
@@ -76,7 +80,7 @@ class AccountARMessageViewSet(viewsets.ViewSet):
def armessage(self, request):
if not hasattr(request.user, "mailbox"):
return response.Response(status=404)
params = dict(param_tools.get_global_parameters("postfix_autoreply"))
params = dict(param_tools.get_global_parameters("autoreply"))
armessage, created = models.ARmessage.objects.get_or_create(
mbox=self.request.user.mailbox,
defaults={
@@ -88,7 +92,7 @@ class AccountARMessageViewSet(viewsets.ViewSet):
serializer = serializers.AccountARMessageSerializer(armessage)
return response.Response(serializer.data)
serializer = serializers.AccountARMessageSerializer(
armessage, data=request.data
armessage, data=request.data, context={"request": request}
)
serializer.is_valid(raise_exception=True)
serializer.save()
@@ -1,10 +1,10 @@
"""Postfix autoreply forms."""
"""Autoreply settings."""
import collections
from django.utils.translation import gettext_lazy as _
POSTFIX_AUTOREPLY_PARAMETERS_STRUCT = collections.OrderedDict(
AUTOREPLY_PARAMETERS_STRUCT = collections.OrderedDict(
[
(
"general",
@@ -13,11 +13,11 @@ POSTFIX_AUTOREPLY_PARAMETERS_STRUCT = collections.OrderedDict(
"params": collections.OrderedDict(
[
(
"autoreplies_timeout",
"tracking_period",
{
"label": _("Automatic reply timeout"),
"label": _("Period between two auto-replies"),
"help_text": _(
"Timeout in seconds between two auto-replies to the same recipient"
"Number of days to wait before sending a new auto-reply to the same recipient"
),
},
),
@@ -11,20 +11,19 @@ def load_settings():
param_tools.registry.add2(
"global",
"postfix_autoreply",
"autoreply",
gettext_lazy("Automatic replies"),
app_settings.POSTFIX_AUTOREPLY_PARAMETERS_STRUCT,
serializers.PostfixAutoreplySettingsSerializer,
app_settings.AUTOREPLY_PARAMETERS_STRUCT,
serializers.AutoreplySettingsSerializer,
False,
)
class PostfixAutoreplyConfig(AppConfig):
class AutoreplyConfig(AppConfig):
"""App configuration."""
name = "modoboa.postfix_autoreply"
verbose_name = "Auto-reply functionality using Postfix"
name = "modoboa.autoreply"
verbose_name = "Auto-reply functionality"
def ready(self):
load_settings()
from . import handlers # NOQA:F401
@@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.utils.timezone
@@ -122,9 +119,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="arhistoric",
name="armessage",
field=models.ForeignKey(
to="postfix_autoreply.ARmessage", on_delete=models.CASCADE
),
field=models.ForeignKey(to="autoreply.ARmessage", on_delete=models.CASCADE),
preserve_default=True,
),
migrations.AlterUniqueTogether(
@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
("postfix_autoreply", "0001_initial"),
("autoreply", "0001_initial"),
]
operations = [
@@ -1,12 +1,9 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def move_aliases(apps, schema_editor):
"""Move exising aliases to the main table."""
OldAlias = apps.get_model("postfix_autoreply", "Alias")
OldAlias = apps.get_model("autoreply", "Alias")
Alias = apps.get_model("admin", "Alias")
AliasRecipient = apps.get_model("admin", "AliasRecipient")
try:
@@ -31,7 +28,7 @@ def move_aliases(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("postfix_autoreply", "0002_auto_20150728_1236"),
("autoreply", "0002_auto_20150728_1236"),
]
operations = [migrations.RunPython(move_aliases)]
@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("postfix_autoreply", "0003_move_aliases"),
("autoreply", "0003_move_aliases"),
]
operations = [
@@ -1,20 +1,15 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def remove_useless_aliases(apps, schema_editor):
"""Remove aliases linked to disabled messages."""
ARmessage = apps.get_model("postfix_autoreply", "ARmessage")
ARmessage = apps.get_model("autoreply", "ARmessage")
AliasRecipient = apps.get_model("admin", "AliasRecipient")
qset = ARmessage.objects.select_related("mbox", "mbox__domain").filter(
enabled=False
)
for armessage in qset:
alr_address = "{0}@{1}@autoreply.{1}".format(
armessage.mbox.address, armessage.mbox.domain
)
alr_address = f"{armessage.mbox.address}@{armessage.mbox.domain}@autoreply.{armessage.mbox.domain}"
try:
alr = AliasRecipient.objects.get(address=alr_address)
except AliasRecipient.DoesNotExist:
@@ -28,7 +23,7 @@ def remove_useless_aliases(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("postfix_autoreply", "0004_delete_alias"),
("autoreply", "0004_delete_alias"),
]
operations = [
@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("postfix_autoreply", "0005_auto_20151202_1623"),
("autoreply", "0005_auto_20151202_1623"),
]
operations = [
@@ -1,13 +1,9 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.15 on 2018-09-28 12:23
from __future__ import unicode_literals
from django.db import migrations
def move_transport_entries(apps, schema_editor):
"""Move old transport entries to new model."""
pf_Transport = apps.get_model("postfix_autoreply", "Transport")
pf_Transport = apps.get_model("autoreply", "Transport")
Transport = apps.get_model("transport", "Transport")
to_create = []
for old_transport in pf_Transport.objects.all():
@@ -23,7 +19,7 @@ class Migration(migrations.Migration):
dependencies = [
("transport", "0002_auto_20180928_1520"),
("postfix_autoreply", "0006_auto_20160329_1501"),
("autoreply", "0006_auto_20160329_1501"),
]
operations = [
@@ -1,14 +1,10 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-09-28 12:27
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("postfix_autoreply", "0007_auto_20180928_1423"),
("autoreply", "0007_auto_20180928_1423"),
]
operations = [
@@ -1,12 +1,10 @@
# Generated by Django 3.2.12 on 2022-09-26 07:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("postfix_autoreply", "0008_delete_transport"),
("autoreply", "0008_delete_transport"),
]
operations = [
@@ -1,12 +1,10 @@
# Generated by Django 3.2.17 on 2023-04-21 16:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("postfix_autoreply", "0009_auto_20220926_0913"),
("autoreply", "0009_auto_20220926_0913"),
]
operations = [
@@ -0,0 +1,30 @@
# Generated by Django 4.2.22 on 2025-06-09 08:51
import copy
from django.db import migrations
def cleanup_and_rename(apps, schema_editor):
# Delete transport entries
Transport = apps.get_model("transport", "Transport")
Transport.objects.filter(pattern__startswith="autoreply").delete()
# Delete aliases
AliasRecipient = apps.get_model("admin", "AliasRecipient")
AliasRecipient.objects.filter(address__contains="@autoreply.").delete()
# Rename parameters
LocalConfig = apps.get_model("core", "LocalConfig")
cfg = LocalConfig.objects.first()
if not cfg:
return
cfg._parameters["autoreply"] = copy.deepcopy(cfg._parameters["postfix_autoreply"])
del cfg._parameters["postfix_autoreply"]
cfg.save()
class Migration(migrations.Migration):
dependencies = [
("autoreply", "0010_auto_20230421_1825"),
]
operations = [migrations.RunPython(cleanup_and_rename, migrations.RunPython.noop)]
@@ -0,0 +1,16 @@
# Generated by Django 4.2.22 on 2025-06-10 14:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("autoreply", "0011_auto_20250609_0851"),
]
operations = [
migrations.DeleteModel(
name="ARhistoric",
),
]
+81
View File
@@ -0,0 +1,81 @@
"""Postfix autoreply models."""
from django.db import models
from django.utils import timezone
from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _
from modoboa.admin.models import Mailbox
from modoboa.sievefilters.lib import SieveClient
class ARmessage(models.Model):
"""Auto reply messages."""
mbox = models.ForeignKey(Mailbox, on_delete=models.CASCADE)
subject = models.CharField(
_("subject"),
max_length=255,
help_text=_("The subject that will appear in sent emails"),
)
content = models.TextField(
_("content"), help_text=_("The content that will appear in sent emails")
)
enabled = models.BooleanField(
_("enabled"), help_text=_("Activate/Deactivate your auto reply"), default=False
)
fromdate = models.DateTimeField(default=timezone.now)
untildate = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "postfix_autoreply_armessage"
def __str__(self):
return f"AR<{self.mbox}>: {self.enabled}"
def update_sieve_rule(self, request, fset):
context = {"name": self.mbox.user.fullname}
days = request.localconfig.parameters.get_value("tracking_period")
context["fromdate"] = localize(self.fromdate)
fromdate = self.fromdate.isoformat()
fromdate, tz = fromdate.split("+")
tz = f"+{tz.replace(':', '')}"
condition = [("currentdate", ":zone", tz, ":value", "ge", "iso8601", fromdate)]
if self.untildate:
context["untildate"] = localize(self.untildate)
condition.append(
(
"currentdate",
":zone",
tz,
":value",
"lt",
"iso8601",
self.untildate.isoformat().split("+")[0],
)
)
content = self.content % context
name = "autoreply"
action = [("vacation", ":subject", self.subject, ":days", days, content)]
if not fset.getfilter(name):
fset.addfilter(name, condition, action)
else:
fset.updatefilter(name, name, condition, action)
for filter in fset.filters:
if filter["name"] == name:
filter["description"] = (
"Autoreply rule generated by Modoboa, do not edit!"
)
def manage_sieve_rule(self, request):
client = SieveClient(user=request.user.username, password=str(request.auth))
active_script, scripts = client.listscripts()
if not active_script:
active_script = "DefaultScript"
client.pushscript(active_script, "# Empty script", True)
fset = client.getscript(active_script, format="fset")
if self.enabled:
self.update_sieve_rule(request, fset)
else:
fset.removefilter("autoreply")
client.pushscript(fset.name, str(fset))
@@ -92,7 +92,7 @@ MODOBOA_APPS = (
'modoboa.pdfcredentials',
'modoboa.dmarc',
'modoboa.imap_migration',
'modoboa.postfix_autoreply',
'modoboa.autoreply',
'modoboa.sievefilters',
'modoboa.contacts',
'modoboa.calendars',
+1 -1
View File
@@ -145,7 +145,7 @@ SETTINGS_SAMPLE = {
"max_sync_accounts": 2,
"create_folders": True,
},
"postfix_autoreply": {
"autoreply": {
"autoreplies_timeout": 86400,
"default_subject": "I'm off",
"default_content": "I'm off",
-11
View File
@@ -1,11 +0,0 @@
"""Away message editor for Modoboa (postfix compatible)."""
from pkg_resources import DistributionNotFound, get_distribution
try:
__version__ = get_distribution(__name__).version
except DistributionNotFound:
# package is not installed
pass
default_app_config = "modoboa_postfix_autoreply.apps.PostfixAutoreplyConfig"
-76
View File
@@ -1,76 +0,0 @@
"""Django signal handlers for modoboa_postfix_autoreply."""
from django.db.models import signals
from django.dispatch import receiver
from modoboa.admin import models as admin_models
from modoboa.transport import models as tr_models
from . import models
@receiver(signals.post_save, sender=admin_models.Domain)
def manage_transport_entry(sender, instance, **kwargs):
"""Create or update a transport entry for this domain."""
if kwargs.get("created"):
tr_models.Transport.objects.get_or_create(
pattern=f"autoreply.{instance}", service="autoreply"
)
return
oldname = getattr(instance, "oldname", "None")
if oldname is None or oldname == instance.name:
return
tr_models.Transport.objects.filter(pattern=f"autoreply.{oldname}").update(
pattern=f"autoreply.{instance.name}"
)
qset = admin_models.AliasRecipient.objects.select_related(
"alias", "r_mailbox"
).filter(
alias__domain=instance, alias__internal=True, address__contains="@autoreply"
)
for alr in qset:
alr.address = alr.address.replace(oldname, instance.name)
alr.save()
@receiver(signals.post_delete, sender=admin_models.Domain)
def delete_transport_entry(sender, instance, **kwargs):
"""Delete a transport entry."""
tr_models.Transport.objects.filter(pattern=f"autoreply.{instance}").delete()
@receiver(signals.post_save, sender=admin_models.Mailbox)
def rename_autoreply_alias(sender, instance, **kwargs):
"""Rename AR alias if needed."""
old_address = getattr(instance, "old_full_address", None)
if old_address is None or old_address == instance.full_address:
return
admin_models.AliasRecipient.objects.filter(
address__contains=f"{old_address}@autoreply"
).update(address=f"{instance.full_address}@autoreply.{instance.domain}")
@receiver(signals.post_delete, sender=admin_models.Mailbox)
def delete_autoreply_alias(sender, instance, **kwargs):
"""Delete alias."""
admin_models.AliasRecipient.objects.filter(
address=f"{instance.full_address}@autoreply.{instance.domain}"
).delete()
@receiver(signals.post_save, sender=models.ARmessage)
def manage_autoreply_alias(sender, instance, **kwargs):
"""Create or delete the alias."""
ar_alias_address = f"{instance.mbox.full_address}@autoreply.{instance.mbox.domain}"
admin_models.Alias.objects.get(
address=instance.mbox.full_address, domain=instance.mbox.domain, internal=True
)
alias, created = admin_models.Alias.objects.get_or_create(
address=instance.mbox.full_address, domain=instance.mbox.domain, internal=True
)
if instance.enabled:
admin_models.AliasRecipient.objects.get_or_create(
alias=alias, address=ar_alias_address
)
else:
admin_models.AliasRecipient.objects.filter(address=ar_alias_address).delete()
@@ -1,29 +0,0 @@
"""Know problems and associated fixes."""
from modoboa.admin import models as admin_models
from modoboa.admin.management.commands.subcommands import _repair
from . import models
@_repair.known_problem
def ensure_autoreplies_recipents_are_valids(**options):
"""Sometime autoreply alias exists when ARmessage is not enabled."""
deleted = 0
qs = admin_models.AliasRecipient.objects.select_related("alias").filter(
address__contains="@autoreply."
)
for alr in qs:
address, domain = alr.alias.address.split("@")
arqs = models.ARmessage.objects.filter(
mbox__address=address, mbox__domain__name=domain
)
if not arqs.exists():
_repair.log(f"Delete {alr} (AR does not exist)", **options)
alr.delete()
deleted += 1
elif arqs.filter(enabled=False).exists():
_repair.log(f"Delete {alr} (AR is disabled)", **options)
alr.delete()
deleted += 1
if deleted:
_repair.log(f"{deleted} alias recipient(s) deleted", **options)
@@ -1,205 +0,0 @@
import datetime
import email
import email.header
import fileinput
from io import StringIO
import logging
import smtplib
import sys
from logging.handlers import SysLogHandler
from django.core.mail import EmailMessage
from django.core.management.base import BaseCommand
from django.utils import timezone, translation
from django.utils.encoding import smart_str
from django.utils.formats import localize
from modoboa.admin.models import Mailbox
from modoboa.lib.email_utils import split_mailbox
from modoboa.parameters import tools as param_tools
from ...models import ARhistoric, ARmessage
logger = logging.getLogger()
def safe_subject(msg):
"""Clean message subject and return it."""
decoded = email.header.decode_header(msg.get("Subject"))
subject = ""
for sub, charset in decoded:
if isinstance(sub, str):
subject += sub
continue
# charset can be None
charset = charset if charset else "utf-8"
try:
subject += sub.decode(charset)
except UnicodeDecodeError:
pass
return " ".join(subject.split())
def send_autoreply(sender, mailbox, armessage, original_msg):
"""Send an autoreply message."""
if armessage.fromdate > timezone.now():
# Too soon, come back later
return
condition = armessage.untildate is not None and armessage.untildate < timezone.now()
if condition:
# ARmessage has expired, disable it
armessage.enabled = False
armessage.save(update_fields=["enabled"])
return
try:
lastar = ARhistoric.objects.get(armessage=armessage.id, sender=sender)
timeout = param_tools.get_global_parameter(
"autoreplies_timeout", app="postfix_autoreply"
)
delta = datetime.timedelta(seconds=int(timeout))
now = timezone.make_aware(
datetime.datetime.now(), timezone.get_default_timezone()
)
if lastar.last_sent + delta > now:
logger.debug(
"no autoreply message sent because delta (%s) < timetout (%s)",
delta,
timeout,
)
return
except ARhistoric.DoesNotExist:
lastar = ARhistoric()
lastar.armessage = armessage
lastar.sender = sender
headers = {"Auto-Submitted": "auto-replied", "Precedence": "bulk"}
message_id = original_msg.get("Message-ID", "").strip("\n")
if message_id:
headers.update({"In-Reply-To": message_id, "References": message_id})
subject = safe_subject(original_msg)
with translation.override(mailbox.user.language):
context = {
"name": mailbox.user.fullname,
"fromdate": localize(armessage.fromdate),
"untildate": "",
}
if armessage.untildate:
context.update({"untildate": localize(armessage.untildate)})
content = armessage.content % context
msg = EmailMessage(
f"Auto: {armessage.subject} Re: {subject}",
smart_str(content),
mailbox.user.encoded_address,
[sender],
headers=headers,
)
try:
msg.send()
except smtplib.SMTPException as exp:
logger.error("Failed to send autoreply message: %s", exp)
sys.exit(1)
logger.debug("autoreply message sent to %s", mailbox.user.encoded_address)
lastar.last_sent = datetime.datetime.now()
lastar.save()
class Command(BaseCommand):
"""Command definition."""
help = "Send autoreply emails" # NOQA:A003
def add_arguments(self, parser):
"""Add extra arguments to command line."""
parser.add_argument("--debug", action="store_true", dest="debug", default=False)
parser.add_argument(
"--syslog-socket-path", default="/dev/log", help="Path to syslog socket"
)
parser.add_argument("sender")
parser.add_argument("recipient", nargs="+")
def handle(self, *args, **options):
try:
handler = SysLogHandler(address=options["syslog_socket_path"])
except OSError as ex:
if ex.errno == 2:
# try the default, localhost:514
handler = SysLogHandler()
else:
raise
logger.addHandler(handler)
logger.setLevel(logging.ERROR)
if options["debug"]:
logger.setLevel(logging.DEBUG)
logger.debug(
"autoreply sender=%s recipient=%s",
options["sender"],
",".join(options["recipient"]),
)
sender = smart_str(options["sender"])
sender_localpart = split_mailbox(sender.lower())[0]
if (
(sender_localpart in ("mailer-daemon", "listserv", "majordomo"))
or (sender_localpart.startswith("owner-"))
or (sender_localpart.endswith("-request"))
):
logger.debug("Skip auto reply, this mail comes from a mailing list")
return
content = StringIO()
for line in fileinput.input([]):
content.write(line)
content.seek(0)
original_msg = email.message_from_file(content)
# Mailing list filter based on
# https://tools.ietf.org/html/rfc5230#page-7
ml_known_headers = [
"List-Id",
"List-Help",
"List-Subscribe",
"List-Unsubscribe",
"List-Post",
"List-Owner",
"List-Archive",
]
from_ml = False
for header in ml_known_headers:
if header in original_msg:
from_ml = True
break
condition = (
original_msg.get("Precedence") == "bulk"
or original_msg.get("X-Mailer") == "PHPMailer"
or from_ml
)
if condition:
logger.debug("Skip auto reply, this mail comes from a mailing list")
return
recipients = [smart_str(rcpt) for rcpt in options["recipient"]]
for fulladdress in recipients:
address, domain = split_mailbox(fulladdress)
try:
mbox = Mailbox.objects.get(address=address, domain__name=domain)
except Mailbox.DoesNotExist:
msg = f"Unknown recipient {fulladdress}"
logger.debug("autoreply %s", msg)
continue
try:
armessage = ARmessage.objects.get(mbox=mbox.id, enabled=True)
logger.debug("autoreply message found")
except ARmessage.DoesNotExist:
logger.debug("autoreply message not found")
continue
send_autoreply(sender, mbox, armessage, original_msg)
-44
View File
@@ -1,44 +0,0 @@
"""Postfix autoreply models."""
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from modoboa.admin.models import Mailbox
class ARmessage(models.Model):
"""Auto reply messages."""
mbox = models.ForeignKey(Mailbox, on_delete=models.CASCADE)
subject = models.CharField(
_("subject"),
max_length=255,
help_text=_("The subject that will appear in sent emails"),
)
content = models.TextField(
_("content"), help_text=_("The content that will appear in sent emails")
)
enabled = models.BooleanField(
_("enabled"), help_text=_("Activate/Deactivate your auto reply"), default=False
)
fromdate = models.DateTimeField(default=timezone.now)
untildate = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "postfix_autoreply_armessage"
def __str__(self):
return f"AR<{self.mbox}>: {self.enabled}"
class ARhistoric(models.Model):
"""Auto reply historic."""
armessage = models.ForeignKey(ARmessage, on_delete=models.CASCADE)
last_sent = models.DateTimeField(auto_now=True)
sender = models.CharField(max_length=254)
class Meta:
unique_together = ("armessage", "sender")
db_table = "postfix_autoreply_arhistoric"
-419
View File
@@ -1,419 +0,0 @@
"""modoboa-postfix-autoreply unit tests."""
import datetime
from io import StringIO
import sys
from dateutil.relativedelta import relativedelta
from django.core import mail, management
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
from modoboa.admin import factories as admin_factories, models as admin_models
from modoboa.core.models import User
from modoboa.lib.tests import ModoAPITestCase, ModoTestCase
from modoboa.transport import models as tr_models
from . import factories, models
SIMPLE_EMAIL_CONTENT = """
From: Homer Simpson <homer@simpson.test>
Date: Wed, 15 Mar 2017 18:35:19 +0100
Message-ID: <CAN0378wA1V0VJg5OxyavB2uJgAimMc2ttGSc-yvWsXTaKqnKuw@simpson.test>
Subject: Test
To: user@test.com
Content-Type: multipart/alternative; boundary=001a114420be4c231a054ac85e75
--001a114420be4c231a054ac85e75
Content-Type: text/plain; charset=UTF-8
pouet
--001a114420be4c231a054ac85e75
Content-Type: text/html; charset=UTF-8
<div dir="ltr">pouet<br></div>
--001a114420be4c231a054ac85e75--
"""
SIMPLE_EMAIL_CONTENT_WITH_BAD_HEADER = """
From: Homer Simpson <homer@simpson.test>
Date: Wed, 15 Mar 2017 18:35:19 +0100
Message-ID:
<CAN0378wA1V0VJg5OxyavB2uJgAimMc2ttGSc-yvWsXTaKqnKuw@simpson.test>
Subject: Test
To: user@test.com
Content-Type: multipart/alternative; boundary=001a114420be4c231a054ac85e75
--001a114420be4c231a054ac85e75
Content-Type: text/plain; charset=UTF-8
pouet
--001a114420be4c231a054ac85e75
Content-Type: text/html; charset=UTF-8
<div dir="ltr">pouet<br></div>
--001a114420be4c231a054ac85e75--
"""
ENCODED_EMAIL_SUBJECT = r"""
From: Homer Simpson <homer@simpson.test>
Date: Wed, 15 Mar 2017 18:35:19 +0100
Message-ID: <CAN0378wA1V0VJg5OxyavB2uJgAimMc2ttGSc-yvWsXTaKqnKuw@simpson.test>
Subject: =?utf-8?q?A_m=C3=A9_non!!?=\n =?utf-8?q?_=C3=A7a_va_pas!!?=
To: user@test.com
Content-Type: multipart/alternative; boundary=001a114420be4c231a054ac85e75
--001a114420be4c231a054ac85e75
Content-Type: text/plain; charset=UTF-8
pouet
--001a114420be4c231a054ac85e75
Content-Type: text/html; charset=UTF-8
<div dir="ltr">pouet<br></div>
--001a114420be4c231a054ac85e75--
"""
EMAIL_FROM_ML_CONTENT = """
From: Homer Simpson <homer@simpson.test>
Date: Wed, 15 Mar 2017 18:35:19 +0100
Message-ID: <CAN0378wA1V0VJg5OxyavB2uJgAimMc2ttGSc-yvWsXTaKqnKuw@simpson.test>
List-Id: "Test list" <list@test.org>
List-Archive: <http://lists.test.org/pipermail/test-list/>
Subject: Test
To: user@test.com
Content-Type: multipart/alternative; boundary=001a114420be4c231a054ac85e75
--001a114420be4c231a054ac85e75
Content-Type: text/plain; charset=UTF-8
pouet
--001a114420be4c231a054ac85e75
Content-Type: text/html; charset=UTF-8
<div dir="ltr">pouet<br></div>
--001a114420be4c231a054ac85e75--
"""
class EventsTestCase(ModoAPITestCase):
@classmethod
def setUpTestData(cls): # NOQA:N802
"""Create test data."""
super().setUpTestData()
admin_factories.populate_database()
def _create_armessage(self, account):
values = {
"mbox": account.mailbox.id,
"subject": "I'm off",
"content": "I'll back soon",
"enabled": True,
}
response = self.client.post(reverse("v2:armessage-list"), values, format="json")
self.assertEqual(response.status_code, 201)
return response
def test_domain_created_event(self):
values = {
"name": "domain.tld",
"quota": 100,
"default_mailbox_quota": 1,
"type": "domain",
}
response = self.client.post(reverse("v2:domain-list"), values, format="json")
self.assertEqual(response.status_code, 201)
self.assertTrue(
tr_models.Transport.objects.filter(pattern="autoreply.domain.tld").exists()
)
def test_domain_deleted_event(self):
dom = admin_models.Domain.objects.get(name="test.com")
response = self.client.post(
reverse("v2:domain-delete", args=[dom.id]), {}, format="json"
)
self.assertEqual(response.status_code, 204)
with self.assertRaises(tr_models.Transport.DoesNotExist):
tr_models.Transport.objects.get(pattern="autoreply.test.com")
def test_domain_modified_event(self):
values = {
"name": "test.fr",
"quota": 100,
"default_mailbox_quota": 1,
"enabled": True,
"type": "domain",
}
dom = admin_models.Domain.objects.get(name="test.com")
response = self.client.put(
reverse("v2:domain-detail", args=[dom.id]), values, format="json"
)
self.assertEqual(response.status_code, 200)
self.assertTrue(
tr_models.Transport.objects.filter(pattern="autoreply.test.fr").exists()
)
self.assertEqual(
admin_models.Alias.objects.filter(domain=dom, internal=True).count(), 2
)
for alr in admin_models.AliasRecipient.objects.filter(
address__contains="@test.fr"
):
self.assertIn("autoreply.test.fr", alr.address)
def test_armessage_postsave_event(self):
values = {
"username": "leon@test.com",
"first_name": "Tester",
"last_name": "Toto",
"role": "SimpleUsers",
"mailbox": {
"use_domain_quota": True,
},
"is_active": True,
"email": "leon@test.com",
"enabled": True,
"language": "en",
}
account = User.objects.get(username="user@test.com")
armessage = self._create_armessage(account)
# Rename account and check if alias is updated
response = self.client.put(
reverse("v2:account-detail", args=[account.id]), values, format="json"
)
self.assertEqual(response.status_code, 200)
self.assertTrue(
admin_models.AliasRecipient.objects.filter(
alias__address="leon@test.com",
alias__internal=True,
address="leon@test.com@autoreply.test.com",
).exists()
)
values = {
"mbox": account.mailbox.id,
"subject": "I'm off",
"content": "I'll back soon",
"enabled": False,
}
self.client.put(
reverse("v2:armessage-detail", args=[armessage.json()["id"]]),
values,
format="json",
)
self.assertFalse(
admin_models.AliasRecipient.objects.filter(
alias__address="leon@test.com",
alias__internal=True,
address="leon@test.com@autoreply.test.com",
).exists()
)
def test_mailbox_deleted_event(self):
account = User.objects.get(username="user@test.com")
self._create_armessage(account)
response = self.client.post(
reverse("v2:account-delete", args=[account.id]), {}, format="json"
)
self.assertEqual(response.status_code, 204)
self.assertFalse(
admin_models.AliasRecipient.objects.filter(
address="user@test.com@autoreply.test.com"
).exists()
)
self.assertFalse(
models.ARmessage.objects.filter(
mbox__address="user", mbox__domain__name="test.com"
).exists()
)
def test_modify_mailbox_event(self):
"""Rename mailbox."""
account = User.objects.get(username="user@test.com")
factories.ARmessageFactory(mbox=account.mailbox)
values = {
"username": "leon@test.com",
"first_name": "Tester",
"last_name": "Toto",
"role": "SimpleUsers",
"mailbox": {
"use_domain_quota": True,
},
"is_active": True,
"email": "leon@test.com",
"enabled": True,
"language": "en",
}
self.client.put(
reverse("v2:account-detail", args=[account.id]), values, format="json"
)
self.assertFalse(
admin_models.AliasRecipient.objects.filter(
alias__address="user@test.com",
alias__internal=True,
address="user@test.com@autoreply.test.com",
).exists()
)
self.assertTrue(
admin_models.AliasRecipient.objects.filter(
alias__address="leon@test.com",
alias__internal=True,
address="leon@test.com@autoreply.test.com",
).exists()
)
class RepairTestCase(ModoTestCase):
"""Check repair command."""
@classmethod
def setUpTestData(cls): # NOQA:N802
"""Create some data."""
super().setUpTestData()
admin_factories.populate_database()
def test_management_command(self):
"""Check if problems are fixed."""
mbox = admin_models.Mailbox.objects.get(user__username="user@test.com")
alias = admin_models.Alias.objects.get(internal=True, address=mbox.full_address)
arm = factories.ARmessageFactory(mbox=mbox, enabled=False)
ar_address = f"{mbox.full_address}@autoreply.{mbox.domain.name}"
admin_factories.AliasRecipientFactory(alias=alias, address=ar_address)
management.call_command("modo", "repair", "--quiet")
self.assertFalse(
admin_models.AliasRecipient.objects.filter(address=ar_address).exists()
)
admin_factories.AliasRecipientFactory(alias=alias, address=ar_address)
arm.delete()
management.call_command("modo", "repair", "--quiet")
self.assertFalse(
admin_models.AliasRecipient.objects.filter(address=ar_address).exists()
)
class ManagementCommandTestCase(ModoTestCase):
"""Management command related tests."""
@classmethod
def setUpTestData(cls): # NOQA:N802
"""Create some data."""
super().setUpTestData()
admin_factories.populate_database()
cls.account = User.objects.get(username="user@test.com")
def setUp(self):
"""Replace stdin."""
super().setUp()
self.stdin = sys.stdin
sys.stdin = StringIO(SIMPLE_EMAIL_CONTENT.strip())
self.arm = factories.ARmessageFactory(mbox=self.account.mailbox)
def tearDown(self):
"""Restore stdin."""
sys.stdin = self.stdin
def test_simple_case(self):
"""Check basic autoreply."""
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
self.assertEqual(len(mail.outbox), 1)
self.assertTrue(models.ARhistoric.objects.filter(armessage=self.arm).exists())
# Try to send a new one
sys.stdin.seek(0)
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
self.assertEqual(len(mail.outbox), 1)
def test_armessage_date_constraints(self):
"""Check date constraints."""
account = User.objects.get(username="admin@test.com")
arm = factories.ARmessageFactory(
mbox=account.mailbox, fromdate=timezone.now() + relativedelta(days=1)
)
management.call_command("autoreply", "homer@simpson.com", "admin@test.com")
self.assertEqual(len(mail.outbox), 0)
arm.fromdate = timezone.now() - relativedelta(days=2)
arm.untildate = timezone.now() - relativedelta(days=1)
arm.save()
sys.stdin.seek(0)
management.call_command("autoreply", "homer@simpson.com", "admin@test.com")
self.assertEqual(len(mail.outbox), 0)
arm.refresh_from_db()
self.assertFalse(arm.enabled)
def test_recipient_does_not_exist(self):
"""Message received for non local recipient."""
management.call_command("autoreply", "homer@simpson.com", "pouet@test.fr")
self.assertEqual(len(mail.outbox), 0)
def test_no_ar_message_defined(self):
"""No AR defined for local recipient."""
management.call_command("autoreply", "homer@simpson.com", "admin@test.com")
self.assertEqual(len(mail.outbox), 0)
def test_encoded_message(self):
"""Message received with encoded header"""
sys.stdin = StringIO(ENCODED_EMAIL_SUBJECT.strip())
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
self.assertEqual(len(mail.outbox), 1)
def test_message_from_ml(self):
"""Message received from a mailing list."""
sys.stdin = StringIO(EMAIL_FROM_ML_CONTENT.strip())
management.call_command("autoreply", "mailer-daemon@list.test", "user@test.com")
self.assertEqual(len(mail.outbox), 0)
sys.stdin.seek(0)
management.call_command("autoreply", "sender@list.test", "user@test.com")
self.assertEqual(len(mail.outbox), 0)
def test_variable_substitution(self):
"""Check when message contains variables."""
tz = timezone.get_current_timezone()
self.arm.fromdate = datetime.datetime(2017, 12, 8, 14, 0, 0, 0).replace(
tzinfo=tz
)
self.arm.content = "%(name)s %(fromdate)s %(untildate)s"
self.arm.save()
self.account.language = "fr"
self.account.save(update_fields=["language"])
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(
"user@test.com 8 décembre 2017 14:00", mail.outbox[0].body.strip()
)
def test_untildate_substitution(self):
"""Check substitution of until date."""
tz = timezone.get_current_timezone()
self.arm.fromdate = datetime.datetime(2017, 12, 8, 14, 0, 0, 0).replace(
tzinfo=tz
)
self.arm.untildate = timezone.now() + relativedelta(hours=1)
self.arm.content = "%(name)s %(fromdate)s %(untildate)s"
self.arm.save()
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(
"user@test.com Dec. 8, 2017, 2 p.m. {}".format(
date_format(self.arm.untildate, "DATETIME_FORMAT")
),
mail.outbox[0].body.strip(),
)
def test_message_with_bad_headers(self):
"""Message received with bad header (including newline)"""
sys.stdin = StringIO(SIMPLE_EMAIL_CONTENT_WITH_BAD_HEADER.strip())
management.call_command("autoreply", "homer@simpson.test", "user@test.com")
self.assertEqual(len(mail.outbox), 1)
@@ -64,6 +64,8 @@ class FilterSerializer(serializers.Serializer):
def from_filters(filters: List[Filter]) -> "FilterSerializer":
result = []
for fobj in filters:
if fobj["name"] == "autoreply":
continue
test = (
fobj["content"].children[0] if not fobj["enabled"] else fobj["content"]
)
+17 -14
View File
@@ -18,7 +18,23 @@ Application = get_application_model()
AccessToken = get_access_token_model()
class FilterSetViewSetTestCase(ModoAPITestCase):
class PatcherMixin:
"""Mixin to patch external dependencies."""
def setUp(self):
super().setUp()
patcher = mock.patch("sievelib.managesieve.Client")
self.mock_client = patcher.start()
self.mock_client.return_value = mocks.ManagesieveClientMock()
self.addCleanup(patcher.stop)
patcher = mock.patch("imaplib.IMAP4")
self.mock_imap4 = patcher.start()
self.mock_imap4.return_value = IMAP4Mock()
self.addCleanup(patcher.stop)
class FilterSetViewSetTestCase(PatcherMixin, ModoAPITestCase):
@classmethod
def setUpTestData(cls):
"""Create some users."""
@@ -42,19 +58,6 @@ class FilterSetViewSetTestCase(ModoAPITestCase):
application=cls.application,
)
def setUp(self):
"""Connect with a simpler user."""
super().setUp()
patcher = mock.patch("sievelib.managesieve.Client")
self.mock_client = patcher.start()
self.mock_client.return_value = mocks.ManagesieveClientMock()
self.addCleanup(patcher.stop)
patcher = mock.patch("imaplib.IMAP4")
self.mock_imap4 = patcher.start()
self.mock_imap4.return_value = IMAP4Mock()
self.addCleanup(patcher.stop)
def authenticate(self, username: str = "user@test.com"):
self.client.credentials(Authorization="Bearer " + self.access_token.token)
+5 -2
View File
@@ -10,7 +10,7 @@ from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework import response, viewsets
from rest_framework import permissions, response, viewsets
from rest_framework.decorators import action
from modoboa.lib.viewsets import HasMailbox
@@ -33,7 +33,10 @@ FILTER_SET_ID = OpenApiParameter(
class FilterSetViewSet(viewsets.ViewSet):
permission_classes = (HasMailbox,)
permission_classes = (
permissions.IsAuthenticated,
HasMailbox,
)
serializer_class = serializers.FilterSetSerializer
def get_sieve_client(self, request):
+1 -1
View File
@@ -61,7 +61,7 @@ class ManagesieveClientMock:
}
def connect(self, username, password, **kwargs):
return username == "user@test.com"
return True
def logout(self):
return True
+1 -1
View File
@@ -18,7 +18,7 @@ urlpatterns = [
path("", include("modoboa.maillog.api.v2.urls")),
path("", include("modoboa.transport.api.v2.urls")),
path("", include("modoboa.pdfcredentials.api.v2.urls")),
path("", include("modoboa.postfix_autoreply.api.v2.urls")),
path("", include("modoboa.autoreply.api.v2.urls")),
path("", include("modoboa.sievefilters.api.v2.urls")),
path("", include("modoboa.contacts.urls")),
path("", include("modoboa.calendars.urls")),
+1 -1
View File
@@ -97,7 +97,7 @@ MODOBOA_APPS = (
"modoboa.pdfcredentials",
"modoboa.dmarc",
"modoboa.imap_migration",
"modoboa.postfix_autoreply",
"modoboa.autoreply",
"modoboa.sievefilters",
# Modoboa extensions here.
"modoboa.contacts",