234 lines
8 KiB
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)
|
|
}
|