Skip to content
Zever
Home

Mailer

Send transactional email through a log-by-default mailer, and switch to SMTP for production.

3 min readEdit on GitHub

Use mailer.Mailer for transactional outbound mail: receipts, cancellations, password resets. It defaults to log, so validated messages go to the log instead of the wire and real delivery is an explicit adapter change. It handles validated delivery only (no template rendering, no lists), so build the finished Mail with NewMail, or with a Sender that binds the default From.

Stay on log for development and CI, and move to smtp for production; there is no in-between adapter. Enqueue sends as jobs so an SMTP blip retries at the job layer instead of failing the booking write.

Terminal window
MAILER_ADAPTER=log
MAILER_HOST=localhost
MAILER_PORT=25

host and port are required by Validate even for the log adapter, which never dials; the bookings default uses the dummy localhost pair for that reason.

main.go
package main
import (
"context"
"fmt"
maillog "github.com/zenta-dev/zever/adapters/mailer/log"
"github.com/zenta-dev/zever/core/mailer"
)
func main() {
maillog.Register()
m, _ := mailer.Open(mailer.Log, mailer.Options{Host: "localhost", Port: 25})
defer m.Close()
msg := mailer.NewMail(
mailer.Address{Address: "bookings@example.com"},
[]mailer.Address{{Address: "guest@example.com"}},
"Booking confirmed",
"Space Loft, Fri to Sun. Receipt inside.",
)
_ = m.Send(context.Background(), &msg)
fmt.Println("logged, not delivered")
}
{"from":{"address":"bookings@example.com"},"to":[{"address":"guest@example.com"}],"cc":[],"bcc":[],"subject":"Booking confirmed","body":"Space Loft, Fri to Sun. Receipt inside.","html":"","attachments":[]}
logged, not delivered

Send validates, writes one JSON line to stdout (attachments log only name and size, never bytes), and returns nil without opening a socket. In tests, use maillog.NewWithWriter(opts, w).

Terminal window
MAILER_ADAPTER=smtp
MAILER_HOST=mail.example.com
MAILER_PORT=587
MAILER_USERNAME=bookings
MAILER_PASSWORD=prod-secret-here
MAILER_ENCRYPTION=starttls
MAILER_TIMEOUT=30s
MAILER_MAX_MESSAGE_SIZE=10485760
  • encryption: starttls (default when empty), implicit, or none.
  • timeout: zero means the 30s default. File durations are integer nanoseconds, env accepts "30s".
  • max_message_size: zero means the 10 MiB default; larger messages fail with ErrMessageTooLarge.

File decoding is strict snake_case. All keys: mailer config reference.

Adapter Package Notes
log adapters/mailer/log Default, safe sink, writes each message as a JSON line
smtp adapters/mailer/smtp Production delivery, honors encryption and timeout

Both serve mailer.Mailer with Send plus Close; delivery errors stay errors.Is-compatible for job-layer retry branching.

main.go
m, err := c.Mailer()

Mailer() resolves lazily and caches; on error the cache clears. Address parsing helpers live in core/mailer/address.go and the attachment shape in core/mailer/mail.go. Close() error is instant, and the container probes it as a snapshot service with no leaf ordering. See the container.

  • EncryptionNone must not carry username or password; validation rejects that pair so credentials never cross plaintext. Use implicit only when the provider documents it.
  • Set username and password together; a lone value fails validation instead of half-authenticating at send time.
  • host must not contain a scheme (mail.example.com, not smtp://mail.example.com). Port must be 1 to 65535.
  • Address validation rejects header injection and control characters; parse user input through core/mailer/address.go before using it as From.
  • Keep attachments small, or link to storage presigned URLs instead of inlining.
  • SMTP passwords never appear in logs. Supply them by file or env, display config only through config.RedactedServices, and keep error text to names and reasons.
  • Retry transient failures at the job layer; bookings SendConfirmation uses max_attempts(3) with exponential base 30s.
host must be non-empty on boot with the log adapter

host and port were left zero; validation requires both even though log never dials. Set MAILER_HOST=localhost and MAILER_PORT=25 in your .env.

username and password must be set together

Only one of the pair was supplied, often a password without a username. Set both from the same secret source, or clear both for an anonymous relay with a non-none encryption mode.

encryption none must not carry credentials

encryption: none combined with SMTP auth values. Switch to starttls or implicit when auth is needed, or drop the credentials when the relay needs none.

Sends pass locally but time out in production

Wrong port or encryption for the provider, or blocked egress. Confirm the provider tuple (commonly 587 with starttls, 465 with implicit), raise timeout from the 30s default, and move the send behind job.Dispatcher so timeouts retry with backoff.