first commit
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
// Package admin implements the admin portal REST API — domains (with DKIM
|
||||
// key generation), tenants, users, list rules, outbound queue management,
|
||||
// global quarantine, and dashboard stats. Reuses webtoken for sessions
|
||||
// (same JWT scheme as webmail) but enforces role-based access: only
|
||||
// global_admin and tenant_admin roles may authenticate here at all, and
|
||||
// tenant_admin is scoped to their own tenant for every operation.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gomail/internal/auth"
|
||||
"gomail/internal/crypto"
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/dkim"
|
||||
"gomail/internal/webtoken"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const sessionTTL = 24 * time.Hour
|
||||
|
||||
type Handler struct {
|
||||
database *db.DB
|
||||
mk *crypto.MasterKey
|
||||
jwtSecret string
|
||||
}
|
||||
|
||||
func NewHandler(database *db.DB, mk *crypto.MasterKey, jwtSecret string) *Handler {
|
||||
return &Handler{database: database, mk: mk, jwtSecret: jwtSecret}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/admin/auth/login", h.login)
|
||||
mux.HandleFunc("/api/admin/stats", h.withAdmin(h.stats))
|
||||
mux.HandleFunc("/api/admin/tenants", h.withAdmin(h.tenants))
|
||||
mux.HandleFunc("/api/admin/domains", h.withAdmin(h.domains))
|
||||
mux.HandleFunc("/api/admin/domains/", h.withAdmin(h.domainByID))
|
||||
mux.HandleFunc("/api/admin/users", h.withAdmin(h.users))
|
||||
mux.HandleFunc("/api/admin/users/", h.withAdmin(h.userByID))
|
||||
mux.HandleFunc("/api/admin/list-rules", h.withAdmin(h.listRules))
|
||||
mux.HandleFunc("/api/admin/list-rules/", h.withAdmin(h.listRuleByID))
|
||||
mux.HandleFunc("/api/admin/queue", h.withAdmin(h.queue))
|
||||
mux.HandleFunc("/api/admin/queue/", h.withAdmin(h.queueByID))
|
||||
mux.HandleFunc("/api/admin/quarantine", h.withAdmin(h.quarantine))
|
||||
mux.HandleFunc("/api/admin/quarantine/", h.withAdmin(h.quarantineByID))
|
||||
}
|
||||
|
||||
// ── JSON helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// ── Auth (admin-only roles) ─────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct{ Email, Password string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := auth.Authenticate(h.database, req.Email, req.Password, auth.ScopeIMAP)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
if user.Role != db.RoleGlobalAdmin && user.Role != db.RoleTenantAdmin {
|
||||
// Deliberately the same error as bad credentials — don't leak "this
|
||||
// account exists but lacks admin rights" to an unauthenticated caller.
|
||||
writeErr(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := webtoken.Issue(h.jwtSecret, user.ID, user.TenantID, string(user.Role), sessionTTL)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "token generation failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"token": token,
|
||||
"user": map[string]any{"id": user.ID, "email": user.Email, "role": user.Role},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) withAdmin(next func(http.ResponseWriter, *http.Request, *db.User)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := ""
|
||||
if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
}
|
||||
if tokenStr == "" {
|
||||
writeErr(w, http.StatusUnauthorized, "missing token")
|
||||
return
|
||||
}
|
||||
claims, err := webtoken.Verify(h.jwtSecret, tokenStr)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if claims.Role != string(db.RoleGlobalAdmin) && claims.Role != string(db.RoleTenantAdmin) {
|
||||
writeErr(w, http.StatusForbidden, "admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.database.GetUser(claims.Subject)
|
||||
if err != nil || !user.Active {
|
||||
writeErr(w, http.StatusUnauthorized, "user not found or inactive")
|
||||
return
|
||||
}
|
||||
// Re-check role against the live DB row, not just the JWT claim — a
|
||||
// demoted admin's existing token shouldn't keep working until it
|
||||
// naturally expires.
|
||||
if user.Role != db.RoleGlobalAdmin && user.Role != db.RoleTenantAdmin {
|
||||
writeErr(w, http.StatusForbidden, "admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
next(w, r, user)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) encryptDKIMKey(domainID string, keyPEM []byte) ([]byte, error) {
|
||||
return crypto.Encrypt(h.mk, domainID, "dkim-key", keyPEM)
|
||||
}
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) stats(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
s, err := h.database.GetStats()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
// ── Tenants (global_admin only) ─────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) tenants(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if user.Role != db.RoleGlobalAdmin {
|
||||
writeErr(w, http.StatusForbidden, "only global_admin may manage tenants")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
list, err := h.database.ListTenants()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
case http.MethodPost:
|
||||
var req struct{ Name, DisplayName string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||
writeErr(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
t := &db.Tenant{ID: uuid.NewString(), Name: req.Name, DisplayName: req.DisplayName}
|
||||
if err := h.database.CreateTenant(t); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Domains ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) domains(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
all, err := h.database.ListDomains()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, filterDomainsByTenant(all, scopeTenant(user)))
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct{ Domain, TenantID string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
|
||||
writeErr(w, http.StatusBadRequest, "domain is required")
|
||||
return
|
||||
}
|
||||
tenantID := req.TenantID
|
||||
if user.Role != db.RoleGlobalAdmin {
|
||||
tenantID = user.TenantID
|
||||
} else if tenantID == "" {
|
||||
tenantID = user.TenantID // global_admin defaults to their own tenant if unspecified
|
||||
}
|
||||
if tenantID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "tenant_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
domainID := uuid.NewString()
|
||||
selector := "mail"
|
||||
kp, err := dkim.GenerateKeyPair()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "DKIM key generation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
keyEnc, err := h.encryptDKIMKey(domainID, kp.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "DKIM key encryption failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
d := &db.Domain{ID: domainID, TenantID: tenantID, Domain: req.Domain, DKIMSelector: selector, DKIMPrivateKeyEnc: keyEnc}
|
||||
if err := h.database.CreateDomain(d); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"domain": d, "dkim_dns_record": kp.DNSRecordValue, "dkim_dns_name": selector + "._domainkey." + req.Domain,
|
||||
})
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) domainByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/admin/domains/"), "/")
|
||||
id := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
d, err := h.database.GetDomain(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
if user.Role != db.RoleGlobalAdmin && d.TenantID != user.TenantID {
|
||||
writeErr(w, http.StatusForbidden, "not your tenant's domain")
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodPost && action == "dkim-rotate":
|
||||
kp, err := dkim.GenerateKeyPair()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
encKey, err := h.encryptDKIMKey(d.ID, kp.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.database.UpdateDomainDKIMKey(d.ID, d.DKIMSelector, encKey); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"dkim_dns_record": kp.DNSRecordValue, "dkim_dns_name": d.DKIMSelector + "._domainkey." + d.Domain,
|
||||
})
|
||||
|
||||
case r.Method == http.MethodDelete && action == "":
|
||||
if err := h.database.DeleteDomain(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// scopeTenant returns the tenant ID a tenant_admin is restricted to, or ""
|
||||
// for global_admin (meaning "all tenants, no filter").
|
||||
func scopeTenant(user *db.User) string {
|
||||
if user.Role == db.RoleGlobalAdmin {
|
||||
return ""
|
||||
}
|
||||
return user.TenantID
|
||||
}
|
||||
|
||||
func filterDomainsByTenant(all []db.Domain, tenantID string) []db.Domain {
|
||||
if tenantID == "" {
|
||||
return all
|
||||
}
|
||||
var out []db.Domain
|
||||
for _, d := range all {
|
||||
if d.TenantID == tenantID {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package admin
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed static/index.html
|
||||
var StaticFS embed.FS
|
||||
@@ -0,0 +1,271 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gomail/internal/db"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) users(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
list, err := h.database.ListUsers(scopeTenant(user))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Email, Password, DisplayName, Role, DomainID string
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" || req.Password == "" {
|
||||
writeErr(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
if req.Role == "" {
|
||||
req.Role = string(db.RoleUser)
|
||||
}
|
||||
tenantID := user.TenantID
|
||||
if user.Role != db.RoleGlobalAdmin && req.Role != string(db.RoleUser) {
|
||||
writeErr(w, http.StatusForbidden, "tenant_admin may only create regular users")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "password hashing failed")
|
||||
return
|
||||
}
|
||||
|
||||
newUser := &db.User{
|
||||
ID: uuid.NewString(), TenantID: tenantID, DomainID: req.DomainID,
|
||||
Email: req.Email, DisplayName: req.DisplayName, Role: db.UserRole(req.Role),
|
||||
}
|
||||
if err := h.database.CreateUser(newUser, string(hash)); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, newUser)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) userByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/admin/users/"), "/")
|
||||
id := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
target, err := h.database.GetUser(id)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if user.Role != db.RoleGlobalAdmin && target.TenantID != user.TenantID {
|
||||
writeErr(w, http.StatusForbidden, "not your tenant's user")
|
||||
return
|
||||
}
|
||||
if user.Role != db.RoleGlobalAdmin && (target.Role == db.RoleGlobalAdmin || target.Role == db.RoleTenantAdmin) && target.ID != user.ID {
|
||||
writeErr(w, http.StatusForbidden, "cannot modify an admin account")
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodPost && action == "suspend":
|
||||
if err := h.database.SetUserActive(id, false); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "suspended"})
|
||||
|
||||
case r.Method == http.MethodPost && action == "activate":
|
||||
if err := h.database.SetUserActive(id, true); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "activated"})
|
||||
|
||||
case r.Method == http.MethodPost && action == "reset-password":
|
||||
var req struct{ NewPassword string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.NewPassword) < 8 {
|
||||
writeErr(w, http.StatusBadRequest, "new_password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "hashing failed")
|
||||
return
|
||||
}
|
||||
if err := h.database.SetUserPassword(id, string(hash)); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "password reset"})
|
||||
|
||||
case r.Method == http.MethodDelete && action == "":
|
||||
if err := h.database.DeleteUser(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── List rules ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listRules(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
tenantID := user.TenantID
|
||||
if user.Role == db.RoleGlobalAdmin {
|
||||
if qt := r.URL.Query().Get("tenant_id"); qt != "" {
|
||||
tenantID = qt
|
||||
}
|
||||
}
|
||||
if tenantID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "tenant_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
rules, err := h.database.ListListRules(tenantID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rules)
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct{ ListType, MatchType, Value, Note string }
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.ListType != "allow" && req.ListType != "block" {
|
||||
writeErr(w, http.StatusBadRequest, "list_type must be 'allow' or 'block'")
|
||||
return
|
||||
}
|
||||
if req.MatchType != "email" && req.MatchType != "domain" {
|
||||
writeErr(w, http.StatusBadRequest, "match_type must be 'email' or 'domain'")
|
||||
return
|
||||
}
|
||||
if req.Value == "" {
|
||||
writeErr(w, http.StatusBadRequest, "value is required")
|
||||
return
|
||||
}
|
||||
rule := &db.ListRule{
|
||||
ID: uuid.NewString(), TenantID: tenantID,
|
||||
ListType: db.ListRuleAction(req.ListType), MatchType: req.MatchType, Value: req.Value, Note: req.Note,
|
||||
}
|
||||
if err := h.database.CreateListRule(rule); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rule)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) listRuleByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/admin/list-rules/")
|
||||
if err := h.database.DeleteListRule(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "deleted"})
|
||||
}
|
||||
|
||||
// ── Outbound queue ────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) queue(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
entries, err := h.database.ListAllOutboundQueue()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
func (h *Handler) queueByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/admin/queue/"), "/")
|
||||
id := parts[0]
|
||||
action := ""
|
||||
if len(parts) > 1 {
|
||||
action = parts[1]
|
||||
}
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodPost && action == "retry":
|
||||
if err := h.database.RetryQueueEntryNow(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "scheduled for immediate retry"})
|
||||
|
||||
case r.Method == http.MethodDelete && action == "":
|
||||
if err := h.database.DeleteOutboundEntry(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "cancelled"})
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global quarantine ─────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) quarantine(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
entries, err := h.database.ListAllQuarantine()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
func (h *Handler) quarantineByID(w http.ResponseWriter, r *http.Request, user *db.User) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/admin/quarantine/")
|
||||
if err := h.database.DeleteQuarantineEntry(id); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "discarded"})
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoMail Admin</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0}
|
||||
.sidebar{width:200px;background:#1e293b;border-right:1px solid #334155;min-height:100vh;position:fixed;top:0;left:0}
|
||||
.main{margin-left:200px;padding:28px;max-width:1100px}
|
||||
.nav-item{padding:9px 16px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:8px;margin:2px 8px}
|
||||
.nav-item:hover{background:#334155}
|
||||
.nav-item.active{background:#7c3aed22;color:#a78bfa}
|
||||
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:18px}
|
||||
.btn{padding:6px 12px;border-radius:7px;font-size:12px;font-weight:500;cursor:pointer;border:none}
|
||||
.btn-primary{background:#7c3aed;color:#fff}
|
||||
.btn-danger{background:#dc262622;color:#f87171;border:1px solid #dc262655}
|
||||
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}
|
||||
.inp{background:#0f172a;border:1px solid #334155;border-radius:7px;padding:7px 10px;color:#e2e8f0;font-size:13px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;color:#64748b;font-weight:500;padding:8px;border-bottom:1px solid #334155}
|
||||
td{padding:8px;border-bottom:1px solid #1e293b}
|
||||
.stat{font-size:28px;font-weight:700;color:#fff}
|
||||
.stat-label{font-size:12px;color:#64748b}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
|
||||
<div style="background:#1e293b;border:1px solid #334155;border-radius:14px;padding:28px;width:320px">
|
||||
<h1 style="font-weight:700;color:#fff;text-align:center;margin-bottom:20px">Admin</h1>
|
||||
<input id="le" class="inp" placeholder="admin@example.com" style="width:100%;margin-bottom:10px;box-sizing:border-box">
|
||||
<input id="lp" type="password" class="inp" placeholder="Password" style="width:100%;margin-bottom:10px;box-sizing:border-box" onkeydown="if(event.key==='Enter')login()">
|
||||
<button onclick="login()" class="btn btn-primary" style="width:100%">Sign in</button>
|
||||
<p id="lerr" style="display:none;color:#f87171;font-size:12px;text-align:center;margin-top:10px"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app" style="display:none">
|
||||
<aside class="sidebar">
|
||||
<div style="padding:16px;border-bottom:1px solid #334155;font-weight:700;color:#fff">Admin</div>
|
||||
<div style="padding:12px 8px">
|
||||
<div class="nav-item active" onclick="showPage('dashboard',this)">Dashboard</div>
|
||||
<div class="nav-item" onclick="showPage('domains',this)">Domains</div>
|
||||
<div class="nav-item" onclick="showPage('users',this)">Users</div>
|
||||
<div class="nav-item" onclick="showPage('rules',this)">List Rules</div>
|
||||
<div class="nav-item" onclick="showPage('queue',this)">Queue</div>
|
||||
<div class="nav-item" onclick="showPage('quarantine',this)">Quarantine</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div id="page-dashboard">
|
||||
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Dashboard</h2>
|
||||
<div id="stats-grid" style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px"></div>
|
||||
</div>
|
||||
|
||||
<div id="page-domains" style="display:none">
|
||||
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Domains</h2>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<input id="d-domain" class="inp" placeholder="example.com">
|
||||
<button onclick="createDomain()" class="btn btn-primary">Add Domain</button>
|
||||
</div>
|
||||
<div class="card"><table id="domains-table"><thead><tr><th>Domain</th><th>ID</th><th>DKIM</th><th></th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div id="page-users" style="display:none">
|
||||
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Users</h2>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<input id="u-email" class="inp" placeholder="user@example.com">
|
||||
<input id="u-password" class="inp" type="password" placeholder="Password">
|
||||
<input id="u-domain-id" class="inp" placeholder="Domain ID (see Domains page)">
|
||||
<button onclick="createUser()" class="btn btn-primary">Add User</button>
|
||||
</div>
|
||||
<div class="card"><table id="users-table"><thead><tr><th>Email</th><th>Role</th><th>Active</th><th></th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div id="page-rules" style="display:none">
|
||||
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">List Rules</h2>
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<select id="r-type" class="inp"><option value="allow">allow</option><option value="block">block</option></select>
|
||||
<select id="r-match" class="inp"><option value="email">email</option><option value="domain">domain</option></select>
|
||||
<input id="r-value" class="inp" placeholder="value">
|
||||
<button onclick="createRule()" class="btn btn-primary">Add Rule</button>
|
||||
</div>
|
||||
<div class="card"><table id="rules-table"><thead><tr><th>Type</th><th>Match</th><th>Value</th><th></th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div id="page-queue" style="display:none">
|
||||
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Outbound Queue</h2>
|
||||
<div class="card"><table id="queue-table"><thead><tr><th>From</th><th>To</th><th>Attempts</th><th>Error</th><th></th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div id="page-quarantine" style="display:none">
|
||||
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Quarantine</h2>
|
||||
<div class="card"><table id="quarantine-table"><thead><tr><th>Reason</th><th>Held</th><th></th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='/api/admin';
|
||||
let token=localStorage.getItem('gomail_admin_token')||'';
|
||||
|
||||
async function api(path,opts={}){
|
||||
const r=await fetch(API+path,{...opts,headers:{'Content-Type':'application/json','Authorization':'Bearer '+token,...(opts.headers||{})}});
|
||||
if(r.status===401){showLogin();return null;}
|
||||
return r.ok?r.json():Promise.reject(await r.json());
|
||||
}
|
||||
|
||||
async function login(){
|
||||
const email=document.getElementById('le').value,pwd=document.getElementById('lp').value;
|
||||
try{
|
||||
const d=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({Email:email,Password:pwd})}).then(r=>r.json());
|
||||
if(d.error)throw new Error(d.error);
|
||||
token=d.token;localStorage.setItem('gomail_admin_token',token);
|
||||
showApp();
|
||||
}catch(e){const el=document.getElementById('lerr');el.textContent=e.message||'Login failed';el.style.display='';}
|
||||
}
|
||||
function showLogin(){document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';}
|
||||
function showApp(){
|
||||
document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
function showPage(name,el){
|
||||
['dashboard','domains','users','rules','queue','quarantine'].forEach(p=>document.getElementById('page-'+p).style.display=p===name?'block':'none');
|
||||
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
({dashboard:loadDashboard,domains:loadDomains,users:loadUsers,rules:loadRules,queue:loadQueue,quarantine:loadQuarantine})[name]();
|
||||
}
|
||||
|
||||
async function loadDashboard(){
|
||||
const s=await api('/stats');if(!s)return;
|
||||
document.getElementById('stats-grid').innerHTML=[
|
||||
['Users',s.TotalUsers],['Domains',s.TotalDomains],['Messages (24h)',s.Messages24h],
|
||||
['Queue depth',s.QueueDepth],['Quarantine held',s.QuarantineHeld]
|
||||
].map(function(pair){return '<div class="card"><div class="stat">'+pair[1]+'</div><div class="stat-label">'+pair[0]+'</div></div>';}).join('');
|
||||
}
|
||||
|
||||
async function loadDomains(){
|
||||
const list=await api('/domains');if(!list)return;
|
||||
document.querySelector('#domains-table tbody').innerHTML=(list||[]).map(function(d){
|
||||
return '<tr><td>'+esc(d.Domain)+'</td><td style="font-family:monospace;font-size:11px;cursor:pointer" title="Click to copy" onclick="navigator.clipboard.writeText(\''+d.ID+'\')">'+d.ID.slice(0,8)+'...</td><td>'+(d.DKIMSelector||'-')+'</td>'+
|
||||
'<td><button onclick="rotateDkim(\''+d.ID+'\')" class="btn btn-ghost">Rotate DKIM</button> '+
|
||||
'<button onclick="deleteDomain(\''+d.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
async function createDomain(){
|
||||
const domain=document.getElementById('d-domain').value;
|
||||
if(!domain)return;
|
||||
try{
|
||||
const r=await api('/domains',{method:'POST',body:JSON.stringify({Domain:domain})});
|
||||
alert('Domain created. Publish this DNS TXT record:\n\n'+r.dkim_dns_name+'\n\n'+r.dkim_dns_record);
|
||||
document.getElementById('d-domain').value='';
|
||||
loadDomains();
|
||||
}catch(e){alert('Error: '+(e.error||e.message));}
|
||||
}
|
||||
async function rotateDkim(id){
|
||||
try{
|
||||
const r=await api('/domains/'+id+'/dkim-rotate',{method:'POST'});
|
||||
alert('New DKIM key. Update this DNS TXT record:\n\n'+r.dkim_dns_name+'\n\n'+r.dkim_dns_record);
|
||||
}catch(e){alert('Error: '+(e.error||e.message));}
|
||||
}
|
||||
async function deleteDomain(id){
|
||||
if(!confirm('Delete this domain?'))return;
|
||||
await api('/domains/'+id,{method:'DELETE'});
|
||||
loadDomains();
|
||||
}
|
||||
|
||||
async function loadUsers(){
|
||||
const list=await api('/users');if(!list)return;
|
||||
document.querySelector('#users-table tbody').innerHTML=(list||[]).map(function(u){
|
||||
var actionBtn=u.Active?
|
||||
'<button onclick="suspendUser(\''+u.ID+'\')" class="btn btn-ghost">Suspend</button>':
|
||||
'<button onclick="activateUser(\''+u.ID+'\')" class="btn btn-ghost">Activate</button>';
|
||||
return '<tr><td>'+esc(u.Email)+'</td><td>'+u.Role+'</td><td>'+(u.Active?'yes':'no')+'</td>'+
|
||||
'<td>'+actionBtn+' <button onclick="deleteUser(\''+u.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
async function createUser(){
|
||||
const email=document.getElementById('u-email').value,password=document.getElementById('u-password').value,domainId=document.getElementById('u-domain-id').value;
|
||||
if(!email||!password||!domainId)return;
|
||||
try{
|
||||
await api('/users',{method:'POST',body:JSON.stringify({Email:email,Password:password,DomainID:domainId})});
|
||||
document.getElementById('u-email').value='';document.getElementById('u-password').value='';document.getElementById('u-domain-id').value='';
|
||||
loadUsers();
|
||||
}catch(e){alert('Error: '+(e.error||e.message));}
|
||||
}
|
||||
async function suspendUser(id){await api('/users/'+id+'/suspend',{method:'POST'});loadUsers();}
|
||||
async function activateUser(id){await api('/users/'+id+'/activate',{method:'POST'});loadUsers();}
|
||||
async function deleteUser(id){if(!confirm('Delete this user?'))return;await api('/users/'+id,{method:'DELETE'});loadUsers();}
|
||||
|
||||
async function loadRules(){
|
||||
const list=await api('/list-rules');if(!list)return;
|
||||
document.querySelector('#rules-table tbody').innerHTML=(list||[]).map(function(r){
|
||||
return '<tr><td>'+r.ListType+'</td><td>'+r.MatchType+'</td><td>'+esc(r.Value)+'</td>'+
|
||||
'<td><button onclick="deleteRule(\''+r.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
async function createRule(){
|
||||
const listType=document.getElementById('r-type').value,matchType=document.getElementById('r-match').value,value=document.getElementById('r-value').value;
|
||||
if(!value)return;
|
||||
try{
|
||||
await api('/list-rules',{method:'POST',body:JSON.stringify({ListType:listType,MatchType:matchType,Value:value})});
|
||||
document.getElementById('r-value').value='';
|
||||
loadRules();
|
||||
}catch(e){alert('Error: '+(e.error||e.message));}
|
||||
}
|
||||
async function deleteRule(id){await api('/list-rules/'+id,{method:'DELETE'});loadRules();}
|
||||
|
||||
async function loadQueue(){
|
||||
const list=await api('/queue');if(!list)return;
|
||||
document.querySelector('#queue-table tbody').innerHTML=(list||[]).map(function(q){
|
||||
return '<tr><td>'+esc(q.FromAddress)+'</td><td>'+esc(q.ToAddress)+'</td><td>'+q.Attempts+'</td>'+
|
||||
'<td style="color:#f87171">'+esc(q.LastError||'')+'</td>'+
|
||||
'<td><button onclick="retryQueue(\''+q.ID+'\')" class="btn btn-ghost">Retry now</button> '+
|
||||
'<button onclick="cancelQueue(\''+q.ID+'\')" class="btn btn-danger">Cancel</button></td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
async function retryQueue(id){await api('/queue/'+id+'/retry',{method:'POST'});loadQueue();}
|
||||
async function cancelQueue(id){await api('/queue/'+id,{method:'DELETE'});loadQueue();}
|
||||
|
||||
async function loadQuarantine(){
|
||||
const list=await api('/quarantine');if(!list)return;
|
||||
document.querySelector('#quarantine-table tbody').innerHTML=(list&&list.length)?list.map(function(q){
|
||||
return '<tr><td>'+esc(q.Reason||'-')+'</td><td>'+q.CreatedAt+'</td>'+
|
||||
'<td><button onclick="discardQuarantine(\''+q.ID+'\')" class="btn btn-danger">Discard</button></td></tr>';
|
||||
}).join(''):'<tr><td colspan="3" style="text-align:center;color:#475569;padding:20px">Nothing held</td></tr>';
|
||||
}
|
||||
async function discardQuarantine(id){await api('/quarantine/'+id,{method:'DELETE'});loadQuarantine();}
|
||||
|
||||
function esc(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||
|
||||
if(!token){showLogin();}else{showApp();}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user