.NET SDK
A .NET 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 .NET 8 or later, and its one dependency is BouncyCastle, for X25519 and ChaCha20.
dotnet add package MailcycleSign in
Section titled “Sign in”using Mailcycle;
using var mc = await MailcycleClient.SignInAsync(Environment.GetEnvironmentVariable("MAILCYCLE_PHRASE")!);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:
using var mc = await MailcycleClient.WithApiKeyAsync( Environment.GetEnvironmentVariable("MAILCYCLE_API_KEY")!, Environment.GetEnvironmentVariable("MAILCYCLE_PHRASE")); // null to manage without opening mailWithout 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 SignInAsync opens a 30-day session. Keep mc.SessionToken somewhere private and pick it up with MailcycleClient.ResumeAsync(token, phrase) rather than signing in on every run.
Every call is asynchronous and takes an optional CancellationToken. MailcycleOptions sets the API address and the request timeout.
Addresses
Section titled “Addresses”var address = await mc.Addresses.CreateAsync(new NewAddress{ Prefix = "signup", Label = "Test run 42",});
var all = await mc.Addresses.ListAsync();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.
With no Prefix or LocalPart, the server rolls a name like quiet_falcon. Set Style to choose the kind:
var address = await mc.Addresses.CreateAsync(new NewAddress { Style = NameStyle.Person }); // maya.holtThe styles are Person, Business, Team and Neutral, the default. Style is ignored when Prefix or LocalPart is set.
To pick a name before creating, roll a few:
var names = await mc.Addresses.RollNamesAsync(NameStyle.Business); // up to 8, like harbor-supply42
var address = await mc.Addresses.CreateAsync(new NewAddress { LocalPart = names[0] });A rolled name works as LocalPart on every plan; any other name needs Scale and up. The names are free when rolled, but someone can take one before you create it, and then CreateAsync is refused. Pass a domain as the second argument to roll for it; leaving it out uses the one CreateAsync would.
Reading mail
Section titled “Reading mail”var messages = await mc.Messages.AllAsync(address.Id);foreach (var message in messages){ Console.WriteLine($"{message.FromAddress} {message.Subject}");}
byte[] data = await mc.Messages.AttachmentAsync(messages[0], 0); // 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 exception, 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”var code = await mc.Messages.WaitForAsync(new WaitFor{ 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 throws an ApiException 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”await mc.Messages.SendAsync(new Outgoing(address.EmailAddress, "someone@example.com", "Hello", "Hi"));Set ReplyTo on the Outgoing to the message you are answering to thread a reply. A session can send from Personal up; an API key on Scale and up. Free receives only.
SendAsync returns a SendResult: MessageId is the Message-ID header, and SentCopyId is the id of the copy in Sent, which the other message calls take, or null when the address keeps none.
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.
await mc.Messages.SendAsync(new Outgoing(address.EmailAddress, "someone@example.com", "Your invoice", "Attached."){ Html = """<img src="cid:logo"><p>Attached.</p>""", Attachments = [ new OutgoingAttachment("invoice.pdf", await File.ReadAllBytesAsync("invoice.pdf")) { MimeType = "application/pdf" }, new OutgoingAttachment("logo.png", await File.ReadAllBytesAsync("logo.png")) { MimeType = "image/png", ContentId = "logo" }, ],});Events and webhooks
Section titled “Events and webhooks”await using var stream = mc.Events.Stream();await foreach (var ev in stream.ReadAllAsync()){ Console.WriteLine(ev.Type);}Events carry ids, never content, and are not replayed after a drop, so EventHooks.OnReconnect, passed to Stream(hooks), is where to refetch what you are showing. ReadAllAsync() runs until the stream is closed; NextEventAsync(timeout) reads one event with a deadline and returns null if none comes.
To check a webhook came from Mailcycle, pass the body exactly as it arrived:
bool genuine = WebhookSignature.VerifyNow(secret, rawBody, header);API keys
Section titled “API keys”var created = await mc.ApiKeys.CreateAsync("Deploy script");Console.WriteLine(created.Token); // shown once; keep it somewhere private
foreach (var key in await mc.ApiKeys.ListAsync()){ Console.WriteLine($"{key.Id} {key.Name} ...{key.Hint}");}await mc.ApiKeys.RevokeAsync(created.ApiKey.Id);Listing, creating and revoking keys need a signed-in session, so a leaked key cannot make more of itself. The name is sealed on your machine, and the server stores it as ciphertext. A name that will not open comes back as null.
SignOutAsync() on a client built with an API key does nothing. A key is not a session; it works until it is revoked.
Webhooks
Section titled “Webhooks”var created = await mc.Webhooks.CreateAsync("https://example.com/hooks/mail", ["message.received"], "Orders");var secret = created.Secret; // shown once, for checking signatures
await mc.Webhooks.UpdateAsync(created.Webhook.Id, new WebhookUpdate { Enabled = false });await mc.Webhooks.TestAsync(created.Webhook.Id);
var page = await mc.Webhooks.DeliveriesAsync(created.Webhook.Id);foreach (var delivery in page.Deliveries){ Console.WriteLine($"{delivery.EventType} {delivery.Status} {delivery.ResponseStatus}");}Webhooks are on Operator and up. ListAsync() returns the webhooks with their names opened, and every event type one can ask for. RotateSecretAsync returns a new secret once, and RedeliverAsync sends a logged event again with the same event id. Adding, changing, rotating and removing need a signed-in session; an API key can list, test, read deliveries and redeliver.
Sessions
Section titled “Sessions”foreach (var session in await mc.Sessions.ListAsync()){ Console.WriteLine($"{session.Id} {session.ExpiresAt} {session.Current}");}await mc.Sessions.RevokeOthersAsync();RevokeAsync(id) ends one session. All three need a signed-in session.
Deleting the account
Section titled “Deleting the account”await mc.DeleteAccountAsync();Erases the account and everything in it at once, with no grace period. Its addresses are retired and never issued again. It needs a signed-in session. If the account balance still holds money you paid in, it is refused with balance_held: ask for the balance back through support first.
mc.SubscriptionAsync() returns the plan, renewal date and balance, and mc.PlansAsync() lists every plan.
Pairing devices
Section titled “Pairing devices”// The text of the device's QR code, or the eight digits it shows.var device = await mc.Devices.PairAsync("MC1:12345678:...", "Kitchen Pi");
await mc.Devices.AssignAsync(device.Id, address.Id); // hands the keys over tooawait mc.Devices.UnassignAsync(device.Id, address.Id);await mc.Devices.HandOverKeysAsync(device.Id); // again, on its ownThis is what the app’s Add device screen does, done by a script. It needs the phrase and a signed-in session. The name is sealed on your machine.
Each device has a wrap secret, and its address keys are sealed under it. The account keeps a copy in the device’s sealed profile, so keys can be handed over again when an address is assigned or taken off.
There are two ways the secret gets here. The QR code carries the device’s own secret, so it never passes through Mailcycle. The eight digits carry no secret, so the client makes one and seals it to a key the device published. The server relays that key, so it could put its own in its place at pairing and read the keys that follow. The QR path does not have that gap. Use it where you can.
A device on an older build publishes no key. Pairing it by its digits fails with an error that says to pair it in the app, or pass its QR code instead.
Errors
Section titled “Errors”Everything this package throws is a MailcycleException. An API failure is an ApiException carrying the HTTP Status and the API’s own code in Code, which is what to match on rather than the message.
try{ await mc.Webhooks.ListAsync();}catch (ApiException error) when (error.Code == "plan_required"){ // Webhooks are on Operator and up.}How the encryption is checked
Section titled “How the encryption is checked”It is checked against the published test vectors, including the refusals. SHA-256, HMAC-SHA256 and PBKDF2 are the platform’s own; X25519 and ChaCha20 come from BouncyCastle.
© Northlab Studios Ltd, trading as Mailcycle. Registered in England and Wales, company no. 17462734. Registered office: 167-169 Great Portland Street, London W1W 5PF.