Add SMTP auto TLS fallback to support non-SSL testing (#380)

* Enable SMTP auto TLS fallback for testing

* Add HTTP fallback for account auth forms
This commit is contained in:
shenlan 2025-10-03 00:01:35 +08:00 committed by GitHub
parent 6d19bfb762
commit 7f6c3be316
7 changed files with 138 additions and 17 deletions

View File

@ -101,7 +101,7 @@ var rootCmd = &cobra.Command{
var emailSender api.EmailSender
if strings.TrimSpace(cfg.SMTP.Host) != "" {
tlsMode := mailer.TLSMode(strings.ToLower(strings.TrimSpace(cfg.SMTP.TLS.Mode)))
tlsMode := mailer.ParseTLSMode(cfg.SMTP.TLS.Mode)
sender, err := mailer.New(mailer.Config{
Host: cfg.SMTP.Host,
Port: cfg.SMTP.Port,

View File

@ -35,5 +35,5 @@ smtp:
replyTo: ""
timeout: 10s
tls:
mode: "starttls"
mode: "auto"
insecureSkipVerify: false

View File

@ -79,7 +79,10 @@ type SMTP struct {
TLS SMTPTLS `yaml:"tls"`
}
// SMTPTLS describes TLS settings for SMTP connections.
// SMTPTLS describes TLS settings for SMTP connections. Mode supports "auto",
// "starttls", "implicit", and "none". The "auto" mode negotiates STARTTLS
// when the server advertises support and otherwise falls back to an unencrypted
// connection which is useful for local testing.
type SMTPTLS struct {
Mode string `yaml:"mode"`
InsecureSkipVerify bool `yaml:"insecureSkipVerify"`

View File

@ -23,12 +23,37 @@ type TLSMode string
const (
// TLSModeNone disables TLS.
TLSModeNone TLSMode = "none"
// TLSModeStartTLS upgrades a plain connection via STARTTLS.
// TLSModeStartTLS upgrades a plain connection via STARTTLS and fails when unsupported.
TLSModeStartTLS TLSMode = "starttls"
// TLSModeImplicit establishes the connection over TLS immediately.
TLSModeImplicit TLSMode = "implicit"
// TLSModeAuto attempts STARTTLS when supported and gracefully falls back to plain SMTP otherwise.
TLSModeAuto TLSMode = "auto"
)
// ParseTLSMode normalises the provided value to a supported TLSMode. Unrecognised values
// default to TLSModeAuto in order to support both secure and non-secure transports when
// testing against simple SMTP servers.
func ParseTLSMode(value string) TLSMode {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case "", "auto", "automatic", "detect":
return TLSModeAuto
case "none", "disable", "disabled", "off", "plain", "plaintext":
return TLSModeNone
case "implicit", "smtps":
return TLSModeImplicit
case "starttls", "start_tls", "start-tls":
return TLSModeStartTLS
default:
return TLSModeAuto
}
}
func normalizeTLSMode(mode TLSMode) TLSMode {
return ParseTLSMode(string(mode))
}
// Config contains the information required to send email via SMTP.
type Config struct {
Host string
@ -95,10 +120,7 @@ func New(cfg Config) (Sender, error) {
}
}
mode := TLSMode(strings.ToLower(strings.TrimSpace(string(cfg.TLSMode))))
if mode == "" {
mode = TLSModeStartTLS
}
mode := normalizeTLSMode(cfg.TLSMode)
sender := &smtpSender{
host: host,
@ -152,11 +174,19 @@ func (s *smtpSender) Send(ctx context.Context, msg Message) error {
}
defer client.Close()
if s.tlsMode == TLSModeStartTLS {
switch s.tlsMode {
case TLSModeStartTLS:
tlsCfg := s.tlsConfig()
if err := client.StartTLS(tlsCfg); err != nil {
return err
}
case TLSModeAuto:
if ok, _ := client.Extension("STARTTLS"); ok {
tlsCfg := s.tlsConfig()
if err := client.StartTLS(tlsCfg); err != nil {
return err
}
}
}
if s.username != "" {

View File

@ -0,0 +1,30 @@
package mailer
import "testing"
func TestParseTLSMode(t *testing.T) {
cases := map[string]TLSMode{
"": TLSModeAuto,
"auto": TLSModeAuto,
"automatic": TLSModeAuto,
"detect": TLSModeAuto,
"starttls": TLSModeStartTLS,
"start_tls": TLSModeStartTLS,
"start-tls": TLSModeStartTLS,
"implicit": TLSModeImplicit,
"smtps": TLSModeImplicit,
"none": TLSModeNone,
"disable": TLSModeNone,
"disabled": TLSModeNone,
"off": TLSModeNone,
"plain": TLSModeNone,
"plaintext": TLSModeNone,
"unknown": TLSModeAuto,
}
for input, expected := range cases {
if mode := ParseTLSMode(input); mode != expected {
t.Errorf("ParseTLSMode(%q) = %q, expected %q", input, mode, expected)
}
}
}

View File

@ -1,6 +1,6 @@
'use client'
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Github } from 'lucide-react'
@ -58,6 +58,12 @@ export default function LoginContent({ children }: LoginContentProps) {
const wechatAuthUrl = process.env.NEXT_PUBLIC_WECHAT_AUTH_URL || '/api/auth/wechat'
const loginUrl = process.env.NEXT_PUBLIC_LOGIN_URL || `${accountServiceBaseUrl}/api/auth/login`
const loginUrlRef = useRef(loginUrl)
useEffect(() => {
loginUrlRef.current = loginUrl
}, [loginUrl])
const socialButtonsDisabled = true
const initialAlert = useMemo(() => {
@ -119,7 +125,7 @@ export default function LoginContent({ children }: LoginContentProps) {
setAlert(null)
try {
const response = await fetch(loginUrl, {
const requestPayload = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -130,7 +136,30 @@ export default function LoginContent({ children }: LoginContentProps) {
password,
remember,
}),
})
} as const
let response: Response
let usedUrl = loginUrlRef.current
try {
response = await fetch(usedUrl, requestPayload)
} catch (primaryError) {
const httpsPattern = /^https:/i
if (httpsPattern.test(usedUrl)) {
const insecureUrl = usedUrl.replace(httpsPattern, 'http:')
try {
response = await fetch(insecureUrl, requestPayload)
loginUrlRef.current = insecureUrl
usedUrl = insecureUrl
} catch (fallbackError) {
console.error('Primary login request failed, insecure fallback also failed', fallbackError)
throw fallbackError
}
} else {
throw primaryError
}
}
if (!response.ok) {
let errorCode = 'invalid_credentials'
@ -167,7 +196,7 @@ export default function LoginContent({ children }: LoginContentProps) {
setIsSubmitting(false)
}
},
[alerts, isSubmitting, loginUrl, normalize, router],
[alerts, isSubmitting, normalize, router],
)
const socialButtonClass = `flex w-full items-center justify-center gap-3 rounded-xl border border-gray-200 px-4 py-2.5 text-sm font-medium text-gray-800 ${

View File

@ -2,7 +2,7 @@
import Link from 'next/link'
import { Github } from 'lucide-react'
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Navbar from '@components/Navbar'
@ -28,6 +28,12 @@ export default function RegisterContent() {
const registerUrl = process.env.NEXT_PUBLIC_REGISTER_URL || `${accountServiceBaseUrl}/api/auth/register`
const isSocialAuthVisible = false
const registerUrlRef = useRef(registerUrl)
useEffect(() => {
registerUrlRef.current = registerUrl
}, [registerUrl])
useEffect(() => {
const sensitiveKeys = ['username', 'password', 'confirmPassword', 'email']
const hasSensitiveParams = sensitiveKeys.some((key) => searchParams.has(key))
@ -130,7 +136,7 @@ export default function RegisterContent() {
setAlert(null)
try {
const response = await fetch(registerUrl, {
const requestPayload = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -140,7 +146,30 @@ export default function RegisterContent() {
email,
password,
}),
})
} as const
let response: Response
let usedUrl = registerUrlRef.current
try {
response = await fetch(usedUrl, requestPayload)
} catch (primaryError) {
const httpsPattern = /^https:/i
if (httpsPattern.test(usedUrl)) {
const insecureUrl = usedUrl.replace(httpsPattern, 'http:')
try {
response = await fetch(insecureUrl, requestPayload)
registerUrlRef.current = insecureUrl
usedUrl = insecureUrl
} catch (fallbackError) {
console.error('Primary register request failed, insecure fallback also failed', fallbackError)
throw fallbackError
}
} else {
throw primaryError
}
}
if (!response.ok) {
let errorCode = 'generic_error'
@ -181,7 +210,7 @@ export default function RegisterContent() {
setIsSubmitting(false)
}
},
[alerts, isSubmitting, normalize, registerUrl, router],
[alerts, isSubmitting, normalize, router],
)
return (