Files
jcoffey de120ba7ca 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.
2026-09-20 16:07:30 -07:00

108 lines
4.1 KiB
TypeScript

import { defineConfig, type Plugin } from "vitest/config";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
import { resolveVersion } from "../scripts/version.mjs";
import { baseUrlOf } from "../scripts/basePath.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();
/*
* 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
* index.html when the bundle is built, so a build that does not know its prefix
* emits `/assets/...` and the shell 404s under `/mail/`. So `BASE_PATH` is read
* at build time here as well as at run time in the server, and the Dockerfile
* carries one value into both.
*
* Vite wants the directory form with the trailing slash, and hands it back to
* the app as `import.meta.env.BASE_URL` -- which is where `lib/basePath.ts`
* gets it, so the browser never has to be told separately.
*/
const base = baseUrlOf(process.env.BASE_PATH);
/*
* Every file the build made, written into the app page for the service worker.
*
* The page names only what it loads at start; the composer, settings, viewers
* and the rest arrive when first used, and after each deploy that first use
* went back to the server. With the whole list in the page, the worker can
* fetch them in the background once a new version is seen, and knows to keep
* them. Language catalogs are listed apart: a reader wants one of them, which
* is cached when it is first loaded.
*
* An inert JSON block rather than prefetch links, which the browser would
* fetch on every load.
*/
function assetList(): Plugin {
return {
name: "ihasmail-asset-list",
apply: "build",
transformIndexHtml: {
order: "post",
handler(html, ctx) {
if (!ctx.bundle) return html;
const precache: string[] = [];
const onDemand: string[] = [];
for (const file of Object.values(ctx.bundle)) {
if (!file.fileName.startsWith("assets/") || file.fileName.endsWith(".map")) continue;
const catalog = file.type === "chunk" && file.moduleIds.length > 0 && file.moduleIds.every((id) => /[\\/]src[\\/]locales[\\/][^\\/]+\.ts$/.test(id));
(catalog ? onDemand : precache).push(`${base}${file.fileName}`);
}
const json = JSON.stringify({ precache: precache.sort(), onDemand: onDemand.sort() });
return html.replace("</body>", ` <script type="application/json" id="ihasmail-assets">${json}</script>\n </body>`);
},
},
};
}
export default defineConfig({
base,
plugins: [react(), assetList()],
define: { __IHASMAIL_VERSION__: JSON.stringify(version) },
resolve: {
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
},
server: {
port: 5173,
proxy: {
// Under a prefix the dev server serves the app from `base`, so the app's
// API calls arrive here prefixed too. Forwarded whole, prefix included:
// the dev server behind this reads the same BASE_PATH and expects it.
[`${base}api`]: {
target: "http://127.0.0.1:8080",
changeOrigin: false,
},
},
},
build: {
target: "es2022",
sourcemap: false,
rolldownOptions: {
output: {
/*
* Rolldown, which vite 8 bundles with, dropped the object form of
* `manualChunks` -- naming a chunk and listing the packages in it --
* and takes groups matched against module paths instead. Same two
* chunks out the other end; `icons` is listed first because groups are
* tried in order and the first match wins.
*/
codeSplitting: {
groups: [
{ name: "icons", test: /node_modules[\\/]lucide-react[\\/]/ },
{
name: "vendor",
test: /node_modules[\\/](wouter|zustand|dompurify|@tanstack[\\/]react-virtual)[\\/]/,
},
],
},
},
},
},
test: {
environment: "jsdom",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
});