Merge pull request #3061 from modoboa/few-fixes

Few fixes
This commit is contained in:
Antoine Nguyen
2023-09-28 14:31:24 +02:00
committed by GitHub
24 changed files with 189 additions and 73 deletions
+1
View File
@@ -32,3 +32,4 @@ test_project/modoboa.db
# Misc
.ash_history
modoboa.kdev4
+1 -1
View File
@@ -7,7 +7,7 @@
</template>
<template v-else>
<navbar />
<navbar v-if="isAuthenticated" />
<v-main>
<v-container fluid>
<transition name="fade">
@@ -122,7 +122,9 @@
</v-chip>
</v-col>
</v-row>
<v-row v-if="value.enable_dkim">
</template>
<template v-if="value.enable_dkim">
<v-row>
<v-col cols="3"><translate>DKIM key</translate></v-col>
<v-col cols="9">
<div v-if="value.dkim_private_key_path && value.dkim_public_key">
@@ -92,7 +92,7 @@ export default {
items: [
{ key: this.$gettext('Quota'), value: this.domain.quota },
{ key: this.$gettext('Default mailbox quota'), value: this.domain.default_mailbox_quota },
{ key: this.$gettext('Message sending limit'), value: this.domain.message_sending_limit }
{ key: this.$gettext('Message sending limit'), value: this.domain.message_limit }
]
}
]
@@ -3,12 +3,12 @@
<v-switch
:label="'Enable DNS checks' | translate"
v-model="form.enable_dns_checks"
@input="update"
@change="update"
/>
<v-switch
:label="'Enable DKIM signing' | translate"
v-model="form.enable_dkim"
@input="update"
@change="update"
/>
<v-text-field
:label="'DKIM key selector' | translate"
@@ -89,8 +89,8 @@
<v-col cols="6">
<translate class="mr-2">Quota: </translate> {{ domain.quota }}
</v-col>
<v-col cols="6" v-if="domain.message_sending_limit">
<translate class="mr-2">Sending limit: </translate> {{ domain.message_sending_limit }}
<v-col cols="6" v-if="domain.message_limit">
<translate class="mr-2">Sending limit: </translate> {{ domain.message_limit }}
</v-col>
<v-col cols="6" v-else>
<translate class="mr-2">No sending limit</translate>
@@ -207,7 +207,7 @@ export default {
}
this.working = true
try {
const data = { ...this.editedDomain }
const data = JSON.parse(JSON.stringify(this.editedDomain))
if (data.transport === null) {
delete data.transport
}
@@ -231,7 +231,7 @@ export default {
domain: {
handler: function (val) {
if (val) {
this.editedDomain = JSON.parse(JSON.stringify(val))
this.editedDomain = { ...val }
}
},
immediate: true
@@ -38,7 +38,7 @@
:label="'Message sending limit' | translate"
:hint="'Number of messages this domain can send per day. Leave empty for no limit.' | translate"
persistent-hint
v-model="form.message_sending_limit"
v-model="form.message_limit"
:error-messages="errors"
outlined
@input="update"
@@ -49,9 +49,7 @@
<script>
export default {
props: {
value: Object
},
props: ['value'],
data () {
return {
form: {}
@@ -65,12 +63,18 @@ export default {
this.$emit('input', this.form)
}
},
mounted () {
this.form = { ...this.value }
},
watch: {
value: {
handler: function (newValue) {
this.form = { ...this.value }
handler (newValue) {
if (newValue.message_limit === '') {
newValue.message_limit = null
}
this.form = { ...newValue }
},
immediate: true
deep: true
}
}
}
@@ -14,15 +14,17 @@
</v-row>
<v-row>
<v-col cols="6"><translate>Quota</translate></v-col>
<v-col cols="6">{{ domain.quota }} <translate>MB</translate></v-col>
<v-col cols="6" v-if="domain.quota === '0'"><translate>Unlimited</translate></v-col>
<v-col cols="6" v-else>{{ domain.quota }} <translate>MB</translate></v-col>
</v-row>
<v-row>
<v-col cols="6"><translate>Default mailbox quota</translate></v-col>
<v-col cols="6">{{ domain.default_mailbox_quota }} MB</v-col>
<v-col cols="6" v-if="domain.default_mailbox_quota === '0'"><translate>Unlimited</translate></v-col>
<v-col cols="6" v-else>{{ domain.default_mailbox_quota }} MB</v-col>
</v-row>
<v-row v-if="domain.sending_limit !== undefined">
<v-row v-if="domain.message_limit !== undefined">
<v-col cols="6"><translate>Daily sending limit</translate></v-col>
<v-col cols="6">{{ domain.sending_limit }} <translate>messages</translate></v-col>
<v-col cols="6">{{ domain.message_limit }} <translate>messages</translate></v-col>
</v-row>
<v-row>
<v-col cols="6"><translate>Mailboxes</translate></v-col>
@@ -50,6 +50,7 @@
</template>
<script>
import { mapGetters } from 'vuex'
import { bus } from '@/main'
import accounts from '@/api/accounts'
import AccountAliasForm from './AccountAliasForm'
@@ -70,6 +71,9 @@ export default {
needsMailbox () {
return this.account.role !== 'SuperAdmins' || (this.account.username && this.account.username.indexOf('@') !== -1)
},
...mapGetters({
authUser: 'auth/authUser'
}),
summarySections () {
const result = [
{
@@ -137,15 +137,37 @@
<v-expansion-panel
v-if="limitsConfig.params && limitsConfig.params.enable_admin_limits && account.role !== 'SimpleUsers' && account.role !== 'SuperAdmins'"
>
<v-expansion-panel-header>
<v-expansion-panel-header v-slot="{ open }">
<v-row no-gutters>
<v-col cols="4">
<translate>Resources</translate>
</v-col>
<v-col
cols="8"
class="text--secondary"
>
<v-fade-transition leave-absolute>
<span v-if="open"></span>
<v-row
v-else
no-gutters
style="width: 100%"
>
<template v-if="account.resources != undefined && account.resources.length == 2">
<v-col cols="6">
<translate class="mr-2">Mailbox: </translate> <translate>Number of allowed mailboxes: </translate> {{ account.resources[0].max_value}}
</v-col>
<v-col cols="6">
<translate class="mr-2">Mailbox aliases: </translate> <translate>Number of allowed mailbox aliases: </translate> {{ account.resources[1].max_value}}
</v-col>
</template>
</v-row>
</v-fade-transition>
</v-col>
</v-row>
</v-expansion-panel-header>
<v-expansion-panel-content>
<resources-form ref="resourcesForm" :resources="editedAccount.resources" />
<resources-form ref="resourcesForm" v-model="editedAccount.resources" />
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
@@ -161,6 +183,7 @@
</template>
<script>
import { mapGetters } from 'vuex'
import _isEmpty from 'lodash/isEmpty'
import { bus } from '@/main'
import accounts from '@/api/accounts'
@@ -183,7 +206,10 @@ export default {
computed: {
usernameIsEmail () {
return this.editedAccount.username && this.editedAccount.username.indexOf('@') !== -1
}
},
...mapGetters({
authUser: 'auth/authUser'
})
},
data () {
return {
@@ -29,12 +29,13 @@
<script>
import ChoiceField from '@/components/tools/ChoiceField'
import { mapGetters } from 'vuex'
export default {
components: {
ChoiceField
},
props: ['value', 'account'],
props: ['value', 'account', 'role'],
computed: {
roleLabel () {
const role = this.accountRoles.find(role => role.value === this.account.role)
@@ -43,29 +44,49 @@ export default {
roleHelp () {
const role = this.accountRoles.find(role => role.value === this.account.role)
return role !== undefined ? role.help : ''
}
},
accountRoles () {
if (this.authUser.role === 'SuperAdmins') {
return [...this.domainAdminsRole, ...this.resellerRole, ...this.simpleUserRole, ...this.superAdminsRole]
} else if (this.authUser.role === 'DomainAdmins') {
return this.simpleUserRole
} else if (this.authUser.role === 'Resellers') {
return [...this.domainAdminsRole, ...this.simpleUserRole]
} else {
return []
}
},
...mapGetters({
authUser: 'auth/authUser'
})
},
methods: {
reset () { }
},
data () {
return {
accountRoles: [
{
label: this.$gettext('Domain administrator'),
value: 'DomainAdmins',
help: this.$gettext('A user with privileges on one or more domain. He will be allowed to administer mailboxes and he can also have a mailbox.')
},
{
label: this.$gettext('Reseller'),
value: 'Resellers',
help: this.$gettext('help')
},
simpleUserRole: [
{
label: this.$gettext('Simple user'),
value: 'SimpleUsers',
help: this.$gettext('A user with no privileges but with a mailbox. He will be allowed to use all end-user features: webmail, calendar, contacts, filters.')
},
}
],
domainAdminsRole: [
{
label: this.$gettext('Domain administrator'),
value: 'DomainAdmins',
help: this.$gettext('A user with privileges on one or more domain. He will be allowed to administer mailboxes and he can also have a mailbox.')
}
],
resellerRole: [
{
label: this.$gettext('Reseller'),
value: 'Resellers',
help: this.$gettext('help')
}
],
superAdminsRole: [
{
label: this.$gettext('Super administrator'),
value: 'SuperAdmins',
@@ -32,7 +32,10 @@
<template v-if="account.mailbox">
<v-row>
<v-col cols="6"><translate>Quota</translate></v-col>
<v-col cols="6">
<v-col cols="6" v-if="account.mailbox.quota === '0'">
<translate>Unlimited</translate>
</v-col>
<v-col cols="6" v-else>
<v-progress-linear
:color="account.mailbox.quota_usage < 80 ? 'primary' : 'warning'"
:value="account.mailbox.quota_usage"
+10 -12
View File
@@ -284,20 +284,18 @@ export default {
}
},
created () {
if (this.isAuthenticated) {
parameters.getApplications().then(response => {
response.data.forEach(item => {
this.mainMenuItems[6].children.push({
text: item.label,
to: { name: 'ParametersEdit', params: { app: item.name } }
})
parameters.getApplications().then(response => {
response.data.forEach(item => {
this.mainMenuItems[6].children.push({
text: item.label,
to: { name: 'ParametersEdit', params: { app: item.name } }
})
})
parameters.getApplication('imap_migration').then(response => {
this.imapMigration = response.data.params.enabled_imapmigration
})
bus.$on('imapSettingsChanged', (status) => { this.imapMigration = status })
}
})
parameters.getApplication('imap_migration').then(response => {
this.imapMigration = response.data.params.enabled_imapmigration
})
bus.$on('imapSettingsChanged', (status) => { this.imapMigration = status })
},
methods: {
displayMenuItem (item) {
+9
View File
@@ -109,6 +109,15 @@ class DomainSerializer(serializers.ModelSerializer):
domain.save(creator=creator)
return domain
def update(self, instance, validated_data):
"""Update domain and create/update transport."""
super().update(instance, validated_data)
_enable_dkim = validated_data.get("enable_dkim")
if _enable_dkim is False and instance.dkim_private_key_path != "":
instance.dkim_private_key_path = ""
instance.save()
return instance
class DomainAliasSerializer(serializers.ModelSerializer):
"""Base DomainAlias serializer."""
+10
View File
@@ -133,6 +133,16 @@ class DomainAPITestCase(ModoAPITestCase):
domain.refresh_from_db()
self.assertEqual(domain.name, "toto.test")
def test_remove_dkim_domain(self):
"""Try to remove DKIM from a domain."""
domain = models.Domain.objects.get(name="test.com")
url = reverse("v1:domain-detail", args=[domain.pk])
response = self.client.put(
url, {"name": "test.com", "enable_dkim": False})
self.assertEqual(response.status_code, 200)
domain.refresh_from_db()
self.assertEqual(domain.dkim_private_key_path, "")
def test_delete_domain(self):
"""Try to delete a domain."""
domain = models.Domain.objects.get(name="test.com")
+3 -1
View File
@@ -36,7 +36,9 @@ from . import serializers
summary="Create a new domain"
)
)
class DomainViewSet(GetThrottleViewsetMixin, lib_viewsets.RevisionModelMixin, viewsets.ModelViewSet):
class DomainViewSet(GetThrottleViewsetMixin,
lib_viewsets.RevisionModelMixin,
viewsets.ModelViewSet):
"""Domain viewset."""
permission_classes = [IsAuthenticated, DjangoModelPermissions, ]
+3
View File
@@ -220,6 +220,9 @@ class DomainFormGeneral(forms.ModelForm, DynamicForm):
options = {"creator": user} if domalias_post_create else {}
DomainAlias(name=alias, target=d, enabled=d.enabled).save(
**options)
if self.cleaned_data.get("enable_dkim") is False and d.dkim_private_key_path != "":
d.dkim_private_key_path = ""
d.save()
return d
@@ -74,13 +74,9 @@ class ManageDKIMKeys(BaseCommand):
dkim_private_key_path="")
if domain.exists():
self.create_new_dkim_key(domain[0])
signals.new_dkim_keys.send(sender=self.__class__,
domains=domain)
return
qset = models.Domain.objects.filter(
enable_dkim=True, dkim_private_key_path="")
for domain in qset:
self.create_new_dkim_key(domain)
if qset.exists():
signals.new_dkim_keys.send(sender=self.__class__, domains=qset)
-1
View File
@@ -19,5 +19,4 @@ get_domain_form_instances = django.dispatch.Signal() # Provides user, domain
# get_domain_tags = django.dispatch.Signal(providing_args=["domain"])
import_object = django.dispatch.Signal() # Provides objtype
use_external_recipients = django.dispatch.Signal() # Provides recipients
new_dkim_keys = django.dispatch.Signal() # Provides domain
extra_account_identities_actions = django.dispatch.Signal() # Provides account
+13
View File
@@ -485,6 +485,19 @@ class DKIMTestCase(ModoTestCase):
domain.refresh_from_db()
self.assertNotEqual(domain.dkim_private_key_path, "")
# Try disabling DKIM and checking that dkim_private_key_path is emptied
values = {
"name": "pouet.com", "enable_dkim": False,
"quota": 1000, "default_mailbox_quota": 100,
"type": "domain"
}
self.ajax_post(
reverse("admin:domain_change", args=[domain.id]),
values
)
domain.refresh_from_db()
self.assertEqual(domain.dkim_private_key_path, "")
def test_dkim_key_length_modification(self):
""" """
self.set_global_parameter("dkim_keys_storage_dir", self.workdir)
+9 -1
View File
@@ -1,5 +1,6 @@
"""PDF credentials handlers."""
import logging
import os
from django.urls import reverse
@@ -10,6 +11,7 @@ from django.utils.translation import gettext as _
from modoboa.admin import signals as admin_signals
from modoboa.core import models as core_models
from modoboa.core import signals as core_signals
from modoboa.lib.exceptions import InternalError
from modoboa.parameters import tools as param_tools
from .documents import credentials
@@ -25,7 +27,13 @@ def password_updated(sender, account, password, created, **kwargs):
"generate_at_creation")
if (generate_at_creation and not created) or account.is_superuser:
return
init_storage_dir()
try:
init_storage_dir()
except InternalError as e:
logger = logging.getLogger("modoboa.admin")
logger.error(
_("Failed to create PDF_credentials directory. "
"Please check the permissions or the path."))
credentials(account, password)
+19 -12
View File
@@ -1,6 +1,7 @@
"""Internal library."""
from io import BytesIO
import logging
import os
import struct
@@ -18,7 +19,7 @@ from modoboa.parameters import tools as param_tools
def init_storage_dir():
"""Create the directory whare documents will be stored."""
"""Create the directory where documents will be stored."""
storage_dir = param_tools.get_global_parameter("storage_dir")
if os.path.exists(storage_dir) and os.access(storage_dir, os.W_OK):
return
@@ -66,17 +67,23 @@ def crypt_and_save_to_file(content, filename, length, chunksize=64*512):
iv = os.urandom(16)
cipher = _get_cipher(iv)
encryptor = cipher.encryptor()
with open(filename, "wb") as fp:
fp.write(struct.pack(b"<Q", length))
fp.write(iv)
while True:
chunk = content.read(chunksize)
if not len(chunk):
break
elif len(chunk) % 16:
chunk += b" " * (16 - len(chunk) % 16)
fp.write(encryptor.update(force_bytes(chunk)))
fp.write(encryptor.finalize())
try:
with open(filename, "wb") as fp:
fp.write(struct.pack(b"<Q", length))
fp.write(iv)
while True:
chunk = content.read(chunksize)
if not len(chunk):
break
elif len(chunk) % 16:
chunk += b" " * (16 - len(chunk) % 16)
fp.write(encryptor.update(force_bytes(chunk)))
fp.write(encryptor.finalize())
except FileNotFoundError as e:
logger = logging.getLogger("modoboa.admin")
logger.error(
_("Failed to create PDF_credentials file. "
"Please check the permissions or the path."))
def decrypt_file(filename, chunksize=24*1024):
+8 -1
View File
@@ -144,4 +144,11 @@ class EventsTestCase(ModoTestCase):
def test_storage_dir_creation(self):
"""Test storage directory creation."""
self.set_global_parameter("storage_dir", "/nonexistentdir")
self._create_account("leon@test.com", expected_status=500)
with self.assertLogs(logger='modoboa.admin', level='ERROR') as log:
resp = self._create_account("leon@test.com")
self.assertIn(
"ERROR:modoboa.admin:Failed to create PDF_credentials directory. "
"Please check the permissions or the path.",
log.output
)
+4 -3
View File
@@ -14,18 +14,19 @@ authors = [
]
description = "Mail hosting made simple"
readme = "README.rst"
requires-python = ">=3.7"
requires-python = ">=3.8"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Web Environment",
"Framework :: Django :: 2.2",
"Framework :: Django :: 4.2",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: ISC License (ISCL)",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Topic :: Communications :: Email",
"Topic :: Internet :: WWW/HTTP",
]