Skip to content
Merged
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
77 changes: 67 additions & 10 deletions internal/services/email/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ type DryRunResponse struct {

// ValidateSend runs all validation checks without persisting or sending.
func (s *Service) ValidateSend(ctx context.Context, userID uint, workspaceID *uint, userEmail string, req *SendRequest) (*DryRunResponse, error) {
if err := validateAddresses(req.From, req.To); err != nil {
return nil, err
}

if s.settings != nil && s.settings.MaintenanceMode() {
return nil, fmt.Errorf("maintenance: email sending is temporarily disabled")
}
Expand Down Expand Up @@ -373,6 +377,19 @@ func (s *Service) ValidateSendWithTemplate(ctx context.Context, userID uint, wor

// ValidateSendBatch validates a batch send request without sending.
func (s *Service) ValidateSendBatch(ctx context.Context, userID uint, workspaceID *uint, userEmail string, req *BatchRequest) (*DryRunResponse, error) {
from := req.From
if from == "" {
from = defaultFromAddress
}
if err := validateEmailAddress(from); err != nil {
return nil, fmt.Errorf("invalid sender: %w", err)
}
for _, r := range req.Recipients {
if err := validateEmailAddress(r.Email); err != nil {
return nil, fmt.Errorf("invalid recipient: %w", err)
}
}

maxBatch := 0
if s.planLimits != nil {
maxBatch = s.planLimits.EffectiveLimits(workspaceID).MaxBatchSize
Expand All @@ -388,11 +405,6 @@ func (s *Service) ValidateSendBatch(ctx context.Context, userID uint, workspaceI
return nil, fmt.Errorf("template not found: %w", err)
}

from := req.From
if from == "" {
from = defaultFromAddress
}

emails := make([]string, len(req.Recipients))
for i, r := range req.Recipients {
emails[i] = r.Email
Expand Down Expand Up @@ -421,6 +433,11 @@ func (s *Service) ValidateSendBatch(ctx context.Context, userID uint, workspaceI
}

func (s *Service) Send(ctx context.Context, userID, apiKeyID uint, workspaceID *uint, userEmail string, req *SendRequest) (*SendResponse, error) {
// Validate email addresses
if err := validateAddresses(req.From, req.To); err != nil {
return nil, err
}

// Enforce maintenance mode
if s.settings != nil && s.settings.MaintenanceMode() {
return nil, fmt.Errorf("maintenance: email sending is temporarily disabled")
Expand Down Expand Up @@ -744,16 +761,27 @@ func (s *Service) SendBatch(ctx context.Context, userID, apiKeyID uint, workspac
return nil, fmt.Errorf("batch size %d exceeds maximum allowed (%d)", len(req.Recipients), maxBatch)
}

tmpl, err := s.findTemplate(userID, workspaceID, req.TemplateID, req.Template)
if err != nil {
return nil, fmt.Errorf("template not found: %w", err)
}

from := req.From
if from == "" {
from = defaultFromAddress
}

// Validate sender address
if err := validateEmailAddress(from); err != nil {
return nil, fmt.Errorf("invalid sender: %w", err)
}
// Validate all recipient addresses
for _, r := range req.Recipients {
if err := validateEmailAddress(r.Email); err != nil {
return nil, fmt.Errorf("invalid recipient: %w", err)
}
}

tmpl, err := s.findTemplate(userID, workspaceID, req.TemplateID, req.Template)
if err != nil {
return nil, fmt.Errorf("template not found: %w", err)
}

resp := &BatchResponse{
Total: len(req.Recipients),
Results: make([]BatchResult, 0, len(req.Recipients)),
Expand Down Expand Up @@ -1143,6 +1171,35 @@ func (s *Service) checkDomainVerification(userID uint, from string) error {
return nil
}

// validateEmailAddress checks that a string is a valid RFC 5322 email address
func validateEmailAddress(addr string) error {
parsed, err := mail.ParseAddress(addr)
if err != nil {
return fmt.Errorf("invalid email address %q: %w", addr, err)
}
parts := strings.SplitN(parsed.Address, "@", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return fmt.Errorf("invalid email address %q: missing local part or domain", addr)
}
if !strings.Contains(parts[1], ".") {
return fmt.Errorf("invalid email address %q: domain has no TLD", addr)
}
return nil
}

// validateAddresses validates the From and all To addresses.
func validateAddresses(from string, to []string) error {
if err := validateEmailAddress(from); err != nil {
return fmt.Errorf("invalid sender: %w", err)
}
for _, addr := range to {
if err := validateEmailAddress(addr); err != nil {
return fmt.Errorf("invalid recipient: %w", err)
}
}
return nil
}

// extractSenderDomain returns the domain part of a sender address.
func extractSenderDomain(from string) string {
bare := extractEmail(from)
Expand Down
Loading