Mailcycle 0.2.0
dotnet add package Mailcycle --version 0.2.0
NuGet\Install-Package Mailcycle -Version 0.2.0
<PackageReference Include="Mailcycle" Version="0.2.0" />
<PackageVersion Include="Mailcycle" Version="0.2.0" />
<PackageReference Include="Mailcycle" />
paket add Mailcycle --version 0.2.0
#r "nuget: Mailcycle, 0.2.0"
#:package Mailcycle@0.2.0
#addin nuget:?package=Mailcycle&version=0.2.0
#tool nuget:?package=Mailcycle&version=0.2.0
Mailcycle
A .NET 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 .NET 8 or later.
dotnet add package Mailcycle
Sign in
using Mailcycle;
// With the recovery phrase: everything the app can do, on any plan.
using var mc = await MailcycleClient.SignInAsync(Environment.GetEnvironmentVariable("MAILCYCLE_PHRASE")!);
// With an API key (Operator and up). Add the phrase to open mail and create
// addresses that receive it.
using var scripted = await MailcycleClient.WithApiKeyAsync(
Environment.GetEnvironmentVariable("MAILCYCLE_API_KEY")!,
Environment.GetEnvironmentVariable("MAILCYCLE_PHRASE"));
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 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. MailcycleClient.CreateAccountAsync() makes a new account and returns the client and its phrase; keep the phrase, because nothing can recover the account without it.
Every call is asynchronous and takes an optional CancellationToken. MailcycleOptions sets the API address and the request timeout.
Addresses that receive mail
var address = await mc.Addresses.CreateAsync(new NewAddress
{
Prefix = "signup",
Label = "Test run 42",
});
Console.WriteLine(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
foreach (var message in await mc.Messages.AllAsync(address.Id))
{
Console.WriteLine($"{message.FromAddress} {message.Subject}");
}
byte[] data = await mc.Messages.AttachmentAsync(message, 0); // opened locally
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 exception, 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
var code = await mc.Messages.WaitForAsync(new WaitFor
{
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 throws an ApiException with Code "timeout".
Since counts mail received from that time on, including mail that arrived before the stream was up, so a reply that comes back quickly is not missed. It defaults to when the wait starts; to wait for the reply to something you send, take DateTimeOffset.UtcNow just before the send and pass it. Five seconds are allowed for the difference between your clock and the server's.
var since = DateTimeOffset.UtcNow;
await mc.Messages.SendAsync(new Outgoing(address.EmailAddress, "bot@example.com", "Hello", "Hi"));
var reply = await mc.Messages.WaitForAsync(new WaitFor { AddressId = address.Id, Since = since });
Sending
await mc.Messages.SendAsync(new Outgoing(address.EmailAddress, "someone@example.com", "Hello", "Hi"));
await mc.Messages.SendAsync(new Outgoing(address.EmailAddress, message.ReplyTo, "Re: Hello", "Thanks")
{
ReplyTo = message, // fills in the threading headers
});
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" },
],
});
A session can send from Personal up; an API key on Scale and up. Free receives only. Limits are in the rate limits.
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.
Events
await using var stream = mc.Events.Stream();
await foreach (var ev in stream.ReadAllAsync())
{
Console.WriteLine($"{ev.Type} {ev.Payload.ToJsonString()}");
}
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 EventHooks.OnReconnect is where to refetch whatever you are showing. NextEventAsync(timeout) reads one event with a deadline.
To check a webhook came from Mailcycle, pass the body exactly as it arrived, before any parsing:
WebhookSignature.VerifyNow(secret, rawBody, request.Headers["Mailcycle-Signature"]);
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
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
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
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, and 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.
mc.SubscriptionAsync() returns the plan, renewal date and balance, and mc.PlansAsync() lists every plan.
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 too
await mc.Devices.UnassignAsync(device.Id, address.Id);
await mc.Devices.HandOverKeysAsync(device.Id); // again, on its own
This 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
Everything this package throws is a MailcycleException. An API failure is an ApiException carrying the HTTP Status and the API's own Code, such as plan_required or session_required, which is what to match on rather than the message. See errors.
try
{
await mc.Webhooks.ListAsync();
}
catch (ApiException error) when (error.Code == "plan_required")
{
// Webhooks are on Operator and up.
}
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 package, including inside your own applications, under the terms in the LICENSE file included with it. Using the Mailcycle API itself is governed by the Terms of Service. Never ship an API key in software you distribute.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- BouncyCastle.Cryptography (>= 2.6.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.