// Keywarden - Centralized SSH Key Management and Deployment // Copyright (C) 2026 Patrick Asmus (scriptos) // SPDX-License-Identifier: AGPL-3.0-or-later package mail import ( "bytes" "crypto/tls" "fmt" "mime" "net" "net/smtp" "strings" "text/template" "time" "git.techniverse.net/scriptos/keywarden/internal/config" "git.techniverse.net/scriptos/keywarden/internal/logging" ) // LoginNotificationData holds the template data for a login notification email type LoginNotificationData struct { Username string IPAddress string Timestamp string UserAgent string } // InvitationData holds the template data for an invitation email type InvitationData struct { Username string InviteURL string ExpiresIn string } // Service handles email sending type Service struct { cfg *config.Config enabled bool } // NewService creates a new mail service func NewService(cfg *config.Config) *Service { enabled := cfg.SMTPEnabled && cfg.SMTPHost != "" if enabled { logging.Info("Email notifications enabled (SMTP: %s:%s)", cfg.SMTPHost, cfg.SMTPPort) } else { logging.Info("Email notifications disabled (no SMTP host configured)") } return &Service{ cfg: cfg, enabled: enabled, } } // IsEnabled returns whether the mail service is configured and active func (s *Service) IsEnabled() bool { return s.enabled } // SendLoginNotification sends a login notification email to the user. // This runs synchronously but callers should invoke it in a goroutine. func (s *Service) SendLoginNotification(toEmail string, data LoginNotificationData) error { if !s.enabled { return nil } htmlBody, err := renderTemplate(loginNotificationHTML, data) if err != nil { return fmt.Errorf("failed to render HTML template: %w", err) } txtBody, err := renderTemplate(loginNotificationTXT, data) if err != nil { return fmt.Errorf("failed to render TXT template: %w", err) } subject := fmt.Sprintf("Keywarden: Login notification for %s", data.Username) return s.sendMultipart(toEmail, subject, txtBody, htmlBody) } // SendTestEmail sends a test email to verify SMTP configuration func (s *Service) SendTestEmail(toEmail string) error { if !s.enabled { return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)") } subject := "Keywarden: SMTP Test Email" txtBody := "This is a test email from Keywarden.\n\nIf you received this, your SMTP configuration is working correctly.\n" htmlBody := `

🔑 Keywarden – SMTP Test

This is a test email from Keywarden.

✅ Your SMTP configuration is working correctly.

` return s.sendMultipart(toEmail, subject, txtBody, htmlBody) } // SendInvitation sends an invitation email with a registration link to a new user. func (s *Service) SendInvitation(toEmail string, data InvitationData) error { if !s.enabled { return fmt.Errorf("email is not configured (KEYWARDEN_SMTP_HOST not set)") } htmlBody, err := renderTemplate(invitationHTML, data) if err != nil { return fmt.Errorf("failed to render invitation HTML template: %w", err) } txtBody, err := renderTemplate(invitationTXT, data) if err != nil { return fmt.Errorf("failed to render invitation TXT template: %w", err) } subject := fmt.Sprintf("Keywarden: You have been invited – %s", data.Username) return s.sendMultipart(toEmail, subject, txtBody, htmlBody) } // sendMultipart sends a multipart (text + HTML) email func (s *Service) sendMultipart(to, subject, textBody, htmlBody string) error { logging.Info("Sending email: to=%s subject='%s' smtp=%s:%s", to, subject, s.cfg.SMTPHost, s.cfg.SMTPPort) logging.Debug("Email details: from=%s tls=%v", s.cfg.SMTPFrom, s.cfg.SMTPTLS) boundary := fmt.Sprintf("keywarden-%d", time.Now().UnixNano()) // Encode Subject per RFC 2047 so non-ASCII characters (e.g. en-dash) // are transmitted safely through all MTAs. encodedSubject := mime.QEncoding.Encode("utf-8", subject) headers := map[string]string{ "From": s.cfg.SMTPFrom, "To": to, "Subject": encodedSubject, "MIME-Version": "1.0", "Content-Type": fmt.Sprintf("multipart/alternative; boundary=\"%s\"", boundary), "Date": time.Now().Format(time.RFC1123Z), "X-Mailer": "Keywarden SSH Key Management", } var msg bytes.Buffer for k, v := range headers { msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) } msg.WriteString("\r\n") // Text part msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) msg.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n") msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n") msg.WriteString(textBody) msg.WriteString("\r\n") // HTML part msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) msg.WriteString("Content-Type: text/html; charset=\"utf-8\"\r\n") msg.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n") msg.WriteString(htmlBody) msg.WriteString("\r\n") msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) err := s.send(to, msg.Bytes()) if err != nil { logging.Error("Email delivery failed: to=%s error=%v", to, err) } else { logging.Info("Email delivered successfully: to=%s", to) } return err } // send delivers a raw email message via SMTP func (s *Service) send(to string, msg []byte) error { addr := net.JoinHostPort(s.cfg.SMTPHost, s.cfg.SMTPPort) var auth smtp.Auth if s.cfg.SMTPUser != "" { auth = smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPassword, s.cfg.SMTPHost) } if s.cfg.SMTPTLS { // STARTTLS or implicit TLS tlsConfig := &tls.Config{ ServerName: s.cfg.SMTPHost, MinVersion: tls.VersionTLS12, } // Try implicit TLS first (port 465), fall back to STARTTLS if s.cfg.SMTPPort == "465" { conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return fmt.Errorf("TLS dial failed: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, s.cfg.SMTPHost) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) } defer client.Close() if auth != nil { if err := client.Auth(auth); err != nil { return fmt.Errorf("SMTP auth failed: %w", err) } } if err := client.Mail(s.cfg.SMTPFrom); err != nil { return fmt.Errorf("SMTP MAIL FROM failed: %w", err) } if err := client.Rcpt(to); err != nil { return fmt.Errorf("SMTP RCPT TO failed: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("SMTP DATA failed: %w", err) } if _, err := w.Write(msg); err != nil { return fmt.Errorf("SMTP write failed: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("SMTP close data failed: %w", err) } return client.Quit() } // STARTTLS (port 587 etc.) conn, err := net.DialTimeout("tcp", addr, 10*time.Second) if err != nil { return fmt.Errorf("dial failed: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, s.cfg.SMTPHost) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) } defer client.Close() if err := client.StartTLS(tlsConfig); err != nil { return fmt.Errorf("STARTTLS failed: %w", err) } if auth != nil { if err := client.Auth(auth); err != nil { return fmt.Errorf("SMTP auth failed: %w", err) } } if err := client.Mail(s.cfg.SMTPFrom); err != nil { return fmt.Errorf("SMTP MAIL FROM failed: %w", err) } if err := client.Rcpt(to); err != nil { return fmt.Errorf("SMTP RCPT TO failed: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("SMTP DATA failed: %w", err) } if _, err := w.Write(msg); err != nil { return fmt.Errorf("SMTP write failed: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("SMTP close data failed: %w", err) } return client.Quit() } // Plain SMTP (no TLS) – use manual client to avoid Go's smtp.SendMail // automatically attempting STARTTLS when the server advertises it. conn, err := net.DialTimeout("tcp", addr, 10*time.Second) if err != nil { return fmt.Errorf("dial failed: %w", err) } defer conn.Close() client, err := smtp.NewClient(conn, s.cfg.SMTPHost) if err != nil { return fmt.Errorf("SMTP client creation failed: %w", err) } defer client.Close() if auth != nil { if err := client.Auth(auth); err != nil { return fmt.Errorf("SMTP auth failed: %w", err) } } if err := client.Mail(s.cfg.SMTPFrom); err != nil { return fmt.Errorf("SMTP MAIL FROM failed: %w", err) } if err := client.Rcpt(to); err != nil { return fmt.Errorf("SMTP RCPT TO failed: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("SMTP DATA failed: %w", err) } if _, err := w.Write(msg); err != nil { return fmt.Errorf("SMTP write failed: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("SMTP close data failed: %w", err) } return client.Quit() } func renderTemplate(tmplStr string, data interface{}) (string, error) { tmpl, err := template.New("email").Parse(tmplStr) if err != nil { return "", err } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return "", err } return buf.String(), nil } // --- Email Templates --- var loginNotificationHTML = strings.TrimSpace(`

🔑 Keywarden

Centralized SSH Key Management and Deployment

Login Notification

A successful login to your Keywarden account was detected:

User {{.Username}}
IP Address {{.IPAddress}}
Time {{.Timestamp}}
Browser {{.UserAgent}}

⚠️ If this was not you, please change your password immediately and review your account security settings.

You received this email because login notifications are enabled for your account. You can disable them in the Settings page.

© 2026 Keywarden – Centralized SSH Key Management and Deployment
`) var loginNotificationTXT = strings.TrimSpace(` Keywarden - Login Notification ============================ A successful login to your Keywarden account was detected: User: {{.Username}} IP Address: {{.IPAddress}} Time: {{.Timestamp}} Browser: {{.UserAgent}} If this was not you, please change your password immediately and review your account security settings. -- You received this email because login notifications are enabled for your account. You can disable them in the Settings page. Keywarden - Centralized SSH Key Management and Deployment `) var invitationHTML = strings.TrimSpace(`

🔑 Keywarden

Centralized SSH Key Management and Deployment

You have been invited!

An account has been created for you in Keywarden. Please complete your registration by setting a password.

Username {{.Username}}
Valid for {{.ExpiresIn}}
🚀 Complete Registration

🛈 If the button does not work, copy and paste this link into your browser:

{{.InviteURL}}

⚠️ This link is valid for {{.ExpiresIn}} and can only be used once. If you did not expect this invitation, you can safely ignore this email.

This is an automated invitation from Keywarden.

© 2026 Keywarden – Centralized SSH Key Management and Deployment
`) var invitationTXT = strings.TrimSpace(` Keywarden - Invitation ====================== You have been invited to Keywarden! An account has been created for you. Please complete your registration by setting a password. Username: {{.Username}} Valid for: {{.ExpiresIn}} Complete your registration here: {{.InviteURL}} This link is valid for {{.ExpiresIn}} and can only be used once. If you did not expect this invitation, you can safely ignore this email. -- Keywarden - Centralized SSH Key Management and Deployment `)