Router and streams
A Sender delivers a Message; a Router picks which Sender to use and runs the checks that depend on where the message is going, not on its content.
type Sender interface {
Send(ctx context.Context, m *Message) error
}
lttr.SMTP (see SMTP) is the one Sender the library ships. A provider client (SES, Postmark, Mailgun, …) is just another type with a Send method.
Understand the Router's fields
type Router struct {
Streams map[Stream]Sender
InternalAllowed func(addr string) bool
Suppressed func(ctx context.Context, s Stream, addr string) (bool, error)
}
Streamsmaps eachStream(Transactional,Security,Marketing,Outreach,Internal) to theSenderthat carries it. A stream with no entry, or an entry set tonil, cannot be sent through —Router.Sendfails withno sender for stream "…". Streams keep reputations apart, so it is normal to point transactional and marketing mail at different providers, or the same provider under different configuration sets.InternalAllowedreports whether an address may receiveInternalmail — staff-only mail such as ops digests. Anilfunction denies all internal mail, which is the safe default: without an allow-list, nothing meant for your own team can leak to an outside address.Suppressedreports whether an address is on the suppression list for a stream: bounces, complaints, unsubscribes. Anilfunction suppresses nothing. An error fromSuppressedis returned wrapped, and the router sends nothing — a suppression check you cannot answer is not a "send anyway."
Send through the Router
func (r *Router) Send(ctx context.Context, m *Message) error
The checks run in this order, and the Router never modifies the message:
mmust not benil.- The stream must have a non-nil
SenderinStreams. - The recipient address is validated first — the same
local@domaincheckMIMEandRenderuse — beforeInternalAllowedorSuppressedever see it. This matters:net/smtpwrites the envelope from the address as given, so if the allow-list or suppression check ran on an unvalidated address, a crafted address could pass those checks while SMTP delivered somewhere else entirely. - If the stream is
Internal,InternalAllowed(addr)must returntrue, or the send fails withinternal mail to {addr} is not allowed. - If
Suppressedis set, it runs; atrueresult fails with{addr}: recipient suppressed, wrapping the exportedlttr.ErrSuppressed— test witherrors.Is(err, lttr.ErrSuppressed). - Only then does
Streams[m.Stream].Send(ctx, m)run.
Wire it up
package main
import (
"context"
"errors"
"fmt"
"net/mail"
"github.com/sulv-io/lttr"
)
// logSender is a Sender that just prints what it would send. Swap it for
// lttr.SMTP or your provider's Sender in production.
type logSender struct{ label string }
func (s logSender) Send(_ context.Context, m *lttr.Message) error {
fmt.Printf("[%s] %s -> %s: %s\n", s.label, m.From.Address, m.To.Address, m.Subject)
return nil
}
func main() {
router := <tr.Router{
Streams: map[lttr.Stream]lttr.Sender{
lttr.Transactional: logSender{"transactional"},
lttr.Security: logSender{"security"},
lttr.Marketing: logSender{"marketing"},
lttr.Internal: logSender{"internal"},
// Outreach has no sender here, so Router.Send fails for it with
// "no sender for stream ...".
},
InternalAllowed: func(addr string) bool {
return false // no staff allow-list configured in this example
},
Suppressed: func(_ context.Context, s lttr.Stream, addr string) (bool, error) {
return isSuppressed(s, addr), nil
},
}
m := <tr.Message{
Stream: lttr.Transactional,
From: mail.Address{Name: "CaribHubs", Address: "orders@caribhubs.com"},
To: mail.Address{Name: "Keisha Browne", Address: "keisha@example.com"},
Subject: "Your order is on its way",
HTML: "<p>On its way.</p>",
Text: "On its way.",
}
err := router.Send(context.Background(), m)
switch {
case errors.Is(err, lttr.ErrSuppressed):
fmt.Println("recipient is suppressed for this stream; not sent")
case err != nil:
fmt.Println("send failed:", err)
default:
fmt.Println("sent")
}
}
// isSuppressed would check a real suppression list (bounces, complaints,
// unsubscribes) keyed by stream and address.
func isSuppressed(lttr.Stream, string) bool { return false }
Running this prints:
[transactional] orders@caribhubs.com -> keisha@example.com: Your order is on its way
sent
Change m.Stream to lttr.Outreach and the same call fails with no sender for stream "outreach", since Streams has no entry for it. Change it to lttr.Internal instead — Streams does have a sender for it above, so this time the sender check passes and InternalAllowed actually runs — and it fails with internal mail to keisha@example.com is not allowed, since InternalAllowed always returns false above.
Next steps
- SMTP for the
Senderthat actually delivers mail. - Message and MIME for what a
Routeris moving.