Skip to content

Commit

Permalink
Merge pull request #140 from stuartmaxwell:spf-records-mvp
Browse files Browse the repository at this point in the history
Spf-records-mvp
  • Loading branch information
stuartmaxwell authored Nov 18, 2024
2 parents 53c217f + 490fbe7 commit be580b7
Show file tree
Hide file tree
Showing 22 changed files with 1,138 additions and 0 deletions.
1 change: 1 addition & 0 deletions config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"timezone_converter",
"markdown_editor",
"shell",
"spf_generator",
]

if DEBUG:
Expand Down
1 change: 1 addition & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
]
8 changes: 8 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
1 change: 1 addition & 0 deletions spf_generator/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""SPF Generator App."""
98 changes: 98 additions & 0 deletions spf_generator/admin.py
Original file line number Diff line number Diff line change
@@ -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(
"<code>{}</code>",
obj.get_mechanism(),
)
10 changes: 10 additions & 0 deletions spf_generator/apps.py
Original file line number Diff line number Diff line change
@@ -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"
104 changes: 104 additions & 0 deletions spf_generator/forms.py
Original file line number Diff line number Diff line change
@@ -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=(
"<p>Choose how to handle mail from unlisted servers:</p>"
"<ul>"
"<li><strong>Fail (-all)</strong>: Recommended. Explicitly reject mail from unlisted servers. "
"Use this if you're sure you've listed all legitimate sending servers.</li>"
"<li><strong>Softfail (~all)</strong>: Suggest rejection but don't enforce it. "
"Useful during SPF testing or if you're unsure about all legitimate senders.</li>"
"<li><strong>Neutral (?all)</strong>: Take no position on unlisted servers. "
"Not recommended as it doesn't help prevent email spoofing.</li>"
"</ul>"
),
)

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 ""
1 change: 1 addition & 0 deletions spf_generator/management/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Management commands for the SPF generator."""
Loading

0 comments on commit be580b7

Please sign in to comment.