mailer is a high-performance, provider-agnostic Go library for sending emails with a focus on transactional email use cases. It provides a clean, idiomatic Go API with support for multiple email providers, template management, and built-in reliability features.
- 🚀 Provider Agnostic: Support for AWS SES, SendGrid, Mailgun, and SMTP, plus a dry-run provider for local development
- 📧 Template Management: HTML/text templates with helper functions
- 🔄 Automatic Retries: Exponential backoff with jitter
- 🚦 Rate Limiting: Configurable rate limiting per provider or recipient
- 🔌 Circuit Breaker: Fault tolerance with circuit breaker pattern
- 📊 Observability: Built-in distributed tracing with OpenTelemetry
- 🏗️ Batch Operations: Efficient batch email sending
- ⚡ Context Support: Full context.Context propagation
- 🔒 Thread Safe: All operations are safe for concurrent use
- 🎯 Type Safe: Leverages Go's type system for compile-time safety
go get github.com/lattiq/mailerpackage main
import (
"context"
"log"
"github.com/lattiq/mailer"
)
func main() {
// Create client with SMTP provider
client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithSMTP("localhost", "1025"),
)
if err != nil {
log.Fatal(err)
}
defer client.Close()
// Create and send email
email := &mailer.Email{
From: mailer.Address{Email: "sender@example.com", Name: "Sender"},
To: []mailer.Address{{Email: "recipient@example.com", Name: "Recipient"}},
Subject: "Welcome!",
HTMLBody: "<h1>Welcome to our service!</h1>",
TextBody: "Welcome to our service!",
}
err = client.Send(context.Background(), email)
if err != nil {
log.Fatal(err)
}
log.Println("Email sent successfully!")
}client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithAWSSES("us-east-1"),
)With explicit credentials:
client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithAWSSESCredentials("us-east-1", "access-key", "secret-key"),
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithSendGrid("your-api-key"),
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithMailgun("your-api-key", "your-domain.com"),
)For Mailgun EU:
client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithMailgunEU("your-api-key", "your-domain.com"),
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithSMTPAuth("smtp.gmail.com", "587", "username", "password"),
)With TLS:
client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithSMTPTLS("smtp.gmail.com", "587", "username", "password", false),
)The SMTP provider upgrades the connection with STARTTLS (port 587). Implicit TLS on port 465 is not supported.
A no-delivery provider that records every send to slog instead of dispatching it. Intended for local development and tests — do not use in production.
client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithDryRun(mailer.DryRunOptions{}),
)By default each entry logs the envelope (from, to, subject) and body sizes. Set IncludeText or IncludeHTML to also dump the rendered bodies — useful when debugging template rendering, but produces large log lines:
client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithDryRun(mailer.DryRunOptions{
IncludeText: true,
IncludeHTML: true,
}),
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithRetry(3, 100*time.Millisecond, 5*time.Second, 2.0),
mailer.WithJitter(true),
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithRateLimit(100, time.Minute, 10), // 100 emails per minute, burst of 10
mailer.WithPerRecipientRateLimit(true),
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithCircuitBreaker(5, 3, 60*time.Second), // 5 failures trigger, 3 successes to close, 60s timeout
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithAWSSES("us-east-1"),
mailer.WithFallbackProvider(mailer.ProviderSendGrid, mailer.ProviderSettings{
"api_key": "your-sendgrid-key",
}),
)config := mailer.DefaultConfig()
config.Templates.Enabled = true
config.Templates.Directory = "templates"
config.Templates.Extension = []string{".html", ".text"}
client, err := mailer.New(config, mailer.WithAWSSES("us-east-1"))
if err != nil {
log.Fatal(err)
}
defer client.Close()Templates use double extensions to specify both the template name and format:
templates/
├── welcome.html.html # HTML template for welcome emails
├── welcome.text.text # Text template for welcome emails
├── otp.html.html # HTML template for OTP emails
└── otp.text.text # Text template for OTP emails
Important: Template files must use double extensions (e.g., name.html.html, name.text.text) so that when the file extension is removed during loading, the templates are registered with names like name.html and name.text, which is what SendTemplate expects.
templateRequest := &mailer.TemplateRequest{
Template: "otp", // Template name (without extension)
From: mailer.Address{Email: "noreply@example.com", Name: "Example"},
To: []mailer.Address{{Email: "user@example.com", Name: "User"}},
Subject: "Your OTP Code",
Data: map[string]interface{}{
"UserName": "John Doe",
"OTP": "123456",
"ExpiryTime": "10 minutes",
},
Headers: map[string]string{
"X-Category": "authentication",
},
}
err := client.SendTemplate(context.Background(), templateRequest)Embed an image in the email instead of linking to it, so it displays without the recipient clicking "download pictures" (Outlook blocks remote images by default). Attach it with Inline: true and reference it from the template with the cid function:
<img src="{{cid "logo"}}" width="150" height="40" alt="Example" />//go:embed logo.png
var logoPNG []byte
err := client.SendTemplate(ctx, &mailer.TemplateRequest{
Template: "otp",
// ...
Attachments: []mailer.Attachment{{
Filename: "logo.png",
ContentType: "image/png",
Data: bytes.NewReader(logoPNG),
Inline: true,
ContentID: "logo",
}},
})- Use
{{cid ...}}rather than writingcid:into the template data:html/templatetreatscid:as unsafe and replaces it with#ZgotmplZ. - Pass a seekable reader such as
bytes.NewReader. Retries rewind and re-read it; a non-seekable reader would be empty on a retry. - Mailgun uses the Content-ID as the uploaded file name and infers the image type from it, so keep the extension (
ContentID: "logo.png", referenced as{{cid "logo.png"}}). - Attachments without
Inlineare sent as regular downloadable attachments. Both kinds work on every provider; SES switches toSendRawEmailwhen an email has attachments or headers.
email.Priority = mailer.PriorityHigh // adds X-Priority: 2, Importance: high
email.Headers = map[string]string{"X-Category": "otp"}PriorityHighandPriorityUrgentaddX-PriorityandImportanceheaders on every provider.PriorityLowandPriorityNormaladd nothing:PriorityLowis the zero value, so it cannot be told apart from an unset priority.- A custom header with the same name overrides the priority headers.
- Custom headers must not contain line breaks and cannot override headers the mailer sets itself (
From,To,Cc,Bcc,Subject,Date,MIME-Version,Content-Type,Content-Transfer-Encoding). Such emails fail validation. - Non-ASCII subjects and header values are encoded automatically.
emails := []*mailer.Email{
{
From: mailer.Address{Email: "noreply@example.com"},
To: []mailer.Address{{Email: "user1@example.com"}},
Subject: "Batch Email 1",
TextBody: "First email in batch",
},
{
From: mailer.Address{Email: "noreply@example.com"},
To: []mailer.Address{{Email: "user2@example.com"}},
Subject: "Batch Email 2",
TextBody: "Second email in batch",
},
}
err := client.SendBatch(context.Background(), emails)Common tasks are wrapped in the Makefile:
make install # install dev tools (golangci-lint, goimports)
make build # verify the package compiles
make test # run tests
make lint # run golangci-lint
make check # lint + test (CI gate)
make sast # SAST: govulncheck + gosec (high severity)
make sbom # CycloneDX SBOM (syft) + vulnerability scan (grype, fails on High)
make ci # everything above: run before every commit
make fix # format, lint --fix, and go mod tidy
make patch # bump patch version and create a git tagRun make help to see the full target list.
Run make ci before committing: GitHub Actions (.github/workflows/ci.yaml) runs the same targets on every push and PR, plus the tests on the minimum Go version in go.mod. make sbom needs syft and grype (brew install syft grype). govulncheck also reports vulnerabilities in the Go standard library you build with, so keep your Go toolchain on the latest patch release.
client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithTracing("my-service", "1.0.0", 1.0),
mailer.WithTracingEndpoint("http://jaeger:14268/api/traces"),
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithMetrics("myapp_mailer", 30*time.Second),
)client, err := mailer.New(
mailer.DefaultConfig(),
mailer.WithLogging("info", "json", "stdout"),
mailer.WithRequestResponseLogging(false), // Be careful with sensitive data
)The library provides rich error types for different scenarios:
err := client.Send(ctx, email)
if err != nil {
switch {
case mailer.IsRetryable(err):
log.Println("Error is retryable:", err)
case mailer.IsTemporary(err):
log.Println("Error is temporary:", err)
default:
log.Println("Permanent error:", err)
}
// Check for specific error types
var validationErr *mailer.ValidationError
if errors.As(err, &validationErr) {
log.Printf("Validation error in field %s: %s", validationErr.Field, validationErr.Message)
}
var providerErr *mailer.ProviderError
if errors.As(err, &providerErr) {
log.Printf("Provider %s error [%s]: %s", providerErr.Provider, providerErr.Code, providerErr.Message)
}
}email := &mailer.Email{
From: mailer.Address{Email: "sender@example.com"},
To: []mailer.Address{{Email: "recipient@example.com"}},
Subject: "Test",
TextBody: "Hello, World!",
}
if err := email.Validate(); err != nil {
log.Printf("Email validation failed: %v", err)
}Send and SendTemplate validate automatically. Validation checks the addresses, requires a subject and a body, and rejects line breaks in the subject or custom headers (header injection) and custom headers that override mailer-set ones.
- Connection Pooling: HTTP connections are pooled automatically
- Memory Usage: ~1KB per email (excluding attachments)
- Throughput: <10ms processing time per email (excluding network)
- Template Caching: Templates are cached for improved performance
- Batch Operations: ~1ms per email in batch operations
- Input Validation: All email addresses and headers are validated
- Header Injection: Protection against email header injection attacks
- TLS Support: Full TLS/SSL support for SMTP connections
- Credential Management: Support for environment variables and IAM roles
This project is licensed under the MIT License - see the LICENSE file for details.
For more examples, see the examples/ directory:
- OTP Email with Templates - Complete example showing how to send OTP emails using templates with AWS SES