Skip to content

Commit 35ad8ab

Browse files
authored
feat: add new mail tools for admining (#107)
1 parent 9d94c7b commit 35ad8ab

File tree

14 files changed

+544
-94
lines changed

14 files changed

+544
-94
lines changed

src/central_command/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"persistence",
5454
"baby_serverlist",
5555
"drf_spectacular",
56+
"mail_tools",
5657
]
5758

5859
# What user model to use for authentication?

src/central_command/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@
2626
path("accounts/", include("accounts.api.urls", "Accounts API")),
2727
path("persistence/", include("persistence.api.urls")),
2828
path("baby-serverlist/", include("baby_serverlist.api.urls")),
29+
path("mail-tools/", include("mail_tools.urls")),
2930
]

src/mail_tools/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
default_app_config = "mail_tools.apps.MailToolsConfig"

src/mail_tools/apps.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django.apps import AppConfig
2+
3+
4+
class MailToolsConfig(AppConfig):
5+
name = "mail_tools"
6+
verbose_name = "Mail Tools"
7+
8+
def ready(self) -> None:
9+
# Ensure registry entries are loaded on startup.
10+
from . import registry # noqa: F401

src/mail_tools/forms.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from collections.abc import Iterable
2+
from typing import cast
3+
4+
from django import forms
5+
6+
from accounts.models import Account
7+
8+
from .registry import TemplatePreview, broadcastable_previews, get_preview
9+
10+
11+
def _preview_choices() -> Iterable[tuple[str, str]]:
12+
return [(preview.slug, preview.label) for preview in broadcastable_previews()]
13+
14+
15+
class BroadcastEmailForm(forms.Form):
16+
template_slug = forms.ChoiceField(label="Template")
17+
subject = forms.CharField(label="Subject", max_length=120)
18+
body_html = forms.CharField(
19+
label="Body (HTML)",
20+
widget=forms.HiddenInput(),
21+
required=False,
22+
help_text="Optional rich text body injected into the template (used by the info template).",
23+
)
24+
recipients = forms.ModelMultipleChoiceField(
25+
label="Recipients",
26+
queryset=Account.objects.none(),
27+
widget=forms.SelectMultiple(attrs={"size": 12}),
28+
)
29+
30+
def __init__(self, *args, **kwargs):
31+
super().__init__(*args, **kwargs)
32+
template_field = cast(forms.ChoiceField, self.fields["template_slug"])
33+
template_field.choices = list(_preview_choices())
34+
recipients_field = cast(forms.ModelMultipleChoiceField, self.fields["recipients"])
35+
recipients_field.queryset = Account.objects.order_by("email")
36+
self.preview: TemplatePreview | None = None
37+
38+
def clean_template_slug(self) -> str:
39+
slug = self.cleaned_data["template_slug"]
40+
try:
41+
preview = get_preview(slug)
42+
except LookupError as exc:
43+
raise forms.ValidationError(str(exc))
44+
if not preview.allow_broadcast:
45+
raise forms.ValidationError("This template cannot be used for manual sending.")
46+
self.preview = preview
47+
return slug
48+
49+
def get_preview(self) -> TemplatePreview:
50+
if self.preview is None:
51+
raise ValueError("Preview not set. Did you call is_valid()?")
52+
return self.preview

src/mail_tools/registry.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
from copy import deepcopy
5+
from dataclasses import dataclass
6+
from typing import Any, Optional
7+
8+
from django.conf import settings
9+
10+
11+
@dataclass(frozen=True)
12+
class TemplatePreview:
13+
slug: str
14+
label: str
15+
template_name: str
16+
context: dict[str, Any]
17+
allow_broadcast: bool = False
18+
19+
def build_context(
20+
self,
21+
*,
22+
user_name: Optional[str] = None,
23+
overrides: Optional[dict[str, Any]] = None,
24+
) -> dict[str, Any]:
25+
data = deepcopy(self.context)
26+
if user_name:
27+
data["user_name"] = user_name
28+
if overrides:
29+
data.update({k: v for k, v in overrides.items() if v not in (None, "")})
30+
return data
31+
32+
33+
_registry: dict[str, TemplatePreview] = {}
34+
35+
36+
def register(preview: TemplatePreview) -> None:
37+
if preview.slug in _registry:
38+
raise ValueError(f"Duplicate mail preview slug '{preview.slug}'")
39+
_registry[preview.slug] = preview
40+
41+
42+
def all_previews() -> Iterable[TemplatePreview]:
43+
return _registry.values()
44+
45+
46+
def broadcastable_previews() -> Iterable[TemplatePreview]:
47+
return (preview for preview in _registry.values() if preview.allow_broadcast)
48+
49+
50+
def get_preview(slug: str) -> TemplatePreview:
51+
try:
52+
return _registry[slug]
53+
except KeyError as exc:
54+
raise LookupError(f"No mail preview registered for '{slug}'") from exc
55+
56+
57+
register(
58+
TemplatePreview(
59+
slug="confirm-account",
60+
label="Account confirmation",
61+
template_name="confirm_template.html",
62+
context={
63+
"user_name": "Alex Crew",
64+
"link": f"{settings.ACCOUNT_CONFIRMATION_URL}?token=example-token",
65+
},
66+
)
67+
)
68+
69+
register(
70+
TemplatePreview(
71+
slug="password-reset",
72+
label="Password reset",
73+
template_name="password_reset.html",
74+
context={
75+
"user_name": "Alex Crew",
76+
"link": f"{settings.PASS_RESET_URL}?token=example-token",
77+
},
78+
)
79+
)
80+
81+
register(
82+
TemplatePreview(
83+
slug="info",
84+
label="Informational message",
85+
template_name="info_template.html",
86+
context={
87+
"user_name": "Alex Crew",
88+
"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>",
89+
},
90+
allow_broadcast=True,
91+
)
92+
)
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Broadcast email</title>
6+
<style>
7+
:root {
8+
color-scheme: light;
9+
}
10+
body { font-family: "Segoe UI", Arial, sans-serif; background-color: #f4f6fb; margin: 0; padding: 32px; }
11+
.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); }
12+
h1 { margin-top: 0; color: #111827; }
13+
.field { margin-bottom: 22px; }
14+
label { display: block; font-weight: 600; margin-bottom: 6px; color: #111827; }
15+
input[type="text"], select { width: 100%; padding: 10px 12px; border: 1px solid #cbd5f5; border-radius: 8px; font-size: 14px; }
16+
select[multiple] { min-height: 220px; }
17+
.errorlist { margin: 4px 0 0; padding: 0; list-style: none; color: #b91c1c; font-size: 13px; }
18+
button[type="submit"] { background-color: #2563eb; color: #fff; border: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; cursor: pointer; }
19+
button:disabled { opacity: 0.6; cursor: not-allowed; }
20+
a { color: #2563eb; text-decoration: none; }
21+
.editor-toolbar { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
22+
.editor-toolbar button { background: #e0e7ff; border: 1px solid #c7d2fe; color: #1e1b4b; padding: 6px 12px; border-radius: 6px; font-size: 13px; cursor: pointer; }
23+
.editor-toolbar button:hover { background: #c7d2fe; }
24+
.rich-editor { border: 1px solid #cbd5f5; border-radius: 10px; padding: 12px; min-height: 200px; line-height: 1.5; font-size: 15px; background: #f8fafc; }
25+
.rich-editor:focus { outline: 2px solid #6366f1; background: #fff; }
26+
.rich-editor:empty:before { content: attr(data-placeholder); color: #94a3b8; }
27+
.search-input { width: 100%; padding: 8px 10px; border: 1px solid #cbd5f5; border-radius: 6px; margin-bottom: 10px; font-size: 14px; }
28+
small { color: #6b7280; display: block; margin-top: 4px; }
29+
form .actions { margin-top: 28px; }
30+
</style>
31+
</head>
32+
<body>
33+
<div class="container">
34+
<h1>Broadcast email</h1>
35+
<p>Pick a template and send it to selected accounts. Only templates explicitly allowed for broadcasting will appear here.</p>
36+
<p><a href="{% url 'mail_tools:list' %}">&larr; Back to templates</a></p>
37+
38+
<form method="post" id="broadcast-form">
39+
{% csrf_token %}
40+
<div class="field">
41+
{{ form.template_slug.label_tag }}
42+
{{ form.template_slug }}
43+
{{ form.template_slug.errors }}
44+
</div>
45+
46+
<div class="field">
47+
{{ form.subject.label_tag }}
48+
{{ form.subject }}
49+
{{ form.subject.errors }}
50+
</div>
51+
52+
<div class="field" id="editor-field">
53+
<label for="rich-editor">Message body</label>
54+
<noscript><small>Rich text editing requires JavaScript. Please enable it to compose a message.</small></noscript>
55+
<div class="editor-toolbar" aria-label="Formatting toolbar">
56+
<button type="button" data-command="bold"><strong>B</strong></button>
57+
<button type="button" data-command="italic"><em>I</em></button>
58+
<button type="button" data-command="insertUnorderedList">• List</button>
59+
<button type="button" data-command="createLink">Link</button>
60+
<button type="button" data-command="removeFormat">Clear</button>
61+
</div>
62+
<div id="rich-editor" class="rich-editor" contenteditable="true" data-placeholder="Compose your optional message..."></div>
63+
{{ form.body_html }}
64+
{% if form.body_html.help_text %}
65+
<small>{{ form.body_html.help_text }}</small>
66+
{% endif %}
67+
{{ form.body_html.errors }}
68+
</div>
69+
70+
<div class="field">
71+
<label for="recipient-filter">Recipients</label>
72+
<input type="search" id="recipient-filter" class="search-input" placeholder="Filter recipients by email or username…" autocomplete="off">
73+
{{ form.recipients }}
74+
<small>Hold Ctrl/Cmd to select multiple recipients.</small>
75+
{{ form.recipients.errors }}
76+
</div>
77+
78+
{% if form.non_field_errors %}
79+
<ul class="errorlist">
80+
{% for error in form.non_field_errors %}
81+
<li>{{ error }}</li>
82+
{% endfor %}
83+
</ul>
84+
{% endif %}
85+
86+
<div class="actions">
87+
<button type="submit">Send emails</button>
88+
</div>
89+
</form>
90+
</div>
91+
92+
<script>
93+
(function () {
94+
const form = document.getElementById("broadcast-form");
95+
const editor = document.getElementById("rich-editor");
96+
const hiddenInput = document.getElementById("id_body_html");
97+
98+
const initial = hiddenInput.value || "";
99+
if (initial) {
100+
editor.innerHTML = initial;
101+
}
102+
103+
const syncHidden = () => {
104+
hiddenInput.value = editor.innerHTML.trim();
105+
};
106+
107+
editor.addEventListener("input", syncHidden);
108+
form.addEventListener("submit", syncHidden);
109+
110+
document.querySelector(".editor-toolbar").addEventListener("click", (event) => {
111+
const button = event.target.closest("button[data-command]");
112+
if (!button) {
113+
return;
114+
}
115+
event.preventDefault();
116+
const command = button.dataset.command;
117+
if (command === "createLink") {
118+
const url = prompt("Enter the URL");
119+
if (url) {
120+
document.execCommand("createLink", false, url);
121+
}
122+
} else {
123+
document.execCommand(command, false, null);
124+
}
125+
editor.focus();
126+
syncHidden();
127+
});
128+
129+
const filterInput = document.getElementById("recipient-filter");
130+
const recipientSelect = document.getElementById("id_recipients");
131+
132+
filterInput.addEventListener("input", () => {
133+
const term = filterInput.value.trim().toLowerCase();
134+
Array.from(recipientSelect.options).forEach((option) => {
135+
const text = option.text.toLowerCase();
136+
const value = option.value.toLowerCase();
137+
option.hidden = term && !text.includes(term) && !value.includes(term);
138+
});
139+
});
140+
})();
141+
</script>
142+
</body>
143+
</html>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Mail templates</title>
6+
<style>
7+
body { font-family: "Segoe UI", Arial, sans-serif; background-color: #f4f6fb; margin: 0; padding: 32px; }
8+
.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); }
9+
h1 { margin-top: 0; color: #111827; }
10+
ul { list-style: none; padding: 0; }
11+
li { border: 1px solid #e5e7eb; border-radius: 10px; margin-bottom: 16px; padding: 16px; display: flex; justify-content: space-between; align-items: center; }
12+
a { text-decoration: none; color: #2563eb; font-weight: 600; }
13+
.badge { font-size: 12px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.08em; }
14+
.actions { display: flex; gap: 12px; }
15+
</style>
16+
</head>
17+
<body>
18+
<div class="container">
19+
<h1>Mail template previews</h1>
20+
<p>Select a template to render it with its sample context. Use the broadcast page to send informational emails.</p>
21+
<p><a href="{% url 'mail_tools:broadcast' %}">Go to broadcast composer</a></p>
22+
<ul>
23+
{% for preview in previews %}
24+
<li>
25+
<div>
26+
<div>{{ preview.label }}</div>
27+
<div class="badge">{{ preview.slug }}</div>
28+
</div>
29+
<div class="actions">
30+
<a href="{% url 'mail_tools:detail' preview.slug %}">Preview</a>
31+
</div>
32+
</li>
33+
{% empty %}
34+
<li>No templates registered.</li>
35+
{% endfor %}
36+
</ul>
37+
</div>
38+
</body>
39+
</html>

src/mail_tools/urls.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.urls import path
2+
3+
from . import views
4+
5+
app_name = "mail_tools"
6+
7+
urlpatterns = [
8+
path("", views.preview_list, name="list"),
9+
path("preview/<slug:slug>/", views.preview_detail, name="detail"),
10+
path("broadcast/", views.broadcast_email, name="broadcast"),
11+
]

0 commit comments

Comments
 (0)