mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Merge pull request #3550 from modoboa/feature/sieve_autoreply
Use sieve vacation plugin to manage auto-replies.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
=============
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Away message editor for Modoboa."""
|
||||
|
||||
default_app_config = "modoboa.autoreply.apps.AutoreplyConfig"
|
||||
+18
-3
@@ -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,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
-4
@@ -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 = [
|
||||
+2
-5
@@ -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
-4
@@ -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 = [
|
||||
+3
-8
@@ -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
-4
@@ -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 = [
|
||||
+2
-6
@@ -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
-5
@@ -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
-3
@@ -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
-3
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -61,7 +61,7 @@ class ManagesieveClientMock:
|
||||
}
|
||||
|
||||
def connect(self, username, password, **kwargs):
|
||||
return username == "user@test.com"
|
||||
return True
|
||||
|
||||
def logout(self):
|
||||
return True
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user