diff --git a/apimail/admin.py b/apimail/admin.py index 6ea48c9d3e78691675e90ac96297cc8bfc46672b..e7be3ceba704a0ff769a7c1a25c683dcd09b7687 100644 --- a/apimail/admin.py +++ b/apimail/admin.py @@ -11,7 +11,9 @@ from .models import ( ComposedMessage, ComposedMessageAPIResponse, Event, StoredMessage, - UserTag) + UserTag, + ValidatedAddress, AddressValidation +) admin.site.register(Domain) @@ -66,3 +68,15 @@ class UserTagAdmin(admin.ModelAdmin): pass admin.site.register(UserTag, UserTagAdmin) + + +class AddressValidationInline(admin.StackedInline): + model = AddressValidation + extra = 0 + min_num = 0 + + +class ValidatedAddressAdmin(admin.ModelAdmin): + inlines = [AddressValidationInline,] + +admin.site.register(ValidatedAddress, ValidatedAddressAdmin) diff --git a/apimail/migrations/0026_addressvalidation_validatedaddress.py b/apimail/migrations/0026_addressvalidation_validatedaddress.py new file mode 100644 index 0000000000000000000000000000000000000000..355e9a2e0ae4d79c2a271e42f05f77641ceeb460 --- /dev/null +++ b/apimail/migrations/0026_addressvalidation_validatedaddress.py @@ -0,0 +1,38 @@ +# Generated by Django 2.2.16 on 2020-10-24 15:51 + +import datetime +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('apimail', '0025_composedmessage_headers_added'), + ] + + operations = [ + migrations.CreateModel( + name='ValidatedAddress', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address', models.EmailField(max_length=512, unique=True)), + ], + options={ + 'ordering': ['address'], + }, + ), + migrations.CreateModel( + name='AddressValidation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', django.contrib.postgres.fields.jsonb.JSONField(default=dict)), + ('datestamp', models.DateField(default=datetime.date.today)), + ('address', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='validations', to='apimail.ValidatedAddress')), + ], + options={ + 'ordering': ['address__address', '-datestamp'], + }, + ), + ] diff --git a/apimail/models/__init__.py b/apimail/models/__init__.py index 4f8395d53ee2ec0bc63bbef7012e0b5bfe279768..d4cc703bc356bf44abae880faadbfa4151c72479 100644 --- a/apimail/models/__init__.py +++ b/apimail/models/__init__.py @@ -15,3 +15,5 @@ from .event import Event from .stored_message import StoredMessage from .tag import UserTag + +from .validated_address import ValidatedAddress, AddressValidation diff --git a/apimail/models/stored_message.py b/apimail/models/stored_message.py index 08b87f2601a92e52c8de7523354c86739a93f3ec..24690aee94c7ed7b6cc66fbbc1fb51f0698838d3 100644 --- a/apimail/models/stored_message.py +++ b/apimail/models/stored_message.py @@ -1,8 +1,7 @@ __copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" - -#import pytz +import pytz import uuid as uuid_lib from django.conf import settings diff --git a/apimail/models/validated_address.py b/apimail/models/validated_address.py new file mode 100644 index 0000000000000000000000000000000000000000..2d0341285615b7207a4c6a004446037795b5b3b6 --- /dev/null +++ b/apimail/models/validated_address.py @@ -0,0 +1,83 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import datetime +import requests + +from django.conf import settings +from django.contrib.postgres.fields import JSONField +from django.db import models + + +class ValidatedAddress(models.Model): + """ + Email address (lowercased) with related validation info. + + The Mailgun email validation v4 API is queried at least once per year + and the response is saved as a related AddressValidation object. + """ + address = models.EmailField( + max_length=512, # as per Mailgun limit + unique=True + ) + + class Meta: + ordering = ['address',] + + def save(self, *args, **kwargs): + self.address = self.address.lower() + return super().save(*args, **kwargs) + + def __str__(self): + return self.address + + @property + def is_good_for_sending(self): + """ + Return the status of the latest Mailgun validation. + """ + self.update_mailgun_validation() + try: + return self.validations.first().data['result'] \ + in ('deliverable', 'catch_all', 'unknown') + except AttributeError: + return False + + def update_mailgun_validation(self): + """ + If no validation check within last year, call the Mailgun validation v4 API. + """ + one_year_ago = datetime.date.today() - datetime.timedelta(days=365) + if not self.validations.filter(datestamp__gt=one_year_ago).exists(): + response = requests.get( + "https://api.mailgun.net/v4/address/validate", + auth=("api", settings.MAILGUN_API_KEY), + params={"address": self.address} + ).json() + validation = AddressValidation( + address=self, + data=response + ) + validation.save() + + +class AddressValidation(models.Model): + """ + For a given ValidatedAddress, timestamped response from a Mailgun API validation v4 query. + """ + address = models.ForeignKey( + 'apimail.ValidatedAddress', + related_name='validations', + on_delete=models.CASCADE) + data = JSONField(default=dict) + datestamp = models.DateField(default=datetime.date.today) + + class Meta: + ordering = [ + 'address__address', + '-datestamp' + ] + + def __str__(self): + return "%s: %s (%s)" % (self.address, self.data['result'], self.datestamp)