Message and MIME
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.
namemust be a non-empty run of printable ASCII without:; anything else fails withheader name %q is invalidbefore the reserved-name or value checks even run. - The name is reserved.
From,To,Subject,Date,Message-Id,Mime-Version,Content-TypeandContent-Transfer-Encodingare reserved becauseMIMEwrites them itself.CcandBccare reserved for a different reason:MIMEnever writes either one (there is only ever one recipient,Message.To), but a header by either name would functionally add a recipient that theRouter's checks and the SMTP envelope never see — so both are blocked even thoughMIMEitself never emits them.List-UnsubscribeandList-Unsubscribe-Postare reserved for a third reason again: they can only be set throughSetListUnsubscribe, which enforceshttps. - 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 callingSetHeader.
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:
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:
package main
import (
"net/mail"
"os"
"time"
"github.com/sulv-io/lttr"
)
func main() {
m := <tr.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.Encodeleaves already-ASCII values unchanged. Message-IDis<{32 hex characters}@{From's domain}>, and the MIME boundary is a second 32-character hex string — both fromcrypto/rand, so two calls toMIMEon 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.
MIMEitself refuses an emptyTextorHTML—"text part is empty"or"html part is empty"— rather than serialising a one-sided message; this isn't somethingRenderchecks up front, it'sMIME's own safety net for a hand-builtMessagetoo. MIMEre-checksFrom,ToandSubjecteven ifRenderalready checked them (below), so a hand-builtMessagegets the same guaranteesRendergives 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
subjectfrontmatter key rather than insideMIME. - The recipient address must be
local@domain.checkAddressrequires 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/smtpwritesMAIL FROM:<%s>andRCPT TO:<%s>raw, so an address with a stray>or a second@could let the SMTP envelope disagree with the address aRouteralready validated; rejecting it earlier closes that gap. The same check runs on a frontmatter-suppliedfromaddress for a family that takes one (FromFrontmatter: true, e.g. the b2b family); a family with a fixedFromdoesn'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 insideMIME(to name contains CR, LF or NUL), since each comes from its own call to the same internalcheckAddresshelper with a different role name.
Next steps
- Router and streams for how a
Messagereaches aSender. - SMTP for the one
Senderthe library ships.