Back to Blog

How to Send Emails in Go (Golang, 2026 Guide)

18 min read

Most "how to send email in Go" tutorials show net/smtp with a Gmail account. That's fine for a throwaway script. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences to real users who expect emails to actually arrive.

This guide covers the full picture: picking a provider, building an email client with Go's standard library, building HTML templates with html/template, sending emails in the background with goroutines, handling Stripe webhooks with crypto/hmac, and shipping to production. If you're using a different stack, we also have guides for Rust and Node.js. All code uses Go idioms — interfaces, error wrapping, context propagation.

Pick an Email Provider

You have three solid options. Each code example below lets you switch between them.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. Native Stripe integration and built-in retries.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.

Initialize Your Module

mkdir send-email-app && cd send-email-app
go mod init yourapp

No third-party packages needed for email sending. Go's net/http is all you need to call any email API. Add a router if you want:

# Optional — for HTTP routing
go get github.com/gin-gonic/gin

Set your API key:

export SEQUENZY_API_KEY=sq_your_api_key_here

Build the Email Client

Go's standard net/http is all you need. Create a client with proper timeouts, error types, and context support:

email/client.go
package email

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

// Client sends emails through the Sequenzy API.
type Client struct {
	apiKey     string
	httpClient *http.Client
	baseURL    string
}

// NewClient creates an email client. Reads SEQUENZY_API_KEY from env.
func NewClient() *Client {
	return &Client{
		apiKey: os.Getenv("SEQUENZY_API_KEY"),
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
		baseURL: "https://api.sequenzy.com/v1",
	}
}

type SendParams struct {
	To      string `json:"to"`
	Subject string `json:"subject"`
	Body    string `json:"body"`
}

type SendResult struct {
	JobID string `json:"jobId"`
}

func (c *Client) Send(ctx context.Context, params SendParams) (*SendResult, error) {
	payload, err := json.Marshal(params)
	if err != nil {
		return nil, fmt.Errorf("email: marshal: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, "POST",
		c.baseURL+"/transactional/send",
		bytes.NewReader(payload))
	if err != nil {
		return nil, fmt.Errorf("email: new request: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

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

	if resp.StatusCode >= 400 {
		body, _ := io.ReadAll(resp.Body)
		return nil, &SendError{
			Status:    resp.StatusCode,
			Message:   string(body),
			Retryable: resp.StatusCode == 429 || resp.StatusCode >= 500,
		}
	}

	var result SendResult
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("email: decode: %w", err)
	}
	return &result, nil
}

// SendError represents an email API error.
type SendError struct {
	Status    int
	Message   string
	Retryable bool
}

func (e *SendError) Error() string {
	return fmt.Sprintf("email API error %d: %s", e.Status, e.Message)
}
email/client.go
package email

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

type Client struct {
	apiKey     string
	fromEmail  string
	httpClient *http.Client
	baseURL    string
}

func NewClient() *Client {
	return &Client{
		apiKey:    os.Getenv("RESEND_API_KEY"),
		fromEmail: "Your App <noreply@yourdomain.com>",
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
		baseURL: "https://api.resend.com",
	}
}

type SendParams struct {
	To      string
	Subject string
	HTML    string
}

type sendPayload struct {
	From    string `json:"from"`
	To      string `json:"to"`
	Subject string `json:"subject"`
	HTML    string `json:"html"`
}

type SendResult struct {
	ID string `json:"id"`
}

func (c *Client) Send(ctx context.Context, params SendParams) (*SendResult, error) {
	payload, err := json.Marshal(sendPayload{
		From:    c.fromEmail,
		To:      params.To,
		Subject: params.Subject,
		HTML:    params.HTML,
	})
	if err != nil {
		return nil, fmt.Errorf("email: marshal: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, "POST",
		c.baseURL+"/emails",
		bytes.NewReader(payload))
	if err != nil {
		return nil, fmt.Errorf("email: new request: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

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

	if resp.StatusCode >= 400 {
		body, _ := io.ReadAll(resp.Body)
		return nil, &SendError{
			Status:    resp.StatusCode,
			Message:   string(body),
			Retryable: resp.StatusCode == 429 || resp.StatusCode >= 500,
		}
	}

	var result SendResult
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("email: decode: %w", err)
	}
	return &result, nil
}

type SendError struct {
	Status    int
	Message   string
	Retryable bool
}

func (e *SendError) Error() string {
	return fmt.Sprintf("email API error %d: %s", e.Status, e.Message)
}
email/client.go
package email

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

type Client struct {
	apiKey     string
	fromEmail  string
	httpClient *http.Client
	baseURL    string
}

func NewClient() *Client {
	return &Client{
		apiKey:    os.Getenv("SENDGRID_API_KEY"),
		fromEmail: "noreply@yourdomain.com",
		httpClient: &http.Client{
			Timeout: 30 * time.Second,
		},
		baseURL: "https://api.sendgrid.com/v3",
	}
}

type SendParams struct {
	To      string
	Subject string
	HTML    string
}

type sendPayload struct {
	Personalizations []personalization `json:"personalizations"`
	From             emailAddr         `json:"from"`
	Subject          string            `json:"subject"`
	Content          []content         `json:"content"`
}

type personalization struct {
	To []emailAddr `json:"to"`
}

type emailAddr struct {
	Email string `json:"email"`
}

type content struct {
	Type  string `json:"type"`
	Value string `json:"value"`
}

func (c *Client) Send(ctx context.Context, params SendParams) error {
	payload, err := json.Marshal(sendPayload{
		Personalizations: []personalization{{To: []emailAddr{{Email: params.To}}}},
		From:             emailAddr{Email: c.fromEmail},
		Subject:          params.Subject,
		Content:          []content{{Type: "text/html", Value: params.HTML}},
	})
	if err != nil {
		return fmt.Errorf("email: marshal: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, "POST",
		c.baseURL+"/mail/send",
		bytes.NewReader(payload))
	if err != nil {
		return fmt.Errorf("email: new request: %w", err)
	}

	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

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

	if resp.StatusCode >= 400 {
		body, _ := io.ReadAll(resp.Body)
		return &SendError{
			Status:    resp.StatusCode,
			Message:   string(body),
			Retryable: resp.StatusCode == 429 || resp.StatusCode >= 500,
		}
	}

	return nil
}

type SendError struct {
	Status    int
	Message   string
	Retryable bool
}

func (e *SendError) Error() string {
	return fmt.Sprintf("email API error %d: %s", e.Status, e.Message)
}

Key Go patterns here:

  1. context.Context: Every Send call takes a context. If the HTTP request is cancelled, the email send is cancelled too.
  2. http.NewRequestWithContext: Propagates the context to the HTTP client.
  3. Custom SendError: Implements the error interface with a Retryable field for retry logic.
  4. Shared http.Client: Go's HTTP client reuses connections by default. Create one and share it.

Send Your First Email

main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"yourapp/email"
)

var emailClient = email.NewClient()

type WelcomeRequest struct {
	Email string `json:"email"`
	Name  string `json:"name"`
}

func sendWelcomeHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req WelcomeRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	if req.Email == "" || req.Name == "" {
		http.Error(w, "email and name required", http.StatusBadRequest)
		return
	}

	result, err := emailClient.Send(r.Context(), email.SendParams{
		To:      req.Email,
		Subject: "Welcome, " + req.Name,
		Body:    "<h1>Welcome, " + req.Name + "</h1><p>Your account is ready.</p>",
	})
	if err != nil {
		log.Printf("email send failed: %v", err)
		http.Error(w, "Failed to send email", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"jobId": result.JobID})
}

func main() {
	http.HandleFunc("/api/send", sendWelcomeHandler)
	log.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"yourapp/email"
)

var emailClient = email.NewClient()

type WelcomeRequest struct {
	Email string `json:"email"`
	Name  string `json:"name"`
}

func sendWelcomeHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req WelcomeRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	if req.Email == "" || req.Name == "" {
		http.Error(w, "email and name required", http.StatusBadRequest)
		return
	}

	result, err := emailClient.Send(r.Context(), email.SendParams{
		To:      req.Email,
		Subject: "Welcome, " + req.Name,
		HTML:    "<h1>Welcome, " + req.Name + "</h1><p>Your account is ready.</p>",
	})
	if err != nil {
		log.Printf("email send failed: %v", err)
		http.Error(w, "Failed to send email", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"id": result.ID})
}

func main() {
	http.HandleFunc("/api/send", sendWelcomeHandler)
	log.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"yourapp/email"
)

var emailClient = email.NewClient()

type WelcomeRequest struct {
	Email string `json:"email"`
	Name  string `json:"name"`
}

func sendWelcomeHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req WelcomeRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	if req.Email == "" || req.Name == "" {
		http.Error(w, "email and name required", http.StatusBadRequest)
		return
	}

	err := emailClient.Send(r.Context(), email.SendParams{
		To:      req.Email,
		Subject: "Welcome, " + req.Name,
		HTML:    "<h1>Welcome, " + req.Name + "</h1><p>Your account is ready.</p>",
	})
	if err != nil {
		log.Printf("email send failed: %v", err)
		http.Error(w, "Failed to send email", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]bool{"sent": true})
}

func main() {
	http.HandleFunc("/api/send", sendWelcomeHandler)
	log.Println("Server starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Test it:

go run main.go
curl -X POST http://localhost:8080/api/send \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "name": "Alice"}'

Notice we pass r.Context() to the email client. This means if the client disconnects before the email is sent, the HTTP request to the email API is also cancelled. No wasted resources.

Build Email Templates with html/template

Go's html/template package auto-escapes values and supports template composition:

// email/templates.go
package email
 
import (
	"bytes"
	"html/template"
	"time"
)
 
var templates = template.Must(template.ParseGlob("templates/emails/*.html"))
 
func RenderTemplate(name string, data any) (string, error) {
	var buf bytes.Buffer
	if err := templates.ExecuteTemplate(&buf, name, data); err != nil {
		return "", err
	}
	return buf.String(), nil
}
 
type WelcomeData struct {
	Name     string
	LoginURL string
	Year     int
}
 
func NewWelcomeData(name, loginURL string) WelcomeData {
	return WelcomeData{
		Name:     name,
		LoginURL: loginURL,
		Year:     time.Now().Year(),
	}
}
<!-- templates/emails/base.html -->
{{define "base"}}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
  <div style="max-width:480px;margin:40px auto;background:#fff;padding:40px;border-radius:8px;">
    {{template "content" .}}
    <hr style="border:none;border-top:1px solid #e5e7eb;margin:32px 0 16px;" />
    <p style="font-size:12px;color:#9ca3af;">
      &copy; {{.Year}} Your App. All rights reserved.
    </p>
  </div>
</body>
</html>
{{end}}
<!-- templates/emails/welcome.html -->
{{define "content"}}
<h1 style="font-size:24px;margin-bottom:16px;">Welcome, {{.Name}}</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
  Your account is ready. You can log in and start exploring.
</p>
<a href="{{.LoginURL}}"
   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;margin-top:16px;">
  Go to Dashboard
</a>
{{end}}

Use it:

html, err := email.RenderTemplate("base", email.NewWelcomeData("Alice", "https://app.yoursite.com"))
if err != nil {
    log.Printf("template render failed: %v", err)
    return
}
 
emailClient.Send(ctx, email.SendParams{
    To:      "alice@example.com",
    Subject: "Welcome, Alice",
    Body:    html,
})

Embed Templates in the Binary

Use Go's embed package to bundle templates in your binary. No need to deploy template files separately:

package email
 
import (
	"embed"
	"html/template"
)
 
//go:embed templates/emails/*.html
var templateFS embed.FS
 
var templates = template.Must(template.ParseFS(templateFS, "templates/emails/*.html"))

Background Sending with Goroutines

Go's goroutines and channels are perfect for background email processing:

// email/worker.go
package email
 
import (
	"context"
	"log"
	"sync"
)
 
type Job struct {
	Params SendParams
}
 
type Worker struct {
	client  *Client
	jobs    chan Job
	wg      sync.WaitGroup
}
 
func NewWorker(client *Client, bufferSize, concurrency int) *Worker {
	w := &Worker{
		client: client,
		jobs:   make(chan Job, bufferSize),
	}
 
	for i := 0; i < concurrency; i++ {
		w.wg.Add(1)
		go w.process()
	}
 
	return w
}
 
func (w *Worker) process() {
	defer w.wg.Done()
	for job := range w.jobs {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		_, err := w.client.Send(ctx, job.Params)
		cancel()
		if err != nil {
			log.Printf("email to %s failed: %v", job.Params.To, err)
		}
	}
}
 
// Enqueue adds a job to the queue. Non-blocking if buffer has space.
func (w *Worker) Enqueue(job Job) {
	w.jobs <- job
}
 
// Shutdown closes the queue and waits for all workers to finish.
func (w *Worker) Shutdown() {
	close(w.jobs)
	w.wg.Wait()
}

Use it in your handlers:

// main.go
var worker *email.Worker
 
func main() {
    emailClient := email.NewClient()
    worker = email.NewWorker(emailClient, 100, 5) // buffer 100, 5 concurrent workers
 
    http.HandleFunc("/api/signup", signupHandler)
 
    // Graceful shutdown
    srv := &http.Server{Addr: ":8080"}
 
    go func() {
        sigCh := make(chan os.Signal, 1)
        signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
        <-sigCh
 
        log.Println("Shutting down...")
        srv.Shutdown(context.Background())
        worker.Shutdown() // Wait for queued emails to finish
    }()
 
    log.Fatal(srv.ListenAndServe())
}
 
func signupHandler(w http.ResponseWriter, r *http.Request) {
    // Create user...
 
    // Queue welcome email — returns immediately
    worker.Enqueue(email.Job{
        Params: email.SendParams{
            To:      user.Email,
            Subject: "Welcome, " + user.Name,
            Body:    html,
        },
    })
 
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"message": "Account created"})
}

The sync.WaitGroup ensures all queued emails are sent before the process exits. The signal.Notify catches SIGINT/SIGTERM for clean shutdowns.

Common Email Patterns for SaaS

Password Reset

// email/patterns.go
package email
 
import "context"
 
func (c *Client) SendPasswordReset(ctx context.Context, to, resetToken string) error {
	resetURL := "https://yourapp.com/reset-password?token=" + resetToken
 
	html, err := RenderTemplate("base", struct {
		ResetURL string
		Year     int
	}{resetURL, time.Now().Year()})
	if err != nil {
		return err
	}
 
	_, err = c.Send(ctx, SendParams{
		To:      to,
		Subject: "Reset your password",
		Body:    html,
	})
	return err
}

Payment Receipt

func (c *Client) SendReceipt(ctx context.Context, to string, amount int, plan, invoiceURL string) error {
	formatted := fmt.Sprintf("$%.2f", float64(amount)/100)
 
	html, err := RenderTemplate("base", struct {
		Amount     string
		Plan       string
		InvoiceURL string
		Year       int
	}{formatted, plan, invoiceURL, time.Now().Year()})
	if err != nil {
		return err
	}
 
	_, err = c.Send(ctx, SendParams{
		To:      to,
		Subject: "Payment receipt - " + formatted,
		Body:    html,
	})
	return err
}

Stripe Webhook Handler

Go's crypto/hmac makes Stripe signature verification straightforward:

handlers/stripe.go
package handlers

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
	"yourapp/email"
)

func verifyStripeSignature(payload []byte, signature, secret string) bool {
	parts := make(map[string]string)
	for _, part := range strings.Split(signature, ",") {
		kv := strings.SplitN(part, "=", 2)
		if len(kv) == 2 {
			parts[kv[0]] = kv[1]
		}
	}

	timestamp, ok := parts["t"]
	if !ok {
		return false
	}
	expectedSig, ok := parts["v1"]
	if !ok {
		return false
	}

	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}

	// Check timestamp is within 5 minutes
	if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return false
	}

	signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(signedPayload))
	computed := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(computed), []byte(expectedSig))
}

func StripeWebhookHandler(emailClient *email.Client) http.HandlerFunc {
	secret := os.Getenv("STRIPE_WEBHOOK_SECRET")

	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "Bad request", http.StatusBadRequest)
			return
		}

		signature := r.Header.Get("Stripe-Signature")
		if !verifyStripeSignature(body, signature, secret) {
			http.Error(w, "Invalid signature", http.StatusBadRequest)
			return
		}

		var event struct {
			Type string `json:"type"`
			Data struct {
				Object json.RawMessage `json:"object"`
			} `json:"data"`
		}
		if err := json.Unmarshal(body, &event); err != nil {
			http.Error(w, "Invalid JSON", http.StatusBadRequest)
			return
		}

		ctx := r.Context()

		switch event.Type {
		case "checkout.session.completed":
			var session struct {
				CustomerEmail string `json:"customer_email"`
				AmountTotal   int    `json:"amount_total"`
				Metadata      struct {
					Plan string `json:"plan"`
				} `json:"metadata"`
			}
			json.Unmarshal(event.Data.Object, &session)

			if err := emailClient.SendReceipt(ctx, session.CustomerEmail,
				session.AmountTotal, session.Metadata.Plan, "https://yourapp.com/billing"); err != nil {
				log.Printf("receipt email failed: %v", err)
			}

		case "invoice.payment_failed":
			var invoice struct {
				CustomerEmail string `json:"customer_email"`
			}
			json.Unmarshal(event.Data.Object, &invoice)

			emailClient.Send(ctx, email.SendParams{
				To:      invoice.CustomerEmail,
				Subject: "Payment failed - action needed",
				Body: `<h2>Payment Failed</h2>
					<p>We couldn't process your payment. Please update your billing info.</p>
					<a href="https://yourapp.com/billing"
					   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
					  Update Billing
					</a>`,
			})
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]bool{"received": true})
	}
}
handlers/stripe.go
package handlers

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
	"yourapp/email"
)

func verifyStripeSignature(payload []byte, signature, secret string) bool {
	parts := make(map[string]string)
	for _, part := range strings.Split(signature, ",") {
		kv := strings.SplitN(part, "=", 2)
		if len(kv) == 2 {
			parts[kv[0]] = kv[1]
		}
	}

	timestamp, ok := parts["t"]
	if !ok {
		return false
	}
	expectedSig, ok := parts["v1"]
	if !ok {
		return false
	}

	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}

	if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return false
	}

	signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(signedPayload))
	computed := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(computed), []byte(expectedSig))
}

func StripeWebhookHandler(emailClient *email.Client) http.HandlerFunc {
	secret := os.Getenv("STRIPE_WEBHOOK_SECRET")

	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "Bad request", http.StatusBadRequest)
			return
		}

		signature := r.Header.Get("Stripe-Signature")
		if !verifyStripeSignature(body, signature, secret) {
			http.Error(w, "Invalid signature", http.StatusBadRequest)
			return
		}

		var event struct {
			Type string `json:"type"`
			Data struct {
				Object json.RawMessage `json:"object"`
			} `json:"data"`
		}
		json.Unmarshal(body, &event)

		ctx := r.Context()

		switch event.Type {
		case "checkout.session.completed":
			var session struct {
				CustomerEmail string `json:"customer_email"`
				AmountTotal   int    `json:"amount_total"`
				Metadata      struct {
					Plan string `json:"plan"`
				} `json:"metadata"`
			}
			json.Unmarshal(event.Data.Object, &session)

			formatted := fmt.Sprintf("$%.2f", float64(session.AmountTotal)/100)
			emailClient.Send(ctx, email.SendParams{
				To:      session.CustomerEmail,
				Subject: "Payment receipt - " + formatted,
				HTML:    "<h2>Payment Received</h2><p>Thanks for your payment of " + formatted + ".</p>",
			})

		case "invoice.payment_failed":
			var invoice struct {
				CustomerEmail string `json:"customer_email"`
			}
			json.Unmarshal(event.Data.Object, &invoice)

			emailClient.Send(ctx, email.SendParams{
				To:      invoice.CustomerEmail,
				Subject: "Payment failed - action needed",
				HTML: `<h2>Payment Failed</h2>
					<p>We couldn't process your payment. Please update your billing info.</p>
					<a href="https://yourapp.com/billing"
					   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
					  Update Billing
					</a>`,
			})
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]bool{"received": true})
	}
}
handlers/stripe.go
package handlers

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
	"yourapp/email"
)

func verifyStripeSignature(payload []byte, signature, secret string) bool {
	parts := make(map[string]string)
	for _, part := range strings.Split(signature, ",") {
		kv := strings.SplitN(part, "=", 2)
		if len(kv) == 2 {
			parts[kv[0]] = kv[1]
		}
	}

	timestamp, ok := parts["t"]
	if !ok {
		return false
	}
	expectedSig, ok := parts["v1"]
	if !ok {
		return false
	}

	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}

	if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
		return false
	}

	signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(signedPayload))
	computed := hex.EncodeToString(mac.Sum(nil))

	return hmac.Equal([]byte(computed), []byte(expectedSig))
}

func StripeWebhookHandler(emailClient *email.Client) http.HandlerFunc {
	secret := os.Getenv("STRIPE_WEBHOOK_SECRET")

	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "Bad request", http.StatusBadRequest)
			return
		}

		signature := r.Header.Get("Stripe-Signature")
		if !verifyStripeSignature(body, signature, secret) {
			http.Error(w, "Invalid signature", http.StatusBadRequest)
			return
		}

		var event struct {
			Type string `json:"type"`
			Data struct {
				Object json.RawMessage `json:"object"`
			} `json:"data"`
		}
		json.Unmarshal(body, &event)

		ctx := r.Context()

		switch event.Type {
		case "checkout.session.completed":
			var session struct {
				CustomerEmail string `json:"customer_email"`
				AmountTotal   int    `json:"amount_total"`
			}
			json.Unmarshal(event.Data.Object, &session)

			formatted := fmt.Sprintf("$%.2f", float64(session.AmountTotal)/100)
			emailClient.Send(ctx, email.SendParams{
				To:      session.CustomerEmail,
				Subject: "Payment receipt - " + formatted,
				HTML:    "<h2>Payment Received</h2><p>Thanks for your payment of " + formatted + ".</p>",
			})

		case "invoice.payment_failed":
			var invoice struct {
				CustomerEmail string `json:"customer_email"`
			}
			json.Unmarshal(event.Data.Object, &invoice)

			emailClient.Send(ctx, email.SendParams{
				To:      invoice.CustomerEmail,
				Subject: "Payment failed - action needed",
				HTML: `<h2>Payment Failed</h2>
					<p>We couldn't process your payment. Please update your billing info.</p>
					<a href="https://yourapp.com/billing"
					   style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
					  Update Billing
					</a>`,
			})
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]bool{"received": true})
	}
}

Error Handling with Retries

Use the Retryable field from SendError to decide whether to retry:

// email/retry.go
package email
 
import (
	"context"
	"errors"
	"fmt"
	"math"
	"time"
)
 
func (c *Client) SendWithRetry(ctx context.Context, params SendParams, maxRetries int) (*SendResult, error) {
	var lastErr error
 
	for attempt := 0; attempt <= maxRetries; attempt++ {
		result, err := c.Send(ctx, params)
		if err == nil {
			return result, nil
		}
 
		lastErr = err
 
		var sendErr *SendError
		if errors.As(err, &sendErr) && !sendErr.Retryable {
			return nil, err // Don't retry auth errors or validation errors
		}
 
		if attempt < maxRetries {
			delay := time.Duration(math.Pow(2, float64(attempt))) * time.Second
			select {
			case <-time.After(delay):
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		}
	}
 
	return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
}

The select on ctx.Done() lets the retry loop be cancelled if the context expires. Standard Go pattern.

Using with Gin

If you prefer Gin over the standard library:

package main
 
import (
	"net/http"
	"yourapp/email"
	"yourapp/handlers"
 
	"github.com/gin-gonic/gin"
)
 
func main() {
	emailClient := email.NewClient()
	r := gin.Default()
 
	r.POST("/api/send-welcome", func(c *gin.Context) {
		var req struct {
			Email string `json:"email" binding:"required,email"`
			Name  string `json:"name" binding:"required"`
		}
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}
 
		result, err := emailClient.Send(c.Request.Context(), email.SendParams{
			To:      req.Email,
			Subject: "Welcome, " + req.Name,
			Body:    "<h1>Welcome, " + req.Name + "</h1><p>Your account is ready.</p>",
		})
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send"})
			return
		}
 
		c.JSON(http.StatusOK, gin.H{"jobId": result.JobID})
	})
 
	r.POST("/api/webhooks/stripe", gin.WrapF(handlers.StripeWebhookHandler(emailClient)))
 
	r.Run(":8080")
}

Gin's binding:"required,email" tags give you input validation for free.

Going to Production

1. Verify Your Domain

Every email provider requires domain verification. Add SPF, DKIM, and DMARC DNS records. Without this, your emails go straight to spam. Our email authentication guide walks through the full setup.

2. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. Protects your main domain's reputation.

3. Graceful Shutdown

Always drain email workers before shutting down:

srv := &http.Server{Addr: ":8080"}
 
go func() {
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh
 
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
 
    srv.Shutdown(ctx) // Stop accepting new requests
    worker.Shutdown() // Wait for queued emails
}()

4. Structured Logging

Use log/slog (Go 1.21+) for structured logging:

import "log/slog"
 
slog.Info("email sent", "to", params.To, "subject", params.Subject)
slog.Error("email failed", "to", params.To, "error", err)

5. Health Check

http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off transactional emails. You'll want to send onboarding sequences, run marketing campaigns, automate lifecycle emails, and track engagement.

Sequenzy handles both transactional and marketing from one platform. Same API, same dashboard. You get transactional sends, campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS.

Here's subscriber management from Go:

// Add a subscriber
c.httpClient.Post("https://api.sequenzy.com/v1/subscribers", "application/json",
    bytes.NewReader(mustJSON(map[string]any{
        "email":     "user@example.com",
        "firstName": "Jane",
        "tags":      []string{"signed-up"},
    })))
 
// Track events to trigger sequences
c.httpClient.Post("https://api.sequenzy.com/v1/subscribers/events", "application/json",
    bytes.NewReader(mustJSON(map[string]any{
        "email": "user@example.com",
        "event": "onboarding.completed",
    })))

FAQ

Should I use net/smtp or an email API?

Use an email API. net/smtp gives you raw SMTP, which means you handle TLS negotiation, MIME encoding, deliverability (SPF/DKIM/DMARC), bounce processing, and retry logic yourself. Email APIs handle all of this. The only reason to use net/smtp is if you have an internal SMTP server you're required to use.

Do I need a framework like Gin or Echo?

No. Go's standard library net/http is production-ready. Gin/Echo add convenience (binding, validation, middleware) but aren't required. The email client code works with any HTTP framework.

Why pass context.Context to every Send call?

Context propagation is idiomatic Go. It lets you: cancel email sends when the HTTP request is cancelled, set timeouts on individual sends, propagate tracing information, and gracefully handle shutdowns. Without context, a slow email API call blocks your handler indefinitely.

How do I test email sending?

Use an interface:

type EmailSender interface {
    Send(ctx context.Context, params SendParams) (*SendResult, error)
}

In tests, use a mock implementation:

type mockEmailSender struct {
    calls []SendParams
}
 
func (m *mockEmailSender) Send(ctx context.Context, p SendParams) (*SendResult, error) {
    m.calls = append(m.calls, p)
    return &SendResult{JobID: "test-123"}, nil
}

How do I handle email bounces?

Set up a webhook endpoint to receive delivery notifications from your provider. Parse the webhook, mark bounced addresses in your database, and stop sending to them.

Is goroutine-based email sending reliable?

For most apps, yes. But goroutine email queues don't survive process restarts — if your server crashes, queued emails are lost. For critical emails (password resets, receipts), send synchronously or use a persistent queue like Redis/NATS. For non-critical emails (welcome messages, notifications), goroutines are fine.

How do I send to multiple recipients?

Send one email per recipient for transactional emails. For batch sends, use a goroutine pool or your provider's batch API. Never put multiple recipients in the to field — they'll all see each other's addresses.

What about Go email libraries like gomail or jordan-wright/email?

These are SMTP wrappers. They add a nicer API over net/smtp but still require you to manage an SMTP server. For production email, HTTP-based providers are simpler and more reliable.

How do I embed templates in my binary?

Use Go's embed package (Go 1.16+). See the "Embed Templates in the Binary" section above. This eliminates the need to deploy template files alongside your binary.

Wrapping Up

Here's what we covered:

  1. Email client with net/http, context support, and custom error types
  2. html/template with composition for type-safe email templates
  3. embed for bundling templates in your binary
  4. Goroutine workers with channels for background sends
  5. Graceful shutdown with sync.WaitGroup and signal handling
  6. Retry logic that respects the Retryable error field and context cancellation
  7. Stripe webhooks with crypto/hmac signature verification
  8. Gin integration with struct tag validation
  9. Production checklist: domain verification, structured logging, health checks

The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start sending.

Frequently Asked Questions

Should I use Go's net/smtp package or a dedicated email API?

Use a dedicated email API. Go's net/smtp package is low-level, requiring you to format MIME messages, manage TLS connections, and handle delivery yourself. Email provider APIs give you deliverability tracking, retries, and analytics through simple HTTP calls.

How do I send emails concurrently in Go?

Use goroutines with a channel-based worker pool to control concurrency. Launch a fixed number of worker goroutines that read email jobs from a channel. This prevents overwhelming your email provider's API while maximizing throughput.

How do I handle email sending errors in Go?

Return errors from your email-sending function and handle them at the call site. Use fmt.Errorf with %w for error wrapping so callers can inspect specific error types. For retries, implement exponential backoff with a maximum retry count.

What's the best way to structure email code in a Go project?

Create an email package with an EmailService struct that holds the API client and configuration. Define a Sender interface for testability. Keep email templates in an embed.FS so they're compiled into the binary.

How do I use HTML templates for emails in Go?

Use html/template from the standard library. Parse template files at startup with template.ParseFS() using embed.FS. Execute templates with dynamic data to produce HTML strings. Go's template engine supports inheritance, partials, and custom functions.

How do I test email sending in Go?

Define a Sender interface with a Send method. Create a mock implementation for tests that captures sent emails in a slice. Assert on the captured emails' fields. This is idiomatic Go testing—interfaces enable easy mocking without external libraries.

How do I store email API keys in Go?

Use environment variables read with os.Getenv(). For local development, use a .env file loaded with godotenv. In production, use your platform's secrets management (Kubernetes secrets, AWS Secrets Manager). Never commit keys to source control.

How do I send emails in the background from an HTTP handler in Go?

Pass the email job to a buffered channel and process it in a background goroutine. Return the HTTP response immediately. Use context.Background() for the background task since the request context gets canceled after the response. Add graceful shutdown to drain the channel.

Can I use Go's embed package for email templates?

Yes, and you should. Embed templates with //go:embed templates/* so they're included in the compiled binary. This eliminates runtime file system dependencies and works perfectly in containers. Parse embedded templates once at startup for efficiency.

How do I implement rate limiting for email sends in Go?

Use golang.org/x/time/rate to create a rate limiter. Call limiter.Wait(ctx) before each email send to respect your provider's rate limits. For HTTP endpoint rate limiting, use middleware that tracks requests per IP using a sync.Map or Redis.