Go Email SDK: Build a Cancellable Email Worker
Use the Robotomail Go email SDK to list mailboxes, send with a display name, and run an SSE worker with context cancellation and manual reconnect.
John Joubert
Founder, Robotomail

Table of contents
The Robotomail Go email SDK is a small generated client that wraps the v1 API: list mailboxes, send messages, and stream inbound events over SSE. Every call takes a context.Context, so cancellation and deadlines are yours to control, and a long-lived mailbox worker shuts down cleanly when the process gets a signal. This tutorial builds that worker end to end, with real error handling instead of discarded errors.
Two things to be clear about before you start: the SDK does not retry sends, and it does not reconnect an SSE stream for you. Both are deliberate. You decide the retry policy, and you decide whether a dropped stream resumes from Last-Event-ID or starts fresh.
Install and authenticate
The SDK targets Go 1.22+. Install the tested tag:
go get github.com/robotomail/robotomail-go@v0.1.0
The source lives at robotomail-go v0.1.0 under MIT. Install by module path with go get, which is the normal Go workflow, there is no separate package registry step.
robotomail.NewClient reads ROBOTOMAIL_API_KEY from the environment when you pass an empty Options. Keep the key server side only:
export ROBOTOMAIL_API_KEY=rm_your_api_key
Key scope matters here. Mailbox-scoped keys work for sending and reading inside their own mailbox, while account-level operations such as listing all mailboxes and uploading attachments need a full-access key. See authentication for the full matrix.
First request: list mailboxes, pick a sender
Start with the mailbox your account already has. This is the smallest program that proves your key, network path, and module version are all good:
package main
import (
"context"
"fmt"
"log"
robotomail "github.com/robotomail/robotomail-go"
)
func main() {
mail, err := robotomail.NewClient(robotomail.Options{}) // reads ROBOTOMAIL_API_KEY
if err != nil {
log.Fatal(err)
}
result, err := mail.ListMailboxes(context.Background())
if err != nil {
log.Fatal(err)
}
for _, box := range result.Mailboxes {
fmt.Println(box.FullAddress)
}
}
box.FullAddress is the routable address, for example shopping-agent@robotomail.co. If you want a dedicated mailbox for this worker rather than reusing an existing one, create it first:
npx @robotomail/cli mailbox create shopping-agent
Mailboxes are cheap and isolated, which is why most agent designs give each agent its own. The reasoning behind that is covered in mailboxes as a concept.
Send with a display name and a deadline
Send bodies require to, subject and bodyText. displayName sets the friendly From name that recipients see, so your agent shows up as "Shopping Agent" rather than a bare address. Wrap the call in a deadline so a hung TCP connection cannot stall your worker:
func sendUpdate(ctx context.Context, mail *robotomail.Client, mailboxID string) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
sent, err := mail.SendMessage(ctx, mailboxID, robotomail.SendMessageRequest{
To: []string{"vendor@example.com"},
Subject: "Order #A-4521 delivery update?",
BodyText: "Hi, checking on the status of order A-4521.",
DisplayName: "Shopping Agent",
})
if err != nil {
return fmt.Errorf("send order update: %w", err)
}
log.Printf("sent message %s", sent.MessageID)
return nil
}
The request struct mirrors the JSON body field for field, so the API reference for messages is also the field reference for the SDK.
On an ambiguous failure, a deadline that fires while the request is in flight, do not blindly resend. The accept may already have happened. List the mailbox's sent messages and check before retrying. That is the whole reason the SDK stays out of the retry business: only your code knows whether a duplicate email is acceptable.
Stream inbound events over SSE
For receiving, you have two options. Webhooks push JSON to a public HTTPS endpoint, which is the right choice for serverless and for anything behind a load balancer. SSE is the right choice for a worker process with no inbound ingress, like a container on a private network or a binary on your laptop. The tradeoffs are laid out in webhooks vs websockets, and SSE semantics including event IDs are documented under events.
A stream consumer in Go is a loop over Next(), then a single check of Err() when the loop ends:
func consume(ctx context.Context, mail *robotomail.Client, lastEventID *string) error {
stream, err := mail.StreamEvents(ctx, robotomail.StreamOptions{
LastEventID: *lastEventID,
})
if err != nil {
return fmt.Errorf("open stream: %w", err)
}
defer stream.Close()
for stream.Next() {
ev := stream.Event()
if ev.ID != "" {
*lastEventID = ev.ID
}
if ev.Event != "message.received" {
continue
}
if err := handle(ctx, mail, ev); err != nil {
log.Printf("handle %s: %v", ev.Data.MessageID, err)
}
}
return stream.Err()
}
Two details that bite people:
- Record the event ID before handling, and persist
*lastEventIDif your worker restarts across processes. A resume is only as good as the last ID you kept. defer stream.Close()is not optional. Without it, a returning function leaks the underlying HTTP response body.
The inbound payload fields match the webhook JSON, so ev.Data carries MessageID, MailboxID, MailboxAddress, From, Subject, BodyText, ThreadID and ReceivedAt. Replying in thread means passing the inbound message's RFC Message-ID through InReplyTo:
func handle(ctx context.Context, mail *robotomail.Client, ev robotomail.Event) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
_, err := mail.SendMessage(ctx, ev.Data.MailboxID, robotomail.SendMessageRequest{
To: []string{ev.Data.From},
Subject: "Re: " + ev.Data.Subject,
BodyText: "Got it, I am checking on that now.",
DisplayName: "Shopping Agent",
InReplyTo: ev.Data.MessageID,
})
if err != nil {
return fmt.Errorf("reply in thread %s: %w", ev.Data.ThreadID, err)
}
return nil
}
Threading is header driven, not subject driven. If you skip InReplyTo, Gmail and Outlook will often split the conversation. See threading for why.
Clean shutdown on SIGTERM
Never put a context.WithTimeout on a stream unless you actually want a hard session cap. Use a cancellable context tied to signals instead, so Kubernetes draining a pod ends the stream in milliseconds rather than waiting for a TCP timeout:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
mail, err := robotomail.NewClient(robotomail.Options{})
if err != nil {
log.Fatalf("client: %v", err)
}
listCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
boxes, err := mail.ListMailboxes(listCtx)
cancel()
if err != nil {
log.Fatalf("list mailboxes: %v", err)
}
if len(boxes.Mailboxes) == 0 {
log.Fatal("no mailboxes on this account, create one first")
}
log.Printf("worker listening for %s", boxes.Mailboxes[0].FullAddress)
if err := runWorker(ctx, mail); err != nil && !errors.Is(err, context.Canceled) {
log.Fatalf("worker: %v", err)
}
log.Print("worker stopped cleanly")
}
Cancelling the parent context closes the stream, unblocks Next(), and lets consume return through its defer.
Explicit reconnect with Last-Event-ID
Reconnect is your loop, not the SDK's. Distinguish "the context was cancelled" from "the connection broke", back off on the second case, and resume from the last ID you saw:
func runWorker(ctx context.Context, mail *robotomail.Client) error {
var lastEventID string
backoff := time.Second
for {
err := consume(ctx, mail, &lastEventID)
if ctx.Err() != nil {
return ctx.Err() // shutdown, not a failure
}
if err == nil {
log.Print("stream closed by server, reconnecting")
} else {
log.Printf("stream error: %v", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
Reset backoff to one second after a stream that stayed up for a while, otherwise a single flaky hour leaves you reconnecting every thirty seconds forever.
Because a resume can replay an event you already processed, make handle idempotent. Key your dedupe store on ev.Data.MessageID and drop repeats before you send anything.
Error handling that is not ignored
Three rules keep a Go mail worker debuggable:
- Wrap, do not swallow.
fmt.Errorf("send order update: %w", err)keeps the operation name attached while leaving the API error type inspectable witherrors.As. - Check
ctx.Err()before treating any failure as a real error. A cancelled context looks like a network failure if you only read the returned error string. - Log per-message handler failures and keep the loop alive. One malformed inbound email should not kill the stream. Let the handler return an error, log it with the message ID, and continue.
Error codes and their meanings are listed under API errors.
Troubleshooting
401 on ListMailboxes but sends work. You are holding a mailbox-scoped key. Account operations need a full-access key.
context deadline exceeded on sends only. Your per-send timeout is too tight for a message with attachments. Upload attachments first, then pass the returned IDs in attachments, and give the send 30 seconds.
stream.Next() returns false immediately. Check stream.Err(). An auth failure or a bad LastEventID surfaces there, and if you ignore Err() the symptom looks like an empty inbox.
Duplicate replies after a restart. Your lastEventID is in memory only, or your handler is not idempotent. Persist the ID and dedupe on message ID.
Build picks up the wrong version. Pin it: go get github.com/robotomail/robotomail-go@v0.1.0, then confirm with go list -m github.com/robotomail/robotomail-go.
FAQ
Does the Go SDK retry failed sends?
No. A send returns an error and stops there. That is intentional, since a blind retry on an ambiguous timeout can deliver the same email twice. List the mailbox's messages to confirm state before resending.
Does it reconnect SSE automatically?
No. When the stream ends, Next() returns false and Err() tells you why. Reconnecting, backing off, and passing LastEventID are all your code, as shown in the worker loop above.
Should I use SSE or webhooks in Go?
Use webhooks when you already run an HTTP
Give your AI agent a real email address
Create a mailbox, connect your agent and test a conversation. Send, receive and retrieve the thread through one API.
Related posts

Rust Email SDK: Async Inbox Worker with Tokio
Build an async Rust inbox worker with Tokio and the Robotomail Rust SDK: list mailboxes, send with a sender name, and stream inbound SSE frames.
Read post
How to Connect an AI Agent to a Real Email Inbox
Give your AI agent its own real email inbox: provision a mailbox, send via API, receive inbound webhooks, and reply in thread. Working code included.
Read post
How to Parse Inbound Email Webhook Payloads
How to parse inbound email webhook payloads: required fields, MIME edge cases, attachments, threading ids, and idempotent retry handling.
Read post