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`;
+53
View File
@@ -0,0 +1,53 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
/** The handle field of the sign-in and sign-up forms: the user types the
* localpart, the instance's domain sits after it and cannot be edited. Typing
* or pasting a full address keeps only the part before the "@". */
export function HandleInput({
value,
onChange,
placeholder,
disabled,
autoFocus,
}: {
value: string;
onChange: (localpart: string) => void;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
}) {
const status = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
const domain = status.data?.domain;
return (
<label
className={`flex w-full cursor-text items-center rounded-lg border border-app-border bg-app-surface px-3 py-2 focus-within:border-app-accent ${
disabled ? "opacity-50" : ""
}`}
>
{/* The input is as wide as its text (an invisible copy sizes the grid
cell), so the domain follows the typed name: demo@example.com. */}
<span className="inline-grid min-w-0 grid-cols-[minmax(0,auto)]">
<span aria-hidden className="invisible col-start-1 row-start-1 overflow-hidden whitespace-pre">
{value || placeholder}
</span>
<input
className="col-start-1 row-start-1 w-full min-w-[1ch] bg-transparent text-app-fg placeholder:text-app-faint focus:outline-none"
size={1}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value.split("@")[0])}
disabled={disabled}
autoFocus={autoFocus}
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
/>
</span>
{domain && <span className="shrink-0 select-none whitespace-pre text-app-muted">@{domain}</span>}
</label>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { createPortal } from "react-dom";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
const inputClass =
"w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
/**
* "Invite a friend" in the sidebar: shown while the administrator has
* invitations on and this user has some left (two each); gone once they are
* all sent. Opens a dialog asking for the friend's name and address; the server
* emails them a single-use sign-up link (app/identity/invitations.py).
*/
export function InviteFriendEntry({ collapsed, onDone }: { collapsed: boolean; onDone?: () => void }) {
const t = useT();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const state = useQuery({ queryKey: ["invitations"], queryFn: api.invitations, staleTime: 60_000 });
const send = useMutation({
mutationFn: () => api.sendInvitation(name.trim(), email.trim()),
onSuccess: (data) => {
queryClient.setQueryData(["invitations"], data);
window.dispatchEvent(
new CustomEvent("webmail:toast", { detail: { text: t("invite.sent", { name: name.trim() }) } }),
);
close();
onDone?.();
},
});
const close = () => {
setOpen(false);
setName("");
setEmail("");
send.reset();
};
const data = state.data;
// The dialog stays up while the last invitation is on its way out.
if (!open && (!data?.available || data.remaining <= 0)) return null;
return (
<div className="px-2 pb-1 text-[15px]">
<button
type="button"
className={`wm-online-only flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
}`}
title={collapsed ? t("invite.entry") : undefined}
onClick={() => setOpen(true)}
>
<InviteGlyph />
{!collapsed && t("invite.entry")}
</button>
{open &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] backdrop-blur-sm sm:items-center"
onClick={close}
>
<form
role="dialog"
aria-label={t("invite.title")}
className="w-full max-w-sm space-y-3 rounded-2xl bg-app-elevated p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === "Escape" && close()}
onSubmit={(e) => {
e.preventDefault();
send.mutate();
}}
>
<div>
<h2 className="text-base font-semibold">{t("invite.title")}</h2>
<p className="mt-1 text-sm text-app-muted">
{t("invite.help", { count: data?.remaining ?? 0 })}
</p>
</div>
<label className="block space-y-1 text-sm">
<span className="text-app-muted">{t("invite.name")}</span>
<input
className={inputClass}
value={name}
maxLength={100}
autoFocus
required
onChange={(e) => setName(e.target.value)}
/>
</label>
<label className="block space-y-1 text-sm">
<span className="text-app-muted">{t("invite.email")}</span>
<input
className={inputClass}
type="email"
value={email}
maxLength={320}
required
placeholder="friend@example.com"
onChange={(e) => setEmail(e.target.value)}
/>
</label>
{send.error instanceof ApiError && <p className="text-sm text-red-500">{send.error.message}</p>}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="rounded-lg px-3 py-1.5 text-sm text-app-muted hover:bg-app-hover"
onClick={close}
>
{t("common.cancel")}
</button>
<button
type="submit"
className="rounded-lg bg-app-accent px-4 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={send.isPending || !name.trim() || !email.trim()}
>
{send.isPending ? t("invite.sending") : t("invite.send")}
</button>
</div>
</form>
</div>,
document.body,
)}
</div>
);
}
function InviteGlyph() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="9.5" cy="8.5" r="3.5" />
<path d="M3 19.5c.7-3.3 3.3-5.25 6.5-5.25s5.8 1.95 6.5 5.25" />
<path d="M18.5 8v6M15.5 11h6" />
</svg>
);
}
+35
View File
@@ -24,6 +24,7 @@ export const en = {
"auth.registerTitle": "Create account",
"auth.handle": "Handle",
"auth.handlePlaceholder": "e.g. alice",
"auth.localpartPlaceholder": "alice",
"auth.handleHint": "3–30 chars: a–z, 0–9, . _ -",
"auth.password": "Password",
"auth.passwordHint": "at least 8 characters",
@@ -511,6 +512,29 @@ export const en = {
"admin.registrationOff": "Closed",
"admin.registrationHelp":
"While closed, the signup page is hidden and the server refuses new accounts.",
"admin.invitations": "Allow invitations",
"admin.invitationsOn": "On",
"admin.invitationsOff": "Off",
"admin.invitationsHelp":
"Each user can invite 2 friends from the sidebar. An invitation link signs up one person, even while registration is closed, and expires after 14 days (unused ones are given back).",
"admin.inviteMail": "Invitation email",
"admin.inviteConfigure": "Configure…",
"admin.inviteMailHelp":
"Sent through the Outgoing system mail account (on the right of the panel), which must be allowed to send from the sender address. Replies go to the user who sent the invitation.",
"admin.inviteFrom": "Sender address",
"admin.inviteFromHint": "Empty: the placeholder address shown.",
"admin.inviteSubject": "Subject",
"admin.inviteTemplateDefault": "Using the built-in template.",
"admin.inviteTemplateCustom": "Using your uploaded template.",
"admin.inviteUpload": "Upload HTML template…",
"admin.inviteDownload": "Download template",
"admin.inviteReset": "Back to the built-in template",
"admin.inviteResetConfirm": "Remove your template and use the built-in one again?",
"admin.invitePlaceholders": "Placeholders:",
"admin.invitePlaceholdersHelp":
"The template must contain {{invite_link}} or {{invite_button}}. The subject can use the names too. Images written into the HTML are sent inside the email.",
"admin.invitePreview": "Preview with sample names —",
"admin.inviteTestSent": "Test invitation sent (its link is a dummy).",
"admin.defaultQuota": "Default quota for new users",
"admin.quotaHelp":
"Applies to accounts created from now on. Existing mailboxes keep the quota they were given.",
@@ -658,6 +682,17 @@ export const en = {
"Waiting for your browser's permission. If it didn't ask, click the icon at the left of the address bar and set Notifications to Allow — they turn on by themselves then.",
"push.failed": "Couldn't turn notifications on.",
"install.entry": "Install app",
"invite.entry": "Invite a friend",
"invite.title": "Invite a friend",
"invite.help": "We'll email them a link to create an account. You have {count} invitations left; each link works once.",
"invite.help_one": "We'll email them a link to create an account. This is your last invitation; the link works once.",
"invite.name": "Friend's name",
"invite.email": "Friend's email address",
"invite.send": "Send invitation",
"invite.sending": "Sending…",
"invite.sent": "Invitation sent to {name}",
"invite.welcome": "Hi {name} — {inviter} invited you. Choose your address and password.",
"invite.linkInvalid": "This invitation has already been used or has expired.",
"install.title": "Install Ultimo",
"install.stepsIos":
"1. Tap the Share button (the square with an arrow) in Safari's toolbar.\n2. Choose Add to Home Screen.\n3. Tap Add. Ultimo opens from your Home Screen like an app, and can show notifications about new mail.",
+23 -2
View File
@@ -2,6 +2,8 @@
// (SPEC §10.3) and includes cookies for the session (SPEC §6.1).
import type {
AdminInviteTemplate,
Invitations,
AppPassword,
AccountDetail,
AdminMail,
@@ -136,6 +138,25 @@ export const api = {
apiFetch<AdminMail>("/admin/mail", { method: "PUT", body: JSON.stringify(body) }),
testAdminMail: (to: string) =>
apiFetch<{ status: string }>("/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }),
adminInviteTemplate: () => apiFetch<AdminInviteTemplate>("/admin/invitations/template"),
// PUT merges; an empty string goes back to the built-in one.
saveAdminInviteTemplate: (body: { subject?: string; html?: string; from_address?: string }) =>
apiFetch<AdminInviteTemplate>("/admin/invitations/template", { method: "PUT", body: JSON.stringify(body) }),
previewInvite: (subject: string, html: string) =>
apiFetch<{ subject: string; html: string }>("/admin/invitations/preview", {
method: "POST",
body: JSON.stringify({ subject, html }),
}),
testInvite: (to: string) =>
apiFetch<{ status: string }>("/admin/invitations/test", { method: "POST", body: JSON.stringify({ to }) }),
invitations: () => apiFetch<Invitations>("/invitations"),
sendInvitation: (name: string, email: string) =>
apiFetch<Invitations>("/invitations", { method: "POST", body: JSON.stringify({ name, email }) }),
invitationCheck: (token: string) =>
apiFetch<{ valid: boolean; invitee_name: string; inviter_name: string }>("/auth/invitation/check", {
method: "POST",
body: JSON.stringify({ token }),
}),
passwordResetRequest: (handle: string) =>
apiFetch<{ status: string }>("/auth/password/reset-request", {
method: "POST",
@@ -157,10 +178,10 @@ export const api = {
body: JSON.stringify({ password, recovery_email }),
}),
registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"),
register: (localpart: string, password: string) =>
register: (localpart: string, password: string, invite?: string) =>
apiFetch<UserOut>("/auth/register", {
method: "POST",
body: JSON.stringify({ localpart, password }),
body: JSON.stringify({ localpart, password, ...(invite ? { invite } : {}) }),
}),
// rememberDevice: with a correct 2FA code, don't ask this browser for one again.
login: (handle: string, password: string, totp?: string, rememberDevice = false) =>
+24
View File
@@ -46,12 +46,36 @@ export interface AdminSettings {
registration_open: boolean;
/** Quota given to users created from now on; existing mailboxes keep theirs. */
default_quota_bytes: number;
/** Users may send friends sign-up invitations. */
invitations_enabled: boolean;
}
/** The invitation email (admin panel). Empty strings: the built-in ones. */
export interface AdminInviteTemplate {
subject: string;
html: string;
from_address: string;
default_subject: string;
default_html: string;
default_from: string;
placeholders: string[];
}
/** Invite a friend: what the sidebar entry and its dialog show. */
export interface Invitations {
/** Switched on by the administrator, and the server can send email. */
available: boolean;
remaining: number;
per_user: number;
sent: { invitee_name: string; invitee_email: string; created_at: string; used: boolean; expired: boolean }[];
}
export interface RegistrationStatus {
open: boolean;
/** No users yet: the next signup creates the administrator. */
bootstrap: boolean;
/** Every handle is localpart@domain. */
domain: string;
}
export interface ThreadSummary {
+4 -7
View File
@@ -2,12 +2,10 @@ import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Link } from "react-router-dom";
import { HandleInput } from "../components/HandleInput";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
const inputClass =
"w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
// "Forgot password?": ask for the user name and have a reset link mailed to the
// account's recovery address. The answer is the same whether or not the account
// exists — the page never tells a stranger which user names are real.
@@ -31,11 +29,10 @@ export function ForgotPassword() {
if (handle.trim()) request.mutate();
}}
>
<input
className={inputClass}
placeholder={t("auth.handlePlaceholder")}
<HandleInput
placeholder={t("auth.localpartPlaceholder")}
value={handle}
onChange={(e) => setHandle(e.target.value)}
onChange={setHandle}
autoFocus
/>
<button
+4 -4
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { HandleInput } from "../components/HandleInput";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
@@ -44,11 +45,10 @@ export function Login() {
mutation.mutate();
}}
>
<input
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
placeholder={t("auth.handlePlaceholder")}
<HandleInput
placeholder={t("auth.localpartPlaceholder")}
value={handle}
onChange={(e) => setHandle(e.target.value)}
onChange={setHandle}
disabled={needTotp}
autoFocus
/>
+3
View File
@@ -28,6 +28,7 @@ import { DraftsList, ScheduledList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon";
import { InstallAppEntry } from "../components/InstallApp";
import { MessageList } from "../components/list/MessageList";
import { InviteFriendEntry } from "../components/InviteFriend";
import { Resizer } from "../components/Resizer";
import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration";
@@ -1202,6 +1203,8 @@ export function MailLayout() {
<InstallAppEntry onDone={() => setDrawerOpen(false)} />
</div>
)}
{/* Above the divider over Settings; gone once the invitations are used. */}
<InviteFriendEntry collapsed={collapsed} onDone={() => setDrawerOpen(false)} />
<div className="border-t border-app-border p-2 text-[15px]">
<Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
+36 -7
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { HandleInput } from "../components/HandleInput";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
@@ -19,8 +20,21 @@ export function Register() {
// no form at all. The server enforces this too — this is only the UI.
const status = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
// A friend's invitation (/register#invite=…): opens sign-up even while it is
// closed. The token rides in the fragment, which never reaches a server log.
const [invite] = useState(
() => new URLSearchParams(window.location.hash.replace(/^#/, "")).get("invite") ?? "",
);
const inviteCheck = useQuery({
queryKey: ["invitation", invite],
queryFn: () => api.invitationCheck(invite),
enabled: invite !== "",
staleTime: Infinity,
});
const invited = inviteCheck.data?.valid === true;
const mutation = useMutation({
mutationFn: () => api.register(localpart, password),
mutationFn: () => api.register(localpart, password, invited ? invite : undefined),
onSuccess: (user) => {
queryClient.setQueryData(["me"], user);
// The first signup closes registration behind itself; drop the cached answer.
@@ -38,7 +52,7 @@ export function Register() {
</p>
);
if (status.isPending) {
if (status.isPending || (invite && inviteCheck.isPending)) {
return (
<div className="mx-auto mt-24 max-w-sm px-4">
<p className="text-sm text-app-muted">{t("auth.checking")}</p>
@@ -46,9 +60,16 @@ export function Register() {
);
}
if (status.data && !status.data.open) {
const inviteGone = invite !== "" && !invited && (
<p className="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
{t("invite.linkInvalid")}
</p>
);
if (status.data && !status.data.open && !invited) {
return (
<div className="mx-auto mt-24 max-w-sm px-4">
{inviteGone}
<h1 className="mb-3 text-2xl font-semibold">{t("auth.registrationClosedTitle")}</h1>
<p className="text-sm text-app-muted">{t("auth.registrationClosed")}</p>
{signInLink}
@@ -60,9 +81,18 @@ export function Register() {
return (
<div className="mx-auto mt-24 max-w-sm px-4">
{inviteGone}
<h1 className="mb-3 text-2xl font-semibold">
{bootstrap ? t("auth.firstUserTitle") : t("auth.registerTitle")}
</h1>
{invited && (
<p className="mb-5 text-sm text-app-muted">
{t("invite.welcome", {
name: inviteCheck.data?.invitee_name ?? "",
inviter: inviteCheck.data?.inviter_name ?? "",
})}
</p>
)}
{bootstrap && <p className="mb-5 text-sm text-app-muted">{t("auth.firstUserHint")}</p>}
<form
className="space-y-3"
@@ -71,11 +101,10 @@ export function Register() {
mutation.mutate();
}}
>
<input
className={inputClass}
placeholder={t("auth.handlePlaceholder")}
<HandleInput
placeholder={t("auth.localpartPlaceholder")}
value={localpart}
onChange={(e) => setLocalpart(e.target.value)}
onChange={setLocalpart}
autoFocus
/>
<input
+5 -3
View File
@@ -23,10 +23,12 @@ export default defineConfig({
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
// Host kept as the browser sent it: the API's cross-site check
// (app/security/cross_site.py) compares the page's Origin with it, and
// a WebSocket upgrade carries only Origin (no Sec-Fetch-Site, and the
// proxy adds no X-Forwarded-Host to upgrades).
changeOrigin: false,
ws: true,
// X-Forwarded-Host: the API's cross-site check (app/security/cross_site.py)
// compares the page's Origin with the name the browser used.
xfwd: true,
},
// CalDAV / CardDAV (SPEC Appendix E), for trying clients against dev.