From a7eb3cda813248d49b612c37a9f25182f8d058d7 Mon Sep 17 00:00:00 2001
From: Bardioc26 <13843924+Bardioc26@users.noreply.github.com>
Date: Tue, 17 Feb 2026 23:12:58 +0100
Subject: [PATCH] Password email not sent (#34)
* Sending mails from password reset
now we are really sending the mail
* Must set Mail configuration
---
backend/config/config.go | 34 ++++++++
backend/mail/smtp.go | 163 ++++++++++++++++++++++++++++++++++++++
backend/mail/smtp_test.go | 140 ++++++++++++++++++++++++++++++++
backend/user/handlers.go | 94 +++++++++++++++++-----
docker/.env.dev | 8 +-
docker/.env.prd | 6 ++
6 files changed, 425 insertions(+), 20 deletions(-)
create mode 100644 backend/mail/smtp.go
create mode 100644 backend/mail/smtp_test.go
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(`
+
+
+
+
+
+
+
+
+
+
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