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,
|
||||
|
||||
Reference in New Issue
Block a user