Go email
last modified October 30, 2026
In this article we show how to send email in Go with the net/smtp
package. The protocol is text based: the client connects to an SMTP server,
declares the sender and the recipients, and then sends the message headers and
body.
The examples were compiled and run with Go 1.27.0 against Mailpit, a local SMTP server that accepts every message and exposes the mailbox through an HTTP API. This makes the examples reproducible and keeps real credentials and real recipients out of the article.
The SMTP test server
Mailpit listens on port 1025 for SMTP and on port 8025 for its web interface and API. The container can be started with one command.
The API is used only to verify the messages. A production program sends to a real SMTP server instead, usually with authentication and TLS.
$ docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit:latest 6de90130df34c1da004fac8f872c6801cca6a0be436fae64ccbaa304a6e73167 $ curl -s localhost:8025/api/v1/messages | jq '.total' 0
Sending a message
smtp.SendMail connects to the server, optionally authenticates,
and sends one message. The message is a byte slice with RFC 822 style headers,
a blank line and the body. Header lines end with CRLF.
package main
import (
"fmt"
"net/smtp"
)
func main() {
from := "john.doe@example.com"
to := []string{"roger.roe@example.com"}
msg := []byte("From: John Doe <john.doe@example.com>\r\n" +
"To: Roger Roe <roger.roe@example.com>\r\n" +
"Subject: Test mail\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=\"utf-8\"\r\n" +
"\r\n" +
"Email body.\r\n")
if err := smtp.SendMail("localhost:1025", nil, from, to, msg); err != nil {
fmt.Println(err)
return
}
fmt.Println("email sent")
}
The second argument is the authentication mechanism; nil means no
authentication, which is what the local test server expects. A real server uses
smtp.PlainAuth with a user name, a password and the host name, and
the connection should be protected by TLS.
$ go run main.go email sent
Checking the message
The Mailpit API returns the messages in the mailbox, newest first. The sender, the first recipient and the subject are enough to confirm the delivery.
The message also appears in the Mailpit web interface at
http://localhost:8025, where the headers and the body can be
inspected without writing a verification program.
$ curl -s localhost:8025/api/v1/messages | jq -r '.messages[] | "\(.From.Address) -> \(.To[0].Address): \(.Subject)"' john.doe@example.com -> roger.roe@example.com: Test mail
An HTML message
The body format is declared with the Content-Type header. With
text/html the body is rendered as HTML by the mail client, and
Mailpit also generates a plain-text alternative for display.
package main
import (
"fmt"
"net/smtp"
)
func main() {
from := "john.doe@example.com"
to := []string{"roger.roe@example.com"}
msg := "MIME-Version: 1.0\r\n" +
"Content-Type: text/html; charset=\"UTF-8\"\r\n" +
"From: John Doe <john.doe@example.com>\r\n" +
"To: Roger Roe <roger.roe@example.com>\r\n" +
"Subject: Simple HTML mail\r\n" +
"\r\n" +
"<p>An old <b>falcon</b> in the sky.</p>\r\n"
if err := smtp.SendMail("localhost:1025", nil, from, to, []byte(msg)); err != nil {
fmt.Println(err)
return
}
fmt.Println("email sent")
}
The message is deleted from the mailbox first so that the next query returns
exactly this message. The ID is assigned by Mailpit and is
different on every run.
$ curl -s -X DELETE localhost:8025/api/v1/messages ok $ go run main.go email sent $ ID=$(curl -s localhost:8025/api/v1/messages | jq -r '.messages[0].ID') $ curl -s localhost:8025/api/v1/message/$ID | jq -r '.HTML, .Text' <p>An old <b>falcon</b> in the sky.</p> An old *falcon* in the sky.
Several recipients, CC and BCC
The addresses passed to SendMail are the envelope recipients, and
the addresses in the message headers are what the recipients see. Carbon copy
recipients appear in a Cc header, while blind carbon copy
recipients are passed only to SendMail and never written into the
headers.
package main
import (
"fmt"
"net/smtp"
"strings"
)
func main() {
from := "john.doe@example.com"
to := []string{
"roger.roe@example.com",
"adam.smith@example.com",
"oliver.holmes@example.com",
}
cc := []string{"thomas.wayne@example.com"}
msg := "From: " + from + "\r\n" +
"To: " + strings.Join(to[:1], ", ") + "\r\n" +
"Cc: " + strings.Join(cc, ", ") + "\r\n" +
"Subject: Report\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=\"utf-8\"\r\n" +
"\r\n" +
"The report is attached.\r\n"
if err := smtp.SendMail("localhost:1025", nil, from, to, []byte(msg)); err != nil {
fmt.Println(err)
return
}
fmt.Println("email sent")
}
The message is delivered to all three envelope recipients, but only the first
one is listed in To and the CC address is listed in
Cc. The other two appear as blind copies.
$ curl -s -X DELETE localhost:8025/api/v1/messages ok $ go run main.go email sent $ curl -s localhost:8025/api/v1/messages | jq -r '.messages[0] | .To[].Address, .Cc[].Address, .Subject' roger.roe@example.com thomas.wayne@example.com Report
An attachment
A message with an attachment is a multipart/mixed MIME message.
Each part has its own headers, and the binary attachment is encoded with
base64. The mime/multipart writer generates the boundary and the
part separators.
sky blue rock water
The file has four short lines and is small enough to attach without compression.
package main
import (
"bytes"
"encoding/base64"
"fmt"
"mime/multipart"
"net/smtp"
"net/textproto"
"os"
)
func buildMessage() ([]byte, error) {
var body bytes.Buffer
fmt.Fprintf(&body, "From: john.doe@example.com\r\n")
fmt.Fprintf(&body, "To: roger.roe@example.com\r\n")
fmt.Fprintf(&body, "Subject: Report with attachment\r\n")
fmt.Fprintf(&body, "MIME-Version: 1.0\r\n")
mw := multipart.NewWriter(&body)
fmt.Fprintf(&body, "Content-Type: multipart/mixed; boundary=%s\r\n\r\n", mw.Boundary())
textHeader := textproto.MIMEHeader{}
textHeader.Set("Content-Type", "text/plain; charset=utf-8")
part, err := mw.CreatePart(textHeader)
if err != nil {
return nil, err
}
fmt.Fprint(part, "The report is attached.\r\n")
data, err := os.ReadFile("words.txt")
if err != nil {
return nil, err
}
fileHeader := textproto.MIMEHeader{}
fileHeader.Set("Content-Type", "text/plain; charset=utf-8")
fileHeader.Set("Content-Disposition", `attachment; filename="words.txt"`)
fileHeader.Set("Content-Transfer-Encoding", "base64")
part, err = mw.CreatePart(fileHeader)
if err != nil {
return nil, err
}
encoder := base64.NewEncoder(base64.StdEncoding, part)
if _, err := encoder.Write(data); err != nil {
return nil, err
}
if err := encoder.Close(); err != nil {
return nil, err
}
if err := mw.Close(); err != nil {
return nil, err
}
return body.Bytes(), nil
}
func main() {
msg, err := buildMessage()
if err != nil {
fmt.Println(err)
return
}
err = smtp.SendMail("localhost:1025", nil,
"john.doe@example.com", []string{"roger.roe@example.com"}, msg)
if err != nil {
fmt.Println(err)
return
}
fmt.Println("email sent")
}
The first part is plain text for the message body and the second part carries the file. The base64 encoder must be closed before the multipart writer, otherwise the last bytes of the attachment are missing.
$ curl -s -X DELETE localhost:8025/api/v1/messages
ok
$ go run main.go
email sent
$ ID=$(curl -s localhost:8025/api/v1/messages | jq -r '.messages[0].ID')
$ curl -s localhost:8025/api/v1/message/$ID | jq '.Attachments[] | {FileName, ContentType, Size}'
{
"FileName": "words.txt",
"ContentType": "text/plain",
"Size": 20
}
Messages from a template
For personalized mail, the body is generated with text/template.
The same template is executed once per recipient, and each message is sent
separately.
package main
import (
"bytes"
"fmt"
"net/smtp"
"text/template"
)
type User struct {
Name string
Email string
Debt float64
}
var message = `Dear {{ .Name }},
your outstanding balance is ${{ printf "%.2f" .Debt }}.
`
func main() {
users := []User{
{"Roger Roe", "roger.roe@example.com", 890.50},
{"Peter Smith", "peter.smith@example.com", 350},
}
tmpl := template.Must(template.New("mail").Parse(message))
for _, u := range users {
var body bytes.Buffer
if err := tmpl.Execute(&body, u); err != nil {
fmt.Println(err)
return
}
msg := fmt.Sprintf("From: billing@example.com\r\nTo: %s\r\nSubject: Amount due\r\n\r\n%s",
u.Email, body.String())
if err := smtp.SendMail("localhost:1025", nil,
"billing@example.com", []string{u.Email}, []byte(msg)); err != nil {
fmt.Println(err)
return
}
}
fmt.Println("sent", len(users), "messages")
}
The template is parsed once and executed for each user. Sending one message per recipient keeps the personal data out of a shared recipient list.
$ curl -s -X DELETE localhost:8025/api/v1/messages ok $ go run main.go sent 2 messages $ curl -s localhost:8025/api/v1/messages | jq -r '.messages[] | "\(.To[0].Address): \(.Subject)"' peter.smith@example.com: Amount due roger.roe@example.com: Amount due $ ID=$(curl -s localhost:8025/api/v1/messages | jq -r '.messages[0].ID') $ curl -s localhost:8025/api/v1/message/$ID | jq -r '.Text' Dear Peter Smith, your outstanding balance is $350.00.
Connection errors
SendMail returns the error from the network connection when the
server is not reachable. The error names the address and the reason, which is
usually enough to distinguish a wrong port from a missing service.
package main
import (
"fmt"
"net/smtp"
)
func main() {
msg := []byte("From: a@example.com\r\nTo: b@example.com\r\nSubject: x\r\n\r\nbody\r\n")
err := smtp.SendMail("localhost:1", nil, "a@example.com",
[]string{"b@example.com"}, msg)
fmt.Println(err)
}
Port 1 has no SMTP service, so the TCP connection is refused immediately. A wrong host name would instead produce a DNS error.
$ go run main.go dial tcp 127.0.0.1:1: connect: connection refused
Source
Go net/smtp package - reference
Mailpit - documentation
In this article we have sent email in Go. We configured a local SMTP test server, sent plain text and HTML messages, addressed several recipients with CC and BCC, attached a file with a multipart message, generated personalized mail from a template and inspected a connection error.
Author
List all Go tutorials.