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/justfile b/justfile index 78f04a2..bf98aef 100644 --- a/justfile +++ b/justfile @@ -83,3 +83,11 @@ 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}} + +# Generic manage command +@manage ARGS="": + {{uvr}} manage.py {{ARGS}} 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/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:

" + "" + ), + ) + + 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.

+
+ +
+ {% 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 %} +
+ +

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.

+ +
+
+

Default Policy

+
+
+
+ {{ form.all_mechanism }} + {% if form.all_mechanism.help_text %} + {{ form.all_mechanism.help_text|safe }} + {% endif %} +
+
+
+ + +
+ + +
+
+ +{% 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 @@ + +
+
+ +

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..eca1a73 --- /dev/null +++ b/spf_generator/templates/spf_generator/partials/result.html @@ -0,0 +1,19 @@ + +
+
+ +

Your SPF Record

+
+
{{ spf_record }}
+ +
+
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 %}