omnimail

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Oct 8, 2026 License: MIT Imports: 20 Imported by: 0

README

OmniMail

Go CI Go Lint Go SAST Coverage Docs Docs License

A vendor-free Go core for transactional email: verification links, security notices, receipts and other mail an application sends as itself.

OmniMail defines one Sender interface, a validated message model, MIME assembly, templates and classified errors. Applications depend only on the core; the deployment chooses the delivery provider. Vendor SDKs live in separate adapter modules, so the core has no dependencies outside the Go standard library.

OmniMail is not a mailbox or chat client. For conversational messaging authenticated as a user, see OmniChat.

Features

  • One interface - Sender.Send(ctx, *Message) (*SendResult, error) for every provider
  • Safe by default - address validation via net/mail, internationalized addresses, header-injection protection (CR/LF rejected in every header value)
  • MIME assembly - RFC 5322 multipart/alternative, quoted-printable/base64 bodies, RFC 2047 headers, Date and Message-ID; Bcc never written
  • Templates - subject/text/HTML rendering with html/template autoescaping, from strings or an fs.FS (embed.FS)
  • Classified errors - InvalidMessage, InvalidAddress, Rejected, Throttled, Transient, Auth with Retryable()
  • Built-in senders - SMTP (STARTTLS / implicit TLS, PLAIN/LOGIN), log (bodies redacted), memory (tests)
  • Conformance suite - providertest keeps every adapter behaving the same

Providers

Provider Module / Package Status
SMTP github.com/plexusone/omnimail/smtp Available
Log (development) github.com/plexusone/omnimail/logsender Available
Memory (tests) github.com/plexusone/omnimail/memsender Available
AWS SES v2 github.com/plexusone/omni-aws/omnimail Planned
SendGrid github.com/plexusone/omni-twilio/omnimail Planned
Gmail API github.com/plexusone/omni-google/omnimail Planned

Installation

go get github.com/plexusone/omnimail

Quick Start

package main

import (
    "context"
    "log"
    "os"

    "github.com/plexusone/omnimail"
    "github.com/plexusone/omnimail/smtp"
)

func main() {
    sender, err := smtp.New(smtp.Config{
        Host:     os.Getenv("SMTP_HOST"),
        Username: os.Getenv("SMTP_USERNAME"),
        Password: os.Getenv("SMTP_PASSWORD"),
    })
    if err != nil {
        log.Fatal(err)
    }

    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 your address: https://example.com/verify?t=...",
        HTML:    `<p><a href="https://example.com/verify?t=...">Verify your address</a></p>`,
        Tags:    map[string]string{"purpose": "verify_email"},
    }

    res, err := sender.Send(context.Background(), msg)
    if err != nil {
        if omnimail.IsRetryable(err) {
            log.Printf("temporary failure, retry later: %v", err)
            return
        }
        log.Fatal(err)
    }
    log.Printf("sent %s via %s", res.MessageID, res.Provider)
}

Templates

//go:embed mail/*.tmpl
var mailFS embed.FS

tmpl, err := omnimail.ParseTemplateFS(mailFS, "mail/verify") // verify.subject.tmpl, verify.text.tmpl, verify.html.tmpl
if err != nil {
    return err
}
if err := tmpl.Apply(msg, map[string]any{"Name": user.Name, "Link": link}); err != nil {
    return err
}

Errors

_, err := sender.Send(ctx, msg)
switch {
case errors.Is(err, omnimail.ErrInvalidMessage): // fix the message; never retry
case errors.Is(err, omnimail.ErrThrottled):      // back off: omnimail.RetryAfter(err)
case omnimail.IsRetryable(err):                  // transient: retry with backoff
case errors.Is(err, omnimail.ErrAuth):           // credentials or permissions
}

Testing

s := memsender.New()
app := NewApp(s)
// ... exercise the app ...
if s.Len() != 1 || !strings.Contains(s.Last().Text, "/verify?") {
    t.Fatal("verification email not sent")
}

Writing a Provider Adapter

Adapters implement omnimail.Sender in their own module and run the conformance suite against a fake endpoint:

func TestConformance(t *testing.T) {
    providertest.RunAll(t, providertest.Config{
        Harness:      newFakeEndpointHarness(t),
        Provider:     "ses",
        SupportsTags: true,
    })
}

See the adapter guide and conformance suite.

Documentation

License

MIT License - see LICENSE for details.

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

View Source
const (
	SubjectSuffix = ".subject.tmpl"
	TextSuffix    = ".text.tmpl"
	HTMLSuffix    = ".html.tmpl"
)

Template file suffixes read by ParseTemplateFS.

Variables

View Source
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

func IsRetryable(err error) bool

IsRetryable reports whether err is an *Error whose Retryable method returns true.

func ReservedHeader

func ReservedHeader(name string) bool

ReservedHeader reports whether name is a header managed by the library that cannot be set through Message.Headers.

func RetryAfter

func RetryAfter(err error) time.Duration

RetryAfter returns the provider-suggested retry delay carried by err, or 0.

func ValidateMessage

func ValidateMessage(msg *Message) error

ValidateMessage validates msg, treating nil as invalid. Senders call it before contacting a provider.

Types

type Address

type Address struct {
	Name  string `json:"name,omitempty"`
	Email string `json:"email"`
}

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

func MustParseAddress(s string) Address

MustParseAddress is like ParseAddress but panics on error. It is intended for addresses that are compile-time constants.

func ParseAddress

func ParseAddress(s string) (Address, error)

ParseAddress parses a single RFC 5322 address such as "Jane Doe <jane@example.com>" or "jane@example.com".

func ParseAddressList

func ParseAddressList(s string) ([]Address, error)

ParseAddressList parses a comma-separated list of RFC 5322 addresses.

func (Address) Domain

func (a Address) Domain() string

Domain returns the part of the email after the last '@', or "" when there is none.

func (Address) IsASCII

func (a Address) IsASCII() bool

IsASCII reports whether the email (not the display name) is pure ASCII. Non-ASCII addresses require SMTPUTF8 support from the delivery path.

func (Address) String

func (a Address) String() string

String formats the address for an RFC 5322 header. A display name is quoted or RFC 2047 encoded as needed. An address without a display name is returned as the bare email.

func (Address) Validate

func (a Address) Validate() error

Validate reports whether the address is well formed. It returns an *Error of kind KindInvalidAddress when it is not.

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 NewError

func NewError(kind Kind, provider string, err error) *Error

NewError returns an *Error of the given kind for a provider, wrapping err.

func (*Error) Error

func (e *Error) Error() string

Error implements the error interface.

func (*Error) Is

func (e *Error) Is(target error) bool

Is matches the sentinel error for the error's kind.

func (*Error) Retryable

func (e *Error) Retryable() bool

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.

func (*Error) Temporary

func (e *Error) Temporary() bool

Temporary reports whether the failure condition is expected to clear on its own (throttling or a transient fault).

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the underlying cause.

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

func KindOf(err error) Kind

KindOf returns the kind of the first *Error in err's chain, or KindUnknown when there is none.

func Kinds

func Kinds() []Kind

Kinds returns every failure kind except KindUnknown, in declaration order.

func (Kind) String

func (k Kind) String() string

String returns the snake_case name of the kind.

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) Clone

func (m *Message) Clone() *Message

Clone returns a deep copy of m.

func (*Message) Recipients

func (m *Message) Recipients() []Address

Recipients returns To, Cc and Bcc in that order (the SMTP envelope recipients).

func (*Message) Validate

func (m *Message) Validate() error

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

type Rendered struct {
	Subject string
	Text    string
	HTML    string
}

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

func ParseTemplateFS(fsys fs.FS, name string, opts ...TemplateOption) (*Template, error)

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")

func (*Template) Apply

func (t *Template) Apply(msg *Message, data any) error

Apply renders the template with data and sets msg's Subject (when the template has one), Text and HTML.

func (*Template) Name

func (t *Template) Name() string

Name returns the template name.

func (*Template) Render

func (t *Template) Render(data any) (*Rendered, error)

Render executes the templates with data.

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

type TemplateSource struct {
	Subject string
	Text    string
	HTML    string
}

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.

Jump to

Keyboard shortcuts

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