diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17f7d1e..25b3d8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- `MailerMailgun`, a second built-in transport delivering through Mailgun's
+ messages HTTP API over HTTPS. It reaches providers from environments that
+ block outbound SMTP ports and reports delivery failures synchronously rather
+ than by bounce. Accepts an optional `*http.Client` so a caller's existing
+ timeout, retry policy and connection pool are inherited rather than
+ duplicated; refuses a non-`https` URL, because the API key travels as a
+ basic-auth header on every request.
- Exported read accessors on `MailContent` (`FromName`, `FromAddress`, `ToName`,
`ToAddress`, `MimeType`, `Subject`, `Body`) so external `MailerService`
implementations can read message fields.
diff --git a/README.md b/README.md
index 6d886f1..2ab5792 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ flowchart LR
end
T{{"MailerService
Send(ctx, MailContent)"}}
SMTP["MailerSMTP
(TLS / STARTTLS)"]
+ MG["MailerMailgun
(HTTPS messages API)"]
CUS["Custom backend
(SES, SendGrid, …)"]
P -->|"NewMailContentBuilder()"| B
@@ -28,6 +29,7 @@ flowchart LR
Q --> W1 & W2 & WN
W1 & W2 & WN --> T
T --> SMTP
+ T --> MG
T --> CUS
classDef q fill:#fde68a,stroke:#b45309,color:#000;
@@ -43,6 +45,7 @@ flowchart LR
- **Graceful shutdown** — `Stop()` closes the queue, drains in-flight work, and waits for workers. Context cancellation stops workers immediately.
- **Pluggable transports** — implement the small `MailerService` interface to send through any provider; `MailContent` exposes read accessors so external backends work.
- **TLS-capable SMTP** — implicit TLS (SMTPS, port 465) and opportunistic/required STARTTLS, PLAIN auth, configurable dial timeout and EHLO name.
+- **Mailgun HTTP API** — `MailerMailgun` posts to the messages endpoint over HTTPS, which reaches providers from environments that block outbound SMTP ports and reports delivery failures synchronously instead of by bounce. Bring your own `*http.Client` to inherit an existing timeout and pool.
- **Validated, injection-safe content** — a fluent `MailContentBuilder` validates addresses, MIME type, and lengths, and rejects CR/LF/NUL in header fields (SMTP header-injection protection).
- **Observability** — structured `log/slog` logging and typed, wrappable errors (`errors.Is`/`errors.As`).
- **Zero third-party dependencies** — standard library only.
@@ -77,7 +80,8 @@ import (
)
func main() {
- // 1. Configure a transport (the built-in SMTP backend).
+ // 1. Configure a transport (the built-in SMTP backend; see
+ // NewMailerMailgun for the HTTP API alternative).
smtpMailer, err := mailer.NewMailerSMTP(mailer.MailerSMTPConf{
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: 587,
diff --git a/doc.go b/doc.go
index 267d8d7..5b846f8 100644
--- a/doc.go
+++ b/doc.go
@@ -5,7 +5,7 @@ for Go applications.
It features a queue-based dispatcher (MailService) backed by a pool of worker
goroutines, so applications can enqueue validated messages quickly without
blocking on delivery. Delivery is performed by any implementation of the
-MailerService interface; a standard-library SMTP transport (MailerSMTP) with
+MailerService interface; two standard-library transports -- SMTP (MailerSMTP) with
TLS/STARTTLS and authentication is included.
Key features:
@@ -15,6 +15,9 @@ Key features:
- Graceful shutdown (queue drain) and context-driven hard shutdown.
- Pluggable transports via the MailerService interface; MailContent exposes
read accessors so external backends can read every field.
+ - Mailgun transport (MailerMailgun) over the messages HTTP API, for
+ environments where outbound SMTP ports are blocked and for synchronous
+ delivery errors.
- SMTP transport with implicit TLS (SMTPS), opportunistic/required STARTTLS,
PLAIN auth, and a configurable dial timeout and EHLO name.
- Validated, injection-safe message construction via MailContentBuilder
diff --git a/docs/examples/custom-backend.md b/docs/examples/custom-backend.md
index 754bf1a..dde9547 100644
--- a/docs/examples/custom-backend.md
+++ b/docs/examples/custom-backend.md
@@ -1,9 +1,12 @@
# Custom backend example
The `MailService` queue and worker pool are transport-agnostic. To deliver
-through something other than SMTP — a provider API (Amazon SES, SendGrid,
-Postmark), a message bus, or a test double — implement the `MailerService`
-interface:
+through something other than the built-in transports — another provider API
+(Amazon SES, SendGrid, Postmark), a message bus, or a test double — implement
+the `MailerService` interface:
+
+> Mailgun no longer needs this: `MailerMailgun` ships with the library. Read it
+> (`mailgun.go`) as a worked example of everything below.
```go
type MailerService interface {
diff --git a/mailgun.go b/mailgun.go
new file mode 100644
index 0000000..04ac299
--- /dev/null
+++ b/mailgun.go
@@ -0,0 +1,184 @@
+package mailer
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// Mailgun validation bounds and defaults.
+const (
+ ValidMinMailgunAPIKeyLength = 1
+ ValidMaxMailgunAPIKeyLength = 512
+
+ // DefaultMailgunTimeout bounds one request when MailerMailgunConf.HTTPClient
+ // is nil and the package builds its own client.
+ DefaultMailgunTimeout = 30 * time.Second
+
+ // mailgunBasicAuthUser is the literal username Mailgun expects; the API key
+ // is the password. It is not the account's email address.
+ mailgunBasicAuthUser = "api"
+
+ // maxMailgunErrorBody bounds how much of a failed response is read into an
+ // error. A provider having a bad day can answer with a full HTML page, and
+ // none of it belongs in a log line.
+ maxMailgunErrorBody = 4 << 10
+)
+
+// MailerMailgunConf configures the Mailgun transport.
+type MailerMailgunConf struct {
+ // APIURL is the messages endpoint, in full, including the sending domain:
+ //
+ // https://api.mailgun.net/v3/mg.example.com/messages
+ //
+ // The domain is part of the path rather than a separate field because
+ // Mailgun's EU region uses a different host entirely
+ // (api.eu.mailgun.net), and a host+domain pair would not express that.
+ APIURL string
+
+ // APIKey is the private API key, sent as the HTTP basic-auth password.
+ APIKey string
+
+ // HTTPClient sends the request. Optional: when nil a client with
+ // DefaultMailgunTimeout is built.
+ //
+ // Supplying one is the norm for a service that already has a configured
+ // client -- it makes this transport inherit that client's timeout, retry
+ // policy and connection pool instead of quietly keeping its own.
+ HTTPClient *http.Client
+}
+
+// MailerMailgun is a Mailgun HTTP API implementation of MailerService.
+//
+// # Why an API transport at all
+//
+// SMTP requires an outbound connection on a port many hosting environments
+// block, and it reports delivery failures asynchronously by bouncing. A
+// provider API answers synchronously over HTTPS on 443, which is both reachable
+// from more places and easier to surface as an error the caller can act on.
+//
+// # The wire format is form-encoded, not JSON
+//
+// Mailgun's messages endpoint takes application/x-www-form-urlencoded. The
+// fields are from, to, subject, and exactly one of text or html -- chosen from
+// the MailContent MIME type, so an HTML template does not arrive as visible
+// source. Authentication is HTTP basic with the literal username "api".
+type MailerMailgun struct {
+ apiURL string
+ apiKey string
+ client *http.Client
+}
+
+// Ensure MailerMailgun satisfies the transport interface.
+var _ MailerService = (*MailerMailgun)(nil)
+
+// NewMailerMailgun validates the configuration and returns a Mailgun transport.
+//
+// Configuration errors are returned here rather than on the first Send. The
+// first message a service sends is usually a password reset or an account
+// verification, so a misconfiguration discovered there is discovered as a user
+// who cannot get in.
+func NewMailerMailgun(conf MailerMailgunConf) (*MailerMailgun, error) {
+ if strings.TrimSpace(conf.APIURL) == "" {
+ return nil, &MailerError{Message: "APIURL is required"}
+ }
+
+ parsed, err := url.Parse(conf.APIURL)
+ if err != nil {
+ return nil, &MailerError{Message: fmt.Sprintf("APIURL %q is not a valid URL", conf.APIURL), Err: err}
+ }
+
+ if parsed.Host == "" {
+ return nil, &MailerError{Message: fmt.Sprintf("APIURL %q must be absolute, with a scheme and host", conf.APIURL)}
+ }
+
+ if parsed.Scheme != "https" {
+ // The API key travels as a basic-auth header on every send, so plain
+ // HTTP puts a credential on the wire. A mail provider is on the public
+ // internet by definition; there is no private-network case to allow for.
+ return nil, &MailerError{Message: fmt.Sprintf("APIURL must use https, got %q", parsed.Scheme)}
+ }
+
+ if l := len(conf.APIKey); l < ValidMinMailgunAPIKeyLength || l > ValidMaxMailgunAPIKeyLength {
+ return nil, &MailerError{Message: fmt.Sprintf("APIKey must be between %d and %d characters", ValidMinMailgunAPIKeyLength, ValidMaxMailgunAPIKeyLength)}
+ }
+
+ client := conf.HTTPClient
+ if client == nil {
+ client = &http.Client{Timeout: DefaultMailgunTimeout}
+ }
+
+ return &MailerMailgun{apiURL: conf.APIURL, apiKey: conf.APIKey, client: client}, nil
+}
+
+// Send delivers a single message through the Mailgun API. It honours the
+// context and returns a *MailerError wrapping the underlying failure.
+func (m *MailerMailgun) Send(ctx context.Context, content MailContent) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+
+ form := url.Values{}
+ form.Set("from", formatMailgunAddress(content.FromName(), content.FromAddress()))
+ form.Set("to", formatMailgunAddress(content.ToName(), content.ToAddress()))
+ form.Set("subject", content.Subject())
+
+ // text and html are DIFFERENT fields at Mailgun, and putting the body under
+ // the wrong one delivers the markup as visible source to the recipient.
+ if content.MimeType() == MimeTypeTextHTML {
+ form.Set("html", content.Body())
+ } else {
+ form.Set("text", content.Body())
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, m.apiURL, strings.NewReader(form.Encode()))
+ if err != nil {
+ return &MailerError{Message: "failed to build the Mailgun request", Err: err}
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth(mailgunBasicAuthUser, m.apiKey)
+
+ resp, err := m.client.Do(req)
+ if err != nil {
+ // The URL is configuration and safe to name. The KEY never appears in an
+ // error: an error message is the one place guaranteed to reach a log.
+ return &MailerError{Message: fmt.Sprintf("failed to send the message to %s", m.apiURL), Err: err}
+ }
+
+ defer func() { _ = resp.Body.Close() }()
+
+ // Read before deciding, so the body is available for the error below and so
+ // the connection can be reused on the success path.
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxMailgunErrorBody))
+
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return &MailerError{Message: fmt.Sprintf(
+ "Mailgun returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)),
+ )}
+ }
+
+ return nil
+}
+
+// formatMailgunAddress renders a recipient as RFC 5322 wants it.
+//
+// An unquoted display name containing a comma would split one recipient into
+// two, so any name carrying a special character is quoted. An empty name yields
+// the bare address rather than a stray "<>" prefix.
+func formatMailgunAddress(name, address string) string {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return address
+ }
+
+ if strings.ContainsAny(name, `",;:<>@[]\`) {
+ name = `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(name) + `"`
+ }
+
+ return name + " <" + address + ">"
+}
diff --git a/mailgun_test.go b/mailgun_test.go
new file mode 100644
index 0000000..64962a3
--- /dev/null
+++ b/mailgun_test.go
@@ -0,0 +1,298 @@
+package mailer
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// captured is what the fake Mailgun endpoint saw.
+type captured struct {
+ method string
+ path string
+ contentType string
+ authUser string
+ authPass string
+ authOK bool
+ form url.Values
+}
+
+// mailgunServer stands in for Mailgun's messages endpoint, recording the
+// request and answering with the status the test asks for.
+//
+// It asserts the REQUEST this package builds against Mailgun's documented wire
+// format. It deliberately does not model a response body this package parses --
+// there is none to parse, only a status code -- so there is no risk of the test
+// agreeing with the code about a shape neither shares with the real provider.
+func mailgunServer(t *testing.T, status int, body string) (*httptest.Server, *captured) {
+ t.Helper()
+
+ got := &captured{}
+
+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got.method = r.Method
+ got.path = r.URL.Path
+ got.contentType = r.Header.Get("Content-Type")
+ got.authUser, got.authPass, got.authOK = r.BasicAuth()
+
+ raw, _ := io.ReadAll(r.Body)
+ got.form, _ = url.ParseQuery(string(raw))
+
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(body))
+ }))
+ t.Cleanup(srv.Close)
+
+ return srv, got
+}
+
+func newTestMailgun(t *testing.T, srv *httptest.Server, key string) *MailerMailgun {
+ t.Helper()
+
+ m, err := NewMailerMailgun(MailerMailgunConf{
+ APIURL: srv.URL + "/v3/mg.example.com/messages",
+ APIKey: key,
+ HTTPClient: srv.Client(),
+ })
+ if err != nil {
+ t.Fatalf("NewMailerMailgun: %v", err)
+ }
+
+ return m
+}
+
+// The documented Mailgun contract, asserted field by field: a form-encoded POST
+// with basic auth under the literal username "api".
+func TestMailerMailgunSendsTheDocumentedRequest(t *testing.T) {
+ srv, got := mailgunServer(t, http.StatusOK, `{"id":"<20260829@mg.example.com>","message":"Queued. Thank you."}`)
+
+ if err := newTestMailgun(t, srv, "key-secret").Send(t.Context(), testMailContent(t)); err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+
+ if got.method != http.MethodPost {
+ t.Errorf("method = %q, want POST", got.method)
+ }
+
+ if got.path != "/v3/mg.example.com/messages" {
+ t.Errorf("path = %q; the sending domain is part of the URL and must be preserved", got.path)
+ }
+
+ if got.contentType != "application/x-www-form-urlencoded" {
+ t.Errorf("Content-Type = %q; Mailgun takes form encoding, not JSON", got.contentType)
+ }
+
+ if !got.authOK || got.authUser != "api" || got.authPass != "key-secret" {
+ t.Errorf("basic auth = (%q, %q, ok=%v); Mailgun expects the literal user \"api\" and the key as the password",
+ got.authUser, got.authPass, got.authOK)
+ }
+
+ for field, want := range map[string]string{
+ "from": "Test Sender ",
+ "to": "Test Recipient ",
+ "subject": "Basic Send Test",
+ "text": "This is a basic test.",
+ } {
+ if g := got.form.Get(field); g != want {
+ t.Errorf("form[%s] = %q, want %q", field, g, want)
+ }
+ }
+
+ if got.form.Has("html") {
+ t.Error("a text/plain message must not be sent as html")
+ }
+}
+
+// The MIME type chooses the field. Getting this wrong delivers markup as
+// visible source, which no status code would reveal.
+func TestMailerMailgunUsesTheHTMLFieldForHTMLContent(t *testing.T) {
+ srv, got := mailgunServer(t, http.StatusOK, "{}")
+
+ content, err := NewMailContentBuilder().
+ WithFromName("Sender").
+ WithFromAddress("sender@example.com").
+ WithToName("Recipient").
+ WithToAddress("recipient@example.com").
+ WithMimeType(MimeTypeTextHTML).
+ WithSubject("HTML Test").
+ WithBody("hello
").
+ Build()
+ if err != nil {
+ t.Fatalf("build content: %v", err)
+ }
+
+ if err := newTestMailgun(t, srv, "key").Send(t.Context(), content); err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+
+ if g := got.form.Get("html"); g != "hello
" {
+ t.Errorf("form[html] = %q, want the body", g)
+ }
+
+ if got.form.Has("text") {
+ t.Error("an html message must not also be sent as text")
+ }
+}
+
+// A display name that is only whitespace must not produce a stray "<>" prefix.
+//
+// The builder validates the name with len() BEFORE any trimming, so " " is a
+// perfectly valid three-character name as far as it is concerned -- which is
+// what makes this reachable rather than defensive.
+func TestMailerMailgunOmitsAWhitespaceOnlyDisplayName(t *testing.T) {
+ srv, got := mailgunServer(t, http.StatusOK, "{}")
+
+ content, err := NewMailContentBuilder().
+ WithFromName(" ").
+ WithFromAddress("sender@example.com").
+ WithToName("Recipient").
+ WithToAddress("recipient@example.com").
+ WithSubject("s").
+ WithBody("b").
+ Build()
+ if err != nil {
+ t.Fatalf("build content: %v", err)
+ }
+
+ if err := newTestMailgun(t, srv, "key").Send(t.Context(), content); err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+
+ if g := got.form.Get("from"); g != "sender@example.com" {
+ t.Errorf("form[from] = %q, want the bare address", g)
+ }
+}
+
+// A comma in a display name would split one recipient into two if it were not
+// quoted -- so the message would silently go somewhere else as well.
+func TestMailerMailgunQuotesADisplayNameWithSpecialCharacters(t *testing.T) {
+ srv, got := mailgunServer(t, http.StatusOK, "{}")
+
+ content, err := NewMailContentBuilder().
+ WithFromName(`Doe, John`).
+ WithFromAddress("sender@example.com").
+ WithToName("Recipient").
+ WithToAddress("recipient@example.com").
+ WithSubject("s").
+ WithBody("b").
+ Build()
+ if err != nil {
+ t.Fatalf("build content: %v", err)
+ }
+
+ if err := newTestMailgun(t, srv, "key").Send(t.Context(), content); err != nil {
+ t.Fatalf("Send: %v", err)
+ }
+
+ if g := got.form.Get("from"); g != `"Doe, John" ` {
+ t.Errorf("form[from] = %q; a name containing a comma must be quoted", g)
+ }
+}
+
+// A non-2xx is an error, and it carries the provider's own explanation --
+// "Domain not found" is the whole diagnosis, and dropping it leaves an operator
+// with a bare 400.
+func TestMailerMailgunReportsAnAPIFailure(t *testing.T) {
+ srv, _ := mailgunServer(t, http.StatusBadRequest, `{"message":"Domain not found: mg.example.com"}`)
+
+ err := newTestMailgun(t, srv, "key").Send(t.Context(), testMailContent(t))
+ if err == nil {
+ t.Fatal("a 400 must be an error")
+ }
+
+ if !strings.Contains(err.Error(), "400") || !strings.Contains(err.Error(), "Domain not found") {
+ t.Errorf("error = %q; it must carry the status and the provider's message", err)
+ }
+}
+
+// The API key must never reach an error string: an error is the one place
+// guaranteed to be written to a log.
+func TestMailerMailgunNeverPutsTheKeyInAnError(t *testing.T) {
+ const key = "key-super-secret-value"
+
+ srv, _ := mailgunServer(t, http.StatusUnauthorized, `{"message":"Invalid private key"}`)
+
+ err := newTestMailgun(t, srv, key).Send(t.Context(), testMailContent(t))
+ if err == nil {
+ t.Fatal("a 401 must be an error")
+ }
+
+ if strings.Contains(err.Error(), key) {
+ t.Errorf("the API key leaked into an error: %q", err)
+ }
+}
+
+func TestNewMailerMailgunRejectsBadConfiguration(t *testing.T) {
+ for name, conf := range map[string]MailerMailgunConf{
+ "empty url": {APIURL: "", APIKey: "k"},
+ "relative url": {APIURL: "/v3/mg.example.com/messages", APIKey: "k"},
+ "not https": {APIURL: "http://api.mailgun.net/v3/d/messages", APIKey: "k"},
+ "empty key": {APIURL: "https://api.mailgun.net/v3/d/messages", APIKey: ""},
+ "unparsable": {APIURL: "https://api.mailgun.net/%zz", APIKey: "k"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := NewMailerMailgun(conf); err == nil {
+ t.Fatal("expected a configuration error")
+ }
+ })
+ }
+}
+
+// http rather than https is refused, and the reason is worth naming: the key is
+// a basic-auth header on every request.
+func TestNewMailerMailgunRefusesPlainHTTP(t *testing.T) {
+ _, err := NewMailerMailgun(MailerMailgunConf{
+ APIURL: "http://api.mailgun.net/v3/mg.example.com/messages",
+ APIKey: "key",
+ })
+ if err == nil {
+ t.Fatal("plain http must be refused; the API key travels on every request")
+ }
+
+ if !strings.Contains(err.Error(), "https") {
+ t.Errorf("error = %q; it should say what is wrong", err)
+ }
+}
+
+// With no client supplied the transport builds its own, so the zero-config case
+// works rather than panicking on a nil client.
+func TestNewMailerMailgunDefaultsItsHTTPClient(t *testing.T) {
+ m, err := NewMailerMailgun(MailerMailgunConf{
+ APIURL: "https://api.mailgun.net/v3/mg.example.com/messages",
+ APIKey: "key",
+ })
+ if err != nil {
+ t.Fatalf("NewMailerMailgun: %v", err)
+ }
+
+ if m.client == nil {
+ t.Fatal("no HTTP client was built")
+ }
+
+ if m.client.Timeout != DefaultMailgunTimeout {
+ t.Errorf("timeout = %v, want %v", m.client.Timeout, DefaultMailgunTimeout)
+ }
+}
+
+// A cancelled context is refused before the request is built, so a caller that
+// has already given up does not cost a connection.
+func TestMailerMailgunHonoursACancelledContext(t *testing.T) {
+ srv, got := mailgunServer(t, http.StatusOK, "{}")
+
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+
+ err := newTestMailgun(t, srv, "key").Send(ctx, testMailContent(t))
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("error = %v, want context.Canceled", err)
+ }
+
+ if got.method != "" {
+ t.Error("a request was sent despite the context already being cancelled")
+ }
+}