Developer Tutorial

How to Send Email with Go: net/smtp, gomail, and the API Approach

Covers the standard library's net/smtp for basic sends, gomail for HTML email, and a REST API for production apps that need delivery webhooks.

8 min read·Updated March 2026

Go's concurrency model and static binaries make it ideal for microservices and CLI tools — and all of them eventually need to send email. This guide covers every real approach: the standard library's net/smtp for simple scripts, gomail for HTML email with attachments, and a REST API call for production apps where delivery events actually matter.

1. net/smtp vs gomail vs REST API

Three approaches, three different tradeoffs. The right one depends on what you're building.

net/smtp (standard library)

Connects directly to an SMTP relay. Zero dependencies — it's in the Go standard library. Good for CLI tools, internal notifications, and dev environments.

+ Zero dependencies, ships with Go
+ Works with any SMTP server
– Raw MIME construction is tedious for HTML email
– No delivery events or bounce handling
– SMTP credentials to manage and rotate

gomail (third-party)

A thin wrapper that handles MIME, HTML/text multipart, and attachments cleanly. Still SMTP underneath.

+ Clean API for HTML email and attachments
+ Handles charset encoding and headers correctly
– Still SMTP — same operational issues as net/smtp
– Library is in maintenance mode (no new features)

REST API via net/http (recommended for apps)

POST JSON to an email API. One API key, webhook delivery events, templates, and no SMTP connection state to manage. Works perfectly with Go's native concurrency model.

+ Stateless HTTP — natural fit for goroutines and services
+ Webhooks for bounces, spam complaints, opens, clicks
+ Deliverability managed by provider (warm IPs, feedback loops)
+ Context support for cancellation and timeouts
+ Idempotency keys prevent duplicate sends on retry

2. Sending email with net/smtp

The standard library approach. net/smtp handles the SMTP protocol; you build the message yourself as a raw string:

package main

import (
	"fmt"
	"net/smtp"
	"os"
	"strings"
)

func sendEmail(to, subject, body string) error {
	from := os.Getenv("EMAIL_FROM")
	host := os.Getenv("SMTP_HOST")
	port := os.Getenv("SMTP_PORT") // typically "587"
	user := os.Getenv("SMTP_USER")
	pass := os.Getenv("SMTP_PASSWORD")

	auth := smtp.PlainAuth("", user, pass, host)

	msg := strings.Join([]string{
		fmt.Sprintf("From: %s", from),
		fmt.Sprintf("To: %s", to),
		fmt.Sprintf("Subject: %s", subject),
		"MIME-Version: 1.0",
		"Content-Type: text/plain; charset=UTF-8",
		"",
		body,
	}, "\r\n")

	addr := fmt.Sprintf("%s:%s", host, port)
	return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg))
}

func main() {
	err := sendEmail(
		"[email protected]",
		"Your order shipped",
		"Hi, your order #1234 has shipped. Track it at https://example.com/track/1234",
	)
	if err != nil {
		fmt.Fprintf(os.Stderr, "send failed: %v\n", err)
		os.Exit(1)
	}
	fmt.Println("sent")
}

A few gotchas with net/smtp in production:

  • smtp.SendMail opens a new connection for every call. For high-volume sends, reuse a smtp.Client and call Reset() between messages.
  • Building raw MIME strings is fragile. Headers must use \r\n line endings, not \n. One wrong byte and the message is rejected.
  • HTML email requires Content-Type: multipart/alternative with separate plain-text and HTML parts — cumbersome to build manually. Use gomail instead.

3. HTML email with gomail

gomail wraps the SMTP complexity and handles multipart MIME correctly:

go get gopkg.in/gomail.v2
package email

import (
	"crypto/tls"
	"fmt"
	"os"
	"strconv"

	gomail "gopkg.in/gomail.v2"
)

type Message struct {
	To      string
	Subject string
	HTML    string
	Text    string
}

func Send(m Message) error {
	host := os.Getenv("SMTP_HOST")
	portStr := os.Getenv("SMTP_PORT")
	user := os.Getenv("SMTP_USER")
	pass := os.Getenv("SMTP_PASSWORD")
	from := os.Getenv("EMAIL_FROM")

	port, err := strconv.Atoi(portStr)
	if err != nil {
		return fmt.Errorf("invalid SMTP_PORT: %w", err)
	}

	msg := gomail.NewMessage()
	msg.SetHeader("From", from)
	msg.SetHeader("To", m.To)
	msg.SetHeader("Subject", m.Subject)
	msg.SetBody("text/plain", m.Text)
	msg.AddAlternative("text/html", m.HTML)

	dialer := gomail.NewDialer(host, port, user, pass)
	dialer.TLSConfig = &tls.Config{ServerName: host}

	return dialer.DialAndSend(msg)
}

// Usage
// email.Send(email.Message{
//     To:      "[email protected]",
//     Subject: "Your account is ready",
//     Text:    "Sign in at https://app.example.com",
//     HTML:    "<p>Sign in at <a href='https://app.example.com'>app.example.com</a></p>",
// })

Adding attachments is straightforward with gomail:

msg := gomail.NewMessage()
msg.SetHeader("From", from)
msg.SetHeader("To", "[email protected]")
msg.SetHeader("Subject", "Your invoice")
msg.SetBody("text/plain", "Please find your invoice attached.")
msg.AddAlternative("text/html", "<p>Please find your invoice attached.</p>")
msg.Attach("/tmp/invoice-1234.pdf")

4. Sending email via REST API

No third-party dependencies — Go's standard net/http is all you need. This pattern works perfectly in goroutines and supports context cancellation:

package email

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

var httpClient = &http.Client{Timeout: 10 * time.Second}

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

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

func Send(ctx context.Context, req SendRequest) (SendResponse, error) {
	body, err := json.Marshal(req)
	if err != nil {
		return SendResponse{}, err
	}

	httpReq, err := http.NewRequestWithContext(ctx, "POST",
		"https://api.tinysend.co/v1/emails", bytes.NewReader(body))
	if err != nil {
		return SendResponse{}, err
	}

	httpReq.Header.Set("Authorization", "Bearer "+os.Getenv("TINYSEND_API_KEY"))
	httpReq.Header.Set("Content-Type", "application/json")

	resp, err := httpClient.Do(httpReq)
	if err != nil {
		return SendResponse{}, fmt.Errorf("http request failed: %w", err)
	}
	defer resp.Body.Close()

	respBody, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
		return SendResponse{}, fmt.Errorf("API error %d: %s", resp.StatusCode, respBody)
	}

	var result SendResponse
	if err := json.Unmarshal(respBody, &result); err != nil {
		return SendResponse{}, fmt.Errorf("decode response: %w", err)
	}
	return result, nil
}

// Usage in a handler
func handleRegistration(w http.ResponseWriter, r *http.Request) {
	// ... create user ...

	result, err := email.Send(r.Context(), email.SendRequest{
		From:    os.Getenv("EMAIL_FROM"),
		To:      user.Email,
		Subject: fmt.Sprintf("Welcome, %s!", user.FirstName),
		HTML:    fmt.Sprintf("<p>Hi %s, your account is ready. <a href='https://app.example.com'>Sign in</a></p>", user.FirstName),
		Text:    fmt.Sprintf("Hi %s, sign in at https://app.example.com", user.FirstName),
	})
	if err != nil {
		http.Error(w, "failed to send welcome email", http.StatusInternalServerError)
		return
	}
	_ = result.ID // log or store for debugging
}

Share the http.Client across calls. Creating a new client per request skips connection reuse and adds latency.

Use idempotency keys for critical emails (password resets, receipts). Set an Idempotency-Key header derived from your domain data — if the request is retried after a timeout, the API returns the original send instead of duplicating it:

idempotencyKey := fmt.Sprintf("password-reset-%s-%s", userID, token)
httpReq.Header.Set("Idempotency-Key", idempotencyKey)

5. HTML templates with text/template

Go's standard library includes a capable HTML templating engine. Combine it with the REST API approach for maintainable email templates:

package email

import (
	"bytes"
	"html/template"
	"text/template" // use this for plain text
)

// templates/welcome.html
// <h1>Welcome, {{.FirstName}}!</h1>
// <p>Your account is ready. <a href="{{.AppURL}}">Get started</a></p>

// templates/welcome.txt
// Welcome, {{.FirstName}}!
// Sign in at: {{.AppURL}}

var (
	htmlTmpl = template.Must(template.ParseFiles("templates/welcome.html"))
	textTmpl = txttemplate.Must(txttemplate.ParseFiles("templates/welcome.txt"))
)

type WelcomeData struct {
	FirstName string
	AppURL    string
}

func RenderWelcome(data WelcomeData) (html, text string, err error) {
	var htmlBuf, textBuf bytes.Buffer
	if err = htmlTmpl.Execute(&htmlBuf, data); err != nil {
		return
	}
	if err = textTmpl.Execute(&textBuf, data); err != nil {
		return
	}
	return htmlBuf.String(), textBuf.String(), nil
}

// In your handler:
// html, text, err := email.RenderWelcome(email.WelcomeData{
//     FirstName: user.FirstName,
//     AppURL:    "https://app.example.com/onboarding?token=" + token,
// })
// email.Send(ctx, email.SendRequest{To: user.Email, Subject: "Welcome!", HTML: html, Text: text})

Parse templates once at startup with template.Must. Parsing on every send adds latency and surfaces template errors only at runtime. For editable templates without redeployment, store them in your email platform and pass variables at send time instead.

6. Error handling and retries

Not all errors are equal. 422 validation errors mean your payload is wrong — fix it, don't retry. 5xx errors are transient — retry with exponential backoff:

package email

import (
	"context"
	"errors"
	"fmt"
	"time"
)

type APIError struct {
	StatusCode int
	Message    string
	Retryable  bool
}

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

func SendWithRetry(ctx context.Context, req SendRequest, maxRetries int) (SendResponse, error) {
	var lastErr error
	for attempt := 0; attempt <= maxRetries; attempt++ {
		result, err := Send(ctx, req)
		if err == nil {
			return result, nil
		}

		var apiErr *APIError
		if errors.As(err, &apiErr) && !apiErr.Retryable {
			return SendResponse{}, err // Validation error — don't retry
		}

		lastErr = err

		if attempt < maxRetries {
			wait := time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s
			select {
			case <-time.After(wait):
			case <-ctx.Done():
				return SendResponse{}, ctx.Err()
			}
		}
	}
	return SendResponse{}, fmt.Errorf("send failed after %d retries: %w", maxRetries, lastErr)
}

The select on ctx.Done() is important: if the HTTP handler's context is cancelled (request timeout, client disconnect), the retry loop exits immediately instead of burning goroutines on a send that no one is waiting for.

7. Deliverability checklist before you launch

SPF, DKIM, and DMARC are configured
Without these DNS records, Gmail and Outlook will reject or spam-folder your email. See the complete DKIM/SPF/DMARC guide.
TINYSEND_API_KEY is in environment variables, not source code
Run git log -S TINYSEND_API_KEY before your first push to catch any accidental commits.
Bounce webhook handler is live
Hard bounces must be suppressed immediately. An unhandled bounce rate above 2% will get your sending account suspended.
Always include a plain-text body
HTML-only emails score worse in spam filters. Always send a text field alongside your HTML.
Shared http.Client with timeouts set
A http.Client with no timeout will block goroutines indefinitely if the API is slow. Always set Timeout: 10 * time.Second.

SMTP vs REST API for Go services

In containerized Go services, SMTP connections are often killed by network policies between restarts. REST API calls over HTTPS are stateless and pass through load balancers without connection teardown issues. See our SMTP vs email API comparison for a full breakdown.

Send your first email from Go in 2 minutes

tinysend gives you a clean REST API, templates with variables, bounce handling, and BYOK — so you can use your existing SES or Postmark account as the backend. Free to start.

Get your API key →