Skip to content

Frontend API

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).

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 ApiError with status and message (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.

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.

1. Classfrontend/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. Useawait api.xyz.getAll() in any component.

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.

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').

Terminal window
make dev

starts 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).