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:
Christoph Haas 2026-03-22 22:43:41 +01:00
parent 3e3620a24f
commit 68285d861a
9 changed files with 728 additions and 203 deletions

View file

@ -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"
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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,

View file

@ -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
}

View file

@ -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

View file

@ -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")
}

View file

@ -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++
}
}