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.
net/smtp for short plain-text messages when you already have SMTP credentials and no dependencies is a virtue.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.
With the standard library
No dependencies at all. net/smtp is in the Go distribution.
package mainimport ("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 mainimport ("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.
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
Install
go get github.com/aws/aws-sdk-go-v2/configgo 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 mainimport ("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.
Options one and two solve delivery. Here is what tends to arrive afterward:
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:
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 mainimport ("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.
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.
| SMTP | Amazon SES | Notification API | |
|---|---|---|---|
| Module | net/smtp (stdlib), or go-mail v0.8.1 | aws-sdk-go-v2/service/sesv2 v1.73.0 | courier-go/v4 v4.31.0 |
| Dependencies | none, for plain text | AWS SDK | one module |
| HTML body | headers by hand, or a library | yes | yes |
| Attachments | library only | yes | yes |
| Blocked-port risk | real, 25 and 587 | no, HTTPS | no, HTTPS |
| Delivery status | accept or reject only | bounce and complaint events | one log, per-message status |
| Free tier | n/a, your relay's terms | yes, check current terms | yes, check current terms |
| Templates outside your binary | no | basic | yes |
| Preferences and unsubscribes | you build it | you build it | built in |
| Rate-limit handling for bulk sends | you build it | you build it | digest and throttle nodes |
| Other channels | none | none | SMS, push, in-app, chat |
| Best for | short plain-text mail | teams already on AWS | email 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.
FAQ
[]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.gopkg.in/gomail.v2 (go-gomail/gomail) was last pushed in March 2023 and has seen no releases since, though it is not formally archived and still works. If you want a maintained SMTP library with MIME, HTML, and attachment support, github.com/wneessen/go-mail is actively released, at v0.8.1 as of July 2026.courier.CreateClient(token, nil) and client.SendMessage(ctx, courier.SendMessageRequestBody{...}) belonged to courier-go v2. The module is now v4, so the import path is github.com/trycourier/courier-go/v4, the constructor is courier.NewClient(), and the call is client.Send.Message(ctx, courier.SendMessageParams{...}) with typed structs instead of nested maps.net/smtp sends whatever bytes you give it, so an HTML body means writing your own Content-Type: text/html header, and an attachment means assembling multipart MIME with boundaries by hand. A library does this for you. The standard library is the right choice only for short plain-text messages.Keep exploring
One API, every channel
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.