Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Email OTP login with constant-time hash verification, role-based access checked

### 🌍 Self-Hosted

Single Go binary + PostgreSQL + (optional) S3-compatible storage for release artifacts. No Redis, no microservices. Auto-migration on startup. Setup wizard for first run. Custom branding, email templates, and i18n (English/Chinese built-in).
Single Go binary + PostgreSQL + (optional) S3-compatible storage for release artifacts. No Redis, no microservices. Auto-migration on startup. Setup wizard for first run. Custom branding, email templates, and i18n (English/Chinese/Turkish built-in).

<br />

Expand Down
112 changes: 88 additions & 24 deletions internal/service/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,56 @@ type EmailService struct {
tlsConfig *tls.Config
}

func (s *EmailService) IsConfigured() bool { return s.enabled }
type smtpConfig struct {
host string
port string
username string
password string
from string
enabled bool
}

// getConfig returns the active SMTP configuration. Settings stored
// dynamically in the database (via Admin Settings) take precedence over
// the startup environment variables (s.host, s.port, etc.), allowing
// administrators to configure or update SMTP from the web dashboard
// without restarting the server.
func (s *EmailService) getConfig() smtpConfig {
cfg := smtpConfig{
host: s.host,
port: s.port,
username: s.username,
password: s.password,
from: s.from,
}
if s.store != nil {
settings, err := s.store.GetSettings(context.Background())
if err == nil {
if v, ok := settings["smtp_host"]; ok && strings.TrimSpace(v) != "" {
cfg.host = strings.TrimSpace(v)
}
if v, ok := settings["smtp_port"]; ok && strings.TrimSpace(v) != "" {
cfg.port = strings.TrimSpace(v)
}
if v, ok := settings["smtp_username"]; ok && (strings.TrimSpace(v) != "" || settings["smtp_host"] != "") {
cfg.username = strings.TrimSpace(v)
}
if v, ok := settings["smtp_password"]; ok && v != "" {
cfg.password = v
}
if v, ok := settings["smtp_from"]; ok && strings.TrimSpace(v) != "" {
cfg.from = strings.TrimSpace(v)
}
}
}
if cfg.port == "" {
cfg.port = "587"
}
cfg.enabled = cfg.host != "" && cfg.from != ""
return cfg
}

func (s *EmailService) IsConfigured() bool { return s.getConfig().enabled }

func NewEmailService(host, port, username, password, from string, logger *slog.Logger, s *store.Store) *EmailService {
enabled := host != "" && from != ""
Expand Down Expand Up @@ -80,7 +129,8 @@ func DefaultTemplates() map[string]string {
}

func (s *EmailService) Send(to, subject, htmlBody string) error {
if !s.enabled {
cfg := s.getConfig()
if !cfg.enabled {
s.logger.Info("email skipped (not configured)", "to", to, "subject", subject)
return nil
}
Expand All @@ -91,7 +141,7 @@ func (s *EmailService) Send(to, subject, htmlBody string) error {
}

msg := strings.Join([]string{
"From: " + s.from,
"From: " + cfg.from,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
Expand All @@ -100,13 +150,13 @@ func (s *EmailService) Send(to, subject, htmlBody string) error {
htmlBody,
}, "\r\n")

addr := s.host + ":" + s.port
err := s.sendOnce(addr, to, []byte(msg))
addr := cfg.host + ":" + cfg.port
err := s.sendOnceWithConfig(cfg, addr, to, []byte(msg))
if err != nil {
// Retry once after a short delay — transient TCP / TLS hiccups.
s.logger.Warn("email send failed, retrying", "to", to, "error", err)
time.Sleep(3 * time.Second)
err = s.sendOnce(addr, to, []byte(msg))
err = s.sendOnceWithConfig(cfg, addr, to, []byte(msg))
if err != nil {
s.logger.Error("email send failed after retry", "to", to, "error", err)
return fmt.Errorf("email send: %w", err)
Expand All @@ -125,10 +175,10 @@ func (s *EmailService) Send(to, subject, htmlBody string) error {
// "504 5.7.4 Unrecognized authentication type").
//
// Order of operations:
// 1. TCP dial.
// 1. TCP dial (or direct TLS dial if port == 465).
// 2. EHLO (records server-advertised extensions).
// 3. STARTTLS if advertised — Office 365 / Gmail / most modern
// submission endpoints require it on port 587.
// 3. STARTTLS if advertised and not already on TLS — Office 365 /
// Gmail / most modern submission endpoints require it on port 587.
// 4. EHLO again (Client.StartTLS does this internally; the
// extension list refreshes — AUTH only appears post-TLS on
// stricter servers).
Expand All @@ -144,16 +194,21 @@ func (s *EmailService) Send(to, subject, htmlBody string) error {
// own guard. That's the safe default; relay-style deployments that
// genuinely want plaintext auth can run their own postfix in front.
func (s *EmailService) sendOnce(addr, to string, msg []byte) error {
cfg := s.getConfig()
return s.sendOnceWithConfig(cfg, addr, to, msg)
}

func (s *EmailService) sendOnceWithConfig(cfg smtpConfig, addr, to string, msg []byte) error {
// The SMTP envelope sender (MAIL FROM, RFC 5321) must be a BARE
// address — "noreply@x.com", never "Keygate <noreply@x.com>".
// The display-name form is only legal in the RFC 5322 "From:"
// header (which Send() builds separately). Strict MTAs like
// Postmark reject a display-name envelope with
// "501 Bad sender address syntax". Parse once here so operators
// can keep configuring the friendly form in SMTP_FROM.
envelopeFrom, err := parseEnvelopeAddress(s.from)
envelopeFrom, err := parseEnvelopeAddress(cfg.from)
if err != nil {
return fmt.Errorf("invalid SMTP_FROM %q: %w", s.from, err)
return fmt.Errorf("invalid SMTP_FROM %q: %w", cfg.from, err)
}
// CR/LF guard (SMTP injection) — same as net/smtp.SendMail.
if err := validateSMTPLine(envelopeFrom); err != nil {
Expand All @@ -163,11 +218,22 @@ func (s *EmailService) sendOnce(addr, to string, msg []byte) error {
return err
}

conn, err := net.DialTimeout("tcp", addr, 30*time.Second)
var conn net.Conn
dialer := &net.Dialer{Timeout: 30 * time.Second}
tlsConf := s.tlsConfig
if tlsConf == nil {
tlsConf = &tls.Config{ServerName: cfg.host, MinVersion: tls.VersionTLS12}
}

if cfg.port == "465" {
conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsConf)
} else {
conn, err = dialer.Dial("tcp", addr)
}
if err != nil {
return fmt.Errorf("dial: %w", err)
}
c, err := smtp.NewClient(conn, s.host)
c, err := smtp.NewClient(conn, cfg.host)
if err != nil {
_ = conn.Close()
return fmt.Errorf("smtp client: %w", err)
Expand All @@ -178,20 +244,18 @@ func (s *EmailService) sendOnce(addr, to string, msg []byte) error {
return fmt.Errorf("ehlo: %w", err)
}

// STARTTLS upgrade if the server advertises it. (Client.StartTLS
// internally re-EHLOs so post-TLS extensions land in c.Extension.)
if ok, _ := c.Extension("STARTTLS"); ok {
cfg := s.tlsConfig
if cfg == nil {
cfg = &tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}
}
if err := c.StartTLS(cfg); err != nil {
return fmt.Errorf("starttls: %w", err)
// STARTTLS upgrade if not on implicit TLS (port 465) and the server advertises it.
// (Client.StartTLS internally re-EHLOs so post-TLS extensions land in c.Extension.)
if cfg.port != "465" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(tlsConf); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
}

if s.username != "" {
auth, perr := pickAuth(c, s.host, s.username, s.password)
if cfg.username != "" {
auth, perr := pickAuth(c, cfg.host, cfg.username, cfg.password)
if perr != nil {
return perr
}
Expand Down
163 changes: 163 additions & 0 deletions internal/service/email_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -495,3 +495,166 @@ func genSelfSignedCert(t *testing.T) tls.Certificate {
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}
}

func newMockImplicitTLSSMTP(t *testing.T, cfg mockSMTPConfig) *mockSMTP {
t.Helper()
cert := genSelfSignedCert(t)
tlsConf := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, err := tls.Listen("tcp", "127.0.0.1:0", tlsConf)
if err != nil {
t.Fatalf("tls listen: %v", err)
}
srv := &mockSMTP{t: t, ln: ln, tlsCert: cert}
srv.wg.Add(1)
go srv.acceptImplicitTLSLoop(cfg)
return srv
}

func (s *mockSMTP) acceptImplicitTLSLoop(cfg mockSMTPConfig) {
defer s.wg.Done()
for {
conn, err := s.ln.Accept()
if err != nil {
return
}
s.wg.Add(1)
go func(c net.Conn) {
defer s.wg.Done()
defer c.Close() //nolint:errcheck
s.handleImplicitTLS(c, cfg)
}(conn)
}
}

func (s *mockSMTP) handleImplicitTLS(c net.Conn, cfg mockSMTPConfig) {
fmt.Fprint(c, "220 mockSMTP implicit TLS ready\r\n")
r := bufio.NewReader(c)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimSpace(line)
upper := strings.ToUpper(cmd)
switch {
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
fmt.Fprint(c, "250-mockSMTP\r\n")
if cfg.advertiseAuth != "" {
fmt.Fprintf(c, "250-AUTH %s\r\n", cfg.advertiseAuth)
}
fmt.Fprint(c, "250 HELP\r\n")

case strings.HasPrefix(upper, "AUTH PLAIN"):
s.mu.Lock()
s.selectedMech = "PLAIN"
s.mu.Unlock()
parts := strings.SplitN(cmd, " ", 3)
if len(parts) < 3 {
fmt.Fprint(c, "501 Syntax error\r\n")
continue
}
raw, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil {
fmt.Fprint(c, "501 base64 decode\r\n")
continue
}
segments := strings.Split(string(raw), "\x00")
if len(segments) != 3 || segments[1] != cfg.wantUsername || segments[2] != cfg.wantPassword {
fmt.Fprint(c, "535 Authentication credentials invalid\r\n")
continue
}
s.mu.Lock()
s.authSucceeded = true
s.mu.Unlock()
fmt.Fprint(c, "235 Authentication succeeded\r\n")

case strings.HasPrefix(upper, "MAIL FROM"):
s.mu.Lock()
s.gotFrom = cmd
s.mu.Unlock()
fmt.Fprint(c, "250 OK\r\n")

case strings.HasPrefix(upper, "RCPT TO"):
s.mu.Lock()
s.gotTo = append(s.gotTo, cmd)
s.mu.Unlock()
fmt.Fprint(c, "250 OK\r\n")

case upper == "DATA":
fmt.Fprint(c, "354 End data with <CR><LF>.<CR><LF>\r\n")
var data strings.Builder
for {
dl, err := r.ReadString('\n')
if err != nil {
return
}
if dl == ".\r\n" || dl == ".\n" {
break
}
data.WriteString(dl)
}
s.mu.Lock()
s.gotData = data.String()
s.mu.Unlock()
fmt.Fprint(c, "250 OK\r\n")

case upper == "QUIT":
fmt.Fprint(c, "221 Bye\r\n")
return

default:
fmt.Fprint(c, "502 Command not implemented\r\n")
}
}
}

func TestSendOnce_ImplicitTLS465(t *testing.T) {
srv := newMockImplicitTLSSMTP(t, mockSMTPConfig{
advertiseAuth: "PLAIN",
wantUsername: "bob",
wantPassword: "secret",
})
defer srv.Close()

svc := &EmailService{
host: "127.0.0.1",
port: "465",
username: "bob",
password: "secret",
from: "system@keygate.test",
enabled: true,
logger: slog.Default(),
tlsConfig: &tls.Config{InsecureSkipVerify: true, ServerName: "127.0.0.1"},
}

addr := fmt.Sprintf("127.0.0.1:%d", srv.Port())
msg := []byte("Subject: test 465\r\n\r\nhello over implicit tls")
cfg := svc.getConfig()
cfg.port = "465"
if err := svc.sendOnceWithConfig(cfg, addr, "recipient@test.com", msg); err != nil {
t.Fatalf("sendOnceWithConfig failed on 465: %v", err)
}

if !srv.AuthSucceeded() {
t.Errorf("expected AUTH to succeed over implicit TLS")
}
}

func TestEmailService_DynamicConfig(t *testing.T) {
// 1. Without store or env: not configured
svc := NewEmailService("", "", "", "", "", slog.Default(), nil)
if svc.IsConfigured() {
t.Errorf("expected IsConfigured() to be false when nothing is set")
}

// 2. Struct env settings only
svcEnv := NewEmailService("smtp.example.com", "587", "user", "pass", "noreply@example.com", slog.Default(), nil)
if !svcEnv.IsConfigured() {
t.Errorf("expected IsConfigured() to be true with env values")
}
cfg := svcEnv.getConfig()
if cfg.host != "smtp.example.com" || cfg.port != "587" {
t.Errorf("unexpected cfg: %+v", cfg)
}
}

16 changes: 12 additions & 4 deletions internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,18 @@ func (s *Store) UpsertUser(ctx context.Context, u *model.User) error {
if u.ID == "" {
u.ID = newID()
}
_, err := s.DB.NewInsert().Model(u).
On("CONFLICT (email) DO UPDATE").
Set("name = EXCLUDED.name, avatar_url = EXCLUDED.avatar_url, updated_at = now()").
Exec(ctx)
role := u.Role
if role == "" {
role = model.RoleUser
}
_, err := s.DB.NewRaw(`
INSERT INTO users (id, email, name, avatar_url, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, now(), now())
ON CONFLICT (email) DO UPDATE SET
name = CASE WHEN EXCLUDED.name != '' THEN EXCLUDED.name ELSE users.name END,
avatar_url = CASE WHEN EXCLUDED.avatar_url != '' THEN EXCLUDED.avatar_url ELSE users.avatar_url END,
updated_at = now()
`, u.ID, u.Email, u.Name, u.AvatarURL, role).Exec(ctx)
return err
}

Expand Down
Loading