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

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 yourappNo 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/ginSet your API key:
export SEQUENZY_API_KEY=sq_your_api_key_hereBuild the Email Client
Go's standard net/http is all you need. Create a client with proper timeouts, error types, and context support:
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)
}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)
}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:
context.Context: EverySendcall takes a context. If the HTTP request is cancelled, the email send is cancelled too.http.NewRequestWithContext: Propagates the context to the HTTP client.- Custom
SendError: Implements theerrorinterface with aRetryablefield for retry logic. - Shared
http.Client: Go's HTTP client reuses connections by default. Create one and share it.
Send Your First Email
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))
}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))
}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;">
© {{.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:
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})
}
}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})
}
}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:
- Email client with
net/http, context support, and custom error types - html/template with composition for type-safe email templates
embedfor bundling templates in your binary- Goroutine workers with channels for background sends
- Graceful shutdown with
sync.WaitGroupand signal handling - Retry logic that respects the
Retryableerror field and context cancellation - Stripe webhooks with
crypto/hmacsignature verification - Gin integration with struct tag validation
- 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.