Repository: arp242/goatcounter

Issue:
Improve sending of emails
This came rolling out of #824; originally emails were just for the `-errors:mailto` and a few goatcounter.com-specific things like registration emails so I just integrated basic support quickly, but with the addition of email reports (already a few years ago) it's more useful for people self-hosting GoatCounter to set the `-smtp` flag.

Currently there are a few issues:

- It's currently hard to debug things: there are no debug logs, little feedback on errors

- There is no straight-forward way to send a test email.

- TLS connections are not supported (only upgrading with STARTTLS).

Discussion:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c414968a..4e5996ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,10 @@ unreleased
 - Use PostgreSQL 17 in compose.yaml; also update the PostgreSQL settings to be
   less conservative.
 
+- The `-smtp` flag now also supports smtps:// URLs. You can also add `?debug=1`
+  to print SMTP traffic to stderr, or use the new *Server Management → Email
+  page* for testing the SMTP connection.
+
 ### Fixes
 
 - Adjust screen size categories for more modern devices.
diff --git a/cmd/goatcounter/serve.go b/cmd/goatcounter/serve.go
index cd391b6a..6c92c182 100644
--- a/cmd/goatcounter/serve.go
+++ b/cmd/goatcounter/serve.go
@@ -6,6 +6,7 @@ import (
 	"log/slog"
 	"net"
 	"net/http"
+	"net/url"
 	"os"
 	"os/exec"
 	"os/signal"
@@ -109,18 +110,15 @@ Flags:
 
   -automigrate Automatically run all pending migrations on startup.
 
-  -smtp        SMTP relay server, as URL (e.g. "smtp://user:pass@server").
+  -smtp        SMTP relay server, as URL (e.g. "smtp://user:pass@server:port").
+               TLS connections are supported via smtps:// or STARTTLS.
 
-               A special value of "stdout" will print emails to stdout without
-               actually sending them.  This is the default.
+               When the debug query parameter is present ("smtp://...?debug=1")
+               all client/server traffic will be written to stderr.
 
-               If this is an empty string (-smtp='') emails will be sent
-               without using a relay. This implementation is very simple and
-               deliverability will usually be bad (i.e. it will end up in the
-               spam box, or just be outright rejected). This usually requires
-               rDNS properly set up, and GoatCounter will *not* retry on
-               errors. Using a local smtp relay is almost always better unless
-               you really know what you're doing.
+               A special value of "stdout" or "stderr" will print emails to
+               stdout or stderr without actually sending them The default is
+               "stdout".
 
   -email-from  From: address in emails. Default: <user>@<hostname>
 
@@ -202,7 +200,7 @@ func cmdServe(f zli.Flags, ready chan<- struct{}, stop chan struct{}, saas bool)
 		dev          = f.Bool(false, "dev")
 		automigrate  = f.Bool(false, "automigrate")
 		listen       = f.String(":8080", "listen")
-		smtp         = f.String(blackmail.ConnectWriter, "smtp")
+		smtp         = f.String("stdout", "smtp")
 		flagTLS      = f.String("http", "tls")
 		errorsFlag   = f.String("", "errors")
 		from         = f.String("", "email-from")
@@ -235,10 +233,10 @@ func cmdServe(f zli.Flags, ready chan<- struct{}, stop chan struct{}, saas bool)
 	}
 
 	flagErrors(&v, errorsFlag.String())
-	flagEmail(&v, smtp.String())
+	mailer := flagEmail(&v, smtp.String())
 	geodb := setupGeo(&v, geodbFlag.String())
 	ratelimits := setupRatelimits(&v, ratelimit.String())
-	*from.Pointer() = flagFrom(&v, from.String(), domain.String())
+	*from.Pointer() = flagFrom(&v, saas, from.String(), domain.String())
 	domainCount, urlStatic := setupDomains(&v, saas, dev.Bool(), domain.Pointer(),
 		domainStatic.Pointer(), basePath.Pointer())
 
@@ -259,6 +257,7 @@ func cmdServe(f zli.Flags, ready chan<- struct{}, stop chan struct{}, saas bool)
 
 	ctx = z18n.With(ctx, z18n.NewBundle(language.English).Locale("en"))
 	ctx = geo.With(ctx, geodb)
+	ctx = blackmail.With(ctx, mailer)
 
 	if err := setupTpl(ctx, dev.Bool()); err != nil {
 		return err
@@ -474,11 +473,31 @@ func flagErrors(v *zvalidate.Validator, errors string) {
 	}
 }
 
-func flagEmail(v *zvalidate.Validator, smtp string) {
-	if smtp != blackmail.ConnectDirect && smtp != blackmail.ConnectWriter {
+func flagEmail(v *zvalidate.Validator, smtp string) blackmail.Mailer {
+	var (
+		m   blackmail.Mailer
+		err error
+	)
+	switch {
+	case strings.ToLower(smtp) == "stdout":
+		m = blackmail.NewWriter(os.Stdout)
+	case strings.ToLower(smtp) == "stderr":
+		m = blackmail.NewWriter(os.Stderr)
+	default:
 		v.URLLocal("-smtp", smtp)
+
+		var opt blackmail.RelayOptions
+		u, _ := url.Parse(smtp)
+		if u.Query().Has("debug") {
+			opt.Debug = os.Stderr
+		}
+		m, err = blackmail.NewRelay(smtp, &opt)
+	}
+	if err != nil {
+		v.Append("-smtp", fmt.Sprintf("setting up mailer: %s", err))
 	}
-	blackmail.DefaultMailer = blackmail.NewMailer(smtp)
+
+	return m
 }
 
 func setupGeo(v *zvalidate.Validator, geodbFlag string) *geoip2.Reader {
@@ -522,9 +541,9 @@ func setupRatelimits(v *zvalidate.Validator, ratelimit string) handlers.Ratelimi
 	return h
 }
 
-func flagFrom(v *zvalidate.Validator, from, domain string) string {
+func flagFrom(v *zvalidate.Validator, saas bool, from, domain string) string {
 	if from == "" {
-		if domain != "" { // saas only.
+		if saas && domain != "" { // saas only.
 			from = "support@" + znet.RemovePort(domain)
 		} else {
 			u, err := user.Current()
diff --git a/context.go b/context.go
index 7d6dbefe..a2a6469e 100644
--- a/context.go
+++ b/context.go
@@ -3,9 +3,11 @@ package goatcounter
 import (
 	"context"
 	"fmt"
+	"os"
 	"runtime/debug"
 	"time"
 
+	"zgo.at/blackmail"
 	"zgo.at/goatcounter/v2/pkg/geo"
 	"zgo.at/z18n"
 	"zgo.at/zcache/v2"
@@ -205,6 +207,9 @@ func CopyContextValues(ctx context.Context) context.Context {
 	if g := geo.Get(ctx); g != nil {
 		n = geo.With(n, g)
 	}
+	if m := blackmail.Get(ctx); m != nil {
+		n = blackmail.With(ctx, m)
+	}
 	return n
 }
 
@@ -214,6 +219,11 @@ func NewContext(ctx context.Context, db zdb.DB) context.Context {
 	n = geo.With(n, geo.Get(ctx))
 	n = NewCache(n)
 	n = NewConfig(n)
+	m := blackmail.Get(ctx)
+	if m != nil {
+		m = blackmail.NewWriter(os.Stderr)
+	}
+	n = blackmail.With(n, m)
 	return n
 }
 
diff --git a/cron/cron.go b/cron/cron.go
index 4233d13f..b5b0b346 100644
--- a/cron/cron.go
+++ b/cron/cron.go
@@ -30,7 +30,7 @@ var Tasks = []Task{
 	{"vacuum soft-deleted sites", vacuumDeleted, 12 * time.Hour},
 	{"rm old exports", oldExports, 1 * time.Hour},
 	{"cycle sessions", sessions, 1 * time.Minute},
-	{"send email reports", emailReports, 1 * time.Hour},
+	{"send email reports", EmailReports, 1 * time.Hour},
 	{"persist hits", persistAndStat, time.Duration(persistInterval.Load())},
 }
 
diff --git a/cron/email_reports.go b/cron/email_reports.go
index daa551f7..2260b9cc 100644
--- a/cron/email_reports.go
+++ b/cron/email_reports.go
@@ -21,7 +21,7 @@ import (
 var el = log.Module("email-report")
 
 // EmailReports sends email reports for sites that have this configured.
-func emailReports(ctx context.Context) error {
+func EmailReports(ctx context.Context) error {
 	users, err := reportUsers(ctx)
 	if err != nil {
 		return errors.Errorf("cron.emailReports: %w", err)
@@ -57,7 +57,7 @@ func emailReports(ctx context.Context) error {
 			continue
 		}
 
-		err = blackmail.Send(subject,
+		err = blackmail.Get(ctx).Send(subject,
 			blackmail.From("GoatCounter reports", goatcounter.Config(ctx).EmailFrom),
 			blackmail.To(user.Email),
 			blackmail.HeadersAutoreply(),
diff --git a/exportcsv.go b/exportcsv.go
index a1bd35af..0161405b 100644
--- a/exportcsv.go
+++ b/exportcsv.go
@@ -163,7 +163,7 @@ func (e *Export) RunCSV(ctx context.Context, fp *os.File, mailUser bool) {
 	if mailUser {
 		site := MustGetSite(ctx)
 		user := GetUser(ctx)
-		err = blackmail.Send("GoatCounter export ready",
+		err = blackmail.Get(ctx).Send("GoatCounter export ready",
 			blackmail.From("GoatCounter export", Config(ctx).EmailFrom),
 			blackmail.To(user.Email),
 			blackmail.HeadersAutoreply(),
@@ -261,7 +261,7 @@ func ImportCSV(
 		// Send email after 10s delay to make sure the cron task has finished
 		// updating all the rows.
 		time.Sleep(10 * time.Second)
-		err = blackmail.Send("GoatCounter import ready",
+		err = blackmail.Get(ctx).Send("GoatCounter import ready",
 			blackmail.From("GoatCounter import", Config(ctx).EmailFrom),
 			blackmail.To(GetUser(ctx).Email),
 			blackmail.BodyMustText(TplEmailImportDone{ctx, *site, n, errs}.Render))
diff --git a/go.mod b/go.mod
index e96c3258..41efc357 100644
--- a/go.mod
+++ b/go.mod
@@ -20,7 +20,7 @@ require (
 	github.com/BurntSushi/toml v1.5.0
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
 	github.com/teamwork/reload v1.4.2
-	zgo.at/blackmail v0.0.0-20241118175226-9a8f9442bb0d
+	zgo.at/blackmail v0.0.0-20251205074349-835f88ad0615
 	zgo.at/errors v1.4.0
 	zgo.at/follow v0.0.0-20250412000721-fecf5127a123
 	zgo.at/gadget v1.0.0
diff --git a/go.sum b/go.sum
index 4e58f8da..c5c83762 100644
--- a/go.sum
+++ b/go.sum
@@ -57,8 +57,8 @@ golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
 golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
 golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
 golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
-zgo.at/blackmail v0.0.0-20241118175226-9a8f9442bb0d h1:j82yKhWF5jDs8i62JJBhbR2LLuDH7UCul5hmpUGDBHU=
-zgo.at/blackmail v0.0.0-20241118175226-9a8f9442bb0d/go.mod h1:GJGG5S0K6R1SxIvJuT+vQPVYF3uusvljesKv/E3/7hs=
+zgo.at/blackmail v0.0.0-20251205074349-835f88ad0615 h1:AdTk8a17k7bJv2OIGnN6JHtqljSVltt0PG+jcLG7nOE=
+zgo.at/blackmail v0.0.0-20251205074349-835f88ad0615/go.mod h1:8zhuXghYxes7SXEgi6tugjG8iN3XE/M7HdYOTqTnCbI=
 zgo.at/errors v1.4.0 h1:vFMu0mNUMOYtV1jufpZQ8LDvI5/JYTLH8y0NjzZnNeg=
 zgo.at/errors v1.4.0/go.mod h1:POfgvh1LafF2NZJk6buGYCIhcHWuR/miB3nndyf3ozs=
 zgo.at/follow v0.0.0-20250412000721-fecf5127a123 h1:ogO8HrEECBi9igWgza1oUGdezlKCsAQE3UQhLOttiUY=
diff --git a/handlers/bosmang.go b/handlers/bosmang.go
index 581b7974..b12ef916 100644
--- a/handlers/bosmang.go
+++ b/handlers/bosmang.go
@@ -1,10 +1,13 @@
 package handlers
 
 import (
+	"bytes"
 	"fmt"
+	"io"
 	"net/http"
 
 	"github.com/go-chi/chi/v5"
+	"zgo.at/blackmail"
 	"zgo.at/goatcounter/v2"
 	"zgo.at/goatcounter/v2/cron"
 	"zgo.at/goatcounter/v2/pkg/bgrun"
@@ -33,6 +36,8 @@ func (h bosmang) mount(r chi.Router, db zdb.DB) {
 	a.Get("/bosmang/cache", zhttp.Wrap(h.cache))
 	a.Get("/bosmang/error", zhttp.Wrap(h.error))
 	a.Get("/bosmang/bgrun", zhttp.Wrap(h.bgrun))
+	a.Get("/bosmang/email", zhttp.Wrap(h.email))
+	a.Post("/bosmang/email", zhttp.Wrap(h.sendEmail))
 	a.Post("/bosmang/bgrun/{task}", zhttp.Wrap(h.runTask))
 	a.Get("/bosmang/metrics", zhttp.Wrap(h.metrics))
 	a.Handle("/bosmang/profile*", zprof.NewHandler(zprof.Prefix("/bosmang/profile")))
@@ -109,3 +114,54 @@ func (h bosmang) metrics(w http.ResponseWriter, r *http.Request) error {
 func (h bosmang) error(w http.ResponseWriter, r *http.Request) error {
 	return guru.New(500, "test error")
 }
+
+func (h bosmang) emailTpl(w http.ResponseWriter, r *http.Request, email string, err error, out string) error {
+	return zhttp.Template(w, "bosmang_email.gohtml", struct {
+		Globals
+		Info       map[string]any
+		From       string
+		Email      string
+		TestErr    error
+		TestResult string
+	}{newGlobals(w, r), blackmail.Get(r.Context()).Info(),
+		goatcounter.Config(r.Context()).EmailFrom,
+		email, err, out,
+	})
+}
+
+func (h bosmang) email(w http.ResponseWriter, r *http.Request) error {
+	return h.emailTpl(w, r, goatcounter.Config(r.Context()).EmailFrom, nil, "")
+}
+
+func (h bosmang) sendEmail(w http.ResponseWriter, r *http.Request) error {
+	var (
+		email = r.Form.Get("email")
+		m     = blackmail.Get(r.Context())
+		info  = m.Info()
+		dbg   = new(bytes.Buffer)
+	)
+	switch info["sender"] {
+	case "mailerRelay":
+		o := info["opts"].(blackmail.RelayOptions)
+		if o.Debug != nil {
+			o.Debug = io.MultiWriter(o.Debug, dbg)
+		} else {
+			o.Debug = dbg
+		}
+		var err error
+		m, err = blackmail.NewRelay(info["url"].(string), &o)
+		if err != nil {
+			return err
+		}
+	case "mailerWriter":
+		fmt.Fprintf(dbg, "mailerWriter: wrote email to fd %s (%s)", info["fd"], info["name"])
+	}
+
+	err := m.Send(
+		"GoatCounter test email",
+		blackmail.From("GoatCounter", goatcounter.Config(r.Context()).EmailFrom),
+		blackmail.To(email),
+		blackmail.HeadersAutoreply(),
+		blackmail.BodyText([]byte("Test email from GoatCounter")))
+	return h.emailTpl(w, r, email, err, dbg.String())
+}
diff --git a/handlers/i18n.go b/handlers/i18n.go
index 8a14d737..3be01c11 100644
--- a/handlers/i18n.go
+++ b/handlers/i18n.go
@@ -282,7 +282,7 @@ func (h i18n) submit(w http.ResponseWriter, r *http.Request) error {
 
 	ctx := zcontext.WithoutTimeout(r.Context())
 	go func() {
-		err := blackmail.Send("GoatCounter translation updates",
+		err := blackmail.Get(ctx).Send("GoatCounter translation updates",
 			blackmail.From("", User(ctx).Email),
 			blackmail.To("support@goatcounter.com"),
 			blackmail.BodyText([]byte(msg)))
diff --git a/handlers/settings.go b/handlers/settings.go
index d4418450..79fc50c0 100644
--- a/handlers/settings.go
+++ b/handlers/settings.go
@@ -641,7 +641,7 @@ func (h settings) exportImport(w http.ResponseWriter, r *http.Request) error {
 				err = e.Unwrap()
 			}
 
-			sendErr := blackmail.Send("GoatCounter import error",
+			sendErr := blackmail.Get(ctx).Send("GoatCounter import error",
 				blackmail.From("GoatCounter import", goatcounter.Config(r.Context()).EmailFrom),
 				blackmail.To(user.Email),
 				blackmail.HeadersAutoreply(),
@@ -949,7 +949,8 @@ func (h settings) usersAdd(w http.ResponseWriter, r *http.Request) error {
 
 	ctx := goatcounter.CopyContextValues(r.Context())
 	bgrun.RunFunction(fmt.Sprintf("adduser:%d", newUser.ID), func() {
-		err := blackmail.Send(fmt.Sprintf("A GoatCounter account was created for you at %s", account.Display(ctx)),
+		err := blackmail.Get(ctx).Send(
+			fmt.Sprintf("A GoatCounter account was created for you at %s", account.Display(ctx)),
 			blackmail.From("GoatCounter", goatcounter.Config(r.Context()).EmailFrom),
 			blackmail.To(newUser.Email),
 			blackmail.BodyMustText(goatcounter.TplEmailAddUser{ctx, *account, newUser, goatcounter.GetUser(ctx).Email}.Render),
diff --git a/handlers/user.go b/handlers/user.go
index 93f0d063..3acf7f62 100644
--- a/handlers/user.go
+++ b/handlers/user.go
@@ -121,7 +121,7 @@ func (h user) requestReset(w http.ResponseWriter, r *http.Request) error {
 	site := Site(r.Context())
 	ctx := goatcounter.CopyContextValues(r.Context())
 	bgrun.RunFunction("email:password", func() {
-		err := blackmail.Send(
+		err := blackmail.Get(ctx).Send(
 			T(ctx, "email/reset-user-email-subject|Password reset for %(domain)", site.Domain(ctx)),
 			blackmail.From("GoatCounter login", goatcounter.Config(ctx).EmailFrom),
 			blackmail.To(u.Email),
@@ -427,7 +427,7 @@ func (h user) resendVerify(w http.ResponseWriter, r *http.Request) error {
 func sendEmailVerify(ctx context.Context, site *goatcounter.Site, user *goatcounter.User, emailFrom string) {
 	ctx = goatcounter.CopyContextValues(ctx)
 	bgrun.RunFunction("email:verify", func() {
-		err := blackmail.Send("Verify your email",
+		err := blackmail.Get(ctx).Send("Verify your email",
 			mail.Address{Name: "GoatCounter", Address: emailFrom},
 			blackmail.To(user.Email),
 			blackmail.BodyMustText(goatcounter.TplEmailVerify{ctx, *site, *user}.Render))
diff --git a/handlers/website.go b/handlers/website.go
index 14fd00d6..d6f2c7e0 100644
--- a/handlers/website.go
+++ b/handlers/website.go
@@ -186,7 +186,7 @@ func (h website) contact(w http.ResponseWriter, r *http.Request) error {
 		return render(v)
 	}
 
-	err = blackmail.Send("GoatCounter message",
+	err = blackmail.Get(r.Context()).Send("GoatCounter message",
 		email,
 		blackmail.To("support@goatcounter.com"),
 		blackmail.BodyText(append([]byte(args.Message), "\n"...)))
@@ -379,7 +379,7 @@ func (h website) doSignup(w http.ResponseWriter, r *http.Request) error {
 
 	ctx := goatcounter.CopyContextValues(r.Context())
 	bgrun.RunFunction("welcome email", func() {
-		err := blackmail.Send("Welcome to GoatCounter!",
+		err := blackmail.Get(ctx).Send("Welcome to GoatCounter!",
 			blackmail.From("GoatCounter", goatcounter.Config(r.Context()).EmailFrom),
 			blackmail.To(user.Email),
 			blackmail.BodyMustText(goatcounter.TplEmailWelcome{ctx, site, user, goatcounter.Config(ctx).DomainCount}.Render),
@@ -455,7 +455,7 @@ func (h website) doForgot(w http.ResponseWriter, r *http.Request) error {
 	ctx := goatcounter.CopyContextValues(r.Context())
 	bgrun.RunFunction("email:sites", func() {
 		defer log.Recover(ctx)
-		err := blackmail.Send("Your GoatCounter sites",
+		err := blackmail.Get(ctx).Send("Your GoatCounter sites",
 			mail.Address{Name: "GoatCounter", Address: goatcounter.Config(ctx).EmailFrom},
 			blackmail.To(args.Email),
 			blackmail.BodyMustText(goatcounter.TplEmailForgotSite{ctx, sites, args.Email}.Render))
diff --git a/pkg/email_log/email.go b/pkg/email_log/email.go
index b500f00c..45e76573 100644
--- a/pkg/email_log/email.go
+++ b/pkg/email_log/email.go
@@ -69,7 +69,7 @@ func (e Email) Handle(ctx context.Context, r slog.Record) error {
 	subject = strings.TrimLeft(subject, " \t:")
 
 	go func() {
-		err := blackmail.Send(subject,
+		err := blackmail.Get(ctx).Send(subject,
 			blackmail.From("", e.from),
 			blackmail.To(e.to),
 			blackmail.BodyText([]byte(msg)))
diff --git a/tpl/bosmang_email.gohtml b/tpl/bosmang_email.gohtml
new file mode 100644
index 00000000..7d9b160d
--- /dev/null
+++ b/tpl/bosmang_email.gohtml
@@ -0,0 +1,33 @@
+{{template "_backend_top.gohtml" .}}
+
+<h1>Email</h1>
+
+<h2>Configuration</h2>
+<p>
+	<span style="display:inline-block; min-width:8rem;">-email-from </span>{{.From}}<br>
+	<span style="display:inline-block; min-width:8rem;">-smtp:</span><br>
+	<span style="display:inline-block; min-width:8rem; margin-left:2rem;">sender </span><code>{{index .Info "sender"}}</code><br>
+	{{range $k, $v := .Info}}
+		{{if ne $k "sender"}}
+			<span style="display:inline-block; min-width:8rem; margin-left:2rem;">{{$k}} </span><code>{{$v}}</code><br>
+		{{end}}
+	{{end}}
+</p>
+
+<h2>Send test email</h2>
+<form method="POST" action="{{$.Base}}/bosmang/email">
+	<input type="hidden" name="csrf" value="{{$.User.CSRFToken}}">
+
+	<label for="email">Recipient</label>
+	<input type="email" name="email" id="email" value="{{.Email}}">
+
+	<button>Send test email</button>
+</form>
+
+{{if .TestResult}}
+	<h3>Test output</h3>
+	{{if .TestErr}}<div class="flash flash-e" style="text-align:left; margin:1em">{{.TestErr}}</div>{{end}}
+	<pre>{{.TestResult}}</pre>
+{{end}}
+
+{{template "_backend_bottom.gohtml" .}}
diff --git a/tpl/settings_server.gohtml b/tpl/settings_server.gohtml
index b146f019..e65310cb 100644
--- a/tpl/settings_server.gohtml
+++ b/tpl/settings_server.gohtml
@@ -19,6 +19,7 @@ to users with “server mangagement” access set.</p>
 	<li><a href="{{.Base}}/bosmang/metrics" >Metrics</a>          – Some performance metrics.</li>
 	<li><a href="{{.Base}}/bosmang/profile" >Profile</a>          – Go internal performance metrics (pprof).</li>
 	<li><a href="{{.Base}}/bosmang/error"   >Error</a>            – Generate an error; for testing logs and -errors flag.</li>
+	<li><a href="{{.Base}}/bosmang/email"   >Email</a>            – Send test email, for testing <code>-smtp</code> setting.</li>
 </ul>
 
 {{template "_backend_bottom.gohtml" .}}



Test patch:
diff --git a/cmd/goatcounter/main_test.go b/cmd/goatcounter/main_test.go
index 12478138..3437b614 100644
--- a/cmd/goatcounter/main_test.go
+++ b/cmd/goatcounter/main_test.go
@@ -7,7 +7,6 @@ import (
 	"strings"
 	"testing"
 
-	"zgo.at/blackmail"
 	"zgo.at/goatcounter/v2"
 	"zgo.at/goatcounter/v2/cron"
 	"zgo.at/goatcounter/v2/gctest"
@@ -32,8 +31,6 @@ func startTest(t *testing.T) (
 ) {
 	t.Helper()
 
-	blackmail.DefaultMailer = blackmail.NewMailer(blackmail.ConnectWriter)
-
 	goatcounter.Memstore.Reset()
 
 	ctx = gctest.DBFile(t)
diff --git a/cron/email_reports_test.go b/cron/email_reports_test.go
index acb1984e..45ac13c5 100644
--- a/cron/email_reports_test.go
+++ b/cron/email_reports_test.go
@@ -194,17 +194,14 @@ func TestEmailReports(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			ctx := tt.setup(ztime.WithNow(gctest.DB(t), time.Date(2019, 6, 17, 0, 1, 0, 0, time.UTC)))
-
 			buf := new(bytes.Buffer)
+			ctx = blackmail.With(ctx, blackmail.NewWriter(buf))
 			goatcounter.Config(ctx).EmailFrom = "test@goatcounter.localhost.com"
-			blackmail.DefaultMailer = blackmail.NewMailer(blackmail.ConnectWriter, blackmail.MailerOut(buf))
 
-			err := cron.TaskEmailReports()
+			err := cron.EmailReports(ctx)
 			if err != nil {
 				t.Fatal(err)
 			}
-			cron.WaitEmailReports()
-			//fmt.Println(buf.String())
 
 			if tt.want == "" {
 				if buf.String() != "" {
diff --git a/exportcsv_test.go b/exportcsv_test.go
index 8c611a93..b2539a27 100644
--- a/exportcsv_test.go
+++ b/exportcsv_test.go
@@ -7,7 +7,6 @@ import (
 	"testing"
 	"time"
 
-	"zgo.at/blackmail"
 	"zgo.at/goatcounter/v2"
 	"zgo.at/goatcounter/v2/gctest"
 	"zgo.at/zdb"
@@ -16,7 +15,6 @@ import (
 )
 
 func TestCSVExport(t *testing.T) {
-	blackmail.DefaultMailer = blackmail.NewMailer(blackmail.ConnectWriter)
 	ctx := gctest.DB(t)
 
 	var site goatcounter.Site
diff --git a/gctest/gctest.go b/gctest/gctest.go
index 935697d6..09633d6a 100644
--- a/gctest/gctest.go
+++ b/gctest/gctest.go
@@ -4,11 +4,13 @@ package gctest
 import (
 	"context"
 	"fmt"
+	"io"
 	"os"
 	"os/exec"
 	"testing"
 
 	"golang.org/x/text/language"
+	"zgo.at/blackmail"
 	"zgo.at/goatcounter/v2"
 	"zgo.at/goatcounter/v2/cron"
 	"zgo.at/goatcounter/v2/db/migrate/gomig"
@@ -45,6 +47,7 @@ func Context(db zdb.DB) context.Context {
 	ctx = z18n.With(ctx, z18n.NewBundle(language.BritishEnglish).Locale("en-GB"))
 	geodb, _ := geo.Open("")
 	ctx = geo.With(ctx, geodb)
+	ctx = blackmail.With(ctx, blackmail.NewWriter(io.Discard))
 
 	goatcounter.Config(ctx).BcryptMinCost = true
 	goatcounter.Config(ctx).GoatcounterCom = true
diff --git a/handlers/http_test.go b/handlers/http_test.go
index ee546d3a..eb1709a2 100644
--- a/handlers/http_test.go
+++ b/handlers/http_test.go
@@ -15,7 +15,6 @@ import (
 	"testing"
 
 	"github.com/go-chi/chi/v5"
-	"zgo.at/blackmail"
 	"zgo.at/goatcounter/v2"
 	"zgo.at/goatcounter/v2/gctest"
 	"zgo.at/goatcounter/v2/pkg/log"
@@ -47,9 +46,6 @@ type handlerTest struct {
 func init() {
 	supportWebsocket.Store(false)
 
-	blackmail.DefaultMailer = blackmail.NewMailer(blackmail.ConnectWriter,
-		blackmail.MailerOut(new(bytes.Buffer)))
-
 	files, _ := fs.Sub(os.DirFS(zgo.ModuleRoot()), "tpl")
 	err := ztpl.Init(files)
 	if err != nil {
@@ -69,7 +65,7 @@ func TestMain(m *testing.M) {
 		// Don't need tests.
 		"", "bosmang.gohtml", "bosmang_site.gohtml", "bosmang_cache.gohtml",
 		"bosmang_bgrun.gohtml", "bosmang_metrics.gohtml", "bosmang_sites.gohtml",
-		"i18n_list.gohtml", "i18n_show.gohtml", "i18n_manage.gohtml",
+		"bosmang_email.gohtml", "i18n_list.gohtml", "i18n_show.gohtml", "i18n_manage.gohtml",
 
 		// Tested in tpl_test.go
 		"email_export_done.gotxt", "email_forgot_site.gotxt",