Files
Ilker ManapandClaude Opus 5.5 66f702d5c1 A forgotten password is set through a one-time link
/forgot mails a link to set a new password when the cluster's mail settings
say how - SMTP relay, sender, and the public base_url the link points at,
never the request's Host - and says the same whether or not the address has
an account. /reset sets it once, closing every session. Links are kept by
their hash in password_resets, good for an hour, a newer one replacing an
older. Without mail, /forgot says to ask an operator, and llmgw users
reset-link makes the link to hand over. Single sign-on accounts get none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:20:11 +02:00

126 lines
3.9 KiB
Go

// Package mail sends the gateway's few emails — a password reset link — by
// SMTP, when the cluster's settings say where through.
package mail
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
)
// Config is where mail goes out.
type Config struct {
// SMTP is the relay, host:port: 587 with STARTTLS, 465 with TLS from the
// start, 25 as the relay allows. Empty sends nothing.
SMTP string `koanf:"smtp"`
// From is the sender, llmgw@example.com or "llmgw <llmgw@example.com>".
From string `koanf:"from"`
// Username and PasswordFile log in to the relay, when it wants that.
// The password is a secret reference: a file, env:NAME or vault:path.
Username string `koanf:"username"`
PasswordFile string `koanf:"password_file"`
// BaseURL is the gateway's public address, which links in mail point
// at: https://llm.example.com. Never taken from a request, whose Host a
// sender chooses and could point a reset link at their own site.
BaseURL string `koanf:"base_url"`
}
// Enabled is whether mail can be sent.
func (c Config) Enabled() bool { return c.SMTP != "" && c.From != "" && c.BaseURL != "" }
// Validate refuses a half-set configuration.
func (c Config) Validate() error {
if c.SMTP == "" && c.From == "" && c.BaseURL == "" {
return nil
}
if _, _, err := net.SplitHostPort(c.SMTP); err != nil {
return fmt.Errorf("mail.smtp %q: host:port, like smtp.example.com:587", c.SMTP)
}
if c.From == "" || !strings.Contains(c.From, "@") {
return errors.New("mail.from: the sender's address")
}
if !strings.HasPrefix(c.BaseURL, "https://") && !strings.HasPrefix(c.BaseURL, "http://") {
return fmt.Errorf("mail.base_url %q: the gateway's public address, https://…", c.BaseURL)
}
return nil
}
// Send sends one plain-text message.
func (c Config) Send(ctx context.Context, to, subject, body string) error {
if !c.Enabled() {
return errors.New("no mail relay is set (mail.smtp, mail.from, mail.base_url)")
}
host, port, _ := net.SplitHostPort(c.SMTP)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
d := net.Dialer{}
var conn net.Conn
var err error
if port == "465" {
conn, err = (&tls.Dialer{NetDialer: &d, Config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}}).DialContext(ctx, "tcp", c.SMTP)
} else {
conn, err = d.DialContext(ctx, "tcp", c.SMTP)
}
if err != nil {
return fmt.Errorf("reaching %s: %w", c.SMTP, err)
}
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return err
}
defer cl.Close()
if ok, _ := cl.Extension("STARTTLS"); ok && port != "465" {
if err := cl.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("STARTTLS with %s: %w", c.SMTP, err)
}
}
if c.Username != "" {
pass, err := secrets.ReadString(ctx, c.PasswordFile)
if err != nil {
return fmt.Errorf("mail.password_file: %w", err)
}
if err := cl.Auth(smtp.PlainAuth("", c.Username, pass, host)); err != nil {
return fmt.Errorf("logging in to %s: %w", c.SMTP, err)
}
}
if err := cl.Mail(address(c.From)); err != nil {
return err
}
if err := cl.Rcpt(to); err != nil {
return err
}
w, err := cl.Data()
if err != nil {
return err
}
msg := "From: " + c.From + "\r\nTo: " + to + "\r\nSubject: " + subject +
"\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nDate: " +
time.Now().UTC().Format(time.RFC1123Z) + "\r\n\r\n" + strings.ReplaceAll(body, "\n", "\r\n")
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return cl.Quit()
}
// address is the bare address in "Name <a@b>".
func address(from string) string {
if i, j := strings.LastIndexByte(from, '<'), strings.LastIndexByte(from, '>'); i >= 0 && j > i {
return from[i+1 : j]
}
return strings.TrimSpace(from)
}