Type-checked email templates for Kotlin, written in KMX.
  • Kotlin 86.7%
  • JavaScript 9.1%
  • CSS 4%
  • HTML 0.2%
Find a file
NillPoe 06622d008a
All checks were successful
Publish Package (quill) TeamCity build finished
chore: release 2.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 05:14:52 +09:00
gradle feat: inline Tailwind classes into mail styles 2026-09-30 05:14:40 +09:00
quill-gradle-plugin feat(preview): serve on another address with --host 2026-09-30 05:14:51 +09:00
quill-preview feat: inline Tailwind classes into mail styles 2026-09-30 05:14:40 +09:00
quill-preview-server feat(preview): serve on another address with --host 2026-09-30 05:14:51 +09:00
quill-runtime feat: inline Tailwind classes into mail styles 2026-09-30 05:14:40 +09:00
quill-tailwind feat: inline Tailwind classes into mail styles 2026-09-30 05:14:40 +09:00
.gitattributes chore: normalize line endings 2026-09-29 15:54:00 +09:00
.gitignore feat: add quill template framework 2026-09-29 15:43:36 +09:00
build.gradle.kts feat!: render mail templates with kmx instead of freemarker 2026-09-29 16:28:51 +09:00
gradle.properties chore: release 2.3.0 2026-09-30 05:14:52 +09:00
gradlew feat: add quill template framework 2026-09-29 15:43:36 +09:00
gradlew.bat feat: add quill template framework 2026-09-29 15:43:36 +09:00
LICENSE feat: add quill template framework 2026-09-29 15:43:36 +09:00
README.md feat(preview): serve on another address with --host 2026-09-30 05:14:51 +09:00
settings.gradle.kts feat: inline Tailwind classes into mail styles 2026-09-30 05:14:40 +09:00

Quill

Quill turns KMX templates into mails. Each template is one .kmx file per locale that renders a Kotlin payload class; kotlinc checks it like any other code, so a misspelled property or an unchecked nullable fails the build. Quill adds what a mail needs around the template engine: metadata (sender, subject), locales, environments, an XHTML body and a plain-text body from the same template, and helpers for formatting and mail-safe markup.

class InvoicePaidParam(
    val handle: String,
    val lines: List<InvoiceLineParam>,
    val total: Long,
    val discount: String?,
)

class InvoiceLineParam(val description: String, val amount: Long)
// src/main/quill/templates/invoice_paid/en_us.kmx
import com.example.mails.InvoicePaidParam

props: InvoicePaidParam

<EmailDocument title="Receipt" preheader="Thanks, {handle}">
  <p>Hi {handle},</p>
  <ul>
    @for (line in lines) {
      <li>{line.description}: {formatMoney(line.amount, "USD")}</li>
    }
  </ul>
  @if (discount != null) { <p>Discount: -{discount}</p> }
  <p>Total: {formatMoney(total, "USD")}</p>
  <Button href="https://{env.SITE}/invoices">See invoices</Button>
</EmailDocument>
val renderer = QuillRenderer.load(EmailTemplates, environment = "prod")
val mail = renderer.render(EmailTemplates.INVOICE_PAID, "en_us", param, ZoneId.of("UTC"))
mail.metadata.subject
mail.html   // XHTML
mail.text   // plain text, derived from the same tree

Setup

Quill and KMX publish to the Forgejo package registry at https://forgejo.verchase.com/api/packages/nillpoe/maven. Add it to the plugin and dependency repositories, then apply the plugin:

plugins {
    kotlin("jvm") version "2.4.10"
    id("xyz.nillpoe.quill") version "2.2.0"
}

quill {
    registry = "com.example.mails.EmailTemplates"
    locales = listOf("ko_kr", "en_us")
}

The plugin adds quill-runtime (which brings kmx-runtime) to api, or implementation without java-library. It also applies the KMX plugin, so components shared by several templates can live in src/main/kmx.

Setting Meaning Default
registry Fully qualified name of the generated object required
locales Locales every template exists in, as in the file names required
directory Root of templates/ and environments/ src/main/quill
addRuntimeDependency Adds quill-runtime true
previews Fully qualified name of the previews object in the test sources. Adds quill-preview to testImplementation, quillCheck to check, and enables quillGallery and quillServe none

Files

src/main/quill/
├── tailwind.properties       optional: turns `class` into inline styles
├── environments/
│   ├── dev.properties        SITE=example.dev
│   └── prod.properties       SITE=example.com
└── templates/
    └── invoice_paid/
        ├── en_us.kmx          template, required
        └── en_us.json         metadata, required
src/main/kmx/
└── com/example/mails/ui/footer.kmx   shared components, compiled by the KMX plugin

<id>/<locale>.kmx starts with props: PayloadClass. Every locale of an id must name the same class, written the same way. The file has no package line: Quill compiles it into the registry's package as an internal object.

<id>/<locale>.json holds the sender, optional reply-to and subject:

{"sender": {"name": "Example", "address": "noreply@example.com"}, "subject": "Your receipt from ${SITE}"}

In templates

Quill templates import xyz.nillpoe.quill.template.* automatically. Everything else is KMX; see its README for the syntax.

Name What it gives
env.KEY The value of KEY in the environment being rendered
locale, zone The template's Locale (from the file name) and the ZoneId passed to render
formatNumber(value, pattern?) 12,345.5, or with a DecimalFormat pattern
formatMoney(amount, "KRW") ₩12,000, $12.00: the locale's currency format
formatDate(value, "yyyy.MM.dd"), formatDate(value, FormatStyle.LONG), formatDateTime(value) Dates in the locale and zone
<EmailDocument title preheader lang> <html> with the XHTML namespace, head, and a hidden preheader
<Button href background color> A table-based button that renders in Outlook
<Table class width> A layout <table>: role="presentation", no border, padding or spacing, collapsed borders
cx("px-4", extra) The classes that are not null or blank, separated by spaces
<Mso> <!--[if mso]>...<![endif]-->
<HtmlOnly>, <TextOnly> Content for one body only

Values a render needs besides the payload go through the KMX render scope: declare a RenderKey, pass RenderScope.of(key, value) to render, and read scope[key] in the template.

Tailwind

Mail clients read styles only from style attributes, reliably. With a tailwind.properties file in the Quill directory, templates and components write Tailwind classes instead, and every render turns them into inline styles:

<td class="px-4 py-3.5 border border-line">
  <div class="text-xs/[18px] text-muted">Capacity</div>
  <div class="pt-0.5 text-xl font-semibold">{capacity} h</div>
</td>
<td style="border:1px solid #eeeeee;padding:14px 16px;">
  <div style="font-size:12px;line-height:18px;color:#898884;">Capacity</div>
  <div style="font-size:20px;line-height:28px;font-weight:600;padding-top:2px;">12 h</div>
</td>

The file adds to Tailwind's default theme, or replaces its entries; it can be empty:

color.muted = #898884
color.line = #eeeeee
fontSize.xs = 12px/18px
fontFamily.sans = -apple-system, 'Segoe UI', Roboto, Arial, sans-serif
spacing.gutter = 24px
screen.sm = 600px

Quill knows the Tailwind utilities mail clients support: padding and margin, width and height, font size, weight, family, line height and letter spacing, text color, alignment, decoration and transform, background color, borders and radii, display, vertical alignment, white space, overflow, object fit, opacity and table layout. Each takes arbitrary values (p-[14px_16px], text-[#898884], text-[13px]/5), colors take an opacity (text-black/50), and ! makes a class !important. Lengths are pixels and colors hex or rgba(), because many clients ignore rem and CSS variables. Classes resolve as Tailwind's stylesheet orders them, so p-4 pt-2 and pt-2 p-4 both give padding:8px 16px 16px.

The element's own style comes after the classes and wins over them.

Classes with a variant need a stylesheet: sm: and xs: apply to screens up to 600px and 430px wide (desktop first, as clients without media queries show the inline styles), dark: in dark mode, and hover: under the pointer. Such an element keeps a class for each, and their rules go into a <style> at the end of <head>, !important to win over the inline styles.

A component takes classes as a class parameter, which KMX declares in backticks:

component Row(class: String? = null, children: Node)

<tr><td class={cx("pt-[18px]", `class`)}>{children}</td></tr>

A class Quill does not know fails the build when a template or a component writes it (generateQuillSources names the file, line and column), and the render when an expression builds it, which quillCheck reports. Without tailwind.properties, classes are written as they are.

Environments

Each environments/<name>.properties is one environment, and every environment must define the same keys. The plugin generates EmailTemplates.Environment with one String property per key, so templates read {env.SITE} and a misspelled key fails the build. Metadata files use ${SITE}, replaced when the renderer loads.

QuillRenderer.load(registry, "<name>") reads that environment and the metadata of every template, and throws one exception listing every missing or broken file.

Output

html is XHTML written as XML: well-formed, escaped, with the XHTML 1.0 Transitional doctype when the root is <html>. Only void elements such as <br/> and <img/> self-close, so HTML parsers in mail clients read it correctly.

text comes from the same tree: paragraphs, headings and tables become blocks, list items get - or numbers, links become label (href), images their alt, and whitespace collapses. <HtmlOnly> parts are left out, <TextOnly> parts added.

mail.node is the rendered tree with the classes inlined, for other outputs. renderer.inline(node) inlines the classes of a component rendered in renderer.scope(...).

The registry

For registry = "com.example.mails.EmailTemplates" the plugin generates:

object EmailTemplates : TemplateRegistry<EmailTemplates.Environment> {
    val INVOICE_PAID: TemplateKey<InvoicePaidParam>
    // one property per template directory, the id in upper case

    override val entries: List<TemplateKey<*>>  // sorted by id
    override val locales: List<String>
    override val environmentKey: RenderKey<Environment>

    class Environment(val SITE: String)
}

What the build checks

generateQuillSources fails with every problem at once, each with its file and, for syntax errors, line and column:

  • Every id has <locale>.kmx and <locale>.json for each locale, and no files for other locales.
  • Ids are lowercase letters, digits and _, starting with a letter.
  • Each template parses as KMX and declares props: PayloadClass, the same in every locale.
  • Every environment defines the same keys, usable as Kotlin names.
  • With tailwind.properties, its entries are valid, and every class the templates and the components in src/main/kmx write is one Quill knows.
  • .ftl and .ftlh files are reported, to finish a migration from Quill 1.

Then kotlinc checks the generated code: unknown properties, missing null checks ({note} for a String? does not compile; write {note ?: ""} or @if (note != null)), wrong types, and a locale rendering a different payload class.

Previews

Previews are sample payloads of the templates and sample calls of the components, declared in an object in the test sources:

// src/test/kotlin/com/example/mails/MailPreviews.kt
object MailPreviews : Previews<EmailTemplates.Environment>(EmailTemplates, environment = "dev") {
    val paid = preview(EmailTemplates.INVOICE_PAID, "Paid") {
        InvoicePaidParam("ada", listOf(InvoiceLineParam("Pro plan", 12_000)), total = 12_000, discount = null)
    }
    val manyLines = paid.pin("lines = 200 items")

    val button = component("Button", "Primary") {
        Button(href = "https://example.com", children = markup { text("Pay") })
    }
}
quill {
    previews = "com.example.mails.MailPreviews"
}

A template preview renders in every locale unless locales names some. A component preview runs in the scope a template of its locale renders in. zone, lint and fuzz can be overridden in the object.

./gradlew quillCheck, which check runs, renders every preview, lints it and fuzzes its payload, and fails on errors. ./gradlew quillGallery writes them all to build/quill/gallery, and ./gradlew quillServe serves them live. All three run on the test runtime classpath. PreviewCheck(MailPreviews).run() runs the same checks from a test.

Lint

Lint walks the rendered tree, not the HTML text. Every element knows the template line that wrote it, or the line that called the component that built it, so findings point at the template:

e: src/main/quill/templates/invoice_paid/en_us.kmx:22:9 img-alt: `<img>` has no `alt`. Describe the image, or write `alt=""` if it is decoration. (invoice_paid "Paid" en_us)
w: src/main/quill/templates/invoice_paid/en_us.kmx:14:5 css-support: display:flex is not supported in Outlook for Windows. https://www.caniemail.com/features/css-display-flex/ (invoice_paid "Paid" en_us)
Rule Reports Severity
css-support CSS properties and values, elements, attributes and image formats that a client of clients does not support, from caniemail.com data bundled with quill-preview warning
img-alt <img> without alt error
contrast Text below the WCAG AA contrast ratio against its background: 4.5:1, or 3:1 for large text warning
meta-length A subject longer than subjectLength (60) or empty, a preheader longer than preheaderLength (100) warning
size-budget HTML larger than sizeBudget (90 KB). Gmail clips bodies above 102 KB warning
url Links and images with relative, empty, malformed or javascript: addresses error
render The preview or the template threw error
override val lint = Lint(
    clients = setOf(Client.GMAIL, Client.OUTLOOK_WINDOWS, Client.APPLE_MAIL),  // the default
    sizeBudget = 90 * 1024,
    severities = mapOf(Rule.CONTRAST to Severity.ERROR, Rule.META_LENGTH to Severity.OFF),
)

An element is exempt from the rules its quill:suppress attribute names, which neither body writes: <div style="display:flex" quill:suppress="css-support">. <Button> and the preheader of <EmailDocument> use it for their deliberate fallbacks. Size and subject rules are turned off through severities.

To update the support data, replace quill-preview/src/main/resources/xyz/nillpoe/quill/preview/caniemail.json with https://www.caniemail.com/api/data.json.

Fuzzing

quillCheck renders each preview again with its payload changed, one property at a time, and reports what the changes break that the preview as written does not:

Property Variants
Nullable null, or a value when the preview has null
List empty, one item, 200 items
String empty, long text, a long word, CJK, emoji, XML special characters
Numbers zero, negative, large
Boolean, enums, sealed objects every other value
Classes with a primary constructor their properties, three levels deep, and the first item of lists
Fuzz invoice_paid "Paid" en_us: 38 variants, 2 failed
  lines = 200 items
    w: src/main/quill/templates/invoice_paid/en_us.kmx size-budget: HTML body is 143 KB, over the budget of 90 KB. Gmail clips bodies above 102 KB.
    Keep it as a preview: .pin("lines = 200 items")

Fuzzing rebuilds payloads through their primary constructor and skips variants the constructor rejects. pin keeps a variant as a preview, so it stays in the checks and the gallery. variants(Money::class) { mapOf("zero" to Money(0, it.currency)) } declares variants for a type, and override val fuzz = Fuzz(mode = FuzzMode.PAIRWISE, depth = 2) changes several properties at once so that every two changes meet in some variant. fuzz = null turns fuzzing off.

build/quill/gallery/index.html is one self-contained page that lists every template and component. A template shows its previews and locales, the subject and sender, the render at desktop or mobile width, the HTML (formatted), the text body and the template source, and a problems panel with the findings and the failing fuzz variants. A component shows its signature, its KDoc, every preview and its source. The address keeps the view, so a link opens the same preview, locale and tab. Open it from disk, or publish it from CI next to each pull request.

Keys: / searches, d, m and c switch to desktop, mobile and code.

Live preview

./gradlew :mails:quillServe          # http://localhost:7070
./gradlew :mails:quillServe --port 8080
./gradlew :mails:quillServe --host 0.0.0.0   # also reachable from other devices on the network

quillServe serves the gallery and keeps it current while you edit. The page does not reload: renders that changed are swapped in place, and the selected preview, locale, view and scroll position stay. Two paths lead there:

  • A saved .kmx template or component is compiled by the server itself, with KMX and the Kotlin compiler running in its JVM, into classes that shadow the ones Gradle built. Only the previews it affects render again, usually within a second once the compiler has warmed up. This path needs the file to declare the same props or component signature as before.
  • Everything else (preview objects, payload classes, helpers, metadata, environments, new templates, new signatures) goes through a continuous Gradle build of testClasses, which the server runs with the project's Gradle wrapper. When it succeeds the project loads again. It also rebuilds .kmx changes, so what you see always ends up as Gradle builds it.

A template that does not compile, a failed build or previews that throw show an overlay with each problem at its file, line and column, a few lines of source around it, and the build output. Errors in Kotlin generated from a template point at the .kmx line. Fix the file and save: the overlay closes by itself.

Classes the IDE builds into the same output directories are picked up too. IntelliJ saves files when it loses focus; to update as you type, set Settings | Appearance & Behavior | System Settings | Autosave | Save files if the IDE is idle for 1 second.

The server binds to localhost unless --host names another address; 0.0.0.0 serves on every interface and prints the LAN addresses to open, for checking mails on a phone. It has no authentication, so do that on trusted networks only. It runs on the project's Java toolchain and uses the Kotlin compiler of the project's Kotlin plugin version, in a class loader of its own, so its dependencies do not reach the project's classes. The continuous build gets the init scripts and --offline of the build that started it, and stops when the server stops, even when that one is killed.

Migrating from Quill 1

  • Rewrite each <id>/<locale>.ftl and .ftlh pair as one <id>/<locale>.kmx with props: PayloadClass. The text body is derived from it; use <TextOnly> and <HtmlOnly> where the two differ.
  • Remove @Quill from payload classes; the directory name is the id.
  • Replace ${KEY} placeholders in templates with {env.KEY}. Metadata keeps ${KEY}.
  • Replace globals with RenderKeys passed in the render scope.
  • render takes a ZoneId instead of a TimeZone, and html is always present.
  • Payload properties that no template reads are no longer reported.

Building

./gradlew build

quill-gradle-plugin's tests publish Quill to build/testing-repository and build sample projects against it. They resolve KMX from Maven local first, so run ./gradlew publishToMavenLocal in the KMX repository to test against an unreleased KMX.

Publishing

./gradlew publishAllPublicationsToForgejoRepository -PforgejoUsername=... -PforgejoPassword=...