Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

[Email] — mail rendered from data

[Email] component Receipt(Order order) { subject => $"Your order {order.Reference}"; render { … } }

An `[Email]` component is a mail template. It is rendered on the server — from any function, a workflow step included — into HTML every mail client lays out (tables, every style inline, no script, no web font, absolute image addresses) and a plain-text twin from the same tree, in the app's own theme. With a `subject =>` it is a whole mail and calling it answers a `RenderedEmail`; without one it is a part other mails compose.

stable2 examples compiled by CIuiemailservertheme

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.

atomwhat it drawssettings
Stack { … }children one above the othergap align + the panel settings
Row { … }children side by side, in table cellsgap justify align + the panel settings
Box { … }a panelalign + 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 buttontext 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 rulecolor py
Spacer()empty vertical spaceh
Markdown(text)formatted textcolor fontSize lineHeight fontFamily textAlign linkColor placeholders
Region("name", allow: [...]) { … }the part of a mail staff arrange — see belowallow require sample
Preview(text)the line an inbox shows beside the subject — hidden in the mail itself—
Slotin 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 — order above is the confirmation's own Order. 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, with RenderedEmail? 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 a Text it is shown as text. The words themselves render as Markdown(text) does: raw HTML is shown as text and a javascript: 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#

Related

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…

Blocks and regions — pages the staff arrange

A `[Block]` is a component staff can place on a page: its parameters are the fields they fill in, and each parameter's…

The PageBuilder kit — the desk staff arrange the site at

`Osysharp.PageBuilder` is the desk where the people who run a site arrange its [[ui-blocks|regions]]: add, move, fill…

Frame

`Frame(html)` shows a whole HTML document inside the page, in a frame of its own — what a mail preview is. The document…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…

style props

Inside a `variants` block, each `Name = value` is a style prop from a fixed vocabulary the renderer maps to CSS — paint…

Markdown — rendering markdown text

`Markdown(text)` renders a markdown string as formatted content — headings, lists, tables, code, links. It is a…

Ui — drive the app's UI from a test

Drive the real UI from a test: navigate to a route, click what a person would click, and assert on what the screen…