diff --git a/frontend/src/api/accounts.js b/frontend/src/api/accounts.js index ba04cf807..5e1063140 100644 --- a/frontend/src/api/accounts.js +++ b/frontend/src/api/accounts.js @@ -5,5 +5,11 @@ const resource = 'accounts' export default { getAll ({ domain, role }) { return repository.get(`/${resource}/`, { params: { domain, role } }) + }, + validate (data) { + return repository.post(`${resource}/validate/`, data) + }, + create (data) { + return repository.post(`${resource}/`, data) } } diff --git a/frontend/src/components/identities/AccountAliasForm.vue b/frontend/src/components/identities/AccountAliasForm.vue new file mode 100644 index 000000000..a77310ba2 --- /dev/null +++ b/frontend/src/components/identities/AccountAliasForm.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/components/identities/AccountCreationForm.vue b/frontend/src/components/identities/AccountCreationForm.vue index f6ac7caff..f58b1ad86 100644 --- a/frontend/src/components/identities/AccountCreationForm.vue +++ b/frontend/src/components/identities/AccountCreationForm.vue @@ -7,11 +7,19 @@ max-width="190" /> - General + Role - - Mail + Identification + + + Mailbox + + + Aliases + + + Summary mdi-close @@ -21,10 +29,10 @@
New account / - General + Role
- -
+ +
+ +
+ New account / + Identification +
+ +
+ + Back + + + Next + +
+
+ +
+ New account / + Mailbox +
+ +
+ + Back + + + Next + +
+
+ +
+ New account / + Aliases +
+ +
+ + Back + + + Next + +
+
+ +
Summary
+ +
+ + Confirm and create + +
+
@@ -41,21 +122,98 @@ diff --git a/frontend/src/components/identities/AccountGeneralForm.vue b/frontend/src/components/identities/AccountGeneralForm.vue index 454bef6b3..55d6f1827 100644 --- a/frontend/src/components/identities/AccountGeneralForm.vue +++ b/frontend/src/components/identities/AccountGeneralForm.vue @@ -3,37 +3,25 @@ - - - - diff --git a/frontend/src/components/identities/AccountMailboxForm.vue b/frontend/src/components/identities/AccountMailboxForm.vue new file mode 100644 index 000000000..ed2a788f1 --- /dev/null +++ b/frontend/src/components/identities/AccountMailboxForm.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/components/identities/AccountRoleForm.vue b/frontend/src/components/identities/AccountRoleForm.vue new file mode 100644 index 000000000..c2440aa9c --- /dev/null +++ b/frontend/src/components/identities/AccountRoleForm.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/components/identities/IdentityList.vue b/frontend/src/components/identities/IdentityList.vue index e7bb3c091..7fd509c1a 100644 --- a/frontend/src/components/identities/IdentityList.vue +++ b/frontend/src/components/identities/IdentityList.vue @@ -76,10 +76,15 @@ export default { selected: [] } }, + methods: { + fetchIdentities () { + identities.getAll().then(resp => { + this.identities = resp.data + }) + } + }, mounted () { - identities.getAll().then(resp => { - this.identities = resp.data - }) + this.fetchIdentities() } } diff --git a/frontend/src/components/tools/ChoiceField.vue b/frontend/src/components/tools/ChoiceField.vue index 43b2621fe..43c493c25 100644 --- a/frontend/src/components/tools/ChoiceField.vue +++ b/frontend/src/components/tools/ChoiceField.vue @@ -1,14 +1,15 @@ @@ -56,6 +57,9 @@ export default { methods: { closeCreationWizard () { this.showCreationWizard = false + }, + updateIdentities () { + this.$refs.identities.fetchIdentities() } } } diff --git a/modoboa/admin/api/v1/serializers.py b/modoboa/admin/api/v1/serializers.py index e93115678..6e2042af5 100644 --- a/modoboa/admin/api/v1/serializers.py +++ b/modoboa/admin/api/v1/serializers.py @@ -153,7 +153,7 @@ class MailboxSerializer(serializers.ModelSerializer): """Base mailbox serializer.""" full_address = lib_fields.DRFEmailFieldUTF8() - quota = serializers.CharField() + quota = serializers.CharField(required=False) class Meta: model = models.Mailbox @@ -169,6 +169,15 @@ class MailboxSerializer(serializers.ModelSerializer): """Convert quota to MB.""" return web_utils.size2integer(value, output_unit="MB") + def validate(self, data): + """Check if quota is required.""" + if not data.get("use_domain_quota", False): + if "quota" not in data: + raise serializers.ValidationError({ + "quota": _("This field is required") + }) + return data + class AccountSerializer(serializers.ModelSerializer): """Base account serializer.""" @@ -262,7 +271,7 @@ class WritableAccountSerializer(AccountSerializer): def __init__(self, *args, **kwargs): """Adapt fields to current user.""" - super(WritableAccountSerializer, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) request = self.context.get("request") if not request: return @@ -292,11 +301,11 @@ class WritableAccountSerializer(AccountSerializer): "full_address": data["username"], "use_domain_quota": True } - elif mailbox["full_address"] != data["username"]: + elif "full_address" in mailbox and mailbox["full_address"] != data["username"]: raise serializers.ValidationError({ "username": _("Must be equal to mailbox full_address") }) - if not data.get("random_password"): + if not self.partial and not data.get("random_password"): password = data.get("password") if password: try: @@ -415,45 +424,32 @@ class WritableAccountSerializer(AccountSerializer): class AliasSerializer(serializers.ModelSerializer): - """Base Alias serializer.""" + """Alias serializer.""" address = lib_fields.DRFEmailFieldUTF8AndEmptyUser() recipients = serializers.ListField( child=lib_fields.DRFEmailFieldUTF8AndEmptyUser(), allow_empty=False, - help_text=ugettext_lazy("A list of recipient")) + help_text=ugettext_lazy("A list of recipient") + ) class Meta: model = admin_models.Alias - fields = ("pk", "address", "enabled", "internal", "recipients") + fields = ( + "pk", "address", "enabled", "internal", "recipients" + ) def validate_address(self, value): """Check domain.""" - local_part, domain = email_utils.split_mailbox(value) - self.domain = admin_models.Domain.objects.filter(name=domain).first() - if self.domain is None: - raise serializers.ValidationError(_("Domain not found.")) - if not self.context["request"].user.can_access(self.domain): - raise serializers.ValidationError(_("Permission denied.")) + local_part, self.domain = admin_models.validate_alias_address( + value, self.context["request"].user) return value def create(self, validated_data): """Create appropriate objects.""" creator = self.context["request"].user - try: - core_signals.can_create_object.send( - sender=self.__class__, context=creator, - klass=admin_models.Alias) - core_signals.can_create_object.send( - sender=self.__class__, context=self.domain, - object_type="mailbox_aliases") - except lib_exceptions.ModoboaException as inst: - raise serializers.ValidationError(force_text(inst)) - recipients = validated_data.pop("recipients", None) - alias = admin_models.Alias(domain=self.domain, **validated_data) - alias.save(creator=creator) - alias.set_recipients(recipients) - return alias + return admin_models.Alias.objects.create( + creator=creator, domain=self.domain, **validated_data) def update(self, instance, validated_data): """Update objects.""" diff --git a/modoboa/admin/api/v2/serializers.py b/modoboa/admin/api/v2/serializers.py index 99198f194..058d2b091 100644 --- a/modoboa/admin/api/v2/serializers.py +++ b/modoboa/admin/api/v2/serializers.py @@ -7,6 +7,7 @@ from rest_framework import serializers from modoboa.admin.api.v1 import serializers as v1_serializers from modoboa.core import models as core_models, signals as core_signals from modoboa.lib import exceptions as lib_exceptions +from modoboa.lib import fields as lib_fields from modoboa.parameters import tools as param_tools from ... import constants, lib, models @@ -148,3 +149,49 @@ class IdentitySerializer(serializers.Serializer): identity = serializers.CharField() name_or_rcpt = serializers.CharField() tags = TagSerializer(many=True) + + +class WritableAccountSerializer(v1_serializers.WritableAccountSerializer): + """Add support for aliases and sender addresses.""" + + aliases = serializers.ListField( + child=lib_fields.DRFEmailFieldUTF8(), + required=False + ) + + class Meta(v1_serializers.WritableAccountSerializer.Meta): + fields = v1_serializers.WritableAccountSerializer.Meta.fields + ( + "aliases", + ) + + def validate_aliases(self, value): + """Check if required domains are locals and user can access them.""" + aliases = [] + for alias in value: + localpart, domain = models.validate_alias_address( + alias, self.context["request"].user) + aliases.append({"localpart": localpart, "domain": domain}) + return aliases + + def create(self, validated_data): + """Handle aliases.""" + user = self.context["request"].user + aliases = validated_data.pop("aliases", None) + user = super().create(validated_data) + if aliases: + for alias in aliases: + models.Alias.objects.create( + creator=user, + domain=alias.domain, + address=alias.localpart, + recipients=[validated_data["mailbox"]["full_address"]] + ) + return user + + +class AliasSerializer(v1_serializers.AliasSerializer): + """Alias serializer for v2 API.""" + + class Meta(v1_serializers.AliasSerializer.Meta): + # We remove 'internal' field + fields = ("pk", "address", "enabled", "internal", "recipients") diff --git a/modoboa/admin/api/v2/viewsets.py b/modoboa/admin/api/v2/viewsets.py index d0a942aa1..43ca7b5a9 100644 --- a/modoboa/admin/api/v2/viewsets.py +++ b/modoboa/admin/api/v2/viewsets.py @@ -136,6 +136,11 @@ class AccountViewSet(v1_viewsets.AccountViewSet): filter_backends = (filters.SearchFilter, dj_filters.DjangoFilterBackend) filterset_class = AccountFilterSet + def get_serializer_class(self): + if self.action in ["create", "validate"]: + return serializers.WritableAccountSerializer + return super().get_serializer_class() + def get_queryset(self): """Filter queryset based on current user.""" user = self.request.user @@ -146,6 +151,16 @@ class AccountViewSet(v1_viewsets.AccountViewSet): ) return core_models.User.objects.filter(pk__in=ids) + @action(methods=["post"], detail=False) + def validate(self, request, **kwargs): + """Validate given alias without creating it.""" + serializer = self.get_serializer( + data=request.data, + context=self.get_serializer_context(), + partial=True) + serializer.is_valid(raise_exception=True) + return response.Response(status=204) + class IdentityViewSet(viewsets.ViewSet): """Viewset for identities.""" diff --git a/modoboa/admin/models/__init__.py b/modoboa/admin/models/__init__.py index 67e40e208..e5a6c1d69 100644 --- a/modoboa/admin/models/__init__.py +++ b/modoboa/admin/models/__init__.py @@ -1,7 +1,7 @@ """Admin models.""" from .alarm import Alarm -from .alias import Alias, AliasRecipient +from .alias import Alias, AliasRecipient, validate_alias_address from .base import AdminObject from .domain import Domain from .domain_alias import DomainAlias @@ -21,4 +21,5 @@ __all__ = [ "MXRecord", "Quota", "SenderAddress", + "validate_alias_address", ] diff --git a/modoboa/admin/models/alias.py b/modoboa/admin/models/alias.py index 468b335df..f31db2af2 100644 --- a/modoboa/admin/models/alias.py +++ b/modoboa/admin/models/alias.py @@ -3,8 +3,7 @@ import hashlib import random -from reversion import revisions as reversion - +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import ( @@ -12,10 +11,12 @@ from django.utils.encoding import ( ) from django.utils.translation import ugettext as _, ugettext_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, PermDeniedException + BadRequest, Conflict, NotFound, ModoboaException ) from .. import signals from .base import AdminObject @@ -23,9 +24,43 @@ from .domain import Domain from .mailbox import Mailbox -@python_2_unicode_compatible -class Alias(AdminObject): +def validate_alias_address(address, creator, internal=False): + """Check if the given alias address can be created by creator.""" + local_part, domain = split_mailbox(address) + 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 Alias.objects.filter(address=address, internal=internal).exists(): + raise ValidationError(_("An alias with this name already exists.")) + 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)) + return local_part, domain + +class AliasManager(models.Manager): + """Custom manager for Alias.""" + + def create(self, *args, **kwargs): + """Custom create method.""" + creator = kwargs.pop("creator") + recipients = kwargs.pop("recipients") + alias = super().create(*args, **kwargs) + alias.post_create(creator) + alias.set_recipients(recipients) + return alias + + +class Alias(AdminObject): """Mailbox alias.""" address = models.CharField( @@ -47,13 +82,15 @@ class Alias(AdminObject): ugettext_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 smart_text(self.address) + return self.address @classmethod def generate_random_address(cls): @@ -167,23 +204,11 @@ class Alias(AdminObject): def from_csv(self, user, row, expected_elements=5): """Create a new alias from a CSV file entry.""" if len(row) < expected_elements: - raise BadRequest(_("Invalid line: %s" % row)) - address = row[1].strip().lower() - localpart, domname = split_mailbox(address) - try: - domain = Domain.objects.get(name=domname) - except Domain.DoesNotExist: - raise BadRequest(_("Domain '%s' does not exist" % domname)) - if not user.can_access(domain): - raise PermDeniedException - core_signals.can_create_object.send( - sender="import", context=user, klass=Alias) - core_signals.can_create_object.send( - sender="import", context=domain, object_type="mailbox_aliases") - if Alias.objects.filter(address=address, internal=False).exists(): + raise BadRequest(_("Invalid line: {}").format(row)) + self.address = row[1].strip().lower() + local_part, self.domain = validate_alias_address(self.address, user) + if Alias.objects.filter(address=self.address, internal=False).exists(): raise Conflict - self.address = address - self.domain = domain self.enabled = (row[2].strip().lower() in ["true", "1", "yes", "y"]) self.save() self.set_recipients([raddress.strip() for raddress in row[3:]])