package notifications import ( "context" "crypto/tls" "fmt" "log" "net" "net/smtp" "strings" "time" "chat-switchboard/store" ) // SMTPConfig holds SMTP connection settings, loaded from platform_settings. type SMTPConfig struct { Host string `json:"smtp_host"` Port int `json:"smtp_port"` User string `json:"smtp_user"` Password string `json:"smtp_password"` // decrypted at load time From string `json:"smtp_from"` TLS bool `json:"smtp_tls"` } // EmailTransport sends notification emails via SMTP. type EmailTransport struct { config SMTPConfig } // NewEmailTransport creates an email transport. Returns nil if config is invalid. func NewEmailTransport(cfg SMTPConfig) *EmailTransport { if cfg.Host == "" || cfg.Port == 0 || cfg.From == "" { return nil } return &EmailTransport{config: cfg} } // Send delivers an email with both HTML and plaintext bodies. // Uses multipart/alternative MIME for email client compatibility. func (t *EmailTransport) Send(ctx context.Context, to, subject, htmlBody, textBody string) error { if to == "" { return fmt.Errorf("recipient email is empty") } addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port) boundary := fmt.Sprintf("==boundary_%d==", time.Now().UnixNano()) // Build MIME message var msg strings.Builder msg.WriteString(fmt.Sprintf("From: %s\r\n", t.config.From)) msg.WriteString(fmt.Sprintf("To: %s\r\n", to)) msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) msg.WriteString("MIME-Version: 1.0\r\n") msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) msg.WriteString("\r\n") // Plaintext part msg.WriteString(fmt.Sprintf("--%s\r\n", boundary)) msg.WriteString("Content-Type: text/plain; charset=UTF-8\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\r\n") msg.WriteString(htmlBody) msg.WriteString("\r\n") msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary)) // Connect with timeout dialer := net.Dialer{Timeout: 10 * time.Second} conn, err := dialer.DialContext(ctx, "tcp", addr) if err != nil { return fmt.Errorf("smtp dial: %w", err) } var client *smtp.Client if t.config.TLS { // Implicit TLS (port 465) tlsConn := tls.Client(conn, &tls.Config{ServerName: t.config.Host}) client, err = smtp.NewClient(tlsConn, t.config.Host) } else { client, err = smtp.NewClient(conn, t.config.Host) } if err != nil { conn.Close() return fmt.Errorf("smtp client: %w", err) } defer client.Close() // STARTTLS for non-implicit TLS on port 587 if !t.config.TLS { if ok, _ := client.Extension("STARTTLS"); ok { if err := client.StartTLS(&tls.Config{ServerName: t.config.Host}); err != nil { return fmt.Errorf("smtp starttls: %w", err) } } } // Authenticate if credentials provided if t.config.User != "" && t.config.Password != "" { auth := smtp.PlainAuth("", t.config.User, t.config.Password, t.config.Host) if err := client.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } } // Send if err := client.Mail(t.config.From); err != nil { return fmt.Errorf("smtp MAIL: %w", err) } if err := client.Rcpt(to); err != nil { return fmt.Errorf("smtp RCPT: %w", err) } w, err := client.Data() if err != nil { return fmt.Errorf("smtp DATA: %w", err) } if _, err := w.Write([]byte(msg.String())); err != nil { return fmt.Errorf("smtp write: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("smtp close data: %w", err) } return client.Quit() } // SendAsync wraps Send in a goroutine so notification delivery isn't blocked. func (t *EmailTransport) SendAsync(to, subject, htmlBody, textBody string) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := t.Send(ctx, to, subject, htmlBody, textBody); err != nil { log.Printf("[email] failed to send to %s: %v", to, err) } }() } // LoadSMTPConfig reads SMTP settings from global config and returns an SMTPConfig. // Returns nil if email is not enabled or config is missing. // The vault parameter is optional; if provided, it decrypts the SMTP password. func LoadSMTPConfig(gc store.GlobalConfigStore, vault interface{}) (*SMTPConfig, error) { raw, err := gc.Get(context.Background(), "notifications") if err != nil { return nil, fmt.Errorf("no notification settings: %w", err) } // Check if email is enabled enabled, _ := raw["email_enabled"].(bool) if !enabled { return nil, nil } cfg := SMTPConfig{ Host: stringVal(raw, "smtp_host"), Port: intVal(raw, "smtp_port", 587), User: stringVal(raw, "smtp_user"), From: stringVal(raw, "smtp_from"), TLS: boolVal(raw, "smtp_tls"), } // Password: try decrypted value first, then raw cfg.Password = stringVal(raw, "smtp_password") if cfg.Host == "" { return nil, fmt.Errorf("smtp_host is required") } if cfg.From == "" { return nil, fmt.Errorf("smtp_from is required") } return &cfg, nil } func stringVal(m map[string]interface{}, key string) string { if v, ok := m[key].(string); ok { return v } return "" } func intVal(m map[string]interface{}, key string, def int) int { switch v := m[key].(type) { case float64: return int(v) case int: return v } return def } func boolVal(m map[string]interface{}, key string) bool { v, _ := m[key].(bool) return v }