Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/central_command/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"persistence",
"baby_serverlist",
"drf_spectacular",
"mail_tools",
]

# What user model to use for authentication?
Expand Down
1 change: 1 addition & 0 deletions src/central_command/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
]
1 change: 1 addition & 0 deletions src/mail_tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
default_app_config = "mail_tools.apps.MailToolsConfig"
10 changes: 10 additions & 0 deletions src/mail_tools/apps.py
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions src/mail_tools/forms.py
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions src/mail_tools/registry.py
Original file line number Diff line number Diff line change
@@ -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": "<p>We wanted to let you know that scheduled maintenance will occur on <strong>Saturday at 18:00 UTC</strong>. Servers may be unavailable for roughly 30 minutes.</p><p>Thanks for your patience!</p>",
},
allow_broadcast=True,
)
)
143 changes: 143 additions & 0 deletions src/mail_tools/templates/mail_tools/broadcast.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Broadcast email</title>
<style>
:root {
color-scheme: light;
}
body { font-family: "Segoe UI", Arial, sans-serif; background-color: #f4f6fb; margin: 0; padding: 32px; }
.container { max-width: 820px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 32px; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); }
h1 { margin-top: 0; color: #111827; }
.field { margin-bottom: 22px; }
label { display: block; font-weight: 600; margin-bottom: 6px; color: #111827; }
input[type="text"], select { width: 100%; padding: 10px 12px; border: 1px solid #cbd5f5; border-radius: 8px; font-size: 14px; }
select[multiple] { min-height: 220px; }
.errorlist { margin: 4px 0 0; padding: 0; list-style: none; color: #b91c1c; font-size: 13px; }
button[type="submit"] { background-color: #2563eb; color: #fff; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; cursor: pointer; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
a { color: #2563eb; text-decoration: none; }
.editor-toolbar { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.editor-toolbar button { background: #e0e7ff; border: 1px solid #c7d2fe; color: #1e1b4b; padding: 6px 12px; border-radius: 6px; font-size: 13px; cursor: pointer; }
.editor-toolbar button:hover { background: #c7d2fe; }
.rich-editor { border: 1px solid #cbd5f5; border-radius: 10px; padding: 12px; min-height: 200px; line-height: 1.5; font-size: 15px; background: #f8fafc; }
.rich-editor:focus { outline: 2px solid #6366f1; background: #fff; }
.rich-editor:empty:before { content: attr(data-placeholder); color: #94a3b8; }
.search-input { width: 100%; padding: 8px 10px; border: 1px solid #cbd5f5; border-radius: 6px; margin-bottom: 10px; font-size: 14px; }
small { color: #6b7280; display: block; margin-top: 4px; }
form .actions { margin-top: 28px; }
</style>
</head>
<body>
<div class="container">
<h1>Broadcast email</h1>
<p>Pick a template and send it to selected accounts. Only templates explicitly allowed for broadcasting will appear here.</p>
<p><a href="{% url 'mail_tools:list' %}">&larr; Back to templates</a></p>

<form method="post" id="broadcast-form">
{% csrf_token %}
<div class="field">
{{ form.template_slug.label_tag }}
{{ form.template_slug }}
{{ form.template_slug.errors }}
</div>

<div class="field">
{{ form.subject.label_tag }}
{{ form.subject }}
{{ form.subject.errors }}
</div>

<div class="field" id="editor-field">
<label for="rich-editor">Message body</label>
<noscript><small>Rich text editing requires JavaScript. Please enable it to compose a message.</small></noscript>
<div class="editor-toolbar" aria-label="Formatting toolbar">
<button type="button" data-command="bold"><strong>B</strong></button>
<button type="button" data-command="italic"><em>I</em></button>
<button type="button" data-command="insertUnorderedList">• List</button>
<button type="button" data-command="createLink">Link</button>
<button type="button" data-command="removeFormat">Clear</button>
</div>
<div id="rich-editor" class="rich-editor" contenteditable="true" data-placeholder="Compose your optional message..."></div>
{{ form.body_html }}
{% if form.body_html.help_text %}
<small>{{ form.body_html.help_text }}</small>
{% endif %}
{{ form.body_html.errors }}
</div>

<div class="field">
<label for="recipient-filter">Recipients</label>
<input type="search" id="recipient-filter" class="search-input" placeholder="Filter recipients by email or username…" autocomplete="off">
{{ form.recipients }}
<small>Hold Ctrl/Cmd to select multiple recipients.</small>
{{ form.recipients.errors }}
</div>

{% if form.non_field_errors %}
<ul class="errorlist">
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}

<div class="actions">
<button type="submit">Send emails</button>
</div>
</form>
</div>

<script>
(function () {
const form = document.getElementById("broadcast-form");
const editor = document.getElementById("rich-editor");
const hiddenInput = document.getElementById("id_body_html");

const initial = hiddenInput.value || "";
if (initial) {
editor.innerHTML = initial;
}

const syncHidden = () => {
hiddenInput.value = editor.innerHTML.trim();
};

editor.addEventListener("input", syncHidden);
form.addEventListener("submit", syncHidden);

document.querySelector(".editor-toolbar").addEventListener("click", (event) => {
const button = event.target.closest("button[data-command]");
if (!button) {
return;
}
event.preventDefault();
const command = button.dataset.command;
if (command === "createLink") {
const url = prompt("Enter the URL");
if (url) {
document.execCommand("createLink", false, url);
}
} else {
document.execCommand(command, false, null);
}
editor.focus();
syncHidden();
});

const filterInput = document.getElementById("recipient-filter");
const recipientSelect = document.getElementById("id_recipients");

filterInput.addEventListener("input", () => {
const term = filterInput.value.trim().toLowerCase();
Array.from(recipientSelect.options).forEach((option) => {
const text = option.text.toLowerCase();
const value = option.value.toLowerCase();
option.hidden = term && !text.includes(term) && !value.includes(term);
});
});
})();
</script>
</body>
</html>
39 changes: 39 additions & 0 deletions src/mail_tools/templates/mail_tools/list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Mail templates</title>
<style>
body { font-family: "Segoe UI", Arial, sans-serif; background-color: #f4f6fb; margin: 0; padding: 32px; }
.container { max-width: 800px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 32px; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.12); }
h1 { margin-top: 0; color: #111827; }
ul { list-style: none; padding: 0; }
li { border: 1px solid #e5e7eb; border-radius: 10px; margin-bottom: 16px; padding: 16px; display: flex; justify-content: space-between; align-items: center; }
a { text-decoration: none; color: #2563eb; font-weight: 600; }
.badge { font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.08em; }
.actions { display: flex; gap: 12px; }
</style>
</head>
<body>
<div class="container">
<h1>Mail template previews</h1>
<p>Select a template to render it with its sample context. Use the broadcast page to send informational emails.</p>
<p><a href="{% url 'mail_tools:broadcast' %}">Go to broadcast composer</a></p>
<ul>
{% for preview in previews %}
<li>
<div>
<div>{{ preview.label }}</div>
<div class="badge">{{ preview.slug }}</div>
</div>
<div class="actions">
<a href="{% url 'mail_tools:detail' preview.slug %}">Preview</a>
</div>
</li>
{% empty %}
<li>No templates registered.</li>
{% endfor %}
</ul>
</div>
</body>
</html>
11 changes: 11 additions & 0 deletions src/mail_tools/urls.py
Original file line number Diff line number Diff line change
@@ -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/<slug:slug>/", views.preview_detail, name="detail"),
path("broadcast/", views.broadcast_email, name="broadcast"),
]
Loading
Loading