feat: add email notification service with SMTP support
Some checks failed
Build and Deploy GooSeek / build-and-deploy (push) Failing after 8m22s
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:
132
CONTINUE.md
132
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 <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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
214
backend/pkg/email/sender.go
Normal 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
209
backend/pkg/email/templates.go
Normal file
209
backend/pkg/email/templates.go
Normal 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;">🔍</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;">💬</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;">📚</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(
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
`"`, """,
|
||||||
|
"'", "'",
|
||||||
|
)
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
60
backend/pkg/email/types.go
Normal file
60
backend/pkg/email/types.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user