improve web panel; integrate with API server

This commit is contained in:
ari melody 2026-02-25 06:25:22 +00:00
parent 7d15d70432
commit c22ab15895
Signed by: ari
GPG key ID: CF99829C92678188
23 changed files with 1031 additions and 115 deletions

1
src/web/backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
tmp/

View file

@ -0,0 +1,212 @@
package controller
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"strings"
"jupiter-mail.org/web/model"
)
var USERDB_PATH = path.Clean("/etc/dovecot/userdb")
const ROOT_UID = 0
const DOVECOT_GID = 106
func OpenUserDB() (*os.File, error) {
err := os.MkdirAll(path.Dir(USERDB_PATH), 0755)
if err != nil { return nil, err }
file, err := os.OpenFile(USERDB_PATH, os.O_CREATE | os.O_RDWR, 0640)
if err != nil && errors.Is(err, os.ErrExist) { return nil, err }
if err := file.Chown(ROOT_UID, DOVECOT_GID); err != nil { return nil, err }
return file, nil
}
func FetchAccount(username string, domain string) (*model.MailAccount, error) {
accounts, err := FetchAccounts()
if err != nil {
return nil, fmt.Errorf("fetch accounts: %v", err)
}
for _, account := range accounts {
if account.Username == username && account.Domain == domain {
return account, nil
}
}
return nil, fmt.Errorf("account does not exist: %s@%s", username, domain)
}
func FetchAccountByEmail(email string) (*model.MailAccount, error) {
accounts, err := FetchAccounts()
if err != nil {
return nil, fmt.Errorf("fetch accounts: %v", err)
}
for _, account := range accounts {
if fmt.Sprintf("%s@%s", account.Username, account.Domain) == email {
return account, nil
}
}
return nil, fmt.Errorf("account does not exist: %s@%s", email)
}
func IfAccountExists(username string, domain string) (bool, error) {
accounts, err := FetchAccounts()
if err != nil {
return false, fmt.Errorf("fetch accounts: %v", err)
}
for _, account := range accounts {
if account.Username == username && account.Domain == domain {
return true, nil
}
}
return false, nil
}
func HashPassword(username string, domain string, password string) ([]byte, error) {
// doveadm pw -s SHA512-CRYPT -u "$USERNAME" -p "$PASSWORD"
hashCmd := exec.Command(
"doveadm", "pw",
"-s", "SHA512-CRYPT",
"-u", fmt.Sprintf("%s@%s", username, domain),
"-p", password,
)
out, err := hashCmd.Output()
if err != nil { return nil, err }
return out, nil
}
func WriteAccounts(accounts []*model.MailAccount) (error) {
newUserDb := strings.Builder{}
for _, account := range accounts {
newUserDb.WriteString(account.UserDbLine())
}
if err := os.WriteFile(USERDB_PATH, []byte(newUserDb.String()), 0640); err != nil {
return fmt.Errorf("write userdb: %v", err)
}
return nil
}
func CreateAccount(username string, domain string, password string) (*model.MailAccount, error) {
exists, err := IfAccountExists(username, domain)
if err != nil { return nil, fmt.Errorf("failed to check if account exists: %v", err) }
if exists { return nil, fmt.Errorf("account already exists: %s@%s", username, domain) }
passwordHash, err := HashPassword(username, domain, password)
if err != nil { return nil, fmt.Errorf("hash password: %v", err) }
account := &model.MailAccount{
Username: username,
Domain: domain,
PasswordHash: []byte(passwordHash),
}
account.MailDirectory = account.DefaultMailHome()
accounts, err := FetchAccounts()
if err != nil { return nil, fmt.Errorf("failed to fetch accounts: %v", err) }
accounts = append(accounts, account)
if err = WriteAccounts(accounts); err != nil {
return nil, fmt.Errorf("write accounts: %v", err)
}
return account, nil
}
func FetchAccounts() ([]*model.MailAccount, error) {
file, err := OpenUserDB()
if err != nil { return nil, fmt.Errorf("open userdb: %v", err) }
defer file.Close()
data, err := io.ReadAll(file)
if err != nil { return nil, fmt.Errorf("read userdb: %v", err) }
lines := strings.Split(string(data), "\n")
accounts := []*model.MailAccount{}
for i, line := range lines {
if len(line) == 0 { continue }
segments := strings.Split(line, ":")
if len(segments) != 8 {
return nil, fmt.Errorf("malformed data on line %d", i)
}
// <username>:<password>:5000:5000::/var/mail/<domain>/<user>/home::
fullUsername := segments[0]
usernameSplits := strings.Split(fullUsername, "@")
if len(usernameSplits) != 2 {
return nil, fmt.Errorf("invalid email format on line %d: %s", i, fullUsername)
}
username := usernameSplits[0]
domain := usernameSplits[1]
passwordHash := []byte(segments[1])
mailDir := segments[5]
account := &model.MailAccount{
Username: username,
Domain: domain,
PasswordHash: passwordHash,
MailDirectory: mailDir,
}
account.MailDirectory = mailDir
accounts = append(accounts, account)
}
return accounts, nil
}
func UpdateAccount(username string, domain string, password string) (error) {
accounts, err := FetchAccounts()
if err != nil { return fmt.Errorf("fetch accounts: %v", err) }
var account *model.MailAccount = nil
for _, acc := range accounts {
if acc.Username == username && acc.Domain == domain {
account = acc
break
}
}
if account == nil { return fmt.Errorf("account does not exist: %s@%s", username, domain) }
passwordHash, err := HashPassword(username, domain, password)
if err != nil { return fmt.Errorf("hash password: %v", err) }
account.PasswordHash = passwordHash
WriteAccounts(accounts)
return nil
}
func DeleteAccount(username string, domain string) (error) {
accounts, err := FetchAccounts()
if err != nil { return fmt.Errorf("fetch accounts: %v", err) }
userDbFile, err := OpenUserDB()
if err != nil { return fmt.Errorf("open userdb: %v", err) }
defer userDbFile.Close()
newAccounts := []*model.MailAccount{}
for _, account := range accounts {
if account.Username != username || account.Domain != domain {
newAccounts = append(newAccounts, account)
}
}
if err := WriteAccounts(newAccounts); err != nil {
return fmt.Errorf("write accounts: %v", err)
}
return nil
}

View file

@ -1,3 +1,39 @@
module jupiter-mail.org/web module jupiter-mail.org/web
go 1.25.4 go 1.25.4
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/cors v1.7.6 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.11.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)

87
src/web/backend/go.sum Normal file
View file

@ -0,0 +1,87 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,154 @@
package main
import (
"fmt"
"net/http"
"slices"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
type Account struct {
Username string `json:"username"`
Domain string `json:"domain"`
DisplayName string `json:"display_name"`
MailDirectory string `json:"mail_directory"`
}
var mockAccounts = []*Account{
{
DisplayName: "Ari Butterly",
Username: "ari",
Domain: "example.org",
MailDirectory: `/var/mail/example.org/ari/home`,
},
{
DisplayName: "Test Account",
Username: "testaccount",
Domain: "mydomain.ie",
MailDirectory: `/var/mail/mydomain.ie/testaccount/home`,
},
{
DisplayName: "Cooler One",
Username: "otherone",
Domain: "cooler.space",
MailDirectory: `/var/mail/cooler.space/otherone/home`,
},
}
func main() {
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE"},
MaxAge: 12 * time.Hour,
}))
r.GET("/ping", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
r.GET("/api/v1/dashboard", func(ctx *gin.Context) {
type Dashboard struct {
NumAccounts int `json:"num_accounts"`
}
ctx.JSON(http.StatusOK, Dashboard{
NumAccounts: len(mockAccounts),
})
})
r.GET("/api/v1/accounts", func(ctx *gin.Context) {
accounts := mockAccounts
/*
accounts, err := controller.FetchAccounts()
if err != nil {
log.Fatalf("Failed to fetch accounts: %v", err)
ctx.Error(errors.New("Failed to fetch accounts"))
return
}
*/
ctx.JSON(http.StatusOK, accounts)
})
r.GET("/api/v1/accounts/:email", func(ctx *gin.Context) {
email := ctx.Params.ByName("email")
search := func(account *Account) bool {
return fmt.Sprintf("%s@%s", account.Username, account.Domain) == email
}
var account *Account = mockAccounts[slices.IndexFunc(mockAccounts, search)]
if account == nil {
ctx.Error(fmt.Errorf("Account does not exist: %s", email))
return
}
/*
account, err := controller.FetchAccountByEmail(email)
if err != nil {
if strings.Contains(err.Error(), "does not exist") {
ctx.Error(fmt.Errorf("Account does not exist: %s", email))
return
}
log.Fatalf("Failed to fetch account: %v", err)
ctx.Error(errors.New("Failed to fetch account"))
return
}
*/
ctx.JSON(http.StatusOK, account)
})
r.DELETE("/api/v1/accounts/:email", func(ctx *gin.Context) {
email := ctx.Params.ByName("email")
search := func(account *Account) bool {
return fmt.Sprintf("%s@%s", account.Username, account.Domain) == email
}
if !slices.ContainsFunc(mockAccounts, search) {
ctx.Error(fmt.Errorf("Account does not exist: %s", email))
return
}
mockAccounts = slices.DeleteFunc(mockAccounts, search)
ctx.Status(http.StatusOK)
})
r.GET("/api/v1/settings", func(ctx *gin.Context) {
type (
MailDelivery struct {
Version string `json:"version"`
}
MailTransfer struct {
Version string `json:"version"`
}
Settings struct {
MailDelivery MailDelivery `json:"mail_delivery"`
MailTransfer MailTransfer `json:"mail_transfer"`
}
)
ctx.JSON(http.StatusOK, Settings{
MailDelivery: MailDelivery{
Version: "v1.2.3",
},
MailTransfer: MailTransfer{
Version: "v4.5.6",
},
})
})
r.Run()
}

View file

@ -0,0 +1,40 @@
package model
import (
"fmt"
"strings"
)
type (
MailAccount struct {
Username string `json:"username"`
Domain string `json:"domain"`
DisplayName string `json:"display_name"`
PasswordHash []byte `json:"password"`
MailDirectory string `json:"mail_dir"`
}
)
func (account *MailAccount) FullUsername() string {
return fmt.Sprintf("%s@%s", account.Username, account.Domain)
}
func (account *MailAccount) UserDbLine() string {
uid := 5000; gid := 5000
// <username>:<password>:5000:5000::/var/mail/<domain>/<user>/home::
return fmt.Sprintf(
"%s:%s:%d:%d::%s::\n",
account.FullUsername(),
strings.Trim(string(account.PasswordHash), " \n"),
uid, gid,
account.DefaultMailHome(),
)
}
func (account *MailAccount) DefaultMailHome() string {
return fmt.Sprintf(
"/var/mail/%s/%s/home",
account.Domain,
account.Username,
)
}

View file

@ -16,6 +16,7 @@
--sp-xxxl: var(--sp-64); --sp-xxxl: var(--sp-64);
--sz-member-list: 270px; --sz-member-list: 270px;
--skeleton-gradient-width: 500px;
--radius-0: var(--sp-sm); --radius-0: var(--sp-sm);
--radius-1: calc(var(--radius-0) * 2); --radius-1: calc(var(--radius-0) * 2);
@ -69,3 +70,58 @@ a {
a:hover { a:hover {
text-decoration: underline; text-decoration: underline;
} }
.skeleton {
border: 1px solid var(--bg-2);
border-radius: var(--sp-sm);
background-image: linear-gradient(
90deg,
var(--bg-1),
var(--bg-0),
var(--bg-1)
);
background-size: var(--skeleton-gradient-width) 100%;
background-repeat: repeat;
animation: skeleton 1s ease-in-out infinite;
}
@keyframes throb {
from { opacity: 1 }
to { opacity: .25 }
}
@keyframes skeleton {
from {
background-position: 0 0;
}
to {
background-position: var(--skeleton-gradient-width) 0;
}
}
code {
width: fit-content;
height: 1.5em;
margin: 0;
padding: 0 .2em;
font-family: monospace;
font-weight: normal;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: inline-flex;
flex-direction: row;
align-items: center;
color: var(--on-bg-3);
background-color: var(--bg-1);
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
transition: background-color .1s, border-color .1s;
}

View file

@ -9,6 +9,18 @@
account: Account, account: Account,
}>(); }>();
let email: string = $state("");
$effect(() => {
email = `${account.username}@${account.domain}`;
});
let link: HTMLAnchorElement;
function onPress(event: MouseEvent | KeyboardEvent) {
if (event instanceof MouseEvent && event.button !== 0) return;
if (event instanceof KeyboardEvent && event.key !== "Enter") return;
link.click();
}
async function copyEmail() { async function copyEmail() {
await navigator.clipboard.write([new ClipboardItem({ await navigator.clipboard.write([new ClipboardItem({
"text/plain": `${account.username}@${account.domain}`, "text/plain": `${account.username}@${account.domain}`,
@ -18,13 +30,23 @@
} }
</script> </script>
<div class="account" role="button"> <div
class="account"
role="button"
data-email="{email}"
tabindex="0"
onclick={onPress}
onkeydown={onPress}>
<a href="/accounts/{email}" hidden bind:this={link}>{email}</a>
<div class="icon-container"><User /></div> <div class="icon-container"><User /></div>
<hr> <hr>
<div class="info-container"> <div class="info-container">
<h2 class="name">{account.username}</h2> <h2 class="name">{account.display_name}</h2>
<button class="email" onclick={() => {copyEmail()}}> <button class="email" onclick={(event) => {
{account.username}@{account.domain}<Copy /> event.stopPropagation();
copyEmail();
}}>
{email}<Copy />
</button> </button>
</div> </div>
</div> </div>
@ -59,12 +81,15 @@
} }
.icon-container { .icon-container {
width: var(--sp-xxxl); height: 100%;
min-width: var(--sp-xxxl); aspect-ratio: 1;
height: var(--sp-xxxl);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: var(--bg-1);
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
} }
.icon-container :global(svg) { .icon-container :global(svg) {
@ -99,17 +124,15 @@
.info-container .email { .info-container .email {
width: fit-content; width: fit-content;
height: 1.5em;
margin: 0; margin: 0;
padding: .2em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-family: monospace; font-family: monospace;
font-size: 1.2em; font-size: 1.2em;
line-height: 1.6em; text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -134,6 +157,7 @@
} }
.info-container .email :global(svg) { .info-container .email :global(svg) {
width: 1em;
height: 1em; height: 1em;
margin-left: var(--sp-sm); margin-left: var(--sp-sm);
} }

View file

@ -1,26 +1,30 @@
<script lang="ts"> <script lang="ts">
let { let { children, onclick, href, class: className } = $props<{
children,
href,
} = $props<{
children: () => any, children: () => any,
href: string onclick?: Function,
href?: string,
class?: string,
}>(); }>();
</script> </script>
<a href="{href}">{@render children()}</a> {#if href}
<a href="{href}" class="button {className}" onclick={onclick}><span>{@render children()}</span></a>
{:else}
<button onclick={onclick} class="{className}"><span>{@render children()}</span></button>
{/if}
<style> <style>
a { button, .button {
margin-left: var(--sp-sm); margin-left: var(--sp-sm);
padding: .5em; padding: .5em;
display: flex; display: inline-flex;
flex-direction: row; flex-direction: row;
justify-content: start; justify-content: start;
align-items: center; align-items: center;
gap: .5em; gap: .5em;
font-size: 1em;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@ -35,11 +39,17 @@
user-select: none; user-select: none;
} }
a:hover { button:hover, .button:hover {
background-color: var(--bg-1); background-color: var(--bg-1);
} }
a:active { button:active, .button:active {
background-color: var(--bg-2); background-color: var(--bg-2);
} }
button :global(svg), .button :global(svg) {
width: 1.1em;
height: 1.1em;
margin: 0 .1em -.175em 0;
}
</style> </style>

View file

@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import { type Icon } from '@lucide/svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
@ -24,4 +22,9 @@
header :global(h1) { header :global(h1) {
margin: 0; margin: 0;
} }
header :global(svg) {
width: 2em;
height: 2em;
}
</style> </style>

View file

@ -0,0 +1,17 @@
<script lang="ts">
let { children } = $props();
</script>
<p class="label">{@render children()}</p>
<style>
.label {
margin: 1.5em 0 .5em .2em;
font-size: .8em;
text-transform: uppercase;
font-weight: 500;
opacity: .66;
}
</style>

View file

@ -1,37 +0,0 @@
<div class="skeleton"><slot></slot></div>
<style>
:root {
--width: 500px;
}
.skeleton {
border: 1px solid var(--bg-2);
border-radius: var(--sp-sm);
background-image: linear-gradient(
90deg,
var(--bg-1),
var(--bg-0),
var(--bg-1)
);
background-size: var(--width) 100%;
background-repeat: repeat;
animation: skeleton 1s ease-in-out infinite;
}
@keyframes throb {
from { opacity: 1 }
to { opacity: .25 }
}
@keyframes skeleton {
from {
background-position: 0 0;
}
to {
background-position: var(--width) 0;
}
}
</style>

View file

@ -1,6 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
export default interface Account { export interface Account {
display_name: string,
username: string, username: string,
domain: string, domain: string,
mail_directory: string, mail_directory: string,

View file

@ -1,21 +1,55 @@
import type { Account } from "./accounts.ts"; import type { Account } from "@jupiter/lib/account";
let token = "";
export function fetchAccounts(): Promise<Account[]> { export const BASE_URL = "http://localhost:8080";
return new Promise((res) => {
setTimeout(() => { export async function fetchDashboard(): Promise<any> {
res([ return new Promise(async (ok, err) => {
{ fetch(BASE_URL + "/api/v1/dashboard")
username: "ari", .then(res => {
domain: "example.org", res.json().then(json => {
mail_directory: `/var/mail/example.org/ari/home`, ok(json);
}, });
{ }).catch(e => {
username: "testbanger", err(e);
domain: "gmail.com", });
mail_directory: `/var/mail/example.org/ari/home`, });
} }
]);
}, 300) export async function fetchAccounts(): Promise<Account[]> {
}) return new Promise((ok, err) => {
fetch(BASE_URL + "/api/v1/accounts")
.then(res => {
res.json().then(accounts => {
ok(accounts as Account[]);
});
}).catch(e => {
err(e);
});
});
}
export async function fetchAccountByEmail(email: string): Promise<Account> {
return new Promise((ok, err) => {
fetch(BASE_URL + "/api/v1/accounts/" + email)
.then(res => {
res.json().then(account => {
ok(account as Account);
});
}).catch(e => {
err(e);
});
});
}
export async function fetchSettings(): Promise<any> {
return new Promise((ok, err) => {
fetch(BASE_URL + "/api/v1/settings")
.then(res => {
res.json().then(json => {
ok(json);
});
}).catch(e => {
err(e);
});
});
} }

View file

@ -2,9 +2,8 @@
import '@jupiter/app.css'; import '@jupiter/app.css';
import favicon from '@jupiter/assets/favicon.svg'; import favicon from '@jupiter/assets/favicon.svg';
import { LayoutDashboard, Mail, Settings, User } from '@lucide/svelte'; import { LayoutDashboard, Mail, Settings, User } from '@lucide/svelte';
import { onMount } from 'svelte';
import ToastOverlay from '@jupiter/components/ui/ToastOverlay.svelte'; import ToastOverlay from '@jupiter/components/ui/ToastOverlay.svelte';
import SidebarLink from '@jupiter/components/ui/SidebarLink.svelte'; import Button from '@jupiter/components/ui/Button.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
@ -16,9 +15,9 @@
<div class="app"> <div class="app">
<header class="sidebar"> <header class="sidebar">
<h1><Mail /> Jupiter Mail</h1> <h1><Mail /> Jupiter Mail</h1>
<SidebarLink href="/"><LayoutDashboard /> Dashboard</SidebarLink> <Button href="/"><LayoutDashboard /> Dashboard</Button>
<SidebarLink href="/accounts"><User /> Accounts</SidebarLink> <Button href="/accounts"><User /> Accounts</Button>
<SidebarLink href="/settings"><Settings /> Settings</SidebarLink> <Button href="/settings"><Settings /> Settings</Button>
</header> </header>
<main> <main>
{@render children()} {@render children()}

View file

@ -1,16 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { LayoutDashboard } from '@lucide/svelte';
import { LayoutDashboard, User } from '@lucide/svelte';
import Card from '@jupiter/components/ui/Card.svelte'; import Card from '@jupiter/components/ui/Card.svelte';
import Header from '@jupiter/components/ui/Header.svelte'; import Header from '@jupiter/components/ui/Header.svelte';
import * as api from '@jupiter/lib/api';
import { type Account, accounts } from '@jupiter/lib/account';
onMount(async () => { let { data } = $props();
if ($accounts == null) api.fetchAccounts().then(accs => {
accounts.update(() => accs);
});
});
</script> </script>
<div class="page-container"> <div class="page-container">
@ -22,7 +15,7 @@
<main> <main>
<Card> <Card>
<h2><a href="/accounts">Accounts</a></h2> <h2><a href="/accounts">Accounts</a></h2>
<p class="active-accounts">Active: {$accounts.length}</p> <p class="active-accounts">Active: {data.dashboard.num_accounts}</p>
</Card> </Card>
</main> </main>
</div> </div>

View file

@ -0,0 +1,16 @@
import * as api from '@jupiter/lib/api';
import { error, isHttpError } from '@sveltejs/kit';
export async function load({ fetch }: { fetch: Function }) {
try {
const res = await fetch(api.BASE_URL + "/api/v1/dashboard");
const json = await res.json();
return { dashboard: json }
} catch(err) {
if (isHttpError(err)) {
error(err.status, err.body);
} else {
error(500);
}
}
}

View file

@ -1,22 +1,15 @@
<script lang="ts"> <script lang="ts">
import { User } from '@lucide/svelte'; import { User } from '@lucide/svelte';
import { onMount } from 'svelte';
import * as api from '@jupiter/lib/api';
import { type Account, accounts } from '@jupiter/lib/account';
import { pushToast, ToastType } from '@jupiter/lib/toasts';
import Header from '@jupiter/components/ui/Header.svelte'; import Header from '@jupiter/components/ui/Header.svelte';
import AccountListItem from '@jupiter/components/ui/AccountListItem.svelte'; import AccountListItem from '@jupiter/components/ui/AccountListItem.svelte';
import AccountAddButton from '@jupiter/components/ui/AccountAddButton.svelte'; import AccountAddButton from '@jupiter/components/ui/AccountAddButton.svelte';
import Skeleton from '@jupiter/components/ui/Skeleton.svelte'; import { pushToast, ToastType } from '@jupiter/lib/toasts';
onMount(async () => { let { data } = $props();
if ($accounts == null) api.fetchAccounts().then(accs => { const accounts = (() => data.accounts)();
accounts.update(() => accs);
});
});
function addAccount() { function addAccount() {
alert("TODO: this"); pushToast("TODO: account creation", ToastType.INFO);
} }
</script> </script>
@ -30,9 +23,9 @@
<p>Click an account below to configure:</p> <p>Click an account below to configure:</p>
<hr> <hr>
<div class="account-list"> <div class="account-list">
{#if $accounts} {#if accounts}
{#each $accounts as account} {#each accounts as account}
<AccountListItem account={account} /> <AccountListItem account={account}/>
{/each} {/each}
<AccountAddButton <AccountAddButton
onclick={() => { addAccount() }} onclick={() => { addAccount() }}
@ -41,8 +34,8 @@
}} }}
/> />
{:else} {:else}
{#each {length: 4} as i} {#each {length: 4}}
<Skeleton><div class="account-skeleton"></div></Skeleton> <div class="skeleton"></div>
{/each} {/each}
{/if} {/if}
</div> </div>
@ -70,7 +63,7 @@
gap: 1em; gap: 1em;
} }
.account-skeleton { .account-list .skeleton {
width: 480px; width: 480px;
height: var(--sp-xxxl); height: var(--sp-xxxl);
padding: .5em; padding: .5em;

View file

@ -0,0 +1,17 @@
import type { Account } from "@jupiter/lib/account";
import * as api from "@jupiter/lib/api";
import { error, isHttpError } from "@sveltejs/kit";
export async function load({ fetch }: { fetch: Function }) {
try {
const res = await fetch(api.BASE_URL + "/api/v1/accounts");
const json = await res.json();
return { accounts: json as Account[] }
} catch(err: any) {
if (isHttpError(err)) {
error(err.status, err.body);
} else {
error(500);
}
}
}

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { Copy, KeyRound, Mail, Trash, User } from '@lucide/svelte';
import Header from '@jupiter/components/ui/Header.svelte';
import { pushToast, ToastType } from '@jupiter/lib/toasts';
import Button from '@jupiter/components/ui/Button.svelte';
import Label from '@jupiter/components/ui/Label.svelte';
import { goto } from '$app/navigation';
import * as api from '@jupiter/lib/api';
const { data } = $props();
const account = (() => data.account)();
const email = (() => data.email)();
async function copyEmail() {
await navigator.clipboard.write([new ClipboardItem({
"text/plain": email,
})]);
pushToast("Email copied to clipboard.", ToastType.SUCCESS);
}
async function resetPassword() {
pushToast("TODO: password reset", ToastType.INFO);
}
async function deleteAccount() {
if (prompt(
"Are you sure you wish to delete this account? " +
"This action is irreversible.\n\n" +
`To confirm, please enter ${email}:`) !== email) return;
const res = await fetch(api.BASE_URL + "/api/v1/accounts/" + email, {
method: "DELETE",
})
if (!res.ok) {
const text = await res.text();
pushToast("Failed to delete account: " + text, ToastType.ERROR);
return
}
pushToast("Account deleted successfully.", ToastType.SUCCESS);
goto("/accounts");
}
</script>
<div class="page-container">
<Header>
<User />
<h1>Account Configuration</h1>
</Header>
<main>
{#if account}
<div class="account-title-container">
<div class="account-title-icon">
<User />
</div>
<hr>
<h2 class="account-title">
{account.display_name}
<button class="email" onclick={() => { copyEmail() }}>{email} <Copy /></button>
</h2>
</div>
<hr>
<Label>Actions</Label>
<div class="account-actions">
<Button href="mailto:{email}"><Mail /> Send Email</Button>
<Button onclick={() => resetPassword()}><KeyRound /> Reset Password</Button>
<Button onclick={() => deleteAccount()} class="delete">
<Trash /> Delete Account
</Button>
</div>
<Label>Details</Label>
<div class="account-details">
<p>Mail Directory: <code>{account.mail_directory}</code></p>
</div>
{:else}
<p>Just a moment...</p>
{/if}
</main>
</div>
<style>
.page-container {
min-height: 100%;
background-color: var(--bg-0);
}
main {
padding: 2em;
}
.account-title-container {
height: var(--sp-xxxl);
display: flex;
flex-direction: row;
align-items: center;
gap: .2em;
}
.account-title-icon {
height: 100%;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--bg-1);
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
}
.account-title-icon :global(svg) {
--size: 60%;
width: var(--size);
height: var(--size);
}
.account-title-container hr {
height: 100%;
margin: 0 .5em;
border: none;
border-left: 1px solid var(--bg-2);
}
.account-title {
margin: 0;
display: flex;
flex-direction: column;
gap: .2em;
}
.account-title .email {
width: fit-content;
height: 1.5em;
margin: 0;
font-family: monospace;
font-size: .8em;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: flex;
flex-direction: row;
align-items: center;
color: var(--on-bg-3);
background-color: var(--bg-1);
border: 1px solid var(--bg-2);
border-radius: var(--radius-0);
transition: background-color .1s, border-color .1s;
cursor: pointer;
}
.account-title .email:hover {
background-color: var(--bg-2);
border-color: var(--bg-3);
}
.account-title .email:active {
background-color: var(--bg-3);
}
.account-title .email :global(svg) {
width: 1em;
height: 1em;
margin-left: var(--sp-sm);
}
.account-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: .5em;
}
.account-actions :global(button.delete) {
box-shadow: calc(0px - var(--sp-sm)) 0 0 var(--error);
}
.account-details p {
margin: 0;
}
</style>

View file

@ -0,0 +1,21 @@
import type { Account } from "@jupiter/lib/account";
import * as api from "@jupiter/lib/api";
import { error, isHttpError } from "@sveltejs/kit";
export async function load({ fetch, params }: { fetch: Function, params: any }) {
try {
const res = await fetch(api.BASE_URL + "/api/v1/accounts/" + params.email);
const json = await res.json();
const account = json as Account;
const email = `${account.username}@${account.domain}`;
return { account, email }
} catch(err: any) {
if (isHttpError(err)) {
error(err.status, err.body);
} else {
error(500);
}
}
}

View file

@ -1,6 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Settings } from '@lucide/svelte'; import { Settings } from '@lucide/svelte';
import Header from '@jupiter/components/ui/Header.svelte'; import Header from '@jupiter/components/ui/Header.svelte';
import Card from '@jupiter/components/ui/Card.svelte';
let { data } = $props();
let settings = (() => data.settings)();
</script> </script>
<div class="page-container"> <div class="page-container">
@ -8,6 +13,18 @@
<Settings /> <Settings />
<h1>Settings</h1> <h1>Settings</h1>
</Header> </Header>
<main>
<Card>
<h2>Mail Delivery Settings</h2>
<p>Running: <code>{settings.mail_delivery.version}</code></p>
</Card>
<Card>
<h2>Mail Transfer Settings</h2>
<p>Running: <code>{settings.mail_transfer.version}</code></p>
</Card>
</main>
</div> </div>
<style> <style>
@ -16,4 +33,21 @@
background-color: var(--bg-0); background-color: var(--bg-0);
} }
main {
padding: 2em;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1em;
}
h2 {
margin: 0 0 .5em 0;
}
p {
margin: 0;
}
</style> </style>

View file

@ -0,0 +1,16 @@
import * as api from '@jupiter/lib/api';
import { error, isHttpError } from '@sveltejs/kit';
export async function load({ fetch }: { fetch: Function }) {
try {
const res = await fetch(api.BASE_URL + "/api/v1/settings");
const json = await res.json();
return { settings: json }
} catch(err) {
if (isHttpError(err)) {
error(err.status, err.body);
} else {
error(500);
}
}
}