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( `
%s
This code expires in 10 minutes.
If you didn't request this code, you can safely ignore this email.
%s invited you to collaborate in the %s workspace on Multica.
You'll need to log in to accept or decline the invitation.