---
title: "How to Send Email in Go: net/smtp, Gin and API"
description: "Send email from Go with net/smtp, go-mail, Gin or net/http, or with an email API. Copy-paste examples for HTML email, attachments, background workers and retries without duplicates."
canonical: "https://reloop.sh/blog/send-email-go"
last-updated: "2026-10-26"
---

# How to Send Email in Go: net/smtp, Gin and API

> For the site documentation index, see [llms.txt](/llms.txt). Docs index: [llms-docs.txt](/llms-docs.txt). Marketing corpus: [llms-full.txt](/llms-full.txt). Docs corpus: [llms-full-docs.txt](/llms-full-docs.txt). Product skill: [skill.md](/skill.md). Prefer markdown URLs (append `.md`) when available.

> Send email from Go with net/smtp, go-mail, Gin or net/http, or with an email API. Copy-paste examples for HTML email, attachments, background workers and retries without duplicates.
Source: https://reloop.sh/blog/send-email-go
Published: 2026-10-26
**Go can send email with the built-in `net/smtp` package, no install needed.** For anything in production, send through a provider: either over SMTP with `net/smtp` or a library like go-mail, or over HTTPS with an email API SDK.

This guide covers plain `net/smtp`, the Reloop Go SDK, Gin and `net/http`, then the production details: attachments, background sending and safe retries.

Working in another language? See [how to send email in Python](/blog/send-email-python) or [Node.js](/blog/send-email-nodejs).

---

## Which approach should you use?

| | `net/smtp` / go-mail | Email API SDK |
|---|---------------------|---------------|
| **Install** | Nothing for `net/smtp`, it is in the standard library | `go get github.com/reloop-labs/reloop-go/v2` |
| **Transport** | SMTP on port 587 (STARTTLS) | HTTPS on port 443 |
| **Building the message** | Raw headers and MIME parts, or go-mail | A `SendMailParams` struct with `HTML`, `Text`, `Attachments` |
| **Errors** | `*textproto.Error` with an SMTP code | `*reloop.APIError` with an HTTP status |
| **Safe retries** | No, a retry can send twice | Yes, with an idempotency key |
| **Dependencies** | None, or one for go-mail | Standard library only |

If a tool or library you do not control only speaks SMTP, use SMTP. If you are writing the send call yourself, use the API.

---

## Prerequisites

1. **Go 1.25 or later.** The Reloop SDK supports Go 1.20, but current Gin and go-mail releases need 1.25.
2. A **verified sending domain** with [SPF, DKIM and DMARC](/blog/spf-dkim-dmarc-setup-guide)
3. An **API key** from the [Reloop dashboard](https://reloop.sh/dashboard). Keys start with `rl_`, and the same key works for the API and SMTP.

```bash
```

Keep the key in the environment or a secrets manager, never in your code or git.

---

## Method 1: Send email with net/smtp

`net/smtp` sends bytes. You build the headers and the MIME body yourself. The `mime/multipart` package handles the boundaries, so a plain-text and HTML email looks like this:

```go
package main

	"bytes"
	"fmt"
	"log"
	"mime"
	"mime/multipart"
	"net/smtp"
	"net/textproto"
	"os"
	"time"
)

func main() {
	from := "Acme <hello@mail.acme.com>"
	to := "user@example.com"
	subject := "Welcome to Acme"

	// Build a multipart/alternative body: plain text first, then HTML
	var body bytes.Buffer
	mw := multipart.NewWriter(&body)

	textPart, _ := mw.CreatePart(textproto.MIMEHeader{
		"Content-Type": {"text/plain; charset=UTF-8"},
	})
	textPart.Write([]byte("Welcome! Thanks for signing up."))

	htmlPart, _ := mw.CreatePart(textproto.MIMEHeader{
		"Content-Type": {"text/html; charset=UTF-8"},
	})
	htmlPart.Write([]byte("<h1>Welcome!</h1><p>Thanks for signing up.</p>"))
	mw.Close()

	var msg bytes.Buffer
	fmt.Fprintf(&msg, "From: %s\r\n", from)
	fmt.Fprintf(&msg, "To: %s\r\n", to)
	fmt.Fprintf(&msg, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", subject))
	fmt.Fprintf(&msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
	fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n")
	fmt.Fprintf(&msg, "Content-Type: multipart/alternative; boundary=%s\r\n\r\n", mw.Boundary())
	msg.Write(body.Bytes())

	auth := smtp.PlainAuth("", "reloop", os.Getenv("RELOOP_API_KEY"), "smtp.reloop.sh")

	err := smtp.SendMail(
		"smtp.reloop.sh:587",
		auth,
		"hello@mail.acme.com", // envelope sender: the bare address
		[]string{to},
		msg.Bytes(),
	)
	if err != nil {
		log.Fatal(err)
	}
}
```

The username can be any string. The password is your API key.

`smtp.SendMail` upgrades the connection with STARTTLS when the server offers it, and `PlainAuth` refuses to send your key over an unencrypted connection to anything other than localhost. The envelope sender (third argument) is the bare address. The display name goes in the `From:` header.

`net/smtp` is frozen and gets no new features. It also has no timeout: `SendMail` can block for as long as the network lets it. That is fine for a script. In a server, use go-mail (below) or dial with `net.DialTimeout` and drive `smtp.NewClient` yourself.

### When port 587 is blocked

Some hosts block outbound 587. Reloop also accepts STARTTLS on port **2587** (and 2025), so only the address changes:

```go
err := smtp.SendMail("smtp.reloop.sh:2587", auth, "hello@mail.acme.com", []string{to}, msg.Bytes())
```

Reloop does not offer implicit TLS on port 465, so do not wrap the connection in `tls.Dial` before the SMTP handshake. If the connection hangs or times out, see [SMTP ports explained](/blog/smtp-ports-explained).

### Handle SMTP errors

Server replies come back as `*textproto.Error` with the SMTP code. Anything else is a network, DNS or TLS problem:

```go
if err != nil {
	var smtpErr *textproto.Error
	switch {
	case errors.As(err, &smtpErr) && smtpErr.Code == 535:
		log.Print("wrong API key")
	case errors.As(err, &smtpErr) && smtpErr.Code >= 500:
		log.Printf("rejected (%d): %s", smtpErr.Code, smtpErr.Msg)
	case errors.As(err, &smtpErr):
		log.Printf("temporary failure (%d), retry later: %s", smtpErr.Code, smtpErr.Msg)
	default:
		log.Printf("send failed: %v", err) // DNS, timeout, TLS
	}
}
```

`5xx` codes are permanent: retrying sends the same rejected message. `4xx` codes are temporary.

### Use go-mail when you need more

For attachments, timeouts or anything beyond a simple body, [go-mail](https://github.com/wneessen/go-mail) is the maintained option. It builds the MIME message for you:

```go
package main

	"log"
	"os"
	"time"

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

func main() {
	m := mail.NewMsg()
	if err := m.From("Acme <hello@mail.acme.com>"); err != nil {
		log.Fatal(err)
	}
	if err := m.To("user@example.com"); err != nil {
		log.Fatal(err)
	}
	m.Subject("Welcome to Acme")
	m.SetBodyString(mail.TypeTextPlain, "Welcome! Thanks for signing up.")
	m.AddAlternativeString(mail.TypeTextHTML, "<h1>Welcome!</h1><p>Thanks for signing up.</p>")

	c, err := mail.NewClient("smtp.reloop.sh",
		mail.WithPort(587),
		mail.WithTLSPolicy(mail.TLSMandatory),
		mail.WithSMTPAuth(mail.SMTPAuthPlain),
		mail.WithUsername("reloop"),
		mail.WithPassword(os.Getenv("RELOOP_API_KEY")),
		mail.WithTimeout(30*time.Second),
	)
	if err != nil {
		log.Fatal(err)
	}
	if err := c.DialAndSend(m); err != nil {
		log.Fatal(err)
	}
}
```

`TLSMandatory` fails the send if STARTTLS is not available, instead of falling back to plain text.

### Can I send with Gmail from Go?

For a personal script, yes: use `smtp.gmail.com:587` with an [App Password](https://support.google.com/accounts/answer/185833) and your Gmail address as the `PlainAuth` username. For an app, no. Gmail limits personal accounts to about 500 recipients a day, every message comes from your `@gmail.com` address, and bulk sending gets the account suspended.

---

## Method 2: Send email with the Go SDK

### Install

```bash
go get github.com/reloop-labs/reloop-go/v2
```

The SDK uses only the standard library.

### Send an email

```go
package main

	"errors"
	"fmt"
	"log"
	"os"

	reloop "github.com/reloop-labs/reloop-go/v2"
)

func main() {
	client, err := reloop.NewClient(reloop.ClientOptions{
		APIKey: os.Getenv("RELOOP_API_KEY"),
	})
	if err != nil {
		log.Fatal(err) // the API key is empty
	}

	res, err := client.Mail.Send(reloop.SendMailParams{
		From:    "Acme <hello@mail.acme.com>",
		To:      "user@example.com",
		Subject: "Welcome to Acme",
		HTML:    reloop.String("<h1>Welcome!</h1><p>Thanks for signing up.</p>"),
		Text:    reloop.String("Welcome! Thanks for signing up."),
	})
	if err != nil {
		var valErr *reloop.ValidationError
		var apiErr *reloop.APIError
		switch {
		case errors.As(err, &valErr):
			log.Fatalf("invalid %s: %s", valErr.Field, valErr.Message)
		case errors.As(err, &apiErr):
			log.Fatalf("failed (%d): %s %s", apiErr.Status, apiErr.Body.Message, apiErr.Body.Fix)
		default:
			log.Fatal(err)
		}
	}

	fmt.Println("Sent:", res.ID)
}
```

`Send` returns `(*SendMailResponse, error)`. There are two error types:

- `*reloop.ValidationError` means a required field (`From`, `To`, `Subject`) is missing. No request was made.
- `*reloop.APIError` covers everything else. `Status` is the HTTP status, and `Body` holds the API's `Message`, `Why`, `Fix` and `Link`. Network failures come back as an `APIError` with status `0`.

Optional string fields like `HTML` and `Text` are pointers. `reloop.String` turns a string literal into one.

Create the client **once** at startup and share it. It is safe to use from many goroutines. `Send` does not take a `context.Context`; it uses an `http.Client` with a 30 second timeout. To change the timeout or transport, pass your own with `client.SetHTTPClient`.

### Send to several recipients

`To`, `Cc`, `Bcc` and `ReplyTo` accept either a `string` or a `[]string`:

```go
_, err := client.Mail.Send(reloop.SendMailParams{
	From:    "Acme <hello@mail.acme.com>",
	To:      []string{"ada@example.com", "grace@example.com"},
	Cc:      "team@acme.com",
	ReplyTo: "support@acme.com",
	Subject: "Your weekly report",
	Text:    reloop.String("Here is your report."),
	Tags: []reloop.SendMailTag{
		{Name: "type", Value: "weekly_report"},
	},
})
```

Every address in `To` sees the others. For personalized mail, send one message per recipient.

### Send a stored template

Keep copy out of your code so non-developers can edit it. Create the template in Reloop, then send it by ID with variables:

```go
_, err := client.Mail.Send(reloop.SendMailParams{
	From:    "Acme <hello@mail.acme.com>",
	To:      "user@example.com",
	Subject: "Welcome to Acme",
	Template: &reloop.SendMailTemplate{
		ID: "tmpl_welcome",
		Variables: map[string]interface{}{
			"first_name": "Ada",
			"plan":       "Pro",
		},
	},
})
```

The SDK requires `Subject` on every send, including template sends. See [templates](/docs/learn/templates) for variables and defaults.

---

## Gin

The SDK client is safe for concurrent use, so create it in `main` and close over it in your handlers:

```go
package main

	"log"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	reloop "github.com/reloop-labs/reloop-go/v2"
)

type contactRequest struct {
	Email   string `json:"email" binding:"required,email"`
	Message string `json:"message" binding:"required"`
}

func main() {
	client, err := reloop.NewClient(reloop.ClientOptions{
		APIKey: os.Getenv("RELOOP_API_KEY"),
	})
	if err != nil {
		log.Fatal(err)
	}

	r := gin.Default()

	r.POST("/contact", func(c *gin.Context) {
		var req contactRequest
		if err := c.ShouldBindJSON(&req); err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
			return
		}

		res, err := client.Mail.Send(reloop.SendMailParams{
			From:    "Acme Website <contact@mail.acme.com>",
			To:      "support@acme.com",
			ReplyTo: req.Email,
			Subject: "New message from " + req.Email,
			Text:    reloop.String(req.Message),
		})
		if err != nil {
			log.Printf("contact email failed: %v", err)
			c.JSON(http.StatusBadGateway, gin.H{"error": "Could not send message"})
			return
		}

		c.JSON(http.StatusOK, gin.H{"id": res.ID})
	})

	r.Run(":8080")
}
```

The visitor's address goes in `ReplyTo`, never in `From`. You can only send from your verified domain, and putting someone else's domain in `From` fails [DMARC](/blog/gmail-yahoo-bulk-sender-requirements).

To return before the email is sent, start a goroutine. Copy what you need out of the request first, and do not use `c` inside the goroutine:

```go
r.POST("/signup", func(c *gin.Context) {
	email := c.PostForm("email")
	// ... create the user ...

	go func() {
		_, err := client.Mail.Send(reloop.SendMailParams{
			From:    "Acme <hello@mail.acme.com>",
			To:      email,
			Subject: "Welcome to Acme",
			Text:    reloop.String("Thanks for signing up."),
		})
		if err != nil {
			log.Printf("welcome email to %s failed: %v", email, err)
		}
	}()

	c.JSON(http.StatusOK, gin.H{"ok": true})
})
```

A bare goroutine has no retries and is lost if the process exits. For emails that must arrive, use a worker or a queue (see below).

### Plain net/http

Without a framework, the same handler works with the standard library. Go 1.22 and later route on method and path in `http.ServeMux`:

```go
mux := http.NewServeMux()
mux.HandleFunc("POST /contact", func(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Email   string `json:"email"`
		Message string `json:"message"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Email == "" {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}

	res, err := client.Mail.Send(reloop.SendMailParams{
		From:    "Acme Website <contact@mail.acme.com>",
		To:      "support@acme.com",
		ReplyTo: req.Email,
		Subject: "New message from " + req.Email,
		Text:    reloop.String(req.Message),
	})
	if err != nil {
		http.Error(w, "could not send message", http.StatusBadGateway)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"id": res.ID})
})

log.Fatal(http.ListenAndServe(":8080", mux))
```

Echo, Fiber and Chi look the same: one shared client, a handler that calls `client.Mail.Send`.

---

## Attachments

With the SDK, pass base64 content:

```go
pdf, err := os.ReadFile("invoice-1042.pdf")
if err != nil {
	log.Fatal(err)
}

_, err = client.Mail.Send(reloop.SendMailParams{
	From:    "Acme Billing <billing@mail.acme.com>",
	To:      "user@example.com",
	Subject: "Your invoice #1042",
	Text:    reloop.String("Your invoice is attached."),
	Attachments: []reloop.SendMailAttachment{{
		Filename:    reloop.String("invoice-1042.pdf"),
		Content:     base64.StdEncoding.EncodeToString(pdf),
		ContentType: reloop.String("application/pdf"),
	}},
})
```

Over SMTP, building attachment MIME parts by hand with `net/smtp` is error-prone. Use go-mail instead:

```go
m.AttachFile("invoice-1042.pdf")
```

It sets the content type from the file extension and encodes the file for you. `m.AttachReader` does the same for data already in memory. Note that `AttachFile` returns nothing and silently skips a file it cannot find, so check the path first.

Keep attachments small. Many inboxes reject messages over 25 MB, and large files hurt deliverability. For big files, send a signed download link.

---

## Send in the background

Sending inside a web request adds latency and fails the request when the provider is slow. Move it to a worker and retry the errors that can succeed later.

First, decide which errors to retry:

```go
func isRetryable(err error) bool {
	var apiErr *reloop.APIError
	if !errors.As(err, &apiErr) {
		return false // ValidationError: the same input fails every time
	}
	return apiErr.Status == 0 || apiErr.Status == 429 || apiErr.Status >= 500
}
```

**Retry** network errors (status `0`), `429` and `5xx`. **Do not retry** other `4xx` errors: an invalid address or unverified domain fails the same way every time.

### A channel worker

Go makes an in-process queue easy: a buffered channel and a few goroutines reading from it.

```go
type Mailer struct {
	client *reloop.Client
	jobs   chan reloop.SendMailParams
	wg     sync.WaitGroup
}

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

// Enqueue returns immediately. It returns false if the buffer is full.
func (m *Mailer) Enqueue(p reloop.SendMailParams) bool {
	select {
	case m.jobs <- p:
		return true
	default:
		return false
	}
}

// Close stops accepting jobs and waits for queued ones to finish.
func (m *Mailer) Close() {
	close(m.jobs)
	m.wg.Wait()
}

func (m *Mailer) work() {
	defer m.wg.Done()
	for p := range m.jobs {
		m.sendWithRetry(p)
	}
}

func (m *Mailer) sendWithRetry(p reloop.SendMailParams) {
	backoff := time.Second
	for attempt := 1; attempt <= 5; attempt++ {
		_, err := m.client.Mail.Send(p)
		if err == nil {
			return
		}
		if !isRetryable(err) {
			log.Printf("email to %v failed, not retrying: %v", p.To, err)
			return
		}
		log.Printf("email to %v failed (attempt %d): %v", p.To, attempt, err)
		time.Sleep(backoff)
		backoff *= 2
	}
}
```

Create one `Mailer` at startup, call `Enqueue` from handlers, and call `Close` during graceful shutdown so queued emails finish.

Be honest about what this is: the queue lives in memory. A crash, deploy or OOM kill loses everything still in the channel. That is acceptable for low-stakes mail. For receipts, password resets and anything else that must arrive, use a persistent queue.

### A persistent queue with River

[River](https://riverqueue.com) stores jobs in Postgres, so they survive restarts, and you can insert the job in the same transaction that creates the user. [asynq](https://github.com/hibiken/asynq) is the Redis-based equivalent. With River, a worker looks like this:

```go
type WelcomeEmailArgs struct {
	Email string `json:"email"`
}

func (WelcomeEmailArgs) Kind() string { return "welcome_email" }

type WelcomeEmailWorker struct {
	river.WorkerDefaults[WelcomeEmailArgs]
	Reloop *reloop.Client
}

func (w *WelcomeEmailWorker) Work(ctx context.Context, job *river.Job[WelcomeEmailArgs]) error {
	_, err := w.Reloop.Mail.Send(reloop.SendMailParams{
		From:    "Acme <hello@mail.acme.com>",
		To:      job.Args.Email,
		Subject: "Welcome to Acme",
		Text:    reloop.String("Thanks for signing up."),
	})
	if err != nil && !isRetryable(err) {
		return river.JobCancel(err) // 4xx: do not retry
	}
	return err // nil, or a retryable error River retries with backoff
}
```

Register it with `river.AddWorker(workers, &WelcomeEmailWorker{Reloop: client})`. River retries returned errors with exponential backoff, up to 25 attempts by default. With asynq, return an error wrapping `asynq.SkipRetry` for the same effect as `river.JobCancel`.

---

## Retries without duplicate emails

A request times out and your worker retries. If the first attempt actually went through, the user gets two emails. The API prevents this with an [`Idempotency-Key` header](/docs/api/idempotency): repeat a request with the same key and Reloop returns the original result instead of sending again.

The Go SDK does not set this header, so call the endpoint with `net/http`. You can still reuse the SDK's types for the body, the response and the error:

```go
var httpClient = &http.Client{Timeout: 30 * time.Second}

func sendReceipt(ctx context.Context, orderID, to string) (*reloop.SendMailResponse, error) {
	body, err := json.Marshal(reloop.SendMailParams{
		From:    "Acme <orders@mail.acme.com>",
		To:      to,
		Subject: "Receipt for order " + orderID,
		Text:    reloop.String("Thanks for your order."),
	})
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		"https://reloop.sh/api/mail/v1/send", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("x-api-key", os.Getenv("RELOOP_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", "receipt-"+orderID)

	res, err := httpClient.Do(req)
	if err != nil {
		// Same shape as the SDK, so isRetryable works unchanged
		return nil, &reloop.APIError{Status: 0, StatusText: "Network Error",
			Body: reloop.APIErrorBody{Message: err.Error()}}
	}
	defer res.Body.Close()

	if res.StatusCode >= 400 {
		apiErr := &reloop.APIError{Status: res.StatusCode, StatusText: res.Status}
		_ = json.NewDecoder(res.Body).Decode(&apiErr.Body)
		return nil, apiErr
	}

	var out reloop.SendMailResponse
	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
		return nil, err
	}
	return &out, nil
}
```

Base the key on the business event (`receipt-{orderID}`), not a new UUID per attempt, so retries from any worker are safe. If a request with the same key is still running, the API returns `409` with `Retry-After: 1`. Treat it like any other retryable error and try again with the same key. As a bonus, this version takes a `context.Context`, so it stops when the caller cancels.

---

## Testing without sending real email

- **Unit tests:** point the SDK at an `httptest.Server` with `BaseURL` and assert on the request body. No mocks or interfaces needed:

```go
func TestSendWelcome(t *testing.T) {
	var got map[string]any
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewDecoder(r.Body).Decode(&got)
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"success":true,"id":"em_test"}`))
	}))
	defer srv.Close()

	client, _ := reloop.NewClient(reloop.ClientOptions{
		APIKey:  "rl_test",
		BaseURL: srv.URL,
	})

	if err := SendWelcome(client, "ada@example.com"); err != nil {
		t.Fatal(err)
	}
	if got["to"] != "ada@example.com" {
		t.Errorf("to = %v", got["to"])
	}
}
```

- **Staging:** send to [test addresses](/docs/guides/testing-email-addresses) so you do not hurt your [sender reputation](/blog/why-emails-land-in-spam-fix) with bounces.
- **Local SMTP:** run [Mailpit](https://mailpit.axllent.org) with `docker run -p 1025:1025 -p 8025:8025 axllent/mailpit` and call `smtp.SendMail("localhost:1025", nil, ...)` with no auth. Open `http://localhost:8025` to see the messages.

---

## Production checklist

- [ ] API key in an environment variable or secrets manager
- [ ] Sending domain verified with SPF, DKIM and DMARC
- [ ] `From` on your verified domain; user addresses in `ReplyTo`
- [ ] Both HTML and plain-text bodies
- [ ] One shared client, created at startup
- [ ] Sending in a worker (River, asynq, or a channel worker for low-stakes mail), not the request
- [ ] Retries only on network errors, `429` and `5xx`
- [ ] Idempotency keys on anything that retries
- [ ] Graceful shutdown that drains queued emails
- [ ] [Webhooks](/features/webhooks) for bounces and complaints
- [ ] Disposable addresses blocked at signup ([how](/blog/block-disposable-emails-at-signup))

---

## FAQ

<AccordionGroup>
  <Accordion title="How do I send an HTML email in Go?">
    With net/smtp, build a multipart/alternative body with mime/multipart: a
    text/plain part, then a text/html part, and set the Content-Type header
    with the boundary. With go-mail, call SetBodyString and
    AddAlternativeString. With the Reloop SDK, set the HTML and Text fields.
  </Accordion>

  <Accordion title="Why does net/smtp fail with an unencrypted connection error?">
    smtp.PlainAuth refuses to send credentials unless the connection uses TLS
    or the server is localhost. Connect on port 587 and let SendMail upgrade
    with STARTTLS. If the server does not offer STARTTLS, you get this error
    instead of leaking your password.
  </Accordion>

  <Accordion title="Does the Reloop Go SDK support context.Context?">
    Not yet. Mail.Send uses an http.Client with a 30 second timeout, which you
    can replace with SetHTTPClient. If you need cancellation, call the HTTP
    API directly with http.NewRequestWithContext, as shown in the idempotency
    section.
  </Accordion>

  <Accordion title="Why do my Go emails go to spam?">
    Usually the sending domain is not authenticated, or the From domain does
    not match the domain that signed the message. Set up SPF, DKIM and DMARC,
    send from your own domain, and always include a plain-text body.
  </Accordion>

  <Accordion title="Should I send email from a goroutine?">
    A goroutine keeps the request fast, but it has no retries and is lost if
    the process exits. Use it for low-stakes mail. For anything that must
    arrive, use a persistent queue like River or asynq with retry rules and an
    idempotency key.
  </Accordion>
</AccordionGroup>

---

<Cta
  title="Send your first email from Go"
  description="Go SDK with no dependencies, SMTP relay for net/smtp and go-mail, and a free tier of 6,000 emails a month."
  href="/dashboard/signup"
  label="Get started"
/>