Developer Tutorial
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.
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.
Three approaches, three different tradeoffs. The right one depends on what you're building.
Connects directly to an SMTP relay. Zero dependencies — it's in the Go standard library. Good for CLI tools, internal notifications, and dev environments.
A thin wrapper that handles MIME, HTML/text multipart, and attachments cleanly. Still SMTP underneath.
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.
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.\r\n line endings, not \n. One wrong byte and the message is rejected.Content-Type: multipart/alternative with separate plain-text and HTML parts — cumbersome to build manually. Use gomail instead.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") 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)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.
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.
git log -S TINYSEND_API_KEY before your first push to catch any accidental commits.text field alongside your HTML.http.Client with no timeout will block goroutines indefinitely if the API is slow. Always set Timeout: 10 * time.Second.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.
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 →