Documentation
¶
Overview ¶
Package omnimail is a vendor-free core for sending transactional email (verification links, notices, receipts) as a service.
The package defines the message model (Message, Address), the Sender interface every delivery provider implements, message validation with header-injection protection, RFC 5322 MIME assembly (BuildMIME) for SMTP-like providers, text/HTML template rendering (Template) and a classified error type (Error) so callers can decide whether to retry.
Built-in senders live in subpackages:
- smtp: delivery to any SMTP server (STARTTLS or implicit TLS, PLAIN/LOGIN)
- logsender: writes messages to a slog.Logger for development
- memsender: captures messages in memory for tests
Vendor adapters (for example AWS SES) live in separate modules and verify themselves with the providertest conformance suite.
A minimal send:
msg := &omnimail.Message{
From: omnimail.Address{Name: "Example", Email: "no-reply@example.com"},
To: []omnimail.Address{{Email: "user@example.org"}},
Subject: "Verify your email",
Text: "Open this link to verify: https://example.com/verify?t=...",
}
res, err := sender.Send(ctx, msg)
if err != nil {
if omnimail.IsRetryable(err) {
// queue for retry
}
return err
}
log.Println("sent", res.MessageID)
Index ¶
- Constants
- Variables
- func IsRetryable(err error) bool
- func ReservedHeader(name string) bool
- func RetryAfter(err error) time.Duration
- func ValidateMessage(msg *Message) error
- type Address
- type BuildOptions
- type Error
- type Kind
- type MIMEMessage
- type Message
- type Rendered
- type SendResult
- type Sender
- type SenderFunc
- type Template
- type TemplateOption
- type TemplateSource
Constants ¶
const ( SubjectSuffix = ".subject.tmpl" TextSuffix = ".text.tmpl" HTMLSuffix = ".html.tmpl" )
Template file suffixes read by ParseTemplateFS.
Variables ¶
var ( ErrInvalidMessage = errors.New("omnimail: invalid message") ErrInvalidAddress = errors.New("omnimail: invalid address") ErrRejected = errors.New("omnimail: rejected") ErrThrottled = errors.New("omnimail: throttled") ErrTransient = errors.New("omnimail: transient failure") ErrAuth = errors.New("omnimail: authentication failed") )
Sentinel errors matched by errors.Is against an *Error of the corresponding kind. ErrInvalidMessage also matches KindInvalidAddress, because an unusable address makes the message invalid.
Functions ¶
func IsRetryable ¶
IsRetryable reports whether err is an *Error whose Retryable method returns true.
func ReservedHeader ¶
ReservedHeader reports whether name is a header managed by the library that cannot be set through Message.Headers.
func RetryAfter ¶
RetryAfter returns the provider-suggested retry delay carried by err, or 0.
func ValidateMessage ¶
ValidateMessage validates msg, treating nil as invalid. Senders call it before contacting a provider.
Types ¶
type Address ¶
Address is an email address with an optional display name.
Email holds the bare addr-spec (local@domain). Internationalized addresses (UTF-8 local parts or domains, RFC 6531/6532) are accepted and passed through unchanged; delivering them requires a provider or SMTP server that supports SMTPUTF8. Display names may contain any printable Unicode text and are RFC 2047 encoded when a message is assembled.
func MustParseAddress ¶
MustParseAddress is like ParseAddress but panics on error. It is intended for addresses that are compile-time constants.
func ParseAddress ¶
ParseAddress parses a single RFC 5322 address such as "Jane Doe <jane@example.com>" or "jane@example.com".
func ParseAddressList ¶
ParseAddressList parses a comma-separated list of RFC 5322 addresses.
func (Address) Domain ¶
Domain returns the part of the email after the last '@', or "" when there is none.
func (Address) IsASCII ¶
IsASCII reports whether the email (not the display name) is pure ASCII. Non-ASCII addresses require SMTPUTF8 support from the delivery path.
type BuildOptions ¶
type BuildOptions struct {
// Date is written to the Date header. Zero means time.Now().
Date time.Time
// MessageID is the Message-ID without angle brackets. Empty means a
// random ID is generated.
MessageID string
// MessageIDDomain is the right-hand side of a generated Message-ID.
// Empty means the From domain when it is ASCII, otherwise
// "omnimail.invalid".
MessageIDDomain string
// Boundary fixes the multipart boundary (useful for golden tests).
// Empty means a random boundary.
Boundary string
}
BuildOptions controls BuildMIME. The zero value is valid.
type Error ¶
type Error struct {
// Kind classifies the failure.
Kind Kind
// Provider names the sender that produced the error ("smtp", "ses", ...).
// Empty for validation errors raised by the core.
Provider string
// Code is the provider-specific code, e.g. an SMTP reply code ("550") or
// an API error code ("Throttling").
Code string
// Field names the message field that failed validation, if any.
Field string
// Message is a human-readable description.
Message string
// RetryAfter is a provider-suggested delay before retrying, if known.
RetryAfter time.Duration
// Err is the underlying cause, if any.
Err error
}
Error is a classified send or validation failure. Providers return it (or an error wrapping it) so callers can branch on Error.Kind or use errors.Is with the sentinel errors.
func (*Error) Retryable ¶
Retryable reports whether resending the same message may succeed. It is Temporary, except that failures caused by the caller's context being canceled or timing out are not retryable under that context.
type Kind ¶
type Kind int
Kind classifies a send failure so callers can decide how to react without knowing the provider.
const ( // KindUnknown is a failure that could not be classified. KindUnknown Kind = iota // KindInvalidMessage means the message failed validation (missing // recipients or body, header injection, malformed headers). Not retryable. KindInvalidMessage // KindInvalidAddress means an address is malformed or was refused by the // provider as undeliverable. Not retryable. KindInvalidAddress // KindRejected means the provider refused the message (policy, content, // suppression list, unverified sender). Not retryable as-is. KindRejected // KindThrottled means a rate or quota limit was hit. Retryable after a // delay; see [Error.RetryAfter]. KindThrottled // KindTransient means a temporary failure (network, 4xx SMTP reply, // provider 5xx). Retryable. KindTransient // KindAuth means the provider rejected the sender's credentials or // permissions. Not retryable until configuration changes. KindAuth )
func KindOf ¶
KindOf returns the kind of the first *Error in err's chain, or KindUnknown when there is none.
type MIMEMessage ¶
type MIMEMessage struct {
// MessageID is the Message-ID header value without angle brackets.
MessageID string
// Data is the full message (headers and body) with CRLF line endings,
// ready for SMTP DATA or a provider's raw-message API. Bcc recipients are
// never included.
Data []byte
}
MIMEMessage is an assembled RFC 5322 message.
func BuildMIME ¶
func BuildMIME(msg *Message, opts BuildOptions) (*MIMEMessage, error)
BuildMIME validates msg and assembles it into an RFC 5322 message: a text/plain or text/html single part, or multipart/alternative when both bodies are set. Bodies are UTF-8 and encoded 7bit, quoted-printable or base64, whichever is safest and smallest. Non-ASCII header values (subject, display names, custom headers) are RFC 2047 encoded and long headers are folded. Bcc is used only for the envelope and never written to a header.
Tags and IdempotencyKey are provider metadata and are not written.
type Message ¶
type Message struct {
From Address `json:"from"`
To []Address `json:"to,omitempty"`
Cc []Address `json:"cc,omitempty"`
Bcc []Address `json:"bcc,omitempty"`
ReplyTo []Address `json:"replyTo,omitempty"`
// Subject is plain text; it must not contain line breaks. Non-ASCII
// subjects are RFC 2047 encoded on assembly.
Subject string `json:"subject"`
// Text is the plain-text body.
Text string `json:"text,omitempty"`
// HTML is the HTML body.
HTML string `json:"html,omitempty"`
// Headers are additional header fields, e.g. "List-Unsubscribe".
// Structural headers managed by the library (From, To, Subject,
// Content-Type, ...) cannot be set here; see [ReservedHeader].
Headers map[string]string `json:"headers,omitempty"`
// Tags are provider metadata (e.g. SES message tags, SendGrid categories)
// used for analytics and event routing. Providers that do not support
// tags ignore them.
Tags map[string]string `json:"tags,omitempty"`
// IdempotencyKey, when set, asks providers that support it to deliver the
// message at most once per key.
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
Message is a transactional email.
At least one recipient (To, Cc or Bcc) and at least one body (Text or HTML) are required. When both bodies are set the message is sent as multipart/alternative.
func (*Message) Recipients ¶
Recipients returns To, Cc and Bcc in that order (the SMTP envelope recipients).
func (*Message) Validate ¶
Validate checks that the message can be sent safely. It returns an *Error of kind KindInvalidMessage or KindInvalidAddress; both match ErrInvalidMessage with errors.Is.
Validation rejects line breaks and other control characters in every value that becomes a header (subject, display names, custom headers, tags, idempotency key), which prevents header injection.
type Rendered ¶
Rendered is the output of Template.Render.
type SendResult ¶
type SendResult struct {
// MessageID is the identifier assigned to the message: the provider's ID
// when it returns one, otherwise the RFC 5322 Message-ID.
MessageID string `json:"messageId"`
// Provider names the sender that accepted the message ("smtp", "ses", ...).
Provider string `json:"provider"`
}
SendResult describes an accepted message.
type Sender ¶
type Sender interface {
Send(ctx context.Context, msg *Message) (*SendResult, error)
}
Sender delivers a message. Implementations must:
- validate the message (normally by calling Message.Validate) and return an error matching ErrInvalidMessage without contacting the provider when it is invalid;
- not modify msg;
- honor ctx: return promptly with an error matching ctx.Err() when ctx is canceled or its deadline passes;
- classify provider failures with *Error so callers can use KindOf, IsRetryable and errors.Is with the sentinel errors;
- be safe for concurrent use.
The providertest package verifies these rules.
type SenderFunc ¶
type SenderFunc func(ctx context.Context, msg *Message) (*SendResult, error)
SenderFunc adapts a function to the Sender interface.
func (SenderFunc) Send ¶
func (f SenderFunc) Send(ctx context.Context, msg *Message) (*SendResult, error)
Send calls f(ctx, msg).
type Template ¶
type Template struct {
// contains filtered or unexported fields
}
Template renders the subject and bodies of a transactional email.
Safe defaults: the HTML body uses html/template, so data is contextually autoescaped; the subject and text body use text/template; a missing map key is an error rather than "<no value>"; and the rendered subject is collapsed to a single line so data can never inject headers.
func ParseTemplate ¶
func ParseTemplate(name string, src TemplateSource, opts ...TemplateOption) (*Template, error)
ParseTemplate parses an email template from strings.
func ParseTemplateFS ¶
ParseTemplateFS parses the email template called name from fsys, reading name+".subject.tmpl", name+".text.tmpl" and name+".html.tmpl". Missing files are skipped, but at least one body file must exist. name may include a directory, e.g. "mail/verify". It works with embed.FS:
//go:embed mail/*.tmpl var mailFS embed.FS tmpl, err := omnimail.ParseTemplateFS(mailFS, "mail/verify")
type TemplateOption ¶
type TemplateOption func(*templateConfig)
TemplateOption configures ParseTemplate and ParseTemplateFS.
func WithDelims ¶
func WithDelims(left, right string) TemplateOption
WithDelims sets the action delimiters (default "{{" and "}}").
func WithFuncs ¶
func WithFuncs(funcs map[string]any) TemplateOption
WithFuncs adds template functions available to the subject, text and HTML templates.
type TemplateSource ¶
TemplateSource holds the template text for one email: a subject line, a plain-text body and an HTML body. Text or HTML (or both) is required; Subject is optional.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package logsender provides an omnimail.Sender that writes messages to a slog.Logger instead of delivering them.
|
Package logsender provides an omnimail.Sender that writes messages to a slog.Logger instead of delivering them. |
|
Package memsender provides an omnimail.Sender that captures messages in memory.
|
Package memsender provides an omnimail.Sender that captures messages in memory. |
|
Package providertest is the conformance suite for omnimail.Sender implementations.
|
Package providertest is the conformance suite for omnimail.Sender implementations. |
|
Package smtp provides an omnimail.Sender that delivers through an SMTP server (a relay such as Postfix, a provider's SMTP endpoint, or a local development server such as Mailpit).
|
Package smtp provides an omnimail.Sender that delivers through an SMTP server (a relay such as Postfix, a provider's SMTP endpoint, or a local development server such as Mailpit). |
|
smtptest
Package smtptest provides an in-process SMTP server for tests.
|
Package smtptest provides an in-process SMTP server for tests. |