"""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}")