feat: add email notification service with SMTP support
Some checks failed
Build and Deploy GooSeek / build-and-deploy (push) Failing after 8m22s

- Create pkg/email package (sender, templates, types)
- SMTP client with TLS, rate limiting, async sending
- HTML email templates with GooSeek branding
- Integrate welcome + password reset emails in auth-svc
- Add limit warning emails (80%/100%) in llm-svc middleware
- Add space invite endpoint with email notification in thread-svc
- Add GetUserEmail helper in JWT middleware
- Add SMTP config to .env, config.go, K8s configmap

Made-with: Cursor
This commit is contained in:
home
2026-03-03 02:50:17 +03:00
parent 7a40ff629e
commit 52134df4d1
12 changed files with 767 additions and 92 deletions

View File

@@ -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 <noreply@gooseek.ru>
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/pkg/email/types.go` — новый
- `backend/cmd/llm-svc/main.go` — роутинг по тарифу, /embed endpoint - `backend/pkg/email/sender.go` — новый
- `backend/internal/llm/ollama.go` — qwen3.5:9b, убран токен, GenerateEmbedding - `backend/pkg/email/templates.go` — новый
- `backend/internal/llm/client.go` — убран OllamaToken - `backend/pkg/config/config.go` — SMTP конфиг
- `backend/deploy/k8s/ollama.yaml` — GPU + параллельность - `backend/pkg/middleware/jwt.go` — GetUserEmail()
- `backend/deploy/k8s/ollama-models.yaml` — без авторизации - `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 - IP: 5.187.77.89

View File

@@ -16,6 +16,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/gooseek/backend/internal/auth" "github.com/gooseek/backend/internal/auth"
"github.com/gooseek/backend/pkg/config" "github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/email"
_ "github.com/lib/pq" _ "github.com/lib/pq"
) )
@@ -73,6 +74,22 @@ func main() {
authRepo := auth.NewRepository(db) 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) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
if err := authRepo.RunMigrations(ctx); err != nil { if err := authRepo.RunMigrations(ctx); err != nil {
log.Printf("Migration warning: %v", err) log.Printf("Migration warning: %v", err)
@@ -150,6 +167,10 @@ func main() {
return c.Status(500).JSON(fiber.Map{"error": "Failed to generate tokens"}) 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) return c.Status(201).JSON(tokens)
}) })
@@ -331,7 +352,10 @@ func main() {
if err == nil && user != nil { if err == nil && user != nil {
token, err := authRepo.CreatePasswordResetToken(c.Context(), user.ID) token, err := authRepo.CreatePasswordResetToken(c.Context(), user.ID)
if err == nil { 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)
})
} }
} }

View File

@@ -15,6 +15,7 @@ import (
"github.com/gooseek/backend/internal/llm" "github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/usage" "github.com/gooseek/backend/internal/usage"
"github.com/gooseek/backend/pkg/config" "github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/email"
"github.com/gooseek/backend/pkg/metrics" "github.com/gooseek/backend/pkg/metrics"
"github.com/gooseek/backend/pkg/middleware" "github.com/gooseek/backend/pkg/middleware"
"github.com/gooseek/backend/pkg/ndjson" "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{ llmAPI := app.Group("/api/v1", middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret, Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL, AuthSvcURL: cfg.AuthSvcURL,
AllowGuest: false, AllowGuest: false,
}), middleware.LLMLimits(middleware.LLMLimitsConfig{ }), middleware.LLMLimits(middleware.LLMLimitsConfig{
UsageRepo: usageRepo, UsageRepo: usageRepo,
EmailSender: emailSender,
})) }))
llmAPI.Post("/generate", func(c *fiber.Ctx) error { llmAPI.Post("/generate", func(c *fiber.Ctx) error {

View File

@@ -16,6 +16,7 @@ import (
"github.com/gooseek/backend/internal/llm" "github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/pages" "github.com/gooseek/backend/internal/pages"
"github.com/gooseek/backend/pkg/config" "github.com/gooseek/backend/pkg/config"
"github.com/gooseek/backend/pkg/email"
"github.com/gooseek/backend/pkg/middleware" "github.com/gooseek/backend/pkg/middleware"
) )
@@ -79,6 +80,17 @@ func main() {
log.Fatal("DATABASE_URL required for thread-svc") 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 var llmClient llm.Client
if cfg.OpenAIAPIKey != "" { if cfg.OpenAIAPIKey != "" {
llmClient, err = llm.NewClient(llm.ProviderConfig{ llmClient, err = llm.NewClient(llm.ProviderConfig{
@@ -388,6 +400,88 @@ func main() {
return c.Status(204).Send(nil) 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{ memory := app.Group("/api/v1/memory", middleware.JWT(middleware.JWTConfig{
Secret: cfg.JWTSecret, Secret: cfg.JWTSecret,
AuthSvcURL: cfg.AuthSvcURL, AuthSvcURL: cfg.AuthSvcURL,

View File

@@ -41,6 +41,13 @@ data:
S3_USE_SSL: "${S3_USE_SSL}" S3_USE_SSL: "${S3_USE_SSL}"
S3_REGION: "${S3_REGION}" S3_REGION: "${S3_REGION}"
S3_PUBLIC_URL: "${S3_PUBLIC_URL}" 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 apiVersion: v1
kind: Secret kind: Secret
@@ -55,5 +62,6 @@ stringData:
JWT_SECRET: "${JWT_SECRET}" JWT_SECRET: "${JWT_SECRET}"
TIMEWEB_API_KEY: "${TIMEWEB_API_KEY}" TIMEWEB_API_KEY: "${TIMEWEB_API_KEY}"
OLLAMA_API_TOKEN: "${OLLAMA_API_TOKEN}" OLLAMA_API_TOKEN: "${OLLAMA_API_TOKEN}"
SMTP_PASSWORD: "${SMTP_PASSWORD}"
POSTGRES_USER: "gooseek" POSTGRES_USER: "gooseek"
POSTGRES_PASSWORD: "gooseek" POSTGRES_PASSWORD: "gooseek"

View File

@@ -156,6 +156,17 @@ func (r *Repository) GetUsageHistory(ctx context.Context, userID string, days in
return metrics, nil 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) { func (r *Repository) CheckLLMLimits(ctx context.Context, userID, tier string) (bool, string) {
usage, err := r.GetTodayUsage(ctx, userID) usage, err := r.GetTodayUsage(ctx, userID)
if err != nil { if err != nil {

View File

@@ -91,6 +91,16 @@ type Config struct {
ScrapeTimeout time.Duration ScrapeTimeout time.Duration
SearchTimeout time.Duration SearchTimeout time.Duration
// SMTP / Email
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPFrom string
SMTPTLS bool
SiteURL string
SiteName string
// CORS // CORS
AllowedOrigins []string AllowedOrigins []string
} }
@@ -174,6 +184,15 @@ func Load() (*Config, error) {
ScrapeTimeout: time.Duration(getEnvInt("SCRAPE_TIMEOUT_MS", 25000)) * time.Millisecond, ScrapeTimeout: time.Duration(getEnvInt("SCRAPE_TIMEOUT_MS", 25000)) * time.Millisecond,
SearchTimeout: time.Duration(getEnvInt("SEARCH_TIMEOUT_MS", 10000)) * 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 <noreply@gooseek.ru>"),
SMTPTLS: getEnv("SMTP_TLS", "true") == "true",
SiteURL: getEnv("SITE_URL", "https://gooseek.ru"),
SiteName: getEnv("SITE_NAME", "GooSeek"),
AllowedOrigins: parseOrigins(getEnv("ALLOWED_ORIGINS", "*")), AllowedOrigins: parseOrigins(getEnv("ALLOWED_ORIGINS", "*")),
} }

214
backend/pkg/email/sender.go Normal file
View File

@@ -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)
}
}()
}

View File

@@ -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 = "<p>Уведомление</p>"
}
return wrapBase(content, siteName, siteURL)
}
func wrapBase(content, siteName, siteURL string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f7;">
<tr><td align="center" style="padding:40px 20px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,#6366f1,#8b5cf6);padding:32px 40px;text-align:center;">
<a href="%s" style="text-decoration:none;color:#ffffff;font-size:28px;font-weight:700;letter-spacing:-0.5px;">%s</a>
</td></tr>
<!-- Content -->
<tr><td style="padding:40px;">
%s
</td></tr>
<!-- Footer -->
<tr><td style="padding:24px 40px;background-color:#f9fafb;border-top:1px solid #e5e7eb;text-align:center;">
<p style="margin:0 0 8px;font-size:13px;color:#9ca3af;">
Вы получили это письмо, потому что зарегистрированы на <a href="%s" style="color:#6366f1;text-decoration:none;">%s</a>
</p>
<p style="margin:0;font-size:12px;color:#d1d5db;">
<a href="%s/settings/notifications" style="color:#9ca3af;text-decoration:underline;">Настроить уведомления</a>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`, siteName, siteURL, siteName, content, siteURL, siteName, siteURL)
}
func renderWelcome(d WelcomeData) string {
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Добро пожаловать, %s!</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Мы рады, что вы присоединились. GooSeek — это ваш AI-помощник для поиска, анализа и работы с информацией.
</p>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Вот что вы можете делать:
</p>
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr><td style="padding:12px 16px;background:#f0fdf4;border-radius:8px;margin-bottom:8px;">
<span style="font-size:20px;">&#x1F50D;</span>
<span style="font-size:15px;color:#166534;margin-left:8px;">Умный поиск с AI-ответами</span>
</td></tr>
<tr><td style="height:8px;"></td></tr>
<tr><td style="padding:12px 16px;background:#eff6ff;border-radius:8px;">
<span style="font-size:20px;">&#x1F4AC;</span>
<span style="font-size:15px;color:#1e40af;margin-left:8px;">Чат с нейросетью</span>
</td></tr>
<tr><td style="height:8px;"></td></tr>
<tr><td style="padding:12px 16px;background:#fdf4ff;border-radius:8px;">
<span style="font-size:20px;">&#x1F4DA;</span>
<span style="font-size:15px;color:#7e22ce;margin-left:8px;">Библиотека и коллекции</span>
</td></tr>
</table>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Начать работу</a>
`, escapeHTML(d.Name), d.SiteURL)
}
func renderEmailVerify(d EmailVerifyData) string {
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Подтвердите email</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Здравствуйте, %s! Для завершения регистрации подтвердите ваш email-адрес.
</p>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Подтвердить email</a>
<p style="margin:24px 0 0;font-size:13px;color:#9ca3af;">
Если кнопка не работает, скопируйте ссылку:<br>
<a href="%s" style="color:#6366f1;word-break:break-all;">%s</a>
</p>
<p style="margin:16px 0 0;font-size:13px;color:#9ca3af;">Ссылка действительна 24 часа.</p>
`, escapeHTML(d.Name), d.VerifyURL, d.VerifyURL, d.VerifyURL)
}
func renderPasswordReset(d PasswordResetData) string {
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Сброс пароля</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Здравствуйте, %s! Мы получили запрос на сброс пароля для вашего аккаунта.
</p>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#ef4444,#dc2626);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Сбросить пароль</a>
<p style="margin:24px 0 0;font-size:13px;color:#9ca3af;">
Если кнопка не работает, скопируйте ссылку:<br>
<a href="%s" style="color:#6366f1;word-break:break-all;">%s</a>
</p>
<p style="margin:16px 0 0;font-size:13px;color:#9ca3af;">Ссылка действительна 1 час. Если вы не запрашивали сброс — проигнорируйте это письмо.</p>
`, 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(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Лимит запросов</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
Здравствуйте, %s! Вы использовали <strong>%d%%</strong> дневного лимита запросов на тарифе <strong>%s</strong>.
</p>
<!-- Progress bar -->
<div style="background:#e5e7eb;border-radius:8px;height:12px;margin-bottom:8px;overflow:hidden;">
<div style="background:%s;height:12px;width:%d%%;border-radius:8px;"></div>
</div>
<p style="margin:0 0 24px;font-size:14px;color:#6b7280;">%d из %d запросов</p>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Увеличить лимит</a>
`, escapeHTML(d.Name), d.Percentage, tierLabel, barColor, barWidth, d.UsageCount, d.LimitCount, d.UpgradeURL)
}
func renderSpaceInvite(d SpaceInviteData) string {
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">Приглашение в Space</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">
<strong>%s</strong> приглашает вас присоединиться к пространству <strong>«%s»</strong>.
</p>
<a href="%s" style="display:inline-block;padding:14px 32px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#ffffff;text-decoration:none;border-radius:8px;font-size:16px;font-weight:600;">Принять приглашение</a>
<p style="margin:24px 0 0;font-size:13px;color:#9ca3af;">
Если кнопка не работает, скопируйте ссылку:<br>
<a href="%s" style="color:#6366f1;word-break:break-all;">%s</a>
</p>
`, escapeHTML(d.InviterName), escapeHTML(d.SpaceName), d.InviteURL, d.InviteURL, d.InviteURL)
}
func renderSystemAlert(d SystemAlertData) string {
detailsBlock := ""
if d.Details != "" {
detailsBlock = fmt.Sprintf(`
<pre style="background:#f3f4f6;padding:16px;border-radius:8px;font-size:13px;color:#374151;overflow-x:auto;white-space:pre-wrap;">%s</pre>
`, escapeHTML(d.Details))
}
return fmt.Sprintf(`
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#111827;">%s</h1>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#4b5563;">%s</p>
%s
`, escapeHTML(d.Title), escapeHTML(d.Message), detailsBlock)
}
func escapeHTML(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
)
return r.Replace(s)
}

View File

@@ -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
}

View File

@@ -212,6 +212,14 @@ func GetUserTier(c *fiber.Ctx) string {
return user.Tier return user.Tier
} }
func GetUserEmail(c *fiber.Ctx) string {
user := GetUser(c)
if user == nil {
return ""
}
return user.Email
}
func RequireAuth() fiber.Handler { func RequireAuth() fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
user := GetUser(c) user := GetUser(c)

View File

@@ -3,14 +3,17 @@ package middleware
import ( import (
"context" "context"
"database/sql" "database/sql"
"log"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gooseek/backend/internal/usage" "github.com/gooseek/backend/internal/usage"
"github.com/gooseek/backend/pkg/email"
"github.com/gooseek/backend/pkg/metrics" "github.com/gooseek/backend/pkg/metrics"
) )
type LLMLimitsConfig struct { type LLMLimitsConfig struct {
UsageRepo *usage.Repository UsageRepo *usage.Repository
EmailSender *email.Sender
} }
func LLMLimits(config LLMLimitsConfig) fiber.Handler { func LLMLimits(config LLMLimitsConfig) fiber.Handler {
@@ -42,6 +45,8 @@ func LLMLimits(config LLMLimitsConfig) fiber.Handler {
} }
metrics.RecordRateLimitHit("llm-svc", clientIP, reason) metrics.RecordRateLimitHit("llm-svc", clientIP, reason)
sendLimitEmail(config, userID, tier, limits.LLMRequestsPerDay, limits.LLMRequestsPerDay)
return c.Status(429).JSON(fiber.Map{ return c.Status(429).JSON(fiber.Map{
"error": reason, "error": reason,
"tier": tier, "tier": tier,
@@ -50,12 +55,68 @@ func LLMLimits(config LLMLimitsConfig) fiber.Handler {
"upgradeUrl": "/settings/billing", "upgradeUrl": "/settings/billing",
}) })
} }
checkLimitWarning(config, userID, tier)
} }
return c.Next() 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 { type UsageTracker struct {
repo *usage.Repository repo *usage.Repository
} }