Release: v0.1.0-alpha
Release Docker Image / Build & Push Docker Image (release) Failing after 1m30s

This commit is contained in:
2026-04-05 16:56:16 +02:00
parent 23ff731579
commit fd13e67aef
89 changed files with 18786 additions and 0 deletions
+501
View File
@@ -0,0 +1,501 @@
// 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 := `<!DOCTYPE html>
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background-color: #f1f5f9;">
<div style="max-width: 500px; margin: 0 auto; background: #ffffff; border-radius: 8px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #206bc4; margin-top: 0;">&#x1F511; Keywarden SMTP Test</h2>
<p>This is a test email from Keywarden.</p>
<p style="color: #4caf50; font-weight: bold;">&#x2705; Your SMTP configuration is working correctly.</p>
</div>
</body>
</html>`
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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; background-color: #f1f5f9; -webkit-font-smoothing: antialiased;">
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #206bc4 0%, #1a56a0 100%); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">&#x1F511; Keywarden</h1>
<p style="color: rgba(255,255,255,0.8); margin: 8px 0 0; font-size: 14px;">Centralized SSH Key Management and Deployment</p>
</div>
<!-- Body -->
<div style="background: #ffffff; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<h2 style="color: #1e293b; margin-top: 0; font-size: 20px;">Login Notification</h2>
<p style="color: #475569; line-height: 1.6;">
A successful login to your Keywarden account was detected:
</p>
<table style="width: 100%; border-collapse: collapse; margin: 24px 0;">
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155; width: 140px;">User</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Username}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">IP Address</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.IPAddress}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Time</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Timestamp}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Browser</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.UserAgent}}</td>
</tr>
</table>
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #856404; font-size: 14px;">
&#x26A0;&#xFE0F; <strong>If this was not you</strong>, please change your password immediately and review your account security settings.
</p>
</div>
<p style="color: #94a3b8; font-size: 12px; margin-bottom: 0;">
You received this email because login notifications are enabled for your account. You can disable them in the Settings page.
</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 16px; color: #94a3b8; font-size: 12px;">
&copy; 2026 Keywarden &ndash; Centralized SSH Key Management and Deployment
</div>
</div>
</body>
</html>
`)
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(`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; background-color: #f1f5f9; -webkit-font-smoothing: antialiased;">
<div style="max-width: 600px; margin: 0 auto; padding: 24px;">
<!-- Header -->
<div style="background: linear-gradient(135deg, #206bc4 0%, #1a56a0 100%); border-radius: 12px 12px 0 0; padding: 32px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">&#x1F511; Keywarden</h1>
<p style="color: rgba(255,255,255,0.8); margin: 8px 0 0; font-size: 14px;">Centralized SSH Key Management and Deployment</p>
</div>
<!-- Body -->
<div style="background: #ffffff; padding: 32px; border-radius: 0 0 12px 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);">
<h2 style="color: #1e293b; margin-top: 0; font-size: 20px;">You have been invited!</h2>
<p style="color: #475569; line-height: 1.6;">
An account has been created for you in Keywarden. Please complete your registration by setting a password.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 24px 0;">
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155; width: 140px;">Username</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.Username}}</td>
</tr>
<tr>
<td style="padding: 12px 16px; background: #f8fafc; border: 1px solid #e2e8f0; font-weight: 600; color: #334155;">Valid for</td>
<td style="padding: 12px 16px; border: 1px solid #e2e8f0; color: #475569;">{{.ExpiresIn}}</td>
</tr>
</table>
<div style="text-align: center; margin: 32px 0;">
<a href="{{.InviteURL}}" style="display: inline-block; background: #206bc4; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
&#x1F680; Complete Registration
</a>
</div>
<div style="background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #0369a1; font-size: 14px;">
&#x1F6C8; If the button does not work, copy and paste this link into your browser:
</p>
<p style="margin: 8px 0 0; color: #0369a1; font-size: 12px; word-break: break-all;">{{.InviteURL}}</p>
</div>
<div style="background: #fff3cd; border: 1px solid #ffc107; border-radius: 8px; padding: 16px; margin: 24px 0;">
<p style="margin: 0; color: #856404; font-size: 14px;">
&#x26A0;&#xFE0F; <strong>This link is valid for {{.ExpiresIn}} and can only be used once.</strong> If you did not expect this invitation, you can safely ignore this email.
</p>
</div>
<p style="color: #94a3b8; font-size: 12px; margin-bottom: 0;">
This is an automated invitation from Keywarden.
</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 16px; color: #94a3b8; font-size: 12px;">
&copy; 2026 Keywarden &ndash; Centralized SSH Key Management and Deployment
</div>
</div>
</body>
</html>
`)
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
`)