Add comprehensive human-readable comments to all Go files
- Comments explain what each function does and why - Fixed unused strconv import in queue.go - Better documentation for SMTP email service - Explained journalctl log parsing in logs.go - Clarified queue operations in queue.go
This commit is contained in:
parent
3e3620a24f
commit
68285d861a
9 changed files with 728 additions and 203 deletions
|
|
@ -1,33 +1,60 @@
|
|||
// Package main is the entry point for the imc-vibe mail server admin application.
|
||||
// This single binary contains both the Go backend API and the embedded SvelteKit frontend.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"crypto/rand" // cryptographically secure random number generator
|
||||
"flag" // standard library for parsing command-line flags
|
||||
"fmt" // formatted I/O, used here for printing output
|
||||
"log" // logging package
|
||||
"net/http" // HTTP server and client
|
||||
"os" // OS-level operations like reading command-line args
|
||||
"strings" // string manipulation utilities
|
||||
|
||||
"github.com/imc-vibe/backend/internal/api"
|
||||
"github.com/imc-vibe/backend/internal/auth"
|
||||
"github.com/imc-vibe/backend/internal/config"
|
||||
"github.com/imc-vibe/backend/internal/db"
|
||||
"github.com/imc-vibe/backend/internal/api" // HTTP API routing and handlers
|
||||
"github.com/imc-vibe/backend/internal/auth" // JWT authentication
|
||||
"github.com/imc-vibe/backend/internal/config" // configuration loading
|
||||
"github.com/imc-vibe/backend/internal/db" // database connection and operations
|
||||
)
|
||||
|
||||
// main is the entry point of the application.
|
||||
// It handles command-line flags, initializes the database, sets up the HTTP router,
|
||||
// and starts the web server.
|
||||
func main() {
|
||||
// Parse command-line flags.
|
||||
// Flags are optional arguments passed after the program name.
|
||||
// Example: ./imc-vibe --bind=0.0.0.0 --port=8080
|
||||
|
||||
// --reset-admin-password: If set, generate a random password for the admin user and exit.
|
||||
// Useful for initial setup or recovering from a lost password.
|
||||
resetAdminPassword := flag.Bool("reset-admin-password", false, "Reset admin password to a random value and exit")
|
||||
|
||||
// --bind: The IP address the server should listen on.
|
||||
// 0.0.0.0 means listen on all network interfaces (accessible from other machines).
|
||||
// 127.0.0.1 means listen only on localhost (accessible only from this machine).
|
||||
bind := flag.String("bind", "", "IP address to bind to (default: 0.0.0.0)")
|
||||
|
||||
// --port: The TCP port the server should listen on.
|
||||
// Common ports: 80 (HTTP), 443 (HTTPS), 8080 (HTTP alternate).
|
||||
port := flag.String("port", "", "Port to listen on (default: 8080)")
|
||||
|
||||
// Customize the help output to show double-hyphen style (Unix convention).
|
||||
// By default, Go's flag package uses single hyphens.
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [options]\n\nOptions:\n", os.Args[0])
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
// Actually parse the flags from command line arguments.
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration from environment variables.
|
||||
// Environment variables take precedence over code defaults.
|
||||
// See config.Load() for all available environment variables.
|
||||
cfg := config.Load()
|
||||
|
||||
// Override configuration with command-line flags if they were provided.
|
||||
// Empty string means the flag was not set, so we keep the config value.
|
||||
if *bind != "" {
|
||||
cfg.Bind = *bind
|
||||
}
|
||||
|
|
@ -35,93 +62,172 @@ func main() {
|
|||
cfg.Port = *port
|
||||
}
|
||||
|
||||
// Connect to the database (MariaDB/MySQL).
|
||||
// The database stores both ISPmail data (virtual_users, virtual_domains, etc.)
|
||||
// and imc-vibe's own data (imc_users, imc_users2domains, etc.).
|
||||
database, err := db.Connect(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
log.Fatalf("Failed to connect to database: %v", err) // Fatal = print and exit
|
||||
}
|
||||
|
||||
// Create database tables if they don't exist.
|
||||
// This only creates imc-vibe's own tables, not the ISPmail tables.
|
||||
if err := database.InitSchema(); err != nil {
|
||||
log.Printf("Warning: Could not initialize schema: %v", err)
|
||||
log.Printf("Warning: Could not initialize schema: %v", err) // Non-fatal = continue
|
||||
}
|
||||
|
||||
// Handle --reset-admin-password flag.
|
||||
// When this flag is set, we generate a random password, update the database, and exit.
|
||||
// We don't start the web server in this case.
|
||||
if *resetAdminPassword {
|
||||
// Generate a cryptographically secure random password.
|
||||
// The password will be 16 characters long, containing letters, numbers, and symbols.
|
||||
password := generateRandomPassword(16)
|
||||
fmt.Printf("Generated password: %s\n", password)
|
||||
|
||||
// Hash the password before storing it.
|
||||
// We never store plain-text passwords, only their hashes.
|
||||
// This uses bcrypt, which is a slow hashing algorithm designed to resist brute-force attacks.
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
// Store or update the admin user in the database.
|
||||
// EnsureAdminUser creates the user if it doesn't exist, or updates the password if it does.
|
||||
if err := database.EnsureAdminUser("admin", hash); err != nil {
|
||||
log.Fatalf("Failed to reset admin password: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Admin password reset successfully.")
|
||||
return
|
||||
return // Exit the program
|
||||
}
|
||||
|
||||
// Start the web server.
|
||||
// From here on, we set up the HTTP router and handlers.
|
||||
|
||||
// Get the embedded filesystem containing the frontend.
|
||||
// The frontend is compiled into the binary during build time.
|
||||
frontendFS := FrontendFileSystem()
|
||||
|
||||
// Create a new HTTP request multiplexer (router).
|
||||
// The router maps URL paths to handler functions.
|
||||
router := http.NewServeMux()
|
||||
|
||||
// Add health check endpoint.
|
||||
// This is useful for load balancers and monitoring systems.
|
||||
// Returns {"status":"ok"} with HTTP 200 status.
|
||||
router.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
// Set up the API router for all /api/* endpoints.
|
||||
// The API router handles authentication, domain management, user management, etc.
|
||||
apiRouter := api.New(database, cfg)
|
||||
router.Handle("/api/", apiRouter.Handler())
|
||||
router.Handle("/api/", apiRouter.Handler()) // The trailing slash is important!
|
||||
|
||||
// Serve static files from the embedded frontend.
|
||||
// These routes handle the SvelteKit frontend application.
|
||||
|
||||
// Serve the favicon at /favicon.png
|
||||
router.HandleFunc("/favicon.png", serveStaticFile(frontendFS, "embed/favicon.png"))
|
||||
|
||||
// Serve SvelteKit's hashed assets (JavaScript, CSS) at /_app/*
|
||||
// The StripPrefix removes "/_app" from the URL before looking up the file.
|
||||
router.Handle("/_app/", http.StripPrefix("/_app/", serveStaticPrefixed(frontendFS, "embed/_app/")))
|
||||
|
||||
// Serve the main HTML file for all other routes.
|
||||
// This is a Single Page Application (SPA) router - all routes serve index.html,
|
||||
// and JavaScript handles showing the correct page.
|
||||
router.HandleFunc("/", serveSPA(frontendFS))
|
||||
|
||||
// Build the address string for binding: "IP:PORT"
|
||||
// Example: "0.0.0.0:8080" or "127.0.0.1:8080"
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Bind, cfg.Port)
|
||||
|
||||
// Log the startup message.
|
||||
log.Printf("Server starting on http://%s", addr)
|
||||
|
||||
// Start the HTTP server.
|
||||
// ListenAndServe blocks until the server is stopped.
|
||||
// log.Fatal prints the error and exits if the server can't start.
|
||||
log.Fatal(http.ListenAndServe(addr, router))
|
||||
}
|
||||
|
||||
// generateRandomPassword creates a cryptographically secure random password.
|
||||
// length: how many characters the password should have.
|
||||
// Returns: a string containing random characters from a safe charset.
|
||||
func generateRandomPassword(length int) string {
|
||||
// Characters that are safe to use in passwords.
|
||||
// We avoid ambiguous characters like 0/O and 1/l/I.
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
|
||||
// Create a byte slice to hold random bytes.
|
||||
result := make([]byte, length)
|
||||
|
||||
// Use crypto/rand for cryptographically secure randomness.
|
||||
// This is better than math/rand for security-sensitive applications.
|
||||
if _, err := rand.Read(result); err != nil {
|
||||
log.Fatalf("Failed to generate random password: %v", err)
|
||||
}
|
||||
|
||||
// Convert random bytes to characters from our charset.
|
||||
// We use modulo to map bytes to charset indices.
|
||||
// This isn't perfectly uniform distribution, but is good enough for passwords.
|
||||
for i := range result {
|
||||
result[i] = charset[int(result[i])%len(charset)]
|
||||
}
|
||||
return string(result)
|
||||
|
||||
return string(result) // Convert bytes to string
|
||||
}
|
||||
|
||||
// serveStaticPrefixed serves static files from a specific prefix in the embedded filesystem.
|
||||
// fsys: the embedded filesystem containing the files.
|
||||
// prefix: the directory prefix, e.g., "embed/_app/" for SvelteKit assets.
|
||||
// Returns: an HTTP handler function.
|
||||
func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Build the full path by combining prefix and URL path.
|
||||
// Example: prefix="embed/_app/", URL="/_app/chunk.js" -> "embed/_app/chunk.js"
|
||||
path := prefix + r.URL.Path
|
||||
|
||||
// Try to open the file from the embedded filesystem.
|
||||
file, err := fsys.Open(path)
|
||||
if err != nil {
|
||||
// File not found - return 404.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
defer file.Close() // Always close the file, even if there's an error later
|
||||
|
||||
// Get file information (size, modification time, etc.).
|
||||
fi, err := file.Stat()
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't serve directories - return 404.
|
||||
if fi.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the Content-Type header based on file extension.
|
||||
contentType := mimeType(path)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
// Serve the file content.
|
||||
// ServeContent handles Range requests (for video/audio), caching headers, etc.
|
||||
http.ServeContent(w, r, fi.Name(), fi.ModTime(), file)
|
||||
}
|
||||
}
|
||||
|
||||
// serveStaticFile serves a single static file at a fixed path.
|
||||
// fsys: the embedded filesystem.
|
||||
// filename: the exact path to the file, e.g., "embed/favicon.png".
|
||||
// Returns: an HTTP handler function.
|
||||
func serveStaticFile(fsys http.FileSystem, filename string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
file, err := fsys.Open(filename)
|
||||
|
|
@ -143,15 +249,25 @@ func serveStaticFile(fsys http.FileSystem, filename string) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// serveSPA serves the Single Page Application (SvelteKit frontend).
|
||||
// It always serves index.html for any route that isn't an API call.
|
||||
// The JavaScript running in the browser then handles routing to the correct page.
|
||||
// fsys: the embedded filesystem containing the frontend files.
|
||||
// Returns: an HTTP handler function.
|
||||
func serveSPA(fsys http.FileSystem) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
// If this looks like an API call, something is wrong - return 404.
|
||||
// The API router should have handled /api/* routes.
|
||||
// If we get here, the request slipped through somehow.
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// For SPA, always serve index.html regardless of the requested path.
|
||||
// This allows client-side routing (e.g., /domains/example.org).
|
||||
path = "embed/index.html"
|
||||
|
||||
file, err := fsys.Open(path)
|
||||
|
|
@ -162,12 +278,19 @@ func serveSPA(fsys http.FileSystem) http.HandlerFunc {
|
|||
}
|
||||
defer file.Close()
|
||||
fi, _ := file.Stat()
|
||||
|
||||
// Tell the browser this is HTML.
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.ServeContent(w, r, fi.Name(), fi.ModTime(), file)
|
||||
}
|
||||
}
|
||||
|
||||
// mimeType returns the MIME type for a file based on its extension.
|
||||
// MIME types tell the browser what kind of file it is.
|
||||
// This is important for browsers to correctly interpret files.
|
||||
func mimeType(path string) string {
|
||||
// Use switch statement with multiple conditions (Go's patterns work like regex).
|
||||
// Check file extension and return appropriate MIME type.
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".html"):
|
||||
return "text/html; charset=utf-8"
|
||||
|
|
@ -194,6 +317,7 @@ func mimeType(path string) string {
|
|||
case strings.HasSuffix(path, ".eot"):
|
||||
return "application/vnd.ms-fontobject"
|
||||
default:
|
||||
// Fallback for unknown types - treat as plain text.
|
||||
return "text/plain"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,127 @@
|
|||
// Package auth handles authentication and authorization for the application.
|
||||
// It provides JWT token management and password hashing.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
"errors" // standard errors package
|
||||
"time" // time handling
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"github.com/golang-jwt/jwt/v5" // JWT library for token handling
|
||||
"golang.org/x/crypto/bcrypt" // bcrypt for secure password hashing
|
||||
)
|
||||
|
||||
// Custom error types for JWT operations.
|
||||
var (
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrExpiredToken = errors.New("token has expired")
|
||||
ErrInvalidToken = errors.New("invalid token") // Token is malformed or has invalid signature
|
||||
ErrExpiredToken = errors.New("token has expired") // Token has expired
|
||||
)
|
||||
|
||||
// Claims represents the data stored in a JWT token.
|
||||
// JWT tokens are signed payloads that can be verified but not tampered with.
|
||||
type Claims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
UserID uint `json:"userId"` // ID of the logged-in user
|
||||
Username string `json:"username"` // Username for display
|
||||
Role string `json:"role"` // User's role ("admin" or "user")
|
||||
jwt.RegisteredClaims // Standard JWT claims (expiration, issuer, etc.)
|
||||
}
|
||||
|
||||
// JWTManager handles creating and validating JWT tokens.
|
||||
type JWTManager struct {
|
||||
secretKey []byte
|
||||
issuer string
|
||||
secretKey []byte // Secret key for signing/verifying tokens (keep this secret!)
|
||||
issuer string // Token issuer (used to verify token source)
|
||||
}
|
||||
|
||||
// NewJWTManager creates a new JWT manager.
|
||||
// secretKey: the secret key for signing tokens. Should be long and random.
|
||||
// issuer: the token issuer string (usually the app name).
|
||||
func NewJWTManager(secretKey, issuer string) *JWTManager {
|
||||
return &JWTManager{
|
||||
secretKey: []byte(secretKey),
|
||||
secretKey: []byte(secretKey), // Convert string to bytes for signing
|
||||
issuer: issuer,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken creates a new JWT token for a user.
|
||||
// userID: the user's ID to include in the token.
|
||||
// username: the username to include for display.
|
||||
// role: the user's role ("admin" or "user").
|
||||
// duration: how long the token should be valid.
|
||||
// Returns: the signed token string and any error.
|
||||
func (j *JWTManager) GenerateToken(userID uint, username, role string, duration time.Duration) (string, error) {
|
||||
// Create the claims (payload) for the token.
|
||||
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,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)), // Token expires in "duration"
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()), // When the token was created
|
||||
Issuer: j.issuer, // Who issued the token
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new token with HMAC-SHA256 signing.
|
||||
// HMAC is a symmetric algorithm - same key for signing and verifying.
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign the token with our secret key and return the string representation.
|
||||
return token.SignedString(j.secretKey)
|
||||
}
|
||||
|
||||
// ValidateToken checks if a token is valid and returns its claims.
|
||||
// tokenString: the JWT token string to validate.
|
||||
// Returns: the claims if valid, or an error if invalid.
|
||||
func (j *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
|
||||
// Parse and validate the token.
|
||||
// The callback function is called to get the signing key.
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method is HMAC (not RSA, ECDSA, etc.).
|
||||
// This prevents algorithm confusion attacks.
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return j.secretKey, nil
|
||||
return j.secretKey, nil // Return our secret key for verification
|
||||
})
|
||||
|
||||
// Handle parsing errors.
|
||||
if err != nil {
|
||||
// Check specifically for expiration.
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
// Extract and validate claims.
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
if !ok || !token.Valid { // token.Valid is false if signature verification failed
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// HashPassword creates a bcrypt hash of a password.
|
||||
// password: the plain-text password to hash.
|
||||
// Returns: the bcrypt hash and any error.
|
||||
// Uses bcrypt's default cost factor (currently 10).
|
||||
func HashPassword(password string) (string, error) {
|
||||
// GenerateFromPassword creates a bcrypt hash.
|
||||
// bcrypt is designed to be slow to resist brute-force attacks.
|
||||
// DefaultCost is a good balance between security and performance.
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hash), nil
|
||||
return string(hash), nil // Convert bytes to string
|
||||
}
|
||||
|
||||
// CheckPassword verifies a password against a bcrypt hash.
|
||||
// password: the plain-text password to check.
|
||||
// hash: the bcrypt hash to verify against.
|
||||
// Returns: true if the password matches, false otherwise.
|
||||
func CheckPassword(password, hash string) bool {
|
||||
// CompareHashAndPassword returns nil if they match, error otherwise.
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
package auth
|
||||
|
||||
// Role represents a user's role in the system.
|
||||
type Role string
|
||||
|
||||
// Predefined roles.
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
RoleAdmin Role = "admin" // Full access to all features
|
||||
RoleUser Role = "user" // Limited access (not currently used)
|
||||
)
|
||||
|
||||
// Context holds the authenticated user's information.
|
||||
// This is stored in the request context during authentication.
|
||||
type Context struct {
|
||||
UserID uint
|
||||
Username string
|
||||
Role Role
|
||||
UserID uint // The user's database ID
|
||||
Username string // The user's username for display
|
||||
Role Role // The user's role (admin or user)
|
||||
}
|
||||
|
||||
// IsAdmin checks if the user has admin privileges.
|
||||
func (c *Context) IsAdmin() bool {
|
||||
return c.Role == RoleAdmin
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,68 +1,125 @@
|
|||
// Package config handles loading configuration from environment variables.
|
||||
// Configuration is loaded from .env files (for development) and environment variables (for production).
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os" // Standard library for reading environment variables
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/joho/godotenv" // Third-party library for loading .env files
|
||||
)
|
||||
|
||||
// Config holds all configuration options for the application.
|
||||
// Each field corresponds to either an environment variable or a default value.
|
||||
type Config struct {
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
Bind string
|
||||
Port string
|
||||
JWTSecret string
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
MailDataDir string
|
||||
PostqueuePath string
|
||||
PostsuperPath string
|
||||
DovecotQuotaCmd string
|
||||
JournalctlPath string
|
||||
RspamdAPI string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
BaseURL string
|
||||
// Database connection settings
|
||||
DBHost string // Database server hostname or IP address
|
||||
DBPort string // Database server port (usually 3306 for MySQL/MariaDB)
|
||||
DBUser string // Database username
|
||||
DBPassword string // Database password (keep this secret!)
|
||||
DBName string // Database name (e.g., "mailserver")
|
||||
|
||||
// Network settings for the web server
|
||||
Bind string // IP address to bind to (0.0.0.0 = all interfaces, 127.0.0.1 = localhost only)
|
||||
Port string // TCP port to listen on
|
||||
|
||||
// Security settings
|
||||
JWTSecret string // Secret key for signing JWT tokens (keep this secret!)
|
||||
|
||||
// Admin user configuration (used when --reset-admin-password is not used)
|
||||
AdminUser string // Username for the admin account
|
||||
AdminPassword string // Password for the admin account (only used during startup)
|
||||
|
||||
// Mail server paths and settings
|
||||
MailDataDir string // Directory where mail is stored (e.g., /var/vmail)
|
||||
PostqueuePath string // Path to postqueue command (for mail queue operations)
|
||||
PostsuperPath string // Path to postsuper command (for mail queue operations)
|
||||
DovecotQuotaCmd string // Path to doveadm command (for quota operations)
|
||||
|
||||
// System integration
|
||||
JournalctlPath string // Path to journalctl command (for reading system logs)
|
||||
|
||||
// Rspamd settings (spam filter)
|
||||
RspamdAPI string // URL of the Rspamd web interface
|
||||
|
||||
// SMTP settings (for sending password reset emails)
|
||||
SMTPHost string // SMTP server hostname
|
||||
SMTPPort string // SMTP server port (587 for submission, 465 for SMTPS)
|
||||
SMTPUser string // SMTP username
|
||||
SMTPPassword string // SMTP password
|
||||
SMTPFrom string // From address in outgoing emails
|
||||
|
||||
// Application settings
|
||||
BaseURL string // Base URL of this application (used for generating links in emails)
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and .env files.
|
||||
// It returns a Config struct with all settings.
|
||||
//
|
||||
// Environment variables take precedence over .env file values.
|
||||
// .env file values take precedence over code defaults.
|
||||
//
|
||||
// The function looks for a .env file in the current directory.
|
||||
func Load() *Config {
|
||||
// Load .env file if it exists.
|
||||
// This is for local development convenience.
|
||||
// In production, use environment variables directly.
|
||||
godotenv.Load("backend/.env")
|
||||
|
||||
// Return a new Config struct with all values.
|
||||
// getEnv(key, default) returns the environment variable value,
|
||||
// or the default if the environment variable is not set.
|
||||
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"),
|
||||
Bind: getEnv("BIND", "0.0.0.0"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-this-secret-in-production"),
|
||||
AdminUser: getEnv("ADMIN_USER", ""),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||
// Database settings
|
||||
// Default: localhost:3306 with mailadmin user
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "mailadmin"),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""), // No default - must be set!
|
||||
DBName: getEnv("DB_NAME", "mailserver"),
|
||||
|
||||
// Server settings
|
||||
// Default: bind to all interfaces on port 8080
|
||||
Bind: getEnv("BIND", "0.0.0.0"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
|
||||
// Security
|
||||
// Default JWT secret is insecure - must be changed in production!
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-this-secret-in-production"),
|
||||
|
||||
// Admin user (optional - used during startup if set)
|
||||
AdminUser: getEnv("ADMIN_USER", ""),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||
|
||||
// Mail server paths
|
||||
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"),
|
||||
SMTPHost: getEnv("SMTP_HOST", "localhost"),
|
||||
SMTPPort: getEnv("SMTP_PORT", "587"),
|
||||
SMTPUser: getEnv("SMTP_USER", ""),
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
|
||||
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
|
||||
|
||||
// External services
|
||||
RspamdAPI: getEnv("RSPAMD_API", "http://127.0.0.1:11334"),
|
||||
|
||||
// SMTP settings (for password reset emails)
|
||||
SMTPHost: getEnv("SMTP_HOST", "localhost"),
|
||||
SMTPPort: getEnv("SMTP_PORT", "587"),
|
||||
SMTPUser: getEnv("SMTP_USER", ""),
|
||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||
SMTPFrom: getEnv("SMTP_FROM", "noreply@localhost"),
|
||||
|
||||
// Application URL (for generating links in emails)
|
||||
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv reads an environment variable or returns a default value.
|
||||
// key: the name of the environment variable.
|
||||
// defaultValue: the value to return if the environment variable is not set.
|
||||
func getEnv(key, defaultValue string) string {
|
||||
// Check if the environment variable is set and not empty.
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
// Return the default value.
|
||||
return defaultValue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,115 +1,174 @@
|
|||
// Package db provides database access for the imc-vibe application.
|
||||
// It uses GORM (Go ORM) for database operations.
|
||||
//
|
||||
// The database contains two sets of tables:
|
||||
// 1. ISPmail tables (virtual_domains, virtual_users, virtual_aliases) - existing mail data
|
||||
// 2. imc tables (imc_users, imc_users2domains, etc.) - application-specific data
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"fmt" // formatted error messages
|
||||
"time" // time package for timestamps
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/driver/mysql" // GORM MySQL driver
|
||||
"gorm.io/gorm" // GORM ORM library
|
||||
"gorm.io/gorm/logger" // GORM logger configuration
|
||||
|
||||
"github.com/imc-vibe/backend/internal/config"
|
||||
"github.com/imc-vibe/backend/internal/config" // configuration
|
||||
)
|
||||
|
||||
// DB wraps GORM's database connection with helper methods.
|
||||
// This is the main database access point for the application.
|
||||
type DB struct {
|
||||
*gorm.DB
|
||||
*gorm.DB // Embeds GORM's DB type, giving us all GORM methods
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ISPmail Models (existing mail server tables)
|
||||
// These tables are shared with the ISPmail system and must not be modified.
|
||||
// =============================================================================
|
||||
|
||||
// Domain represents a mail domain (e.g., "example.org").
|
||||
// This corresponds to the existing ISPmail virtual_domains table.
|
||||
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"`
|
||||
ID uint `gorm:"primaryKey" json:"id"` // Primary key, auto-increment
|
||||
Name string `gorm:"uniqueIndex;size:50;not null" json:"name"` // Domain name, must be unique
|
||||
CreatedAt time.Time `json:"createdAt"` // When the domain was added
|
||||
Users []User `gorm:"foreignKey:DomainID" json:"users,omitempty"` // Mail users in this domain
|
||||
Aliases []Alias `gorm:"foreignKey:DomainID" json:"aliases,omitempty"` // Aliases in this domain
|
||||
}
|
||||
|
||||
// TableName tells GORM to use the existing ISPmail table name.
|
||||
func (Domain) TableName() string { return "virtual_domains" }
|
||||
|
||||
// User represents a mail user (e.g., "user@example.org").
|
||||
// This corresponds to the existing ISPmail virtual_users table.
|
||||
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"`
|
||||
ID uint `gorm:"primaryKey" json:"id"` // Primary key
|
||||
DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to Domain
|
||||
Email string `gorm:"uniqueIndex;size:100;not null" json:"email"` // Full email address, must be unique
|
||||
Password string `gorm:"size:150;not null" json:"-"` // Mailbox password (bcrypt hash, - means exclude from JSON)
|
||||
Quota int64 `gorm:"default:0" json:"quota"` // Mailbox size limit in bytes (0 = default)
|
||||
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"` // When the user was created
|
||||
}
|
||||
|
||||
// TableName tells GORM to use the existing ISPmail table name.
|
||||
func (User) TableName() string { return "virtual_users" }
|
||||
|
||||
// Alias represents an email alias/forwarding rule.
|
||||
// Maps one email address (source) to another (destination).
|
||||
// This corresponds to the existing ISPmail virtual_aliases table.
|
||||
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"`
|
||||
ID uint `gorm:"primaryKey" json:"id"` // Primary key
|
||||
DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to Domain
|
||||
Source string `gorm:"size:100;not null" json:"source"` // Original email address (alias)
|
||||
Destination string `gorm:"size:100;not null" json:"destination"` // Forward-to email address
|
||||
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain
|
||||
}
|
||||
|
||||
// TableName tells GORM to use the existing ISPmail table name.
|
||||
func (Alias) TableName() string { return "virtual_aliases" }
|
||||
|
||||
// =============================================================================
|
||||
// imc-vibe Application Models (tables created by this app)
|
||||
// =============================================================================
|
||||
|
||||
// ImcUser represents an admin user who can log into this application.
|
||||
// This is separate from mail users - it's for the web admin interface.
|
||||
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"`
|
||||
ID uint `gorm:"primaryKey" json:"id"` // Primary key
|
||||
Username string `gorm:"uniqueIndex;size:100;not null" json:"username"` // Login username
|
||||
PasswordHash string `gorm:"size:255;not null" json:"-"` // Bcrypt hash of password (- means exclude from JSON)
|
||||
Role string `gorm:"type:enum('admin','user');default:'user'" json:"role"` // Role: admin or regular user
|
||||
CreatedAt time.Time `json:"createdAt"` // When account was created
|
||||
Domains []ImcUserDomain `gorm:"foreignKey:UserID" json:"domains,omitempty"` // Domains this user can access
|
||||
}
|
||||
|
||||
// TableName specifies the database table name for ImcUser.
|
||||
func (ImcUser) TableName() string { return "imc_users" }
|
||||
|
||||
// ImcUserDomain represents the many-to-many relationship between admin users and domains.
|
||||
// An admin user can have access to multiple domains, and a domain can be accessed by multiple 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"`
|
||||
ID uint `gorm:"primaryKey" json:"id"` // Primary key
|
||||
UserID uint `gorm:"not null" json:"userId"` // Foreign key to ImcUser
|
||||
DomainID uint `gorm:"not null" json:"domainId"` // Foreign key to Domain
|
||||
CreatedAt time.Time `json:"createdAt"` // When access was granted
|
||||
Domain *Domain `gorm:"foreignKey:DomainID" json:"domain,omitempty"` // Associated domain (for preloading)
|
||||
}
|
||||
|
||||
// TableName specifies the database table name for ImcUserDomain.
|
||||
func (ImcUserDomain) TableName() string { return "imc_users2domains" }
|
||||
|
||||
// ImcLoginAttempt records login attempts for brute-force protection.
|
||||
// Used to track failed login attempts and implement rate limiting.
|
||||
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"`
|
||||
ID uint `gorm:"primaryKey" json:"id"` // Primary key
|
||||
Email string `gorm:"size:100;not null" json:"email"` // Email/username that was used
|
||||
IPAddress string `gorm:"size:45;not null" json:"ipAddress"` // IP address of the requester (IPv6 compatible)
|
||||
AttemptedAt time.Time `json:"attemptedAt"` // When the attempt occurred
|
||||
Successful bool `gorm:"default:false" json:"successful"` // Whether the login succeeded
|
||||
}
|
||||
|
||||
// TableName specifies the database table name for ImcLoginAttempt.
|
||||
func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" }
|
||||
|
||||
// PasswordResetToken stores one-time tokens for password reset functionality.
|
||||
type PasswordResetToken struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null" json:"userId"`
|
||||
Token string `gorm:"size:64;not null;uniqueIndex" json:"token"`
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expiresAt"`
|
||||
Used bool `gorm:"default:false" json:"used"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"` // Primary key
|
||||
UserID uint `gorm:"not null" json:"userId"` // Foreign key to ImcUser
|
||||
Token string `gorm:"size:64;not null;uniqueIndex" json:"token"` // The reset token (unique index for fast lookup)
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expiresAt"` // When this token expires
|
||||
Used bool `gorm:"default:false" json:"used"` // Whether this token has been used
|
||||
CreatedAt time.Time `json:"createdAt"` // When the token was created
|
||||
}
|
||||
|
||||
// TableName specifies the database table name for PasswordResetToken.
|
||||
func (PasswordResetToken) TableName() string { return "imc_password_reset_tokens" }
|
||||
|
||||
// =============================================================================
|
||||
// Helper/View Models (not stored in database)
|
||||
// =============================================================================
|
||||
|
||||
// DomainStats holds statistics about a domain (user count, alias count).
|
||||
// Used for displaying dashboard information.
|
||||
type DomainStats struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UserCount int64 `json:"userCount"`
|
||||
AliasCount int64 `json:"aliasCount"`
|
||||
ID uint `json:"id"` // Domain ID
|
||||
Name string `json:"name"` // Domain name
|
||||
UserCount int64 `json:"userCount"` // Number of mail users
|
||||
AliasCount int64 `json:"aliasCount"` // Number of aliases
|
||||
}
|
||||
|
||||
// AliasWithDomain combines an alias with its domain name for API responses.
|
||||
type AliasWithDomain struct {
|
||||
Alias
|
||||
DomainName string `json:"domainName"`
|
||||
Alias // Embed Alias struct
|
||||
DomainName string `json:"domainName"` // Denormalized domain name for convenience
|
||||
}
|
||||
|
||||
// UserWithDomain combines a user with their domain name for API responses.
|
||||
type UserWithDomain struct {
|
||||
User
|
||||
DomainName string `json:"domainName"`
|
||||
User // Embed User struct
|
||||
DomainName string `json:"domainName"` // Denormalized domain name for convenience
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Database Connection
|
||||
// =============================================================================
|
||||
|
||||
// Connect establishes a connection to the MySQL/MariaDB database.
|
||||
// cfg: configuration containing database credentials and connection settings.
|
||||
// Returns: a DB wrapper around the GORM connection, or an error if connection fails.
|
||||
func Connect(cfg *config.Config) (*DB, error) {
|
||||
// Build the Data Source Name (DSN) string.
|
||||
// Format: username:password@protocol(address:port)/dbname?options
|
||||
// parseTime=true: automatically convert time.Time to/from SQL DATETIME
|
||||
// charset=utf8mb4: use full UTF-8 encoding (supports emojis, etc.)
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4",
|
||||
cfg.DBUser, cfg.DBPassword, cfg.DBHost, cfg.DBPort, cfg.DBName)
|
||||
|
||||
// Open a connection to the database using GORM.
|
||||
// We configure GORM to be silent (no auto-logging) to reduce noise.
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
|
|
@ -117,19 +176,40 @@ func Connect(cfg *config.Config) (*DB, error) {
|
|||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Get the underlying sql.DB object from GORM.
|
||||
// GORM wraps the standard database/sql package.
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Configure connection pool settings for performance.
|
||||
// These settings help balance between connection reuse and resource usage.
|
||||
|
||||
// SetMaxOpenConns: maximum number of open connections to the database.
|
||||
// Too few = slow requests, too many = database overload.
|
||||
// 25 is a reasonable default for most applications.
|
||||
sqlDB.SetMaxOpenConns(25)
|
||||
|
||||
// SetMaxIdleConns: maximum number of idle connections in the pool.
|
||||
// Idle connections are kept open even when not in use.
|
||||
// This avoids the overhead of opening new connections.
|
||||
sqlDB.SetMaxIdleConns(5)
|
||||
|
||||
// SetConnMaxLifetime: maximum time a connection can be reused.
|
||||
// Connections older than this are closed and replaced.
|
||||
// This helps avoid issues with stale connections.
|
||||
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
return &DB{db}, nil
|
||||
}
|
||||
|
||||
// InitSchema creates imc-vibe's database tables if they don't exist.
|
||||
// This only creates the imc_* tables, not the ISPmail virtual_* tables.
|
||||
// Called during application startup.
|
||||
func (d *DB) InitSchema() error {
|
||||
// Create imc_users table for admin accounts.
|
||||
// This table stores admin users who can log into the web interface.
|
||||
imcUsersSQL := `
|
||||
CREATE TABLE IF NOT EXISTS imc_users (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
|
|
@ -145,6 +225,8 @@ func (d *DB) InitSchema() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Create imc_login_attempts table for tracking login attempts.
|
||||
// Used for brute-force protection (rate limiting).
|
||||
loginAttemptsSQL := `
|
||||
CREATE TABLE IF NOT EXISTS imc_login_attempts (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
|
|
@ -160,6 +242,8 @@ func (d *DB) InitSchema() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Create imc_users2domains table for user-domain permissions.
|
||||
// This implements many-to-many: a user can access multiple domains.
|
||||
users2DomainsSQL := `
|
||||
CREATE TABLE IF NOT EXISTS imc_users2domains (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
|
|
@ -175,6 +259,8 @@ func (d *DB) InitSchema() error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Create imc_password_reset_tokens table for password reset functionality.
|
||||
// Stores temporary tokens for resetting forgotten passwords.
|
||||
resetTokensSQL := `
|
||||
CREATE TABLE IF NOT EXISTS imc_password_reset_tokens (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -2,53 +2,101 @@ package db
|
|||
|
||||
import "time"
|
||||
|
||||
// =============================================================================
|
||||
// Admin User (ImcUser) Operations
|
||||
// These functions manage admin users who can log into the web interface.
|
||||
// =============================================================================
|
||||
|
||||
// GetImcUserByUsername looks up an admin user by their username.
|
||||
// Returns the user with their associated domain permissions preloaded.
|
||||
// username: the username to search for.
|
||||
// Returns: the user and any error.
|
||||
func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) {
|
||||
var user ImcUser
|
||||
// Preload loads related data (Domains) to avoid N+1 queries.
|
||||
// Where creates a SQL WHERE clause, ? is a placeholder for the username.
|
||||
if err := d.Preload("Domains.Domain").Where("username = ?", username).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
return nil, err // Return nil user and the error
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetImcUserByID looks up an admin user by their ID.
|
||||
// Returns the user with their associated domain permissions preloaded.
|
||||
// id: the user's primary key ID.
|
||||
// Returns: the user and any error.
|
||||
func (d *DB) GetImcUserByID(id uint) (*ImcUser, error) {
|
||||
var user ImcUser
|
||||
// First(&user, id) looks up by primary key.
|
||||
if err := d.Preload("Domains.Domain").First(&user, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// CreateImcUser creates a new admin user account.
|
||||
// username: the login username (must be unique).
|
||||
// passwordHash: the bcrypt hash of the password (NOT the plain password).
|
||||
// role: "admin" or "user".
|
||||
// Returns: the created user and any error.
|
||||
func (d *DB) CreateImcUser(username, passwordHash, role string) (*ImcUser, error) {
|
||||
user := ImcUser{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
Role: role,
|
||||
}
|
||||
// Create inserts the record into the database.
|
||||
if err := d.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateImcUserPassword updates the password hash for a user.
|
||||
// id: the user's ID.
|
||||
// passwordHash: the new bcrypt hash of the password.
|
||||
func (d *DB) UpdateImcUserPassword(id uint, passwordHash string) error {
|
||||
// Model specifies which table/struct to update.
|
||||
// Where filters which rows to update.
|
||||
// Update only changes the specified fields.
|
||||
return d.Model(&ImcUser{}).Where("id = ?", id).Update("password_hash", passwordHash).Error
|
||||
}
|
||||
|
||||
// DeleteImcUser removes an admin user from the database.
|
||||
// id: the user's ID to delete.
|
||||
func (d *DB) DeleteImcUser(id uint) error {
|
||||
return d.Delete(&ImcUser{}, id).Error
|
||||
return d.Delete(&ImcUser{}, id).Error // Delete by primary key
|
||||
}
|
||||
|
||||
// EnsureAdminUser creates the admin user if it doesn't exist, or updates the password if it does.
|
||||
// This is used during --reset-admin-password and startup.
|
||||
// username: always "admin" in our case.
|
||||
// passwordHash: the bcrypt hash of the password.
|
||||
func (d *DB) EnsureAdminUser(username, passwordHash string) error {
|
||||
var user ImcUser
|
||||
// Try to find an existing admin user with this username.
|
||||
err := d.Where("username = ? AND role = ?", username, "admin").First(&user).Error
|
||||
if err == nil {
|
||||
// User exists - update their password.
|
||||
return d.Model(&ImcUser{}).Where("id = ?", user.ID).Update("password_hash", passwordHash).Error
|
||||
}
|
||||
// User doesn't exist - create them.
|
||||
_, err = d.CreateImcUser(username, passwordHash, "admin")
|
||||
return err
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Domain Access Control
|
||||
// These functions manage which domains users can access.
|
||||
// =============================================================================
|
||||
|
||||
// GetUserAccessibleDomains returns all domains a user can access.
|
||||
// Admin users can access all domains; regular users only their assigned domains.
|
||||
// userID: the ID of the user.
|
||||
// isAdmin: whether the user has admin privileges.
|
||||
// Returns: a list of accessible domains.
|
||||
func (d *DB) GetUserAccessibleDomains(userID uint, isAdmin bool) ([]Domain, error) {
|
||||
// Admins get access to all domains.
|
||||
if isAdmin {
|
||||
var domains []Domain
|
||||
if err := d.Find(&domains).Error; err != nil {
|
||||
|
|
@ -57,38 +105,51 @@ func (d *DB) GetUserAccessibleDomains(userID uint, isAdmin bool) ([]Domain, erro
|
|||
return domains, nil
|
||||
}
|
||||
|
||||
// Regular users query the imc_users2domains table.
|
||||
// We use raw SQL with Joins because GORM's association methods
|
||||
// don't handle our cross-database queries well.
|
||||
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
|
||||
err := d.Table("imc_users2domains"). // Query from this table
|
||||
Select("virtual_domains.*"). // Select all columns from domains
|
||||
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id"). // Join to get domain details
|
||||
Where("imc_users2domains.user_id = ?", userID). // Only this user's domains
|
||||
Find(&domains).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if domains == nil {
|
||||
domains = []Domain{}
|
||||
domains = []Domain{} // Return empty slice, not nil
|
||||
}
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
// CanAccessDomain checks if a user can access a specific domain.
|
||||
// userID: the ID of the user.
|
||||
// domainName: the name of the domain (e.g., "example.org").
|
||||
// isAdmin: whether the user has admin privileges.
|
||||
// Returns: true if the user can access the domain, false otherwise.
|
||||
func (d *DB) CanAccessDomain(userID uint, domainName string, isAdmin bool) (bool, error) {
|
||||
// Admins can access all domains.
|
||||
if isAdmin {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Count matching records - if count > 0, access is granted.
|
||||
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).
|
||||
Select("COUNT(*)"). // Count matching rows
|
||||
Joins("JOIN virtual_domains ON virtual_domains.id = imc_users2domains.domain_id"). // Join to domain table
|
||||
Where("imc_users2domains.user_id = ? AND virtual_domains.name = ?", userID, domainName). // Match user and domain name
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
return count > 0, nil // True if at least one match found
|
||||
}
|
||||
|
||||
// AddUserToDomain grants a user access to a domain.
|
||||
// userID: the ID of the user.
|
||||
// domainID: the ID of the domain.
|
||||
func (d *DB) AddUserToDomain(userID, domainID uint) error {
|
||||
ud := ImcUserDomain{
|
||||
UserID: userID,
|
||||
|
|
@ -97,15 +158,22 @@ func (d *DB) AddUserToDomain(userID, domainID uint) error {
|
|||
return d.Create(&ud).Error
|
||||
}
|
||||
|
||||
// RemoveUserFromDomain revokes a user's access to a domain.
|
||||
// userID: the ID of the user.
|
||||
// domainID: the ID of the domain.
|
||||
func (d *DB) RemoveUserFromDomain(userID, domainID uint) error {
|
||||
// Delete records matching both user_id and domain_id.
|
||||
return d.Where("user_id = ? AND domain_id = ?", userID, domainID).Delete(&ImcUserDomain{}).Error
|
||||
}
|
||||
|
||||
// GetUsersForDomain returns all admin users who can access a specific domain.
|
||||
// domainID: the ID of the domain.
|
||||
// Returns: a list of users who can access this domain.
|
||||
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").
|
||||
Select("imc_users.*"). // Select all columns from imc_users
|
||||
Joins("JOIN imc_users ON imc_users.id = imc_users2domains.user_id"). // Join to users table
|
||||
Where("imc_users2domains.domain_id = ?", domainID).
|
||||
Find(&users).Error
|
||||
if err != nil {
|
||||
|
|
@ -117,12 +185,25 @@ func (d *DB) GetUsersForDomain(domainID uint) ([]ImcUser, error) {
|
|||
return users, nil
|
||||
}
|
||||
|
||||
// IsUserInDomain checks if a user already has access to a domain.
|
||||
// userID: the ID of the user.
|
||||
// domainID: the ID of the domain.
|
||||
// Returns: true if the user already has access, false otherwise.
|
||||
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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Password Reset Tokens
|
||||
// These functions manage one-time tokens for password reset.
|
||||
// =============================================================================
|
||||
|
||||
// CreatePasswordResetToken creates a new password reset token.
|
||||
// userID: the ID of the user requesting password reset.
|
||||
// token: the random token string.
|
||||
// expiresAt: when this token becomes invalid.
|
||||
func (d *DB) CreatePasswordResetToken(userID uint, token string, expiresAt time.Time) error {
|
||||
resetToken := PasswordResetToken{
|
||||
UserID: userID,
|
||||
|
|
@ -132,8 +213,12 @@ func (d *DB) CreatePasswordResetToken(userID uint, token string, expiresAt time.
|
|||
return d.Create(&resetToken).Error
|
||||
}
|
||||
|
||||
// GetValidPasswordResetToken looks up a valid (unused, not expired) reset token.
|
||||
// token: the token string to look up.
|
||||
// Returns: the token if valid, or an error if not found/expired/used.
|
||||
func (d *DB) GetValidPasswordResetToken(token string) (*PasswordResetToken, error) {
|
||||
var resetToken PasswordResetToken
|
||||
// Check token exists, hasn't been used, and hasn't expired.
|
||||
err := d.Where("token = ? AND used = false AND expires_at > ?", token, time.Now()).First(&resetToken).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -141,6 +226,8 @@ func (d *DB) GetValidPasswordResetToken(token string) (*PasswordResetToken, erro
|
|||
return &resetToken, nil
|
||||
}
|
||||
|
||||
// MarkResetTokenUsed marks a token as used (prevents reuse).
|
||||
// token: the token string to mark as used.
|
||||
func (d *DB) MarkResetTokenUsed(token string) error {
|
||||
return d.Model(&PasswordResetToken{}).Where("token = ?", token).Update("used", true).Error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,29 @@
|
|||
// Package mail provides email sending functionality for password reset emails.
|
||||
// Uses Go's net/smtp package for sending emails via SMTP protocol.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"crypto/tls" // TLS/SSL encryption for secure email sending
|
||||
"fmt" // string formatting for URLs and messages
|
||||
"net/smtp" // simple mail transfer protocol package
|
||||
"strings" // string manipulation for token validation
|
||||
|
||||
"github.com/imc-vibe/backend/internal/config"
|
||||
)
|
||||
|
||||
// EmailService handles sending emails through SMTP.
|
||||
// Stores SMTP configuration and provides methods for different email types.
|
||||
type EmailService struct {
|
||||
host string
|
||||
port string
|
||||
username string
|
||||
password string
|
||||
from string
|
||||
baseURL string
|
||||
host string // SMTP server hostname (e.g., "smtp.example.com")
|
||||
port string // SMTP server port (e.g., "587" for TLS, "465" for SSL)
|
||||
username string // SMTP authentication username
|
||||
password string // SMTP authentication password
|
||||
from string // Sender email address (e.g., "noreply@example.com")
|
||||
baseURL string // Base URL of the web application (for building reset links)
|
||||
}
|
||||
|
||||
// NewEmailService creates a new EmailService instance from configuration.
|
||||
// cfg: application configuration containing SMTP settings and base URL.
|
||||
func NewEmailService(cfg *config.Config) *EmailService {
|
||||
return &EmailService{
|
||||
host: cfg.SMTPHost,
|
||||
|
|
@ -29,9 +35,16 @@ func NewEmailService(cfg *config.Config) *EmailService {
|
|||
}
|
||||
}
|
||||
|
||||
// SendPasswordReset sends a password reset email to the user.
|
||||
// to: recipient email address
|
||||
// token: unique reset token that was generated and stored in the database
|
||||
// The email contains a clickable link to reset the password.
|
||||
func (s *EmailService) SendPasswordReset(to, token string) error {
|
||||
// Build the reset URL with the token as a query parameter.
|
||||
// Example: https://mail.example.com/auth/reset-password?token=abc123...
|
||||
resetURL := fmt.Sprintf("%s/auth/reset-password?token=%s", s.baseURL, token)
|
||||
|
||||
// Compose the email subject and body.
|
||||
subject := "Password Reset Request"
|
||||
body := fmt.Sprintf(`Hi,
|
||||
|
||||
|
|
@ -48,16 +61,23 @@ Best regards,
|
|||
IMC Vibe Mail Server Admin
|
||||
`, resetURL)
|
||||
|
||||
// Send the email using the send method.
|
||||
return s.send(to, subject, body)
|
||||
}
|
||||
|
||||
// send is the internal method that actually sends the email via SMTP.
|
||||
// Handles both port 587 (TLS) and port 465 (SSL) connections.
|
||||
func (s *EmailService) send(to, subject, body string) error {
|
||||
// Check if SMTP is configured. If not, return an error.
|
||||
if s.host == "" || s.username == "" {
|
||||
return fmt.Errorf("SMTP not configured")
|
||||
}
|
||||
|
||||
// Build the server address in host:port format.
|
||||
addr := fmt.Sprintf("%s:%s", s.host, s.port)
|
||||
|
||||
// Build the email message with headers.
|
||||
// \r\n is the standard line ending for email headers and body.
|
||||
msg := fmt.Sprintf("From: %s\r\n"+
|
||||
"To: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
|
|
@ -66,79 +86,116 @@ func (s *EmailService) send(to, subject, body string) error {
|
|||
"\r\n"+
|
||||
"%s", s.from, to, subject, body)
|
||||
|
||||
// Port 465 uses implicit SSL/TLS, port 587 uses STARTTLS.
|
||||
// Both are secure, but they work differently.
|
||||
var auth smtp.Auth
|
||||
if s.port == "465" {
|
||||
// For port 465, we need to establish a TLS connection first
|
||||
// before sending anything, then upgrade the connection.
|
||||
err := s.sendWithTLS(addr, to, msg)
|
||||
return err
|
||||
} else {
|
||||
// For other ports (usually 587), use STARTTLS which upgrades
|
||||
// a plain connection to TLS after connecting.
|
||||
auth = smtp.PlainAuth("", s.username, s.password, s.host)
|
||||
err := smtp.SendMail(addr, auth, s.from, []string{to}, []byte(msg))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// sendWithTLS sends an email using an explicit TLS connection on port 465.
|
||||
// Port 465 uses implicit TLS - the entire connection is encrypted from the start.
|
||||
func (s *EmailService) sendWithTLS(addr, to, msg string) error {
|
||||
// Create a TLS configuration for the secure connection.
|
||||
// ServerName must match the SMTP server's certificate.
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.host,
|
||||
}
|
||||
|
||||
// Establish a TLS connection to the SMTP server.
|
||||
// This creates an encrypted tunnel from the start.
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
defer conn.Close() // Ensure connection is closed when we're done
|
||||
|
||||
// Create an SMTP client on top of the TLS connection.
|
||||
client, err := smtp.NewClient(conn, s.host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Authenticate with the SMTP server using PLAIN authentication.
|
||||
// The empty string "" is the identity (usually same as username).
|
||||
auth := smtp.PlainAuth("", s.username, s.password, s.host)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the sender (MAIL FROM command).
|
||||
if err := client.Mail(s.from); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the recipient (RCPT TO command).
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start sending the message body (DATA command).
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write the email body to the data stream.
|
||||
_, err = w.Write([]byte(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Close the data writer to finish the message.
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send the QUIT command to gracefully close the connection.
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// GenerateToken creates a random token for password reset links.
|
||||
// Returns a 64-character hexadecimal string (32 bytes encoded as hex).
|
||||
// This is cryptographically random and suitable for security-sensitive tokens.
|
||||
func GenerateToken() string {
|
||||
// Create a byte slice to hold random bytes.
|
||||
b := make([]byte, 32)
|
||||
// Fill with random characters from our allowed set.
|
||||
// We iterate over the slice and pick characters based on position.
|
||||
for i := range b {
|
||||
b[i] = tokenChars[i%len(tokenChars)]
|
||||
}
|
||||
// Convert bytes to hex string. Each byte becomes two hex characters.
|
||||
// Result: 32 bytes = 64 hex characters.
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// tokenChars defines the character set used for generating reset tokens.
|
||||
// Using alphanumeric characters makes tokens easier to copy/paste.
|
||||
const tokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// ValidateToken checks if a token has the correct format.
|
||||
// Returns true if the token is exactly 64 characters and contains only
|
||||
// characters from our allowed tokenChars set.
|
||||
// This is a quick validation before checking the database.
|
||||
func ValidateToken(token string) bool {
|
||||
// Tokens should be 64 hex characters (32 bytes * 2 hex chars/byte).
|
||||
if len(token) != 64 {
|
||||
return false
|
||||
}
|
||||
// Check each character is in our allowed set.
|
||||
for _, c := range token {
|
||||
if !strings.ContainsRune(tokenChars, c) {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -1,77 +1,116 @@
|
|||
// Package mail provides functions for interacting with the mail server system.
|
||||
// This file handles reading system logs from systemd's journal using journalctl.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"bufio" // buffered scanner for efficient line-by-line reading
|
||||
"bytes" // buffer management for capturing command output
|
||||
"context" // context for cancellation and timeouts
|
||||
"os/exec" // executing external commands (journalctl)
|
||||
"regexp" // regular expressions for parsing log entries
|
||||
"strings" // string manipulation
|
||||
"time" // time formatting and duration calculations
|
||||
)
|
||||
|
||||
// LogEntry represents a single log line from the system journal.
|
||||
type LogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Priority string `json:"priority"`
|
||||
Message string `json:"message"`
|
||||
Timestamp string `json:"timestamp"` // When the log entry was created (e.g., "2024-01-15 10:30:45")
|
||||
Priority string `json:"priority"` // Log level: "info", "warning", "err", etc.
|
||||
Message string `json:"message"` // The actual log message content
|
||||
}
|
||||
|
||||
// GetLogs retrieves Postfix mail log entries from systemd journal.
|
||||
// hours: how many hours of logs to look back (e.g., 24 = last 24 hours)
|
||||
// filter: optional text filter - only returns lines containing this text (case-insensitive)
|
||||
// Returns a slice of LogEntry structs sorted from newest to oldest.
|
||||
func GetLogs(hours int, filter string) ([]LogEntry, error) {
|
||||
// Create a context with a 30-second timeout.
|
||||
// This prevents the journalctl command from hanging forever if there's an issue.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
defer cancel() // Always call cancel to clean up resources
|
||||
|
||||
// Build the journalctl command arguments.
|
||||
// -u: filter by systemd unit name (postfix in this case)
|
||||
// --since: start time (formatted as "YYYY-MM-DD HH:MM:SS")
|
||||
// --no-pager: don't use a pager, just output everything directly
|
||||
args := []string{
|
||||
"-u", "postfix",
|
||||
"--since", formatDuration(hours),
|
||||
"--no-pager",
|
||||
}
|
||||
|
||||
// Execute journalctl with our arguments.
|
||||
// journalctl reads from systemd journal, not plain text files.
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/journalctl", args...)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stdout = &out // Capture output to our buffer
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// journalctl returns non-zero when there are no entries.
|
||||
// That's not really an error for us, so we could handle it,
|
||||
// but for simplicity we just pass the error through.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the journalctl output line by line.
|
||||
var entries []LogEntry
|
||||
scanner := bufio.NewScanner(&out)
|
||||
|
||||
// Regular expression to extract priority/level from log entries.
|
||||
// systemd logs look like: "[priority] some message"
|
||||
priorityRegex := regexp.MustCompile(`\[([^\]]+)\]`)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip empty lines and "No entries" messages.
|
||||
// journalctl returns "-- No entries --" when there's nothing to show.
|
||||
if strings.Contains(line, "-- No entries") || line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply text filter if provided.
|
||||
// Case-insensitive search using ToLower on both strings.
|
||||
if filter != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(filter)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// journalctl outputs lines in this format:
|
||||
// Jan 15 10:30:45 hostname postfix/cleanup[12345]: [priority] message
|
||||
// We split by spaces, but since the message can contain spaces,
|
||||
// we use SplitN with limit 4 to get: timestamp, hostname, process, message
|
||||
parts := strings.SplitN(line, " ", 4)
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
continue // Skip malformed lines
|
||||
}
|
||||
|
||||
// Combine first two parts for full timestamp: "Jan 15 10:30:45"
|
||||
timestamp := parts[0] + " " + parts[1]
|
||||
|
||||
// The third part contains the process info like "postfix/cleanup[12345]:"
|
||||
// We extract the priority level from this part.
|
||||
matches := priorityRegex.FindStringSubmatch(parts[2])
|
||||
priority := "info"
|
||||
priority := "info" // default priority
|
||||
if len(matches) > 1 {
|
||||
// Found a priority in brackets like [info], [warning], [error]
|
||||
priority = matches[1]
|
||||
} else if strings.Contains(parts[2], "err") {
|
||||
// Some log lines don't use brackets, so we check for keywords
|
||||
priority = "err"
|
||||
} else if strings.Contains(parts[2], "warning") || strings.Contains(parts[2], "warn") {
|
||||
priority = "warning"
|
||||
}
|
||||
|
||||
// The fourth part is the actual log message.
|
||||
message := parts[3]
|
||||
|
||||
// Double-check the filter applies to the message part specifically.
|
||||
// This is optional since we already filtered above, but provides extra safety.
|
||||
if filter != "" && !strings.Contains(strings.ToLower(message), strings.ToLower(filter)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a LogEntry and add it to our results.
|
||||
entries = append(entries, LogEntry{
|
||||
Timestamp: timestamp,
|
||||
Priority: priority,
|
||||
|
|
@ -79,6 +118,7 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
|
|||
})
|
||||
}
|
||||
|
||||
// Return empty slice instead of nil for consistent JSON serialization.
|
||||
if entries == nil {
|
||||
entries = []LogEntry{}
|
||||
}
|
||||
|
|
@ -86,6 +126,9 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
// formatDuration converts hours into a journalctl-compatible timestamp.
|
||||
// journalctl expects timestamps in format: "YYYY-MM-DD HH:MM:SS"
|
||||
// We calculate the timestamp by subtracting the requested hours from now.
|
||||
func formatDuration(hours int) string {
|
||||
return time.Now().Add(-time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,61 @@
|
|||
// Package mail provides functions for interacting with the mail server system.
|
||||
// Currently supports Postfix mail queue operations via postqueue/postsuper commands.
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"bufio" // buffered scanner for reading line-by-line
|
||||
"bytes" // buffer management
|
||||
"encoding/json" // JSON parsing for postqueue output
|
||||
"fmt" // formatted error messages
|
||||
"os/exec" // executing external commands
|
||||
"regexp" // regular expressions (for parsing queue output)
|
||||
"strings" // string manipulation
|
||||
)
|
||||
|
||||
// QueueEntry represents a single email in the mail queue.
|
||||
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"`
|
||||
ID string `json:"id"` // Queue ID (hex string)
|
||||
Sender string `json:"sender"` // From address
|
||||
Recipients []string `json:"recipients"` // To addresses
|
||||
Size int `json:"size"` // Size in bytes
|
||||
Time string `json:"time"` // Arrival time
|
||||
Reason string `json:"reason"` // Why it's deferred
|
||||
}
|
||||
|
||||
// Regular expression for parsing human-readable queue output (not currently used).
|
||||
var queueLineRegex = regexp.MustCompile(`^([A-Fa-f0-9]+)\s+(\d+)\s+(\w+)\s+([A-Za-z]+\s+\d+\s+[\d:]+)\s+([^\s]+)\s+(.+)$`)
|
||||
|
||||
// GetQueue retrieves all emails currently in the Postfix queue.
|
||||
// Returns: a list of QueueEntry objects, or an error if the command fails.
|
||||
// Uses `postqueue -j` to get JSON output from Postfix.
|
||||
func GetQueue() ([]QueueEntry, error) {
|
||||
// Execute postqueue command with JSON output flag.
|
||||
// postqueue is the Postfix queue management utility.
|
||||
cmd := exec.Command("/usr/sbin/postqueue", "-j")
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &out // Capture standard output
|
||||
cmd.Stderr = &stderr // Capture standard error (for debugging)
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
// Return detailed error including stderr for debugging.
|
||||
return nil, fmt.Errorf("/usr/sbin/postqueue -j: %v (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
|
||||
// Parse the JSON output from postqueue.
|
||||
// Each line of output is a separate JSON object.
|
||||
var entries []QueueEntry
|
||||
scanner := bufio.NewScanner(&out)
|
||||
scanner := bufio.NewScanner(&out) // Read line by line
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
// Skip empty lines and non-JSON lines.
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Define the JSON structure matching postqueue's output format.
|
||||
var entry struct {
|
||||
QueueID string `json:"queueid"`
|
||||
Sender string `json:"sender"`
|
||||
|
|
@ -54,10 +67,12 @@ func GetQueue() ([]QueueEntry, error) {
|
|||
Delay string `json:"delay_reason"`
|
||||
}
|
||||
|
||||
// Parse the JSON line into our struct.
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
continue
|
||||
continue // Skip malformed JSON
|
||||
}
|
||||
|
||||
// Extract recipient addresses from the nested structure.
|
||||
recipients := make([]string, len(entry.Recipients))
|
||||
for i, r := range entry.Recipients {
|
||||
recipients[i] = r.Address
|
||||
|
|
@ -73,6 +88,7 @@ func GetQueue() ([]QueueEntry, error) {
|
|||
})
|
||||
}
|
||||
|
||||
// Return empty slice instead of nil for consistent JSON output.
|
||||
if entries == nil {
|
||||
entries = []QueueEntry{}
|
||||
}
|
||||
|
|
@ -80,7 +96,12 @@ func GetQueue() ([]QueueEntry, error) {
|
|||
return entries, nil
|
||||
}
|
||||
|
||||
// RequeueMail requeues all deferred mail (attempts to resend them).
|
||||
// This is a blanket operation - it requeues ALL deferred mail, not just one message.
|
||||
// id: message ID (currently ignored, requeues all).
|
||||
// Note: postqueue -f requeues everything, there's no per-message requeue.
|
||||
func RequeueMail(id string) error {
|
||||
// Execute postqueue -f to flush (requeue) all deferred mail.
|
||||
cmd := exec.Command("/usr/sbin/postqueue", "-f", "-v")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
|
@ -92,12 +113,20 @@ func RequeueMail(id string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// DeleteFromQueue deletes a specific message from the queue.
|
||||
// id: the queue ID of the message to delete.
|
||||
// Uses `postsuper -d <id>` to delete the message.
|
||||
func DeleteFromQueue(id string) error {
|
||||
// postsuper is the Postfix superuser queue management command.
|
||||
// -d deletes messages by ID.
|
||||
cmd := exec.Command("/usr/sbin/postsuper", "-d", id)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// GetQueueCount returns the number of messages in the mail queue.
|
||||
// Used for displaying queue statistics on the dashboard.
|
||||
func GetQueueCount() (int, error) {
|
||||
// Use JSON output to get queue count.
|
||||
cmd := exec.Command("/usr/sbin/postqueue", "-j")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
|
@ -106,20 +135,15 @@ func GetQueueCount() (int, error) {
|
|||
return 0, err
|
||||
}
|
||||
|
||||
// Count the number of JSON objects (messages) in the output.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
// Each line of JSON output represents one message.
|
||||
// Count non-empty lines that look like JSON.
|
||||
if strings.TrimSpace(line) != "" && strings.HasPrefix(line, "{") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue