From 6cb87c6868b94de7a99ef65865b8bc08b502696c Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 17:23:54 +0200 Subject: [PATCH 1/2] feat: replace m CLI email with direct SMTP over TLS The m CLI isn't available in Docker containers. Replace exec.Command("m", "mail", "send") with direct SMTP using crypto/tls + net/smtp (implicit TLS on port 465). Env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM Gracefully skips sending if SMTP is not configured. Note: mgmt@msbls.de rejected by Hostinger as not owned by mail@msbls.de. Default from address set to mail@msbls.de until alias is created. --- backend/internal/config/config.go | 13 +++ .../internal/services/notification_service.go | 91 ++++++++++++++++--- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 620a7f4..e649b27 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", "mail@msbls.de"), } if cfg.DatabaseURL == "" { diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 579d32e..0dcf987 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" + from = "mail@msbls.de" } - cmd := exec.Command("m", "mail", "send", - "--from", from, - "--to", to, - "--subject", subject, - "--body", body, - "--yes") - output, err := cmd.CombinedOutput() + + if host == "" || user == "" || pass == "" { + slog.Warn("SMTP not configured, skipping email", "to", to, "subject", subject) + return nil + } + + // 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 { - 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) + return fmt.Errorf("smtp tls dial: %w", err) } - slog.Info("email sent", "from", from, "to", to, "subject", subject) + + 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 } From e5387734aa423e1f65d0f23c2a86d69b36f41db0 Mon Sep 17 00:00:00 2001 From: m Date: Mon, 30 Mar 2026 17:28:11 +0200 Subject: [PATCH 2/2] fix: use mgmt@msbls.de as default MAIL_FROM (alias now exists) --- backend/internal/config/config.go | 2 +- backend/internal/services/notification_service.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e649b27..2c240f0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -38,7 +38,7 @@ func Load() (*Config, error) { SMTPPort: getEnv("SMTP_PORT", "465"), SMTPUser: os.Getenv("SMTP_USER"), SMTPPass: os.Getenv("SMTP_PASS"), - MailFrom: getEnv("MAIL_FROM", "mail@msbls.de"), + 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 0dcf987..74e881d 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -473,7 +473,7 @@ func SendEmail(to, subject, body string) error { port = "465" } if from == "" { - from = "mail@msbls.de" + from = "mgmt@msbls.de" } if host == "" || user == "" || pass == "" {