diff --git a/modoboa/admin/forms/account.py b/modoboa/admin/forms/account.py index d14a9a365..eb6134ca5 100644 --- a/modoboa/admin/forms/account.py +++ b/modoboa/admin/forms/account.py @@ -13,10 +13,12 @@ from modoboa.core.models import User from modoboa.lib import events, parameters from modoboa.lib.email_utils import split_mailbox from modoboa.lib.exceptions import PermDeniedException, Conflict, NotFound +from modoboa.lib.fields import DomainNameField, UTF8EmailField from modoboa.lib.form_utils import ( - DomainNameField, DynamicForm, TabForms, WizardForm, WizardStep + DynamicForm, TabForms, WizardForm, WizardStep ) from modoboa.lib.permissions import get_account_roles +from modoboa.lib.validators import validate_utf8_email from modoboa.lib.web_utils import render_to_json_response from ..models import Domain, Mailbox, Alias @@ -121,13 +123,12 @@ class AccountFormGeneral(forms.ModelForm): return self.cleaned_data["role"] def clean_username(self): - from django.core.validators import validate_email if "role" not in self.cleaned_data: return self.cleaned_data["username"] if self.cleaned_data["role"] != "SimpleUsers": return self.cleaned_data["username"] uname = self.cleaned_data["username"].lower() - validate_email(uname) + validate_utf8_email(uname) return uname def clean_master_user(self): @@ -167,7 +168,7 @@ class AccountFormMail(forms.Form, DynamicForm): """Form to handle mail part.""" - email = forms.EmailField(label=ugettext_lazy("E-mail"), required=False) + email = UTF8EmailField(label=ugettext_lazy("E-mail"), required=False) quota = forms.IntegerField( label=ugettext_lazy("Quota"), required=False, @@ -178,7 +179,7 @@ class AccountFormMail(forms.Form, DynamicForm): widget=forms.widgets.TextInput(attrs={"class": "form-control"}) ) quota_act = forms.BooleanField(required=False) - aliases = forms.EmailField( + aliases = UTF8EmailField( label=ugettext_lazy("Alias(es)"), required=False, help_text=ugettext_lazy( @@ -211,7 +212,8 @@ class AccountFormMail(forms.Form, DynamicForm): if ralias.alias.recipients_count >= 2: continue name = "aliases_%d" % cpt - self._create_field(forms.EmailField, name, ralias.alias.address) + self._create_field( + UTF8EmailField, name, ralias.alias.address) cpt += 1 self.fields["email"].initial = self.mb.full_address self.fields["quota_act"].initial = self.mb.use_domain_quota @@ -221,7 +223,7 @@ class AccountFormMail(forms.Form, DynamicForm): self.fields["quota_act"].initial = True if len(args) and isinstance(args[0], QueryDict): - self._load_from_qdict(args[0], "aliases", forms.EmailField) + self._load_from_qdict(args[0], "aliases", UTF8EmailField) def clean_email(self): """Ensure lower case emails""" diff --git a/modoboa/admin/forms/alias.py b/modoboa/admin/forms/alias.py index 55dc77c70..76fa537ed 100644 --- a/modoboa/admin/forms/alias.py +++ b/modoboa/admin/forms/alias.py @@ -7,19 +7,19 @@ from django.http import QueryDict from django.utils.translation import ugettext as _, ugettext_lazy from modoboa.lib.email_utils import split_mailbox +from modoboa.lib import fields as lib_fields from modoboa.lib.form_utils import ( DynamicForm ) from ..models import Domain, Alias -from .base import EmailField class AliasForm(forms.ModelForm, DynamicForm): """A form to create/modify an alias.""" - address = EmailField( + address = lib_fields.UTF8AndEmptyUserEmailField( label=ugettext_lazy("Email address"), help_text=ugettext_lazy( "The alias address. To create a catchall alias, just enter the " @@ -27,7 +27,7 @@ class AliasForm(forms.ModelForm, DynamicForm): ), widget=forms.TextInput(attrs={"class": "form-control"}) ) - recipients = EmailField( + recipients = lib_fields.UTF8AndEmptyUserEmailField( label=ugettext_lazy("Recipients"), required=False, help_text=ugettext_lazy( "Addresses this alias will point to. Indicate only one address " diff --git a/modoboa/admin/forms/base.py b/modoboa/admin/forms/base.py deleted file mode 100644 index f562bd4c9..000000000 --- a/modoboa/admin/forms/base.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Custom form tools.""" - -import re - -from django.core.validators import EmailValidator -from django.forms.fields import EmailField as OriginalEmailField - - -class CustomEmailValidator(EmailValidator): - - """Allow empty user_part.""" - - user_regex = re.compile( - r"(^$" - r"|^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*\Z" # dot-atom - r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*"\Z)', # quoted-string - re.IGNORECASE) - -validate_email = CustomEmailValidator() - - -class EmailField(OriginalEmailField): - - """A field that accepts email addresses.""" - - default_validators = [validate_email] diff --git a/modoboa/admin/forms/domain.py b/modoboa/admin/forms/domain.py index 2b9f6435c..74e7f9362 100644 --- a/modoboa/admin/forms/domain.py +++ b/modoboa/admin/forms/domain.py @@ -8,8 +8,9 @@ from django.utils.translation import ugettext as _, ugettext_lazy from modoboa.core.models import User from modoboa.lib import events, parameters from modoboa.lib.exceptions import ModoboaException, Conflict +from modoboa.lib.fields import DomainNameField from modoboa.lib.form_utils import ( - DomainNameField, YesNoField, WizardForm, WizardStep, DynamicForm, TabForms + YesNoField, WizardForm, WizardStep, DynamicForm, TabForms ) from modoboa.lib.web_utils import render_to_json_response diff --git a/modoboa/admin/handlers.py b/modoboa/admin/handlers.py index f5cedfdad..a767b0666 100644 --- a/modoboa/admin/handlers.py +++ b/modoboa/admin/handlers.py @@ -12,16 +12,16 @@ def create_alias_for_domainalias(sender, instance, **kwargs): if not kwargs.get("created"): return alias = models.Alias.objects.create( - address="@{}".format(instance.name), enabled=True, internal=True) + address=u"@{}".format(instance.name), enabled=True, internal=True) models.AliasRecipient.objects.create( - address="@{}".format(instance.target.name), alias=alias) + address=u"@{}".format(instance.target.name), alias=alias) @receiver(signals.post_delete, sender=models.DomainAlias) def remove_alias_for_domainalias(sender, instance, **kwargs): """Remove the alias associated to domain alias.""" models.Alias.objects.filter( - address="@{}".format(instance.name)).delete() + address=u"@{}".format(instance.name)).delete() @receiver(signals.post_save, sender=models.Mailbox) diff --git a/modoboa/admin/models/domain.py b/modoboa/admin/models/domain.py index 848e0cf06..bbe6928f4 100644 --- a/modoboa/admin/models/domain.py +++ b/modoboa/admin/models/domain.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes import generic from django.db import models from django.db.models.manager import Manager +from django.utils.encoding import python_2_unicode_compatible, smart_text from django.utils.translation import ugettext as _, ugettext_lazy import reversion @@ -26,6 +27,7 @@ class DomainManager(Manager): return self.get_queryset().filter(owners__user=admin) +@python_2_unicode_compatible class Domain(AdminObject): """Mail domain.""" @@ -135,7 +137,7 @@ class Domain(AdminObject): events.raiseEvent("MailboxAliasDeleted", self.alias_set.all()) ungrant_access_to_objects(self.alias_set.all()) if parameters.get_admin("AUTO_ACCOUNT_REMOVAL") == "yes": - for account in User.objects.filter(mailbox__domain__name=self.name): + for account in User.objects.filter(mailbox__domain=self): account.delete(fromuser, keepdir) elif self.mailbox_set.count(): Quota.objects.filter(username__contains='@%s' % self.name).delete() @@ -144,7 +146,7 @@ class Domain(AdminObject): super(Domain, self).delete() def __str__(self): - return self.name + return smart_text(self.name) def from_csv(self, user, row): """Create a new domain from a CSV entry. diff --git a/modoboa/admin/models/domain_alias.py b/modoboa/admin/models/domain_alias.py index 5d6f37854..6a5cedf32 100644 --- a/modoboa/admin/models/domain_alias.py +++ b/modoboa/admin/models/domain_alias.py @@ -1,6 +1,7 @@ """Models related to domain aliases management.""" from django.db import models +from django.utils.encoding import python_2_unicode_compatible, smart_text from django.utils.translation import ugettext as _, ugettext_lazy import reversion @@ -10,6 +11,7 @@ from .domain import Domain from modoboa.lib.exceptions import BadRequest, Conflict +@python_2_unicode_compatible class DomainAlias(AdminObject): """Domain aliases.""" @@ -32,8 +34,8 @@ class DomainAlias(AdminObject): ) app_label = "admin" - def __unicode__(self): - return self.name + def __str__(self): + return smart_text(self.name) def from_csv(self, user, row): """Create a domain alias from a CSV row diff --git a/modoboa/admin/models/mailbox.py b/modoboa/admin/models/mailbox.py index a66dbaea2..c1d249069 100644 --- a/modoboa/admin/models/mailbox.py +++ b/modoboa/admin/models/mailbox.py @@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import Q from django.db.models.manager import Manager +from django.utils.encoding import python_2_unicode_compatible, smart_text from django.utils.translation import ugettext as _, ugettext_lazy import reversion @@ -69,6 +70,7 @@ class MailboxManager(Manager): return self.get_queryset().select_related().filter(qf) +@python_2_unicode_compatible class Mailbox(AdminObject): """User mailbox.""" @@ -96,7 +98,7 @@ class Mailbox(AdminObject): self.__mail_home = None def __str__(self): - return self.full_address + return smart_text(self.full_address) def __full_address(self, localpart): return "%s@%s" % (localpart, self.domain.name) diff --git a/modoboa/admin/templatetags/admin_tags.py b/modoboa/admin/templatetags/admin_tags.py index 6503d8f9f..e7abbd24a 100644 --- a/modoboa/admin/templatetags/admin_tags.py +++ b/modoboa/admin/templatetags/admin_tags.py @@ -147,7 +147,7 @@ def identities_menu(user, selection=None): def domain_actions(user, domain): actions = [ {"name": "listidentities", - "url": "{0}#list/?searchquery=@{1}".format( + "url": u"{0}#list/?searchquery=@{1}".format( reverse("admin:identity_list"), domain.name), "title": _("View the domain's identities"), "img": "fa fa-user"} diff --git a/modoboa/admin/tests/account.py b/modoboa/admin/tests/account.py index 24397ba04..df10dd7b0 100644 --- a/modoboa/admin/tests/account.py +++ b/modoboa/admin/tests/account.py @@ -1,3 +1,5 @@ +# coding: utf-8 + from django.core.urlresolvers import reverse from modoboa.core.models import User @@ -65,6 +67,16 @@ class AccountTestCase(ModoTestCase): alias__internal=True).exists() ) + def test_utf8_username(self): + """Create an account with non-ASCII characters.""" + values = dict( + username="téster@test.com", first_name="Tester", last_name="Toto", + password1="Toto1234", password2="Toto1234", role="SimpleUsers", + quota_act=True, + is_active=True, email="téster@test.com", stepid="step2" + ) + self.ajax_post(reverse("admin:account_add"), values) + def _set_quota(self, email, value, expected_status=200): account = User.objects.get(username=email) values = { diff --git a/modoboa/admin/tests/alias.py b/modoboa/admin/tests/alias.py index 5e5636dfb..b7cbb1503 100644 --- a/modoboa/admin/tests/alias.py +++ b/modoboa/admin/tests/alias.py @@ -114,6 +114,17 @@ class AliasTestCase(ModoTestCase): r_alias__isnull=True).exists() ) + def test_utf8_alias(self): + """Test alias with non-ASCII characters.""" + values = dict(address="testé@test.com", + recipients="user@test.com", + recipients_1="admin@test.com", + recipients_2="ext@titi.com", + enabled=True) + self.ajax_post( + reverse("admin:alias_add"), values + ) + def test_dlist(self): values = dict(address="all@test.com", recipients="user@test.com", diff --git a/modoboa/admin/tests/domain.py b/modoboa/admin/tests/domain.py index bc909015f..3738ef2fb 100644 --- a/modoboa/admin/tests/domain.py +++ b/modoboa/admin/tests/domain.py @@ -1,3 +1,5 @@ +# coding: utf-8 + """Domain related test cases.""" import json @@ -35,6 +37,19 @@ class DomainTestCase(ModoTestCase): self.assertEqual(dom.enabled, False) self.assertFalse(dom.admins) + def test_create_utf8(self): + """Test the creation of a domain with non-ASCII characters.""" + values = { + "name": "pouét.com", "quota": 100, "create_dom_admin": "no", + "type": "domain", "stepid": "step3" + } + self.ajax_post(reverse("admin:domain_add"), values) + dom = Domain.objects.get(name=u"pouét.com") + self.assertEqual(dom.name, u"pouét.com") + self.assertEqual(dom.quota, 100) + self.assertEqual(dom.enabled, False) + self.assertFalse(dom.admins) + def test_create_with_template(self): """Test the creation of a domain with a template diff --git a/modoboa/admin/tests/domain_alias.py b/modoboa/admin/tests/domain_alias.py index 38c4d5ef5..3a665b3e4 100644 --- a/modoboa/admin/tests/domain_alias.py +++ b/modoboa/admin/tests/domain_alias.py @@ -1,3 +1,5 @@ +# coding: utf-8 + from django.core.urlresolvers import reverse from modoboa.lib.tests import ModoTestCase @@ -38,7 +40,7 @@ class DomainAliasTestCase(ModoTestCase): dom = Domain.objects.get(name="test.com") values = dict( name=dom.name, quota=dom.quota, enabled=dom.enabled, - aliases="domalias.net", aliases_1="domalias.com", + aliases="domalias.net", aliases_1="domaliasé.com", type="domain" ) self.ajax_post( diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 08c103250..68bca8dff 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -13,6 +13,7 @@ from django.db import models from django.db.models.signals import post_delete from django.dispatch import receiver from django.utils import timezone +from django.utils.encoding import python_2_unicode_compatible, smart_text from django.utils.translation import ugettext as _, ugettext_lazy import reversion @@ -33,6 +34,7 @@ except ImportError: ldap_available = False +@python_2_unicode_compatible class User(PermissionsMixin): """Custom User model. @@ -182,7 +184,7 @@ class User(PermissionsMixin): return getattr(self, self.USERNAME_FIELD) def __str__(self): - return self.get_username() + return smart_text(self.get_username()) def natural_key(self): return (self.get_username(),) diff --git a/modoboa/lib/fields.py b/modoboa/lib/fields.py new file mode 100644 index 000000000..d38322d21 --- /dev/null +++ b/modoboa/lib/fields.py @@ -0,0 +1,38 @@ +"""Custom form fields.""" + +from django import forms +from django.utils.translation import ugettext_lazy + +from . import validators + + +class DomainNameField(forms.fields.CharField): + + """A subclass of CharField that only accepts a valid domain name.""" + + default_error_messages = { + "invalid": ugettext_lazy("Enter a valid domain name") + } + + default_validators = [validators.validate_hostname] + + def clean(self, value): + value = self.to_python(value).strip() + return super(DomainNameField, self).clean(value) + + +class UTF8EmailField(forms.fields.EmailField): + + """A subclass of EmailField to support UTF8 addresses.""" + + default_validators = [validators.validate_utf8_email] + + +class UTF8AndEmptyUserEmailField(forms.fields.EmailField): + + """ + A subclass of EmailField to support UTF8 addresses (with or without + local part). + """ + + default_validators = [validators.validate_utf8_and_empty_user_email] diff --git a/modoboa/lib/form_utils.py b/modoboa/lib/form_utils.py index edb92305b..b7abb9c3b 100644 --- a/modoboa/lib/form_utils.py +++ b/modoboa/lib/form_utils.py @@ -6,9 +6,8 @@ import abc from collections import OrderedDict import re -from django.core.exceptions import ValidationError from django.forms import ChoiceField -from django.forms.fields import CharField, Field +from django.forms.fields import Field from django.forms.widgets import RadioSelect from django.forms.widgets import RadioChoiceInput from django.shortcuts import render @@ -371,37 +370,6 @@ class TabForms(object): # -def is_valid_hostname(hostname): - """Domain name validaton.""" - if len(hostname) > 255: - return False - if hostname[-1] == ".": - hostname = hostname[:-1] # strip exactly one dot from the - # right, if present - allowed = re.compile(r"(?!-)[A-Z\d-]{1,63}(? 255: + raise ValidationError(self.message, self.code) + if value[-1] == ".": + # strip exactly one dot from the right, if present. + value = value[:-1] + if not self.regex.match(value): + raise ValidationError(self.message, self.code) + +validate_hostname = HostnameValidator() + + +class UTF8EmailValidator(EmailValidator): + + """Validator for addresses using non-ASCII characters.""" + + # unicode letters range (must be a unicode string, not a raw string) + ul = u"\u00a1-\uffff" + ascii_set = u"-!#$%&'*+/=?^_`{}|~0-9A-Z" + user_regex_raw = ( + # dot-atom + ur"^[" + ascii_set + ul + "]+(\.[" + ascii_set + ul + "]+)*\Z" + # quoted-string + ur'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*"\Z)' + ) + user_regex = re.compile("(" + user_regex_raw, re.IGNORECASE) + +validate_utf8_email = UTF8EmailValidator() + + +class UT8AndEmptyUserEmailValidator(UTF8EmailValidator): + + """Same as upper + allows empty local part.""" + + user_regex = re.compile( + ur"(^$|" + UTF8EmailValidator.user_regex_raw, re.IGNORECASE) + +validate_utf8_and_empty_user_email = UT8AndEmptyUserEmailValidator() diff --git a/modoboa/relaydomains/forms.py b/modoboa/relaydomains/forms.py index a9055d7e5..c9be3021a 100644 --- a/modoboa/relaydomains/forms.py +++ b/modoboa/relaydomains/forms.py @@ -4,7 +4,8 @@ Postfix relay domains extension forms. from django import forms from django.utils.translation import ugettext_lazy -from modoboa.lib.form_utils import DomainNameField, WizardStep +from modoboa.lib.fields import DomainNameField +from modoboa.lib.form_utils import WizardStep from .models import RelayDomain