All posts
5 min readPigeonify

How to send email in Go

Send transactional email from Go with net/http or net/smtp — with timeouts, retries and context handling that match Go's conventions.

golangapismtptutorial

Go's standard library has net/smtp, and it has been frozen for years — the package documentation says so itself. It works, but it does not support modern authentication mechanisms and will not be extended. For new code, an HTTP API is the simpler path.

Before you start

You need three things, and they take about five minutes:

  1. An account. Sign up free — 5,000 emails a month, no card.
  2. A verified sending domain. Add your domain in the dashboard and publish the DKIM and SPF records it gives you. You cannot send from a domain you have not proved you control, and neither can anyone pretending to be you — that is the whole point. If the terms are unfamiliar, SPF, DKIM and DMARC explained covers them properly.
  3. An API key. Create one under API Keys. Treat it like a password: it can send mail as you.

Put the key in your environment rather than your source. Go reads it from there in every example below.

With net/http

No dependencies:

package mail

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

type Message struct {
	From    string `json:"from"`
	To      string `json:"to"`
	Subject string `json:"subject"`
	HTML    string `json:"html"`
	ReplyTo string `json:"reply_to,omitempty"`
}

type Client struct {
	apiKey string
	http   *http.Client
}

func New(apiKey string) *Client {
	return &Client{
		apiKey: apiKey,
		// Always set a timeout. http.DefaultClient has none, so a hung
		// connection blocks the goroutine until the process restarts.
		http: &http.Client{Timeout: 10 * time.Second},
	}
}

func (c *Client) Send(ctx context.Context, msg Message) (string, error) {
	body, err := json.Marshal(msg)
	if err != nil {
		return "", fmt.Errorf("encode message: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"https://www.pigeonify.com/api/v1/send", bytes.NewReader(body))
	if err != nil {
		return "", fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-API-Key", c.apiKey)

	resp, err := c.http.Do(req)
	if err != nil {
		return "", fmt.Errorf("send request: %w", err)
	}
	defer resp.Body.Close()

	var result struct {
		ID    string `json:"id"`
		Error string `json:"error"`
		Code  string `json:"code"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", fmt.Errorf("decode response: %w", err)
	}

	if resp.StatusCode >= 400 {
		return "", fmt.Errorf("email rejected (%s): %s", result.Code, result.Error)
	}

	return result.ID, nil
}

Take a context.Context and use NewRequestWithContext. A send that cannot be cancelled outlives the request that started it, and under load that is how a service runs out of goroutines.

Retrying

func (c *Client) SendWithRetry(ctx context.Context, msg Message, attempts int) (string, error) {
	var lastErr error

	for attempt := 0; attempt < attempts; attempt++ {
		id, err := c.Send(ctx, msg)
		if err == nil {
			return id, nil
		}

		// A rejection is permanent: the address is wrong or the domain is
		// unverified. Retrying produces the same answer.
		if errors.Is(err, ErrRejected) {
			return "", err
		}

		lastErr = err
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-time.After(time.Duration(1<<attempt) * time.Second):
		}
	}

	return "", fmt.Errorf("after %d attempts: %w", attempts, lastErr)
}

Sleep with select on ctx.Done() rather than time.Sleep. A plain sleep ignores cancellation, and a shutdown then waits for every backoff to finish.

A worker pool

For bulk sending, bound the concurrency rather than spawning a goroutine per message:

func SendBatch(ctx context.Context, client *Client, messages []Message, workers int) []error {
	jobs := make(chan Message)
	errs := make(chan error, len(messages))
	var wg sync.WaitGroup

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for msg := range jobs {
				if _, err := client.SendWithRetry(ctx, msg, 3); err != nil {
					errs <- fmt.Errorf("%s: %w", msg.To, err)
				}
			}
		}()
	}

	for _, msg := range messages {
		select {
		case jobs <- msg:
		case <-ctx.Done():
		}
	}
	close(jobs)
	wg.Wait()
	close(errs)

	var failures []error
	for err := range errs {
		failures = append(failures, err)
	}
	return failures
}

Keep the worker count near the provider's documented send rate. Going faster does not send faster — it gets you rate limited, and the retries make it slower than the bounded version.

With net/smtp

auth := smtp.PlainAuth("", user, pass, "smtp.pigeonify.com")

msg := []byte("To: customer@example.com\r\n" +
	"From: you@yourdomain.com\r\n" +
	"Subject: Welcome aboard\r\n" +
	"MIME-Version: 1.0\r\n" +
	"Content-Type: text/html; charset=UTF-8\r\n\r\n" +
	"<p>Thanks for signing up.</p>\r\n")

err := smtp.SendMail("smtp.pigeonify.com:587", auth, "you@yourdomain.com",
	[]string{"customer@example.com"}, msg)

Note the \r\n line endings and the blank line between headers and body — SMTP requires both, and getting either wrong produces a message that looks fine locally and malformed in an inbox. Building MIME by hand is also how attachments become painful. This is why most Go projects reach for an API.

What to get right before production

Do not send from a request handler and wait. An HTTP call to any email provider can take a second or more, and a slow provider becomes a slow checkout page. Queue the send and return.

Handle the failure. A send can fail because the address is wrong, because you are rate limited, or because the provider is having a bad minute. The first is permanent and the other two are not — retrying a dead address forever is how a sender's reputation degrades. See how to handle bounces.

Use a subdomain. Send transactional mail from mail.yourdomain.com rather than the domain that carries your business correspondence. If sending reputation is ever damaged, the damage stays contained.

Never send to an address that has bounced or unsubscribed. Suppression should be automatic, not a thing you remember. Pigeonify suppresses permanent bounces and complaints for you and refuses to send to them afterwards.

Where to go next

If you are weighing providers, we publish honest comparisons that say where the other product is better.

Send email that actually arrives

Authentication, suppression and bounce handling built in. Start free, no card required.

Start free