diff --git a/src/central_command/settings.py b/src/central_command/settings.py index 91431f4..69cf6c4 100644 --- a/src/central_command/settings.py +++ b/src/central_command/settings.py @@ -53,6 +53,7 @@ "persistence", "baby_serverlist", "drf_spectacular", + "mail_tools", ] # What user model to use for authentication? diff --git a/src/central_command/urls.py b/src/central_command/urls.py index fd3c277..3b5ecb0 100644 --- a/src/central_command/urls.py +++ b/src/central_command/urls.py @@ -26,4 +26,5 @@ path("accounts/", include("accounts.api.urls", "Accounts API")), path("persistence/", include("persistence.api.urls")), path("baby-serverlist/", include("baby_serverlist.api.urls")), + path("mail-tools/", include("mail_tools.urls")), ] diff --git a/src/mail_tools/__init__.py b/src/mail_tools/__init__.py new file mode 100644 index 0000000..ca1706c --- /dev/null +++ b/src/mail_tools/__init__.py @@ -0,0 +1 @@ +default_app_config = "mail_tools.apps.MailToolsConfig" diff --git a/src/mail_tools/apps.py b/src/mail_tools/apps.py new file mode 100644 index 0000000..93ded32 --- /dev/null +++ b/src/mail_tools/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class MailToolsConfig(AppConfig): + name = "mail_tools" + verbose_name = "Mail Tools" + + def ready(self) -> None: + # Ensure registry entries are loaded on startup. + from . import registry # noqa: F401 diff --git a/src/mail_tools/forms.py b/src/mail_tools/forms.py new file mode 100644 index 0000000..1f3905e --- /dev/null +++ b/src/mail_tools/forms.py @@ -0,0 +1,52 @@ +from collections.abc import Iterable +from typing import cast + +from django import forms + +from accounts.models import Account + +from .registry import TemplatePreview, broadcastable_previews, get_preview + + +def _preview_choices() -> Iterable[tuple[str, str]]: + return [(preview.slug, preview.label) for preview in broadcastable_previews()] + + +class BroadcastEmailForm(forms.Form): + template_slug = forms.ChoiceField(label="Template") + subject = forms.CharField(label="Subject", max_length=120) + body_html = forms.CharField( + label="Body (HTML)", + widget=forms.HiddenInput(), + required=False, + help_text="Optional rich text body injected into the template (used by the info template).", + ) + recipients = forms.ModelMultipleChoiceField( + label="Recipients", + queryset=Account.objects.none(), + widget=forms.SelectMultiple(attrs={"size": 12}), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + template_field = cast(forms.ChoiceField, self.fields["template_slug"]) + template_field.choices = list(_preview_choices()) + recipients_field = cast(forms.ModelMultipleChoiceField, self.fields["recipients"]) + recipients_field.queryset = Account.objects.order_by("email") + self.preview: TemplatePreview | None = None + + def clean_template_slug(self) -> str: + slug = self.cleaned_data["template_slug"] + try: + preview = get_preview(slug) + except LookupError as exc: + raise forms.ValidationError(str(exc)) + if not preview.allow_broadcast: + raise forms.ValidationError("This template cannot be used for manual sending.") + self.preview = preview + return slug + + def get_preview(self) -> TemplatePreview: + if self.preview is None: + raise ValueError("Preview not set. Did you call is_valid()?") + return self.preview diff --git a/src/mail_tools/registry.py b/src/mail_tools/registry.py new file mode 100644 index 0000000..56547b1 --- /dev/null +++ b/src/mail_tools/registry.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from collections.abc import Iterable +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Optional + +from django.conf import settings + + +@dataclass(frozen=True) +class TemplatePreview: + slug: str + label: str + template_name: str + context: dict[str, Any] + allow_broadcast: bool = False + + def build_context( + self, + *, + user_name: Optional[str] = None, + overrides: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + data = deepcopy(self.context) + if user_name: + data["user_name"] = user_name + if overrides: + data.update({k: v for k, v in overrides.items() if v not in (None, "")}) + return data + + +_registry: dict[str, TemplatePreview] = {} + + +def register(preview: TemplatePreview) -> None: + if preview.slug in _registry: + raise ValueError(f"Duplicate mail preview slug '{preview.slug}'") + _registry[preview.slug] = preview + + +def all_previews() -> Iterable[TemplatePreview]: + return _registry.values() + + +def broadcastable_previews() -> Iterable[TemplatePreview]: + return (preview for preview in _registry.values() if preview.allow_broadcast) + + +def get_preview(slug: str) -> TemplatePreview: + try: + return _registry[slug] + except KeyError as exc: + raise LookupError(f"No mail preview registered for '{slug}'") from exc + + +register( + TemplatePreview( + slug="confirm-account", + label="Account confirmation", + template_name="confirm_template.html", + context={ + "user_name": "Alex Crew", + "link": f"{settings.ACCOUNT_CONFIRMATION_URL}?token=example-token", + }, + ) +) + +register( + TemplatePreview( + slug="password-reset", + label="Password reset", + template_name="password_reset.html", + context={ + "user_name": "Alex Crew", + "link": f"{settings.PASS_RESET_URL}?token=example-token", + }, + ) +) + +register( + TemplatePreview( + slug="info", + label="Informational message", + template_name="info_template.html", + context={ + "user_name": "Alex Crew", + "body_html": "
We wanted to let you know that scheduled maintenance will occur on Saturday at 18:00 UTC. Servers may be unavailable for roughly 30 minutes.
Thanks for your patience!
", + }, + allow_broadcast=True, + ) +) diff --git a/src/mail_tools/templates/mail_tools/broadcast.html b/src/mail_tools/templates/mail_tools/broadcast.html new file mode 100644 index 0000000..9476164 --- /dev/null +++ b/src/mail_tools/templates/mail_tools/broadcast.html @@ -0,0 +1,143 @@ + + + + +Pick a template and send it to selected accounts. Only templates explicitly allowed for broadcasting will appear here.
+ + + +Select a template to render it with its sample context. Use the broadcast page to send informational emails.
+ +
+
+
|
+
Hello {{ user_name }},
-We're excited to have you on board! To get started with your new account, please confirm your email address by clicking the button below:
- -If you did not initiate this request, please disregard this email, or contact us for support if you feel this is an error.
-Need help? Join our community on Discord for support and interaction.
-Thank you for joining us,
-The Unitystation Team
-Hello {{ user_name }},
+We're thrilled you're joining the crew. Click below to verify your email and access your account.
+ +{% endblock %} + +{% block supplemental %} +{{ link }}
+ + This email is for your information only, no action is required. + If you have questions, visit our + Discord. +
+{% endblock %} + +{% block signature %} +Warm regards,If you did not initiate this request, please disregard this email, or contact us for support if you feel this is an error.
-Need help? Join our community on Discord for support and interaction.
-Thank you for playing unitystation,
-The Unitystation Team
-Hi there,
+We received a request to reset the password for your Unitystation account. If this was you, click the button below to continue.
++ Reset password +
+{% endblock %} + +{% block supplemental %} +{{ link }}
+