package service import ( "crypto/tls" "fmt" "html" "mime" "mime/quotedprintable" "net" "net/smtp" "os" "strings" "time" "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 smtpHost string smtpPort string smtpUsername string smtpPassword string smtpTLSInsecure bool } func NewEmailService() *EmailService { apiKey := os.Getenv("RESEND_API_KEY") from := strings.TrimSpace(os.Getenv("RESEND_FROM_EMAIL")) if from == "" { from = "noreply@multica.ai" } smtpHost := strings.TrimSpace(os.Getenv("SMTP_HOST")) smtpPort := strings.TrimSpace(os.Getenv("SMTP_PORT")) if smtpPort == "" { smtpPort = "25" } smtpUsername := os.Getenv("SMTP_USERNAME") smtpPassword := os.Getenv("SMTP_PASSWORD") smtpTLSInsecure := os.Getenv("SMTP_TLS_INSECURE") == "true" var client *resend.Client if apiKey != "" { client = resend.NewClient(apiKey) } switch { case smtpHost != "": fmt.Printf("EmailService: SMTP relay %s:%s from=%s\n", smtpHost, smtpPort, from) case client != nil: fmt.Printf("EmailService: Resend API from=%s\n", from) default: fmt.Println("EmailService: DEV mode — codes printed to stdout (set MULTICA_DEV_VERIFICATION_CODE in .env for a fixed local code)") } return &EmailService{ client: client, fromEmail: from, smtpHost: smtpHost, smtpPort: smtpPort, smtpUsername: smtpUsername, smtpPassword: smtpPassword, smtpTLSInsecure: smtpTLSInsecure, } } // sendSMTP delivers an HTML email via an SMTP server. // Supports unauthenticated relay (SMTP_USERNAME empty) and authenticated SMTP. // Upgrades to STARTTLS when advertised by the server. // Set SMTP_TLS_INSECURE=true for self-signed or private CA certificates. func (s *EmailService) sendSMTP(to, subject, htmlBody string) error { addr := net.JoinHostPort(s.smtpHost, s.smtpPort) // Bounded dial + whole-session deadline: prevents a blackholed SMTP server // from hanging the auth handler (or a background goroutine) indefinitely. conn, err := net.DialTimeout("tcp", addr, 10*time.Second) if err != nil { return fmt.Errorf("smtp dial %s: %w", addr, err) } if err = conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { conn.Close() return fmt.Errorf("smtp set deadline: %w", err) } c, err := smtp.NewClient(conn, s.smtpHost) if err != nil { conn.Close() return fmt.Errorf("smtp client: %w", err) } defer c.Close() // STARTTLS if advertised — refreshes the extension list for 8BITMIME check below. if ok, _ := c.Extension("STARTTLS"); ok { tlsCfg := &tls.Config{ ServerName: s.smtpHost, InsecureSkipVerify: s.smtpTLSInsecure, //nolint:gosec // opt-in via SMTP_TLS_INSECURE=true } if err = c.StartTLS(tlsCfg); err != nil { return fmt.Errorf("smtp starttls: %w", err) } } if s.smtpUsername != "" { auth := smtp.PlainAuth("", s.smtpUsername, s.smtpPassword, s.smtpHost) if err = c.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } } // Probe 8BITMIME after (possible) STARTTLS so the extension list is current. // Use quoted-printable for relays that don't advertise 8BITMIME — safer for // non-ASCII workspace/inviter names crossing strict or older SMTP hops. has8Bit, _ := c.Extension("8BITMIME") encodedSubject := mime.QEncoding.Encode("utf-8", subject) msgID := fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), s.smtpHost) var bodyBytes []byte var cte string if has8Bit { bodyBytes = []byte(htmlBody) cte = "8bit" } else { var buf strings.Builder qpw := quotedprintable.NewWriter(&buf) _, _ = qpw.Write([]byte(htmlBody)) _ = qpw.Close() bodyBytes = []byte(buf.String()) cte = "quoted-printable" } if err = c.Mail(s.fromEmail); err != nil { return fmt.Errorf("smtp MAIL FROM: %w", err) } if err = c.Rcpt(to); err != nil { return fmt.Errorf("smtp RCPT TO <%s>: %w", to, err) } w, err := c.Data() if err != nil { return fmt.Errorf("smtp DATA: %w", err) } headers := "From: " + s.fromEmail + "\r\n" + "To: " + to + "\r\n" + "Subject: " + encodedSubject + "\r\n" + "Date: " + time.Now().UTC().Format(time.RFC1123Z) + "\r\n" + "Message-ID: " + msgID + "\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: text/html; charset=UTF-8\r\n" + "Content-Transfer-Encoding: " + cte + "\r\n" + "\r\n" if _, err = fmt.Fprintf(w, "%s%s", headers, bodyBytes); err != nil { return fmt.Errorf("smtp write body: %w", err) } if err = w.Close(); err != nil { return fmt.Errorf("smtp end data: %w", err) } return c.Quit() } // 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. // Delivery priority: SMTP relay → Resend API → DEV stdout. func (s *EmailService) SendVerificationCode(to, code string) error { body := 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) if s.smtpHost != "" { return s.sendSMTP(to, "Your Multica verification code", body) } 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: body, } _, 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.smtpHost != "" { params := buildInvitationParams(s.fromEmail, to, inviterName, workspaceName, inviteURL) return s.sendSMTP(to, params.Subject, params.Html) } 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 email request for an invitation. // Separated from SendInvitationEmail so the sanitization behavior is unit-testable // without needing to mock the Resend SDK or an SMTP server. 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]) + "…" }