Docs / Go

Send email in Go over SMTP

Go ships an SMTP client in the standard library, and for a plain-text notification it is all you need. This guide starts with net/smtp against Postwing, then moves to go-mail for HTML, attachments and context-aware sending, and finishes with a worker that keeps the send out of your HTTP handlers.

✓
You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

SMTP connection settings

SettingValue
SMTP hostsmtp.postwing.app
Port587
EncryptionSTARTTLS (the connection is upgraded to TLS before login)
UsernameThe login of an SMTP token for your domain
PasswordThe password of that token — shown once, when the token is created
ℹ
Every mode is also available on a high port: 8465 (SSL/TLS), 8587 (STARTTLS) and 8025 (plain). Many hosting providers and clouds block outbound 25, 465 and 587 — if the connection times out, switch to the matching high port.

Send your first email with net/smtp

smtp.SendMail connects, upgrades the connection with STARTTLS, authenticates and sends. The envelope sender and the From header should both be an address on your verified domain, or DKIM and SPF will not align:

main.go
package main

import (
  "log"
  "net/smtp"
  "os"
  "strings"
)

func main() {
  host := "smtp.postwing.app"
  auth := smtp.PlainAuth("", os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASS"), host)

  // Headers and body are separated by an empty line; lines end in CRLF.
  msg := strings.Join([]string{
    "From: Acme <noreply@your-domain.com>",
    "To: customer@example.com",
    "Subject: Your order is confirmed",
    "MIME-Version: 1.0",
    "Content-Type: text/plain; charset=UTF-8",
    "",
    "Thanks! Your order ships tomorrow.",
  }, "\r\n")

  // SendMail upgrades to TLS with STARTTLS before authenticating.
  err := smtp.SendMail(host+":587", auth, "noreply@your-domain.com",
    []string{"customer@example.com"}, []byte(msg))
  if err != nil {
    log.Fatal(err)
  }
}
⚠
It dials without a deadline, so a blocked outbound port hangs the goroutine until the operating system gives up. Anything running in a server should bound the connection, as below.

Add a timeout and context

Build the client yourself to control the dial and the deadline. Note the order: StartTLS must come before Auth, because PlainAuth will not send a password over plaintext:

go
// smtp.SendMail has no timeout. Dial yourself to bound the connection.
func send(ctx context.Context, from string, to []string, msg []byte) error {
  host := "smtp.postwing.app"
  d := net.Dialer{Timeout: 10 * time.Second}
  conn, err := d.DialContext(ctx, "tcp", host+":587")
  if err != nil {
    return err
  }
  conn.SetDeadline(time.Now().Add(30 * time.Second))

  c, err := smtp.NewClient(conn, host)
  if err != nil {
    return err
  }
  defer c.Close()

  if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
    return err
  }
  auth := smtp.PlainAuth("", os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASS"), host)
  if err := c.Auth(auth); err != nil {
    return err
  }
  if err := c.Mail(from); err != nil {
    return err
  }
  for _, rcpt := range to {
    if err := c.Rcpt(rcpt); err != nil {
      return err
    }
  }
  w, err := c.Data()
  if err != nil {
    return err
  }
  if _, err := w.Write(msg); err != nil {
    return err
  }
  if err := w.Close(); err != nil {
    return err
  }
  return c.Quit()
}

HTML and attachments with go-mail

Writing multipart MIME by hand is where net/smtp stops being convenient. github.com/wneessen/go-mail is actively maintained, builds the message for you and takes a context:

bash
go get github.com/wneessen/go-mail
mailer.go
import (
  "context"
  "os"
  "time"

  "github.com/wneessen/go-mail"
)

func newClient() (*mail.Client, error) {
  return mail.NewClient("smtp.postwing.app",
    mail.WithPort(587),
    mail.WithTLSPolicy(mail.TLSMandatory), // STARTTLS, refuse plaintext
    mail.WithSMTPAuth(mail.SMTPAuthPlain),
    mail.WithUsername(os.Getenv("SMTP_USER")),
    mail.WithPassword(os.Getenv("SMTP_PASS")),
    mail.WithTimeout(10*time.Second),
  )
}

func sendInvoice(ctx context.Context, client *mail.Client, to, html, text string) error {
  m := mail.NewMsg()
  if err := m.FromFormat("Acme", "noreply@your-domain.com"); err != nil {
    return err
  }
  if err := m.To(to); err != nil {
    return err
  }
  m.Subject("Your invoice for March")
  m.SetBodyString(mail.TypeTextPlain, text)
  m.AddAlternativeString(mail.TypeTextHTML, html)
  m.AttachFile("/srv/invoices/2026-03.pdf")

  ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
  defer cancel()
  return client.DialAndSendWithContext(ctx, m)
}

Always send a plain-text part next to the HTML — a message without one is a spam signal. AttachFile skips a file it cannot read without returning an error, so check that the path exists if the attachment matters.

If your network only allows port 465, switch to implicit TLS:

go
// Port 465: TLS from the first byte instead of STARTTLS.
client, err := mail.NewClient("smtp.postwing.app",
  mail.WithPort(465),
  mail.WithSSL(),
  mail.WithSMTPAuth(mail.SMTPAuthPlain),
  mail.WithUsername(os.Getenv("SMTP_USER")),
  mail.WithPassword(os.Getenv("SMTP_PASS")),
)

Send in the background

A handler should enqueue the message and return. A small worker pool with retries covers most applications; call Shutdown on exit so queued mail is not lost on a clean restart:

worker.go
// A buffered queue drained by a few goroutines; handlers only enqueue.
type Mailer struct {
  client *mail.Client
  jobs   chan *mail.Msg
  wg     sync.WaitGroup
}

func NewMailer(client *mail.Client, workers int) *Mailer {
  m := &Mailer{client: client, jobs: make(chan *mail.Msg, 100)}
  for i := 0; i < workers; i++ {
    m.wg.Add(1)
    go m.run()
  }
  return m
}

func (m *Mailer) run() {
  defer m.wg.Done()
  for msg := range m.jobs {
    for attempt := 1; attempt <= 3; attempt++ {
      ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
      err := m.client.DialAndSendWithContext(ctx, msg)
      cancel()
      if err == nil {
        break
      }
      log.Printf("send attempt %d failed: %v", attempt, err)
      time.Sleep(time.Duration(attempt) * 5 * time.Second)
    }
  }
}

func (m *Mailer) Enqueue(msg *mail.Msg) { m.jobs <- msg }

// Shutdown stops accepting work and waits for queued mail to go out.
func (m *Mailer) Shutdown() {
  close(m.jobs)
  m.wg.Wait()
}

The queue lives in memory, so a crash drops whatever is in it. For mail a user depends on — password resets, receipts — persist the job first, in your database or a job queue, and let the worker pick it up from there.

Alternative: the REST API

If outbound SMTP ports are blocked or you would rather avoid MIME entirely, send the same message over HTTPS with the same token credentials:

go
type sendRequest struct {
  To      []string          `json:"to"`
  Subject string            `json:"subject"`
  Body    string            `json:"body"`
  Text    string            `json:"text,omitempty"`
  Sender  string            `json:"sender"`
  Auth    map[string]string `json:"auth"`
}

func sendViaAPI(ctx context.Context, to, subject, html, text string) error {
  payload, _ := json.Marshal(sendRequest{
    To:      []string{to},
    Subject: subject,
    Body:    html,
    Text:    text,
    Sender:  "Acme <noreply@your-domain.com>",
    Auth: map[string]string{
      "username": os.Getenv("SMTP_USER"),
      "password": os.Getenv("SMTP_PASS"),
    },
  })

  req, err := http.NewRequestWithContext(ctx, http.MethodPost,
    "https://api.postwing.app/external/send_email_simple/", bytes.NewReader(payload))
  if err != nil {
    return err
  }
  req.Header.Set("Content-Type", "application/json")

  resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
  if err != nil {
    return err
  }
  defer resp.Body.Close()
  if resp.StatusCode >= 300 {
    return fmt.Errorf("send failed: %s", resp.Status)
  }
  return nil
}

Troubleshooting

ErrorCause and fix
unencrypted connectionPlainAuth ran before TLS. Call StartTLS first, or use port 587 with SendMail.
wrong host name The host passed to PlainAuth differs from the one you dialed. Use smtp.postwing.app in both.
535 authentication failedWrong SMTP token login or password.
dial tcp …: i/o timeout or a hang Outbound port blocked. Use 8587 for STARTTLS or 8465 for implicit TLS, or the REST API.
net/smtp hangs on port 465net/smtp speaks plaintext first. Use 587, or implicit TLS via tls.Dial or go-mail's WithSSL.
x509: certificate signed by unknown authorityNo CA certificates in the container image. Install them.
550 sender rejectedThe From address is not on your verified domain.

Frequently asked questions

Does Go's net/smtp support STARTTLS?

Yes. smtp.SendMail issues STARTTLS on its own whenever the server advertises it, before it authenticates, so port 587 works with nothing extra. What net/smtp cannot do by itself is implicit TLS on port 465 — for that you dial with tls.Dial and hand the connection to smtp.NewClient, or use a library such as go-mail.

Why does smtp.PlainAuth fail with 'unencrypted connection'?

PlainAuth refuses to send a password over a connection that is not TLS, unless the server is localhost. It means STARTTLS never happened — usually because you called c.Auth before c.StartTLS on a hand-built client, or you pointed it at the wrong port.

Is net/smtp deprecated?

Not deprecated, but frozen: the Go team accepts no new features for it. It is fine for plain-text notifications. For HTML with a text alternative, attachments, encoded non-ASCII headers and context-aware sending, a maintained library like github.com/wneessen/go-mail saves you from building MIME by hand.

How do I set a timeout when sending email in Go?

smtp.SendMail has none — a blocked port hangs until the OS gives up. Either dial with net.Dialer and set a deadline on the connection before smtp.NewClient, or use go-mail's WithTimeout option together with DialAndSendWithContext and a context deadline.

Why do I get 'x509: certificate signed by unknown authority' in Docker?

Images built FROM scratch or on a bare distroless base have no CA certificates, so the TLS handshake cannot verify the server. Copy /etc/ssl/certs/ca-certificates.crt into the image or install the ca-certificates package. Never set InsecureSkipVerify to get past it.

Should I send email inside an HTTP handler?

Not synchronously. The SMTP round trip adds hundreds of milliseconds and a slow server holds the request open. Push the message onto a channel drained by worker goroutines, and drain it on shutdown. If the mail must survive a crash, keep the queue in your database or a job system instead of memory.

Next steps