Skip to content

Architecture Overview

Dejan Strbac edited this page Nov 12, 2026 · 1 revision

This page describes how ALPS is built and how a browser request reaches the mail server. It covers the Go backend with its sessions, providers and plugins, the Lit frontend embedded in the same binary, and one request followed from the browser to IMAP and SMTP. The full reference is ARCHITECTURE.md.

What ALPS is

ALPS is a webmail with contacts, calendars, mail filters and OpenPGP. It is one Go program and keeps no database. It works on each user's behalf with the username and password typed on the sign-in page:

  • it reads and files mail on an IMAP server, or in a local maildir;
  • it sends through an SMTP submission server;
  • it keeps each account's settings, security keys, linked accounts, OpenPGP keyring and own CalDAV and CardDAV connections on the IMAP server, as IMAP METADATA (see Storage in IMAP METADATA).

On its own host, ALPS holds the sessions, their caches, the rate-limit counts and attachments uploaded but not yet sent. These live in memory (large uploads in temporary files), and a restart loses them.

ALPS has two parts:

  • The backend is an HTTP server written in Go. It serves a JSON API and the frontend's files, and it talks to the mail server and the other services for each signed-in user.
  • The frontend is a single-page application built from Lit web components. It talks only to the backend's API, and its built files are embedded in the Go binary.
flowchart LR
    Browser["Browser: the Lit frontend"] -->|"JSON API, static files, event stream"| ALPS["ALPS: one Go binary"]
    ALPS -->|IMAP| IMAP["IMAP server"]
    ALPS -->|"SMTP submission"| SMTP["SMTP server"]
    ALPS -.->|files| Maildir["Local maildir"]
    ALPS -.->|"CalDAV, CardDAV"| DAV["CalDAV and CardDAV servers"]
    ALPS -.->|ManageSieve| Sieve["ManageSieve server"]
    ALPS -.->|"HTTP API or local command"| Pwd["Password change backend"]
    ALPS -.->|gossip| Peers["Other ALPS nodes"]
    ALPS -.->|"ACME, S3"| LE["Let's Encrypt and certificate storage"]
Loading

The dotted connections are used only when the matching provider, plugin or feature is configured. The local maildir takes the IMAP server's place. Besides the CalDAV and CardDAV servers in the configuration, users can enter servers of their own in Settings, and ALPS connects to those too. Mail Server Requirements says what ALPS needs from the IMAP and SMTP servers. Outbound Connections lists every connection the host must allow.

One binary

  • The program is cmd/alps. It needs -config <file>, a TOML file (see Configuration Overview). It listens on [server] addr, by default :1323.
  • The frontend is compiled in. server.go embeds it with //go:embed all:frontend/dist. frontend/dist is tracked in git, so building and running ALPS needs Go but not Node.js. The binary embeds whatever frontend/dist holds when it is compiled, so after a frontend change, run make build-frontend and then make build.
  • The plugins and the mail providers are compiled in too, through blank imports. The plugins are imported in cmd/alps/plugins.go, and the providers imap, maildir and multi in cmd/alps/config.go. No plugin or provider is loaded from disk at runtime.
  • Building. make build writes build/alps and stamps in the version, commit and date for alps -version. make build-linux and make build-freebsd produce static amd64 binaries (CGO_ENABLED=0). ALPS builds on Linux, macOS, the BSDs and Windows. On Windows, [logging] output = "syslog" falls back to standard error, and the password plugin's command backend is refused, since it needs a Unix host. The README lists the Go and Node.js versions.
  • Outside the binary are the config file and the temp directory, which holds large uploads and the BIMI logo cache. Depending on the configuration, so are the log file, the Let's Encrypt cache, the maildir and its passwd file. See What ALPS Writes to Disk. A plugin may also serve files from plugins/<name>/public/assets, relative to the working directory, at /plugins/<name>/assets/. None of the plugins that ship with ALPS has such a directory.
  • The configuration is read once, at startup. ALPS logs a warning for each key it does not know, such as Unknown configuration key: server.write_timeout (ignored), and goes on without it. To apply a change, restart ALPS. SIGHUP only reopens the log file, when [logging] output is a file path, and SIGINT or SIGTERM shut ALPS down gracefully (Signals and Graceful Shutdown).

How ALPS serves the embedded files. At startup it lays the configured [branding] over the files (see Branding) and computes an ETag for each one. Files under assets/ whose names carry a content hash are sent with Cache-Control: public, max-age=31536000, immutable. index.html and the other files are sent with no-cache, so the browser asks again every time and an unchanged file costs a 304. Files are gzipped when compression helps.

How an open tab notices a new release. Every answer, the API's included, carries X-Alps-Build, a hash of the index.html being served. That page carries the [branding] settings, and with a favicon package as logo a fingerprint of its files, so a changed icon changes the hash too. A tab that sees a different value from the one it started with offers a reload in a notice. It reloads by itself only when a reload would lose nothing (no composer open, no unsaved settings or edits), and only at one of two moments: after half an hour in the background, or when a page it needs is no longer on the server.

Sessions and the IMAP connection

ALPS keeps no shared connection pool. Each session has connections of its own, and it keeps them across HTTP requests. The steps below are for the imap provider. The maildir provider checks the password against its passwd file instead, and reads the maildir on disk.

  1. Sign-in. On POST /session, ALPS connects to the mail server and signs in with IMAP LOGIN, using the username and password the user typed. That connection becomes the new session's own connection. The session is an object in the memory of the ALPS process, and the alps_session cookie names it.
  2. Later requests. ALPS finds the session through the cookie, and the handler's mail work runs on the session's own connection, one operation at a time. A request does not sign in again or open a new connection.
  3. A lost connection. When an operation fails because the connection was lost, ALPS closes that connection, opens a new one and runs the operation once more on it. Moving, copying and deleting messages, and creating, deleting, renaming and emptying folders, are not run again, because the first attempt may already have taken effect: the operation reports its error, and the next one opens a new connection.
  4. The background connection. Work the user is not waiting for runs on a second connection, so it does not hold up the message the user opens next. That work includes folder counts and checks for new mail, sender verdicts, message previews, the storage quota, draft saves, the search of Sent for a conversation's replies, and messages a conversation reads ahead. The second connection is opened when it is first needed and closed after one minute unused. If the server refuses it, the work runs on the session's own connection, and the session does not ask again for five minutes. To turn the second connection off, set [server] background_connection = false (it is on by default).
  5. Push (optional). With [server] push_new_mail = true, the background connection also waits in IMAP IDLE while a page holds the GET /events stream open, so it stays open as long as that page does. It tells the page when a message arrives in the Inbox or leaves it. Push is off by default, and needs the background connection: with background_connection = false, ALPS logs that new mail is not pushed. The page checks for new mail on a timer either way.

A session therefore holds up to two IMAP connections, and for a moment a third. That third is opened while a command the session gave up on is still running. All of them come from ALPS's own address, so the server's per-user and per-address connection limits must leave room for every session ALPS holds (see Mail Server Requirements).

The session cache. Each session also has a cache of its own for folder listings, flags, message structure and message parts. It is set with [cache] enabled (on by default), ttl_minutes (default 10) and max_part_kib (default 1024, the largest message part in KiB the cache keeps). See Logging and Cache.

SMTP. ALPS keeps no SMTP connection open. For each send, it connects to the submission server, says EHLO, authenticates with AUTH PLAIN using the session's credentials, submits the message and quits. An attempt that fails on a lost connection or a temporary (4xx) reply is tried again on a new connection, up to 3 attempts in all, with pauses of 1 and 2 seconds. ALPS then files the sent copy itself, with IMAP APPEND into Sent, and removes the draft the message was sent from.

Restarts and other nodes. Sessions exist only in the memory of the process that created them. After a restart, or when a request reaches another node, the browser's alps_session names no session there. With [server] login_key set, ALPS opens a new session from the encrypted alps_login_token cookie. Without it, the user signs in again. In a cluster, every node needs the same login_key, and the load balancer should keep each browser on one node (see Clustering). Sign-in, the login token, session lifetimes and linked accounts are covered in Sessions and Sign-in.

How a request is handled

Every response carries the security headers (Content-Security-Policy, X-Content-Type-Options: nosniff, X-DNS-Prefetch-Control: off) and X-Alps-Build, static files included. The frontend's files are served without a session.

API requests pass through a chain:

  1. The origin check. For POST, PUT, DELETE and PATCH requests, ALPS compares the Origin header with its own origin and with the origins listed in [server] trusted_origins. When a request carries no Origin, ALPS compares its Referer instead. Behind a reverse proxy, ALPS reads the browser's scheme and host from X-Forwarded-Proto and X-Forwarded-Host only when the request comes from an address in [server] trusted_proxies. List only your own proxies there (see Setup Behind a Reverse Proxy). A request from another origin gets 403 Invalid origin (Invalid referer when ALPS compared the Referer).
  2. The session. The middleware looks up the alps_session cookie. Where it can, it restores a session from alps_login_token. It also asks for the second factor when the account has a security key and the session has not given it yet.
  3. The plugin's handler runs, and reaches mail through the session.

Without a session, only these are reachable: POST /session and DELETE /session (signing in and out), the second-factor step at sign-in (/webauthn/verify...), and /plugins/<name>/assets/. Every other API path answers 401 {"code":"401","error":"Unauthorized"}. Sign-ins and password checks also go through the rate limiter (see Rate Limiting and WebAuthn).

The API has no common prefix. Each plugin registers its routes at the root of the ALPS address, for example GET /mailboxes, POST /messages, GET /settings or /calendar/events. The frontend is the API's client. The route groups are listed in HTTP API.

A request from browser to IMAP and SMTP

This sequence is a sign-in, then opening the Inbox, then sending a message, on the imap provider.

sequenceDiagram
    autonumber
    participant B as Browser
    participant A as ALPS
    participant S as Session in memory
    participant I as IMAP server
    participant M as SMTP server

    B->>A: GET / (index.html and bundles, no session needed)
    A-->>B: files from the binary, with X-Alps-Build
    B->>A: POST /session with username and password
    A->>I: connect, then LOGIN with those credentials
    I-->>A: OK
    A->>S: new session that keeps this connection
    A-->>B: alps_session cookie (and alps_login_token with login_key)

    B->>A: GET /mailboxes/INBOX with the cookie
    Note over A: the cookie finds the session, then the second-factor check
    A->>S: run the listing
    S->>I: commands on the session's own connection, no new LOGIN
    I-->>S: messages
    S-->>A: result, kept in the session cache
    A-->>B: JSON

    B->>A: POST /messages with the cookie and Origin
    Note over A: origin check, then the session
    A->>M: connect, EHLO, AUTH PLAIN, submit, QUIT
    M-->>A: accepted
    A->>I: APPEND the sent copy to Sent, remove the draft if there was one
    A-->>B: JSON

    opt work the user is not waiting for
        S->>I: second connection, opened when first needed, closed after a minute unused
    end
Loading

With the maildir provider, the session reads and writes the maildir on disk instead of talking to an IMAP server. Sending still goes through SMTP.

Mail providers

Handlers reach mail only through the provider.MailProvider interface in provider/types.go. They do not send IMAP commands themselves. A provider registers itself from init() with provider.Register(name, factory), and [provider] type picks one (default imap). Three providers are compiled in:

  • imap, for a network IMAP server;
  • maildir, for a local maildir, with a Dovecot-style passwd file for sign-in;
  • multi, which routes each login by its domain to an imap or maildir backend of its own. Each backend can also name its own SMTP, CardDAV, CalDAV, ManageSieve and password-change settings.

A provider may implement optional interfaces, which turn features on where the server supports them. Examples are push (IDLE), folder news (NOTIFY), numbered changes (CONDSTORE), the storage quota, folder sharing (ACL), message previews, and routing services per login.

Each provider also gives the session a store for per-account data. On IMAP, this store is METADATA on the server. On a maildir, it is a JSON file, alps_store.json, in the user's maildir. When the IMAP server does not offer METADATA, ALPS logs IMAP server doesn't support the METADATA extension, using transient store instead. It then keeps that data only in the memory of one connection, so nothing saved survives a reconnect or a new sign-in. Use such a server only for trying ALPS out.

The interface does not hide IMAP completely. The JSON the API returns reuses the types of the IMAP library ALPS is built on, so a new backend must return data that maps onto them (see plugins/base/provider_adapter.go). The providers and their configuration are covered in Mail Providers.

Plugins

The features are Go packages under plugins/, compiled into the binary:

Plugin What it serves
base The mail API, settings, the account's own CalDAV and CardDAV connections, linked accounts, the second factor, and /session
caldav Calendars, events, tasks, and invitations found in messages
carddav Contacts
gpg The OpenPGP keyring (the cryptography itself runs in the browser)
managesieve Filters and auto-reply
password Password change

Each plugin registers a loader from init() with alps.RegisterPluginLoader, usually through alps.GoPlugin. That helper also registers the plugin's routes, and any jobs, which the scheduler runs every hour once the plugin has loaded. Loaders run once, at startup.

Which plugins load:

  • base always loads, whatever its section says.
  • A plugin whose [plugin.<name>] section says enabled = false does not load.
  • If any section sets enabled = true, those sections form an allow-list: only base and the plugins whose own sections set enabled = true load.
  • Otherwise there is no allow-list, and a plugin loads whether its section leaves enabled out or it has no section at all, as long as it has what it needs (below).
  • caldav and carddav need a section of their own, with or without a server, or a multi backend that names their endpoint. managesieve needs a server or a multi backend that names its endpoint.
  • password loads only with [plugin.password] enabled = true and an endpoint or command to change passwords with, globally or in a multi backend. Without them, Settings has no Password section.

A plugin the enabled rules keep out is not set up at all: ALPS does not read its server or run its jobs. For example, a config with no [plugin.*] sections at all, on the imap or maildir provider, loads base and gpg. GET /session then lists them in EnabledPlugins.

A plugin's frontend lives in plugins/<name>/frontend/index.ts. It registers its navigation tabs, settings tabs, routes and hooks with frontend/src/plugin-registry.ts. The registry shows a plugin's settings tabs when GET /session lists it in EnabledPlugins, and its navigation tabs, routes and hooks when ActivePlugins lists it as well. ActivePlugins leaves out caldav and carddav for an account that has no server for them, so that account sees only their section in Settings, where it can enter a server of its own. base has no plugin frontend: its user interface is frontend/src. See Plugins for writing one, and Plugin Configuration for the configuration keys.

The frontend

  • Lit web components. The user interface is made of custom elements such as <alps-message-list> and <alps-floating-composer>, built with Lit rather than a virtual-DOM framework. Most of them render into their own Shadow DOM, which keeps each component's styles to itself.
  • One build. The frontend is TypeScript built with Vite: npm run build runs tsc && vite build in frontend/. frontend/src/main.ts imports every plugins/*/frontend/index.ts eagerly, so the core and all plugin frontends ship together. Which plugins show is decided at runtime from GET /session.
  • Lazy pages. Pages and settings panes registered with load: () => import(...) become separate chunks, and so do the non-English translations. Third-party code is split into openpgp, editor, emoji, lit and vendor chunks.
  • Navigation uses the URL's hash (#/...), handled by frontend/src/router.ts.
  • State. <app-root> provides ComposeStore, SettingsStore, I18nStore and LinkedAccountsStore through @lit/context. The stores are EventTarget classes that dispatch change. A component consumes a store, listens for change and updates itself. Nothing re-renders automatically.
  • Calls to the backend. The page calls the API on its own origin, with the session cookie. When the user allows a message's remote content, the page loads its remote images, fonts and stylesheets through ALPS's GET /proxy (see The Remote-Content Proxy).

For frontend work, run the backend on :1323 and npm run dev in frontend/, then open http://localhost:5173. The Vite dev server forwards the API paths to http://localhost:1323, the /events stream included. Through it there is no configured branding, because Vite serves its own index.html. A change to the frontend should include the rebuilt frontend/dist. See the README and Testing Overview.

Where to read next

Clone this wiki locally