3 ways to send email with Go

Send email from Go over SMTP, with the AWS SDK for SES, or through a notification API. Current module paths, plus code that actually compiles for each option.

Updated Sep 11, 2026

Last updated: September 2026. Package versions verified against the Go module proxy on 2026-09-11.

Go can send email with nothing but the standard library, which is unusual and slightly misleading: net/smtp will deliver a plain-text message in about fifteen lines, and then stop being enough the moment you want HTML, an attachment, or a delivery receipt.

This guide covers three options with code that compiles against current module versions.

Which option should you pick?

  • net/smtp for short plain-text messages when you already have SMTP credentials and no dependencies is a virtue.
  • Amazon SES via the AWS SDK if you are on AWS. Credentials come from the same IAM chain as everything else, and you get bounce and complaint events.
  • A notification API once email is one channel among several, or when templates, per-user preferences, and a delivery log start mattering more than the send call.

Two things to know before choosing SMTP. Many hosting platforms block outbound port 25, and some block 587, which shows up as a hang rather than an error. And Gmail no longer accepts account passwords: you need an app password, because Google removed the "less secure app access" setting in 2022.

1. Send over SMTP

With the standard library

No dependencies at all. net/smtp is in the Go distribution.

package main
import (
"fmt"
"log"
"net/smtp"
"os"
"strings"
)
func main() {
from := "orders@example.com"
to := []string{"erika@example.com"}
host := "smtp.example.com"
addr := host + ":587"
// net/smtp sends raw bytes, so you write the headers yourself.
// The blank line between headers and body is required.
msg := []byte(strings.Join([]string{
"From: Acme <" + from + ">",
"To: " + strings.Join(to, ", "),
"Subject: Your order shipped",
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
"",
"<h1>Your order shipped</h1><p>Track it in the app.</p>",
}, "\r\n"))
auth := smtp.PlainAuth("", os.Getenv("SMTP_USERNAME"), os.Getenv("SMTP_PASSWORD"), host)
if err := smtp.SendMail(addr, auth, from, to, msg); err != nil {
log.Fatalf("sending mail: %v", err)
}
fmt.Println("sent")
}

Three details that trip people up. Headers must be separated from the body by a blank line, and lines should be joined with \r\n rather than \n. The first argument to smtp.PlainAuth is an identity, and it should be an empty string. And an HTML body needs the MIME-Version and Content-Type headers written by hand, because nothing here builds MIME for you.

With a library, for HTML and attachments

The moment you want an attachment, hand-assembling multipart MIME boundaries stops being reasonable. Use a library.

A note on which one: most Go email tutorials reach for gopkg.in/gomail.v2. It still works, but go-gomail/gomail was last pushed in March 2023 and has had no releases since. If you are starting now, github.com/wneessen/go-mail is actively maintained.

go get github.com/wneessen/go-mail

Verified against github.com/wneessen/go-mail v0.8.1.

package main
import (
"log"
"os"
"github.com/wneessen/go-mail"
)
func main() {
msg := mail.NewMsg()
if err := msg.From("Acme <orders@example.com>"); err != nil {
log.Fatalf("from address: %v", err)
}
if err := msg.To("erika@example.com"); err != nil {
log.Fatalf("to address: %v", err)
}
msg.Subject("Your order shipped")
msg.SetBodyString(mail.TypeTextHTML, "<h1>Your order shipped</h1>")
msg.AttachFile("./receipt.pdf")
client, err := mail.NewClient(
"smtp.example.com",
mail.WithSMTPAuth(mail.SMTPAuthPlain),
mail.WithUsername(os.Getenv("SMTP_USERNAME")),
mail.WithPassword(os.Getenv("SMTP_PASSWORD")),
mail.WithPort(587),
mail.WithTLSPolicy(mail.TLSMandatory),
)
if err != nil {
log.Fatalf("creating client: %v", err)
}
if err := client.DialAndSend(msg); err != nil {
log.Fatalf("sending mail: %v", err)
}
}

Address setters return errors here, which is more verbose than gomail's silent approach and better: a malformed address fails at the call site rather than at send time.

Where it gets thin: SMTP gives you an accept or a reject at handoff and nothing after. No delivery status, no bounce handling, no per-message ID. Blocked ports are a real deployment risk.

2. Send with Amazon SES

An HTTPS API removes the port and TLS problems and gives you delivery events. Amazon SES fits naturally if you are already on AWS, because credentials come from the standard IAM chain rather than being another secret to inject.

On cost, since older guides get this wrong: the often-quoted free tier of 62,000 emails per month applied only to mail sent from EC2 and has been discontinued. Check SES pricing for the current allowance rather than trusting a figure in a tutorial.

What you need first

  • An AWS account, and a verified sending domain or address in SES.
  • To know whether your account is still in the SES sandbox. In the sandbox you can only send to verified addresses, which is the usual reason a correct first send appears to do nothing.
  • Credentials available through the normal AWS chain: environment variables, a profile, or an IAM role.

Install

go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/sesv2

Verified against github.com/aws/aws-sdk-go-v2/service/sesv2 v1.73.0.

Send the message

package main
import (
"context"
"log"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/sesv2"
"github.com/aws/aws-sdk-go-v2/service/sesv2/types"
)
func main() {
ctx := context.Background()
// Picks up region and credentials from the environment, a profile, or an IAM role.
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
log.Fatalf("loading AWS config: %v", err)
}
client := sesv2.NewFromConfig(cfg)
out, err := client.SendEmail(ctx, &sesv2.SendEmailInput{
FromEmailAddress: aws.String("orders@example.com"),
Destination: &types.Destination{
ToAddresses: []string{"erika@example.com"},
},
Content: &types.EmailContent{
Simple: &types.Message{
Subject: &types.Content{
Data: aws.String("Your order shipped"),
Charset: aws.String("UTF-8"),
},
Body: &types.Body{
Html: &types.Content{
Data: aws.String("<h1>Your order shipped</h1>"),
Charset: aws.String("UTF-8"),
},
},
},
},
})
if err != nil {
log.Fatalf("sending email: %v", err)
}
log.Println(*out.MessageId)
}

The nesting is deep but mechanical: a Content for the subject, a Content inside a Body for the HTML, both inside a Message, inside an EmailContent. Set Body.Text as well as Body.Html to send a multipart message with a plain-text fallback.

aws.String exists because these fields are *string, and Go will not let you take the address of a literal.

Where it gets thin: SES is a sending service, not a notification system. Its templating is basic, and preferences, digesting, and other channels are yours to build. You are also committing to AWS.

3. Send with a notification API

Options one and two solve delivery. Here is what tends to arrive afterward:

  • Copy is a string literal in a compiled binary, so a wording change means a rebuild and redeploy.
  • Someone unsubscribes and you now own a suppression list.
  • The same event should reach one user by email and another by push.
  • Your provider has a bad hour and there is no second path.
  • Twenty events fire in a minute and one user gets twenty emails.
  • Someone asks how many notifications failed last week and you cannot answer.

A notification API such as Courier sits above your email provider and handles those. You keep SES or your SMTP relay delivering, and your Go code stops knowing which one it is.

What the layer gives you:

  • Automatic failover between providers when one starts erroring.
  • Journeys for multi-step sequences, with digest and throttling nodes. This is the answer to the delayed-job problem, where a background process needs to mail hundreds of users and hits provider rate limits.
  • One send endpoint for email, SMS, push, in-app, Slack, and Microsoft Teams.
  • Per-user, per-channel preferences enforced before a send.
  • Templates edited outside your codebase, with localization per user. For a compiled language this takes copy changes off your release cycle.
  • One log covering every channel.

Set it up

Create a free account: the Developer plan includes 10,000 messages a month, and past that it's $0.005 per message, the same rate on every channel. Then connect an email provider under Channels using the credentials it already gave you. Same SES account or SMTP relay as above; you are not changing who delivers.

Install

go get github.com/trycourier/courier-go/v4

Verified against github.com/trycourier/courier-go/v4 v4.31.0, which requires Go 1.22 or newer. A snippet importing courier-go/v2 or /v3 will not compile against v4.

Send the message

package main
import (
"context"
"log"
"github.com/trycourier/courier-go/v4"
"github.com/trycourier/courier-go/v4/shared"
)
func main() {
// Reads COURIER_API_KEY from the environment.
client := courier.NewClient()
resp, err := client.Send.Message(context.TODO(), courier.SendMessageParams{
Message: courier.SendMessageParamsMessage{
To: courier.SendMessageParamsMessageToUnion{
OfUserRecipient: &shared.UserRecipientParam{
UserID: courier.String("user-123"),
},
},
Template: courier.String("ORDER_SHIPPED"),
Data: map[string]any{
"name": "Erika",
"tracking_url": "https://example.com/t/abc",
},
},
})
if err != nil {
log.Fatalf("sending notification: %v", err)
}
log.Println(resp.RequestID)
}

Two things worth noticing. The recipient is a tagged union (SendMessageParamsMessageToUnion), because a recipient can be a user, a list, an audience, or a channel-specific address. And courier.String() is a helper for taking the address of a string literal, which Go does not allow inline.

Nothing in that call names email. Routing and the user's stored preferences decide the channel, so moving a notification to push, or sending both, is configuration rather than a rebuild. To address a raw email instead of a stored user, set Email on UserRecipientParam in place of UserID.

Where it gets thin: one more service in the path, and a concept to learn before your first send. If all you need is one plain-text SMTP send from a cron job, net/smtp is less machinery.

Building this with an AI agent

If you are writing this in Cursor, Claude Code, or Codex, connect the agent to Courier directly instead of pasting snippets. It then works from the current SDK shapes rather than the 2022-era API that most training data still contains, which is the difference between Go that compiles and Go that does not.

The MCP server gives the agent typed tool calls. In Claude Code:

claude mcp add --transport http courier https://mcp.courier.com --header api_key:YOUR_API_KEY

Courier Skills adds the practices that are easy to get wrong, such as never batching a one-time passcode:

npx skills add trycourier/courier-skills

Both sit alongside the Courier CLI for scripting and CI. courier.com/agents collects the setup for Cursor, Claude Code, Codex, and VS Code in one place, and Build with AI has the machine-readable docs index an agent can fetch on its own.

Then ask in plain language: "email a receipt when a payment succeeds, and respect the user's preferences." The agent picks the right primitive, and knows which channels need a recorded opt-in.

This matters for the same reason this guide version-stamps every snippet. An agent working from stale training data will confidently write a send call that no longer exists.

Comparing the three options

SMTPAmazon SESNotification API
Modulenet/smtp (stdlib), or go-mail v0.8.1aws-sdk-go-v2/service/sesv2 v1.73.0courier-go/v4 v4.31.0
Dependenciesnone, for plain textAWS SDKone module
HTML bodyheaders by hand, or a libraryyesyes
Attachmentslibrary onlyyesyes
Blocked-port riskreal, 25 and 587no, HTTPSno, HTTPS
Delivery statusaccept or reject onlybounce and complaint eventsone log, per-message status
Free tiern/a, your relay's termsyes, check current termsyes, check current terms
Templates outside your binarynobasicyes
Preferences and unsubscribesyou build ityou build itbuilt in
Rate-limit handling for bulk sendsyou build ityou build itdigest and throttle nodes
Other channelsnonenoneSMS, push, in-app, chat
Best forshort plain-text mailteams already on AWSemail plus other channels

If you have SMTP credentials and need to mail one plain-text alert, net/smtp is the whole answer. If email is becoming a product feature, the notification layer is what stops copy changes from being deploys and gives you somewhere to look when a send fails.

Next steps


FAQ

Frequently asked questions

For plain text, the standard library is enough: build a []byte message with Subject: and To: headers, create credentials with smtp.PlainAuth, and call smtp.SendMail(addr, auth, from, to, msg). For HTML or attachments you need a library, because net/smtp does not build MIME parts. For anything transactional, an HTTPS API is usually the better choice.

One API, every channel

Ship notifications without the boilerplate

Courier gives you one API for email, SMS, push, and chat, with templates, routing, retries, and delivery logs built in.

Last updated Sep 11, 2026. Code samples are illustrative; provider APIs and pricing change over time, so check each provider’s docs before relying on them.