Make the AGPL's source offer point at the source being run

Three things a licence audit turned up. None of them is a conflict --
every one of the 182 installed packages is permissive, and the relicence
was within the copyright holder's gift -- but all three are ways the
AGPL fails to stick.

The offer was hard-coded to this repository. Section 13 asks whoever
runs a modified version to offer *that* version's source, so every
deployment with a patch in it was pointing at the wrong tree, and would
have gone on doing so unless its operator noticed and edited the About
page. SOURCE_URL now sets it, alongside APP_NAME, and both the sign-in
page and About read it.

The offer was also only visible after signing in. Whoever is looking at
the sign-in form is interacting with the program over a network too, so
the footer carries it now.

And the two workspace packages declared no licence at all. Private, so
npm never minded, but anything reading the tree saw a blank where the
rest of the project says AGPL-3.0-or-later.

Checked both ways round: with SOURCE_URL set to a fork, the sign-in page
and About both point at the fork; with it unset, both fall back to this
repository.
This commit is contained in:
2026-08-25 13:42:11 -07:00
parent 259b625c3e
commit 7b05322577
11 changed files with 54 additions and 2 deletions
+6
View File
@@ -39,3 +39,9 @@ IMAGE_PROXY=1
# Branding # Branding
APP_NAME=ihasmail APP_NAME=ihasmail
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which
# asks whoever runs a modified version to offer *that* version's source -- so if
# you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About.
SOURCE_URL=https://github.com/LINUXexpert-org/ihasmail
+5
View File
@@ -233,3 +233,8 @@ nearly always run as a network service rather than handed to anyone as a
binary, and the AGPL's section 13 closes that gap: anyone running a modified binary, and the AGPL's section 13 closes that gap: anyone running a modified
ihasmail for other people has to offer them its source, which the GPL alone ihasmail for other people has to offer them its source, which the GPL alone
does not require. does not require.
That offer has to point at *your* source, not this one. If you run a modified
ihasmail, set `SOURCE_URL` to your own repository: the sign-in page and
Settings About both show it, so the people using your instance are told where
the code they are actually running can be found.
+1
View File
@@ -9,6 +9,7 @@ services:
STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env} STALWART_URL: ${STALWART_URL:?set STALWART_URL in .env}
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)} APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail} APP_NAME: ${APP_NAME:-ihasmail}
SOURCE_URL: ${SOURCE_URL:-https://github.com/LINUXexpert-org/ihasmail}
TRUST_PROXY: "1" TRUST_PROXY: "1"
IMAGE_PROXY: "1" IMAGE_PROXY: "1"
volumes: volumes:
+1
View File
@@ -2,6 +2,7 @@
"name": "@ihasmail/server", "name": "@ihasmail/server",
"version": "2.0.0", "version": "2.0.0",
"private": true, "private": true,
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+2
View File
@@ -148,6 +148,7 @@ export function createApp(): Hono<Env> {
api.get("/config", (c) => api.get("/config", (c) =>
c.json({ c.json({
appName: config.appName, appName: config.appName,
sourceUrl: config.sourceUrl,
imageProxy: config.imageProxy, imageProxy: config.imageProxy,
maxUploadBytes: config.maxUploadBytes, maxUploadBytes: config.maxUploadBytes,
}), }),
@@ -581,6 +582,7 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
return { return {
ihasmail: { ihasmail: {
appName: config.appName, appName: config.appName,
sourceUrl: config.sourceUrl,
imageProxy: config.imageProxy, imageProxy: config.imageProxy,
maxUploadBytes: config.maxUploadBytes, maxUploadBytes: config.maxUploadBytes,
sessionId: session.id, sessionId: session.id,
+8
View File
@@ -60,6 +60,14 @@ const stalwartUrl = env("STALWART_URL", "https://mail.example.com").replace(/\/+
export const config = { export const config = {
isProd, isProd,
appName: env("APP_NAME", "ihasmail"), appName: env("APP_NAME", "ihasmail"),
/**
* Where this instance's source can be had, shown to everyone who reaches it.
*
* The AGPL asks whoever *runs* a modified version to offer that version's
* source, not the one it was forked from -- so anyone deploying a patched
* ihasmail should point this at their own tree.
*/
sourceUrl: env("SOURCE_URL", "https://github.com/LINUXexpert-org/ihasmail"),
host: env("HOST", "0.0.0.0"), host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080), port: int("PORT", 8080),
stalwartUrl, stalwartUrl,
+1
View File
@@ -2,6 +2,7 @@
"name": "@ihasmail/web", "name": "@ihasmail/web",
"version": "2.0.0", "version": "2.0.0",
"private": true, "private": true,
"license": "AGPL-3.0-or-later",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -25,6 +25,8 @@ export interface JmapSession {
state: string; state: string;
ihasmail?: { ihasmail?: {
appName: string; appName: string;
/** Where this instance's source can be had, for the AGPL's sake. */
sourceUrl?: string;
imageProxy: boolean; imageProxy: boolean;
maxUploadBytes: number; maxUploadBytes: number;
sessionId: string; sessionId: string;
+8
View File
@@ -0,0 +1,8 @@
/**
* Where to point someone who wants this instance's source.
*
* The AGPL asks whoever runs a modified version to offer *that* version's
* source. The server says where its own lives, via SOURCE_URL; this is only the
* fallback for when it has not been asked yet, or has nothing to say.
*/
export const DEFAULT_SOURCE_URL = "https://github.com/LINUXexpert-org/ihasmail";
+16 -1
View File
@@ -1,10 +1,23 @@
import { useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react"; import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client"; import { ApiError } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
export function LoginPage() { export function LoginPage() {
const login = useSession((s) => s.login); const login = useSession((s) => s.login);
// The AGPL's offer has to reach everyone who interacts with the app over the
// network, and that includes whoever is looking at this form. The server says
// where its own source lives, so a modified deployment points at its own.
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
useEffect(() => {
let live = true;
fetch("/api/config")
.then((r) => (r.ok ? r.json() : null))
.then((c) => { if (live && c?.sourceUrl) setSourceUrl(c.sourceUrl as string); })
.catch(() => { /* the default stands */ });
return () => { live = false; };
}, []);
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? ""); const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [totp, setTotp] = useState(""); const [totp, setTotp] = useState("");
@@ -81,6 +94,8 @@ export function LoginPage() {
</button> </button>
<p className="foot"> <p className="foot">
ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a> ihasmail by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
{" · "}
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
</p> </p>
</form> </form>
</div> </div>
+4 -1
View File
@@ -1,9 +1,12 @@
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
export function AboutSettings() { export function AboutSettings() {
const session = useSession((s) => s.session); const session = useSession((s) => s.session);
const caps = Object.keys(session?.capabilities ?? {}); const caps = Object.keys(session?.capabilities ?? {});
// A deployment running modified code should offer its own source, not ours.
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
return ( return (
<div> <div>
<h1>About ihasmail</h1> <h1>About ihasmail</h1>
@@ -12,7 +15,7 @@ export function AboutSettings() {
<img src="/img/logo.png" alt="ihasmail" width={96} /> <img src="/img/logo.png" alt="ihasmail" width={96} />
<div> <div>
<div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div> <div style={{ fontWeight: 700, fontSize: "1.2em" }}>ihasmail 2.0</div>
<div className="hint">AGPL-3.0-or-later · <a href="https://github.com/LINUXexpert-org/ihasmail" target="_blank" rel="noreferrer">github.com/LINUXexpert-org/ihasmail</a></div> <div className="hint">AGPL-3.0-or-later · <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a></div>
</div> </div>
</div> </div>
<h2>Server</h2> <h2>Server</h2>