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/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..cf4a06e
--- /dev/null
+++ b/spf_generator/management/commands/populate_spf_data.py
@@ -0,0 +1,254 @@
+"""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\n"
+ "https://support.google.com/a/answer/33786?sjid=13899028837607159847-AP#spf-value"
+ ),
+ },
+ {
+ "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.\n"
+ "https://learn.microsoft.com/en-us/defender-office-365/email-authentication-spf-configure"
+ ),
+ },
+ {
+ "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.\n"
+ "https://www.fastmail.help/hc/en-us/articles/360060591153-Manual-DNS-configuration"
+ ),
+ },
+ {
+ "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\nhttps://proton.me/support/anti-spoofing-custom-domain"
+ ),
+ },
+ {
+ "name": "Zoho Mail India",
+ "category": ProviderCategory.EMAIL_HOSTING,
+ "description": "Zoho Mail India hosting service",
+ "mechanism_type": SpfMechanism.INCLUDE,
+ "mechanism_value": "zoho.in",
+ "lookup_count": 1,
+ "priority": 10,
+ "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",
+ },
+ ]
+
+ # 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\n"
+ "https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-spf.html"
+ ),
+ },
+ {
+ "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\n"
+ "https://www.twilio.com/docs/sendgrid/ui/account-and-settings/spf-records"
+ ),
+ },
+ {
+ "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.\n"
+ "https://help.mailgun.com/hc/en-us/articles/360026833053-Domain-Verification-Setup-Guide"
+ ),
+ },
+ {
+ "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\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": "MailJet",
+ "category": ProviderCategory.TRANSACTIONAL,
+ "description": "MailJet email delivery service",
+ "mechanism_type": SpfMechanism.INCLUDE,
+ "mechanism_value": "spf.mailjet.com",
+ "lookup_count": 1,
+ "priority": 20,
+ "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": "Zendesk",
+ "category": ProviderCategory.OTHER,
+ "description": "Zendesk helpdesk service",
+ "mechanism_type": SpfMechanism.INCLUDE,
+ "mechanism_value": "mail.zendesk.com",
+ "lookup_count": 40,
+ "priority": 30,
+ "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": "Freshdesk",
+ "category": ProviderCategory.OTHER,
+ "description": "Freshdesk helpdesk service",
+ "mechanism_type": SpfMechanism.INCLUDE,
+ "mechanism_value": "email.freshdesk.com",
+ "lookup_count": 40,
+ "priority": 30,
+ "notes": (
+ "For Freshdesk customers.\n"
+ "https://support.freshdesk.com/support/solutions/articles/43170-creating-an-spf-record-to-ensure-proper-email-delivery"
+ ),
+ },
+ ]
+
+ # 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..f5d009e
--- /dev/null
+++ b/spf_generator/models.py
@@ -0,0 +1,165 @@
+"""Models for the spf_generator app."""
+
+from typing import ClassVar
+
+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.
+
+ 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..c7fc79e
--- /dev/null
+++ b/spf_generator/templates/spf_generator/generator.html
@@ -0,0 +1,125 @@
+{% extends "base.html" %}
+{% load spf_generator_filters %}
+
+{% block head %}
+
+{% endblock head %}
+
+{% block content %}
+
+
+
+
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.
+
+
+
+
+
+
+
+
+{% 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
new file mode 100644
index 0000000..2d8258a
--- /dev/null
+++ b/spf_generator/templates/spf_generator/partials/error.html
@@ -0,0 +1,9 @@
+
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..eca1a73
--- /dev/null
+++ b/spf_generator/templates/spf_generator/partials/result.html
@@ -0,0 +1,19 @@
+
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/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/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..f90fc4e
--- /dev/null
+++ b/spf_generator/views.py
@@ -0,0 +1,115 @@
+"""Views for the SPF Generator app."""
+
+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
+
+
+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 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 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
+ 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 = []
+
+ # 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,
+ )
+ )
+
+ spf_record = f"v=spf1 {' '.join(mechanisms)} {form.cleaned_data['all_mechanism']}"
+
+ # 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"},
+ )
+
+ all_mechanism = SpfAllMechanism(form.cleaned_data["all_mechanism"])
+
+ # Success - render SPF record
+ response = render(
+ request,
+ "spf_generator/partials/result.html",
+ {
+ "spf_record": spf_record,
+ "all_mechanism": all_mechanism.label,
+ "all_mechanism_description": all_mechanism.description,
+ },
+ )
+ response["HX-Retarget"] = "#result"
+ return response
+
+ # 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 - please check if you've entered an invalid IP address"},
+ )
+ 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)
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 %}