Frontend API
Basic Principle
Section titled “Basic Principle”Svelte components never call fetch() directly. Instead, there is a
service class per backend resource in frontend/src/api/, bundled in the
central api object:
<script> import { api, ApiError } from '../api';
let tenants = $state([]);
async function load() { tenants = await api.tenants.getAll(); }</script>Available: api.auth, api.users, api.apiKeys, api.tenants
(incl. submissions/quarantine/stats), api.routes (SMTP routes,
test sending), api.blocklist, api.system (version/build), as well as
api.client (reactive state).
What ApiClient Handles Centrally
Section titled “What ApiClient Handles Centrally”frontend/src/api/client.svelte.js is the only place with fetch:
- Automatically attach JWT: If a session is active, every request gets
the
Authorization: Bearer …header. - Normalize errors: Every error response becomes an
ApiErrorwithstatusandmessage(from the{"error": "..."}body). - Auto-logout on 401: discard session + redirect to
/login— implemented exactly once, applies everywhere. - Session persistence: token + profile in
localStorage; a reload keeps the login.
The auth state is a $state field in a .svelte.js file —
api.client.isLoggedIn, api.client.user, and hasPermission() are
thereby reactive.
The auth Store
Section titled “The auth Store”For components there is the lightweight wrapper
frontend/src/stores/auth.svelte.js:
<script> import { auth } from '../stores/auth.svelte.js';</script>
{#if auth.isLoggedIn} Hello, {auth.user.display_name}!{/if}
{#if auth.can('tenants:write')} <button>New Tenant</button>{/if}auth.can() is purely UI cosmetics (hiding buttons) — the binding check
is always performed by the server via RBAC middleware.
Creating a New API Class
Section titled “Creating a New API Class”1. Class — frontend/src/api/xyz.js (template: routes.js):
export class XyzApi { #client; constructor(client) { this.#client = client; }
getAll() { return this.#client.get('/xyz'); } create(input) { return this.#client.post('/xyz', input); } remove(id) { return this.#client.delete(`/xyz/${id}`); }}2. Register — import it in frontend/src/api/index.js and attach it
to the api object.
3. Use — await api.xyz.getAll() in any component.
Svelte 5 Patterns
Section titled “Svelte 5 Patterns”Local state + loading (template: pages/Tenants.svelte):
<script> let tenants = $state([]); let error = $state('');
async function load() { try { tenants = await api.tenants.getAll(); error = ''; } catch (e) { error = e instanceof ApiError ? e.message : String(e); } }
$effect(() => { load(); });</script>Dependency-triggered reload (template: pages/TenantDetail.svelte
— status filter):
$effect(() => { statusFilter; // tracked dependency: filter change triggers reload load();});Props (svelte-spa-router passes route parameters):
<script> let { params = {} } = $props(); // params.id from '/tenants/:id'</script>Error handling: always catch ApiError and display e.message
— this turns a 403 (“missing permission: tenants:write”) into an
understandable UI message instead of a console exception.
Routing
Section titled “Routing”svelte-spa-router with hash routing (/#/tenants): works in the
single binary without server configuration. Routes are defined in
App.svelte; a new page means a new file in pages/ plus an entry in
the routes object. Links use use:link, programmatic navigation uses
push('/path').
Dev Workflow
Section titled “Dev Workflow”make devstarts the Go backend on :8080 and Vite on :5173 (with an
/api proxy to the backend). Frontend changes appear instantly via
hot reload; for final testing, run make build and start the binary.
The Public API (/v1) is easiest to test with the example page
client/example.html against the
running binary (register the test server’s origin with the tenant).