Back to Blog

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

14 min read

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

email/client.go
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
}
email/client.go
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
}
email/client.go
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

main.go
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")
}
main.go
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")
}
main.go
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_key

3. 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

  1. net/smtp vs API providers and when to use each
  2. Gin routes for HTTP email endpoints
  3. html/template for type-safe email templates
  4. Goroutine workers for background sending
  5. Retry logic for production reliability

Pick your provider, copy the patterns, and start sending.