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.
Prerequisites
Section titled “Prerequisites”- A comail account with at least one enrolled sending domain. Sign up at /signup.
- Your DID (
did:plc:…) and API key. Regenerate the key at /account/api-keys if it’s lost. - Go 1.21+ (
go version). - A recipient address you can check.
HTTP variant (net/http)
Section titled “HTTP variant (net/http)”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-hereexport COMAIL_API_KEY=atmos_your-api-keygo run send.goExpected:
accepted: 1rejected: 0 -> alice@example.com (messageId: 12345)SMTP variant (net/smtp)
Section titled “SMTP variant (net/smtp)”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.goA couple of things to know about the SMTP path:
- Port 587 with STARTTLS only. Port 465 (implicit TLS) is not exposed.
net/smtpis 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 forgithub.com/wneessen/go-mailor similar.- The username for
PlainAuthis either your DID or your enrolled sending domain. The DID is preferred; the domain is the fallback for tooling that can’t pass colons.
Reusable client
Section titled “Reusable client”For a project where you’ll send from multiple places:
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.APIErrorswitch {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.
Many recipients
Section titled “Many recipients”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)}Note on messageId
Section titled “Note on messageId”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.
Next steps
Section titled “Next steps”- Message categories: pick the right tag.
- Bounces + suppression: handle DSNs and the suppression list.
- Reply forwarding: get replies back to your inbox.