Vectrum
Sign In

Merge branch 't3/smtp-email-sending'

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 2 parents 2704b3b e6cf1fccommit7b8afe3

Showing 27 changed files with 659 additions and 15 deletions.

Merge commit — showing changes against the first parent.

+3−0.gitignoreView File
@@ -27,3 +27,6 @@ yarn-error.log*
2727 # editors
2828 .idea/
2929 *.iml
30+
31+ # example settings, safe to commit
32+ !.env.example
+13−0apps/web/.env.exampleaddedView File
@@ -0,0 +1,13 @@
1+ # Copy to .env (Next.js reads it on start). See docs/installation.md#configuration.
2+
3+ # The address users reach Vectrum at; links in emails point here.
4+ # PUBLIC_URL=https://vectrum.example.com
5+
6+ # Email: address confirmation, password resets, invitations. Without SMTP_HOST, nothing is sent.
7+ # SMTP_HOST=smtp.example.com
8+ # SMTP_PORT=587
9+ # 1 for TLS from the start (the default for port 465); otherwise STARTTLS is used when the server offers it.
10+ # SMTP_SECURE=0
11+ # SMTP_USER=vectrum@example.com
12+ # SMTP_PASSWORD=
13+ # MAIL_FROM="Vectrum <vectrum@example.com>"
+23−1apps/web/app/actions/auth.tsView File
@@ -2,8 +2,10 @@
22
33 import { redirect } from "next/navigation";
44 import { completeSignIn, endSession, safeNext, signIn } from "@/lib/auth";
5+ import { requestPasswordReset, resetPassword, sendVerificationEmailLater } from "@/lib/emails";
6+ import { MAIL_ENABLED } from "@/lib/mail";
57 import { SIGNUPS_ENABLED } from "@/lib/signups";
6- import { createUser, UserError } from "@/lib/users";
8+ import { createUser, UserError, validatePassword } from "@/lib/users";
79 import type { FormState } from "./types";
810
911 export async function signupAction(_prev: FormState, formData: FormData): Promise<FormState> {
@@ -16,6 +18,7 @@ export async function signupAction(_prev: FormState, formData: FormData): Promis
1618 password,
1719 name: String(formData.get("name") ?? ""),
1820 });
21+ sendVerificationEmailLater(user);
1922 await signIn(user.email, password);
2023 } catch (e) {
2124 if (e instanceof UserError) return { error: e.message };
@@ -41,6 +44,25 @@ export async function twoFactorLoginAction(_prev: FormState, formData: FormData)
4144 redirect(next);
4245 }
4346
47+ export async function forgotPasswordAction(_prev: FormState, formData: FormData): Promise<FormState> {
48+ if (!MAIL_ENABLED) return { error: "This server can't send email. Ask the person who runs it to reset your password." };
49+ const login = String(formData.get("login") ?? "").trim();
50+ if (!login) return { error: "Enter your username or email address." };
51+ await requestPasswordReset(login);
52+ return { success: "If that's an account here, we've sent a link to choose a new password to its email address. It works for one hour." };
53+ }
54+
55+ export async function resetPasswordAction(_prev: FormState, formData: FormData): Promise<FormState> {
56+ const password = String(formData.get("password") ?? "");
57+ if (password !== formData.get("confirm")) return { error: "The passwords don't match." };
58+ const problem = validatePassword(password);
59+ if (problem) return { error: problem };
60+ if (!(await resetPassword(String(formData.get("token") ?? ""), password))) {
61+ return { error: "This link has expired or was already used. Ask for a new one." };
62+ }
63+ redirect("/login?reset=1");
64+ }
65+
4466 export async function logoutAction() {
4567 await endSession();
4668 redirect("/");
+2−0apps/web/app/actions/orgs.tsView File
@@ -2,6 +2,7 @@
22
33 import { revalidatePath } from "next/cache";
44 import { redirect } from "next/navigation";
5+ import { notifyInvitation } from "@/lib/emails";
56 import {
67 answerInvitation,
78 cancelInvitation,
@@ -100,6 +101,7 @@ export async function inviteMemberAction(name: string, _prev: FormState, formDat
100101 const { org, viewer } = await orgForAction(name, "owner");
101102 const role = roleOf(formData);
102103 const user = await inviteMember(org, viewer, String(formData.get("username") ?? ""), role);
104+ notifyInvitation(user, org, viewer, role);
103105 revalidatePath(`/organizations/${org.username}/settings/members`);
104106 return { success: `Invited ${user.username} as ${ROLE_INFO[role].title.toLowerCase()}. They’ll find the invitation on their dashboard.` };
105107 });
+4−2apps/web/app/actions/repo.tsView File
@@ -16,6 +16,7 @@ import {
1616 updateRepo,
1717 type Visibility,
1818 } from "@/lib/repos";
19+ import { notifyCollaborator } from "@/lib/emails";
1920 import { getOrg, mayCreateRepos } from "@/lib/orgs";
2021 import { ActionError, repoForAction, viewerOrThrow } from "./guard";
2122 import type { FormState } from "./types";
@@ -134,9 +135,10 @@ export async function deleteRepoAction(owner: string, name: string, _prev: FormS
134135
135136 export async function addCollaboratorAction(owner: string, name: string, _prev: FormState, formData: FormData) {
136137 return attempt(async () => {
137- const { repo } = await repoForAction(owner, name, "admin");
138+ const { repo, viewer } = await repoForAction(owner, name, "admin");
138139 const username = String(formData.get("username") ?? "").trim();
139- await addCollaborator(repo, username);
140+ const { user, added } = await addCollaborator(repo, username);
141+ if (added) notifyCollaborator(user, repo, viewer);
140142 revalidatePath(`/${repo.owner}/${repo.name}/settings`);
141143 return { success: `${username} can now push to this repository.` };
142144 });
+22−2apps/web/app/actions/settings.tsView File
@@ -2,6 +2,7 @@
22
33 import { revalidatePath } from "next/cache";
44 import { renewSession } from "@/lib/auth";
5+ import { notifyEmailChanged, notifyPasswordChanged, sendVerificationEmail, sendVerificationEmailLater } from "@/lib/emails";
56 import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys";
67 import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens";
78 import {
@@ -19,13 +20,18 @@ import type { FormState } from "./types";
1920 export async function updateProfileAction(_prev: FormState, formData: FormData): Promise<FormState> {
2021 const viewer = await viewerOrThrow();
2122 try {
22- const { emailChanged } = await updateProfile(viewer.id, {
23+ const email = String(formData.get("email") ?? "").trim();
24+ const { emailChanged, newAddress } = await updateProfile(viewer.id, {
2325 name: String(formData.get("name") ?? ""),
24- email: String(formData.get("email") ?? ""),
26+ email,
2527 bio: String(formData.get("bio") ?? ""),
2628 });
2729 // PocketBase revokes a user's tokens when their email changes; keep this browser signed in.
2830 if (emailChanged) await renewSession(viewer.id);
31+ if (newAddress) {
32+ notifyEmailChanged(viewer, email);
33+ sendVerificationEmailLater({ ...viewer, email, verified: false });
34+ }
2935 } catch (e) {
3036 if (e instanceof UserError) return { error: e.message };
3137 throw e;
@@ -34,6 +40,19 @@ export async function updateProfileAction(_prev: FormState, formData: FormData):
3440 return { success: "Profile updated." };
3541 }
3642
43+ export async function resendVerificationAction(): Promise<FormState> {
44+ const viewer = await viewerOrThrow();
45+ try {
46+ const result = await sendVerificationEmail(viewer);
47+ if (result === "wait") return { error: "We just sent one. Wait a minute before asking again." };
48+ if (result === "verified") return { success: "Your email address is already confirmed." };
49+ } catch (e) {
50+ console.error("[mail] couldn't send the verification email:", e instanceof Error ? e.message : e);
51+ return { error: "The email couldn't be sent. Try again later." };
52+ }
53+ return { success: `Sent. Check ${viewer.email} for the link.` };
54+ }
55+
3756 export async function changePasswordAction(_prev: FormState, formData: FormData): Promise<FormState> {
3857 const viewer = await viewerOrThrow();
3958 const current = String(formData.get("current") ?? "");
@@ -45,6 +64,7 @@ export async function changePasswordAction(_prev: FormState, formData: FormData)
4564 // This signs the user out everywhere, so sign this browser back in.
4665 await setPassword(viewer.id, next);
4766 await renewSession(viewer.id);
67+ notifyPasswordChanged(viewer);
4868 return { success: "Password changed." };
4969 }
5070
+35−0apps/web/app/login/forgot-password/page.tsxaddedView File
@@ -0,0 +1,35 @@
1+ import type { Metadata } from "next";
2+ import NextLink from "next/link";
3+ import { notFound } from "next/navigation";
4+ import { Link } from "@unlocalhosted/metalui";
5+ import { ForgotPasswordForm } from "@/components/auth-forms";
6+ import { KeyIcon } from "@/components/icons";
7+ import { Box } from "@/components/ui";
8+ import { MAIL_ENABLED } from "@/lib/mail";
9+
10+ export const metadata: Metadata = { title: "Forgot your password?" };
11+
12+ export default function ForgotPasswordPage() {
13+ if (!MAIL_ENABLED) notFound();
14+ return (
15+ <main className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-16">
16+ <div className="aurora pointer-events-none absolute inset-0" aria-hidden />
17+ <div className="dot-grid pointer-events-none absolute inset-0" aria-hidden />
18+ <div className="animate-fade-up relative w-full max-w-[380px]">
19+ <div className="text-center">
20+ <span className="mx-auto flex size-16 items-center justify-center rounded-hero material-cap [&_svg]:size-8">
21+ <KeyIcon />
22+ </span>
23+ <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Forgot your password?</h1>
24+ <p className="mt-1 type-lead text-ink2">We’ll email you a link to choose a new one</p>
25+ </div>
26+ <Box className="mt-8 p-6">
27+ <ForgotPasswordForm />
28+ </Box>
29+ <p className="mt-6 text-center type-body text-ink2">
30+ Remembered it? <Link render={<NextLink href="/login" />}>Sign in</Link>
31+ </p>
32+ </div>
33+ </main>
34+ );
35+ }
+7−1apps/web/app/login/page.tsxView File
@@ -6,6 +6,7 @@ import { LoginForm } from "@/components/auth-forms";
66 import { BRAND, LogoMark } from "@/components/brand";
77 import { Alert, Box } from "@/components/ui";
88 import { getViewer, safeNext } from "@/lib/auth";
9+ import { MAIL_ENABLED } from "@/lib/mail";
910 import { SIGNUPS_ENABLED } from "@/lib/signups";
1011
1112 export const metadata: Metadata = { title: "Sign in" };
@@ -32,8 +33,13 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {
3233 <Alert kind="info">Your sign-in timed out. Enter your password again.</Alert>
3334 </div>
3435 )}
36+ {params.reset && (
37+ <div className="mt-8">
38+ <Alert kind="success">Your password was changed. Sign in with the new one.</Alert>
39+ </div>
40+ )}
3541 <Box className="mt-8 p-6">
36- <LoginForm next={next} />
42+ <LoginForm next={next} canReset={MAIL_ENABLED} />
3743 </Box>
3844 {SIGNUPS_ENABLED && (
3945 <p className="mt-6 text-center type-body text-ink2">
+45−0apps/web/app/login/reset-password/page.tsxaddedView File
@@ -0,0 +1,45 @@
1+ import type { Metadata } from "next";
2+ import NextLink from "next/link";
3+ import { Link } from "@unlocalhosted/metalui";
4+ import { ResetPasswordForm } from "@/components/auth-forms";
5+ import { KeyIcon } from "@/components/icons";
6+ import { Alert, Box } from "@/components/ui";
7+ import { passwordResetUser } from "@/lib/emails";
8+
9+ export const metadata: Metadata = { title: "Choose a new password" };
10+
11+ /** Where the link in a password reset email (lib/emails.ts) leads. */
12+ export default async function ResetPasswordPage({ searchParams }: PageProps<"/login/reset-password">) {
13+ const token = (await searchParams).token;
14+ const user = typeof token === "string" ? await passwordResetUser(token) : null;
15+
16+ return (
17+ <main className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-16">
18+ <div className="aurora pointer-events-none absolute inset-0" aria-hidden />
19+ <div className="dot-grid pointer-events-none absolute inset-0" aria-hidden />
20+ <div className="animate-fade-up relative w-full max-w-[380px]">
21+ <div className="text-center">
22+ <span className="mx-auto flex size-16 items-center justify-center rounded-hero material-cap [&_svg]:size-8">
23+ <KeyIcon />
24+ </span>
25+ <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Choose a new password</h1>
26+ {user && <p className="mt-1 type-lead text-ink2">For @{user.username}</p>}
27+ </div>
28+ <Box className="mt-8 p-6">
29+ {user && typeof token === "string" ? (
30+ <ResetPasswordForm token={token} username={user.username} />
31+ ) : (
32+ <Alert>This link has expired or was already used.</Alert>
33+ )}
34+ </Box>
35+ <p className="mt-6 text-center type-body text-ink2">
36+ {user ? (
37+ <Link render={<NextLink href="/login" />}>Back to sign in</Link>
38+ ) : (
39+ <Link render={<NextLink href="/login/forgot-password" />}>Ask for a new link</Link>
40+ )}
41+ </p>
42+ </div>
43+ </main>
44+ );
45+ }
+48−0apps/web/app/login/verify-email/page.tsxaddedView File
@@ -0,0 +1,48 @@
1+ import type { Metadata } from "next";
2+ import NextLink from "next/link";
3+ import { Link } from "@unlocalhosted/metalui";
4+ import { MailIcon } from "@/components/icons";
5+ import { Alert, Box } from "@/components/ui";
6+ import { getViewer } from "@/lib/auth";
7+ import { confirmEmail } from "@/lib/emails";
8+
9+ export const metadata: Metadata = { title: "Confirm your email address" };
10+
11+ /** Where the link in a verification email (lib/emails.ts) leads. Opening it confirms the address. */
12+ export default async function VerifyEmailPage({ searchParams }: PageProps<"/login/verify-email">) {
13+ const token = (await searchParams).token;
14+ const [user, viewer] = await Promise.all([typeof token === "string" ? confirmEmail(token) : null, getViewer()]);
15+
16+ return (
17+ <main className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-16">
18+ <div className="aurora pointer-events-none absolute inset-0" aria-hidden />
19+ <div className="dot-grid pointer-events-none absolute inset-0" aria-hidden />
20+ <div className="animate-fade-up relative w-full max-w-[380px]">
21+ <div className="text-center">
22+ <span className="mx-auto flex size-16 items-center justify-center rounded-hero material-cap [&_svg]:size-8">
23+ <MailIcon />
24+ </span>
25+ <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Confirm your email address</h1>
26+ </div>
27+ <Box className="mt-8 p-6">
28+ {user ? (
29+ <Alert kind="success">
30+ {user.email} is confirmed as the address of @{user.username}.
31+ </Alert>
32+ ) : (
33+ <Alert>
34+ This link has expired, or the account’s email address has changed since. Send a new one from your profile settings.
35+ </Alert>
36+ )}
37+ </Box>
38+ <p className="mt-6 text-center type-body text-ink2">
39+ {viewer ? (
40+ <Link render={<NextLink href={user ? "/" : "/settings/profile"} />}>{user ? "Go to your dashboard" : "Open profile settings"}</Link>
41+ ) : (
42+ <Link render={<NextLink href={user ? "/login" : "/login?next=%2Fsettings%2Fprofile"} />}>Sign in</Link>
43+ )}
44+ </p>
45+ </div>
46+ </main>
47+ );
48+ }
+8−0apps/web/app/page.tsxView File
@@ -23,10 +23,12 @@ import {
2323 import { CardDescription, CardTitle } from "@/components/metal";
2424 import { RepoFilterList } from "@/components/repo-filter-list";
2525 import { Avatar, avatarUrl, Blankslate, Box, ButtonLink, Counter, Label, RefChip, RepoAvatar } from "@/components/ui";
26+ import { VerifyEmailNotice } from "@/components/verify-email-notice";
2627 import { getViewer } from "@/lib/auth";
2728 import { shortSha, timeAgo } from "@/lib/format";
2829 import { recentActivity, withActivity } from "@/lib/listing";
2930 import { listListsOf, listPath } from "@/lib/lists";
31+ import { MAIL_ENABLED } from "@/lib/mail";
3032 import { listInvitationsFor, listOrgsOf } from "@/lib/orgs";
3133 import { listReposForUser, searchRepos } from "@/lib/repos";
3234 import { serverHost } from "@/lib/request-origin";
@@ -217,6 +219,12 @@ async function Dashboard({ viewer }: { viewer: User }) {
217219 </div>
218220 </div>
219221
222+ {MAIL_ENABLED && !viewer.verified && (
223+ <div className="mb-6">
224+ <VerifyEmailNotice email={viewer.email} />
225+ </div>
226+ )}
227+
220228 {invitations.length > 0 && (
221229 <div className="mb-6">
222230 <OrgInvitations invitations={invitations} />
+3−0apps/web/app/settings/profile/page.tsxView File
@@ -1,7 +1,9 @@
11 import type { Metadata } from "next";
22 import { PlateSection } from "@/components/settings-plate";
33 import { PageTitle } from "@/components/ui";
4+ import { VerifyEmailNotice } from "@/components/verify-email-notice";
45 import { requireViewer } from "@/lib/auth";
6+ import { MAIL_ENABLED } from "@/lib/mail";
57 import { PasswordForm, ProfileForm } from "./forms";
68
79 export const metadata: Metadata = { title: "Your profile" };
@@ -12,6 +14,7 @@ export default async function ProfileSettingsPage() {
1214 <div>
1315 <PageTitle title="Public profile" description="How you appear to others across the instance." />
1416 <div className="flex flex-col gap-7">
17+ {MAIL_ENABLED && !viewer.verified && <VerifyEmailNotice email={viewer.email} />}
1518 <PlateSection title="Profile">
1619 <ProfileForm user={viewer} />
1720 </PlateSection>
+51−3apps/web/components/auth-forms.tsxView File
@@ -3,18 +3,26 @@
33 import NextLink from "next/link";
44 import { useActionState } from "react";
55 import { Link } from "@unlocalhosted/metalui";
6- import { loginAction, signupAction, twoFactorLoginAction } from "@/app/actions/auth";
6+ import { forgotPasswordAction, loginAction, resetPasswordAction, signupAction, twoFactorLoginAction } from "@/app/actions/auth";
77 import { TextField } from "./settings-fields";
88 import { Alert, Button } from "./ui";
99
10- export function LoginForm({ next }: { next: string }) {
10+ /** With `canReset`, links to resetting a forgotten password (only when the server can send email). */
11+ export function LoginForm({ next, canReset }: { next: string; canReset?: boolean }) {
1112 const [state, action, pending] = useActionState(loginAction, undefined);
1213 return (
1314 <form action={action} className="flex flex-col gap-4">
1415 <input type="hidden" name="next" value={next} />
1516 {state?.error && <Alert>{state.error}</Alert>}
1617 <TextField label="Username or email address" name="login" required autoFocus autoComplete="username" />
17- <TextField label="Password" name="password" type="password" required autoComplete="current-password" />
18+ <TextField
19+ label="Password"
20+ description={canReset && <Link render={<NextLink href="/login/forgot-password" />}>Forgot your password?</Link>}
21+ name="password"
22+ type="password"
23+ required
24+ autoComplete="current-password"
25+ />
1826 <Button variant="primary" className="mt-1 w-full" disabled={pending}>
1927 {pending ? "Signing in…" : "Sign In"}
2028 </Button>
@@ -82,3 +90,43 @@ export function SignupForm({ next }: { next: string }) {
8290 </form>
8391 );
8492 }
93+
94+ export function ForgotPasswordForm() {
95+ const [state, action, pending] = useActionState(forgotPasswordAction, undefined);
96+ if (state?.success) return <Alert kind="success">{state.success}</Alert>;
97+ return (
98+ <form action={action} className="flex flex-col gap-4">
99+ {state?.error && <Alert>{state.error}</Alert>}
100+ <TextField label="Username or email address" name="login" required autoFocus autoComplete="username" />
101+ <Button variant="primary" className="mt-1 w-full" disabled={pending}>
102+ {pending ? "Sending…" : "Send Link"}
103+ </Button>
104+ </form>
105+ );
106+ }
107+
108+ export function ResetPasswordForm({ token, username }: { token: string; username: string }) {
109+ const [state, action, pending] = useActionState(resetPasswordAction, undefined);
110+ return (
111+ <form action={action} className="flex flex-col gap-4">
112+ <input type="hidden" name="token" value={token} />
113+ {/* For password managers. */}
114+ <input type="hidden" name="username" value={username} autoComplete="username" />
115+ {state?.error && <Alert>{state.error}</Alert>}
116+ <TextField
117+ label="New password"
118+ description="At least 8 characters."
119+ name="password"
120+ type="password"
121+ required
122+ minLength={8}
123+ autoFocus
124+ autoComplete="new-password"
125+ />
126+ <TextField label="Confirm new password" name="confirm" type="password" required minLength={8} autoComplete="new-password" />
127+ <Button variant="primary" className="mt-1 w-full" disabled={pending}>
128+ {pending ? "Saving…" : "Set Password"}
129+ </Button>
130+ </form>
131+ );
132+ }
+21−0apps/web/components/verify-email-notice.tsxaddedView File
@@ -0,0 +1,21 @@
1+ import { resendVerificationAction } from "@/app/actions/settings";
2+ import { ActionButton } from "./action-button";
3+ import { MailIcon } from "./icons";
4+ import { Alert } from "./ui";
5+
6+ /** For a signed-in person who hasn't confirmed their email address yet (lib/emails.ts). */
7+ export function VerifyEmailNotice({ email }: { email: string }) {
8+ return (
9+ <Alert kind="info">
10+ <div className="flex flex-wrap items-center justify-between gap-3">
11+ <span>
12+ Confirm your email address: we sent a link to <strong className="font-semibold">{email}</strong>. Until then, you won’t get
13+ emails about invitations and your account.
14+ </span>
15+ <ActionButton action={resendVerificationAction} icon={<MailIcon />}>
16+ Send Again
17+ </ActionButton>
18+ </div>
19+ </Alert>
20+ );
21+ }
+2−0apps/web/lib/app-api.tsView File
@@ -1,6 +1,7 @@
11 import "server-only";
22 import { person, type Actor } from "./actor";
33 import { checkSecondFactor, userForSession } from "./auth";
4+ import { sendVerificationEmailLater } from "./emails";
45 import { describeEvent } from "./event-text";
56 import { listEvents as listRepoEvents, type RepoEvent } from "./events";
67 import { commitCount, defaultBranch, diffCommit, getCommit, GitError, listBranches, listTags, listTree, log, normalizeRepoName, objectType, readBlob, revParse, type Commit, type FileDiff } from "./git";
@@ -148,6 +149,7 @@ export async function signUp(request: Request) {
148149 const password = text(body.password, 200);
149150 try {
150151 const user = await createUser({ username: text(body.username, 100), email: text(body.email, 200), password, name: text(body.name, 100) });
152+ sendVerificationEmailLater(user);
151153 const auth = await authenticateUser(user.email, password);
152154 if (!auth) throw new ApiError(500, "The account was created, but signing in failed. Try signing in.");
153155 return { token: auth.token, user: userJson(auth.user) };
+187−0apps/web/lib/emails.tsaddedView File
@@ -0,0 +1,187 @@
1+ import "server-only";
2+ import { createHash, randomBytes } from "node:crypto";
3+ import { db, filter, findFirst, withLock } from "./db";
4+ import { linkTo, MAIL_ENABLED, sendMail, sendMailLater, type Message } from "./mail";
5+ import { ROLE_INFO, type OrgRole } from "./org-roles";
6+ import { getPersonByLogin, getUserById, setPassword, type User } from "./users";
7+
8+ /*
9+ * The emails Vectrum sends (through lib/mail.ts), and the links in them that confirm an address
10+ * or set a new password. A link carries a random token; only its hash is kept (in email_tokens),
11+ * with the address it went to, so it stops working once the account's email changes.
12+ *
13+ * Notifications (invitations, access, security notices) only go to confirmed addresses: anyone can
14+ * sign up with someone else's.
15+ */
16+
17+ const LIFETIME_MS = { verify: 3 * 86400_000, reset: 60 * 60_000 };
18+ /** At most one link of a kind per account in this time, so nobody can flood an inbox with them. */
19+ const RESEND_MS = 60_000;
20+
21+ type Purpose = keyof typeof LIFETIME_MS;
22+
23+ const hash = (token: string) => createHash("sha256").update(token).digest("hex");
24+ const tokens = async () => (await db()).collection("email_tokens");
25+
26+ /** A new token for a link, or null if one went out too recently. */
27+ async function issueToken(user: User, purpose: Purpose) {
28+ return withLock(`email-tokens:${user.id}`, async () => {
29+ const now = Date.now();
30+ const since = now - RESEND_MS;
31+ if (await findFirst("email_tokens", filter("user = {:id} && purpose = {:purpose} && created_at > {:since}", { id: user.id, purpose, since }), { fields: "id" })) {
32+ return null;
33+ }
34+ const collection = await tokens();
35+ const expired = await collection.getFullList({ filter: filter("user = {:id} && expires_at < {:now}", { id: user.id, now }), fields: "id" });
36+ await Promise.all(expired.map((r) => collection.delete(r.id)));
37+ const token = randomBytes(32).toString("base64url");
38+ await collection.create({ user: user.id, purpose, email: user.email, token_hash: hash(token), expires_at: now + LIFETIME_MS[purpose], created_at: now });
39+ return token;
40+ });
41+ }
42+
43+ /** Sends an email with a new link, or returns false if one went out too recently. A link that couldn't be sent doesn't count. */
44+ async function sendLink(user: User, purpose: Purpose, message: (url: string) => Message) {
45+ const token = await issueToken(user, purpose);
46+ if (!token) return false;
47+ try {
48+ await sendMail(message(linkTo(`/login/${purpose === "verify" ? "verify-email" : "reset-password"}?token=${token}`)));
49+ } catch (e) {
50+ const row = await findFirst("email_tokens", filter("token_hash = {:hash}", { hash: hash(token) }), { fields: "id" });
51+ if (row) await (await tokens()).delete(row.id);
52+ throw e;
53+ }
54+ return true;
55+ }
56+
57+ /** The account a link is for, while it's good. */
58+ async function tokenUser(token: string, purpose: Purpose) {
59+ if (!token || token.length > 100) return null;
60+ const row = await findFirst("email_tokens", filter("token_hash = {:hash} && purpose = {:purpose}", { hash: hash(token), purpose }));
61+ if (!row || row.expires_at < Date.now()) return null;
62+ const user = await getUserById(row.user);
63+ if (!user || user.email.toLowerCase() !== String(row.email).toLowerCase()) return null;
64+ return user;
65+ }
66+
67+ const markVerified = async (userId: string) => void (await (await db()).collection("users").update(userId, { verified: true }));
68+
69+ const logFailure = (what: string) => (e: unknown) => console.error(`[mail] couldn't send ${what}:`, e instanceof Error ? e.message : e);
70+
71+ // ---------------------------------------------------------------------------
72+ // Confirming an address
73+
74+ /** Emails a link that confirms the user's address. "wait" if one went out less than a minute ago. */
75+ export async function sendVerificationEmail(user: User): Promise<"sent" | "wait" | "verified"> {
76+ if (user.verified) return "verified";
77+ const sent = await sendLink(user, "verify", (url) => ({
78+ to: user.email,
79+ subject: "Confirm your email address",
80+ body: [`Hi ${user.name || user.username},`, `Confirm that ${user.email} is the email address of your account, @${user.username}.`],
81+ action: { label: "Confirm email address", url },
82+ footer: "The link works for 3 days. If you didn't sign up or change your email address, ignore this email.",
83+ }));
84+ return sent ? "sent" : "wait";
85+ }
86+
87+ /** After signing up or changing the address: sends the link without holding up the request. */
88+ export function sendVerificationEmailLater(user: User) {
89+ if (MAIL_ENABLED) sendVerificationEmail(user).catch(logFailure("the verification email"));
90+ }
91+
92+ /**
93+ * Confirms the address a link was sent to. The link keeps working until it expires: mail
94+ * scanners open links before people do.
95+ */
96+ export async function confirmEmail(token: string) {
97+ const user = await tokenUser(token, "verify");
98+ if (user && !user.verified) await markVerified(user.id);
99+ return user;
100+ }
101+
102+ // ---------------------------------------------------------------------------
103+ // Resetting a forgotten password
104+
105+ /**
106+ * Emails a link to set a new password if `login` (a username or email address) is someone's
107+ * account. It doesn't tell whether it is, and doesn't wait for the email, as how long that takes
108+ * would tell too.
109+ */
110+ export async function requestPasswordReset(login: string) {
111+ const user = await getPersonByLogin(login);
112+ if (!user?.email) return;
113+ sendLink(user, "reset", (url) => ({
114+ to: user.email,
115+ subject: "Reset your password",
116+ body: [`Hi ${user.name || user.username},`, `Someone asked to reset the password of your account, @${user.username}. Choose a new one here:`],
117+ action: { label: "Choose a new password", url },
118+ footer: "The link works for one hour, once. If you didn't ask for it, ignore this email: your password stays the same.",
119+ })).catch(logFailure("the password reset email"));
120+ }
121+
122+ /** The account a password reset link is for, while it's good. */
123+ export const passwordResetUser = (token: string) => tokenUser(token, "reset");
124+
125+ /** Sets a new password with a reset link, which then stops working (as do the account's sessions). */
126+ export async function resetPassword(token: string, password: string) {
127+ const user = await passwordResetUser(token);
128+ if (!user) return false;
129+ await setPassword(user.id, password);
130+ // The link reached them, so the address is theirs.
131+ if (!user.verified) await markVerified(user.id);
132+ notifyPasswordChanged({ ...user, verified: true });
133+ return true;
134+ }
135+
136+ // ---------------------------------------------------------------------------
137+ // Notifications
138+
139+ function notify(user: User, message: Omit<Message, "to">) {
140+ if (MAIL_ENABLED && user.verified && user.email) sendMailLater({ ...message, to: user.email });
141+ }
142+
143+ export function notifyInvitation(user: User, org: User, inviter: User, role: OrgRole) {
144+ notify(user, {
145+ subject: `@${inviter.username} invited you to join ${org.username}`,
146+ body: [
147+ `Hi ${user.name || user.username},`,
148+ `@${inviter.username} invited you to join the organization ${org.name || org.username}, with the ${ROLE_INFO[role].title} role.`,
149+ "Accept or decline the invitation on your dashboard.",
150+ ],
151+ action: { label: "View invitation", url: linkTo("/settings/organizations") },
152+ footer: `You're getting this because @${inviter.username} invited your account, @${user.username}.`,
153+ });
154+ }
155+
156+ export function notifyCollaborator(user: User, repo: { owner: string; name: string }, by: User) {
157+ notify(user, {
158+ subject: `You can now push to ${repo.owner}/${repo.name}`,
159+ body: [`Hi ${user.name || user.username},`, `@${by.username} added you as a collaborator to ${repo.owner}/${repo.name}. You can now push to it.`],
160+ action: { label: "Open repository", url: linkTo(`/${repo.owner}/${repo.name}`) },
161+ footer: `You're getting this because @${by.username} gave your account, @${user.username}, access.`,
162+ });
163+ }
164+
165+ export function notifyPasswordChanged(user: User) {
166+ notify(user, {
167+ subject: "Your password was changed",
168+ body: [
169+ `Hi ${user.name || user.username},`,
170+ `The password of your account, @${user.username}, was just changed, and its other sessions were signed out.`,
171+ "If that wasn't you, reset your password right away.",
172+ ],
173+ action: { label: "Reset password", url: linkTo("/login/forgot-password") },
174+ });
175+ }
176+
177+ /** Tells the old address (`before`) that the account's email changed, in case someone else did it. */
178+ export function notifyEmailChanged(before: User, newEmail: string) {
179+ notify(before, {
180+ subject: "Your email address was changed",
181+ body: [
182+ `Hi ${before.name || before.username},`,
183+ `The email address of your account, @${before.username}, was changed from ${before.email} to ${newEmail}. Emails go to the new address from now on.`,
184+ "If that wasn't you, someone else has your account: tell whoever runs this server.",
185+ ],
186+ });
187+ }
+76−0apps/web/lib/mail.tsaddedView File
@@ -0,0 +1,76 @@
1+ import "server-only";
2+ import { createTransport } from "nodemailer";
3+ import { BRAND } from "@/components/brand";
4+ import { PUBLIC_ORIGIN } from "./request-origin";
5+
6+ /*
7+ * Email goes out through the SMTP server in SMTP_HOST (with SMTP_PORT, SMTP_USER, SMTP_PASSWORD and
8+ * SMTP_SECURE), from MAIL_FROM. Without SMTP_HOST nothing is sent, and what needs email (confirming
9+ * addresses, resetting a forgotten password) isn't offered. The messages are in lib/emails.ts.
10+ */
11+
12+ export const MAIL_ENABLED = !!process.env.SMTP_HOST;
13+
14+ const PORT = Number(process.env.SMTP_PORT) || 587;
15+ // Port 465 speaks TLS from the start; others upgrade with STARTTLS when the server offers it.
16+ const SECURE = process.env.SMTP_SECURE ? ["1", "true"].includes(process.env.SMTP_SECURE.toLowerCase()) : PORT === 465;
17+ const FROM = process.env.MAIL_FROM || process.env.SMTP_USER;
18+
19+ const g = globalThis as unknown as { __mailer?: ReturnType<typeof createTransport>; __mailOriginWarned?: boolean };
20+
21+ function mailer() {
22+ if (!FROM) throw new Error("Set MAIL_FROM to the address emails are sent from.");
23+ return (g.__mailer ??= createTransport({
24+ host: process.env.SMTP_HOST,
25+ port: PORT,
26+ secure: SECURE,
27+ auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD ?? "" } : undefined,
28+ }));
29+ }
30+
31+ /** An absolute link to a page of this server. */
32+ export function linkTo(path: string) {
33+ if (!PUBLIC_ORIGIN && !g.__mailOriginWarned) {
34+ g.__mailOriginWarned = true;
35+ console.warn("[mail] PUBLIC_URL isn't set, so links in emails point at localhost.");
36+ }
37+ return `${PUBLIC_ORIGIN ?? `http://localhost:${process.env.PORT ?? 3000}`}${path}`;
38+ }
39+
40+ export type Message = {
41+ to: string;
42+ subject: string;
43+ /** Paragraphs of plain text. */
44+ body: string[];
45+ /** The one thing to do, as a button. */
46+ action?: { label: string; url: string };
47+ /** Smaller print below, e.g. why the email was sent. */
48+ footer?: string;
49+ };
50+
51+ const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
52+
53+ function html({ body, action, footer }: Message) {
54+ const p = (text: string) => `<p style="margin:0 0 16px">${escape(text)}</p>`;
55+ const button = action
56+ ? `<p style="margin:24px 0"><a href="${escape(action.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;text-decoration:none;font-weight:600">${escape(action.label)}</a></p>` +
57+ `<p style="margin:0 0 16px;font-size:13px;color:#6b7280">Or open this link: <a href="${escape(action.url)}" style="color:#6b7280">${escape(action.url)}</a></p>`
58+ : "";
59+ const small = footer ? `<p style="margin:24px 0 0;font-size:13px;color:#6b7280">${escape(footer)}</p>` : "";
60+ return `<!doctype html><html><body style="margin:0;padding:24px;background:#f9fafb"><div style="max-width:520px;margin:0 auto;padding:28px;background:#ffffff;border-radius:12px;font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827">${body.map(p).join("")}${button}${small}</div></body></html>`;
61+ }
62+
63+ function text({ body, action, footer }: Message) {
64+ return [...body, ...(action ? [`${action.label}: ${action.url}`] : []), ...(footer ? [`--\n${footer}`] : [])].join("\n\n");
65+ }
66+
67+ /** Sends an email; throws when the SMTP server refuses it. */
68+ export async function sendMail(message: Message) {
69+ if (!MAIL_ENABLED) return;
70+ await mailer().sendMail({ from: FROM, to: message.to, subject: `[${BRAND.name}] ${message.subject}`, text: text(message), html: html(message) });
71+ }
72+
73+ /** Sends an email without waiting for it, for when the request shouldn't fail (or take longer) because of it. */
74+ export function sendMailLater(message: Message) {
75+ sendMail(message).catch((e) => console.error(`[mail] couldn't send "${message.subject}":`, e instanceof Error ? e.message : e));
76+ }
+3−0apps/web/lib/repos.tsView File
@@ -423,15 +423,18 @@ export async function listCollaborators(repoId: string) {
423423 .sort((a, b) => a.username.localeCompare(b.username));
424424 }
425425
426+ /** Returns the person, and whether they weren't a collaborator yet. */
426427 export async function addCollaborator(repo: RepoRecord, username: string) {
427428 const user = await getPersonByUsername(username.trim());
428429 if (!user) throw new RepoError(`No user named ${username}.`);
429430 if (user.id === repo.ownerId) throw new RepoError("The owner already has full access.");
430431 try {
431432 await (await db()).collection("collaborators").create({ repo: repo.id, user: user.id, created_at: Date.now() });
433+ return { user, added: true };
432434 } catch (e) {
433435 // Already a collaborator.
434436 if (!isUniqueViolation(e)) throw e;
437+ return { user, added: false };
435438 }
436439 }
437440
+3−0apps/web/lib/request-origin.tsView File
@@ -9,6 +9,9 @@ const PUBLIC_URL = process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL) : nu
99 /** Session cookies are marked Secure when the instance is served over HTTPS (or SECURE_COOKIES=1). */
1010 export const SECURE_COOKIES = process.env.SECURE_COOKIES === "1" || PUBLIC_URL?.protocol === "https:";
1111
12+ /** PUBLIC_URL's origin, or null. Links in emails use it, never the Host header (lib/mail.ts). */
13+ export const PUBLIC_ORIGIN = PUBLIC_URL?.origin ?? null;
14+
1215 /** The address users reach this server at, e.g. https://vectrum.cc. */
1316 export async function serverOrigin() {
1417 if (PUBLIC_URL) return PUBLIC_URL.origin;
+34−6apps/web/lib/users.tsView File
@@ -16,6 +16,8 @@ export type User = {
1616 isOrg: boolean;
1717 /** When an avatar was uploaded (used to bust caches), or null to draw the initials. */
1818 avatarVersion: number | null;
19+ /** Whether the person confirmed their email address with a link sent to it (lib/emails.ts). */
20+ verified: boolean;
1921 };
2022
2123 /** PocketBase dates read "2026-01-02 03:04:05.678Z". */
@@ -31,6 +33,7 @@ export function toUser(r: RecordModel): User {
3133 createdAt: parseDate(r.created),
3234 isOrg: r.kind === "org",
3335 avatarVersion: r.avatar ? orNull(r.avatar_updated_at) : null,
36+ verified: !!r.verified,
3437 };
3538 }
3639
@@ -79,10 +82,10 @@ export async function authenticateUser(login: string, password: string) {
7982 login = login.trim();
8083 if (!login || !password) return null;
8184 // PocketBase signs in by email; usernames are resolved here, case-insensitively.
82- const record = await findFirst("users", filter("kind != 'org' && (username:lower = {:login} || email:lower = {:login})", { login: login.toLowerCase() }));
85+ const person = await getPersonByLogin(login);
8386 try {
8487 // Unknown logins are still checked, so usernames can't be probed by timing.
85- const auth = await client().collection("users").authWithPassword(record?.email ?? login, password);
88+ const auth = await client().collection("users").authWithPassword(person?.email ?? login, password);
8689 return { user: toUser(auth.record), token: auth.token };
8790 } catch (e) {
8891 if (e instanceof ClientResponseError && e.status === 400) return null;
@@ -95,9 +98,25 @@ export async function verifyUserPassword(userId: string, password: string) {
9598 return !!user && !!(await authenticateUser(user.email, password));
9699 }
97100
98- /** Changes the password. PocketBase then rejects the user's existing session tokens. */
101+ /** Changes the password. PocketBase then rejects the user's existing session tokens, and links to reset it stop working. */
99102 export async function setPassword(userId: string, password: string) {
100103 await (await db()).collection("users").update(userId, { password, passwordConfirm: password });
104+ await revokeEmailTokens(userId, "reset");
105+ }
106+
107+ /** Makes the unused links emailed to a user (lib/emails.ts) for `purpose` stop working. */
108+ export async function revokeEmailTokens(userId: string, purpose: "verify" | "reset") {
109+ const tokens = (await db()).collection("email_tokens");
110+ const rows = await tokens.getFullList({ filter: filter("user = {:userId} && purpose = {:purpose}", { userId, purpose }), fields: "id" });
111+ await Promise.all(rows.map((r) => tokens.delete(r.id)));
112+ }
113+
114+ /** A person by username or email address, as they sign in. */
115+ export async function getPersonByLogin(login: string) {
116+ login = login.trim().toLowerCase();
117+ if (!login) return null;
118+ const record = await findFirst("users", filter("kind != 'org' && (username:lower = {:login} || email:lower = {:login})", { login }));
119+ return record ? toUser(record) : null;
101120 }
102121
103122 export async function getUserById(id: string) {
@@ -116,19 +135,28 @@ export async function getPersonByUsername(username: string) {
116135 return user && !user.isOrg ? user : null;
117136 }
118137
119- /** Returns whether the email changed; PocketBase then rejects the user's existing session tokens. */
138+ /**
139+ * Returns whether the email changed; PocketBase then rejects the user's existing session tokens. A
140+ * new address needs confirming again, and links sent to the old one stop working.
141+ */
120142 export async function updateProfile(userId: string, data: { name: string; email: string; bio: string }) {
121143 const email = data.email.trim();
122144 if (!isEmail(email)) throw new UserError("Email address is invalid.");
123145 if (await emailTaken(email, userId)) throw new UserError("That email address is already in use.");
124146 const before = await getUserById(userId);
125147 try {
126- await (await db()).collection("users").update(userId, { name: data.name.trim().slice(0, 100), email, bio: data.bio.trim().slice(0, 300) });
148+ const emailChanged = before?.email !== email;
149+ // Not just a change of case.
150+ const newAddress = before?.email.toLowerCase() !== email.toLowerCase();
151+ await (await db())
152+ .collection("users")
153+ .update(userId, { name: data.name.trim().slice(0, 100), email, bio: data.bio.trim().slice(0, 300), ...(newAddress && { verified: false }) });
154+ if (newAddress) await Promise.all([revokeEmailTokens(userId, "verify"), revokeEmailTokens(userId, "reset")]);
155+ return { emailChanged, newAddress };
127156 } catch (e) {
128157 if (isUniqueViolation(e)) throw new UserError("That email address is already in use.");
129158 throw e;
130159 }
131- return { emailChanged: before?.email !== email };
132160 }
133161
134162 export async function searchUsers(query: string, limit = 8) {
+1−0apps/web/package.jsonView File
@@ -20,6 +20,7 @@
2020 "@uiw/react-codemirror": "^4.25.12",
2121 "@unlocalhosted/metalui": "^0.3.3",
2222 "next": "16.3.7",
23+ "nodemailer": "^10.0.16",
2324 "pocketbase": "^0.28.1",
2425 "react": "19.2.8",
2526 "react-dom": "19.2.8",
+35−0apps/web/pb_migrations/1791700000_email_tokens.jsaddedView File
@@ -0,0 +1,35 @@
1+ // Links sent by email (lib/emails.ts): "verify" confirms an address, "reset" sets a new password.
2+ //
3+ // Only a SHA-256 hash of the token in the link is stored. `email` is the address it went to, so a
4+ // link stops working once the account's email changes. Only the server (a superuser) reads them.
5+
6+ migrate(
7+ (app) => {
8+ const users = app.findCollectionByNameOrId("users");
9+ const collection = new Collection({
10+ type: "base",
11+ name: "email_tokens",
12+ listRule: null,
13+ viewRule: null,
14+ createRule: null,
15+ updateRule: null,
16+ deleteRule: null,
17+ fields: [
18+ { type: "relation", name: "user", collectionId: users.id, maxSelect: 1, required: true, cascadeDelete: true },
19+ { type: "select", name: "purpose", values: ["verify", "reset"], maxSelect: 1, required: true },
20+ { type: "text", name: "email", max: 200, required: true },
21+ { type: "text", name: "token_hash", max: 64, required: true },
22+ { type: "number", name: "expires_at", onlyInt: true, min: 0, required: true },
23+ { type: "number", name: "created_at", onlyInt: true, min: 0, required: true },
24+ ],
25+ indexes: [
26+ "CREATE UNIQUE INDEX idx_email_tokens_hash ON email_tokens (token_hash)",
27+ "CREATE INDEX idx_email_tokens_user ON email_tokens (user, purpose)",
28+ ],
29+ });
30+ app.save(collection);
31+ },
32+ (app) => {
33+ app.delete(app.findCollectionByNameOrId("email_tokens"));
34+ },
35+ );
+3−0bun.lockView File
@@ -21,6 +21,7 @@
2121 "@uiw/react-codemirror": "^4.25.12",
2222 "@unlocalhosted/metalui": "^0.3.3",
2323 "next": "16.3.7",
24+ "nodemailer": "^10.0.16",
2425 "pocketbase": "^0.28.1",
2526 "react": "19.2.8",
2627 "react-dom": "19.2.8",
@@ -1030,6 +1031,8 @@
10301031
10311032 "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="],
10321033
1034+ "nodemailer": ["nodemailer@10.0.16", "", {}, "sha512-VL9xKErxN+el77o8eBOuYKUPMyEx7F9hfnDCuiUKJRp9v3wnWMOudYgP/SWbwSHy8rUL4cylobuXtki1LpQS7A=="],
1035+
10331036 "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
10341037
10351038 "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+6−0docker-compose.ymlView File
@@ -18,6 +18,12 @@ services:
1818 # SSH_HOST: vectrum.example.com
1919 # Set to 1 to turn off the built-in SSH server (and remove the 2222 port above).
2020 # SSH_DISABLED: "1"
21+ # Send email (address confirmation, password resets, invitations) through this SMTP server.
22+ # SMTP_HOST: smtp.example.com
23+ # SMTP_PORT: "587" # 465 uses TLS from the start (or set SMTP_SECURE: "1")
24+ # SMTP_USER: vectrum@example.com
25+ # SMTP_PASSWORD: change-me
26+ # MAIL_FROM: Vectrum <vectrum@example.com>
2127 volumes:
2228 - vectrum-data:/data # repositories, the PocketBase database and the SSH host key
2329
+1−0docs/features.mdView File
@@ -1,6 +1,7 @@
11 # Features
22
33 - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions)
4+ - **Email** through an SMTP server of your own ([Configuration](installation.md#email)): confirming addresses, resetting a forgotten password, organization invitations, collaborator access, and notices when a password or email address changes
45 - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before
56 - **Repositories**: public or private, rename, transfer to an organization (or back to your account), delete, optional README on creation
67 - **Collaborators**: give other users push access to a repository
+22−0docs/installation.mdView File
@@ -46,6 +46,28 @@ To build the image without Compose, run `docker build -f apps/web/Dockerfile -t
4646 | `SSH_DISABLED` | — | Set to `1` to not start the SSH server |
4747 | `SIGNUPS_DISABLED` | — | Set to `1` to close registration (add users with `scripts/create-user.mjs`) |
4848 | `WEBHOOKS_ALLOW_PRIVATE` | — | Set to `1` to let webhooks reach private and local addresses (a trusted, single-team server) |
49+ | `SMTP_HOST` | — | SMTP server to send email through (see [Email](#email)); without it, Vectrum sends none |
50+ | `SMTP_PORT` | `587` | Its port |
51+ | `SMTP_SECURE` | on for port 465 | `1` for TLS from the start (port 465); otherwise the connection upgrades with STARTTLS when the server offers it |
52+ | `SMTP_USER`, `SMTP_PASSWORD` | — | Login for the SMTP server, if it needs one |
53+ | `MAIL_FROM` | `SMTP_USER` | Sender, e.g. `Vectrum <noreply@vectrum.cc>` |
54+
55+ ### Email
56+
57+ With `SMTP_HOST` set, Vectrum sends email: a link to confirm the address after signing up or changing it, a link to reset a forgotten password (*Forgot your password?* on the sign-in page), organization invitations, being added as a collaborator, and notices when an account's password or email address changes. Notifications only go to confirmed addresses. Set `PUBLIC_URL` too, as the links in emails point there.
58+
59+ Outside Docker, put the settings in `apps/web/.env` (see `apps/web/.env.example`), which Next.js reads on start:
60+
61+ ```bash
62+ SMTP_HOST=smtp.example.com
63+ SMTP_PORT=587
64+ SMTP_USER=vectrum@example.com
65+ SMTP_PASSWORD=…
66+ MAIL_FROM="Vectrum <vectrum@example.com>"
67+ PUBLIC_URL=https://vectrum.example.com
68+ ```
69+
70+ With Docker Compose, add them under `environment:`, or put them in a `.env` file next to `docker-compose.yml` and list it under `env_file:`. Failures to send are logged.
4971
5072 Data layout:
5173
+1−0docs/security.mdView File
@@ -8,4 +8,5 @@
88 - OAuth for agents follows the MCP authorization spec: public clients only, PKCE (S256) required, exact redirect URIs (any port for `localhost`), one-time codes that expire in 5 minutes, access tokens that last an hour, and refresh tokens that rotate on every use (presenting an old one revokes the grant). Only hashes of codes and tokens are stored. The consent page can't be framed, and it shows where it will send you, because anyone can register an application under any name.
99 - Release files are always served as `application/octet-stream` attachments with `nosniff` and a sandboxing CSP. Uploads signed in with the browser session must come from the site's own origin; scripts use tokens.
1010 - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin.
11+ - Links in emails (confirming an address, resetting a password) carry 256-bit random tokens; only their SHA-256 hashes are stored, with the address they went to, so they stop working when the email changes. Reset links work for an hour, once, and are revoked by any password change. At most one link of a kind goes to an account per minute. Asking for a reset link answers the same for unknown accounts and doesn't wait for the email. Links always use `PUBLIC_URL`, never the request's `Host` header. Notifications only go to confirmed addresses, as anyone can sign up with someone else's.
1112 - Two-factor authentication (TOTP, RFC 6238) is checked after the password, before any session exists: the password's session token waits on the server for 5 minutes and 5 codes. Wrong codes also count per account (10 in 15 minutes locks codes out for that long). A code works once (also no older one after it), recovery codes are stored as hashes and deleted when used, and the secret is a hidden field only the server reads. Turning 2FA off takes a code.