Files
multica/server/internal/service/email.go
fengchangguo-star 2cf8107fc8 feat(email): support implicit TLS (SMTPS/465) for SMTP relay (MUL-2768) (#3340)
* feat(email): support implicit TLS (SMTPS/465) for SMTP relay

The SMTP relay previously only did opportunistic STARTTLS: it dialed
plaintext and upgraded if the server advertised STARTTLS. Providers that
only offer implicit TLS on port 465 and do not advertise STARTTLS (e.g.
Aliyun enterprise mail) could not be used as a relay at all.

Add an SMTP_TLS env var:
  - unset / starttls (default): unchanged STARTTLS-upgrade behavior.
  - implicit / smtps / ssl: dial with tls.DialWithDialer (SMTPS).
Implicit TLS is auto-enabled when SMTP_PORT=465 and SMTP_TLS is unset, so
the common case works with no extra config. The startup log line now
reports the negotiated mode (starttls / implicit-tls).

Co-authored-by: multica-agent <github@multica.ai>

* feat(email): plumb SMTP_TLS through selfhost compose, warn on unknown values

The backend reads SMTP_TLS but docker-compose.selfhost.yml never forwarded
it, so SMTP_TLS=implicit on a non-standard port (or an explicit starttls
override on 465) silently did nothing inside the container. Add it to the
backend.environment block.

Also log a one-line warning when SMTP_TLS is set to an unrecognized value
(e.g. "tls"/"true"/"on"), which would otherwise fall through to STARTTLS
and fail to dial a 465 SMTPS port with no startup hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(email): cover SMTP_TLS precedence and alias resolution

Table-driven test over NewEmailService asserting the implicit-TLS decision:
465 auto-enables implicit; explicit starttls on 465 overrides auto-detect;
implicit/smtps/ssl aliases (case-insensitive, whitespace-trimmed) force SMTPS
on any port; unknown values fall back to starttls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* docs: document SMTPS / SMTP_TLS support, drop "465 unsupported"

Port 465 implicit TLS is now supported, so the five places that said it was
unsupported are wrong. Replace those sentences, add an SMTP_TLS row to the
environment-variables tables (EN + ZH), and add a copy-pasteable SMTPS env
block to the auth-setup pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: guofengchang <guofengchang@cumulon.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:15:04 +08:00

296 lines
9.9 KiB
Go

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
smtpTLSImplicit 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"
// 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,
}
}
// 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)
tlsCfg := &tls.Config{
ServerName: s.smtpHost,
InsecureSkipVerify: s.smtpTLSInsecure, //nolint:gosec // opt-in via SMTP_TLS_INSECURE=true
}
// Bounded dial + whole-session deadline: prevents a blackholed SMTP server
// from hanging the auth handler (or a background goroutine) indefinitely.
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 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 upgrade only makes sense when the underlying connection is still
// plaintext. Skip when we already dialed with implicit TLS.
if !s.smtpTLSImplicit {
if ok, _ := c.Extension("STARTTLS"); ok {
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(
`<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. &lt;script&gt;) 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]) + "…"
}