diff --git a/.env.example b/.env.example
index 90c23cd..eb81223 100644
--- a/.env.example
+++ b/.env.example
@@ -73,8 +73,9 @@ 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/Coffey-Labs/ihasmail
+# and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so
+# the default is this fork.
+SOURCE_URL=https://github.com/inbuxa/ihasmail-inbuxa
# ---- Settings this installation decides (all optional) ----
#
diff --git a/README.md b/README.md
index ca70e38..a2bf470 100644
--- a/README.md
+++ b/README.md
@@ -69,10 +69,13 @@ docker compose up --build -d
## Source code
-Every build carries its own source. The sign-in page and Settings › About
-link to `source.tar.gz`, the exact tree the running version was built from,
-uncommitted work included. It's written next to the app at build time and
-named after that tree.
+INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork:
+. The sign-in page and Settings ›
+About link there, beside the version, which names the commit the running build
+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
diff --git a/docker-compose.yml b/docker-compose.yml
index 5764e30..84b9c1c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -29,7 +29,7 @@ services:
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)}
APP_NAME: ${APP_NAME:-ihasmail}
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"
IMAGE_PROXY: "1"
volumes:
diff --git a/scripts/source-archive.d.mts b/scripts/source-archive.d.mts
deleted file mode 100644
index d394e7e..0000000
--- a/scripts/source-archive.d.mts
+++ /dev/null
@@ -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;
diff --git a/scripts/source-archive.mjs b/scripts/source-archive.mjs
deleted file mode 100644
index c052ecd..0000000
--- a/scripts/source-archive.mjs
+++ /dev/null
@@ -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-` 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 });
- }
-}
diff --git a/server/src/config.ts b/server/src/config.ts
index 1400bd5..ce26650 100644
--- a/server/src/config.ts
+++ b/server/src/config.ts
@@ -292,9 +292,10 @@ export const config = {
*
* 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.
+ * 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"),
port: int("PORT", 8080),
/**
diff --git a/web/src/env.d.ts b/web/src/env.d.ts
index 2e4f777..e49a916 100644
--- a/web/src/env.d.ts
+++ b/web/src/env.d.ts
@@ -6,5 +6,3 @@
* `scripts/version.mjs`.
*/
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;
diff --git a/web/src/lib/source.ts b/web/src/lib/source.ts
index 9750935..74e6266 100644
--- a/web/src/lib/source.ts
+++ b/web/src/lib/source.ts
@@ -4,5 +4,8 @@
* 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.
+ *
+ * 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";
diff --git a/web/src/lib/version.ts b/web/src/lib/version.ts
index ffdf289..7bfb016 100644
--- a/web/src/lib/version.ts
+++ b/web/src/lib/version.ts
@@ -6,10 +6,3 @@
* parts are what they are.
*/
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";
diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx
index 8315576..63d9fdb 100644
--- a/web/src/views/Login.tsx
+++ b/web/src/views/Login.tsx
@@ -3,7 +3,8 @@ import { Eye, EyeOff, LogIn } from "lucide-react";
import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client";
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 { t } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -11,9 +12,8 @@ import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
export function LoginPage() {
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. ihasmail-inbuxa
- // offers the exact source of this build, which the build writes next to the
- // app (see SOURCE_ARCHIVE), rather than a repository link that can drift.
+ // network, and that includes whoever is looking at this form.
+ const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
/*
* What this instance calls itself.
*
@@ -41,6 +41,7 @@ export function LoginPage() {
.then((r) => (r.ok ? r.json() : null))
.then((c) => {
if (!live || !c) return;
+ if (c.sourceUrl) setSourceUrl(c.sourceUrl as string);
if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim());
setSignIn(c.signIn === "oauth" ? "oauth" : "password");
setDirect(c.signIn === "oauth" && c.signInDirect === true);
@@ -156,9 +157,7 @@ export function LoginPage() {
inbuxa.org
{" · "}
- {t("AGPL-3.0 source")}
- {" "}
- ({SOURCE_ID})
+ {t("AGPL-3.0 source")}
diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx
index 066dfd4..2dae186 100644
--- a/web/src/views/settings/AboutSettings.tsx
+++ b/web/src/views/settings/AboutSettings.tsx
@@ -1,7 +1,8 @@
import { useSession } from "@/store/session";
import { useAppName } from "@/lib/brand";
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 { t, tNode } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -10,7 +11,8 @@ export function AboutSettings() {
const appName = useAppName();
const session = useSession((s) => s.session);
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 (
{/* 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. */}
{appName} webmail v{APP_VERSION}
{tNode("Built on {project}", { project:
ihasmail })}
-
+
{t("Server")}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 44be812..7006acc 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -3,31 +3,11 @@ import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
import { resolveVersion } from "../scripts/version.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
// the Docker build, which is handed the answer as IHASMAIL_VERSION instead.
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
* cannot wait until the process starts: the hashed asset URLs are written into
@@ -79,8 +59,8 @@ function assetList(): Plugin {
export default defineConfig({
base,
- plugins: [react(), assetList(), sourceArchive()],
- define: { __IHASMAIL_VERSION__: JSON.stringify(version), __SOURCE_ID__: JSON.stringify(source.id) },
+ plugins: [react(), assetList()],
+ define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},