Files
multica/server/internal/service/email_test.go
Bohan Jiang 8db619c1cd fix(email): wire SMTP_EHLO_NAME through self-host config + docs [MUL-2984] (#3749)
* fix(email): wire SMTP_EHLO_NAME through self-host config + docs

Follow-up to #3679, which added SMTP_EHLO_NAME in code but never exposed
it to operators.

- docker-compose.selfhost.yml: pass SMTP_EHLO_NAME through to the backend
  container. The compose env block is an explicit allowlist, so without
  this the override set in .env was silently dropped and never reached
  the process — making the escape hatch unusable on the docker path.
- Document the var alongside its SMTP_* siblings: .env.example,
  SELF_HOSTING_ADVANCED.md, environment-variables.mdx, auth-setup.mdx,
  and self-host-quickstart.mdx (the last two with a strict-relay example).
- email.go: log when os.Hostname() fails instead of silently falling back
  to net/smtp's lazy "localhost" — the exact greeting strict relays reject.
- Add TestNewEmailService_EHLOName covering the env override, trimming,
  and the hostname fallback.

MUL-2984

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

* fix(email): gate EHLO resolution to SMTP mode + sync docs to zh/ja/ko

Addresses review nits on this PR:

- email.go: resolve smtpEHLOName only when SMTP_HOST is set, so the
  Resend / DEV-stdout paths never call os.Hostname() or emit its
  failure log. The EHLO name is only ever used on the SMTP send path.
- docs: add SMTP_EHLO_NAME to the zh/ja/ko variants of
  environment-variables, self-host-quickstart, and auth-setup, in sync
  with the English docs updated earlier in this PR.

Note: the ja/ko self-host-quickstart and auth-setup pages were already
missing the port-465 implicit-TLS example (pre-existing i18n drift from
an earlier SMTP_TLS change, unrelated to this PR); the new EHLO block is
inserted at the correct logical anchor regardless. A full ja/ko re-sync
is left as a separate follow-up.

MUL-2984

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 14:44:55 +08:00

253 lines
7.3 KiB
Go

package service
import (
"os"
"strings"
"testing"
)
func TestSanitizeSubjectField(t *testing.T) {
long := strings.Repeat("a", 100)
longRunes := strings.Repeat("深", 100)
tests := []struct {
name string
in string
want string
}{
{"plain ascii", "Acme", "Acme"},
{"strips newline", "Acme\nEvil", "AcmeEvil"},
{"strips crlf header-style", "Acme\r\nBcc: evil@example.com", "AcmeBcc: evil@example.com"},
{"strips tab", "Acme\tTeam", "AcmeTeam"},
{"strips unicode control", "Acme\x07Beep", "AcmeBeep"},
{"preserves non-ascii", "深度学习工作区", "深度学习工作区"},
{"preserves emoji", "Team 🚀", "Team 🚀"},
{"truncates long ascii", long, strings.Repeat("a", maxSubjectFieldRunes-1) + "…"},
{"truncates rune-aware", longRunes, strings.Repeat("深", maxSubjectFieldRunes-1) + "…"},
{"empty stays empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeSubjectField(tt.in)
if got != tt.want {
t.Errorf("sanitizeSubjectField(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestNewEmailService_TLSMode(t *testing.T) {
tests := []struct {
name string
smtpTLS string
smtpPort string
wantImplicit bool
}{
{"unset on 465 auto-enables implicit", "", "465", true},
{"unset on 587 stays starttls", "", "587", false},
{"unset default port stays starttls", "", "", false},
{"explicit implicit on 587 forces SMTPS", "implicit", "587", true},
{"smtps alias", "smtps", "587", true},
{"ssl alias", "ssl", "587", true},
{"explicit starttls on 465 overrides auto-detect", "starttls", "465", false},
{"case-insensitive", "IMPLICIT", "587", true},
{"trims whitespace", " implicit ", "587", true},
{"unknown value falls back to starttls", "tls", "465", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Isolate from any ambient mail config so only SMTP_TLS/SMTP_PORT drive the result.
t.Setenv("RESEND_API_KEY", "")
t.Setenv("SMTP_HOST", "smtp.example.com")
t.Setenv("SMTP_PORT", tt.smtpPort)
t.Setenv("SMTP_TLS", tt.smtpTLS)
s := NewEmailService()
if s.smtpTLSImplicit != tt.wantImplicit {
t.Errorf("SMTP_TLS=%q SMTP_PORT=%q: smtpTLSImplicit = %v, want %v",
tt.smtpTLS, tt.smtpPort, s.smtpTLSImplicit, tt.wantImplicit)
}
})
}
}
func TestNewEmailService_EHLOName(t *testing.T) {
tests := []struct {
name string
ehloEnv string
want string // when fromEnv is false, the os.Hostname() fallback is expected instead
fromEnv bool
}{
{"explicit name used verbatim", "mail.example.com", "mail.example.com", true},
{"explicit name is trimmed", " mail.example.com ", "mail.example.com", true},
{"unset falls back to hostname", "", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Isolate from ambient mail config so only SMTP_EHLO_NAME drives the result.
t.Setenv("RESEND_API_KEY", "")
t.Setenv("SMTP_HOST", "smtp.example.com")
t.Setenv("SMTP_EHLO_NAME", tt.ehloEnv)
s := NewEmailService()
if tt.fromEnv {
if s.smtpEHLOName != tt.want {
t.Errorf("SMTP_EHLO_NAME=%q: smtpEHLOName = %q, want %q", tt.ehloEnv, s.smtpEHLOName, tt.want)
}
return
}
// Unset: must mirror os.Hostname() exactly — including the empty result if
// Hostname() errors, which makes sendSMTP skip the EHLO override.
want, _ := os.Hostname()
if s.smtpEHLOName != want {
t.Errorf("SMTP_EHLO_NAME unset: smtpEHLOName = %q, want os.Hostname() %q", s.smtpEHLOName, want)
}
})
}
}
func TestBuildInvitationParams_EscapesHTMLInBody(t *testing.T) {
tests := []struct {
name string
inviter string
workspace string
wantInBody []string
wantNotInBody []string
}{
{
name: "escapes script tag in inviter",
inviter: "<script>alert(1)</script>",
workspace: "Acme",
wantInBody: []string{
"&lt;script&gt;alert(1)&lt;/script&gt;",
},
wantNotInBody: []string{
"<script>alert(1)</script>",
},
},
{
name: "escapes attribute-break payload in inviter",
inviter: `Alice" onclick="evil()`,
workspace: "Acme",
wantNotInBody: []string{
`Alice" onclick="evil()`,
},
},
{
name: "escapes anchor tag in workspace",
inviter: "Alice",
workspace: `<a href="https://evil.example">Click</a>`,
wantInBody: []string{
"&lt;a href=",
"&gt;Click&lt;/a&gt;",
},
wantNotInBody: []string{
`<a href="https://evil.example">Click</a>`,
},
},
{
name: "benign text unchanged",
inviter: "Alice",
workspace: "Acme",
wantInBody: []string{
"Alice",
"Acme",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
tt.inviter,
tt.workspace,
"https://app.multica.ai/invite/abc-123",
)
for _, needle := range tt.wantInBody {
if !strings.Contains(p.Html, needle) {
t.Errorf("body missing %q\nbody: %s", needle, p.Html)
}
}
for _, needle := range tt.wantNotInBody {
if strings.Contains(p.Html, needle) {
t.Errorf("body should not contain raw %q\nbody: %s", needle, p.Html)
}
}
})
}
}
func TestBuildInvitationParams_SubjectStripsControls(t *testing.T) {
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
"Alice\r\n",
"Acme\t",
"https://app.multica.ai/invite/abc",
)
if strings.ContainsAny(p.Subject, "\r\n\t") {
t.Errorf("subject still contains control characters: %q", p.Subject)
}
if p.Subject != "Alice invited you to Acme on Multica" {
t.Errorf("unexpected subject: %q", p.Subject)
}
}
func TestBuildInvitationParams_SubjectNotHTMLEscaped(t *testing.T) {
// Subject is not HTML-rendered; entities would render literally in inboxes.
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
"Alice",
"Acme & Co.",
"https://app.multica.ai/invite/abc",
)
if strings.Contains(p.Subject, "&amp;") {
t.Errorf("subject should not be HTML-escaped, got %q", p.Subject)
}
if !strings.Contains(p.Subject, "Acme & Co.") {
t.Errorf("subject missing literal ampersand: %q", p.Subject)
}
}
func TestBuildInvitationParams_SubjectTruncated(t *testing.T) {
longWorkspace := strings.Repeat("A", 200)
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
"Alice",
longWorkspace,
"https://app.multica.ai/invite/abc",
)
// Template: "Alice invited you to <ws> on Multica"
// ws is capped at maxSubjectFieldRunes; overall subject should also be bounded.
maxExpected := len("Alice invited you to on Multica") + maxSubjectFieldRunes
if runes := len([]rune(p.Subject)); runes > maxExpected {
t.Errorf("subject not bounded: %d runes, max %d: %q", runes, maxExpected, p.Subject)
}
if !strings.Contains(p.Subject, "…") {
t.Errorf("truncated subject should contain ellipsis marker: %q", p.Subject)
}
}
func TestBuildInvitationParams_ToAndFromPassedThrough(t *testing.T) {
p := buildInvitationParams(
"noreply@multica.ai",
"invitee@example.com",
"Alice",
"Acme",
"https://app.multica.ai/invite/abc",
)
if p.From != "noreply@multica.ai" {
t.Errorf("From = %q", p.From)
}
if len(p.To) != 1 || p.To[0] != "invitee@example.com" {
t.Errorf("To = %v", p.To)
}
if !strings.Contains(p.Html, "https://app.multica.ai/invite/abc") {
t.Errorf("body missing invite URL: %s", p.Html)
}
}