OquMail
Loading account…
Email API2 min readUpdated

Send Email from Go: net/http API Call or net/smtp on Port 587

Send email from a Go service on your own domain: a small net/http client with context timeouts, the net/smtp alternative with STARTTLS on 587, and retry rules.

Go's standard library covers both ways to send: net/http for a JSON API call and net/smtp for talking to a mail server directly. The API call is shorter, returns an id you can log, and needs only port 443 open, which matters on cloud hosts that block SMTP ports for new accounts. net/smtp is fine for a server-side tool that already builds raw messages. Both fit in one file.

Quick answer

Read the key from OQUMAIL_API_KEY, build a struct with To []string, Subject and HTML or Text, marshal it, and POST to https://api.oqumail.com/api/v1/emails with a Bearer header and a context timeout. Treat 202 as success, 429 and 403 as "wait and retry", and everything else as a bug to log. For SMTP, smtp.SendMail on mail.oqumail.com:587 with smtp.PlainAuth upgrades to STARTTLS by itself.

Option 1: a small API client

package mail

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

const endpoint = "https://api.oqumail.com/api/v1/emails"

type Message struct {
	To       []string `json:"to"`
	Subject  string   `json:"subject"`
	HTML     string   `json:"html,omitempty"`
	Text     string   `json:"text,omitempty"`
	FromName string   `json:"fromName,omitempty"`
}

type Receipt struct {
	ID        string `json:"id"`
	MessageID string `json:"messageId"`
	Status    string `json:"status"`
}

var ErrRetryLater = errors.New("oqumail: retry later")

func Send(ctx context.Context, m Message) (*Receipt, error) {
	body, err := json.Marshal(m)
	if err != nil {
		return nil, err
	}
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("OQUMAIL_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	switch res.StatusCode {
	case http.StatusAccepted:
		var r Receipt
		return &r, json.NewDecoder(res.Body).Decode(&r)
	case http.StatusTooManyRequests, http.StatusForbidden:
		return nil, ErrRetryLater
	default:
		b, _ := io.ReadAll(res.Body)
		return nil, fmt.Errorf("oqumail: %d %s", res.StatusCode, b)
	}
}

Call it

receipt, err := mail.Send(ctx, mail.Message{
	To:       []string{user.Email},
	Subject:  "Your export is ready",
	Text:     "Download it from the dashboard within 24 hours.",
	FromName: "Acme",
})
if errors.Is(err, mail.ErrRetryLater) {
	// re-enqueue with backoff; do not loop
}
log.Printf("queued %s", receipt.ID)

Free business email on your own domain

OquMail gives you up to 15 mailboxes on your domain — free — with guided SPF/DKIM/DMARC, webmail, IMAP/SMTP for any mail app, and a send API. Setup is a few DNS records; changes usually take effect within minutes, occasionally up to 48 hours. Start at oqumail.com.

Get started free

Option 2: net/smtp

SMTP (the standard way computers hand off outgoing email).SendMail issues STARTTLS automatically when the server advertises it, which mail.oqumail.com does on 587. Build the message with CRLF line endings and a blank line before the body:

package main

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

func main() {
	user := "app@yourdomain.com"
	pass := os.Getenv("OQUMAIL_SMTP_PASSWORD")
	to := []string{"someone@example.com"}

	msg := strings.Join([]string{
		"From: Acme <" + user + ">",
		"To: " + to[0],
		"Subject: Nightly backup finished",
		"MIME-Version: 1.0",
		"Content-Type: text/plain; charset=UTF-8",
		"",
		"Backup completed at 02:14 UTC. 1.2 GB written.",
	}, "\r\n")

	auth := smtp.PlainAuth("", user, pass, "mail.oqumail.com")
	if err := smtp.SendMail("mail.oqumail.com:587", auth, user, to, []byte(msg)); err != nil {
		panic(err)
	}
}

What the API answers

  • 202 Accepted: the message is queued. The JSON carries id, messageId and status; store the id so a support question can be matched to the delivery log.
  • 400: the body failed validation, usually to missing or not an array, or neither html nor text present.
  • 401: the key is missing, revoked or mistyped.
  • 403: the mailbox is suspended, has reached its daily limit of 250, or sent more than 10 messages in a minute ("Sending too fast"). Back off; do not retry in a tight loop.
  • 422: the message was blocked by content checks; the response explains why. Do not retry unchanged.
  • 429: requests arrived faster than the per-key limit. Honour the Retry-After header.

Common questions

Should I retry on a network error?

Once or twice with a short backoff, but only if you can dedupe: a request that timed out may still have been accepted. Store your own id with the receipt id and check before resending; see idempotent sends.

Why does net/smtp fail with "unencrypted connection"?

PlainAuth refuses to send a password without TLS. That means STARTTLS did not happen, usually because the port was 25 or 465 rather than 587.

Can I send from a goroutine per message?

Yes, but cap concurrency at a handful. The mailbox allows 10 sends a minute; a burst of goroutines will hit 403 and waste the retries.