Sending

Message and MIME

The rendered email as a Go value, the rules for adding your own headers, and the exact bytes MIME writes.

Template.Render (see Config and Renderer) returns a *lttr.Message: the envelope, both bodies and any extra headers. A Sender — SMTP or your own — delivers it; Message.MIME turns it into the bytes a mail server accepts.

Read the rendered fields

type Message struct {
    Template, Family string
    Stream    Stream
    From, To  mail.Address
    Subject, Preheader string
    HTML, Text string
    Tracking  bool
    Warnings  []string
}

Template and Family name what produced the message; they are never written to the wire. Stream is what a Router uses to pick a Sender. Tracking reports whether the family the template belongs to has link tracking on. Warnings carries non-fatal findings from compile and render — for example, the HTML is over 90 KB but under the 100 KB hard limit.

Message.Clone() returns a deep copy: changing the copy's headers or warnings never touches the original. SMTP.Send clones before adding its own headers, so the same *Message can be sent through more than one Sender without them stepping on each other.

Set a custom header

func (m *Message) SetHeader(name, value string) error
func (m *Message) Header(name string) string
func (m *Message) Headers() []string

SetHeader replaces any earlier value for name. Three things make it fail:

  • The name itself is invalid. name must be a non-empty run of printable ASCII without :; anything else fails with header name %q is invalid before the reserved-name or value checks even run.
  • The name is reserved. From, To, Subject, Date, Message-Id, Mime-Version, Content-Type and Content-Transfer-Encoding are reserved because MIME writes them itself. Cc and Bcc are reserved for a different reason: MIME never writes either one (there is only ever one recipient, Message.To), but a header by either name would functionally add a recipient that the Router's checks and the SMTP envelope never see — so both are blocked even though MIME itself never emits them. List-Unsubscribe and List-Unsubscribe-Post are reserved for a third reason again: they can only be set through SetListUnsubscribe, which enforces https.
  • The value is unsafe. A value containing CR, LF or NUL is refused — this is what stops header injection. A value must also be printable ASCII (space and tab allowed); raw 8-bit or control bytes would make an invalid RFC 5322 header, so they are refused rather than silently written. Encode such a value (for example with mime.QEncoding) before calling SetHeader.

Names are matched case-insensitively and stored under their canonical form (textproto.CanonicalMIMEHeaderKey), so m.Header("x-order-id") and m.Header("X-Order-Id") return the same value. Headers() returns the canonical names, sorted — the order MIME writes them in, between MIME-Version and Content-Type.

Set the one-click unsubscribe header

func (m *Message) SetListUnsubscribe(u string) error

SetListUnsubscribe sets the RFC 8058 one-click headers:

List-Unsubscribe: <https://example.com/u/abc123>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

u must be an absolute https URL with a host; anything else is an error, including a URL containing CR, LF, NUL, a space, <, > or a byte outside printable ASCII. Render calls this itself for every marketing-stream message, not only ones with an F1 footer — it passes the recipient's UnsubscribeURL regardless of footer variant. The F1-specific rule is a separate, earlier check: an F1 footer with no UnsubscribeURL fails render up front with render: marketing mail needs an unsubscribe url, before SetListUnsubscribe is even reached; a marketing message with a different footer and no UnsubscribeURL instead fails later, inside SetListUnsubscribe itself, since "" is not an https URL. See Know what Render refuses below.

Serialise to MIME

func (m *Message) MIME(date time.Time) ([]byte, error)

MIME writes an RFC 5322 message with CRLF line endings: multipart/alternative, text/plain first, then text/html, both UTF-8 and quoted-printable. The headers are written in a fixed order — From, To, Subject, Date, Message-ID, MIME-Version, then the custom headers sorted by name, then Content-Type:

order.eml
From: "CaribHubs" <orders@caribhubs.com>
To: "Keisha Browne" <keisha@example.com>
Subject: Your order is on its way
Date: Thu, 24 Sep 2026 22:53:44 -0400
Message-ID: <ad1d38dcd0c431e3cba79e0db7474199@caribhubs.com>
MIME-Version: 1.0
List-Unsubscribe: <https://caribhubs.com/u/abc123>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
X-Order-Id: CH-20417
Content-Type: multipart/alternative;
 boundary="4c060431c4092f157d21b49dcc17fb0b"

--4c060431c4092f157d21b49dcc17fb0b
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=utf-8

On its way.
--4c060431c4092f157d21b49dcc17fb0b
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=utf-8

<p>On its way.</p>
--4c060431c4092f157d21b49dcc17fb0b--

produced by:

main.go
package main

import (
    "net/mail"
    "os"
    "time"

    "github.com/sulv-io/lttr"
)

func main() {
    m := &lttr.Message{
        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.",
    }
    must(m.SetHeader("X-Order-Id", "CH-20417"))
    must(m.SetListUnsubscribe("https://caribhubs.com/u/abc123"))

    eml, err := m.MIME(time.Now())
    must(err)
    os.Stdout.Write(eml)
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

A few details worth relying on:

  • The Subject and both display names are RFC 2047 encoded when they are not plain ASCII; mime.QEncoding.Encode leaves already-ASCII values unchanged.
  • Message-ID is <{32 hex characters}@{From's domain}>, and the MIME boundary is a second 32-character hex string — both from crypto/rand, so two calls to MIME on the same message never collide.
  • A line is folded at a space so it stays within 78 bytes where the value allows it (RFC 5322 §2.1.1); a word longer than that stays whole on its line. A line that cannot be folded to 998 bytes or fewer is an error.
  • Both bodies are required. MIME itself refuses an empty Text or HTML — "text part is empty" or "html part is empty" — rather than serialising a one-sided message; this isn't something Render checks up front, it's MIME's own safety net for a hand-built Message too.
  • MIME re-checks From, To and Subject even if Render already checked them (below), so a hand-built Message gets the same guarantees Render gives a compiled template's output.

Know what Render refuses

Template.Render (D34) checks the following itself, before a message ever reaches MIME:

  • The subject must be sane. Empty (after trimming whitespace), or containing CR, LF or NUL, fails at the subject frontmatter key rather than inside MIME.
  • The recipient address must be local@domain. checkAddress requires an RFC 5322 dot-atom local part and a dot-separated hostname — no quoted local parts, no address literals, no internationalised addresses (SMTPUTF8) in this version. net/smtp writes MAIL FROM:<%s> and RCPT TO:<%s> raw, so an address with a stray > or a second @ could let the SMTP envelope disagree with the address a Router already validated; rejecting it earlier closes that gap. The same check runs on a frontmatter-supplied from address for a family that takes one (FromFrontmatter: true, e.g. the b2b family); a family with a fixed From doesn't re-validate it at render time, since that address is a Go literal the family author already controls.
  • Display names must be injection-safe. A recipient name containing CR, LF or NUL fails with render: recipient name contains CR, LF or NUL — worded differently from the equivalent failure inside MIME (to name contains CR, LF or NUL), since each comes from its own call to the same internal checkAddress helper with a different role name.

Next steps

Copyright © 2026