Skip to content

Commit 47a92f4

Browse files
os-zhuangCopilot
andcommitted
feat(spec): promote email_template to first-class metadata type
Repoint email_template's canonical Zod schema from the legacy EmailTemplateSchema (a sub-shape inside Notification) to EmailTemplateDefinitionSchema across both runtime registries: - packages/objectql/src/protocol.ts (TYPE_TO_SCHEMA → used by protocol.getMetaTypes() to emit JSON Schemas for Studio) - packages/spec/src/kernel/metadata-type-schemas.ts (BUILTIN_METADATA_TYPE_SCHEMAS) Resolves Prime Directive #8 (one canonical Zod source per metadata type) for email_template. Studio property panel now renders all canonical fields (name, label, category, locale, subject, bodyHtml, bodyText, variables, fromOverride, replyTo, active, isSystem, description) with no missing-schema warnings. Also wires emailTemplates as a top-level defineStack() collection: - MAP_SUPPORTED_FIELDS + PLURAL_TO_SINGULAR (spec/shared) - ARTIFACT_FIELD_TO_TYPE (metadata plugin) - APP_CATEGORY_KEYS (runtime app-plugin) Rewrites the email-template FormView for the new shape and ships three reference templates in examples/app-crm (crm.deal_won, crm.welcome, crm.lead_followup) wired via emailTemplates: Object.values(emails). Verified end-to-end: list, detail+preview iframe, property panel, and GET /api/v1/meta all return the new schema. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7444f22 commit 47a92f4

14 files changed

Lines changed: 331 additions & 44 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/metadata': patch
5+
'@objectstack/runtime': patch
6+
'@objectstack/example-crm': patch
7+
---
8+
9+
Promote `email_template` to a first-class metadata type using the canonical
10+
`EmailTemplateDefinitionSchema`.
11+
12+
Previously `email_template` had two competing Zod schemas (Prime Directive
13+
#8 violation): the legacy `EmailTemplateSchema` (a sub-shape of
14+
`Notification`) and the richer `EmailTemplateDefinitionSchema`. The runtime
15+
metadata protocol (`packages/objectql/src/protocol.ts`) and Studio's
16+
property panel registered the legacy one, which is why all the new fields
17+
(`name`, `label`, `category`, `locale`, `bodyHtml`, `bodyText`, …) were
18+
reported as “declared in form layout but missing from schema”.
19+
20+
This change:
21+
22+
- Repoints the `email_template` entry in `TYPE_TO_SCHEMA`
23+
(`packages/objectql/src/protocol.ts`) and in
24+
`BUILTIN_METADATA_TYPE_SCHEMAS`
25+
(`packages/spec/src/kernel/metadata-type-schemas.ts`) to
26+
`EmailTemplateDefinitionSchema`. The legacy `EmailTemplateSchema` is
27+
kept only as an inline sub-shape inside `Notification`.
28+
- Adds an `emailTemplates` collection to `defineStack()` input
29+
(`packages/spec/src/stack.zod.ts`), registers it in
30+
`MAP_SUPPORTED_FIELDS`/`PLURAL_TO_SINGULAR`
31+
(`packages/spec/src/shared/metadata-collection.zod.ts`), wires it into
32+
`ARTIFACT_FIELD_TO_TYPE` (`packages/metadata/src/plugin.ts`) and
33+
`APP_CATEGORY_KEYS` (`packages/runtime/src/app-plugin.ts`).
34+
- Rewrites `packages/spec/src/system/email-template.form.ts` for the new
35+
schema with sections for Identity, Subject, HTML body, Plain-text body,
36+
Variables, Delivery overrides, Status.
37+
- Ships three reference templates in `examples/app-crm/src/emails/`:
38+
`crm.deal_won` (rewritten to canonical shape), `crm.welcome` (new),
39+
`crm.lead_followup` (new), and wires them into the CRM stack via
40+
`emailTemplates: Object.values(emails)`.
41+
42+
End-to-end verified in Studio: list view at
43+
`/_console/apps/studio/metadata/email_template` shows all three entries;
44+
the detail view renders the EmailTemplatePreview iframe and the property
45+
panel cleanly renders every canonical field (no missing-schema warnings).
46+
`GET /api/v1/meta` now returns the new `properties` set
47+
(`name, label, category, locale, subject, bodyHtml, bodyText, variables,
48+
fromOverride, replyTo, active, isSystem, description`).

‎examples/app-crm/dist/objectstack.json‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3746,6 +3746,124 @@
37463746
"enabled": true
37473747
}
37483748
],
3749+
"emailTemplates": [
3750+
{
3751+
"name": "crm.deal_won",
3752+
"label": "Deal Won — Owner Congrats",
3753+
"category": "workflow",
3754+
"locale": "en-US",
3755+
"subject": "Congratulations — {{opportunity.name}} closed!",
3756+
"bodyHtml": "<!doctype html><html><body style=\"font-family:-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1f2937;margin:0;padding:24px;background:#f9fafb\">\n <div style=\"max-width:560px;margin:0 auto;background:#ffffff;padding:32px;border-radius:8px;border:1px solid #e5e7eb\">\n <h1 style=\"margin:0 0 16px 0;font-size:20px;font-weight:600\">Great news!</h1>\n <p>Hi {{user.name}},</p>\n <p>The opportunity <strong>{{opportunity.name}}</strong> for {{account.name}} just closed at <strong>${{opportunity.amount}}</strong>.</p>\n <p>Nice work! 🎉</p>\n </div>\n</body></html>",
3757+
"bodyText": "Hi {{user.name}},\n\nThe opportunity {{opportunity.name}} for {{account.name}} just closed at ${{opportunity.amount}}.\n\nNice work!",
3758+
"variables": [
3759+
{
3760+
"name": "user.name",
3761+
"type": "string",
3762+
"required": true,
3763+
"description": "Opportunity owner"
3764+
},
3765+
{
3766+
"name": "opportunity.name",
3767+
"type": "string",
3768+
"required": true
3769+
},
3770+
{
3771+
"name": "opportunity.amount",
3772+
"type": "number",
3773+
"required": true,
3774+
"description": "Closed amount in USD"
3775+
},
3776+
{
3777+
"name": "account.name",
3778+
"type": "string",
3779+
"required": true
3780+
}
3781+
],
3782+
"active": true,
3783+
"isSystem": false,
3784+
"description": "Internal congrats email fired by the High-Value Deal workflow when stage = Closed Won."
3785+
},
3786+
{
3787+
"name": "crm.lead_followup",
3788+
"label": "Lead — Follow-Up Reminder",
3789+
"category": "notification",
3790+
"locale": "en-US",
3791+
"subject": "Reminder: follow up on {{lead.name}}",
3792+
"bodyHtml": "<!doctype html><html><body style=\"font-family:-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1f2937;margin:0;padding:24px;background:#f9fafb\">\n <div style=\"max-width:560px;margin:0 auto;background:#ffffff;padding:32px;border-radius:8px;border:1px solid #e5e7eb\">\n <h1 style=\"margin:0 0 16px 0;font-size:20px;font-weight:600\">Time to follow up</h1>\n <p>Hi {{owner.name}},</p>\n <p>Lead <strong>{{lead.name}}</strong> ({{lead.company}}) has had no activity for <strong>{{days_idle}} days</strong>.</p>\n <p>Open the lead in CRM and log the next action to keep your pipeline healthy.</p>\n <p style=\"margin:24px 0\"><a href=\"{{{lead_url}}}\" style=\"display:inline-block;padding:10px 20px;background:#0ea5e9;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600\">View lead</a></p>\n </div>\n</body></html>",
3793+
"bodyText": "Hi {{owner.name}},\n\nLead {{lead.name}} ({{lead.company}}) has had no activity for {{days_idle}} days.\n\nFollow up: {{lead_url}}",
3794+
"variables": [
3795+
{
3796+
"name": "owner.name",
3797+
"type": "string",
3798+
"required": true
3799+
},
3800+
{
3801+
"name": "lead.name",
3802+
"type": "string",
3803+
"required": true
3804+
},
3805+
{
3806+
"name": "lead.company",
3807+
"type": "string",
3808+
"required": false
3809+
},
3810+
{
3811+
"name": "days_idle",
3812+
"type": "number",
3813+
"required": true
3814+
},
3815+
{
3816+
"name": "lead_url",
3817+
"type": "url",
3818+
"required": true
3819+
}
3820+
],
3821+
"active": true,
3822+
"isSystem": false,
3823+
"description": "Internal reminder fired by the Stale Opportunity workflow."
3824+
},
3825+
{
3826+
"name": "crm.welcome",
3827+
"label": "Welcome — New Contact",
3828+
"category": "marketing",
3829+
"locale": "en-US",
3830+
"subject": "Welcome to {{account.name}}, {{contact.first_name}}!",
3831+
"bodyHtml": "<!doctype html><html><body style=\"font-family:-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1f2937;margin:0;padding:24px;background:#f9fafb\">\n <div style=\"max-width:560px;margin:0 auto;background:#ffffff;padding:32px;border-radius:8px;border:1px solid #e5e7eb\">\n <h1 style=\"margin:0 0 16px 0;font-size:22px;font-weight:600\">Welcome aboard 👋</h1>\n <p>Hi {{contact.first_name}},</p>\n <p>Thanks for connecting with <strong>{{account.name}}</strong>. Your account manager <strong>{{owner.name}}</strong> will be in touch shortly.</p>\n <p style=\"margin:24px 0\"><a href=\"{{{portal_url}}}\" style=\"display:inline-block;padding:12px 24px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600\">Open your customer portal</a></p>\n <p style=\"font-size:13px;color:#6b7280\">Or copy this link: <span style=\"word-break:break-all\">{{portal_url}}</span></p>\n </div>\n</body></html>",
3832+
"bodyText": "Hi {{contact.first_name}},\n\nThanks for connecting with {{account.name}}. Your account manager {{owner.name}} will be in touch shortly.\n\nOpen your portal: {{portal_url}}",
3833+
"variables": [
3834+
{
3835+
"name": "contact.first_name",
3836+
"type": "string",
3837+
"required": true
3838+
},
3839+
{
3840+
"name": "account.name",
3841+
"type": "string",
3842+
"required": true
3843+
},
3844+
{
3845+
"name": "owner.name",
3846+
"type": "string",
3847+
"required": true,
3848+
"description": "Assigned account manager"
3849+
},
3850+
{
3851+
"name": "portal_url",
3852+
"type": "url",
3853+
"required": true,
3854+
"description": "Customer portal link"
3855+
}
3856+
],
3857+
"fromOverride": {
3858+
"name": "Acme Sales",
3859+
"address": "sales@acme.example"
3860+
},
3861+
"replyTo": "support@acme.example",
3862+
"active": true,
3863+
"isSystem": false,
3864+
"description": "Marketing welcome email sent on contact creation."
3865+
}
3866+
],
37493867
"roles": [
37503868
{
37513869
"name": "sales_rep",

‎examples/app-crm/objectstack.config.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export default defineStack({
107107
workflows: [HighValueDealWorkflow, StaleOpportunityWorkflow],
108108
approvals: [DiscountApprovalProcess],
109109
jobs: [LeadScoringJob, PipelineReportJob, RenewalSweepJob],
110+
emailTemplates: Object.values(emails),
110111

111112
// Security
112113
roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole],
@@ -144,5 +145,4 @@ export default defineStack({
144145
*/
145146
export const referenceMetadata = {
146147
tools: [LookupContactTool],
147-
emailTemplates: Object.values(emails),
148148
};
Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,36 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import type { EmailTemplateDefinition } from '@objectstack/spec/system';
4+
35
/**
4-
* Example email template — sent when a deal is marked Closed Won.
5-
*
6-
* Email templates are not yet a top-level defineStack key; this is
7-
* re-exported via the `referenceMetadata` object in objectstack.config.ts
8-
* so the Studio metadata-admin UI can use it as a sample.
6+
* Sent when an opportunity moves to Closed Won.
7+
* Referenced by the `notify_owner_deal_won` workflow action.
98
*/
10-
export const DealWonEmail = {
11-
id: 'crm_deal_won',
9+
export const DealWonEmail: EmailTemplateDefinition = {
10+
name: 'crm.deal_won',
11+
label: 'Deal Won — Owner Congrats',
12+
category: 'workflow',
13+
locale: 'en-US',
1214
subject: 'Congratulations — {{opportunity.name}} closed!',
13-
bodyType: 'html' as const,
14-
body: `
15-
<h1>Great news!</h1>
15+
bodyHtml: `<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1f2937;margin:0;padding:24px;background:#f9fafb">
16+
<div style="max-width:560px;margin:0 auto;background:#ffffff;padding:32px;border-radius:8px;border:1px solid #e5e7eb">
17+
<h1 style="margin:0 0 16px 0;font-size:20px;font-weight:600">Great news!</h1>
1618
<p>Hi {{user.name}},</p>
17-
<p>
18-
The opportunity <strong>{{opportunity.name}}</strong>
19-
for {{account.name}} just closed at
20-
<strong>\${{opportunity.amount}}</strong>.
21-
</p>
22-
<p>Nice work!</p>
23-
`,
19+
<p>The opportunity <strong>{{opportunity.name}}</strong> for {{account.name}} just closed at <strong>\${{opportunity.amount}}</strong>.</p>
20+
<p>Nice work! 🎉</p>
21+
</div>
22+
</body></html>`,
23+
bodyText: `Hi {{user.name}},
24+
25+
The opportunity {{opportunity.name}} for {{account.name}} just closed at \${{opportunity.amount}}.
26+
27+
Nice work!`,
2428
variables: [
25-
{ name: 'user.name', type: 'string' as const },
26-
{ name: 'opportunity.name', type: 'string' as const },
27-
{ name: 'opportunity.amount', type: 'number' as const },
28-
{ name: 'account.name', type: 'string' as const },
29+
{ name: 'user.name', type: 'string', required: true, description: 'Opportunity owner' },
30+
{ name: 'opportunity.name', type: 'string', required: true },
31+
{ name: 'opportunity.amount', type: 'number', required: true, description: 'Closed amount in USD' },
32+
{ name: 'account.name', type: 'string', required: true },
2933
],
34+
active: true,
35+
description: 'Internal congrats email fired by the High-Value Deal workflow when stage = Closed Won.',
3036
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
export { DealWonEmail } from './deal-won.email.js';
4+
export { WelcomeEmail } from './welcome.email.js';
5+
export { LeadFollowUpEmail } from './lead-follow-up.email.js';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { EmailTemplateDefinition } from '@objectstack/spec/system';
4+
5+
/**
6+
* Follow-up nudge sent by the Stale Opportunity workflow when no
7+
* activity has been logged on a Lead for the configured threshold.
8+
*/
9+
export const LeadFollowUpEmail: EmailTemplateDefinition = {
10+
name: 'crm.lead_followup',
11+
label: 'Lead — Follow-Up Reminder',
12+
category: 'notification',
13+
locale: 'en-US',
14+
subject: 'Reminder: follow up on {{lead.name}}',
15+
bodyHtml: `<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1f2937;margin:0;padding:24px;background:#f9fafb">
16+
<div style="max-width:560px;margin:0 auto;background:#ffffff;padding:32px;border-radius:8px;border:1px solid #e5e7eb">
17+
<h1 style="margin:0 0 16px 0;font-size:20px;font-weight:600">Time to follow up</h1>
18+
<p>Hi {{owner.name}},</p>
19+
<p>Lead <strong>{{lead.name}}</strong> ({{lead.company}}) has had no activity for <strong>{{days_idle}} days</strong>.</p>
20+
<p>Open the lead in CRM and log the next action to keep your pipeline healthy.</p>
21+
<p style="margin:24px 0"><a href="{{{lead_url}}}" style="display:inline-block;padding:10px 20px;background:#0ea5e9;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600">View lead</a></p>
22+
</div>
23+
</body></html>`,
24+
bodyText: `Hi {{owner.name}},
25+
26+
Lead {{lead.name}} ({{lead.company}}) has had no activity for {{days_idle}} days.
27+
28+
Follow up: {{lead_url}}`,
29+
variables: [
30+
{ name: 'owner.name', type: 'string', required: true },
31+
{ name: 'lead.name', type: 'string', required: true },
32+
{ name: 'lead.company', type: 'string', required: false },
33+
{ name: 'days_idle', type: 'number', required: true },
34+
{ name: 'lead_url', type: 'url', required: true },
35+
],
36+
active: true,
37+
description: 'Internal reminder fired by the Stale Opportunity workflow.',
38+
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { EmailTemplateDefinition } from '@objectstack/spec/system';
4+
5+
/**
6+
* Welcome email sent to a Contact after it's added to the CRM.
7+
* Demonstrates marketing-category templates with a clear CTA.
8+
*/
9+
export const WelcomeEmail: EmailTemplateDefinition = {
10+
name: 'crm.welcome',
11+
label: 'Welcome — New Contact',
12+
category: 'marketing',
13+
locale: 'en-US',
14+
subject: 'Welcome to {{account.name}}, {{contact.first_name}}!',
15+
bodyHtml: `<!doctype html><html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.5;color:#1f2937;margin:0;padding:24px;background:#f9fafb">
16+
<div style="max-width:560px;margin:0 auto;background:#ffffff;padding:32px;border-radius:8px;border:1px solid #e5e7eb">
17+
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600">Welcome aboard 👋</h1>
18+
<p>Hi {{contact.first_name}},</p>
19+
<p>Thanks for connecting with <strong>{{account.name}}</strong>. Your account manager <strong>{{owner.name}}</strong> will be in touch shortly.</p>
20+
<p style="margin:24px 0"><a href="{{{portal_url}}}" style="display:inline-block;padding:12px 24px;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600">Open your customer portal</a></p>
21+
<p style="font-size:13px;color:#6b7280">Or copy this link: <span style="word-break:break-all">{{portal_url}}</span></p>
22+
</div>
23+
</body></html>`,
24+
bodyText: `Hi {{contact.first_name}},
25+
26+
Thanks for connecting with {{account.name}}. Your account manager {{owner.name}} will be in touch shortly.
27+
28+
Open your portal: {{portal_url}}`,
29+
variables: [
30+
{ name: 'contact.first_name', type: 'string', required: true },
31+
{ name: 'account.name', type: 'string', required: true },
32+
{ name: 'owner.name', type: 'string', required: true, description: 'Assigned account manager' },
33+
{ name: 'portal_url', type: 'url', required: true, description: 'Customer portal link' },
34+
],
35+
fromOverride: { name: 'Acme Sales', address: 'sales@acme.example' },
36+
replyTo: 'support@acme.example',
37+
active: true,
38+
description: 'Marketing welcome email sent on contact creation.',
39+
};

‎packages/metadata/src/plugin.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
5757
mappings: 'mapping',
5858
analyticsCubes: 'analytics_cube',
5959
connectors: 'connector',
60+
emailTemplates: 'email_template',
6061
data: 'dataset',
6162
};
6263

‎packages/objectql/src/protocol.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL } from '@objectstack/spec/shared
1818
import { ListViewSchema, FormViewSchema, ViewSchema, DashboardSchema, AppSchema, PageSchema, ReportSchema, ActionSchema, type FormView } from '@objectstack/spec/ui';
1919
import { RoleSchema } from '@objectstack/spec/identity';
2020
import { PermissionSetSchema } from '@objectstack/spec/security';
21-
import { EmailTemplateSchema, JobSchema, METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
21+
import { EmailTemplateDefinitionSchema, JobSchema, METADATA_FORM_REGISTRY } from '@objectstack/spec/system';
2222
import { ToolSchema, SkillSchema, AgentSchema } from '@objectstack/spec/ai';
2323
import { FlowSchema, WorkflowRuleSchema, ApprovalProcessSchema } from '@objectstack/spec/automation';
2424
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
@@ -43,7 +43,7 @@ const TYPE_TO_SCHEMA: Record<string, z.ZodTypeAny> = {
4343
role: RoleSchema,
4444
permission: PermissionSetSchema,
4545
profile: PermissionSetSchema,
46-
email_template: EmailTemplateSchema,
46+
email_template: EmailTemplateDefinitionSchema,
4747
tool: ToolSchema,
4848
skill: SkillSchema,
4949
agent: AgentSchema,

‎packages/runtime/src/app-plugin.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export class AppPlugin implements Plugin {
6565
'objects', 'views', 'apps', 'pages', 'dashboards', 'reports',
6666
'flows', 'workflows', 'triggers', 'agents', 'tools', 'skills',
6767
'actions', 'permissions', 'roles', 'profiles', 'translations',
68-
'sharingRules', 'ragPipelines', 'data',
68+
'sharingRules', 'ragPipelines', 'data', 'emailTemplates',
6969
];
7070
const hasAppPayload = APP_CATEGORY_KEYS.some((k) => {
7171
const v = (bundle && bundle[k]) ?? (sys && sys[k]);

0 commit comments

Comments
 (0)