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

Go's standard library has net/smtp for sending emails. It works, but it's low-level: you handle SMTP handshakes, TLS negotiation, MIME encoding, and connection management yourself.
For production apps, API-based providers are simpler and more reliable. One HTTP call, they handle deliverability. This guide covers both approaches with working examples for Gin, Echo, and plain Go HTTP servers.
net/smtp vs API Providers
// net/smtp: you manage SMTP, TLS, MIME headers
import "net/smtp"
auth := smtp.PlainAuth("", "you@gmail.com", "password", "smtp.gmail.com")
smtp.SendMail("smtp.gmail.com:587", auth, "you@gmail.com",
[]string{"user@example.com"}, []byte(msg))
// API provider: one HTTP call
resp, err := http.Post("https://api.sequenzy.com/v1/transactional/send", ...)Use net/smtp for internal SMTP servers. Use an API provider for everything else.
Pick a Provider
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences from one API. Native Stripe integration.
- Resend is developer-friendly with a clean API. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise option. Good for high volume.
Create an Email Client
package email
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
var client = &http.Client{Timeout: 30 * time.Second}
type SendRequest struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
type SendResponse struct {
JobID string `json:"jobId"`
}
func Send(req SendRequest) (*SendResponse, error) {
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
httpReq, err := http.NewRequest("POST",
"https://api.sequenzy.com/v1/transactional/send",
bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+os.Getenv("SEQUENZY_API_KEY"))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("send: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("email API returned %d", resp.StatusCode)
}
var result SendResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &result, nil
}package email
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
var client = &http.Client{Timeout: 30 * time.Second}
const fromEmail = "Your App <noreply@yourdomain.com>"
type SendRequest 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 SendResponse struct {
ID string `json:"id"`
}
func Send(req SendRequest) (*SendResponse, error) {
payload, _ := json.Marshal(sendPayload{
From: fromEmail, To: req.To,
Subject: req.Subject, HTML: req.HTML,
})
httpReq, _ := http.NewRequest("POST",
"https://api.resend.com/emails",
bytes.NewReader(payload))
httpReq.Header.Set("Authorization", "Bearer "+os.Getenv("RESEND_API_KEY"))
httpReq.Header.Set("Content-Type", "application/json")
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("send: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("resend API returned %d", resp.StatusCode)
}
var result SendResponse
json.NewDecoder(resp.Body).Decode(&result)
return &result, nil
}package email
import (
"fmt"
"os"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
)
const fromEmail = "noreply@yourdomain.com"
type SendRequest struct {
To string
Subject string
HTML string
}
func Send(req SendRequest) error {
from := mail.NewEmail("Your App", fromEmail)
to := mail.NewEmail("", req.To)
message := mail.NewSingleEmail(from, req.Subject, to, "", req.HTML)
client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY"))
resp, err := client.Send(message)
if err != nil {
return fmt.Errorf("send: %w", err)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("sendgrid returned %d: %s", resp.StatusCode, resp.Body)
}
return nil
}Send from a Gin Route
package main
import (
"net/http"
"yourapp/email"
"github.com/gin-gonic/gin"
)
type WelcomeRequest struct {
Email string `json:"email" binding:"required,email"`
Name string `json:"name" binding:"required"`
}
func main() {
r := gin.Default()
r.POST("/api/send-welcome", func(c *gin.Context) {
var req WelcomeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := email.Send(email.SendRequest{
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.Run(":8080")
}package main
import (
"net/http"
"yourapp/email"
"github.com/gin-gonic/gin"
)
type WelcomeRequest struct {
Email string `json:"email" binding:"required,email"`
Name string `json:"name" binding:"required"`
}
func main() {
r := gin.Default()
r.POST("/api/send-welcome", func(c *gin.Context) {
var req WelcomeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
result, err := email.Send(email.SendRequest{
To: req.Email,
Subject: "Welcome, " + req.Name,
HTML: "<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{"id": result.ID})
})
r.Run(":8080")
}package main
import (
"net/http"
"yourapp/email"
"github.com/gin-gonic/gin"
)
type WelcomeRequest struct {
Email string `json:"email" binding:"required,email"`
Name string `json:"name" binding:"required"`
}
func main() {
r := gin.Default()
r.POST("/api/send-welcome", func(c *gin.Context) {
var req WelcomeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := email.Send(email.SendRequest{
To: req.Email,
Subject: "Welcome, " + req.Name,
HTML: "<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{"sent": true})
})
r.Run(":8080")
}HTML Templates with html/template
Go's standard library has a solid HTML template engine.
// email/templates.go
package email
import (
"bytes"
"html/template"
)
var templates = template.Must(template.ParseGlob("templates/emails/*.html"))
type WelcomeData struct {
Name string
LoginURL string
}
func RenderWelcome(data WelcomeData) (string, error) {
var buf bytes.Buffer
if err := templates.ExecuteTemplate(&buf, "welcome.html", data); err != nil {
return "", err
}
return buf.String(), nil
}<!-- templates/emails/welcome.html -->
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; background: #f6f9fc; padding: 40px 0;">
<div style="max-width: 480px; margin: 0 auto; background: #fff; padding: 40px; border-radius: 8px;">
<h1 style="font-size: 24px;">Welcome, {{.Name}}</h1>
<p style="font-size: 16px; line-height: 1.6; color: #374151;">
Your account is ready.
</p>
<a href="{{.LoginURL}}"
style="display:inline-block; background:#f97316; color:#fff; padding:12px 24px; border-radius:6px; text-decoration:none;">
Go to Dashboard
</a>
</div>
</body>
</html>Background Sending with Goroutines
Go makes concurrent email sending easy with goroutines and channels.
// email/worker.go
package email
import "log"
type Job struct {
To string
Subject string
Body string
}
func StartWorker(jobs <-chan Job, concurrency int) {
for i := 0; i < concurrency; i++ {
go func() {
for job := range jobs {
_, err := Send(SendRequest{
To: job.To, Subject: job.Subject, Body: job.Body,
})
if err != nil {
log.Printf("email to %s failed: %v", job.To, err)
}
}
}()
}
}// In your main.go
jobs := make(chan email.Job, 100)
email.StartWorker(jobs, 5) // 5 concurrent senders
// Queue emails from handlers
jobs <- email.Job{
To: user.Email, Subject: "Welcome", Body: html,
}Error Handling with Retries
package email
import (
"fmt"
"math"
"time"
)
func SendWithRetry(req SendRequest, maxRetries int) (*SendResponse, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
result, err := Send(req)
if err == nil {
return result, nil
}
lastErr = err
if attempt < maxRetries {
delay := time.Duration(math.Pow(2, float64(attempt))) * time.Second
time.Sleep(delay)
}
}
return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr)
}Going to Production
1. Verify Your Domain
Add SPF, DKIM, and DMARC DNS records. Required for deliverability.
2. Use Environment Variables
export SEQUENZY_API_KEY=sq_your_key3. Graceful Shutdown
Drain your email worker before shutting down:
func main() {
jobs := make(chan email.Job, 100)
email.StartWorker(jobs, 5)
// ... start server ...
// On shutdown signal
close(jobs) // Workers finish current jobs, then exit
}Beyond Transactional
Sequenzy handles transactional sends, marketing campaigns, automated sequences, and subscriber management from one API. Native Stripe integration for SaaS.
Wrapping Up
- net/smtp vs API providers and when to use each
- Gin routes for HTTP email endpoints
- html/template for type-safe email templates
- Goroutine workers for background sending
- Retry logic for production reliability
Pick your provider, copy the patterns, and start sending.