mail

package
v0.3.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Overview

Package mail — the addr-spec subset this domain accepts, and the display-name rule the composer depends on.

Package mail — the mailbox value.

Package mail — the attachment guards.

Package mail — one file or inline part, as a value.

Package mail — range 0.2.31.* (ADR 0064 core/mail block).

Package mail — one record of what a transport put on the wire.

Package mail — the SMTP envelope, and the reason Bcc has no header.

Package mail — declares the sentinel *errs.Error port outcomes. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.

No Public string here carries a header VALUE, an address, a subject or an attachment's bytes. A Public is read by third parties — it lands in an HTTP body, a queue's dead-letter record, a support ticket — and the values this domain refuses are, by construction, exactly the ones an attacker chose. The field NAME travels in Public where it helps; the value travels only as a log-only field, reachable through errs.FieldsOf.

Package mail — the header grammar, and the injection gate every writer in this domain runs before a byte reaches a buffer.

Package mail — one additional header, as a value.

Package mail declares the electronic-mail port: the message as a VALUE, the envelope derived from it, and the Transport that carries one.

The domain's whole security posture is one sentence: a header that contains a carriage return or a line feed is REFUSED, never repaired. RFC 5322 §2.2 makes a header field a name, a colon and a value terminated by CRLF, so a CRLF inside the value does not corrupt the field — it ENDS it and starts another one the caller never wrote. "Bcc: attacker@example.com" is then a perfectly well-formed header, and the mail is delivered to a party the sender cannot see in what it composed.

Sanitising is the tempting answer and it is the wrong one. The standard library will do it silently for you — mime.QEncoding.Encode turns a CRLF into "=0D=0A", mime.FormatMediaType percent-escapes it — and in both cases the message that goes out is not the message the caller wrote, while the caller is told everything succeeded. This domain refuses instead, with a typed error that names the FIELD and never the value.

Composition of the MIME body, and the SMTP transport that delivers it, live in internal/service/mail. Nothing here writes a byte to a socket.

Code range: 0.2.31.* (ADR 0064).

Package mail — the message as a value, and the envelope derived from it.

Package mail — the transport port and its ADR 0039 capability siblings.

They live apart from the value types because a port and a value change for different reasons: the port is FROZEN and the values are not.

Package mail — the one whole-message guard both transports run.

Index

Constants

View Source
const (
	// HeaderFrom is RFC 5322 §3.6.2's originator field.
	HeaderFrom string = "From"
	// HeaderTo is the primary recipient field, RFC 5322 §3.6.3.
	HeaderTo string = "To"
	// HeaderCc is the carbon-copy field, RFC 5322 §3.6.3.
	HeaderCc string = "Cc"
	// HeaderBcc is the blind carbon-copy field. This domain never emits it —
	// see [MessageValue.Envelope].
	HeaderBcc string = "Bcc"
	// HeaderReplyTo is RFC 5322 §3.6.2's Reply-To field.
	HeaderReplyTo string = "Reply-To"
	// HeaderSubject is RFC 5322 §3.6.5's subject field.
	HeaderSubject string = "Subject"
	// HeaderDate is RFC 5322 §3.6.1's origination date field.
	HeaderDate string = "Date"
	// HeaderMessageID is RFC 5322 §3.6.4's identification field.
	HeaderMessageID string = "Message-ID"
	// HeaderMIMEVersion is RFC 2045 §4's version field.
	HeaderMIMEVersion string = "MIME-Version"
	// HeaderContentType is RFC 2045 §5's media type field.
	HeaderContentType string = "Content-Type"
	// HeaderContentTransferEncoding is RFC 2045 §6's encoding field.
	HeaderContentTransferEncoding string = "Content-Transfer-Encoding"
	// HeaderContentDisposition is RFC 2183's disposition field.
	HeaderContentDisposition string = "Content-Disposition"
	// HeaderContentID is RFC 2045 §7's part identifier, referenced by cid:
	// URLs per RFC 2392 §2.
	HeaderContentID string = "Content-ID"
)

Header field names the composer owns. They are compared case-insensitively, because RFC 5322 makes a field name case-insensitive (the field-name production) and an attacker who could smuggle "bCc" past a case-sensitive check would have smuggled Bcc.

View Source
const CodeEmptyBody errs.Code = 0x00_02_1F_05 // 0.2.31.5

CodeEmptyBody identifies a Message with no text, no HTML and no attachment. A zero body is not "send an empty mail", it is a struct nobody filled in.

View Source
const CodeHeaderInjection errs.Code = 0x00_02_1F_01 // 0.2.31.1

CodeHeaderInjection identifies a header name or value carrying CR, LF or NUL. It is THE failure of this domain: RFC 5322 §2.2 terminates a field with CRLF, so those two octets do not corrupt a header, they end it and begin one the caller never wrote.

View Source
const CodeHeaderTooLong errs.Code = 0x00_02_1F_08 // 0.2.31.8

CodeHeaderTooLong identifies a header line that cannot be folded under the RFC 5322 §2.1.1 hard limit of 998 octets. Folding is only legal at existing whitespace (§2.2.3), so a single token longer than the limit has no legal fold point and is refused rather than cut.

View Source
const CodeInvalidAddress errs.Code = 0x00_02_1F_02 // 0.2.31.2

CodeInvalidAddress identifies an addr-spec outside the dot-atom subset of RFC 5322 §3.4.1 that this domain accepts: empty, missing or repeating "@", carrying a space or an angle bracket, or exceeding the RFC 5321 §4.5.3.1 octet limits.

View Source
const CodeInvalidAttachment errs.Code = 0x00_02_1F_07 // 0.2.31.7

CodeInvalidAttachment identifies an attachment the composer cannot render: no filename, an unparseable media type, an inline part with no body to be referenced from, or a Content-ID outside the addr-spec grammar RFC 2392 §2 borrows for cid: URLs.

View Source
const CodeMissingSender errs.Code = 0x00_02_1F_03 // 0.2.31.3

CodeMissingSender identifies a Message with no From mailbox. RFC 5322 §3.6 makes the originator field mandatory, and an empty From is what an unfilled struct looks like.

View Source
const CodeNoRecipients errs.Code = 0x00_02_1F_04 // 0.2.31.4

CodeNoRecipients identifies a Message whose To, Cc and Bcc are all empty. SMTP has no RCPT TO to issue, so the send could only ever fail — it fails here instead, before a socket is opened.

View Source
const CodeReservedHeader errs.Code = 0x00_02_1F_06 // 0.2.31.6

CodeReservedHeader identifies an extra header whose name the composer owns. Letting one through would either emit the field twice — RFC 5322 §3.6 allows exactly one From, Date or Subject — or let a caller spell Bcc by hand, which is the injection attack with the SDK's own cooperation.

View Source
const CodeUnsupportedAddress errs.Code = 0x00_02_1F_09 // 0.2.31.9

CodeUnsupportedAddress identifies a syntactically plausible address this domain deliberately does not carry: a non-ASCII addr-spec (RFC 6531 SMTPUTF8) or a quoted local-part. Both are legal mail and neither is supported, so they are named rather than mangled into something deliverable to the wrong mailbox.

View Source
const DefaultAttachmentType string = "application/octet-stream"

DefaultAttachmentType is the media type used when an attachment names none. RFC 2046 §4.5.1 makes application/octet-stream the type for "arbitrary binary data", and guessing a narrower one from the bytes would be a sniffer — the class of code that decides an uploaded .txt is really HTML.

Variables

View Source
var (
	// HeaderInjection is returned when a header name or value carries CR, LF
	// or NUL.
	//
	// It is a REFUSAL and never a repair. mime.QEncoding.Encode would turn the
	// CRLF into "=0D=0A" and mime.FormatMediaType would percent-escape it —
	// both produce a deliverable message that is not the one the caller wrote,
	// and both report success. A caller who learns nothing cannot fix the
	// input, and the next message carries the same payload.
	//
	// The Public names no value. "Subject" is safe to say; the string an
	// attacker put in it is not.
	HeaderInjection = errs.Define(CodeHeaderInjection, "HEADER_INJECTION",
		"A message header contains a line break and was refused",
		"core/mail: header name or value contains CR, LF or NUL — RFC 5322 §2.2 ends a field at CRLF, so the remainder would become headers the caller never wrote; refused, never sanitised",
		errs.WithExitCode(exitNoPerm))

	// InvalidAddress is returned for an addr-spec outside the dot-atom subset
	// of RFC 5322 §3.4.1 this domain accepts, or outside the RFC 5321 §4.5.3.1
	// octet limits (64 for the local part, 255 for the domain).
	InvalidAddress = errs.Define(CodeInvalidAddress, "INVALID_ADDRESS",
		"A mail address in that message is not usable",
		"core/mail: addr-spec is empty, has no single @, carries a space, angle bracket or control byte, or exceeds the RFC 5321 §4.5.3.1 length limits",
		errs.WithExitCode(exitDataErr))

	// MissingSender is returned for a Message whose From carries no address.
	MissingSender = errs.Define(CodeMissingSender, "MISSING_SENDER",
		"A message must name the mailbox it is from",
		"core/mail: Message.From is the zero Address; RFC 5322 §3.6 makes the originator field mandatory and there is no defensible default",
		errs.WithExitCode(exitDataErr))

	// NoRecipients is returned when To, Cc and Bcc are all empty.
	//
	// The refusal happens before any socket is opened. An SMTP session with no
	// RCPT TO cannot succeed, so the only thing dialling first would buy is a
	// slower, less specific failure.
	NoRecipients = errs.Define(CodeNoRecipients, "NO_RECIPIENTS",
		"A message must name at least one recipient",
		"core/mail: To, Cc and Bcc are all empty, so the SMTP session would have no RCPT TO to issue",
		errs.WithExitCode(exitDataErr))

	// EmptyBody is returned for a Message with no text, no HTML and no
	// attachment — ADR 0031's refusing half, applied to content.
	EmptyBody = errs.Define(CodeEmptyBody, "EMPTY_BODY",
		"A message must carry text, HTML or an attachment",
		"core/mail: Text, HTML and Attachments are all empty; a zero body is an unfilled struct rather than a decision to send nothing",
		errs.WithExitCode(exitDataErr))

	// ReservedHeader is returned when Message.Headers names a field the
	// composer owns.
	//
	// Two different failures share this refusal. Emitting From, Date or Subject
	// twice violates RFC 5322 §3.6, which permits exactly one of each, and
	// leaves every receiver free to pick a different one. And spelling "Bcc"
	// by hand is the injection this domain exists to stop, arriving through the
	// front door instead.
	ReservedHeader = errs.Define(CodeReservedHeader, "RESERVED_HEADER",
		"That header is set by the mail composer and cannot be overridden",
		"core/mail: Headers names a composer-owned field (From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID, MIME-Version or a Content-* field)",
		errs.WithExitCode(exitDataErr))

	// InvalidAttachment is returned for an attachment the composer cannot
	// render: no filename, an unparseable media type, a malformed Content-ID,
	// or an inline part in a message with no body for its cid: to be
	// referenced from.
	InvalidAttachment = errs.Define(CodeInvalidAttachment, "INVALID_ATTACHMENT",
		"An attachment on that message is not usable",
		"core/mail: attachment has no filename, an unparseable media type, a Content-ID outside the RFC 2392 §2 grammar, or is inline in a message with no text or HTML body to reference it",
		errs.WithExitCode(exitDataErr))

	// HeaderTooLong is returned when a header line cannot be folded under the
	// RFC 5322 §2.1.1 hard limit of 998 octets.
	//
	// Folding inserts CRLF before existing whitespace (§2.2.3) — unfolding
	// removes the CRLF and keeps the whitespace, so the value survives exactly.
	// A token with no whitespace in it has no legal fold point, and truncating
	// it would deliver a different value while reporting success.
	HeaderTooLong = errs.Define(CodeHeaderTooLong, "HEADER_TOO_LONG",
		"A message header is too long to fold and was refused",
		"core/mail: a header line exceeds the RFC 5322 §2.1.1 limit of 998 octets and contains no whitespace to fold at; refused rather than truncated",
		errs.WithExitCode(exitDataErr))

	// UnsupportedAddress is returned for a non-ASCII addr-spec (RFC 6531
	// SMTPUTF8) or a quoted local-part.
	//
	// Both are legal mail. Neither is carried here, and saying so BY NAME is
	// the point: SMTPUTF8 must be negotiated with the server, net/smtp does
	// not negotiate it, and the alternative to refusing is punycoding a domain
	// or dropping accents from a local part — which delivers, silently, to a
	// different mailbox.
	UnsupportedAddress = errs.Define(CodeUnsupportedAddress, "UNSUPPORTED_ADDRESS",
		"That address form is not supported by this mail transport",
		"core/mail: non-ASCII addr-spec (RFC 6531 SMTPUTF8) or quoted local-part; refused by name rather than transliterated into a deliverable but different address",
		errs.WithExitCode(exitUnavailable))
)

Functions

func IsReservedHeader

func IsReservedHeader(name string) bool

IsReservedHeader reports whether name is a field the composer emits itself, comparing case-insensitively, as RFC 5322 requires of a field name.

func NeedsQuotedDisplayName

func NeedsQuotedDisplayName(name string) bool

NeedsQuotedDisplayName reports whether name must be emitted as an RFC 5322 §3.2.4 quoted-string rather than as a bare sequence of atoms.

It is exported because the composer is in another package and this is a property of the GRAMMAR, not of the writer. Getting it wrong is the classic address bug: an unquoted "Doe, John" makes the comma a list separator, so a message to one person is parsed as a message to two, one of which is not an address at all.

func Validate

func Validate(m MessageValue) error

Validate reports whether m can be composed and sent, and returns the first typed refusal it finds.

It is exported and lives in core rather than beside the composer because BOTH transports run it, and that is what makes the in-memory one a faithful double: a message the SMTP transport would refuse is refused identically by the memory transport, in a test, before anybody deploys.

It stops at the FIRST problem rather than collecting every one. That is the opposite of what the validation domain does (ADR 0046) and it is deliberate: a validation report describes a form a human will fix field by field, while this is a security gate, and a gate that keeps evaluating a message it has already decided to refuse is a gate doing work an attacker chose for it.

func ValidateAddress

func ValidateAddress(field string, a AddressValue) error

ValidateAddress reports whether a is a mailbox this domain will carry, and returns a typed refusal naming field — "From", "To[1]", "Cc[0]" — when it is not.

The accepted grammar is a deliberate SUBSET of RFC 5322 §3.4.1: an ASCII dot-atom local part, one "@", and an ASCII dot-atom domain, inside the RFC 5321 §4.5.3.1 length limits. Two legal forms are refused BY NAME rather than mangled:

  • A quoted local part (`"john doe"@example.com`) is legal RFC 5322 and is refused, because quoting and unquoting it correctly through a display name, an envelope and a log line is four chances to get it wrong for a form essentially nobody uses.
  • A non-ASCII addr-spec (RFC 6531 SMTPUTF8) is refused because SMTPUTF8 must be negotiated with the server and net/smtp does not negotiate it. The alternatives are worse than a refusal: punycoding the domain or stripping accents from the local part both produce an address that delivers, to somebody else.

func ValidateAttachment

func ValidateAttachment(field string, a *AttachmentValue) error

ValidateAttachment reports whether a can be composed into a body part, and returns InvalidAttachment naming the position when it cannot.

The filename faces the injection gate like any other header value, because it lands in Content-Disposition. mime.FormatMediaType would silently percent-escape a CRLF there — the same "repair the caller never hears about" this domain refuses everywhere else.

func ValidateHeader

func ValidateHeader(field HeaderFieldValue) error

ValidateHeader runs ValidateHeaderName and ValidateHeaderValue over one caller-supplied field and refuses a name the composer owns.

func ValidateHeaderName

func ValidateHeaderName(name string) error

ValidateHeaderName reports whether name is a usable field name and returns a typed refusal when it is not.

RFC 5322 §3.6.8 defines a field name as one or more printable US-ASCII characters other than the colon. Anything else — a space, a control byte, a non-ASCII rune, an embedded colon — would produce a line no receiver parses as the caller intended, and an embedded CRLF would produce two fields.

func ValidateHeaderValue

func ValidateHeaderValue(name, value string) error

ValidateHeaderValue reports whether value may be emitted under the field called name, and returns HeaderInjection when it may not.

This is the gate the whole domain rests on. A header field ends at CRLF (RFC 5322 §2.2), so a CR or an LF inside a value does not corrupt the field — it TERMINATES it, and every octet after it becomes new header lines. A subject of "Hi\r\nBcc: attacker@example.com" is not a malformed subject; it is a well-formed subject followed by a well-formed blind-copy field, and the message is delivered to a party the sender never saw.

A bare CR and a bare LF are refused as well as the pair, because receivers disagree about them — some normalise a lone LF to CRLF, which reconstructs the attack downstream from an input that looked survivable here.

The refusal names the FIELD and never the value. The value is, by construction, the string an attacker chose, and a Public message travels places the SDK does not control.

Types

type AddressValue

type AddressValue struct {
	// Name is the optional display name. It may hold any printable text,
	// including non-ASCII, which is RFC 2047 encoded at composition. CR, LF and
	// NUL are refused.
	Name string
	// Addr is the addr-spec: "local@domain", ASCII, in the dot-atom form of
	// RFC 5322 §3.4.1. Angle brackets are added by the composer and must not
	// appear here.
	Addr string
}

AddressValue is one RFC 5322 mailbox: an addr-spec and an optional display name.

It is deliberately NOT net/mail.Address, and the reason is measurable rather than aesthetic. net/mail.Address.String() emits its Address field verbatim:

mail.Address{Name: "ok", Address: "u@exa\r\nmple.com"}.String()
  == "\"ok\" <u@exa\r\nmple.com>"

The CRLF survives into the header, which is the injection this whole domain exists to refuse. The display name is protected there (it is RFC 2047 encoded, so a CRLF becomes "=0D=0A") and the address is not — so a caller who reached for the obvious stdlib helper would be protected on the field an attacker rarely controls and exposed on the one they usually do.

func (AddressValue) IsZero

func (a AddressValue) IsZero() bool

IsZero reports whether the address carries no addr-spec at all, which is the zero value a caller gets from an unfilled struct field.

type AttachmentValue

type AttachmentValue struct {
	// Filename is the name presented to the recipient. It is required: an
	// attachment with no name is a part a client shows as "noname", and the
	// SDK will not invent one.
	Filename string
	// ContentType is the media type. Empty selects the type implied by the
	// filename's extension, and [DefaultAttachmentType] when there is none.
	ContentType string
	// ContentID, when non-empty, makes this part INLINE and referenceable as
	// cid:<ContentID>. It is an addr-spec-shaped token (RFC 2045 §7), written
	// without angle brackets — the composer adds them.
	ContentID string
	// Content is the part's bytes, base64 encoded at composition (RFC 2045
	// §6.8). An empty slice is a legal zero-length attachment.
	Content []byte
}

AttachmentValue is one MIME body part carrying a file.

The distinction between an attachment and an inline image is ONE field. A non-empty ContentID makes the part inline: it is placed in a multipart/related alongside the body (RFC 2387) and referenced from the HTML as cid:<ContentID> (RFC 2392 §2). An empty one makes it a regular attachment in a multipart/mixed (RFC 2046 §5.1.3).

That is why there is no Disposition field to set wrongly. The disposition and the enclosing multipart subtype are two halves of one decision, and letting a caller spell them separately is letting them disagree — which is exactly the message that renders as a broken image in one client and a stray attachment in another.

func (*AttachmentValue) Inline

func (a *AttachmentValue) Inline() bool

Inline reports whether this part is referenced from the body rather than listed as a file.

type BatchSender

type BatchSender interface {
	// SendBatch sends every message in msgs and returns the joined failures.
	// A nil error means every message was accepted.
	SendBatch(ctx context.Context, msgs []MessageValue) error
}

BatchSender sends several messages over ONE session. It is the ADR 0039 sibling of Transport, reached by type assertion:

if batch, ok := transport.(mail.BatchSender); ok {
	err = batch.SendBatch(ctx, notifications)
}

It is a capability and not a method, because it is not one for every transport: an HTTP provider API has no session to reuse, and a transport that implemented it by looping would be advertising an optimisation it does not perform.

The semantics of a partial failure are the reason this is not just a loop in the caller: SendBatch sends every message it can and returns the failures AGGREGATED with errors.Join, rather than stopping at the first. One bad recipient in a batch of five hundred must not silence the other four hundred and ninety-nine, and the caller still learns about it — errs.HasCode walks Unwrap() []error, so a joined error answers the same questions a single one does.

type DeliveryValue

type DeliveryValue struct {
	// Envelope is the MAIL FROM and the RCPT TO list the session would issue.
	Envelope EnvelopeValue
	// Raw is the RFC 5322 message: headers, blank line, MIME body, CRLF line
	// endings throughout.
	Raw []byte
}

DeliveryValue is one record of what a transport actually put on the wire.

It carries the composed BYTES and not the message, because those are two different assertions. A test that checks the message checks what it itself built a moment earlier; a test that checks Raw checks what the composer produced — the encoded subject, the multipart structure, the absence of a Bcc header — which is the only half that can be wrong.

type EnvelopeValue

type EnvelopeValue struct {
	// From is the return path (RFC 5321 §4.1.1.2): where a bounce goes. It is
	// the message's From addr-spec.
	From string
	// To is every RCPT TO the session will issue, in To, Cc, Bcc order. Bcc
	// recipients appear HERE and in no header.
	To []string
}

EnvelopeValue is what an SMTP session actually carries: one MAIL FROM and one or more RCPT TO (RFC 5321 §3.3). It is NOT the header block, and the difference is the whole reason this type exists.

A receiving MTA routes on the envelope and never on the headers. To, Cc and Bcc are text a client renders; the envelope is the delivery instruction. They agree by convention and not by protocol, which is how a Bcc can exist at all — and how a forwarding rule can deliver a message to somebody who appears in no header.

type FullTransport

type FullTransport interface {
	Transport
	BatchSender
	Outbox
}

FullTransport is the union the SDK's in-memory transport returns: a Transport that also batches and records.

It exists for the same reason core/vfs.FullFS and core/metrics.FullMeter do — the port stays frozen and the capabilities stay assertable for a THIRD-PARTY transport that has neither, while a caller wiring one of the SDK's own does not have to type-assert for a feature it just constructed. A parameter should still ask for the narrowest thing it uses.

type HeaderFieldValue

type HeaderFieldValue struct {
	// Name is the field name — printable US-ASCII without a colon
	// (RFC 5322 §3.6.8).
	Name string
	// Value is the unstructured field value. It is encoded per RFC 2047 when it
	// carries non-ASCII, folded to the RFC 5322 §2.1.1 line limits, and refused
	// outright when it carries CR, LF or NUL.
	Value string
}

HeaderFieldValue is one additional header: a name and a value.

It is a slice element rather than a map entry for two reasons that both matter: a map iterates in a random order, so the same message would compose to different bytes on different runs and no golden test could exist; and some fields legitimately repeat (Received, References), which a map cannot express at all.

type MessageValue

type MessageValue struct {
	// Date is the origination date (RFC 5322 §3.6.1), which is REQUIRED in a
	// message. A zero Date is stamped by the composer from its clock rather
	// than refused: unlike a file mode, there is exactly one defensible value
	// and it is "now" (ADR 0031's clamping half).
	Date time.Time
	// From is the author mailbox (RFC 5322 §3.6.2). Exactly one is supported;
	// the multi-author form requires a Sender header to disambiguate and no
	// caller has ever wanted it by accident.
	From AddressValue
	// Subject is the unstructured subject field (RFC 5322 §3.6.5). Non-ASCII
	// content is encoded per RFC 2047 at composition; a CR or LF here is
	// [HeaderInjection].
	Subject string
	// MessageID is the optional Message-ID (RFC 5322 §3.6.4), angle brackets
	// included or omitted at the caller's choice — the composer adds them.
	//
	// An empty MessageID emits NO header. RFC 6409 §8.2 makes adding one the
	// submission server's job when it is absent, and generating one here would
	// mean inventing both entropy and a domain the SDK does not own.
	MessageID string
	// ReplyTo is the optional Reply-To mailbox list (RFC 5322 §3.6.2).
	ReplyTo []AddressValue
	// To is the primary recipient list (RFC 5322 §3.6.3).
	To []AddressValue
	// Cc is the carbon-copy recipient list (RFC 5322 §3.6.3).
	Cc []AddressValue
	// Bcc is the blind carbon-copy list. It reaches the ENVELOPE and never a
	// header — see [MessageValue.Envelope].
	Bcc []AddressValue
	// Headers are additional fields, emitted in slice order so a composed
	// message is byte-deterministic. A name the composer owns is
	// [ReservedHeader]; see [IsReservedHeader].
	Headers []HeaderFieldValue
	// Text is the text/plain body.
	Text string
	// HTML is the text/html body. With Text it becomes a multipart/alternative
	// in which the plain part comes FIRST — RFC 2046 §5.1.4 orders alternatives
	// by increasing preference, so the last one is the one a client shows.
	HTML string
	// Attachments are the file parts. One carrying a ContentID is INLINE and is
	// referenced from the HTML body as cid:<ContentID> (RFC 2392 §2).
	Attachments []AttachmentValue
}

MessageValue is one mail, as a value. It is deliberately a struct and not an interface: a message has no behaviour worth abstracting, it is data, and a value can be built in a literal, compared field by field in a test, and carried across a goroutine without a lock.

Nothing here is a wire format. The MIME structure the fields imply is chosen by the composer in internal/service/mail, and which parts appear is a pure function of which fields are populated — see that package's CLAUDE.md for the table.

Every string field that reaches a header is validated by Validate. The slices are never mutated by this package.

func (MessageValue) Envelope

func (m MessageValue) Envelope() (envelope EnvelopeValue, err error)

Envelope derives the SMTP envelope from the message, after validating it.

Bcc is the point. RFC 5322 §3.6.3 describes three permissible treatments of a Bcc field, and this domain takes the one that cannot leak: the addresses reach RCPT TO and the header is never written. The other two — sending each Bcc recipient their own copy carrying only their own Bcc line, or sending the header intact to everyone — are either N messages the caller did not ask for or a disclosure RFC 5321 §7.2 warns about explicitly.

The recipient order is To, then Cc, then Bcc, and it is deterministic so a test can assert on it. Duplicates are NOT removed: an address listed in both To and Cc is two RCPT TO commands, which every MTA collapses into one delivery, and removing them here would mean deciding that two addresses that differ only in case are the same mailbox — a judgement only the receiving domain is entitled to make.

type Outbox

type Outbox interface {
	// Sent returns the deliveries recorded so far, oldest first. The returned
	// slice and every byte slice in it are the caller's own copy.
	Sent() []DeliveryValue
	// Reset discards the record, so one test's messages are not another's.
	Reset()
}

Outbox exposes what a transport has been asked to send. It is the ADR 0039 sibling that makes a test double a double rather than a stub:

if box, ok := transport.(mail.Outbox); ok {
	sent := box.Sent()
}

Only an in-memory transport implements it, and that is the point — a consumer's test asserts against this interface without importing the SDK's service layer, and a production transport that started implementing it would be a transport keeping every message it ever sent in memory.

type Transport

type Transport interface {
	// Send composes msg and hands it to the next hop.
	//
	// It validates before it dials: a message that cannot be composed is
	// refused without opening a socket, so a header-injection attempt never
	// reaches a server and never costs a connection.
	//
	// ctx bounds the whole attempt. A transport that cannot honour a deadline
	// natively must still stop when ctx does — by severing its own connection
	// if that is the only mechanism available — because a Send that outlives
	// its context is a goroutine the caller believes it has cancelled.
	Send(ctx context.Context, msg MessageValue) error
}

Transport carries a message to whatever will deliver it. It is FROZEN at one method.

One method, because "send this mail" is the only thing every transport can do. A capability that some transports have arrives as a SIBLING interface reached by type assertion, never as a second method (ADR 0039): pkg/v1/mail aliases this type, Go interfaces are structural, and a second method would break every downstream implementation at compile time with no deprecation window.

Implementations MUST be safe for concurrent use.

What a Transport does NOT promise is as important as what it does. A nil error means the message was ACCEPTED by the next hop — nothing more. It is not a promise of delivery, of inbox placement, or of the recipient existing: SMTP accepts responsibility hop by hop (RFC 5321 §6.1), and the hop that eventually refuses reports it in a bounce message hours later, to the envelope's return path, over a channel this port has no access to.

It is named for the ROLE it plays rather than for its one method: "the SMTP transport" and "the in-memory transport" are the words this domain is discussed in, and a Sender would name the verb instead.

IFACE-PLUGIN: the concrete transports stay unexported behind their constructors in internal/service/mail.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL