From 41f01dd8583aee5404f1dffca6161b3964580827 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 24 Mar 2026 15:39:07 -0700 Subject: [PATCH 01/11] Added email templates design document Design for reusable email_templates table to persist email design settings across multiple email types (welcome emails, newsletters) --- .../2026-03-24-email-templates-design.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/plans/2026-03-24-email-templates-design.md diff --git a/docs/plans/2026-03-24-email-templates-design.md b/docs/plans/2026-03-24-email-templates-design.md new file mode 100644 index 00000000000..c56dc818886 --- /dev/null +++ b/docs/plans/2026-03-24-email-templates-design.md @@ -0,0 +1,106 @@ +# Email Templates Design + +## Overview + +Add a reusable `email_templates` table to persist email design settings (colors, fonts, layout options) and general settings (header image, footer, badge visibility). This decouples visual design from email content, allowing multiple email types (welcome emails, newsletters, future transactional emails) to share templates. + +## Current State + +- `automated_emails` table stores welcome email content (subject, lexical, sender info) +- Welcome email customize modal (`welcome-email-customize-modal.tsx`) has design/general fields but does NOT persist them (TODO at line 200) +- Newsletters table has identical design columns inline — the template table unifies this pattern + +## Database Schema + +### New table: `email_templates` + +| Column | Type | Nullable | Default | Validations | +|---|---|---|---|---| +| `id` | string(24) | no | — | Primary key | +| `name` | string(191) | no | — | Unique | +| `slug` | string(191) | no | — | Unique | +| **General settings** | | | | | +| `header_image` | string(2000) | yes | null | | +| `show_publication_title` | boolean | no | true | | +| `show_badge` | boolean | no | true | | +| `footer_content` | text(longtext) | yes | null | | +| **Design settings** | | | | | +| `background_color` | string(50) | no | `'#ffffff'` | | +| `title_font_category` | string(191) | no | `'sans_serif'` | isIn: serif, sans_serif | +| `title_font_weight` | string(50) | no | `'bold'` | isIn: normal, medium, semibold, bold | +| `body_font_category` | string(191) | no | `'sans_serif'` | isIn: serif, sans_serif | +| `header_background_color` | string(50) | no | `'#ffffff'` | | +| `title_alignment` | string(191) | no | `'center'` | isIn: center, left | +| `post_title_color` | string(50) | yes | null | | +| `section_title_color` | string(50) | yes | null | | +| `button_color` | string(50) | yes | `'accent'` | | +| `button_style` | string(50) | no | `'fill'` | isIn: fill, outline | +| `button_corners` | string(50) | no | `'rounded'` | isIn: square, rounded, pill | +| `link_color` | string(50) | yes | `'accent'` | | +| `link_style` | string(50) | no | `'underline'` | isIn: underline, regular, bold | +| `image_corners` | string(50) | no | `'square'` | isIn: square, rounded | +| `divider_color` | string(50) | yes | null | | +| **Timestamps** | | | | | +| `created_at` | dateTime | no | — | | +| `updated_at` | dateTime | yes | — | | + +Indexes: `['slug']` + +### Modified table: `automated_emails` + +Add column: + +| Column | Type | Nullable | Default | Notes | +|---|---|---|---|---| +| `email_template_id` | string(24) | yes | null | References `email_templates.id` | + +## Migrations + +Three sequential migrations: + +1. **Add `email_templates` table** — creates the table with all columns +2. **Add `email_template_id` to `automated_emails`** — adds the FK column +3. **Seed default template and link automated emails** — creates a `default` template row and updates both automated_email rows to reference it + +## API Design + +### New endpoint: `GET/PUT /email_templates/` + +| Action | Method | Path | +|---|---|---| +| Browse | GET | `/email_templates/` | +| Read | GET | `/email_templates/:id/` | +| Edit | PUT | `/email_templates/:id/` | + +No create/delete for now — templates are seeded, users edit the default. Create/delete can be added later for multiple template support. + +### Changes to `/automated_emails/` + +- Response payload includes `email_template_id` +- Edit accepts `email_template_id` to associate a template + +## Backend Implementation + +| File | Action | Purpose | +|---|---|---| +| `ghost/core/core/server/data/schema/schema.js` | Edit | Add `email_templates` table, add `email_template_id` to `automated_emails` | +| `ghost/core/core/server/data/migrations/versions/6.*/` | Create (x3) | Three migrations | +| `ghost/core/core/server/models/email-template.js` | Create | Bookshelf model | +| `ghost/core/core/server/models/automated-email.js` | Edit | Add relationship to email_template | +| `ghost/core/core/server/api/endpoints/email-templates.js` | Create | API controller (browse, read, edit) | +| `ghost/core/core/server/api/endpoints/index.js` | Edit | Register endpoint | + +## Frontend Implementation + +| File | Action | Purpose | +|---|---|---| +| `apps/admin-x-framework/src/api/email-templates.ts` | Create | TypeScript types + React Query hooks | +| `apps/admin-x-settings/.../welcome-email-customize-modal.tsx` | Edit | Wire up to email_templates API | + +## Design Decisions + +- **Individual columns over JSON** — matches newsletters pattern, enables DB-level validation, consistent with existing codebase +- **Separate table over extending automated_emails** — reusable across email types (newsletters will get `email_template_id` FK later) +- **Sender fields stay on automated_emails** — sender identity is per-email-type, not per-template +- **Seeded default template** — avoids "create on first use" logic; always has a template to read/edit +- **No create/delete API yet** — YAGNI; single default template is sufficient for current needs From 5efae27d7155b48fa124e9c67aea691d1714950a Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 24 Mar 2026 15:47:02 -0700 Subject: [PATCH 02/11] Added email templates implementation plan 12-task plan covering: schema, migrations, model, API endpoint, routes, E2E tests, frontend hooks, and modal wiring --- ...26-03-24-email-templates-implementation.md | 711 ++++++++++++++++++ 1 file changed, 711 insertions(+) create mode 100644 docs/plans/2026-03-24-email-templates-implementation.md diff --git a/docs/plans/2026-03-24-email-templates-implementation.md b/docs/plans/2026-03-24-email-templates-implementation.md new file mode 100644 index 00000000000..3547d991768 --- /dev/null +++ b/docs/plans/2026-03-24-email-templates-implementation.md @@ -0,0 +1,711 @@ +# Email Templates Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a reusable `email_templates` table with design/general columns, link it to `automated_emails` via FK, expose browse/read/edit API, and wire up the frontend customize modal. + +**Architecture:** New `email_templates` table with individual design columns (mirroring newsletters). `automated_emails` gets an `email_template_id` FK. New REST endpoint at `/email_templates/` with browse/read/edit. Frontend hooks load and save template via React Query. + +**Tech Stack:** Knex migrations, Bookshelf ORM, Express routes, React Query (admin-x-framework), TypeScript + +--- + +### Task 1: Add `email_templates` table to schema.js + +**Files:** +- Modify: `ghost/core/core/server/data/schema/schema.js` (insert before `automated_emails` definition around line 1145) + +**Step 1: Add the email_templates table definition** + +Add this block in `schema.js` immediately before the `automated_emails` definition: + +```javascript +email_templates: { + id: {type: 'string', maxlength: 24, nullable: false, primary: true}, + name: {type: 'string', maxlength: 191, nullable: false, unique: true}, + slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, + header_image: {type: 'string', maxlength: 2000, nullable: true}, + show_publication_title: {type: 'boolean', nullable: false, defaultTo: true}, + show_badge: {type: 'boolean', nullable: false, defaultTo: true}, + footer_content: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, + background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, + title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, + title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold', validations: {isIn: [['normal', 'medium', 'semibold', 'bold']]}}, + body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, + header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, + title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: [['center', 'left']]}}, + post_title_color: {type: 'string', maxlength: 50, nullable: true}, + section_title_color: {type: 'string', maxlength: 50, nullable: true}, + button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, + button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill', validations: {isIn: [['fill', 'outline']]}}, + button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded', validations: {isIn: [['square', 'rounded', 'pill']]}}, + link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, + link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline', validations: {isIn: [['underline', 'regular', 'bold']]}}, + image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square', validations: {isIn: [['square', 'rounded']]}}, + divider_color: {type: 'string', maxlength: 50, nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + '@@INDEXES@@': [ + ['slug'] + ] +}, +``` + +**Step 2: Add `email_template_id` FK to `automated_emails`** + +In the `automated_emails` table definition (around line 1145), add this column after `sender_reply_to`: + +```javascript +email_template_id: {type: 'string', maxlength: 24, nullable: true, references: 'email_templates.id'}, +``` + +**Step 3: Commit** + +```bash +git add ghost/core/core/server/data/schema/schema.js +git commit -m "Added email_templates table and email_template_id FK to schema" +``` + +--- + +### Task 2: Create migration — add email_templates table + +**Files:** +- Create: `ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-00-add-email-templates-table.js` + +**Step 1: Create the migration file** + +```javascript +const {addTable} = require('../../utils'); + +module.exports = addTable('email_templates', { + id: {type: 'string', maxlength: 24, nullable: false, primary: true}, + name: {type: 'string', maxlength: 191, nullable: false, unique: true}, + slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, + header_image: {type: 'string', maxlength: 2000, nullable: true}, + show_publication_title: {type: 'boolean', nullable: false, defaultTo: true}, + show_badge: {type: 'boolean', nullable: false, defaultTo: true}, + footer_content: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, + background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, + title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif'}, + title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold'}, + body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif'}, + header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, + title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center'}, + post_title_color: {type: 'string', maxlength: 50, nullable: true}, + section_title_color: {type: 'string', maxlength: 50, nullable: true}, + button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, + button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill'}, + button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded'}, + link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, + link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline'}, + image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square'}, + divider_color: {type: 'string', maxlength: 50, nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + '@@INDEXES@@': [ + ['slug'] + ] +}); +``` + +**Step 2: Commit** + +```bash +git add ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-00-add-email-templates-table.js +git commit -m "Added migration to create email_templates table" +``` + +--- + +### Task 3: Create migration — add email_template_id column to automated_emails + +**Files:** +- Create: `ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-01-add-email-template-id-to-automated-emails.js` + +**Step 1: Create the migration file** + +```javascript +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('automated_emails', 'email_template_id', { + type: 'string', + maxlength: 24, + nullable: true, + references: 'email_templates.id' +}); +``` + +**Step 2: Commit** + +```bash +git add ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-01-add-email-template-id-to-automated-emails.js +git commit -m "Added migration to add email_template_id FK to automated_emails" +``` + +--- + +### Task 4: Create migration — seed default email template + +**Files:** +- Create: `ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-02-seed-default-email-template.js` + +**Step 1: Create the seed migration file** + +```javascript +const logging = require('@tryghost/logging'); +const {default: ObjectID} = require('bson-objectid'); +const {createTransactionalMigration} = require('../../utils'); + +const DEFAULT_TEMPLATE_SLUG = 'default'; + +module.exports = createTransactionalMigration( + async function up(knex) { + const existing = await knex('email_templates') + .where({slug: DEFAULT_TEMPLATE_SLUG}) + .first(); + + if (existing) { + logging.warn('Default email template already exists, skipping'); + return; + } + + const templateId = (new ObjectID()).toHexString(); + + logging.info('Creating default email template'); + await knex('email_templates').insert({ + id: templateId, + name: 'Default', + slug: DEFAULT_TEMPLATE_SLUG, + created_at: knex.raw('current_timestamp') + }); + + logging.info('Linking existing automated emails to default template'); + await knex('automated_emails') + .whereNull('email_template_id') + .update({email_template_id: templateId}); + }, + async function down(knex) { + logging.info('Unlinking automated emails from default template'); + await knex('automated_emails') + .update({email_template_id: null}); + + logging.info('Deleting default email template'); + await knex('email_templates') + .where({slug: DEFAULT_TEMPLATE_SLUG}) + .del(); + } +); +``` + +**Step 2: Commit** + +```bash +git add ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-02-seed-default-email-template.js +git commit -m "Added migration to seed default email template and link automated emails" +``` + +--- + +### Task 5: Create EmailTemplate Bookshelf model + +**Files:** +- Create: `ghost/core/core/server/models/email-template.js` + +**Step 1: Create the model file** + +```javascript +const ghostBookshelf = require('./base'); + +const EmailTemplate = ghostBookshelf.Model.extend({ + tableName: 'email_templates', + + defaults() { + return { + show_publication_title: true, + show_badge: true, + background_color: '#ffffff', + title_font_category: 'sans_serif', + title_font_weight: 'bold', + body_font_category: 'sans_serif', + header_background_color: '#ffffff', + title_alignment: 'center', + button_color: 'accent', + button_style: 'fill', + button_corners: 'rounded', + link_color: 'accent', + link_style: 'underline', + image_corners: 'square' + }; + }, + + automatedEmails() { + return this.hasMany('AutomatedEmail', 'email_template_id'); + } +}); + +module.exports = { + EmailTemplate: ghostBookshelf.model('EmailTemplate', EmailTemplate) +}; +``` + +**Step 2: Add relationship to AutomatedEmail model** + +In `ghost/core/core/server/models/automated-email.js`, add this method to the `AutomatedEmail` model extend block (after the `defaults()` method): + +```javascript +emailTemplate() { + return this.belongsTo('EmailTemplate', 'email_template_id'); +}, +``` + +**Step 3: Commit** + +```bash +git add ghost/core/core/server/models/email-template.js ghost/core/core/server/models/automated-email.js +git commit -m "Added EmailTemplate model and relationship to AutomatedEmail" +``` + +--- + +### Task 6: Create email-templates API endpoint + +**Files:** +- Create: `ghost/core/core/server/api/endpoints/email-templates.js` +- Modify: `ghost/core/core/server/api/endpoints/index.js` (add getter around line 84, near `automatedEmails`) + +**Step 1: Create the endpoint controller** + +```javascript +const tpl = require('@tryghost/tpl'); +const errors = require('@tryghost/errors'); +const models = require('../../models'); + +const messages = { + emailTemplateNotFound: 'Email template not found.' +}; + +/** @type {import('@tryghost/api-framework').Controller} */ +const controller = { + docName: 'email_templates', + + browse: { + headers: { + cacheInvalidate: false + }, + options: [ + 'filter', + 'fields', + 'limit', + 'order', + 'page' + ], + permissions: true, + query(frame) { + return models.EmailTemplate.findPage(frame.options); + } + }, + + read: { + headers: { + cacheInvalidate: false + }, + options: [ + 'filter', + 'fields' + ], + data: [ + 'id' + ], + permissions: true, + async query(frame) { + const model = await models.EmailTemplate.findOne(frame.data, frame.options); + if (!model) { + throw new errors.NotFoundError({ + message: tpl(messages.emailTemplateNotFound) + }); + } + + return model; + } + }, + + edit: { + headers: { + cacheInvalidate: false + }, + options: [ + 'id' + ], + validation: { + options: { + id: { + required: true + } + } + }, + permissions: true, + async query(frame) { + const data = frame.data.email_templates[0]; + const model = await models.EmailTemplate.edit(data, frame.options); + if (!model) { + throw new errors.NotFoundError({ + message: tpl(messages.emailTemplateNotFound) + }); + } + + return model; + } + } +}; + +module.exports = controller; +``` + +**Step 2: Register in endpoints index** + +In `ghost/core/core/server/api/endpoints/index.js`, add this getter near the `automatedEmails` getter (around line 84): + +```javascript +get emailTemplates() { + return apiFramework.pipeline(require('./email-templates'), localUtils); +}, +``` + +**Step 3: Commit** + +```bash +git add ghost/core/core/server/api/endpoints/email-templates.js ghost/core/core/server/api/endpoints/index.js +git commit -m "Added email_templates API endpoint (browse, read, edit)" +``` + +--- + +### Task 7: Add admin API routes for email_templates + +**Files:** +- Modify: `ghost/core/core/server/web/api/endpoints/admin/routes.js` (add routes near automated_emails routes around line 194) + +**Step 1: Add routes** + +Add these lines after the automated_emails routes block: + +```javascript +// ## Email Templates +router.get('/email_templates', mw.authAdminApi, http(api.emailTemplates.browse)); +router.get('/email_templates/:id', mw.authAdminApi, http(api.emailTemplates.read)); +router.put('/email_templates/:id', mw.authAdminApi, http(api.emailTemplates.edit)); +``` + +**Step 2: Commit** + +```bash +git add ghost/core/core/server/web/api/endpoints/admin/routes.js +git commit -m "Added admin API routes for email_templates" +``` + +--- + +### Task 8: Write E2E API tests for email_templates + +**Files:** +- Create: `ghost/core/test/e2e-api/admin/email-templates.test.js` + +**Step 1: Write the test file** + +```javascript +const {agentProvider, fixtureManager, matchers, dbUtils} = require('../../utils/e2e-framework'); +const {anyContentVersion, anyObjectId, anyISODateTime, anyEtag} = matchers; + +const matchEmailTemplate = { + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime +}; + +describe('Email Templates API', function () { + let agent; + + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('users'); + await agent.loginAsOwner(); + }); + + describe('Browse', function () { + it('Can browse email templates', async function () { + await agent + .get('email_templates') + .expectStatus(200) + .matchBodySnapshot({ + email_templates: [matchEmailTemplate] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + }); + + describe('Read', function () { + it('Can read an email template by id', async function () { + const {body: {email_templates: [template]}} = await agent + .get('email_templates') + .expectStatus(200); + + await agent + .get(`email_templates/${template.id}`) + .expectStatus(200) + .matchBodySnapshot({ + email_templates: [matchEmailTemplate] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + + it('Cannot read a non-existent template', async function () { + await agent + .get('email_templates/aaaaaaaaaaaaaaaaaaaaaaaa') + .expectStatus(404) + .matchBodySnapshot({ + errors: [{ + id: anyObjectId + }] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + }); + + describe('Edit', function () { + it('Can edit an email template', async function () { + const {body: {email_templates: [template]}} = await agent + .get('email_templates') + .expectStatus(200); + + await agent + .put(`email_templates/${template.id}`) + .body({email_templates: [{ + background_color: '#f0f0f0', + title_font_category: 'serif', + button_style: 'outline', + show_badge: false, + footer_content: 'Custom footer text' + }]}) + .expectStatus(200) + .matchBodySnapshot({ + email_templates: [matchEmailTemplate] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + + it('Cannot edit a non-existent template', async function () { + await agent + .put('email_templates/aaaaaaaaaaaaaaaaaaaaaaaa') + .body({email_templates: [{ + background_color: '#000000' + }]}) + .expectStatus(404) + .matchBodySnapshot({ + errors: [{ + id: anyObjectId + }] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + }); +}); +``` + +**Step 2: Run the tests to verify they pass** + +```bash +cd ghost/core && yarn test:e2e test/e2e-api/admin/email-templates.test.js +``` + +Expected: All tests pass. Snapshot files are auto-generated on first run. + +**Step 3: Commit** + +```bash +git add ghost/core/test/e2e-api/admin/email-templates.test.js ghost/core/test/e2e-api/admin/__snapshots__/ +git commit -m "Added E2E API tests for email_templates endpoint" +``` + +--- + +### Task 9: Create frontend API hooks for email_templates + +**Files:** +- Create: `apps/admin-x-framework/src/api/email-templates.ts` + +**Step 1: Create the TypeScript API file** + +```typescript +import {Meta, createMutation, createQuery} from '../utils/api/hooks'; +import {updateQueryCache} from '../utils/api/update-queries'; + +export type EmailTemplate = { + id: string; + name: string; + slug: string; + header_image: string | null; + show_publication_title: boolean; + show_badge: boolean; + footer_content: string | null; + background_color: string; + title_font_category: string; + title_font_weight: string; + body_font_category: string; + header_background_color: string; + title_alignment: string; + post_title_color: string | null; + section_title_color: string | null; + button_color: string | null; + button_style: string; + button_corners: string; + link_color: string | null; + link_style: string; + image_corners: string; + divider_color: string | null; + created_at: string; + updated_at: string | null; +} + +export interface EmailTemplatesResponseType { + meta?: Meta; + email_templates: EmailTemplate[]; +} + +const dataType = 'EmailTemplatesResponseType'; + +export const useBrowseEmailTemplates = createQuery({ + dataType, + path: '/email_templates/' +}); + +export const useEditEmailTemplate = createMutation({ + method: 'PUT', + path: emailTemplate => `/email_templates/${emailTemplate.id}/`, + body: emailTemplate => ({email_templates: [emailTemplate]}), + updateQueries: { + dataType, + emberUpdateType: 'createOrUpdate', + update: updateQueryCache('email_templates') + } +}); +``` + +**Step 2: Commit** + +```bash +git add apps/admin-x-framework/src/api/email-templates.ts +git commit -m "Added frontend API hooks for email_templates" +``` + +--- + +### Task 10: Wire up the welcome email customize modal to email_templates API + +**Files:** +- Modify: `apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx` + +**Step 1: Update imports and hook up API** + +Replace local state initialization with API data loading: + +1. Add imports for `useBrowseEmailTemplates` and `useEditEmailTemplate` +2. Load the default template via `useBrowseEmailTemplates()` +3. Initialize `designSettings` and `generalSettings` from the loaded template +4. Update `handleSave` to call `useEditEmailTemplate` mutation instead of the TODO comment + +Key changes to the `WelcomeEmailCustomizeModal` component: + +- Load template: `const {data: {email_templates: templates} = {email_templates: []}} = useBrowseEmailTemplates();` +- Get first template: `const template = templates[0];` +- Initialize state from template data (with fallbacks to defaults) +- On save: call `editEmailTemplate({...template, ...designSettings, ...generalSettingsAsMapped})` +- Map frontend `GeneralSettings` keys to DB column names (e.g., `showBadge` → `show_badge`) + +**Step 2: Run the admin-x-settings tests** + +```bash +cd apps/admin-x-settings && yarn test +``` + +Expected: Tests pass (may need snapshot updates for the new API calls). + +**Step 3: Commit** + +```bash +git add apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx +git commit -m "Wired welcome email customize modal to email_templates API" +``` + +--- + +### Task 11: Update automated_emails type to include email_template_id + +**Files:** +- Modify: `apps/admin-x-framework/src/api/automated-emails.ts` + +**Step 1: Add email_template_id to the AutomatedEmail type** + +Add to the `AutomatedEmail` type: + +```typescript +email_template_id: string | null; +``` + +**Step 2: Commit** + +```bash +git add apps/admin-x-framework/src/api/automated-emails.ts +git commit -m "Added email_template_id to AutomatedEmail type" +``` + +--- + +### Task 12: Final verification + +**Step 1: Run Ghost core linting** + +```bash +cd ghost/core && yarn lint +``` + +Expected: No lint errors. + +**Step 2: Run Ghost core unit tests** + +```bash +cd ghost/core && yarn test:unit +``` + +Expected: All pass. + +**Step 3: Run the email-templates E2E test** + +```bash +cd ghost/core && yarn test:e2e test/e2e-api/admin/email-templates.test.js +``` + +Expected: All pass. + +**Step 4: Run admin-x-framework build** + +```bash +cd apps/admin-x-framework && yarn build +``` + +Expected: Builds without errors. From 589a33263a7b0ecb0d521488274eb64e4a98c075 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 24 Mar 2026 23:14:10 +0000 Subject: [PATCH 03/11] Added email_templates table and email_template_id FK to schema --- ghost/core/core/server/data/schema/schema.js | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 0811b2e28b8..813fe1e8f24 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -1142,6 +1142,35 @@ module.exports = { ['event_type', 'status', 'created_at'] ] }, + email_templates: { + id: {type: 'string', maxlength: 24, nullable: false, primary: true}, + name: {type: 'string', maxlength: 191, nullable: false, unique: true}, + slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, + header_image: {type: 'string', maxlength: 2000, nullable: true}, + show_publication_title: {type: 'boolean', nullable: false, defaultTo: true}, + show_badge: {type: 'boolean', nullable: false, defaultTo: true}, + footer_content: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, + background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, + title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, + title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold', validations: {isIn: [['normal', 'medium', 'semibold', 'bold']]}}, + body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, + header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, + title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: [['center', 'left']]}}, + post_title_color: {type: 'string', maxlength: 50, nullable: true}, + section_title_color: {type: 'string', maxlength: 50, nullable: true}, + button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, + button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill', validations: {isIn: [['fill', 'outline']]}}, + button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded', validations: {isIn: [['square', 'rounded', 'pill']]}}, + link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, + link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline', validations: {isIn: [['underline', 'regular', 'bold']]}}, + image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square', validations: {isIn: [['square', 'rounded']]}}, + divider_color: {type: 'string', maxlength: 50, nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + '@@INDEXES@@': [ + ['slug'] + ] + }, automated_emails: { id: {type: 'string', maxlength: 24, nullable: false, primary: true}, status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'inactive', validations: {isIn: [['active', 'inactive']]}}, @@ -1152,6 +1181,7 @@ module.exports = { sender_name: {type: 'string', maxlength: 191, nullable: true}, sender_email: {type: 'string', maxlength: 191, nullable: true, validations: {isEmail: true}}, sender_reply_to: {type: 'string', maxlength: 191, nullable: true, validations: {isEmail: true}}, + email_template_id: {type: 'string', maxlength: 24, nullable: true, references: 'email_templates.id'}, created_at: {type: 'dateTime', nullable: false}, updated_at: {type: 'dateTime', nullable: true}, '@@INDEXES@@': [ From 3b8b06fbcce66294b43d3406d3a2f58da1515553 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 24 Mar 2026 23:15:48 +0000 Subject: [PATCH 04/11] Added migrations for email_templates table, FK, seed data, and permissions --- ...3-24-23-14-31-add-email-templates-table.js | 31 +++++++++++++ ...d-email-template-id-to-automated-emails.js | 8 ++++ ...23-14-39-add-email-template-permissions.js | 28 ++++++++++++ ...24-23-14-39-seed-default-email-template.js | 43 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-31-add-email-templates-table.js create mode 100644 ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-add-email-template-id-to-automated-emails.js create mode 100644 ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-add-email-template-permissions.js create mode 100644 ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-seed-default-email-template.js diff --git a/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-31-add-email-templates-table.js b/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-31-add-email-templates-table.js new file mode 100644 index 00000000000..0d4fe1e4f94 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-31-add-email-templates-table.js @@ -0,0 +1,31 @@ +const {addTable} = require('../../utils'); + +module.exports = addTable('email_templates', { + id: {type: 'string', maxlength: 24, nullable: false, primary: true}, + name: {type: 'string', maxlength: 191, nullable: false, unique: true}, + slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, + header_image: {type: 'string', maxlength: 2000, nullable: true}, + show_publication_title: {type: 'boolean', nullable: false, defaultTo: true}, + show_badge: {type: 'boolean', nullable: false, defaultTo: true}, + footer_content: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, + background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, + title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif'}, + title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold'}, + body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif'}, + header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, + title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center'}, + post_title_color: {type: 'string', maxlength: 50, nullable: true}, + section_title_color: {type: 'string', maxlength: 50, nullable: true}, + button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, + button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill'}, + button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded'}, + link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, + link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline'}, + image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square'}, + divider_color: {type: 'string', maxlength: 50, nullable: true}, + created_at: {type: 'dateTime', nullable: false}, + updated_at: {type: 'dateTime', nullable: true}, + '@@INDEXES@@': [ + ['slug'] + ] +}); diff --git a/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-add-email-template-id-to-automated-emails.js b/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-add-email-template-id-to-automated-emails.js new file mode 100644 index 00000000000..96aec62f3cc --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-add-email-template-id-to-automated-emails.js @@ -0,0 +1,8 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('automated_emails', 'email_template_id', { + type: 'string', + maxlength: 24, + nullable: true, + references: 'email_templates.id' +}); diff --git a/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-add-email-template-permissions.js b/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-add-email-template-permissions.js new file mode 100644 index 00000000000..1b0f69310f9 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-add-email-template-permissions.js @@ -0,0 +1,28 @@ +const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils'); + +module.exports = combineTransactionalMigrations( + addPermissionWithRoles({ + name: 'Browse email templates', + action: 'browse', + object: 'email_template' + }, [ + 'Administrator', + 'Admin Integration' + ]), + addPermissionWithRoles({ + name: 'Read email templates', + action: 'read', + object: 'email_template' + }, [ + 'Administrator', + 'Admin Integration' + ]), + addPermissionWithRoles({ + name: 'Edit email templates', + action: 'edit', + object: 'email_template' + }, [ + 'Administrator', + 'Admin Integration' + ]) +); diff --git a/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-seed-default-email-template.js b/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-seed-default-email-template.js new file mode 100644 index 00000000000..4c459f43e02 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.23/2026-03-24-23-14-39-seed-default-email-template.js @@ -0,0 +1,43 @@ +const logging = require('@tryghost/logging'); +const {default: ObjectID} = require('bson-objectid'); +const {createTransactionalMigration} = require('../../utils'); + +const DEFAULT_TEMPLATE_SLUG = 'default'; + +module.exports = createTransactionalMigration( + async function up(knex) { + const existing = await knex('email_templates') + .where({slug: DEFAULT_TEMPLATE_SLUG}) + .first(); + + if (existing) { + logging.warn('Default email template already exists, skipping'); + return; + } + + const templateId = (new ObjectID()).toHexString(); + + logging.info('Creating default email template'); + await knex('email_templates').insert({ + id: templateId, + name: 'Default', + slug: DEFAULT_TEMPLATE_SLUG, + created_at: knex.raw('current_timestamp') + }); + + logging.info('Linking existing automated emails to default template'); + await knex('automated_emails') + .whereNull('email_template_id') + .update({email_template_id: templateId}); + }, + async function down(knex) { + logging.info('Unlinking automated emails from default template'); + await knex('automated_emails') + .update({email_template_id: null}); + + logging.info('Deleting default email template'); + await knex('email_templates') + .where({slug: DEFAULT_TEMPLATE_SLUG}) + .del(); + } +); From 475a710a27040b883571bd19e9d71d1333e70e2c Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 24 Mar 2026 23:16:34 +0000 Subject: [PATCH 05/11] Added EmailTemplate model and relationship to AutomatedEmail --- .../core/server/models/automated-email.js | 4 +++ .../core/core/server/models/email-template.js | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 ghost/core/core/server/models/email-template.js diff --git a/ghost/core/core/server/models/automated-email.js b/ghost/core/core/server/models/automated-email.js index 061a4bfc76f..f0ae4e563db 100644 --- a/ghost/core/core/server/models/automated-email.js +++ b/ghost/core/core/server/models/automated-email.js @@ -67,6 +67,10 @@ const AutomatedEmail = ghostBookshelf.Model.extend({ slug } }, isEnableTransition ? 'Welcome email enabled' : 'Welcome email disabled'); + }, + + emailTemplate() { + return this.belongsTo('EmailTemplate', 'email_template_id'); } }); diff --git a/ghost/core/core/server/models/email-template.js b/ghost/core/core/server/models/email-template.js new file mode 100644 index 00000000000..b67d40bf5e0 --- /dev/null +++ b/ghost/core/core/server/models/email-template.js @@ -0,0 +1,32 @@ +const ghostBookshelf = require('./base'); + +const EmailTemplate = ghostBookshelf.Model.extend({ + tableName: 'email_templates', + + defaults() { + return { + show_publication_title: true, + show_badge: true, + background_color: '#ffffff', + title_font_category: 'sans_serif', + title_font_weight: 'bold', + body_font_category: 'sans_serif', + header_background_color: '#ffffff', + title_alignment: 'center', + button_color: 'accent', + button_style: 'fill', + button_corners: 'rounded', + link_color: 'accent', + link_style: 'underline', + image_corners: 'square' + }; + }, + + automatedEmails() { + return this.hasMany('AutomatedEmail', 'email_template_id'); + } +}); + +module.exports = { + EmailTemplate: ghostBookshelf.model('EmailTemplate', EmailTemplate) +}; From 92d2a99282db43d00fd7bcabf877cf3a69c7af8c Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 24 Mar 2026 23:17:30 +0000 Subject: [PATCH 06/11] Added email_templates API endpoint and admin routes --- .../server/api/endpoints/email-templates.js | 83 +++++++++++++++++++ ghost/core/core/server/api/endpoints/index.js | 4 + .../server/web/api/endpoints/admin/routes.js | 5 ++ 3 files changed, 92 insertions(+) create mode 100644 ghost/core/core/server/api/endpoints/email-templates.js diff --git a/ghost/core/core/server/api/endpoints/email-templates.js b/ghost/core/core/server/api/endpoints/email-templates.js new file mode 100644 index 00000000000..cf6dc545b20 --- /dev/null +++ b/ghost/core/core/server/api/endpoints/email-templates.js @@ -0,0 +1,83 @@ +const tpl = require('@tryghost/tpl'); +const errors = require('@tryghost/errors'); +const models = require('../../models'); + +const messages = { + emailTemplateNotFound: 'Email template not found.' +}; + +/** @type {import('@tryghost/api-framework').Controller} */ +const controller = { + docName: 'email_templates', + + browse: { + headers: { + cacheInvalidate: false + }, + options: [ + 'filter', + 'fields', + 'limit', + 'order', + 'page' + ], + permissions: true, + query(frame) { + return models.EmailTemplate.findPage(frame.options); + } + }, + + read: { + headers: { + cacheInvalidate: false + }, + options: [ + 'filter', + 'fields' + ], + data: [ + 'id' + ], + permissions: true, + async query(frame) { + const model = await models.EmailTemplate.findOne(frame.data, frame.options); + if (!model) { + throw new errors.NotFoundError({ + message: tpl(messages.emailTemplateNotFound) + }); + } + + return model; + } + }, + + edit: { + headers: { + cacheInvalidate: false + }, + options: [ + 'id' + ], + validation: { + options: { + id: { + required: true + } + } + }, + permissions: true, + async query(frame) { + const data = frame.data.email_templates[0]; + const model = await models.EmailTemplate.edit(data, frame.options); + if (!model) { + throw new errors.NotFoundError({ + message: tpl(messages.emailTemplateNotFound) + }); + } + + return model; + } + } +}; + +module.exports = controller; diff --git a/ghost/core/core/server/api/endpoints/index.js b/ghost/core/core/server/api/endpoints/index.js index 087065a8827..0bb813b29d1 100644 --- a/ghost/core/core/server/api/endpoints/index.js +++ b/ghost/core/core/server/api/endpoints/index.js @@ -85,6 +85,10 @@ module.exports = { return apiFramework.pipeline(require('./automated-emails'), localUtils); }, + get emailTemplates() { + return apiFramework.pipeline(require('./email-templates'), localUtils); + }, + get membersStripeConnect() { return apiFramework.pipeline(require('./members-stripe-connect'), localUtils); }, diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 733ec22e05a..38e125e468b 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -193,6 +193,11 @@ module.exports = function apiRoutes() { router.del('/automated_emails/:id', mw.authAdminApi, http(api.automatedEmails.destroy)); router.post('/automated_emails/:id/test', shared.middleware.brute.previewEmailLimiter, mw.authAdminApi, http(api.automatedEmails.sendTestEmail)); + // ## Email Templates + router.get('/email_templates', mw.authAdminApi, http(api.emailTemplates.browse)); + router.get('/email_templates/:id', mw.authAdminApi, http(api.emailTemplates.read)); + router.put('/email_templates/:id', mw.authAdminApi, http(api.emailTemplates.edit)); + // ## Roles router.get('/roles/', mw.authAdminApi, http(api.roles.browse)); From 7b7c7fc8876b84408f592f0c5e16e7f534e899ab Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 24 Mar 2026 23:42:55 +0000 Subject: [PATCH 07/11] Added E2E API tests for email_templates endpoint - Created email-templates.test.js with browse, read, and edit tests - Added email_template permissions to both production and test fixtures - Added EmailTemplate model seed to fixtures for default template --- .../server/data/schema/fixtures/fixtures.json | 26 +++ .../email-templates.test.js.snap | 211 ++++++++++++++++++ .../e2e-api/admin/email-templates.test.js | 136 +++++++++++ ghost/core/test/utils/fixtures/fixtures.json | 26 +++ 4 files changed, 399 insertions(+) create mode 100644 ghost/core/test/e2e-api/admin/__snapshots__/email-templates.test.js.snap create mode 100644 ghost/core/test/e2e-api/admin/email-templates.test.js diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json index ca4818246c4..c0f3e10e743 100644 --- a/ghost/core/core/server/data/schema/fixtures/fixtures.json +++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json @@ -51,6 +51,15 @@ } ] }, + { + "name": "EmailTemplate", + "entries": [ + { + "name": "Default", + "slug": "default" + } + ] + }, { "name": "Tag", "entries": [ @@ -536,6 +545,21 @@ "action_type": "destroy", "object_type": "automated_email" }, + { + "name": "Browse email templates", + "action_type": "browse", + "object_type": "email_template" + }, + { + "name": "Read email templates", + "action_type": "read", + "object_type": "email_template" + }, + { + "name": "Edit email templates", + "action_type": "edit", + "object_type": "email_template" + }, { "name": "Read member signin urls", "action_type": "read", @@ -892,6 +916,7 @@ "product": "all", "label": "all", "automated_email": "all", + "email_template": "all", "email_preview": "all", "email": "all", "member_signin_url": "read", @@ -941,6 +966,7 @@ "member": "all", "label": "all", "automated_email": "all", + "email_template": "all", "email_preview": "all", "email": "all", "snippet": "all", diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-templates.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-templates.test.js.snap new file mode 100644 index 00000000000..071a6377bed --- /dev/null +++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-templates.test.js.snap @@ -0,0 +1,211 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Email Templates API Browse Can browse email templates 1: [body] 1`] = ` +Object { + "email_templates": Array [ + Object { + "background_color": "#ffffff", + "body_font_category": "sans_serif", + "button_color": "accent", + "button_corners": "rounded", + "button_style": "fill", + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "divider_color": null, + "footer_content": null, + "header_background_color": "#ffffff", + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "image_corners": "square", + "link_color": "accent", + "link_style": "underline", + "name": "Default", + "post_title_color": null, + "section_title_color": null, + "show_badge": true, + "show_publication_title": true, + "slug": "default", + "title_alignment": "center", + "title_font_category": "sans_serif", + "title_font_weight": "bold", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], + "meta": Object { + "pagination": Object { + "limit": 15, + "next": null, + "page": 1, + "pages": 1, + "prev": null, + "total": 1, + }, + }, +} +`; + +exports[`Email Templates API Browse Can browse email templates 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "752", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Email Templates API Edit Can edit an email template 1: [body] 1`] = ` +Object { + "email_templates": Array [ + Object { + "background_color": "#f0f0f0", + "body_font_category": "sans_serif", + "button_color": "accent", + "button_corners": "rounded", + "button_style": "outline", + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "divider_color": null, + "footer_content": "Custom footer text", + "header_background_color": "#ffffff", + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "image_corners": "square", + "link_color": "accent", + "link_style": "underline", + "name": "Default", + "post_title_color": null, + "section_title_color": null, + "show_badge": false, + "show_publication_title": true, + "slug": "default", + "title_alignment": "center", + "title_font_category": "serif", + "title_font_weight": "bold", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], +} +`; + +exports[`Email Templates API Edit Can edit an email template 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "679", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Email Templates API Edit Cannot edit a non-existent template 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "Resource could not be found.", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Resource not found error, cannot edit email_template.", + "property": null, + "type": "NotFoundError", + }, + ], +} +`; + +exports[`Email Templates API Edit Cannot edit a non-existent template 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "265", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Email Templates API Read Can read an email template by id 1: [body] 1`] = ` +Object { + "email_templates": Array [ + Object { + "background_color": "#ffffff", + "body_font_category": "sans_serif", + "button_color": "accent", + "button_corners": "rounded", + "button_style": "fill", + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "divider_color": null, + "footer_content": null, + "header_background_color": "#ffffff", + "header_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "image_corners": "square", + "link_color": "accent", + "link_style": "underline", + "name": "Default", + "post_title_color": null, + "section_title_color": null, + "show_badge": true, + "show_publication_title": true, + "slug": "default", + "title_alignment": "center", + "title_font_category": "sans_serif", + "title_font_weight": "bold", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + }, + ], +} +`; + +exports[`Email Templates API Read Can read an email template by id 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "664", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Email Templates API Read Cannot read a non-existent template 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "Email template not found.", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Resource not found error, cannot read email_template.", + "property": null, + "type": "NotFoundError", + }, + ], +} +`; + +exports[`Email Templates API Read Cannot read a non-existent template 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "262", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/email-templates.test.js b/ghost/core/test/e2e-api/admin/email-templates.test.js new file mode 100644 index 00000000000..8183019a670 --- /dev/null +++ b/ghost/core/test/e2e-api/admin/email-templates.test.js @@ -0,0 +1,136 @@ +const {agentProvider, fixtureManager, matchers, dbUtils} = require('../../utils/e2e-framework'); +const {anyContentVersion, anyObjectId, anyISODateTime, anyErrorId, anyEtag} = matchers; + +const matchEmailTemplate = { + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime +}; + +describe('Email Templates API', function () { + let agent; + + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('users'); + await agent.loginAsOwner(); + }); + + afterEach(async function () { + // Reset any changes made during edit tests so browse snapshot stays stable + await dbUtils.knex('email_templates').update({ + header_image: null, + show_publication_title: true, + show_badge: true, + footer_content: null, + background_color: '#ffffff', + title_font_category: 'sans_serif', + title_font_weight: 'bold', + body_font_category: 'sans_serif', + header_background_color: '#ffffff', + title_alignment: 'center', + post_title_color: null, + section_title_color: null, + button_color: 'accent', + button_style: 'fill', + button_corners: 'rounded', + link_color: 'accent', + link_style: 'underline', + image_corners: 'square', + divider_color: null + }); + }); + + describe('Browse', function () { + it('Can browse email templates', async function () { + await agent + .get('email_templates') + .expectStatus(200) + .matchBodySnapshot({ + email_templates: [matchEmailTemplate] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + }); + + describe('Read', function () { + it('Can read an email template by id', async function () { + const {body: {email_templates: [template]}} = await agent + .get('email_templates') + .expectStatus(200); + + await agent + .get(`email_templates/${template.id}`) + .expectStatus(200) + .matchBodySnapshot({ + email_templates: [matchEmailTemplate] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + + it('Cannot read a non-existent template', async function () { + await agent + .get('email_templates/aaaaaaaaaaaaaaaaaaaaaaaa') + .expectStatus(404) + .matchBodySnapshot({ + errors: [{ + id: anyErrorId + }] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + }); + + describe('Edit', function () { + it('Can edit an email template', async function () { + const {body: {email_templates: [template]}} = await agent + .get('email_templates') + .expectStatus(200); + + await agent + .put(`email_templates/${template.id}`) + .body({email_templates: [{ + background_color: '#f0f0f0', + title_font_category: 'serif', + button_style: 'outline', + show_badge: false, + footer_content: 'Custom footer text' + }]}) + .expectStatus(200) + .matchBodySnapshot({ + email_templates: [matchEmailTemplate] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + + it('Cannot edit a non-existent template', async function () { + await agent + .put('email_templates/aaaaaaaaaaaaaaaaaaaaaaaa') + .body({email_templates: [{ + background_color: '#000000' + }]}) + .expectStatus(404) + .matchBodySnapshot({ + errors: [{ + id: anyErrorId + }] + }) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + }); + }); +}); diff --git a/ghost/core/test/utils/fixtures/fixtures.json b/ghost/core/test/utils/fixtures/fixtures.json index ae6fd2f5686..d974cc39522 100644 --- a/ghost/core/test/utils/fixtures/fixtures.json +++ b/ghost/core/test/utils/fixtures/fixtures.json @@ -50,6 +50,15 @@ } ] }, + { + "name": "EmailTemplate", + "entries": [ + { + "name": "Default", + "slug": "default" + } + ] + }, { "name": "Tag", "entries": [ @@ -541,6 +550,21 @@ "action_type": "destroy", "object_type": "automated_email" }, + { + "name": "Browse email templates", + "action_type": "browse", + "object_type": "email_template" + }, + { + "name": "Read email templates", + "action_type": "read", + "object_type": "email_template" + }, + { + "name": "Edit email templates", + "action_type": "edit", + "object_type": "email_template" + }, { "name": "Read member signin urls", "action_type": "read", @@ -1045,6 +1069,7 @@ "product": "all", "label": "all", "automated_email": "all", + "email_template": "all", "email_preview": "all", "email": "all", "member_signin_url": "read", @@ -1095,6 +1120,7 @@ "member_signin_url": "read", "label": "all", "automated_email": "all", + "email_template": "all", "email_preview": "all", "email": "all", "snippet": "all", From 4af48d0fd84f227958ac1da53f7206503d4329b4 Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Tue, 24 Mar 2026 23:44:52 +0000 Subject: [PATCH 08/11] Wired frontend to email_templates API with hooks and modal integration --- .../src/api/automated-emails.ts | 1 + .../src/api/email-templates.ts | 52 +++++++++++++++++++ .../welcome-email-customize-modal.tsx | 51 ++++++++++++++++-- 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 apps/admin-x-framework/src/api/email-templates.ts diff --git a/apps/admin-x-framework/src/api/automated-emails.ts b/apps/admin-x-framework/src/api/automated-emails.ts index 65551a94a0e..49c95bded18 100644 --- a/apps/admin-x-framework/src/api/automated-emails.ts +++ b/apps/admin-x-framework/src/api/automated-emails.ts @@ -11,6 +11,7 @@ export type AutomatedEmail = { sender_name: string | null; sender_email: string | null; sender_reply_to: string | null; + email_template_id: string | null; created_at: string; updated_at: string | null; } diff --git a/apps/admin-x-framework/src/api/email-templates.ts b/apps/admin-x-framework/src/api/email-templates.ts new file mode 100644 index 00000000000..7b2a80f05ce --- /dev/null +++ b/apps/admin-x-framework/src/api/email-templates.ts @@ -0,0 +1,52 @@ +import {Meta, createMutation, createQuery} from '../utils/api/hooks'; +import {updateQueryCache} from '../utils/api/update-queries'; + +export type EmailTemplate = { + id: string; + name: string; + slug: string; + header_image: string | null; + show_publication_title: boolean; + show_badge: boolean; + footer_content: string | null; + background_color: string; + title_font_category: string; + title_font_weight: string; + body_font_category: string; + header_background_color: string; + title_alignment: string; + post_title_color: string | null; + section_title_color: string | null; + button_color: string | null; + button_style: string; + button_corners: string; + link_color: string | null; + link_style: string; + image_corners: string; + divider_color: string | null; + created_at: string; + updated_at: string | null; +} + +export interface EmailTemplatesResponseType { + meta?: Meta; + email_templates: EmailTemplate[]; +} + +const dataType = 'EmailTemplatesResponseType'; + +export const useBrowseEmailTemplates = createQuery({ + dataType, + path: '/email_templates/' +}); + +export const useEditEmailTemplate = createMutation({ + method: 'PUT', + path: emailTemplate => `/email_templates/${emailTemplate.id}/`, + body: emailTemplate => ({email_templates: [emailTemplate]}), + updateQueries: { + dataType, + emberUpdateType: 'createOrUpdate', + update: updateQueryCache('email_templates') + } +}); diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx index 442682a6be8..79c541cec8e 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx @@ -31,7 +31,8 @@ import { Textarea } from '@tryghost/shade'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; -import {useCallback, useState} from 'react'; +import {useBrowseEmailTemplates, useEditEmailTemplate} from '@tryghost/admin-x-framework/api/email-templates'; +import {useCallback, useEffect, useState} from 'react'; import {useGlobalData} from '../../../providers/global-data-provider'; interface GeneralSettings { @@ -178,6 +179,10 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { const {siteData, settings: globalSettings, config} = useGlobalData(); const [siteTitle, defaultEmailAddress, supportEmailAddress] = getSettingValues(globalSettings, ['title', 'default_email_address', 'support_email_address']); + const {data: emailTemplatesData} = useBrowseEmailTemplates(); + const {mutateAsync: editEmailTemplate} = useEditEmailTemplate(); + const template = emailTemplatesData?.email_templates?.[0]; + const [designSettings, setDesignSettings] = useState({...DEFAULT_EMAIL_DESIGN}); const [generalSettings, setGeneralSettings] = useState({ senderName: siteTitle || '', @@ -188,6 +193,35 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { emailFooter: '' }); + useEffect(() => { + if (template) { + setDesignSettings({ + background_color: template.background_color, + title_font_category: template.title_font_category, + title_font_weight: template.title_font_weight, + body_font_category: template.body_font_category, + header_background_color: template.header_background_color, + post_title_color: template.post_title_color, + title_alignment: template.title_alignment, + section_title_color: template.section_title_color, + button_color: template.button_color, + button_style: template.button_style, + button_corners: template.button_corners, + link_color: template.link_color, + link_style: template.link_style, + image_corners: template.image_corners, + divider_color: template.divider_color + }); + setGeneralSettings(prev => ({ + ...prev, + headerImage: template.header_image || '', + showPublicationTitle: template.show_publication_title, + showBadge: template.show_badge, + emailFooter: template.footer_content || '' + })); + } + }, [template]); + const handleDesignChange = useCallback((updates: Partial) => { setDesignSettings(prev => ({...prev, ...updates})); }, []); @@ -196,10 +230,19 @@ const WelcomeEmailCustomizeModal = NiceModal.create(() => { setGeneralSettings(prev => ({...prev, ...updates})); }, []); - const handleSave = useCallback(() => { - // TODO: persist to backend when design columns are added + const handleSave = useCallback(async () => { + if (template) { + await editEmailTemplate({ + ...template, + ...designSettings, + header_image: generalSettings.headerImage || null, + show_publication_title: generalSettings.showPublicationTitle, + show_badge: generalSettings.showBadge, + footer_content: generalSettings.emailFooter || null + }); + } modal.remove(); - }, [modal]); + }, [modal, template, editEmailTemplate, designSettings, generalSettings]); const handleClose = useCallback(() => { modal.remove(); From 3ea1932b6ecf0b1cf9a38335385a08c4a95da37e Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 25 Mar 2026 00:08:34 +0000 Subject: [PATCH 09/11] Updated automated_emails snapshots and fixed missing email_template_id in mock --- .../components/settings/membership/member-emails.tsx | 1 + .../__snapshots__/automated-emails.test.js.snap | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx index f752a8764b1..b2aa65aa260 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails.tsx @@ -173,6 +173,7 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => { sender_name: null, sender_email: null, sender_reply_to: null, + email_template_id: null, created_at: '', updated_at: null }); diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap index f297a03bb79..181b67cf269 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/automated-emails.test.js.snap @@ -5,6 +5,7 @@ Object { "automated_emails": Array [ Object { "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email_template_id": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "lexical": "{\\"root\\":{\\"children\\":[]}}", "name": "Welcome Email (Free)", @@ -24,7 +25,7 @@ exports[`Automated Emails API Add Can add an automated email 2: [headers] 1`] = Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "357", + "content-length": "382", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -132,6 +133,7 @@ Object { "automated_emails": Array [ Object { "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email_template_id": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "lexical": "{\\"root\\":{\\"children\\":[]}}", "name": "Welcome Email (Free)", @@ -161,7 +163,7 @@ exports[`Automated Emails API Browse Can browse automated emails 2: [headers] 1` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "445", + "content-length": "470", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -246,6 +248,7 @@ Object { "automated_emails": Array [ Object { "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email_template_id": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "lexical": "{\\"root\\":{\\"children\\":[]}}", "name": "Welcome Email (Free)", @@ -265,7 +268,7 @@ exports[`Automated Emails API Edit Can edit an automated email 2: [headers] 1`] Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "350", + "content-length": "375", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -496,6 +499,7 @@ Object { "automated_emails": Array [ Object { "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "email_template_id": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "lexical": "{\\"root\\":{\\"children\\":[]}}", "name": "Welcome Email (Free)", @@ -515,7 +519,7 @@ exports[`Automated Emails API Read Can read an automated email by id 2: [headers Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "357", + "content-length": "382", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, From 1196cc28e12d86fb12cd3e3b6f34884db6debc3a Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 25 Mar 2026 00:11:26 +0000 Subject: [PATCH 10/11] Bumped version to 6.23.0-rc.0 for new migrations --- ghost/admin/package.json | 4 ++-- ghost/core/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 389253f97e4..78a17b404f1 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.22.1", + "version": "6.23.0-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", @@ -228,4 +228,4 @@ } } } -} \ No newline at end of file +} diff --git a/ghost/core/package.json b/ghost/core/package.json index 10ec73e211e..7078a4e901a 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.22.1", + "version": "6.23.0-rc.0", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org", From 89a1b56d4f3c63c12f3aa88916b8286f8df9f97f Mon Sep 17 00:00:00 2001 From: Chris Raible Date: Wed, 25 Mar 2026 00:34:01 +0000 Subject: [PATCH 11/11] Removed email templates design documents from branch --- .../2026-03-24-email-templates-design.md | 106 --- ...26-03-24-email-templates-implementation.md | 711 ------------------ 2 files changed, 817 deletions(-) delete mode 100644 docs/plans/2026-03-24-email-templates-design.md delete mode 100644 docs/plans/2026-03-24-email-templates-implementation.md diff --git a/docs/plans/2026-03-24-email-templates-design.md b/docs/plans/2026-03-24-email-templates-design.md deleted file mode 100644 index c56dc818886..00000000000 --- a/docs/plans/2026-03-24-email-templates-design.md +++ /dev/null @@ -1,106 +0,0 @@ -# Email Templates Design - -## Overview - -Add a reusable `email_templates` table to persist email design settings (colors, fonts, layout options) and general settings (header image, footer, badge visibility). This decouples visual design from email content, allowing multiple email types (welcome emails, newsletters, future transactional emails) to share templates. - -## Current State - -- `automated_emails` table stores welcome email content (subject, lexical, sender info) -- Welcome email customize modal (`welcome-email-customize-modal.tsx`) has design/general fields but does NOT persist them (TODO at line 200) -- Newsletters table has identical design columns inline — the template table unifies this pattern - -## Database Schema - -### New table: `email_templates` - -| Column | Type | Nullable | Default | Validations | -|---|---|---|---|---| -| `id` | string(24) | no | — | Primary key | -| `name` | string(191) | no | — | Unique | -| `slug` | string(191) | no | — | Unique | -| **General settings** | | | | | -| `header_image` | string(2000) | yes | null | | -| `show_publication_title` | boolean | no | true | | -| `show_badge` | boolean | no | true | | -| `footer_content` | text(longtext) | yes | null | | -| **Design settings** | | | | | -| `background_color` | string(50) | no | `'#ffffff'` | | -| `title_font_category` | string(191) | no | `'sans_serif'` | isIn: serif, sans_serif | -| `title_font_weight` | string(50) | no | `'bold'` | isIn: normal, medium, semibold, bold | -| `body_font_category` | string(191) | no | `'sans_serif'` | isIn: serif, sans_serif | -| `header_background_color` | string(50) | no | `'#ffffff'` | | -| `title_alignment` | string(191) | no | `'center'` | isIn: center, left | -| `post_title_color` | string(50) | yes | null | | -| `section_title_color` | string(50) | yes | null | | -| `button_color` | string(50) | yes | `'accent'` | | -| `button_style` | string(50) | no | `'fill'` | isIn: fill, outline | -| `button_corners` | string(50) | no | `'rounded'` | isIn: square, rounded, pill | -| `link_color` | string(50) | yes | `'accent'` | | -| `link_style` | string(50) | no | `'underline'` | isIn: underline, regular, bold | -| `image_corners` | string(50) | no | `'square'` | isIn: square, rounded | -| `divider_color` | string(50) | yes | null | | -| **Timestamps** | | | | | -| `created_at` | dateTime | no | — | | -| `updated_at` | dateTime | yes | — | | - -Indexes: `['slug']` - -### Modified table: `automated_emails` - -Add column: - -| Column | Type | Nullable | Default | Notes | -|---|---|---|---|---| -| `email_template_id` | string(24) | yes | null | References `email_templates.id` | - -## Migrations - -Three sequential migrations: - -1. **Add `email_templates` table** — creates the table with all columns -2. **Add `email_template_id` to `automated_emails`** — adds the FK column -3. **Seed default template and link automated emails** — creates a `default` template row and updates both automated_email rows to reference it - -## API Design - -### New endpoint: `GET/PUT /email_templates/` - -| Action | Method | Path | -|---|---|---| -| Browse | GET | `/email_templates/` | -| Read | GET | `/email_templates/:id/` | -| Edit | PUT | `/email_templates/:id/` | - -No create/delete for now — templates are seeded, users edit the default. Create/delete can be added later for multiple template support. - -### Changes to `/automated_emails/` - -- Response payload includes `email_template_id` -- Edit accepts `email_template_id` to associate a template - -## Backend Implementation - -| File | Action | Purpose | -|---|---|---| -| `ghost/core/core/server/data/schema/schema.js` | Edit | Add `email_templates` table, add `email_template_id` to `automated_emails` | -| `ghost/core/core/server/data/migrations/versions/6.*/` | Create (x3) | Three migrations | -| `ghost/core/core/server/models/email-template.js` | Create | Bookshelf model | -| `ghost/core/core/server/models/automated-email.js` | Edit | Add relationship to email_template | -| `ghost/core/core/server/api/endpoints/email-templates.js` | Create | API controller (browse, read, edit) | -| `ghost/core/core/server/api/endpoints/index.js` | Edit | Register endpoint | - -## Frontend Implementation - -| File | Action | Purpose | -|---|---|---| -| `apps/admin-x-framework/src/api/email-templates.ts` | Create | TypeScript types + React Query hooks | -| `apps/admin-x-settings/.../welcome-email-customize-modal.tsx` | Edit | Wire up to email_templates API | - -## Design Decisions - -- **Individual columns over JSON** — matches newsletters pattern, enables DB-level validation, consistent with existing codebase -- **Separate table over extending automated_emails** — reusable across email types (newsletters will get `email_template_id` FK later) -- **Sender fields stay on automated_emails** — sender identity is per-email-type, not per-template -- **Seeded default template** — avoids "create on first use" logic; always has a template to read/edit -- **No create/delete API yet** — YAGNI; single default template is sufficient for current needs diff --git a/docs/plans/2026-03-24-email-templates-implementation.md b/docs/plans/2026-03-24-email-templates-implementation.md deleted file mode 100644 index 3547d991768..00000000000 --- a/docs/plans/2026-03-24-email-templates-implementation.md +++ /dev/null @@ -1,711 +0,0 @@ -# Email Templates Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a reusable `email_templates` table with design/general columns, link it to `automated_emails` via FK, expose browse/read/edit API, and wire up the frontend customize modal. - -**Architecture:** New `email_templates` table with individual design columns (mirroring newsletters). `automated_emails` gets an `email_template_id` FK. New REST endpoint at `/email_templates/` with browse/read/edit. Frontend hooks load and save template via React Query. - -**Tech Stack:** Knex migrations, Bookshelf ORM, Express routes, React Query (admin-x-framework), TypeScript - ---- - -### Task 1: Add `email_templates` table to schema.js - -**Files:** -- Modify: `ghost/core/core/server/data/schema/schema.js` (insert before `automated_emails` definition around line 1145) - -**Step 1: Add the email_templates table definition** - -Add this block in `schema.js` immediately before the `automated_emails` definition: - -```javascript -email_templates: { - id: {type: 'string', maxlength: 24, nullable: false, primary: true}, - name: {type: 'string', maxlength: 191, nullable: false, unique: true}, - slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, - header_image: {type: 'string', maxlength: 2000, nullable: true}, - show_publication_title: {type: 'boolean', nullable: false, defaultTo: true}, - show_badge: {type: 'boolean', nullable: false, defaultTo: true}, - footer_content: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, - background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, - title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, - title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold', validations: {isIn: [['normal', 'medium', 'semibold', 'bold']]}}, - body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif', validations: {isIn: [['serif', 'sans_serif']]}}, - header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, - title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center', validations: {isIn: [['center', 'left']]}}, - post_title_color: {type: 'string', maxlength: 50, nullable: true}, - section_title_color: {type: 'string', maxlength: 50, nullable: true}, - button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, - button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill', validations: {isIn: [['fill', 'outline']]}}, - button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded', validations: {isIn: [['square', 'rounded', 'pill']]}}, - link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, - link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline', validations: {isIn: [['underline', 'regular', 'bold']]}}, - image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square', validations: {isIn: [['square', 'rounded']]}}, - divider_color: {type: 'string', maxlength: 50, nullable: true}, - created_at: {type: 'dateTime', nullable: false}, - updated_at: {type: 'dateTime', nullable: true}, - '@@INDEXES@@': [ - ['slug'] - ] -}, -``` - -**Step 2: Add `email_template_id` FK to `automated_emails`** - -In the `automated_emails` table definition (around line 1145), add this column after `sender_reply_to`: - -```javascript -email_template_id: {type: 'string', maxlength: 24, nullable: true, references: 'email_templates.id'}, -``` - -**Step 3: Commit** - -```bash -git add ghost/core/core/server/data/schema/schema.js -git commit -m "Added email_templates table and email_template_id FK to schema" -``` - ---- - -### Task 2: Create migration — add email_templates table - -**Files:** -- Create: `ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-00-add-email-templates-table.js` - -**Step 1: Create the migration file** - -```javascript -const {addTable} = require('../../utils'); - -module.exports = addTable('email_templates', { - id: {type: 'string', maxlength: 24, nullable: false, primary: true}, - name: {type: 'string', maxlength: 191, nullable: false, unique: true}, - slug: {type: 'string', maxlength: 191, nullable: false, unique: true}, - header_image: {type: 'string', maxlength: 2000, nullable: true}, - show_publication_title: {type: 'boolean', nullable: false, defaultTo: true}, - show_badge: {type: 'boolean', nullable: false, defaultTo: true}, - footer_content: {type: 'text', maxlength: 1000000000, fieldtype: 'long', nullable: true}, - background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, - title_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif'}, - title_font_weight: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'bold'}, - body_font_category: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'sans_serif'}, - header_background_color: {type: 'string', maxlength: 50, nullable: false, defaultTo: '#ffffff'}, - title_alignment: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'center'}, - post_title_color: {type: 'string', maxlength: 50, nullable: true}, - section_title_color: {type: 'string', maxlength: 50, nullable: true}, - button_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, - button_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'fill'}, - button_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'rounded'}, - link_color: {type: 'string', maxlength: 50, nullable: true, defaultTo: 'accent'}, - link_style: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'underline'}, - image_corners: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'square'}, - divider_color: {type: 'string', maxlength: 50, nullable: true}, - created_at: {type: 'dateTime', nullable: false}, - updated_at: {type: 'dateTime', nullable: true}, - '@@INDEXES@@': [ - ['slug'] - ] -}); -``` - -**Step 2: Commit** - -```bash -git add ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-00-add-email-templates-table.js -git commit -m "Added migration to create email_templates table" -``` - ---- - -### Task 3: Create migration — add email_template_id column to automated_emails - -**Files:** -- Create: `ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-01-add-email-template-id-to-automated-emails.js` - -**Step 1: Create the migration file** - -```javascript -const {createAddColumnMigration} = require('../../utils'); - -module.exports = createAddColumnMigration('automated_emails', 'email_template_id', { - type: 'string', - maxlength: 24, - nullable: true, - references: 'email_templates.id' -}); -``` - -**Step 2: Commit** - -```bash -git add ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-01-add-email-template-id-to-automated-emails.js -git commit -m "Added migration to add email_template_id FK to automated_emails" -``` - ---- - -### Task 4: Create migration — seed default email template - -**Files:** -- Create: `ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-02-seed-default-email-template.js` - -**Step 1: Create the seed migration file** - -```javascript -const logging = require('@tryghost/logging'); -const {default: ObjectID} = require('bson-objectid'); -const {createTransactionalMigration} = require('../../utils'); - -const DEFAULT_TEMPLATE_SLUG = 'default'; - -module.exports = createTransactionalMigration( - async function up(knex) { - const existing = await knex('email_templates') - .where({slug: DEFAULT_TEMPLATE_SLUG}) - .first(); - - if (existing) { - logging.warn('Default email template already exists, skipping'); - return; - } - - const templateId = (new ObjectID()).toHexString(); - - logging.info('Creating default email template'); - await knex('email_templates').insert({ - id: templateId, - name: 'Default', - slug: DEFAULT_TEMPLATE_SLUG, - created_at: knex.raw('current_timestamp') - }); - - logging.info('Linking existing automated emails to default template'); - await knex('automated_emails') - .whereNull('email_template_id') - .update({email_template_id: templateId}); - }, - async function down(knex) { - logging.info('Unlinking automated emails from default template'); - await knex('automated_emails') - .update({email_template_id: null}); - - logging.info('Deleting default email template'); - await knex('email_templates') - .where({slug: DEFAULT_TEMPLATE_SLUG}) - .del(); - } -); -``` - -**Step 2: Commit** - -```bash -git add ghost/core/core/server/data/migrations/versions/6.19/2026-03-24-12-00-02-seed-default-email-template.js -git commit -m "Added migration to seed default email template and link automated emails" -``` - ---- - -### Task 5: Create EmailTemplate Bookshelf model - -**Files:** -- Create: `ghost/core/core/server/models/email-template.js` - -**Step 1: Create the model file** - -```javascript -const ghostBookshelf = require('./base'); - -const EmailTemplate = ghostBookshelf.Model.extend({ - tableName: 'email_templates', - - defaults() { - return { - show_publication_title: true, - show_badge: true, - background_color: '#ffffff', - title_font_category: 'sans_serif', - title_font_weight: 'bold', - body_font_category: 'sans_serif', - header_background_color: '#ffffff', - title_alignment: 'center', - button_color: 'accent', - button_style: 'fill', - button_corners: 'rounded', - link_color: 'accent', - link_style: 'underline', - image_corners: 'square' - }; - }, - - automatedEmails() { - return this.hasMany('AutomatedEmail', 'email_template_id'); - } -}); - -module.exports = { - EmailTemplate: ghostBookshelf.model('EmailTemplate', EmailTemplate) -}; -``` - -**Step 2: Add relationship to AutomatedEmail model** - -In `ghost/core/core/server/models/automated-email.js`, add this method to the `AutomatedEmail` model extend block (after the `defaults()` method): - -```javascript -emailTemplate() { - return this.belongsTo('EmailTemplate', 'email_template_id'); -}, -``` - -**Step 3: Commit** - -```bash -git add ghost/core/core/server/models/email-template.js ghost/core/core/server/models/automated-email.js -git commit -m "Added EmailTemplate model and relationship to AutomatedEmail" -``` - ---- - -### Task 6: Create email-templates API endpoint - -**Files:** -- Create: `ghost/core/core/server/api/endpoints/email-templates.js` -- Modify: `ghost/core/core/server/api/endpoints/index.js` (add getter around line 84, near `automatedEmails`) - -**Step 1: Create the endpoint controller** - -```javascript -const tpl = require('@tryghost/tpl'); -const errors = require('@tryghost/errors'); -const models = require('../../models'); - -const messages = { - emailTemplateNotFound: 'Email template not found.' -}; - -/** @type {import('@tryghost/api-framework').Controller} */ -const controller = { - docName: 'email_templates', - - browse: { - headers: { - cacheInvalidate: false - }, - options: [ - 'filter', - 'fields', - 'limit', - 'order', - 'page' - ], - permissions: true, - query(frame) { - return models.EmailTemplate.findPage(frame.options); - } - }, - - read: { - headers: { - cacheInvalidate: false - }, - options: [ - 'filter', - 'fields' - ], - data: [ - 'id' - ], - permissions: true, - async query(frame) { - const model = await models.EmailTemplate.findOne(frame.data, frame.options); - if (!model) { - throw new errors.NotFoundError({ - message: tpl(messages.emailTemplateNotFound) - }); - } - - return model; - } - }, - - edit: { - headers: { - cacheInvalidate: false - }, - options: [ - 'id' - ], - validation: { - options: { - id: { - required: true - } - } - }, - permissions: true, - async query(frame) { - const data = frame.data.email_templates[0]; - const model = await models.EmailTemplate.edit(data, frame.options); - if (!model) { - throw new errors.NotFoundError({ - message: tpl(messages.emailTemplateNotFound) - }); - } - - return model; - } - } -}; - -module.exports = controller; -``` - -**Step 2: Register in endpoints index** - -In `ghost/core/core/server/api/endpoints/index.js`, add this getter near the `automatedEmails` getter (around line 84): - -```javascript -get emailTemplates() { - return apiFramework.pipeline(require('./email-templates'), localUtils); -}, -``` - -**Step 3: Commit** - -```bash -git add ghost/core/core/server/api/endpoints/email-templates.js ghost/core/core/server/api/endpoints/index.js -git commit -m "Added email_templates API endpoint (browse, read, edit)" -``` - ---- - -### Task 7: Add admin API routes for email_templates - -**Files:** -- Modify: `ghost/core/core/server/web/api/endpoints/admin/routes.js` (add routes near automated_emails routes around line 194) - -**Step 1: Add routes** - -Add these lines after the automated_emails routes block: - -```javascript -// ## Email Templates -router.get('/email_templates', mw.authAdminApi, http(api.emailTemplates.browse)); -router.get('/email_templates/:id', mw.authAdminApi, http(api.emailTemplates.read)); -router.put('/email_templates/:id', mw.authAdminApi, http(api.emailTemplates.edit)); -``` - -**Step 2: Commit** - -```bash -git add ghost/core/core/server/web/api/endpoints/admin/routes.js -git commit -m "Added admin API routes for email_templates" -``` - ---- - -### Task 8: Write E2E API tests for email_templates - -**Files:** -- Create: `ghost/core/test/e2e-api/admin/email-templates.test.js` - -**Step 1: Write the test file** - -```javascript -const {agentProvider, fixtureManager, matchers, dbUtils} = require('../../utils/e2e-framework'); -const {anyContentVersion, anyObjectId, anyISODateTime, anyEtag} = matchers; - -const matchEmailTemplate = { - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime -}; - -describe('Email Templates API', function () { - let agent; - - before(async function () { - agent = await agentProvider.getAdminAPIAgent(); - await fixtureManager.init('users'); - await agent.loginAsOwner(); - }); - - describe('Browse', function () { - it('Can browse email templates', async function () { - await agent - .get('email_templates') - .expectStatus(200) - .matchBodySnapshot({ - email_templates: [matchEmailTemplate] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); - }); - }); - - describe('Read', function () { - it('Can read an email template by id', async function () { - const {body: {email_templates: [template]}} = await agent - .get('email_templates') - .expectStatus(200); - - await agent - .get(`email_templates/${template.id}`) - .expectStatus(200) - .matchBodySnapshot({ - email_templates: [matchEmailTemplate] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); - }); - - it('Cannot read a non-existent template', async function () { - await agent - .get('email_templates/aaaaaaaaaaaaaaaaaaaaaaaa') - .expectStatus(404) - .matchBodySnapshot({ - errors: [{ - id: anyObjectId - }] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); - }); - }); - - describe('Edit', function () { - it('Can edit an email template', async function () { - const {body: {email_templates: [template]}} = await agent - .get('email_templates') - .expectStatus(200); - - await agent - .put(`email_templates/${template.id}`) - .body({email_templates: [{ - background_color: '#f0f0f0', - title_font_category: 'serif', - button_style: 'outline', - show_badge: false, - footer_content: 'Custom footer text' - }]}) - .expectStatus(200) - .matchBodySnapshot({ - email_templates: [matchEmailTemplate] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); - }); - - it('Cannot edit a non-existent template', async function () { - await agent - .put('email_templates/aaaaaaaaaaaaaaaaaaaaaaaa') - .body({email_templates: [{ - background_color: '#000000' - }]}) - .expectStatus(404) - .matchBodySnapshot({ - errors: [{ - id: anyObjectId - }] - }) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); - }); - }); -}); -``` - -**Step 2: Run the tests to verify they pass** - -```bash -cd ghost/core && yarn test:e2e test/e2e-api/admin/email-templates.test.js -``` - -Expected: All tests pass. Snapshot files are auto-generated on first run. - -**Step 3: Commit** - -```bash -git add ghost/core/test/e2e-api/admin/email-templates.test.js ghost/core/test/e2e-api/admin/__snapshots__/ -git commit -m "Added E2E API tests for email_templates endpoint" -``` - ---- - -### Task 9: Create frontend API hooks for email_templates - -**Files:** -- Create: `apps/admin-x-framework/src/api/email-templates.ts` - -**Step 1: Create the TypeScript API file** - -```typescript -import {Meta, createMutation, createQuery} from '../utils/api/hooks'; -import {updateQueryCache} from '../utils/api/update-queries'; - -export type EmailTemplate = { - id: string; - name: string; - slug: string; - header_image: string | null; - show_publication_title: boolean; - show_badge: boolean; - footer_content: string | null; - background_color: string; - title_font_category: string; - title_font_weight: string; - body_font_category: string; - header_background_color: string; - title_alignment: string; - post_title_color: string | null; - section_title_color: string | null; - button_color: string | null; - button_style: string; - button_corners: string; - link_color: string | null; - link_style: string; - image_corners: string; - divider_color: string | null; - created_at: string; - updated_at: string | null; -} - -export interface EmailTemplatesResponseType { - meta?: Meta; - email_templates: EmailTemplate[]; -} - -const dataType = 'EmailTemplatesResponseType'; - -export const useBrowseEmailTemplates = createQuery({ - dataType, - path: '/email_templates/' -}); - -export const useEditEmailTemplate = createMutation({ - method: 'PUT', - path: emailTemplate => `/email_templates/${emailTemplate.id}/`, - body: emailTemplate => ({email_templates: [emailTemplate]}), - updateQueries: { - dataType, - emberUpdateType: 'createOrUpdate', - update: updateQueryCache('email_templates') - } -}); -``` - -**Step 2: Commit** - -```bash -git add apps/admin-x-framework/src/api/email-templates.ts -git commit -m "Added frontend API hooks for email_templates" -``` - ---- - -### Task 10: Wire up the welcome email customize modal to email_templates API - -**Files:** -- Modify: `apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx` - -**Step 1: Update imports and hook up API** - -Replace local state initialization with API data loading: - -1. Add imports for `useBrowseEmailTemplates` and `useEditEmailTemplate` -2. Load the default template via `useBrowseEmailTemplates()` -3. Initialize `designSettings` and `generalSettings` from the loaded template -4. Update `handleSave` to call `useEditEmailTemplate` mutation instead of the TODO comment - -Key changes to the `WelcomeEmailCustomizeModal` component: - -- Load template: `const {data: {email_templates: templates} = {email_templates: []}} = useBrowseEmailTemplates();` -- Get first template: `const template = templates[0];` -- Initialize state from template data (with fallbacks to defaults) -- On save: call `editEmailTemplate({...template, ...designSettings, ...generalSettingsAsMapped})` -- Map frontend `GeneralSettings` keys to DB column names (e.g., `showBadge` → `show_badge`) - -**Step 2: Run the admin-x-settings tests** - -```bash -cd apps/admin-x-settings && yarn test -``` - -Expected: Tests pass (may need snapshot updates for the new API calls). - -**Step 3: Commit** - -```bash -git add apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx -git commit -m "Wired welcome email customize modal to email_templates API" -``` - ---- - -### Task 11: Update automated_emails type to include email_template_id - -**Files:** -- Modify: `apps/admin-x-framework/src/api/automated-emails.ts` - -**Step 1: Add email_template_id to the AutomatedEmail type** - -Add to the `AutomatedEmail` type: - -```typescript -email_template_id: string | null; -``` - -**Step 2: Commit** - -```bash -git add apps/admin-x-framework/src/api/automated-emails.ts -git commit -m "Added email_template_id to AutomatedEmail type" -``` - ---- - -### Task 12: Final verification - -**Step 1: Run Ghost core linting** - -```bash -cd ghost/core && yarn lint -``` - -Expected: No lint errors. - -**Step 2: Run Ghost core unit tests** - -```bash -cd ghost/core && yarn test:unit -``` - -Expected: All pass. - -**Step 3: Run the email-templates E2E test** - -```bash -cd ghost/core && yarn test:e2e test/e2e-api/admin/email-templates.test.js -``` - -Expected: All pass. - -**Step 4: Run admin-x-framework build** - -```bash -cd apps/admin-x-framework && yarn build -``` - -Expected: Builds without errors.