package service import ( "fmt" "html" "os" "strings" "unicode" "unicode/utf8" "github.com/resend/resend-go/v2" ) // maxSubjectFieldRunes bounds how much user-controlled text (workspace name, // inviter name) can land in an email Subject. Prevents attackers from stuffing // a full phishing pitch into a workspace name that gets sent from our domain. const maxSubjectFieldRunes = 60 type EmailService struct { client *resend.Client fromEmail string } func NewEmailService() *EmailService { apiKey := os.Getenv("RESEND_API_KEY") from := os.Getenv("RESEND_FROM_EMAIL") if from == "" { from = "noreply@multica.ai" } var client *resend.Client if apiKey != "" { client = resend.NewClient(apiKey) } return &EmailService{ client: client, fromEmail: from, } } // SendVerificationCode sends a one-time login code. The code is server-generated // (6-digit numeric) so no user-controlled text reaches the email body here. // If that ever changes, escape the user-controlled fields the same way // SendInvitationEmail does. func (s *EmailService) SendVerificationCode(to, code string) error { if s.client == nil { fmt.Printf("[DEV] Verification code for %s: %s\n", to, code) return nil } params := &resend.SendEmailRequest{ From: s.fromEmail, To: []string{to}, Subject: "Your Multica verification code", Html: fmt.Sprintf( `

Your verification code

%s

This code expires in 10 minutes.

If you didn't request this code, you can safely ignore this email.

`, code), } _, err := s.client.Emails.Send(params) return err } // SendInvitationEmail notifies the invitee that they have been invited to a workspace. // invitationID is included in the URL so the email deep-links to /invite/{id}. func (s *EmailService) SendInvitationEmail(to, inviterName, workspaceName, invitationID string) error { appURL := strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN")) if appURL == "" { appURL = "https://app.multica.ai" } inviteURL := fmt.Sprintf("%s/invite/%s", appURL, invitationID) if s.client == nil { fmt.Printf("[DEV] Invitation email to %s: %s invited you to %s — %s\n", to, inviterName, workspaceName, inviteURL) return nil } params := buildInvitationParams(s.fromEmail, to, inviterName, workspaceName, inviteURL) _, err := s.client.Emails.Send(params) return err } // buildInvitationParams assembles the Resend request for an invitation email. // Separated from SendInvitationEmail so the sanitization behavior is unit-testable // without needing to mock the Resend SDK. func buildInvitationParams(from, to, inviterName, workspaceName, inviteURL string) *resend.SendEmailRequest { safeWorkspace := html.EscapeString(workspaceName) safeInviter := html.EscapeString(inviterName) subjectInviter := sanitizeSubjectField(inviterName) subjectWorkspace := sanitizeSubjectField(workspaceName) return &resend.SendEmailRequest{ From: from, To: []string{to}, Subject: fmt.Sprintf("%s invited you to %s on Multica", subjectInviter, subjectWorkspace), Html: fmt.Sprintf( `

You're invited to join %s

%s invited you to collaborate in the %s workspace on Multica.

Accept invitation

You'll need to log in to accept or decline the invitation.

`, safeWorkspace, safeInviter, safeWorkspace, inviteURL), } } // sanitizeSubjectField prepares user-controlled text for the email Subject line. // Subject is not HTML-rendered, so HTML-escaping would leak literal entities // (e.g. <script>) into the recipient's inbox. Instead strip control // characters (defense in depth against header-injection-adjacent abuse even // though Resend also filters CR/LF) and cap length so attackers can't stuff // a full phishing subject into a workspace name. func sanitizeSubjectField(s string) string { var b strings.Builder b.Grow(len(s)) for _, r := range s { if unicode.IsControl(r) { continue } b.WriteRune(r) } cleaned := b.String() if utf8.RuneCountInString(cleaned) <= maxSubjectFieldRunes { return cleaned } runes := []rune(cleaned) return string(runes[:maxSubjectFieldRunes-1]) + "…" }