Sending transactional email from a Go service
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.
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 →