Go SDK
A Go client for the API that does the encryption the Mailcycle apps do, on your machine. With it a program can create addresses that receive mail, open what arrives, and send. The recovery phrase never leaves the machine it runs on.
It needs Go 1.26 or later.
go get mailcycle.email/goSign in
Section titled “Sign in”import "mailcycle.email/go" // package mailcycle
mc, err := mailcycle.SignIn(os.Getenv("MAILCYCLE_PHRASE"), mailcycle.Options{})This is the same handshake the apps use: nothing secret crosses the wire, and it works on every plan.
With an API key instead, on Operator and up:
mc, err := mailcycle.WithAPIKey( os.Getenv("MAILCYCLE_API_KEY"), os.Getenv("MAILCYCLE_PHRASE"), // pass "" to manage without opening mail mailcycle.Options{},)Without the phrase a client can manage addresses, devices and metadata, but cannot open mail or create an address that receives it, because the address’s key is derived from the phrase. With both, the phrase is checked against the key’s account before anything is opened.
Each SignIn opens a 30-day session. Keep mc.SessionToken() somewhere private and pick it up with mailcycle.Resume(token, phrase, options) rather than signing in on every run.
Addresses
Section titled “Addresses”address, err := mc.Addresses().Create(mailcycle.NewAddress{ Prefix: "signup", Label: "Test run 42",})
addresses, err := mc.Addresses().List()The public half of the address’s key goes up with the request so the mail server can seal what arrives; the private half stays on your machine. The label is sealed locally, and the server stores it as ciphertext.
To have the server pick a name, set Style to mailcycle.StylePerson, StyleBusiness, StyleTeam or StyleNeutral. It is used only when LocalPart and Prefix are empty. With none of the three, the name is neutral.
address, err := mc.Addresses().Create(mailcycle.NewAddress{Style: mailcycle.StylePerson})To choose from a few first, RollNames returns up to eight that are free on the domain right now, like maya.holt or harbor-supply42. An empty domain means the first the account can use.
names, err := mc.Addresses().RollNames(mailcycle.StyleBusiness, "")address, err := mc.Addresses().Create(mailcycle.NewAddress{LocalPart: names[0]})A rolled name works as LocalPart on every plan; any other name needs Scale and up. Someone may take a rolled name before you use it, and Create then returns an error.
Reading mail
Section titled “Reading mail”messages, err := mc.Messages().All(address.ID, "")for _, message := range messages { fmt.Println(message.FromAddress, message.Subject) fmt.Println(message.Text)}
data, err := mc.Messages().Attachment(messages[0], 0) // []byte, opened locallyA message that will not open, because there is no phrase or it is the wrong one, comes back with Opened == false and empty content rather than an error, so one unreadable message does not hide the rest of a page.
message.HTML is the HTML as the sender wrote it. message.SafeHTML has every remote image, stylesheet and embed removed, as the apps do it, and is the one to render; message.Blocked lists what was taken out.
Waiting for a message
Section titled “Waiting for a message”code, err := mc.Messages().WaitFor(mailcycle.WaitOptions{ AddressID: address.ID, Subject: "verification",})Listens on the event stream where it is available and polls the address otherwise, and returns the message opened. If nothing matching arrives in time it returns an *APIError with Code == "timeout". Mail received from Since on counts, even if it landed before the stream was up; it defaults to now, so to wait for a reply to something you are about to send, take the time before sending.
Sending
Section titled “Sending”sent, err := mc.Messages().Send(mailcycle.Outgoing{ From: address.EmailAddress, To: "someone@example.com", Subject: "Hello", Text: "Hi",})sent.SentCopyID is the copy in Sent, which Messages().Get takes, and is empty when the address keeps no copy. sent.MessageID is the Message-ID header.
Set InReplyTo to thread a reply. A session can send from Personal up; an API key on Scale and up. Free receives only.
Attachments take raw bytes and the client encodes them. A message can carry up to ten files, 3.5 MB between them. Give a file a ContentID to show it inline, where the HTML says cid: and that id. Program and script files, like .exe and .js, are refused.
pdf, _ := os.ReadFile("invoice.pdf")logo, _ := os.ReadFile("logo.png")_, err := mc.Messages().Send(mailcycle.Outgoing{ From: address.EmailAddress, To: "someone@example.com", Subject: "Your invoice", Text: "Attached.", HTML: `<img src="cid:logo"><p>Attached.</p>`, Attachments: []mailcycle.OutgoingAttachment{ {Filename: "invoice.pdf", MimeType: "application/pdf", Content: pdf}, {Filename: "logo.png", MimeType: "image/png", Content: logo, ContentID: "logo"}, },})Events and webhooks
Section titled “Events and webhooks”stream, err := mc.Events().Stream(nil)defer stream.Close()
for { event, ok := stream.Next(0) if !ok { break } fmt.Println(event.Type)}Events carry ids, never content, and are not replayed after a drop, so StreamHooks.OnReconnect is where to refetch what you are showing.
To check a webhook came from Mailcycle, pass the body exactly as it arrived:
mailcycle.VerifyWebhookSignature(secret, rawBody, r.Header.Get("Mailcycle-Signature"))API keys
Section titled “API keys”key, token, err := mc.APIKeys().Create("CI runner") // keep token; it is shown oncekeys, err := mc.APIKeys().List()err = mc.APIKeys().Revoke(key.ID)These need a session; an API key gets session_required. The name is sealed on your machine. A name that will not open comes back empty with NameOpened == false. See API keys.
Webhooks
Section titled “Webhooks”webhook, secret, err := mc.Webhooks().Create( "https://example.com/mailcycle", []string{"message.received"}, "Orders",)
off := false_, err = mc.Webhooks().Update(webhook.ID, mailcycle.WebhookUpdate{Enabled: &off})List, Delete, RotateSecret, Test, Deliveries and Redeliver do what the webhook endpoints do. The secret is shown on create and on rotate, and never again. Creating, changing, rotating and deleting need a session.
Sessions and deleting the account
Section titled “Sessions and deleting the account”sessions, err := mc.Sessions().List()ended, err := mc.Sessions().RevokeOthers()
err = mc.DeleteAccount()DeleteAccount erases the account and all its mail at once, with no undo, and needs a session. SignOut on an API-key client does nothing; revoke the key instead.
Pairing devices
Section titled “Pairing devices”device, err := mc.Devices().Pair("4827 1934", "Front desk") // the 8 digitsdevice, err = mc.Devices().Pair("MC1:48271934:9vQ...", "Front desk") // or the QR text
err = mc.Devices().Assign(device.ID, address.ID)err = mc.Devices().Unassign(device.ID, address.ID)err = mc.Devices().HandOverKeys(device.ID)This is the app’s Add device screen, done by a script. A new device holds no addresses. Assign gives it one and hands it the keys, sealed on your machine. Unassign takes one back and hands over a fresh set without it. HandOverKeys does the handover alone.
The QR text carries the device’s own secret, so nothing about the keys passes through Mailcycle. With only the digits, a secret is made here and sealed to a key the device published. The server relays that key, so at pairing it could put its own in its place and read the keys handed over after. The QR path does not have that step. A device that published no key cannot be paired from its digits; scan its QR code in the app, or pass the QR text.
All of this needs a session signed in with the phrase. An API key gets session_required.
Errors
Section titled “Errors”An API failure is an *APIError carrying the HTTP Status and the API’s own code. mailcycle.ErrorCode(err) pulls it out, and that is what to branch on.
if mailcycle.ErrorCode(err) == "rate_limited" { // back off}How the encryption is checked
Section titled “How the encryption is checked”It is checked against the published test vectors, including the refusals.
© Northlab Studios Ltd, trading as Mailcycle. Registered in England and Wales, company no. 17462734. Registered office: 167-169 Great Portland Street, London W1W 5PF.