mailcycle

package module
v0.7.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 6, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

README

mailcycle-go

A Go client for the Mailcycle API that does the encryption on your machine. Create addresses that receive mail, read what arrives, send, and listen for events. Mailcycle stores mail sealed to keys derived from your recovery phrase, and this package derives them locally; the phrase never leaves your machine.

Needs Go 1.26 or later.

go get mailcycle.email/go

Sign in

import "mailcycle.email/go" // package mailcycle

// With the recovery phrase: everything the app can do, on any plan.
mc, err := mailcycle.SignIn(os.Getenv("MAILCYCLE_PHRASE"), mailcycle.Options{})

// With an API key (Operator and up). Add the phrase to open mail and create
// addresses that receive it; pass "" for a client that cannot.
scripted, err := mailcycle.WithAPIKey(
    os.Getenv("MAILCYCLE_API_KEY"),
    os.Getenv("MAILCYCLE_PHRASE"),
    mailcycle.Options{},
)

Signing in uses the same handshake as the app: the server sends a challenge and the client proves it holds the key, so nothing secret crosses the wire. With an API key and a phrase, 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. mailcycle.CreateAccount("", options) makes a new account and returns the client and its phrase; keep the phrase, because nothing can recover the account without it.

Addresses that receive mail

address, err := mc.Addresses().Create(mailcycle.NewAddress{
    Prefix: "signup",
    Label:  "Test run 42",
})
fmt.Println(address.EmailAddress)

The address's public key is derived from the phrase and sent with it, so the mail server can seal what arrives. The label is sealed on your machine, and the server stores it as ciphertext.

Reading mail

messages, err := mc.Messages().All(address.ID, "")
for _, message := range messages {
    fmt.Println(message.FromAddress, message.Subject)
}

data, err := mc.Messages().Attachment(message, 0)   // []byte, opened locally

An attachment with Stored == false had no body, so there is nothing to fetch. ContentID is set on a part the HTML shows inline.

A 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, remote images and all. message.SafeHTML has everything remote removed, the same way the app does it, and is the one to render; message.Blocked says what was taken out and who it would have reported to.

Waiting for mail

code, err := mc.Messages().WaitFor(mailcycle.WaitOptions{
    AddressID: address.ID,
    Subject:   "verification",
})

Listens on the event stream and returns the next matching message, opened. Where the stream cannot be opened it checks the address every five seconds instead, so AddressID is needed then. If nothing 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

sent, err := mc.Messages().Send(mailcycle.Outgoing{
    From:    address.EmailAddress,
    To:      "someone@example.com",
    Subject: "Hello",
    Text:    "Hi",
})
fmt.Println(sent.SentCopyID)   // the copy in Sent, for Messages().Get

_, err = mc.Messages().Send(mailcycle.Outgoing{
    From:      address.EmailAddress,
    To:        message.ReplyTo,
    Subject:   "Re: " + message.Subject,
    Text:      "Thanks",
    InReplyTo: &message,     // fills in the threading headers
})

sent.MessageID is the Message-ID header. sent.SentCopyID is empty when the address keeps no copy.

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"},
    },
})

A session can send from Personal up; an API key on Scale and up. Free receives only. Limits are in the rate limits.

Events

stream, err := mc.Events().Stream(nil)
defer stream.Close()

for {
    event, ok := stream.Next(0)
    if !ok {
        break
    }
    fmt.Println(event.Type, event.PayloadString("inboxId"))
}

The same events as webhooks, the moment they happen, and they carry ids rather than content. The stream reconnects after a drop, and events that happen while it is down are not replayed, so StreamHooks.OnReconnect is where to refetch whatever you are showing.

To check a webhook came from Mailcycle, pass the body exactly as it arrived, before any parsing:

mailcycle.VerifyWebhookSignature(secret, rawBody, r.Header.Get("Mailcycle-Signature"))

API keys

key, token, err := mc.APIKeys().Create("CI runner")   // keep token; it is shown once
keys, 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, like a label. A name that will not open, because there is no phrase or it is the wrong one, comes back empty with NameOpened == false.

Webhooks

webhook, secret, err := mc.Webhooks().Create(
    "https://example.com/mailcycle",
    []string{"message.received"},   // or []string{"*"}
    "Orders",
)
list, err := mc.Webhooks().List()   // list.Webhooks, list.EventTypes

off := false
_, err = mc.Webhooks().Update(webhook.ID, mailcycle.WebhookUpdate{Enabled: &off})

secret, err = mc.Webhooks().RotateSecret(webhook.ID)
_, _, err = mc.Webhooks().Test(webhook.ID)
page, err := mc.Webhooks().Deliveries(webhook.ID, "", 0)
_, err = mc.Webhooks().Redeliver(webhook.ID, page.Deliveries[0].ID)
err = mc.Webhooks().Delete(webhook.ID)

Operator and up. The secret is shown when the webhook is created and when it is rotated, and never again. Creating, changing, rotating and deleting need a session; an API key can list, test, read deliveries and redeliver. The name is sealed like an API key's.

Sessions and the account

sessions, err := mc.Sessions().List()       // Current marks this one
err = mc.Sessions().Revoke(sessions[1].ID)
ended, err := mc.Sessions().RevokeOthers()

subscription, err := mc.Subscription()
plans, err := mc.Plans()

err = mc.DeleteAccount()

DeleteAccount erases the account and all its mail at once, and there is no undo. Its addresses are never issued again. It confirms with the phrase by answering a fresh challenge. If the account balance still holds money you paid in, it is refused with balance_held: ask for the balance back from Support first. Sessions and deleting need a session. SignOut on an API-key client does nothing; revoke the key instead.

A Plan has its ID and Name, and every other field the API sends in Extra.

Pairing devices

device, err := mc.Devices().Pair("4827 1934", "Front desk")        // the 8 digits
device, 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. The device holds no addresses at first. 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, for when assignments changed some other way.

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

An API failure is an *APIError carrying the HTTP Status and the API's own Code, such as plan_required or session_required. mailcycle.ErrorCode(err) pulls it out, and that is what to branch on rather than the message. See errors.

How it is checked

The encryption is checked against Mailcycle's published test vectors (https://docs.mailcycle.email/vectors.json), including the refusals.

Licence

Apache License 2.0. Copyright (c) 2026 Northlab Studios Ltd. You may use, modify and redistribute this module, including inside your own applications, under the terms in LICENSE. Using the Mailcycle API itself is governed by the Terms of Service. Never ship an API key in software you distribute.

Documentation

Overview

Package mailcycle is a Go client for the Mailcycle API that does the encryption on your machine.

Create addresses that receive mail, read what arrives, send, and listen for events. Mailcycle stores mail sealed to keys derived from your recovery phrase, and this package derives them locally; the phrase never leaves your machine.

mc, err := mailcycle.SignIn(os.Getenv("MAILCYCLE_PHRASE"), mailcycle.Options{})
if err != nil {
	return err
}

address, err := mc.Addresses().Create(mailcycle.NewAddress{Label: "Sign-in codes"})
if err != nil {
	return err
}

message, err := mc.Messages().WaitFor(mailcycle.WaitOptions{AddressID: address.ID})
if err != nil {
	return err
}
fmt.Println(message.Subject, message.Text)

Lose the phrase and the mail is gone; nobody at Mailcycle can help.

How the encryption is checked

This is one of several implementations of Mailcycle's scheme, and another implementation is a liability unless it is proven rather than trusted. The test suite runs it against packages/crypto-vectors/vectors.json, the frozen vectors the app and the mail server are checked against, with the refusals checked as carefully as the successes. Two further suites have this package and the app seal for each other, and require both tracker strippers to agree exactly on the same markup.

Documentation: https://docs.mailcycle.email/

Index

Constants

View Source
const (
	// DefaultAPIURL is the live API.
	DefaultAPIURL = "https://api.mailcycle.email"
	// DefaultTimeout is how long a single request may take.
	DefaultTimeout = 30 * time.Second
)
View Source
const (
	// PhraseWordCount is how many words a recovery phrase has.
	PhraseWordCount = 12

	// SeedIterations is the derivation cost.
	//
	// Changing it changes every account id ever derived, so this number is
	// part of the format, not a tuning knob. Stretching buys little over a
	// 128-bit phrase and earns its keep only for the optional passphrase,
	// which may be low entropy.
	SeedIterations = 120_000
	// SeedBytes is the size of the root seed.
	SeedBytes = 64
)
View Source
const (
	// KeyTransportOptical is the QR path: the wrap secret came by camera.
	KeyTransportOptical = "optical"
	// KeyTransportRelayed is the typed-code path: the wrap secret was sealed
	// to a key the server relayed.
	KeyTransportRelayed = "relayed"
)
View Source
const AuthProofDomain = "mailcycle/v1/auth-proof"

AuthProofDomain separates one kind of proof from another, so neither can stand in for the other.

View Source
const DefaultToleranceSeconds int64 = 300

DefaultToleranceSeconds is how old a delivery may be and still be accepted.

View Source
const SealVersion = 2

SealVersion is which version new boxes are written as.

View Source
const Version = "0.7.1"

Version is this module's release, without the leading v. The release tool refuses to write a version that differs.

Variables

View Source
var ErrBase64URL = errors.New("not canonical base64url")

ErrBase64URL is returned for text that is not canonical base64url.

Functions

func ErrorCode

func ErrorCode(err error) string

ErrorCode returns the API's code for err, or "" if it is not an APIError. It is what a caller should branch on.

func Fingerprint

func Fingerprint(accountID string) string

Fingerprint is the short, human-comparable form the app shows when pairing.

func GeneratePhrase

func GeneratePhrase() string

GeneratePhrase returns a fresh twelve-word phrase from the operating system's CSPRNG.

func HostOf

func HostOf(url string) string

HostOf returns the host of a URL, lowercased and without a leading "www.".

func IsValidWord

func IsValidWord(word string) bool

IsValidWord reports whether word is in the list.

func NormalizePhrase

func NormalizePhrase(phrase string) []string

NormalizePhrase lowercases, drops everything that is not a letter, and splits on whitespace.

func SummarizeBlocked

func SummarizeBlocked(blocked []BlockedTracker) string

SummarizeBlocked is the one-line summary the app shows above a message.

func VerifyWebhookSignature

func VerifyWebhookSignature(secret string, rawBody []byte, header string) bool

VerifyWebhookSignature is VerifyWebhookSignatureAt against the system clock.

func VerifyWebhookSignatureAt

func VerifyWebhookSignatureAt(secret string, rawBody []byte, header string, toleranceSeconds, now int64) bool

VerifyWebhookSignatureAt reports whether the signature is Mailcycle's, recent, and over this exact body.

now is passed in rather than read from the clock, so this is testable and so a caller with a trusted time source can use it.

func WebhookSignatureHeader

func WebhookSignatureHeader(secret string, rawBody []byte, timestamp int64) string

WebhookSignatureHeader is the header Mailcycle would send for this body at this second.

Exported so a test suite can build a believable delivery without a live webhook, and so this implementation can be checked against the shared vectors rather than trusted.

Types

type APIError

type APIError struct {
	// Status is the HTTP status, or 0 when the request never got an answer.
	Status int
	// Code is the API's stable, machine-readable code.
	Code    string
	Message string
}

APIError is a request the API refused, or one that never reached it.

Match on Code, which is the API's own stable string (plan_required, rate_limited), rather than on the message.

func (*APIError) Error

func (e *APIError) Error() string

type APIKey

type APIKey struct {
	ID string
	// Name is what the key was called, opened with the phrase. Empty when
	// NameOpened is false.
	Name string
	// NameOpened is false when the name would not open: no phrase, or the
	// wrong one.
	NameOpened bool
	// Hint is the token's last four characters.
	Hint      string
	CreatedAt string
	// LastUsedAt is rounded down to five minutes, and empty until the key is
	// used.
	LastUsedAt string
}

APIKey is one API key. The token is shown only when the key is created.

type APIKeys

type APIKeys struct {
	// contains filtered or unexported fields
}

APIKeys is the account's API keys. Every call needs a session: an API key cannot list, mint or revoke keys, and gets session_required.

func (*APIKeys) Create

func (k *APIKeys) Create(name string) (APIKey, string, error)

Create mints a key and returns it with its token, "mak_...". The token is shown only this once, so keep it. Operator plan and up; 20 keys per account. Needs the phrase, to seal the name.

func (*APIKeys) List

func (k *APIKeys) List() ([]APIKey, error)

List returns the account's keys, newest first, with their names opened.

func (*APIKeys) Revoke

func (k *APIKeys) Revoke(keyID string) error

Revoke revokes a key. Anything using it is refused from then on.

type Account

type Account struct {
	ID        string
	PlanID    string
	CreatedAt string
	Plan      Plan
}

type AccountEvent

type AccountEvent struct {
	ID        string                     `json:"id"`
	Type      string                     `json:"type"`
	CreatedAt string                     `json:"createdAt"`
	Payload   map[string]json.RawMessage `json:"payload"`
}

AccountEvent is one event from the live stream. Events carry ids, never content.

func (AccountEvent) PayloadString

func (e AccountEvent) PayloadString(field string) string

PayloadString reads one string field out of an event's payload.

type ActivityEntry

type ActivityEntry struct {
	ID        string `json:"id"`
	Kind      string `json:"kind"`
	CreatedAt string `json:"createdAt"`
	AddressID string `json:"inboxId"`
	WorkerID  string `json:"workerId"`
}

type ActivityOptions

type ActivityOptions struct {
	// Limit is up to 200.
	Limit int
	// Cursor is a page's NextCursor, passed back as it came. It is opaque.
	Cursor string
}

ActivityOptions picks a page of the event log. The zero value is the newest page in the server's default size.

type ActivityPage

type ActivityPage struct {
	Activity []ActivityEntry
	// NextCursor fetches the page before when passed back as
	// ActivityOptions.Cursor. It is opaque, and empty on the last page.
	NextCursor string
}

type Address

type Address struct {
	ID           string
	EmailAddress string
	// Status is "active" when assigned to a device, "unassigned" otherwise.
	Status           string
	CreatedAt        string
	RetentionDays    int
	AssignedWorkerID string
	PausedAt         string
	// Label is what was set in the app. Empty without the phrase, or if it
	// will not open.
	Label string
	// SenderName is the name mail from the address goes out under, set in
	// the app. Empty without the phrase, or if none is set.
	SenderName string
	// Receives says whether the address can receive mail: it was created with
	// its public key.
	Receives bool
	// KeyEpoch is the key epoch mail is sealed under now: 0 unless its keys
	// were rotated.
	KeyEpoch int
}

type Addresses

type Addresses struct {
	// contains filtered or unexported fields
}

Addresses is the email addresses on the account.

func (*Addresses) Allocation

func (a *Addresses) Allocation() (map[string]json.RawMessage, error)

Allocation says how many addresses the plan allows and how many are left.

func (*Addresses) Create

func (a *Addresses) Create(wanted NewAddress) (Address, error)

Create makes an address that can receive mail.

Needs the phrase: the address's public key is derived from it here, and only the public half is uploaded. Mail sealed to it opens nowhere else.

func (*Addresses) Delete

func (a *Addresses) Delete(addressID string) error

Delete removes the address and its mail. It is never issued again.

func (*Addresses) List

func (a *Addresses) List() ([]Address, error)

func (*Addresses) RollNames

func (a *Addresses) RollNames(style NameStyle, domain string) ([]string, error)

RollNames returns up to eight names in one style that are free on the domain right now. Pass one to Create as LocalPart. Someone may take it first, and Create says so.

An empty style means neutral. An empty domain means the first the account can use, as in Create.

func (*Addresses) Update

func (a *Addresses) Update(addressID string, label *string, retentionDays int) (Address, error)

Update changes a label, a retention window, or both.

A nil label leaves it alone; a pointer to the empty string clears it. Changing a label needs the phrase.

type AsymmetricBox

type AsymmetricBox struct {
	V int `json:"v"`
	// EPK is the sender's ephemeral public key.
	EPK string `json:"epk"`
	N   string `json:"n"`
	C   string `json:"c"`
	T   string `json:"t"`
}

AsymmetricBox is what the mail server writes into a message's content.

type Attachment

type Attachment struct {
	Index    int
	Filename string
	MimeType string
	Size     int64
	// ContentID is set on a part the HTML shows inline, as cid:ContentID.
	ContentID string
	// Stored is false when the part had no body. There is nothing to fetch,
	// and Attachment would get a 404.
	Stored bool
}

type BlockedTracker

type BlockedTracker struct {
	URL  string `json:"url"`
	Host string `json:"host"`
	// Kind is "pixel", "image" or "link".
	Kind string `json:"kind"`
}

BlockedTracker is one remote resource that was removed.

func FindTrackingLinks(text string) []BlockedTracker

FindTrackingLinks finds trackers in a plain-text body, which carry them too as one-pixel-equivalent link opens.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a Mailcycle client.

func CreateAccount

func CreateAccount(phrase string, options Options) (*Client, string, error)

CreateAccount creates an account and signs in, returning the client and its phrase.

Keep the phrase. Nothing can recover the account without it, and nobody at Mailcycle can help. Pass an empty phrase to have one generated here.

func Resume

func Resume(sessionToken, phrase string, options Options) (*Client, error)

Resume picks up a session from an earlier SignIn.

The phrase is still needed to open mail, and is checked against the session's account before it is used.

func SignIn

func SignIn(phrase string, options Options) (*Client, error)

SignIn signs in with the recovery phrase.

The same challenge-response the app uses, in which nothing secret crosses the wire. Sessions last 30 days; keep SessionToken and pass it to Resume rather than deriving the keys again on every run.

func WithAPIKey

func WithAPIKey(apiKey, phrase string, options Options) (*Client, error)

WithAPIKey uses an API key, "mak_...". Operator plan and up.

With the phrase as well, the client can open mail and create addresses that receive it; the phrase is checked against the key's account first. Pass an empty phrase for a client that manages addresses, devices and metadata but cannot open mail.

func (*Client) APIKeys

func (c *Client) APIKeys() *APIKeys

APIKeys returns the API key API. Session only.

func (*Client) Account

func (c *Client) Account() (Account, error)

func (*Client) AccountID

func (c *Client) AccountID() string

AccountID is the account id, when the phrase is known. Public, safe to log.

func (*Client) Activity

func (c *Client) Activity(limit int) ([]ActivityEntry, error)

Activity returns recent account events, newest first: ids and times, never content.

func (*Client) ActivityPage

func (c *Client) ActivityPage(options ActivityOptions) (ActivityPage, error)

ActivityPage returns one page of account events, newest first. Pass its NextCursor back as ActivityOptions.Cursor for the page before.

func (*Client) Addresses

func (c *Client) Addresses() *Addresses

Addresses returns the address API.

func (*Client) AllActivity

func (c *Client) AllActivity() ([]ActivityEntry, error)

AllActivity walks every page of the event log: everything the server still holds, which is the last 90 days, newest first.

func (*Client) CanDecrypt

func (c *Client) CanDecrypt() bool

CanDecrypt reports whether this client can open mail and create addresses that receive it.

func (*Client) DeleteAccount

func (c *Client) DeleteAccount() error

DeleteAccount erases the account and everything in it, at once. Its addresses are retired and never issued again. There is no undo. Session only.

It answers a fresh challenge with the phrase, so a copied session token alone cannot erase the account.

func (*Client) Devices

func (c *Client) Devices() *Devices

Devices returns the device API.

func (*Client) Domains

func (c *Client) Domains() *Domains

Domains returns the domain API.

func (*Client) Events

func (c *Client) Events() *Events

Events returns the live event API.

func (*Client) Messages

func (c *Client) Messages() *Messages

Messages returns the mail API.

func (*Client) Plans

func (c *Client) Plans() ([]Plan, error)

Plans returns the plan catalogue. Anything beyond the id and name is in each Plan's Extra.

func (*Client) SessionToken

func (c *Client) SessionToken() string

SessionToken is the bearer this client uses: the session from SignIn, or the API key.

func (*Client) Sessions

func (c *Client) Sessions() *Sessions

Sessions returns the session API. Session only.

func (*Client) SignOut

func (c *Client) SignOut() error

SignOut ends this session.

On a client built with WithAPIKey it does nothing and returns nil: a key is not a session, and ends only when it is revoked, with APIKeys().Revoke from a session or in the app.

func (*Client) Subscription

func (c *Client) Subscription() (Subscription, error)

Subscription returns the account's plan and billing state.

func (*Client) Usage

func (c *Client) Usage() (map[string]json.RawMessage, error)

Usage is what the account has used, against what its plan allows.

func (*Client) Webhooks

func (c *Client) Webhooks() *Webhooks

Webhooks returns the webhook API. Operator and up.

type DecryptionError

type DecryptionError struct{}

DecryptionError is data that would not open with the key provided.

Every way an open can fail arrives as this one, including a box that is not a box at all. The fields come from a server, so "malformed" is a state the server can choose, and a reader that panicked on it would let the server decide that a program crashes instead of showing one record as unreadable.

func (DecryptionError) Error

func (DecryptionError) Error() string

type DeliveryPage

type DeliveryPage struct {
	Deliveries []WebhookDelivery
	// NextCursor fetches the next page. Opaque, and empty on the last page.
	NextCursor string
}

DeliveryPage is a page of the delivery log, newest first.

type Device

type Device struct {
	ID           string
	Platform     string
	Kind         string
	SendMode     string
	Status       string
	CreatedAt    string
	LastActiveAt string
	PausedAt     string
	// Name is what was set in the app. Empty without the phrase.
	Name string
	Tags []string
}

type Devices

type Devices struct {
	// contains filtered or unexported fields
}

Devices is the paired devices and AI agents.

func (*Devices) Assign

func (d *Devices) Assign(deviceID, addressID string) error

Assign gives an address to a device, then hands the device its keys.

Needs a session signed in with the phrase. Without the keys the device would list the address and open nothing, so a client without the phrase is refused before anything changes. If the handover fails, the address stays assigned and the error says so; HandOverKeys tries again.

func (*Devices) Get

func (d *Devices) Get(deviceID string) (Device, error)

func (*Devices) HandOverKeys

func (d *Devices) HandOverKeys(deviceID string) error

HandOverKeys seals the keys for every address the device holds and gives them to it, replacing what it had.

Assign and Unassign do this already; call it after changing assignments some other way. Needs a session signed in with the phrase, and a device paired with its wrap secret kept, which Pair always does.

func (*Devices) List

func (d *Devices) List() ([]Device, error)

func (*Devices) Pair

func (d *Devices) Pair(codeOrQR, name string) (Device, error)

Pair adds a device to the account, from the eight digits it shows or the text of its QR code.

With the QR text, the wrap secret in it is kept in the device's sealed profile and nothing about the keys passes through Mailcycle. With the digits alone, a wrap secret is made here and sealed to the key the device published with its code. The server relays that key, so it could substitute its own at pairing; the device's credential records this as KeyTransportRelayed. A device that published no key cannot be paired from its digits: scan its QR code in the app, or pass the QR text here.

An empty name uses the name the device gave. Needs a session signed in with the phrase: the server refuses an API key with session_required, at the lookup, before the device's screen moves on. The device holds no addresses yet; Assign gives it one and hands its keys over.

func (*Devices) Unassign

func (d *Devices) Unassign(deviceID, addressID string) error

Unassign takes an address back from a device, then hands the device a fresh set of keys without it.

The handover is best effort, as in the app: the address is gone from the device either way, and an error from it is not returned. Without the phrase there is no handover, and the device keeps the old key until one happens.

type Domain

type Domain struct {
	Domain string `json:"domain"`
	// Reserved is true for a domain that belongs to this account alone.
	Reserved bool `json:"reserved"`
}

type Domains

type Domains struct {
	// contains filtered or unexported fields
}

Domains is the domains the account can create addresses on.

func (*Domains) List

func (d *Domains) List() ([]Domain, error)

type EventStream

type EventStream struct {
	// contains filtered or unexported fields
}

EventStream is a live connection. Read it with Next; Close it when finished.

func (*EventStream) Close

func (s *EventStream) Close()

Close stops listening and closes the socket. Safe to call twice.

func (*EventStream) ClosedReason

func (s *EventStream) ClosedReason() (int, string)

ClosedReason says why the stream ended, once it has.

func (*EventStream) Ended

func (s *EventStream) Ended() bool

Ended reports whether the stream has stopped for good and will not reconnect.

func (*EventStream) Next

func (s *EventStream) Next(timeout time.Duration) (AccountEvent, bool)

Next returns the next event.

A zero timeout blocks until something arrives, which is right for a long-lived listener and wrong for anything with a deadline: a stream that is healthy and silent would never return. ok is false when the stream has ended or the timeout ran out.

func (*EventStream) WaitUntilOpen

func (s *EventStream) WaitUntilOpen(timeout time.Duration) bool

WaitUntilOpen blocks until the socket is up, the stream ends, or time runs out.

Connecting is retried in the background forever, which is right for a long-lived listener and wrong for a caller with something else it could be doing: without this, a stream that never connects looks exactly like a stream with nothing to say. WaitFor uses it to decide whether to keep listening or fall back to polling.

type Events

type Events struct {
	// contains filtered or unexported fields
}

Events is the live event stream.

func (*Events) Stream

func (e *Events) Stream(hooks *StreamHooks) (*EventStream, error)

Stream opens the account event stream.

Sessions on any plan; API keys on Operator and up. Events carry ids, never content.

type Keys

type Keys struct {
	// contains filtered or unexported fields
}

Keys is everything derived from one recovery phrase.

Nothing here leaves the machine. Deriving is 120,000 PBKDF2 iterations for a 64-byte seed, which takes a few tens of milliseconds natively. The app computes the same bytes in its own JavaScript and takes far longer over it; the iteration count is the format either way, and changing it would change every account id ever derived.

func KeysFromPhrase

func KeysFromPhrase(phrase, passphrase string) (*Keys, error)

KeysFromPhrase derives an account's keys from its recovery phrase.

func (*Keys) AccountID

func (k *Keys) AccountID() string

AccountID is public, safe to log, and meaningless on its own.

func (*Keys) AddressPublicKey

func (k *Keys) AddressPublicKey(addressID string) (string, error)

AddressPublicKey is the key that lets the mail server seal to an address.

func (*Keys) AuthPublicKey

func (k *Keys) AuthPublicKey() string

AuthPublicKey is the one thing about an account the server stores.

func (*Keys) AuthVerifier

func (k *Keys) AuthVerifier() string

AuthVerifier is the hash an older account was registered with.

func (*Keys) String

func (k *Keys) String() string

String never prints key material, however convenient that would be in a log.

type ListOptions

type ListOptions struct {
	// Folder is "inbox", "sent", "junk", "trash" or "starred".
	Folder string
	// Limit is up to 200.
	Limit int
	// Cursor is a page's NextCursor, passed back as it came. It is opaque.
	Cursor string
}

ListOptions narrows a page of mail. The zero value is the inbox, newest first, in the server's default page size.

type Message

type Message struct {
	ID         string
	AddressID  string
	ReceivedAt string
	Read       bool
	Starred    bool
	Folder     string
	SizeBytes  int64
	// SentVia is "app", "api", "device", "agent" or "member" (a team member)
	// for sent mail.
	SentVia string
	// KeyEpoch is the address key epoch it was sealed under: 0 unless the
	// address's keys were rotated.
	KeyEpoch int
	// Opened is false when the content could not be opened: no phrase, or the
	// wrong one.
	Opened      bool
	FromAddress string
	FromName    string
	// ReplyTo is where a reply goes: Reply-To if the sender set one, otherwise
	// From.
	ReplyTo string
	To      string
	Subject string
	Text    string
	// HTML is the part as sent, remote images and all. Do not render this one.
	HTML string
	// SafeHTML is the HTML with everything remote removed, as the app shows it.
	SafeHTML string
	// Blocked is what SafeHTML had taken out of it, and who it would have told.
	Blocked []BlockedTracker
	// MessageID is the sender's Message-ID, for threading a reply.
	MessageID   string
	Attachments []Attachment
}

type MessagePage

type MessagePage struct {
	Messages []Message
	// NextCursor fetches the next page when passed back as ListOptions.Cursor.
	// It is opaque, and empty on the last page.
	NextCursor string
}

type Messages

type Messages struct {
	// contains filtered or unexported fields
}

Messages is mail: list, read, organise, send, and wait for new mail.

func (*Messages) All

func (m *Messages) All(addressID, folder string) ([]Message, error)

All walks every page of a folder.

func (*Messages) Attachment

func (m *Messages) Attachment(message Message, index int) ([]byte, error)

Attachment returns the attachment's bytes, opened on this machine. It needs the phrase.

func (*Messages) Delete

func (m *Messages) Delete(messageID string) error

func (*Messages) EmptyTrash

func (m *Messages) EmptyTrash() (int, error)

func (*Messages) Get

func (m *Messages) Get(messageID string) (Message, error)

func (*Messages) List

func (m *Messages) List(addressID string, options ListOptions) (MessagePage, error)

func (*Messages) MarkRead

func (m *Messages) MarkRead(messageID string, read bool) error

func (*Messages) Move

func (m *Messages) Move(messageID, folder string) error

Move sends a message to "inbox", "junk" or "trash", or "restore" to put it back.

func (*Messages) Send

func (m *Messages) Send(outgoing Outgoing) (SendResult, error)

Send sends mail from an address on this account.

func (*Messages) Star

func (m *Messages) Star(messageID string, starred bool) error

func (*Messages) WaitFor

func (m *Messages) WaitFor(options WaitOptions) (Message, error)

WaitFor returns the next message that arrives and matches, opened.

It listens on the event stream, and polls AddressID where the stream cannot be opened. If nothing matching arrives in time it returns an *APIError with Code "timeout".

type NameStyle

type NameStyle string

NameStyle is the kind of name the server rolls for an address, such as maya.holt (person), harbor-supply42 (business) or quiet_falcon (neutral).

const (
	StylePerson   NameStyle = "person"
	StyleBusiness NameStyle = "business"
	StyleTeam     NameStyle = "team"
	StyleNeutral  NameStyle = "neutral"
)

type NewAddress

type NewAddress struct {
	// Domain comes from Domains().List(). Empty means the first available.
	Domain string
	// Prefix is the start of the address; the server adds a random ending.
	Prefix string
	// LocalPart is the whole part before the @. A name from RollNames works
	// on every plan; any other name needs Scale and up.
	LocalPart string
	// Style is the kind of name the server rolls when LocalPart and Prefix
	// are empty. Empty means neutral.
	Style NameStyle
	// Label is a private label, sealed on this machine.
	Label string
	// RetentionDays is 1 to 90. Zero means the server's default of 7.
	RetentionDays int
}

NewAddress says what a new address should look like. The zero value leaves every choice to the server except the domain, which falls back to the first the account can use.

type Options

type Options struct {
	// APIURL defaults to DefaultAPIURL.
	APIURL string
	// Passphrase is BIP-39's "25th word", if the account uses one.
	Passphrase string
	// Timeout defaults to DefaultTimeout.
	Timeout time.Duration
	// HTTPClient replaces the one this package would build. Set it only when
	// you have a reason; the default refuses to follow redirects, so the
	// bearer is never replayed to another host.
	HTTPClient *http.Client
}

Options say how to reach the API. The zero value uses the live API with a 30-second timeout and no passphrase.

type Outgoing

type Outgoing struct {
	// From is an address on this account.
	From    string
	To      string
	Subject string
	Text    string
	HTML    string
	// FromName is a display name for the From line.
	FromName string
	// InReplyTo threads a reply: pass the message being replied to.
	InReplyTo *Message
	// Attachments are up to ten files, 3.5 MB between them.
	Attachments []OutgoingAttachment
}

Outgoing is what to send.

type OutgoingAttachment

type OutgoingAttachment struct {
	// Filename is what the recipient saves it as. Program and script files
	// (.exe, .js, .ps1 and the like) are refused.
	Filename string
	// MimeType defaults to application/octet-stream.
	MimeType string
	Content  []byte
	// ContentID makes the file inline, shown where the HTML says cid:ContentID.
	ContentID string
}

OutgoingAttachment is a file to send with a message.

type PaymentPause

type PaymentPause struct {
	// PlanID is the plan to pay for.
	PlanID string `json:"planId"`
	Since  string `json:"since"`
	// DeletesAt is when the paused items are deleted if it stays unpaid.
	DeletesAt string `json:"deletesAt"`
	Addresses int    `json:"addresses"`
	Devices   int    `json:"devices"`
	APIKeys   int    `json:"apiKeys"`
	Domains   int    `json:"domains"`
}

PaymentPause is what a missed payment has paused, and when it goes.

type PhraseProblem

type PhraseProblem struct {
	// Kind is "length", "unknown_word" or "checksum".
	Kind string
	// Count is how many words there were, for Kind == "length".
	Count int
	// Word and Index locate the offending word, for Kind == "unknown_word".
	Word  string
	Index int
}

PhraseProblem says why a recovery phrase was refused.

func ValidatePhrase

func ValidatePhrase(phrase string) *PhraseProblem

ValidatePhrase checks a phrase completely, checksum included, and returns nil if it is one.

A single mistyped word fails here roughly fifteen times out of sixteen, which is what the checksum is for: it turns "your mail is gone" into "word 7 is wrong".

func (*PhraseProblem) Error

func (p *PhraseProblem) Error() string

type Plan

type Plan struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	// Extra is every other field the API sent, such as inboxLimit and
	// capabilities, as it came.
	Extra map[string]json.RawMessage `json:"-"`
}

func (Plan) MarshalJSON

func (p Plan) MarshalJSON() ([]byte, error)

MarshalJSON writes Extra back alongside the known fields.

func (*Plan) UnmarshalJSON

func (p *Plan) UnmarshalJSON(data []byte) error

UnmarshalJSON reads the known fields and keeps the rest in Extra.

type SealedBox

type SealedBox struct {
	V int    `json:"v"`
	N string `json:"n"`
	C string `json:"c"`
	T string `json:"t"`
}

SealedBox is a sealed payload, in the wire shape the API uses.

type SendResult

type SendResult struct {
	// MessageID is the sending service's id, in the Message-ID header. It is
	// not a Mailcycle message id.
	MessageID string
	// SentCopyID is the Sent copy's id, which Get and the other message
	// methods take. Empty when the address keeps no copy.
	SentCopyID string
}

SendResult is what a send returns.

type Session

type Session struct {
	ID        string `json:"id"`
	CreatedAt string `json:"createdAt"`
	ExpiresAt string `json:"expiresAt"`
	// Current is the session this client is using.
	Current bool `json:"current"`
}

Session is one signed-in session on the account. Its ID is not the token.

type Sessions

type Sessions struct {
	// contains filtered or unexported fields
}

Sessions is the account's signed-in sessions. Session only.

func (*Sessions) List

func (s *Sessions) List() ([]Session, error)

List returns the live sessions, newest first, with this one marked Current.

func (*Sessions) Revoke

func (s *Sessions) Revoke(sessionID string) error

Revoke ends one session.

func (*Sessions) RevokeOthers

func (s *Sessions) RevokeOthers() (int, error)

RevokeOthers ends every session except this one and returns how many ended.

type StreamHooks

type StreamHooks struct {
	// OnReconnect is where to refetch what a program is showing, because
	// events that happened while the stream was down are not replayed.
	OnReconnect func()
	// OnError is called with the API's code and message when it refuses.
	OnError func(code, message string)
}

StreamHooks are callbacks run on the stream's own goroutine.

type StripResult

type StripResult struct {
	HTML    string
	Blocked []BlockedTracker
}

StripResult is what StripRemoteContent produced.

func StripRemoteContent

func StripRemoteContent(html string) StripResult

StripRemoteContent removes every remote resource from an HTML body.

Inline cid: and data: references are left alone; they travel inside the message and fetch nothing. Everything remote goes, tracker or not, because "block only the ones that look like trackers" is a game the sender wins.

type Subscription

type Subscription struct {
	ID        string `json:"id"`
	AccountID string `json:"operatorId"`
	PlanID    string `json:"planId"`
	Status    string `json:"status"`
	// RenewalDate is when the month paid for runs out.
	RenewalDate string `json:"renewalDate"`
	// PendingPlanID is the plan the account moves to at RenewalDate, if one
	// is due.
	PendingPlanID string `json:"pendingPlanId"`
	// BalanceMinor is the account balance in minor units.
	BalanceMinor int64 `json:"-"`
	// Pause is set while a missed payment has paused things, nil otherwise.
	Pause *PaymentPause `json:"-"`
}

Subscription is the account's plan and billing state.

type UnsupportedError

type UnsupportedError struct{ What string }

UnsupportedError is something the caller asked for that this client cannot do.

func (*UnsupportedError) Error

func (e *UnsupportedError) Error() string

type WaitOptions

type WaitOptions struct {
	// AddressID limits the wait to one address, and is needed when the stream
	// is not available.
	AddressID string
	// From matches the sender, case-insensitively, as a substring.
	From string
	// Subject matches the subject, case-insensitively, as a substring.
	Subject string
	// Timeout defaults to a minute.
	Timeout time.Duration
	// PollEvery defaults to five seconds.
	PollEvery time.Duration
	// StreamOpen is how long to give the stream before polling instead.
	StreamOpen time.Duration
	// Since counts mail received from this time on, including mail that
	// arrived before the stream was up. Defaults to when WaitFor is called;
	// set it to just before a send to wait for the reply. Five seconds are
	// allowed for the difference between this clock and the server's.
	Since time.Time
}

WaitOptions says what to wait for. The zero value matches any message for sixty seconds.

type Webhook

type Webhook struct {
	ID string
	// Name is what the webhook was called, opened with the phrase. Empty when
	// NameOpened is false.
	Name       string
	NameOpened bool
	URL        string
	// Events are event types, or ["*"] for every event.
	Events []string
	// Status is "active" or "disabled".
	Status string
	// DisabledReason says why a webhook is off.
	DisabledReason string
	// FailingSince is set while every attempt since the last success has
	// failed.
	FailingSince  string
	LastSuccessAt string
	CreatedAt     string
}

Webhook is one webhook endpoint. The secret is shown only when it is created or rotated.

type WebhookDelivery

type WebhookDelivery struct {
	ID        string `json:"id"`
	EventID   string `json:"eventId"`
	EventType string `json:"eventType"`
	// Event is the body as sent: type, ids and times, never content.
	Event AccountEvent `json:"event"`
	// Status is "pending", "succeeded" or "failed".
	Status   string `json:"status"`
	Attempts int    `json:"attempts"`
	// ResponseStatus is the endpoint's HTTP status, or 0 if none came back.
	ResponseStatus int    `json:"responseStatus"`
	Error          string `json:"error"`
	CreatedAt      string `json:"createdAt"`
	LastAttemptAt  string `json:"lastAttemptAt"`
	NextAttemptAt  string `json:"nextAttemptAt"`
}

WebhookDelivery is one attempt to deliver an event, and what came of it.

type WebhookList

type WebhookList struct {
	Webhooks []Webhook
	// EventTypes is every event a webhook can subscribe to.
	EventTypes []string
}

WebhookList is the account's webhooks and the events they can ask for.

type WebhookUpdate

type WebhookUpdate struct {
	URL *string
	// Events replaces the event list when it is not nil.
	Events []string
	// Name needs the phrase, to seal it.
	Name *string
	// Enabled switches the webhook on or off. Switching it back on clears its
	// failure record.
	Enabled *bool
}

WebhookUpdate says what to change. A nil field is left alone.

type Webhooks

type Webhooks struct {
	// contains filtered or unexported fields
}

Webhooks is the account's webhook endpoints. Operator and up.

Creating, changing, deleting and rotating need a session. An API key can list them, read the delivery log, send a test and redeliver.

func (*Webhooks) Create

func (w *Webhooks) Create(targetURL string, events []string, name string) (Webhook, string, error)

Create adds a webhook and returns it with its signing secret. The secret is shown only this once. events is a list of event types, or ["*"] for every event. 10 per account. Needs the phrase, to seal the name.

func (*Webhooks) Delete

func (w *Webhooks) Delete(webhookID string) error

Delete removes a webhook and its delivery log.

func (*Webhooks) Deliveries

func (w *Webhooks) Deliveries(webhookID, cursor string, limit int) (DeliveryPage, error)

Deliveries returns a page of the delivery log, newest first. Pass "" for the first page and the page's NextCursor after that. limit is sent when it is not zero: up to 200, 50 when it is left out.

func (*Webhooks) List

func (w *Webhooks) List() (WebhookList, error)

List returns the account's webhooks with their names opened, and the event types a webhook can subscribe to.

func (*Webhooks) Redeliver

func (w *Webhooks) Redeliver(webhookID, deliveryID string) (string, error)

Redeliver sends a delivery's event again, with the same event id, and returns the new delivery's id.

func (*Webhooks) RotateSecret

func (w *Webhooks) RotateSecret(webhookID string) (string, error)

RotateSecret replaces the signing secret and returns the new one, shown only this once. The old one stops working at once.

func (*Webhooks) Test

func (w *Webhooks) Test(webhookID string) (deliveryID, eventID string, err error)

Test sends a webhook.test event and returns the delivery and event ids.

func (*Webhooks) Update

func (w *Webhooks) Update(webhookID string, changes WebhookUpdate) (Webhook, error)

Update changes a webhook.

Source Files

  • account.go
  • asymmetric.go
  • authproof.go
  • cipher.go
  • client.go
  • doc.go
  • encoding.go
  • errors.go
  • events.go
  • http.go
  • identity.go
  • keys.go
  • mail.go
  • mnemonic.go
  • models.go
  • pairing.go
  • primitives.go
  • sealedbox.go
  • trackers.go
  • version.go
  • webhooks.go
  • wordlist.go

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL