The source offer is a link to this fork, not a tarball in the image (#2)

INBUXA's webmail built its own source into every image: the whole tree,
web and server, packed as dist/source.tar.gz with an identity string
beside the link naming the exact tree it came from. The sign-in page and
Settings > About offered that download.

It answered the AGPL precisely -- the source of *this* build, uncommitted
work and all -- but it paid for that precision by carrying 2.5 MB of
source into production on every deploy, to a repository that is public
and already has it. The fork is at github.com/inbuxa/ihasmail-inbuxa;
the version shown directly above the link already names the commit the
build came from, so the link and the version together say the same
thing the archive said.

Both links now go there, through the mechanism upstream ihasmail already
has and this fork had replaced: the server's SOURCE_URL, read from
/api/config on the sign-in page and from the session in About, with
web/src/lib/source.ts as the fallback before either answers. That
mechanism is better than a hardcoded URL for the deployer who patches
this tree -- they set SOURCE_URL and both links follow -- which is the
case the AGPL is actually about. The defaults in config.ts, the compose
file and .env.example move from the upstream repo to this one, since a
build from this tree is a modified ihasmail and its offer is ours.

Removed with it: scripts/source-archive.mjs and its type stub, the Vite
plugin that ran it, __SOURCE_ID__, and SOURCE_ARCHIVE/SOURCE_ID. The
build no longer shells out to git or tar, and nothing is written next
to the app.

Links to Coffey-Labs/ihasmail that are credit rather than a source
offer -- the README's "built on", the translation issue link -- are
left alone.

No new strings: "AGPL-3.0 source" is unchanged, and the About line keeps
its existing {source} placeholder, now filled with the host and path
instead of a file name.
This commit is contained in:
jcoffey
2026-09-20 16:07:30 -07:00
committed by GitHub
parent 25763832f3
commit de120ba7ca
12 changed files with 31 additions and 164 deletions
+3 -2
View File
@@ -73,8 +73,9 @@ APP_NAME=ihasmail
# Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which # 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 # 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 # you have patched it, point this at your own tree. Shown on the sign-in page
# and in Settings > About. # and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so
SOURCE_URL=https://github.com/Coffey-Labs/ihasmail # the default is this fork.
SOURCE_URL=https://github.com/inbuxa/ihasmail-inbuxa
# ---- Settings this installation decides (all optional) ---- # ---- Settings this installation decides (all optional) ----
# #
+7 -4
View File
@@ -69,10 +69,13 @@ docker compose up --build -d
## Source code ## Source code
Every build carries its own source. The sign-in page and Settings About INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork:
link to `source.tar.gz`, the exact tree the running version was built from, <https://github.com/inbuxa/ihasmail-inbuxa>. The sign-in page and Settings
uncommitted work included. It's written next to the app at build time and About link there, beside the version, which names the commit the running build
named after that tree. came from.
Run your own patched build and that offer becomes yours, not ours: point
`SOURCE_URL` at your tree and both links follow it.
## Development ## Development
+1 -1
View File
@@ -29,7 +29,7 @@ services:
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}
BASE_PATH: ${BASE_PATH:-} BASE_PATH: ${BASE_PATH:-}
SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail} SOURCE_URL: ${SOURCE_URL:-https://github.com/inbuxa/ihasmail-inbuxa}
TRUST_PROXY: "1" TRUST_PROXY: "1"
IMAGE_PROXY: "1" IMAGE_PROXY: "1"
volumes: volumes:
-8
View File
@@ -1,8 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export function sourceIdentity(root: string): { ref: string | null; id: string };
export function writeSourceArchive(root: string, outFile: string, name: string, identity: { ref: string | null; id: string }): void;
-105
View File
@@ -1,105 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/*
* The AGPL's offer, for this build: the exact source it was built from,
* written next to the app as `source.tar.gz`, and an identity for it that the
* interface shows beside the download link.
*
* In a git checkout, "exact" includes uncommitted work, new files too: every
* file git doesn't ignore goes into a throwaway index, never the real one, and
* the tree that makes is what gets archived. The identity is HEAD's short id,
* with `+local-<tree>` when the tree differs from HEAD's.
*
* In the Docker build there is no git (.dockerignore keeps .git out on
* purpose), so the build context's files are packed as they are, minus what
* .dockerignore already dropped and what the build made. The identity is then
* a hash of those files' paths and contents, so the same source always gets
* the same name.
*/
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative } from "node:path";
const SKIP = new Set(["node_modules", "dist", ".git", "coverage"]);
/** Local state, never source: the session file's folder. */
const SKIP_PATHS = new Set(["server/data"]);
function git(args, cwd, env) {
return execFileSync("git", args, { cwd, env: env ?? process.env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
}
function hasGit(root) {
try {
return git(["rev-parse", "--is-inside-work-tree"], root) === "true";
} catch {
return false;
}
}
/** Every file that isn't build output, dependencies or local data, sorted. */
function projectFiles(root) {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (SKIP.has(entry.name) || entry.name === "source.tar.gz" || SKIP_PATHS.has(relative(root, full))) continue;
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) out.push(relative(root, full));
}
};
walk(root);
return out.sort();
}
/** @returns {{ ref: string | null, id: string }} */
export function sourceIdentity(root) {
if (hasGit(root)) {
const dir = mkdtempSync(join(tmpdir(), "inbuxa-source-"));
try {
const env = { ...process.env, GIT_INDEX_FILE: join(dir, "index") };
git(["read-tree", "HEAD"], root, env);
git(["add", "--all", "."], root, env);
const tree = git(["write-tree"], root, env);
const head = git(["rev-parse", "--short=12", "HEAD"], root);
return tree === git(["rev-parse", "HEAD^{tree}"], root)
? { ref: tree, id: head }
: { ref: tree, id: `${head}+local-${tree.slice(0, 12)}` };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const hash = createHash("sha256");
for (const file of projectFiles(root)) {
hash.update(file).update("\0").update(readFileSync(join(root, file))).update("\0");
}
return { ref: null, id: `files-${hash.digest("hex").slice(0, 12)}` };
}
/** Write the archive of `identity`'s tree to `outFile`. */
export function writeSourceArchive(root, outFile, name, identity) {
if (!existsSync(dirname(outFile))) mkdirSync(dirname(outFile), { recursive: true });
const prefix = `${name}-${identity.id}`;
if (identity.ref) {
execFileSync("git", ["archive", "--format=tar.gz", `--prefix=${prefix}/`, "-o", outFile, identity.ref], { cwd: root });
return;
}
// Staged under the prefix and packed from there: BusyBox tar, in the Alpine
// image, can't rewrite paths as it packs.
const stage = mkdtempSync(join(tmpdir(), "inbuxa-source-"));
try {
for (const file of projectFiles(root)) {
const to = join(stage, prefix, file);
mkdirSync(dirname(to), { recursive: true });
copyFileSync(join(root, file), to);
}
execFileSync("tar", ["-czf", outFile, "-C", stage, prefix]);
} finally {
rmSync(stage, { recursive: true, force: true });
}
}
+3 -2
View File
@@ -292,9 +292,10 @@ export const config = {
* *
* The AGPL asks whoever *runs* a modified version to offer that version's * 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 * source, not the one it was forked from -- so anyone deploying a patched
* ihasmail should point this at their own tree. * ihasmail should point this at their own tree. ihasmail-inbuxa is itself
* such a tree, so the default is INBUXA's fork.
*/ */
sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"), sourceUrl: env("SOURCE_URL", "https://github.com/inbuxa/ihasmail-inbuxa"),
host: env("HOST", "0.0.0.0"), host: env("HOST", "0.0.0.0"),
port: int("PORT", 8080), port: int("PORT", 8080),
/** /**
-2
View File
@@ -6,5 +6,3 @@
* `scripts/version.mjs`. * `scripts/version.mjs`.
*/ */
declare const __IHASMAIL_VERSION__: string; declare const __IHASMAIL_VERSION__: string;
/** ihasmail-inbuxa: the identity of the source this build was made from (scripts/source-archive.mjs). */
declare const __SOURCE_ID__: string;
+4 -1
View File
@@ -4,5 +4,8 @@
* The AGPL asks whoever runs a modified version to offer *that* version's * 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 * 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. * fallback for when it has not been asked yet, or has nothing to say.
*
* ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's
* fork and not the project it came from.
*/ */
export const DEFAULT_SOURCE_URL = "https://github.com/Coffey-Labs/ihasmail"; export const DEFAULT_SOURCE_URL = "https://github.com/inbuxa/ihasmail-inbuxa";
-7
View File
@@ -6,10 +6,3 @@
* parts are what they are. * parts are what they are.
*/ */
export const APP_VERSION = __IHASMAIL_VERSION__; export const APP_VERSION = __IHASMAIL_VERSION__;
/**
* ihasmail-inbuxa: the source this build was made from, which the build writes
* next to the app as `source.tar.gz`. The AGPL's offer links there.
*/
export const SOURCE_ID = __SOURCE_ID__;
export const SOURCE_ARCHIVE = "/source.tar.gz";
+6 -7
View File
@@ -3,7 +3,8 @@ import { Eye, EyeOff, LogIn } from "lucide-react";
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client"; import { ApiError } from "@/jmap/client";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { DEFAULT_APP_NAME } from "@/lib/brand"; import { DEFAULT_APP_NAME } from "@/lib/brand";
import { t } from "@/lib/i18n"; import { t } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -11,9 +12,8 @@ import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
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 // 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. ihasmail-inbuxa // network, and that includes whoever is looking at this form.
// offers the exact source of this build, which the build writes next to the const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
// app (see SOURCE_ARCHIVE), rather than a repository link that can drift.
/* /*
* What this instance calls itself. * What this instance calls itself.
* *
@@ -41,6 +41,7 @@ export function LoginPage() {
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((c) => { .then((c) => {
if (!live || !c) return; if (!live || !c) return;
if (c.sourceUrl) setSourceUrl(c.sourceUrl as string);
if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim()); if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim());
setSignIn(c.signIn === "oauth" ? "oauth" : "password"); setSignIn(c.signIn === "oauth" ? "oauth" : "password");
setDirect(c.signIn === "oauth" && c.signInDirect === true); setDirect(c.signIn === "oauth" && c.signInDirect === true);
@@ -156,9 +157,7 @@ export function LoginPage() {
<br /> <br />
<a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a> <a href="https://inbuxa.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">inbuxa.org</a>
{" · "} {" · "}
<a href={withBase(SOURCE_ARCHIVE)} target="_blank" rel="noopener noreferrer">{t("AGPL-3.0 source")}</a> <a href={sourceUrl} target="_blank" rel="noopener noreferrer">{t("AGPL-3.0 source")}</a>
{" "}
<span className="notranslate" translate="no">({SOURCE_ID})</span>
</p> </p>
</form> </form>
</div> </div>
+5 -3
View File
@@ -1,7 +1,8 @@
import { useSession } from "@/store/session"; import { useSession } from "@/store/session";
import { useAppName } from "@/lib/brand"; import { useAppName } from "@/lib/brand";
import { client } from "@/jmap/client"; import { client } from "@/jmap/client";
import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version"; import { APP_VERSION } from "@/lib/version";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { withBase } from "@/lib/basePath"; import { withBase } from "@/lib/basePath";
import { t, tNode } from "@/lib/i18n"; import { t, tNode } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -10,7 +11,8 @@ export function AboutSettings() {
const appName = useAppName(); const appName = useAppName();
const session = useSession((s) => s.session); const session = useSession((s) => s.session);
const caps = Object.keys(session?.capabilities ?? {}); const caps = Object.keys(session?.capabilities ?? {});
// The exact source of this build, written next to the app by the build. // A deployment running modified code should offer its own source, not ours.
const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL;
return ( return (
<div> <div>
{/* ihasmail-inbuxa: INBUXA's webmail, built on ihasmail. The version and {/* ihasmail-inbuxa: INBUXA's webmail, built on ihasmail. The version and
@@ -26,7 +28,7 @@ export function AboutSettings() {
{/* A product name and a version string: neither is a word to translate. */} {/* A product name and a version string: neither is a word to translate. */}
<div style={{ fontWeight: 700 }} className="notranslate" translate="no">{appName} webmail v{APP_VERSION}</div> <div style={{ fontWeight: 700 }} className="notranslate" translate="no">{appName} webmail v{APP_VERSION}</div>
<div className="hint">{tNode("Built on {project}", { project: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">ihasmail</a> })}</div> <div className="hint">{tNode("Built on {project}", { project: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">ihasmail</a> })}</div>
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={withBase(SOURCE_ARCHIVE)} target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">source.tar.gz ({SOURCE_ID})</a> })}</div> <div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={sourceUrl} target="_blank" rel="noopener noreferrer" className="notranslate" translate="no">{sourceUrl.replace(/^https?:\/\//, "")}</a> })}</div>
</div> </div>
</div> </div>
<h2>{t("Server")}</h2> <h2>{t("Server")}</h2>
+2 -22
View File
@@ -3,31 +3,11 @@ import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { resolveVersion } from "../scripts/version.mjs"; import { resolveVersion } from "../scripts/version.mjs";
import { baseUrlOf } from "../scripts/basePath.mjs"; import { baseUrlOf } from "../scripts/basePath.mjs";
import { sourceIdentity, writeSourceArchive } from "../scripts/source-archive.mjs";
// Resolved here, at build time: the browser has no git to ask, and neither does // Resolved here, at build time: the browser has no git to ask, and neither does
// the Docker build, which is handed the answer as IHASMAIL_VERSION instead. // the Docker build, which is handed the answer as IHASMAIL_VERSION instead.
const version = resolveVersion(); const version = resolveVersion();
/*
* ihasmail-inbuxa: the AGPL's offer for this build. The whole project's source
* (web and server), exactly as built, goes into dist/source.tar.gz, and its
* identity into the app so the download link can name it. See
* scripts/source-archive.mjs.
*/
const projectRoot = fileURLToPath(new URL("..", import.meta.url));
const source = sourceIdentity(projectRoot);
function sourceArchive(): Plugin {
return {
name: "inbuxa-source-archive",
apply: "build",
closeBundle() {
writeSourceArchive(projectRoot, fileURLToPath(new URL("./dist/source.tar.gz", import.meta.url)), "ihasmail-inbuxa", source);
},
};
}
/* /*
* Where the app is mounted. Unlike everything else ihasmail is told, this one * Where the app is mounted. Unlike everything else ihasmail is told, this one
* cannot wait until the process starts: the hashed asset URLs are written into * cannot wait until the process starts: the hashed asset URLs are written into
@@ -79,8 +59,8 @@ function assetList(): Plugin {
export default defineConfig({ export default defineConfig({
base, base,
plugins: [react(), assetList(), sourceArchive()], plugins: [react(), assetList()],
define: { __IHASMAIL_VERSION__: JSON.stringify(version), __SOURCE_ID__: JSON.stringify(source.id) }, define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
resolve: { resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
}, },