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 foldedStructure 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#
- The UserAccounts kit — sign-in done properly, once — sends its reset and verification mail through this outbox
- The EmailTemplates kit — mail that looks designed —
[Email]components, whose answerSendRenderedsends - The MailingLists kit — a newsletter done properly — lists and campaigns
- The Identity kit — who signs in, and which of the app's roles they hold — the roles the outbox's grants are written in
- Receiving mail — watched mailboxes (Gmail, Microsoft 365, IMAP) — watched mailboxes: Gmail, Microsoft 365, IMAP
- Mail on the wire — Mime.Parse, Mime.Build, Imap.Fetch, Smtp.Send — the bytes:
Mime.Parse/Build,Imap.Fetch/Smtp.Send