This commit is contained in:
Christoph Haas 2026-03-21 22:41:23 +01:00
commit f4be03ceba
1826 changed files with 830924 additions and 0 deletions

View file

@ -0,0 +1,160 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/imc-vibe/backend/internal/db"
)
type AliasHandler struct {
db *db.DB
}
func NewAliasHandler(database *db.DB) *AliasHandler {
return &AliasHandler{db: database}
}
type CreateAliasRequest struct {
Source string `json:"source"`
Destination string `json:"destination"`
}
func (h *AliasHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
aliases, err := h.db.GetAliasesByDomain(domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
Success(w, aliases)
}
func (h *AliasHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
var req CreateAliasRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Source == "" || req.Destination == "" {
Error(w, http.StatusBadRequest, "source and destination required")
return
}
alias, err := h.db.CreateAliasInDomain(req.Source, req.Destination, domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to create alias")
return
}
Created(w, alias)
}
func (h *AliasHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
pathParts := strings.Split(r.URL.Path, "/")
var domainName, idStr string
for i, part := range pathParts {
if part == "domains" && i+1 < len(pathParts) {
domainName = pathParts[i+1]
}
if part == "aliases" && i+1 < len(pathParts) {
idStr = pathParts[i+1]
}
}
if domainName == "" || idStr == "" {
Error(w, http.StatusBadRequest, "invalid path")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid alias id")
return
}
if err := h.db.DeleteAlias(uint(id)); err != nil {
Error(w, http.StatusInternalServerError, "failed to delete alias")
return
}
NoContent(w)
}

View file

@ -0,0 +1,269 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/db"
)
const MaxLoginAttempts = 5
type AuthHandler struct {
db *db.DB
jwtManager *auth.JWTManager
}
func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager) *AuthHandler {
return &AuthHandler{
db: database,
jwtManager: jwtManager,
}
}
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type ChangePasswordRequest struct {
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
type ForgotPasswordRequest struct {
Identifier string `json:"identifier"`
}
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Domains []string `json:"domains"`
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Username == "" || req.Password == "" {
Error(w, http.StatusBadRequest, "username and password required")
return
}
ip := getClientIP(r)
if isLockedOut(ip, req.Username, h.db) {
Error(w, http.StatusTooManyRequests, "too many failed attempts, try again later")
return
}
user, err := h.db.GetImcUserByUsername(req.Username)
if err != nil || user == nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
recordFailedAttempt(req.Username, ip, h.db)
Error(w, http.StatusUnauthorized, "invalid credentials")
return
}
clearFailedAttempts(req.Username, ip, h.db)
domains, _ := h.db.GetUserAccessibleDomains(user.ID, user.Role == "admin")
domainNames := make([]string, len(domains))
for i, d := range domains {
domainNames[i] = d.Name
}
token, err := h.jwtManager.GenerateToken(user.ID, user.Username, user.Role, 24*time.Hour)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to generate token")
return
}
Success(w, map[string]interface{}{
"token": token,
"user": UserResponse{
ID: user.ID,
Username: user.Username,
Role: user.Role,
Domains: domainNames,
},
})
}
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "not authenticated")
return
}
user, err := h.db.GetImcUserByID(authCtx.UserID)
if err != nil || user == nil {
Error(w, http.StatusNotFound, "user not found")
return
}
domains, _ := h.db.GetUserAccessibleDomains(user.ID, user.Role == "admin")
domainNames := make([]string, len(domains))
for i, d := range domains {
domainNames[i] = d.Name
}
Success(w, UserResponse{
ID: user.ID,
Username: user.Username,
Role: user.Role,
Domains: domainNames,
})
}
func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var req ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request")
return
}
req.Identifier = strings.TrimSpace(strings.ToLower(req.Identifier))
if req.Identifier == "" {
Error(w, http.StatusBadRequest, "identifier required")
return
}
_, err := h.db.GetImcUserByUsername(req.Identifier)
if err == nil {
Success(w, map[string]string{
"message": "If the account exists, a password reset link will be sent",
})
return
}
Success(w, map[string]string{
"message": "If the account exists, a password reset link will be sent",
})
}
func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
var req ChangePasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request")
return
}
if req.OldPassword == "" || req.NewPassword == "" {
Error(w, http.StatusBadRequest, "old and new password required")
return
}
if len(req.NewPassword) < 8 {
Error(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
user, err := h.db.GetImcUserByID(authCtx.UserID)
if err != nil || user == nil {
Error(w, http.StatusNotFound, "user not found")
return
}
if !auth.CheckPassword(req.OldPassword, user.PasswordHash) {
Error(w, http.StatusUnauthorized, "current password is incorrect")
return
}
newHash, err := auth.HashPassword(req.NewPassword)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to hash password")
return
}
err = h.db.UpdateImcUserPassword(user.ID, newHash)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to update password")
return
}
Success(w, map[string]string{"message": "password updated successfully"})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
Success(w, map[string]string{"message": "logged out"})
}
func isLockedOut(ip, identifier string, database *db.DB) bool {
var count int64
cutoff := time.Now().Add(-15 * time.Minute)
database.Model(&db.ImcLoginAttempt{}).
Where("ip_address = ? AND attempted_at > ? AND successful = false", ip, cutoff).
Count(&count)
if count >= MaxLoginAttempts {
return true
}
database.Model(&db.ImcLoginAttempt{}).
Where("email = ? AND attempted_at > ? AND successful = false", identifier, cutoff).
Count(&count)
return count >= MaxLoginAttempts
}
func recordFailedAttempt(identifier, ip string, database *db.DB) {
database.Create(&db.ImcLoginAttempt{
Email: identifier,
IPAddress: ip,
Successful: false,
})
}
func clearFailedAttempts(identifier, ip string, database *db.DB) {
database.Model(&db.ImcLoginAttempt{}).
Where("email = ? OR ip_address = ?", identifier, ip).
Update("successful", true)
}
func getClientIP(r *http.Request) string {
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
return strings.Split(forwarded, ",")[0]
}
if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 {
return r.RemoteAddr[:idx]
}
return r.RemoteAddr
}

View file

@ -0,0 +1,316 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/imc-vibe/backend/internal/db"
)
type DomainHandler struct {
db *db.DB
}
func NewDomainHandler(database *db.DB) *DomainHandler {
return &DomainHandler{db: database}
}
type CreateDomainRequest struct {
Name string `json:"name"`
}
type DomainPermissions struct {
DomainID uint `json:"domainId"`
DomainName string `json:"domainName"`
UserID uint `json:"userId"`
CanManage bool `json:"canManage"`
}
func (h *DomainHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
isAdmin := authCtx.IsAdmin()
domains, err := h.db.GetUserAccessibleDomains(authCtx.UserID, isAdmin)
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
domainStats := make([]db.DomainStats, len(domains))
for i, d := range domains {
var userCount, aliasCount int64
h.db.Model(&db.User{}).Where("domain_id = ?", d.ID).Count(&userCount)
h.db.Model(&db.Alias{}).Where("domain_id = ?", d.ID).Count(&aliasCount)
domainStats[i] = db.DomainStats{
ID: d.ID,
Name: d.Name,
UserCount: userCount,
AliasCount: aliasCount,
}
}
Success(w, domainStats)
}
func (h *DomainHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
Success(w, domain)
}
func (h *DomainHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
var req CreateDomainRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
existing, err := h.db.GetDomainByName(req.Name)
if err == nil && existing != nil {
Error(w, http.StatusConflict, "domain already exists")
return
}
domain, err := h.db.CreateDomain(req.Name)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to create domain")
return
}
Created(w, domain)
}
func (h *DomainHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
if err := h.db.DeleteDomain(domain.ID); err != nil {
Error(w, http.StatusInternalServerError, "failed to delete domain")
return
}
NoContent(w)
}
func (h *DomainHandler) GetPermissions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
if !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
users, err := h.db.GetUsersForDomain(domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
permissions := make([]DomainPermissions, len(users))
for i, u := range users {
permissions[i] = DomainPermissions{
DomainID: domain.ID,
DomainName: domain.Name,
UserID: u.ID,
CanManage: true,
}
}
Success(w, permissions)
}
func (h *DomainHandler) AddPermission(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
var req struct {
UserID uint `json:"userId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request")
return
}
if err := h.db.AddUserToDomain(req.UserID, domain.ID); err != nil {
Error(w, http.StatusInternalServerError, "failed to add user to domain")
return
}
Success(w, map[string]string{"message": "user added to domain"})
}
func (h *DomainHandler) RemovePermission(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
domainName := extractDomainName(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
userID := extractIDFromPath(r.URL.Path)
if err := h.db.RemoveUserFromDomain(userID, domain.ID); err != nil {
Error(w, http.StatusInternalServerError, "failed to remove user from domain")
return
}
NoContent(w)
}
func extractDomainName(path string) string {
parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/")
if len(parts) >= 2 && parts[1] != "" {
return parts[1]
}
return ""
}
func extractIDFromPath(path string) uint {
parts := strings.Split(path, "/")
for i := len(parts) - 1; i >= 0; i-- {
if idStr := parts[i]; idStr != "" {
var id uint
for _, c := range idStr {
if c >= '0' && c <= '9' {
id = id*10 + uint(c-'0')
} else {
break
}
}
if id > 0 {
return id
}
}
}
return 0
}

View file

@ -0,0 +1,38 @@
package handlers
import (
"net/http"
"strconv"
"github.com/imc-vibe/backend/internal/mail"
)
type LogsHandler struct{}
func NewLogsHandler() *LogsHandler {
return &LogsHandler{}
}
func (h *LogsHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
hours := 1
if h := r.URL.Query().Get("hours"); h != "" {
if n, err := strconv.Atoi(h); err == nil && n > 0 {
hours = n
}
}
filter := r.URL.Query().Get("filter")
entries, err := mail.GetLogs(hours, filter)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to get logs")
return
}
Success(w, entries)
}

View file

@ -0,0 +1,72 @@
package handlers
import (
"context"
"net/http"
"strings"
"github.com/imc-vibe/backend/internal/auth"
)
type contextKey string
const AuthContextKey contextKey = "auth"
func AuthMiddleware(jwtManager *auth.JWTManager, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, `{"error":"authorization header required"}`, http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
http.Error(w, `{"error":"invalid authorization header format"}`, http.StatusUnauthorized)
return
}
claims, err := jwtManager.ValidateToken(parts[1])
if err != nil {
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
return
}
authCtx := &auth.Context{
UserID: claims.UserID,
Username: claims.Username,
Role: auth.Role(claims.Role),
}
ctx := context.WithValue(r.Context(), AuthContextKey, authCtx)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func GetAuthContext(r *http.Request) *auth.Context {
if ctx := r.Context().Value(AuthContextKey); ctx != nil {
if authCtx, ok := ctx.(*auth.Context); ok {
return authCtx
}
}
return nil
}
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
http.Error(w, `{"error":"admin access required"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func extractID(path string) string {
parts := strings.Split(path, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}

View file

@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"github.com/imc-vibe/backend/internal/mail"
)
type QueueHandler struct{}
func NewQueueHandler() *QueueHandler {
return &QueueHandler{}
}
func (h *QueueHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
entries, err := mail.GetQueue()
if err != nil {
Error(w, http.StatusInternalServerError, "failed to get queue")
return
}
Success(w, entries)
}
func (h *QueueHandler) Requeue(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
id := extractID(r.URL.Path)
if err := mail.RequeueMail(id); err != nil {
Error(w, http.StatusInternalServerError, "failed to requeue mail")
return
}
Success(w, map[string]string{"message": "mail requeued"})
}
func (h *QueueHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
id := extractID(r.URL.Path)
if err := mail.DeleteFromQueue(id); err != nil {
Error(w, http.StatusInternalServerError, "failed to delete from queue")
return
}
NoContent(w)
}

View file

@ -0,0 +1,41 @@
package handlers
import (
"encoding/json"
"net/http"
)
type Response struct {
Data interface{} `json:"data"`
Error string `json:"error,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
type Meta struct {
Total int `json:"total,omitempty"`
Page int `json:"page,omitempty"`
}
func JSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(Response{Data: data})
}
func Error(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(Response{Error: message})
}
func Success(w http.ResponseWriter, data interface{}) {
JSON(w, http.StatusOK, data)
}
func Created(w http.ResponseWriter, data interface{}) {
JSON(w, http.StatusCreated, data)
}
func NoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}

View file

@ -0,0 +1,53 @@
package handlers
import (
"net/http"
"github.com/imc-vibe/backend/internal/db"
)
type StatsHandler struct {
db *db.DB
}
func NewStatsHandler(database *db.DB) *StatsHandler {
return &StatsHandler{db: database}
}
type Stats struct {
TotalDomains int `json:"totalDomains"`
TotalUsers int `json:"totalUsers"`
TotalAliases int `json:"totalAliases"`
QueueSize int `json:"queueSize"`
}
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domains, err := h.db.GetAllDomains()
if err != nil {
domains = []db.DomainStats{}
}
users, err := h.db.GetAllUsers()
if err != nil {
users = []db.User{}
}
aliases, err := h.db.GetAllAliases()
if err != nil {
aliases = []db.AliasWithDomain{}
}
stats := Stats{
TotalDomains: len(domains),
TotalUsers: len(users),
TotalAliases: len(aliases),
QueueSize: 0,
}
Success(w, stats)
}

View file

@ -0,0 +1,351 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/db"
)
type UserHandler struct {
db *db.DB
}
func NewUserHandler(database *db.DB) *UserHandler {
return &UserHandler{db: database}
}
type CreateUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Quota int64 `json:"quota"`
}
type UpdateUserRequest struct {
Password string `json:"password,omitempty"`
Quota int64 `json:"quota,omitempty"`
}
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
users, err := h.db.GetUsersByDomain(domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
Success(w, users)
}
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/")
if len(parts) < 4 {
Error(w, http.StatusBadRequest, "invalid path")
return
}
domainName := parts[0]
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
idStr := parts[3]
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
user, err := h.db.GetUserByID(uint(id))
if err != nil {
Error(w, http.StatusNotFound, "user not found")
return
}
Success(w, user)
}
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
Error(w, http.StatusBadRequest, "domain name required")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
domain, err := h.db.GetDomainByName(domainName)
if err != nil {
Error(w, http.StatusNotFound, "domain not found")
return
}
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Email == "" || req.Password == "" {
Error(w, http.StatusBadRequest, "email and password required")
return
}
existing, _ := h.db.GetUserByEmail(req.Email)
if existing != nil {
Error(w, http.StatusConflict, "user already exists")
return
}
passwordHash := "{BLF-CRYPT}" + req.Password
user, err := h.db.CreateUserInDomain(req.Email, passwordHash, req.Quota, domain.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to create user")
return
}
Created(w, user)
}
func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/")
if len(parts) < 4 {
Error(w, http.StatusBadRequest, "invalid path")
return
}
domainName := parts[0]
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
idStr := parts[3]
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
user, err := h.db.GetUserByID(uint(id))
if err != nil {
Error(w, http.StatusNotFound, "user not found")
return
}
var req UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Password != "" {
passwordHash := "{BLF-CRYPT}" + req.Password
if err := h.db.UpdateUserPassword(user.ID, passwordHash); err != nil {
Error(w, http.StatusInternalServerError, "failed to update password")
return
}
}
if req.Quota >= 0 {
if err := h.db.UpdateUserQuota(user.ID, req.Quota); err != nil {
Error(w, http.StatusInternalServerError, "failed to update quota")
return
}
}
Success(w, map[string]string{"message": "user updated"})
}
func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/")
if len(parts) < 4 {
Error(w, http.StatusBadRequest, "invalid path")
return
}
domainName := parts[0]
authCtx := GetAuthContext(r)
if authCtx == nil {
Error(w, http.StatusUnauthorized, "authentication required")
return
}
canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
Error(w, http.StatusForbidden, "access denied")
return
}
idStr := parts[3]
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
if err := h.db.DeleteUser(uint(id)); err != nil {
Error(w, http.StatusInternalServerError, "failed to delete user")
return
}
NoContent(w)
}
func (h *UserHandler) ListAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
Error(w, http.StatusForbidden, "admin access required")
return
}
users, err := h.db.GetAllUsers()
if err != nil {
Error(w, http.StatusInternalServerError, "database error")
return
}
Success(w, users)
}
func extractDomainNameFromPath(path string) string {
parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/")
if len(parts) >= 2 && parts[0] == "domains" && parts[1] != "" {
if idx := strings.Index(parts[1], "/"); idx > 0 {
return parts[1][:idx]
}
return parts[1]
}
return ""
}
func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetAuthContext(r) == nil {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authCtx := GetAuthContext(r)
if authCtx == nil || !authCtx.IsAdmin() {
http.Error(w, `{"error":"admin access required"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func checkDomainAccess(database *db.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authCtx := GetAuthContext(r)
if authCtx == nil {
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
return
}
domainName := extractDomainNameFromPath(r.URL.Path)
if domainName == "" {
next.ServeHTTP(w, r)
return
}
canAccess, _ := database.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin())
if !canAccess {
http.Error(w, `{"error":"access denied"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
var _ = auth.RoleAdmin

View file

@ -0,0 +1,247 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/imc-vibe/backend/internal/api/handlers"
"github.com/imc-vibe/backend/internal/auth"
"github.com/imc-vibe/backend/internal/config"
"github.com/imc-vibe/backend/internal/db"
)
type Router struct {
authHandler *handlers.AuthHandler
domainHandler *handlers.DomainHandler
userHandler *handlers.UserHandler
aliasHandler *handlers.AliasHandler
statsHandler *handlers.StatsHandler
queueHandler *handlers.QueueHandler
logsHandler *handlers.LogsHandler
jwtManager *auth.JWTManager
db *db.DB
}
func New(database *db.DB, cfg *config.Config) *Router {
jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe")
return &Router{
authHandler: handlers.NewAuthHandler(database, jwtManager),
domainHandler: handlers.NewDomainHandler(database),
userHandler: handlers.NewUserHandler(database),
aliasHandler: handlers.NewAliasHandler(database),
statsHandler: handlers.NewStatsHandler(database),
queueHandler: handlers.NewQueueHandler(),
logsHandler: handlers.NewLogsHandler(),
jwtManager: jwtManager,
db: database,
}
}
func (r *Router) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
// Only handle /api/* routes
if !strings.HasPrefix(path, "/api/") {
http.NotFound(w, req)
return
}
// Public routes - no auth required
switch path {
case "/api/auth/login":
r.authHandler.Login(w, req)
return
case "/api/auth/forgot":
r.authHandler.ForgotPassword(w, req)
return
case "/api/auth/logout":
r.authHandler.Logout(w, req)
return
}
// Protected routes - auth required
req = r.validateAuth(req)
if req == nil {
http.Error(w, `{"error":"not authenticated"}`, http.StatusUnauthorized)
return
}
switch {
case path == "/api/auth/me":
r.authHandler.Me(w, req)
case path == "/api/auth/change-password":
r.authHandler.ChangePassword(w, req)
case path == "/api/stats":
r.statsHandler.Get(w, req)
case strings.HasPrefix(path, "/api/domains/"):
if strings.Contains(strings.TrimPrefix(path, "/api/domains/"), "/users") {
r.handleDomainUsers(w, req)
} else if strings.Contains(strings.TrimPrefix(path, "/api/domains/"), "/aliases") {
r.handleDomainAliases(w, req)
} else {
r.handleDomains(w, req)
}
case strings.HasPrefix(path, "/api/domains"):
r.handleDomains(w, req)
case strings.HasPrefix(path, "/api/queue"):
r.handleQueue(w, req)
case path == "/api/logs":
r.logsHandler.List(w, req)
default:
http.NotFound(w, req)
}
})
}
func (r *Router) validateAuth(req *http.Request) *http.Request {
authHeader := req.Header.Get("Authorization")
if authHeader == "" {
return nil
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return nil
}
claims, err := r.jwtManager.ValidateToken(parts[1])
if err != nil {
return nil
}
authCtx := &auth.Context{
UserID: claims.UserID,
Username: claims.Username,
Role: auth.Role(claims.Role),
}
ctx := context.WithValue(req.Context(), handlers.AuthContextKey, authCtx)
return req.WithContext(ctx)
}
func (r *Router) handleDomains(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if len(req.URL.Path) > len("/api/domains/") {
r.domainHandler.Get(w, req)
} else {
r.domainHandler.List(w, req)
}
case http.MethodPost:
r.domainHandler.Create(w, req)
case http.MethodDelete:
r.domainHandler.Delete(w, req)
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (r *Router) handleDomainUsers(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
parts := strings.Split(strings.TrimPrefix(path, "/api/domains/"), "/")
if len(parts) < 2 || parts[1] != "users" {
http.NotFound(w, req)
return
}
domainName := parts[0]
var idStr string
if len(parts) >= 3 {
idStr = parts[2]
}
switch req.Method {
case http.MethodGet:
if idStr != "" {
req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr
r.userHandler.Get(w, req)
} else {
req.URL.Path = "/api/domains/" + domainName + "/users"
r.userHandler.List(w, req)
}
case http.MethodPost:
req.URL.Path = "/api/domains/" + domainName + "/users"
r.userHandler.Create(w, req)
case http.MethodPut:
if idStr != "" {
req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr
r.userHandler.Update(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
case http.MethodDelete:
if idStr != "" {
req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr
r.userHandler.Delete(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (r *Router) handleDomainAliases(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
parts := strings.Split(strings.TrimPrefix(path, "/api/domains/"), "/")
if len(parts) < 2 || parts[1] != "aliases" {
http.NotFound(w, req)
return
}
domainName := parts[0]
var idStr string
if len(parts) >= 3 {
idStr = parts[2]
}
switch req.Method {
case http.MethodGet:
req.URL.Path = "/api/domains/" + domainName + "/aliases"
r.aliasHandler.List(w, req)
case http.MethodPost:
req.URL.Path = "/api/domains/" + domainName + "/aliases"
r.aliasHandler.Create(w, req)
case http.MethodDelete:
if idStr != "" {
req.URL.Path = "/api/domains/" + domainName + "/aliases/" + idStr
r.aliasHandler.Delete(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}
func (r *Router) handleQueue(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
id := ""
if len(path) > len("/api/queue/") {
id = path[len("/api/queue/"):]
}
switch req.Method {
case http.MethodGet:
r.queueHandler.List(w, req)
case http.MethodPost:
if id != "" {
req.URL.Path = "/api/queue/" + id
r.queueHandler.Requeue(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
case http.MethodDelete:
if id != "" {
req.URL.Path = "/api/queue/" + id
r.queueHandler.Delete(w, req)
} else {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
default:
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
}

View file

@ -0,0 +1,85 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrExpiredToken = errors.New("token has expired")
)
type Claims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type JWTManager struct {
secretKey []byte
issuer string
}
func NewJWTManager(secretKey, issuer string) *JWTManager {
return &JWTManager{
secretKey: []byte(secretKey),
issuer: issuer,
}
}
func (j *JWTManager) GenerateToken(userID uint, username, role string, duration time.Duration) (string, error) {
claims := &Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: j.issuer,
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(j.secretKey)
}
func (j *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return j.secretKey, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrExpiredToken
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View file

@ -0,0 +1,18 @@
package auth
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
)
type Context struct {
UserID uint
Username string
Role Role
}
func (c *Context) IsAdmin() bool {
return c.Role == RoleAdmin
}

View file

@ -0,0 +1,56 @@
package config
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
DBHost string
DBPort string
DBUser string
DBPassword string
DBName string
Port string
UseEmbedded bool
JWTSecret string
AdminUser string
AdminPassword string
MailDataDir string
PostqueuePath string
PostsuperPath string
DovecotQuotaCmd string
JournalctlPath string
RspamdAPI string
}
func Load() *Config {
godotenv.Load("backend/.env")
return &Config{
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnv("DB_PORT", "3306"),
DBUser: getEnv("DB_USER", "mailadmin"),
DBPassword: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", "mailserver"),
Port: getEnv("PORT", "8080"),
UseEmbedded: getEnv("USE_EMBEDDED", "true") == "true",
JWTSecret: getEnv("JWT_SECRET", "change-this-secret"),
AdminUser: getEnv("ADMIN_USER", ""),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
MailDataDir: getEnv("MAIL_DATA_DIR", "/var/vmail"),
PostqueuePath: getEnv("POSTQUEUE_PATH", "/usr/sbin/postqueue"),
PostsuperPath: getEnv("POSTSUPER_PATH", "/usr/sbin/postsuper"),
DovecotQuotaCmd: getEnv("DOVECOT_QUOTA_CMD", "/usr/bin/doveadm"),
JournalctlPath: getEnv("JOURNALCTL_PATH", "/usr/bin/journalctl"),
RspamdAPI: getEnv("RSPAMD_API", "http://127.0.0.1:11334"),
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

View file

@ -0,0 +1,70 @@
package db
func (d *DB) GetAllAliases() ([]AliasWithDomain, error) {
var aliases []Alias
if err := d.Preload("Domain").Find(&aliases).Error; err != nil {
return nil, err
}
result := make([]AliasWithDomain, len(aliases))
for i, a := range aliases {
result[i] = AliasWithDomain{
Alias: a,
DomainName: "",
}
if a.Domain != nil {
result[i].DomainName = a.Domain.Name
}
}
if result == nil {
result = []AliasWithDomain{}
}
return result, nil
}
func (d *DB) GetAliasesByDomain(domainID uint) ([]Alias, error) {
var aliases []Alias
if err := d.Where("domain_id = ?", domainID).Find(&aliases).Error; err != nil {
return nil, err
}
if aliases == nil {
aliases = []Alias{}
}
return aliases, nil
}
func (d *DB) GetAliasByID(id uint) (*Alias, error) {
var alias Alias
if err := d.Preload("Domain").First(&alias, id).Error; err != nil {
return nil, err
}
return &alias, nil
}
func (d *DB) CreateAlias(source, destination string) (*Alias, error) {
alias := Alias{
Source: source,
Destination: destination,
}
if err := d.Create(&alias).Error; err != nil {
return nil, err
}
return &alias, nil
}
func (d *DB) CreateAliasInDomain(source, destination string, domainID uint) (*Alias, error) {
alias := Alias{
DomainID: domainID,
Source: source,
Destination: destination,
}
if err := d.Create(&alias).Error; err != nil {
return nil, err
}
return &alias, nil
}
func (d *DB) DeleteAlias(id uint) error {
return d.Delete(&Alias{}, id).Error
}

164
backend/internal/db/db.go Normal file
View file

@ -0,0 +1,164 @@
package db
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/imc-vibe/backend/internal/config"
)
type DB struct {
*gorm.DB
}
type Domain struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex;size:50;not null" json:"name"`
CreatedAt time.Time `json:"createdAt"`
Users []User `gorm:"foreignKey:DomainID" json:"users,omitempty"`
Aliases []Alias `gorm:"foreignKey:DomainID" json:"aliases,omitempty"`
}
func (Domain) TableName() string { return "virtual_domains" }
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
DomainID uint `gorm:"not null" json:"domainId"`
Email string `gorm:"uniqueIndex;size:100;not null" json:"email"`
Password string `gorm:"size:150;not null" json:"-"`
Quota int64 `gorm:"default:0" json:"quota"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
}
func (User) TableName() string { return "virtual_users" }
type Alias struct {
ID uint `gorm:"primaryKey" json:"id"`
DomainID uint `gorm:"not null" json:"domainId"`
Source string `gorm:"size:100;not null" json:"source"`
Destination string `gorm:"size:100;not null" json:"destination"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
}
func (Alias) TableName() string { return "virtual_aliases" }
type ImcUser struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex;size:100;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role string `gorm:"type:enum('admin','user');default:'user'" json:"role"`
CreatedAt time.Time `json:"createdAt"`
Domains []ImcUserDomain `gorm:"foreignKey:UserID" json:"domains,omitempty"`
}
func (ImcUser) TableName() string { return "imc_users" }
type ImcUserDomain struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null" json:"userId"`
DomainID uint `gorm:"not null" json:"domainId"`
CreatedAt time.Time `json:"createdAt"`
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"`
}
func (ImcUserDomain) TableName() string { return "imc_users2domains" }
type ImcLoginAttempt struct {
ID uint `gorm:"primaryKey" json:"id"`
Email string `gorm:"size:100;not null" json:"email"`
IPAddress string `gorm:"size:45;not null" json:"ipAddress"`
AttemptedAt time.Time `json:"attemptedAt"`
Successful bool `gorm:"default:false" json:"successful"`
}
func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" }
type DomainStats struct {
ID uint `json:"id"`
Name string `json:"name"`
UserCount int64 `json:"userCount"`
AliasCount int64 `json:"aliasCount"`
}
type AliasWithDomain struct {
Alias
DomainName string `json:"domainName"`
}
type UserWithDomain struct {
User
DomainName string `json:"domainName"`
}
func Connect(cfg *config.Config) (*DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4",
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(5)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
return &DB{db}, nil
}
func (d *DB) InitSchema() error {
imcUsersSQL := `
CREATE TABLE IF NOT EXISTS imc_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin','user') DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
if err := d.Exec(imcUsersSQL).Error; err != nil {
return err
}
loginAttemptsSQL := `
CREATE TABLE IF NOT EXISTS imc_login_attempts (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(100) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
successful BOOLEAN DEFAULT FALSE,
INDEX idx_email_time (email, attempted_at),
INDEX idx_ip_time (ip_address, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
if err := d.Exec(loginAttemptsSQL).Error; err != nil {
return err
}
users2DomainsSQL := `
CREATE TABLE IF NOT EXISTS imc_users2domains (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
domain_id INT UNSIGNED NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_user_domain (user_id, domain_id),
INDEX idx_user_id (user_id),
INDEX idx_domain_id (domain_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`
return d.Exec(users2DomainsSQL).Error
}

View file

@ -0,0 +1,68 @@
package db
func (d *DB) GetAllDomains() ([]DomainStats, error) {
var domains []Domain
if err := d.Find(&domains).Error; err != nil {
return nil, err
}
stats := make([]DomainStats, len(domains))
for i, dom := range domains {
var userCount, aliasCount int64
d.Model(&User{}).Where("domain_id = ?", dom.ID).Count(&userCount)
d.Model(&Alias{}).Where("domain_id = ?", dom.ID).Count(&aliasCount)
stats[i] = DomainStats{
ID: dom.ID,
Name: dom.Name,
UserCount: userCount,
AliasCount: aliasCount,
}
}
if stats == nil {
stats = []DomainStats{}
}
return stats, nil
}
func (d *DB) GetDomainByID(id uint) (*Domain, error) {
var domain Domain
if err := d.Where("id = ?", id).First(&domain).Error; err != nil {
return nil, err
}
return &domain, nil
}
func (d *DB) GetDomainByName(name string) (*Domain, error) {
var domain Domain
if err := d.Where("name = ?", name).First(&domain).Error; err != nil {
return nil, err
}
return &domain, nil
}
func (d *DB) CreateDomain(name string) (*Domain, error) {
domain := Domain{Name: name}
if err := d.Create(&domain).Error; err != nil {
return nil, err
}
return &domain, nil
}
func (d *DB) DeleteDomain(id uint) error {
tx := d.Begin()
defer func() { tx.Rollback() }()
if err := tx.Where("domain_id = ?", id).Delete(&User{}).Error; err != nil {
return err
}
if err := tx.Where("domain_id = ?", id).Delete(&Alias{}).Error; err != nil {
return err
}
if err := tx.Delete(&Domain{}, id).Error; err != nil {
return err
}
return tx.Commit().Error
}

View file

@ -0,0 +1,122 @@
package db
func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) {
var user ImcUser
if err := d.Preload("Domains.Domain").Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) GetImcUserByID(id uint) (*ImcUser, error) {
var user ImcUser
if err := d.Preload("Domains.Domain").First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) CreateImcUser(username, passwordHash, role string) (*ImcUser, error) {
user := ImcUser{
Username: username,
PasswordHash: passwordHash,
Role: role,
}
if err := d.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) UpdateImcUserPassword(id uint, passwordHash string) error {
return d.Model(&ImcUser{}).Where("id = ?", id).Update("password_hash", passwordHash).Error
}
func (d *DB) DeleteImcUser(id uint) error {
return d.Delete(&ImcUser{}, id).Error
}
func (d *DB) EnsureAdminUser(username, passwordHash string) error {
var user ImcUser
err := d.Where("username = ? AND role = ?", username, "admin").First(&user).Error
if err == nil {
return d.Model(&ImcUser{}).Where("id = ?", user.ID).Update("password_hash", passwordHash).Error
}
_, err = d.CreateImcUser(username, passwordHash, "admin")
return err
}
func (d *DB) GetUserAccessibleDomains(userID uint, isAdmin bool) ([]Domain, error) {
if isAdmin {
var domains []Domain
if err := d.Find(&domains).Error; err != nil {
return nil, err
}
return domains, nil
}
var domains []Domain
err := d.Table("imc_users2domains").
Select("virtual_domains.*").
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id").
Where("imc_users2domains.user_id = ?", userID).
Find(&domains).Error
if err != nil {
return nil, err
}
if domains == nil {
domains = []Domain{}
}
return domains, nil
}
func (d *DB) CanAccessDomain(userID uint, domainName string, isAdmin bool) (bool, error) {
if isAdmin {
return true, nil
}
var count int64
err := d.Table("imc_users2domains").
Select("COUNT(*)").
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id").
Where("imc_users2domains.user_id = ? AND virtual_domains.name = ?", userID, domainName).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (d *DB) AddUserToDomain(userID, domainID uint) error {
ud := ImcUserDomain{
UserID: userID,
DomainID: domainID,
}
return d.Create(&ud).Error
}
func (d *DB) RemoveUserFromDomain(userID, domainID uint) error {
return d.Where("user_id = ? AND domain_id = ?", userID, domainID).Delete(&ImcUserDomain{}).Error
}
func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) {
var users []ImcUser
err := d.Table("imc_users2domains").
Select("imc_users.*").
Joins("JOIN imc_users ON imc_users.id = imc_users2domains.user_id").
Where("imc_users2domains.domain_id = ?", domainID).
Find(&users).Error
if err != nil {
return nil, err
}
if users == nil {
users = []ImcUser{}
}
return users, nil
}
func (d *DB) IsUserInDomain(userID, domainID uint) (bool, error) {
var count int64
err := d.Model(&ImcUserDomain{}).Where("user_id = ? AND domain_id = ?", userID, domainID).Count(&count).Error
return count > 0, err
}

View file

@ -0,0 +1,76 @@
package db
func (d *DB) GetAllUsers() ([]User, error) {
var users []User
if err := d.Preload("Domain").Find(&users).Error; err != nil {
return nil, err
}
if users == nil {
users = []User{}
}
return users, nil
}
func (d *DB) GetUsersByDomain(domainID uint) ([]User, error) {
var users []User
if err := d.Where("domain_id = ?", domainID).Find(&users).Error; err != nil {
return nil, err
}
if users == nil {
users = []User{}
}
return users, nil
}
func (d *DB) GetUserByID(id uint) (*User, error) {
var user User
if err := d.Preload("Domain").First(&user, id).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) GetUserByEmail(email string) (*User, error) {
var user User
if err := d.Where("email = ?", email).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) CreateUser(email, passwordHash string, quota int64) (*User, error) {
user := User{
Email: email,
Password: passwordHash,
Quota: quota,
}
if err := d.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) CreateUserInDomain(email, passwordHash string, quota int64, domainID uint) (*User, error) {
user := User{
DomainID: domainID,
Email: email,
Password: passwordHash,
Quota: quota,
}
if err := d.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (d *DB) UpdateUserPassword(id uint, passwordHash string) error {
return d.Model(&User{}).Where("id = ?", id).Update("password", passwordHash).Error
}
func (d *DB) UpdateUserQuota(id uint, quota int64) error {
return d.Model(&User{}).Where("id = ?", id).Update("quota", quota).Error
}
func (d *DB) DeleteUser(id uint) error {
return d.Delete(&User{}, id).Error
}

View file

@ -0,0 +1,88 @@
package mail
import (
"bufio"
"bytes"
"context"
"os/exec"
"regexp"
"strings"
"time"
)
type LogEntry struct {
Timestamp string `json:"timestamp"`
Priority string `json:"priority"`
Message string `json:"message"`
}
func GetLogs(hours int, filter string) ([]LogEntry, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
args := []string{
"-u", "postfix",
"--since", formatDuration(hours),
"--no-pager",
"-o", "short=false",
}
cmd := exec.CommandContext(ctx, "journalctl", args...)
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
var entries []LogEntry
scanner := bufio.NewScanner(&out)
priorityRegex := regexp.MustCompile(`\[([^\]]+)\]`)
for scanner.Scan() {
line := scanner.Text()
if filter != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(filter)) {
continue
}
parts := strings.SplitN(line, " ", 4)
if len(parts) < 4 {
continue
}
timestamp := parts[0] + " " + parts[1]
matches := priorityRegex.FindStringSubmatch(parts[2])
priority := "info"
if len(matches) > 1 {
priority = matches[1]
} else if strings.Contains(parts[2], "err") {
priority = "err"
} else if strings.Contains(parts[2], "warning") || strings.Contains(parts[2], "warn") {
priority = "warning"
}
message := parts[3]
if filter != "" && !strings.Contains(strings.ToLower(message), strings.ToLower(filter)) {
continue
}
entries = append(entries, LogEntry{
Timestamp: timestamp,
Priority: priority,
Message: message,
})
}
if entries == nil {
entries = []LogEntry{}
}
return entries, nil
}
func formatDuration(hours int) string {
return time.Now().Add(-time.Duration(hours) * time.Hour).Format("15:04:05 2006-01-02")
}

View file

@ -0,0 +1,123 @@
package mail
import (
"bufio"
"bytes"
"encoding/json"
"os/exec"
"regexp"
"strconv"
"strings"
)
type QueueEntry struct {
ID string `json:"id"`
Sender string `json:"sender"`
Recipients []string `json:"recipients"`
Size int `json:"size"`
Time string `json:"time"`
Reason string `json:"reason"`
}
var queueLineRegex = regexp.MustCompile(`^([A-Fa-f0-9]+)\s+(\d+)\s+(\w+)\s+([A-Za-z]+\s+\d+\s+[\d:]+)\s+([^\s]+)\s+(.+)$`)
func GetQueue() ([]QueueEntry, error) {
cmd := exec.Command("postqueue", "-p", "-j")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, err
}
var entries []QueueEntry
scanner := bufio.NewScanner(&out)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
var entry struct {
QueueID string `json:"queueid"`
Sender string `json:"sender"`
Recipients []struct {
Address string `json:"address"`
} `json:"recipients"`
Size int64 `json:"size"`
Arrival string `json:"arrival_timestamp"`
Delay string `json:"delay_reason"`
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue
}
recipients := make([]string, len(entry.Recipients))
for i, r := range entry.Recipients {
recipients[i] = r.Address
}
entries = append(entries, QueueEntry{
ID: entry.QueueID,
Sender: entry.Sender,
Recipients: recipients,
Size: int(entry.Size),
Time: entry.Arrival,
Reason: entry.Delay,
})
}
if entries == nil {
entries = []QueueEntry{}
}
return entries, nil
}
func RequeueMail(id string) error {
cmd := exec.Command("postqueue", "-f", "-v")
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return err
}
return nil
}
func DeleteFromQueue(id string) error {
cmd := exec.Command("postsuper", "-d", id)
return cmd.Run()
}
func GetQueueCount() (int, error) {
cmd := exec.Command("postqueue", "-p")
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return 0, err
}
count := 0
scanner := bufio.NewScanner(&out)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, " mail queue") {
parts := strings.Fields(line)
for i, p := range parts {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
count = n
if i > 0 && parts[i-1] == "in" {
return n, nil
}
}
}
}
}
return count, nil
}