From 7b28bd4b621d92b0028ccaef389823d96bf9e35a Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Mon, 18 Nov 2024 10:33:11 +1300 Subject: [PATCH 1/8] Update justfile with startapp command --- justfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/justfile b/justfile index 78f04a2..fb36116 100644 --- a/justfile +++ b/justfile @@ -83,3 +83,7 @@ dc-exec-dev: # Generate a secret key for Django secret: {{uvr}} manage.py shell -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" + +# Create a new Django app +startapp APPNAME: + {{uvr}} manage.py startapp {{APPNAME}} From df86e4f251690fac320ae79fb7691ef03b4db3c7 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Mon, 18 Nov 2024 11:54:30 +1300 Subject: [PATCH 2/8] Initial commit of the spf_generator app --- config/settings.py | 1 + config/urls.py | 1 + spf_generator/__init__.py | 1 + spf_generator/admin.py | 98 +++++++++++ spf_generator/apps.py | 10 ++ spf_generator/management/commands/__init__.py | 1 + .../management/commands/populate_spf_data.py | 163 ++++++++++++++++++ spf_generator/migrations/0001_initial.py | 34 ++++ spf_generator/migrations/__init__.py | 0 spf_generator/models.py | 142 +++++++++++++++ .../templates/spf_generator/generator.html | 68 ++++++++ .../spf_generator/partials/error.html | 26 +++ .../spf_generator/partials/result.html | 9 + spf_generator/templatetags/__init__.py | 1 + .../templatetags/spf_generator_filters.py | 42 +++++ spf_generator/tests.py | 3 + spf_generator/urls.py | 11 ++ spf_generator/views.py | 129 ++++++++++++++ 18 files changed, 740 insertions(+) create mode 100644 spf_generator/__init__.py create mode 100644 spf_generator/admin.py create mode 100644 spf_generator/apps.py create mode 100644 spf_generator/management/commands/__init__.py create mode 100644 spf_generator/management/commands/populate_spf_data.py create mode 100644 spf_generator/migrations/0001_initial.py create mode 100644 spf_generator/migrations/__init__.py create mode 100644 spf_generator/models.py create mode 100644 spf_generator/templates/spf_generator/generator.html create mode 100644 spf_generator/templates/spf_generator/partials/error.html create mode 100644 spf_generator/templates/spf_generator/partials/result.html create mode 100644 spf_generator/templatetags/__init__.py create mode 100644 spf_generator/templatetags/spf_generator_filters.py create mode 100644 spf_generator/tests.py create mode 100644 spf_generator/urls.py create mode 100644 spf_generator/views.py diff --git a/config/settings.py b/config/settings.py index 362e9d3..99b8fb3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -70,6 +70,7 @@ "timezone_converter", "markdown_editor", "shell", + "spf_generator", ] if DEBUG: diff --git a/config/urls.py b/config/urls.py index e2d6b86..7e45ce6 100644 --- a/config/urls.py +++ b/config/urls.py @@ -18,6 +18,7 @@ path(f"{settings.ADMIN_URL}/", admin.site.urls), path("utils/timezones/", view=include("timezone_converter.urls")), path("utils/markdown-editor/", view=include("markdown_editor.urls")), + path("utils/spf/", view=include("spf_generator.urls")), path("robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain")), path("", include("djpress.urls")), ] diff --git a/spf_generator/__init__.py b/spf_generator/__init__.py new file mode 100644 index 0000000..fedacb4 --- /dev/null +++ b/spf_generator/__init__.py @@ -0,0 +1 @@ +"""SPF Generator App.""" diff --git a/spf_generator/admin.py b/spf_generator/admin.py new file mode 100644 index 0000000..bf02bc8 --- /dev/null +++ b/spf_generator/admin.py @@ -0,0 +1,98 @@ +"""Admin configuration for the SPF Generator app.""" + +from typing import ClassVar + +from django.contrib import admin +from django.utils.html import format_html + +from .models import EmailProvider + + +@admin.register(EmailProvider) +class EmailProviderAdmin(admin.ModelAdmin): + """Admin interface configuration for EmailProvider model.""" + + list_display: ClassVar = [ + "name", + "category", + "mechanism_display", + "lookup_count", + "priority", + "active", + ] + + list_filter: ClassVar = [ + "category", + "active", + "mechanism_type", + ] + + search_fields: ClassVar = [ + "name", + "description", + "mechanism_value", + "notes", + ] + + readonly_fields: ClassVar = [ + "created_at", + "updated_at", + ] + + fieldsets: ClassVar = [ + ( + None, + { + "fields": [ + "name", + "category", + "description", + ], + }, + ), + ( + "SPF Configuration", + { + "fields": [ + "mechanism_type", + "mechanism_value", + "lookup_count", + "priority", + ], + }, + ), + ( + "Status", + { + "fields": [ + "active", + "notes", + ], + }, + ), + ( + "Metadata", + { + "classes": ["collapse"], + "fields": [ + "created_at", + "updated_at", + ], + }, + ), + ] + + @admin.display(description="Mechanism") + def mechanism_display(self, obj: EmailProvider) -> str: + """Displays the complete SPF mechanism in the list view. + + Args: + obj: The EmailProvider instance + + Returns: + str: HTML-formatted SPF mechanism + """ + return format_html( + "{}", + obj.get_mechanism(), + ) diff --git a/spf_generator/apps.py b/spf_generator/apps.py new file mode 100644 index 0000000..c761dd8 --- /dev/null +++ b/spf_generator/apps.py @@ -0,0 +1,10 @@ +"""App configuration for the SPF Generator app.""" + +from django.apps import AppConfig + + +class SpfGeneratorConfig(AppConfig): + """App configuration for the SPF Generator app.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "spf_generator" diff --git a/spf_generator/management/commands/__init__.py b/spf_generator/management/commands/__init__.py new file mode 100644 index 0000000..0a8e0e8 --- /dev/null +++ b/spf_generator/management/commands/__init__.py @@ -0,0 +1 @@ +"""Management commands for the SPF generator.""" diff --git a/spf_generator/management/commands/populate_spf_data.py b/spf_generator/management/commands/populate_spf_data.py new file mode 100644 index 0000000..05e4317 --- /dev/null +++ b/spf_generator/management/commands/populate_spf_data.py @@ -0,0 +1,163 @@ +"""Management command to populate the SPF data.""" + +from typing import Any + +from django.core.management.base import BaseCommand + +from spf_generator.models import EmailProvider, ProviderCategory, SpfMechanism + + +class Command(BaseCommand): + """Management command to populate the database with common email provider SPF records. + + Save this file as your_app_name/management/commands/populate_spf_providers.py + """ + + help = "Populates the database with common email provider SPF records" + + def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 + """Handle the command execution.""" + # Email Hosting Providers + email_hosting_providers = [ + { + "name": "Google Workspace", + "category": ProviderCategory.EMAIL_HOSTING, + "description": "Google Workspace (formerly G Suite) email hosting", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "_spf.google.com", + "lookup_count": 2, + "priority": 10, + "notes": "Includes gmail.com and googlemail.com domains", + }, + { + "name": "Microsoft 365", + "category": ProviderCategory.EMAIL_HOSTING, + "description": "Microsoft 365 (formerly Office 365) email hosting", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "spf.protection.outlook.com", + "lookup_count": 2, + "priority": 10, + "notes": "Used for all Microsoft 365 email services", + }, + { + "name": "Zoho Mail", + "category": ProviderCategory.EMAIL_HOSTING, + "description": "Zoho Mail hosting service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "zoho.com", + "lookup_count": 1, + "priority": 10, + "notes": "Basic Zoho Mail SPF record", + }, + { + "name": "FastMail", + "category": ProviderCategory.EMAIL_HOSTING, + "description": "FastMail email hosting", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "spf.messagingengine.com", + "lookup_count": 1, + "priority": 10, + "notes": "Covers all FastMail sending IPs", + }, + { + "name": "ProtonMail", + "category": ProviderCategory.EMAIL_HOSTING, + "description": "ProtonMail secure email hosting", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "spf.protonmail.ch", + "lookup_count": 1, + "priority": 10, + "notes": "Covers all ProtonMail infrastructure", + }, + ] + + # Transactional Email Providers + transactional_providers = [ + { + "name": "Amazon SES", + "category": ProviderCategory.TRANSACTIONAL, + "description": "Amazon Simple Email Service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "amazonses.com", + "lookup_count": 1, + "priority": 20, + "notes": "Note: Region-specific SPF records are also available", + }, + { + "name": "SendGrid", + "category": ProviderCategory.TRANSACTIONAL, + "description": "SendGrid email delivery service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "sendgrid.net", + "lookup_count": 1, + "priority": 20, + "notes": "Covers all SendGrid sending IPs", + }, + { + "name": "Mailgun", + "category": ProviderCategory.TRANSACTIONAL, + "description": "Mailgun email delivery service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "mailgun.org", + "lookup_count": 1, + "priority": 20, + "notes": "Basic Mailgun SPF record", + }, + { + "name": "Postmark", + "category": ProviderCategory.TRANSACTIONAL, + "description": "Postmark email delivery service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "spf.mtasv.net", + "lookup_count": 1, + "priority": 20, + "notes": "Covers all Postmark sending servers", + }, + { + "name": "Mailchimp", + "category": ProviderCategory.TRANSACTIONAL, + "description": "Mailchimp Transactional (formerly Mandrill)", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "spf.mandrillapp.com", + "lookup_count": 1, + "priority": 20, + "notes": "Used for Mailchimp Transactional emails", + }, + ] + + # Other Common Services + other_providers = [ + { + "name": "Outlook.com", + "category": ProviderCategory.OTHER, + "description": "Personal Outlook.com/Hotmail accounts", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "spf.protection.outlook.com", + "lookup_count": 2, + "priority": 30, + "notes": "For personal Microsoft email accounts (not Microsoft 365)", + }, + { + "name": "Yahoo Mail", + "category": ProviderCategory.OTHER, + "description": "Yahoo Mail service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "spf.mail.yahoo.com", + "lookup_count": 1, + "priority": 30, + "notes": "Covers Yahoo Mail infrastructure", + }, + ] + + # Combine all providers + all_providers = email_hosting_providers + transactional_providers + other_providers + + # Create providers in database + for provider_data in all_providers: + EmailProvider.objects.get_or_create( + name=provider_data["name"], + defaults=provider_data, + ) + self.stdout.write( + self.style.SUCCESS(f'Created provider: {provider_data["name"]}'), + ) diff --git a/spf_generator/migrations/0001_initial.py b/spf_generator/migrations/0001_initial.py new file mode 100644 index 0000000..0aef209 --- /dev/null +++ b/spf_generator/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.3 on 2024-11-17 22:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EmailProvider', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text="Name of the email provider (e.g., 'Google Workspace', 'SendGrid')", max_length=100, unique=True)), + ('category', models.CharField(choices=[('EMAIL_HOSTING', 'Email Hosting'), ('TRANSACTIONAL', 'Transactional Email'), ('OTHER', 'Other')], help_text='Category of email provider', max_length=20)), + ('description', models.TextField(blank=True, help_text='Public description of the provider')), + ('mechanism_type', models.CharField(choices=[('include', 'Include'), ('a', 'A Record'), ('mx', 'MX Record'), ('ip4', 'IPv4'), ('ip6', 'IPv6'), ('exists', 'Exists')], help_text='Type of SPF mechanism used', max_length=10)), + ('mechanism_value', models.CharField(help_text="The actual SPF mechanism value (e.g., 'include:_spf.google.com')", max_length=255)), + ('lookup_count', models.PositiveSmallIntegerField(default=1, help_text='Number of DNS lookups this mechanism requires')), + ('priority', models.PositiveSmallIntegerField(default=100, help_text='Order in which this should appear in combined SPF record')), + ('active', models.BooleanField(default=True, help_text='Whether this provider is currently available for selection')), + ('notes', models.TextField(blank=True, help_text='Internal notes about this provider')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['priority', 'name'], + }, + ), + ] diff --git a/spf_generator/migrations/__init__.py b/spf_generator/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/spf_generator/models.py b/spf_generator/models.py new file mode 100644 index 0000000..0d2018c --- /dev/null +++ b/spf_generator/models.py @@ -0,0 +1,142 @@ +"""Models for the spf_generator app.""" + +from typing import ClassVar + +from django.db import models + + +class ProviderCategory(models.TextChoices): + """Enumeration of different email provider categories. + + EMAIL_HOSTING: Services that provide email hosting (e.g., Google Workspace, Microsoft 365) + TRANSACTIONAL: Services for sending automated/transactional emails (e.g., SendGrid, Mailgun) + """ + + EMAIL_HOSTING = "EMAIL_HOSTING", "Email Hosting" + TRANSACTIONAL = "TRANSACTIONAL", "Transactional Email" + OTHER = "OTHER", "Other" + + +class SpfMechanism(models.TextChoices): + """Enumeration of SPF mechanisms in order of recommended usage. + + The order matters as it affects how SPF records are evaluated. + """ + + INCLUDE = "include", "Include" + A = "a", "A Record" + MX = "mx", "MX Record" + IP4 = "ip4", "IPv4" + IP6 = "ip6", "IPv6" + EXISTS = "exists", "Exists" + + +class EmailProvider(models.Model): + """Model to store email provider information and their SPF requirements. + + Attributes: + name (str): Name of the email provider + category (ProviderCategory): Category of the provider + description (str): Optional description of the provider + mechanism_type (SpfMechanism): Type of SPF mechanism used + mechanism_value (str): The actual SPF record value + lookup_count (int): Number of DNS lookups this mechanism requires + priority (int): Order in which this should appear in combined SPF record + active (bool): Whether this provider is currently available for selection + notes (str): Optional internal notes about this provider + """ + + name = models.CharField( + max_length=100, + unique=True, + help_text="Name of the email provider (e.g., 'Google Workspace', 'SendGrid')", + ) + + category = models.CharField( + max_length=20, + choices=ProviderCategory.choices, + help_text="Category of email provider", + ) + + description = models.TextField( + blank=True, + help_text="Public description of the provider", + ) + + mechanism_type = models.CharField( + max_length=10, + choices=SpfMechanism.choices, + help_text="Type of SPF mechanism used", + ) + + mechanism_value = models.CharField( + max_length=255, + help_text="The actual SPF mechanism value (e.g., 'include:_spf.google.com')", + ) + + lookup_count = models.PositiveSmallIntegerField( + default=1, + help_text="Number of DNS lookups this mechanism requires", + ) + + priority = models.PositiveSmallIntegerField( + default=100, + help_text="Order in which this should appear in combined SPF record", + ) + + active = models.BooleanField( + default=True, + help_text="Whether this provider is currently available for selection", + ) + + notes = models.TextField( + blank=True, + help_text="Internal notes about this provider", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + """Meta options for the EmailProvider model.""" + + ordering: ClassVar = ["priority", "name"] + + def __str__(self) -> str: + """Returns the name of the provider when converted to a string.""" + return self.name + + def get_mechanism(self) -> str: + """Returns the complete SPF mechanism string for this provider. + + Returns: + str: Formatted SPF mechanism string + """ + return f"{self.mechanism_type}:{self.mechanism_value}" + + @staticmethod + def validate_combination(providers: list["EmailProvider"]) -> tuple[bool, str | None]: + """Validates whether a combination of providers can be used together. + + Args: + providers: List of EmailProvider instances to validate + + Returns: + Tuple containing: + - Boolean indicating if combination is valid + - Error message if invalid, None if valid + """ + # Check total lookup count + total_lookups = sum(p.lookup_count for p in providers) + max_lookups = 10 + if total_lookups > max_lookups: + return False, f"Total DNS lookups ({total_lookups}) exceeds maximum of 10" + + # Calculate total mechanism length (including basic SPF framework) + mechanisms = " ".join(p.get_mechanism() for p in providers) + spf_record = f"v=spf1 {mechanisms} -all" + max_spf_length = 255 + if len(spf_record) > max_spf_length: + return False, "Combined SPF record exceeds 255 characters" + + return True, None diff --git a/spf_generator/templates/spf_generator/generator.html b/spf_generator/templates/spf_generator/generator.html new file mode 100644 index 0000000..9b991d7 --- /dev/null +++ b/spf_generator/templates/spf_generator/generator.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% load spf_generator_filters %} + +{% block head %} + +{% endblock head %} + +{% block content %} +
+
+
+

SPF Record Generator

+
+ +
+ {% csrf_token %} + + {% for category, category_name in categories %} +
+
+

{{ category_name }}

+
+ +
+ {% for field in form %} + {% if field.name|startswith:'provider_' %} + {% with provider=providers|get_provider:field.name %} + {% if provider.category == category %} + + {% endif %} + {% endwith %} + {% endif %} + {% endfor %} +
+
+ {% endfor %} + + +
+ + +
+
+ + +{% endblock content %} diff --git a/spf_generator/templates/spf_generator/partials/error.html b/spf_generator/templates/spf_generator/partials/error.html new file mode 100644 index 0000000..15faab6 --- /dev/null +++ b/spf_generator/templates/spf_generator/partials/error.html @@ -0,0 +1,26 @@ +
+
+

Error

+
+

{{ error }}

+
+ + diff --git a/spf_generator/templates/spf_generator/partials/result.html b/spf_generator/templates/spf_generator/partials/result.html new file mode 100644 index 0000000..570961c --- /dev/null +++ b/spf_generator/templates/spf_generator/partials/result.html @@ -0,0 +1,9 @@ +
+
+

Generated SPF Record

+
+
{{ spf_record }}
+
+ Add this record as a TXT record in your domain's DNS settings. +
+
diff --git a/spf_generator/templatetags/__init__.py b/spf_generator/templatetags/__init__.py new file mode 100644 index 0000000..a52ef05 --- /dev/null +++ b/spf_generator/templatetags/__init__.py @@ -0,0 +1 @@ +"""Custom template tags for spf_generator app.""" diff --git a/spf_generator/templatetags/spf_generator_filters.py b/spf_generator/templatetags/spf_generator_filters.py new file mode 100644 index 0000000..7333676 --- /dev/null +++ b/spf_generator/templatetags/spf_generator_filters.py @@ -0,0 +1,42 @@ +"""Custom template filters for spf_generator app.""" +# your_app_name/templatetags/spf_generator_filters.py + +from django import template +from django.template.defaultfilters import stringfilter + +from spf_generator.models import EmailProvider + +register = template.Library() + + +@register.filter +@stringfilter +def startswith(text: str, starts: str) -> bool: + """Template filter to check if a string starts with given text. + + Args: + text: String to check + starts: Prefix to look for + + Returns: + bool: True if text starts with given prefix + """ + return text.startswith(starts) + + +@register.filter +def get_provider(providers_dict: dict[int, EmailProvider], field_name: str) -> EmailProvider: + """Template filter to get provider object from field name. + + Args: + providers_dict: Dictionary of providers + field_name: Form field name (e.g., 'provider_1') + + Returns: + EmailProvider: The provider object or None + """ + try: + provider_id = int(field_name.split("_")[1]) + return providers_dict.get(provider_id) + except (IndexError, ValueError): + return None diff --git a/spf_generator/tests.py b/spf_generator/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/spf_generator/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/spf_generator/urls.py b/spf_generator/urls.py new file mode 100644 index 0000000..f6c275e --- /dev/null +++ b/spf_generator/urls.py @@ -0,0 +1,11 @@ +"""URLs for markdown_editor app.""" + +from django.urls import path + +from spf_generator.views import generate_spf_record + +app_name = "spf_generator" + +urlpatterns = [ + path("", generate_spf_record, name="spf_generator"), +] diff --git a/spf_generator/views.py b/spf_generator/views.py new file mode 100644 index 0000000..be9109c --- /dev/null +++ b/spf_generator/views.py @@ -0,0 +1,129 @@ +"""Views for the SPF Generator app.""" + +from typing import Any + +from django import forms +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render + +from spf_generator.models import EmailProvider, ProviderCategory + + +class ProviderSelectForm(forms.Form): + """Form for selecting email providers. + + The form dynamically generates checkboxes for each active provider, + grouped by category. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 + """Initializes the form with provider fields.""" + super().__init__(*args, **kwargs) + + # Group providers by category + for category in ProviderCategory.choices: + providers = EmailProvider.objects.filter( + category=category[0], + active=True, + ) + + for provider in providers: + field_name = f"provider_{provider.id}" + self.fields[field_name] = forms.BooleanField( + required=False, + label=provider.name, + help_text=provider.description, + ) + + +def generate_spf_record(request: HttpRequest) -> HttpResponse: + """View function for the SPF record generator. + + Displays the provider selection form on GET requests. + Processes form submission and generates SPF record on POST requests. + + Args: + request: The HTTP request object + + Returns: + HttpResponse containing either the full page or HTMX partial + """ + if request.method == "POST": + form = ProviderSelectForm(request.POST) + if form.is_valid(): + # Get selected providers + selected_providers: list[EmailProvider] = [] + for field_name, value in form.cleaned_data.items(): + if value and field_name.startswith("provider_"): + provider_id = int(field_name.split("_")[1]) + provider = EmailProvider.objects.get(id=provider_id) + selected_providers.append(provider) + + # Check if any providers were selected + if not selected_providers: + response = render( + request, + "spf_generator/partials/error.html", + {"error": "Please select at least one email provider"}, + ) + response["HX-Retarget"] = "#result" + return response + + # Check total lookup count + total_lookups = sum(p.lookup_count for p in selected_providers) + max_dns_lookups = 10 + if total_lookups > max_dns_lookups: + response = render( + request, + "spf_generator/partials/error.html", + {"error": f"Total DNS lookups ({total_lookups}) exceeds maximum of 10"}, + ) + response["HX-Retarget"] = "#result" + return response + + # Generate SPF record + mechanisms = " ".join( + p.get_mechanism() + for p in sorted( + selected_providers, + key=lambda x: x.priority, + ) + ) + spf_record = f"v=spf1 {mechanisms} -all" + + # Check record length + max_record_length = 255 + if len(spf_record) > max_record_length: + return render( + request, + "spf_generator/partials/error.html", + {"error": "Combined SPF record exceeds 255 characters"}, + headers={"HX-Retarget": "#result"}, + ) + + response = render( + request, + "spf_generator/partials/result.html", + {"spf_record": spf_record}, + ) + response["HX-Retarget"] = "#result" + return response + + # Form validation failed + response = render( + request, + "spf_generator/partials/error.html", + {"error": "Invalid form submission"}, + ) + response["HX-Retarget"] = "#result" + return response + + # GET request - display form + providers = {provider.id: provider for provider in EmailProvider.objects.filter(active=True)} + + context = { + "form": ProviderSelectForm(), + "categories": ProviderCategory.choices, + "providers": providers, # Add this line + } + return render(request, "spf_generator/generator.html", context) From 0402c11e9659531697ce059542325a5a7c5dac4c Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Mon, 18 Nov 2024 12:16:26 +1300 Subject: [PATCH 3/8] Added an option to control the strictness --- spf_generator/models.py | 23 ++++++++++++++ .../templates/spf_generator/generator.html | 15 +++++++++ .../spf_generator/partials/result.html | 6 +++- spf_generator/views.py | 31 ++++++++++++++++--- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/spf_generator/models.py b/spf_generator/models.py index 0d2018c..f5d009e 100644 --- a/spf_generator/models.py +++ b/spf_generator/models.py @@ -5,6 +5,29 @@ from django.db import models +class SpfAllMechanism(models.TextChoices): + """SPF 'all' mechanism options. + + FAIL: -all - Explicitly deny all other servers + SOFTFAIL: ~all - Suggest denial but don't enforce + NEUTRAL: ?all - Take no position + """ + + FAIL = "-all", "Fail (-all)" + SOFTFAIL = "~all", "Softfail (~all)" + NEUTRAL = "?all", "Neutral (?all)" + + @property + def description(self) -> str: + """Returns a brief description of what this mechanism does.""" + descriptions = { + self.FAIL: "explicitly rejects mail from unlisted servers", + self.SOFTFAIL: "suggests rejection but doesn't enforce it", + self.NEUTRAL: "takes no position on unlisted servers", + } + return descriptions[self] + + class ProviderCategory(models.TextChoices): """Enumeration of different email provider categories. diff --git a/spf_generator/templates/spf_generator/generator.html b/spf_generator/templates/spf_generator/generator.html index 9b991d7..e432a72 100644 --- a/spf_generator/templates/spf_generator/generator.html +++ b/spf_generator/templates/spf_generator/generator.html @@ -10,6 +10,7 @@

SPF Record Generator

+

Select the email services that you use with your domain from the lists below.

{{ category_name }}
{% endfor %} +
+
+

Default Policy

+
+
+
+ {{ form.all_mechanism }} + {% if form.all_mechanism.help_text %} + {{ form.all_mechanism.help_text|safe }} + {% endif %} +
+
+
+ diff --git a/spf_generator/templates/spf_generator/partials/result.html b/spf_generator/templates/spf_generator/partials/result.html index 570961c..8ad34ef 100644 --- a/spf_generator/templates/spf_generator/partials/result.html +++ b/spf_generator/templates/spf_generator/partials/result.html @@ -4,6 +4,10 @@

Generated SPF Record

{{ spf_record }}
- Add this record as a TXT record in your domain's DNS settings. + + Add this record as a TXT record in your domain's DNS settings.
+ Note: This record uses {{ all_mechanism }} as the default policy.
+ {{ all_mechanism_help|safe }} +
diff --git a/spf_generator/views.py b/spf_generator/views.py index be9109c..6c55e0e 100644 --- a/spf_generator/views.py +++ b/spf_generator/views.py @@ -6,7 +6,7 @@ from django.http import HttpRequest, HttpResponse from django.shortcuts import render -from spf_generator.models import EmailProvider, ProviderCategory +from spf_generator.models import EmailProvider, ProviderCategory, SpfAllMechanism class ProviderSelectForm(forms.Form): @@ -16,6 +16,23 @@ class ProviderSelectForm(forms.Form): grouped by category. """ + all_mechanism = forms.ChoiceField( + choices=SpfAllMechanism.choices, + initial=SpfAllMechanism.FAIL, + required=True, + help_text=( + "

Choose how to handle mail from unlisted servers:

" + "
    " + "
  • Fail (-all): Recommended. Explicitly reject mail from unlisted servers. " + "Use this if you're sure you've listed all legitimate sending servers.
  • " + "
  • Softfail (~all): Suggest rejection but don't enforce it. " + "Useful during SPF testing or if you're unsure about all legitimate senders.
  • " + "
  • Neutral (?all): Take no position on unlisted servers. " + "Not recommended as it doesn't help prevent email spoofing.
  • " + "
" + ), + ) + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 """Initializes the form with provider fields.""" super().__init__(*args, **kwargs) @@ -81,7 +98,7 @@ def generate_spf_record(request: HttpRequest) -> HttpResponse: response["HX-Retarget"] = "#result" return response - # Generate SPF record + # Generate SPF record with selected 'all' mechanism mechanisms = " ".join( p.get_mechanism() for p in sorted( @@ -89,7 +106,8 @@ def generate_spf_record(request: HttpRequest) -> HttpResponse: key=lambda x: x.priority, ) ) - spf_record = f"v=spf1 {mechanisms} -all" + all_mechanism = SpfAllMechanism(form.cleaned_data["all_mechanism"]) + spf_record = f"v=spf1 {mechanisms} {all_mechanism}" # Check record length max_record_length = 255 @@ -101,10 +119,15 @@ def generate_spf_record(request: HttpRequest) -> HttpResponse: headers={"HX-Retarget": "#result"}, ) + # Success - render SPF record response = render( request, "spf_generator/partials/result.html", - {"spf_record": spf_record}, + { + "spf_record": spf_record, + "all_mechanism": all_mechanism.label, + "all_mechanism_description": all_mechanism.description, + }, ) response["HX-Retarget"] = "#result" return response From b6cab874f91c15fafe4bb76e545f384d611c2f22 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Mon, 18 Nov 2024 12:33:20 +1300 Subject: [PATCH 4/8] SPF results in a modal with a copy button --- .../templates/spf_generator/generator.html | 64 ++++++++----------- .../spf_generator/partials/result.html | 62 +++++++++++++++--- 2 files changed, 78 insertions(+), 48 deletions(-) diff --git a/spf_generator/templates/spf_generator/generator.html b/spf_generator/templates/spf_generator/generator.html index e432a72..07a34e0 100644 --- a/spf_generator/templates/spf_generator/generator.html +++ b/spf_generator/templates/spf_generator/generator.html @@ -17,31 +17,35 @@

SPF Record Generator

hx-target="#result"> {% csrf_token %} - {% for category, category_name in categories %} -
-
-

{{ category_name }}

-
- -
- {% for field in form %} - {% if field.name|startswith:'provider_' %} - {% with provider=providers|get_provider:field.name %} - {% if provider.category == category %} -
- {% endfor %} + {% endfor %} + + +

Now select the policy you want to apply with this SPF record. This is an important choice and is explained below.

@@ -64,20 +68,4 @@

Default Policy

- {% endblock content %} diff --git a/spf_generator/templates/spf_generator/partials/result.html b/spf_generator/templates/spf_generator/partials/result.html index 8ad34ef..486126b 100644 --- a/spf_generator/templates/spf_generator/partials/result.html +++ b/spf_generator/templates/spf_generator/partials/result.html @@ -1,13 +1,55 @@ -
-
-

Generated SPF Record

-
-
{{ spf_record }}
-
- + +
+
+ +

Generated SPF Record

+
+
{{ spf_record }}
+
Add this record as a TXT record in your domain's DNS settings.
Note: This record uses {{ all_mechanism }} as the default policy.
{{ all_mechanism_help|safe }} -
-
-
+
+ +
+
+
+ + + From 661642439f294b9ae8e570a287d1f5430a6116ad Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Mon, 18 Nov 2024 13:04:52 +1300 Subject: [PATCH 5/8] Styling updates --- .../templates/spf_generator/generator.html | 38 +++++++++++++++++++ .../spf_generator/partials/error.html | 35 +++++------------ .../spf_generator/partials/result.html | 38 +------------------ templates/base.html | 1 + 4 files changed, 49 insertions(+), 63 deletions(-) diff --git a/spf_generator/templates/spf_generator/generator.html b/spf_generator/templates/spf_generator/generator.html index 07a34e0..ec91dab 100644 --- a/spf_generator/templates/spf_generator/generator.html +++ b/spf_generator/templates/spf_generator/generator.html @@ -69,3 +69,41 @@

Default Policy

{% endblock content %} + +{% block body_close %} + +{% endblock body_close %} diff --git a/spf_generator/templates/spf_generator/partials/error.html b/spf_generator/templates/spf_generator/partials/error.html index 15faab6..2d8258a 100644 --- a/spf_generator/templates/spf_generator/partials/error.html +++ b/spf_generator/templates/spf_generator/partials/error.html @@ -1,26 +1,9 @@ -
-
-

Error

-
-

{{ error }}

-
- - + +
+
+ +

Error

+
+

{{ error }}

+
+
diff --git a/spf_generator/templates/spf_generator/partials/result.html b/spf_generator/templates/spf_generator/partials/result.html index 486126b..eca1a73 100644 --- a/spf_generator/templates/spf_generator/partials/result.html +++ b/spf_generator/templates/spf_generator/partials/result.html @@ -2,7 +2,7 @@
-

Generated SPF Record

+

Your SPF Record

{{ spf_record }}
@@ -17,39 +17,3 @@

Generated SPF Record

- - diff --git a/templates/base.html b/templates/base.html index e2527a0..f579873 100644 --- a/templates/base.html +++ b/templates/base.html @@ -31,5 +31,6 @@ {% endblock content %} + {% block body_close %}{% endblock %} From 8db2499a724505a9c45a1c4be89683eb39f2e060 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Mon, 18 Nov 2024 13:11:45 +1300 Subject: [PATCH 6/8] Add manage command --- justfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/justfile b/justfile index fb36116..bf98aef 100644 --- a/justfile +++ b/justfile @@ -87,3 +87,7 @@ secret: # Create a new Django app startapp APPNAME: {{uvr}} manage.py startapp {{APPNAME}} + +# Generic manage command +@manage ARGS="": + {{uvr}} manage.py {{ARGS}} From c91b2364da7f602ca36fc0855ca4fb20bee9d443 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Mon, 18 Nov 2024 14:45:07 +1300 Subject: [PATCH 7/8] Update providers --- .../management/commands/populate_spf_data.py | 155 ++++++++++++++---- 1 file changed, 123 insertions(+), 32 deletions(-) diff --git a/spf_generator/management/commands/populate_spf_data.py b/spf_generator/management/commands/populate_spf_data.py index 05e4317..cf4a06e 100644 --- a/spf_generator/management/commands/populate_spf_data.py +++ b/spf_generator/management/commands/populate_spf_data.py @@ -27,7 +27,10 @@ def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 "mechanism_value": "_spf.google.com", "lookup_count": 2, "priority": 10, - "notes": "Includes gmail.com and googlemail.com domains", + "notes": ( + "Includes gmail.com and googlemail.com domains\n" + "https://support.google.com/a/answer/33786?sjid=13899028837607159847-AP#spf-value" + ), }, { "name": "Microsoft 365", @@ -37,37 +40,65 @@ def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 "mechanism_value": "spf.protection.outlook.com", "lookup_count": 2, "priority": 10, - "notes": "Used for all Microsoft 365 email services", + "notes": ( + "Used for all Microsoft 365 email services.\n" + "https://learn.microsoft.com/en-us/defender-office-365/email-authentication-spf-configure" + ), }, { - "name": "Zoho Mail", + "name": "FastMail", "category": ProviderCategory.EMAIL_HOSTING, - "description": "Zoho Mail hosting service", + "description": "FastMail email hosting", "mechanism_type": SpfMechanism.INCLUDE, - "mechanism_value": "zoho.com", + "mechanism_value": "spf.messagingengine.com", "lookup_count": 1, "priority": 10, - "notes": "Basic Zoho Mail SPF record", + "notes": ( + "Covers all FastMail sending IPs.\n" + "https://www.fastmail.help/hc/en-us/articles/360060591153-Manual-DNS-configuration" + ), }, { - "name": "FastMail", + "name": "ProtonMail", "category": ProviderCategory.EMAIL_HOSTING, - "description": "FastMail email hosting", + "description": "ProtonMail secure email hosting", "mechanism_type": SpfMechanism.INCLUDE, - "mechanism_value": "spf.messagingengine.com", + "mechanism_value": "_spf.protonmail.ch", "lookup_count": 1, "priority": 10, - "notes": "Covers all FastMail sending IPs", + "notes": ( + "Covers all ProtonMail infrastructure\nhttps://proton.me/support/anti-spoofing-custom-domain" + ), }, { - "name": "ProtonMail", + "name": "Zoho Mail India", "category": ProviderCategory.EMAIL_HOSTING, - "description": "ProtonMail secure email hosting", + "description": "Zoho Mail India hosting service", "mechanism_type": SpfMechanism.INCLUDE, - "mechanism_value": "spf.protonmail.ch", + "mechanism_value": "zoho.in", "lookup_count": 1, "priority": 10, - "notes": "Covers all ProtonMail infrastructure", + "notes": "India-specific Zoho Mail SPF record", + }, + { + "name": "Zoho Mail Europe", + "category": ProviderCategory.EMAIL_HOSTING, + "description": "Zoho Mail Europe hosting service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "spf.zoho.eu", + "lookup_count": 1, + "priority": 10, + "notes": "Europe Zoho Mail SPF record", + }, + { + "name": "Zoho Mail Global", + "category": ProviderCategory.EMAIL_HOSTING, + "description": "Zoho Mail global hosting service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "zoho.com", + "lookup_count": 1, + "priority": 10, + "notes": "Global Zoho Mail SPF record", }, ] @@ -81,7 +112,10 @@ def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 "mechanism_value": "amazonses.com", "lookup_count": 1, "priority": 20, - "notes": "Note: Region-specific SPF records are also available", + "notes": ( + "Note: Region-specific SPF records are also available\n" + "https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-spf.html" + ), }, { "name": "SendGrid", @@ -91,7 +125,10 @@ def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 "mechanism_value": "sendgrid.net", "lookup_count": 1, "priority": 20, - "notes": "Covers all SendGrid sending IPs", + "notes": ( + "Covers all SendGrid sending IPs\n" + "https://www.twilio.com/docs/sendgrid/ui/account-and-settings/spf-records" + ), }, { "name": "Mailgun", @@ -101,7 +138,10 @@ def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 "mechanism_value": "mailgun.org", "lookup_count": 1, "priority": 20, - "notes": "Basic Mailgun SPF record", + "notes": ( + "Basic Mailgun SPF record.\n" + "https://help.mailgun.com/hc/en-us/articles/360026833053-Domain-Verification-Setup-Guide" + ), }, { "name": "Postmark", @@ -111,41 +151,92 @@ def handle(self, *args: Any, **options: Any) -> None: # noqa: ANN401, ARG002 "mechanism_value": "spf.mtasv.net", "lookup_count": 1, "priority": 20, - "notes": "Covers all Postmark sending servers", + "notes": ( + "Covers all Postmark sending servers\n" + "https://postmarkapp.com/guides/spf#2-create-your-spf-record" + ), + }, + { + "name": "SparkPost Global", + "category": ProviderCategory.TRANSACTIONAL, + "description": "SparkPost email delivery service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "_spf.sparkpostmail.com", + "lookup_count": 1, + "priority": 20, + "notes": ( + "Covers global SparkPost sending servers. Europe have different settings\n" + "https://support.sparkpost.com/docs/faq/sender-id-spf-failures" + ), + }, + { + "name": "SparkPost Europe", + "category": ProviderCategory.TRANSACTIONAL, + "description": "SparkPost Europe email delivery service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "_spf.eu.sparkpostmail.com", + "lookup_count": 1, + "priority": 20, + "notes": ( + "Covers Europe SparkPost sending servers. Global have different settings\n" + "https://support.sparkpost.com/docs/faq/sender-id-spf-failures" + ), }, { - "name": "Mailchimp", + "name": "MailJet", "category": ProviderCategory.TRANSACTIONAL, - "description": "Mailchimp Transactional (formerly Mandrill)", + "description": "MailJet email delivery service", "mechanism_type": SpfMechanism.INCLUDE, - "mechanism_value": "spf.mandrillapp.com", + "mechanism_value": "spf.mailjet.com", "lookup_count": 1, "priority": 20, - "notes": "Used for Mailchimp Transactional emails", + "notes": ( + "Covers all MailJet sending servers\n" + "https://documentation.mailjet.com/hc/en-us/articles/360042412734-Authenticating-Domains-with-SPF-DKIM" + ), + }, + { + "name": "Scaleway", + "category": ProviderCategory.TRANSACTIONAL, + "description": "Scaleway email delivery service", + "mechanism_type": SpfMechanism.INCLUDE, + "mechanism_value": "_spf.tem.scaleway.com", + "lookup_count": 1, + "priority": 20, + "notes": ( + "Covers all Scaleway sending servers\n" + "https://www.scaleway.com/en/docs/managed-services/transactional-email/how-to/add-spf-dkim-records-to-your-domain/" + ), }, ] # Other Common Services other_providers = [ { - "name": "Outlook.com", + "name": "Zendesk", "category": ProviderCategory.OTHER, - "description": "Personal Outlook.com/Hotmail accounts", + "description": "Zendesk helpdesk service", "mechanism_type": SpfMechanism.INCLUDE, - "mechanism_value": "spf.protection.outlook.com", - "lookup_count": 2, + "mechanism_value": "mail.zendesk.com", + "lookup_count": 40, "priority": 30, - "notes": "For personal Microsoft email accounts (not Microsoft 365)", + "notes": ( + "For Zendesk customers.\n" + "https://support.zendesk.com/hc/en-us/articles/4408832543770-Allowing-Zendesk-to-send-email-on-behalf-of-your-email-domain" + ), }, { - "name": "Yahoo Mail", + "name": "Freshdesk", "category": ProviderCategory.OTHER, - "description": "Yahoo Mail service", + "description": "Freshdesk helpdesk service", "mechanism_type": SpfMechanism.INCLUDE, - "mechanism_value": "spf.mail.yahoo.com", - "lookup_count": 1, + "mechanism_value": "email.freshdesk.com", + "lookup_count": 40, "priority": 30, - "notes": "Covers Yahoo Mail infrastructure", + "notes": ( + "For Freshdesk customers.\n" + "https://support.freshdesk.com/support/solutions/articles/43170-creating-an-spf-record-to-ensure-proper-email-delivery" + ), }, ] From 490fbe7c8a913d4c26c037ec08c9052c3cbea806 Mon Sep 17 00:00:00 2001 From: Stuart Maxwell Date: Mon, 18 Nov 2024 15:21:28 +1300 Subject: [PATCH 8/8] Add custom IP --- spf_generator/forms.py | 104 ++++++++++++++ .../templates/spf_generator/generator.html | 16 +++ spf_generator/test_views.py | 135 ++++++++++++++++++ spf_generator/views.py | 77 +++------- 4 files changed, 275 insertions(+), 57 deletions(-) create mode 100644 spf_generator/forms.py create mode 100644 spf_generator/test_views.py diff --git a/spf_generator/forms.py b/spf_generator/forms.py new file mode 100644 index 0000000..ca69463 --- /dev/null +++ b/spf_generator/forms.py @@ -0,0 +1,104 @@ +"""Forms for the spf_generator app.""" + +import ipaddress +from typing import Any + +from django import forms +from django.core.exceptions import ValidationError + +from spf_generator.models import EmailProvider, ProviderCategory, SpfAllMechanism + + +def validate_ip_address(value: str) -> None: + """Validates if the string is a valid IPv4 or IPv6 address. + + Args: + value: String to validate + + Raises: + ValidationError: If the string is not a valid IP address + """ + if not value: + return + + try: + ipaddress.ip_address(value) + except ValueError as exc: + msg = "Please enter a valid IP address" + raise ValidationError(msg) from exc + + +class ProviderSelectForm(forms.Form): + """Form for selecting email providers. + + The form dynamically generates checkboxes for each active provider, + grouped by category. + """ + + all_mechanism = forms.ChoiceField( + choices=SpfAllMechanism.choices, + initial=SpfAllMechanism.FAIL, + required=True, + help_text=( + "

Choose how to handle mail from unlisted servers:

" + "
    " + "
  • Fail (-all): Recommended. Explicitly reject mail from unlisted servers. " + "Use this if you're sure you've listed all legitimate sending servers.
  • " + "
  • Softfail (~all): Suggest rejection but don't enforce it. " + "Useful during SPF testing or if you're unsure about all legitimate senders.
  • " + "
  • Neutral (?all): Take no position on unlisted servers. " + "Not recommended as it doesn't help prevent email spoofing.
  • " + "
" + ), + ) + + custom_ip = forms.CharField( + required=False, + validators=[validate_ip_address], + help_text="If you have a server that sends email, enter its IP address here", + widget=forms.TextInput( + attrs={ + "placeholder": "e.g., 192.168.1.1 or 2001:db8::1", + "aria-label": "Custom IP Address", + }, + ), + ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 + """Initializes the form with provider fields.""" + super().__init__(*args, **kwargs) + + # Group providers by category + for category in ProviderCategory.choices: + providers = EmailProvider.objects.filter( + category=category[0], + active=True, + ) + + for provider in providers: + field_name = f"provider_{provider.id}" + self.fields[field_name] = forms.BooleanField( + required=False, + label=provider.name, + help_text=provider.mechanism_value, + ) + + def clean_custom_ip(self) -> str: + """Clean and validate the custom IP address. + + Returns: + str: The cleaned IP address or empty string + """ + ip = self.cleaned_data.get("custom_ip", "").strip() + if ip: + try: + # Determine if IPv4 or IPv6 and format accordingly + ip_obj = ipaddress.ip_address(ip) + except ValueError as exc: + msg = "Please enter a valid IP address" + raise ValidationError(msg) from exc + else: + if isinstance(ip_obj, ipaddress.IPv6Address): + return f"ip6:{ip}" + return f"ip4:{ip}" + return "" diff --git a/spf_generator/templates/spf_generator/generator.html b/spf_generator/templates/spf_generator/generator.html index ec91dab..c7fc79e 100644 --- a/spf_generator/templates/spf_generator/generator.html +++ b/spf_generator/templates/spf_generator/generator.html @@ -10,6 +10,7 @@

SPF Record Generator

+

Please note: I have made best efforts to ensure the information below is correct. But I highly recommend checking and validating the SPF record before updating it in your DNS. Please contact me if you spot any mistakes.

Select the email services that you use with your domain from the lists below.

@@ -45,6 +46,21 @@

{{ category_name }}

{% endfor %} +

Optionally, you can add the IP address of your own server to your SPF record. This is useful if you have a server that sends emails directly to the internet.

+ +
+
+

Custom Server

+
+ {{ form.custom_ip }} + {% if form.custom_ip.help_text %} + {{ form.custom_ip.help_text }} + {% endif %} + {% if form.custom_ip.errors %} + {{ form.custom_ip.errors.0 }} + {% endif %} +
+

Now select the policy you want to apply with this SPF record. This is an important choice and is explained below.

diff --git a/spf_generator/test_views.py b/spf_generator/test_views.py new file mode 100644 index 0000000..1f53e67 --- /dev/null +++ b/spf_generator/test_views.py @@ -0,0 +1,135 @@ +"""Basic tests for the views.py file.""" + +import pytest +from django.test import Client, RequestFactory +from django.urls import reverse +from spf_generator.models import EmailProvider, ProviderCategory, SpfMechanism +from spf_generator.views import generate_spf_record + + +@pytest.fixture +def client(): + return Client() + + +@pytest.fixture +def email_providers(): + """Create some test email providers.""" + providers = [ + EmailProvider.objects.create( + name="Google Workspace", + category=ProviderCategory.EMAIL_HOSTING, + mechanism_type=SpfMechanism.INCLUDE, + mechanism_value="_spf.google.com", + lookup_count=2, + priority=10, + ), + EmailProvider.objects.create( + name="SendGrid", + category=ProviderCategory.TRANSACTIONAL, + mechanism_type=SpfMechanism.INCLUDE, + mechanism_value="sendgrid.net", + lookup_count=1, + priority=20, + ), + EmailProvider.objects.create( + name="Office 365", + category=ProviderCategory.EMAIL_HOSTING, + mechanism_type=SpfMechanism.INCLUDE, + mechanism_value="spf.protection.outlook.com", + lookup_count=2, + priority=10, + ), + ] + return providers + + +@pytest.mark.django_db +class TestSpfGenerator: + def test_get_form(self, client): + """Test that the form loads correctly.""" + url = reverse("spf_generator:spf_generator") + response = client.get(url) + assert response.status_code == 200 + assert "form" in response.context + + def test_post_without_providers(self, client): + """Test submission without selecting any providers.""" + url = reverse("spf_generator:spf_generator") + response = client.post(url, {"all_mechanism": "-all"}) + assert b"Please select at least one email provider" in response.content + + def test_post_with_single_provider(self, client, email_providers): + """Test generating SPF record with a single provider.""" + url = reverse("spf_generator:spf_generator") + data = {"all_mechanism": "-all", f"provider_{email_providers[0].id}": True} + response = client.post(url, data) + assert response.status_code == 200 + assert b"include:_spf.google.com -all" in response.content + + def test_post_with_multiple_providers(self, client, email_providers): + """Test generating SPF record with multiple providers.""" + url = reverse("spf_generator:spf_generator") + data = { + "all_mechanism": "-all", + f"provider_{email_providers[0].id}": True, + f"provider_{email_providers[1].id}": True, + } + response = client.post(url, data) + assert response.status_code == 200 + # Check that both providers are in the SPF record + assert b"include:_spf.google.com" in response.content + assert b"include:sendgrid.net" in response.content + + def test_lookup_count_limit(self, client, email_providers): + """Test that the 10 lookup limit is enforced.""" + # Create a provider with 9 lookups + high_lookup_provider = EmailProvider.objects.create( + name="High Lookup Provider", + category=ProviderCategory.OTHER, + mechanism_type=SpfMechanism.INCLUDE, + mechanism_value="test.com", + lookup_count=9, + priority=30, + ) + + url = reverse("spf_generator:spf_generator") + data = { + "all_mechanism": "-all", + f"provider_{email_providers[0].id}": True, # 2 lookups + f"provider_{high_lookup_provider.id}": True, # 9 lookups + } + response = client.post(url, data) + assert b"exceeds maximum of 10" in response.content + + @pytest.mark.parametrize( + "mechanism,expected", + [ + ("-all", b"-all"), + ("~all", b"~all"), + ("?all", b"?all"), + ], + ) + def test_all_mechanism_options(self, client, email_providers, mechanism, expected): + """Test different 'all' mechanism options.""" + url = reverse("spf_generator:spf_generator") + data = { + "all_mechanism": mechanism, + f"provider_{email_providers[0].id}": True, + } + response = client.post(url, data) + assert response.status_code == 200 + assert expected in response.content + + def test_provider_ordering(self, client, email_providers): + """Test that providers are ordered by priority.""" + url = reverse("spf_generator:spf_generator") + data = { + "all_mechanism": "-all", + f"provider_{email_providers[0].id}": True, # Email hosting (priority 10) + f"provider_{email_providers[1].id}": True, # Transactional (priority 20) + } + response = client.post(url, data) + content = response.content.decode() + # Check that Google Workspace (priority 10) comes before SendGrid (priority 20) + assert content.index("_spf.google.com") < content.index("sendgrid.net") diff --git a/spf_generator/views.py b/spf_generator/views.py index 6c55e0e..f90fc4e 100644 --- a/spf_generator/views.py +++ b/spf_generator/views.py @@ -1,58 +1,12 @@ """Views for the SPF Generator app.""" -from typing import Any - -from django import forms from django.http import HttpRequest, HttpResponse from django.shortcuts import render +from spf_generator.forms import ProviderSelectForm from spf_generator.models import EmailProvider, ProviderCategory, SpfAllMechanism -class ProviderSelectForm(forms.Form): - """Form for selecting email providers. - - The form dynamically generates checkboxes for each active provider, - grouped by category. - """ - - all_mechanism = forms.ChoiceField( - choices=SpfAllMechanism.choices, - initial=SpfAllMechanism.FAIL, - required=True, - help_text=( - "

Choose how to handle mail from unlisted servers:

" - "
    " - "
  • Fail (-all): Recommended. Explicitly reject mail from unlisted servers. " - "Use this if you're sure you've listed all legitimate sending servers.
  • " - "
  • Softfail (~all): Suggest rejection but don't enforce it. " - "Useful during SPF testing or if you're unsure about all legitimate senders.
  • " - "
  • Neutral (?all): Take no position on unlisted servers. " - "Not recommended as it doesn't help prevent email spoofing.
  • " - "
" - ), - ) - - def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 - """Initializes the form with provider fields.""" - super().__init__(*args, **kwargs) - - # Group providers by category - for category in ProviderCategory.choices: - providers = EmailProvider.objects.filter( - category=category[0], - active=True, - ) - - for provider in providers: - field_name = f"provider_{provider.id}" - self.fields[field_name] = forms.BooleanField( - required=False, - label=provider.name, - help_text=provider.description, - ) - - def generate_spf_record(request: HttpRequest) -> HttpResponse: """View function for the SPF record generator. @@ -76,16 +30,16 @@ def generate_spf_record(request: HttpRequest) -> HttpResponse: provider = EmailProvider.objects.get(id=provider_id) selected_providers.append(provider) - # Check if any providers were selected - if not selected_providers: + # Check if either providers are selected or custom IP is provided + custom_ip = form.cleaned_data.get("custom_ip", "") + if not selected_providers and not custom_ip: response = render( request, "spf_generator/partials/error.html", - {"error": "Please select at least one email provider"}, + {"error": "Please select at least one email provider or enter a custom IP address"}, ) response["HX-Retarget"] = "#result" return response - # Check total lookup count total_lookups = sum(p.lookup_count for p in selected_providers) max_dns_lookups = 10 @@ -98,16 +52,23 @@ def generate_spf_record(request: HttpRequest) -> HttpResponse: response["HX-Retarget"] = "#result" return response - # Generate SPF record with selected 'all' mechanism - mechanisms = " ".join( + # Generate SPF record + mechanisms = [] + + # Add custom IP first if provided + if custom_ip: + mechanisms.append(custom_ip) + + # Add provider mechanisms + mechanisms.extend( p.get_mechanism() for p in sorted( selected_providers, key=lambda x: x.priority, ) ) - all_mechanism = SpfAllMechanism(form.cleaned_data["all_mechanism"]) - spf_record = f"v=spf1 {mechanisms} {all_mechanism}" + + spf_record = f"v=spf1 {' '.join(mechanisms)} {form.cleaned_data['all_mechanism']}" # Check record length max_record_length = 255 @@ -119,6 +80,8 @@ def generate_spf_record(request: HttpRequest) -> HttpResponse: headers={"HX-Retarget": "#result"}, ) + all_mechanism = SpfAllMechanism(form.cleaned_data["all_mechanism"]) + # Success - render SPF record response = render( request, @@ -132,11 +95,11 @@ def generate_spf_record(request: HttpRequest) -> HttpResponse: response["HX-Retarget"] = "#result" return response - # Form validation failed + # Form validation failed - must be the IP address if this point is reached response = render( request, "spf_generator/partials/error.html", - {"error": "Invalid form submission"}, + {"error": "Invalid form submission - please check if you've entered an invalid IP address"}, ) response["HX-Retarget"] = "#result" return response