From 9acbd2d46e1c2974c90f0451f991d4d54a1388bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 29 Aug 2026 12:22:08 +0200 Subject: [PATCH] feat: add MailerMailgun, an HTTP API transport The library shipped one transport, SMTP, and docs/examples/custom-backend.md sketched an "APIMailer" as something each caller would write for themselves. This makes that concrete for the provider most likely to be reached for. Why an API transport is worth having alongside SMTP: - SMTP needs an outbound connection on a port many hosting environments block outright. The messages API is HTTPS on 443. - SMTP reports most delivery failures asynchronously, by bounce. The API answers synchronously, so "Domain not found" arrives as an error the caller can act on rather than as a message that silently never lands. Notes on the implementation, in case they read as arbitrary: - The whole messages URL is configuration, not a host plus a domain. Mailgun puts the sending domain in the path AND runs a separate EU host (api.eu.mailgun.net), which a host+domain pair cannot express. - Basic auth with the literal username "api" is what Mailgun expects; the key is the password, not the account address. - The body is form-encoded, not JSON, and `text` and `html` are different fields. Choosing between them from MailContent's MIME type is what stops an HTML template arriving as visible source -- a failure no status code would reveal. - A non-https URL is refused at construction. The key is a basic-auth header on every request, and a mail provider is on the public internet by definition, so there is no private-network case to allow for. - HTTPClient is optional but expected: a service that already has a configured client should pass it, so this transport inherits that timeout, retry policy and pool instead of quietly keeping its own. - The API key never appears in an error. An error message is the one place guaranteed to reach a log file, and a test asserts it. Configuration errors surface from NewMailerMailgun rather than from the first Send, because the first message a service sends is usually a password reset or an account verification -- a misconfiguration found there is found as a user who cannot get in. Tests assert the request against Mailgun's documented wire format field by field: method, path, content type, basic-auth pair, and the form fields. They deliberately do not model a response body the code parses, because there is none -- only a status -- so there is no way for the test to agree with the code about a shape neither shares with the real provider. Two of them found real things while being written: the builder validates a display name with len() BEFORE trimming, so " " is a valid three-character name and the empty-name path is reachable rather than defensive; and the html/text mutation was verified to fail the suite. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 + README.md | 6 +- doc.go | 5 +- docs/examples/custom-backend.md | 9 +- mailgun.go | 184 ++++++++++++++++++++ mailgun_test.go | 298 ++++++++++++++++++++++++++++++++ 6 files changed, 504 insertions(+), 5 deletions(-) create mode 100644 mailgun.go create mode 100644 mailgun_test.go 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") + } +}