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

> `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.

<!-- id: ui-mail-kit · area: ui · stability: preview · html: https://osysharp.com/reference/ui/mail-kit/ -->

## Summary        {#summary}
`use Osysharp.Mail@0;` gives an app one way to send mail, whichever carrier it ends up using:

```osy syntax
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      {#signature}
```osy syntax
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 <noreply@acme.com>", 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    {#description}

### The message                                {#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)](https://osysharp.com/reference/ui/mail-receiving-kit/)), 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](https://osysharp.com/reference/stdlib/mail-wire/)).

### The outbox and its states                  {#outbox}
`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              {#security}
The outbox grants only the sign-in flow (`IsAuthenticator`, which mails reset links and verifications). An app grants
its own people with a partial:

```osy syntax
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                       {#erase}
`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                                     {#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:

```osy syntax
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 <noreply@acme.com>",
};
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      {#mailboxes}
`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)](https://osysharp.com/reference/ui/mail-receiving-kit/).

A message whose `From` is one of those mailboxes goes out **through it** rather than through `Sender`, so a reply
leaves from `support@acme.com`, threaded, and sits in that mailbox's Sent folder.

### What they wrote, and what their client quoted   {#reply-extraction}
A reply arrives with the whole thread under it. `SplitReply(text, html)` separates what the sender wrote THIS time
from what their client quoted:

```osy syntax
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   {#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       {#examples}

### Mail an order's customer, and answer in the same thread   {#example-order}
```osy title="send, record, reply in the thread — through Resend once its key is set" test app=ui-mail-kit
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 <orders@acme.test>",
};
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       {#see-also}
- [The UserAccounts kit — sign-in done properly, once](https://osysharp.com/reference/security/accounts-kit/) — sends its reset and verification mail through this outbox
- [The EmailTemplates kit — mail that looks designed](https://osysharp.com/reference/ui/email-templates-kit/) — `[Email]` components, whose answer `SendRendered` sends
- [The MailingLists kit — a newsletter done properly](https://osysharp.com/reference/ui/mailing-lists-kit/) — lists and campaigns
- [The Identity kit — who signs in, and which of the app's roles they hold](https://osysharp.com/reference/security/identity-kit/) — the roles the outbox's grants are written in
- [Receiving mail — watched mailboxes (Gmail, Microsoft 365, IMAP)](https://osysharp.com/reference/ui/mail-receiving-kit/) — watched mailboxes: Gmail, Microsoft 365, IMAP
- [Mail on the wire — Mime.Parse, Mime.Build, Imap.Fetch, Smtp.Send](https://osysharp.com/reference/stdlib/mail-wire/) — the bytes: `Mime.Parse`/`Build`, `Imap.Fetch`/`Smtp.Send`
