ZetCode

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.

main.go
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.

main.go
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.

main.go
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.

words.txt
sky
blue
rock
water

The file has four short lines and is small enough to attach without compression.

main.go
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.

main.go
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.

main.go
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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.