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:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <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", "*")),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user