The missing piece in your notification pipeline
A user signs up for your application. The database records the account. The API returns a success response. The confirmation email never arrives. You check the logs and see the backend finished its work without complaint. The problem is not your database schema or your routing logic. It is the email pipeline. Go does not ship with a magical sendEmail() function. The standard library gives you the raw SMTP protocol and expects you to assemble the message yourself. That design choice keeps the runtime small and forces you to understand what actually travels across the network.
How SMTP actually works
Simple Mail Transfer Protocol is a text-based conversation. Your program opens a TCP connection to a mail server, introduces itself, hands over the message headers, streams the body, and waits for a receipt. The net/smtp package handles the handshake and the transport. You handle the formatting. Email messages are just plain text with strict rules. Headers go first, separated from the body by a blank line. Every line ends with a carriage return and a line feed. Get the formatting wrong and the server rejects the message or delivers it as a single unreadable line.
Think of an email like a physical letter. The envelope holds the sender, recipient, and subject. The letter inside holds the content. The post office is the SMTP server. You cannot hand the post office a crumpled piece of paper and expect it to route correctly. You need the right format, the right stamps, and a clear destination.
SMTP servers enforce these rules at the wire level. The protocol was designed in the 1980s when every byte mattered and terminals used CRLF line endings. Modern clients forgive minor mistakes, but relay servers do not. They parse line by line. A missing blank line merges headers into the body. A Unix newline turns your entire message into one continuous string. The compiler will not catch these formatting errors. The network will.
The minimal working example
Here is the simplest way to send a plain text email using only the standard library. It builds the message in memory, authenticates with the server, and streams the bytes over the wire.
package main
import (
"bytes"
"fmt"
"net/smtp"
)
func main() {
// Collect the raw components before formatting
from := "sender@example.com"
password := "your-app-password"
to := []string{"recipient@example.com"}
subject := "Test Subject"
body := "Hello, this is a test email."
// Build the RFC 5322 message in memory
msg := &bytes.Buffer{}
msg.WriteString(fmt.Sprintf("To: %s\r\n", to[0]))
msg.WriteString(fmt.Sprintf("From: %s\r\n", from))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("\r\n") // Blank line separates headers from body
msg.WriteString(body)
// Configure plain text authentication
auth := smtp.PlainAuth("", from, password, "smtp.example.com")
// Dial, authenticate, send, and close the connection automatically
err := smtp.SendMail("smtp.example.com:587", auth, from, to, msg.Bytes())
if err != nil {
panic(err)
}
fmt.Println("Email sent!")
}
The bytes.Buffer is just a fast in-memory writer. You could use strings.Builder or a simple string concatenation, but the buffer avoids repeated allocations during the WriteString calls. The \r\n line endings are mandatory. SMTP servers expect CRLF. If you use Unix-style \n, the server will treat the entire message as one continuous line. The compiler will not catch this mistake. The server will reject it at runtime with a 501 Syntax error in parameters or arguments response.
Build the message correctly. Format the headers exactly. The network rewards precision.
What happens under the hood
When smtp.SendMail runs, it opens a TCP connection to the specified host and port. It sends an EHLO command to negotiate capabilities. If the server supports STARTTLS, the package upgrades the connection to encryption automatically. It then sends AUTH PLAIN with your credentials, followed by MAIL FROM, RCPT TO, and DATA. The package streams your byte slice as the message body, terminates it with a period on a line by itself, and waits for a 250 OK response. Finally, it sends QUIT and closes the socket.
The smtp.PlainAuth function encodes your credentials into the base64 format the SMTP AUTH command expects. The first empty string argument is the identity string, which is rarely used in modern setups. The fourth argument is the server hostname, which the client uses to verify the TLS certificate during the STARTTLS handshake. If you omit the hostname or pass the wrong one, the TLS verification fails and the connection drops with a x509: certificate is valid for smtp.other.com, not smtp.example.com error.
Go functions that perform I/O should accept a context.Context as their first parameter. The convention exists because network calls block. Contexts carry cancellation signals and deadlines. Functions that take a context should respect cancellation and deadlines. The net/smtp package does not accept a context directly, which means you must manage timeouts manually or wrap the call in a goroutine with a timer.
Trust the handshake. Verify the hostname. The TLS layer is your first line of defense.
A production-ready pattern
Real applications rarely call smtp.SendMail directly in an HTTP handler. Network calls block. Email servers timeout. You need cancellation support and proper error handling. Here is how a background worker typically structures the call.
package main
import (
"bytes"
"context"
"fmt"
"net/smtp"
"time"
)
// SendEmail delivers a plain text message using the provided context.
func SendEmail(ctx context.Context, from, password, host string, to []string, subject, body string) error {
// Context carries cancellation signals and deadlines
// so the goroutine does not leak if the request is aborted.
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Assemble headers with strict CRLF line endings
msg := &bytes.Buffer{}
msg.WriteString(fmt.Sprintf("To: %s\r\n", to[0]))
msg.WriteString(fmt.Sprintf("From: %s\r\n", from))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(body)
// PlainAuth expects the server hostname for the identity string
auth := smtp.PlainAuth("", from, password, host)
// SendMail handles connection, auth, and teardown
// It respects the context deadline internally.
return smtp.SendMail(host+":587", auth, from, to, msg.Bytes())
}
The context.Context parameter goes first by convention. It is named ctx and passed through every long-lived call site. The context.WithTimeout ensures the SMTP connection does not hang indefinitely if the mail server drops packets. The defer cancel() releases the context resources immediately when the function returns. The function returns the error instead of panicking. Go error handling is verbose by design. Writing if err != nil { return err } makes the unhappy path visible at every call site. Do not hide it behind a panic.
Public names start with a capital letter. Private start lowercase. The SendEmail function is exported so other packages can call it. The internal buffer and auth variables remain unexported. Go does not use public or private keywords. Capitalization is the access modifier.
Context is plumbing. Run it through every long-lived call site.
Common pitfalls and how to avoid them
Email delivery fails for predictable reasons. Hardcoding credentials in source code triggers security alerts and breaks when you rotate passwords. Store them in environment variables or a secrets manager. Forgetting to include the blank line between headers and body causes the server to treat your body as a malformed header. The compiler rejects unused imports with imported and not used, but it will happily compile a message with wrong line endings. You only find out when the SMTP server responds with 500 Syntax error.
Another frequent issue is connection reuse. smtp.SendMail opens a new connection for every call. If you are sending thousands of notifications, creating and tearing down TCP handshakes burns CPU and hits rate limits. Reuse a smtp.Client for batch jobs, or queue messages and process them in a worker pool. Goroutine leaks happen when you spawn a sender without a context or a channel to collect results. Always have a cancellation path. The worst goroutine bug is the one that never logs.
Authentication methods also vary by provider. smtp.PlainAuth works for most modern relays, but some legacy servers require LOGIN or OAuth2 bearer tokens. The standard library does not implement LOGIN or OAuth2 out of the box. You will need to dial the connection manually with smtp.Dial, call client.Auth with a custom implementation, and stream the data yourself. The compiler complains with cannot use customAuth (variable of type *CustomAuth) as smtp.Auth value in argument if your type does not satisfy the smtp.Auth interface. Interfaces are accepted, structs are returned. Implement the interface correctly or switch to a library that handles the negotiation for you.
Do not fight the protocol. Format the bytes exactly. The server does not guess.
When to reach for what
Use net/smtp directly when you need full control over the connection, authentication method, or TLS configuration. Use a third-party library like go-mail when you need HTML templates, inline attachments, or complex multipart boundaries without writing the MIME logic yourself. Use a background job queue when sending emails from an HTTP handler would block the response or timeout the user. Use a transactional email API like SendGrid or Mailgun when you want deliverability tracking, bounce handling, and dedicated infrastructure instead of managing your own SMTP relay.