- TypeScript 98.3%
- CSS 1.7%
|
|
||
|---|---|---|
| .claude | ||
| .forgejo/workflows | ||
| app | ||
| config | ||
| db | ||
| docs/superpowers | ||
| public | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .nvmrc | ||
| CITATION.cff | ||
| CODE_OF_CONDUCT.md | ||
| components.json | ||
| CONTRIBUTING.md | ||
| docker-compose.yml | ||
| dockerfile | ||
| drizzle.config.ts | ||
| env.ts | ||
| instrumentation.ts | ||
| LICENSE | ||
| next-env.d.ts | ||
| next.config.ts | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| postcss.config.mjs | ||
| README.md | ||
| SECURITY.md | ||
| tsconfig.json | ||
Alice
Self-hosted email alias manager for Stalwart
Beta software. Alice is functional but under active development. You may encounter bugs or breaking changes between releases. Back up your database before upgrading.
Features
- Aliases on demand — Bitwarden creates one on every sign-up, no manual step
- Your own apps too — Alice has its own API, and pairs mobile devices without sharing secrets
- Know where each alias came from — tag it with the service you gave it to
- See what arrives — mail received per alias, and when the last one came in
- Spot a leak — spam counts and unknown senders, flagged per alias
- Find anything — search by address or service, filter by status and activity
- Bulk everything — enable, disable or delete a whole selection at once
- CSV in and out — export the full list, import with a preview before anything is written
- Sign in with OIDC — including Stalwart's own OAuth server
Alice sits on top of Stalwart and speaks two APIs: an addy.io-compatible one for Bitwarden, and its own for everything you write yourself.
How it works
Bitwarden ──► /api/v1/… addy.io emulation
your apps ──► /api/alice/v1/… Alice's own API
│
▼
Stalwart (JMAP)
│
▼
alias@relay.example.com
forwards to you@mail.example.com
- You register a client in Alice and give it the key it hands you.
- When the client needs an alias, it calls Alice.
- Alice creates a new address on Stalwart under your relay domain.
- Incoming mail to that alias is delivered to your real inbox by Stalwart.
Both surfaces share the same authentication and the same domain layer; only the response format differs. /api/v1 is frozen — its shape is dictated by addy.io, and Bitwarden appends those paths to the server URL you give it.
Authentication is handled via OIDC — you can use any standard identity provider, including Stalwart's built-in OAuth server.
Username matching. When a user logs in, Alice reads their OIDC
nameclaim and looks for a Stalwart account with that exact username. If no matching account is found, login is denied. This means the username in your identity provider must match the Stalwart account name one-to-one. Using Stalwart as the OIDC provider is the simplest way to guarantee this, since accounts are the same on both sides.
Prerequisites
- A running Stalwart instance with an admin API key
- An OIDC provider — any standard provider works; Stalwart's built-in OAuth server is the recommended choice (see Username matching)
- Docker and Docker Compose
- Bitwarden (self-hosted or cloud, any plan) — only if you want the addy.io integration
Installation
1. docker-compose.yml
Copy the block below into a docker-compose.yml and fill in your values.
services:
alice:
image: git.aktech.fr/alice/alice:latest
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- alice_data:/app/data
environment:
NODE_ENV: production
PROJECT_DATABASE_URL: file:./data/app.db
PROJECT_AUTH_SECRET: "change-me-at-least-32-chars"
PROJECT_AUTH_URL: "https://alice.example.com"
PROJECT_STALWART_URL: "https://mail.example.com"
PROJECT_STALWART_API_KEY: "your-stalwart-admin-api-key"
PROJECT_RELAY_DOMAIN: "relay.example.com"
PROJECT_ALIAS_FORMAT: "words"
PROJECT_OIDC_ISSUER: "https://mail.example.com"
PROJECT_OIDC_CLIENT_ID: "alice"
PROJECT_OIDC_CLIENT_SECRET: "your-oidc-client-secret"
volumes:
alice_data:
2. Run migrations and start
docker compose run --rm alice pnpm db:migrate
docker compose up -d
Alice will be available at the URL set in PROJECT_AUTH_URL.
Volumes
One volume, /app/data, holds:
| Path | What it is |
|---|---|
/app/data/local.db |
the embedded database — aliases live in Stalwart, but clients, GDPR requests and the privacy-contact directory live here |
/app/data/templates/ |
request templates of your own, read on every start (see Request templates) |
Losing this volume loses your API keys, your erasure request history and your own privacy contacts. Aliases themselves are safe — they belong to Stalwart.
To edit templates from the host, bind-mount that subdirectory instead:
volumes:
- alice_data:/app/data
- ./templates:/app/data/templates:ro
Environment variables
| Variable | Description | Example |
|---|---|---|
PROJECT_DATABASE_URL |
SQLite file path | file:./data/app.db |
PROJECT_AUTH_SECRET |
Random secret, min 32 chars | openssl rand -hex 32 |
PROJECT_AUTH_URL |
Public URL of this Alice instance | https://alice.example.com |
PROJECT_STALWART_URL |
Stalwart admin URL | https://mail.example.com |
PROJECT_STALWART_API_KEY |
Stalwart admin API key | |
PROJECT_RELAY_DOMAIN |
Domain used for generated aliases | relay.example.com |
PROJECT_ALIAS_FORMAT |
Alias local-part format: words, hex, uuid |
words |
PROJECT_OIDC_ISSUER |
OIDC discovery base URL | https://auth.example.com |
PROJECT_OIDC_CLAIM |
Claim holding the Stalwart username | preferred_username |
PROJECT_GDPR_ARCHIVE_URL |
Tarball of the privacy-contact directory, {ref} substituted |
see below |
PROJECT_GDPR_REF |
Commit or tag to pin that directory to | 02eb6f24… |
PROJECT_GDPR_TEMPLATES_DIR |
Where your own templates are read from | ./data/templates |
PROJECT_OIDC_CLIENT_ID |
OIDC client ID | |
PROJECT_OIDC_CLIENT_SECRET |
OIDC client secret |
Alias formats
| Value | Example output |
|---|---|
words |
fox-oak-342@relay.example.com |
hex |
a3f8b2c1@relay.example.com |
uuid |
550e8400-e29b-41d4-a716-446655440000@relay.example.com |
Bitwarden configuration
Once Alice is running:
-
Open Alice in your browser and log in via your OIDC provider.
-
Go to Clients → Add client, name it
Bitwarden, and copy the key. It is shown once and never again. -
In Bitwarden, go to Settings → Generator.
-
Select Username as the type, then Forwarded email alias.
-
Choose Addy.io as the service.
-
Fill in the two fields:
Field Value API access token Your Alice API key (from step 2) URL Your Alice instance URL (e.g. https://alice.example.com) -
Click Generate — Bitwarden will call Alice and return a fresh alias.
From that point on, every time you create a new login in Bitwarden with the username generator set to "Forwarded email alias", a new alias is created automatically.
Leak detection
An alias given to one service should only ever hear from that service. Alice watches for the two ways that stops being true, and reports them separately because the remedy differs.
Spam. How many of an alias's messages landed in Junk. An address that receives nothing but spam has been sold or scraped — the fix is to disable it, which the warning offers directly. Disabling one alias never touches the others.
Unknown senders. The sending domains seen on the alias. One domain is the normal case and raises nothing. From the second onwards, Alice lists the ones you have not vouched for: mark a domain Expected and it stops being reported, withdraw the approval later and it comes back.
Approvals are the point rather than an afterthought. Plenty of legitimate services send from more than one domain — transactional mail through SendGrid, newsletters through Mailchimp, amazon.com alongside amazon-ses.com — so without a way to say "this one is fine" the warning would be permanent noise and you would learn to ignore it.
The privacy-contact directory
Alice does not keep a list of privacy contacts of its own. It loads one from a git archive at startup, into its database.
PROJECT_GDPR_ARCHIVE_URL=https://codeload.github.com/datenanfragen/data/tar.gz/{ref}
PROJECT_GDPR_REF=02eb6f247e80ee4b423f9ccb68be4cc7ca23ab26
Request templates
The letters Alice sends come from the same archive, under
templates/<locale>/<kind>.txt, and are stored in the database as upstream.
Every sync wipes and rewrites them.
Two constructs:
{name} |
replaced with a value |
[flag>text] |
kept when the flag is set, dropped otherwise |
Braces Alice does not recognise are left alone on purpose — the reminder and complaint templates use them for passages a human must write, and the UI refuses to send while any remain.
Writing your own
Drop a file in the templates directory, mirroring the upstream layout:
data/templates/
└── fr/
└── erasure-default.txt
It is read on every start and wins over the upstream template of the same locale and kind. Files are the interface rather than a form in the UI: a mounted directory is something you can version, review and back up.
Alice fills these placeholders:
| Template | Placeholders | Flags |
|---|---|---|
erasure-default |
{id_data} — the alias, and nothing else |
erase_all, has_fields |
admonition |
{request_date}, {request_article} |
— |
{id_data} carries only the alias address. That is the identifier the
controller already holds, so it is enough to identify you — while adding a name
or a postal address would hand them personal data they never had.
To remove a custom template, delete the file and restart: local templates are reloaded wholesale, so a deleted file actually disappears.
Clients
Anything that talks to Alice's API is a client holding its own key: Bitwarden, a script, a mobile app. Revoking one leaves the others untouched.
AliceConnect API
Authenticate with Authorization: Bearer <client key>.
GET /api/alice/v1/me |
username, relay domain, available alias formats |
GET /api/alice/v1/aliases |
the list; add ?usage=true for mail counts |
POST /api/alice/v1/aliases |
{ format?, service? } → a generated address |
GET·PATCH·DELETE /api/alice/v1/aliases/{address} |
one alias, address URL-encoded |
PATCH·DELETE /api/alice/v1/aliases |
{ addresses[], … } — one Stalwart write |
The list returns an ETag; send it back as If-None-Match and you get a 304 when nothing changed. Errors carry a machine-readable code: { "error": { "code": "unknown_domain", "message": "…" } }.
Usage is opt-in because it costs an extra round trip to Stalwart, and it is billed per alias — ask for it when you display it, not on every poll.
Connecting a device
Native apps must not ship the OIDC client secret, so they sign in through the device authorization grant against Alice — never against Stalwart directly:
1. POST /api/auth/device/code → show the user_code to the user
2. user opens /device, enters the code, approves
3. poll POST /api/auth/device/token → access_token
4. POST /api/auth/api-key/create → the client key, stored for good
5. POST /api/auth/sign-out → discard the access_token
Step 5 matters: the token from step 3 is a full session valid for days, more powerful than the key it just created, and it appears nowhere in the Clients list. Once the key is stored, the token has no further use.
Stalwart setup
Alice needs permission to add and remove email addresses on Stalwart. Make sure:
- The
PROJECT_STALWART_API_KEYbelongs to an admin account. - Your relay domain (e.g.
relay.example.com) is configured as a local domain in Stalwart. - Each Alice user's primary account (their OIDC username) already exists as a Stalwart account before they log in.
Using Stalwart as the OIDC provider
Stalwart ships with a built-in OAuth 2.0 / OpenID Connect server, which makes it the simplest identity provider to use with Alice — accounts are shared, so usernames always match.
To configure it, create an OAuth client in Stalwart and point Alice at it:
PROJECT_OIDC_ISSUER=https://mail.example.com
PROJECT_OIDC_CLIENT_ID=alice
PROJECT_OIDC_CLIENT_SECRET=your-client-secret
The discovery endpoint Stalwart exposes (/.well-known/openid-configuration) is picked up automatically.
If you use a third-party provider (Authelia, Keycloak, Authentik…), make sure the
nameclaim in the issued token matches the Stalwart account name exactly. Alice uses that value to look up the account on every login.
Roadmap
More protocols The API layer is already split so that only serialisation is protocol-specific — authentication and the Stalwart layer are shared. Adding SimpleLogin or Firefox Relay means writing a serialiser, not a second integration. The paths those clients impose can be arbitrary; route groups keep the tree organised regardless.
Contributions toward any of these are welcome — see CONTRIBUTING.md.