Envello
Tutorial

Sending transactional email from a Go service

Envello Team·2026-07-17·8 min read

Envello has a real Go SDK, unlike Rails, and it's installable today with go get, in its own repository under the Aktai org.

Using the SDK

The SDK wraps the HTTP calls and gives you typed request/response structs.

  • go get github.com/Aktai-ltd/[email protected]
  • client := envello.NewClient("env_live_...")
  • resp, err := client.SendEmail(ctx, envello.SendEmailRequest{ From: "Acme <[email protected]>", To: recipient, Subject: "Welcome!", Html: renderedHTML })

The honest caveat

v0.1.0 is a real tagged release, but it's a 0.x version: the API surface can still change between minor versions without the usual semver guarantees a 1.0 release implies. Pin the exact tag in go.mod (go get already does this for you) and read the changelog before bumping.

Without the SDK

net/http covers this in a few lines if you'd rather not take the dependency at this stage.

  • Build the JSON body with encoding/json: body, _ := json.Marshal(SendEmailRequest{From: from, To: to, Subject: subject, Html: html})
  • req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.envello.dev/emails", bytes.NewReader(body))
  • req.Header.Set("Authorization", "Bearer "+apiKey); req.Header.Set("Content-Type", "application/json")
  • resp, err := http.DefaultClient.Do(req); check err, then check resp.StatusCode before assuming success

Error handling

Whether using the SDK or net/http directly, handle status codes explicitly. Go's error handling conventions make this natural: check errors immediately, wrap them with context.

  • 2xx: parse the response body for the message ID, log it for tracing
  • 400: validation error. Parse the error body (json.Unmarshal into an ErrorResponse struct) and return a wrapped error: fmt.Errorf("invalid email request: %w", err)
  • 429: rate limited. Check for a Retry-After header and back off before retrying
  • 5xx: retry once with a short delay using a simple backoff, or use a library like retry-go for exponential backoff
  • Network errors (resp, err := ... where err != nil): the request may not have reached the server; safe to retry with an idempotency key

A reusable email package

Wrap the send logic in a package so every call site in your service uses the same client configuration and error handling.

  • Create an internal/email package with a Client struct holding the HTTP client, API key, and base URL
  • Define a Send(ctx context.Context, req SendEmailRequest) (*SendEmailResponse, error) method
  • Use a custom error type: type SendError struct { StatusCode int; Body string } implementing the error interface, so callers can type-assert and handle specific status codes
  • Set a reasonable timeout on the http.Client (5-10 seconds) to avoid a hung request blocking a goroutine indefinitely

Async sending with goroutines

For transactional emails triggered by an HTTP handler, send in a goroutine so the handler's response doesn't wait on Envello's round-trip. Go's concurrency primitives make this straightforward, but a fire-and-forget goroutine loses the result if the process exits before it completes.

  • For simple fire-and-forget: go func() { if _, err := emailClient.Send(context.Background(), req); err != nil { log.Printf("email send failed: %v", err) } }()
  • Use context.Background() rather than the request's context, since the request context is canceled once the handler returns, which would cancel the in-flight email send too
  • For guaranteed delivery, use a worker pool pattern: push send requests onto a buffered channel, have a fixed number of worker goroutines consume from it and retry on failure
  • For production services, a proper job queue (backed by Redis or Postgres, e.g. using a library like river or asynq) survives process restarts, which a bare goroutine does not

Worker pool pattern in detail

A bounded worker pool prevents goroutine explosion under load and gives you backpressure.

  • Create a buffered channel: emailQueue := make(chan SendEmailRequest, 1000)
  • Start N worker goroutines that range over the channel and call emailClient.Send for each item
  • Handlers push to the channel: select { case emailQueue <- req: default: log.Println("email queue full, dropping send") } to avoid blocking the handler if the queue backs up
  • On graceful shutdown, close the channel and let workers drain remaining items with a timeout

Webhook handling

Create an HTTP handler to receive Envello's delivery event webhooks.

  • func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body); ... }
  • Get the signature: r.Header.Get("X-Envello-Signature")
  • Verify HMAC-SHA256: mac := hmac.New(sha256.New, []byte(secret)); mac.Write(body); expected := hex.EncodeToString(mac.Sum(nil))
  • Use hmac.Equal (constant-time comparison) rather than == to compare signatures and prevent timing attacks
  • Parse the event with encoding/json and handle: delivered (log), bounced (call suppressionStore.Add(email)), complained (suppress)
  • Write w.WriteHeader(http.StatusOK) immediately; process the event asynchronously if it involves database writes

Testing

Use httptest.NewServer to spin up a mock Envello API in tests and verify the request payload without making real network calls.

httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { /* assert headers, body */ w.WriteHeader(202) })) gives you a real *http.Server to point your client at during tests.

For error-path tests, configure the mock server to return 400, 429, and 500 responses and verify your client's error handling and retry logic behaves correctly.

For integration tests against the real API, use Envello's test mode with a dedicated test API key.

Free tool

Check your domain's SPF, DKIM, and DMARC records

Paste in a domain and see what's missing, plus the exact DNS records to fix it. Free, no account needed.

Check your domain →
Envello

EU-hosted transactional email, done right by default.