Compare commits

1 Commits
Author SHA1 Message Date
Squid a79cb61104 Add Migadu domain admin with shared lists, compaction, and safer apply.
Build the Go API and React UI for managing domain denylist/allowlist/recipient
deny and spam settings, plus shared lists that import/apply across domains with
wildcard compaction, entry verification, and Migadu-friendly list encoding
(including per-domain recipient filtering and rejection bisect on apply).
2026-07-26 21:50:26 +02:00
55 changed files with 7600 additions and 1 deletions
+26
View File
@@ -0,0 +1,26 @@
# Server
LISTEN_ADDR=:8080
SESSION_SECRET=change-me-to-a-long-random-secret-at-least-32-chars
DATABASE_PATH=data/migaduadmin.db
COOKIE_SECURE=false
# Migadu API (https://www.migadu.com/api/)
# Username is your Migadu account email; password is the API key ("app-key").
# `make dev` creates .env automatically; replace these before loading domains.
MIGADU_USER=you@example.com
MIGADU_API_KEY=your-migadu-api-key
MIGADU_BASE_URL=https://api.migadu.com/v1
# Auth: local | pangolin | both
AUTH_MODE=both
# Comma-separated CIDRs allowed to supply Pangolin SSO headers
# (Remote-Email, Remote-User, Remote-Name, Remote-Role).
TRUSTED_PROXIES=127.0.0.1/32,::1/128
# After the first admin exists, registration stays closed unless true.
REGISTRATION_OPEN=false
# When true, unknown Pangolin SSO emails are auto-created as role=user.
# First-ever user is always admin regardless of this setting.
SSO_AUTO_PROVISION=false
+24
View File
@@ -0,0 +1,24 @@
# Binaries / local data
/bin/
/data/
*.db
*.exe
/migaduadmin
/tmp/
# Env
.env
.env.local
# Frontend
web/node_modules/
web/dist/
# Editor / OS
.DS_Store
.idea/
.vscode/
*.swp
# Go
vendor/
+41
View File
@@ -0,0 +1,41 @@
.PHONY: setup-dev dev build frontend backend run tidy clean
export PATH := $(HOME)/.local/go/bin:$(HOME)/.local/bin:$(PATH)
setup-dev:
@bash scripts/setup-dev.sh
dev: setup-dev
@echo "API → http://127.0.0.1:8080"
@echo "UI → http://127.0.0.1:5173 (live reload; proxies /api → Go)"
@echo "Open the UI URL in your browser. Ctrl+C stops both."
@bash -c 'set -euo pipefail; \
go run ./cmd/server & \
api_pid=$$!; \
(cd web && npm run dev -- --host 127.0.0.1 --port 5173) & \
ui_pid=$$!; \
trap "kill $$api_pid $$ui_pid 2>/dev/null; wait $$api_pid $$ui_pid 2>/dev/null" EXIT INT TERM; \
wait'
frontend:
cd web && npm install && npm run build
rm -rf cmd/server/ui
mkdir -p cmd/server/ui
cp -R web/dist/. cmd/server/ui/
backend:
go build -o bin/migaduadmin ./cmd/server
build: frontend backend
run: build
./bin/migaduadmin
tidy:
go mod tidy
cd web && npm install
clean:
rm -rf bin web/dist cmd/server/ui
mkdir -p cmd/server/ui
printf '%s\n' '<!doctype html><html><body><p>UI not built. Run make frontend.</p></body></html>' > cmd/server/ui/index.html
+93 -1
View File
@@ -1,3 +1,95 @@
# MigaduAdmin
Administrate settings for several domains in one place.
Administrate domain-level Migadu settings for multiple domains in one place:
sender denylist (blacklist), sender allowlist (whitelist), recipient denylist,
and spam controls.
The Go backend holds your Migadu credentials and talks to the
[Migadu JSON API](https://www.migadu.com/api/). The React UI only talks to this
application.
## Features
- Multi-domain list and detail views
- Shared denylist / allowlist / recipient deny lists with manual apply to all or selected domains
- Edit per-domain denylist / allowlist / recipient deny lists
- Edit domain spam aggressiveness and related toggles
- First registered local account becomes admin
- Optional [Pangolin](https://docs.pangolin.net/manage/access-control/forwarded-headers)
SSO via `Remote-Email` (and related) headers from trusted proxies
## Requirements
- Go 1.25+ (or Go toolchain that can download it)
- Node.js 20+ (for building the UI)
## Quick start
```bash
make dev
```
On first run this will:
1. Create `.env` from `.env.example` with a generated `SESSION_SECRET`
2. Install frontend dependencies
3. Start the Go API and Vite UI together
Then open **http://127.0.0.1:5173**, create the first admin account, and edit
`.env` with your real `MIGADU_USER` / `MIGADU_API_KEY` before managing domains.
Production-style single binary:
```bash
# edit .env first
make build
make run
```
Open http://127.0.0.1:8080.
## Configuration
| Variable | Description |
| --- | --- |
| `MIGADU_USER` | Migadu account email (Basic Auth username) |
| `MIGADU_API_KEY` | Migadu API key |
| `MIGADU_BASE_URL` | Default `https://api.migadu.com/v1` |
| `LISTEN_ADDR` | Default `:8080` |
| `SESSION_SECRET` | Cookie signing secret (min 32 characters) |
| `DATABASE_PATH` | SQLite path for users/sessions |
| `AUTH_MODE` | `local`, `pangolin`, or `both` |
| `TRUSTED_PROXIES` | CIDRs allowed to set SSO headers |
| `REGISTRATION_OPEN` | Allow new local signups after bootstrap |
| `SSO_AUTO_PROVISION` | Auto-create users from trusted Pangolin emails |
| `COOKIE_SECURE` | Set `true` behind HTTPS |
Create a Migadu API key under **My Account → API Keys** in the Migadu admin UI.
## Pangolin SSO
When `AUTH_MODE` is `pangolin` or `both`, and the request comes from an IP in
`TRUSTED_PROXIES`, the backend trusts:
- `Remote-Email` (required)
- `Remote-User`, `Remote-Name`, `Remote-Role` (optional)
Identity headers from untrusted peers are stripped. Put Pangolin / Badger in
front of this app and add the proxy address to `TRUSTED_PROXIES`.
## API overview
| Method | Path | Notes |
| --- | --- | --- |
| GET | `/api/auth/status` | Registration open + auth mode |
| POST | `/api/auth/register` | Bootstrap / open registration |
| POST | `/api/auth/login` | Local login |
| POST | `/api/auth/logout` | Clear session |
| GET | `/api/auth/me` | Current user |
| GET | `/api/domains` | List domains (admin) |
| GET | `/api/domains/{domain}` | Domain settings (admin) |
| PATCH | `/api/domains/{domain}` | Update lists/spam fields (admin) |
| GET/POST | `/api/users` | List / create users (admin) |
## License
See [LICENSE](LICENSE).
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"embed"
"io/fs"
"log"
"net/http"
"strings"
"github.com/squid/MigaduAdmin/internal/api"
"github.com/squid/MigaduAdmin/internal/auth"
"github.com/squid/MigaduAdmin/internal/config"
"github.com/squid/MigaduAdmin/internal/migadu"
"github.com/squid/MigaduAdmin/internal/store"
)
//go:embed all:ui
var uiEmbed embed.FS
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
st, err := store.Open(cfg.DatabasePath)
if err != nil {
log.Fatalf("database: %v", err)
}
defer st.Close()
authService := auth.NewService(st, cfg)
migaduClient := migadu.NewClient(cfg.MigaduBaseURL, cfg.MigaduUser, cfg.MigaduAPIKey)
server := api.New(cfg, authService, st, migaduClient)
staticHandler, err := spaHandler()
if err != nil {
log.Fatalf("ui assets: %v", err)
}
handler := server.Router(staticHandler)
log.Printf("MigaduAdmin listening on %s", cfg.ListenAddr)
log.Printf(
"Migadu credentials: user=%q api_key=%s base=%s",
cfg.MigaduUser,
cfg.MaskedMigaduKey(),
cfg.MigaduBaseURL,
)
if err := http.ListenAndServe(cfg.ListenAddr, handler); err != nil {
log.Fatalf("server: %v", err)
}
}
func spaHandler() (http.Handler, error) {
sub, err := fs.Sub(uiEmbed, "ui")
if err != nil {
return nil, err
}
fileServer := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(sub, path); err == nil && !strings.HasSuffix(path, "/") {
fileServer.ServeHTTP(w, r)
return
}
// SPA fallback for client-side routes
index, err := fs.ReadFile(sub, "index.html")
if err != nil {
http.Error(w, "ui not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(index)
}), nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MigaduAdmin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=IBM+Plex+Mono:wght@400;500&family=Source+Sans+3:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index-BWmBGRQb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CNHJ4d38.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
module github.com/squid/MigaduAdmin
go 1.25.0
require (
github.com/go-chi/chi/v5 v5.3.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.54.0
modernc.org/sqlite v1.54.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+57
View File
@@ -0,0 +1,57 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+758
View File
@@ -0,0 +1,758 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/squid/MigaduAdmin/internal/auth"
"github.com/squid/MigaduAdmin/internal/config"
"github.com/squid/MigaduAdmin/internal/lists"
"github.com/squid/MigaduAdmin/internal/migadu"
"github.com/squid/MigaduAdmin/internal/store"
)
type Server struct {
cfg *config.Config
auth *auth.Service
store *store.Store
migadu *migadu.Client
}
func New(cfg *config.Config, authService *auth.Service, st *store.Store, migaduClient *migadu.Client) *Server {
return &Server{
cfg: cfg,
auth: authService,
store: st,
migadu: migaduClient,
}
}
func (s *Server) Router(static http.Handler) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Do not use RealIP before auth: trusted-proxy checks need the immediate peer.
r.Use(s.auth.StripUntrustedIdentityHeaders)
r.Route("/api", func(api chi.Router) {
api.Get("/health", s.handleHealth)
api.Route("/auth", func(ar chi.Router) {
ar.Get("/status", s.handleAuthStatus)
ar.Post("/register", s.handleRegister)
ar.Post("/login", s.handleLogin)
ar.Post("/logout", s.handleLogout)
ar.With(s.auth.Middleware).Get("/me", s.handleMe)
})
api.Group(func(protected chi.Router) {
protected.Use(s.auth.Middleware)
protected.Use(s.auth.RequireAdmin)
protected.Get("/managed-domains", s.handleListManagedDomains)
protected.Post("/managed-domains", s.handleAddManagedDomain)
protected.Delete("/managed-domains/{domain}", s.handleDeleteManagedDomain)
protected.Get("/shared-lists", s.handleGetSharedLists)
protected.Put("/shared-lists", s.handlePutSharedLists)
protected.Post("/shared-lists/apply", s.handleApplySharedLists)
protected.Post("/shared-lists/import", s.handleImportSharedLists)
protected.Get("/domains/{domain}", s.handleGetDomain)
protected.Patch("/domains/{domain}", s.handleUpdateDomain)
protected.Get("/users", s.handleListUsers)
protected.Post("/users", s.handleCreateUser)
})
})
if static != nil {
r.Handle("/*", static)
}
return r
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
allowed, err := s.auth.RegistrationAllowed(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check registration status")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"registration_open": allowed,
"auth_mode": s.cfg.AuthMode,
})
}
type credentialsRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req credentialsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.auth.Register(r.Context(), req.Email, req.DisplayName, req.Password)
if err != nil {
switch {
case errors.Is(err, auth.ErrClosedReg):
writeError(w, http.StatusForbidden, "registration is closed")
case errors.Is(err, store.ErrEmailTaken):
writeError(w, http.StatusConflict, "email already registered")
case errors.Is(err, store.ErrInvalidInput):
writeError(w, http.StatusBadRequest, "invalid email or role")
default:
if strings.Contains(err.Error(), "password") {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "registration failed")
}
return
}
if err := s.auth.CreateSession(r.Context(), w, user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create session")
return
}
writeJSON(w, http.StatusCreated, user)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if s.cfg.AuthMode == config.AuthModePangolin {
writeError(w, http.StatusBadRequest, "local login disabled; use Pangolin SSO")
return
}
var req credentialsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.auth.AuthenticateLocal(r.Context(), req.Email, req.Password)
if err != nil {
if errors.Is(err, auth.ErrUnauthorized) {
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if err := s.auth.CreateSession(r.Context(), w, user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create session")
return
}
writeJSON(w, http.StatusOK, user)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.auth.ClearSession(r.Context(), w, r)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
writeJSON(w, http.StatusOK, user)
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.ListUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list users")
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
type createUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Role store.Role `json:"role"`
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
user, err := s.auth.CreateUserAsAdmin(r.Context(), req.Email, req.DisplayName, req.Password, req.Role)
if err != nil {
switch {
case errors.Is(err, store.ErrEmailTaken):
writeError(w, http.StatusConflict, "email already registered")
case errors.Is(err, store.ErrInvalidInput):
writeError(w, http.StatusBadRequest, "invalid email or role")
default:
if strings.Contains(err.Error(), "password") {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to create user")
}
return
}
writeJSON(w, http.StatusCreated, user)
}
func (s *Server) handleListManagedDomains(w http.ResponseWriter, r *http.Request) {
managed, err := s.store.ListManagedDomains(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list managed domains")
return
}
type managedDomainResponse struct {
Name string `json:"name"`
AddedAt string `json:"added_at"`
AddedBy *int64 `json:"added_by,omitempty"`
State string `json:"state,omitempty"`
Description string `json:"description,omitempty"`
Accessible bool `json:"accessible"`
Error string `json:"error,omitempty"`
}
out := make([]managedDomainResponse, 0, len(managed))
for _, item := range managed {
entry := managedDomainResponse{
Name: item.Name,
AddedAt: item.AddedAt.UTC().Format(time.RFC3339Nano),
AddedBy: item.AddedBy,
}
domain, err := s.migadu.GetDomain(r.Context(), item.Name)
if err != nil {
entry.Accessible = false
var apiErr *migadu.APIError
if errors.As(err, &apiErr) {
entry.Error = fmt.Sprintf("migadu HTTP %d", apiErr.StatusCode)
} else {
entry.Error = "migadu unreachable"
}
} else {
entry.Accessible = true
entry.State = domain.State
entry.Description = domain.Description
}
out = append(out, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"domains": out})
}
type addManagedDomainRequest struct {
Name string `json:"name"`
}
func (s *Server) handleAddManagedDomain(w http.ResponseWriter, r *http.Request) {
var req addManagedDomainRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if name == "" {
writeError(w, http.StatusBadRequest, "domain name is required")
return
}
log.Printf("managed-domains: verifying access to %q via Migadu", name)
domain, err := s.migadu.GetDomain(r.Context(), name)
if err != nil {
var apiErr *migadu.APIError
if errors.As(err, &apiErr) {
log.Printf(
"managed-domains: access check failed for %q: HTTP %d body=%q",
name,
apiErr.StatusCode,
apiErr.Body,
)
// Migadu commonly returns 400 on failure; also treat auth/not-found as inaccessible.
if apiErr.StatusCode == http.StatusBadRequest ||
apiErr.StatusCode == http.StatusNotFound ||
apiErr.StatusCode == http.StatusForbidden ||
apiErr.StatusCode == http.StatusUnauthorized {
writeJSON(w, http.StatusBadRequest, map[string]any{
"error": "cannot access domain with configured API key",
"migadu_status": apiErr.StatusCode,
"migadu_body": apiErr.Body,
"domain": name,
})
return
}
writeMigaduError(w, err)
return
}
log.Printf("managed-domains: access check failed for %q: %v", name, err)
writeError(w, http.StatusBadGateway, "cannot verify domain with Migadu: "+err.Error())
return
}
log.Printf("managed-domains: access ok for %q (state=%q)", domain.Name, domain.State)
user := auth.UserFromContext(r.Context())
if user == nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
managed, err := s.store.AddManagedDomain(r.Context(), domain.Name, user.ID)
if err != nil {
switch {
case errors.Is(err, store.ErrDomainExists):
writeError(w, http.StatusConflict, "domain already managed")
case errors.Is(err, store.ErrInvalidInput):
writeError(w, http.StatusBadRequest, "invalid domain name")
default:
writeError(w, http.StatusInternalServerError, "failed to save domain")
}
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"name": managed.Name,
"added_at": managed.AddedAt.UTC().Format(time.RFC3339Nano),
"added_by": managed.AddedBy,
"state": domain.State,
"description": domain.Description,
"accessible": true,
})
}
func (s *Server) handleDeleteManagedDomain(w http.ResponseWriter, r *http.Request) {
domainName := chi.URLParam(r, "domain")
if err := s.store.DeleteManagedDomain(r.Context(), domainName); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "domain not found in registry")
return
}
writeError(w, http.StatusInternalServerError, "failed to remove domain")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleGetSharedLists(w http.ResponseWriter, r *http.Request) {
lists, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handlePutSharedLists(w http.ResponseWriter, r *http.Request) {
var req store.SharedLists
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.SenderDenylist == nil {
req.SenderDenylist = []string{}
}
if req.SenderAllowlist == nil {
req.SenderAllowlist = []string{}
}
if req.RecipientDenylist == nil {
req.RecipientDenylist = []string{}
}
if err := s.store.PutSharedLists(r.Context(), req); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save shared lists")
return
}
lists, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
writeJSON(w, http.StatusOK, lists)
}
type sharedListsTargetsRequest struct {
All bool `json:"all"`
Domains []string `json:"domains"`
Replace bool `json:"replace"`
}
type sharedListsDomainResult struct {
Domain string `json:"domain"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Rejected []string `json:"rejected,omitempty"`
}
func (s *Server) handleApplySharedLists(w http.ResponseWriter, r *http.Request) {
var req sharedListsTargetsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if !req.All && len(req.Domains) == 0 {
writeError(w, http.StatusBadRequest, "provide all=true or a non-empty domains list")
return
}
shared, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
targets, err := s.resolveSharedListTargets(r, req)
if err != nil {
if strings.Contains(err.Error(), "failed to") {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
sharedDenylist := lists.Compact(shared.SenderDenylist)
sharedAllowlist := lists.Compact(shared.SenderAllowlist)
sharedRecipient := lists.Compact(shared.RecipientDenylist)
// Persist normalized/compacted shared lists (repairs typos, drops covered entries).
if err := s.store.PutSharedLists(r.Context(), store.SharedLists{
SenderDenylist: sharedDenylist,
SenderAllowlist: sharedAllowlist,
RecipientDenylist: sharedRecipient,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save compacted shared lists")
return
}
log.Printf(
"shared-lists apply: replace=%v targets=%d denylist=%d allowlist=%d recipient=%d",
req.Replace,
len(targets),
len(sharedDenylist),
len(sharedAllowlist),
len(sharedRecipient),
)
results := make([]sharedListsDomainResult, 0, len(targets))
for _, domainName := range targets {
result := sharedListsDomainResult{Domain: domainName}
var denylist, allowlist, recipient []string
if req.Replace {
// Always use non-nil slices so JSON encodes [] not null (Migadu 400s on null).
denylist = append([]string{}, sharedDenylist...)
allowlist = append([]string{}, sharedAllowlist...)
// Recipient deny is domain-scoped: only addresses for this domain.
recipient = lists.FilterRecipientEntriesForDomain(domainName, sharedRecipient)
} else {
domain, err := s.migadu.GetDomain(r.Context(), domainName)
if err != nil {
result.Error = migaduErrorMessage(err)
results = append(results, result)
continue
}
denylist = unionStringLists([]string(domain.SenderDenylist), sharedDenylist)
allowlist = unionStringLists([]string(domain.SenderAllowlist), sharedAllowlist)
recipient = unionStringLists(
[]string(domain.RecipientDenylist),
lists.FilterRecipientEntriesForDomain(domainName, sharedRecipient),
)
}
writeResult, err := s.migadu.UpdateDomainLists(r.Context(), domainName, denylist, allowlist, recipient)
if err != nil {
result.Error = migaduErrorMessage(err)
results = append(results, result)
continue
}
result.OK = true
result.Rejected = writeResult.Rejected
results = append(results, result)
}
writeJSON(w, http.StatusOK, map[string]any{"results": results})
}
func (s *Server) handleImportSharedLists(w http.ResponseWriter, r *http.Request) {
var req sharedListsTargetsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if !req.All && len(req.Domains) == 0 {
writeError(w, http.StatusBadRequest, "provide all=true or a non-empty domains list")
return
}
shared, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
targets, err := s.resolveSharedListTargets(r, req)
if err != nil {
if strings.Contains(err.Error(), "failed to") {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeError(w, http.StatusBadRequest, err.Error())
return
}
denylist := append([]string(nil), shared.SenderDenylist...)
allowlist := append([]string(nil), shared.SenderAllowlist...)
recipient := append([]string(nil), shared.RecipientDenylist...)
results := make([]sharedListsDomainResult, 0, len(targets))
for _, domainName := range targets {
result := sharedListsDomainResult{Domain: domainName}
domain, err := s.migadu.GetDomain(r.Context(), domainName)
if err != nil {
result.Error = migaduErrorMessage(err)
results = append(results, result)
continue
}
denylist = append(denylist, []string(domain.SenderDenylist)...)
allowlist = append(allowlist, []string(domain.SenderAllowlist)...)
recipient = append(recipient, []string(domain.RecipientDenylist)...)
result.OK = true
results = append(results, result)
}
merged := store.SharedLists{
SenderDenylist: lists.Compact(denylist),
SenderAllowlist: lists.Compact(allowlist),
RecipientDenylist: lists.Compact(recipient),
}
if err := s.store.PutSharedLists(r.Context(), merged); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save shared lists")
return
}
saved, err := s.store.GetSharedLists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shared lists")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"lists": saved,
"results": results,
})
}
func (s *Server) resolveSharedListTargets(r *http.Request, req sharedListsTargetsRequest) ([]string, error) {
if req.All {
managed, err := s.store.ListManagedDomains(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to list managed domains")
}
names := make([]string, 0, len(managed))
for _, item := range managed {
names = append(names, item.Name)
}
return names, nil
}
seen := make(map[string]struct{}, len(req.Domains))
names := make([]string, 0, len(req.Domains))
for _, raw := range req.Domains {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
ok, err := s.store.IsManagedDomain(r.Context(), name)
if err != nil {
return nil, fmt.Errorf("failed to check managed domain")
}
if !ok {
return nil, fmt.Errorf("domain is not in the administered registry: %s", name)
}
seen[name] = struct{}{}
names = append(names, name)
}
if len(names) == 0 {
return nil, fmt.Errorf("provide all=true or a non-empty domains list")
}
return names, nil
}
func unionStringLists(existing, shared []string) []string {
out := make([]string, 0, len(existing)+len(shared))
out = append(out, existing...)
out = append(out, shared...)
return lists.Compact(out)
}
func migaduErrorMessage(err error) string {
var apiErr *migadu.APIError
if errors.As(err, &apiErr) {
if apiErr.Body != "" {
return fmt.Sprintf("migadu HTTP %d: %s", apiErr.StatusCode, apiErr.Body)
}
return fmt.Sprintf("migadu HTTP %d", apiErr.StatusCode)
}
return err.Error()
}
func (s *Server) requireManagedDomain(w http.ResponseWriter, r *http.Request, domainName string) bool {
ok, err := s.store.IsManagedDomain(r.Context(), domainName)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to check managed domain")
return false
}
if !ok {
writeError(w, http.StatusForbidden, "domain is not in the administered registry")
return false
}
return true
}
func (s *Server) handleGetDomain(w http.ResponseWriter, r *http.Request) {
domainName := chi.URLParam(r, "domain")
if !s.requireManagedDomain(w, r, domainName) {
return
}
domain, err := s.migadu.GetDomain(r.Context(), domainName)
if err != nil {
writeMigaduError(w, err)
return
}
writeJSON(w, http.StatusOK, domain)
}
func (s *Server) handleUpdateDomain(w http.ResponseWriter, r *http.Request) {
domainName := chi.URLParam(r, "domain")
if !s.requireManagedDomain(w, r, domainName) {
return
}
var raw map[string]json.RawMessage
if err := decodeJSON(r, &raw); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
allowed := map[string]struct{}{
"sender_denylist": {},
"sender_allowlist": {},
"recipient_denylist": {},
"spam_aggressiveness": {},
"junk_subject_keyword_spam": {},
"subject_rewriting_enabled": {},
}
for key := range raw {
if _, ok := allowed[key]; !ok {
writeError(w, http.StatusBadRequest, "unsupported field: "+key)
return
}
}
var update migadu.DomainUpdate
if value, ok := raw["sender_denylist"]; ok {
var list []string
if err := json.Unmarshal(value, &list); err != nil {
writeError(w, http.StatusBadRequest, "sender_denylist must be a string array")
return
}
list = lists.Compact(list)
update.SenderDenylist = &list
}
if value, ok := raw["sender_allowlist"]; ok {
var list []string
if err := json.Unmarshal(value, &list); err != nil {
writeError(w, http.StatusBadRequest, "sender_allowlist must be a string array")
return
}
list = lists.Compact(list)
update.SenderAllowlist = &list
}
if value, ok := raw["recipient_denylist"]; ok {
var list []string
if err := json.Unmarshal(value, &list); err != nil {
writeError(w, http.StatusBadRequest, "recipient_denylist must be a string array")
return
}
list = lists.Compact(list)
update.RecipientDenylist = &list
}
if value, ok := raw["spam_aggressiveness"]; ok {
var aggressiveness string
if err := json.Unmarshal(value, &aggressiveness); err != nil {
writeError(w, http.StatusBadRequest, "spam_aggressiveness must be a string")
return
}
update.SpamAggressiveness = &aggressiveness
}
if value, ok := raw["junk_subject_keyword_spam"]; ok {
var flag bool
if err := json.Unmarshal(value, &flag); err != nil {
writeError(w, http.StatusBadRequest, "junk_subject_keyword_spam must be a boolean")
return
}
update.JunkSubjectKeywordSpam = &flag
}
if value, ok := raw["subject_rewriting_enabled"]; ok {
var flag bool
if err := json.Unmarshal(value, &flag); err != nil {
writeError(w, http.StatusBadRequest, "subject_rewriting_enabled must be a boolean")
return
}
update.SubjectRewritingEnabled = &flag
}
domain, err := s.migadu.UpdateDomain(r.Context(), domainName, update)
if err != nil {
writeMigaduError(w, err)
return
}
writeJSON(w, http.StatusOK, domain)
}
func decodeJSON(r *http.Request, dest any) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
return decoder.Decode(dest)
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeMigaduError(w http.ResponseWriter, err error) {
var apiErr *migadu.APIError
if errors.As(err, &apiErr) {
status := http.StatusBadGateway
if apiErr.StatusCode == http.StatusNotFound {
status = http.StatusNotFound
} else if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
status = apiErr.StatusCode
}
writeJSON(w, status, map[string]any{
"error": "migadu request failed",
"migadu_status": apiErr.StatusCode,
"migadu_body": apiErr.Body,
})
return
}
writeError(w, http.StatusBadGateway, "migadu request failed")
}
+320
View File
@@ -0,0 +1,320 @@
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/squid/MigaduAdmin/internal/config"
"github.com/squid/MigaduAdmin/internal/store"
"golang.org/x/crypto/bcrypt"
)
const (
SessionCookieName = "migaduadmin_session"
SessionTTL = 7 * 24 * time.Hour
bcryptCost = 12
)
type contextKey string
const userContextKey contextKey = "user"
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrClosedReg = errors.New("registration is closed")
)
type Service struct {
store *store.Store
config *config.Config
}
func NewService(st *store.Store, cfg *config.Config) *Service {
return &Service{store: st, config: cfg}
}
func (s *Service) HashPassword(password string) (string, error) {
if len(password) < 8 {
return "", fmt.Errorf("password must be at least 8 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func (s *Service) CheckPassword(hash, password string) bool {
if hash == "" || password == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (s *Service) RegistrationAllowed(ctx context.Context) (bool, error) {
count, err := s.store.CountUsers(ctx)
if err != nil {
return false, err
}
if count == 0 {
return true, nil
}
return s.config.RegistrationOpen, nil
}
func (s *Service) Register(ctx context.Context, email, displayName, password string) (*store.User, error) {
allowed, err := s.RegistrationAllowed(ctx)
if err != nil {
return nil, err
}
if !allowed {
return nil, ErrClosedReg
}
count, err := s.store.CountUsers(ctx)
if err != nil {
return nil, err
}
role := store.RoleUser
if count == 0 {
role = store.RoleAdmin
}
hash, err := s.HashPassword(password)
if err != nil {
return nil, err
}
return s.store.CreateUser(ctx, email, displayName, hash, role)
}
func (s *Service) CreateUserAsAdmin(ctx context.Context, email, displayName, password string, role store.Role) (*store.User, error) {
hash, err := s.HashPassword(password)
if err != nil {
return nil, err
}
if role == "" {
role = store.RoleUser
}
return s.store.CreateUser(ctx, email, displayName, hash, role)
}
func (s *Service) AuthenticateLocal(ctx context.Context, email, password string) (*store.User, error) {
user, err := s.store.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
if !s.CheckPassword(user.PasswordHash, password) {
return nil, ErrUnauthorized
}
return user, nil
}
func (s *Service) CreateSession(ctx context.Context, w http.ResponseWriter, userID int64) error {
_ = s.store.DeleteExpiredSessions(ctx)
token, err := randomToken(32)
if err != nil {
return err
}
expires := time.Now().UTC().Add(SessionTTL)
if err := s.store.CreateSession(ctx, token, userID, expires); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.config.CookieSecure,
Expires: expires,
MaxAge: int(SessionTTL.Seconds()),
})
return nil
}
func (s *Service) ClearSession(ctx context.Context, w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
_ = s.store.DeleteSession(ctx, cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.config.CookieSecure,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (*store.User, error) {
if s.config.AuthMode == config.AuthModePangolin || s.config.AuthMode == config.AuthModeBoth {
if user, err := s.userFromPangolin(ctx, r); err == nil {
return user, nil
} else if !errors.Is(err, ErrUnauthorized) {
return nil, err
}
}
if s.config.AuthMode == config.AuthModeLocal || s.config.AuthMode == config.AuthModeBoth {
return s.userFromSession(ctx, r)
}
return nil, ErrUnauthorized
}
func (s *Service) userFromSession(ctx context.Context, r *http.Request) (*store.User, error) {
cookie, err := r.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
return nil, ErrUnauthorized
}
session, err := s.store.GetSession(ctx, cookie.Value)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
if time.Now().UTC().After(session.ExpiresAt) {
_ = s.store.DeleteSession(ctx, session.ID)
return nil, ErrUnauthorized
}
user, err := s.store.GetUserByID(ctx, session.UserID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
return user, nil
}
func (s *Service) userFromPangolin(ctx context.Context, r *http.Request) (*store.User, error) {
if !s.isTrustedPeer(r) {
return nil, ErrUnauthorized
}
email := strings.TrimSpace(r.Header.Get("Remote-Email"))
if email == "" {
return nil, ErrUnauthorized
}
displayName := strings.TrimSpace(r.Header.Get("Remote-Name"))
if displayName == "" {
displayName = strings.TrimSpace(r.Header.Get("Remote-User"))
}
user, err := s.store.GetUserByEmail(ctx, email)
if err == nil {
return user, nil
}
if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
count, err := s.store.CountUsers(ctx)
if err != nil {
return nil, err
}
role := store.RoleUser
if count == 0 {
role = store.RoleAdmin
} else if !s.config.SSOAutoProvision {
return nil, ErrUnauthorized
}
return s.store.CreateUser(ctx, email, displayName, "", role)
}
func (s *Service) isTrustedPeer(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
return s.config.IsTrustedProxy(ip)
}
// StripUntrustedIdentityHeaders removes Pangolin identity headers unless the peer is trusted.
func (s *Service) StripUntrustedIdentityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.isTrustedPeer(r) {
headers := r.Header.Clone()
headers.Del("Remote-Email")
headers.Del("Remote-User")
headers.Del("Remote-Name")
headers.Del("Remote-Role")
r = r.Clone(r.Context())
r.Header = headers
}
next.ServeHTTP(w, r)
})
}
func (s *Service) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := s.UserFromRequest(r.Context(), r)
if err != nil {
if errors.Is(err, ErrUnauthorized) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Service) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil || user.Role != store.RoleAdmin {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func UserFromContext(ctx context.Context) *store.User {
user, _ := ctx.Value(userContextKey).(*store.User)
return user
}
func ConstantTimeEquals(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func RandomSecret(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
+165
View File
@@ -0,0 +1,165 @@
package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
type AuthMode string
const (
AuthModeLocal AuthMode = "local"
AuthModePangolin AuthMode = "pangolin"
AuthModeBoth AuthMode = "both"
)
type Config struct {
ListenAddr string
MigaduUser string
MigaduAPIKey string
MigaduBaseURL string
SessionSecret string
DatabasePath string
AuthMode AuthMode
TrustedProxies []*net.IPNet
RegistrationOpen bool
SSOAutoProvision bool
CookieSecure bool
}
func Load() (*Config, error) {
// Prefer project .env over stale exported shell vars (common after editing the key).
if err := godotenv.Overload(); err != nil {
// Missing .env is fine when vars are provided by the environment.
if !os.IsNotExist(err) {
// godotenv returns a plain error for missing file; ignore only that case.
if !strings.Contains(err.Error(), "no such file") {
return nil, fmt.Errorf("load .env: %w", err)
}
}
}
authMode := AuthMode(strings.ToLower(strings.TrimSpace(envOr("AUTH_MODE", "both"))))
switch authMode {
case AuthModeLocal, AuthModePangolin, AuthModeBoth:
default:
return nil, fmt.Errorf("invalid AUTH_MODE %q (use local, pangolin, or both)", authMode)
}
trusted, err := parseCIDRs(envOr("TRUSTED_PROXIES", "127.0.0.1/32,::1/128"))
if err != nil {
return nil, fmt.Errorf("TRUSTED_PROXIES: %w", err)
}
cfg := &Config{
ListenAddr: envOr("LISTEN_ADDR", ":8080"),
MigaduUser: cleanEnv(os.Getenv("MIGADU_USER")),
MigaduAPIKey: cleanEnv(os.Getenv("MIGADU_API_KEY")),
MigaduBaseURL: strings.TrimRight(envOr("MIGADU_BASE_URL", "https://api.migadu.com/v1"), "/"),
SessionSecret: cleanEnv(os.Getenv("SESSION_SECRET")),
DatabasePath: envOr("DATABASE_PATH", "data/migaduadmin.db"),
AuthMode: authMode,
TrustedProxies: trusted,
RegistrationOpen: envBool("REGISTRATION_OPEN", false),
SSOAutoProvision: envBool("SSO_AUTO_PROVISION", false),
CookieSecure: envBool("COOKIE_SECURE", false),
}
if cfg.MigaduUser == "" {
return nil, fmt.Errorf("MIGADU_USER is required")
}
if cfg.MigaduAPIKey == "" {
return nil, fmt.Errorf("MIGADU_API_KEY is required")
}
if strings.ContainsAny(cfg.MigaduAPIKey, " \t\n\r") {
return nil, fmt.Errorf("MIGADU_API_KEY contains whitespace; check .env quoting")
}
if cfg.SessionSecret == "" || len(cfg.SessionSecret) < 32 {
return nil, fmt.Errorf("SESSION_SECRET is required and must be at least 32 characters")
}
return cfg, nil
}
// MaskedMigaduKey returns a safe fingerprint for logs.
func (c *Config) MaskedMigaduKey() string {
key := c.MigaduAPIKey
if key == "" {
return "(empty)"
}
if len(key) <= 8 {
return "****"
}
return fmt.Sprintf("%s…%s (len=%d)", key[:4], key[len(key)-4:], len(key))
}
func cleanEnv(value string) string {
value = strings.TrimSpace(value)
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = strings.TrimSpace(value[1 : len(value)-1])
}
}
return value
}
func (c *Config) IsTrustedProxy(ip net.IP) bool {
if ip == nil {
return false
}
for _, network := range c.TrustedProxies {
if network.Contains(ip) {
return true
}
}
return false
}
func envOr(key, fallback string) string {
if value := cleanEnv(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func envBool(key string, fallback bool) bool {
raw := cleanEnv(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.ParseBool(raw)
if err != nil {
return fallback
}
return value
}
func parseCIDRs(raw string) ([]*net.IPNet, error) {
parts := strings.Split(raw, ",")
networks := make([]*net.IPNet, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if !strings.Contains(part, "/") {
if strings.Contains(part, ":") {
part += "/128"
} else {
part += "/32"
}
}
_, network, err := net.ParseCIDR(part)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q: %w", part, err)
}
networks = append(networks, network)
}
return networks, nil
}
+25
View File
@@ -0,0 +1,25 @@
package config
import (
"net"
"testing"
)
func TestIsTrustedProxy(t *testing.T) {
cfg := &Config{}
networks, err := parseCIDRs("10.0.0.0/8,127.0.0.1")
if err != nil {
t.Fatal(err)
}
cfg.TrustedProxies = networks
if !cfg.IsTrustedProxy(net.ParseIP("10.1.2.3")) {
t.Fatal("expected 10.1.2.3 trusted")
}
if !cfg.IsTrustedProxy(net.ParseIP("127.0.0.1")) {
t.Fatal("expected 127.0.0.1 trusted")
}
if cfg.IsTrustedProxy(net.ParseIP("192.168.1.1")) {
t.Fatal("expected 192.168.1.1 untrusted")
}
}
+136
View File
@@ -0,0 +1,136 @@
package lists
import "strings"
// Compact trims, lowercases, normalizes, dedupes, and drops entries covered by a
// broader wildcard in the same list (*@domain or *@*.suffix).
func Compact(entries []string) []string {
if len(entries) == 0 {
return []string{}
}
unique := make([]string, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, value := range entries {
entry := normalizeEntry(value)
if entry == "" {
continue
}
if _, ok := seen[entry]; ok {
continue
}
seen[entry] = struct{}{}
unique = append(unique, entry)
}
out := make([]string, 0, len(unique))
for i, entry := range unique {
covered := false
for j, other := range unique {
if i == j {
continue
}
if Covers(other, entry) && other != entry {
covered = true
break
}
}
if !covered {
out = append(out, entry)
}
}
return out
}
// normalizeEntry lowercases and repairs common wildcard typos:
// *domain.tld → *@domain.tld
// *@*rumble.com → *@*.rumble.com
func normalizeEntry(value string) string {
entry := strings.ToLower(strings.TrimSpace(value))
if entry == "" {
return ""
}
if strings.HasPrefix(entry, "*") && !strings.Contains(entry, "@") && strings.Contains(entry, ".") {
entry = "*@" + strings.TrimPrefix(entry, "*")
}
local, domain, ok := splitAddress(entry)
if ok && local == "*" && strings.HasPrefix(domain, "*") && !strings.HasPrefix(domain, "*.") {
entry = "*@*." + strings.TrimPrefix(domain, "*")
}
return entry
}
// Covers reports whether broader covers specific.
// Equal entries cover each other.
// *@domain covers any local@domain for that domain.
// *@*.suffix covers any local@host.suffix (domain glob).
func Covers(broader, specific string) bool {
broader = normalizeEntry(broader)
specific = normalizeEntry(specific)
if broader == "" || specific == "" {
return false
}
if broader == specific {
return true
}
broaderLocal, broaderDomain, broaderOK := splitAddress(broader)
_, specificDomain, specificOK := splitAddress(specific)
if !broaderOK || !specificOK {
return false
}
if broaderLocal != "*" {
return false
}
return domainMatches(broaderDomain, specificDomain)
}
func domainMatches(pattern, domain string) bool {
if pattern == domain {
return true
}
// *.suffix → any domain ending with .suffix (and longer than the suffix).
if strings.HasPrefix(pattern, "*.") {
suffix := pattern[1:] // ".shop", ".ac.in"
return strings.HasSuffix(domain, suffix) && len(domain) > len(suffix)
}
return false
}
func splitAddress(entry string) (local, domain string, ok bool) {
at := strings.LastIndex(entry, "@")
if at <= 0 || at == len(entry)-1 {
return "", "", false
}
return entry[:at], entry[at+1:], true
}
// FilterRecipientEntriesForDomain keeps recipient-deny entries that belong to
// the given domain. Migadu rejects addresses for other domains on PATCH.
// Bare local parts (no @) are kept and applied to every domain.
func FilterRecipientEntriesForDomain(domainName string, entries []string) []string {
domainName = strings.ToLower(strings.TrimSpace(domainName))
out := make([]string, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, value := range entries {
entry := normalizeEntry(value)
if entry == "" {
continue
}
local, entryDomain, hasAt := splitAddress(entry)
if hasAt {
if entryDomain != domainName {
continue
}
// Prefer local@domain form; keep as normalized.
entry = local + "@" + entryDomain
}
// Bare local-part applies to this domain as-is.
if _, ok := seen[entry]; ok {
continue
}
seen[entry] = struct{}{}
out = append(out, entry)
}
return out
}
+127
View File
@@ -0,0 +1,127 @@
package lists
import (
"reflect"
"testing"
)
func TestCompact(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input []string
want []string
}{
{
name: "empty",
input: nil,
want: []string{},
},
{
name: "exact duplicates case insensitive",
input: []string{"Lisa@Simpsons.com", "lisa@simpsons.com", " LISA@SIMPSONS.COM "},
want: []string{"lisa@simpsons.com"},
},
{
name: "wildcard covers specific",
input: []string{"lisa@simpsons.com", "*@simpsons.com"},
want: []string{"*@simpsons.com"},
},
{
name: "specific dropped when wildcard present first",
input: []string{"*@simpsons.com", "lisa@simpsons.com", "homer@simpsons.com"},
want: []string{"*@simpsons.com"},
},
{
name: "distinct locals kept",
input: []string{"lisa@simpsons.com", "homer@simpsons.com"},
want: []string{"lisa@simpsons.com", "homer@simpsons.com"},
},
{
name: "different domains kept",
input: []string{"*@simpsons.com", "lisa@flanders.com", "*@flanders.com"},
want: []string{"*@simpsons.com", "*@flanders.com"},
},
{
name: "bare hostname exact dedupe only",
input: []string{"simpsons.com", "Simpsons.com", "lisa@simpsons.com"},
want: []string{"simpsons.com", "lisa@simpsons.com"},
},
{
name: "blank entries dropped",
input: []string{"", " ", "a@b.com"},
want: []string{"a@b.com"},
},
{
name: "repair star without at",
input: []string{"*vsa.instalily.ai", "lisa@vsa.instalily.ai"},
want: []string{"*@vsa.instalily.ai"},
},
{
name: "suffix glob covers specific domains",
input: []string{"*@*.shop", "*@alth.shop", "spam@woowstars.shop", "*@other.com"},
want: []string{"*@*.shop", "*@other.com"},
},
{
name: "repair star-domain without dot",
input: []string{"*@*rumble.com", "user@foo.rumble.com"},
want: []string{"*@*.rumble.com"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Compact(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("Compact() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestFilterRecipientEntriesForDomain(t *testing.T) {
t.Parallel()
got := FilterRecipientEntriesForDomain("skaab.nu", []string{
"brittis@gronberg.info",
"brgr@tspse.net",
"abuse@skaab.nu",
"postmaster",
"ABUSE@SKAAB.NU",
})
want := []string{"abuse@skaab.nu", "postmaster"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
}
func TestCovers(t *testing.T) {
t.Parallel()
tests := []struct {
broader string
specific string
want bool
}{
{"*@simpsons.com", "lisa@simpsons.com", true},
{"*@simpsons.com", "*@simpsons.com", true},
{"lisa@simpsons.com", "*@simpsons.com", false},
{"lisa@simpsons.com", "homer@simpsons.com", false},
{"*@simpsons.com", "lisa@flanders.com", false},
{"simpsons.com", "lisa@simpsons.com", false},
{"", "a@b.com", false},
{"*@*.shop", "*@alth.shop", true},
{"*@*.shop", "user@alth.shop", true},
{"*@*.shop", "*@other.com", false},
{"*@simpsons.com", "lisa@mail.simpsons.com", false},
{"*vsa.instalily.ai", "x@vsa.instalily.ai", true},
}
for _, tt := range tests {
got := Covers(tt.broader, tt.specific)
if got != tt.want {
t.Fatalf("Covers(%q, %q) = %v, want %v", tt.broader, tt.specific, got, tt.want)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package lists
import (
"fmt"
"strings"
"unicode"
)
// EntryIssue describes a single invalid list entry.
type EntryIssue struct {
Entry string `json:"entry"`
Reason string `json:"reason"`
}
// ValidateEntries checks each entry against Migadu-style address patterns.
// Empty input is valid. Entries are normalized before checking.
func ValidateEntries(entries []string) []EntryIssue {
issues := make([]EntryIssue, 0)
for _, value := range entries {
raw := strings.TrimSpace(value)
if raw == "" {
continue
}
entry := normalizeEntry(raw)
if reason := validateNormalizedEntry(entry); reason != "" {
issues = append(issues, EntryIssue{Entry: raw, Reason: reason})
}
}
return issues
}
// ValidateEntry returns an error reason for one entry, or "" if valid.
func ValidateEntry(value string) string {
raw := strings.TrimSpace(value)
if raw == "" {
return "entry is empty"
}
return validateNormalizedEntry(normalizeEntry(raw))
}
func validateNormalizedEntry(entry string) string {
if entry == "" {
return "entry is empty"
}
if strings.ContainsAny(entry, " \t\r\n") {
return "entry must not contain whitespace"
}
if strings.Count(entry, "@") != 1 {
return "entry must look like local@domain (e.g. user@example.com or *@example.com)"
}
local, domain, ok := splitAddress(entry)
if !ok {
return "entry must look like local@domain"
}
if local == "" {
return "local part before @ is empty"
}
if !validLocalPart(local) {
return "invalid local part (use user, *@domain, or *prefix)"
}
if domain == "" {
return "domain after @ is empty"
}
if !validDomainPart(domain) {
return "invalid domain (use example.com or *.com / *.co.uk)"
}
return ""
}
func validLocalPart(local string) bool {
if local == "*" {
return true
}
// *prefix or normal local-part characters commonly used in email addresses.
if strings.HasPrefix(local, "*") {
rest := local[1:]
if rest == "" {
return true
}
return validLocalChars(rest)
}
return validLocalChars(local)
}
func validLocalChars(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
switch r {
case '.', '_', '+', '-', '%':
continue
default:
return false
}
}
return true
}
func validDomainPart(domain string) bool {
if strings.HasPrefix(domain, "*.") {
return validDNSSuffix(domain[2:])
}
return validDNSDomain(domain)
}
// validDNSSuffix allows "shop" or "ac.in" after a *. glob prefix.
func validDNSSuffix(suffix string) bool {
if suffix == "" || len(suffix) > 253 {
return false
}
if strings.HasPrefix(suffix, ".") || strings.HasSuffix(suffix, ".") {
return false
}
labels := strings.Split(suffix, ".")
if len(labels) < 1 {
return false
}
for _, label := range labels {
if !validDNSLabel(label) {
return false
}
}
tld := labels[len(labels)-1]
for _, r := range tld {
if !unicode.IsLetter(r) {
return false
}
}
return true
}
func validDNSDomain(domain string) bool {
if domain == "" || len(domain) > 253 {
return false
}
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return false
}
labels := strings.Split(domain, ".")
if len(labels) < 2 {
return false
}
for _, label := range labels {
if !validDNSLabel(label) {
return false
}
}
// TLD should be alphabetic (allows multi-char like com, uk, info).
tld := labels[len(labels)-1]
for _, r := range tld {
if !unicode.IsLetter(r) {
return false
}
}
return true
}
func validDNSLabel(label string) bool {
if label == "" || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
if r == '-' && i > 0 && i < len(label)-1 {
continue
}
return false
}
return true
}
// FormatIssues returns a short multi-line summary for logs/UI.
func FormatIssues(issues []EntryIssue) string {
if len(issues) == 0 {
return ""
}
parts := make([]string, 0, len(issues))
for _, issue := range issues {
parts = append(parts, fmt.Sprintf("%s: %s", issue.Entry, issue.Reason))
}
return strings.Join(parts, "\n")
}
+45
View File
@@ -0,0 +1,45 @@
package lists
import "testing"
func TestValidateEntries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
entries []string
wantN int
}{
{name: "empty list", entries: nil, wantN: 0},
{name: "valid user", entries: []string{"user@example.com"}, wantN: 0},
{name: "valid star domain", entries: []string{"*@example.com"}, wantN: 0},
{name: "valid suffix glob", entries: []string{"*@*.shop", "*@*.ac.in"}, wantN: 0},
{name: "valid plus addressing", entries: []string{"a.b+c@mail-host.co.uk"}, wantN: 0},
{name: "repair then valid", entries: []string{"*vsa.instalily.ai"}, wantN: 0},
{name: "missing at", entries: []string{"not-an-email"}, wantN: 1},
{name: "bare domain", entries: []string{"example.com"}, wantN: 1},
{name: "spaces", entries: []string{"user @example.com"}, wantN: 1},
{name: "bad domain", entries: []string{"user@-example.com"}, wantN: 1},
{name: "single label domain", entries: []string{"*@localhost"}, wantN: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := ValidateEntries(tt.entries)
if len(got) != tt.wantN {
t.Fatalf("ValidateEntries() issues=%v want %d", got, tt.wantN)
}
})
}
}
func TestValidateEntryReasons(t *testing.T) {
t.Parallel()
if got := ValidateEntry(""); got == "" {
t.Fatal("expected empty reason")
}
if got := ValidateEntry("lisa@simpsons.com"); got != "" {
t.Fatalf("unexpected: %s", got)
}
}
+443
View File
@@ -0,0 +1,443 @@
package migadu
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
baseURL string
username string
apiKey string
httpClient *http.Client
}
func NewClient(baseURL, username, apiKey string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// StringList normalizes Migadu fields that may arrive as a string, string list, or null.
type StringList []string
func (s *StringList) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
*s = []string{}
return nil
}
if data[0] == '"' {
var single string
if err := json.Unmarshal(data, &single); err != nil {
return err
}
single = strings.TrimSpace(single)
if single == "" {
*s = []string{}
return nil
}
parts := strings.FieldsFunc(single, func(r rune) bool {
return r == ',' || r == '\n' || r == ';'
})
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
*s = out
return nil
}
var list []string
if err := json.Unmarshal(data, &list); err != nil {
return err
}
out := make([]string, 0, len(list))
for _, item := range list {
item = strings.TrimSpace(item)
if item != "" {
out = append(out, item)
}
}
*s = out
return nil
}
func (s StringList) MarshalJSON() ([]byte, error) {
if s == nil {
return json.Marshal([]string{})
}
return json.Marshal([]string(s))
}
type DomainSummary struct {
Name string `json:"name"`
State string `json:"state"`
Description string `json:"description"`
SpamAggressiveness any `json:"spam_aggressiveness"`
CanSend bool `json:"can_send"`
CanReceive bool `json:"can_receive"`
}
type Domain struct {
Name string `json:"name"`
State string `json:"state"`
Description string `json:"description"`
Tags StringList `json:"tags"`
ActivatedAt *string `json:"activated_at"`
DeactivatedAt *string `json:"deactivated_at"`
CanSend bool `json:"can_send"`
CanReceive bool `json:"can_receive"`
CanAccess bool `json:"can_access"`
MxProxyEnabled bool `json:"mx_proxy_enabled"`
SpamAggressiveness any `json:"spam_aggressiveness"`
SubjectRewritingEnabled bool `json:"subject_rewriting_enabled"`
JunkSubjectKeywordSpam bool `json:"junk_subject_keyword_spam"`
SenderDenylist StringList `json:"sender_denylist"`
SenderAllowlist StringList `json:"sender_allowlist"`
RecipientDenylist StringList `json:"recipient_denylist"`
CatchallDestinations StringList `json:"catchall_destinations"`
HostedDNS bool `json:"hosted_dns"`
}
type DomainUpdate struct {
SenderDenylist *[]string `json:"-"`
SenderAllowlist *[]string `json:"-"`
RecipientDenylist *[]string `json:"-"`
SpamAggressiveness *string `json:"spam_aggressiveness,omitempty"`
JunkSubjectKeywordSpam *bool `json:"junk_subject_keyword_spam,omitempty"`
SubjectRewritingEnabled *bool `json:"subject_rewriting_enabled,omitempty"`
}
// MarshalJSON encodes denylist/allowlist fields as comma-separated strings.
// Migadu's API documents these as String/List and rejects JSON arrays on PATCH.
func (u DomainUpdate) MarshalJSON() ([]byte, error) {
raw := map[string]any{}
if u.SenderDenylist != nil {
raw["sender_denylist"] = joinListField(*u.SenderDenylist)
}
if u.SenderAllowlist != nil {
raw["sender_allowlist"] = joinListField(*u.SenderAllowlist)
}
if u.RecipientDenylist != nil {
raw["recipient_denylist"] = joinListField(*u.RecipientDenylist)
}
if u.SpamAggressiveness != nil {
raw["spam_aggressiveness"] = *u.SpamAggressiveness
}
if u.JunkSubjectKeywordSpam != nil {
raw["junk_subject_keyword_spam"] = *u.JunkSubjectKeywordSpam
}
if u.SubjectRewritingEnabled != nil {
raw["subject_rewriting_enabled"] = *u.SubjectRewritingEnabled
}
return json.Marshal(raw)
}
func joinListField(values []string) string {
if len(values) == 0 {
return ""
}
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
parts = append(parts, value)
}
}
return strings.Join(parts, ",")
}
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
if e.Body == "" {
return fmt.Sprintf("migadu api error: HTTP %d", e.StatusCode)
}
return fmt.Sprintf("migadu api error: HTTP %d: %s", e.StatusCode, e.Body)
}
func (c *Client) ListDomains(ctx context.Context) ([]DomainSummary, error) {
raw, err := c.doRaw(ctx, http.MethodGet, "/domains", nil, false)
if err != nil {
return nil, err
}
var wrapped struct {
Domains []DomainSummary `json:"domains"`
}
if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Domains != nil {
return wrapped.Domains, nil
}
var list []DomainSummary
if err := json.Unmarshal(raw, &list); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return list, nil
}
func (c *Client) GetDomain(ctx context.Context, name string) (*Domain, error) {
var domain Domain
path := "/domains/" + url.PathEscape(name)
if err := c.do(ctx, http.MethodGet, path, nil, &domain, false); err != nil {
return nil, err
}
return &domain, nil
}
func (c *Client) UpdateDomain(ctx context.Context, name string, update DomainUpdate) (*Domain, error) {
return c.updateDomain(ctx, name, update, false)
}
func (c *Client) updateDomain(ctx context.Context, name string, update DomainUpdate, quiet bool) (*Domain, error) {
update.normalizeListPointers()
var domain Domain
path := "/domains/" + url.PathEscape(name)
if err := c.do(ctx, http.MethodPatch, path, update, &domain, quiet); err != nil {
return nil, err
}
return &domain, nil
}
// ListWriteResult is the outcome of writing denylist/allowlist fields with
// rejection bisect when Migadu returns HTTP 400.
type ListWriteResult struct {
SenderDenylist []string
SenderAllowlist []string
RecipientDenylist []string
Rejected []string
}
// UpdateDomainLists writes list fields one at a time. On HTTP 400 it bisects to
// find entries Migadu rejects, skips them, and applies the rest.
func (c *Client) UpdateDomainLists(ctx context.Context, name string, denylist, allowlist, recipient []string) (*ListWriteResult, error) {
result := &ListWriteResult{}
acceptedDeny, rejectedDeny, err := c.writeListField(ctx, name, "sender_denylist", denylist)
if err != nil {
return nil, err
}
acceptedAllow, rejectedAllow, err := c.writeListField(ctx, name, "sender_allowlist", allowlist)
if err != nil {
return nil, err
}
acceptedRecipient, rejectedRecipient, err := c.writeListField(ctx, name, "recipient_denylist", recipient)
if err != nil {
return nil, err
}
result.SenderDenylist = acceptedDeny
result.SenderAllowlist = acceptedAllow
result.RecipientDenylist = acceptedRecipient
result.Rejected = append(result.Rejected, rejectedDeny...)
result.Rejected = append(result.Rejected, rejectedAllow...)
result.Rejected = append(result.Rejected, rejectedRecipient...)
if len(result.Rejected) > 0 {
log.Printf(
"migadu: %s list write skipped %d rejected entr(y/ies): %s",
name,
len(result.Rejected),
strings.Join(result.Rejected, ", "),
)
}
return result, nil
}
func (c *Client) writeListField(ctx context.Context, domainName, field string, entries []string) (accepted, rejected []string, err error) {
entries = append([]string{}, entries...)
_, err = c.updateDomain(ctx, domainName, domainUpdateForField(field, entries), false)
if err == nil {
return entries, nil, nil
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest {
return nil, nil, err
}
rejected = c.findRejectedEntries(ctx, domainName, field, entries)
rejectedSet := make(map[string]struct{}, len(rejected))
for _, entry := range rejected {
rejectedSet[entry] = struct{}{}
}
accepted = make([]string, 0, len(entries))
for _, entry := range entries {
if _, bad := rejectedSet[entry]; !bad {
accepted = append(accepted, entry)
}
}
if _, err = c.updateDomain(ctx, domainName, domainUpdateForField(field, accepted), false); err != nil {
return nil, rejected, err
}
return accepted, rejected, nil
}
func domainUpdateForField(field string, entries []string) DomainUpdate {
list := append([]string{}, entries...)
switch field {
case "sender_denylist":
return DomainUpdate{SenderDenylist: &list}
case "sender_allowlist":
return DomainUpdate{SenderAllowlist: &list}
case "recipient_denylist":
return DomainUpdate{RecipientDenylist: &list}
default:
return DomainUpdate{}
}
}
func (c *Client) findRejectedEntries(ctx context.Context, domainName, field string, entries []string) []string {
if len(entries) == 0 {
return nil
}
_, err := c.updateDomain(ctx, domainName, domainUpdateForField(field, entries), true)
if err == nil {
return nil
}
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadRequest {
return append([]string{}, entries...)
}
if len(entries) == 1 {
log.Printf("migadu: %s rejected %s entry %q", domainName, field, entries[0])
return []string{entries[0]}
}
mid := len(entries) / 2
left := c.findRejectedEntries(ctx, domainName, field, entries[:mid])
right := c.findRejectedEntries(ctx, domainName, field, entries[mid:])
return append(left, right...)
}
// normalizeListPointers ensures list fields are non-nil when present so they
// encode as empty strings rather than null.
func (u *DomainUpdate) normalizeListPointers() {
u.SenderDenylist = nonNilStringListPtr(u.SenderDenylist)
u.SenderAllowlist = nonNilStringListPtr(u.SenderAllowlist)
u.RecipientDenylist = nonNilStringListPtr(u.RecipientDenylist)
}
func nonNilStringListPtr(list *[]string) *[]string {
if list == nil {
return nil
}
if *list == nil {
empty := []string{}
return &empty
}
return list
}
func (c *Client) do(ctx context.Context, method, path string, body any, out any, quiet bool) error {
raw, err := c.doRaw(ctx, method, path, body, quiet)
if err != nil {
return err
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
return nil
}
func (c *Client) doRaw(ctx context.Context, method, path string, body any, quiet bool) ([]byte, error) {
var reader io.Reader
var encoded []byte
if body != nil {
var err error
encoded, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("encode request: %w", err)
}
reader = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.SetBasicAuth(c.username, c.apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
log.Printf("migadu: %s %s failed for user=%q: %v", method, path, c.username, err)
return nil, fmt.Errorf("migadu request failed: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
responseBody := strings.TrimSpace(string(raw))
if !quiet {
log.Printf(
"migadu: %s %s → HTTP %d user=%q api_key=%s request_bytes=%d request=%s body=%q",
method,
c.baseURL+path,
resp.StatusCode,
c.username,
maskSecret(c.apiKey),
len(encoded),
stringOrEmpty(encoded),
truncate(responseBody, 500),
)
}
return nil, &APIError{StatusCode: resp.StatusCode, Body: responseBody}
}
if !quiet {
log.Printf("migadu: %s %s → HTTP %d ok", method, path, resp.StatusCode)
}
return raw, nil
}
func stringOrEmpty(value []byte) string {
if len(value) == 0 {
return "(none)"
}
return string(value)
}
func maskSecret(secret string) string {
secret = strings.TrimSpace(secret)
if secret == "" {
return "(empty)"
}
if len(secret) <= 8 {
return "****"
}
return secret[:4] + "…" + secret[len(secret)-4:] + fmt.Sprintf(" (len=%d)", len(secret))
}
func truncate(value string, max int) string {
if len(value) <= max {
return value
}
return value[:max] + "…"
}
+64
View File
@@ -0,0 +1,64 @@
package migadu
import (
"encoding/json"
"testing"
)
func TestStringListUnmarshal(t *testing.T) {
cases := []struct {
name string
raw string
want []string
}{
{name: "null", raw: `null`, want: []string{}},
{name: "empty string", raw: `""`, want: []string{}},
{name: "csv string", raw: `"a@x.com, b@y.com"`, want: []string{"a@x.com", "b@y.com"}},
{name: "array", raw: `["a@x.com","b@y.com"]`, want: []string{"a@x.com", "b@y.com"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got StringList
if err := json.Unmarshal([]byte(tc.raw), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len=%d want %d (%v)", len(got), len(tc.want), got)
}
for i := range tc.want {
if got[i] != tc.want[i] {
t.Fatalf("got %v want %v", got, tc.want)
}
}
})
}
}
func TestDomainUpdateNilListMarshalsAsEmptyArray(t *testing.T) {
var nilList []string
update := DomainUpdate{
SenderDenylist: &nilList,
SenderAllowlist: &[]string{"a@b.com", "c@d.com"},
RecipientDenylist: &[]string{},
}
update.normalizeListPointers()
raw, err := json.Marshal(update)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded map[string]json.RawMessage
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("decode: %v", err)
}
if string(decoded["sender_denylist"]) != `""` {
t.Fatalf("sender_denylist=%s want empty string", decoded["sender_denylist"])
}
if string(decoded["sender_allowlist"]) != `"a@b.com,c@d.com"` {
t.Fatalf("sender_allowlist=%s", decoded["sender_allowlist"])
}
if string(decoded["recipient_denylist"]) != `""` {
t.Fatalf("recipient_denylist=%s want empty string", decoded["recipient_denylist"])
}
}
+469
View File
@@ -0,0 +1,469 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/squid/MigaduAdmin/internal/lists"
_ "modernc.org/sqlite"
)
var (
ErrNotFound = errors.New("not found")
ErrEmailTaken = errors.New("email already registered")
ErrInvalidInput = errors.New("invalid input")
ErrDomainExists = errors.New("domain already managed")
)
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role Role `json:"role"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type Session struct {
ID string
UserID int64
ExpiresAt time.Time
CreatedAt time.Time
}
type ManagedDomain struct {
Name string `json:"name"`
AddedAt time.Time `json:"added_at"`
AddedBy *int64 `json:"added_by,omitempty"`
}
// SharedLists holds denylist/allowlist entries applied across managed domains.
type SharedLists struct {
SenderDenylist []string `json:"sender_denylist"`
SenderAllowlist []string `json:"sender_allowlist"`
RecipientDenylist []string `json:"recipient_denylist"`
}
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
store := &Store{db: db}
if err := store.migrate(); err != nil {
_ = db.Close()
return nil, err
}
return store, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
const schema = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL,
password_hash TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS managed_domains (
name TEXT PRIMARY KEY COLLATE NOCASE,
added_at TEXT NOT NULL,
added_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS shared_lists (
id INTEGER PRIMARY KEY CHECK (id = 1),
sender_denylist TEXT NOT NULL DEFAULT '[]',
sender_allowlist TEXT NOT NULL DEFAULT '[]',
recipient_denylist TEXT NOT NULL DEFAULT '[]'
);
INSERT OR IGNORE INTO shared_lists (id, sender_denylist, sender_allowlist, recipient_denylist)
VALUES (1, '[]', '[]', '[]');
`
_, err := s.db.Exec(schema)
return err
}
func (s *Store) CountUsers(ctx context.Context) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
func (s *Store) CreateUser(ctx context.Context, email, displayName, passwordHash string, role Role) (*User, error) {
email = normalizeEmail(email)
displayName = strings.TrimSpace(displayName)
if email == "" {
return nil, ErrInvalidInput
}
if displayName == "" {
displayName = email
}
if role != RoleAdmin && role != RoleUser {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
result, err := s.db.ExecContext(ctx, `
INSERT INTO users (email, display_name, role, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)`,
email, displayName, string(role), passwordHash, now.Format(time.RFC3339Nano),
)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return nil, ErrEmailTaken
}
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &User{
ID: id,
Email: email,
DisplayName: displayName,
Role: role,
PasswordHash: passwordHash,
CreatedAt: now,
}, nil
}
func (s *Store) GetUserByID(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users WHERE id = ?`, id)
return scanUser(row)
}
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*User, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users WHERE email = ? COLLATE NOCASE`, normalizeEmail(email))
return scanUser(row)
}
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, email, display_name, role, password_hash, created_at
FROM users ORDER BY id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, *user)
}
return users, rows.Err()
}
func (s *Store) CreateSession(ctx context.Context, id string, userID int64, expiresAt time.Time) error {
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `
INSERT INTO sessions (id, user_id, expires_at, created_at)
VALUES (?, ?, ?, ?)`,
id, userID, expiresAt.UTC().Format(time.RFC3339Nano), now.Format(time.RFC3339Nano),
)
return err
}
func (s *Store) GetSession(ctx context.Context, id string) (*Session, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?`, id)
var session Session
var expiresAt, createdAt string
if err := row.Scan(&session.ID, &session.UserID, &expiresAt, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
var err error
session.ExpiresAt, err = time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
return nil, err
}
session.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, err
}
return &session, nil
}
func (s *Store) DeleteSession(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, id)
return err
}
func (s *Store) DeleteExpiredSessions(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
DELETE FROM sessions WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339Nano))
return err
}
func normalizeDomainName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func (s *Store) AddManagedDomain(ctx context.Context, name string, addedBy int64) (*ManagedDomain, error) {
name = normalizeDomainName(name)
if name == "" || !strings.Contains(name, ".") {
return nil, ErrInvalidInput
}
now := time.Now().UTC()
_, err := s.db.ExecContext(ctx, `
INSERT INTO managed_domains (name, added_at, added_by)
VALUES (?, ?, ?)`, name, now.Format(time.RFC3339Nano), addedBy)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return nil, ErrDomainExists
}
return nil, err
}
return &ManagedDomain{
Name: name,
AddedAt: now,
AddedBy: &addedBy,
}, nil
}
func (s *Store) ListManagedDomains(ctx context.Context) ([]ManagedDomain, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT name, added_at, added_by FROM managed_domains ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
domains := make([]ManagedDomain, 0)
for rows.Next() {
var domain ManagedDomain
var addedAt string
var addedBy sql.NullInt64
if err := rows.Scan(&domain.Name, &addedAt, &addedBy); err != nil {
return nil, err
}
parsed, err := time.Parse(time.RFC3339Nano, addedAt)
if err != nil {
return nil, err
}
domain.AddedAt = parsed
if addedBy.Valid {
value := addedBy.Int64
domain.AddedBy = &value
}
domains = append(domains, domain)
}
return domains, rows.Err()
}
func (s *Store) GetManagedDomain(ctx context.Context, name string) (*ManagedDomain, error) {
row := s.db.QueryRowContext(ctx, `
SELECT name, added_at, added_by FROM managed_domains WHERE name = ? COLLATE NOCASE`,
normalizeDomainName(name))
var domain ManagedDomain
var addedAt string
var addedBy sql.NullInt64
if err := row.Scan(&domain.Name, &addedAt, &addedBy); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
parsed, err := time.Parse(time.RFC3339Nano, addedAt)
if err != nil {
return nil, err
}
domain.AddedAt = parsed
if addedBy.Valid {
value := addedBy.Int64
domain.AddedBy = &value
}
return &domain, nil
}
func (s *Store) IsManagedDomain(ctx context.Context, name string) (bool, error) {
_, err := s.GetManagedDomain(ctx, name)
if err == nil {
return true, nil
}
if errors.Is(err, ErrNotFound) {
return false, nil
}
return false, err
}
func (s *Store) DeleteManagedDomain(ctx context.Context, name string) error {
result, err := s.db.ExecContext(ctx, `
DELETE FROM managed_domains WHERE name = ? COLLATE NOCASE`, normalizeDomainName(name))
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) GetSharedLists(ctx context.Context) (*SharedLists, error) {
row := s.db.QueryRowContext(ctx, `
SELECT sender_denylist, sender_allowlist, recipient_denylist
FROM shared_lists WHERE id = 1`)
var denylistJSON, allowlistJSON, recipientJSON string
if err := row.Scan(&denylistJSON, &allowlistJSON, &recipientJSON); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return &SharedLists{
SenderDenylist: []string{},
SenderAllowlist: []string{},
RecipientDenylist: []string{},
}, nil
}
return nil, err
}
lists := &SharedLists{}
if err := unmarshalStringList(denylistJSON, &lists.SenderDenylist); err != nil {
return nil, err
}
if err := unmarshalStringList(allowlistJSON, &lists.SenderAllowlist); err != nil {
return nil, err
}
if err := unmarshalStringList(recipientJSON, &lists.RecipientDenylist); err != nil {
return nil, err
}
return lists, nil
}
func (s *Store) PutSharedLists(ctx context.Context, lists SharedLists) error {
denylistJSON, err := marshalStringList(lists.SenderDenylist)
if err != nil {
return err
}
allowlistJSON, err := marshalStringList(lists.SenderAllowlist)
if err != nil {
return err
}
recipientJSON, err := marshalStringList(lists.RecipientDenylist)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO shared_lists (id, sender_denylist, sender_allowlist, recipient_denylist)
VALUES (1, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
sender_denylist = excluded.sender_denylist,
sender_allowlist = excluded.sender_allowlist,
recipient_denylist = excluded.recipient_denylist`,
denylistJSON, allowlistJSON, recipientJSON,
)
return err
}
func marshalStringList(values []string) (string, error) {
normalized := normalizeStringList(values)
data, err := json.Marshal(normalized)
if err != nil {
return "", err
}
return string(data), nil
}
func unmarshalStringList(raw string, dest *[]string) error {
if strings.TrimSpace(raw) == "" {
*dest = []string{}
return nil
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return err
}
*dest = normalizeStringList(values)
return nil
}
func normalizeStringList(values []string) []string {
return lists.Compact(values)
}
type scannable interface {
Scan(dest ...any) error
}
func scanUser(row scannable) (*User, error) {
var user User
var role string
var createdAt string
if err := row.Scan(&user.ID, &user.Email, &user.DisplayName, &role, &user.PasswordHash, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
user.Role = Role(role)
parsed, err := time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, err
}
user.CreatedAt = parsed
return &user, nil
}
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Bootstrap a local development .env and dependencies.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
EXAMPLE=".env.example"
ENV_FILE=".env"
if [[ ! -f "$EXAMPLE" ]]; then
echo "error: missing $EXAMPLE" >&2
exit 1
fi
created_env=0
if [[ ! -f "$ENV_FILE" ]]; then
cp "$EXAMPLE" "$ENV_FILE"
created_env=1
if command -v openssl >/dev/null 2>&1; then
secret="$(openssl rand -base64 48 | tr -d '\n')"
elif command -v python3 >/dev/null 2>&1; then
secret="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')"
else
secret="dev-$(date +%s)-please-change-this-secret-value-now"
fi
# Portable in-place replace for SESSION_SECRET
tmp="$(mktemp)"
awk -v secret="$secret" '
BEGIN { replaced = 0 }
/^SESSION_SECRET=/ {
print "SESSION_SECRET=" secret
replaced = 1
next
}
{ print }
END {
if (!replaced) {
print "SESSION_SECRET=" secret
}
}
' "$ENV_FILE" > "$tmp"
mv "$tmp" "$ENV_FILE"
echo "Created $ENV_FILE with a generated SESSION_SECRET."
echo "Edit MIGADU_USER and MIGADU_API_KEY before calling Migadu."
else
echo "Using existing $ENV_FILE."
fi
mkdir -p data
# Ensure node deps for Vite
if [[ -d web ]]; then
(cd web && npm install --silent)
fi
# Friendly reminder when placeholders remain
if grep -Eq '^MIGADU_USER=(you@example\.com)?[[:space:]]*$' "$ENV_FILE" \
|| grep -Eq '^MIGADU_API_KEY=(your-migadu-api-key)?[[:space:]]*$' "$ENV_FILE"; then
echo
echo "Note: set real MIGADU_USER / MIGADU_API_KEY in .env to load domains."
echo "Auth/UI still work locally without them until you open Domains."
fi
if [[ "$created_env" -eq 1 ]]; then
echo
echo "Dev defaults ready. Next: make dev"
fi
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MigaduAdmin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=IBM+Plex+Mono:wght@400;500&family=Source+Sans+3:wght@400;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1343
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+175
View File
@@ -0,0 +1,175 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { useAuth } from './auth/AuthContext'
import { DomainsProvider } from './auth/DomainsContext'
import { DomainDetailPage } from './pages/DomainDetailPage'
import { HomePage } from './pages/HomePage'
import { LoginPage } from './pages/LoginPage'
import { RegisterPage } from './pages/RegisterPage'
import { SharedListsPage } from './pages/SharedListsPage'
import { StubPage } from './pages/StubPage'
import type { ReactNode } from 'react'
function Protected({ children }: { children: ReactNode }) {
const { user, loading, status } = useAuth()
if (loading) {
return (
<div className="auth-layout">
<p className="muted">Loading</p>
</div>
)
}
if (!user) {
if (status?.registration_open) {
return <Navigate to="/register" replace />
}
return <Navigate to="/login" replace />
}
if (user.role !== 'admin') {
return (
<div className="auth-layout">
<div className="auth-panel">
<h1>Access denied</h1>
<p className="lede">
Your account is signed in but does not have admin permission for
domain controls.
</p>
</div>
</div>
)
}
return <DomainsProvider>{children}</DomainsProvider>
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/"
element={
<Protected>
<HomePage />
</Protected>
}
/>
<Route
path="/shared-lists"
element={
<Protected>
<SharedListsPage />
</Protected>
}
/>
<Route
path="/domains/:domain/settings"
element={
<Protected>
<DomainDetailPage />
</Protected>
}
/>
<Route
path="/domains/:domain/records"
element={
<Protected>
<StubPage
title="DNS records"
description="View Migadu DNS records for the selected domain."
domainScoped
/>
</Protected>
}
/>
<Route
path="/domains/:domain/diagnostics"
element={
<Protected>
<StubPage
title="Diagnostics"
description="Domain diagnostics from the Migadu API."
domainScoped
/>
</Protected>
}
/>
<Route
path="/domains/:domain/usage"
element={
<Protected>
<StubPage
title="Usage"
description="Domain usage statistics from the Migadu API."
domainScoped
/>
</Protected>
}
/>
<Route
path="/mailboxes"
element={
<Protected>
<StubPage
title="Mailboxes"
description="Manage mailboxes on administered domains."
domainScoped
/>
</Protected>
}
/>
<Route
path="/identities"
element={
<Protected>
<StubPage
title="Identities"
description="Manage mailbox identities."
domainScoped
/>
</Protected>
}
/>
<Route
path="/forwardings"
element={
<Protected>
<StubPage
title="Forwardings"
description="Manage address forwardings."
domainScoped
/>
</Protected>
}
/>
<Route
path="/aliases"
element={
<Protected>
<StubPage
title="Aliases"
description="Manage aliases on administered domains."
domainScoped
/>
</Protected>
}
/>
<Route
path="/rewrites"
element={
<Protected>
<StubPage
title="Rewrites"
description="Manage rewrite rules."
domainScoped
/>
</Protected>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
+111
View File
@@ -0,0 +1,111 @@
import type {
ApiErrorBody,
ApplySharedListsRequest,
ApplySharedListsResult,
AuthStatus,
Domain,
DomainUpdate,
ImportSharedListsRequest,
ImportSharedListsResponse,
ManagedDomain,
SharedLists,
User,
} from './types'
export class ApiError extends Error {
status: number
body: ApiErrorBody
constructor(status: number, body: ApiErrorBody) {
super(body.error || `Request failed (${status})`)
this.status = status
this.body = body
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
credentials: 'include',
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
...init,
})
const text = await response.text()
let payload: unknown = null
if (text) {
try {
payload = JSON.parse(text)
} catch {
payload = { error: text }
}
}
if (!response.ok) {
const body = (payload ?? { error: response.statusText }) as ApiErrorBody
throw new ApiError(response.status, body)
}
return payload as T
}
export const api = {
authStatus: () => request<AuthStatus>('/api/auth/status'),
me: () => request<User>('/api/auth/me'),
login: (email: string, password: string) =>
request<User>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
register: (email: string, password: string, displayName: string) =>
request<User>('/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email,
password,
display_name: displayName,
}),
}),
logout: () =>
request<{ status: string }>('/api/auth/logout', { method: 'POST' }),
listManagedDomains: () =>
request<{ domains: ManagedDomain[] }>('/api/managed-domains').then(
(data) => data.domains ?? [],
),
addManagedDomain: (name: string) =>
request<ManagedDomain>('/api/managed-domains', {
method: 'POST',
body: JSON.stringify({ name }),
}),
removeManagedDomain: (name: string) =>
request<{ status: string }>(
`/api/managed-domains/${encodeURIComponent(name)}`,
{ method: 'DELETE' },
),
getDomain: (name: string) =>
request<Domain>(`/api/domains/${encodeURIComponent(name)}`),
updateDomain: (name: string, update: DomainUpdate) =>
request<Domain>(`/api/domains/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify(update),
}),
getSharedLists: () => request<SharedLists>('/api/shared-lists'),
putSharedLists: (lists: SharedLists) =>
request<SharedLists>('/api/shared-lists', {
method: 'PUT',
body: JSON.stringify(lists),
}),
applySharedLists: (body: ApplySharedListsRequest) =>
request<{ results: ApplySharedListsResult[] }>('/api/shared-lists/apply', {
method: 'POST',
body: JSON.stringify(body),
}).then((data) => data.results ?? []),
importSharedLists: (body: ImportSharedListsRequest) =>
request<ImportSharedListsResponse>('/api/shared-lists/import', {
method: 'POST',
body: JSON.stringify(body),
}),
}
+94
View File
@@ -0,0 +1,94 @@
export type Role = 'admin' | 'user'
export interface User {
id: number
email: string
display_name: string
role: Role
created_at: string
}
export interface AuthStatus {
registration_open: boolean
auth_mode: 'local' | 'pangolin' | 'both'
}
export interface DomainSummary {
name: string
state: string
description: string
spam_aggressiveness: string | number
can_send: boolean
can_receive: boolean
}
export interface Domain {
name: string
state: string
description: string
spam_aggressiveness: string | number
subject_rewriting_enabled: boolean
junk_subject_keyword_spam: boolean
sender_denylist: string[]
sender_allowlist: string[]
recipient_denylist: string[]
can_send: boolean
can_receive: boolean
can_access: boolean
}
export interface DomainUpdate {
sender_denylist?: string[]
sender_allowlist?: string[]
recipient_denylist?: string[]
spam_aggressiveness?: string
junk_subject_keyword_spam?: boolean
subject_rewriting_enabled?: boolean
}
export interface SharedLists {
sender_denylist: string[]
sender_allowlist: string[]
recipient_denylist: string[]
}
export interface SharedListsTargetsRequest {
all?: boolean
domains?: string[]
replace?: boolean
}
export type ApplySharedListsRequest = SharedListsTargetsRequest
export type ImportSharedListsRequest = Pick<
SharedListsTargetsRequest,
'all' | 'domains'
>
export interface ApplySharedListsResult {
domain: string
ok: boolean
error?: string
rejected?: string[]
}
export interface ImportSharedListsResponse {
lists: SharedLists
results: ApplySharedListsResult[]
}
export interface ApiErrorBody {
error: string
migadu_status?: number
migadu_body?: string
}
export interface ManagedDomain {
name: string
added_at: string
added_by?: number
state?: string
description?: string
accessible: boolean
error?: string
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+99
View File
@@ -0,0 +1,99 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { api, ApiError } from '../api/client'
import type { AuthStatus, User } from '../api/types'
interface AuthContextValue {
user: User | null
status: AuthStatus | null
loading: boolean
refresh: () => Promise<void>
login: (email: string, password: string) => Promise<void>
register: (email: string, password: string, displayName: string) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [status, setStatus] = useState<AuthStatus | null>(null)
const [loading, setLoading] = useState(true)
const refresh = useCallback(async () => {
const authStatus = await api.authStatus()
setStatus(authStatus)
try {
const me = await api.me()
setUser(me)
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
setUser(null)
} else {
throw error
}
}
}, [])
useEffect(() => {
let cancelled = false
;(async () => {
try {
await refresh()
} catch {
if (!cancelled) {
setUser(null)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [refresh])
const login = useCallback(async (email: string, password: string) => {
const nextUser = await api.login(email, password)
setUser(nextUser)
}, [])
const register = useCallback(
async (email: string, password: string, displayName: string) => {
const nextUser = await api.register(email, password, displayName)
setUser(nextUser)
const authStatus = await api.authStatus()
setStatus(authStatus)
},
[],
)
const logout = useCallback(async () => {
await api.logout()
setUser(null)
}, [])
const value = useMemo(
() => ({ user, status, loading, refresh, login, register, logout }),
[user, status, loading, refresh, login, register, logout],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
+82
View File
@@ -0,0 +1,82 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react'
import { api, ApiError } from '../api/client'
import type { ManagedDomain } from '../api/types'
import { useAuth } from './AuthContext'
interface DomainsContextValue {
domains: ManagedDomain[]
loading: boolean
error: string | null
refresh: () => Promise<void>
addDomain: (name: string) => Promise<ManagedDomain>
removeDomain: (name: string) => Promise<void>
}
const DomainsContext = createContext<DomainsContextValue | null>(null)
export function DomainsProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const [domains, setDomains] = useState<ManagedDomain[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
if (!user || user.role !== 'admin') {
setDomains([])
return
}
setLoading(true)
setError(null)
try {
const list = await api.listManagedDomains()
setDomains(list)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Failed to load domains')
} finally {
setLoading(false)
}
}, [user])
useEffect(() => {
void refresh()
}, [refresh])
const addDomain = useCallback(async (name: string) => {
const created = await api.addManagedDomain(name)
setDomains((current) => {
const without = current.filter((item) => item.name !== created.name)
return [...without, created].sort((a, b) => a.name.localeCompare(b.name))
})
return created
}, [])
const removeDomain = useCallback(async (name: string) => {
await api.removeManagedDomain(name)
setDomains((current) => current.filter((item) => item.name !== name))
}, [])
const value = useMemo(
() => ({ domains, loading, error, refresh, addDomain, removeDomain }),
[domains, loading, error, refresh, addDomain, removeDomain],
)
return (
<DomainsContext.Provider value={value}>{children}</DomainsContext.Provider>
)
}
export function useManagedDomains() {
const context = useContext(DomainsContext)
if (!context) {
throw new Error('useManagedDomains must be used within DomainsProvider')
}
return context
}
+199
View File
@@ -0,0 +1,199 @@
import { NavLink, useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../auth/AuthContext'
import { useManagedDomains } from '../auth/DomainsContext'
import { ApiError } from '../api/client'
import { useState, type FormEvent, type ReactNode } from 'react'
function domainPath(domain: string, section: string) {
return `/domains/${encodeURIComponent(domain)}/${section}`
}
export function AppShell({
title,
children,
}: {
title: string
children: ReactNode
}) {
const { user, logout } = useAuth()
const { domains, loading, addDomain, removeDomain } = useManagedDomains()
const params = useParams()
const navigate = useNavigate()
const selectedDomain = params.domain ? decodeURIComponent(params.domain) : domains[0]?.name
const [draft, setDraft] = useState('')
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState<string | null>(null)
async function handleAdd(event: FormEvent) {
event.preventDefault()
const name = draft.trim().toLowerCase()
if (!name) {
return
}
setAdding(true)
setAddError(null)
try {
const created = await addDomain(name)
setDraft('')
navigate(domainPath(created.name, 'settings'))
} catch (err) {
if (err instanceof ApiError) {
const detail = [
err.message,
err.body.migadu_status ? `HTTP ${err.body.migadu_status}` : null,
err.body.migadu_body || null,
]
.filter(Boolean)
.join(' — ')
setAddError(detail)
} else {
setAddError('Failed to add domain')
}
} finally {
setAdding(false)
}
}
async function handleRemove(name: string) {
const confirmed = window.confirm(
`Remove ${name} from this admin app? This does not delete it in Migadu.`,
)
if (!confirmed) {
return
}
await removeDomain(name)
if (selectedDomain === name) {
const remaining = domains.filter((item) => item.name !== name)
if (remaining[0]) {
navigate(domainPath(remaining[0].name, 'settings'))
} else {
navigate('/')
}
}
}
return (
<div className="app-shell sidebar-layout">
<aside className="sidebar">
<div className="sidebar-top">
<div className="brand-block">
<span className="brand">MigaduAdmin</span>
<span className="brand-sub">Mail administration</span>
</div>
<nav className="side-nav" aria-label="Main">
<div className="nav-group">
<p className="nav-group-label">Shared</p>
<NavLink to="/shared-lists">Shared lists</NavLink>
</div>
<div className="nav-group">
<p className="nav-group-label">Domains</p>
{selectedDomain ? (
<>
<NavLink to={domainPath(selectedDomain, 'settings')}>Settings</NavLink>
<NavLink to={domainPath(selectedDomain, 'records')}>Records</NavLink>
<NavLink to={domainPath(selectedDomain, 'diagnostics')}>
Diagnostics
</NavLink>
<NavLink to={domainPath(selectedDomain, 'usage')}>Usage</NavLink>
</>
) : (
<span className="nav-disabled">Add a domain below</span>
)}
</div>
<div className="nav-group">
<p className="nav-group-label">Resources</p>
<NavLink to="/mailboxes">Mailboxes</NavLink>
<NavLink to="/identities">Identities</NavLink>
<NavLink to="/forwardings">Forwardings</NavLink>
<NavLink to="/aliases">Aliases</NavLink>
<NavLink to="/rewrites">Rewrites</NavLink>
</div>
</nav>
</div>
<div className="sidebar-bottom">
<div className="domain-registry">
<p className="nav-group-label">Administered domains</p>
{loading ? <p className="muted small">Loading</p> : null}
{!loading && domains.length === 0 ? (
<p className="muted small">None yet. Add a domain you can access.</p>
) : null}
<ul className="domain-registry-list">
{domains.map((domain) => {
const active = selectedDomain === domain.name
return (
<li key={domain.name} className={active ? 'active' : undefined}>
<button
type="button"
className="domain-pick"
onClick={() => navigate(domainPath(domain.name, 'settings'))}
>
<strong>{domain.name}</strong>
<span className="muted small">
{domain.accessible
? domain.state || 'ok'
: domain.error || 'unreachable'}
</span>
</button>
<button
type="button"
className="ghost-button icon-button"
title="Remove from admin"
onClick={() => void handleRemove(domain.name)}
>
×
</button>
</li>
)
})}
</ul>
<form className="domain-add-form" onSubmit={handleAdd}>
<input
type="text"
value={draft}
placeholder="example.com"
disabled={adding}
onChange={(event) => setDraft(event.target.value)}
/>
<button type="submit" disabled={adding || !draft.trim()}>
{adding ? 'Checking…' : 'Add'}
</button>
</form>
{addError ? <p className="form-error small">{addError}</p> : null}
<p className="muted small">
Verifies access with your Migadu API key before saving.
</p>
</div>
<div className="session sidebar-session">
{user ? (
<>
<div className="session-meta">
<strong>{user.display_name || user.email}</strong>
<span>{user.role}</span>
</div>
<button
type="button"
className="ghost-button"
onClick={() => void logout()}
>
Sign out
</button>
</>
) : null}
</div>
</div>
</aside>
<main className="page content-pane">
<h1 className="page-title">{title}</h1>
{children}
</main>
</div>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useState, type FormEvent } from 'react'
import {
compactListEntries,
validateListEntries,
type ListEntryIssue,
} from '../lib/listEntries'
interface ListEditorProps {
label: string
description: string
values: string[]
disabled?: boolean
scrollable?: boolean
onChange: (values: string[]) => void
}
export function ListEditor({
label,
description,
values,
disabled,
scrollable,
onChange,
}: ListEditorProps) {
const [draft, setDraft] = useState('')
const [issues, setIssues] = useState<ListEntryIssue[] | null>(null)
const [verifyMessage, setVerifyMessage] = useState<string | null>(null)
useEffect(() => {
setIssues(null)
setVerifyMessage(null)
}, [values])
function handleAdd(event: FormEvent) {
event.preventDefault()
const entry = draft.trim()
if (!entry) {
return
}
onChange(compactListEntries([...values, entry]))
setDraft('')
}
function handleRemove(entry: string) {
onChange(values.filter((value) => value !== entry))
}
function handleVerify() {
const found = validateListEntries(values)
setIssues(found)
if (values.length === 0) {
setVerifyMessage('No entries to verify.')
return
}
if (found.length === 0) {
setVerifyMessage(`All ${values.length} entries look valid for Migadu.`)
return
}
setVerifyMessage(
`${found.length} invalid entr${found.length === 1 ? 'y' : 'ies'} found.`,
)
}
const issueByEntry = new Map(
(issues ?? []).map((issue) => [issue.entry, issue.reason]),
)
return (
<section className={scrollable ? 'panel list-editor-panel' : 'panel'}>
<header className="panel-header">
<div>
<h2>{label}</h2>
<p>{description}</p>
</div>
<span className="count-pill">{values.length}</span>
</header>
<form className="inline-form" onSubmit={handleAdd}>
<input
type="text"
value={draft}
disabled={disabled}
placeholder="user@example.com or *@domain.tld"
onChange={(event) => setDraft(event.target.value)}
/>
<button type="submit" disabled={disabled || !draft.trim()}>
Add
</button>
<button
type="button"
className="ghost-button"
disabled={disabled}
onClick={handleVerify}
>
Verify entries
</button>
</form>
{verifyMessage ? (
<p className={issues && issues.length > 0 ? 'form-error' : 'form-success'}>
{verifyMessage}
</p>
) : null}
{issues && issues.length > 0 ? (
<ul className="verify-issue-list">
{issues.map((issue) => (
<li key={`${issue.entry}:${issue.reason}`}>
<code>{issue.entry}</code>
<span>{issue.reason}</span>
</li>
))}
</ul>
) : null}
{values.length === 0 ? (
<p className="empty-hint">No entries yet.</p>
) : (
<ul className={scrollable ? 'entry-list entry-list-scroll' : 'entry-list'}>
{values.map((entry) => {
const reason = issueByEntry.get(entry)
return (
<li key={entry} className={reason ? 'entry-invalid' : undefined}>
<div className="entry-main">
<code>{entry}</code>
{reason ? <span className="entry-reason">{reason}</span> : null}
</div>
<button
type="button"
className="ghost-button"
disabled={disabled}
onClick={() => handleRemove(entry)}
>
Remove
</button>
</li>
)
})}
</ul>
)}
</section>
)
}
+737
View File
@@ -0,0 +1,737 @@
:root {
--bg: #edf2f4;
--bg-accent: #d9e4ea;
--ink: #13212b;
--muted: #5b6b76;
--panel: #f7fafb;
--line: #c5d0d7;
--accent: #0f6e6a;
--accent-ink: #f4fffe;
--danger: #9b2c2c;
--success: #1f6b3a;
--shadow: 0 18px 40px rgba(19, 33, 43, 0.08);
--radius: 14px;
--font-display: "Fraunces", "Iowan Old Style", "Palatino Linotype", serif;
--font-body: "Source Sans 3", "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", monospace;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15, 110, 106, 0.12), transparent 34%),
linear-gradient(160deg, #f5f8fa 0%, var(--bg) 48%, var(--bg-accent) 100%);
font-family: var(--font-body);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button,
input,
select {
font: inherit;
}
button {
border: 0;
border-radius: 10px;
background: var(--accent);
color: var(--accent-ink);
padding: 0.7rem 1rem;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ghost-button {
background: transparent;
color: var(--ink);
border: 1px solid var(--line);
}
input,
select {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 0.7rem 0.85rem;
color: var(--ink);
}
.app-shell {
min-height: 100vh;
}
.sidebar-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1.25rem;
padding: 1.15rem 1rem 1rem;
border-right: 1px solid rgba(197, 208, 215, 0.95);
background: rgba(247, 250, 251, 0.92);
backdrop-filter: blur(10px);
position: sticky;
top: 0;
height: 100vh;
overflow: auto;
}
.sidebar-top,
.sidebar-bottom {
display: flex;
flex-direction: column;
gap: 1rem;
}
.side-nav {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.nav-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.nav-group-label {
margin: 0 0 0.35rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.72rem;
font-weight: 700;
color: var(--muted);
}
.side-nav a {
color: var(--ink);
text-decoration: none;
padding: 0.45rem 0.65rem;
border-radius: 8px;
font-weight: 600;
}
.side-nav a:hover {
background: rgba(15, 110, 106, 0.08);
text-decoration: none;
}
.side-nav a.active {
background: var(--ink);
color: #fff;
}
.nav-disabled {
color: var(--muted);
padding: 0.45rem 0.65rem;
font-size: 0.92rem;
}
.domain-registry {
border-top: 1px solid var(--line);
padding-top: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.domain-registry-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 180px;
overflow: auto;
}
.domain-registry-list li {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.35rem;
align-items: center;
border-radius: 10px;
padding: 0.15rem;
}
.domain-registry-list li.active {
background: rgba(15, 110, 106, 0.1);
}
.domain-pick {
text-align: left;
background: transparent;
color: inherit;
padding: 0.4rem 0.5rem;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.domain-pick strong {
font-size: 0.92rem;
}
.domain-add-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.4rem;
}
.icon-button {
padding: 0.25rem 0.55rem;
min-width: 2rem;
line-height: 1;
}
.sidebar-session {
border-top: 1px solid var(--line);
padding-top: 0.85rem;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.55rem;
}
.sidebar-session .session-meta {
align-items: flex-start;
}
.small {
font-size: 0.82rem;
}
.content-pane {
width: min(960px, calc(100% - 2rem));
margin: 0;
padding: 2rem 1.5rem 3rem;
}
.content-pane:has(.list-page-editor) {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
padding-bottom: 1.25rem;
box-sizing: border-box;
}
.content-pane:has(.list-page-editor) > .page-title,
.content-pane:has(.list-page-editor) > .page-lede,
.content-pane:has(.list-page-editor) > .form-error,
.content-pane:has(.list-page-editor) > .form-success,
.content-pane:has(.list-page-editor) > .muted {
flex-shrink: 0;
}
.list-page-editor {
flex: 1;
min-height: 0;
margin-top: 1rem;
}
.list-page-editor .tabs,
.list-page-editor .editor-actions {
flex-shrink: 0;
}
.list-editor-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.list-editor-panel .panel-header,
.list-editor-panel .inline-form,
.list-editor-panel .empty-hint {
flex-shrink: 0;
}
.entry-list-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-top: 1rem;
padding-right: 0.25rem;
}
.content-pane:has(.list-page-editor) .apply-panel {
flex-shrink: 0;
max-height: min(42vh, 22rem);
overflow-y: auto;
}
.topbar {
display: none;
}
.brand-block {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.brand {
font-family: var(--font-display);
font-size: 1.45rem;
font-weight: 600;
color: var(--ink);
text-decoration: none;
}
.brand-sub,
.eyebrow,
.muted,
.empty-hint,
.page-lede,
.auth-footer {
color: var(--muted);
}
.topnav {
display: flex;
gap: 1rem;
}
.topnav a {
color: var(--muted);
font-weight: 600;
}
.topnav a.active {
color: var(--ink);
}
.session {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.85rem;
}
.session-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 0.9rem;
}
.session-meta span {
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
}
.page {
width: min(960px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 3rem;
}
.page-title {
margin: 0 0 0.4rem;
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 2.6rem);
font-weight: 600;
}
.page-lede {
margin: 0 0 1.5rem;
}
.auth-layout {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem 1rem;
}
.auth-panel,
.panel,
.domain-row {
background: var(--panel);
border: 1px solid rgba(197, 208, 215, 0.95);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.auth-panel {
width: min(440px, 100%);
padding: 2rem;
}
.eyebrow {
margin: 0 0 0.4rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
font-weight: 700;
}
.auth-panel h1,
.panel h2 {
margin: 0;
font-family: var(--font-display);
}
.lede,
.panel p {
margin: 0.55rem 0 0;
color: var(--muted);
line-height: 1.45;
}
.stack-form,
.domain-editor {
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1.4rem;
}
.stack-form label,
.field {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-weight: 600;
}
.form-error {
color: var(--danger);
margin: 0;
}
.form-success {
color: var(--success);
margin: 0 0 1rem;
}
.notice {
margin-top: 1.2rem;
padding: 0.9rem 1rem;
border-radius: 10px;
background: rgba(15, 110, 106, 0.08);
}
.auth-footer {
margin-top: 1.4rem;
}
.domain-table {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.domain-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.15rem;
color: inherit;
text-decoration: none;
transition: transform 140ms ease, border-color 140ms ease;
}
.domain-row:hover {
transform: translateY(-1px);
border-color: var(--accent);
text-decoration: none;
}
.domain-row strong {
display: block;
margin-bottom: 0.2rem;
}
.domain-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.35rem;
white-space: nowrap;
}
.state {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 999px;
background: rgba(19, 33, 43, 0.08);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.state-active {
background: rgba(31, 107, 58, 0.12);
color: var(--success);
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tab {
background: transparent;
color: var(--muted);
border: 1px solid var(--line);
}
.tab.active {
background: var(--ink);
color: #fff;
border-color: var(--ink);
}
.panel {
padding: 1.2rem;
}
.panel-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.count-pill {
min-width: 2rem;
text-align: center;
padding: 0.25rem 0.55rem;
border-radius: 999px;
background: rgba(15, 110, 106, 0.12);
color: var(--accent);
font-weight: 700;
}
.inline-form {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.65rem;
}
.entry-list {
list-style: none;
margin: 1rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.entry-list li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
padding: 0.65rem 0.75rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(197, 208, 215, 0.8);
}
.entry-list li.entry-invalid {
border-color: rgba(176, 48, 48, 0.55);
background: rgba(176, 48, 48, 0.06);
}
.entry-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.entry-reason {
color: var(--danger);
font-size: 0.82rem;
font-weight: 600;
}
.verify-issue-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.verify-issue-list li {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
align-items: baseline;
font-size: 0.9rem;
}
.verify-issue-list code {
font-family: var(--font-mono);
}
.verify-issue-list span {
color: var(--danger);
}
.list-editor-panel .verify-issue-list,
.list-editor-panel .form-error,
.list-editor-panel .form-success {
flex-shrink: 0;
}
.entry-list code {
font-family: var(--font-mono);
font-size: 0.92rem;
}
.check-row {
display: flex;
align-items: center;
gap: 0.65rem;
font-weight: 600;
}
.check-row input {
width: auto;
}
.replace-option {
align-items: flex-start;
margin-top: 0.75rem;
}
.replace-option .check-hint {
display: block;
font-weight: 500;
margin-top: 0.2rem;
}
.editor-actions {
display: flex;
justify-content: flex-end;
gap: 0.65rem;
flex-wrap: wrap;
}
.apply-panel {
margin-top: 1.4rem;
}
.apply-domain-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.apply-results {
list-style: none;
margin: 1rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.apply-results li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
padding: 0.55rem 0.7rem;
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.72);
font-size: 0.92rem;
}
.apply-results li.ok span {
color: var(--success);
font-weight: 700;
}
.apply-results li.fail span {
color: var(--danger);
font-weight: 600;
}
@media (max-width: 720px) {
.sidebar-layout {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.session {
width: 100%;
justify-content: space-between;
}
.domain-row {
flex-direction: column;
}
.domain-meta {
align-items: flex-start;
}
.inline-form {
grid-template-columns: 1fr;
}
.content-pane {
width: calc(100% - 2rem);
margin: 0 auto;
}
}
+225
View File
@@ -0,0 +1,225 @@
/** Compact and validate Migadu-style list entries. */
export interface ListEntryIssue {
entry: string
reason: string
}
export function compactListEntries(entries: string[]): string[] {
const unique: string[] = []
const seen = new Set<string>()
for (const value of entries) {
const entry = normalizeEntry(value)
if (!entry || seen.has(entry)) {
continue
}
seen.add(entry)
unique.push(entry)
}
return unique.filter((entry, index) => {
for (let otherIndex = 0; otherIndex < unique.length; otherIndex += 1) {
if (otherIndex === index) {
continue
}
const other = unique[otherIndex]
if (covers(other, entry) && other !== entry) {
return false
}
}
return true
})
}
export function normalizeEntry(value: string): string {
let entry = value.trim().toLowerCase()
if (!entry) {
return ''
}
if (entry.startsWith('*') && !entry.includes('@') && entry.includes('.')) {
entry = `*@${entry.slice(1)}`
}
const parts = splitAddress(entry)
if (
parts &&
parts.local === '*' &&
parts.domain.startsWith('*') &&
!parts.domain.startsWith('*.')
) {
entry = `*@*.${parts.domain.slice(1)}`
}
return entry
}
export function validateListEntries(entries: string[]): ListEntryIssue[] {
const issues: ListEntryIssue[] = []
for (const value of entries) {
const raw = value.trim()
if (!raw) {
continue
}
const reason = validateListEntry(raw)
if (reason) {
issues.push({ entry: raw, reason })
}
}
return issues
}
export function validateListEntry(value: string): string {
const raw = value.trim()
if (!raw) {
return 'entry is empty'
}
return validateNormalizedEntry(normalizeEntry(raw))
}
function validateNormalizedEntry(entry: string): string {
if (!entry) {
return 'entry is empty'
}
if (/\s/.test(entry)) {
return 'entry must not contain whitespace'
}
if ((entry.match(/@/g) ?? []).length !== 1) {
return 'entry must look like local@domain (e.g. user@example.com or *@example.com)'
}
const parts = splitAddress(entry)
if (!parts) {
return 'entry must look like local@domain'
}
if (!parts.local) {
return 'local part before @ is empty'
}
if (!validLocalPart(parts.local)) {
return 'invalid local part (use user, *@domain, or *prefix)'
}
if (!parts.domain) {
return 'domain after @ is empty'
}
if (!validDomainPart(parts.domain)) {
return 'invalid domain (use example.com or *.com / *.co.uk)'
}
return ''
}
function validLocalPart(local: string): boolean {
if (local === '*') {
return true
}
if (local.startsWith('*')) {
const rest = local.slice(1)
return rest === '' || validLocalChars(rest)
}
return validLocalChars(local)
}
function validLocalChars(value: string): boolean {
if (!value) {
return false
}
return /^[a-z0-9._%+-]+$/i.test(value)
}
function validDomainPart(domain: string): boolean {
if (domain.startsWith('*.')) {
return validDNSSuffix(domain.slice(2))
}
return validDNSDomain(domain)
}
function validDNSSuffix(suffix: string): boolean {
if (!suffix || suffix.length > 253) {
return false
}
if (suffix.startsWith('.') || suffix.endsWith('.')) {
return false
}
const labels = suffix.split('.')
if (labels.length < 1) {
return false
}
for (const label of labels) {
if (!validDNSLabel(label)) {
return false
}
}
const tld = labels[labels.length - 1]
return /^[a-z]+$/i.test(tld)
}
function validDNSDomain(domain: string): boolean {
if (!domain || domain.length > 253) {
return false
}
if (domain.startsWith('.') || domain.endsWith('.')) {
return false
}
const labels = domain.split('.')
if (labels.length < 2) {
return false
}
for (const label of labels) {
if (!validDNSLabel(label)) {
return false
}
}
const tld = labels[labels.length - 1]
return /^[a-z]+$/i.test(tld)
}
function validDNSLabel(label: string): boolean {
if (!label || label.length > 63) {
return false
}
if (label.startsWith('-') || label.endsWith('-')) {
return false
}
return /^[a-z0-9-]+$/i.test(label)
}
export function covers(broader: string, specific: string): boolean {
const normalizedBroader = normalizeEntry(broader)
const normalizedSpecific = normalizeEntry(specific)
if (!normalizedBroader || !normalizedSpecific) {
return false
}
if (normalizedBroader === normalizedSpecific) {
return true
}
const broaderParts = splitAddress(normalizedBroader)
const specificParts = splitAddress(normalizedSpecific)
if (!broaderParts || !specificParts) {
return false
}
if (broaderParts.local !== '*') {
return false
}
return domainMatches(broaderParts.domain, specificParts.domain)
}
function domainMatches(pattern: string, domain: string): boolean {
if (pattern === domain) {
return true
}
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1)
return domain.endsWith(suffix) && domain.length > suffix.length
}
return false
}
function splitAddress(
entry: string,
): { local: string; domain: string } | null {
const at = entry.lastIndexOf('@')
if (at <= 0 || at === entry.length - 1) {
return null
}
return {
local: entry.slice(0, at),
domain: entry.slice(at + 1),
}
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { AuthProvider } from './auth/AuthContext'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)
+277
View File
@@ -0,0 +1,277 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { useParams } from 'react-router-dom'
import { api, ApiError } from '../api/client'
import type { Domain } from '../api/types'
import { AppShell } from '../components/AppShell'
import { ListEditor } from '../components/ListEditor'
type TabId = 'blacklist' | 'whitelist' | 'recipient' | 'spam'
const SPAM_LEVELS = ['default', 'low', 'medium', 'high'] as const
export function DomainDetailPage() {
const params = useParams()
const domainName = params.domain ? decodeURIComponent(params.domain) : ''
const [domain, setDomain] = useState<Domain | null>(null)
const [draft, setDraft] = useState<Domain | null>(null)
const [tab, setTab] = useState<TabId>('blacklist')
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
let cancelled = false
;(async () => {
if (!domainName) {
return
}
setLoading(true)
setError(null)
try {
const loaded = await api.getDomain(domainName)
if (!cancelled) {
setDomain(loaded)
setDraft(normalizeDomain(loaded))
}
} catch (err) {
if (!cancelled) {
setError(err instanceof ApiError ? err.message : 'Failed to load domain')
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [domainName])
const dirty = useMemo(() => {
if (!domain || !draft) {
return false
}
return JSON.stringify(serialize(draft)) !== JSON.stringify(serialize(domain))
}, [domain, draft])
async function handleSave(event: FormEvent) {
event.preventDefault()
if (!draft || !domain) {
return
}
setSaving(true)
setError(null)
setMessage(null)
try {
const updated = await api.updateDomain(domain.name, {
sender_denylist: draft.sender_denylist,
sender_allowlist: draft.sender_allowlist,
recipient_denylist: draft.recipient_denylist,
spam_aggressiveness: String(draft.spam_aggressiveness),
junk_subject_keyword_spam: draft.junk_subject_keyword_spam,
subject_rewriting_enabled: draft.subject_rewriting_enabled,
})
const normalized = normalizeDomain(updated)
setDomain(normalized)
setDraft(normalized)
setMessage('Settings saved to Migadu.')
} catch (err) {
if (err instanceof ApiError) {
const detail = err.body.migadu_body
? `${err.message}: ${err.body.migadu_body}`
: err.message
setError(detail)
} else {
setError('Failed to save settings')
}
} finally {
setSaving(false)
}
}
function handleReset() {
if (domain) {
setDraft(normalizeDomain(domain))
setMessage(null)
setError(null)
}
}
return (
<AppShell title={domainName ? `Settings · ${domainName}` : 'Settings'}>
<p className="page-lede">
Blacklist, whitelist, recipient deny, and spam controls for this domain
on Migadu. Shared lists are edited separately and applied manually; to
allow a sender that appears on a shared blacklist, add them to this
domains whitelist (whitelist is evaluated before blacklist).
</p>
{loading ? <p className="muted">Loading domain settings</p> : null}
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
{draft ? (
<form className="domain-editor" onSubmit={handleSave}>
<div className="tabs" role="tablist">
{(
[
['blacklist', 'Blacklist'],
['whitelist', 'Whitelist'],
['recipient', 'Recipient deny'],
['spam', 'Spam'],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
className={tab === id ? 'tab active' : 'tab'}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</div>
{tab === 'blacklist' ? (
<ListEditor
label="Sender denylist"
description="Senders that are always rejected for this domain."
values={draft.sender_denylist}
disabled={saving}
onChange={(sender_denylist) =>
setDraft({ ...draft, sender_denylist })
}
/>
) : null}
{tab === 'whitelist' ? (
<ListEditor
label="Sender allowlist"
description="Senders that are always accepted for this domain."
values={draft.sender_allowlist}
disabled={saving}
onChange={(sender_allowlist) =>
setDraft({ ...draft, sender_allowlist })
}
/>
) : null}
{tab === 'recipient' ? (
<ListEditor
label="Recipient denylist"
description="Recipients that will never receive mail on this domain."
values={draft.recipient_denylist}
disabled={saving}
onChange={(recipient_denylist) =>
setDraft({ ...draft, recipient_denylist })
}
/>
) : null}
{tab === 'spam' ? (
<section className="panel">
<header className="panel-header">
<div>
<h2>Spam controls</h2>
<p>Domain-level spam aggressiveness and subject handling.</p>
</div>
</header>
<label className="field">
Spam aggressiveness
<select
value={String(draft.spam_aggressiveness)}
disabled={saving}
onChange={(event) =>
setDraft({
...draft,
spam_aggressiveness: event.target.value,
})
}
>
{!SPAM_LEVELS.includes(
String(draft.spam_aggressiveness) as (typeof SPAM_LEVELS)[number],
) ? (
<option value={String(draft.spam_aggressiveness)}>
{String(draft.spam_aggressiveness)}
</option>
) : null}
{SPAM_LEVELS.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</label>
<label className="check-row">
<input
type="checkbox"
checked={draft.junk_subject_keyword_spam}
disabled={saving}
onChange={(event) =>
setDraft({
...draft,
junk_subject_keyword_spam: event.target.checked,
})
}
/>
Mark mail with junk subject keywords as spam
</label>
<label className="check-row">
<input
type="checkbox"
checked={draft.subject_rewriting_enabled}
disabled={saving}
onChange={(event) =>
setDraft({
...draft,
subject_rewriting_enabled: event.target.checked,
})
}
/>
Enable subject rewriting
</label>
</section>
) : null}
<div className="editor-actions">
<button type="button" className="ghost-button" disabled={!dirty || saving} onClick={handleReset}>
Reset
</button>
<button type="submit" disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
) : null}
</AppShell>
)
}
function normalizeDomain(domain: Domain): Domain {
return {
...domain,
sender_denylist: [...(domain.sender_denylist ?? [])],
sender_allowlist: [...(domain.sender_allowlist ?? [])],
recipient_denylist: [...(domain.recipient_denylist ?? [])],
spam_aggressiveness: domain.spam_aggressiveness ?? 'default',
}
}
function serialize(domain: Domain) {
return {
sender_denylist: domain.sender_denylist,
sender_allowlist: domain.sender_allowlist,
recipient_denylist: domain.recipient_denylist,
spam_aggressiveness: String(domain.spam_aggressiveness),
junk_subject_keyword_spam: domain.junk_subject_keyword_spam,
subject_rewriting_enabled: domain.subject_rewriting_enabled,
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Navigate } from 'react-router-dom'
import { AppShell } from '../components/AppShell'
import { useManagedDomains } from '../auth/DomainsContext'
export function HomePage() {
const { domains, loading } = useManagedDomains()
if (!loading && domains.length > 0) {
return (
<Navigate
to={`/domains/${encodeURIComponent(domains[0].name)}/settings`}
replace
/>
)
}
return (
<AppShell title="Get started">
{loading ? (
<p className="muted">Loading administered domains</p>
) : (
<p className="page-lede">
Add a domain in the sidebar. The backend will verify it is reachable
with your configured Migadu API key before registering it here.
</p>
)}
</AppShell>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useState, type FormEvent } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { ApiError } from '../api/client'
import { useAuth } from '../auth/AuthContext'
export function LoginPage() {
const { user, status, loading, login } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
if (!loading && user) {
return <Navigate to="/" replace />
}
const localEnabled = status?.auth_mode !== 'pangolin'
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setError(null)
setSubmitting(true)
try {
await login(email, password)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Login failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="auth-layout">
<div className="auth-panel">
<p className="eyebrow">MigaduAdmin</p>
<h1>Sign in</h1>
<p className="lede">
Manage domain denylists, allowlists, and spam settings across your
Migadu domains.
</p>
{!localEnabled ? (
<p className="notice">
Local login is disabled. Authenticate through Pangolin to continue.
</p>
) : (
<form className="stack-form" onSubmit={handleSubmit}>
<label>
Email
<input
type="email"
autoComplete="username"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label>
Password
<input
type="password"
autoComplete="current-password"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error ? <p className="form-error">{error}</p> : null}
<button type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
)}
{status?.registration_open ? (
<p className="auth-footer">
First-time setup? <Link to="/register">Create the admin account</Link>
</p>
) : null}
</div>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useState, type FormEvent } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { ApiError } from '../api/client'
import { useAuth } from '../auth/AuthContext'
export function RegisterPage() {
const { user, status, loading, register } = useAuth()
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
if (!loading && user) {
return <Navigate to="/" replace />
}
if (!loading && status && !status.registration_open) {
return <Navigate to="/login" replace />
}
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setError(null)
setSubmitting(true)
try {
await register(email, password, displayName)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Registration failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="auth-layout">
<div className="auth-panel">
<p className="eyebrow">MigaduAdmin</p>
<h1>Create admin</h1>
<p className="lede">
The first registered account becomes the default administrator.
</p>
<form className="stack-form" onSubmit={handleSubmit}>
<label>
Display name
<input
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Optional"
/>
</label>
<label>
Email
<input
type="email"
autoComplete="username"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label>
Password
<input
type="password"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{error ? <p className="form-error">{error}</p> : null}
<button type="submit" disabled={submitting}>
{submitting ? 'Creating…' : 'Create admin account'}
</button>
</form>
<p className="auth-footer">
Already set up? <Link to="/login">Sign in</Link>
</p>
</div>
</div>
)
}
+442
View File
@@ -0,0 +1,442 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import { Link } from 'react-router-dom'
import { api, ApiError } from '../api/client'
import type { ApplySharedListsResult, SharedLists } from '../api/types'
import { useManagedDomains } from '../auth/DomainsContext'
import { AppShell } from '../components/AppShell'
import { ListEditor } from '../components/ListEditor'
type TabId = 'blacklist' | 'whitelist' | 'recipient'
type PanelMode = 'apply' | 'import' | null
function emptyLists(): SharedLists {
return {
sender_denylist: [],
sender_allowlist: [],
recipient_denylist: [],
}
}
function normalizeLists(lists: SharedLists): SharedLists {
return {
sender_denylist: [...(lists.sender_denylist ?? [])],
sender_allowlist: [...(lists.sender_allowlist ?? [])],
recipient_denylist: [...(lists.recipient_denylist ?? [])],
}
}
export function SharedListsPage() {
const { domains, loading: domainsLoading } = useManagedDomains()
const [saved, setSaved] = useState<SharedLists | null>(null)
const [draft, setDraft] = useState<SharedLists | null>(null)
const [tab, setTab] = useState<TabId>('blacklist')
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [panelMode, setPanelMode] = useState<PanelMode>(null)
const [targetAll, setTargetAll] = useState(true)
const [selectedDomains, setSelectedDomains] = useState<string[]>([])
const [replaceLists, setReplaceLists] = useState(false)
const [busy, setBusy] = useState(false)
const [panelResults, setPanelResults] = useState<ApplySharedListsResult[] | null>(
null,
)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
setError(null)
try {
const lists = await api.getSharedLists()
if (!cancelled) {
const normalized = normalizeLists(lists)
setSaved(normalized)
setDraft(normalized)
}
} catch (err) {
if (!cancelled) {
setError(
err instanceof ApiError ? err.message : 'Failed to load shared lists',
)
setSaved(emptyLists())
setDraft(emptyLists())
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [])
const dirty = useMemo(() => {
if (!saved || !draft) {
return false
}
return JSON.stringify(draft) !== JSON.stringify(saved)
}, [draft, saved])
async function handleSave(event: FormEvent) {
event.preventDefault()
if (!draft) {
return
}
setSaving(true)
setError(null)
setMessage(null)
try {
const updated = normalizeLists(await api.putSharedLists(draft))
setSaved(updated)
setDraft(updated)
setMessage('Shared lists saved locally. Use Apply to push them to Migadu.')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Failed to save shared lists')
} finally {
setSaving(false)
}
}
function handleReset() {
if (saved) {
setDraft(normalizeLists(saved))
setMessage(null)
setError(null)
}
}
function openPanel(mode: 'apply' | 'import') {
setPanelMode(mode)
setTargetAll(true)
setSelectedDomains(domains.map((domain) => domain.name))
setReplaceLists(false)
setPanelResults(null)
setError(null)
setMessage(null)
}
function toggleDomain(name: string) {
setSelectedDomains((current) =>
current.includes(name)
? current.filter((item) => item !== name)
: [...current, name],
)
}
function targetBody() {
return targetAll ? { all: true as const } : { domains: selectedDomains }
}
async function handleApply() {
setBusy(true)
setError(null)
setMessage(null)
setPanelResults(null)
try {
if (dirty && draft) {
const updated = normalizeLists(await api.putSharedLists(draft))
setSaved(updated)
setDraft(updated)
}
const results = await api.applySharedLists({
...targetBody(),
replace: replaceLists,
})
setPanelResults(results)
const failed = results.filter((result) => !result.ok).length
const okCount = results.length - failed
const rejectedCount = results.reduce(
(sum, result) => sum + (result.rejected?.length ?? 0),
0,
)
const modeLabel = replaceLists ? 'Replaced lists on' : 'Applied shared lists to'
if (failed === 0 && rejectedCount === 0) {
setMessage(`${modeLabel} ${okCount} domain(s).`)
} else if (failed === 0) {
setMessage(
`${modeLabel} ${okCount} domain(s); Migadu rejected ${rejectedCount} entr(y/ies) (skipped). See details below.`,
)
} else {
setMessage(
`${modeLabel} ${okCount} domain(s); ${failed} failed. See details below.`,
)
}
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Failed to apply shared lists',
)
} finally {
setBusy(false)
}
}
async function handleImport() {
setBusy(true)
setError(null)
setMessage(null)
setPanelResults(null)
try {
if (dirty && draft) {
await api.putSharedLists(draft)
}
const response = await api.importSharedLists(targetBody())
const updated = normalizeLists(response.lists)
setSaved(updated)
setDraft(updated)
setPanelResults(response.results ?? [])
const failed = (response.results ?? []).filter((result) => !result.ok).length
const okCount = (response.results ?? []).length - failed
if (failed === 0) {
setMessage(
`Imported lists from ${okCount} domain(s) into shared (merged + compacted).`,
)
} else {
setMessage(
`Imported from ${okCount} domain(s); ${failed} failed. Shared lists updated from successful imports.`,
)
}
} catch (err) {
setError(
err instanceof ApiError ? err.message : 'Failed to import into shared lists',
)
} finally {
setBusy(false)
}
}
const canRunTargets =
!busy && domains.length > 0 && (targetAll || selectedDomains.length > 0)
return (
<AppShell title="Shared lists">
<p className="page-lede">
Shared blacklist, whitelist, and recipient deny. Import from domains to
merge into shared, or Apply to push to Migadu (merge by default, or
replace domain lists). Broader filters like *@domain win over specifics.
Recipient deny is domain-scoped on apply (only addresses for that domain
are sent). To allow a shared-blocked sender on one domain, add them to
that domains whitelist.
</p>
{loading ? <p className="muted">Loading shared lists</p> : null}
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
{draft ? (
<form className="domain-editor list-page-editor" onSubmit={handleSave}>
<div className="tabs" role="tablist">
{(
[
['blacklist', 'Blacklist'],
['whitelist', 'Whitelist'],
['recipient', 'Recipient deny'],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
className={tab === id ? 'tab active' : 'tab'}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</div>
<div className="editor-actions">
<button
type="button"
className="ghost-button"
disabled={!dirty || saving || busy}
onClick={handleReset}
>
Reset
</button>
<button type="submit" disabled={!dirty || saving || busy}>
{saving ? 'Saving…' : 'Save shared lists'}
</button>
<button
type="button"
disabled={saving || busy || domainsLoading || domains.length === 0}
onClick={() => openPanel('import')}
>
Import
</button>
<button
type="button"
disabled={saving || busy || domainsLoading || domains.length === 0}
onClick={() => openPanel('apply')}
>
Apply
</button>
</div>
{tab === 'blacklist' ? (
<ListEditor
label="Shared sender denylist"
description="Senders rejected on every domain you apply these lists to."
values={draft.sender_denylist}
disabled={saving || busy}
scrollable
onChange={(sender_denylist) =>
setDraft({ ...draft, sender_denylist })
}
/>
) : null}
{tab === 'whitelist' ? (
<ListEditor
label="Shared sender allowlist"
description="Senders always accepted on every domain you apply these lists to."
values={draft.sender_allowlist}
disabled={saving || busy}
scrollable
onChange={(sender_allowlist) =>
setDraft({ ...draft, sender_allowlist })
}
/>
) : null}
{tab === 'recipient' ? (
<ListEditor
label="Shared recipient denylist"
description="Recipients that will never receive mail on applied domains."
values={draft.recipient_denylist}
disabled={saving || busy}
scrollable
onChange={(recipient_denylist) =>
setDraft({ ...draft, recipient_denylist })
}
/>
) : null}
</form>
) : null}
{panelMode ? (
<section className="panel apply-panel">
<header className="panel-header">
<div>
<h2>
{panelMode === 'apply' ? 'Apply to domains' : 'Import from domains'}
</h2>
<p>
{panelMode === 'apply'
? 'Push shared lists to selected domains on Migadu. Unsaved shared edits are saved first.'
: 'Merge denylist, allowlist, and recipient deny from selected domains into shared (compacted). Unsaved shared edits are saved first.'}
</p>
</div>
</header>
<label className="check-row">
<input
type="checkbox"
checked={targetAll}
disabled={busy}
onChange={(event) => setTargetAll(event.target.checked)}
/>
All administered domains
</label>
{!targetAll ? (
<ul className="apply-domain-list">
{domains.map((domain) => (
<li key={domain.name}>
<label className="check-row">
<input
type="checkbox"
checked={selectedDomains.includes(domain.name)}
disabled={busy}
onChange={() => toggleDomain(domain.name)}
/>
{domain.name}
</label>
</li>
))}
</ul>
) : null}
{panelMode === 'apply' ? (
<label className="check-row replace-option">
<input
type="checkbox"
checked={replaceLists}
disabled={busy}
onChange={(event) => setReplaceLists(event.target.checked)}
/>
<span>
Replace domain lists
<span className="muted small check-hint">
{replaceLists
? 'Each domains lists are cleared and set to the shared lists.'
: 'Shared entries are union-merged into each domain (default).'}
</span>
</span>
</label>
) : null}
{domains.length === 0 ? (
<p className="muted">
No administered domains yet. Add one from the sidebar first.
</p>
) : null}
<div className="editor-actions">
<button
type="button"
className="ghost-button"
disabled={busy}
onClick={() => setPanelMode(null)}
>
Close
</button>
{panelMode === 'apply' ? (
<button
type="button"
disabled={!canRunTargets}
onClick={() => void handleApply()}
>
{busy ? 'Applying…' : 'Apply now'}
</button>
) : (
<button
type="button"
disabled={!canRunTargets}
onClick={() => void handleImport()}
>
{busy ? 'Importing…' : 'Import now'}
</button>
)}
</div>
{panelResults ? (
<ul className="apply-results">
{panelResults.map((result) => (
<li key={result.domain} className={result.ok ? 'ok' : 'fail'}>
<div>
<Link to={`/domains/${encodeURIComponent(result.domain)}/settings`}>
{result.domain}
</Link>
{result.rejected && result.rejected.length > 0 ? (
<p className="muted small">
Skipped rejected: {result.rejected.join(', ')}
</p>
) : null}
</div>
<span>{result.ok ? 'ok' : result.error || 'failed'}</span>
</li>
))}
</ul>
) : null}
</section>
) : null}
</AppShell>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { useParams } from 'react-router-dom'
import { AppShell } from '../components/AppShell'
import { useManagedDomains } from '../auth/DomainsContext'
export function StubPage({
title,
description,
domainScoped = false,
}: {
title: string
description: string
domainScoped?: boolean
}) {
const params = useParams()
const { domains } = useManagedDomains()
const domainName = params.domain
? decodeURIComponent(params.domain)
: domains[0]?.name
return (
<AppShell title={title}>
<p className="page-lede">{description}</p>
{domainScoped ? (
<p className="muted">
{domainName
? `Selected domain: ${domainName}`
: 'Select or add a domain in the sidebar first.'}
</p>
) : null}
<p className="notice">This section is stubbed for a later Migadu API integration.</p>
</AppShell>
)
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})