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