Skip to content

feat: add MailerMailgun, an HTTP API transport - #10

Merged
christiangda merged 1 commit into
mainfrom
feat/mailgun-transport
Aug 29, 2026
Merged

feat: add MailerMailgun, an HTTP API transport#10
christiangda merged 1 commit into
mainfrom
feat/mailgun-transport

Conversation

@christiangda

Copy link
Copy Markdown
Contributor

The library ships one transport, SMTP, and docs/examples/custom-backend.md
sketches an "APIMailer" as something each caller writes for themselves. This
makes it concrete for the provider most likely to be reached for.

Why an API transport 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.

Decisions that might otherwise 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.
  • Form-encoded, not JSON, and text / html are different fields.
    Choosing between them from the MailContent 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 rather than 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; a test asserts it.

Configuration errors come from NewMailerMailgun, not the first Send — the
first message a service sends is usually a password reset or an account
verification, so a misconfiguration found there is found as a user who cannot
get in.

On the tests

They assert the request against Mailgun's documented wire format field by field:
method, path, content type, the 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 found real things while being written:

  • the builder validates a display name with len() before trimming, so
    " " is a valid three-character name — the empty-name path is reachable,
    not defensive;
  • the html/text selection was verified by mutation: forcing the wrong branch
    fails the suite.

Not verified against a live Mailgun account — there is none in this project
to capture golden fixtures from, so the request shape rests on the published API
documentation. Worth stating plainly rather than letting "tested" be read as
"verified against the real thing".

go vet, go test -race and gofmt are clean; README, doc.go, CHANGELOG and
the custom-backend example are updated.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@christiangda christiangda self-assigned this Aug 29, 2026
@christiangda
christiangda merged commit f46a604 into main Aug 29, 2026
1 check passed
@christiangda
christiangda deleted the feat/mailgun-transport branch August 29, 2026 10:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant