mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 01:59:24 +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>
217 lines
6.1 KiB
Go
217 lines
6.1 KiB
Go
package service
|
|
|
|
import (
|
|
"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 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)
|
|
}
|
|
}
|