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.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
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:
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)
}
} 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:
// 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()
} 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:
go get github.com/wneessen/go-mailimport (
"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:
// 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")),
) 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:
// 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.
If outbound SMTP ports are blocked or you would rather avoid MIME entirely, send the same message over HTTPS with the same token credentials:
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
}| Error | Cause and fix |
|---|---|
unencrypted connection | PlainAuth 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 failed | Wrong 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 465 | net/smtp speaks plaintext first. Use 587, or implicit TLS via tls.Dial or go-mail's WithSSL. |
x509: certificate signed by unknown authority | No CA certificates in the container image. Install them. |
550 sender rejected | The From address is not on your verified domain. |
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.
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.
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.
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.
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.
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.