Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/26948-mail-unknown-formatter-formula.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/formula': minor
---

feat(formula)!: the mail hole grammar refuses a hole whose formatter the whitelist does not name, by name, listing the known formatters

Clause-②: no (narrowing) — `judgeHoleFormatter` gains a third rule, `known-formatter`: a hole that names a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`, `{{ due | dat:long }}`) is refused, so `parseMailHole` carries the refusal and `judgeMailHoleGrammar` reports the hole, where both accepted it before. The in-app dialect, which already refused an unknown formatter in its own parse, now also refuses an inherited object key it used to call as a formatter (`{{ x | toString }}`, which printed `[object Object]`). No accept set is widened and no export is added or removed; `TemplateHoleGrammarRule` gains the member `'known-formatter'`.
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no export is removed: a hole whose formatter never formatted anything is now refused where the template is judged. A conversion cannot know which formatter the author meant, and the refusal names the hole and lists the known formatters, so there is nothing for a migration to rewrite. -->

**BREAKING** accept-set narrowing in the shared hole grammar.

- **Before.** `parseMailHole(' amount | curency:EUR ')` answered a hole with no refusal, and the mail renderer printed the raw value for it (`1234.5`, an ISO timestamp), because `formatValue` knows no such formatter.
- **Now.** The hole carries a `known-formatter` refusal whose sentence names the hole and lists `TEMPLATE_FORMATTERS`. Text after the `|` that is not a single word (`{{ x | two words }}`) still names no formatter and is left as written. A chained hole is still refused by the one-formatter rule first.
- **In-app.** `templateEngine.compile` keeps its own sentence for an unknown formatter. It now also refuses `toString`, `constructor` and the other inherited object keys, which it used to accept and run as formatters.
- **Who sees it.** A template whose hole misspells a formatter or names one that does not exist. No template shipped in this repository does.

**The remedy.** Correct the formatter's name to one the refusal lists, or format the value in the caller that supplies the data and pass the finished text as its own variable.
18 changes: 18 additions & 0 deletions .changeset/26948-mail-unknown-formatter-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/lint': minor
---

feat(lint)!: `email-template-hole-grammar` refuses an email template hole whose formatter the whitelist does not name, naming the hole and listing the known formatters

Clause-②: no (narrowing) — `os validate`, `os build`, `os lint` and the metadata publish door (`422 INVALID_METADATA`) refuse an `email_template` whose `subject`, `bodyText` or `bodyHtml` writes a hole naming a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`, `{{ due | dat:long }}`), which they accepted before. No accept set is widened, and no export or rule id is added or removed.
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: a hole whose formatter never formatted anything (the renderer printed the raw value) is now refused where it is authored. A conversion cannot know which formatter was meant, and the refusal names the hole and lists the known formatters, so there is no key for a migration to rewrite and no tombstone. -->

**BREAKING** accept-set narrowing at every authoring door for `email_template`.

- **Before.** `{{ amount | curency:EUR }}` passed every gate, and the email went out with `1234.5` where money was meant. The in-app template dialect already refused such a hole; mail did not.
- **Now.** The rule refuses it at `error`, naming the hole, the face and the template, and listing the known formatters. The judgement is `@objectstack/formula`'s `judgeMailHoleGrammar`, the grammar the mail renderer parses with.
- **Who sees it.** A template whose hole misspells a formatter or names one that does not exist. No template shipped in this repository does.

**The remedy.** Correct the formatter's name to one the finding lists, or format the value in the caller that supplies the data and pass the finished text as its own variable.
18 changes: 18 additions & 0 deletions .changeset/26948-mail-unknown-formatter-plugin-email.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/plugin-email': minor
---

feat(plugin-email)!: a mail template hole whose formatter the whitelist does not name fails the render with `TEMPLATE_INVALID` instead of printing the raw value

Clause-②: no (narrowing) — `sendTemplate` and `renderTemplate` refuse a stored template whose `subject`, `body_text` or `body_html` writes a hole naming a formatter `TEMPLATE_FORMATTERS` does not hold (`{{ amount | curency:EUR }}`), which rendered the raw value before. No accept set is widened, and the package adds no export.
Metadata-protocol: narrowed

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped and no export changes: the renderer refuses a hole the authoring rule refuses, for a template that reached it without being judged. A conversion cannot know which formatter was meant, so there is nothing for a migration to rewrite. -->

**BREAKING** a render that printed a raw value where a formatted one was meant now fails.

- **Before.** `{{ amount | curency:EUR }}` with `amount = 1234.5` rendered `1234.5` in every face, and `{{ due | dat:long }}` rendered the stored timestamp as written: the renderer's fallback for a formatter it did not know was the raw value.
- **Now.** The render throws `TEMPLATE_INVALID: email template NAME (locale=LOCALE): the hole … names the formatter 'curency', which is not a template formatter …`, listing the known formatters; `sendTemplate` sends nothing, and the messaging channels grade the failure permanent as they grade every `TEMPLATE_INVALID`. The authoring doors refuse the same hole first (`email-template-hole-grammar`), so this is reached only by a template stored before that rule, until its next publish.
- **The raw fallback is gone.** `renderTemplate` and `renderPlainTextTemplate` throw `TEMPLATE_INVALID` for such a hole too, when handed a body directly.

**The remedy.** Republish the template with the formatter's name corrected to one the refusal lists.
13 changes: 13 additions & 0 deletions .changeset/26948-mail-unknown-formatter-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': patch
---

docs(spec): the `email_template` face descriptions and the `IEmailService` `TEMPLATE_INVALID` TSDoc name a formatter the template engine does not know among the refused holes

Clause-②: no
Metadata-protocol: none

- **The face descriptions.** `subject`, `bodyHtml` and `bodyText` now say that a hole's formatter is one the template engine knows, and that an unknown name (a misspelled `curency`) is refused, naming the known formatters. The `currency` sentence is unchanged. The refusal itself ships in `@objectstack/formula`, `@objectstack/lint` and `@objectstack/plugin-email`; the spec only states the grammar, so no accept set of any schema moves.
- **The TSDoc.** The `TEMPLATE_INVALID` error of `sendTemplate` and `renderTemplate` now names all three holes `email-template-hole-grammar` refuses: a second formatter, a formatter the whitelist does not name, and a `currency` argument that is neither a literal ISO 4217 code nor a marked `@path`.

No type, schema shape or export changes.
8 changes: 8 additions & 0 deletions content/docs/automation/email-templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ never needs triple braces.
same way they do in-app. `datetime` honours the reference timezone the caller
passes; calendar-day `date` values are timezone-naive.

**The formatter is one the whitelist names.** `{{ amount | curency:EUR }}` is
refused by `os validate`, `os build`, `os lint` and the metadata publish door
(`email-template-hole-grammar`, a `422 INVALID_METADATA` at the publish door),
naming the hole and listing the known formatters (`TEMPLATE_FORMATTERS`,
exported from `@objectstack/formula`). A template stored before this rule fails its render with
`TEMPLATE_INVALID` and sends nothing, rather than print the raw value (`1234.5`)
where a formatted one was meant; republish it with the name corrected.

**One formatter per hole.** `{{ amount | default:0 | number:2 }}` is refused by
`os validate`, `os build`, `os lint` and the metadata publish door
(`email-template-hole-grammar`): a hole takes one formatter, and there is no
Expand Down
6 changes: 3 additions & 3 deletions content/docs/references/system/email-template.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ const result = EmailTemplateDefinitionSchema.parse(data);
| **label** | `string` | ✅ | Display label |
| **category** | `never` | optional | [REMOVED] `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY. A call that NAMES a locale gets exactly one retry rung, the literal `en-US`, with no language-subtag folding: `en` and `en-US` are different ROWS of one bundle and neither reaches the other, so a bundle with no `en-US` row has no fallback floor for those calls and every recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale is the other case and does not dead-letter: it drops to the bundle's lowest locale tag and renders that silently. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. |
| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **subject** | `string` | ✅ | Subject template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **bodyHtml** | `string` | ✅ | HTML body template. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **bodyText** | `string` | optional | Plain-text body template (auto-derived from HTML when omitted). Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over the send's `data`, with at most one formatter per hole (a second, chained formatter is refused), and that formatter is one the template engine knows (an unknown name, such as a misspelled `curency`, is refused, naming the known formatters). A `currency` argument is either a literal ISO 4217 code in three capital letters (`currency:EUR`) or, in a mail template only, `@` followed by a path into the send's data whose value is such a code (`currency:@currency_code`). |
| **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'date' \| 'url' \| 'user' \| 'record'>; required?: boolean; description?: string }[]` | optional (default: `[]`) | |
| **fromOverride** | `{ name?: string; address: string }` | optional | |
| **replyTo** | `string` | optional | |
Expand Down
5 changes: 3 additions & 2 deletions packages/formula/src/mail-hole-grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
* What follows the first `|` is judged by `judgeHoleFormatter`
* (`template-engine.ts`), the function the in-app template dialect judges its
* holes with too, so the two dialects refuse the same formatter parts with the
* same sentence: one formatter per hole, and a `currency` argument that is a
* literal ISO 4217 code. A refused hole is a hole — it is found, located and
* same sentence: one formatter per hole, a formatter the whitelist names
* (`TEMPLATE_FORMATTERS`), and a `currency` argument that is a literal ISO
* 4217 code. A refused hole is a hole — it is found, located and
* named — and the renderer refuses to render it rather than print a wrong
* value or formatter syntax.
*
Expand Down
19 changes: 19 additions & 0 deletions packages/formula/src/template-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS);
/** The rule a refused hole breaks. */
export type TemplateHoleGrammarRule =
| 'one-formatter'
| 'known-formatter'
| 'currency-code-literal'
| 'currency-code-path-mail-only'
| 'currency-code-path-undeclared';
Expand All @@ -187,6 +188,9 @@ export interface TemplateHoleGrammarRefusal {
message: string;
}

/** A formatter name as a hole writes one; any other text after the `|` names no formatter. */
const WORD_RE = /^\w+$/;

/** An ISO 4217 alphabetic code: three capital letters, the form `Intl` formats with. */
const CURRENCY_CODE_RE = /^[A-Z]{3}$/;

Expand Down Expand Up @@ -284,6 +288,12 @@ function chainsASecondFormatter(formatterPart: string): boolean {
/**
* Judge one hole's formatter against the grammar both template dialects share.
*
* A formatter the hole names must be one {@link TEMPLATE_FORMATTERS} holds
* (`known-formatter`). The mail renderer used to print the raw value for any
* other name, so `{{ amount | curency:EUR }}` sent `1234.5` where money was
* meant; the in-app dialect refuses such a hole in its own parse, before this
* rule's sentence is read, and keeps its own wording.
*
* @param hole the hole as written, braces included — the refusal names it
* @param formatterPart the text after the hole's first `|`, trimmed
* @param formatter the formatter name the dialect parsed
Expand All @@ -309,6 +319,15 @@ export function judgeHoleFormatter(
+ 'passing the finished value as its own variable.',
};
}
if (WORD_RE.test(formatter) && !TEMPLATE_FORMATTERS.includes(formatter)) {
return {
rule: 'known-formatter',
message:
`the hole ${hole} names the formatter '${formatter}', which is not a template formatter, so it would `
+ `never format the value. Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}. Correct the name, or `
+ 'format the value in the caller that supplies the data and pass the finished text as its own variable.',
};
}
if (formatter === 'currency' && arg !== undefined && !isCurrencyCode(arg)) {
if (currencyCodePath(formatterPart) === undefined) {
return { rule: 'currency-code-literal', message: currencyArgumentRefusal(hole, arg, dialect) };
Expand Down
Loading
Loading