Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates

This commit is contained in:
DmitrL-dev 2026-03-23 16:45:40 +10:00
parent 694e32be26
commit 41cbfd6e0a
178 changed files with 36008 additions and 399 deletions

View file

@ -0,0 +1,225 @@
// Package email provides email notification service for the SYNTREX SOC platform.
// Supports Resend (resend.com) as the primary transactional email provider.
package email
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
)
// Sender defines the email sending interface.
type Sender interface {
Send(to, subject, htmlBody string) error
}
// StubSender logs emails instead of sending them (development mode).
type StubSender struct{}
func (s *StubSender) Send(to, subject, htmlBody string) error {
slog.Info("email: stub send",
"to", to,
"subject", subject,
"body_len", len(htmlBody))
return nil
}
// ResendSender sends emails via Resend API (https://resend.com).
type ResendSender struct {
apiKey string
fromAddr string
client *http.Client
}
// NewResendSender creates a Resend email sender.
// apiKey format: "re_xxxxxxxxx"
// fromAddr example: "SYNTREX <noreply@xn--80akacl3adqr.xn--p1acf>"
func NewResendSender(apiKey, fromAddr string) *ResendSender {
return &ResendSender{
apiKey: apiKey,
fromAddr: fromAddr,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (s *ResendSender) Send(to, subject, htmlBody string) error {
payload := map[string]interface{}{
"from": s.fromAddr,
"to": []string{to},
"subject": subject,
"html": htmlBody,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("email: marshal payload: %w", err)
}
req, err := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("email: create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("email: send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
slog.Error("email: resend API error",
"status", resp.StatusCode,
"body", string(respBody),
"to", to,
"subject", subject)
return fmt.Errorf("email: resend API returned %d: %s", resp.StatusCode, string(respBody))
}
slog.Info("email: sent via Resend",
"to", to,
"subject", subject,
"status", resp.StatusCode)
return nil
}
// Template IDs for standard emails.
const (
TemplateWelcome = "welcome"
TemplatePasswordReset = "password_reset"
TemplateIncidentAlert = "incident_alert"
TemplatePlanUpgrade = "plan_upgrade"
TemplateInvoice = "invoice"
)
// Service provides email notifications with templates.
type Service struct {
sender Sender
fromName string
fromAddr string
}
// NewService creates an email service.
// Pass nil sender for stub mode (logs only).
// For Resend: NewService(NewResendSender(apiKey, from), "SYNTREX", "noreply@отражение.рус")
func NewService(sender Sender, fromName, fromAddr string) *Service {
if sender == nil {
sender = &StubSender{}
}
if fromName == "" {
fromName = "SYNTREX"
}
if fromAddr == "" {
fromAddr = "noreply@xn--80akacl3adqr.xn--p1acf"
}
return &Service{
sender: sender,
fromName: fromName,
fromAddr: fromAddr,
}
}
// SendVerificationCode sends a 6-digit verification code after registration.
func (s *Service) SendVerificationCode(toEmail, userName, code string) error {
subject := "SYNTREX — Код подтверждения"
body := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<body style="font-family: 'Inter', Arial, sans-serif; background: #0a0f1e; color: #e1e5ee; padding: 40px; margin: 0;">
<div style="max-width: 600px; margin: 0 auto; background: #111827; border-radius: 12px; padding: 32px; border: 1px solid #1e293b;">
<h1 style="color: #34d399; margin: 0 0 20px; font-size: 24px;">🛡 SYNTREX</h1>
<p style="margin: 0 0 8px;">Здравствуйте, <strong>%s</strong>!</p>
<p style="margin: 0 0 24px; color: #9ca3af;">Ваш код подтверждения email:</p>
<div style="background: #0a0f1e; border: 2px solid #34d399; border-radius: 8px; padding: 20px; text-align: center; margin: 0 0 24px;">
<span style="font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #34d399; font-family: monospace;">%s</span>
</div>
<p style="color: #9ca3af; font-size: 13px; margin: 0 0 8px;">Код действителен <strong>24 часа</strong>.</p>
<p style="color: #6b7280; font-size: 12px; margin: 24px 0 0; padding-top: 16px; border-top: 1px solid #1e293b;">
Если вы не регистрировались на SYNTREX проигнорируйте это письмо.
</p>
</div>
</body>
</html>`, userName, code)
return s.sender.Send(toEmail, subject, body)
}
// SendWelcome sends a welcome email after registration.
func (s *Service) SendWelcome(toEmail, userName, orgName string) error {
subject := "Добро пожаловать в SYNTREX"
body := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<body style="font-family: 'Inter', Arial, sans-serif; background: #0a0f1e; color: #e1e5ee; padding: 40px; margin: 0;">
<div style="max-width: 600px; margin: 0 auto; background: #111827; border-radius: 12px; padding: 32px; border: 1px solid #1e293b;">
<h1 style="color: #34d399; margin: 0 0 20px;">🛡 SYNTREX</h1>
<p>Здравствуйте, <strong>%s</strong>!</p>
<p>Ваша организация <strong>%s</strong> успешно зарегистрирована.</p>
<h3 style="color: #818cf8;">Как начать:</h3>
<ol>
<li>Откройте <strong>Quick Start</strong> в боковом меню</li>
<li>Создайте API-ключ в <strong>Настройки API Keys</strong></li>
<li>Отправьте первое событие безопасности</li>
</ol>
<p style="color: #9ca3af; font-size: 12px; margin-top: 30px;">
Это автоматическое письмо от SYNTREX. Если вы не регистрировались проигнорируйте.
</p>
</div>
</body>
</html>`, userName, orgName)
return s.sender.Send(toEmail, subject, body)
}
// SendIncidentAlert sends an alert when a critical incident is created.
func (s *Service) SendIncidentAlert(toEmail, incidentID, title, severity string) error {
subject := fmt.Sprintf("[SYNTREX] Инцидент %s: %s", severity, title)
body := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<body style="font-family: 'Inter', Arial, sans-serif; background: #0a0f1e; color: #e1e5ee; padding: 40px; margin: 0;">
<div style="max-width: 600px; margin: 0 auto; background: #111827; border-radius: 12px; padding: 32px; border: 1px solid #dc2626;">
<h1 style="color: #ef4444; margin: 0 0 20px;">🚨 Инцидент безопасности</h1>
<table style="width: 100%%; border-collapse: collapse;">
<tr><td style="color: #9ca3af; padding: 8px 0;">ID:</td><td><strong>%s</strong></td></tr>
<tr><td style="color: #9ca3af; padding: 8px 0;">Название:</td><td><strong>%s</strong></td></tr>
<tr><td style="color: #9ca3af; padding: 8px 0;">Критичность:</td><td style="color: #ef4444;"><strong>%s</strong></td></tr>
</table>
</div>
</body>
</html>`, incidentID, title, severity)
return s.sender.Send(toEmail, subject, body)
}
// SendPasswordReset sends a password reset link.
func (s *Service) SendPasswordReset(toEmail, resetToken string) error {
subject := "SYNTREX — Сброс пароля"
body := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<body style="font-family: 'Inter', Arial, sans-serif; background: #0a0f1e; color: #e1e5ee; padding: 40px; margin: 0;">
<div style="max-width: 600px; margin: 0 auto; background: #111827; border-radius: 12px; padding: 32px; border: 1px solid #1e293b;">
<h1 style="color: #60a5fa; margin: 0 0 20px;">🔐 Сброс пароля</h1>
<p>Вы запросили сброс пароля. Нажмите кнопку ниже:</p>
<p style="margin: 20px 0;">
<a href="https://xn--80akacl3adqr.xn--p1acf/reset-password?token=%s"
style="background: #2563eb; color: white; padding: 12px 28px; border-radius: 6px; text-decoration: none; font-weight: bold;">
Сбросить пароль
</a>
</p>
<p style="color: #9ca3af; font-size: 12px;">Ссылка действительна 1 час. Если вы не запрашивали сброс проигнорируйте.</p>
</div>
</body>
</html>`, resetToken)
return s.sender.Send(toEmail, subject, body)
}