174 lines
6.2 KiB
Go
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
|
|
}
|