CARLOS rastrillo docs

๐Ÿค– mail

amadan.net/rastrillo/rastrillo/mail

The one outbound-email surface.

Sender

type Sender interface {
	Send(ctx context.Context, to, subject, body string) error
}

Signature-compatible with signin's Mailer on purpose, so auth takes either without an adapter.

The context is honoured. A whole SMTP conversation is bounded by your deadline, or by 30 seconds if you pass one without. Before that, a wedged relay could block the caller indefinitely โ€” long enough to hold SQLite's single writer, which is why apps ended up with rules about never sending inside a transaction.

SMTP and FromEnv

func SMTP(host, port, from, user, pass string) Sender
func FromEnv(prefix string, logger *slog.Logger) Sender

FromEnv reads <prefix>_SMTP_{HOST,PORT,FROM,USER,PASS}, which is how a deployed app is normally configured. With HOST or FROM missing it warns and gives you Logged instead โ€” an unconfigured standalone instance is expected, not an error.

Header injection is refused: a to or subject containing a newline is rejected instead of sent. That is the whole CRLF injection class, and refusing at the boundary means no caller has to remember to sanitise.

Logged

func Logged(logger *slog.Logger) Sender

Writes the message to the log instead of sending it, with a warning on every send.

This is what auth falls back to when you configure no mailer, and the warning matters: a magic link is a live credential, so an app logging them is one misconfiguration away from putting credentials in a log aggregator. Development only, and it says so every time.

Mail to a list

A magic link needs nothing beyond From, To and Subject. Anything sent to more than one person needs more than that, so there is a second method:

type ListMessage struct {
	To, Subject, Body string
	ListID            string // announce.example.com
	Unsubscribe       string // https URL
	MessageID         string // id@example.com
	ReplyTo           string
}

func SendList(ctx context.Context, s Sender, m ListMessage) error

Unsubscribe is the one that matters most. It emits List-Unsubscribe together with the List-Unsubscribe-Post that makes it RFC 8058 one-click, and that pair is the difference between a reader leaving your list and a reader pressing "report spam" โ€” which damages the sending domain for every other app on it. Gmail and Yahoo require it above 5,000 messages a day and reward it well below that. It must be an https URL, because one-click is defined over an HTTPS POST; a mailto: would give you a header pair that looks compliant and isn't.

ListID lets a reader file your mail into a folder and lets a provider see successive mailings as one stream. MessageID makes a retried delivery recognisably the same message rather than a second one. Both take angle brackets or not, as you prefer.

The fields are a closed set on purpose. A map[string]string of headers would hand callers a second From and a Bcc, and would turn the injection guard from a property of this package into something every caller has to remember. If you need a header that isn't here, add it here.

One thing this can't check: your relay has to DKIM-sign the two unsubscribe headers, or providers won't honour the one-click.

SendList takes a Sender and refuses if it isn't a ListSender โ€” both senders in this package are. It won't quietly fall back to Send, because that would deliver the message stripped of the unsubscribe header you called it for. If you wrap a Sender, implement SendList to pass it through.

Before a mailing run

func IsConfigured(s Sender) bool

Logged returns nil from Send, which is the right answer for a magic link on a dev box and the wrong one for a mailing. Run a list against it and you record several hundred deliveries that never happened, and write several hundred subscriber addresses into the log in plaintext on the way.

So check before you start:

if !mail.IsConfigured(sender) {
	return errors.New("no relay configured; refusing to run the mailing")
}

A Sender this package didn't make is assumed to deliver. If yours wraps another, implement Deliverer and forward Delivers() bool, or IsConfigured stops seeing through it.

Read this page as markdown โ€” exact, unstyled, and cheap for an agent to fetch.