Skip to content

Go quickstart

Use the Go standard library to send with HTTP or SMTP.

Replace example addresses with your enrolled sending address and a real recipient address you control. The relay rejects IANA example domains such as example.com as recipients.

  1. A comail account with at least one enrolled sending domain. Sign up at /signup.
  2. Your DID (did:plc:…) and API key. Regenerate the key at /account/api-keys if it’s lost.
  3. Go 1.21+ (go version).
  4. A recipient address you can check.

Save as send.go:

package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type sendRequest struct {
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text,omitempty"`
HTML string `json:"html,omitempty"`
ReplyTo string `json:"replyTo,omitempty"`
Category string `json:"category,omitempty"`
}
type recipientResult struct {
Recipient string `json:"recipient"`
MessageID int64 `json:"messageId,omitempty"`
Error string `json:"error,omitempty"`
}
type sendResponse struct {
Accepted []recipientResult `json:"accepted"`
Rejected []recipientResult `json:"rejected"`
}
type sendError struct {
Code string `json:"code"`
Message string `json:"error"`
}
func main() {
did := os.Getenv("COMAIL_DID")
apiKey := os.Getenv("COMAIL_API_KEY")
if did == "" || apiKey == "" {
fmt.Fprintln(os.Stderr, "Set COMAIL_DID and COMAIL_API_KEY")
os.Exit(1)
}
body, err := json.Marshal(sendRequest{
From: "you@yourdomain.com",
To: "alice@example.com",
Subject: "Hello from comail",
Text: "If you can read this, the API key works.",
})
if err != nil {
fmt.Fprintln(os.Stderr, "marshal:", err)
os.Exit(1)
}
req, err := http.NewRequest("POST", "https://smtp.atmos.email/v1/send", bytes.NewReader(body))
if err != nil {
fmt.Fprintln(os.Stderr, "new request:", err)
os.Exit(1)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Atmos-DID", did)
req.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Fprintln(os.Stderr, "send:", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var e sendError
_ = json.NewDecoder(resp.Body).Decode(&e)
fmt.Fprintf(os.Stderr, "send failed: %d %s: %s\n", resp.StatusCode, e.Code, e.Message)
os.Exit(1)
}
var result sendResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Fprintln(os.Stderr, "decode:", err)
os.Exit(1)
}
fmt.Printf("accepted: %d\n", len(result.Accepted))
fmt.Printf("rejected: %d\n", len(result.Rejected))
for _, r := range result.Accepted {
fmt.Printf(" -> %s (messageId: %d)\n", r.Recipient, r.MessageID)
}
}

Run:

export COMAIL_DID=did:plc:your-did-here
export COMAIL_API_KEY=atmos_your-api-key
go run send.go

Expected:

accepted: 1
rejected: 0
-> alice@example.com (messageId: 12345)

net/smtp ships in the standard library. STARTTLS on port 587 plus SASL PLAIN.

package main
import (
"fmt"
"net/smtp"
"os"
"strings"
)
func main() {
did := os.Getenv("COMAIL_DID")
apiKey := os.Getenv("COMAIL_API_KEY")
if did == "" || apiKey == "" {
fmt.Fprintln(os.Stderr, "Set COMAIL_DID and COMAIL_API_KEY")
os.Exit(1)
}
// smtp.PlainAuth handles the SASL PLAIN handshake. The first
// argument (identity) is empty per RFC 4616. The username is
// the DID; comail also accepts the registered sending domain
// as a username if your toolchain can't pass a string with
// colons through cleanly.
auth := smtp.PlainAuth("", did, apiKey, "smtp.atmos.email")
from := "you@yourdomain.com"
to := []string{"alice@example.com"}
msg := strings.Join([]string{
"From: " + from,
"To: " + to[0],
"Subject: Hello from comail (SMTP)",
"",
"If you can read this, SMTP submission works.",
}, "\r\n")
// SendMail handles the connect, STARTTLS, AUTH, MAIL FROM,
// RCPT TO, DATA, and QUIT sequence in one call.
if err := smtp.SendMail("smtp.atmos.email:587", auth, from, to, []byte(msg)); err != nil {
fmt.Fprintln(os.Stderr, "send:", err)
os.Exit(1)
}
fmt.Println("sent")
}

Run the same way:

go run send.go

A couple of things to know about the SMTP path:

  • Port 587 with STARTTLS only. Port 465 (implicit TLS) is not exposed.
  • net/smtp is the stdlib’s minimal SMTP client. It’s frozen in Go 1.x. If you need richer features (DKIM signing on your side, threading, etc.) reach for github.com/wneessen/go-mail or similar.
  • The username for PlainAuth is either your DID or your enrolled sending domain. The DID is preferred; the domain is the fallback for tooling that can’t pass colons.

For a project where you’ll send from multiple places:

internal/comail/comail.go
package comail
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
)
type Client struct {
did string
apiKey string
http *http.Client
}
type Mail struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text,omitempty"`
HTML string `json:"html,omitempty"`
ReplyTo string `json:"replyTo,omitempty"`
Category string `json:"category,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
type Recipient struct {
Recipient string `json:"recipient"`
MessageID int64 `json:"messageId,omitempty"`
Error string `json:"error,omitempty"`
}
type Response struct {
Accepted []Recipient `json:"accepted"`
Rejected []Recipient `json:"rejected"`
}
type APIError struct {
Status int
Code string `json:"code"`
Message string `json:"error"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("comail %d %s: %s", e.Status, e.Code, e.Message)
}
// NewFromEnv reads COMAIL_DID + COMAIL_API_KEY from the environment.
// Returns an error if either is missing so callers can fail fast at
// startup instead of on first send.
func NewFromEnv() (*Client, error) {
did := os.Getenv("COMAIL_DID")
apiKey := os.Getenv("COMAIL_API_KEY")
if did == "" || apiKey == "" {
return nil, errors.New("COMAIL_DID and COMAIL_API_KEY must be set")
}
return &Client{
did: did,
apiKey: apiKey,
http: &http.Client{Timeout: 15 * time.Second},
}, nil
}
func (c *Client) Send(m Mail) (*Response, error) {
body, err := json.Marshal(m)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequest("POST", "https://smtp.atmos.email/v1/send", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Atmos-DID", c.did)
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
var apiErr APIError
_ = json.Unmarshal(raw, &apiErr)
apiErr.Status = resp.StatusCode
return nil, &apiErr
}
var out Response
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &out, nil
}

Usage:

client, err := comail.NewFromEnv()
if err != nil {
log.Fatal(err)
}
result, err := client.Send(comail.Mail{
From: "auth@yourdomain.com",
To: []string{"alice@example.com"},
Subject: "Sign-in link",
Text: "https://your-app.com/auth?token=abc123",
Category: "login-link",
})
var apiErr *comail.APIError
switch {
case errors.As(err, &apiErr):
switch apiErr.Code {
case "RATE_LIMITED":
// warming or per-member cap; back off
case "DOMAIN_MISMATCH":
// from: domain isn't enrolled
case "SUSPENDED", "PENDING":
// account-level issue; check /account
default:
log.Printf("send: %s", apiErr)
}
case err != nil:
log.Fatal(err)
default:
log.Printf("sent to %d recipients", len(result.Accepted))
}

See HTTP Send API for the full error code table.

To is a slice. Up to 50 per request. Each gets its own entry in Accepted or Rejected so you can detect partial failures:

result, _ := client.Send(comail.Mail{
From: "news@yourdomain.com",
To: []string{"alice@example.com", "bob@example.com"},
Subject: "Acme weekly",
HTML: "<p>Latest updates...</p>",
Category: "bulk",
})
for _, r := range result.Accepted {
log.Printf("OK %s: %d", r.Recipient, r.MessageID)
}
for _, r := range result.Rejected {
log.Printf("ERR %s: %s", r.Recipient, r.Error)
}

The relay returns messageId as a JSON number (Go int64). Don’t change it to a string in your own types unless you handle the conversion explicitly.

Back to docs