From 68285d861a74bf4b8cf8ddba16b52bf3b7e75a85 Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sun, 22 Mar 2026 22:43:41 +0100 Subject: [PATCH] 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 --- backend/cmd/server/main.go | 158 +++++++++++++++++++++--- backend/internal/auth/jwt.go | 80 +++++++++--- backend/internal/auth/rbac.go | 15 ++- backend/internal/config/config.go | 139 ++++++++++++++------- backend/internal/db/db.go | 194 +++++++++++++++++++++--------- backend/internal/db/imc_users.go | 115 +++++++++++++++--- backend/internal/mail/email.go | 79 ++++++++++-- backend/internal/mail/logs.go | 71 ++++++++--- backend/internal/mail/queue.go | 80 +++++++----- 9 files changed, 728 insertions(+), 203 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index db7dd02..c41b193 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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" } } diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index d23a360..425b468 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -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 } diff --git a/backend/internal/auth/rbac.go b/backend/internal/auth/rbac.go index 4884cb0..46dad64 100644 --- a/backend/internal/auth/rbac.go +++ b/backend/internal/auth/rbac.go @@ -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 } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 093d1e4..7b0f531 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 } diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index ef5d4df..3b17ad5 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -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, diff --git a/backend/internal/db/imc_users.go b/backend/internal/db/imc_users.go index 71dc537..2d46e43 100644 --- a/backend/internal/db/imc_users.go +++ b/backend/internal/db/imc_users.go @@ -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 } diff --git a/backend/internal/mail/email.go b/backend/internal/mail/email.go index 1e05ab1..f6a7172 100644 --- a/backend/internal/mail/email.go +++ b/backend/internal/mail/email.go @@ -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 diff --git a/backend/internal/mail/logs.go b/backend/internal/mail/logs.go index 4cd8308..3138067 100644 --- a/backend/internal/mail/logs.go +++ b/backend/internal/mail/logs.go @@ -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") } diff --git a/backend/internal/mail/queue.go b/backend/internal/mail/queue.go index cd9e96a..7459620 100644 --- a/backend/internal/mail/queue.go +++ b/backend/internal/mail/queue.go @@ -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 ` 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++ } }