Invite a friend; handle field shows the domain; system mail fixes

- Invitations: each user can invite two friends by email (sign-up works
  through the link even while registration is closed); admin-editable
  template, subject and sender.
- Invitation sender defaults to the system-mail From address, which the
  SMTP account is allowed to send as (was postmaster@APP_DOMAIN, refused
  with 553 when the two differ).
- Sign-in, sign-up and forgot-password fields show the fixed
  @domain after the typed name; the registration status reports it.
- System mail gets a Date header; the invitation's plain-text part keeps
  its paragraphs and line breaks.
- SMTP EHLO uses SMTP_HELO_NAME / APP_DOMAIN, not the container name.
- DNS recommendations for the mail domains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 19:34:31 +02:00
co-authored by Claude Opus 5.5
parent addb2232a1
commit 463f96ad57
30 changed files with 1870 additions and 42 deletions
+255
View File
@@ -1,5 +1,6 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
@@ -163,6 +164,8 @@ function Panel({ handle }: { handle: string }) {
// The quota field is a draft until Save — unlike the toggle, you do not want a
// write per keystroke. `null` means "not edited yet, show the server's value".
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
// The invitation email settings open in a dialog of their own.
const [configuring, setConfiguring] = useState(false);
const save = useMutation({
mutationFn: api.saveAdminSettings,
@@ -216,6 +219,39 @@ function Panel({ handle }: { handle: string }) {
<p className="mt-1 text-xs text-app-faint">{t("admin.registrationHelp")}</p>
</div>
<div className="border-b border-app-border py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-app-muted">{t("admin.invitations")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
{([true, false] as const).map((value) => (
<button
key={String(value)}
disabled={save.isPending}
onClick={() => save.mutate({ invitations_enabled: value })}
className={`rounded px-3 py-1 disabled:opacity-50 ${
settings.data.invitations_enabled === value
? "bg-app-active font-medium text-app-fg"
: "text-app-muted hover:bg-app-hover"
}`}
>
{t(value ? "admin.invitationsOn" : "admin.invitationsOff")}
</button>
))}
</div>
</div>
<p className="mt-1 text-xs text-app-faint">{t("admin.invitationsHelp")}</p>
{settings.data.invitations_enabled && (
<button
type="button"
className="mt-2 rounded border border-app-border px-3 py-1 text-sm hover:bg-app-hover"
onClick={() => setConfiguring(true)}
>
{t("admin.inviteConfigure")}
</button>
)}
</div>
{configuring && <InvitationEmailDialog onClose={() => setConfiguring(false)} />}
<div className="border-b border-app-border py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-app-muted">{t("admin.defaultQuota")}</span>
@@ -445,6 +481,225 @@ function SystemMail() {
);
}
/** "Configure" under Allow invitations: the invitation email settings in a dialog. */
function InvitationEmailDialog({ onClose }: { onClose: () => void }) {
const t = useT();
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm sm:items-center"
onClick={onClose}
>
<div
role="dialog"
aria-label={t("admin.inviteMail")}
className="relative my-4 max-h-[calc(100vh-2rem)] w-full max-w-2xl overflow-y-auto rounded-2xl bg-app-bg p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
aria-label={t("admin.close")}
className="absolute right-4 top-4 rounded px-2 py-0.5 text-xl leading-none text-app-muted hover:bg-app-hover hover:text-app-fg"
onClick={onClose}
>
×
</button>
<InvitationEmail />
</div>
</div>,
document.body,
);
}
/** The invitation email: sender, subject and an uploaded HTML template with
* placeholders; previewed with sample names, and sent as a test. */
function InvitationEmail() {
const t = useT();
const queryClient = useQueryClient();
const tpl = useQuery({ queryKey: ["adminInviteTemplate"], queryFn: api.adminInviteTemplate });
// Sender and subject are drafts until Save; null shows the server's value.
const [from, setFrom] = useState<string | null>(null);
const [subject, setSubject] = useState<string | null>(null);
const [testTo, setTestTo] = useState("");
const save = useMutation({
mutationFn: api.saveAdminInviteTemplate,
onSuccess: (data) => {
queryClient.setQueryData(["adminInviteTemplate"], data);
setFrom(null);
setSubject(null);
},
});
const test = useMutation({ mutationFn: () => api.testInvite(testTo.trim()) });
const data = tpl.data;
const preview = useQuery({
queryKey: ["adminInvitePreview", data?.subject, data?.html],
queryFn: () => api.previewInvite(data?.subject ?? "", data?.html ?? ""),
enabled: !!data,
});
if (!data) {
return tpl.error instanceof ApiError ? <p className="text-sm text-red-500">{tpl.error.message}</p> : null;
}
const custom = data.html !== "";
const dirty = (from !== null && from !== data.from_address) || (subject !== null && subject !== data.subject);
const field =
"w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none";
const label = "block space-y-1 text-sm";
const upload = async (file: File | undefined) => {
if (!file) return;
save.mutate({ html: await file.text() });
};
const download = () => {
const blob = new Blob([data.html || data.default_html], { type: "text/html" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = custom ? "invitation.html" : "invitation-default.html";
a.click();
URL.revokeObjectURL(a.href);
};
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold">{t("admin.inviteMail")}</h2>
<p className="text-xs text-app-faint">{t("admin.inviteMailHelp")}</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate({
...(from !== null ? { from_address: from } : {}),
...(subject !== null ? { subject } : {}),
});
}}
>
<label className={label}>
<span className="text-app-muted">{t("admin.inviteFrom")}</span>
<input
className={field}
type="email"
value={from ?? data.from_address}
placeholder={data.default_from}
onChange={(e) => setFrom(e.target.value)}
/>
<span className="block text-xs text-app-faint">{t("admin.inviteFromHint")}</span>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.inviteSubject")}</span>
<input
className={field}
value={subject ?? data.subject}
placeholder={data.default_subject}
onChange={(e) => setSubject(e.target.value)}
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!dirty || save.isPending}
>
{save.isPending ? t("admin.saving") : t("admin.save")}
</button>
</div>
</form>
<div className="space-y-2 border-t border-app-border pt-3 text-sm">
<p className="text-app-muted">
{custom ? t("admin.inviteTemplateCustom") : t("admin.inviteTemplateDefault")}
</p>
<div className="flex flex-wrap gap-2">
<label className="cursor-pointer rounded border border-app-border px-3 py-1 hover:bg-app-hover">
{t("admin.inviteUpload")}
<input
type="file"
accept=".html,.htm,text/html"
className="hidden"
onChange={(e) => {
void upload(e.target.files?.[0]);
e.target.value = "";
}}
/>
</label>
<button type="button" className="rounded border border-app-border px-3 py-1 hover:bg-app-hover" onClick={download}>
{t("admin.inviteDownload")}
</button>
{custom && (
<button
type="button"
className="rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={save.isPending}
onClick={() => confirm(t("admin.inviteResetConfirm")) && save.mutate({ html: "" })}
>
{t("admin.inviteReset")}
</button>
)}
</div>
<p className="text-xs text-app-faint">
{t("admin.invitePlaceholders")}{" "}
{data.placeholders.map((p, i) => (
<span key={p}>
{i > 0 && ", "}
<code className="select-all rounded bg-app-hover px-1">{`{{${p}}}`}</code>
</span>
))}
</p>
<p className="text-xs text-app-faint">{t("admin.invitePlaceholdersHelp")}</p>
{save.error instanceof ApiError && <p className="text-xs text-red-500">{save.error.message}</p>}
</div>
{preview.data && (
<div className="space-y-1">
<p className="text-xs text-app-muted">
{t("admin.invitePreview")} <span className="font-medium text-app-fg">{preview.data.subject}</span>
</p>
{/* Sandboxed, script-less: the admin's own HTML, shown as mail. */}
<iframe
title={t("admin.invitePreview")}
sandbox=""
srcDoc={preview.data.html}
className="h-80 w-full rounded-lg border border-app-border bg-white"
/>
</div>
)}
<form
className="flex items-center gap-2 border-t border-app-border pt-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
test.mutate();
}}
>
<span className="shrink-0 text-app-muted">{t("admin.testTo")}</span>
<input
className={field}
type="email"
value={testTo}
placeholder="you@example.com"
onChange={(e) => {
setTestTo(e.target.value);
test.reset();
}}
/>
<button
type="submit"
className="shrink-0 rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={!testTo.trim() || test.isPending || dirty}
>
{test.isPending ? t("auth.sending") : t("admin.sendTest")}
</button>
</form>
{test.isSuccess && <p className="text-xs text-emerald-600">{t("admin.inviteTestSent")}</p>}
{test.error instanceof ApiError && <p className="text-xs text-red-500">{test.error.message}</p>}
</section>
);
}
function formatBytes(bytes: number): string {
if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(2))} GiB`;
return `${Number((bytes / 1024 ** 2).toFixed(1))} MiB`;