Skip to content

feat(email): per-template render-error policy (fail/degrade/fallback) - #57

Merged
arongreenspan merged 3 commits into
mainfrom
feat/email-render-error-policy
Jun 14, 2026
Merged

feat(email): per-template render-error policy (fail/degrade/fallback)#57
arongreenspan merged 3 commits into
mainfrom
feat/email-render-error-policy

Conversation

@agreenspan

Copy link
Copy Markdown
Contributor

What

Adds a per-template render-error policy for emails: when a conditional rule throws at render time (a malformed/uncheckable rule — not a normal non-match), the template's onError policy decides what happens.

EmailTemplate.onError: EmailErrorPolicy (default fail):

  • fail — throw EmailRenderError → BullMQ retries → DLQ-equivalent (attempts: 3, removeOnFail: { age: 30d }). Safest.
  • degrade — send the batch with the throwing block(s) dropped (the evaluator already excludes them).
  • fallback — re-compose one owner up (parentOwner: Space → Org → default) and re-render; bounded, loops until clean or a base owner forces fail.

Base owners (default/admin) aren't author-configurable and have no parent → always fail.

How

  • Evaluator (evaluateConditions/interpolate) takes an optional onError: RuleErrorSink, firing only on a rule throw, never on a non-match. The dev-only inline <!-- RULE ERROR --> comment is decoupled from isTest and now gates on EMAIL_INLINE_RENDER_ERRORS — observability (logging) is env-independent; the artifact-mutation is an explicit, testable debug flag.
  • composeTemplate returns the resolved ownerModel + onError; parentOwner is the cascade helper.
  • sendEmail render loop collects throws via onError, always logs (LogScope.email), then applies the policy. fallback threads ...ctx so per-tenant context flows through the cascade once it lands.
  • Save-side: conditionals now validated in mjml and subject; saveComponent validates conditionals at the unit boundary (component MJML is a fragment, so it isn't run through the full-document MJML validator).

Per-tenant owner/locale resolution in sendEmail is still stubbed to default, so the policy resolves to fail until that lands. The machinery + seam ship now and thread through unchanged.

Tests

evaluateConditions onError + inline-flag, parentOwner, compose result fields, rewritten interpolate render-error tests. bun run check green (lint, typecheck, 776 backend tests, frontend, CI rules).

Second commit — chore: get check green

Pre-existing repo debt, unrelated to the feature, fixed to get bun run check passing (it was already red in-tree):

  • biome 2.4.6→2.5.0 config migration (lockfile pins 2.5.0; configs used the deprecated recommended field)
  • exclude **/public from lint (noSvgWithoutTitle on Next boilerplate SVGs)
  • allow default export in .atlas/config.ts + reformat
  • regenerate the stale packages/ui/src/apiClient barrel (was missing Contact endpoints from a prior PR → monorepo typecheck failure)

These are repo-wide lint/toolchain changes — worth a look to confirm you're happy with them as committed.

🤖 Generated with Claude Code

arongreenspan and others added 3 commits June 14, 2026 01:21
A conditional rule that *throws* at render (not a non-match) now drives a
per-template `EmailErrorPolicy` (`onError`, default `fail`):

- evaluator surfaces render-time throws via an `onError` sink (fires only on a
  rule throw, never a non-match); inline error comments now gate on
  EMAIL_INLINE_RENDER_ERRORS (decoupled from env, so testable) instead of isTest
- composeTemplate returns the resolved ownerModel + onError; parentOwner walks
  Space → Org → default
- sendEmail render loop: clean batch sends; on a throw it always logs, then
  degrade (drop the throwing block) / fallback (re-compose one owner up, threading
  ...ctx) / fail (EmailRenderError → BullMQ retries → DLQ). Base owners always fail
- save-time validates conditionals in both mjml and subject; saveComponent
  validates conditionals at the unit boundary (MJML fragments aren't doc-validated)

Per-tenant owner resolution is still stubbed to default, so the policy resolves to
fail until that lands; the machinery + seam ship now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-existing repo debt unrelated to the email feature, surfaced while validating:

- biome 2.4.6→2.5.0 drift: lockfile pins 2.5.0 but configs used the deprecated
  `recommended` field. `biome migrate` updates all three biome.json to `preset`
- exclude `**/public` from lint (noSvgWithoutTitle fired on Next boilerplate SVGs)
- allow default export in `.atlas/config.ts` (config file) + reformat it
- regenerate the stale `packages/ui/src/apiClient` barrel — it was missing Contact
  endpoints from a prior PR, failing the monorepo typecheck

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…any)

Adversarial review (#1): the fallback re-compose could query with an undefined
organizationId/spaceId, which Prisma treats as "no filter" → match-any across
tenants. Latent today (sendEmail starts at default → fallback unreachable) but a
footgun once per-tenant resolution lands.

- lookupAtSpace/lookupAtOrg coalesce a missing tenant id to null (no match at this
  tier → cascade down) instead of undefined (drops the filter)
- sendEmail renderCtx is now the explicit per-send tenant context, reused for the
  initial compose and every fallback re-compose so scoping is preserved up the cascade
- regression test: an Org lookup with no organizationId throws rather than leaking
  another tenant's template

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@arongreenspan
arongreenspan merged commit eccb5b1 into main Jun 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants