Summary#
A mail about something a person did — an order, a shipment, a refund — is written like a component and filled from the data it is about. Calling it renders it:
[Email]
component OrderConfirmation(Order order) {
subject => $"Your order {order.Reference}";
render {
Box(bg: Colors.Bg, p: 8) {
Text($"Thank you — order {order.Reference} is reserved for you.", fontSize: 20, fontWeight: 700);
Link($"https://shop.example.com/orders/{order.Reference}", text: "See your order", bg: Colors.Primary, color: "#FFFFFF");
}
}
}
void Confirm(Order order) {
var mail = OrderConfirmation(order); // mail.Subject · mail.Html · mail.Text
}It renders; it does not send. Hand mail.Html and mail.Text to whatever sends your mail — the Osysharp.Resend
kit takes both. The Osysharp.EmailTemplates kit has the parts a designed mail is built
from — a layout with a logo and a footer, headings, buttons, a receipt's lines.
Signature#
[Email]
component Name(<parameters>) {
subject => <string expression>; // present: a MAIL — Name(...) answers a RenderedEmail
// absent: a PART — used inside other [Email] components
render {
<email atoms, other [Email] components, if / foreach / var>
}
}
[Email]
component Name(<parameters>) {
subject => <string expression>;
placeholders { <a.b> => <expression>; … } // the CLOSED list of {placeholders} words written into it may use
render {
… Region("name", allow: [<mail blocks>], require: [<blocks it keeps>], sample: F) { <default arrangement> } …
}
}
Name(<arguments>, language: "sv") // any mail, rendered in a chosen language instead of its recipient's
public sealed class RenderedEmail { string Subject; string Html; string Text; List<EmailInlineImage> Inline;
List<string> Placeholders; bool IsPreview; }
public sealed class EmailInlineImage { string ContentId; string Path; string FileName; string ContentType; Guid? Asset; }Description#
A mail, or a part of one#
With subject => the component is a mail: OrderConfirmation(order) in any function answers a RenderedEmail
— its Subject, the Html document and its plain-text twin Text. It is called like a function because on the
server that is what it is: its render block runs with a server function's powers. It navigates the entity it was
handed (order.Lines, including rows written in the same unit of work and not yet saved), calls the app's own
functions (Money(order.Total)), reads data, and runs inside a workflow step as anywhere else.
Without a subject it is a part, used inside another [Email] component the way a component is used inside a page.
A part may have one Slot — where the caller's children go — and its own children render when the caller gives
none. A mail cannot contain another mail, and a page cannot contain either: a mail is sent, not shown.
What a mail is built from#
The email atoms carry the names and the settings a page's atoms have, so what you know from a page carries over. Each takes exactly the settings in its row, and anything else is refused by name.
| atom | what it draws | settings |
|---|---|---|
Stack { … } | children one above the other | gap align + the panel settings |
Row { … } | children side by side, in table cells | gap justify align + the panel settings |
Box { … } | a panel | align + the panel settings |
Text(text) | a paragraph (a line break is kept) | color fontSize fontWeight fontStyle lineHeight letterSpacing fontFamily textAlign textTransform textDecoration p px py pt pr pb pl placeholders |
Link(href) { … } | a link — with a bg, a button | text color fontSize fontWeight fontFamily textDecoration bg border borderW rounded px py w align |
Image(src) · Image(picture: p) | a picture — by address, carried inside the mail, or a Picture (one the app ships, or one staff uploaded) | alt w h maxW rounded align inline picture |
Divider() | a horizontal rule | color py |
Spacer() | empty vertical space | h |
Markdown(text) | formatted text | color fontSize lineHeight fontFamily textAlign linkColor placeholders |
Region("name", allow: [...]) { … } | the part of a mail staff arrange — see below | allow require sample |
Preview(text) | the line an inbox shows beside the subject — hidden in the mail itself | — |
Slot | in a part: where the caller's children go | — |
The panel settings are bg color border borderW borderTW borderBW borderLW borderRW rounded
p px py pt pr pb pl w maxW textAlign fontFamily fontSize fontWeight lineHeight
letterSpacing.
A value is written as on a page: a theme token (Colors.Primary, Colors.Primary.Hover, Radius.Md,
FontSize.Title), a keyword (Justify.Between, Align.Center, TextAlign.Center), a number, or a string. Spacing
(p, gap) counts in the page's 0.25rem steps — p: 4 is 16 pixels; a size (w, h, fontSize) counts in pixels.
justify: Justify.Between on a Row puts the first child against the left edge and the last against the right —
how a receipt's line and its amount sit.
It wears the app's theme#
A token names the theme's value, and the renderer writes the app's value into the mail — the one its own theme
declares, or the kit's where it declares none. So a shop whose brand colour is orange sends orange buttons, and a
theme change reaches every mail at the next compile. Light values are used: a mail cannot know which scheme its
reader's client will choose. A token the app does not have writes nothing at all rather than something no mail client
understands. No font is loaded — the theme's font stack is written as it is, so the reader's own system face is used.
What makes the HTML safe in every mail client#
Mail clients drop most of what a browser understands, so the HTML is written the way that survives all of them:
layout is tables (each role="presentation"), every style is inline on the element it styles — font and
colour on every piece of text, because inheritance through a table cell is not reliable — and there is no stylesheet,
no class, no script and no web font. A picture's address is absolute: a path the template wrote (/files/logo.png)
is sent as the app's own address, because a mail client has nothing to resolve a path against. A Link with a
background is drawn as a padded link inside a coloured cell — the one shape that is a button in every client, Outlook
included.
A mail is read on phones, so what must be seen whole is never broken across two lines: a code — letters and digits
joined by hyphens, like an order reference GG-1041 — stays on one line with its characters unchanged (so it still
copies and searches as itself), and a number keeps the short word or sign after it (165 kr, 250 g, 12 %). A
theme's fluid sizes (clamp(26px, 2.6vw, 36px)) are settled for the 600-pixel column, because many clients drop
clamp() and vw and would show the client's own default size instead.
Everything that comes from data is text: it is escaped, a setting's value cannot end its style declaration, a
javascript: address never becomes a link, and HTML inside Markdown is shown as text rather than run.
Pictures: by address, or inside the mail#
A picture is sent by its address unless the template says otherwise. That keeps a mail small, which is right for photographs — a product, a campaign picture — and a path is made absolute against the app's own address. Its cost is that many clients show nothing until the reader allows remote pictures; Outlook asks by default.
Image(path, inline: true) carries the picture inside the mail instead, as an attachment the HTML names by
cid:, so it shows at once, everywhere. Use it for the small pictures a reader needs: a logo, a QR code to show at a
counter. It takes a file the app stores — a store path (public/logo.png) or the app's own file address
(File.Url(path)); a picture on another host stays an address, because the renderer fetches nothing.
A rendered mail lists those pictures in Inline — each with its ContentId, the Path to read its bytes from, its
FileName and ContentType. Whatever sends the mail attaches each file under its ContentId; with
the Resend kit that is new EmailAttachment { …, ContentId = image.ContentId }. The
plain-text twin does not mention them.
A picture pasted into a Markdown editor is an upload — a FileAsset, private to whoever uploaded it, written into
the words as /api/files/<id>. That address only answers with a short-lived signature, which must never go out in a
mail, so the renderer never sends it: a Markdown(...) in a mail carries each uploaded picture inside the mail,
listed once in Inline with Asset set to the upload and no Path. Read its bytes with
[[storage-file-versions#read|File.ReadAllBytes(asset)]], which answers only what the caller may read — so read it as
the person sending, and refuse a picture they cannot open (otherwise a file id pasted from elsewhere would mail
somebody's private file out). Its name and type are the asset's Name and MimeType.
Image("public/logo.png", alt: "Acme", h: 40, inline: true); // inside the mail: shows even where pictures are blocked
Image(product.PhotoUrl, alt: product.Name, w: 552); // by address: the default, and right for a photograph
foreach (var image in mail.Inline) {
var bytes = image.Asset != null ? File.ReadAllBytes(FileAsset.Single(a => a.Id == image.Asset)) // an upload
: File.ReadAllBytes(image.Path); // a stored file
attachments.Add(new EmailAttachment { Filename = image.FileName, Content = bytes,
ContentType = image.ContentType, ContentId = image.ContentId });
}Words staff own: a region of blocks#
The layout is code, and it changes with a deploy. The WORDS of a mail — how an order confirmation greets, what a refund
mail says first — are often somebody else's, and they change on a Tuesday; and sometimes they want more than words: a
photograph before Christmas, a quote from a regular, a button to the new collection. A mail says where that goes with a
Region, exactly as a page does (Blocks and regions — pages the staff arrange): what is written inside it is the mail until staff arrange it
differently at the desk (The PageBuilder kit — the desk staff arrange the site at) — a draft, published, in each language, with no deploy.
[Email]
component Welcome(Member member) {
subject => $"Welcome, {member.Name}";
placeholders { member.name => member.Name; }
render {
EmailLayout("The Rowing Club") {
Region("welcome", allow: [EmailWords, EmailPicture, EmailQuote], sample: WelcomeSample) {
EmailWords(words: "Welcome aboard, **{member.name}**.");
}
}
}
}A mail block is an [Email] part marked [Block]. Its parameters are the fields staff fill in, and their types
choose the editor, as for a page's block. Osysharp.EmailTemplates ships the everyday ones
(words, a heading with words, a picture, a button, a quote, small print); writing your own is writing a part:
[Block("The order", Hint = "What was ordered and what it costs.")]
[Email]
component OrderLines(Order order, [Label("Heading"), MaxLength(60)] string heading = "Your order") {
render { Text(heading, placeholders: true); foreach (var l in order.Lines) { Text($"{l.Quantity} × {l.Name}"); } }
}- An entity a mail block takes is handed to it from the mail —
orderabove is the confirmation's ownOrder. Staff never pick it. The mail must have exactly one parameter of that type; with none, or two, the region is refused by name. require: [OrderLines]keeps a block in the region: staff may move it, never take the last one out. Use it for what the reader must be told — an order confirmation's lines are what the customer bought.sample: F, withRenderedEmail? F(string language), renders the mail around the region for something real — the latest order — which is what the desk shows beside the blocks, as the mail itself.
Placeholders. A block's words may use the mail's placeholders: a mail block writes its text with
placeholders: true (Markdown(words, placeholders: true), Text(heading, placeholders: true)), and they are filled
from whichever mail the block is placed in. Two rules keep words staff write from changing a mail into something else:
- A value is data, never markup. A placeholder's value is escaped before the words are read as Markdown, so a
customer whose name is
[Win](https://evil.example)is greeted with those characters, not a link — and**or#in a value cannot restyle the words around it. In aTextit is shown as text. The words themselves render asMarkdown(text)does: raw HTML is shown as text and ajavascript:link is never a link. - Only a declared placeholder is filled — and a word naming another is refused when staff save it. Staff who write
{order.referense}are told, by name, which placeholders the mail offers; a customer is never sent a token nobody filled.
Words are per language. A mail renders in its reader's language (language => order.Language;), and a region's
words are kept per language like any block's: the code's default arrives in every language the app speaks, as its
locale file translates it, and staff change each language's words separately — words written in English never reach a
Swedish reader.
Versions and dates, as the site's. A mail's region is arranged in the site's versions ([[ui-pages-kit#versions]]), so the confirmation a customer is sent while the Christmas version is live is the Christmas one, and a block dated for the week before Christmas is in it only that week.
A preview is never sent. A member of staff previewing a version (or its drafts, or a date) sees the mail as the
preview shows it — and a mail rendered then says so, IsPreview, and is not to be sent: the shop's outbox refuses one.
Every real send — a checkout, a workflow step, a scheduled job — is rendered with what is live.
Markdown(text, placeholders: true) fills the mail's placeholders in a text the template did not write itself — the
body of a campaign staff wrote — under the same two rules.
⚑ Editable("Intro", default: "…") and [Email(Words = F)] were the first way a mail's words were staff's, and they
are gone: each is refused, naming the region that replaced it.
How the plain-text twin is written from the same tree#
Text is read from the same tree as Html, so the two say the same words: each paragraph on its own, a tight stack's
lines kept together (a receipt), a row's cells on one line, a button as label: address, a link as
label (address). The Preview line is not in it — an inbox shows that beside the subject.
What a mail cannot hold#
An inbox runs no code, keeps no state, has no screen the app can measure, and drops stylesheets. So the compiler
refuses — naming the construct and what to write instead — an action, a field or live var (compute the value with
var x = …; in the render block), a lifecycle hook, an event setting (onClick:), the page atoms that act or draw in
the browser (Button, Input, Icon, Svg, Canvas, …), a page component, the browser's own surfaces
(Layout, Navigation, Keyboard, Theme, …), a variants { } or skeleton { } block, type parameters, and a
setting an atom does not take.
Photograph it in a test#
Ui.Shot(label, mail) photographs a rendered mail. Under osy test --pixels a real browser lays the HTML out as a
mail client does — a 720-pixel reading pane, with no script — and the picture lands in osy-shots/ beside the page
shots. It asserts nothing, and it needs no page open. Under osy test it says why there is no image.
[Test]
void TheConfirmationLooksRight() {
Ui.Shot("order confirmation", OrderConfirmation(AnOrder()));
}Examples#
A receipt, called from a function#
theme Brand { Colors { Brand = "#C2410C"; Ink = "#20242B"; Canvas = "#F6F7F9"; } }
entity Order {
[MaxLength(40)] string Reference;
decimal Total;
[ForeignKey(Order)] OrderLine[] Lines;
security { allow read, create when IsAuthenticated; }
}
entity OrderLine {
[Required] Order Order;
[MaxLength(80)] string Name;
int Quantity;
security { allow read, create when IsAuthenticated; }
}
string Money(decimal amount) { return $"{amount:0.00} kr"; }
// A PART — no subject. Its children arrive at `Slot`.
[Email]
component Section(string title) {
render {
Stack(gap: 2) {
Text(title, fontSize: 20, fontWeight: 700, color: Colors.Ink);
Slot;
}
}
}
// A MAIL — it has a subject, so calling it answers a RenderedEmail.
[Email]
component Receipt(Order order) {
subject => $"Your order {order.Reference}";
render {
Box(bg: Colors.Canvas, p: 6) {
Preview($"Order {order.Reference} is reserved for you");
Section("Thank you") {
foreach (var line in order.Lines) {
Text($"{line.Quantity} × {line.Name}");
}
Row(justify: Justify.Between) {
Text("Total");
Text(Money(order.Total), fontWeight: 600);
}
}
Link($"https://shop.example.com/orders/{order.Reference}", text: "See your order",
bg: Colors.Brand, color: "#FFFFFF", px: 6, py: 3, rounded: 6);
}
}
}
string ReceiptSubject(Order order) {
var mail = Receipt(order);
return mail.Subject;
}A mail whose words staff arrange#
app RowingClub {
use Osysharp.Ui;
use Osysharp.Blocks;
model "model/**/*.osy";
}
using Osysharp.Blocks;
[Principal]
entity Member {
[MaxLength(80)] string Name;
[MaxLength(200)] string Email;
security { allow read when IsAuthenticated; }
}
/// A paragraph in the club's own voice.
[Block("Words")]
[Email]
component Words([Label("Words")] Markdown words) {
render { Markdown(words, placeholders: true, lineHeight: 1.6); }
}
/// The member's own details — handed the member from the mail it is placed in.
[Block("Your details")]
[Email]
component Details(Member member, [Label("Heading"), MaxLength(60)] string heading = "Your details") {
render {
Stack(gap: 1) {
Text(heading, fontWeight: 700);
Text(member.Email);
}
}
}
[Email]
component Welcome(Member member) {
subject => $"Welcome, {member.Name}";
placeholders { member.name => member.Name; }
render {
Box(p: 6) {
Region("welcome", allow: [Words, Details], require: [Details], sample: WelcomeSample) {
Words(words: "Welcome aboard, **{member.name}**.");
Details();
}
}
}
}
/// The welcome mail for the newest member — what the desk previews the region with.
RenderedEmail? WelcomeSample(string language) {
var member = Member.FirstOrDefault();
return member == null ? null : Welcome(member, language: language);
}See also#
- The EmailTemplates kit — mail that looks designed — the parts a designed mail is built from: layout, headings, buttons, receipt lines — and the mail blocks staff arrange in a region.
- Blocks and regions — pages the staff arrange — blocks and regions: the same mechanism a page's arrangeable parts use.
- The PageBuilder kit — the desk staff arrange the site at — the desk staff arrange a mail's region at, with the mail itself beside it.
- theme tokens — the tokens a mail's colours and sizes come from.
- style props — the settings the email atoms share with a page's.
- Markdown — rendering markdown text — the Markdown a
Markdown(text)atom understands. - Frame — show a rendered mail on a page, sandboxed, as a preview.
- Ui — drive the app's UI from a test —
Ui.Shot, and the pixel tier that photographs.