diff --git a/apimail/admin.py b/apimail/admin.py index e7be3ceba704a0ff769a7c1a25c683dcd09b7687..9016f6a7e3a2193262423b08ad0a06c4db90e291 100644 --- a/apimail/admin.py +++ b/apimail/admin.py @@ -5,6 +5,7 @@ __license__ = "AGPL v3" from django.contrib import admin from .models import ( + AddressBookEntry, Domain, EmailAccount, EmailAccountAccess, AttachmentFile, @@ -76,7 +77,13 @@ class AddressValidationInline(admin.StackedInline): min_num = 0 +class AddressBookEntryInline(admin.StackedInline): + model = AddressBookEntry + extra = 0 + min_num = 0 + + class ValidatedAddressAdmin(admin.ModelAdmin): - inlines = [AddressValidationInline,] + inlines = [AddressValidationInline, AddressBookEntryInline,] admin.site.register(ValidatedAddress, ValidatedAddressAdmin) diff --git a/apimail/api/serializers/__init__.py b/apimail/api/serializers/__init__.py index 391f37d44581d74b3b352422f1299bffdc05869e..3101bfb7088376de6be78f8b6bfad92de0eaf4b9 100644 --- a/apimail/api/serializers/__init__.py +++ b/apimail/api/serializers/__init__.py @@ -16,3 +16,10 @@ from .composed_message import ( ) from .stored_message import StoredMessageSerializer + +from .validated_address import ( + ValidatedAddressSerializer, AddressValidationSerializer, + ValidatedAddressSimpleSerializer +) + +from .address_book import AddressBookEntrySerializer diff --git a/apimail/api/serializers/address_book.py b/apimail/api/serializers/address_book.py new file mode 100644 index 0000000000000000000000000000000000000000..d589a58d3ed1ab3d385562242ac47ca50b2f10fb --- /dev/null +++ b/apimail/api/serializers/address_book.py @@ -0,0 +1,21 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from rest_framework import serializers + +from ...models import AddressBookEntry +from ..serializers import ValidatedAddressSerializer + + +class AddressBookEntrySerializer(serializers.ModelSerializer): + user = serializers.CharField() + address = ValidatedAddressSerializer() + + class Meta: + model = AddressBookEntry + fields = ['pk', 'user', 'address', 'description'] + + def get_queryset(self): + user = self.context['request'].user + return AddressBookEntry.objects.filter(user=user) diff --git a/apimail/api/serializers/validated_address.py b/apimail/api/serializers/validated_address.py new file mode 100644 index 0000000000000000000000000000000000000000..e7da63a9472a4e01ad8c20b82f58217e23cc089b --- /dev/null +++ b/apimail/api/serializers/validated_address.py @@ -0,0 +1,37 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from rest_framework import serializers + +from ...models import ValidatedAddress, AddressValidation + + +class AddressValidationSerializer(serializers.ModelSerializer): + class Meta: + model = AddressValidation + fields = ['pk', 'data', 'datestamp'] + + +class ValidatedAddressSerializer(serializers.ModelSerializer): + validations = AddressValidationSerializer(many=True, read_only=True) + + class Meta: + model = ValidatedAddress + fields = ['pk', 'address', 'validations'] + + +class ValidatedAddressSimpleSerializer(serializers.ModelSerializer): + address = serializers.CharField() + can_send = serializers.SerializerMethodField() + result = serializers.SerializerMethodField() + + class Meta: + model = ValidatedAddress + fields = ['address', 'can_send', 'result'] + + def get_can_send(self, obj): + return obj.is_good_for_sending + + def get_result(self, obj): + return obj.validations.first().data['result'] diff --git a/apimail/api/views/__init__.py b/apimail/api/views/__init__.py index e14b3a275032bf0d06b744ae05edd80b9504a6ba..8d910bf00b4be542030daaa60a158e232d0ef9df 100644 --- a/apimail/api/views/__init__.py +++ b/apimail/api/views/__init__.py @@ -4,6 +4,8 @@ __license__ = "AGPL v3" from .account import EmailAccountListAPIView, UserEmailAccountAccessListAPIView +from .address_book import check_address_book, AddressBookAPIView + from .attachment import AttachmentFileCreateAPIView from .event import EventListAPIView, EventRetrieveAPIView diff --git a/apimail/api/views/address_book.py b/apimail/api/views/address_book.py new file mode 100644 index 0000000000000000000000000000000000000000..17823c164b75b35bf3224a1c1d9bf49111e2e00c --- /dev/null +++ b/apimail/api/views/address_book.py @@ -0,0 +1,44 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from rest_framework.decorators import api_view, permission_classes +from rest_framework.generics import ListAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status +from ...models import ValidatedAddress, AddressBookEntry +from ..serializers import AddressBookEntrySerializer, ValidatedAddressSimpleSerializer + + +@api_view(['POST']) +@permission_classes([IsAuthenticated,]) +def check_address_book(request): + """ + For a given email address in POST data, retrieve or create an AddressBookEntry. + + The POST data must contain an 'email' entry. + """ + + if 'email' not in request.data.keys(): + return Response(status=status.HTTP_400_BAD_REQUEST) + + validated_address, address_created = ValidatedAddress.objects.get_or_create( + address=request.data['email'].lower() + ) + validated_address.update_mailgun_validation() + + entry, entry_created = AddressBookEntry.objects.get_or_create( + user=request.user, + address=validated_address, + ) + serializer = ValidatedAddressSimpleSerializer(validated_address) + return Response(serializer.data) + + +class AddressBookAPIView(ListAPIView): + permission_classes = (IsAuthenticated,) + serializer_class = AddressBookEntrySerializer + + def get_queryset(self): + return self.request.user.address_book_entries.all() diff --git a/apimail/migrations/0027_addressbookentry.py b/apimail/migrations/0027_addressbookentry.py new file mode 100644 index 0000000000000000000000000000000000000000..453b3534512036d80b3c4225f199f58ee3820836 --- /dev/null +++ b/apimail/migrations/0027_addressbookentry.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.16 on 2020-10-25 13:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('apimail', '0026_addressvalidation_validatedaddress'), + ] + + operations = [ + migrations.CreateModel( + name='AddressBookEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.CharField(blank=True, help_text='Description: [last name], [first name] or [org name] or other', max_length=512)), + ('address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='address_book_entries', to='apimail.ValidatedAddress')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='address_book_entries', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Address book entries', + 'ordering': ['user', 'address'], + }, + ), + ] diff --git a/apimail/models/__init__.py b/apimail/models/__init__.py index d4cc703bc356bf44abae880faadbfa4151c72479..7ed329eb1c4f449f9afd4c3adedd6781052890ef 100644 --- a/apimail/models/__init__.py +++ b/apimail/models/__init__.py @@ -4,6 +4,8 @@ __license__ = "AGPL v3" from .account import EmailAccount, EmailAccountAccess +from .address_book import AddressBookEntry + from .attachment import AttachmentFile from .composed_message import ComposedMessage, ComposedMessageAPIResponse diff --git a/apimail/models/address_book.py b/apimail/models/address_book.py new file mode 100644 index 0000000000000000000000000000000000000000..695badb316f876ebcde6ef3d8ef3784cb90fc906 --- /dev/null +++ b/apimail/models/address_book.py @@ -0,0 +1,37 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.conf import settings +from django.db import models + + +class AddressBookEntry(models.Model): + """ + Through field relating User and ValidatedAddress. + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='address_book_entries' + ) + + address = models.ForeignKey( + 'apimail.ValidatedAddress', + on_delete=models.CASCADE, + related_name='address_book_entries' + ) + + description = models.CharField( + max_length=512, + blank=True, + help_text='Description: [last name], [first name] or [org name] or other' + ) + + class Meta: + ordering = [ + 'user', + 'address' + ] + verbose_name_plural = 'Address book entries' diff --git a/apimail/static/apimail/assets/vue/components/MessageComposer.vue b/apimail/static/apimail/assets/vue/components/MessageComposer.vue index 0cc5f8b7a2d55ad99c2031870d6bcd77917253b4..485dc52f24a1b4359f29acf92d55360a09ff992f 100644 --- a/apimail/static/apimail/assets/vue/components/MessageComposer.vue +++ b/apimail/static/apimail/assets/vue/components/MessageComposer.vue @@ -10,10 +10,31 @@ > Save draft </b-button> + <span v-if="!allEmailsValid"> + <b-button + type="validateemails" + class="text-white px-2 py-1 my-2" + variant="warning" + @click.stop.prevent="validateAllEmails()" + :disabled="emailValidationHasRun || (!form.to_recipient && form.cc_recipients.length == 0 && form.bcc_recipients == 0)" + > + Validate emails + </b-button> + </span> + <span v-else> + <b-button + type="validateemails" + class="text-white px-2 py-1 my-2" + variant="success" + > + All emails are validated + </b-button> + </span> <b-button type="send" class="text-white px-2 py-1 my-2" variant="primary" + :disabled="!emailValidationHasRun || !allEmailsValid" @click.stop.prevent="saveMessage('ready')" > Send @@ -42,6 +63,15 @@ </div> </template> <span v-if="draftLastSaved" size="sm"> [last saved: {{ draftLastSaved }}]</span> + <template v-if="emailValidationHasRun && !allEmailsValid"> + <p class="m-2 p-2"> + <strong class="text-danger">Some email addresses cannot be sent to:</strong> + <ul class="mb-0"> + <li v-for="item in emailValidations">{{ item.address }} <span v-if="item.can_send" class="text-success">Can send</span><span v-else><strong class="text-danger">Cannot send: {{ item.result }}</strong></span></li> + </ul> + <strong class="text-danger">Please remove the failing addresses from your message draft.</strong> + </p> + </template> <b-form> <b-row> <b-col class="col-lg-6"> @@ -332,6 +362,9 @@ export default { headers_added: {}, attachments: [], }, + emailValidations: [], + emailValidationHasRun: false, + allEmailsValid: false, from_account_accesses: [], response: null, response_body_json: null, @@ -434,6 +467,29 @@ export default { this.currentdraft_uuid = responsejson.uuid }) .catch(error => console.error(error)) + }, + verifyEmailValidity (email) { + fetch('/mail/api/check_address_book', + { + method: 'POST', + headers: { + "X-CSRFToken": csrftoken, + "Content-Type": "application/json; charset=utf-8" + }, + body: JSON.stringify({ + 'email': email + }) + }) + .then(response => response.json()) + .then(responsejson => this.emailValidations.push(responsejson)) + .catch(error => console.error(error)) + }, + validateAllEmails () { + this.emailValidations = [] + if (this.form.to_recipient) this.verifyEmailValidity(this.form.to_recipient) + this.form.cc_recipients.forEach(email => this.verifyEmailValidity(email)) + this.form.bcc_recipients.forEach(email => this.verifyEmailValidity(email)) + this.emailValidationHasRun = true } }, mounted () { @@ -495,6 +551,26 @@ export default { } this.editor.setContent(this.form.body_html) }, + watch: { + "form.to_recipient": function () { + this.emailValidationHasRun = false + this.allEmailsValid = false + }, + "form.cc_recipients": function () { + this.emailValidationHasRun = false + this.allEmailsValid = false + }, + "form.bcc_recipients": function () { + this.emailValidationHasRun = false + this.allEmailsValid = false + }, + emailValidations: function () { + this.allEmailsValid = true + this.emailValidations.forEach(item => { + if (!item.can_send) this.allEmailsValid = false + }) + } + }, beforeDestroy() { this.editor.destroy() }, diff --git a/apimail/static/apimail/assets/vue/components/MessagesTable.vue b/apimail/static/apimail/assets/vue/components/MessagesTable.vue index 00eeea8d29240ad1d72a6d1d3a3a64f9889797c2..489890ddb972f3e16c61f2ba681963bcf78dbac0 100644 --- a/apimail/static/apimail/assets/vue/components/MessagesTable.vue +++ b/apimail/static/apimail/assets/vue/components/MessagesTable.vue @@ -572,6 +572,8 @@ export default { return false }, messagesProvider (ctx) { + if (!this.accountSelected) return [] + var params = '?account=' + this.accountSelected.email // Our API uses limit/offset pagination params += '&limit=' + ctx.perPage + '&offset=' + ctx.perPage * (ctx.currentPage - 1) @@ -612,11 +614,11 @@ export default { this.totalRows = data.count if (this.threadOf) { this.tabbedMessages = items - } - return items || [] + } + return items || [] }) - }, - refreshMessages () { + }, + refreshMessages () { this.messagesProvider({ 'perPage': this.perPage, 'currentPage': this.currentPage diff --git a/apimail/urls.py b/apimail/urls.py index e87725aece0a0c040866c157f4c9563424b7aba9..32f087d415a39c54ab8716fa3d6fce495f2b66b6 100644 --- a/apimail/urls.py +++ b/apimail/urls.py @@ -97,6 +97,16 @@ urlpatterns = [ apiviews.UserTagListAPIView.as_view(), name='api_user_tags' ), + path( # /mail/api/address_book + 'address_book', + apiviews.AddressBookAPIView.as_view(), + name='api_address_book' + ), + path( # /mail/api/check_address_book + 'check_address_book', + apiviews.check_address_book, + name='api_check_address_book' + ), ])),