Files
modoboa/modoboa/admin/models/alias.py
T

338 lines
11 KiB
Python

"""Models related to aliases management."""
import hashlib
import random
from typing import Optional
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.encoding import force_bytes, force_str, smart_str
from django.utils.translation import gettext as _, gettext_lazy
from reversion import revisions as reversion
from modoboa.core import signals as core_signals
from modoboa.lib.email_utils import split_mailbox
from modoboa.lib.exceptions import (
BadRequest,
Conflict,
NotFound,
ModoboaException,
AliasExists,
)
from modoboa.parameters import tools as param_tools
from .. import signals
from .base import AdminObject
from .domain import Domain
from .mailbox import Mailbox
def validate_alias_address(
address: str,
creator,
internal: bool = False,
instance: Optional["Alias"] = None,
ignore_existing: bool = False,
check_limits: bool = True,
) -> tuple[str, Domain]:
"""Check if the given alias address can be created by creator."""
local_part, domain = split_mailbox(address.lower())
domain = Domain.objects.filter(name=domain).first()
if domain is None:
raise ValidationError(_("Domain not found."))
if not creator.can_access(domain):
raise ValidationError(_("Permission denied."))
if not instance or instance.address != address:
alias = Alias.objects.filter(address=address, internal=internal)
condition = alias.exists() and not ignore_existing
if condition:
raise AliasExists(alias.first().pk)
if instance is None and check_limits:
try:
# Check creator limits
core_signals.can_create_object.send(
sender="validate_alias_address", context=creator, klass=Alias
)
# Check domain limits
core_signals.can_create_object.send(
sender="validate_alias_address",
context=domain,
object_type="mailbox_aliases",
)
except ModoboaException as inst:
raise ValidationError(str(inst)) from None
return local_part, domain
class AliasManager(models.Manager):
"""Custom manager for Alias."""
def create(self, *args, **kwargs):
"""Custom create method."""
creator = kwargs.pop("creator", None)
recipients = kwargs.pop("recipients", None)
alias = super().create(*args, **kwargs)
if creator:
alias.post_create(creator)
if recipients:
alias.set_recipients(recipients)
return alias
def modify_or_create(self, address, recipients, creator, domain):
"""Add recipient if the alias already exists or create it."""
alias = self.get_queryset().filter(address=address, internal=False)
if alias.exists():
alias.first().add_recipients(recipients)
return
self.create(
creator=creator, domain=domain, address=address, recipients=recipients
)
def convert_to_list(raw_value: str) -> list[str]:
"""Convert input string to list of string."""
return [item.strip() for item in raw_value.split(",")]
def is_alias_target_allowed(domain: str) -> bool:
"""Check if alias target is allowed or not."""
params = dict(param_tools.get_global_parameters(app="admin"))
if params.get("alias_can_target_any_domain"):
blocked_domains = params.get("alias_target_block_list")
if blocked_domains and domain in convert_to_list(blocked_domains):
return False
else:
allowed_domains = params.get("alias_target_allow_list")
if not allowed_domains or domain not in convert_to_list(allowed_domains):
return False
return True
class Alias(AdminObject):
"""Mailbox alias."""
address = models.CharField(
gettext_lazy("address"),
max_length=254,
help_text=gettext_lazy("The alias address."),
)
domain = models.ForeignKey(Domain, null=True, on_delete=models.CASCADE)
enabled = models.BooleanField(
gettext_lazy("enabled"),
help_text=gettext_lazy("Check to activate this alias"),
default=True,
)
internal = models.BooleanField(default=False)
description = models.TextField(gettext_lazy("Description"), blank=True)
expire_at = models.DateTimeField(gettext_lazy("Expire at"), blank=True, null=True)
_objectname = "MailboxAlias"
objects = AliasManager()
class Meta:
ordering = ["address"]
unique_together = (("address", "internal"),)
app_label = "admin"
def __str__(self):
return self.address
@classmethod
def generate_random_address(cls):
"""Generate a random address (local part)."""
m = hashlib.md5()
for x in random.sample(range(10000000), 60):
m.update(force_bytes(x))
return m.hexdigest()[:20]
@property
def identity(self):
return self.address
@property
def name_or_rcpt(self):
rcpts_count = self.recipients_count
if not rcpts_count:
return "---"
rcpts = self.recipients
if rcpts_count > 1:
return f"{rcpts[0]}, ..."
return rcpts[0]
@property
def type(self):
return "alias"
@property
def tags(self):
return [{"name": "alias", "label": _("alias"), "type": "idt"}]
def get_absolute_url(self):
"""Return detail url for this alias."""
return reverse("admin:alias_detail", args=[self.pk])
def post_create(self, creator):
from modoboa.lib.permissions import grant_access_to_object
super().post_create(creator)
if creator.is_superuser:
for admin in self.domain.admins:
grant_access_to_object(admin, self)
def add_recipients(self, address_list):
"""Add recipients for this alias.
Special recipients:
* local mailbox + extension: r_mailbox will be set to local mailbox
* alias address == recipient address: valid only to keep local copies
(when a forward is defined) and to create exceptions when a catchall
is defined on the associated domain
"""
for address in set(address_list):
if not address:
continue
if self.aliasrecipient_set.filter(address=address).exists():
continue
local_part, domname, extension = split_mailbox(
address, return_extension=True
)
if domname is None:
raise BadRequest("{} {}".format(_("Invalid address"), address))
domain = Domain.objects.filter(name=domname).first()
if not domain and not is_alias_target_allowed(domname):
raise BadRequest(f"{_('Target domain not allowed')}: {domname}")
kwargs = {"address": address, "alias": self}
if (domain is not None) and (
any(
r[1]
for r in signals.use_external_recipients.send(
self, recipients=address
)
)
is False
):
rcpt = Mailbox.objects.filter(domain=domain, address=local_part).first()
if rcpt is None:
rcpt = Alias.objects.filter(
address=f"{local_part}@{domname}"
).first()
if rcpt is None:
raise NotFound(
_("Local recipient {}@{} not found").format(
local_part, domname
)
)
if rcpt.address == self.address:
raise Conflict
kwargs["r_alias"] = rcpt
else:
kwargs["r_mailbox"] = rcpt
AliasRecipient(**kwargs).save()
def remove_recipient_or_delete(self, recipient_to_delete: str):
"""
Delete the selected recipient from this alias
or delete the whole alias if only one is left.
"""
alias_recipients = list(self.recipients)
if recipient_to_delete in alias_recipients:
if len(alias_recipients) == 1:
# Only recipient, we delete the Alias
self.delete()
else:
alias_recipients.remove(recipient_to_delete)
self.set_recipients(alias_recipients)
self.save()
def set_recipients(self, address_list):
"""Set recipients for this alias."""
self.add_recipients(address_list)
# Remove old recipients
self.aliasrecipient_set.exclude(address__in=address_list).delete()
@property
def recipients(self):
"""Return the recipient list."""
return self.aliasrecipient_set.order_by("address").values_list(
"address", flat=True
)
@property
def recipients_count(self):
"""Return the number of recipients of this alias."""
return self.aliasrecipient_set.count()
def from_csv(self, user, row, expected_elements=5, formopts=False, **kwargs):
"""Create a new alias from a CSV file entry."""
if len(row) < expected_elements:
raise BadRequest(_("Invalid line: {}").format(row))
self.address = row[1].strip().lower()
try:
local_part, self.domain = validate_alias_address(self.address, user)
except AliasExists as e:
# If continue_if_exists is true, we simply update the alias
# Else we throw the conflict issue
continue_if_exists = False
if formopts:
continue_if_exists = formopts.get("continue_if_exists", False)
if continue_if_exists:
alias = Alias.objects.filter(
address=self.address, internal=False
).first()
alias.add_recipients([raddress.strip() for raddress in row[3:]])
alias.save()
return
else:
raise e
self.enabled = row[2].strip().lower() in ["true", "1", "yes", "y"]
self.save()
self.set_recipients([raddress.strip() for raddress in row[3:]])
self.post_create(user)
def to_csv_row(self):
"""Return a row to include in a CSV file."""
row = ["alias", force_str(self.address), self.enabled]
row += self.recipients
return row
def to_csv(self, csvwriter):
csvwriter.writerow(self.to_csv_row())
reversion.register(Alias)
class AliasRecipient(models.Model):
"""An alias recipient."""
address = models.EmailField()
alias = models.ForeignKey(Alias, on_delete=models.CASCADE)
# if recipient is a local mailbox
r_mailbox = models.ForeignKey(
Mailbox, blank=True, null=True, on_delete=models.CASCADE
)
# if recipient is a local alias
r_alias = models.ForeignKey(
Alias,
related_name="alias_recipient_aliases",
blank=True,
null=True,
on_delete=models.CASCADE,
)
class Meta:
app_label = "admin"
db_table = "modoboa_admin_aliasrecipient"
unique_together = [("alias", "r_mailbox"), ("alias", "r_alias")]
def __str__(self):
"""Return alias and recipient."""
return smart_str(f"{self.alias.address} -> {self.address}")