diff --git a/backend/config/config.go b/backend/config/config.go index 1e44d30..0441d61 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -32,6 +32,13 @@ type Config struct { // PDF Templates TemplatesDir string // Directory where PDF templates are stored ExportTempDir string // Directory for temporary PDF exports + + // Mail Configuration + MailHost string // SMTP server host + MailPort int // SMTP server port + MailUsername string // SMTP username + MailPassword string // SMTP password + MailFrom string // Default sender email address } // Cfg ist die globale Konfigurationsvariable @@ -56,6 +63,11 @@ func defaultConfig() *Config { FrontendURL: "http://localhost:5173", // Default frontend URL for development TemplatesDir: "./templates", // Default templates directory ExportTempDir: "./xporttemp", // Default export temp directory + MailHost: "", // No default, must be configured + MailPort: 465, // Default SMTP SSL port + MailUsername: "", // No default, must be configured + MailPassword: "", // No default, must be configured + MailFrom: "", // No default, must be configured } } @@ -136,6 +148,28 @@ func LoadConfig() *Config { config.ExportTempDir = exportTempDir } + // Mail Configuration + if mailHost := os.Getenv("MAIL_HOST"); mailHost != "" { + config.MailHost = mailHost + } + if mailPort := os.Getenv("MAIL_PORT"); mailPort != "" { + if port, err := strconv.Atoi(mailPort); err == nil { + config.MailPort = port + } + } + if mailUsername := os.Getenv("MAIL_USERNAME"); mailUsername != "" { + config.MailUsername = mailUsername + } + if mailPassword := os.Getenv("MAIL_PASSWORD"); mailPassword != "" { + config.MailPassword = mailPassword + } + if mailFrom := os.Getenv("MAIL_FROM"); mailFrom != "" { + config.MailFrom = mailFrom + } else if config.MailUsername != "" { + // Fallback: Verwende Username als From-Adresse + config.MailFrom = config.MailUsername + } + fmt.Printf("DEBUG LoadConfig - Finale Config: Environment='%s', DevTesting='%s', DatabaseType='%s'\n Complete: %v\n", config.Environment, config.DevTesting, config.DatabaseType, config) diff --git a/backend/mail/smtp.go b/backend/mail/smtp.go new file mode 100644 index 0000000..7f39524 --- /dev/null +++ b/backend/mail/smtp.go @@ -0,0 +1,163 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net/smtp" + + "bamort/config" + "bamort/logger" +) + +// Client represents an SMTP mail client +type Client struct { + host string + port int + username string + password string + from string +} + +// Message represents an email message +type Message struct { + To string + Subject string + Body string +} + +// NewClient creates a new SMTP mail client from config +func NewClient() *Client { + cfg := config.Cfg + return &Client{ + host: cfg.MailHost, + port: cfg.MailPort, + username: cfg.MailUsername, + password: cfg.MailPassword, + from: cfg.MailFrom, + } +} + +// Send sends an email message via SMTP +func (c *Client) Send(msg Message) error { + if c.host == "" { + logger.Warn("SMTP Host nicht konfiguriert - E-Mail-Versand übersprungen") + return fmt.Errorf("SMTP host not configured") + } + + logger.Debug("Sende E-Mail an %s via SMTP %s:%d", msg.To, c.host, c.port) + + // Prepare email headers and body + headers := make(map[string]string) + headers["From"] = c.from + headers["To"] = msg.To + headers["Subject"] = msg.Subject + headers["MIME-Version"] = "1.0" + headers["Content-Type"] = "text/html; charset=\"UTF-8\"" + + // Build message + message := "" + for key, value := range headers { + message += fmt.Sprintf("%s: %s\r\n", key, value) + } + message += "\r\n" + msg.Body + + // Connect to SMTP server + serverAddr := fmt.Sprintf("%s:%d", c.host, c.port) + + // Create TLS config + tlsConfig := &tls.Config{ + ServerName: c.host, + InsecureSkipVerify: false, + } + + var err error + var client *smtp.Client + + // Port 465 requires direct TLS connection, port 587 uses STARTTLS + if c.port == 465 { + // Direct TLS connection (implicit SSL) + conn, err := tls.Dial("tcp", serverAddr, tlsConfig) + if err != nil { + logger.Error("Fehler beim Verbinden mit SMTP-Server (TLS): %s", err.Error()) + return fmt.Errorf("failed to connect to SMTP server: %w", err) + } + defer conn.Close() + + client, err = smtp.NewClient(conn, c.host) + if err != nil { + logger.Error("Fehler beim Erstellen des SMTP-Clients: %s", err.Error()) + return fmt.Errorf("failed to create SMTP client: %w", err) + } + } else { + // Port 587 or other - use STARTTLS + client, err = smtp.Dial(serverAddr) + if err != nil { + logger.Error("Fehler beim Verbinden mit SMTP-Server: %s", err.Error()) + return fmt.Errorf("failed to dial SMTP server: %w", err) + } + defer client.Close() + + // Start TLS if available + if ok, _ := client.Extension("STARTTLS"); ok { + if err = client.StartTLS(tlsConfig); err != nil { + logger.Error("Fehler beim STARTTLS: %s", err.Error()) + return fmt.Errorf("failed to start TLS: %w", err) + } + } + } + + // Authenticate + if c.username != "" && c.password != "" { + auth := smtp.PlainAuth("", c.username, c.password, c.host) + if err = client.Auth(auth); err != nil { + logger.Error("Fehler bei der SMTP-Authentifizierung: %s", err.Error()) + return fmt.Errorf("failed to authenticate: %w", err) + } + } + + // Set sender + if err = client.Mail(c.from); err != nil { + logger.Error("Fehler beim Setzen des Absenders: %s", err.Error()) + return fmt.Errorf("failed to set sender: %w", err) + } + + // Set recipient + if err = client.Rcpt(msg.To); err != nil { + logger.Error("Fehler beim Setzen des Empfängers: %s", err.Error()) + return fmt.Errorf("failed to set recipient: %w", err) + } + + // Send message body + writer, err := client.Data() + if err != nil { + logger.Error("Fehler beim Öffnen des Data-Writers: %s", err.Error()) + return fmt.Errorf("failed to open data writer: %w", err) + } + + _, err = writer.Write([]byte(message)) + if err != nil { + logger.Error("Fehler beim Schreiben der Nachricht: %s", err.Error()) + writer.Close() + return fmt.Errorf("failed to write message: %w", err) + } + + err = writer.Close() + if err != nil { + logger.Error("Fehler beim Schließen des Data-Writers: %s", err.Error()) + return fmt.Errorf("failed to close data writer: %w", err) + } + + // Quit + if err = client.Quit(); err != nil { + logger.Error("Fehler beim Beenden der SMTP-Verbindung: %s", err.Error()) + return fmt.Errorf("failed to quit SMTP connection: %w", err) + } + + logger.Info("E-Mail erfolgreich an %s versendet", msg.To) + return nil +} + +// IsConfigured returns true if the mail client is properly configured +func (c *Client) IsConfigured() bool { + return c.host != "" && c.port > 0 && c.from != "" +} diff --git a/backend/mail/smtp_test.go b/backend/mail/smtp_test.go new file mode 100644 index 0000000..0e54e17 --- /dev/null +++ b/backend/mail/smtp_test.go @@ -0,0 +1,140 @@ +package mail + +import ( + "os" + "testing" + + "bamort/config" +) + +// setupTestEnvironment setzt die Test-Umgebung auf +func setupTestEnvironment(t *testing.T) { + original := os.Getenv("ENVIRONMENT") + os.Setenv("ENVIRONMENT", "test") + t.Cleanup(func() { + if original != "" { + os.Setenv("ENVIRONMENT", original) + } else { + os.Unsetenv("ENVIRONMENT") + } + }) + // Reload config with test environment + config.Cfg = config.LoadConfig() +} + +func TestNewClient(t *testing.T) { + setupTestEnvironment(t) + + // Set test mail config + os.Setenv("MAIL_HOST", "smtp.example.com") + os.Setenv("MAIL_PORT", "465") + os.Setenv("MAIL_USERNAME", "test@example.com") + os.Setenv("MAIL_PASSWORD", "testpass") + os.Setenv("MAIL_FROM", "sender@example.com") + + // Reload config + config.Cfg = config.LoadConfig() + + client := NewClient() + + if client.host != "smtp.example.com" { + t.Errorf("Expected host 'smtp.example.com', got '%s'", client.host) + } + if client.port != 465 { + t.Errorf("Expected port 465, got %d", client.port) + } + if client.username != "test@example.com" { + t.Errorf("Expected username 'test@example.com', got '%s'", client.username) + } + if client.from != "sender@example.com" { + t.Errorf("Expected from 'sender@example.com', got '%s'", client.from) + } +} + +func TestIsConfigured(t *testing.T) { + tests := []struct { + name string + client *Client + expected bool + }{ + { + name: "Fully configured", + client: &Client{ + host: "smtp.example.com", + port: 465, + from: "test@example.com", + }, + expected: true, + }, + { + name: "Missing host", + client: &Client{ + host: "", + port: 465, + from: "test@example.com", + }, + expected: false, + }, + { + name: "Missing port", + client: &Client{ + host: "smtp.example.com", + port: 0, + from: "test@example.com", + }, + expected: false, + }, + { + name: "Missing from", + client: &Client{ + host: "smtp.example.com", + port: 465, + from: "", + }, + expected: false, + }, + { + name: "Empty client", + client: &Client{ + host: "", + port: 0, + from: "", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.client.IsConfigured() + if result != tt.expected { + t.Errorf("Expected IsConfigured() to return %v, got %v", tt.expected, result) + } + }) + } +} + +func TestSend_NotConfigured(t *testing.T) { + setupTestEnvironment(t) + + // Create client without config + client := &Client{ + host: "", + port: 0, + from: "", + } + + msg := Message{ + To: "recipient@example.com", + Subject: "Test", + Body: "Test body", + } + + err := client.Send(msg) + if err == nil { + t.Error("Expected error when sending with unconfigured client, got nil") + } + if err.Error() != "SMTP host not configured" { + t.Errorf("Expected error 'SMTP host not configured', got '%s'", err.Error()) + } +} diff --git a/backend/user/handlers.go b/backend/user/handlers.go index 2903cab..7ab9ea0 100644 --- a/backend/user/handlers.go +++ b/backend/user/handlers.go @@ -7,6 +7,7 @@ package user import ( "bamort/logger" + "bamort/mail" "crypto/md5" "crypto/rand" "encoding/hex" @@ -239,8 +240,7 @@ func generateResetHash() (string, error) { return hex.EncodeToString(bytes), nil } -// sendResetEmail simuliert das Senden einer E-Mail (hier nur Logging) -// In einer echten Implementierung würde hier ein E-Mail-Service verwendet +// sendResetEmail sends a password reset email via SMTP func sendResetEmail(email, username, resetHash, frontendURL string) error { // Verwende die mitgegebene Frontend-URL oder fallback auf Standard baseURL := frontendURL @@ -250,25 +250,81 @@ func sendResetEmail(email, username, resetHash, frontendURL string) error { resetLink := fmt.Sprintf("%s/reset-password?token=%s", baseURL, resetHash) - logger.Info("=== PASSWORD RESET EMAIL ===") - logger.Info("An: %s", email) - logger.Info("Betreff: Passwort zurücksetzen für %s", username) - logger.Info("Nachricht:") - logger.Info("Hallo %s,", username) - logger.Info("") - logger.Info("Sie haben eine Passwort-Zurücksetzung angefordert.") - logger.Info("Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:") - logger.Info("") - logger.Info("%s", resetLink) - logger.Info("") - logger.Info("Dieser Link ist 14 Tage gültig.") - logger.Info("Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.") - logger.Info("") - logger.Info("=== END EMAIL ===") + // Create mail client + mailClient := mail.NewClient() - // TODO: Hier echte E-Mail-Integration hinzufügen - // z.B. SendGrid, SMTP, etc. + // If mail is not configured, fallback to logging + if !mailClient.IsConfigured() { + logger.Warn("SMTP nicht konfiguriert - E-Mail wird nur geloggt") + logger.Info("=== PASSWORD RESET EMAIL ===") + logger.Info("An: %s", email) + logger.Info("Betreff: Passwort zurücksetzen für %s", username) + logger.Info("Nachricht:") + logger.Info("Hallo %s,", username) + logger.Info("") + logger.Info("Sie haben eine Passwort-Zurücksetzung angefordert.") + logger.Info("Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:") + logger.Info("") + logger.Info("%s", resetLink) + logger.Info("") + logger.Info("Dieser Link ist 14 Tage gültig.") + logger.Info("Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.") + logger.Info("") + logger.Info("=== END EMAIL ===") + return nil + } + // Build HTML email body + htmlBody := fmt.Sprintf(` + + + + + + +
+
+

Passwort zurücksetzen

+
+
+

Hallo %s,

+

Sie haben eine Passwort-Zurücksetzung für Ihren Bamort-Account angefordert.

+

Klicken Sie auf den folgenden Button, um Ihr Passwort zurückzusetzen:

+

+ Passwort zurücksetzen +

+

Oder kopieren Sie diesen Link in Ihren Browser:

+

%s

+

Dieser Link ist 14 Tage gültig.

+

Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren. Ihr Passwort bleibt unverändert.

+
+ +
+ +`, username, resetLink, resetLink) + + // Send email + msg := mail.Message{ + To: email, + Subject: "Passwort zurücksetzen - Bamort", + Body: htmlBody, + } + + if err := mailClient.Send(msg); err != nil { + logger.Error("Fehler beim Versenden der Reset-E-Mail an %s: %s", email, err.Error()) + return err + } + + logger.Info("Password-Reset-E-Mail erfolgreich an %s versendet", email) return nil } diff --git a/docker/.env.dev b/docker/.env.dev index 9b170fc..bdc62ad 100644 --- a/docker/.env.dev +++ b/docker/.env.dev @@ -18,4 +18,10 @@ API_URL=http://192.168.0.48:8180 API_PORT=8180 BASE_URL=http://localhost:5173 TEMPLATES_DIR=./templates -EXPORT_TEMP_DIR=./export_temp \ No newline at end of file +EXPORT_TEMP_DIR=./export_temp + +# Mail Configuration (for development) +MAIL_HOST=mail.wuenscheonline.de +MAIL_PORT=465 +MAIL_USERNAME=bamort@trokan.de +MAIL_PASSWORD=XXXmhqbv.DW+XXX diff --git a/docker/.env.prd b/docker/.env.prd index be6d581..e29dc5c 100644 --- a/docker/.env.prd +++ b/docker/.env.prd @@ -18,3 +18,9 @@ API_PORT=8180 BASE_URL=https://bamort.trokan.de TEMPLATES_DIR=./templates EXPORT_TEMP_DIR=./export_temp + +# Mail Configuration (for production) +MAIL_HOST=mail.wuenscheonline.de +MAIL_PORT=465 +MAIL_USERNAME=bamort@trokan.de +MAIL_PASSWORD=XXXXmhqbv.DW+XXX