AGPL source offer and name cleanup

Every build writes the exact source it was built from, uncommitted work and
new files included, as source.tar.gz next to the app, named after that tree.
Docker builds, which have no git, pack the build context and name it by a
hash of its files. The sign-in page and Settings > About link to it instead of
a repository that can drift.

What users, operators and packagers see no longer names the upstream server:
- interface text, in all nine catalogues, with a token-session line for
  Security;
- server messages;
- the settings, now MAIL_SERVER_URL, MAIL_SERVERS_FILE, ADMIN_URL and
  MAIL_SERVER_FOLLOW_ADVERTISED_URLS, and mail-servers.example.json;
- the Tenants notice, which is gone;
- the README, CONTRIBUTING and SECURITY.

ihasmail's own FEATURES, KNOWN-ISSUES and ROADMAP stay with public ihasmail,
and INBUXA.md is folded into the README.
This commit is contained in:
2026-09-19 00:13:12 -07:00
parent 9d2de725c9
commit bb25355c23
59 changed files with 444 additions and 2436 deletions
+7 -7
View File
@@ -3,8 +3,7 @@ import { Eye, EyeOff, LogIn } from "lucide-react";
import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client";
import { withBase } from "@/lib/basePath";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version";
import { DEFAULT_APP_NAME } from "@/lib/brand";
import { t } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -12,9 +11,9 @@ 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. The server says
// where its own source lives, so a modified deployment points at its own.
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
// 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.
/*
* What this instance calls itself.
*
@@ -42,7 +41,6 @@ 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);
@@ -157,7 +155,9 @@ export function LoginPage() {
<br />
<a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{t("ihasmail.org")}</a>
{" · "}
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">{t("AGPL-3.0 source")}</a>
<a href={withBase(SOURCE_ARCHIVE)} download>{t("AGPL-3.0 source")}</a>
{" "}
<span className="notranslate" translate="no">({SOURCE_ID})</span>
</p>
</form>
</div>
+2 -2
View File
@@ -115,12 +115,12 @@ export function AdminDashboard() {
more numbers will be: this is a glance, and operating the server is
Stalwart's own administration. The link is the operator's to give. */}
<p className="hint admin-dashboard-note">
{t("Detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration.")}
{t("Detailed metrics, the delivery queue, logs and server settings are in INBUXA Admin.")}
{adminUrl && (
<>
{" "}
<a href={adminUrl} target="_blank" rel="noopener noreferrer">
{t("Open Stalwart admin")} <ExternalLink size={13} aria-hidden="true" />
{t("Open INBUXA Admin")} <ExternalLink size={13} aria-hidden="true" />
</a>
</>
)}
+2 -2
View File
@@ -153,7 +153,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
{!creating && !locked && !can(perms, "Role", "Update") && <p className="admin-notice">{t("Your role lets you view roles but not change them.")}</p>}
{kinds.length > 0 && (
<p className="admin-notice warn">
<span>{t("Stalwart gives this role by default to {kinds}. A change here reaches everyone who has it that way.", { kinds: kinds.join(", ") })}</span>
<span>{t("The mail server gives this role by default to {kinds}. A change here reaches everyone who has it that way.", { kinds: kinds.join(", ") })}</span>
</p>
)}
@@ -186,7 +186,7 @@ export function RoleSheet({ role, roles, defaults, entries, permissionsError, on
{!creating && can(perms, "Role", "Destroy") && (
<DeleteRole
role={role}
blocked={kinds.length ? t("Stalwart gives this role by default, so it can't be deleted. Change the defaults in Stalwart's own administration first.") : locked ? t("This role carries permissions yours doesn't.") : null}
blocked={kinds.length ? t("The mail server gives this role by default, so it can't be deleted. Change the defaults in INBUXA Admin first.") : locked ? t("This role carries permissions yours doesn't.") : null}
onDeleted={onDeleted}
/>
)}
+1 -1
View File
@@ -50,7 +50,7 @@ export function RolesAdmin({ selectedId }: { selectedId?: string }) {
let canceled = false;
Promise.all([loadPermissionList(), loadPermissionCatalog()]).then(
([list, catalog]) => !canceled && setEntries(describePermissions(list, catalog, t("General"))),
(err) => !canceled && setPermissionsError(t("Stalwart's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })),
(err) => !canceled && setPermissionsError(t("The mail server's list of permissions could not be loaded, so permissions can't be changed here. ({reason})", { reason: describeDirectoryError(err, "role") })),
);
return () => {
canceled = true;
+2 -2
View File
@@ -176,7 +176,7 @@ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDe
<div className="field">
<label htmlFor="admin-tenant-logo">{t("Logo")}</label>
<input id="admin-tenant-logo" className="input" value={logo} disabled={!editable} placeholder="https://…" spellCheck={false} onChange={(e) => setLogo(e.target.value)} />
<span className="hint">{t("An https address or a data URL of an image. Stalwart shows it to the tenant's people where it shows a logo.")}</span>
<span className="hint">{t("An https address or a data URL of an image. The mail server shows it to the tenant's people where it shows a logo.")}</span>
</div>
{!creating && (
@@ -215,7 +215,7 @@ export function TenantSheet({ tenant, roles, onClose, onChanged, onCreated, onDe
</div>
))}
</div>
<p className="hint">{t("Stalwart refuses to create more than a limit allows. An empty field is no limit.")}</p>
<p className="hint">{t("The mail server refuses to create more than a limit allows. An empty field is no limit.")}</p>
<h3>{t("Role")}</h3>
<select className="input admin-wide" aria-label={t("Role")} value={role} disabled={!editable} onChange={(e) => setRole(e.target.value)}>
+5 -15
View File
@@ -7,7 +7,6 @@ import { drawableLogo, getTenants, queryTenants, type DirectoryTenant } from "@/
import { formatSize } from "@/lib/format";
import { proxiedImageUrl } from "@/lib/text/html";
import { plural, t } from "@/lib/i18n";
import { useSession } from "@/store/session";
import { Empty, Spinner } from "@/ui/misc";
import { usePermissions } from "./usePermissions";
import { TenantSheet } from "./TenantSheet";
@@ -18,23 +17,15 @@ const PAGE_SIZE = 50;
* Tenants: separate organizations on one server, each with its own people,
* domains and limits.
*
* The section is offered to whoever may read tenants. INBUXA ships tenants to
* everybody, whatever edition the server reports, so there is no edition
* check here (public ihasmail shows only a notice unless the server reports
* Enterprise). SHOW_ENTERPRISE_NOTICES still adds the notice, for talking to
* upstream Stalwart.
* The section is offered to whoever may read tenants. The INBUXA mail server
* has one edition with tenants in it, so there is no edition check and no
* notice here, unlike public ihasmail.
*/
export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
const notices = useSession((s) => s.session?.ihasmail?.server?.enterpriseNotices === true);
return <EnterpriseTenants selectedId={selectedId} notice={notices} />;
return <EnterpriseTenants selectedId={selectedId} />;
}
/** Said on every Tenants page, Enterprise or not. */
function EnterpriseNotice({ warn }: { warn: boolean }) {
return <p className={`admin-notice${warn ? " warn" : ""}`}>{t("Tenants are a Stalwart Enterprise feature.")}</p>;
}
function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice: boolean }) {
function EnterpriseTenants({ selectedId }: { selectedId?: string }) {
const [, navigate] = useLocation();
const perms = usePermissions();
const [text, setText] = useState("");
@@ -111,7 +102,6 @@ function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice
)}
</div>
{notice && <EnterpriseNotice warn={false} />}
<div className="admin-toolbar">
<label className="admin-search">
@@ -112,7 +112,7 @@ describe("the Administration dashboard", () => {
});
});
describe("the pointer to Stalwart's own administration", () => {
describe("the pointer to INBUXA Admin", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
@@ -136,7 +136,7 @@ describe("the pointer to Stalwart's own administration", () => {
it("names it, and links it where the operator has said where it is", async () => {
await renderWith("https://admin.example.com");
const note = host.querySelector(".admin-dashboard-note")!;
expect(note.textContent).toContain("Stalwart's own administration");
expect(note.textContent).toContain("INBUXA Admin");
const link = note.querySelector("a")!;
expect(link.getAttribute("href")).toBe("https://admin.example.com");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
@@ -144,7 +144,7 @@ describe("the pointer to Stalwart's own administration", () => {
it("names it without a link where nobody has", async () => {
await renderWith(null);
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("Stalwart's own administration");
expect(host.querySelector(".admin-dashboard-note")?.textContent).toContain("INBUXA Admin");
expect(host.querySelector(".admin-dashboard-note a")).toBeNull();
});
});
@@ -86,7 +86,7 @@ describe("the role sheet", () => {
it("warns about a default role and will not delete it", async () => {
signIn(VIEWER);
await render(roles.get("user")!, { user: ["user"], group: [], tenant: [], admin: [] });
expect(host.textContent).toContain("Stalwart gives this role by default to users");
expect(host.textContent).toContain("The mail server gives this role by default to users");
expect(button(host, "Delete role…")?.disabled).toBe(true);
});
});
@@ -69,15 +69,14 @@ describe("the Tenants page where the installation asks for Enterprise notices",
host.remove();
});
it("says tenants are Enterprise above the list, as the demo does", async () => {
it("shows no Enterprise notice, even where the installation asks for one", async () => {
signIn("enterprise", true);
const { hook } = memoryLocation({ path: "/admin/tenants" });
await act(async () => {
root.render(<Router hook={hook}><TenantsAdmin /></Router>);
});
await act(async () => {});
expect(host.querySelector(".admin-notice")?.textContent).toBe("Tenants are a Stalwart Enterprise feature.");
expect(host.querySelector(".admin-notice.warn")).toBeNull();
expect(host.querySelector(".admin-notice")).toBeNull();
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
});
});
+5 -7
View File
@@ -1,7 +1,6 @@
import { useSession } from "@/store/session";
import { client } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version";
import { withBase } from "@/lib/basePath";
import { t, tNode } from "@/lib/i18n";
import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
@@ -9,8 +8,7 @@ import { InbuxaWordmark } from "@/ui/InbuxaWordmark";
export function AboutSettings() {
const session = useSession((s) => s.session);
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;
// The exact source of this build, written next to the app by the build.
return (
<div>
{/* ihasmail-inbuxa: INBUXA's webmail, built on ihasmail, whose version
@@ -23,7 +21,7 @@ export function AboutSettings() {
<InbuxaWordmark height={26} />
{/* A product name and a version string: neither is a word to translate. */}
<div style={{ fontWeight: 700 }} className="notranslate" translate="no">ihasmail v{APP_VERSION}</div>
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={sourceUrl} target="_blank" rel="noreferrer">{sourceUrl.replace(/^https?:\/\//, "")}</a> })}</div>
<div className="hint">{tNode("AGPL-3.0-or-later · {source}", { source: <a href={withBase(SOURCE_ARCHIVE)} download className="notranslate" translate="no">source.tar.gz ({SOURCE_ID})</a> })}</div>
</div>
</div>
<h2>{t("Server")}</h2>
@@ -36,8 +34,8 @@ export function AboutSettings() {
<tr><td>{t("Image privacy proxy")}</td><td>{session?.ihasmail?.imageProxy ? t("enabled") : t("disabled")}</td></tr>
</tbody>
</table>
<p className="hint" style={{ marginTop: 6 }}>{t("Stalwart does not publish its version number to mail clients, so ihasmail reports the edition where the server gives one. ihasmail requires 0.16 or newer, and sign-in refuses anything older.")}</p>
<p className="hint">{tNode("ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about Stalwart; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> })}</p>
<p className="hint" style={{ marginTop: 6 }}>{t("This webmail works with the INBUXA mail server, and sign-in refuses a server that doesn't offer what it needs.")}</p>
<p className="hint">{tNode("ihasmail's own version is the date of the commit it was built from, followed by where that commit came from: {example} was built from a commit dated the 30th of August 2026 that arrived through pull request 129. A commit that did not come through one carries its short SHA instead — {sha}. The version deliberately says nothing about the mail server; what this build needs from the server is the line above.", { example: <strong className="notranslate" translate="no">v2026.8.30+pr129</strong>, sha: <code>+g1fa6578</code> })}</p>
<h2>{t("Server capabilities")}</h2>
<div className="row wrap gap-4">
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
+5 -1
View File
@@ -60,7 +60,11 @@ export function SecuritySettings() {
return (
<div>
<h1>{t("Security & sessions")}</h1>
<p className="lead">{tNode("You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.", { user: <b className="notranslate" translate="no">{session?.username}</b> })}</p>
<p className="lead">
{session?.ihasmail?.signIn === "oauth"
? tNode("You're signed in as {user}. This webmail never sees your password: it holds a sign-in token from your mail server, encrypted per session.", { user: <b className="notranslate" translate="no">{session?.username}</b> })
: tNode("You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to the mail server.", { user: <b className="notranslate" translate="no">{session?.username}</b> })}
</p>
<h2>{t("Password")}</h2>
{unsupported ? (