Files
enkly-ai/pkg/mailkit/mail.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

174 lines
6.2 KiB
Go

// Package mail sends outgoing email (verification codes and password
// reset links) over SMTP with STARTTLS — the scheme smtp.gmail.com
// requires on port 587. It's built on net/smtp + crypto/tls directly
// rather than a third-party mailer library, matching the rest of
// enkly's minimal-dependencies stance (see go.mod).
package mailkit
import (
"crypto/tls"
"fmt"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
)
// Config holds SMTP connection details, normally sourced from the
// ENKLY_SMTP_* environment variables (see cmd/server/main.go and
// .env.example). Host/Port default to Gmail's submission endpoint;
// Username/Password should be a Gmail *App Password*
// (https://myaccount.google.com/apppasswords), not the account's normal
// login password — Gmail rejects plain-password SMTP auth for accounts
// with 2FA, and discourages it even without 2FA.
type Config struct {
Host string // e.g. "smtp.gmail.com"
Port string // e.g. "587"
Username string // full Gmail address
Password string // Gmail App Password
// From is used for the message's From: header, and may be either a
// bare address ("noreply@example.com") or a full RFC 5322 mailbox
// with a display name ("enkly <noreply@example.com>") — Send picks
// the bare address back out of either form for the SMTP envelope
// (see envelopeAddress), since MAIL FROM must never be given a
// display name. Defaults to Username if empty.
From string
}
// Mailer sends email via a configured SMTP server.
type Mailer struct {
cfg Config
}
// New builds a Mailer from cfg, filling in Gmail's defaults for any
// blank Host/Port so a minimal .env (just username + app password)
// works out of the box. Every field is also trimmed of surrounding
// whitespace, including a stray trailing "\r" — a real failure mode
// when an operator's .env or docker-compose.yml was saved with CRLF
// line endings and the value reaches os.Getenv un-normalized: an
// embedded "\r" in an SMTP command line reads to the server as a
// premature line break, and Gmail answers with a "555 Syntax error"
// that gives no hint the cause was whitespace.
func New(cfg Config) *Mailer {
cfg.Host = strings.TrimSpace(cfg.Host)
cfg.Port = strings.TrimSpace(cfg.Port)
cfg.Username = strings.TrimSpace(cfg.Username)
cfg.Password = strings.TrimSpace(cfg.Password)
cfg.From = strings.TrimSpace(cfg.From)
if cfg.Host == "" {
cfg.Host = "smtp.gmail.com"
}
if cfg.Port == "" {
cfg.Port = "587"
}
if cfg.From == "" {
cfg.From = cfg.Username
}
return &Mailer{cfg: cfg}
}
// Configured reports whether enough SMTP settings are present to
// actually send mail. main.go logs a warning instead of failing to
// start when this is false, so the rest of the app (chat, RAG) keeps
// working even if email hasn't been set up yet — signup will just fail
// with a clear error until it is.
func (m *Mailer) Configured() bool {
return m.cfg.Username != "" && m.cfg.Password != ""
}
// Send delivers a plain-text email to a single recipient, using
// STARTTLS on the configured host/port (Gmail requires this on 587;
// plaintext SMTP is refused). Uses net/smtp's low-level Client instead
// of smtp.SendMail because SendMail only supports implicit TLS (port
// 465) or no TLS at all, not STARTTLS-then-auth.
func (m *Mailer) Send(to, subject, body string) error {
if !m.Configured() {
return fmt.Errorf("mail: not configured (set ENKLY_SMTP_USERNAME / ENKLY_SMTP_PASSWORD)")
}
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("mail: dial %s: %w", addr, err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, m.cfg.Host)
if err != nil {
return fmt.Errorf("mail: smtp handshake: %w", err)
}
defer client.Close()
if ok, _ := client.Extension("STARTTLS"); ok {
tlsConfig := &tls.Config{ServerName: m.cfg.Host}
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("mail: starttls: %w", err)
}
}
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
if ok, _ := client.Extension("AUTH"); ok {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("mail: auth: %w", err)
}
}
if err := client.Mail(envelopeAddress(m.cfg.From)); err != nil {
return fmt.Errorf("mail: MAIL FROM: %w", err)
}
if err := client.Rcpt(envelopeAddress(to)); err != nil {
return fmt.Errorf("mail: RCPT TO: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("mail: DATA: %w", err)
}
msg := buildMessage(m.cfg.From, to, subject, body)
if _, err := w.Write([]byte(msg)); err != nil {
return fmt.Errorf("mail: write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("mail: close body: %w", err)
}
return client.Quit()
}
// buildMessage assembles a minimal RFC 5322 message. Headers are kept
// deliberately simple (plain text, no MIME multipart) since enkly only
// ever sends short verification emails.
func buildMessage(from, to, subject, body string) string {
var b strings.Builder
fmt.Fprintf(&b, "From: %s\r\n", from)
fmt.Fprintf(&b, "To: %s\r\n", to)
fmt.Fprintf(&b, "Subject: %s\r\n", subject)
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
b.WriteString("\r\n")
return b.String()
}
// envelopeAddress reduces addr to the bare mailbox SMTP's envelope
// commands require (RFC 5321 §4.1.2 reverse-/forward-path: an address
// only, never a display name or angle brackets). addr may already be
// bare ("user@example.com") or a full RFC 5322 mailbox with a display
// name ("enkly <user@example.com>") — either way client.Mail/client.Rcpt
// wrap it in its own "<...>", so passing a mailbox that already
// contains a display name and brackets there produces something like
// "MAIL FROM:<enkly <user@example.com>>", which Gmail rejects with
// exactly "555 5.5.2 Syntax error, cannot decode response" — the bug
// this function exists to prevent. If addr doesn't parse as an address
// at all, it's returned unchanged so the SMTP server's own error (not a
// swallowed one here) explains what's wrong with it.
func envelopeAddress(addr string) string {
if parsed, err := mail.ParseAddress(addr); err == nil {
return parsed.Address
}
return addr
}