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 ¶
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).
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.
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.
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.
const CodeDialFailed errs.Code = 0x00_03_3D_03 // 0.3.61.3
CodeDialFailed identifies a TCP connection that was never established.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 ¶
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.