diff --git a/backend/.env b/backend/.env index c47d326..c75d2ef 100644 --- a/backend/.env +++ b/backend/.env @@ -6,3 +6,10 @@ DB_NAME=mailserver JWT_SECRET=change-this-secret-in-production ADMIN_USER=admin ADMIN_PASSWORD=admin123 + +SMTP_HOST=localhost +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@localhost +BASE_URL=http://localhost:8080 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 51602da..8e22691 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,6 +1,9 @@ package main import ( + "crypto/rand" + "flag" + "fmt" "log" "net/http" "os" @@ -13,8 +16,22 @@ import ( ) func main() { + setup := flag.Bool("setup", false, "Create admin user and exit") + adminUser := flag.String("admin-user", "admin", "Admin username for setup") + adminPassword := flag.String("admin-password", "", "Admin password for setup (required with --setup)") + bind := flag.String("bind", "", "IP address to bind to (default: 0.0.0.0)") + port := flag.String("port", "", "Port to listen on (default: 8080)") + flag.Parse() + cfg := config.Load() + if *bind != "" { + cfg.Bind = *bind + } + if *port != "" { + cfg.Port = *port + } + database, err := db.Connect(cfg) if err != nil { log.Fatalf("Failed to connect to database: %v", err) @@ -24,6 +41,28 @@ func main() { log.Printf("Warning: Could not initialize schema: %v", err) } + if *setup { + password := *adminPassword + if password == "" { + password = generateRandomPassword(16) + fmt.Printf("Generated random password: %s\n", password) + } + + hash, err := auth.HashPassword(password) + if err != nil { + log.Fatalf("Failed to hash password: %v", err) + } + + if err := database.EnsureAdminUser(*adminUser, hash); err != nil { + log.Fatalf("Failed to create admin user: %v", err) + } + + fmt.Printf("Admin user '%s' created successfully.\n", *adminUser) + fmt.Printf("You can now login with username: %s\n", *adminUser) + fmt.Printf("Password: %s\n", password) + return + } + if cfg.AdminUser != "" && cfg.AdminPassword != "" { hash, err := auth.HashPassword(cfg.AdminPassword) if err != nil { @@ -38,7 +77,6 @@ func main() { } useEmbedded := os.Getenv("USE_EMBEDDED") != "false" - port := cfg.Port frontendFS := FrontendFileSystem() @@ -62,18 +100,32 @@ func main() { log.Println("Using filesystem frontend") } - log.Printf("Server starting on http://localhost:%s", port) - log.Fatal(http.ListenAndServe(":"+port, router)) + addr := fmt.Sprintf("%s:%s", cfg.Bind, cfg.Port) + log.Printf("Server starting on http://%s", addr) + log.Fatal(http.ListenAndServe(addr, router)) +} + +func generateRandomPassword(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*" + result := make([]byte, length) + if _, err := rand.Read(result); err != nil { + for i := range result { + result[i] = charset[i%len(charset)] + } + return string(result) + } + for i := range result { + result[i] = charset[int(result[i])%len(charset)] + } + return string(result) } func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { path := prefix + r.URL.Path - log.Printf("Static file requested: /_app/%s -> %s", r.URL.Path, path) file, err := fsys.Open(path) if err != nil { - log.Printf("Error opening file %s: %v", path, err) http.NotFound(w, r) return } @@ -98,11 +150,8 @@ func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc { func serveStaticFile(fsys http.FileSystem, filename string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - log.Printf("Static file requested: /%s", filename) - file, err := fsys.Open(filename) if err != nil { - log.Printf("Error opening file %s: %v", filename, err) http.NotFound(w, r) return } @@ -129,11 +178,7 @@ func serveSPA(fsys http.FileSystem) http.HandlerFunc { return } - if path == "/" || path == "" { - path = "embed/index.html" - } else { - path = "embed/index.html" - } + path = "embed/index.html" file, err := fsys.Open(path) if err != nil { diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index a4881dd..3ce3eca 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -2,25 +2,29 @@ package handlers import ( "encoding/json" + "log" "net/http" "strings" "time" "github.com/imc-vibe/backend/internal/auth" "github.com/imc-vibe/backend/internal/db" + "github.com/imc-vibe/backend/internal/mail" ) const MaxLoginAttempts = 5 type AuthHandler struct { - db *db.DB - jwtManager *auth.JWTManager + db *db.DB + jwtManager *auth.JWTManager + emailService *mail.EmailService } -func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager) *AuthHandler { +func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager, emailService *mail.EmailService) *AuthHandler { return &AuthHandler{ - db: database, - jwtManager: jwtManager, + db: database, + jwtManager: jwtManager, + emailService: emailService, } } @@ -153,14 +157,29 @@ func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) { return } - _, err := h.db.GetImcUserByUsername(req.Identifier) - if err == nil { + user, err := h.db.GetImcUserByUsername(req.Identifier) + if err != nil { Success(w, map[string]string{ "message": "If the account exists, a password reset link will be sent", }) return } + token := mail.GenerateToken() + expiresAt := time.Now().Add(1 * time.Hour) + + if err := h.db.CreatePasswordResetToken(user.ID, token, expiresAt); err != nil { + log.Printf("Failed to create password reset token: %v", err) + Error(w, http.StatusInternalServerError, "failed to create reset token") + return + } + + if err := h.emailService.SendPasswordReset(user.Username, token); err != nil { + log.Printf("Failed to send password reset email: %v", err) + Error(w, http.StatusInternalServerError, "failed to send email") + return + } + Success(w, map[string]string{ "message": "If the account exists, a password reset link will be sent", }) diff --git a/backend/internal/api/handlers/logs.go b/backend/internal/api/handlers/logs.go index 7f716d1..6e1b42d 100644 --- a/backend/internal/api/handlers/logs.go +++ b/backend/internal/api/handlers/logs.go @@ -30,7 +30,7 @@ func (h *LogsHandler) List(w http.ResponseWriter, r *http.Request) { entries, err := mail.GetLogs(hours, filter) if err != nil { - Error(w, http.StatusInternalServerError, "failed to get logs") + Error(w, http.StatusServiceUnavailable, "log service not available") return } diff --git a/backend/internal/api/handlers/queue.go b/backend/internal/api/handlers/queue.go index 34bb712..0bd014b 100644 --- a/backend/internal/api/handlers/queue.go +++ b/backend/internal/api/handlers/queue.go @@ -20,7 +20,7 @@ func (h *QueueHandler) List(w http.ResponseWriter, r *http.Request) { entries, err := mail.GetQueue() if err != nil { - Error(w, http.StatusInternalServerError, "failed to get queue") + Error(w, http.StatusServiceUnavailable, "mail queue tools not available") return } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 3f8396b..b77e2ae 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -9,6 +9,7 @@ import ( "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/mail" ) type Router struct { @@ -25,9 +26,10 @@ type Router struct { func New(database *db.DB, cfg *config.Config) *Router { jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe") + emailService := mail.NewEmailService(cfg) return &Router{ - authHandler: handlers.NewAuthHandler(database, jwtManager), + authHandler: handlers.NewAuthHandler(database, jwtManager, emailService), domainHandler: handlers.NewDomainHandler(database), userHandler: handlers.NewUserHandler(database), aliasHandler: handlers.NewAliasHandler(database), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 35efe1c..e288e5b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { DBUser string DBPassword string DBName string + Bind string Port string UseEmbedded bool JWTSecret string @@ -23,6 +24,12 @@ type Config struct { DovecotQuotaCmd string JournalctlPath string RspamdAPI string + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPassword string + SMTPFrom string + BaseURL string } func Load() *Config { @@ -34,9 +41,10 @@ func Load() *Config { 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"), UseEmbedded: getEnv("USE_EMBEDDED", "true") == "true", - JWTSecret: getEnv("JWT_SECRET", "change-this-secret"), + JWTSecret: getEnv("JWT_SECRET", "change-this-secret-in-production"), AdminUser: getEnv("ADMIN_USER", ""), AdminPassword: getEnv("ADMIN_PASSWORD", ""), MailDataDir: getEnv("MAIL_DATA_DIR", "/var/vmail"), @@ -45,6 +53,12 @@ func Load() *Config { 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"), } } diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 4d339a1..ef5d4df 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -78,6 +78,17 @@ type ImcLoginAttempt struct { func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" } +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"` +} + +func (PasswordResetToken) TableName() string { return "imc_password_reset_tokens" } + type DomainStats struct { ID uint `json:"id"` Name string `json:"name"` @@ -160,5 +171,21 @@ func (d *DB) InitSchema() error { INDEX idx_domain_id (domain_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ` - return d.Exec(users2DomainsSQL).Error + if err := d.Exec(users2DomainsSQL).Error; err != nil { + return err + } + + resetTokensSQL := ` + CREATE TABLE IF NOT EXISTS imc_password_reset_tokens ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + token VARCHAR(64) NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + used BOOLEAN DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_token (token), + INDEX idx_user_id (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + ` + return d.Exec(resetTokensSQL).Error } diff --git a/backend/internal/db/imc_users.go b/backend/internal/db/imc_users.go index d3baeb2..71dc537 100644 --- a/backend/internal/db/imc_users.go +++ b/backend/internal/db/imc_users.go @@ -1,5 +1,7 @@ package db +import "time" + func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) { var user ImcUser if err := d.Preload("Domains.Domain").Where("username = ?", username).First(&user).Error; err != nil { @@ -120,3 +122,25 @@ func (d *DB) IsUserInDomain(userID, domainID uint) (bool, error) { err := d.Model(&ImcUserDomain{}).Where("user_id = ? AND domain_id = ?", userID, domainID).Count(&count).Error return count > 0, err } + +func (d *DB) CreatePasswordResetToken(userID uint, token string, expiresAt time.Time) error { + resetToken := PasswordResetToken{ + UserID: userID, + Token: token, + ExpiresAt: expiresAt, + } + return d.Create(&resetToken).Error +} + +func (d *DB) GetValidPasswordResetToken(token string) (*PasswordResetToken, error) { + var resetToken PasswordResetToken + err := d.Where("token = ? AND used = false AND expires_at > ?", token, time.Now()).First(&resetToken).Error + if err != nil { + return nil, err + } + return &resetToken, nil +} + +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 new file mode 100644 index 0000000..1e05ab1 --- /dev/null +++ b/backend/internal/mail/email.go @@ -0,0 +1,148 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "strings" + + "github.com/imc-vibe/backend/internal/config" +) + +type EmailService struct { + host string + port string + username string + password string + from string + baseURL string +} + +func NewEmailService(cfg *config.Config) *EmailService { + return &EmailService{ + host: cfg.SMTPHost, + port: cfg.SMTPPort, + username: cfg.SMTPUser, + password: cfg.SMTPPassword, + from: cfg.SMTPFrom, + baseURL: cfg.BaseURL, + } +} + +func (s *EmailService) SendPasswordReset(to, token string) error { + resetURL := fmt.Sprintf("%s/auth/reset-password?token=%s", s.baseURL, token) + + subject := "Password Reset Request" + body := fmt.Sprintf(`Hi, + +You requested a password reset for your account. + +Click the link below to reset your password: +%s + +This link will expire in 1 hour. + +If you didn't request this, please ignore this email. + +Best regards, +IMC Vibe Mail Server Admin +`, resetURL) + + return s.send(to, subject, body) +} + +func (s *EmailService) send(to, subject, body string) error { + if s.host == "" || s.username == "" { + return fmt.Errorf("SMTP not configured") + } + + addr := fmt.Sprintf("%s:%s", s.host, s.port) + + msg := fmt.Sprintf("From: %s\r\n"+ + "To: %s\r\n"+ + "Subject: %s\r\n"+ + "MIME-Version: 1.0\r\n"+ + "Content-Type: text/plain; charset=\"UTF-8\"\r\n"+ + "\r\n"+ + "%s", s.from, to, subject, body) + + var auth smtp.Auth + if s.port == "465" { + err := s.sendWithTLS(addr, to, msg) + return err + } else { + auth = smtp.PlainAuth("", s.username, s.password, s.host) + err := smtp.SendMail(addr, auth, s.from, []string{to}, []byte(msg)) + return err + } +} + +func (s *EmailService) sendWithTLS(addr, to, msg string) error { + tlsConfig := &tls.Config{ + ServerName: s.host, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return err + } + defer conn.Close() + + client, err := smtp.NewClient(conn, s.host) + if err != nil { + return err + } + defer client.Close() + + auth := smtp.PlainAuth("", s.username, s.password, s.host) + if err := client.Auth(auth); err != nil { + return err + } + + if err := client.Mail(s.from); err != nil { + return err + } + + if err := client.Rcpt(to); err != nil { + return err + } + + w, err := client.Data() + if err != nil { + return err + } + + _, err = w.Write([]byte(msg)) + if err != nil { + return err + } + + err = w.Close() + if err != nil { + return err + } + + return client.Quit() +} + +func GenerateToken() string { + b := make([]byte, 32) + for i := range b { + b[i] = tokenChars[i%len(tokenChars)] + } + return fmt.Sprintf("%x", b) +} + +const tokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func ValidateToken(token string) bool { + if len(token) != 64 { + return false + } + for _, c := range token { + if !strings.ContainsRune(tokenChars, c) { + return false + } + } + return true +} diff --git a/backend/internal/mail/logs.go b/backend/internal/mail/logs.go index 29c20e4..c666c4f 100644 --- a/backend/internal/mail/logs.go +++ b/backend/internal/mail/logs.go @@ -24,7 +24,6 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) { "-u", "postfix", "--since", formatDuration(hours), "--no-pager", - "-o", "short=false", } cmd := exec.CommandContext(ctx, "journalctl", args...) @@ -40,7 +39,11 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) { priorityRegex := regexp.MustCompile(`\[([^\]]+)\]`) for scanner.Scan() { - line := scanner.Text() + line := strings.TrimSpace(scanner.Text()) + + if strings.Contains(line, "-- No entries") || line == "" { + continue + } if filter != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(filter)) { continue @@ -84,5 +87,5 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) { } func formatDuration(hours int) string { - return time.Now().Add(-time.Duration(hours) * time.Hour).Format("15:04:05 2006-01-02") + return time.Now().Add(-time.Duration(hours) * time.Hour).Format("2006-01-02 15:04:05") } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index b1be2a8..9f52f89 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,25 +7,35 @@ let user: { username?: string; email?: string; role?: string } | null = $state(null); let selectedDomain = $state(null); let loading = $state(true); + let showDropdown = $state(false); + let dropdownRef = $state(null); let usersLink = $derived(selectedDomain ? `/domains/${selectedDomain}/users` : '/domains'); let aliasesLink = $derived(selectedDomain ? `/domains/${selectedDomain}/aliases` : '/domains'); + let isInitialized = $state(false); onMount(() => { selectedDomain = localStorage.getItem('selectedDomain'); - updateSelectedDomainFromPath($page.url.pathname); + isInitialized = true; + + function handleClickOutside(event: MouseEvent) { + if (dropdownRef && !dropdownRef.contains(event.target as Node)) { + showDropdown = false; + } + } + + document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); }); - function updateSelectedDomainFromPath(path: string) { + $effect(() => { + if (!isInitialized) return; + const path = $page.url.pathname; const match = path.match(/^\/domains\/([^/]+)/); if (match) { selectedDomain = match[1]; localStorage.setItem('selectedDomain', selectedDomain); } - } - - $effect(() => { - updateSelectedDomainFromPath($page.url.pathname); }); $effect(() => { @@ -73,8 +83,14 @@ localStorage.removeItem('selectedDomain'); user = null; selectedDomain = null; + showDropdown = false; goto('/auth/login'); } + + function toggleDropdown(event: MouseEvent) { + event.stopPropagation(); + showDropdown = !showDropdown; + } {#if loading} @@ -86,17 +102,24 @@ -
- {#if user} - {user.username || user.email} +
+ + {#if showDropdown} + {/if} - Change Password -
@@ -169,40 +192,80 @@ } .user-menu { + position: relative; + } + + .dropdown-trigger { display: flex; align-items: center; - gap: 1rem; - font-size: 0.9rem; - } - - .user-menu span { - color: #ecf0f1; - } - - .user-menu a { - color: #ecf0f1; - text-decoration: none; - padding: 0.375rem 0.75rem; - border: 1px solid #7f8c8d; - border-radius: 4px; - font-size: 0.85rem; - } - - .user-menu a:hover { - background: #34495e; - } - - .user-menu button { + gap: 0.5rem; background: transparent; border: 1px solid #7f8c8d; color: #ecf0f1; padding: 0.375rem 0.75rem; border-radius: 4px; cursor: pointer; - font-size: 0.85rem; + font-size: 0.9rem; + transition: background 0.2s; } - .user-menu button:hover { + .dropdown-trigger:hover { background: #34495e; } + + .username { + font-weight: 500; + } + + .arrow { + font-size: 0.7rem; + transition: transform 0.2s; + } + + .arrow.open { + transform: rotate(180deg); + } + + .dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: 0.5rem; + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 160px; + overflow: hidden; + z-index: 1000; + } + + .dropdown a { + display: block; + padding: 0.75rem 1rem; + color: #2c3e50; + text-decoration: none; + font-size: 0.9rem; + transition: background 0.2s; + } + + .dropdown a:hover { + background: #f5f5f5; + } + + .dropdown .logout-btn { + display: block; + width: 100%; + padding: 0.75rem 1rem; + background: transparent; + border: none; + color: #e74c3c; + text-align: left; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.2s; + } + + .dropdown .logout-btn:hover { + background: #f5f5f5; + } diff --git a/frontend/src/routes/logs/+page.svelte b/frontend/src/routes/logs/+page.svelte index a29e193..408cabe 100644 --- a/frontend/src/routes/logs/+page.svelte +++ b/frontend/src/routes/logs/+page.svelte @@ -15,7 +15,10 @@ loading = true; error = ''; try { - const res = await fetch(`/api/logs?hours=${hours}&filter=${encodeURIComponent(filter)}`); + const token = localStorage.getItem('token'); + const res = await fetch(`/api/logs?hours=${hours}&filter=${encodeURIComponent(filter)}`, { + headers: { Authorization: `Bearer ${token}` } + }); if (res.ok) { const data = await res.json(); logs = data.data || []; diff --git a/frontend/src/routes/queue/+page.svelte b/frontend/src/routes/queue/+page.svelte index aa8047b..dad6b34 100644 --- a/frontend/src/routes/queue/+page.svelte +++ b/frontend/src/routes/queue/+page.svelte @@ -16,7 +16,10 @@ loading = true; error = ''; try { - const res = await fetch('/api/queue'); + const token = localStorage.getItem('token'); + const res = await fetch('/api/queue', { + headers: { Authorization: `Bearer ${token}` } + }); if (res.ok) { const data = await res.json(); queue = data.data || []; @@ -33,7 +36,11 @@ async function requeue(id: string) { try { - await fetch(`/api/queue/${id}/requeue`, { method: 'POST' }); + const token = localStorage.getItem('token'); + await fetch(`/api/queue/${id}/requeue`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` } + }); await loadQueue(); } catch (e) { console.error('Failed to requeue:', e); @@ -43,7 +50,11 @@ async function deleteFromQueue(id: string) { if (!confirm('Delete this message from queue?')) return; try { - await fetch(`/api/queue/${id}`, { method: 'DELETE' }); + const token = localStorage.getItem('token'); + await fetch(`/api/queue/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` } + }); await loadQueue(); } catch (e) { console.error('Failed to delete from queue:', e);