Skip to main content

Email templates

Overview​

crates/lo-email renders every mail through one C · Brief layout. A mail has a category and date above a heading, fine lines instead of cards, content blocks, an optional button, and a footer. The HTML is light by default and supplies its own dark colors for clients that support them. Emails have no seasonal look or animation.

The 59 NoticeKind values in crates/lo-email/src/notices.rs cover account notices and 18 template mails. templates::TemplateContext is the typed input for the latter; it supplies values and optional blocks to the same renderer. The single Sailfish layout, crates/lo-email/templates/brief.stpl, draws the blocks. The NoticeKind::ALL catalog and exhaustive copy match require each kind to be represented.

Two purchase mails are built outside NoticeKind, from their own typed input, on the same layout: the order confirmation (withdrawal::render_order_confirmation, see Billing → Withdrawal waiver and order confirmation) and the notice of an automatic refund of an undeliverable extension checkout (refund::render_refund_notice, see Webhooks → Stripe Connect). Both are transactional: no notification type, no manage link, DE/EN by the buyer's resolved locale. They are not part of the preview example.

Building blocks and copy​

brief::Mail holds the language, tone, category, date, subject, inbox preview text, blocks, and footer. brief::Block provides headings and lead lines, paragraphs and greetings, label/value details, notes with a colored edge, metrics, CID images and charts, platform tables, channel rows, buttons, fine print, and hints. The footer explains why the recipient got the mail and shows localized legal links.

German and English copy live together in notices.rs. Each language supplies the subject, preview text, category, paragraphs, detail labels, button and hint text, and footer reason. Subject and body copy may use placeholders such as {channel} or {date}; callers supply plain-text values via Notice::params or TemplateContext. The renderer fills values before delivery. **bold** in copy marks emphasis; brief::runs_filled splits copy into runs before filling placeholders, so a value cannot create formatting. Sailfish escapes values in HTML. Subjects and text/plain remain plain text. A missing parameter omits a detail row when its entire value depends on that parameter; missing placeholders in running text remain visible, so callers must provide every required value.

Every channel row (channel invite, role changed, dissolution notice to members) shows the channel's platform marks from {platforms} — comma-separated platform keys that apps/api reads with invite_notifications::account_platforms, in the dashboard order Twitch, YouTube, Kick, then the rest — and the recipient's role from {role}. A row without platforms or without a role leaves that part out. Billing mails pass the plan as {plan} and the stored Stripe cadence as {billing_interval} (month or year). The copy uses the derived {plan_term}, for example “Pro · monatlich” / “Pro · monthly”; without a known interval it is the plan name alone, and the raw value never appears in a mail.

The recipient's resolved de or en locale selects the copy. Dates use the recipient's resolved timezone where the caller supplies a localized date. See Notifications for the preference resolution.

Color scheme and client fallback​

The HTML head uses the following combination:

<meta name="color-scheme" content="light only">
<style>
:root { color-scheme: light dark; }
@media (prefers-color-scheme: dark) { /* dark palette */ }
[data-ogsc] …, [data-ogsb] … { /* Outlook dark palette */ }
</style>

light only asks Gmail's mobile apps to leave the light mail unchanged for Google accounts. The :root declaration lets Apple Mail use Lumio's dark palette despite that meta tag. The media query and Outlook selectors apply the dark values in supporting clients. The layout does not use supported-color-schemes. Gmail with a non-Google account and classic Outlook for Windows can still recolor the mail, so the layout follows all eight fallback rules:

  1. Start in light mode and supply a dark palette. Clients without dark-style support have a complete light layout; clients that support it can select Lumio's colors.
  2. Avoid pure white and pure black. The light page uses #fffffe; clients that specially replace #ffffff or #000000 are less likely to produce mismatched surfaces.
  3. Keep readable content as HTML text. Even “Lumio” beside the logo is text, so client recoloring can preserve its contrast. Words baked into an image would keep their original color.
  4. Use images that work on light and near-black backgrounds. The solid mint logo tile, brand-colored platform marks, and mid-tone chart lines remain visible when a client changes the surrounding surface. Avoid dark or white text on transparent images.
  5. Set each page background twice. body, wrapper table, and content cell each carry both bgcolor and an inline background style so mail clients recolor the same surface consistently.
  6. Do not encode meaning with color alone. Category and warning words remain present when a client shifts mint, amber, or red.
  7. Treat hairlines as decoration. A full inversion can nearly erase fine lines, so the text order and labels still communicate the content without them.
  8. Make buttons filled table cells with text. The action remains readable if image rendering or blend modes fail; the layout does not use the Gmail mix-blend-mode trick.

The light and dark color tables and measurements are documented beside the layout in brief.stpl, derived from shared/ui/src/globals.css. The light primary text #147b5e and dark red text #dd5f5f are mail-specific contrast corrections. The letter layout is at most 600 px wide, with a 24 px logo and a text wordmark; category color appears as a short line and a word. Lines separate details and metrics. A note uses a colored edge, and the button has an 8 px radius.

Images, MIME, and inbox preview​

The logo, platform marks, and chart legend dots are PNGs attached by CID. Editable SVG sources and generation notes are in assets/email/README.md; the compiled PNGs are in crates/lo-email/assets/. The stream summary attaches one transparent 1120 × 260 PNG for both themes, displayed at 560 × 130; axes, range, and legend labels are HTML text. It uses mid-tone viewer and chat lines that remain readable on light and dark backgrounds. Images are embedded rather than fetched from a server. The provider does not use an open-tracking pixel or click tracking.

Every mail supplies a separate, hidden inbox preview text and a text/plain body generated from the same blocks. SMTP delivers multipart/alternative with plain text and a multipart/related HTML part holding the CID images; SendGrid sends the same content types with inline attachments. A mail that carries a file (the PDF of a developer's self-billed credit note, lo_email::credit_note) wraps that tree in multipart/mixed with one Content-Disposition: attachment part per file (Email::attachments, EmailService::send_rendered_with_attachments); SendGrid gets it as an attachment with disposition: attachment. The preview text is not the first visible paragraph. The subject and preview text are filled with the same caller values as the body.

The footer contains a localized reason for the message plus privacy, terms, and imprint links. A “Manage notifications” link appears only when the caller passes Notice::manage_url or the corresponding TemplateContext field. Callers obtain that URL from apps/api/src/services/notification_types.rs::manage_url: it returns the settings page only for a registry type that is not locked. Locked and unknown types, and direct transactional mails without a preference key, have no manage link. The registry is also the source for the notification matrix; do not keep a second opt-out list in the email crate. See Notification preferences.

Preview and verification​

From the repository root, generate a local index with each kind in both languages, four views, and a plain-text file per kind:

cargo run -p lo-email --example preview -- /tmp/lumio-mail-preview

Open index.html in that directory. The views are light, Lumio dark, simulated Gmail iOS inversion, and simulated Gmail Android recoloring. The light view is also the expected Gmail view when light only is honored. The simulations are approximations; a real device is needed to verify client behavior.

Sending is an explicit manual step. --send-to sends the sample mails through the email provider configured in apps/api/config; --lang limits sending to de or en (default de, or use all), and --config-dir selects another configuration directory:

cargo run -p lo-email --example preview -- /tmp/lumio-mail-preview --send-to [email protected] --lang de

Adding a mail​

  1. Add its NoticeKind and DE/EN copy in notices.rs, including subject, preview text, category, body, button, and footer reason. For a typed template mail, add the corresponding TemplateContext input and map its values to the same blocks.
  2. Supply every placeholder from the caller as plain text. Preserve the security-mail rule: no codes, IP addresses, or link that itself triggers an action in a security notice. Confirmation and reset mails carry their required token link and a text fallback beneath the button.
  3. Choose reusable blocks in brief.rs and keep the color-scheme head and all eight fallback rules intact. Add images as CID PNGs with sources under assets/email/; never put essential words into an image.
  4. Resolve the recipient language and any date/time at the caller. Use notification_types::manage_url only for an unlocked registry type; leave direct and locked mail without a manage link.
  5. Extend the mail tests so every kind renders in DE and EN and the required head, preview text, text/plain, localized footer, and security exclusions hold. Verify subjects and preview text against the reference copy. Generate the preview and inspect light, dark, mobile, and both recoloring simulations; use --send-to for real-client checks when appropriate.