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

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