// 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(`

You've been invited to MapleFile!

%s wants to share encrypted files with you.

MapleFile is a secure, end-to-end encrypted file storage service. To receive the shared files, you'll need to create a free account.

Create Your Account

Once you've registered, let %s know and they can share their files with you.


If you didn't expect this invitation, you can safely ignore this email.

`, inviterEmail, registerLink, inviterEmail) return svc.emailer.Send(ctx, svc.emailer.GetSenderEmail(), subject, recipientEmail, htmlContent) }