first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+271
View File
@@ -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"})
}