Use sieve vacation plugin to manage auto-replies.

No more postfix script.
This commit is contained in:
Antoine Nguyen
2025-06-10 17:02:18 +02:00
parent d867cac3b2
commit b4083ea3fb
41 changed files with 235 additions and 883 deletions
+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)
+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
@@ -124,7 +125,7 @@ class ARMessageViewSetTestCase(ModoAPITestCase):
)
class AccountARMessageViewSetTestCase(ModoAPITestCase):
class AccountARMessageViewSetTestCase(PatcherMixin, ModoAPITestCase):
"""API test case."""
@classmethod
@@ -9,7 +9,7 @@ from modoboa.admin import models as admin_models
from modoboa.lib.email_utils import split_mailbox
from modoboa.parameters import tools as param_tools
from modoboa.postfix_autoreply import models
from modoboa.autoreply import models
from . import serializers
@@ -76,7 +76,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 +88,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",
),
]
+86
View File
@@ -0,0 +1,86 @@
"""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}
if not self.fromdate:
condition = [("Subject", ":matches", "*")]
else:
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)
+4 -2
View File
@@ -1,6 +1,6 @@
"""Sievefilters serializers."""
from typing import List, Tuple
from typing import List, Self, Tuple
from sievelib import commands
from sievelib.factory import Filter
@@ -61,9 +61,11 @@ class FilterSerializer(serializers.Serializer):
actions = ActionSerializer(many=True)
@staticmethod
def from_filters(filters: List[Filter]) -> "FilterSerializer":
def from_filters(filters: List[Filter]) -> Self:
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)
+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",