monorepo/cloud/maplefile-backend/internal/service/inviteemail/send.go

234 lines
8 KiB
Go

// Package inviteemail provides services for sending invitation emails
// to non-registered users when someone wants to share a collection with them.
package inviteemail
import (
"context"
"fmt"
"strings"
"time"
"github.com/gocql/gocql"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config"
dom_inviteemail "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/inviteemail"
dom_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/domain/user"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/repo/inviteemailratelimit"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/emailer/mailgun"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation"
)
// SendInviteEmailRequestDTO represents the request to send an invitation email
type SendInviteEmailRequestDTO struct {
Email string `json:"email"`
}
// SendInviteEmailResponseDTO represents the response after sending an invitation email
type SendInviteEmailResponseDTO struct {
Success bool `json:"success"`
RemainingToday int `json:"remaining_invites_today"`
Message string `json:"message"`
}
// SendInviteEmailService defines the interface for sending invitation emails
type SendInviteEmailService interface {
Execute(ctx context.Context, inviterID gocql.UUID, req *SendInviteEmailRequestDTO) (*SendInviteEmailResponseDTO, error)
}
type sendInviteEmailServiceImpl struct {
config *config.Config
logger *zap.Logger
userRepo dom_user.Repository
rateLimitRepo inviteemailratelimit.Repository
emailer mailgun.Emailer
maxEmailsPerDay int
}
// NewSendInviteEmailService creates a new instance of the send invite email service
func NewSendInviteEmailService(
cfg *config.Config,
logger *zap.Logger,
userRepo dom_user.Repository,
rateLimitRepo inviteemailratelimit.Repository,
emailer mailgun.Emailer,
) SendInviteEmailService {
logger = logger.Named("SendInviteEmailService")
// Get max emails per day from config, fallback to default
maxEmails := cfg.InviteEmail.MaxEmailsPerDay
if maxEmails <= 0 {
maxEmails = dom_inviteemail.DefaultMaxInviteEmailsPerDay
}
return &sendInviteEmailServiceImpl{
config: cfg,
logger: logger,
userRepo: userRepo,
rateLimitRepo: rateLimitRepo,
emailer: emailer,
maxEmailsPerDay: maxEmails,
}
}
func (svc *sendInviteEmailServiceImpl) Execute(ctx context.Context, inviterID gocql.UUID, req *SendInviteEmailRequestDTO) (*SendInviteEmailResponseDTO, error) {
//
// STEP 1: Sanitize input
//
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
svc.logger.Debug("Processing invite email request",
zap.String("inviter_id", inviterID.String()),
zap.String("invited_email", validation.MaskEmail(req.Email)))
//
// STEP 2: Validate input
//
e := make(map[string]string)
if req.Email == "" {
e["email"] = "Email is required"
} else if !validation.IsValidEmail(req.Email) {
e["email"] = "Invalid email format"
} else if len(req.Email) > 255 {
e["email"] = "Email is too long"
}
if len(e) != 0 {
svc.logger.Warn("Validation failed", zap.Any("errors", e))
return nil, httperror.NewForBadRequest(&e)
}
//
// STEP 3: Get inviter info
//
inviter, err := svc.userRepo.GetByID(ctx, inviterID)
if err != nil {
svc.logger.Error("Failed to get inviter info",
zap.String("inviter_id", inviterID.String()),
zap.Error(err))
return nil, httperror.NewForInternalServerError("Failed to process request")
}
if inviter == nil {
svc.logger.Error("Inviter not found",
zap.String("inviter_id", inviterID.String()))
return nil, httperror.NewForUnauthorizedWithSingleField("user", "User not found")
}
//
// STEP 4: Check rate limit
//
today := time.Now().UTC().Truncate(24 * time.Hour)
dailyCount, err := svc.rateLimitRepo.GetDailyEmailCount(ctx, inviterID, today)
if err != nil {
svc.logger.Warn("Failed to get rate limit count, proceeding with caution",
zap.String("inviter_id", inviterID.String()),
zap.Error(err))
// Fail open but log - don't block users due to rate limit DB issues
dailyCount = 0
}
if dailyCount >= svc.maxEmailsPerDay {
svc.logger.Warn("Rate limit exceeded",
zap.String("inviter_id", inviterID.String()),
zap.Int("daily_count", dailyCount),
zap.Int("max_per_day", svc.maxEmailsPerDay))
return &SendInviteEmailResponseDTO{
Success: false,
RemainingToday: 0,
Message: "Daily invitation limit reached. You can send more invitations tomorrow.",
}, nil
}
//
// STEP 5: Check if recipient already has an account
//
exists, err := svc.userRepo.CheckIfExistsByEmail(ctx, req.Email)
if err != nil {
svc.logger.Error("Failed to check if user exists",
zap.String("email", validation.MaskEmail(req.Email)),
zap.Error(err))
return nil, httperror.NewForInternalServerError("Failed to process request")
}
if exists {
svc.logger.Debug("User already has account",
zap.String("email", validation.MaskEmail(req.Email)))
return &SendInviteEmailResponseDTO{
Success: false,
RemainingToday: svc.maxEmailsPerDay - dailyCount,
Message: "This user already has an account. You can share with them directly.",
}, nil
}
//
// STEP 6: Send invitation email
//
if err := svc.sendInvitationEmail(ctx, inviter.Email, req.Email); err != nil {
svc.logger.Error("Failed to send invitation email",
zap.String("invited_email", validation.MaskEmail(req.Email)),
zap.Error(err))
return nil, httperror.NewForInternalServerError("Failed to send invitation email. Please try again.")
}
//
// STEP 7: Increment rate limit counter
//
if err := svc.rateLimitRepo.IncrementDailyEmailCount(ctx, inviterID, today); err != nil {
svc.logger.Warn("Failed to increment rate limit counter",
zap.String("inviter_id", inviterID.String()),
zap.Error(err))
// Don't fail the request, email was already sent
}
remaining := svc.maxEmailsPerDay - dailyCount - 1
svc.logger.Info("Invitation email sent successfully",
zap.String("inviter_id", inviterID.String()),
zap.String("invited_email", validation.MaskEmail(req.Email)),
zap.Int("remaining_today", remaining))
return &SendInviteEmailResponseDTO{
Success: true,
RemainingToday: remaining,
Message: fmt.Sprintf("Invitation sent to %s", req.Email),
}, nil
}
func (svc *sendInviteEmailServiceImpl) sendInvitationEmail(ctx context.Context, inviterEmail, recipientEmail string) error {
frontendURL := svc.emailer.GetFrontendDomainName()
registerLink := fmt.Sprintf("%s/register", frontendURL)
subject := fmt.Sprintf("%s wants to share files with you on MapleFile", inviterEmail)
htmlContent := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #1e40af;">You've been invited to MapleFile!</h2>
<p><strong>%s</strong> wants to share encrypted files with you.</p>
<p>MapleFile is a secure, end-to-end encrypted file storage service. To receive the shared files, you'll need to create a free account.</p>
<p style="margin: 30px 0;">
<a href="%s" style="background-color: #1e40af; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: 500;">
Create Your Account
</a>
</p>
<p>Once you've registered, let <strong>%s</strong> know and they can share their files with you.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #666; font-size: 14px;">If you didn't expect this invitation, you can safely ignore this email.</p>
</body>
</html>
`, inviterEmail, registerLink, inviterEmail)
return svc.emailer.Send(ctx, svc.emailer.GetSenderEmail(), subject, recipientEmail, htmlContent)
}