mail

package
v0.26.21 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 3 Imported by: 0

Documentation

Overview

Package mail — the mailbox value.

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

Package mail — ranges 0.2.31.* (ADR 0064 core/app/mail block) and 0.3.61.* (ADR 0064 service/app/mail block, declared here since ADR 0160).

Package mail — declares the sentinel *errs.Error outcomes of the domain: the refusals of a message, and the outcomes of composition and of the SMTP session. 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, an attachment's bytes, a credential or a server reply. 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 names of the header fields the composer owns, and 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.

The guards that enforce it — Validate and the per-field checks it runs — live in internal/service/app/mail with the composition of the MIME body and the SMTP transport that delivers it: checking a header, an address or an attachment against its grammar is a mechanism (ADR 0160). This package holds the values, the port, the header names and the refusals. 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 — one record of what a transport put on the wire.

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:
	// a Bcc recipient reaches the [EnvelopeValue] and no header.
	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. A guard compares them 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. The guards — the reserved set and the injection gate every writer runs — are internal/service/app/mail's: the names are vocabulary, checking a field is a mechanism (ADR 0160).

View Source
const CodeAuthFailed errs.Code = 0x00_03_3D_08 // 0.3.61.8

CodeAuthFailed identifies credentials the server rejected, or a server that advertised no AUTH mechanism at all.

View Source
const CodeAuthInsecure errs.Code = 0x00_03_3D_07 // 0.3.61.7

CodeAuthInsecure identifies credentials that would have travelled over an unencrypted session. The refusal happens BEFORE the AUTH command is written.

View Source
const CodeComposeFailed errs.Code = 0x00_03_3D_01 // 0.3.61.1

CodeComposeFailed identifies a MIME body the composer could not assemble: an encoder that refused a byte, or a media type mime.ParseMediaType would not accept.

View Source
const CodeDialFailed errs.Code = 0x00_03_3D_03 // 0.3.61.3

CodeDialFailed identifies a TCP connection that was never established.

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 CodeGreetingFailed errs.Code = 0x00_03_3D_04 // 0.3.61.4

CodeGreetingFailed identifies a server that answered the connection but not the SMTP conversation: a refused greeting, a refused EHLO, or a QUIT the server never acknowledged.

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 CodeInvalidConfig errs.Code = 0x00_03_3D_02 // 0.3.61.2

CodeInvalidConfig identifies an SMTP transport that cannot be constructed as written: no host, a port outside 1-65535, or — the one that matters — a TLSMode left at its zero value.

View Source
const CodeInvalidURL errs.Code = 0x00_03_3D_0A // 0.3.61.10

CodeInvalidURL identifies an SMTP URL ParseURL could not read: not a URL, a scheme other than smtp or smtps, a path, a fragment, an unknown or repeated parameter, a tls mode it does not know, no host, or a port out of range.

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 CodeSendRefused errs.Code = 0x00_03_3D_09 // 0.3.61.9

CodeSendRefused identifies a MAIL, RCPT or DATA command the server answered with a failure reply.

View Source
const CodeTLSFailed errs.Code = 0x00_03_3D_06 // 0.3.61.6

CodeTLSFailed identifies a TLS handshake or STARTTLS command the server or the certificate chain refused.

View Source
const CodeTLSRequired errs.Code = 0x00_03_3D_05 // 0.3.61.5

CodeTLSRequired identifies a session that could not be encrypted because the server never offered STARTTLS. It is a REFUSAL and never a downgrade.

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.Static(CodeHeaderInjection, "HEADER_INJECTION",
		"A message header contains a line break and was refused",
		"core/app/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",
		0,
		77)

	// 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.Static(CodeInvalidAddress, "INVALID_ADDRESS",
		"A mail address in that message is not usable",
		"core/app/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",
		0,
		65)

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

	// 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.Static(CodeNoRecipients, "NO_RECIPIENTS",
		"A message must name at least one recipient",
		"core/app/mail: To, Cc and Bcc are all empty, so the SMTP session would have no RCPT TO to issue",
		0,
		65)

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

	// 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.Static(CodeReservedHeader, "RESERVED_HEADER",
		"That header is set by the mail composer and cannot be overridden",
		"core/app/mail: Headers names a composer-owned field (From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID, MIME-Version or a Content-* field)",
		0,
		65)

	// 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.Static(CodeInvalidAttachment, "INVALID_ATTACHMENT",
		"An attachment on that message is not usable",
		"core/app/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",
		0,
		65)

	// 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.Static(CodeHeaderTooLong, "HEADER_TOO_LONG",
		"A message header is too long to fold and was refused",
		"core/app/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",
		0,
		65)

	// 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.Static(CodeUnsupportedAddress, "UNSUPPORTED_ADDRESS",
		"That address form is not supported by this mail transport",
		"core/app/mail: non-ASCII addr-spec (RFC 6531 SMTPUTF8) or quoted local-part; refused by name rather than transliterated into a deliverable but different address",
		0,
		69)

	// ComposeFailed is returned when the MIME body could not be assembled.
	//
	// It is deliberately rare: everything a caller can get wrong is refused by
	// the message guards with a specific verdict, so reaching this one means
	// an encoder or a media-type parser refused something those guards
	// accepted — which is a defect in the composer, not in the caller's
	// message.
	ComposeFailed = errs.Static(CodeComposeFailed, "COMPOSE_FAILED",
		"The message could not be assembled into a MIME body",
		"service/app/mail: a MIME encoder, a multipart writer or mime.ParseMediaType refused input the core/app/mail guards accepted — the cause travels as a field",
		0,
		65)

	// InvalidConfig is returned by the SMTP constructor for a configuration
	// that cannot be honoured as written.
	//
	// The zero TLSMode is the interesting member. It is REFUSED rather than
	// read as a default, because both candidate defaults are wrong: choosing
	// encryption silently breaks every caller pointing at a plaintext relay on
	// a private network, and choosing none silently ships credentials in the
	// clear for everyone else. ADR 0031's refusing half, applied where the
	// SDK genuinely cannot know.
	InvalidConfig = errs.Static(CodeInvalidConfig, "INVALID_CONFIG",
		"The SMTP transport configuration is not usable as written",
		"service/app/mail: empty Host, Port outside 1-65535, or TLS left at its zero value — the zero is refused because neither 'encrypt' nor 'do not' is a safe guess (ADR 0031)",
		0,
		78)

	// DialFailed is returned when the TCP connection was never established, or
	// was severed by the caller's context before the greeting.
	DialFailed = errs.Static(CodeDialFailed, "DIAL_FAILED",
		"The mail server could not be reached",
		"service/app/mail: TCP dial failed, timed out, or the context was cancelled before the SMTP greeting; the dial error travels as a field",
		0,
		68)

	// GreetingFailed is returned when the connection opened and the SMTP
	// conversation did not: a refused greeting, a refused EHLO, a QUIT the
	// server never acknowledged.
	GreetingFailed = errs.Static(CodeGreetingFailed, "GREETING_FAILED",
		"The mail server did not accept the SMTP conversation",
		"service/app/mail: the 220 greeting, the EHLO/HELO exchange or the closing QUIT was refused or malformed; the server reply travels as a field",
		0,
		75)

	// TLSRequired is returned when the transport is configured for STARTTLS
	// and the server never advertised it.
	//
	// This is the defect the domain refuses to have. "Opportunistic STARTTLS"
	// — encrypt if offered, continue in the clear if not — is the shape almost
	// every mail library ships, and it means an active attacker strips the
	// advertisement and reads everything, including the credentials, while the
	// caller's logs show a successful send. There is no configuration here
	// that produces that behaviour, and adding one is not a feature request.
	TLSRequired = errs.Static(CodeTLSRequired, "TLS_REQUIRED",
		"The mail server did not offer the encryption this transport requires",
		"service/app/mail: TLSStartTLS was configured and the EHLO response advertised no STARTTLS extension; refused, never downgraded to cleartext",
		0,
		77)

	// TLSFailed is returned when STARTTLS or the implicit handshake was
	// attempted and did not complete: a refused command, an untrusted chain, a
	// name that does not match.
	TLSFailed = errs.Static(CodeTLSFailed, "TLS_FAILED",
		"The connection to the mail server could not be encrypted",
		"service/app/mail: the STARTTLS command was refused, or the TLS handshake failed on trust, name or version; the handshake error travels as a field",
		0,
		77)

	// AuthInsecure is returned when credentials were configured for a session
	// that is not encrypted — at CONSTRUCTION when TLS is disabled outright,
	// and again before the AUTH command when the session turned out not to be
	// encrypted after all.
	//
	// The second check is not redundant, and net/smtp is the reason. Its
	// PlainAuth carves out an exception for a server named localhost —
	// "isLocalhost(server.Name)" — and sends the password in the clear to it.
	// That is defensible for the stdlib's audience and indefensible for a
	// transport in a container, where the relay one hop away IS 127.0.0.1 and
	// the network between them is a bridge somebody else can join.
	AuthInsecure = errs.Static(CodeAuthInsecure, "AUTH_INSECURE",
		"Credentials cannot be sent over an unencrypted mail session",
		"service/app/mail: Username is set on a session that is not encrypted; refused before the AUTH command is written, including for a localhost relay where net/smtp.PlainAuth would have sent it",
		0,
		77)

	// AuthFailed is returned when the server rejected the credentials, or
	// advertised no AUTH mechanism to offer them through.
	AuthFailed = errs.Static(CodeAuthFailed, "AUTH_FAILED",
		"The mail server rejected the credentials",
		"service/app/mail: the AUTH exchange was refused, or the EHLO response advertised no AUTH extension; the server reply travels as a field and the credential never leaves this process's memory",
		0,
		77)

	// SendRefused is returned when MAIL, RCPT or DATA was answered with a
	// failure reply.
	//
	// It carries the SMTP reply as a log-only field, never as Public: a reply
	// is third-party text of unbounded length that may name a recipient, and
	// Public is the half that travels to strangers. The SMTP transport also
	// keeps the server's reply reachable through errors.As as a
	// *net/textproto.Error, so a caller can tell a permanent 5xx from a
	// temporary 4xx — the mail spool dead-letters the first at once.
	SendRefused = errs.Static(CodeSendRefused, "SEND_REFUSED",
		"The mail server refused the message",
		"service/app/mail: MAIL FROM, RCPT TO or DATA was answered with a failure reply; the command and the server's reply travel as log-only fields",
		0,
		75)

	// InvalidURL is returned by ParseURL for a URL it cannot read. It names the
	// clause that failed and NEVER the URL: an SMTP URL carries the password
	// in its userinfo, and every part of it — the host, the port, a parameter
	// name — is quoted by no refusal, because a URL that failed to parse is
	// exactly the one whose parts are not where they should be.
	InvalidURL = errs.Static(CodeInvalidURL, "INVALID_URL",
		"The SMTP URL is not usable as written",
		"service/app/mail: ParseURL refused the URL; the problem field names the clause, and the URL itself is never repeated because it carries the password",
		0,
		78)
)

Functions

This section is empty.

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/data/vfs.FullFS and core/observe/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 — the
	// [EnvelopeValue] internal/service/app/mail derives — and never a header.
	Bcc []AddressValue
	// Headers are additional fields, emitted in slice order so a composed
	// message is byte-deterministic. A name the composer owns is
	// [ReservedHeader].
	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/app/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 the guards in internal/service/app/mail, which every transport runs before composing. The slices are never mutated by this package.

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/app/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/app/mail.

Directories

Path Synopsis
Package spool — range 0.3.81.* (ADR 0111 service/app/mail/spool block, declared here since ADR 0160).
Package spool — range 0.3.81.* (ADR 0111 service/app/mail/spool block, declared here since ADR 0160).

Jump to

Keyboard shortcuts

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