Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 097 / 100

Sending email from Go: a practical guide for developers

Transactional email done right.

Expona Admin11 min read

Introduction

Email is one of those features that looks simple until it breaks in production. You send a password reset link and it ends up in spam; you batch notifications and your provider throttles the connection; a customer complains their receipt never arrived, and you have no way to trace it. Sending email from Go: a practical guide for developers walks through the patterns we've used across e-commerce, SaaS, and ride-hailing platforms to make transactional email reliable, debuggable, and fast.

Unlike hobby email scripts, production email needs resilience. This guide covers the real decisions: choosing a provider, moving sends off the critical path with background workers, templating emails for multiple formats, and tracking delivery with webhooks. By the end, you'll have patterns you can use in a live system today.

Use a provider API like SES

Your first choice is whether to send directly or via a managed provider. Direct SMTP is tempting—no extra cost, no vendor lock-in—but it's a false economy. You become responsible for bounce handling, rate limiting, IP warming, and proving you're not a spammer. Even with perfect setup, a single complaint can tank your sending reputation.

Amazon SES, SendGrid, or Postmark abstract these problems. They handle deliverability, give you detailed metrics, and let you focus on application logic. SES is especially practical in AWS environments: it's cheap, integrates cleanly with Go, and you don't pay for failed sends.

Here's a minimal setup using the AWS SDK v2 for Go:

go
package email

import (
	"context"
	"fmt"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/sesv2"
	"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
)

type EmailSender struct {
	client *sesv2.Client
}

func NewEmailSender(ctx context.Context) (*EmailSender, error) {
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		return nil, err
	}
	return &EmailSender{
		client: sesv2.NewFromConfig(cfg),
	}, nil
}

func (es *EmailSender) Send(ctx context.Context, to, subject, body string) error {
	input := &sesv2.SendEmailInput{
		FromEmailAddress: "noreply@example.com",
		Destination: &types.Destination{
			ToAddresses: []string{to},
		},
		Content: &types.EmailContent{
			Simple: &types.Message{
				Subject: &types.Content{
					Data: subject,
				},
				Body: &types.Body{
					Text: &types.Content{
						Data: body,
					},
				},
			},
		},
	}

	result, err := es.client.SendEmail(ctx, input)
	if err != nil {
		return fmt.Errorf("send failed: %w", err)
	}

	fmt.Printf("Message ID: %s\n", *result.MessageId)
	return nil
}

This is production-ready for simple text emails. The key decisions: use SESv2 (not the older SESV1 API), keep the sender address consistent, and capture the MessageId for logging. Store that ID in your database—you'll need it to correlate webhook events later.

Send in background workers

Never send email synchronously in a request handler. A slow provider response or network hiccup blocks your user. Worse, your retries compound the problem. Instead, queue the email and process it asynchronously.

For small to medium systems, an in-memory queue with goroutines works well. For scale, use a persistent queue like Redis or RabbitMQ. We'll show the simpler pattern here, but the principle stays the same:

go
package worker

import (
	"context"
	"fmt"
	"sync"
	"time"
)

type EmailJob struct {
	To      string
	Subject string
	Body    string
	RetryCount int
}

type EmailWorkerPool struct {
	queue   chan *EmailJob
	sender  *email.EmailSender
	wg      sync.WaitGroup
}

func NewEmailWorkerPool(sender *email.EmailSender, workers int) *EmailWorkerPool {
	pool := &EmailWorkerPool{
		queue:  make(chan *EmailJob, 100),
		sender: sender,
	}

	for i := 0; i < workers; i++ {
		pool.wg.Add(1)
		go pool.work()
	}

	return pool
}

func (p *EmailWorkerPool) work() {
	defer p.wg.Done()
	for job := range p.queue {
		p.processJob(job)
	}
}

func (p *EmailWorkerPool) processJob(job *EmailJob) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	err := p.sender.Send(ctx, job.To, job.Subject, job.Body)
	if err != nil {
		if job.RetryCount < 3 {
			job.RetryCount++
			time.Sleep(time.Duration(job.RetryCount*2) * time.Second)
			p.queue <- job
		} else {
			fmt.Printf("Failed to send to %s after retries: %v\n", job.To, err)
		}
	}
}

func (p *EmailWorkerPool) Enqueue(job *EmailJob) {
	p.queue <- job
}

func (p *EmailWorkerPool) Shutdown(ctx context.Context) error {
	close(p.queue)
	done := make(chan struct{})
	go func() {
		p.wg.Wait()
		close(done)
	}()

	select {
	case <-done:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

Now in your handler, enqueue instead of blocking:

go
func HandlePasswordReset(w http.ResponseWriter, r *http.Request) {
	// ... validate request ...

	pool.Enqueue(&worker.EmailJob{
		To:      user.Email,
		Subject: "Reset your password",
		Body:    fmt.Sprintf("Reset link: %s", resetURL),
	})

	w.WriteHeader(http.StatusAccepted)
	json.NewEncoder(w).Encode(map[string]string{"status": "processing"})
}

This returns immediately. Your workers handle the actual send. On shutdown, gracefully drain the queue so in-flight jobs complete. In production, add logging and metrics—track queue depth, send latency, and error rates.

Templates for HTML and text

Most platforms expect both HTML and plain text versions of every email. Text handles old clients and accessibility; HTML handles branding. Don't hand-craft these—use Go's text/template and html/template packages.

Create a template file structure:

go
// templates/email/password_reset.txt
Hi {{ .Name }},

Click this link to reset your password:
{{ .ResetLink }}

Link expires in 1 hour.

---
{{ .AppName }} Team

// templates/email/password_reset.html
<html>
<body style="font-family: sans-serif;">
  <h2>Hi {{ .Name }},</h2>
  <p><a href="{{ .ResetLink }}">Click here to reset your password</a></p>
  <p>Link expires in 1 hour.</p>
  <hr />
  <p>{{ .AppName }} Team</p>
</body>
</html>

And a template manager:

go
package template

import (
	"bytes"
	"embed"
	"text/template"
	"html/template"
)

//go:embed templates/email/*
var emailTemplates embed.FS

type EmailTemplate struct {
	TextTmpl *template.Template
	HTMLTmpl *template.Template
}

type TemplateEngine struct {
	templates map[string]*EmailTemplate
}

func NewTemplateEngine() (*TemplateEngine, error) {
	engine := &TemplateEngine{
		templates: make(map[string]*EmailTemplate),
	}

	// Load password_reset templates
	txtTmpl, err := template.ParseFS(emailTemplates, "templates/email/password_reset.txt")
	if err != nil {
		return nil, err
	}
	htmlTmpl, err := html.ParseFS(emailTemplates, "templates/email/password_reset.html")
	if err != nil {
		return nil, err
	}

	engine.templates["password_reset"] = &EmailTemplate{
		TextTmpl: txtTmpl,
		HTMLTmpl: htmlTmpl,
	}

	return engine, nil
}

func (e *TemplateEngine) Render(name string, data interface{}) (text, html string, err error) {
	tmpl, ok := e.templates[name]
	if !ok {
		return "", "", fmt.Errorf("template not found: %s", name)
	}

	var textBuf, htmlBuf bytes.Buffer
	if err := tmpl.TextTmpl.Execute(&textBuf, data); err != nil {
		return "", "", err
	}
	if err := tmpl.HTMLTmpl.Execute(&htmlBuf, data); err != nil {
		return "", "", err
	}

	return textBuf.String(), htmlBuf.String(), nil
}

Use it when enqueueing:

go
text, html, err := templateEngine.Render("password_reset", map[string]string{
	"Name":      user.Name,
	"ResetLink": resetLink,
	"AppName":   "Acme",
})

pool.Enqueue(&worker.EmailJob{
	To:       user.Email,
	Subject:  "Reset your password",
	Text:     text,
	HTML:     html,
})

Templates separate concerns and make testing easier. You can render templates without touching the network, and designers can iterate on HTML without touching Go code.

Handle bounces with webhooks

Email delivery is not binary: it's sent, bounced, or complained about. Your provider generates events for these states. Without handling them, you're blind. A customer marks your email as spam, but you keep sending them more. An address bounces hard, and you don't know.

Use webhooks. SES sends HTTP POST requests to your endpoint when delivery status changes. Subscribe to Bounce and Complaint events:

go
package webhook

import (
	"encoding/json"
	"fmt"
	"net/http"
)

type SNSMessage struct {
	Type             string          `json:"Type"`
	Message          string          `json:"Message"`
	MessageId        string          `json:"MessageId"`
	Timestamp        string          `json:"Timestamp"`
	SignatureVersion string          `json:"SignatureVersion"`
	Signature        string          `json:"Signature"`
	SigningCertURL   string          `json:"SigningCertUrl"`
}

type SESEvent struct {
	EventType string `json:"eventType"`
	Bounce    *struct {
		BounceType       string `json:"bounceType"` // Undetermined, Permanent, Transient
		BouncedRecipients []struct {
			EmailAddress string `json:"emailAddress"`
		} `json:"bouncedRecipients"`
	} `json:"bounce"`
	Complaint *struct {
		ComplainedRecipients []struct {
			EmailAddress string `json:"emailAddress"`
		} `json:"complainedRecipients"`
	} `json:"complaint"`
}

func HandleWebhook(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var snsMsg SNSMessage
		if err := json.NewDecoder(r.Body).Decode(&snsMsg); err != nil {
			http.Error(w, "Invalid request", http.StatusBadRequest)
			return
		}

		// SNS sends a SubscriptionConfirmation first—handle it
		if snsMsg.Type == "SubscriptionConfirmation" {
			// In production, verify the signature and then confirm
			fmt.Printf("Subscription confirmation for %s\n", snsMsg.SubscribeURL)
			w.WriteHeader(http.StatusOK)
			return
		}

		if snsMsg.Type != "Notification" {
			w.WriteHeader(http.StatusOK)
			return
		}

		var sesEvent SESEvent
		if err := json.Unmarshal([]byte(snsMsg.Message), &sesEvent); err != nil {
			http.Error(w, "Invalid SES event", http.StatusBadRequest)
			return
		}

		switch sesEvent.EventType {
		case "Bounce":
			if sesEvent.Bounce != nil {
				for _, recipient := range sesEvent.Bounce.BouncedRecipients {
					if sesEvent.Bounce.BounceType == "Permanent" {
						// Mark as unsubscribed or invalid
						_, err := db.Exec("UPDATE users SET bounced = true WHERE email = ?", recipient.EmailAddress)
						if err != nil {
							fmt.Printf("Failed to update bounce: %v\n", err)
						}
					}
				}
			}
		case "Complaint":
			if sesEvent.Complaint != nil {
				for _, recipient := range sesEvent.Complaint.ComplainedRecipients {
					// Mark as complained / unsubscribe
					_, err := db.Exec("UPDATE users SET complained = true WHERE email = ?", recipient.EmailAddress)
					if err != nil {
						fmt.Printf("Failed to update complaint: %v\n", err)
					}
				}
			}
		}

		w.WriteHeader(http.StatusOK)
	}
}

In your send logic, check these flags:

go
func (p *EmailWorkerPool) shouldSend(email string) (bool, error) {
	var bounced, complained bool
	err := db.QueryRow("SELECT bounced, complained FROM users WHERE email = ?", email).Scan(&bounced, &complained)
	if err != nil {
		return false, err
	}
	return !bounced && !complained, nil
}

This prevents wasted sends and protects your sender reputation. In production, verify SNS signatures before processing events—use the certificate URL in the notification to validate authenticity.

Common mistakes

  • Sending synchronously. Even a 500ms provider latency times 1000 concurrent requests becomes a bottleneck. Always queue.
  • No retry logic. Network hiccups happen. Exponential backoff with a reasonable cap (we use 3 retries) recovers most failures without hammering the provider.
  • Ignoring bounces and complaints. Your unsubscribe link is useless if you ignore feedback. One complaint report to ISPs can hurt your sending reputation permanently.
  • Hard-coded templates. Concatenating strings into email bodies is error-prone and painful to maintain. Use templates from day one.
  • No logging of message IDs. When a user says they didn't get your email, without message IDs you can't trace what happened. Log them to your database or external logging system.
  • Mixing text and HTML in SES calls. Ensure both versions are valid UTF-8 and properly escaped. Use the SDK to build the request rather than hand-crafting JSON.

Key takeaways

  • Use a managed email provider like SES instead of direct SMTP. Let them handle deliverability and reputation.
  • Move email sends to background workers. Never block a user request on network I/O.
  • Render both plain text and HTML versions from Go templates. Separate content from presentation.
  • Process bounce and complaint webhooks to keep your sender reputation clean and respect user feedback.
  • Log message IDs so you can trace deliverability issues in production.
  • Add retry logic with exponential backoff, but cap it at a reasonable number of attempts.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles