diff --git a/modoboa/admin/forms/account.py b/modoboa/admin/forms/account.py index 5cc8c4394..cbcbbf587 100644 --- a/modoboa/admin/forms/account.py +++ b/modoboa/admin/forms/account.py @@ -22,7 +22,7 @@ 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 +from .. import models class AccountFormGeneral(forms.ModelForm): @@ -197,6 +197,16 @@ class AccountFormMail(forms.Form, DynamicForm): "a 'catchall' alias (ex: *@domain.tld)." ) ) + senderaddress = lib_fields.UTF8AndEmptyUserEmailField( + label=ugettext_lazy("Sender addresses"), + required=False, + help_text=ugettext_lazy( + "Additional sender address(es) for this account. The user will be " + "allowed to send emails using this address, even if it " + "does not exist locally. Indicate one address per input. Press " + "ENTER to add a new input." + ) + ) def __init__(self, *args, **kwargs): self.mb = kwargs.pop("instance", None) @@ -211,14 +221,17 @@ class AccountFormMail(forms.Form, DynamicForm): self.extra_fields.append(fname) if self.mb is not None: self.fields["email"].required = True - cpt = 1 qset = self.mb.aliasrecipient_set.filter(alias__internal=False) - for ralias in qset: - name = "aliases_%d" % cpt + for cpt, ralias in enumerate(qset): + name = "aliases_{}".format(cpt + 1) self._create_field( lib_fields.UTF8AndEmptyUserEmailField, name, ralias.alias.address) - cpt += 1 + for cpt, saddress in enumerate(self.mb.senderaddress_set.all()): + name = "senderaddress_{}".format(cpt + 1) + self._create_field( + lib_fields.UTF8AndEmptyUserEmailField, name, + saddress.address) self.fields["email"].initial = self.mb.full_address self.fields["quota_act"].initial = self.mb.use_domain_quota if not self.mb.use_domain_quota and self.mb.quota: @@ -229,6 +242,9 @@ class AccountFormMail(forms.Form, DynamicForm): if len(args) and isinstance(args[0], QueryDict): self._load_from_qdict( args[0], "aliases", lib_fields.UTF8AndEmptyUserEmailField) + self._load_from_qdict( + args[0], "senderaddress", + lib_fields.UTF8AndEmptyUserEmailField) def clean_email(self): """Ensure lower case emails""" @@ -237,8 +253,8 @@ class AccountFormMail(forms.Form, DynamicForm): if not domname: return email try: - self.domain = Domain.objects.get(name=domname) - except Domain.DoesNotExist: + self.domain = models.Domain.objects.get(name=domname) + except models.Domain.DoesNotExist: raise forms.ValidationError(_("Domain does not exist")) if not self.mb: try: @@ -254,23 +270,27 @@ class AccountFormMail(forms.Form, DynamicForm): Check if quota is >= 0 only when the domain value is not used. """ - super(AccountFormMail, self).clean() - if not self.cleaned_data["quota_act"] \ - and self.cleaned_data['quota'] is not None: - if self.cleaned_data["quota"] < 0: - self.add_error("quota", _("Must be a positive integer")) + cleaned_data = super(AccountFormMail, self).clean() + condition = ( + not cleaned_data["quota_act"] and + cleaned_data["quota"] is not None and + cleaned_data["quota"] < 0) + if condition: + self.add_error("quota", _("Must be a positive integer")) self.aliases = [] - for name, value in self.cleaned_data.iteritems(): - if not name.startswith("aliases"): - continue + self.sender_addresses = [] + for name, value in cleaned_data.iteritems(): if value == "": continue - local_part, domname = split_mailbox(value) - if not Domain.objects.filter(name=domname).exists(): - self.add_error(name, _("Local domain does not exist")) - break - self.aliases.append(value.lower()) - return self.cleaned_data + if name.startswith("aliases"): + local_part, domname = split_mailbox(value) + if not models.Domain.objects.filter(name=domname).exists(): + self.add_error(name, _("Local domain does not exist")) + continue + self.aliases.append(value.lower()) + elif name.startswith("senderaddress"): + self.sender_addresses.append(value.lower()) + return cleaned_data def create_mailbox(self, user, account): """Create a mailbox associated to :kw:`account`.""" @@ -278,7 +298,7 @@ class AccountFormMail(forms.Form, DynamicForm): raise lib_exceptions.PermDeniedException core_signals.can_create_object.send( self.__class__, context=user, object_type="mailboxes") - self.mb = Mailbox( + self.mb = models.Mailbox( address=self.locpart, domain=self.domain, user=account, use_domain_quota=self.cleaned_data["quota_act"]) self.mb.set_quota(self.cleaned_data["quota"], @@ -311,12 +331,27 @@ class AccountFormMail(forms.Form, DynamicForm): alias__address=alias).exists(): continue local_part, domname = split_mailbox(alias) - al = Alias(address=alias, enabled=account.is_active) - al.domain = Domain.objects.get(name=domname) + al = models.Alias(address=alias, enabled=account.is_active) + al.domain = models.Domain.objects.get(name=domname) al.save() al.set_recipients([self.mb.full_address]) al.post_create(user) + def _update_sender_addresses(self): + """Update mailbox sender addresses.""" + for saddress in self.mb.senderaddress_set.all(): + if saddress.address not in self.sender_addresses: + saddress.delete() + else: + self.sender_addresses.remove(saddress.address) + if not len(self.sender_addresses): + return + to_create = [] + for saddress in self.sender_addresses: + to_create.append( + models.SenderAddress(address=saddress, mailbox=self.mb)) + models.SenderAddress.objects.bulk_create(to_create) + def save(self, user, account): """Save or update account mailbox.""" if self.cleaned_data["email"] == "": @@ -339,6 +374,7 @@ class AccountFormMail(forms.Form, DynamicForm): account.save() self._update_aliases(user, account) + self._update_sender_addresses() return self.mb @@ -361,7 +397,8 @@ class AccountPermissionsForm(forms.Form, DynamicForm): if not hasattr(self, "account") or self.account is None: return - for pos, dom in enumerate(Domain.objects.get_for_admin(self.account)): + qset = models.Domain.objects.get_for_admin(self.account) + for pos, dom in enumerate(qset): name = "domains_%d" % (pos + 1) self._create_field(lib_fields.DomainNameField, name, dom.name) if len(args) and isinstance(args[0], QueryDict): @@ -370,7 +407,8 @@ class AccountPermissionsForm(forms.Form, DynamicForm): def save(self): current_domains = [ - dom.name for dom in Domain.objects.get_for_admin(self.account) + dom.name for dom in + models.Domain.objects.get_for_admin(self.account) ] for name, value in self.cleaned_data.items(): if not name.startswith("domains"): @@ -378,34 +416,31 @@ class AccountPermissionsForm(forms.Form, DynamicForm): if value in ["", None]: continue if value not in current_domains: - domain = Domain.objects.get(name=value) + domain = models.Domain.objects.get(name=value) domain.add_admin(self.account) - for domain in Domain.objects.get_for_admin(self.account): + for domain in models.Domain.objects.get_for_admin(self.account): if not filter(lambda name: self.cleaned_data[name] == domain.name, self.cleaned_data.keys()): domain.remove_admin(self.account) class AccountForm(TabForms): - """Account edition form.""" def __init__(self, request, *args, **kwargs): self.user = request.user self.forms = [ - dict(id="general", title=_("General"), - formtpl="admin/account_general_form.html", - cls=AccountFormGeneral, - new_args=[self.user], mandatory=True), - dict(id="mail", - title=_("Mail"), formtpl="admin/mailform.html", - cls=AccountFormMail), - dict( - id="perms", title=_("Permissions"), - formtpl="admin/permsform.html", - cls=AccountPermissionsForm - ) + {"id": "general", "title": _("General"), + "formtpl": "admin/account_general_form.html", + "cls": AccountFormGeneral, + "new_args": [self.user], "mandatory": True}, + {"id": "mail", + "title": _("Mail"), "formtpl": "admin/mailform.html", + "cls": AccountFormMail}, + {"id": "perms", "title": _("Permissions"), + "formtpl": "admin/permsform.html", + "cls": AccountPermissionsForm} ] cbargs = [self.user] if "instances" in kwargs: diff --git a/modoboa/admin/migrations/0006_auto_20160910_1030.py b/modoboa/admin/migrations/0006_auto_20160910_1030.py new file mode 100644 index 000000000..75c5bd8f5 --- /dev/null +++ b/modoboa/admin/migrations/0006_auto_20160910_1030.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2016-09-10 08:30 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0005_auto_20160719_0903'), + ] + + operations = [ + migrations.CreateModel( + name='SenderAddress', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.EmailField(max_length=254)), + ('mailbox', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='admin.Mailbox')), + ], + ), + migrations.AlterUniqueTogether( + name='senderaddress', + unique_together=set([('address', 'mailbox')]), + ), + ] diff --git a/modoboa/admin/models/__init__.py b/modoboa/admin/models/__init__.py index 0fdc800d3..29205eec3 100644 --- a/modoboa/admin/models/__init__.py +++ b/modoboa/admin/models/__init__.py @@ -1,10 +1,20 @@ +"""Admin models.""" + from .base import AdminObject from .domain import Domain, DNSBLResult from .domain_alias import DomainAlias -from .mailbox import Mailbox, Quota, MailboxOperation +from .mailbox import Mailbox, Quota, MailboxOperation, SenderAddress from .alias import Alias, AliasRecipient __all__ = [ - 'Domain', 'DNSBLResult', 'DomainAlias', 'Mailbox', 'Quota', 'Alias', - 'MailboxOperation', 'AdminObject', 'AliasRecipient' + "AdminObject", + "Alias", + "AliasRecipient", + "DNSBLResult", + "Domain", + "DomainAlias", + "Mailbox", + "MailboxOperation", + "Quota", + "SenderAddress", ] diff --git a/modoboa/admin/models/mailbox.py b/modoboa/admin/models/mailbox.py index 107e8f939..3c44842cb 100644 --- a/modoboa/admin/models/mailbox.py +++ b/modoboa/admin/models/mailbox.py @@ -340,6 +340,26 @@ class Mailbox(AdminObject): reversion.register(Mailbox) +@python_2_unicode_compatible +class SenderAddress(models.Model): + """Extra sender address for Mailbox.""" + + address = models.EmailField() + mailbox = models.ForeignKey(Mailbox) + + class Meta: + app_label = "admin" + unique_together = [ + ("address", "mailbox"), + ] + + def __str__(self): + """Return address.""" + return smart_text(self.address) + +reversion.register(SenderAddress) + + class MailboxOperation(models.Model): """An operation on a mailbox.""" diff --git a/modoboa/admin/postfix_maps.py b/modoboa/admin/postfix_maps.py index a0640a131..b256bd966 100644 --- a/modoboa/admin/postfix_maps.py +++ b/modoboa/admin/postfix_maps.py @@ -110,6 +110,33 @@ class SenderLoginMailboxMap(object): ) +class SenderLoginMailboxExtraMap(object): + """Map file to list per-mailbox extra addresses.""" + + filename = "sql-sender-login-mailboxes-extra.cf" + + # FIXME: is it necessary to filter against user status? + + mysql = ( + "SELECT concat(mb.address, '@', dom.name) FROM admin_mailbox mb " + "INNER JOIN admin_senderaddress sad ON sad.mailbox_id=mb.id " + "INNER JOIN admin_domain dom ON dom.id=mb.domain_id " + "WHERE sad.address='%s'" + ) + postgres = ( + "SELECT mb.address || '@' || dom.name) FROM admin_mailbox mb " + "INNER JOIN admin_senderaddress sad ON sad.mailbox_id=mb.id " + "INNER JOIN admin_domain dom ON dom.id=mb.domain_id " + "WHERE sad.address='%s'" + ) + sqlite = ( + "SELECT mb.address || '@' || dom.name) FROM admin_mailbox mb " + "INNER JOIN admin_senderaddress sad ON sad.mailbox_id=mb.id " + "INNER JOIN admin_domain dom ON dom.id=mb.domain_id " + "WHERE sad.address='%s'" + ) + + class SenderLoginAliasMap(object): """Map file to list authorized sender addresses (from aliases).""" @@ -139,5 +166,5 @@ class SenderLoginAliasMap(object): registry.add_files([ DomainsMap, DomainsAliasesMap, AliasesMap, MaintainMap, - SenderLoginAliasMap, SenderLoginMailboxMap + SenderLoginAliasMap, SenderLoginMailboxMap, SenderLoginMailboxExtraMap ]) diff --git a/modoboa/admin/static/admin/js/admin.js b/modoboa/admin/static/admin/js/admin.js index bb3a64c51..191860f40 100644 --- a/modoboa/admin/static/admin/js/admin.js +++ b/modoboa/admin/static/admin/js/admin.js @@ -493,6 +493,15 @@ Identities.prototype = { return true; } }); + $("#original_senderaddress").dynamic_input({ + input_added: function($row) { + $row.find("label").html(""); + }, + input_removed: function($input) { + $input.parents(".form-group").remove(); + return true; + } + }); $("#id_email").autocompleter({ from_character: "@", choices: $.proxy(this.get_domain_list, this) diff --git a/modoboa/admin/templates/admin/account_detail.html b/modoboa/admin/templates/admin/account_detail.html index fa85b5339..6e9c17b9f 100644 --- a/modoboa/admin/templates/admin/account_detail.html +++ b/modoboa/admin/templates/admin/account_detail.html @@ -26,7 +26,7 @@ {% trans "Full name" %}{{ object.fullname }} - {% trans "Role" %}{{ object.role }} + {% trans "Role" %}{{ object.get_role_display }} {% trans "Created" %}{{ object.date_joined|date:"SHORT_DATETIME_FORMAT" }} @@ -82,6 +82,18 @@ {% endfor %} +
+
+

{% trans "Additional sender addresses" %}

+
+ + {% for saddress in object.mailbox.senderaddress_set.all %} + + {% empty %} + + {% endfor %} +
{{ saddress }}
{% trans "No sender address defined" %}
+
{% endif %} {% for template in templates.right %} {% include template %} diff --git a/modoboa/admin/templates/admin/mailform.html b/modoboa/admin/templates/admin/mailform.html index 4b7b6ec63..929d34674 100644 --- a/modoboa/admin/templates/admin/mailform.html +++ b/modoboa/admin/templates/admin/mailform.html @@ -1,5 +1,7 @@ {% load form_tags %} + {% render_field form.email %} {% include "common/generic_field.html" with field=form.quota help_display_mode="tooltip" label_width="col-sm-4" appended_text=_("MB") activator=True activator_value=form.quota_act.value deactivate_if_empty=False activator_label=_("Use domain default value") activator_size="col-sm-5" %} {% render_extra_fields form %} {% render_fields_group form "aliases" %} +{% render_fields_group form "senderaddress" %} diff --git a/modoboa/admin/tests/test_account.py b/modoboa/admin/tests/test_account.py index 84787ff0c..b47fe5e29 100644 --- a/modoboa/admin/tests/test_account.py +++ b/modoboa/admin/tests/test_account.py @@ -70,6 +70,29 @@ class AccountTestCase(ModoTestCase): alias__internal=True).exists() ) + def test_sender_address(self): + """Check if sender addresses are saved.""" + account = User.objects.get(username="user@test.com") + values = { + "username": "user@test.com", "first_name": "Tester", + "last_name": "Toto", "role": "SimpleUsers", + "quota_act": True, "is_active": True, "email": "user@test.com", + "senderaddress": "test@titi.com", "senderaddress_1": "toto@go.com" + } + self.ajax_post( + reverse("admin:account_change", args=[account.pk]), values) + self.assertEqual( + models.SenderAddress.objects.filter( + mailbox__address="user").count(), + 2) + del values["senderaddress_1"] + self.ajax_post( + reverse("admin:account_change", args=[account.pk]), values) + self.assertEqual( + models.SenderAddress.objects.filter( + mailbox__address="user").count(), + 1) + def test_conflicts(self): """Check if unicity constraints are respected.""" values = { diff --git a/modoboa/admin/tests/test_mapfiles.py b/modoboa/admin/tests/test_mapfiles.py index 589d22cd1..a6fa41ade 100644 --- a/modoboa/admin/tests/test_mapfiles.py +++ b/modoboa/admin/tests/test_mapfiles.py @@ -11,5 +11,6 @@ class MapFilesTestCase(MapFilesTestCaseMixin, TestCase): MAP_FILES = [ "sql-domains.cf", "sql-domain-aliases.cf", "sql-aliases.cf", - "sql-maintain.cf" + "sql-maintain.cf", "sql-sender-login-mailboxes.cf", + "sql-sender-login-mailboxes-extra.cf", "sql-sender-login-aliases.cf" ] diff --git a/modoboa/admin/views/identity.py b/modoboa/admin/views/identity.py index 9aebe1050..23035c087 100644 --- a/modoboa/admin/views/identity.py +++ b/modoboa/admin/views/identity.py @@ -174,7 +174,7 @@ def newaccount(request): @login_required @permission_required("core.change_user") @reversion.create_revision() -def editaccount(request, pk, tplname="common/tabforms.html"): +def editaccount(request, pk): account = User.objects.get(pk=pk) if not request.user.can_access(account): raise PermDeniedException diff --git a/modoboa/core/constants.py b/modoboa/core/constants.py index 4130866ed..500f10e3b 100644 --- a/modoboa/core/constants.py +++ b/modoboa/core/constants.py @@ -2,6 +2,20 @@ """Core application constants.""" +from django.utils.translation import ugettext_lazy + +SIMPLEUSERS_ROLE = ("SimpleUsers", ugettext_lazy("Simple user")) +DOMAINADMINS_ROLE = ("DomainAdmins", ugettext_lazy("Domain administrator")) +RESELLERS_ROLE = ("Resellers", ugettext_lazy("Reseller")) +SUPERADMINS_ROLE = ("SuperAdmins", ugettext_lazy("Super administrator")) + +ROLES = ( + SIMPLEUSERS_ROLE, + DOMAINADMINS_ROLE, + RESELLERS_ROLE, + SUPERADMINS_ROLE, +) + ADMIN_GROUPS = [ "SuperAdmins", "Resellers", diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 870be90c9..0fe394e44 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -357,6 +357,13 @@ class User(PermissionsMixin): self.save() self._role = role + def get_role_display(self): + """Return the display name of this role.""" + for role in constants.ROLES: + if role[0] == self.role: + return role[1] + return _("Unknown") + @cached_property def is_admin(self): """Shortcut to check if user is administrator.""" diff --git a/modoboa/lib/permissions.py b/modoboa/lib/permissions.py index da6ac8d49..407453c78 100644 --- a/modoboa/lib/permissions.py +++ b/modoboa/lib/permissions.py @@ -1,17 +1,13 @@ # coding: utf-8 +"""Object level permissions.""" from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType -from django.utils.translation import ugettext_lazy +from modoboa.core import constants as core_constants from modoboa.core.models import ObjectAccess, User from modoboa.lib import events -SIMPLEUSERS_ROLE = ("SimpleUsers", ugettext_lazy("Simple user")) -DOMAINADMINS_ROLE = ("DomainAdmins", ugettext_lazy("Domain administrator")) -RESELLERS_ROLE = ("Resellers", ugettext_lazy("Reseller")) -SUPERADMINS_ROLE = ("SuperAdmins", ugettext_lazy("Super administrator")) - def get_account_roles(user, account=None): """Return the list of available account roles. @@ -23,15 +19,16 @@ def get_account_roles(user, account=None): :param ``User`` account: account beeing modified (None on creation) :return: list of strings """ - result = [SIMPLEUSERS_ROLE] + result = [core_constants.SIMPLEUSERS_ROLE] filters = events.raiseQueryEvent( "UserCanSetRole", user, "DomainAdmins", account ) if user.has_perm("admin.add_domain") and \ (not filters or True in filters): - result += [DOMAINADMINS_ROLE] + result += [core_constants.DOMAINADMINS_ROLE] if user.is_superuser: - result += [RESELLERS_ROLE, SUPERADMINS_ROLE] + result += [ + core_constants.RESELLERS_ROLE, core_constants.SUPERADMINS_ROLE] result += events.raiseQueryEvent("GetExtraRoles", user, account) return sorted(result, key=lambda role: role[1])