diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 620a7f4..2c240f0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -14,6 +14,13 @@ type Config struct { SupabaseJWTSecret string AnthropicAPIKey string FrontendOrigin string + + // SMTP settings (optional — email sending disabled if SMTPHost is empty) + SMTPHost string + SMTPPort string + SMTPUser string + SMTPPass string + MailFrom string } func Load() (*Config, error) { @@ -26,6 +33,12 @@ func Load() (*Config, error) { SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"), + + SMTPHost: os.Getenv("SMTP_HOST"), + SMTPPort: getEnv("SMTP_PORT", "465"), + SMTPUser: os.Getenv("SMTP_USER"), + SMTPPass: os.Getenv("SMTP_PASS"), + MailFrom: getEnv("MAIL_FROM", "mgmt@msbls.de"), } if cfg.DatabaseURL == "" { diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 579d32e..74e881d 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -2,10 +2,12 @@ package services import ( "context" + "crypto/tls" "fmt" "log/slog" + "net" + "net/smtp" "os" - "os/exec" "strings" "sync" "time" @@ -458,26 +460,85 @@ type UpdatePreferencesInput struct { DailyDigest bool `json:"daily_digest"` } -// SendEmail sends an email using the `m mail send` CLI command. -// In Docker, this will fail gracefully (m CLI not available). -// TODO: Replace with direct SMTP for production. +// SendEmail sends an email via direct SMTP over TLS. +// Requires SMTP_HOST, SMTP_USER, SMTP_PASS env vars. Falls back to no-op if unconfigured. func SendEmail(to, subject, body string) error { + host := os.Getenv("SMTP_HOST") + port := os.Getenv("SMTP_PORT") + user := os.Getenv("SMTP_USER") + pass := os.Getenv("SMTP_PASS") from := os.Getenv("MAIL_FROM") + + if port == "" { + port = "465" + } if from == "" { from = "mgmt@msbls.de" } - cmd := exec.Command("m", "mail", "send", - "--from", from, - "--to", to, - "--subject", subject, - "--body", body, - "--yes") - output, err := cmd.CombinedOutput() - if err != nil { - slog.Warn("email send failed (m CLI may not be available in Docker)", "to", to, "error", err, "output", string(output)) - return fmt.Errorf("m mail send failed: %w", err) + + if host == "" || user == "" || pass == "" { + slog.Warn("SMTP not configured, skipping email", "to", to, "subject", subject) + return nil } - slog.Info("email sent", "from", from, "to", to, "subject", subject) + + // Build RFC 2822 message + msg := fmt.Sprintf("From: \"KanzlAI-mGMT\" <%s>\r\n"+ + "To: %s\r\n"+ + "Subject: [KanzlAI] %s\r\n"+ + "MIME-Version: 1.0\r\n"+ + "Content-Type: text/plain; charset=utf-8\r\n"+ + "Date: %s\r\n"+ + "\r\n%s", + from, to, subject, + time.Now().Format(time.RFC1123Z), + body) + + addr := net.JoinHostPort(host, port) + + // Connect with implicit TLS (port 465) + tlsConfig := &tls.Config{ServerName: host} + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("smtp tls dial: %w", err) + } + + client, err := smtp.NewClient(conn, host) + if err != nil { + conn.Close() + return fmt.Errorf("smtp new client: %w", err) + } + defer client.Close() + + // Authenticate + auth := smtp.PlainAuth("", user, pass, host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + + // Send + if err := client.Mail(from); err != nil { + return fmt.Errorf("smtp mail from: %w", err) + } + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("smtp rcpt to: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err := w.Write([]byte(msg)); err != nil { + return fmt.Errorf("smtp write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp close data: %w", err) + } + + if err := client.Quit(); err != nil { + slog.Warn("smtp quit error (non-fatal)", "error", err) + } + + slog.Info("email sent via SMTP", "from", from, "to", to, "subject", subject) return nil }