Email: one branded HTML layout with a text part, and invites can be emailed (#5917)

Each email was a subject and a plain-text f-string at its call site.
mail_templates.py now holds one layout: the site name, a heading, the
message, a button for the link and a footer saying who sent it and why.
Every email (password reset, test, invite) is a function returning its
subject, text and HTML. mailer.send sends them as multipart/alternative,
text first.

The HTML is email-shaped: tables, inline styles, no images or fonts, and
every value from settings or a person escaped.

POST /api/invites takes send_email. It is refused before anything is made
when email is off or the invite names no address. The link is built from
the public address. A failed send leaves the invite standing with
email_error, so the admin still gets the link once. The invite form offers
"Email the link to …" when email is on and an address is filled in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 13:43:13 -04:00
co-authored by Claude Opus 5.5
parent 5cc71c3050
commit c0de50d85d
9 changed files with 291 additions and 33 deletions
+27 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import { errorMessage } from "../api/errors";
import { appLink } from "../router/links";
@@ -7,6 +7,7 @@ import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import OneTimeSecret from "./OneTimeSecret.vue";
import { toastOnFailure, useLoad } from "../composables/useAction";
import { useConfigStore } from "../stores/config";
// Admin: invite one person to register while registration stays closed (#5172).
// The server keeps only a hash of each invite's token, so the link is shown once,
@@ -38,17 +39,30 @@ const days = ref(7);
const creating = ref(false);
// The link for the invite just made. Never retrievable again once dismissed.
const freshLink = ref("");
const freshNote = ref("");
// Emailing the link (#5917) needs email set up and an address to send it to. The
// server says whether email is on in the same flag the sign-in page reads.
const config = useConfigStore();
const sendEmail = ref(true);
const canEmail = computed(() => config.passwordResetByEmail && email.value.trim() !== "");
async function create() {
creating.value = true;
error.value = "";
freshLink.value = "";
try {
const res = await api.post<{ invite: Invite; token: string }>("/api/invites", {
email: email.value.trim() || null,
days: days.value,
});
const to = email.value.trim();
const res = await api.post<{ invite: Invite; token: string; emailed?: boolean; email_error?: string }>(
"/api/invites",
{ email: to || null, days: days.value, send_email: canEmail.value && sendEmail.value },
);
freshLink.value = appLink({ name: "register", query: { invite: res.token } });
freshNote.value = res.emailed
? `Emailed to ${to}. The link is here too, in case it's easier to send yourself.`
: res.email_error
? `${res.email_error} Send this link to ${to} yourself.`
: "Send this to the person you're inviting.";
invites.value = [res.invite, ...invites.value];
email.value = "";
} catch (e) {
@@ -100,7 +114,7 @@ onMounted(() => {
<OneTimeSecret
v-if="freshLink"
:value="freshLink"
note="Send this to the person you're inviting."
:note="freshNote"
@done="freshLink = ''"
/>
@@ -123,7 +137,13 @@ onMounted(() => {
<option v-for="l in LIFETIMES" :key="l.days" :value="l.days">{{ l.label }}</option>
</select>
</div>
<BaseButton type="submit" :loading="creating">Create invite</BaseButton>
<BaseButton type="submit" :loading="creating">{{
canEmail && sendEmail ? "Create and email" : "Create invite"
}}</BaseButton>
<label v-if="canEmail" class="flex w-full items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input v-model="sendEmail" type="checkbox" class="h-4 w-4 accent-brand" />
Email the link to {{ email.trim() }}
</label>
</form>
<p v-if="error" class="form-error">{{ error }}</p>