Sending

Router and streams

Route a rendered Message to the right Sender, with the internal-mail and suppression checks Render cannot make for you.

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)
}
  • Streams maps each Stream (Transactional, Security, Marketing, Outreach, Internal) to the Sender that carries it. A stream with no entry, or an entry set to nil, cannot be sent through — Router.Send fails with no 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.
  • InternalAllowed reports whether an address may receive Internal mail — staff-only mail such as ops digests. A nil function 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.
  • Suppressed reports whether an address is on the suppression list for a stream: bounces, complaints, unsubscribes. A nil function suppresses nothing. An error from Suppressed is 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:

  1. m must not be nil.
  2. The stream must have a non-nil Sender in Streams.
  3. The recipient address is validated first — the same local@domain check MIME and Render use — before InternalAllowed or Suppressed ever see it. This matters: net/smtp writes 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.
  4. If the stream is Internal, InternalAllowed(addr) must return true, or the send fails with internal mail to {addr} is not allowed.
  5. If Suppressed is set, it runs; a true result fails with {addr}: recipient suppressed, wrapping the exported lttr.ErrSuppressed — test with errors.Is(err, lttr.ErrSuppressed).
  6. Only then does Streams[m.Stream].Send(ctx, m) run.

Wire it up

main.go
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 := &lttr.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 := &lttr.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:

Terminal
[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

Copyright © 2026