mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-10-01 19:43:49 +00:00
Registration has been dead since 2026-09-13. `ssh join@bbs.profullstack.com` creates the account, then fails at the confirmation step with "couldn't email the code" and disconnects, so nobody can finish signing up. Cause: Caddy owns ACME for mail.profullstack.com and renewed on 2026-08-14 (valid to Nov 12), but Mailu went on serving the certificate it loaded at container start (Jun 15 -> Sep 13). When that lapsed, the STARTTLS handshake from internal/mail started failing verification and every transactional send died with it -- confirmation codes, signup notifications, credential mail. Reproduced against production; 25/465/993 all still present the expired cert while :443 serves the renewed one. Three things let a single stale certificate take registration down: - setup.sh installed the refresher and enabled its *timer*, but never ran it. `systemctl enable --now <timer>` starts the timer, not the service, so a redeploy left a stale cert in place (and did nothing at all if the timer was never scheduled). The news and IRC sections already run theirs at provision time; the Mailu section now does too, which is what repairs the live host. - refresh-certs.sh only compared files, so a copy whose reload silently failed left a fresh cert on disk and an expiring one on the wire -- invisible. It now reads back what the relay actually serves, forces a reload when that disagrees with /certs, refuses to copy a source cert that is itself expired, and no longer swallows the `docker compose restart` failure. It restarts `front` alone, the only container that mounts ./certs. - internal/mail verified the relay's certificate even on loopback, where there is nothing to intercept. It now skips verification for a loopback relay (the reasoning docs/mail.md already applies to the plaintext Dovecot hand-off) and gains AGENTBBS_SMTP_SERVERNAME, mirroring AGENTBBS_MAIL_SMTP_SERVERNAME, so the documented 127.0.0.1:25 config can verify against the mail host instead of an IP literal. A non-loopback relay is still verified. Errors are wrapped with the address and the failing stage so the next failure is one journal line to diagnose rather than nine days of silence. Tests cover the envelope, the unreachable-relay message, and both halves of the TLS decision: a loopback relay with an expired cert delivers, a non-loopback one with the same cert is refused. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
137 lines
4.6 KiB
Go
137 lines
4.6 KiB
Go
// Package mail sends transactional email (account confirmation) over SMTP.
|
|
// It is intentionally tiny: standard net/smtp with STARTTLS, configured from
|
|
// AGENTBBS_SMTP_* env vars. When unconfigured, Configured() reports false and
|
|
// the caller logs the confirmation link instead of sending it.
|
|
package mail
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// Config is an SMTP relay. Host+From are the minimum for Configured().
|
|
type Config struct {
|
|
Host string // smtp server host (no port)
|
|
Port string // default 587 (STARTTLS)
|
|
User string // auth user; empty = no auth
|
|
Pass string
|
|
From string // envelope + From: header
|
|
|
|
// ServerName is the name STARTTLS certificates are verified against when it
|
|
// differs from Host. A co-located relay is dialled on loopback but presents a
|
|
// certificate for its public mail host, never for 127.0.0.1. Mirrors
|
|
// AGENTBBS_MAIL_SMTP_SERVERNAME on the mailbox gateway.
|
|
ServerName string
|
|
}
|
|
|
|
// ConfigFromEnv reads AGENTBBS_SMTP_{HOST,PORT,USER,PASS,FROM,SERVERNAME}.
|
|
func ConfigFromEnv() Config {
|
|
return Config{
|
|
Host: os.Getenv("AGENTBBS_SMTP_HOST"),
|
|
Port: os.Getenv("AGENTBBS_SMTP_PORT"),
|
|
User: os.Getenv("AGENTBBS_SMTP_USER"),
|
|
Pass: os.Getenv("AGENTBBS_SMTP_PASS"),
|
|
From: os.Getenv("AGENTBBS_SMTP_FROM"),
|
|
ServerName: os.Getenv("AGENTBBS_SMTP_SERVERNAME"),
|
|
}
|
|
}
|
|
|
|
// Configured reports whether email can actually be sent.
|
|
func (c Config) Configured() bool { return c.Host != "" && c.From != "" }
|
|
|
|
// port is the submission port, defaulting to STARTTLS 587.
|
|
func (c Config) port() string {
|
|
if c.Port == "" {
|
|
return "587"
|
|
}
|
|
return c.Port
|
|
}
|
|
|
|
// tlsServerName is the identity STARTTLS certificates are checked against.
|
|
// AGENTBBS_SMTP_SERVERNAME wins; otherwise the dialled host is used, which is
|
|
// only meaningful when that host is a real name.
|
|
func (c Config) tlsServerName() string {
|
|
if c.ServerName != "" {
|
|
return c.ServerName
|
|
}
|
|
return c.Host
|
|
}
|
|
|
|
// isLoopback reports whether the relay lives on this host. A loopback
|
|
// connection never leaves the box, so there is nothing for certificate
|
|
// verification to defend against — the same reasoning docs/mail.md already
|
|
// applies to the plaintext Dovecot hand-off.
|
|
func isLoopback(host string) bool {
|
|
if strings.EqualFold(host, "localhost") {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
// Send delivers a plain-text message. STARTTLS is negotiated whenever the
|
|
// server advertises it (the common case on :587 and on a co-located :25).
|
|
// Implicit-TLS :465 is not supported — use a STARTTLS port.
|
|
//
|
|
// Certificates are verified against tlsServerName() EXCEPT on a loopback relay,
|
|
// where verification is skipped deliberately. An on-box MTA serves a cert for
|
|
// its public mail host and renews it on its own schedule; making join@ signups
|
|
// depend on that cert being both name-matched and unexpired is how registration
|
|
// silently died for nine days when the Mailu cert lapsed. Loopback traffic is
|
|
// not interceptable, so the check bought nothing and cost everything.
|
|
func (c Config) Send(to, subject, body string) error {
|
|
if !c.Configured() {
|
|
return fmt.Errorf("smtp not configured")
|
|
}
|
|
addr := net.JoinHostPort(c.Host, c.port())
|
|
msg := "From: " + c.From + "\r\n" +
|
|
"To: " + to + "\r\n" +
|
|
"Subject: " + subject + "\r\n" +
|
|
"MIME-Version: 1.0\r\n" +
|
|
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
"\r\n" +
|
|
strings.ReplaceAll(body, "\n", "\r\n") + "\r\n"
|
|
|
|
cl, err := smtp.Dial(addr)
|
|
if err != nil {
|
|
return fmt.Errorf("smtp dial %s: %w", addr, err)
|
|
}
|
|
defer func() { _ = cl.Close() }()
|
|
|
|
if ok, _ := cl.Extension("STARTTLS"); ok {
|
|
name := c.tlsServerName()
|
|
conf := &tls.Config{ServerName: name}
|
|
if isLoopback(c.Host) {
|
|
conf = &tls.Config{ServerName: name, InsecureSkipVerify: true} // #nosec G402 -- loopback relay, see doc comment
|
|
}
|
|
if err := cl.StartTLS(conf); err != nil {
|
|
return fmt.Errorf("smtp starttls %s (servername %q): %w", addr, name, err)
|
|
}
|
|
}
|
|
if c.User != "" {
|
|
if err := cl.Auth(smtp.PlainAuth("", c.User, c.Pass, c.tlsServerName())); err != nil {
|
|
return fmt.Errorf("smtp auth %s: %w", addr, err)
|
|
}
|
|
}
|
|
if err := cl.Mail(c.From); err != nil {
|
|
return fmt.Errorf("smtp mail from %s: %w", c.From, err)
|
|
}
|
|
if err := cl.Rcpt(to); err != nil {
|
|
return fmt.Errorf("smtp rcpt to %s: %w", to, err)
|
|
}
|
|
w, err := cl.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("smtp data: %w", err)
|
|
}
|
|
if _, err := w.Write([]byte(msg)); err != nil {
|
|
return fmt.Errorf("smtp write: %w", err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return fmt.Errorf("smtp close: %w", err)
|
|
}
|
|
return cl.Quit()
|
|
}
|