Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

The Mail kit — send a message, keep a record, pick a sender

use Osysharp.Mail@0; · SendMail(new MailMessage { To = […], Subject = …, Text = … }) · app.Mail = new MailSetup { Sender = …, From = … };

`Osysharp.Mail` is the contract between an app that sends mail and whatever carries it. `SendMail` writes the message to an outbox in the caller's own commit; a workflow delivers it after the commit through the app's sender, retrying a sender that is down, and HOLDS it — kept, with the reason — when there is no sender. Every message carries its Message-ID and threading headers. A sender is an interface: `Osysharp.Mail.Resend` is one.

preview1 example compiled by CImailkitintegration

Source on GitHub: Osysharp.Mail — github.com/osysharp/mail ↗ · Osysharp.Mail.Resend — github.com/osysharp/mail/resend ↗

Summary#

use Osysharp.Mail@0; gives an app one way to send mail, whichever carrier it ends up using:

SendMail(new MailMessage { To = [customer.Email], Subject = "Your order shipped", Text = "…" });

The message is a row in the outbox, written in the same commit as whatever it is about — so mail about a change that was rolled back is never sent — and delivered after that commit through the sender app.Mail names.

Signature#

use Osysharp.Mail@0;                                  // with Osysharp.Identity@0 and Osysharp.Workflow

OutgoingMail SendMail(MailMessage message, string? idempotencyKey = null);
OutgoingMail SendRendered(string to, RenderedEmail mail, string? idempotencyKey = null);   // an [Email] component's answer
MailMessage  ReplyTo(OutgoingMail original, string text, string? html = null);             // threaded to the original

app.Mail = new MailSetup { Sender = <an IMailSender>, From = "Acme <[email protected]>", ReplyTo = "" };

public interface IMailSender {
  string Key { get; }
  bool IsReady();
  string SendMail(MailMessage message, string idempotencyKey);
  string NotReadyReason() => $"The mail sender '{Key}' is not ready — its credentials are not set.";   // a default
}

Description#

The message#

MailMessage has From, To, Cc, Bcc, ReplyTo, Subject, Text, Html, Attachments (a MailAttachment with a ContentId is a picture inside the HTML), Headers, and three threading fields: MessageId (its own RFC 5322 id — minted when the row is written, so a reply can thread to it before it has even gone), InReplyTo and References. ReplyTo(original, text) fills them for you, so the reply lands in the reader's conversation.

It is the shape a received message is read in too (Receiving mail — watched mailboxes (Gmail, Microsoft 365, IMAP)), so a conversation is one kind of thing whichever way each message went. ParseMail(bytes) and RawOf(message) turn it into and out of the bytes a provider holds (Mail on the wire — Mime.Parse, Mime.Build, Imap.Fetch, Smtp.Send).

The outbox and its states#

OutgoingMail is the delivery record: Status is Queued, then Sent (with SentAt, SentWith — the sender's Key — and ProviderId, the carrier's own id) or Held (with Problem saying why). A sender that throws is retried by the delivery workflow.

  • No sender, or one not ready (its key is not set): the mail is Held, not lost and not failed. The app keeps working — signs people up, resets passwords — and the held mail is there to read.
  • idempotencyKey: the same key twice is ONE message, so a handler that runs twice mails once. Derive it from what the mail is about ("reset-<account>-<request>"), never from the time.

Who may send, and who may read#

The outbox grants only the sign-in flow (IsAuthenticator, which mails reset links and verifications). An app grants its own people with a partial:

partial entity OutgoingMail { security { allow create, read when IsAgent; } }

Nobody else can put a row there, so nobody can use the app to mail anybody they like in its name — and nobody reads it who was not granted, not even the recipient: a reset link sits in those rows, and reading it out of the outbox would skip the one thing mailing it proves.

Forgetting an address#

EraseMailTo(address) forgets what the outbox holds of mail to a person exercising their right to erasure: a mail to them alone is deleted with its files; one they shared with others keeps its row — the others' record — without their address or the words. It runs with the caller's rights (a workflow runs as the system); the app decides who may ask. Osysharp.Conversations calls it through its mail channel when a customer is forgotten.

Senders#

A sender implements IMailSender: Key (recorded on each message), IsReady() (false leaves mail held) and SendMail(message, idempotencyKey), which sends the message with its Message-ID and threading headers as they are and answers the carrier's id. NotReadyReason() is the sentence written on each message a not-ready sender leaves held; it has a default (added in 0.2.0 — see [[class-interfaces#defaults]]), so a sender omits it unless it knows which setting is missing. osy kits Osysharp.Mail lists the implementations the project you run it in can see:

Generated from the kits in scope (osy kits Osysharp.Mail lists the same):

  • ResendSender — Osysharp.Mail.Resend (use Osysharp.Mail.Resend@0;)

Osysharp.Mail.Resend is the transactional one shipped today:

use Osysharp.Mail.Resend@0 { egress "api.resend.com"; }
use Osysharp.Resend@0 { egress "api.resend.com"; }
use Osysharp.Http;

app.Mail = new MailSetup {
  Sender = new ResendSender { ApiKey = Security.IsSecretSet("ResendApiKey") ? Secret.ResendApiKey : "" },
  From   = "Acme <[email protected]>",
};
app.Secrets = [ new Secret("ResendApiKey") ];

The key is a setting the app fills from its OWN secret; the sender never learns the secret's name, and it reaches no host but api.resend.com.

Mailboxes: receiving, and sending as one#

app.Mail.Mailboxes names the mailboxes the app reads — a Google Workspace or Microsoft 365 inbox, any IMAP account — and app.Mail.Receiver what each received mail is handed to. Watching them is Osysharp.Mail.Receiving's, a package of its own so an app that only sends carries none of its tables: see Receiving mail — watched mailboxes (Gmail, Microsoft 365, IMAP).

A message whose From is one of those mailboxes goes out through it rather than through Sender, so a reply leaves from [email protected], threaded, and sits in that mailbox's Sent folder.

What they wrote, and what their client quoted#

A reply arrives with the whole thread under it. SplitReply(text, html) separates what the sender wrote THIS time from what their client quoted:

ReplySplit split = SplitReply(mail.Text, mail.Html);
split.New       // "The person card."
split.Quoted    // "On Thu, 1 Oct 2026 at 09:40, Harbour Support <support@…> wrote:\n> Thanks Olivia — which card…"
split.Sure      // false when a mark left NOTHING above it — then New is the whole message, nothing is folded

Structure first. In HTML, a client's own quote container (Gmail's gmail_quote, Outlook's reply header, a <blockquote type="cite">) is unambiguous and wins. Text second: an attribution line ("On … wrote:", "Le … a écrit :", "Am … schrieb", "Den … skrev", Outlook's From:/Sent: block, "-----Original Message-----"), a run of > lines, or a signature delimiter (-- ). A short attribution must carry an @ or a digit, so a sentence that merely ends in "wrote:" is not a split. When unsure, everything: nothing is ever dropped — the quoted part is kept beside the message ("Show quoted text"), a message with no mark is shown whole, and a mark with nothing written above it (a bare forward) is not trusted at all.

Bounces#

FailedMessageIds (0.3.0) holds the Message-IDs a delivery-failure report (RFC 3464) names in its own returned part — message/rfc822, or text/rfc822-headers — which is the one place a bounce reliably carries them; many carry no In-Reply-To at all. Mime.Parse fills it for a FAILURE report only, and Osysharp.Conversations.Mail fails exactly those deliveries.

Examples#

Mail an order's customer, and answer in the same thread#

app Orders {
  use Osysharp.Workflow;
  use Osysharp.Identity;
  use Osysharp.Mail;
  use Osysharp.Mail.Resend { egress "api.resend.com"; }
  use Osysharp.Resend { egress "api.resend.com"; }
  use Osysharp.Http;
  model "model/**/*.osy";
}

using Osysharp.Identity;
using Osysharp.Mail;
using Osysharp.Mail.Resend;

[Role] enum AppRole { Authenticator, Clerk }
policy IsClerk => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Clerk);

app.Mail = new MailSetup {
  Sender = new ResendSender { ApiKey = Security.IsSecretSet("ResendApiKey") ? Secret.ResendApiKey : "" },
  From = "Acme Orders <[email protected]>",
};
app.Secrets = [ new Secret("ResendApiKey") ];

// The app's clerks may send, and read what was sent.
partial entity OutgoingMail { security { allow create, read when IsClerk; } }

entity Order {
  [Required, MaxLength(255)] string CustomerEmail;
  [MaxLength(255)] string? ShippedMailId;
  security { allow read, create, update when IsClerk; }
}

void Ship(Order order) {
  var mail = SendMail(new MailMessage {
    To = [order.CustomerEmail], Subject = "Your order is on its way", Text = "It left the warehouse today.",
  }, $"shipped-{order.Id}");
  order.ShippedMailId = mail.MessageId;
}

void FollowUp(Order order, string text) {
  var first = OutgoingMail.FirstOrDefault(m => m.MessageId == order.ShippedMailId);
  if (first != null) { SendMail(ReplyTo(first, text)); }
}

See also#

Related

The UserAccounts kit — sign-in done properly, once

`Osysharp.UserAccounts` is the default way into an app: sign-in with a password or a provider (Google, GitHub, …)…

The Identity kit — who signs in, and which of the app's roles they hold

`Osysharp.Identity` is the contract every people-aware kit builds on: `Principal`, the one `[Principal]` of the app (an…

The EmailTemplates kit — mail that looks designed

`Osysharp.EmailTemplates` ships the parts a designed mail is built from — a layout with its preview line, logo and a…

The MailingLists kit — a newsletter done properly

`Osysharp.MailingLists` keeps an app's mailing lists: sign-up with double opt-in, consent recorded word for word, a…

[Email] — mail rendered from data

An `[Email]` component is a mail template. It is rendered on the server — from any function, a workflow step included —…

Receiving mail — watched mailboxes (Gmail, Microsoft 365, IMAP)

An app WATCHES a mailbox — it is never a mail server. The mail arrives where it always did (a Google Workspace or…

Mail on the wire — Mime.Parse, Mime.Build, Imap.Fetch, Smtp.Send

The four calls a mail kit is built from. `Mime.Parse` reads a whole RFC 5322 message — headers, text and HTML bodies…