diff --git a/account/cmd/accountsvc/main.go b/account/cmd/accountsvc/main.go index f1c1bef..25fea64 100644 --- a/account/cmd/accountsvc/main.go +++ b/account/cmd/accountsvc/main.go @@ -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, diff --git a/account/config/account.yaml b/account/config/account.yaml index b0167c7..b334593 100644 --- a/account/config/account.yaml +++ b/account/config/account.yaml @@ -35,5 +35,5 @@ smtp: replyTo: "" timeout: 10s tls: - mode: "starttls" + mode: "auto" insecureSkipVerify: false diff --git a/account/config/config.go b/account/config/config.go index b8b9b17..27def1c 100644 --- a/account/config/config.go +++ b/account/config/config.go @@ -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"` diff --git a/account/internal/mailer/mailer.go b/account/internal/mailer/mailer.go index bd35ce0..c723f16 100644 --- a/account/internal/mailer/mailer.go +++ b/account/internal/mailer/mailer.go @@ -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 != "" { diff --git a/account/internal/mailer/mailer_test.go b/account/internal/mailer/mailer_test.go new file mode 100644 index 0000000..41aca99 --- /dev/null +++ b/account/internal/mailer/mailer_test.go @@ -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) + } + } +} diff --git a/ui/homepage/app/login/LoginContent.tsx b/ui/homepage/app/login/LoginContent.tsx index 83d4e45..b084863 100644 --- a/ui/homepage/app/login/LoginContent.tsx +++ b/ui/homepage/app/login/LoginContent.tsx @@ -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 ${ diff --git a/ui/homepage/app/register/RegisterContent.tsx b/ui/homepage/app/register/RegisterContent.tsx index 9bf0334..efcbd11 100644 --- a/ui/homepage/app/register/RegisterContent.tsx +++ b/ui/homepage/app/register/RegisterContent.tsx @@ -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 (