mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 20:29:10 +02:00
* 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>
253 lines
7.3 KiB
Go
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{
|
|
"<script>alert(1)</script>",
|
|
},
|
|
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{
|
|
"<a href=",
|
|
">Click</a>",
|
|
},
|
|
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, "&") {
|
|
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)
|
|
}
|
|
}
|