Skip to content

Commit

Permalink
Add unsubscribe list to stop spam emails
Browse files Browse the repository at this point in the history
  • Loading branch information
pyepye committed Jan 24, 2022
1 parent c867d81 commit 62b37ee
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 5 deletions.
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ This package was created with a focus on [ease of setup](#steps-to-impliment), [
pip install django-magiclink
```


## Setup

The setup of the app is simple but has a few steps and a few templates that need overriding.
Expand Down Expand Up @@ -90,6 +89,12 @@ MAGICLINK_SIGNUP_TEMPLATE_NAME = 'magiclink/signup.html'
See [additional configuration settings](#configuration-settings) for all of the different available settings.


Once the app has been added to `INSTALLED_APPS` you must run the migrations for `magiclink`

```bash
python manage.py migrate magiclink
```

#### Login page

Each login page will need different HTML so you need to set the `MAGICLINK_LOGIN_TEMPLATE_NAME` setting to a template of your own. When overriding this template please ensure the following code is included:
Expand Down Expand Up @@ -312,6 +317,10 @@ MAGICLINK_ANTISPAM_FIELD_TIME = 1
# Override the login verify address. You must inherit from Magiclink LoginVerify
# view. See Manual usage for more details
MAGICLINK_LOGIN_VERIFY_URL = 'magiclink:login_verify'

# If an email address has been added to the unsubscribe table but is also
# assocaited with a Django user, should a login email be sent
MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = False
```

## Magic Link cleanup
Expand Down Expand Up @@ -340,6 +349,21 @@ Using magic links can be dangerous as poorly implemented login links can be brut
*Note: Each of the above settings can be overridden / changed when configuring django-magiclink*


## Unsubscribe / stopping email spam

Sadly bots like to go around the internet and fill out any forms they can with random email addresses that don't belong to them. Because of this, if an email is added to the `MagicLinkUnsubscribe` model they will no longer receive a login or welcome email, even if the email has a user associated with it. This behaviour can be changed for existing users using the `MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER` setting.

Adding a user to the unsubscribe model is done using the normal Django ORM create method

```python
from magiclink.models import MagicLinkUnsubscribe

MagicLinkUnsubscribe.objects.create(email='test@example.com')
```

If you are using the Django Magiclink login or signup functionality, the unsubscribe check happens during form validation. This means a new user will never be created if their email address has already been added to the `MagicLinkUnsubscribe` list.


## Manual usage

### Creating magiclinks
Expand Down Expand Up @@ -408,3 +432,12 @@ urlpatterns = [
MAGICLINK_LOGIN_VERIFY_URL = 'custom_login_verify'
...
```


## Upgrading

A new migration was added to version `1.2.0`. If you upgrade to `1.2.0` or above from a previous version please ensure you migrate

```bash
python manage.py migrate magiclink
```
16 changes: 16 additions & 0 deletions magiclink/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from django.core.exceptions import ValidationError

from . import settings
from .models import MagicLinkUnsubscribe

User = get_user_model()

Expand Down Expand Up @@ -88,6 +89,14 @@ def clean_email(self) -> str:
if not settings.IGNORE_IS_ACTIVE_FLAG and not is_active:
raise forms.ValidationError('This user has been deactivated')

if not settings.IGNORE_UNSUBSCRIBE_IF_USER:
try:
MagicLinkUnsubscribe.objects.get(email=email)
error = 'Email address is on the unsubscribe list'
raise forms.ValidationError(error)
except MagicLinkUnsubscribe.DoesNotExist:
pass

return email


Expand All @@ -105,6 +114,13 @@ def clean_email(self) -> str:
if settings.EMAIL_IGNORE_CASE:
email = email.lower()

try:
MagicLinkUnsubscribe.objects.get(email=email)
error = 'Email address is on the unsubscribe list'
raise forms.ValidationError(error)
except MagicLinkUnsubscribe.DoesNotExist:
pass

try:
user = User.objects.get(email=email)
except User.DoesNotExist:
Expand Down
20 changes: 20 additions & 0 deletions magiclink/migrations/0002_magiclinkunsubscribe.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 3.2.7 on 2022-01-23 19:36

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('magiclink', '0001_initial'),
]

operations = [
migrations.CreateModel(
name='MagicLinkUnsubscribe',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
],
),
]
13 changes: 13 additions & 0 deletions magiclink/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ def generate_url(self, request: HttpRequest) -> str:

def send(self, request: HttpRequest) -> None:
user = User.objects.get(email=self.email)

if not settings.IGNORE_UNSUBSCRIBE_IF_USER:
try:
MagicLinkUnsubscribe.objects.get(email=self.email)
raise MagicLinkError(
'Email address is on the unsubscribe list')
except MagicLinkUnsubscribe.DoesNotExist:
pass

context = {
'subject': settings.EMAIL_SUBJECT,
'user': user,
Expand Down Expand Up @@ -132,3 +141,7 @@ def validate(
'You can not login to a staff account using a magic link')

return user


class MagicLinkUnsubscribe(models.Model):
email = models.EmailField()
4 changes: 4 additions & 0 deletions magiclink/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,7 @@
raise ImproperlyConfigured('"MAGICLINK_ANTISPAM_FIELD_TIME" must be a float')

LOGIN_VERIFY_URL = getattr(settings, 'MAGICLINK_LOGIN_VERIFY_URL', 'magiclink:login_verify')

IGNORE_UNSUBSCRIBE_IF_USER = getattr(settings, 'MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER', False)
if not isinstance(IGNORE_UNSUBSCRIBE_IF_USER, bool):
raise ImproperlyConfigured('"MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER" must be a boolean')
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "django-magiclink"
packages = [
{include = "magiclink"}
]
version = "1.1.0"
version = "1.2.0"
description = "Passwordless authentication for Django with Magic Links"
authors = ["Matt Pye <pyematt@gmail.com>"]
readme = "README.md"
Expand Down
33 changes: 32 additions & 1 deletion tests/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.http import HttpRequest
from django.urls import reverse

from magiclink.models import MagicLink
from magiclink.models import MagicLink, MagicLinkUnsubscribe

from .fixtures import magic_link, user # NOQA: F401

Expand Down Expand Up @@ -308,3 +308,34 @@ def test_login_post_redirect_url_unsafe(mocker, client, user, settings): # NOQA
assert usr
magiclink = MagicLink.objects.get(email=user.email)
assert magiclink.redirect_url == reverse('needs_login')


@pytest.mark.django_db
def test_login_error_email_in_unsubscribe(client, user): # NOQA: F811,E501
MagicLinkUnsubscribe.objects.create(email=user.email)

url = reverse('magiclink:login')
data = {'email': user.email}

response = client.post(url, data)
assert response.status_code == 200
form_errors = response.context['login_form'].errors
assert form_errors['email'] == ['Email address is on the unsubscribe list']


@pytest.mark.django_db
def test_login_pass_email_in_unsubscribe(settings, client, user): # NOQA: F811,E501
settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = True
from magiclink import settings as mlsettings
reload(mlsettings)

MagicLinkUnsubscribe.objects.create(email=user.email)

url = reverse('magiclink:login')
data = {'email': user.email}

response = client.post(url, data)
assert response.status_code == 302
assert response.url == reverse('magiclink:login_sent')
magiclink = MagicLink.objects.get(email=user.email)
assert magiclink
39 changes: 38 additions & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.utils import timezone

from magiclink import settings
from magiclink.models import MagicLink, MagicLinkError
from magiclink.models import MagicLink, MagicLinkError, MagicLinkUnsubscribe

from .fixtures import magic_link, user # NOQA: F401

Expand Down Expand Up @@ -98,6 +98,43 @@ def test_send_email(mocker, settings, magic_link): # NOQA: F811
)


@pytest.mark.django_db
def test_send_email_error_email_in_unsubscribe(magic_link): # NOQA: F811
request = HttpRequest()
ml = magic_link(request)
MagicLinkUnsubscribe.objects.create(email=ml.email)

with pytest.raises(MagicLinkError) as error:
ml.send(request)

error.match('Email address is on the unsubscribe list')


@pytest.mark.django_db
def test_send_email_pass_email_in_unsubscribe(mocker, settings, magic_link): # NOQA: E501, F811
settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = True
from magiclink import settings as mlsettings
reload(mlsettings)

send_mail = mocker.patch('magiclink.models.send_mail')

request = HttpRequest()
request.META['SERVER_NAME'] = '127.0.0.1'
request.META['SERVER_PORT'] = 80
ml = magic_link(request)
MagicLinkUnsubscribe.objects.create(email=ml.email)

ml.send(request)

send_mail.assert_called_once_with(
subject=mlsettings.EMAIL_SUBJECT,
message=mocker.ANY,
recipient_list=[ml.email],
from_email=settings.DEFAULT_FROM_EMAIL,
html_message=mocker.ANY,
)


@pytest.mark.django_db
def test_validate(user, magic_link): # NOQA: F811
request = HttpRequest()
Expand Down
15 changes: 15 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,18 @@ def test_login_verify_url(settings):
from magiclink import settings as mlsettings
reload(mlsettings)
assert mlsettings.LOGIN_VERIFY_URL == settings.MAGICLINK_LOGIN_VERIFY_URL


def test_ignore_unsubscribe_if_user(settings):
settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = True
from magiclink import settings as mlsettings
reload(mlsettings)
assert mlsettings.IGNORE_UNSUBSCRIBE_IF_USER == settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER # NOQA: E501


def test_ignore_unsubscribe_if_user_bad_value(settings):
settings.MAGICLINK_IGNORE_UNSUBSCRIBE_IF_USER = 'Test'

with pytest.raises(ImproperlyConfigured):
from magiclink import settings
reload(settings)
20 changes: 19 additions & 1 deletion tests/test_signup.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.contrib.auth import get_user_model
from django.urls import reverse

from magiclink.models import MagicLink
from magiclink.models import MagicLink, MagicLinkUnsubscribe

User = get_user_model()

Expand Down Expand Up @@ -351,3 +351,21 @@ def test_signup_antispam_url_value(settings, client): # NOQA: F811
assert response.status_code == 200
form_errors = response.context[signup_form].errors
assert form_errors['url'] == ['url should be empty']


@pytest.mark.django_db
def test_signup_email_in_unsubscribe(client):
email = 'test@example.com'
MagicLinkUnsubscribe.objects.create(email=email)

url = reverse('magiclink:signup')
signup_form = 'SignupFormEmailOnly'
data = {
'form_name': signup_form,
'email': email,
'url': 'test',
}
response = client.post(url, data)
assert response.status_code == 200
form_errors = response.context[signup_form].errors
assert form_errors['email'] == ['Email address is on the unsubscribe list']

0 comments on commit 62b37ee

Please sign in to comment.