mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 20:29:10 +02:00
* Fix Office 365 SMTP auth fallback * Fix SMTP auth fallback tests * fix(smtp): address code review feedback for Office 365 auth fallback - Move defer c.Close() after nil check in sendSMTP to prevent panic when openSMTPClient() fails (c can be nil on dial/setup failure). - Add TLS security guard to loginAuth.Start: refuse credentials on unencrypted remote connections (mirroring smtp.PlainAuth behavior), validate expected host name, and allow localhost bypass. - Add isLocalhost() helper for loopback/private-network checks. - Add comprehensive test coverage: loginAuth.Start security checks (unencrypted remote, TLS, localhost, loopback IPs, wrong host), sendSMTP no-panic on dial failure, and full sendSMTP flow tests with mock SMTP server (PLAIN success, LOGIN fallback reconnect, unauthenticated relay).
421 lines
13 KiB
Go
421 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"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
|
|
smtpTLSImplicit bool
|
|
smtpEHLOName string
|
|
}
|
|
|
|
type smtpAuthClient interface {
|
|
Auth(smtp.Auth) error
|
|
Extension(string) (bool, string)
|
|
}
|
|
|
|
type smtpClientAdapter struct {
|
|
client *smtp.Client
|
|
}
|
|
|
|
func (a smtpClientAdapter) Auth(auth smtp.Auth) error {
|
|
return a.client.Auth(auth)
|
|
}
|
|
|
|
func (a smtpClientAdapter) Extension(name string) (bool, string) {
|
|
return a.client.Extension(name)
|
|
}
|
|
|
|
func isLocalhost(name string) bool {
|
|
return name == "localhost" || name == "127.0.0.1" || name == "::1"
|
|
}
|
|
|
|
type loginAuth struct {
|
|
username string
|
|
password string
|
|
host string
|
|
}
|
|
|
|
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
|
if !server.TLS && !isLocalhost(server.Name) {
|
|
return "", nil, fmt.Errorf("unencrypted connection")
|
|
}
|
|
if server.Name != a.host {
|
|
return "", nil, fmt.Errorf("wrong host name: %q does not match expected %q", server.Name, a.host)
|
|
}
|
|
return "LOGIN", nil, nil
|
|
}
|
|
|
|
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
|
if !more {
|
|
return nil, nil
|
|
}
|
|
|
|
raw := strings.TrimSpace(string(fromServer))
|
|
challenge := strings.ToLower(raw)
|
|
if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil {
|
|
challenge = strings.ToLower(strings.TrimSpace(string(decoded)))
|
|
}
|
|
|
|
switch {
|
|
case strings.Contains(challenge, "username") || strings.Contains(challenge, "user name"):
|
|
return []byte(a.username), nil
|
|
case strings.Contains(challenge, "password"):
|
|
return []byte(a.password), nil
|
|
default:
|
|
return nil, fmt.Errorf("unexpected LOGIN challenge %q", raw)
|
|
}
|
|
}
|
|
|
|
func smtpAuthWithFallback(c smtpAuthClient, host, username, password string) (bool, error) {
|
|
plainErr := c.Auth(smtp.PlainAuth("", username, password, host))
|
|
if plainErr == nil {
|
|
return false, nil
|
|
}
|
|
|
|
msg := strings.ToLower(plainErr.Error())
|
|
if !strings.Contains(msg, "unrecognized authentication type") && !strings.Contains(msg, "504 5.7.4") {
|
|
return false, plainErr
|
|
}
|
|
|
|
ok, authLine := c.Extension("AUTH")
|
|
if !ok || !strings.Contains(strings.ToUpper(authLine), "LOGIN") {
|
|
return false, plainErr
|
|
}
|
|
return true, plainErr
|
|
}
|
|
|
|
func (s *EmailService) openSMTPClient() (*smtp.Client, error) {
|
|
addr := net.JoinHostPort(s.smtpHost, s.smtpPort)
|
|
|
|
tlsCfg := &tls.Config{
|
|
ServerName: s.smtpHost,
|
|
InsecureSkipVerify: s.smtpTLSInsecure, //nolint:gosec // opt-in via SMTP_TLS_INSECURE=true
|
|
}
|
|
|
|
var conn net.Conn
|
|
var err error
|
|
if s.smtpTLSImplicit {
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg)
|
|
} else {
|
|
conn, err = net.DialTimeout("tcp", addr, 10*time.Second)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("smtp dial %s: %w", addr, err)
|
|
}
|
|
if err = conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("smtp set deadline: %w", err)
|
|
}
|
|
|
|
c, err := smtp.NewClient(conn, s.smtpHost)
|
|
if err != nil {
|
|
conn.Close()
|
|
return nil, fmt.Errorf("smtp client: %w", err)
|
|
}
|
|
|
|
if s.smtpEHLOName != "" {
|
|
if err = c.Hello(s.smtpEHLOName); err != nil {
|
|
c.Close()
|
|
return nil, fmt.Errorf("smtp EHLO %s: %w", s.smtpEHLOName, err)
|
|
}
|
|
}
|
|
|
|
if !s.smtpTLSImplicit {
|
|
if ok, _ := c.Extension("STARTTLS"); ok {
|
|
if err = c.StartTLS(tlsCfg); err != nil {
|
|
c.Close()
|
|
return nil, fmt.Errorf("smtp starttls: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
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"
|
|
|
|
// EHLO/HELO name, only relevant on the SMTP relay send path. net/smtp defaults
|
|
// to "localhost", which strict relays (e.g. smtp-relay.gmail.com) reject from a
|
|
// public source. Fall back to the machine hostname when SMTP_EHLO_NAME is unset.
|
|
// Resolved only in SMTP mode so the Resend/DEV paths never touch os.Hostname()
|
|
// or emit its failure log.
|
|
var smtpEHLOName string
|
|
if smtpHost != "" {
|
|
smtpEHLOName = strings.TrimSpace(os.Getenv("SMTP_EHLO_NAME"))
|
|
if smtpEHLOName == "" {
|
|
hostname, hostErr := os.Hostname()
|
|
if hostErr != nil {
|
|
// Empty name makes sendSMTP skip Hello() and fall back to net/smtp's
|
|
// lazy "localhost" — which strict relays reject. Surface it so operators
|
|
// know to set SMTP_EHLO_NAME explicitly.
|
|
fmt.Printf("EmailService: os.Hostname() failed (%v); SMTP EHLO falls back to \"localhost\" — set SMTP_EHLO_NAME for strict relays\n", hostErr)
|
|
}
|
|
smtpEHLOName = hostname
|
|
}
|
|
}
|
|
|
|
// SMTP_TLS=implicit forces an immediate TLS handshake on connect (SMTPS).
|
|
// Required by providers like Aliyun enterprise mail that only offer port 465
|
|
// SSL and do not advertise STARTTLS. Default (empty / "starttls") preserves
|
|
// the prior STARTTLS-upgrade behavior.
|
|
smtpTLSMode := strings.ToLower(strings.TrimSpace(os.Getenv("SMTP_TLS")))
|
|
smtpTLSImplicit := smtpTLSMode == "implicit" || smtpTLSMode == "smtps" || smtpTLSMode == "ssl"
|
|
if smtpTLSMode == "" && smtpPort == "465" {
|
|
smtpTLSImplicit = true
|
|
}
|
|
if smtpTLSMode != "" && !smtpTLSImplicit && smtpTLSMode != "starttls" {
|
|
fmt.Printf("EmailService: SMTP_TLS=%q not recognized, falling back to starttls\n", smtpTLSMode)
|
|
}
|
|
|
|
var client *resend.Client
|
|
if apiKey != "" {
|
|
client = resend.NewClient(apiKey)
|
|
}
|
|
|
|
switch {
|
|
case smtpHost != "":
|
|
tlsLabel := "starttls"
|
|
if smtpTLSImplicit {
|
|
tlsLabel = "implicit-tls"
|
|
}
|
|
fmt.Printf("EmailService: SMTP relay %s:%s (%s) from=%s\n", smtpHost, smtpPort, tlsLabel, 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,
|
|
smtpTLSImplicit: smtpTLSImplicit,
|
|
smtpEHLOName: smtpEHLOName,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
c, err := s.openSMTPClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer c.Close()
|
|
|
|
if s.smtpUsername != "" {
|
|
fallbackToLogin, authErr := smtpAuthWithFallback(smtpClientAdapter{client: c}, s.smtpHost, s.smtpUsername, s.smtpPassword)
|
|
if authErr != nil {
|
|
if !fallbackToLogin {
|
|
return fmt.Errorf("smtp auth: %w", authErr)
|
|
}
|
|
|
|
c.Close()
|
|
c, err = s.openSMTPClient()
|
|
if err != nil {
|
|
return fmt.Errorf("smtp auth: plain auth failed (%v); login reconnect failed: %w", authErr, err)
|
|
}
|
|
defer c.Close()
|
|
|
|
if err = c.Auth(&loginAuth{username: s.smtpUsername, password: s.smtpPassword, host: s.smtpHost}); err != nil {
|
|
return fmt.Errorf("smtp auth: plain auth failed (%v); login auth fallback failed: %w", authErr, 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(
|
|
`<div style="font-family: sans-serif; max-width: 400px; margin: 0 auto;">
|
|
<h2>Your verification code</h2>
|
|
<p style="font-size: 32px; font-weight: bold; letter-spacing: 8px; margin: 24px 0;">%s</p>
|
|
<p>This code expires in 10 minutes.</p>
|
|
<p style="color: #666; font-size: 14px;">If you didn't request this code, you can safely ignore this email.</p>
|
|
</div>`, 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(
|
|
`<div style="font-family: sans-serif; max-width: 480px; margin: 0 auto;">
|
|
<h2>You're invited to join %s</h2>
|
|
<p><strong>%s</strong> invited you to collaborate in the <strong>%s</strong> workspace on Multica.</p>
|
|
<p style="margin: 24px 0;">
|
|
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #000; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 500;">Accept invitation</a>
|
|
</p>
|
|
<p style="color: #666; font-size: 14px;">You'll need to log in to accept or decline the invitation.</p>
|
|
</div>`, 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]) + "…"
|
|
}
|