Add CLI flags for setup and server configuration
- Add --setup flag to create admin user - Add --admin-user and --admin-password for setup - Generate cryptographically random passwords for --setup - Add --bind and --port flags for server binding - Add BIND env var support - Add SMTP email service for password reset - Add password reset token storage - Add auth header to queue/logs frontend pages - Fix journalctl timestamp format - Fix logs to return empty array instead of error when no entries - Fix queue handler to return proper error message - Hide Users/Aliases nav links when no domain selected
This commit is contained in:
parent
2c3a4c3daa
commit
53ce3b06ae
14 changed files with 435 additions and 69 deletions
|
|
@ -6,3 +6,10 @@ DB_NAME=mailserver
|
||||||
JWT_SECRET=change-this-secret-in-production
|
JWT_SECRET=change-this-secret-in-production
|
||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
SMTP_HOST=localhost
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=noreply@localhost
|
||||||
|
BASE_URL=http://localhost:8080
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -13,8 +16,22 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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()
|
cfg := config.Load()
|
||||||
|
|
||||||
|
if *bind != "" {
|
||||||
|
cfg.Bind = *bind
|
||||||
|
}
|
||||||
|
if *port != "" {
|
||||||
|
cfg.Port = *port
|
||||||
|
}
|
||||||
|
|
||||||
database, err := db.Connect(cfg)
|
database, err := db.Connect(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
|
@ -24,6 +41,28 @@ func main() {
|
||||||
log.Printf("Warning: Could not initialize schema: %v", err)
|
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 != "" {
|
if cfg.AdminUser != "" && cfg.AdminPassword != "" {
|
||||||
hash, err := auth.HashPassword(cfg.AdminPassword)
|
hash, err := auth.HashPassword(cfg.AdminPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -38,7 +77,6 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEmbedded := os.Getenv("USE_EMBEDDED") != "false"
|
useEmbedded := os.Getenv("USE_EMBEDDED") != "false"
|
||||||
port := cfg.Port
|
|
||||||
|
|
||||||
frontendFS := FrontendFileSystem()
|
frontendFS := FrontendFileSystem()
|
||||||
|
|
||||||
|
|
@ -62,18 +100,32 @@ func main() {
|
||||||
log.Println("Using filesystem frontend")
|
log.Println("Using filesystem frontend")
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Server starting on http://localhost:%s", port)
|
addr := fmt.Sprintf("%s:%s", cfg.Bind, cfg.Port)
|
||||||
log.Fatal(http.ListenAndServe(":"+port, router))
|
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 {
|
func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := prefix + r.URL.Path
|
path := prefix + r.URL.Path
|
||||||
log.Printf("Static file requested: /_app/%s -> %s", r.URL.Path, path)
|
|
||||||
|
|
||||||
file, err := fsys.Open(path)
|
file, err := fsys.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error opening file %s: %v", path, err)
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -98,11 +150,8 @@ func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc {
|
||||||
|
|
||||||
func serveStaticFile(fsys http.FileSystem, filename string) http.HandlerFunc {
|
func serveStaticFile(fsys http.FileSystem, filename string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Printf("Static file requested: /%s", filename)
|
|
||||||
|
|
||||||
file, err := fsys.Open(filename)
|
file, err := fsys.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error opening file %s: %v", filename, err)
|
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -129,11 +178,7 @@ func serveSPA(fsys http.FileSystem) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if path == "/" || path == "" {
|
path = "embed/index.html"
|
||||||
path = "embed/index.html"
|
|
||||||
} else {
|
|
||||||
path = "embed/index.html"
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := fsys.Open(path)
|
file, err := fsys.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,29 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/imc-vibe/backend/internal/auth"
|
"github.com/imc-vibe/backend/internal/auth"
|
||||||
"github.com/imc-vibe/backend/internal/db"
|
"github.com/imc-vibe/backend/internal/db"
|
||||||
|
"github.com/imc-vibe/backend/internal/mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
const MaxLoginAttempts = 5
|
const MaxLoginAttempts = 5
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
jwtManager *auth.JWTManager
|
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{
|
return &AuthHandler{
|
||||||
db: database,
|
db: database,
|
||||||
jwtManager: jwtManager,
|
jwtManager: jwtManager,
|
||||||
|
emailService: emailService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,14 +157,29 @@ func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.db.GetImcUserByUsername(req.Identifier)
|
user, err := h.db.GetImcUserByUsername(req.Identifier)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
Success(w, map[string]string{
|
Success(w, map[string]string{
|
||||||
"message": "If the account exists, a password reset link will be sent",
|
"message": "If the account exists, a password reset link will be sent",
|
||||||
})
|
})
|
||||||
return
|
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{
|
Success(w, map[string]string{
|
||||||
"message": "If the account exists, a password reset link will be sent",
|
"message": "If the account exists, a password reset link will be sent",
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func (h *LogsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
entries, err := mail.GetLogs(hours, filter)
|
entries, err := mail.GetLogs(hours, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Error(w, http.StatusInternalServerError, "failed to get logs")
|
Error(w, http.StatusServiceUnavailable, "log service not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ func (h *QueueHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
entries, err := mail.GetQueue()
|
entries, err := mail.GetQueue()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Error(w, http.StatusInternalServerError, "failed to get queue")
|
Error(w, http.StatusServiceUnavailable, "mail queue tools not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"github.com/imc-vibe/backend/internal/auth"
|
"github.com/imc-vibe/backend/internal/auth"
|
||||||
"github.com/imc-vibe/backend/internal/config"
|
"github.com/imc-vibe/backend/internal/config"
|
||||||
"github.com/imc-vibe/backend/internal/db"
|
"github.com/imc-vibe/backend/internal/db"
|
||||||
|
"github.com/imc-vibe/backend/internal/mail"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
|
|
@ -25,9 +26,10 @@ type Router struct {
|
||||||
|
|
||||||
func New(database *db.DB, cfg *config.Config) *Router {
|
func New(database *db.DB, cfg *config.Config) *Router {
|
||||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe")
|
jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe")
|
||||||
|
emailService := mail.NewEmailService(cfg)
|
||||||
|
|
||||||
return &Router{
|
return &Router{
|
||||||
authHandler: handlers.NewAuthHandler(database, jwtManager),
|
authHandler: handlers.NewAuthHandler(database, jwtManager, emailService),
|
||||||
domainHandler: handlers.NewDomainHandler(database),
|
domainHandler: handlers.NewDomainHandler(database),
|
||||||
userHandler: handlers.NewUserHandler(database),
|
userHandler: handlers.NewUserHandler(database),
|
||||||
aliasHandler: handlers.NewAliasHandler(database),
|
aliasHandler: handlers.NewAliasHandler(database),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ type Config struct {
|
||||||
DBUser string
|
DBUser string
|
||||||
DBPassword string
|
DBPassword string
|
||||||
DBName string
|
DBName string
|
||||||
|
Bind string
|
||||||
Port string
|
Port string
|
||||||
UseEmbedded bool
|
UseEmbedded bool
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
|
@ -23,6 +24,12 @@ type Config struct {
|
||||||
DovecotQuotaCmd string
|
DovecotQuotaCmd string
|
||||||
JournalctlPath string
|
JournalctlPath string
|
||||||
RspamdAPI string
|
RspamdAPI string
|
||||||
|
SMTPHost string
|
||||||
|
SMTPPort string
|
||||||
|
SMTPUser string
|
||||||
|
SMTPPassword string
|
||||||
|
SMTPFrom string
|
||||||
|
BaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
|
|
@ -34,9 +41,10 @@ func Load() *Config {
|
||||||
DBUser: getEnv("DB_USER", "mailadmin"),
|
DBUser: getEnv("DB_USER", "mailadmin"),
|
||||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||||
DBName: getEnv("DB_NAME", "mailserver"),
|
DBName: getEnv("DB_NAME", "mailserver"),
|
||||||
|
Bind: getEnv("BIND", "0.0.0.0"),
|
||||||
Port: getEnv("PORT", "8080"),
|
Port: getEnv("PORT", "8080"),
|
||||||
UseEmbedded: getEnv("USE_EMBEDDED", "true") == "true",
|
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", ""),
|
AdminUser: getEnv("ADMIN_USER", ""),
|
||||||
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
|
||||||
MailDataDir: getEnv("MAIL_DATA_DIR", "/var/vmail"),
|
MailDataDir: getEnv("MAIL_DATA_DIR", "/var/vmail"),
|
||||||
|
|
@ -45,6 +53,12 @@ func Load() *Config {
|
||||||
DovecotQuotaCmd: getEnv("DOVECOT_QUOTA_CMD", "/usr/bin/doveadm"),
|
DovecotQuotaCmd: getEnv("DOVECOT_QUOTA_CMD", "/usr/bin/doveadm"),
|
||||||
JournalctlPath: getEnv("JOURNALCTL_PATH", "/usr/bin/journalctl"),
|
JournalctlPath: getEnv("JOURNALCTL_PATH", "/usr/bin/journalctl"),
|
||||||
RspamdAPI: getEnv("RSPAMD_API", "http://127.0.0.1:11334"),
|
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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,17 @@ type ImcLoginAttempt struct {
|
||||||
|
|
||||||
func (ImcLoginAttempt) TableName() string { return "imc_login_attempts" }
|
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 {
|
type DomainStats struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -160,5 +171,21 @@ func (d *DB) InitSchema() error {
|
||||||
INDEX idx_domain_id (domain_id)
|
INDEX idx_domain_id (domain_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package db
|
package db
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) {
|
func (d *DB) GetImcUserByUsername(username string) (*ImcUser, error) {
|
||||||
var user ImcUser
|
var user ImcUser
|
||||||
if err := d.Preload("Domains.Domain").Where("username = ?", username).First(&user).Error; err != nil {
|
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
|
err := d.Model(&ImcUserDomain{}).Where("user_id = ? AND domain_id = ?", userID, domainID).Count(&count).Error
|
||||||
return count > 0, err
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
148
backend/internal/mail/email.go
Normal file
148
backend/internal/mail/email.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,6 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
|
||||||
"-u", "postfix",
|
"-u", "postfix",
|
||||||
"--since", formatDuration(hours),
|
"--since", formatDuration(hours),
|
||||||
"--no-pager",
|
"--no-pager",
|
||||||
"-o", "short=false",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "journalctl", args...)
|
cmd := exec.CommandContext(ctx, "journalctl", args...)
|
||||||
|
|
@ -40,7 +39,11 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
|
||||||
priorityRegex := regexp.MustCompile(`\[([^\]]+)\]`)
|
priorityRegex := regexp.MustCompile(`\[([^\]]+)\]`)
|
||||||
|
|
||||||
for scanner.Scan() {
|
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)) {
|
if filter != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(filter)) {
|
||||||
continue
|
continue
|
||||||
|
|
@ -84,5 +87,5 @@ func GetLogs(hours int, filter string) ([]LogEntry, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatDuration(hours int) string {
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,35 @@
|
||||||
let user: { username?: string; email?: string; role?: string } | null = $state(null);
|
let user: { username?: string; email?: string; role?: string } | null = $state(null);
|
||||||
let selectedDomain = $state<string | null>(null);
|
let selectedDomain = $state<string | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let showDropdown = $state(false);
|
||||||
|
let dropdownRef = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
let usersLink = $derived(selectedDomain ? `/domains/${selectedDomain}/users` : '/domains');
|
let usersLink = $derived(selectedDomain ? `/domains/${selectedDomain}/users` : '/domains');
|
||||||
let aliasesLink = $derived(selectedDomain ? `/domains/${selectedDomain}/aliases` : '/domains');
|
let aliasesLink = $derived(selectedDomain ? `/domains/${selectedDomain}/aliases` : '/domains');
|
||||||
|
let isInitialized = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
selectedDomain = localStorage.getItem('selectedDomain');
|
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\/([^/]+)/);
|
const match = path.match(/^\/domains\/([^/]+)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
selectedDomain = match[1];
|
selectedDomain = match[1];
|
||||||
localStorage.setItem('selectedDomain', selectedDomain);
|
localStorage.setItem('selectedDomain', selectedDomain);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
updateSelectedDomainFromPath($page.url.pathname);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|
@ -73,8 +83,14 @@
|
||||||
localStorage.removeItem('selectedDomain');
|
localStorage.removeItem('selectedDomain');
|
||||||
user = null;
|
user = null;
|
||||||
selectedDomain = null;
|
selectedDomain = null;
|
||||||
|
showDropdown = false;
|
||||||
goto('/auth/login');
|
goto('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleDropdown(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
showDropdown = !showDropdown;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -86,17 +102,24 @@
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">Dashboard</a>
|
<a href="/">Dashboard</a>
|
||||||
<a href="/domains">Domains</a>
|
<a href="/domains">Domains</a>
|
||||||
<a href={usersLink}>Users</a>
|
{#if selectedDomain}
|
||||||
<a href={aliasesLink}>Aliases</a>
|
<a href={usersLink}>Users</a>
|
||||||
|
<a href={aliasesLink}>Aliases</a>
|
||||||
|
{/if}
|
||||||
<a href="/queue">Queue</a>
|
<a href="/queue">Queue</a>
|
||||||
<a href="/logs">Logs</a>
|
<a href="/logs">Logs</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="user-menu">
|
<div class="user-menu" bind:this={dropdownRef}>
|
||||||
{#if user}
|
<button class="dropdown-trigger" onclick={toggleDropdown}>
|
||||||
<span>{user.username || user.email}</span>
|
<span class="username">{user.username || user.email}</span>
|
||||||
|
<span class="arrow" class:open={showDropdown}>▼</span>
|
||||||
|
</button>
|
||||||
|
{#if showDropdown}
|
||||||
|
<div class="dropdown">
|
||||||
|
<a href="/auth/change-password" onclick={() => showDropdown = false}>Change Password</a>
|
||||||
|
<button class="logout-btn" onclick={logout}>Logout</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<a href="/auth/change-password">Change Password</a>
|
|
||||||
<button onclick={logout}>Logout</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -169,40 +192,80 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu {
|
.user-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
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 {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #7f8c8d;
|
border: 1px solid #7f8c8d;
|
||||||
color: #ecf0f1;
|
color: #ecf0f1;
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-menu button:hover {
|
.dropdown-trigger:hover {
|
||||||
background: #34495e;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
logs = data.data || [];
|
logs = data.data || [];
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,10 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
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) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
queue = data.data || [];
|
queue = data.data || [];
|
||||||
|
|
@ -33,7 +36,11 @@
|
||||||
|
|
||||||
async function requeue(id: string) {
|
async function requeue(id: string) {
|
||||||
try {
|
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();
|
await loadQueue();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to requeue:', e);
|
console.error('Failed to requeue:', e);
|
||||||
|
|
@ -43,7 +50,11 @@
|
||||||
async function deleteFromQueue(id: string) {
|
async function deleteFromQueue(id: string) {
|
||||||
if (!confirm('Delete this message from queue?')) return;
|
if (!confirm('Delete this message from queue?')) return;
|
||||||
try {
|
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();
|
await loadQueue();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete from queue:', e);
|
console.error('Failed to delete from queue:', e);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue