Skip to content

Plugins

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

This page covers the six plugins built into ALPS: what each one adds, what it needs from your servers and configuration, and how the configuration decides which of them load. It ends with a short guide to writing a plugin of your own. The full developer reference is docs/PLUGINS.md.

What a plugin is

Almost everything ALPS does comes from a plugin, sign-in and mail included. A plugin is a Go package compiled into the alps binary, and it can have a frontend directory that is bundled into the web app at build time. A built binary cannot take a new plugin: to add one you rebuild ALPS, and to switch one off you change the configuration.

Plugins load once, when ALPS starts. ALPS has no reload, so restart it after you change a [plugin.<name>] section.

The standard build compiles in all six plugins through the blank imports in cmd/alps/plugins.go:

Plugin What it adds What it needs Reference
base Sign-in and sign-out, the mail API, Settings, linked accounts, security keys, the new-mail event stream A mail provider and an SMTP submission server ARCHITECTURE.md
caldav The Calendar and Tasks tabs, Settings > Calendar, and an invitation banner above messages that carry a calendar part A CalDAV server: yours, which accepts the user's ALPS sign-in, or one each user enters in Settings CDAV.md
carddav The Contacts tab, Settings > Contacts, address suggestions in the composer, and saving recipients to the address book A CardDAV server with an address book for each account: yours, or one each user enters in Settings CDAV.md
managesieve Settings > Filters and Settings > Auto-reply A ManageSieve server that accepts the user's ALPS sign-in FILTERS.md
gpg Settings > GPG Keys, an encryption button in the composer, and decryption in the reader Account storage that persists, and carddav for recipients' keys GPG.md
password Settings > Password An HTTP endpoint, or a program on the ALPS host, that changes the password PASSWORD_CHANGE.md

The built-in plugins

base

base serves sign-in and sign-out (POST and DELETE /session) and GET /session, which tells the web app who is signed in and which plugins are loaded. It also serves everything to do with mail: folders, messages, sending, attachments, search, quota and shared folders. On top of that it serves the built-in Settings sections, the account's own CalDAV and CardDAV connections (/settings/dav/caldav and /settings/dav/carddav, for a loaded plugin only), linked accounts, security keys and recovery codes, and the new-mail event stream. The route groups are listed in ARCHITECTURE.md.

  • Needs: a mail provider ([provider]) and an SMTP submission server ([smtp] server). See Mail-Providers, Config-Providers-and-SMTP and Mail-Server-Requirements.
  • Configuration: none. It has no server and no options, and it always loads, whatever its section says.
  • Frontend: base has no plugin frontend directory. Its UI is the core app in frontend/src.
  • Background job: base:cleanup_bimi_avatars deletes cached BIMI logos older than 7 days, once an hour.

caldav

caldav adds the Calendar and Tasks tabs. Tasks are the to-dos (VTODOs) kept in the same calendars. It also shows a banner above a message that carries an invitation, so the user can accept, mark it tentative, decline, or apply an update. If the CalDAV server does not deliver invitations itself, ALPS sends them as email through the user's SMTP submission server.

  • Needs: a CalDAV server that accepts HTTP Basic authentication. The server you configure is the default: ALPS signs in to it with the login and password the user signed in to ALPS with, unless the user saves a separate username and password for it.
  • Settings > Calendar: each user can save a connection of their own there: a separate username and password for the default server, or a CalDAV server of their own (see CalDAV and CardDAV connections in Settings).
  • Configuration: server takes a URL, and is optional. Use https:// or caldavs:// for TLS. The plain-HTTP forms (http://, http+insecure://, caldav+insecure://) send the password unencrypted, so keep them for a server on the same host. A bare domain with no scheme, port or path (example.com) makes ALPS look up the server through DNS SRV (_caldavs._tcp) once, at startup. If that lookup fails, ALPS logs caldav: failed to discover CalDAV server: ... and loads the plugin without a default server. Any other value without a scheme, such as dav.example.com:8443 or dav.example.com/caldav, stops startup. The one option, week_start (alias first_day_of_week), sets the first day of the week for users who have not chosen their own: 0 or "sunday" for Sunday, 1 or "monday" for Monday, which is the default.
  • Per domain: under the multi provider, each backend can set its own caldav = endpoint (see Setup-Many-Domains).
[plugin.caldav]
enabled = true
server = "https://dav.example.com/"
options = { week_start = 0 }   # weeks start on Sunday

carddav

carddav adds the Contacts tab. It also suggests addresses from the contacts as the user types a recipient in the composer, and it saves the recipients of sent mail to the address book. The gpg plugin looks up recipients' public keys through it.

  • Needs: a CardDAV server that accepts the same HTTP Basic sign-in, the default one or one the user enters in Settings > Contacts, as for caldav. ALPS uses only the first address book the server lists for the account, and never creates one, so the server must provide one for each account. Without one, Contacts lists nothing and a contact cannot be saved.
  • Configuration: server is optional, and must be a full URL with a scheme: https:// or carddavs:// for TLS, or one of the plain-HTTP forms http://, http+insecure:// and carddav+insecure://, which send the password unencrypted. The caldav schemes stop startup here (unknown scheme for CardDAV server). There is no discovery, and a bare host name stops ALPS at startup with CardDAV server requires a scheme (https://, http+insecure://), got: //carddav.example.com. carddav reads no options.
  • Per domain: carddav = in a multi backend.
[plugin.carddav]
enabled = true
server = "https://dav.example.com/"

CalDAV and CardDAV connections in Settings

With caldav or carddav loaded, Settings > Calendar and Settings > Contacts let each user set up the account's own connection, for each of the two separately:

  • Server URL. Left blank, the account uses the default server: the plugin's server, or the endpoint the user's multi backend names. Otherwise it is a server of the user's own, given with https://, or with caldavs:// under Calendar and carddavs:// under Contacts. Without a default server for the account, a URL is required.
  • Authentication. The mail username and password, or a separate username and password. Before the mail password goes to a server the user entered, Settings asks for it again, and ALPS checks it. That check counts against the login rate limits.
  • Save connection checks the connection first: ALPS looks up the user's calendar or address-book home on the server, within 10 seconds, and saves nothing when that fails. Remove connection goes back to the default server with the mail credentials.

What this means for you:

  • A default server is optional. A user with none, neither the plugin's server nor a multi endpoint, sees only the plugin's section in Settings. Calendar and Tasks, or Contacts, appear once the user has a server, and so do the plugin's hooks: the invitation banner, or the address suggestions and saved recipients. GET /session lists such a plugin in EnabledPlugins but not in ActivePlugins.
  • Separate passwords need [server] login_key: ALPS stores them encrypted with it. Without the key, Settings offers only the mail credentials. After the key changes, a saved separate password can no longer be used, and the user enters it again.
  • ALPS connects to the servers users enter over HTTPS only, at public addresses only, and without an HTTP proxy. [server] dav_allow_private_servers = true also allows the plain-HTTP schemes (http+insecure://, caldav+insecure://, carddav+insecure://) and any address, for users who keep their DAV servers on a trusted internal network. Set it the same on every node. The servers in your configuration are never restricted.
  • The connection is kept with the account's other records on the mail server (see Storage-in-IMAP-METADATA). A session reads it again at most every 30 seconds.

The full rules are in CDAV.md.

managesieve

managesieve adds two Settings sections, Filters and Auto-reply. Both edit the account's active Sieve script on the mail server, which the server runs when it delivers mail. Filters has a visual rule editor and a raw Sieve editor. The auto-reply is a vacation rule in the same script.

  • Needs: a ManageSieve server (port 4190 unless the URL names another) that accepts SASL PLAIN with the user's ALPS sign-in. ALPS upgrades the connection with STARTTLS and checks the certificate. If the server does not offer STARTTLS, ALPS does not sign in. The auto-reply needs the server's vacation extension, and its start and end dates also need date and relational. ALPS checks a script before it uploads it, and the server checks it too, so a script that needs an extension the server lists but ALPS does not know, such as include, can be saved.
  • Loading: the plugin loads only when it has a server, or when a multi backend names a managesieve endpoint. A section with neither does not load it. When the enabled rules let the plugin through, startup then logs ManageSieve enabled without a server or routed endpoint; plugin disabled.
  • Configuration: server takes managesieve://, managesieves://, sieve:// or a bare host name, all of them STARTTLS. managesieve+insecure:// and sieve+insecure:// allow a connection without TLS and skip the certificate check; keep them for a server on the same host or a trusted local network. Any other scheme stops startup, and so does a host and port with no scheme (mail.example.com:4190), so write managesieve://mail.example.com:4190.
  • Per domain: managesieve = in a multi backend. There is no DNS discovery.
[plugin.managesieve]
enabled = true
server = "managesieve://mail.example.com:4190"

gpg

gpg adds OpenPGP encryption. In Settings > GPG Keys a user generates or imports a keypair, or purges it. A lock button in the composer turns encryption on for a message, which ALPS then signs and encrypts to every recipient and to the sender. In the reader, an encrypted message is decrypted after the user enters their passphrase.

All of the cryptography runs in the browser. The server stores the user's keyring, with the private key encrypted under the user's passphrase, and never receives the passphrase.

  • Needs:
    • Storage that persists. With the IMAP provider the keyring is kept in IMAP METADATA, and with the maildir provider in the user's alps_store.json. On an IMAP server without METADATA, the keyring lasts only as long as the IMAP connection (see Storage-in-IMAP-METADATA).
    • The carddav plugin, with a CardDAV server for the user. Recipients' public keys come from the Public Key field of their contacts. Without one, an encrypted message to anyone other than yourself is not sent.
  • Configuration: only enabled. It has no server and no options.
  • Limits: only the message body is encrypted, as inline PGP. Encryption does not cover the subject, the addresses, the other headers or the drafts saved while you write. A message with attachments cannot be sent encrypted.

password

password adds Settings > Password and the route behind it, POST /password/change. ALPS checks the current password, then passes the change to an HTTP endpoint or runs a program on the ALPS host. If the change succeeds, the user stays signed in.

  • Needs: something that changes passwords for your mail server: an HTTP API, or a command line tool.
  • Configuration: [plugin.password] enabled = true, plus endpoint or command in [plugin.password.options]. A multi backend can carry its own block. With enabled = true, ALPS checks the global block's command settings at startup and refuses to start when they fail. For example, setting both endpoint and command stops it with plugin.password: set either endpoint or command, not both. The HTTP options are not checked at startup. Commands need a Unix host: on Windows, a command in the global block stops startup, and one in a multi backend's block fails when it is used. See Setup-Password-Change and PASSWORD_CHANGE.md.
  • Loading: the plugin loads only with enabled = true and an endpoint or command, in the global block or in a multi backend's block. Unlike the other plugins, a [plugin.password] section that leaves enabled out keeps it off. Without enabled = true, Settings has no Password section and there is no /password/change route. With enabled = true but nothing to change passwords with, the plugin stays off too, and startup logs password: enabled without an endpoint, command or routed backend; plugin disabled. Because enabled = true here also turns on the allow-list (below), set enabled = true for every other plugin you want as well.

How plugins are enabled

Three things decide whether a plugin runs:

  1. It is compiled in. All six plugins are in the standard build.
  2. The enabled rules let it through. base always passes. A plugin whose section says enabled = false does not. If at least one [plugin.<name>] section sets enabled = true, those sections form an allow-list, and only base and the plugins whose own sections set enabled = true pass. If no section sets enabled = true, which includes having no sections at all, every other plugin passes, whether its section leaves enabled out or it has no section.
  3. It has what it needs. gpg needs nothing more. 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 needs enabled = true in its own section and an endpoint or command (see password).

A plugin that step 2 keeps out is not set up at all: ALPS does not read its server or options, so a value there that ALPS would refuse does not stop startup, and its background jobs do not run. Leaving enabled out of a section keeps that plugin's defaults: only enabled = true puts a plugin on the allow-list, and only enabled = false keeps one out.

The safe pattern is to list every plugin you want with enabled = true, as config.example.toml does, and to set enabled = false on the ones you do not use, or leave their sections out. A [plugin.base] section cannot switch base off, but an enabled = true in it turns on the allow-list like any other. Config-Plugins shows that layout.

With the imap or maildir provider, these configurations give these results:

Configuration Plugins that load Result
No [plugin.*] sections base, gpg Mail works. GPG keys work, but without carddav encrypted mail to others is not sent. Settings has no Password section.
Only [plugin.caldav], with a server and no enabled base, caldav, gpg No section sets enabled = true, so there is no allow-list, and gpg loads beside caldav.
gpg with enabled = true, and [plugin.caldav] with a server and no enabled base, gpg caldav is dropped: once a section sets enabled = true, a section that leaves enabled out does not load its plugin.
base and gpg with enabled = true, password with enabled = false base, gpg As intended.
Only gpg with enabled = true base, gpg As intended: base loads without being listed.
base, caldav, carddav, managesieve and gpg with enabled = true, no server lines base, caldav, carddav, gpg ManageSieve stays off, and Settings has no Filters or Auto-reply. Settings has Calendar and Contacts sections, and a user gets the Calendar, Tasks and Contacts tabs once they enter servers of their own there.

A caldav or carddav plugin that is loaded but has no server for a user, neither a global server nor a per-domain endpoint, shows that user only its section in Settings (see above). Under multi, a managesieve plugin loaded for the backends that name an endpoint shows Filters and Auto-reply to the other users too, and their requests fail, logged as ManageSieve server is not configured. Give the plugin a server, or switch it off.

The web app follows the server

Every plugin's frontend is in the bundle that ALPS serves, whether or not the plugin loads. After sign-in, the app reads two lists from GET /session: EnabledPlugins, the plugins that loaded, and ActivePlugins, those of them the account can use, which leaves out caldav and carddav for an account without a server for them. Settings shows the sections of the plugins in EnabledPlugins, and the app shows the tabs, pages and hooks only of the plugins in ActivePlugins. Switching a plugin off therefore needs only a configuration change and a restart, never a frontend rebuild.

Checking what loaded

At startup ALPS logs Loaded plugin "<name>" for each plugin that loads. A plugin that is not set up, because the enabled rules keep it out or it lacks what it needs, gets no line of its own, except managesieve and password with nothing to connect to, which log the lines quoted above. For each section that sets enabled = true but whose plugin did not load, ALPS warns Enabled plugin "<name>" did not load; check its name and backend configuration. That warning also catches a misspelled section name, such as [plugin.calddav] with enabled = true. A signed-in browser sees the same list in the EnabledPlugins field of GET /session. With only base and gpg enabled, on mail servers that advertise no message size limit, the answer is:

{"ActivePlugins":["gpg","base"],"Address":"ada@example.test","EnabledPlugins":["gpg","base"],"MaxAttachmentMiB":32,"PasswordChangeEnabled":false,"Username":"ada@example.test"}

MaxAttachmentMiB is the account's attachment limit: [server] max_attachment_mib, or less when the mail server advertises a message size limit (see Features).

A plugin configuration error stops startup with an error message that starts failed to load plugins:, but only for a plugin the enabled rules let through. Errors of this kind include a CalDAV or CardDAV server that is not a URL ALPS takes, such as a carddav server without a scheme, a ManageSieve scheme ALPS does not know, and an enabled password block whose command settings fail the startup checks. The server lines the DAV and ManageSieve plugins log, and the reachability check each DAV plugin makes at startup, are on Config-Plugins.

Writing a plugin

A new plugin is a directory under plugins/, compiled into your own build of ALPS. You need the Go toolchain and Node.js. The password plugin is the smallest complete example: one route, one Settings section and its translations.

Layout

plugins/myplugin/
├── plugin.go            # the Go side: routes, loader, jobs
├── plugin_test.go       # Go tests sit beside the sources
├── frontend/
│   ├── index.ts         # registers tabs, routes and hooks
│   ├── myplugin-settings.ts
│   └── i18n/            # en.ts and one file for each language
└── public/assets/       # optional static files, served from disk

The Go side

Define the plugin with alps.GoPlugin, register its routes, and register a loader from init():

package myplugin

import (
	"net/http"

	"github.com/migadu/alps"
)

var plugin = alps.GoPlugin{Name: "myplugin"}

func init() {
	plugin.GET("/myplugin/status", handleStatus)
	plugin.POST("/myplugin/note", handleNote)

	alps.RegisterPluginLoader(func(s *alps.Server) ([]alps.Plugin, error) {
		cfg := s.Options.Plugins["myplugin"]
		if !cfg.Enabled {
			return nil, nil // stay off without a [plugin.myplugin] section, or with enabled = false
		}
		return plugin.Loader()(s) // applies the enabled rules, the allow-list included
	})
}

func handleStatus(ctx *alps.Context) error {
	return ctx.JSON(http.StatusOK, map[string]string{"user": ctx.Session.Username()})
}

func handleNote(ctx *alps.Context) error {
	var req struct {
		Text string `json:"text"`
	}
	if err := ctx.BindJSON(&req); err != nil {
		return ctx.RespondBindError(err)
	}
	if req.Text == "" {
		return alps.NewHTTPError(http.StatusBadRequest, "text is required")
	}
	if err := ctx.Session.Store().Put("myplugin_note", req.Text); err != nil {
		return err
	}
	return ctx.NoContent(http.StatusNoContent)
}

Then add a blank import to cmd/alps/plugins.go:

_ "github.com/migadu/alps/plugins/myplugin"

How the Go side behaves:

  • The name is the key everywhere. Name is the plugin's [plugin.<name>] section, its entry in EnabledPlugins and ActivePlugins, and the pluginId its frontend uses.
  • Routes are net/http patterns such as /myplugin/items/{id}, read with ctx.Param("id"). Give them a prefix named after the plugin. A pattern that conflicts with an existing one panics at startup.
  • Every route runs behind the router's middleware. POST, PUT, PATCH and DELETE requests must pass the Origin/Referer check, so make any route that changes something use one of those methods, not GET. Requests without a signed-in session, or whose session still needs its second factor, are answered 401 before your handler runs, so ctx.Session is always set inside it.
  • Errors: return an alps.NewHTTPError(status, "message") to answer with that status and message. ALPS also recognises a refused mail password (401), an unreachable mail server (503) and a message that is gone (404), also inside an error that wraps them, so wrap with %w, not %v. Other errors are answered with a generic 500, and the detail goes only to the log. ctx.BindJSON reads at most 1 MiB.
  • Configuration: s.Options.Plugins["myplugin"] (or ctx.Server.Options.Plugins in a handler) holds Enabled, Server and Options, the free-form [plugin.myplugin.options] table. Enabled is true for a section that sets enabled = true or leaves it out, and false for one that says enabled = false and when there is no section. A loader stays off by returning no plugins, and stops startup by returning an error. Before it reads its options, a loader should ask s.PluginEnabled("myplugin"), as the shipped loaders do, so that a plugin the enabled rules keep out is not set up and cannot stop startup. plugin.Loader() asks it too. ALPS warns at startup about any key in the section other than enabled, server and options, but never about the keys inside options.
  • Per-user data: ctx.Session.Store() keeps JSON values under string keys for the account. With the IMAP provider it is kept in IMAP METADATA, and with the maildir provider in alps_store.json, so it follows the user to every browser and every node. On an IMAP server without METADATA it does not persist. Prefix your keys with the plugin's name so they stay apart from the ones ALPS keeps. See Storage-in-IMAP-METADATA.
  • Static files in plugins/<name>/public/assets are served at /plugins/<name>/assets/.... ALPS reads them from disk relative to its working directory and serves them without a session, so put nothing there that needs a sign-in. The shipped service files run ALPS in / (the systemd unit sets no WorkingDirectory, and the FreeBSD script starts ALPS with daemon -c), so a plugin with static files needs WorkingDirectory set in a systemd drop-in, or its files placed under /plugins/<name>/public/assets.
  • Lifetime: ALPS loads plugins once and never reloads them. At shutdown it calls the Close method of each plugin that loaded, once. A plugin that a loader returned but the enabled rules drop is closed at once, at startup. A GoPlugin's Close does nothing.

Routing per domain under multi, background mail work and the full error mapping are in docs/PLUGINS.md.

The frontend side

At build time, Vite imports every plugins/*/frontend/index.ts into the bundle. Each one runs when the app starts, even when its Go plugin is switched off; only what it registers is filtered. An index.ts registers what the plugin adds with the plugin registry:

import { registry } from '../../../frontend/src/plugin-registry';

registry.registerSettingsTab({
    id: 'myplugin',                                  // equals the Go plugin's Name
    labelKey: 'settings.categories.myplugin',
    icon: 'star',                                    // a <symbol> id in the icon sprite
    component: 'alps-myplugin-settings',
    load: () => import('./myplugin-settings'),       // fetched when the section first opens
});

registry.registerHook('composer:send', async ({ recipients }: { recipients: string[] }) => {
    try {
        console.debug('myplugin: sent to', recipients.length, 'recipients');
    } catch (e) {
        console.error('myplugin: composer:send failed', e);
    }
}, 'myplugin');

The registry has registerSettingsTab (a section of the Settings window), registerRoute and registerNavTab (a full page, such as Calendar), and registerHook. How the registry treats them:

  • Matching: settings tabs are matched to the server's EnabledPlugins, and navigation tabs, routes and hooks to its ActivePlugins. Tabs are matched by pluginId, or by id when there is no pluginId; routes and hooks by pluginId alone. Set pluginId to the Go plugin's Name whenever an id differs from it. A route or hook without a pluginId counts as part of the core app and always runs.
  • Duplicates: a second tab with an id already registered, or a second route with a path already registered, is ignored.
  • Icons: an icon must be the id of a <symbol> in frontend/public/assets/icons/sprite.svg. An unknown id draws nothing.
  • Loading: register components with load: () => import(...), so that they are fetched when first shown rather than with every user's first download.
  • Translations: these go in plugins/myplugin/frontend/i18n/<code>.ts, one file for each of the 19 languages ALPS ships, each with export default { ... }. The keys are merged into the app's dictionary. The frontend tests require each file to have exactly the keys of en.ts, with its language's plural forms and en.ts's placeholders. See LOCALIZATION.md.
  • API calls: call your Go routes with fetchWithTimeout from frontend/src/utils/fetch-utils. It handles an expired session, timeouts, retries of a GET and the offline banner. It resolves with a non-2xx answer rather than throwing, so check response.ok.

Settings sections with their own Save button, page routes and nav tabs, and Web Components are covered in docs/PLUGINS.md.

Hooks

The app invokes five hooks. Pass the plugin's name as the third argument to registerHook, so that the handler runs only while the plugin is enabled.

Hook When it runs What the handler returns
composer:toolbar Each time the composer toolbar is drawn A Lit template for the toolbar (synchronously)
composer:presend Before the send request A FormData to replace the request body, or false to cancel. A handler that fails also cancels the send.
composer:send After the server accepted the message Nothing; the result is ignored
composer:suggest While the user types an address An array of { name, address } suggestions
reader:content Before a message body is shown A replacement body, or nothing. Banners go onto payload.banners.

The built-in plugins use them this way: carddav uses composer:suggest and composer:send, gpg uses composer:toolbar, composer:presend and reader:content, and caldav uses reader:content for the invitation banner. Payloads and failure handling are in docs/PLUGINS.md.

Background jobs

plugin.AddJob("name", func() error { ... }) registers a job that runs once an hour, under the name <plugin>:<name>. The first run comes one hour after ALPS starts. The interval cannot be configured. ALPS logs a job that returns an error or panics, and runs it again on the next tick. ALPS schedules a plugin's jobs only once the plugin has loaded, so a plugin that the enabled rules keep out runs none.

Building and testing

  1. Run make build-frontend (npm install && npm run build in frontend/). The build type-checks every plugins/*/frontend file.
  2. Run make build, because the built frontend is embedded in the binary.
  3. Add a [plugin.myplugin] section to your configuration. If any other section sets enabled = true, as config.example.toml does, set enabled = true in yours as well, or the allow-list keeps your plugin out.

For development, npm run dev in frontend/ proxies the API to ALPS at http://localhost:1323, but only for the path prefixes listed in frontend/vite.config.ts. Add your plugin's prefix there.

Tests:

  • Go tests go beside the plugin (plugins/myplugin/*_test.go) and run with make test. See Go-Tests.
  • Frontend unit tests go in frontend/test/, not in the plugin directory, because Vitest only collects frontend/test/**/*.test.ts. Import the plugin from ../../plugins/myplugin/frontend/..., and run them with npm test in frontend/, which typechecks the tests before it runs them. See Frontend-Unit-Tests.
  • Browser tests go in frontend/e2e/. The test configuration enables plugins by name with enabled = true, which is an allow-list, so a new plugin needs its own [plugin.myplugin] block with enabled = true in scripts/e2e.sh before those tests can load it. See Browser-Tests.

See also

Clone this wiki locally