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 @@ + + + + + Broadcast email + + + +
+

Broadcast email

+

Pick a template and send it to selected accounts. Only templates explicitly allowed for broadcasting will appear here.

+

← Back to templates

+ +
+ {% csrf_token %} +
+ {{ form.template_slug.label_tag }} + {{ form.template_slug }} + {{ form.template_slug.errors }} +
+ +
+ {{ form.subject.label_tag }} + {{ form.subject }} + {{ form.subject.errors }} +
+ +
+ + +
+ + + + + +
+
+ {{ form.body_html }} + {% if form.body_html.help_text %} + {{ form.body_html.help_text }} + {% endif %} + {{ form.body_html.errors }} +
+ +
+ + + {{ form.recipients }} + Hold Ctrl/Cmd to select multiple recipients. + {{ form.recipients.errors }} +
+ + {% if form.non_field_errors %} + + {% endif %} + +
+ +
+
+
+ + + + diff --git a/src/mail_tools/templates/mail_tools/list.html b/src/mail_tools/templates/mail_tools/list.html new file mode 100644 index 0000000..4025e86 --- /dev/null +++ b/src/mail_tools/templates/mail_tools/list.html @@ -0,0 +1,39 @@ + + + + + Mail templates + + + +
+

Mail template previews

+

Select a template to render it with its sample context. Use the broadcast page to send informational emails.

+

Go to broadcast composer

+ +
+ + diff --git a/src/mail_tools/urls.py b/src/mail_tools/urls.py new file mode 100644 index 0000000..11bb3a7 --- /dev/null +++ b/src/mail_tools/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + +app_name = "mail_tools" + +urlpatterns = [ + path("", views.preview_list, name="list"), + path("preview//", views.preview_detail, name="detail"), + path("broadcast/", views.broadcast_email, name="broadcast"), +] diff --git a/src/mail_tools/views.py b/src/mail_tools/views.py new file mode 100644 index 0000000..7062420 --- /dev/null +++ b/src/mail_tools/views.py @@ -0,0 +1,60 @@ +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import redirect, render +from django.template.response import TemplateResponse +from django.urls import reverse + +from accounts.models import Account +from commons.mail_wrapper import send_email_with_template + +from .forms import BroadcastEmailForm +from .registry import all_previews, get_preview + + +@staff_member_required +def preview_list(request: HttpRequest) -> HttpResponse: + return render(request, "mail_tools/list.html", {"previews": list(all_previews())}) + + +@staff_member_required +def preview_detail(request: HttpRequest, slug: str) -> HttpResponse: + try: + preview = get_preview(slug) + except LookupError as exc: + raise Http404(str(exc)) + + context = {"__preview": preview, **preview.context} + return TemplateResponse(request, preview.template_name, context) + + +@staff_member_required +def broadcast_email(request: HttpRequest) -> HttpResponse: + form = BroadcastEmailForm(request.POST or None) + + if request.method == "POST" and form.is_valid(): + preview = form.get_preview() + body_override = form.cleaned_data.get("body_html") + subject = form.cleaned_data["subject"] + recipients = form.cleaned_data["recipients"] + + _send_broadcast(preview, subject, body_override, recipients) + + messages.success(request, f"Queued email using '{preview.label}' for {recipients.count()} recipient(s).") + return redirect(reverse("mail_tools:broadcast")) + + return render( + request, + "mail_tools/broadcast.html", + { + "form": form, + }, + ) + + +def _send_broadcast(preview, subject: str, body_override: str | None, recipients) -> None: + for account in recipients: + if not isinstance(account, Account): + continue + context = preview.build_context(user_name=account.username, overrides={"body_html": body_override}) + send_email_with_template(account.email, subject, preview.template_name, context) diff --git a/src/templates/base_email.html b/src/templates/base_email.html new file mode 100644 index 0000000..19c3819 --- /dev/null +++ b/src/templates/base_email.html @@ -0,0 +1,45 @@ + + + + + + + {% block title %}Unitystation Notification{% endblock %} + + + + + + +
+ + + + + + + +
+ {% block badge_text %}Notification{% endblock %} +

{% block header_title %}Unitystation Update{% endblock %}

+

{% block header_subtitle %}{% endblock %}

+
+
+ {% block body_content %}{% endblock %} + {% block supplemental %}{% endblock %} +

{% block signature %}The Unitystation Team{% endblock %}

+
+
+ + + + +
+ {% block footer_note %} + This is an automated email; please do not reply. Contact us at + info@unitystation.org if you need assistance. + {% endblock %} +
+
+ + diff --git a/src/templates/confirm_template.html b/src/templates/confirm_template.html index 24d129f..405a0a3 100644 --- a/src/templates/confirm_template.html +++ b/src/templates/confirm_template.html @@ -1,48 +1,30 @@ - - - - Welcome to Unitystation - Account Confirmation - - - -
-

Welcome to Unitystation, {{ user_name }}!

-

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:

-

Confirm my account

-

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

-
- - - +{% extends "base_email.html" %} + +{% block title %}Welcome to Unitystation - Account Confirmation{% endblock %} + +{% block badge_text %}Action Required{% endblock %} +{% block header_title %}Welcome aboard, {{ user_name }}.{% endblock %} +{% block header_subtitle %}Please confirm your Unitystation account to finish the signup.{% endblock %} + +{% block body_content %} +

Hello {{ user_name }},

+

We're thrilled you're joining the crew. Click below to verify your email and access your account.

+

+ Confirm my account +

+{% endblock %} + +{% block supplemental %} + +{% endblock %} + +{% block signature %} +Thank you for joining Unitystation,
The Unitystation Team +{% endblock %} diff --git a/src/templates/info_template.html b/src/templates/info_template.html new file mode 100644 index 0000000..a723e42 --- /dev/null +++ b/src/templates/info_template.html @@ -0,0 +1,28 @@ +{% extends "base_email.html" %} + +{% block title %}Unitystation - Information{% endblock %} + +{% block header_cell_style %}padding:28px 32px 16px;background:linear-gradient(135deg,#f0f9ff 0%,#dbeafe 100%);border-bottom:1px solid #dbeafe;{% endblock %} +{% block badge_style %}display:inline-block;padding:4px 12px;font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:600;color:#0f172a;background-color:#bfdbfe;border-radius:999px;{% endblock %} +{% block subtitle_style %}margin:0;color:#1f2937;font-size:15px;{% endblock %} +{% block body_text_style %}font-size:15px;color:#0f172a;{% endblock %} +{% block badge_text %}Information{% endblock %} +{% block header_title %}Hello {{ user_name }},{% endblock %} + +{% block body_content %} +
+ {{ body_html|safe }} +
+{% endblock %} + +{% block supplemental %} +

+ This email is for your information only, no action is required. + If you have questions, visit our + Discord. +

+{% endblock %} + +{% block signature %} +Warm regards,
The Unitystation Team +{% endblock %} diff --git a/src/templates/password_reset.html b/src/templates/password_reset.html index 9a99bb8..40a04f7 100644 --- a/src/templates/password_reset.html +++ b/src/templates/password_reset.html @@ -1,46 +1,31 @@ - - - - Unitystation - Password Reset - - - -
-

Password Reset Request

-

Reset password here

-

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

-
- - - +{% extends "base_email.html" %} + +{% block title %}Unitystation - Password Reset{% endblock %} + +{% block badge_text %}Action Required{% endblock %} +{% block header_title %}Password reset requested{% endblock %} +{% block header_subtitle %}Use the secure link below to set a new password.{% endblock %} + +{% block body_content %} +

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 %} + +{% endblock %} + +{% block signature %} +Stay safe,
The Unitystation Team +{% endblock %}