The contact
EmailContact is what a rule can see about one recipient. Your host computes its fields, including :due-in, the hours until each journey's next scheduled email.
Forma for email automation · pre-alpha prototype
Who gets an email, when, and how fast. Write a campaign rule as a small Lisp form. Forma checks every lane, every decision, and every delivery failure, then generates plain Effect TypeScript for your sender. The whole vocabulary is one short prelude, shown in full below.
; warm-up email 3: who gets it, when, and how fast
(rule "launch-warmup-3"
(lanes
(lane "gmail" :per-hour 2000)
(lane "default" :per-hour 16000))
(when (due? "value-path" 6h) (defer 6h))
(when (tag? "customer") (skip "already bought"))
(when (domain? "gmail.com") (send "gmail"))
(send "default"))Prelude-defined vocabulary · TypeScript engine only · current limits
01 The pitch
Marketing automation is mostly rules: hold this email if another one is about to land, skip people who already bought, send Gmail addresses on a slower lane so the domain keeps trusting you. Those rules usually live in a visual builder, a YAML file, and a throttling script. Nothing checks them together, so mistakes show up in someone's inbox.
Forma treats the rule as source code with a small, closed vocabulary:
"gmial" is a compile error at that clause, not a silent fallback onto the bulk lane.send, defer, or skip.Bounced or RateLimited, the code that sends must turn each failure into a decision.Effect with no escaped failures. No Forma runtime ships with it.None of this is built into the compiler. The words a rule can use (send, tag?, due?, and the rest) are typed definitions in one prelude file. Change the prelude and the language changes with it.
The prelude
EmailContact is what a rule can see about one recipient. Your host computes its fields, including :due-in, the hours until each journey's next scheduled email.
EmailDecision closes the set of answers. send, defer, and skip are ordinary typed functions that build them.
tag?, domain?, and due? are ordinary functions too. They take the contact first; a rule leaves it out.
rule declares the syntax and an emit hook for the exported rule record. The linker adds the rule's lanes as EmailLane, passes the contact to predicates, and reads 6h or 2d as hours.
; email.lisp
; -----------------------------------------------------------------------------
; Purpose: Typed email decisions, predicates, and automation-rule declarations.
; Requires: compiler.lisp; the email linker supplies each rule's EmailLane vocabulary.
; Used by: The optional TypeScript email linker; scheduling and rate enforcement belong to hosts.
; -----------------------------------------------------------------------------
; Host-supplied recipient facts; due-in maps journey names to hours until
; their next scheduled email.
(type EmailContact
{:email String :domain String :tags (List String) :due-in (Map String Int)})
; Every rule answers each contact with exactly one decision.
(type EmailDecision
(Tagged (Send {:lane EmailLane}) (Defer {:hours Int}) (Skip {:reason String})))
; Returns a Send decision; delivery happens in the host's checked operation.
(: send (-> EmailLane EmailDecision))
(define send [lane] (Send {:lane lane}))
; Returns a Defer decision measured in hours.
(: defer (-> Int EmailDecision))
(define defer [hours] (Defer {:hours hours}))
; Returns a Skip decision retaining the author's reason.
(: skip (-> String EmailDecision))
(define skip [reason] (Skip {:reason reason}))
; Predicates take the contact first; rules omit it.
(: tag? (-> EmailContact String Bool))
(define tag? [contact tag] (includes? contact.tags tag))
; Tests the recipient's domain for exact string equality.
(: domain? (-> EmailContact String Bool))
(define domain? [contact domain] (= contact.domain domain))
; Tests whether a known journey is due within the supplied hours; an absent
; journey returns false.
(: due? (-> EmailContact String Int Bool))
(define due? [contact journey hours]
(match (get contact.due-in journey)
(Some next) (<= next hours)
None false))
(type EmailRuleIR
{:kind "EmailRule" :name String :lanes RuntimeExpr :clauses (List RuntimeExpr)})
(form (rule name lanes clause ...)
"Declares an email rule with lane configuration and decision clauses. The
email linker checks predicates and decisions and lowers the rule to
Effect code."
:examples
[[(rule "welcome" (lanes (lane "default" :per-hour 100))
(when (tag? "customer") (skip "Already a customer"))
(send "default"))]]
:types {:name String :lanes Syntax :clause (List Syntax)}
:ir EmailRuleIR
:emit (fn [ir]
`{:name ~(get ir :name)
:decide ~(ts/operation (get ir :name))
:perHour ~(ts/operation "email-lane-per-hour")})
{:kind "EmailRule" :name name :lanes lanes :clauses clause})03 In action
This workbench runs the email prelude and the Effect checker in your browser. The rule is followed by ordinary Effect code: a Mailer service and a run-warmup operation that delivers whatever the rule decides. Introduce error misspells the Gmail lane. Open Effect TypeScript to see fresh generated code.
04 What it catches
Each diagnostic below is real compiler output for a one-line edit to the program above, located in the author's source.
warmup.lisp:8:37 error mechanics/type-mismatch
Expected EmailLane, but this is "gmial".warmup.lisp:8:3 error email/fallthrough
End a rule with a decision for every remaining contact: send, defer, or skip.warmup.lisp:21:24 error mechanics/undeclared-error
Operation run-warmup can fail with Bounced, but its signature declares no errors.warmup.lisp:19:5 error mechanics/non-exhaustive-match
match does not handle Skip.05 The output
This is the complete generated module for the program above, checked against effect@4.1.1. The conformance case compares it with a golden, typechecks it under strict settings, and runs a harness against recording, rate-limited, and bouncing mailers.
EmailContact, EmailLane, and EmailDecision are Effect schemas, so a host can decode contacts from a CRM export before a rule sees them.
LaunchWarmup3 exports the rule's name, its decide function, and perHour for each lane. A scheduler reads rates from it.
Mailer is a Context.Service. You implement it for your provider and provide the layer when you run runWarmup.
runWarmup has type Effect<EmailDecision, never, Mailer>. Every declared provider failure has already become a decision.
import { Context, Effect, Option, Record, Schema } from "effect";
export const EmailLane = Schema.Literals(["gmail", "default"]);
export type EmailLane = typeof EmailLane.Type;
export const EmailContact = Schema.Struct({
email: Schema.String,
domain: Schema.String,
tags: Schema.Array(Schema.String),
"due-in": Schema.Record(Schema.String, Schema.Int),
});
export type EmailContact = typeof EmailContact.Type;
export const EmailDecision = Schema.Union([
Schema.Struct({ _tag: Schema.Literal("Send"), lane: EmailLane }),
Schema.Struct({ _tag: Schema.Literal("Defer"), hours: Schema.Int }),
Schema.Struct({ _tag: Schema.Literal("Skip"), reason: Schema.String }),
]);
export type EmailDecision = typeof EmailDecision.Type;
export class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", {
"retry-in-hours": Schema.Int,
}) {}
export class Bounced extends Schema.TaggedError<Bounced>()("Bounced", {
reason: Schema.String,
}) {}
export class Mailer extends Context.Service<
Mailer,
{
readonly deliver: (arg0: EmailLane, arg1: string) => Effect.Effect<void, RateLimited | Bounced>;
}
>()("Mailer") {}
export const launchWarmup3 = (contact: EmailContact): EmailDecision =>
isDue(contact, "value-path", 6) ? defer(6) : isTag(contact, "customer") ? skip("already bought") : isDomain(contact, "gmail.com") ? send("gmail") : send("default");
export const emailLanePerHour = (lane: EmailLane): number => lane === "gmail" ? 2000 : 16000;
export const send = (lane: EmailLane): EmailDecision => ({ _tag: "Send", lane });
export const defer = (hours: number): EmailDecision => ({ _tag: "Defer", hours });
export const skip = (reason: string): EmailDecision => ({ _tag: "Skip", reason });
export const isTag = (contact: EmailContact, tag: string): boolean => contact.tags.includes(tag);
export const isDomain = (contact: EmailContact, domain: string): boolean =>
contact.domain === domain;
export const isDue = (contact: EmailContact, journey: string, hours: number): boolean =>
Option.match(Record.get(contact["due-in"], journey), {
onNone: () => false,
onSome: (next) => next <= hours,
});
export const runWarmup = (contact: EmailContact): Effect.Effect<EmailDecision, never, Mailer> =>
Effect.gen(function* () {
const mailer = yield* Mailer;
const decision = launchWarmup3(contact);
switch (decision._tag) {
case "Send": {
const s = decision;
return yield* Effect.catchTags(
Effect.gen(function* () {
yield* mailer.deliver(s.lane, contact.email);
return decision;
}),
{
RateLimited: (e) => Effect.succeed(defer(e["retry-in-hours"])),
Bounced: (e) => Effect.succeed(skip(e.reason)),
},
);
}
case "Defer": {
return decision;
}
case "Skip": {
return decision;
}
}
});
export const LaunchWarmup3 = { "name": "launch-warmup-3", "decide": launchWarmup3, "perHour": emailLanePerHour };06 Limits
This is a small prototype on a pre-alpha language. The working part is the checker; most of an email platform is outside it.
EmailContact. Adding a field or predicate means editing the prelude.07 Next
Three proposed RFCs would turn checked rules into rehearsed ones. None of them is implemented.
Tests in Forma would let a rule carry its own examples: a customer is skipped, a new signup is deferred, a Gmail address takes the Gmail lane.
Checked evaluation pauses each host call with its source span, so a dry run could answer every delivery with "recorded, not sent."
Host-call journals would record a real send, replay a changed rule against it, and report the first contact who would now get a different email.
Should suppression be a separate, always-checked clause rather than first-match? Where should journey timing and multi-email sequences live? Should templates be checked against the contact schema too?
08 Fit
The prelude is the language. The conformance case shows the generated module and the mailers that exercise it.