From 1de07066824a052f92be8bb58b5f843e053d75d9 Mon Sep 17 00:00:00 2001 From: "J.-S. Caux" <J.S.Caux@uva.nl> Date: Sun, 25 Oct 2020 21:31:27 +0100 Subject: [PATCH] Add AddressBookEntry and automatic email validation --- apimail/admin.py | 9 ++- apimail/api/serializers/__init__.py | 7 ++ apimail/api/serializers/address_book.py | 21 +++++ apimail/api/serializers/validated_address.py | 37 +++++++++ apimail/api/views/__init__.py | 2 + apimail/api/views/address_book.py | 44 +++++++++++ apimail/migrations/0027_addressbookentry.py | 29 +++++++ apimail/models/__init__.py | 2 + apimail/models/address_book.py | 37 +++++++++ .../assets/vue/components/MessageComposer.vue | 76 +++++++++++++++++++ .../assets/vue/components/MessagesTable.vue | 10 ++- apimail/urls.py | 10 +++ 12 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 apimail/api/serializers/address_book.py create mode 100644 apimail/api/serializers/validated_address.py create mode 100644 apimail/api/views/address_book.py create mode 100644 apimail/migrations/0027_addressbookentry.py create mode 100644 apimail/models/address_book.py diff --git a/apimail/admin.py b/apimail/admin.py index e7be3ceba..9016f6a7e 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 391f37d44..3101bfb70 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 000000000..d589a58d3 --- /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 000000000..e7da63a94 --- /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 e14b3a275..8d910bf00 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 000000000..17823c164 --- /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 000000000..453b35345 --- /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 d4cc703bc..7ed329eb1 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 000000000..695badb31 --- /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 0cc5f8b7a..485dc52f2 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 00eeea8d2..489890ddb 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 e87725aec..32f087d41 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' + ), ])), -- GitLab