Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion cmd/laas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"context"
"flag"
"os"
"strconv"

"github.com/joho/godotenv"
"github.com/lestrrat-go/httprc/v3"
Expand All @@ -23,6 +24,7 @@ import (
"github.com/fossology/LicenseDb/pkg/api"
"github.com/fossology/LicenseDb/pkg/auth"
"github.com/fossology/LicenseDb/pkg/db"
"github.com/fossology/LicenseDb/pkg/email"
logger "github.com/fossology/LicenseDb/pkg/log"
"github.com/fossology/LicenseDb/pkg/utils"
"github.com/fossology/LicenseDb/pkg/validations"
Expand All @@ -37,9 +39,9 @@ func main() {
if err := godotenv.Load(".env"); err != nil {
logger.LogFatal("Error loading .env file", zap.Error(err))
}

flag.Parse()

// Check for mandatory environment variables
if os.Getenv("TOKEN_HOUR_LIFESPAN") == "" ||
os.Getenv("API_SECRET") == "" ||
os.Getenv("DEFAULT_ISSUER") == "" ||
Expand All @@ -65,6 +67,14 @@ func main() {
dbname := os.Getenv("DB_NAME")
password := os.Getenv("DB_PASSWORD")

// initialize email service
enableSMTP, _ := strconv.ParseBool(os.Getenv("ENABLE_SMTP"))
if enableSMTP {
if err := email.Init(); err != nil {
logger.LogFatal("Failed to initialize email service", zap.Error(err))
}
}
// connect to database
db.Connect(&dbhost, &port, &user, &dbname, &password)

if err := validations.RegisterValidations(); err != nil {
Expand Down
13 changes: 12 additions & 1 deletion configs/.env.dev.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,15 @@ DB_HOST=localhost
# This value can be adjusted based on the requirements of the similarity search
# A lower value will result in more matches, while a higher value will be more strict
# Default is set to 0.7, but can be changed to a higher value like 0.8 or 0.9 for stricter matching
SIMILARITY_THRESHOLD = 0.8
SIMILARITY_THRESHOLD = 0.8


# SMTP Configuration
ENABLE_SMTP=false
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
[email protected]
SMTP_PASSWORD=your_password
[email protected]


15 changes: 14 additions & 1 deletion configs/.env.test.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,17 @@ DB_HOST=localhost
# This value can be adjusted based on the requirements of the similarity search
# A lower value will result in more matches, while a higher value will be more strict
# Default is set to 0.7, but can be changed to a higher value like 0.8 or 0.9 for stricter matching
SIMILARITY_THRESHOLD = 0.8
SIMILARITY_THRESHOLD = 0.8




# SMTP Configuration
ENABLE_SMTP=false
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
[email protected]
SMTP_PASSWORD=your_password
[email protected]


14 changes: 14 additions & 0 deletions pkg/api/licenses.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import (
"time"

"github.com/fossology/LicenseDb/pkg/db"
"github.com/fossology/LicenseDb/pkg/email"
logger "github.com/fossology/LicenseDb/pkg/log"
"github.com/fossology/LicenseDb/pkg/models"
"github.com/fossology/LicenseDb/pkg/utils"
"github.com/fossology/LicenseDb/pkg/validations"
Expand Down Expand Up @@ -308,6 +310,12 @@ func CreateLicense(c *gin.Context) {
c.JSON(http.StatusInternalServerError, er)
return err
}
// Send notification email about license creation
if email.Email != nil {
email.NotifyLicenseCreated(*lic.User.UserEmail, *lic.User.UserName, *lic.Shortname)
} else {
logger.LogInfo("Email service is not enabled; skipping notification email sending")
}

res := models.LicenseResponse{
Data: []models.LicenseResponseDTO{lic.ConvertToLicenseResponseDTO()},
Expand Down Expand Up @@ -451,6 +459,12 @@ func UpdateLicense(c *gin.Context) {
c.JSON(http.StatusInternalServerError, er)
return err
}
// Send notification email about license update
if email.Email != nil {
email.NotifyLicenseUpdated(*newLicense.User.UserEmail, *newLicense.User.UserName, *newLicense.Shortname)
} else {
logger.LogInfo("Email service is not enabled; skipping notification email sending")
}

res := models.LicenseResponse{
Data: []models.LicenseResponseDTO{newLicense.ConvertToLicenseResponseDTO()},
Expand Down
22 changes: 22 additions & 0 deletions pkg/email/admin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2025 Chayan Das <[email protected]>
// SPDX-License-Identifier: GPL-2.0-only

package email

import (
"github.com/fossology/LicenseDb/pkg/db"
"github.com/fossology/LicenseDb/pkg/models"
)

func FetchAdminEmails() ([]string, error) {
var emails []string
admin := "ADMIN"
err := db.DB.
Model(&models.User{}).
Where(&models.User{UserLevel: &admin}). // can add super_admin too
Pluck("user_email", &emails).Error
if err != nil {
return nil, err
}
return emails, nil
}
229 changes: 229 additions & 0 deletions pkg/email/email_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// SPDX-FileCopyrightText: 2025 Chayan Das <[email protected]>
// SPDX-License-Identifier: GPL-2.0-only

package email

import (
"context"
"errors"
"fmt"
"os"
"strconv"
"sync"
"time"

"go.uber.org/zap"
"gopkg.in/gomail.v2"
)

type EmailData struct {
To []string
Cc []string
Bcc []string
Subject string
HTML string
}

type EmailService interface {
enqueue(ctx context.Context, email EmailData) error
enqueueAsync(data EmailData)
Start()
Stop(ctx context.Context) error
}

type AsyncEmailService struct {
queue chan EmailData
from string
user string
pass string
host string
port int
logger *zap.Logger
wg sync.WaitGroup
maxRetries int
retryDelay time.Duration
}

const (
defaultQueueSize = 200
defaultWorkerCount = 5
defaultMaxRetries = 3
defaultRetryDelay = 5 * time.Second
)

func NewEmailService(from, smtpUser, pass, host string, port int, opts ...func(*AsyncEmailService)) EmailService {
logger, _ := zap.NewProduction()

s := &AsyncEmailService{
queue: make(chan EmailData, defaultQueueSize),
from: from,
user: smtpUser,
pass: pass,
host: host,
port: port,
logger: logger,
maxRetries: defaultMaxRetries,
retryDelay: defaultRetryDelay,
}

for _, o := range opts {
o(s)
}

return s
}

// Start workers
func (s *AsyncEmailService) Start() {
s.logger.Info("Starting email service",
zap.Int("workers", defaultWorkerCount),
zap.Int("queue_capacity", cap(s.queue)),
zap.Int("max_retries", s.maxRetries),
zap.Duration("retry_delay", s.retryDelay),
)

for i := 0; i < defaultWorkerCount; i++ {
s.wg.Add(1)
go s.worker(i)
}
}

// enqueue enqueues email data for processing with context
func (s *AsyncEmailService) enqueue(ctx context.Context, email EmailData) error {
if len(email.To) == 0 {
return errors.New("missing recipient")
}

select {
case <-ctx.Done():
return ctx.Err()
case s.queue <- email:
return nil
}
}

// enqueueAsync enqueues email data for asynchronous processing( without context )
func (s *AsyncEmailService) enqueueAsync(data EmailData) {
if Email == nil {
return
}

go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = s.enqueue(ctx, data)
}()
}

func (s *AsyncEmailService) worker(id int) {
defer s.wg.Done()
s.logger.Info("worker started", zap.Int("id", id))

for email := range s.queue {
s.processWithRetries(email)
}

s.logger.Info("worker stopped", zap.Int("id", id))
}

func (s *AsyncEmailService) processWithRetries(job EmailData) {
for attempt := 1; attempt <= s.maxRetries; attempt++ {
if err := s.doSend(job); err != nil {
s.logger.Warn("email send failed",
zap.Int("attempt", attempt),
zap.Strings("to", job.To),
zap.Error(err),
)

if attempt < s.maxRetries {
time.Sleep(s.retryDelay)
}
continue
}

s.logger.Info("email sent successfully",
zap.Strings("to", job.To),
zap.String("subject", job.Subject),
)
return
}

s.logger.Error("email permanently failed after retries",
zap.Strings("to", job.To),
)
}

func (s *AsyncEmailService) doSend(job EmailData) error {
msg := gomail.NewMessage()
msg.SetHeader("From", s.from)
msg.SetHeader("To", job.To...)
if len(job.Cc) > 0 {
msg.SetHeader("Cc", job.Cc...)
}
if len(job.Bcc) > 0 {
msg.SetHeader("Bcc", job.Bcc...)
}
msg.SetHeader("Subject", job.Subject)
msg.SetBody("text/html", job.HTML)

username := s.user
if username == "" {
username = s.from
}

// Set up the dialer
d := gomail.NewDialer(s.host, s.port, username, s.pass)

// Send the email
if err := d.DialAndSend(msg); err != nil {
return fmt.Errorf("gomail error: %w", err)
}
return nil
}

// Stop gracefully shuts down the workers
func (s *AsyncEmailService) Stop(ctx context.Context) error {
close(s.queue)

done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()

select {
case <-done:
s.logger.Info("email service stopped gracefully")
return nil
case <-ctx.Done():
return ctx.Err()
}
}

var Email EmailService

func Init() error {
user := os.Getenv("SMTP_USER")
pass := os.Getenv("SMTP_PASSWORD")
host := os.Getenv("SMTP_HOST")
portStr := os.Getenv("SMTP_PORT")
from := os.Getenv("SMTP_FROM")

if from == "" {
from = user
}

if user == "" || pass == "" || host == "" || portStr == "" || from == "" {
return fmt.Errorf("missing SMTP environment variables")
}

port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("invalid SMTP_PORT: %w", err)
}

svc := NewEmailService(from, user, pass, host, port)
Email = svc
Email.Start()
return nil
}
Loading
Loading