How to Build an SMTP Client in Go

Web
Send emails in Go using the standard net/smtp package with a single SendMail function call.

How to Build an SMTP Client in Go

You just shipped a signup flow. The user enters their email, hits submit, and the database saves the record. Now the user waits. Did it work? You need to send a confirmation email. Or maybe a password reset link. Or an invoice. Sending email from a Go program feels like a solved problem, but the protocol is old, the servers are picky, and getting the headers wrong means your message lands in spam or bounces entirely.

The protocol is a conversation

SMTP stands for Simple Mail Transfer Protocol. The name is optimistic. The protocol is a text-based conversation between a client and a server. You connect, identify yourself, tell the server who is sending the mail, who is receiving it, hand over the message body, and disconnect.

Think of it like making a phone call to a post office clerk. You dial the number, say hello, state your name, give the recipient's address, read the letter out loud, and hang up. The server responds to every step with a numeric code. A 2xx code means "go ahead." A 5xx code means "stop, something is wrong." The client must read the response before sending the next command.

The fast path

The standard library provides net/smtp. The fastest way to send an email is smtp.SendMail. It handles the connection, authentication, and handshake in one call. You provide the server address, authentication credentials, sender, recipients, and the raw message bytes.

Here's the simplest goroutine: spawn one, send a message, close the channel.

package main

import (
	"fmt"
	"net/smtp"
)

func main() {
	// PlainAuth requires the username, password, and the server hostname.
	// The first empty string is the identity sent to the server; leave it blank for most providers.
	auth := smtp.PlainAuth("", "sender@example.com", "password", "smtp.example.com")

	// msg must include headers and a blank line before the body.
	// \r\n is the required line ending for SMTP messages.
	msg := []byte("Subject: Hello from Go\r\n\r\nThis is the body.")

	// SendMail connects, authenticates, sends the message, and closes the connection.
	err := smtp.SendMail("smtp.example.com:587", auth, "sender@example.com", []string{"recipient@example.com"}, msg)
	if err != nil {
		fmt.Println("Failed to send:", err)
		return
	}

	fmt.Println("Email sent")
}

SendMail is the shortcut. It works for the happy path.

What happens under the hood

When you call smtp.SendMail, the function orchestrates the entire handshake. It opens a TCP connection to the host and port. It sends an EHLO command to introduce itself. If the server advertises STARTTLS, the client upgrades the connection to encryption. Then it authenticates using the provided mechanism.

Once authenticated, it sends MAIL FROM with the sender address, RCPT TO for each recipient, and DATA followed by the raw message bytes. The server validates each step. If the recipient doesn't exist, the server rejects the RCPT TO command before you even send the body. The function returns an error if any step fails.

Production headers matter

Real emails need proper headers. You usually want a From header that matches the envelope sender, a Date, and maybe HTML content. Constructing the message manually is error-prone. The mime/multipart package helps build complex messages, but for a simple text or HTML email, formatting the string correctly is often enough.

Here's a realistic sender with headers and error wrapping.

package main

import (
	"fmt"
	"net/smtp"
	"time"
)

func sendEmail() error {
	// Headers must end with \r\n. A blank line separates headers from the body.
	// Including Date and MIME-Version improves deliverability.
	headers := fmt.Sprintf(
		"From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Order Confirmation\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n",
		time.Now().Format(time.RFC1123Z),
	)

	msg := []byte(headers + "Thanks for your order!")

	auth := smtp.PlainAuth("", "sender@example.com", "password", "smtp.example.com")

	// SendMail handles the protocol handshake and transmission.
	err := smtp.SendMail("smtp.example.com:587", auth, "sender@example.com", []string{"recipient@example.com"}, msg)
	if err != nil {
		return fmt.Errorf("smtp send failed: %w", err)
	}

	return nil
}

Headers are the envelope. Get them right or the mail vanishes.

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Wrapping the error with %w preserves the chain so callers can inspect the root cause.

Pitfalls and server quirks

SMTP servers are defensive. They reject messages for subtle reasons. If your authentication credentials are wrong, the server returns a 535 error. The client reports this as smtp: authentication failed. If you try to send to a non-existent address, the server rejects the recipient with a 550 error. The error message often includes the server's explanation, like 550 5.1.1 The email account that you tried to reach does not exist.

TLS mismatches are common. If the server uses a self-signed certificate, the client refuses to connect. You get an error like x509: certificate signed by unknown authority. Never disable TLS verification in production. Fix the certificate chain instead.

Spam filters scan headers. A missing Date header or a From address that doesn't match the authenticated user can land your email in the junk folder. Ensure your headers are complete and consistent.

The server always wins. Respect the error codes.

Port 465 and implicit TLS

Many providers like Gmail or AWS SES support port 465 with implicit TLS. The net/smtp package assumes port 587 with STARTTLS. It does not support implicit TLS out of the box. If you try to use SendMail on port 465, the handshake fails because the client expects a plaintext greeting before upgrading to TLS. To use port 465, you must establish the TLS connection yourself using tls.Dial and then pass the connection to smtp.NewClient.

Here's how to set up the connection for implicit TLS.

// tls.Dial creates an encrypted connection immediately.
// This is required for port 465, which expects TLS from the first byte.
conn, err := tls.Dial("tcp", "smtp.example.com:465", &tls.Config{
	ServerName: "smtp.example.com",
})
if err != nil {
	return fmt.Errorf("tls dial failed: %w", err)
}

// NewClient adapts the TLS connection for SMTP protocol commands.
client, err := smtp.NewClient(conn, "smtp.example.com")
if err != nil {
	conn.Close()
	return fmt.Errorf("smtp client init failed: %w", err)
}

defer client.Close()

Once you have the client, you drive the protocol manually.

// Authenticate using the standard PlainAuth mechanism.
auth := smtp.PlainAuth("", "sender@example.com", "password", "smtp.example.com")
if err := client.Auth(auth); err != nil {
	return fmt.Errorf("auth failed: %w", err)
}

// Set sender and recipient. The server validates these addresses.
if err := client.Mail("sender@example.com"); err != nil {
	return fmt.Errorf("mail from failed: %w", err)
}

if err := client.Rcpt("recipient@example.com"); err != nil {
	return fmt.Errorf("rcpt to failed: %w", err)
}

// Open the data stream and write the message.
w, err := client.Data()
if err != nil {
	return fmt.Errorf("data failed: %w", err)
}

msg := []byte("Subject: Test\r\n\r\nBody text.")
_, err = w.Write(msg)
if err != nil {
	return fmt.Errorf("write failed: %w", err)
}

// Close the writer to signal end of message.
return w.Close()

Context and cancellation

The net/smtp package predates the widespread use of context.Context. Functions like SendMail block until the message is sent or the connection fails. There is no way to pass a context to cancel the operation. If you are building a web server and need to respect request cancellation, you cannot rely on SendMail alone.

You must run the send operation in a separate goroutine and communicate the result via a channel, or use a library that wraps the client with context support. This design choice reflects the package's age. The community often wraps smtp.Client in a custom struct that accepts context for modern applications.

Context is plumbing. Run it through every long-lived call site, even if the standard library makes you work for it.

MIME and complexity

When you need attachments or HTML with fallback text, the message structure becomes a MIME multipart entity. The mime/multipart package helps construct these boundaries. You create a writer, create parts, and write headers for each part. The complexity grows quickly. For simple text emails, string formatting is sufficient. For invoices with PDF attachments, the manual MIME construction becomes tedious. This is where third-party libraries shine. They abstract the MIME boundaries and attachment encoding.

Decision matrix

Use smtp.SendMail when you need to send a simple email quickly and don't require fine-grained control over the connection. Use smtp.Dial or smtp.DialStartTLS with a *smtp.Client when you need to reuse the connection for multiple messages or inspect server capabilities before sending. Use a third-party library like gomail or email when you need to build complex multipart messages with attachments and HTML templates without managing MIME boundaries manually. Use a dedicated transactional email service API when you need delivery tracking, bounce handling, and high throughput without managing SMTP infrastructure.

Don't reinvent MIME. Use the right tool for the complexity.

Where to go next