diff --git a/CONTINUE.md b/CONTINUE.md index 093362b..dde0d54 100644 --- a/CONTINUE.md +++ b/CONTINUE.md @@ -1,97 +1,51 @@ -# LLM Routing по тарифам ✅ +# Email Notification Service -## Архитектура +## Статус: Готово +## Что реализовано + +### Пакет `backend/pkg/email/` +- `types.go` — типы уведомлений (Welcome, PasswordReset, LimitWarning, SpaceInvite, SystemAlert) +- `sender.go` — SMTP клиент с TLS, rate limiting (1 письмо/тип/24ч), async отправка +- `templates.go` — HTML шаблоны с брендингом GooSeek + +### Интеграции + +| Сервис | Уведомления | Файл | +|--------|-------------|------| +| auth-svc | Welcome, Password Reset | `backend/cmd/auth-svc/main.go` | +| llm-svc | Limit Warning (80%), Limit Exceeded (100%) | `backend/pkg/middleware/llm_limits.go` | +| thread-svc | Space Invite | `backend/cmd/thread-svc/main.go` | + +### Новые API endpoints +- `POST /api/v1/spaces/:id/invite` — приглашение в Space по email +- `GET /api/v1/spaces/:id/invites` — список приглашений + +### Конфигурация +```env +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=noreply@gooseek.ru +SMTP_PASSWORD= +SMTP_FROM=GooSeek +SMTP_TLS=true +SITE_URL=https://gooseek.ru +SITE_NAME=GooSeek ``` -┌─────────────────────────────────────────────────────────┐ -│ llm-svc │ -│ │ -│ POST /api/v1/generate │ -│ │ │ -│ ▼ │ -│ ┌─────────────────┐ │ -│ │ resolveProvider │ │ -│ │ (tier) │ │ -│ └────────┬────────┘ │ -│ │ │ -│ ┌─────┴─────┐ │ -│ ▼ ▼ │ -│ ┌──────┐ ┌────────┐ │ -│ │ FREE │ │ PRO │ │ -│ └──┬───┘ └───┬────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ Ollama Timeweb │ -│ (local) (cloud) │ -└─────────────────────────────────────────────────────────┘ -``` - -## Роутинг по тарифам - -| Тариф | Провайдер | Модель | Лимиты | -|-------|-----------|--------|--------| -| **free** | Ollama (local) | qwen3.5:9b | 50 req/day, 2000 tokens/req | -| **pro** | Timeweb | gpt-4o, claude, etc | 500 req/day, 8000 tokens/req | -| **business** | Timeweb | all models | 5000 req/day, 32000 tokens/req | - -## API Endpoints - -### POST /api/v1/generate -```json -{ - "providerId": "auto", // или "ollama", "timeweb", etc - "key": "qwen3.5:9b", // модель - "messages": [{"role": "user", "content": "..."}], - "options": { - "maxTokens": 1000, - "temperature": 0.7, - "stream": true - } -} -``` - -### POST /api/v1/embed -```json -{ - "input": "Текст для эмбеддинга", - "model": "qwen3-embedding:0.6b" -} -``` - -### GET /api/v1/providers -Возвращает список доступных провайдеров с указанием tier. - ---- - -## Ollama конфигурация - -| Параметр | Значение | -|----------|----------| -| OLLAMA_NUM_PARALLEL | 4 | -| OLLAMA_MAX_LOADED_MODELS | 2 | -| OLLAMA_FLASH_ATTENTION | true | -| Модель генерации | qwen3.5:9b | -| Модель эмбеддингов | qwen3-embedding:0.6b | - -## Пропускная способность - -| Сценарий | Одновременно | RPM | -|----------|--------------|-----| -| Короткие ответы | 6-8 чел | ~40-60 | -| Средние ответы | 4-6 чел | ~20-30 | -| Эмбеддинги | 10+ чел | ~800+ | - ---- ## Файлы изменены - -- `backend/cmd/llm-svc/main.go` — роутинг по тарифу, /embed endpoint -- `backend/internal/llm/ollama.go` — qwen3.5:9b, убран токен, GenerateEmbedding -- `backend/internal/llm/client.go` — убран OllamaToken -- `backend/deploy/k8s/ollama.yaml` — GPU + параллельность -- `backend/deploy/k8s/ollama-models.yaml` — без авторизации - ---- +- `backend/pkg/email/types.go` — новый +- `backend/pkg/email/sender.go` — новый +- `backend/pkg/email/templates.go` — новый +- `backend/pkg/config/config.go` — SMTP конфиг +- `backend/pkg/middleware/jwt.go` — GetUserEmail() +- `backend/pkg/middleware/llm_limits.go` — email при лимитах +- `backend/internal/usage/repository.go` — GetUserEmail() +- `backend/cmd/auth-svc/main.go` — welcome + reset emails +- `backend/cmd/llm-svc/main.go` — emailSender в LLMLimits +- `backend/cmd/thread-svc/main.go` — invite endpoint + email +- `backend/deploy/k8s/configmap.yaml` — SMTP переменные +- `.env` — SMTP переменные ## Сервер - IP: 5.187.77.89 diff --git a/backend/cmd/auth-svc/main.go b/backend/cmd/auth-svc/main.go index f579a41..da1631d 100644 --- a/backend/cmd/auth-svc/main.go +++ b/backend/cmd/auth-svc/main.go @@ -16,6 +16,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/gooseek/backend/internal/auth" "github.com/gooseek/backend/pkg/config" + "github.com/gooseek/backend/pkg/email" _ "github.com/lib/pq" ) @@ -73,6 +74,22 @@ func main() { authRepo := auth.NewRepository(db) + emailSender := email.NewSender(email.SMTPConfig{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + User: cfg.SMTPUser, + Password: cfg.SMTPPassword, + From: cfg.SMTPFrom, + TLS: cfg.SMTPTLS, + SiteURL: cfg.SiteURL, + SiteName: cfg.SiteName, + }) + if emailSender.IsConfigured() { + log.Println("Email notifications enabled") + } else { + log.Println("Email notifications disabled (SMTP not configured)") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) if err := authRepo.RunMigrations(ctx); err != nil { log.Printf("Migration warning: %v", err) @@ -150,6 +167,10 @@ func main() { return c.Status(500).JSON(fiber.Map{"error": "Failed to generate tokens"}) } + emailSender.SendAsync(func() error { + return emailSender.SendWelcome(user.Email, user.Name) + }) + return c.Status(201).JSON(tokens) }) @@ -331,7 +352,10 @@ func main() { if err == nil && user != nil { token, err := authRepo.CreatePasswordResetToken(c.Context(), user.ID) if err == nil { - log.Printf("Password reset token for %s: %s", req.Email, token.Token) + resetURL := fmt.Sprintf("%s/reset-password?token=%s", cfg.SiteURL, token.Token) + emailSender.SendAsync(func() error { + return emailSender.SendPasswordReset(user.Email, user.Name, resetURL) + }) } } diff --git a/backend/cmd/llm-svc/main.go b/backend/cmd/llm-svc/main.go index abe2069..65b37c2 100644 --- a/backend/cmd/llm-svc/main.go +++ b/backend/cmd/llm-svc/main.go @@ -15,6 +15,7 @@ import ( "github.com/gooseek/backend/internal/llm" "github.com/gooseek/backend/internal/usage" "github.com/gooseek/backend/pkg/config" + "github.com/gooseek/backend/pkg/email" "github.com/gooseek/backend/pkg/metrics" "github.com/gooseek/backend/pkg/middleware" "github.com/gooseek/backend/pkg/ndjson" @@ -189,12 +190,24 @@ func main() { }) }) + emailSender := email.NewSender(email.SMTPConfig{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + User: cfg.SMTPUser, + Password: cfg.SMTPPassword, + From: cfg.SMTPFrom, + TLS: cfg.SMTPTLS, + SiteURL: cfg.SiteURL, + SiteName: cfg.SiteName, + }) + llmAPI := app.Group("/api/v1", middleware.JWT(middleware.JWTConfig{ Secret: cfg.JWTSecret, AuthSvcURL: cfg.AuthSvcURL, AllowGuest: false, }), middleware.LLMLimits(middleware.LLMLimitsConfig{ - UsageRepo: usageRepo, + UsageRepo: usageRepo, + EmailSender: emailSender, })) llmAPI.Post("/generate", func(c *fiber.Ctx) error { diff --git a/backend/cmd/thread-svc/main.go b/backend/cmd/thread-svc/main.go index 02b38f5..b97849d 100644 --- a/backend/cmd/thread-svc/main.go +++ b/backend/cmd/thread-svc/main.go @@ -16,6 +16,7 @@ import ( "github.com/gooseek/backend/internal/llm" "github.com/gooseek/backend/internal/pages" "github.com/gooseek/backend/pkg/config" + "github.com/gooseek/backend/pkg/email" "github.com/gooseek/backend/pkg/middleware" ) @@ -79,6 +80,17 @@ func main() { log.Fatal("DATABASE_URL required for thread-svc") } + emailSender := email.NewSender(email.SMTPConfig{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + User: cfg.SMTPUser, + Password: cfg.SMTPPassword, + From: cfg.SMTPFrom, + TLS: cfg.SMTPTLS, + SiteURL: cfg.SiteURL, + SiteName: cfg.SiteName, + }) + var llmClient llm.Client if cfg.OpenAIAPIKey != "" { llmClient, err = llm.NewClient(llm.ProviderConfig{ @@ -388,6 +400,88 @@ func main() { return c.Status(204).Send(nil) }) + spaces.Post("/:id/invite", func(c *fiber.Ctx) error { + spaceID := c.Params("id") + userID := middleware.GetUserID(c) + + space, err := spaceRepo.GetByID(c.Context(), spaceID) + if err != nil || space == nil { + return c.Status(404).JSON(fiber.Map{"error": "Space not found"}) + } + + if space.UserID != userID { + return c.Status(403).JSON(fiber.Map{"error": "Only space owner can invite"}) + } + + var req struct { + Email string `json:"email"` + Role string `json:"role"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Email == "" { + return c.Status(400).JSON(fiber.Map{"error": "Email is required"}) + } + + if req.Role == "" { + req.Role = "member" + } + + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to generate invite token"}) + } + inviteToken := hex.EncodeToString(tokenBytes) + + invite := &db.SpaceInvite{ + SpaceID: spaceID, + Email: req.Email, + Role: req.Role, + InvitedBy: userID, + Token: inviteToken, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + } + + if err := spaceRepo.CreateInvite(c.Context(), invite); err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to create invite"}) + } + + inviteURL := fmt.Sprintf("%s/spaces/join?token=%s", cfg.SiteURL, inviteToken) + inviterName := middleware.GetUserEmail(c) + + emailSender.SendAsync(func() error { + return emailSender.SendSpaceInvite(req.Email, inviterName, space.Name, inviteURL) + }) + + return c.Status(201).JSON(fiber.Map{ + "invite": invite, + "inviteUrl": inviteURL, + }) + }) + + spaces.Get("/:id/invites", func(c *fiber.Ctx) error { + spaceID := c.Params("id") + userID := middleware.GetUserID(c) + + space, err := spaceRepo.GetByID(c.Context(), spaceID) + if err != nil || space == nil { + return c.Status(404).JSON(fiber.Map{"error": "Space not found"}) + } + + if space.UserID != userID { + return c.Status(403).JSON(fiber.Map{"error": "Access denied"}) + } + + invites, err := spaceRepo.GetInvitesBySpace(c.Context(), spaceID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to get invites"}) + } + + return c.JSON(fiber.Map{"invites": invites}) + }) + memory := app.Group("/api/v1/memory", middleware.JWT(middleware.JWTConfig{ Secret: cfg.JWTSecret, AuthSvcURL: cfg.AuthSvcURL, diff --git a/backend/deploy/k8s/configmap.yaml b/backend/deploy/k8s/configmap.yaml index 5e85196..75b44e8 100644 --- a/backend/deploy/k8s/configmap.yaml +++ b/backend/deploy/k8s/configmap.yaml @@ -41,6 +41,13 @@ data: S3_USE_SSL: "${S3_USE_SSL}" S3_REGION: "${S3_REGION}" S3_PUBLIC_URL: "${S3_PUBLIC_URL}" + SMTP_HOST: "${SMTP_HOST}" + SMTP_PORT: "${SMTP_PORT}" + SMTP_USER: "${SMTP_USER}" + SMTP_FROM: "${SMTP_FROM}" + SMTP_TLS: "${SMTP_TLS}" + SITE_URL: "https://gooseek.ru" + SITE_NAME: "GooSeek" --- apiVersion: v1 kind: Secret @@ -55,5 +62,6 @@ stringData: JWT_SECRET: "${JWT_SECRET}" TIMEWEB_API_KEY: "${TIMEWEB_API_KEY}" OLLAMA_API_TOKEN: "${OLLAMA_API_TOKEN}" + SMTP_PASSWORD: "${SMTP_PASSWORD}" POSTGRES_USER: "gooseek" POSTGRES_PASSWORD: "gooseek" diff --git a/backend/internal/usage/repository.go b/backend/internal/usage/repository.go index e513d29..d7135df 100644 --- a/backend/internal/usage/repository.go +++ b/backend/internal/usage/repository.go @@ -156,6 +156,17 @@ func (r *Repository) GetUsageHistory(ctx context.Context, userID string, days in return metrics, nil } +func (r *Repository) GetUserEmail(ctx context.Context, userID string) (string, error) { + var emailAddr string + err := r.db.QueryRowContext(ctx, + "SELECT email FROM auth_users WHERE id = $1", userID, + ).Scan(&emailAddr) + if err != nil { + return "", err + } + return emailAddr, nil +} + func (r *Repository) CheckLLMLimits(ctx context.Context, userID, tier string) (bool, string) { usage, err := r.GetTodayUsage(ctx, userID) if err != nil { diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 31eca55..744e232 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -91,6 +91,16 @@ type Config struct { ScrapeTimeout time.Duration SearchTimeout time.Duration + // SMTP / Email + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPassword string + SMTPFrom string + SMTPTLS bool + SiteURL string + SiteName string + // CORS AllowedOrigins []string } @@ -174,6 +184,15 @@ func Load() (*Config, error) { ScrapeTimeout: time.Duration(getEnvInt("SCRAPE_TIMEOUT_MS", 25000)) * time.Millisecond, SearchTimeout: time.Duration(getEnvInt("SEARCH_TIMEOUT_MS", 10000)) * time.Millisecond, + SMTPHost: getEnv("SMTP_HOST", ""), + SMTPPort: getEnvInt("SMTP_PORT", 587), + SMTPUser: getEnv("SMTP_USER", ""), + SMTPPassword: getEnv("SMTP_PASSWORD", ""), + SMTPFrom: getEnv("SMTP_FROM", "GooSeek "), + SMTPTLS: getEnv("SMTP_TLS", "true") == "true", + SiteURL: getEnv("SITE_URL", "https://gooseek.ru"), + SiteName: getEnv("SITE_NAME", "GooSeek"), + AllowedOrigins: parseOrigins(getEnv("ALLOWED_ORIGINS", "*")), } diff --git a/backend/pkg/email/sender.go b/backend/pkg/email/sender.go new file mode 100644 index 0000000..41bdeac --- /dev/null +++ b/backend/pkg/email/sender.go @@ -0,0 +1,214 @@ +package email + +import ( + "crypto/tls" + "fmt" + "log" + "net/smtp" + "strings" + "sync" + "time" +) + +type Sender struct { + cfg SMTPConfig + mu sync.Mutex + lastSend map[string]time.Time +} + +func NewSender(cfg SMTPConfig) *Sender { + return &Sender{ + cfg: cfg, + lastSend: make(map[string]time.Time), + } +} + +func (s *Sender) IsConfigured() bool { + return s.cfg.Host != "" && s.cfg.User != "" && s.cfg.Password != "" +} + +func (s *Sender) SendWelcome(to, name string) error { + data := WelcomeData{ + Name: name, + SiteURL: s.cfg.SiteURL, + } + html := renderTemplate(TypeWelcome, data, s.cfg.SiteName, s.cfg.SiteURL) + return s.send(to, fmt.Sprintf("Добро пожаловать в %s!", s.cfg.SiteName), html) +} + +func (s *Sender) SendEmailVerification(to, name, verifyURL string) error { + data := EmailVerifyData{ + Name: name, + VerifyURL: verifyURL, + } + html := renderTemplate(TypeEmailVerify, data, s.cfg.SiteName, s.cfg.SiteURL) + return s.send(to, "Подтвердите ваш email", html) +} + +func (s *Sender) SendPasswordReset(to, name, resetURL string) error { + data := PasswordResetData{ + Name: name, + ResetURL: resetURL, + } + html := renderTemplate(TypePasswordReset, data, s.cfg.SiteName, s.cfg.SiteURL) + return s.send(to, "Сброс пароля", html) +} + +func (s *Sender) SendLimitWarning(to, name string, usageCount, limitCount int, tier string) error { + if s.isRateLimited(to, TypeLimitWarning) { + return nil + } + + percentage := 0 + if limitCount > 0 { + percentage = (usageCount * 100) / limitCount + } + + data := LimitWarningData{ + Name: name, + UsageCount: usageCount, + LimitCount: limitCount, + Percentage: percentage, + Tier: tier, + UpgradeURL: s.cfg.SiteURL + "/pricing", + } + + subject := fmt.Sprintf("Вы использовали %d%% лимита запросов", percentage) + if percentage >= 100 { + subject = "Лимит запросов исчерпан" + } + + html := renderTemplate(TypeLimitWarning, data, s.cfg.SiteName, s.cfg.SiteURL) + return s.send(to, subject, html) +} + +func (s *Sender) SendSpaceInvite(to, inviterName, spaceName, inviteURL string) error { + data := SpaceInviteData{ + InviterName: inviterName, + SpaceName: spaceName, + InviteURL: inviteURL, + } + html := renderTemplate(TypeSpaceInvite, data, s.cfg.SiteName, s.cfg.SiteURL) + return s.send(to, fmt.Sprintf("%s приглашает вас в Space «%s»", inviterName, spaceName), html) +} + +func (s *Sender) SendSystemAlert(to, title, message, details string) error { + data := SystemAlertData{ + Title: title, + Message: message, + Details: details, + } + html := renderTemplate(TypeSystemAlert, data, s.cfg.SiteName, s.cfg.SiteURL) + return s.send(to, fmt.Sprintf("[%s] %s", s.cfg.SiteName, title), html) +} + +func (s *Sender) send(to, subject, htmlBody string) error { + if !s.IsConfigured() { + log.Printf("[email] SMTP not configured, skipping email to %s: %s", to, subject) + return nil + } + + headers := map[string]string{ + "From": s.cfg.From, + "To": to, + "Subject": subject, + "MIME-Version": "1.0", + "Content-Type": "text/html; charset=UTF-8", + "X-Mailer": "GooSeek Notification Service", + "List-Unsubscribe": fmt.Sprintf("<%s/settings/notifications>", s.cfg.SiteURL), + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + } + + var msg strings.Builder + for k, v := range headers { + msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v)) + } + msg.WriteString("\r\n") + msg.WriteString(htmlBody) + + addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) + auth := smtp.PlainAuth("", s.cfg.User, s.cfg.Password, s.cfg.Host) + + var sendErr error + if s.cfg.TLS { + sendErr = s.sendTLS(addr, auth, to, msg.String()) + } else { + sendErr = smtp.SendMail(addr, auth, s.cfg.User, []string{to}, []byte(msg.String())) + } + + if sendErr != nil { + log.Printf("[email] Failed to send to %s: %v", to, sendErr) + return fmt.Errorf("failed to send email: %w", sendErr) + } + + log.Printf("[email] Sent '%s' to %s", subject, to) + return nil +} + +func (s *Sender) sendTLS(addr string, auth smtp.Auth, to, msg string) error { + tlsConfig := &tls.Config{ + ServerName: s.cfg.Host, + MinVersion: tls.VersionTLS12, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("TLS dial failed: %w", err) + } + + client, err := smtp.NewClient(conn, s.cfg.Host) + if err != nil { + conn.Close() + return fmt.Errorf("SMTP client creation failed: %w", err) + } + defer client.Close() + + if err := client.Auth(auth); err != nil { + return fmt.Errorf("SMTP auth failed: %w", err) + } + + if err := client.Mail(s.cfg.User); 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([]byte(msg)); err != nil { + return fmt.Errorf("SMTP write failed: %w", err) + } + + if err := w.Close(); err != nil { + return fmt.Errorf("SMTP close failed: %w", err) + } + + return client.Quit() +} + +func (s *Sender) isRateLimited(to string, notifType NotificationType) bool { + s.mu.Lock() + defer s.mu.Unlock() + + key := fmt.Sprintf("%s:%s", to, notifType) + if last, ok := s.lastSend[key]; ok { + if time.Since(last) < 24*time.Hour { + return true + } + } + s.lastSend[key] = time.Now() + return false +} + +func (s *Sender) SendAsync(fn func() error) { + go func() { + if err := fn(); err != nil { + log.Printf("[email] Async send error: %v", err) + } + }() +} diff --git a/backend/pkg/email/templates.go b/backend/pkg/email/templates.go new file mode 100644 index 0000000..be5ae9e --- /dev/null +++ b/backend/pkg/email/templates.go @@ -0,0 +1,209 @@ +package email + +import ( + "fmt" + "strings" +) + +func renderTemplate(notifType NotificationType, data interface{}, siteName, siteURL string) string { + var content string + + switch notifType { + case TypeWelcome: + d := data.(WelcomeData) + content = renderWelcome(d) + case TypeEmailVerify: + d := data.(EmailVerifyData) + content = renderEmailVerify(d) + case TypePasswordReset: + d := data.(PasswordResetData) + content = renderPasswordReset(d) + case TypeLimitWarning: + d := data.(LimitWarningData) + content = renderLimitWarning(d) + case TypeSpaceInvite: + d := data.(SpaceInviteData) + content = renderSpaceInvite(d) + case TypeSystemAlert: + d := data.(SystemAlertData) + content = renderSystemAlert(d) + default: + content = "

Уведомление

" + } + + return wrapBase(content, siteName, siteURL) +} + +func wrapBase(content, siteName, siteURL string) string { + return fmt.Sprintf(` + + + + +%s + + + + +
+ + + + + + + + + + + +
+%s +
+%s +
+

+Вы получили это письмо, потому что зарегистрированы на %s +

+

+Настроить уведомления +

+
+
+ +`, siteName, siteURL, siteName, content, siteURL, siteName, siteURL) +} + +func renderWelcome(d WelcomeData) string { + return fmt.Sprintf(` +

Добро пожаловать, %s!

+

+Мы рады, что вы присоединились. GooSeek — это ваш AI-помощник для поиска, анализа и работы с информацией. +

+

+Вот что вы можете делать: +

+ + + + + + +
+🔍 +Умный поиск с AI-ответами +
+💬 +Чат с нейросетью +
+📚 +Библиотека и коллекции +
+Начать работу +`, escapeHTML(d.Name), d.SiteURL) +} + +func renderEmailVerify(d EmailVerifyData) string { + return fmt.Sprintf(` +

Подтвердите email

+

+Здравствуйте, %s! Для завершения регистрации подтвердите ваш email-адрес. +

+Подтвердить email +

+Если кнопка не работает, скопируйте ссылку:
+%s +

+

Ссылка действительна 24 часа.

+`, escapeHTML(d.Name), d.VerifyURL, d.VerifyURL, d.VerifyURL) +} + +func renderPasswordReset(d PasswordResetData) string { + return fmt.Sprintf(` +

Сброс пароля

+

+Здравствуйте, %s! Мы получили запрос на сброс пароля для вашего аккаунта. +

+Сбросить пароль +

+Если кнопка не работает, скопируйте ссылку:
+%s +

+

Ссылка действительна 1 час. Если вы не запрашивали сброс — проигнорируйте это письмо.

+`, escapeHTML(d.Name), d.ResetURL, d.ResetURL, d.ResetURL) +} + +func renderLimitWarning(d LimitWarningData) string { + barColor := "#f59e0b" + if d.Percentage >= 100 { + barColor = "#ef4444" + } + + tierLabel := "Бесплатный" + switch d.Tier { + case "pro": + tierLabel = "Pro" + case "business": + tierLabel = "Business" + } + + barWidth := d.Percentage + if barWidth > 100 { + barWidth = 100 + } + + return fmt.Sprintf(` +

Лимит запросов

+

+Здравствуйте, %s! Вы использовали %d%% дневного лимита запросов на тарифе %s. +

+ + +
+
+
+

%d из %d запросов

+ +Увеличить лимит +`, escapeHTML(d.Name), d.Percentage, tierLabel, barColor, barWidth, d.UsageCount, d.LimitCount, d.UpgradeURL) +} + +func renderSpaceInvite(d SpaceInviteData) string { + return fmt.Sprintf(` +

Приглашение в Space

+

+%s приглашает вас присоединиться к пространству «%s». +

+Принять приглашение +

+Если кнопка не работает, скопируйте ссылку:
+%s +

+`, escapeHTML(d.InviterName), escapeHTML(d.SpaceName), d.InviteURL, d.InviteURL, d.InviteURL) +} + +func renderSystemAlert(d SystemAlertData) string { + detailsBlock := "" + if d.Details != "" { + detailsBlock = fmt.Sprintf(` +
%s
+`, escapeHTML(d.Details)) + } + + return fmt.Sprintf(` +

%s

+

%s

+%s +`, escapeHTML(d.Title), escapeHTML(d.Message), detailsBlock) +} + +func escapeHTML(s string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return r.Replace(s) +} diff --git a/backend/pkg/email/types.go b/backend/pkg/email/types.go new file mode 100644 index 0000000..f12916d --- /dev/null +++ b/backend/pkg/email/types.go @@ -0,0 +1,60 @@ +package email + +type NotificationType string + +const ( + TypeWelcome NotificationType = "welcome" + TypeEmailVerify NotificationType = "email_verify" + TypePasswordReset NotificationType = "password_reset" + TypeLimitWarning NotificationType = "limit_warning" + TypeLimitExceeded NotificationType = "limit_exceeded" + TypeSpaceInvite NotificationType = "space_invite" + TypeSystemAlert NotificationType = "system_alert" +) + +type SMTPConfig struct { + Host string + Port int + User string + Password string + From string + TLS bool + SiteURL string + SiteName string +} + +type WelcomeData struct { + Name string + SiteURL string +} + +type EmailVerifyData struct { + Name string + VerifyURL string +} + +type PasswordResetData struct { + Name string + ResetURL string +} + +type LimitWarningData struct { + Name string + UsageCount int + LimitCount int + Percentage int + Tier string + UpgradeURL string +} + +type SpaceInviteData struct { + InviterName string + SpaceName string + InviteURL string +} + +type SystemAlertData struct { + Title string + Message string + Details string +} diff --git a/backend/pkg/middleware/jwt.go b/backend/pkg/middleware/jwt.go index 8c1fde8..17d184f 100644 --- a/backend/pkg/middleware/jwt.go +++ b/backend/pkg/middleware/jwt.go @@ -212,6 +212,14 @@ func GetUserTier(c *fiber.Ctx) string { return user.Tier } +func GetUserEmail(c *fiber.Ctx) string { + user := GetUser(c) + if user == nil { + return "" + } + return user.Email +} + func RequireAuth() fiber.Handler { return func(c *fiber.Ctx) error { user := GetUser(c) diff --git a/backend/pkg/middleware/llm_limits.go b/backend/pkg/middleware/llm_limits.go index 7f16a3e..8d92f1a 100644 --- a/backend/pkg/middleware/llm_limits.go +++ b/backend/pkg/middleware/llm_limits.go @@ -3,14 +3,17 @@ package middleware import ( "context" "database/sql" + "log" "github.com/gofiber/fiber/v2" "github.com/gooseek/backend/internal/usage" + "github.com/gooseek/backend/pkg/email" "github.com/gooseek/backend/pkg/metrics" ) type LLMLimitsConfig struct { - UsageRepo *usage.Repository + UsageRepo *usage.Repository + EmailSender *email.Sender } func LLMLimits(config LLMLimitsConfig) fiber.Handler { @@ -42,6 +45,8 @@ func LLMLimits(config LLMLimitsConfig) fiber.Handler { } metrics.RecordRateLimitHit("llm-svc", clientIP, reason) + sendLimitEmail(config, userID, tier, limits.LLMRequestsPerDay, limits.LLMRequestsPerDay) + return c.Status(429).JSON(fiber.Map{ "error": reason, "tier": tier, @@ -50,12 +55,68 @@ func LLMLimits(config LLMLimitsConfig) fiber.Handler { "upgradeUrl": "/settings/billing", }) } + + checkLimitWarning(config, userID, tier) } return c.Next() } } +func checkLimitWarning(config LLMLimitsConfig, userID, tier string) { + if config.UsageRepo == nil || config.EmailSender == nil || !config.EmailSender.IsConfigured() { + return + } + + go func() { + todayUsage, err := config.UsageRepo.GetTodayUsage(context.Background(), userID) + if err != nil || todayUsage == nil { + return + } + + limits := usage.GetLimits(tier) + if limits.LLMRequestsPerDay == 0 { + return + } + + percentage := (todayUsage.LLMRequests * 100) / limits.LLMRequestsPerDay + if percentage >= 80 && percentage < 100 { + userEmail := getUserEmail(config.UsageRepo, userID) + if userEmail != "" { + if err := config.EmailSender.SendLimitWarning(userEmail, "", todayUsage.LLMRequests, limits.LLMRequestsPerDay, tier); err != nil { + log.Printf("[email] Limit warning send error: %v", err) + } + } + } + }() +} + +func sendLimitEmail(config LLMLimitsConfig, userID, tier string, usageCount, limitCount int) { + if config.EmailSender == nil || !config.EmailSender.IsConfigured() { + return + } + + go func() { + userEmail := getUserEmail(config.UsageRepo, userID) + if userEmail != "" { + if err := config.EmailSender.SendLimitWarning(userEmail, "", usageCount, limitCount, tier); err != nil { + log.Printf("[email] Limit exceeded email error: %v", err) + } + } + }() +} + +func getUserEmail(repo *usage.Repository, userID string) string { + if repo == nil { + return "" + } + emailAddr, err := repo.GetUserEmail(context.Background(), userID) + if err != nil { + return "" + } + return emailAddr +} + type UsageTracker struct { repo *usage.Repository }