mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-14 05:39:08 +02:00
* 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>
296 lines
9.9 KiB
Go
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. <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]) + "…"
|
|
}
|