diff --git a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md b/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md
deleted file mode 100644
index 527bea329fe..00000000000
--- a/.changeset/changelogs/@tryghost!kg-default-nodes@2.2.2.md
+++ /dev/null
@@ -1,5 +0,0 @@
-## 2.2.2
-
-### Patch Changes
-
-- Removed html-minifier from runtime dependencies; it is only used by the package's tests
diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md
deleted file mode 100644
index 618ee77da2b..00000000000
--- a/.changeset/changelogs/@tryghost!koenig-lexical@1.11.0.md
+++ /dev/null
@@ -1,9 +0,0 @@
-## 1.11.0
-
-### Minor Changes
-
-- Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin
-
-### Patch Changes
-
-- Updated dependencies
diff --git a/.changeset/little-foxes-rule.md b/.changeset/little-foxes-rule.md
deleted file mode 100644
index 2f762631c3a..00000000000
--- a/.changeset/little-foxes-rule.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@tryghost/koenig-lexical": patch
----
-
-Updated dependencies
diff --git a/.changeset/proud-tigers-notice.md b/.changeset/proud-tigers-notice.md
deleted file mode 100644
index 22385aa089c..00000000000
--- a/.changeset/proud-tigers-notice.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@tryghost/kg-default-nodes": patch
----
-
-Removed html-minifier from runtime dependencies; it is only used by the package's tests
diff --git a/.changeset/weak-suns-enjoy.md b/.changeset/weak-suns-enjoy.md
deleted file mode 100644
index 70ed7ecceaa..00000000000
--- a/.changeset/weak-suns-enjoy.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"@tryghost/koenig-lexical": minor
----
-
-Added an embedPreviewUrl card config option that previews embed cards in a renderer served from a separate origin
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ecd4188ff2d..ca8a918fa0f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1494,6 +1494,9 @@ jobs:
- name: Docker meta (core)
id: meta-core
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ env:
+ # Tag PR builds `sha-` with the PR head, not the synthetic merge commit
+ DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
images: ${{ steps.strategy.outputs.image-core-name }}
tags: |
@@ -1512,6 +1515,8 @@ jobs:
- name: Docker meta (full)
id: meta-full
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ env:
+ DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
images: ${{ steps.strategy.outputs.image-full-name }}
tags: |
@@ -1762,6 +1767,8 @@ jobs:
continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }}
id: meta-e2e
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ env:
+ DOCKER_METADATA_PR_HEAD_SHA: 'true'
with:
images: ${{ steps.strategy.outputs.image-e2e-name }}
tags: |
diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
index 8a1fc7f258c..ad65e1168e7 100644
--- a/.github/workflows/pr-preview.yml
+++ b/.github/workflows/pr-preview.yml
@@ -7,10 +7,21 @@ on:
jobs:
deploy:
name: Deploy Preview
- # Runs when the "preview" label is added — requires collaborator write access
+ # Runs when "preview" or a "preview:" label is added — either needs
+ # collaborator write access. Adding a profile label to an already-labelled PR
+ # has to redeploy, or the label would claim a profile the preview is not on;
+ # Ghost-Moya notices the change and reseeds.
+ #
+ # Removing a profile label redeploys for the same reason, in reverse: the
+ # labels would otherwise say "default" while the preview stayed seeded on
+ # whatever the removed label named. `startsWith` excludes the bare `preview`
+ # label, which has no colon, so removing that still only destroys.
if: >-
- github.event.action == 'labeled'
- && github.event.label.name == 'preview'
+ (github.event.action == 'labeled'
+ && (github.event.label.name == 'preview'
+ || startsWith(github.event.label.name, 'preview:')))
+ || (github.event.action == 'unlabeled'
+ && startsWith(github.event.label.name, 'preview:'))
runs-on: ubuntu-latest
permissions:
contents: read
@@ -92,23 +103,77 @@ jobs:
if [ "$STATE" != "open" ]; then
echo "::warning::PR is no longer open ($STATE), skipping dispatch"
echo "skip=true" >> "$GITHUB_OUTPUT"
- elif [ "$HAS_LABEL" != "true" ]; then
- echo "::warning::preview label was removed, skipping dispatch"
+ exit 0
+ fi
+ # `preview` is the switch; `preview:` only says which data to
+ # use. A profile label on its own is not a request for a preview, and
+ # saying so beats the older "label was removed" warning, which is
+ # actively misleading when it was never added.
+ if [ "$HAS_LABEL" != "true" ]; then
+ if echo "$PR" | jq -e '.labels | any(startswith("preview:"))' > /dev/null; then
+ echo "::warning::A preview: label needs the 'preview' label too, skipping dispatch"
+ else
+ echo "::warning::preview label was removed, skipping dispatch"
+ fi
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ # A `preview:` label picks how much seed data the preview starts
+ # with. The names are defined in TryGhost/Ghost-Moya's
+ # preview/profiles.json and validated there, so nothing here needs to
+ # know them — passing the suffix through keeps one source of truth.
+ # Sending nothing means Moya applies its own default.
+ # Counted with jq rather than by word-splitting: a label may contain a
+ # space, and counting words would read one such label as two profiles
+ # and reject it with a message about a conflict that does not exist.
+ COUNT=$(echo "$PR" | jq '[.labels[] | select(startswith("preview:"))] | length')
+ PROFILE=$(echo "$PR" | jq -r '[.labels[] | select(startswith("preview:")) | ltrimstr("preview:")] | first // ""')
+
+ if [ "$COUNT" -gt 1 ]; then
+ NAMES=$(echo "$PR" | jq -r '[.labels[] | select(startswith("preview:"))] | join(", ")')
+ echo "::error::Multiple preview profile labels ($NAMES) — remove all but one"
echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 1
+ fi
+
+ if [ "$COUNT" -eq 1 ]; then
+ # Shape only, not a list of names — the names stay Ghost-Moya's to
+ # own. This rejects a malformed label here, with a message that says
+ # what is wrong, rather than dispatching it to fail further away.
+ #
+ # Matched with [[ =~ ]] rather than grep. grep works a line at a
+ # time, so ^ and $ bound a line, not the string, and `grep -q`
+ # succeeds if any one line matches: a label containing a newline
+ # would pass on its first line and then write a second line into
+ # GITHUB_OUTPUT, which becomes a step output of its own. This
+ # workflow is pull_request_target and carries a dispatch token, so
+ # that is worth closing rather than noting. bash anchors to the
+ # whole string, so a newline cannot match at all.
+ if ! [[ "$PROFILE" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
+ echo "::error::Malformed profile label 'preview:${PROFILE}' — expected lowercase words separated by hyphens"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 1
+ fi
+ echo "Preview profile: ${PROFILE}"
+ echo "profile=${PROFILE}" >> "$GITHUB_OUTPUT"
else
- echo "PR still eligible for preview deploy"
- echo "skip=false" >> "$GITHUB_OUTPUT"
+ echo "No preview: label, Ghost-Moya will use its default"
+ echo "profile=" >> "$GITHUB_OUTPUT"
fi
+ echo "PR still eligible for preview deploy"
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+
- name: Resolve image digest from GHCR
id: digest
if: steps.recheck.outputs.skip != 'true'
env:
IMAGE_REPO: tryghost/ghost
- PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
- # Pin the deploy to an immutable digest so Artifact Registry's pull-through
- # cache cannot serve a stale manifest for the mutable pr-N tag.
+ # Resolve the per-commit tag (not mutable pr-N) so a push during the wait
+ # cannot swap the image, then pin the digest past Artifact Registry's cache.
+ IMAGE_TAG="sha-${HEAD_SHA:0:7}"
TOKEN=$(curl -sf "https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull" | jq -r '.token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "::error::Failed to acquire anonymous GHCR token for ${IMAGE_REPO}"
@@ -119,13 +184,13 @@ jobs:
-H "Accept: application/vnd.oci.image.index.v1+json" \
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
- "https://ghcr.io/v2/${IMAGE_REPO}/manifests/pr-${PR_NUMBER}" \
+ "https://ghcr.io/v2/${IMAGE_REPO}/manifests/${IMAGE_TAG}" \
| awk 'tolower($1) == "docker-content-digest:" {print $2}' | tr -d '\r\n')
if [[ ! "$DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then
- echo "::error::Could not resolve digest for ghcr.io/${IMAGE_REPO}:pr-${PR_NUMBER} (got: '$DIGEST')"
+ echo "::error::Could not resolve digest for ghcr.io/${IMAGE_REPO}:${IMAGE_TAG} (got: '$DIGEST')"
exit 1
fi
- echo "Resolved digest: $DIGEST"
+ echo "Resolved ${IMAGE_TAG} digest: $DIGEST"
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
- name: Dispatch deploy to Ghost-Moya
@@ -140,7 +205,7 @@ jobs:
"pr_number": "${{ github.event.pull_request.number }}",
"image_digest": "${{ steps.digest.outputs.digest }}",
"action": "deploy",
- "seed": "true"
+ "profile": "${{ steps.recheck.outputs.profile }}"
}
destroy:
diff --git a/apps/admin/src/editor/editor-header-actions.tsx b/apps/admin/src/editor/editor-header-actions.tsx
index 824f28d341a..e3441871434 100644
--- a/apps/admin/src/editor/editor-header-actions.tsx
+++ b/apps/admin/src/editor/editor-header-actions.tsx
@@ -113,6 +113,14 @@ export function EditorHeaderActions({
}
const preview: HeaderPreviewProps = {
+ subjectEditor: {
+ value: session.settings.email_subject,
+ fallback: session.title,
+ hasUnsavedChanges: session.isDirty(),
+ isSaving,
+ onChange: (value) => session.stageSettings({ email_subject: value }),
+ onSave: saveBeforePreview,
+ },
isPost: postType === 'post',
newsletterSlug: post.newsletter ?? undefined,
open: previewOpen,
diff --git a/apps/admin/src/editor/editor-header.acceptance.test.tsx b/apps/admin/src/editor/editor-header.acceptance.test.tsx
index 4612a923f9a..50133312f56 100644
--- a/apps/admin/src/editor/editor-header.acceptance.test.tsx
+++ b/apps/admin/src/editor/editor-header.acceptance.test.tsx
@@ -378,6 +378,154 @@ describe('Editor header actions', () => {
await expect(previewScreen.modal()).toHaveCount(0);
});
+ it.each(['Enter', 'Tab'])(
+ 'saves the email subject from preview on %s and keeps it when reopened',
+ async (key) => {
+ publishChrome({ newsletters: 1 });
+ const saveApi = fakeSavablePost({ email_subject: null });
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailSubject()).toHaveValue('Hello from React');
+
+ await previewScreen.emailSubject().fill('A custom email subject');
+ await expect.element(previewScreen.testEmailButton()).toBeDisabled();
+ await userEvent.keyboard(`{${key}}`);
+ await expect.poll(() => submittedPost(saveApi)?.email_subject).toBe('A custom email subject');
+ await expect.element(previewScreen.testEmailButton()).toBeEnabled();
+ await previewScreen.closeButton().click();
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailSubject()).toHaveValue('A custom email subject');
+
+ await previewScreen.emailSubject().fill('');
+ await userEvent.keyboard('{Tab}');
+ await expect.poll(() => saveApi.requests.length).toBe(2);
+ expect(submittedPost(saveApi, 1)).toMatchObject({ email_subject: '' });
+ await expect
+ .element(previewScreen.emailSubject())
+ .toHaveAttribute('placeholder', 'Hello from React');
+ },
+ );
+
+ it('keeps an invalid email subject editable without saving or enabling test sends', async () => {
+ publishChrome({ newsletters: 1 });
+ const saveApi = fakeSavablePost();
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await previewScreen.emailSubject().fill('a'.repeat(301));
+ await userEvent.keyboard('{Enter}');
+ await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true');
+ await expect
+ .element(page.getByRole('alert'))
+ .toHaveTextContent('Email subject cannot be longer than 300 characters.');
+ await expect.element(previewScreen.testEmailButton()).toBeDisabled();
+ expect(saveApi.requests).toHaveLength(0);
+
+ await previewScreen.emailSubject().fill('a'.repeat(300));
+ await userEvent.keyboard('{Enter}');
+ await expect.poll(() => submittedPost(saveApi)?.email_subject).toBe('a'.repeat(300));
+ await expect.element(previewScreen.testEmailButton()).toBeEnabled();
+ });
+
+ it.each(['Close', 'Escape'])(
+ 'recovers an invalid subject after leaving preview with %s',
+ async (dismiss) => {
+ publishChrome({ newsletters: 1 });
+ const saveApi = fakeSavablePost();
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, withoutAutosave(MAILGUN_ON));
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await previewScreen.emailSubject().fill('a'.repeat(301));
+ if (dismiss === 'Close') {
+ await previewScreen.closeButton().click();
+ } else {
+ await userEvent.keyboard('{Escape}');
+ }
+ await expect(previewScreen.modal()).toHaveCount(0);
+ await editorScreen.titleInput().fill('Keep this title edit');
+ await editorScreen.previewButton().click();
+ await expect.element(previewScreen.saveFailed()).toBeVisible();
+ await expect(previewScreen.browserFrame()).toHaveCount(0);
+ await expect(previewScreen.emailFrame()).toHaveCount(0);
+ await expect.element(previewScreen.shareButton()).toBeDisabled();
+ await expect.element(previewScreen.emailSubject()).toHaveValue('a'.repeat(301));
+ await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true');
+ expect(saveApi.requests).toHaveLength(0);
+
+ await previewScreen.emailSubject().fill('A corrected subject');
+ await userEvent.keyboard('{Enter}');
+ await expect(saveApi).toHaveSavedFields({
+ email_subject: 'A corrected subject',
+ title: 'Keep this title edit',
+ });
+ await expect(previewScreen.saveFailed()).toHaveCount(0);
+ await expect.element(previewScreen.emailFrame()).toBeVisible();
+ await expect.element(previewScreen.testEmailButton()).toBeEnabled();
+ await expect.element(previewScreen.shareButton()).toBeEnabled();
+ },
+ );
+
+ it('retains the subject and blocks test sends when saving fails', async () => {
+ publishChrome({ newsletters: 1 });
+ const saveApi = fakeSavablePost({}, { failWith: 422 });
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await previewScreen.emailSubject().fill('Keep this subject');
+ await userEvent.keyboard('{Enter}');
+ await expect.poll(() => saveApi.requests.length).toBe(1);
+ await expect.element(previewScreen.emailSubject()).toHaveAttribute('aria-invalid', 'true');
+ await expect.element(previewScreen.emailSubject()).toHaveValue('Keep this subject');
+ await expect.element(previewScreen.testEmailButton()).toBeDisabled();
+ });
+
+ it('keeps a newer subject while an earlier subject save is pending', async () => {
+ publishChrome({ newsletters: 1 });
+ const held = deferred();
+ const saveApi = fakeSavablePost({}, { holdFirstSave: held.promise });
+ fakeAdminEndpoint('GET', /^\/email_previews\/posts\//, {
+ email_previews: [
+ { subject: 'Hello from React', html: 'Email body
', plaintext: 'Email body' },
+ ],
+ });
+ await renderAdminApp(`/editor/post/${POST_ID}`, MAILGUN_ON);
+ await editorScreen.previewButton().click();
+ await previewScreen.emailTab().click();
+ await previewScreen.emailSubject().fill('First subject');
+ await userEvent.keyboard('{Enter}');
+ await expect.poll(() => saveApi.requests.length).toBe(1);
+ await previewScreen.emailSubject().fill('Newer subject');
+ await userEvent.keyboard('{Enter}');
+ await expect.element(previewScreen.testEmailButton()).toBeDisabled();
+ held.resolve();
+ await expect.poll(() => saveApi.requests.length).toBe(2);
+ expect(submittedPost(saveApi, 1)).toMatchObject({ email_subject: 'Newer subject' });
+ await expect.element(previewScreen.emailSubject()).toHaveValue('Newer subject');
+ await expect.element(previewScreen.testEmailButton()).toBeEnabled();
+ });
+
it('keeps the failure in the publish flow and sends nothing more', async () => {
publishChrome();
const saveApi = fakeSavablePost({}, { failWith: 422 });
diff --git a/apps/admin/src/editor/engine/change-tracker.test.ts b/apps/admin/src/editor/engine/change-tracker.test.ts
index 819d17c48b6..f8bd5a0c77c 100644
--- a/apps/admin/src/editor/engine/change-tracker.test.ts
+++ b/apps/admin/src/editor/engine/change-tracker.test.ts
@@ -74,6 +74,7 @@ function post(overrides: Partial = {}): EditablePostProj
visibility: 'public',
tiers: [],
authors: [{ id: 'author-1' }],
+ email_subject: null,
meta_title: null,
meta_description: null,
canonical_url: null,
diff --git a/apps/admin/src/editor/engine/change-tracker.ts b/apps/admin/src/editor/engine/change-tracker.ts
index 5cf49cc53d9..556c0af53dc 100644
--- a/apps/admin/src/editor/engine/change-tracker.ts
+++ b/apps/admin/src/editor/engine/change-tracker.ts
@@ -39,6 +39,7 @@ export interface EditablePostProjection {
lexical: string | null;
tags: ReadonlyArray;
custom_excerpt: string | null;
+ email_subject: string | null;
feature_image: string | null;
feature_image_alt: string | null;
feature_image_caption: string | null;
@@ -117,6 +118,7 @@ const PROJECTION_KEYS: ReadonlyArray = [
'lexical',
'tags',
'custom_excerpt',
+ 'email_subject',
'feature_image',
'feature_image_alt',
'feature_image_caption',
diff --git a/apps/admin/src/editor/fullscreen-dialog.tsx b/apps/admin/src/editor/fullscreen-dialog.tsx
index d2da9d8890c..0bc0fd50116 100644
--- a/apps/admin/src/editor/fullscreen-dialog.tsx
+++ b/apps/admin/src/editor/fullscreen-dialog.tsx
@@ -1,4 +1,5 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@tryghost/shade/components';
+import { Box } from '@tryghost/shade/primitives';
import { cn } from '@tryghost/shade/utils';
import { useLayoutEffect, useRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
@@ -24,6 +25,8 @@ export interface FullscreenDialogProps extends Omit
layout?: keyof typeof LAYOUTS;
/** Controls rendered in the header row beside the title. */
headerActions?: ReactNode;
+ /** View controls centered between the title and actions on wide screens. */
+ headerControls?: ReactNode;
}
/**
@@ -37,6 +40,7 @@ export function FullscreenDialog({
title,
layout = 'plain',
headerActions,
+ headerControls,
className,
children,
onCloseAutoFocus,
@@ -79,9 +83,18 @@ export function FullscreenDialog({
{...props}
>
{layout === 'header' ? (
-
- {title}
- {headerActions}
+
+
+ {title}
+
+ {headerControls && {headerControls} }
+ {headerActions}
) : (
{title}
diff --git a/apps/admin/src/editor/preview/README.md b/apps/admin/src/editor/preview/README.md
index bdfe27042be..525c5602642 100644
--- a/apps/admin/src/editor/preview/README.md
+++ b/apps/admin/src/editor/preview/README.md
@@ -9,11 +9,26 @@
| `previewUrl` | The post's public preview URL; empty until the post has a uuid |
| `isPost` | Pages have no email preview |
| `newsletterSlug` | The post's own newsletter, preselected in the email preview |
+| `subjectEditor` | Live subject, title fallback, save state, and session callbacks for editing the subject |
| `onBeforeOpen` | Awaited before the preview renders, so the caller can save the draft it previews |
| `onPublish` | Renders a Publish button; supplied for every user who can publish |
| `publishDisabled` | Keeps the Publish button rendered but disabled while the caller cannot open its publish flow |
-The modal never writes to the post. `onBeforeOpen` exists because a draft must be persisted before the site or the email renderer can see the latest content; what that means — dirty checks, a save in flight — belongs to the caller.
+The modal delegates subject edits and saves through the optional `subjectEditor` port to the editor session. `onBeforeOpen` exists because a draft must be persisted before the site or the email renderer can see the latest content; what that means — dirty checks, a save in flight — belongs to the caller.
+
+## Layout and controls
+
+View controls are centered in the header when space allows and shift toward the title
+as the screen narrows. Device controls hide below 800px. Below 640px, the title and audience selectors
+are hidden so format tabs and actions stay on one row. Format and device controls use pill groups; audience
+and tier selectors use the ghost header treatment. The icon-only Share menu keeps
+copying the audience-specific preview link and opening it in a new tab together,
+and stays disabled until the post has been saved successfully.
+
+Desktop Web previews fill the space below the header without gutters or device
+chrome. Desktop Email previews are centered at a maximum width of 720px on a
+muted canvas, with the sidebar's corner radius and the mobile frame's shadow.
+Mobile previews retain their phone frame on the same muted canvas in either format.
## Audience
@@ -38,6 +53,17 @@ The newsletters offered are the site's active ones, read from the same full brow
Switching newsletters re-renders the preview against that newsletter, and the test send goes to exactly one address — the current user's, unless it is edited — for the audience currently selected.
+The sender and subject controls share a label column and a local 28px height;
+the subject input keeps its visible outline. The desktop subject field stages edits
+in the session and saves on blur or Enter.
+An empty subject falls back to the post title. The session enforces the 300-character
+limit on every save; validation and save errors appear beside the field. Test sending
+stays disabled while edits are unsaved or a save is pending. The mobile frame displays
+the live subject as text, matching Ember. Closing preview preserves unsaved subject
+edits. If those edits prevent saving when preview reopens, the save-failure screen
+keeps the subject field available for correction. Saving the corrected subject retries
+preparation before displaying the preview or enabling sharing and test sends.
+
## Not here yet
-Known gaps, listed so they are not mistaken for decisions: the email subject is read-only (editing it would write to the post), there is no over-100kB "may get clipped" warning, an Escape pressed inside the site preview frame does not close the modal, an already-sent post is re-rendered by the preview endpoint rather than showing its stored email, and the sender address does not apply the managed-email override.
+Known gaps, listed so they are not mistaken for decisions: there is no over-100kB "may get clipped" warning, an Escape pressed inside the site preview frame does not close the modal, an already-sent post is re-rendered by the preview endpoint rather than showing its stored email, and the sender address does not apply the managed-email override.
diff --git a/apps/admin/src/editor/preview/browser-preview.tsx b/apps/admin/src/editor/preview/browser-preview.tsx
index 67dbaec0e55..5d1e686b024 100644
--- a/apps/admin/src/editor/preview/browser-preview.tsx
+++ b/apps/admin/src/editor/preview/browser-preview.tsx
@@ -1,4 +1,5 @@
import { EmptyIndicator, PreviewChrome } from '@tryghost/shade/components';
+import { Box } from '@tryghost/shade/primitives';
import { LucideIcon } from '@tryghost/shade/utils';
import {
postPreviewBrowser,
@@ -19,7 +20,7 @@ export function BrowserPreview({ previewUrl, audience, device }: BrowserPreviewP
if (!previewUrl) {
return (
+
-
+
);
}
diff --git a/apps/admin/src/editor/preview/email-preview.tsx b/apps/admin/src/editor/preview/email-preview.tsx
index de29a97d01d..d52a11e3114 100644
--- a/apps/admin/src/editor/preview/email-preview.tsx
+++ b/apps/admin/src/editor/preview/email-preview.tsx
@@ -9,7 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from '@tryghost/shade/components';
-import { Inline, Stack } from '@tryghost/shade/primitives';
+import { Box, Grid, Inline, Stack } from '@tryghost/shade/primitives';
import { LucideIcon } from '@tryghost/shade/utils';
import { getSettingValues } from '@tryghost/admin-x-framework/api/settings';
import { useEmailPreview } from '@tryghost/admin-x-framework/api/email-previews';
@@ -26,6 +26,7 @@ import {
import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options';
import { useEditorSettings } from '@/editor/use-editor-settings';
import { SendTestEmail } from './send-test-email';
+import { EmailSubject, type EmailSubjectEditor } from './email-subject';
import {
audienceDescription,
emailPreviewAudience,
@@ -62,6 +63,7 @@ function withPreviewDocumentStyles(html: string): string {
}
interface EmailPreviewProps {
+ subjectEditor?: EmailSubjectEditor;
postId: string;
audience: PreviewAudience;
/** The selected tier's name, for the test-email audience description. */
@@ -81,6 +83,7 @@ interface EmailPreviewProps {
}
export function EmailPreview({
+ subjectEditor,
postId,
audience,
tierName,
@@ -112,12 +115,27 @@ export function EmailPreview({
const selectedNewsletter = newsletters.find((newsletter) => newsletter.slug === newsletterSlug);
const senderAddress = (sender: string | null) => sender ?? defaultEmailAddress ?? '';
+ const Frame = device === 'mobile' ? PreviewChrome : Box;
+
return (
-
+
-
-
-
+
+ From
+
+
{newsletterLookupPending ? (
) : newsletterLookupError ? (
@@ -131,50 +149,61 @@ export function EmailPreview({
>
This newsletter no longer exists
+ ) : newsletters.length > 1 ? (
+
+ `}
+ >
+
+
+
+ {newsletters.map((newsletter) => (
+
+ {newsletter.name} <{senderAddress(newsletter.sender_email)}>
+
+ ))}
+
+
) : (
- <>
- From
- {newsletters.length > 1 ? (
-
-
-
-
-
- {newsletters.map((newsletter) => (
-
- {newsletter.name} <{senderAddress(newsletter.sender_email)}>
-
- ))}
-
-
- ) : (
-
- {selectedNewsletter?.name}{' '}
-
- <{senderAddress(selectedNewsletter?.sender_email ?? null)}>
-
-
- )}
- >
+
+ {selectedNewsletter?.name}{' '}
+
+ <{senderAddress(selectedNewsletter?.sender_email ?? null)}>
+
+
)}
-
+
{canSendTestEmail && (
)}
-
- Subject
+
+ Subject
+
+ {subjectEditor && device === 'desktop' ? (
+
+ ) : (
- {!isFetching && preview?.subject}
+ {subjectEditor
+ ? subjectEditor.value || subjectEditor.fallback
+ : !isFetching && preview?.subject}
-
-
+ )}
+
{newsletterLookupPending || isFetching ? (
@@ -224,6 +253,6 @@ export function EmailPreview({
/>
)}
-
+
);
}
diff --git a/apps/admin/src/editor/preview/email-subject.tsx b/apps/admin/src/editor/preview/email-subject.tsx
new file mode 100644
index 00000000000..5e1218a3071
--- /dev/null
+++ b/apps/admin/src/editor/preview/email-subject.tsx
@@ -0,0 +1,79 @@
+import { useId, useRef, useState } from 'react';
+import { Input } from '@tryghost/shade/components';
+import { Stack } from '@tryghost/shade/primitives';
+import { postPreviewEmailSubject } from '@tryghost/test-data/selectors/editor';
+import {
+ EMAIL_SUBJECT_MAX,
+ EMAIL_SUBJECT_TOO_LONG,
+ overLength,
+} from '@/editor/session/settings-fields';
+
+/** Subject edits belong to the editor session, including while a save is pending. */
+export interface EmailSubjectEditor {
+ value: string | null;
+ fallback: string;
+ hasUnsavedChanges: boolean;
+ isSaving: boolean;
+ onChange: (value: string) => void;
+ onSave: () => Promise;
+}
+
+export function EmailSubject({ editor }: { editor: EmailSubjectEditor }) {
+ const errorId = useId();
+ const [saveError, setSaveError] = useState(null);
+ const editVersion = useRef(0);
+ const validationError = overLength(editor.value, EMAIL_SUBJECT_MAX)
+ ? EMAIL_SUBJECT_TOO_LONG
+ : null;
+ const error = validationError ?? saveError;
+
+ const save = async () => {
+ if (validationError || !editor.hasUnsavedChanges) {
+ return;
+ }
+ setSaveError(null);
+ const version = editVersion.current;
+ try {
+ await editor.onSave();
+ } catch (saveFailure) {
+ if (version === editVersion.current) {
+ setSaveError(
+ saveFailure instanceof Error
+ ? saveFailure.message
+ : 'Couldn’t save the email subject. Try again.',
+ );
+ }
+ }
+ };
+
+ return (
+
+ void save()}
+ onChange={(event) => {
+ editVersion.current += 1;
+ setSaveError(null);
+ editor.onChange(event.target.value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ }}
+ />
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx b/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx
index b20d8441db7..2f0129289bd 100644
--- a/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx
+++ b/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { page } from 'vitest/browser';
import {
@@ -26,12 +26,25 @@ const POST_ID = 'abc123';
const PREVIEW_URL = 'http://localhost:2368/p/post-uuid/';
const CURRENT_USER_EMAIL = String(currentUserResponse().users[0].email);
+async function previewViewport(width: number, height: number) {
+ const initialViewport = { width: window.innerWidth, height: window.innerHeight };
+ const initialFontSize = document.documentElement.style.fontSize;
+ // Embedded Admin uses a 10px rem base; match it when checking pixel breakpoints.
+ document.documentElement.style.fontSize = '10px';
+ onTestFinished(async () => {
+ document.documentElement.style.fontSize = initialFontSize;
+ await page.viewport(initialViewport.width, initialViewport.height);
+ });
+ await page.viewport(width, height);
+}
+
interface RenderOptions {
isPost?: boolean;
newsletterSlug?: string;
previewUrl?: string;
onBeforeOpen?: () => Promise;
onOpenChange?: (open: boolean) => void;
+ onPublish?: () => void;
}
async function renderPreviewModal({
@@ -40,6 +53,7 @@ async function renderPreviewModal({
previewUrl = PREVIEW_URL,
onBeforeOpen,
onOpenChange = () => {},
+ onPublish,
}: RenderOptions = {}) {
return await renderInApp(
,
);
}
@@ -173,14 +188,20 @@ describe('Post preview modal', () => {
await expect.element(previewScreen.modal()).toBeVisible();
await expect.poll(() => onBeforeOpen.mock.calls.length).toBe(1);
- await expect.element(previewScreen.copyLinkButton()).toBeDisabled();
+ await expect.element(previewScreen.shareButton()).toBeDisabled();
await expect(previewScreen.openInNewTabLink()).toHaveCount(0);
expect(frames.stop()).toEqual([]);
+ const status = previewScreen.preparingStatus().element();
+ const bounds = status.getBoundingClientRect();
+ const canvas = status.parentElement!.getBoundingClientRect();
+ expect((bounds.top + bounds.bottom) / 2).toBeCloseTo((canvas.top + canvas.bottom) / 2, 0);
+ expect((bounds.left + bounds.right) / 2).toBeCloseTo((canvas.left + canvas.right) / 2, 0);
releaseSave();
await expect.element(previewScreen.browserFrame()).toBeVisible();
- await expect.element(previewScreen.copyLinkButton()).toBeEnabled();
+ await expect.element(previewScreen.shareButton()).toBeEnabled();
+ await previewScreen.shareButton().click();
await expect.element(previewScreen.openInNewTabLink()).toBeVisible();
});
@@ -217,7 +238,7 @@ describe('Post preview modal', () => {
await expect.element(previewScreen.saveFailed()).toBeVisible();
await expect(previewScreen.browserFrame()).toHaveCount(0);
- await expect.element(previewScreen.copyLinkButton()).toBeDisabled();
+ await expect.element(previewScreen.shareButton()).toBeDisabled();
await expect(previewScreen.openInNewTabLink()).toHaveCount(0);
expect(onBeforeOpen).toHaveBeenCalledTimes(1);
@@ -284,15 +305,182 @@ describe('Post preview modal', () => {
await expect(previewScreen.option('Specific tier')).toHaveCount(0);
});
- it('switches the frame to a mobile viewport', async () => {
+ it.each([1183, 1440])(
+ 'centers the view controls and fills the desktop viewport at %ipx',
+ async (width) => {
+ await previewViewport(width, 900);
+ fakePreviewWorld();
+ await renderPreviewModal({ onPublish: () => {} });
+ await expect.element(previewScreen.browserFrame()).toBeVisible();
+
+ const modal = previewScreen.modal().element().getBoundingClientRect();
+ const frame = previewScreen.browserFrame().element().getBoundingClientRect();
+ const controls = previewScreen
+ .segmentSelect()
+ .element()
+ .parentElement!.getBoundingClientRect();
+ const title = page
+ .getByRole('heading', { name: 'Preview', exact: true })
+ .element()
+ .getBoundingClientRect();
+ const close = previewScreen.closeButton().element().getBoundingClientRect();
+ expect((controls.top + controls.bottom) / 2).toBeCloseTo((title.top + title.bottom) / 2, 0);
+ expect((controls.top + controls.bottom) / 2).toBeCloseTo((close.top + close.bottom) / 2, 0);
+ expect(frame.left).toBeCloseTo(modal.left, 0);
+ expect(frame.right).toBeCloseTo(modal.right, 0);
+ expect(frame.bottom).toBeCloseTo(modal.bottom, 0);
+ expect((controls.left + controls.right) / 2).toBeCloseTo((modal.left + modal.right) / 2, 0);
+ },
+ );
+
+ it('keeps format and actions on one row and hides secondary controls on phones', async () => {
+ await previewViewport(390, 844);
+ fakePreviewWorld();
+ fakeEmailPreview();
+ await renderPreviewModal({ onPublish: () => {} });
+
+ await expect.element(previewScreen.webTab()).toBeVisible();
+ await expect(previewScreen.segmentSelect()).toHaveCount(0);
+ await expect(previewScreen.mobileToggle()).toHaveCount(0);
+ const modal = previewScreen.modal().element();
+ expect(modal.scrollWidth).toBeLessThanOrEqual(modal.clientWidth);
+ const tabs = previewScreen.webTab().element().parentElement!.getBoundingClientRect();
+ const actions = previewScreen.shareButton().element().getBoundingClientRect();
+ expect(tabs.right).toBeLessThanOrEqual(actions.left);
+ expect((tabs.top + tabs.bottom) / 2).toBeCloseTo((actions.top + actions.bottom) / 2, 0);
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailTab()).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it.each([1440, 1183, 1024, 800, 640])(
+ 'keeps a long tier name clear of header actions at %ipx',
+ async (width) => {
+ await previewViewport(width, 844);
+ const name = 'Premium annual membership for independent publishers and supporters';
+ fakePreviewWorld({ tiers: [tier({ name, slug: 'premium' })] });
+ await renderInApp(
+ {}}
+ onPublish={() => {}}
+ />,
+ );
+ await previewScreen.previewAs('Specific tier');
+ await expect.element(previewScreen.tierSelect()).toHaveTextContent(name);
+
+ const modal = previewScreen.modal().element();
+ expect(modal.scrollWidth).toBeLessThanOrEqual(modal.clientWidth);
+ const controls = [
+ previewScreen.webTab(),
+ previewScreen.emailTab(),
+ ...(width >= 800 ? [previewScreen.desktopToggle(), previewScreen.mobileToggle()] : []),
+ previewScreen.segmentSelect(),
+ previewScreen.tierSelect(),
+ ];
+ const actions = [
+ previewScreen.shareButton(),
+ previewScreen.closeButton(),
+ previewScreen.publishButton(),
+ ];
+ for (const control of controls) {
+ const bounds = control.element().getBoundingClientRect();
+ expect(bounds.left).toBeGreaterThanOrEqual(0);
+ expect(bounds.right).toBeLessThanOrEqual(width);
+ for (const action of actions) {
+ const other = action.element().getBoundingClientRect();
+ const overlaps =
+ bounds.left < other.right &&
+ bounds.right > other.left &&
+ bounds.top < other.bottom &&
+ bounds.bottom > other.top;
+ expect(overlaps).toBe(false);
+ }
+ }
+ if (width === 1440) {
+ const group = previewScreen.tierSelect().element().parentElement!.getBoundingClientRect();
+ expect((group.left + group.right) / 2).toBeCloseTo(width / 2, 0);
+ }
+ await previewScreen.tierSelect().click();
+ await expect.element(previewScreen.option(name)).toBeVisible();
+ },
+ );
+
+ it('limits desktop emails to 720px on a muted canvas', async () => {
+ await previewViewport(1440, 900);
+ fakePreviewWorld();
+ fakeEmailPreview();
+ await renderPreviewModal();
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailFrame()).toBeVisible();
+
+ const email = previewScreen.emailChrome().element();
+ expect(email.getBoundingClientRect().width).toBe(720);
+ expect(getComputedStyle(email.parentElement!).backgroundColor).not.toBe(
+ getComputedStyle(previewScreen.modal().element()).backgroundColor,
+ );
+ });
+
+ it('keeps long newsletter names clear of the test button on narrow screens', async () => {
+ await previewViewport(390, 844);
+ fakePreviewWorld({
+ newsletters: [
+ newsletter({
+ name: 'Weekly roundup',
+ slug: 'weekly-roundup',
+ sender_email: 'zimo@ghost.org',
+ }),
+ newsletter({ name: 'Monthly roundup', slug: 'monthly-roundup' }),
+ ],
+ });
+ fakeEmailPreview();
+ await renderPreviewModal();
+ await previewScreen.emailTab().click();
+ await expect.element(previewScreen.emailFrame()).toBeVisible();
+ await expect
+ .element(previewScreen.newsletterSelect())
+ .toHaveTextContent('Weekly roundup ');
+
+ const trigger = previewScreen.newsletterSelect().element();
+ const label = trigger.querySelector('span')!;
+ const testButton = previewScreen.testEmailButton().element();
+ expect(trigger.getBoundingClientRect().right).toBeLessThan(
+ testButton.getBoundingClientRect().left,
+ );
+ expect(label.getBoundingClientRect().right).toBeLessThanOrEqual(
+ trigger.getBoundingClientRect().right,
+ );
+ expect(label.scrollWidth).toBeGreaterThan(label.clientWidth);
+ expect(getComputedStyle(label).overflow).toBe('hidden');
+ expect(testButton.scrollWidth).toBeLessThanOrEqual(testButton.clientWidth);
+ expect(testButton.getBoundingClientRect().right).toBeLessThanOrEqual(390);
+
+ await previewScreen.newsletterSelect().click();
+ await expect.element(previewScreen.option('Weekly roundup ')).toBeVisible();
+ });
+
+ it('switches the frame to a mobile viewport without clipping its top', async () => {
+ await previewViewport(1183, 844);
fakePreviewWorld();
await renderPreviewModal();
await expect.element(previewScreen.browserChrome()).not.toHaveClass('w-[380px]');
await previewScreen.mobileToggle().click();
+ await page.viewport(390, 844);
await expect.element(previewScreen.browserChrome()).toHaveClass('w-[380px]');
+ const chrome = previewScreen.browserChrome().element();
+ const canvas = chrome.parentElement!;
+ expect(chrome.getBoundingClientRect().top).toBeCloseTo(
+ canvas.getBoundingClientRect().top + parseFloat(getComputedStyle(canvas).paddingTop),
+ 0,
+ );
+ expect(canvas.scrollTop).toBe(0);
+ expect(chrome.getBoundingClientRect().bottom).toBeGreaterThan(
+ canvas.getBoundingClientRect().bottom,
+ );
});
it('has nothing to preview, copy or open before the post is first saved', async () => {
@@ -301,7 +489,7 @@ describe('Post preview modal', () => {
await expect.element(previewScreen.unavailable()).toBeVisible();
await expect(previewScreen.browserFrame()).toHaveCount(0);
- await expect.element(previewScreen.copyLinkButton()).toBeDisabled();
+ await expect.element(previewScreen.shareButton()).toBeDisabled();
await expect(previewScreen.openInNewTabLink()).toHaveCount(0);
});
@@ -311,11 +499,14 @@ describe('Post preview modal', () => {
await renderPreviewModal();
await previewScreen.previewAs('Paid member');
+ await previewScreen.shareButton().click();
+ await expect.element(previewScreen.openInNewTabLink()).toHaveAttribute('target', '_blank');
await previewScreen.copyLinkButton().click();
await expect
.poll(() => writeText.mock.calls.at(-1)?.[0])
.toBe(`${PREVIEW_URL}?member_status=paid`);
+ await previewScreen.shareButton().click();
await expect
.element(previewScreen.openInNewTabLink())
.toHaveAttribute('href', `${PREVIEW_URL}?member_status=paid`);
diff --git a/apps/admin/src/editor/preview/post-preview-modal.tsx b/apps/admin/src/editor/preview/post-preview-modal.tsx
index f2f50a0c0b1..dccabc5c3b8 100644
--- a/apps/admin/src/editor/preview/post-preview-modal.tsx
+++ b/apps/admin/src/editor/preview/post-preview-modal.tsx
@@ -1,6 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
EmptyIndicator,
LoadingIndicator,
Select,
@@ -8,7 +12,6 @@ import {
SelectGroup,
SelectItem,
SelectLabel,
- SelectTrigger,
SelectValue,
Tabs,
TabsList,
@@ -21,9 +24,9 @@ import {
useNewslettersEnabled,
usePaidMembersEnabled,
} from '@tryghost/admin-x-framework/api/settings';
-import { Inline } from '@tryghost/shade/primitives';
+import { Inline, Stack } from '@tryghost/shade/primitives';
import { PageHeader } from '@tryghost/shade/patterns';
-import { LucideIcon } from '@tryghost/shade/utils';
+import { cn, LucideIcon } from '@tryghost/shade/utils';
import { toast } from 'sonner';
import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters';
import { useHandleError } from '@tryghost/admin-x-framework/hooks';
@@ -43,6 +46,7 @@ import { useEditorSettings } from '@/editor/use-editor-settings';
import { FullscreenDialog } from '@/editor/fullscreen-dialog';
import { BrowserPreview } from './browser-preview';
import { EmailPreview } from './email-preview';
+import { EmailSubject, type EmailSubjectEditor } from './email-subject';
import {
browserPreviewUrl,
type PreviewAudience,
@@ -60,6 +64,7 @@ interface SegmentOption {
}
export interface PostPreviewModalProps {
+ subjectEditor?: EmailSubjectEditor;
open: boolean;
postId: string;
/** The post's public preview URL (`/p/:uuid/`), empty until the post has a uuid. */
@@ -78,6 +83,7 @@ export interface PostPreviewModalProps {
}
export function PostPreviewModal({
+ subjectEditor,
open,
postId,
previewUrl,
@@ -311,141 +317,188 @@ export function PostPreviewModal({
aria-describedby={undefined}
data-testid={postPreviewModal}
headerActions={
- <>
-
- {emailAvailable && (
- changeFormat(value as PreviewFormat)}
- >
-
- Web
- Email
-
-
- )}
- {
- if (value === 'desktop' || value === 'mobile') {
- setDevice(value);
- }
- }}
- >
-
-
-
-
-
-
-
- {showSegmentSelect && (
- setSegment(value as PreviewSegment)}
- >
-
-
-
-
- {segmentOptions.map((option) => (
-
- {option.label}
-
- ))}
-
-
- )}
- {showTierSelect && (
-
-
-
-
-
- {activeTiers.length > 0 && (
-
- Active tiers
- {activeTiers.map((tier) => (
-
- {tier.name}
-
- ))}
-
- )}
- {archivedTiers.length > 0 && (
-
- Archived tiers
- {archivedTiers.map((tier) => (
-
- {tier.name}
-
- ))}
-
- )}
-
-
- )}
-
-
- void copyPreviewLink()}
- >
-
-
- {previewActionsAvailable ? (
-
+
+
+
+
+
+
+
+
+ void copyPreviewLink()}>
+
+ Copy preview link
+
+
Open in new tab
-
- ) : (
-
-
- Open in new tab
-
- )}
- onOpenChange(false)}>
- Close
+
+
+
+ onOpenChange(false)}>
+ Close
+
+ {onPublish ? (
+
+ Publish
- {onPublish ? (
-
- Publish
-
- ) : null}
-
- >
+ ) : null}
+
+ }
+ headerControls={
+
+ {emailAvailable && (
+ changeFormat(value as PreviewFormat)}
+ >
+
+
+ Web
+
+
+ Email
+
+
+
+ )}
+ {
+ if (value === 'desktop' || value === 'mobile') {
+ setDevice(value);
+ }
+ }}
+ >
+
+
+
+
+
+
+
+ {showSegmentSelect && (
+ setSegment(value as PreviewSegment)}>
+
+
+
+
+ {segmentOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+ {showTierSelect && (
+
+
+
+
+
+ {activeTiers.length > 0 && (
+
+ Active tiers
+ {activeTiers.map((tier) => (
+
+ {tier.name}
+
+ ))}
+
+ )}
+ {archivedTiers.length > 0 && (
+
+ Archived tiers
+ {archivedTiers.map((tier) => (
+
+ {tier.name}
+
+ ))}
+
+ )}
+
+
+ )}
+
}
layout="header"
open={open}
title="Preview"
onOpenChange={onOpenChange}
>
-
+
{prepareState === 'preparing' ? (
-
+
) : prepareState === 'failed' ? (
{
- preparePromise.current = null;
- setPrepareState('preparing');
- }}
- >
- Retry
-
+
+ {emailAvailable && subjectEditor && (
+
+ Email subject
+ {
+ await subjectEditor.onSave();
+ preparePromise.current = null;
+ setPrepareState('preparing');
+ },
+ }}
+ />
+
+ )}
+ {
+ preparePromise.current = null;
+ setPrepareState('preparing');
+ }}
+ >
+ Retry
+
+
}
- className="grow justify-center"
+ className="grow justify-center self-center"
data-testid={postPreviewSaveFailed}
description="Saving the post failed, so there is nothing new to preview."
title="Couldn’t preview this post"
@@ -463,6 +516,7 @@ export function PostPreviewModal({
newsletters={newsletters}
newsletterSlug={selectedNewsletterSlug}
postId={postId}
+ subjectEditor={subjectEditor}
tierName={selectedTier?.name}
onNewsletterChange={setPickedNewsletterSlug}
onRetryNewsletterLookup={retryNewsletterLookup}
diff --git a/apps/admin/src/editor/preview/preview.screen.ts b/apps/admin/src/editor/preview/preview.screen.ts
index 440756b1bd9..576e2c98cba 100644
--- a/apps/admin/src/editor/preview/preview.screen.ts
+++ b/apps/admin/src/editor/preview/preview.screen.ts
@@ -1,6 +1,8 @@
import { page } from 'vitest/browser';
import {
closePreviewButton,
+ preparingPreviewLabel,
+ sharePreviewButton,
copyPreviewLinkButton,
editorPublishButton,
desktopPreviewToggle,
@@ -38,8 +40,10 @@ export const previewScreen = {
tierSelect: () => page.getByRole('combobox', { name: previewTierSelectLabel }),
newsletterSelect: () => page.getByRole('combobox', { name: previewNewsletterSelectLabel }),
option: (name: string) => page.getByRole('option', { name }),
- copyLinkButton: () => page.getByRole('button', { name: copyPreviewLinkButton }),
- openInNewTabLink: () => page.getByRole('link', { name: openPreviewInNewTabLink }),
+ preparingStatus: () => page.getByRole('status', { name: preparingPreviewLabel }),
+ shareButton: () => page.getByRole('button', { name: sharePreviewButton, exact: true }),
+ copyLinkButton: () => page.getByRole('menuitem', { name: copyPreviewLinkButton }),
+ openInNewTabLink: () => page.getByRole('menuitem', { name: openPreviewInNewTabLink }),
closeButton: () =>
page.getByTestId(postPreviewModal).getByRole('button', { name: closePreviewButton }),
publishButton: () =>
diff --git a/apps/admin/src/editor/preview/send-test-email.tsx b/apps/admin/src/editor/preview/send-test-email.tsx
index c986d7165ff..a7b9c3b631d 100644
--- a/apps/admin/src/editor/preview/send-test-email.tsx
+++ b/apps/admin/src/editor/preview/send-test-email.tsx
@@ -88,7 +88,7 @@ export function SendTestEmail({
return (
-
+
Test
diff --git a/apps/admin/src/editor/session/README.md b/apps/admin/src/editor/session/README.md
index d8b6165ed2e..b81dbc77c88 100644
--- a/apps/admin/src/editor/session/README.md
+++ b/apps/admin/src/editor/session/README.md
@@ -3,7 +3,7 @@
`apps/admin/src/editor/session/` composes the three modules described in
[the engine README](../engine/README.md) — the save engine, the change tracker
and the slug machine — into one editing session, and is the editor's only
-writer. Every title, excerpt, body, feature-image and settings change goes
+writer. Every title, excerpt, body, feature-image, email-subject and settings change goes
through it, and it owns everything those three modules deliberately do not:
what a save sends and what an acknowledgement may change. The save engine owns
pending work and scheduling.
diff --git a/apps/admin/src/editor/session/projection.test.ts b/apps/admin/src/editor/session/projection.test.ts
new file mode 100644
index 00000000000..91f62e2a027
--- /dev/null
+++ b/apps/admin/src/editor/session/projection.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { record } from '@/editor/session/__test-utils__/session-harness';
+import { projectionOf } from '@/editor/session/projection';
+
+describe('projectionOf email subject boundary', () => {
+ it.each(['A custom subject', '', null, undefined])(
+ 'preserves supported subject values (%s)',
+ (value) => {
+ expect(projectionOf(record({ email_subject: value })).email_subject).toBe(value ?? null);
+ },
+ );
+
+ it('supports responses without the optional subject field', () => {
+ const response = record();
+ Reflect.deleteProperty(response, 'email_subject');
+
+ expect(projectionOf(response).email_subject).toBeNull();
+ });
+
+ it.each([
+ { value: 42 },
+ { value: false },
+ { value: {} },
+ { value: [] },
+ { value: ['Unexpected array subject'] },
+ ])('uses the title fallback for malformed API subject metadata: $value', ({ value }) => {
+ const response = record({ title: 'Original title' });
+ // Deliberately violate the compile-time API type, as a malformed JSON response can.
+ Reflect.set(response, 'email_subject', value);
+
+ expect(projectionOf(response)).toMatchObject({
+ email_subject: null,
+ title: 'Original title',
+ lexical: response.lexical,
+ });
+ });
+});
diff --git a/apps/admin/src/editor/session/projection.ts b/apps/admin/src/editor/session/projection.ts
index c242fed9163..b4012388266 100644
--- a/apps/admin/src/editor/session/projection.ts
+++ b/apps/admin/src/editor/session/projection.ts
@@ -1,3 +1,4 @@
+import { z } from 'zod';
import type { PageEditorRecord } from '@tryghost/admin-x-framework/api/pages';
import type { PostEditorRecord } from '@tryghost/admin-x-framework/api/posts';
import { parsePostRevisions, revisionTime } from '@/editor/post-revisions';
@@ -5,6 +6,8 @@ import type { EditablePostProjection, RevisionProjection } from '@/editor/engine
export type EditorRecord = PostEditorRecord | PageEditorRecord;
+const emailSubjectSchema = z.string().nullish();
+
/** A post the writer has not saved yet, credited to whoever is creating it. */
export function newPostProjection(currentUserId?: string): EditablePostProjection {
return {
@@ -13,6 +16,7 @@ export function newPostProjection(currentUserId?: string): EditablePostProjectio
lexical: null,
tags: [],
custom_excerpt: null,
+ email_subject: null,
feature_image: null,
feature_image_alt: null,
feature_image_caption: null,
@@ -38,12 +42,19 @@ export function newPostProjection(currentUserId?: string): EditablePostProjectio
}
export function projectionOf(record: EditorRecord): EditablePostProjection {
+ // API hook types do not validate runtime responses. Malformed optional subject
+ // metadata uses the same title fallback as an absent subject.
+ const emailSubject = emailSubjectSchema.safeParse(
+ 'email_subject' in record ? record.email_subject : undefined,
+ );
+
return {
title: record.title,
slug: record.slug,
lexical: record.lexical ?? null,
tags: record.tags ?? [],
custom_excerpt: record.custom_excerpt ?? null,
+ email_subject: emailSubject.success ? (emailSubject.data ?? null) : null,
feature_image: record.feature_image ?? null,
feature_image_alt: record.feature_image_alt ?? null,
feature_image_caption: record.feature_image_caption ?? null,
diff --git a/apps/admin/src/editor/session/settings-fields.test.ts b/apps/admin/src/editor/session/settings-fields.test.ts
index 7ecf9674e5f..364542b1623 100644
--- a/apps/admin/src/editor/session/settings-fields.test.ts
+++ b/apps/admin/src/editor/session/settings-fields.test.ts
@@ -26,6 +26,7 @@ import {
const VALID = {
visibility: 'public',
tiers: [],
+ email_subject: null,
meta_title: null,
meta_description: null,
canonical_url: null,
@@ -67,6 +68,13 @@ describe('validatedFieldsOf', () => {
});
describe('settingsFieldError', () => {
+ it('validates the email subject on every save, counting Unicode characters', () => {
+ expect(settingsFieldError({ ...VALID, email_subject: '𝔘'.repeat(300) }, false)).toBeNull();
+ expect(settingsFieldError({ ...VALID, email_subject: '𝔘'.repeat(301) }, false)).toBe(
+ 'Email subject cannot be longer than 300 characters.',
+ );
+ expect(settingsFieldError({ ...VALID, email_subject: '' }, false)).toBeNull();
+ });
it('passes fields that break no rule', () => {
expect(settingsFieldError(VALID, false)).toBeNull();
});
diff --git a/apps/admin/src/editor/session/settings-fields.ts b/apps/admin/src/editor/session/settings-fields.ts
index 724a31a5156..ccd005051ab 100644
--- a/apps/admin/src/editor/session/settings-fields.ts
+++ b/apps/admin/src/editor/session/settings-fields.ts
@@ -5,7 +5,7 @@ import { tagIdentities } from '@/shared/tags/tag-selection';
import type { EditorCreatePayload } from './write-payload';
/**
- * The projection keys the settings sidebar may write. Slug, status and publish
+ * The projection keys settings and the email subject editor may write. Slug, status and publish
* time are absent on purpose: the slug machine and the save engine's command
* target own them, and a field patch would be dropped before the request.
*/
@@ -13,6 +13,7 @@ export const SETTINGS_FIELD_KEYS = [
'tags',
'authors',
'custom_excerpt',
+ 'email_subject',
'featured',
'visibility',
'tiers',
@@ -78,6 +79,8 @@ export function identityFor(
}
/** The column widths the schema gives these fields. */
+export const EMAIL_SUBJECT_MAX = 300;
+export const EMAIL_SUBJECT_TOO_LONG = `Email subject cannot be longer than ${EMAIL_SUBJECT_MAX} characters.`;
export const META_TITLE_MAX = 300;
export const META_DESCRIPTION_MAX = 500;
export const OG_TITLE_MAX = 300;
@@ -123,6 +126,7 @@ export function overLength(value: string | null, max: number): boolean {
/** The settings keys the validator reads, and all a prepared save carries for it. */
export const VALIDATED_SETTINGS_FIELD_KEYS = [
+ 'email_subject',
'visibility',
'tiers',
'meta_title',
@@ -149,6 +153,7 @@ const LENGTH_RULES: Record<
Exclude,
{ max: number; message: string }
> = {
+ email_subject: { max: EMAIL_SUBJECT_MAX, message: EMAIL_SUBJECT_TOO_LONG },
meta_title: { max: META_TITLE_MAX, message: META_TITLE_TOO_LONG },
meta_description: { max: META_DESCRIPTION_MAX, message: META_DESCRIPTION_TOO_LONG },
og_title: { max: OG_TITLE_MAX, message: OG_TITLE_TOO_LONG },
diff --git a/apps/admin/src/editor/settings/use-settings-field.test.ts b/apps/admin/src/editor/settings/use-settings-field.test.ts
index 70d5af769e2..c986d0f1ee4 100644
--- a/apps/admin/src/editor/settings/use-settings-field.test.ts
+++ b/apps/admin/src/editor/settings/use-settings-field.test.ts
@@ -13,6 +13,7 @@ import { useSettingsField } from './use-settings-field';
const SETTINGS: ValidatedSettingsFields = {
visibility: 'public',
tiers: [],
+ email_subject: null,
meta_title: null,
meta_description: null,
canonical_url: null,
diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json
index 6cd005ec81a..07afbc4cffe 100644
--- a/apps/ember-admin/package.json
+++ b/apps/ember-admin/package.json
@@ -1,6 +1,6 @@
{
"name": "ghost-admin",
- "version": "6.66.1-rc.0",
+ "version": "6.67.1-rc.0",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",
diff --git a/apps/shade/src/components/ui/select.stories.tsx b/apps/shade/src/components/ui/select.stories.tsx
index 56ab1ceb957..e66658f5c6c 100644
--- a/apps/shade/src/components/ui/select.stories.tsx
+++ b/apps/shade/src/components/ui/select.stories.tsx
@@ -52,7 +52,8 @@ export const Default: Story = {
parameters: {
docs: {
description: {
- story: 'Basic select dropdown with simple options.',
+ story:
+ 'Basic select dropdown with simple options. The chevron has no left margin; triggers with a chevron reduce right padding by one spacing step (4px).',
},
},
},
diff --git a/apps/shade/src/components/ui/select.tsx b/apps/shade/src/components/ui/select.tsx
index 52fe1be3823..ed3a846b82f 100644
--- a/apps/shade/src/components/ui/select.tsx
+++ b/apps/shade/src/components/ui/select.tsx
@@ -81,6 +81,7 @@ const SelectTrigger = React.forwardRef<
inputSurface('self'),
inputSurfaceClasses.disabledFieldSelf,
selectTriggerVariants({ shape: resolvedShape, variant, isAdmin7 }),
+ showChevron && 'pr-2',
className,
)}
data-control-shape={resolvedShape}
@@ -90,7 +91,7 @@ const SelectTrigger = React.forwardRef<
{children}
{showChevron && (
-
+
)}
diff --git a/ghost/core/core/server/api/endpoints/automated-emails.js b/ghost/core/core/server/api/endpoints/automated-emails.js
index 5bd4a6a529a..24e7e46a984 100644
--- a/ghost/core/core/server/api/endpoints/automated-emails.js
+++ b/ghost/core/core/server/api/endpoints/automated-emails.js
@@ -22,6 +22,10 @@ const messages = {
// acts as a facade that joins/splits data between those two models while preserving the original
// `automated_emails` API shape externally.
const AUTOMATION_FIELDS = ['status', 'name', 'slug'];
+const TRIGGER_TIER_SCOPE_BY_SLUG = {
+ [MEMBER_WELCOME_EMAIL_SLUGS.free]: 'free',
+ [MEMBER_WELCOME_EMAIL_SLUGS.paid]: 'all_paid',
+};
const EMAIL_FIELDS = ['subject', 'lexical', 'email_design_setting_id'];
const SENDER_FIELDS = ['sender_name', 'sender_email', 'sender_reply_to'];
@@ -159,6 +163,7 @@ const controller = {
const emailData = _.pick(data, EMAIL_FIELDS);
const senderData = _.pick(data, SENDER_FIELDS);
const automationData = _.pick(data, AUTOMATION_FIELDS);
+ automationData.trigger_tier_scope = TRIGGER_TIER_SCOPE_BY_SLUG[data.slug];
emailAddressService.init();
validateEmailSenderFields(emailAddressService.service, senderData);
diff --git a/ghost/core/core/server/api/endpoints/automations.ts b/ghost/core/core/server/api/endpoints/automations.ts
index be0e39e5862..ed3ccb71b45 100644
--- a/ghost/core/core/server/api/endpoints/automations.ts
+++ b/ghost/core/core/server/api/endpoints/automations.ts
@@ -48,6 +48,22 @@ export const controller = {
},
},
+ add: {
+ statusCode: 201,
+ headers: {
+ cacheInvalidate: false,
+ },
+ permissions: true,
+ async query() {
+ // TODO(NY-1637) Implement this endpoint.
+ throw new errors.InternalServerError({
+ statusCode: 501,
+ code: 'NOT_IMPLEMENTED',
+ message: 'Adding automations is not implemented.',
+ });
+ },
+ },
+
edit: {
headers: {
cacheInvalidate: false,
diff --git a/ghost/core/core/server/data/exporter/table-lists.js b/ghost/core/core/server/data/exporter/table-lists.js
index c3477f3cf54..aad8a0161f0 100644
--- a/ghost/core/core/server/data/exporter/table-lists.js
+++ b/ghost/core/core/server/data/exporter/table-lists.js
@@ -75,6 +75,7 @@ const BACKUP_TABLES = [
'automation_action_revisions',
'automation_run_steps',
'automation_runs',
+ 'automation_trigger_tiers',
'welcome_email_automation_runs',
'welcome_email_automated_emails',
'tinybird_syncs',
diff --git a/ghost/core/core/server/data/migrations/versions/6.67/2026-09-29-16-22-08-add-description-to-automations.js b/ghost/core/core/server/data/migrations/versions/6.67/2026-09-29-16-22-08-add-description-to-automations.js
new file mode 100644
index 00000000000..583de9276ad
--- /dev/null
+++ b/ghost/core/core/server/data/migrations/versions/6.67/2026-09-29-16-22-08-add-description-to-automations.js
@@ -0,0 +1,30 @@
+const logging = require('@tryghost/logging');
+const {
+ combineNonTransactionalMigrations,
+ createAddColumnMigration,
+ createNonTransactionalMigration,
+} = require('../../utils');
+
+module.exports = combineNonTransactionalMigrations(
+ createAddColumnMigration('automations', 'description', {
+ type: 'string',
+ maxlength: 2000,
+ nullable: false,
+ defaultTo: '',
+ }),
+
+ createNonTransactionalMigration(
+ async function up(knex) {
+ const freeMembersUpdated = await knex('automations')
+ .where('slug', 'member-welcome-email-free')
+ .update({ description: 'Welcome new free members after they sign up.' });
+ const paidMembersUpdated = await knex('automations')
+ .where('slug', 'member-welcome-email-paid')
+ .update({ description: 'Welcome new paid members after they start their subscription.' });
+ logging.info(`Set descriptions for ${freeMembersUpdated + paidMembersUpdated} automations`);
+ },
+ async function down() {
+ logging.info('Keeping automation descriptions');
+ },
+ ),
+);
diff --git a/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-17-39-23-add-automation-trigger-tier-scope.js b/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-17-39-23-add-automation-trigger-tier-scope.js
new file mode 100644
index 00000000000..9c55eeca998
--- /dev/null
+++ b/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-17-39-23-add-automation-trigger-tier-scope.js
@@ -0,0 +1,57 @@
+const logging = require('@tryghost/logging');
+const {
+ addTable,
+ combineNonTransactionalMigrations,
+ createAddColumnMigration,
+ createNonTransactionalMigration,
+} = require('../../utils');
+
+/**
+ * This migration:
+ *
+ * - Creates `automations.trigger_tier_scope` column with backfill
+ * - Creates the `automation_trigger_tiers` table
+ *
+ * Databases should have at most two automations, making this migration
+ * simpler.
+ */
+module.exports = combineNonTransactionalMigrations(
+ createAddColumnMigration('automations', 'trigger_tier_scope', {
+ type: 'string',
+ maxlength: 50,
+ nullable: true,
+ validations: { isIn: [['free', 'all_paid', 'selected_paid']] },
+ }),
+
+ createNonTransactionalMigration(
+ async function up(knex) {
+ const freeUpdatedRows = await knex('automations')
+ .where({ slug: 'member-welcome-email-free' })
+ .update('trigger_tier_scope', 'free');
+ const paidUpdatedRows = await knex('automations')
+ .where({ slug: 'member-welcome-email-paid' })
+ .update('trigger_tier_scope', 'all_paid');
+ logging.info(`Set trigger tier scope for ${freeUpdatedRows + paidUpdatedRows} automations`);
+ },
+ async function down() {
+ logging.info('Keeping automation trigger tier scopes until column rollback');
+ },
+ ),
+
+ addTable('automation_trigger_tiers', {
+ automation_id: {
+ type: 'string',
+ maxlength: 24,
+ nullable: false,
+ references: 'automations.id',
+ cascadeDelete: true,
+ },
+ product_id: {
+ type: 'string',
+ maxlength: 24,
+ nullable: false,
+ references: 'products.id',
+ },
+ '@@UNIQUE_CONSTRAINTS@@': [['automation_id', 'product_id']],
+ }),
+);
diff --git a/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-18-00-00-add-automation-add-permission.js b/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-18-00-00-add-automation-add-permission.js
new file mode 100644
index 00000000000..d939266f35e
--- /dev/null
+++ b/ghost/core/core/server/data/migrations/versions/6.68/2026-09-29-18-00-00-add-automation-add-permission.js
@@ -0,0 +1,10 @@
+const { addPermissionWithRoles } = require('../../utils');
+
+module.exports = addPermissionWithRoles(
+ {
+ name: 'Add automations',
+ action: 'add',
+ object: 'automation',
+ },
+ ['Administrator', 'Admin Integration'],
+);
diff --git a/ghost/core/core/server/data/schema/fixtures/fixtures.json b/ghost/core/core/server/data/schema/fixtures/fixtures.json
index 18888031e9f..9f91d3e0a0d 100644
--- a/ghost/core/core/server/data/schema/fixtures/fixtures.json
+++ b/ghost/core/core/server/data/schema/fixtures/fixtures.json
@@ -795,6 +795,11 @@
"action_type": "read",
"object_type": "automation"
},
+ {
+ "name": "Add automations",
+ "action_type": "add",
+ "object_type": "automation"
+ },
{
"name": "Edit automations",
"action_type": "edit",
@@ -987,7 +992,7 @@
"collection": "all",
"recommendation": "all",
"gift_link": "all",
- "automation": ["browse", "read", "edit"],
+ "automation": ["browse", "read", "edit", "add"],
"identity": "read"
},
"DB Backup Integration": {
@@ -1036,7 +1041,7 @@
"mention": "browse",
"collection": "all",
"recommendation": "all",
- "automation": ["browse", "read", "edit"],
+ "automation": ["browse", "read", "edit", "add"],
"member_signin_url": "read",
"gift_link": "manage"
},
diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js
index 237aac1f551..40027f5675c 100644
--- a/ghost/core/core/server/data/schema/schema.js
+++ b/ghost/core/core/server/data/schema/schema.js
@@ -2170,10 +2170,33 @@ module.exports = {
validations: { isIn: [['active', 'inactive']] },
},
name: { type: 'string', maxlength: 191, nullable: false, unique: true },
+ description: { type: 'string', maxlength: 2000, nullable: false, defaultTo: '' },
slug: { type: 'string', maxlength: 191, nullable: false, unique: true },
+ trigger_tier_scope: {
+ type: 'string',
+ maxlength: 50,
+ nullable: true,
+ validations: { isIn: [['free', 'all_paid', 'selected_paid']] },
+ },
created_at: { type: 'dateTime', nullable: false },
updated_at: { type: 'dateTime', nullable: true },
},
+ automation_trigger_tiers: {
+ automation_id: {
+ type: 'string',
+ maxlength: 24,
+ nullable: false,
+ references: 'automations.id',
+ cascadeDelete: true,
+ },
+ product_id: {
+ type: 'string',
+ maxlength: 24,
+ nullable: false,
+ references: 'products.id',
+ },
+ '@@UNIQUE_CONSTRAINTS@@': [['automation_id', 'product_id']],
+ },
automation_actions: {
id: { type: 'string', maxlength: 24, nullable: false, primary: true },
created_at: { type: 'dateTime', nullable: false },
diff --git a/ghost/core/core/server/data/seeders/importers/automations-importer.ts b/ghost/core/core/server/data/seeders/importers/automations-importer.ts
index b1f1ec8486c..f22345a93ca 100644
--- a/ghost/core/core/server/data/seeders/importers/automations-importer.ts
+++ b/ghost/core/core/server/data/seeders/importers/automations-importer.ts
@@ -12,6 +12,8 @@ type Automation = {
status: 'active' | 'inactive';
name: string;
slug: string;
+ description: string;
+ trigger_tier_scope: 'free' | 'all_paid';
created_at: string;
updated_at: string;
};
@@ -20,12 +22,16 @@ const defaultAutomations = [
{
name: 'Free member welcome flow',
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
+ description: 'Welcome new free members after they sign up.',
+ trigger_tier_scope: 'free',
},
{
name: 'Paid member welcome flow',
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
+ description: 'Welcome new paid members after they start their subscription.',
+ trigger_tier_scope: 'all_paid',
},
-];
+] as const;
export class AutomationsImporter extends TableImporter {
static table = 'automations';
@@ -53,7 +59,9 @@ export class AutomationsImporter extends TableImporter {
id,
status: faker.helpers.arrayElement(['active', 'inactive']),
name,
+ description: defaultAutomation?.description ?? '',
slug,
+ trigger_tier_scope: defaultAutomation?.trigger_tier_scope ?? 'all_paid',
created_at: toDatabaseDate(createdAt),
updated_at: toDatabaseDate(createdAt),
};
diff --git a/ghost/core/core/server/lib/email-rendering/finalize.js b/ghost/core/core/server/lib/email-rendering/finalize.ts
similarity index 69%
rename from ghost/core/core/server/lib/email-rendering/finalize.js
rename to ghost/core/core/server/lib/email-rendering/finalize.ts
index 0b45985258e..bf485adf270 100644
--- a/ghost/core/core/server/lib/email-rendering/finalize.js
+++ b/ghost/core/core/server/lib/email-rendering/finalize.ts
@@ -1,12 +1,9 @@
-const cheerio = require('cheerio/slim');
-const juice = require('juice');
-const htmlToPlaintext = require('@tryghost/html-to-plaintext');
+import * as cheerio from 'cheerio/slim';
+import juice from 'juice';
+// @ts-expect-error This module currently lacks type definitions.
+import htmlToPlaintext from '@tryghost/html-to-plaintext';
-/**
- * @param {string} html
- * @returns {string}
- */
-const finalizeHtml = (html) => {
+const finalizeHtml = (html: string): string => {
// Add a class to each figcaption so we can style them in the email.
let $ = cheerio.load(html, null, false);
$('figcaption').addClass('kg-card-figcaption');
@@ -40,13 +37,7 @@ const finalizeHtml = (html) => {
return html;
};
-module.exports = {
- /**
- * @param {string} html
- * @returns {{html: string, plaintext: string}}
- */
- finalize(html) {
- const resultHtml = finalizeHtml(html);
- return { html: resultHtml, plaintext: htmlToPlaintext.email(resultHtml) };
- },
+export const finalize = (html: string): { html: string; plaintext: string } => {
+ const resultHtml = finalizeHtml(html);
+ return { html: resultHtml, plaintext: htmlToPlaintext.email(resultHtml) };
};
diff --git a/ghost/core/core/server/models/automation.js b/ghost/core/core/server/models/automation.js
index 61e11d577a3..8e04f485756 100644
--- a/ghost/core/core/server/models/automation.js
+++ b/ghost/core/core/server/models/automation.js
@@ -11,6 +11,7 @@ const Automation = ghostBookshelf.Model.extend(
defaults() {
return {
status: 'inactive',
+ description: '',
};
},
diff --git a/ghost/core/core/server/services/automations/automations-api.ts b/ghost/core/core/server/services/automations/automations-api.ts
index 19c0fc98db4..1c6b8a432e5 100644
--- a/ghost/core/core/server/services/automations/automations-api.ts
+++ b/ghost/core/core/server/services/automations/automations-api.ts
@@ -19,7 +19,7 @@ const TinybirdServiceWrapper = require('../tinybird');
const { create: createTinybirdClient } = require('../stats/utils/tinybird');
const lexicalLib = require('../../lib/lexical');
-const MAX_AUTOMATION_ACTIONS = 20;
+const MAX_AUTOMATION_ACTIONS = 50;
const messages = {
automationNotFound: 'Automation not found.',
diff --git a/ghost/core/core/server/services/automations/automations-repository.ts b/ghost/core/core/server/services/automations/automations-repository.ts
index 67c6fe5e9e7..2ff222887ab 100644
--- a/ghost/core/core/server/services/automations/automations-repository.ts
+++ b/ghost/core/core/server/services/automations/automations-repository.ts
@@ -60,6 +60,7 @@ export type AutomationSummary = {
id: string;
slug: null | string;
name: string;
+ description: string;
status: string;
created_at: string;
updated_at: string;
@@ -108,6 +109,8 @@ export type RecordEmailSentOptions = Readonly<{
trackOpens: boolean;
}>;
+export type AutomationTriggerTierScope = 'free' | 'all_paid' | 'selected_paid';
+
type AutomationStepBase = {
id: string;
locked_by: string;
diff --git a/ghost/core/core/server/services/automations/database-automations-repository.ts b/ghost/core/core/server/services/automations/database-automations-repository.ts
index 601b7ac407d..f9ecb1168e5 100644
--- a/ghost/core/core/server/services/automations/database-automations-repository.ts
+++ b/ghost/core/core/server/services/automations/database-automations-repository.ts
@@ -22,6 +22,7 @@ import type {
AutomationSummary,
AutomationStepTerminalStatus,
AutomationStepToRun,
+ AutomationTriggerTierScope,
AutomationsRepository,
BrowseOptions,
EditAutomationData,
@@ -35,13 +36,17 @@ const HOUR_MS = 60 * 60 * 1000;
const DEFAULT_WELCOME_EMAIL_AUTOMATIONS = [
{
name: 'Free member welcome flow',
+ description: 'Welcome new free members after they sign up.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
+ trigger_tier_scope: 'free',
},
{
name: 'Paid member welcome flow',
+ description: 'Welcome new paid members after they start their subscription.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
+ trigger_tier_scope: 'all_paid',
},
-];
+] as const;
const messages = {
invalidAutomationActionRevision:
@@ -59,6 +64,7 @@ type AutomationRow = {
id: string;
slug: null | string;
name: string;
+ description: string;
status: string;
created_at: DatabaseDate;
updated_at: DatabaseDate;
@@ -501,7 +507,12 @@ async function ensureDefaultAutomations(trx: Knex.Transaction): Promise {
async function ensureAutomation(
trx: Knex.Transaction,
- defaults: Readonly<{ name: string; slug: string }>,
+ defaults: Readonly<{
+ name: string;
+ description: string;
+ slug: string;
+ trigger_tier_scope: AutomationTriggerTierScope;
+ }>,
): Promise {
const now = toDatabaseDate(new Date());
const id = ObjectId().toHexString();
@@ -511,7 +522,9 @@ async function ensureAutomation(
id,
status: 'inactive',
name: defaults.name,
+ description: defaults.description,
slug: defaults.slug,
+ trigger_tier_scope: defaults.trigger_tier_scope,
created_at: now,
updated_at: now,
})
@@ -1087,7 +1100,7 @@ async function loadAutomation(
automationId: string,
): Promise {
const row = await trx('automations')
- .select('id', 'slug', 'name', 'status', 'created_at', 'updated_at')
+ .select('id', 'slug', 'name', 'description', 'status', 'created_at', 'updated_at')
.where('id', automationId)
.first();
return row ?? null;
@@ -1098,7 +1111,7 @@ async function loadAutomationBySlug(
slug: string,
): Promise {
const row = await trx('automations')
- .select('id', 'slug', 'name', 'status', 'created_at', 'updated_at')
+ .select('id', 'slug', 'name', 'description', 'status', 'created_at', 'updated_at')
.where('slug', slug)
.first();
return row ?? null;
@@ -1106,7 +1119,7 @@ async function loadAutomationBySlug(
async function loadAutomations(trx: Knex.Transaction): Promise {
return await trx('automations')
- .select('id', 'slug', 'name', 'status', 'created_at', 'updated_at')
+ .select('id', 'slug', 'name', 'description', 'status', 'created_at', 'updated_at')
.orderBy('name');
}
@@ -1128,6 +1141,7 @@ async function loadAutomationsWithStats(trx: Knex.Transaction): Promise AGENTS.md.
+const symlinks = (await fs.readdir(BUILD_DIR, { recursive: true, withFileTypes: true })).filter(
+ (entry) => entry.isSymbolicLink(),
+);
+await Promise.all(
+ symlinks.map(async (entry) => {
+ const file = path.join(entry.parentPath, entry.name);
+ await fs.rm(file);
+ console.log(` Removed symlink ${path.relative(BUILD_DIR, file)}`);
+ }),
+);
+
// Carry the root packageManager over — ghost/core's own manifest doesn't declare one.
const pkgPath = path.join(BUILD_DIR, 'package.json');
const pkg = await readJson(pkgPath);
diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap
index 90a1575d663..f3f151ed399 100644
--- a/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap
+++ b/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap
@@ -5,6 +5,7 @@ Object {
"automations": Array [
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
+ "description": "Welcome new free members after they sign up.",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Free member welcome flow",
"slug": "member-welcome-email-free",
@@ -18,6 +19,7 @@ Object {
},
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
+ "description": "Welcome new paid members after they start their subscription.",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Paid member welcome flow",
"slug": "member-welcome-email-paid",
@@ -47,7 +49,7 @@ exports[`Automations API browse returns automations sourced from the database 2:
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": "676",
+ "content-length": "815",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@@ -195,6 +197,7 @@ Object {
},
],
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
+ "description": "Welcome new free members after they sign up.",
"edges": Array [
Object {
"source_action_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
@@ -223,7 +226,7 @@ exports[`Automations API read returns the automation, ordered actions, and edges
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": "1887",
+ "content-length": "1948",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
diff --git a/ghost/core/test/e2e-api/admin/automated-emails.test.ts b/ghost/core/test/e2e-api/admin/automated-emails.test.ts
index faa49d0cbb6..410e38b8f93 100644
--- a/ghost/core/test/e2e-api/admin/automated-emails.test.ts
+++ b/ghost/core/test/e2e-api/admin/automated-emails.test.ts
@@ -358,6 +358,23 @@ describe('Automated Emails API', function () {
etag: anyEtag,
location: anyLocationFor('automated_emails'),
});
+
+ const automation = await models.Base.knex('automations')
+ .where('slug', 'member-welcome-email-free')
+ .first('trigger_tier_scope');
+ assert.equal(automation.trigger_tier_scope, 'free');
+ });
+
+ it('Sets all paid tier scope for paid welcome email', async function () {
+ const automatedEmail = await createAutomatedEmail({
+ name: 'Paid member welcome flow',
+ slug: 'member-welcome-email-paid',
+ });
+
+ const automation = await models.Base.knex('automations')
+ .where('id', automatedEmail.id)
+ .first('trigger_tier_scope');
+ assert.equal(automation.trigger_tier_scope, 'all_paid');
});
it('Writes sender settings to email design settings on add', async function () {
diff --git a/ghost/core/test/e2e-api/admin/automations.test.js b/ghost/core/test/e2e-api/admin/automations.test.js
index e97b73f70a3..8160afdb8d8 100644
--- a/ghost/core/test/e2e-api/admin/automations.test.js
+++ b/ghost/core/test/e2e-api/admin/automations.test.js
@@ -139,6 +139,56 @@ describe('Automations API', function () {
await cleanupAutomationsFixture();
});
+ describe('add', function () {
+ afterEach(async function () {
+ await agent.useStaffTokenForOwner();
+ });
+
+ for (const role of ['Owner', 'Admin']) {
+ // TODO(NY-1637): Remove this placeholder test once the endpoint is finished.
+ it(`${role} bypasses permissions checks`, async function () {
+ await agent[`useStaffTokenFor${role}`]();
+ const { body } = await agent
+ .post('automations')
+ .body({ automations: [{ name: 'Test automation' }] })
+ .expectStatus(501)
+ .expect(cacheInvalidateHeaderNotSet());
+
+ assert.equal(body.errors[0].code, 'NOT_IMPLEMENTED');
+ });
+ }
+
+ // TODO(NY-1637): Remove this placeholder test once the endpoint is finished.
+ it('Admin Integration bypasses permissions checks', async function () {
+ await agent.useZapierAdminAPIKey();
+ await agent
+ .post('automations')
+ .body({ automations: [{ name: 'Test automation' }] })
+ .expectStatus(501);
+ });
+
+ it('denies unauthenticated requests', async function () {
+ agent.resetAuthentication();
+ await agent
+ .post('automations')
+ .body({ automations: [{ name: 'Test automation' }] })
+ .expectStatus(403);
+ });
+
+ for (const role of ['Editor', 'Author', 'Contributor']) {
+ it(`denies ${role} permission to add automations`, async function () {
+ await agent[`useStaffTokenFor${role}`]();
+ const { body } = await agent
+ .post('automations')
+ .body({ automations: [{ name: 'Test automation' }] })
+ .expectStatus(403)
+ .expect(cacheInvalidateHeaderNotSet());
+
+ assert.equal(body.errors[0].type, 'NoPermissionError');
+ });
+ }
+ });
+
describe('browse', function () {
async function createAutomationRun(automationId, createdAt) {
const runId = ObjectId().toHexString();
@@ -1001,11 +1051,11 @@ describe('Automations API', function () {
assert.deepEqual(readBody.automations[0], automation);
});
- it('allows an automation with 20 actions', async function () {
+ it('allows an automation with 50 actions', async function () {
const { body: browseBody } = await agent.get('automations').expectStatus(200);
const automationId = browseBody.automations[0].id;
- const actions = Array.from({ length: 20 }, buildWaitAction);
+ const actions = Array.from({ length: 50 }, buildWaitAction);
const edges = buildLinearEdges(actions);
const { body: editBody } = await agent
@@ -1024,20 +1074,20 @@ describe('Automations API', function () {
const automation = editBody.automations[0];
assert.equal(automation.status, 'inactive');
- assert.equal(automation.actions.length, 20);
- assert.equal(automation.edges.length, 19);
+ assert.equal(automation.actions.length, 50);
+ assert.equal(automation.edges.length, 49);
assert.deepEqual(automation.actions, actions);
assert.deepEqual(automation.edges, edges);
});
- it('rejects an automation with more than 20 actions', async function () {
+ it('rejects an automation with more than 50 actions', async function () {
const { body: browseBody } = await agent.get('automations').expectStatus(200);
const automationId = browseBody.automations[0].id;
const { body: beforeBody } = await agent.get(`automations/${automationId}`).expectStatus(200);
- const actions = Array.from({ length: 21 }, buildWaitAction);
+ const actions = Array.from({ length: 51 }, buildWaitAction);
await agent
.put(`automations/${automationId}`)
diff --git a/ghost/core/test/e2e-api/members/automations.test.js b/ghost/core/test/e2e-api/members/automations.test.js
index 722601fa723..5b2ca43ac85 100644
--- a/ghost/core/test/e2e-api/members/automations.test.js
+++ b/ghost/core/test/e2e-api/members/automations.test.js
@@ -224,6 +224,24 @@ describe('Members Automations', function () {
await cleanupAutomationsFixture();
});
+ it('returns descriptions when browsing and reading automations', async function () {
+ const description = 'Welcome new free members with a short email sequence';
+ await db
+ .knex('automations')
+ .where('slug', MEMBER_WELCOME_EMAIL_SLUGS.free)
+ .update({ description });
+
+ const { body: browseBody } = await agent.get('automations').expectStatus(200);
+ const summary = browseBody.automations.find(
+ (automation) => automation.slug === MEMBER_WELCOME_EMAIL_SLUGS.free,
+ );
+ assert(summary);
+ assert.equal(summary.description, description);
+
+ const { body: readBody } = await agent.get(`automations/${summary.id}`).expectStatus(200);
+ assert.equal(readBody.automations[0].description, description);
+ });
+
it('runs every step in the free member signup automation', async function () {
let automation = await getFreeMemberSignupAutomation();
assert.equal(automation.actions.length, 4);
diff --git a/ghost/core/test/integration/exporter/exporter.test.js b/ghost/core/test/integration/exporter/exporter.test.js
index e20d66b1d10..2f0b75bfc91 100644
--- a/ghost/core/test/integration/exporter/exporter.test.js
+++ b/ghost/core/test/integration/exporter/exporter.test.js
@@ -30,6 +30,7 @@ describe('Exporter', function () {
'automation_actions',
'automation_run_steps',
'automation_runs',
+ 'automation_trigger_tiers',
'automated_email_recipients',
'automations',
'benefits',
diff --git a/ghost/core/test/integration/migrations/migration.test.js b/ghost/core/test/integration/migrations/migration.test.js
index f814d0afd49..dc95b21dd37 100644
--- a/ghost/core/test/integration/migrations/migration.test.js
+++ b/ghost/core/test/integration/migrations/migration.test.js
@@ -87,7 +87,7 @@ describe('Migrations', function () {
// Custom assertion to wrap all permissions
function assertCompletePermissions(permissions) {
// If you have to change this number, please add the relevant `assertHavePermission` checks below
- assert.equal(permissions.length, 141);
+ assert.equal(permissions.length, 142);
assertHavePermission(permissions, 'Export database', [
'Administrator',
@@ -664,6 +664,7 @@ describe('Migrations', function () {
'Admin Integration',
]);
assertHavePermission(permissions, 'Read automations', ['Administrator', 'Admin Integration']);
+ assertHavePermission(permissions, 'Add automations', ['Administrator', 'Admin Integration']);
assertHavePermission(permissions, 'Edit automations', ['Administrator', 'Admin Integration']);
assertHavePermission(permissions, 'Poll automations', ['Scheduler Integration']);
diff --git a/ghost/core/test/integration/services/member-welcome-emails.test.js b/ghost/core/test/integration/services/member-welcome-emails.test.js
index 93978e4e968..be2f3b62602 100644
--- a/ghost/core/test/integration/services/member-welcome-emails.test.js
+++ b/ghost/core/test/integration/services/member-welcome-emails.test.js
@@ -81,6 +81,7 @@ describe('Member Welcome Emails Integration', function () {
id: freeAutomationId,
status: 'active',
name: 'Free Member Welcome Email',
+ description: 'Welcome new free members after they sign up.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
created_at: new Date(),
});
@@ -99,6 +100,7 @@ describe('Member Welcome Emails Integration', function () {
id: paidAutomationId,
status: 'active',
name: 'Paid Member Welcome Email',
+ description: 'Welcome new paid members after they start their subscription.',
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
created_at: new Date(),
});
diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js
index bf1f911f23f..e094c55aac7 100644
--- a/ghost/core/test/unit/server/data/schema/integrity.test.js
+++ b/ghost/core/test/unit/server/data/schema/integrity.test.js
@@ -38,8 +38,8 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya
*/
describe('DB version integrity', function () {
// Only these variables should need updating
- const currentSchemaHash = 'b3467bb26d2c4ef862382718ca6ed6e8';
- const currentFixturesHash = '5718e0d4eb037f159c312369e949829a';
+ const currentSchemaHash = 'f830d874fe0850d84021c1301e7ce6b7';
+ const currentFixturesHash = '7b7dc2bb39eb98031ef81223c5f5c28e';
const currentSettingsHash = 'ad77752f31c6a7f174c04c499214b975';
const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1';
diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js
index 4f54cc5ac72..cc5647bb026 100644
--- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js
+++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js
@@ -524,6 +524,8 @@ describe('Importer', function () {
table.string('status');
table.string('name').unique();
table.string('slug').unique();
+ table.string('description', 2000);
+ table.string('trigger_tier_scope');
table.dateTime('created_at');
table.dateTime('updated_at');
});
@@ -565,24 +567,33 @@ describe('Importer', function () {
await automationsImporter.import(3);
await transaction.commit();
- const automations = await db.select('id', 'status', 'name', 'slug').from('automations');
+ const automations = await db
+ .select('id', 'status', 'name', 'description', 'slug', 'trigger_tier_scope')
+ .from('automations');
assert.equal(automations.length, 3);
assert.deepEqual(
- automations.slice(0, 2).map(({ name, slug }) => ({ name, slug })),
+ automations.slice(0, 2).map(({ name, description, slug }) => ({ name, description, slug })),
[
{
name: 'Free member welcome flow',
+ description: 'Welcome new free members after they sign up.',
slug: 'member-welcome-email-free',
},
{
name: 'Paid member welcome flow',
+ description: 'Welcome new paid members after they start their subscription.',
slug: 'member-welcome-email-paid',
},
],
);
assert.equal(new Set(automations.map((automation) => automation.name)).size, 3);
assert.equal(new Set(automations.map((automation) => automation.slug)).size, 3);
+ assert.equal(automations[2].description, '');
+ assert.deepEqual(
+ automations.map((automation) => automation.trigger_tier_scope),
+ ['free', 'all_paid', 'all_paid'],
+ );
assert.ok(
automations.every((automation) => ['active', 'inactive'].includes(automation.status)),
);
diff --git a/ghost/core/test/unit/server/models/automation.test.js b/ghost/core/test/unit/server/models/automation.test.js
index 68c8d22f56b..abcefacaac2 100644
--- a/ghost/core/test/unit/server/models/automation.test.js
+++ b/ghost/core/test/unit/server/models/automation.test.js
@@ -21,8 +21,9 @@ describe('Unit: models/automation', function () {
const defaults = model.defaults();
assert.ok(defaults);
- assert.equal(Object.keys(defaults).length, 1);
+ assert.equal(Object.keys(defaults).length, 2);
assert.equal(defaults.status, 'inactive');
+ assert.equal(defaults.description, '');
});
});
diff --git a/ghost/core/test/unit/server/services/automations/automations-api.test.ts b/ghost/core/test/unit/server/services/automations/automations-api.test.ts
index 96b225827ee..f7f7e16721b 100644
--- a/ghost/core/test/unit/server/services/automations/automations-api.test.ts
+++ b/ghost/core/test/unit/server/services/automations/automations-api.test.ts
@@ -8,6 +8,12 @@ import {
NON_EMPTY_EMAIL_LEXICAL,
} from '../../../../utils/automations-fixtures';
+const buildWaitAction = () => ({
+ id: ObjectId().toHexString(),
+ type: 'wait',
+ data: { wait_hours: 1 },
+});
+
const buildSendEmailAction = (dataOverrides = {}) => ({
id: ObjectId().toHexString(),
type: 'send_email',
@@ -19,6 +25,11 @@ const buildSendEmailAction = (dataOverrides = {}) => ({
},
});
+const buildEdge = (source: Readonly<{ id: string }>, target: Readonly<{ id: string }>) => ({
+ source_action_id: source.id,
+ target_action_id: target.id,
+});
+
describe('automations API', function () {
afterEach(function () {
sinon.restore();
@@ -128,5 +139,37 @@ describe('automations API', function () {
/well-formed Lexical document/,
);
});
+
+ it('rejects duplicate edges', async function () {
+ const first = buildWaitAction();
+ const second = buildWaitAction();
+
+ await assert.rejects(
+ automationsApi.edit(automationId, {
+ status: 'inactive',
+ actions: [first, second],
+ edges: [buildEdge(first, second), buildEdge(first, second)],
+ }),
+ /edges must be unique/,
+ );
+ });
+
+ it('rejects a path with a separate cycle', async function () {
+ // A -> B is a valid path, but C <-> D forms a disconnected cycle. The
+ // edge count and head/tail counts still look like a linear path.
+ const a = buildWaitAction();
+ const b = buildWaitAction();
+ const c = buildWaitAction();
+ const d = buildWaitAction();
+
+ await assert.rejects(
+ automationsApi.edit(automationId, {
+ status: 'inactive',
+ actions: [a, b, c, d],
+ edges: [buildEdge(a, b), buildEdge(c, d), buildEdge(d, c)],
+ }),
+ /graph must be a single linear path/,
+ );
+ });
});
});
diff --git a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
index 700808199c5..bef87f2b2fb 100644
--- a/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
+++ b/ghost/core/test/unit/server/services/automations/database-automations-repository.test.ts
@@ -75,7 +75,9 @@ const createDatabase = async (): Promise => {
table.text('updated_at').notNullable();
table.text('slug').notNullable().unique();
table.text('name').notNullable();
+ table.text('description').notNullable();
table.text('status').notNullable();
+ table.text('trigger_tier_scope');
});
await database.schema.createTable('automation_actions', (table) => {
@@ -222,6 +224,7 @@ const createDatabase = async (): Promise => {
updated_at: now(),
slug: 'member-welcome-email-free',
name: 'Free member welcome flow',
+ description: 'Welcome new free members after they sign up.',
status: 'active',
},
{
@@ -230,6 +233,7 @@ const createDatabase = async (): Promise => {
updated_at: now(),
slug: 'member-welcome-email-paid',
name: 'Paid member welcome flow',
+ description: 'Welcome new paid members after they start their subscription.',
status: 'active',
},
]);
@@ -716,6 +720,7 @@ describe('automations repository', function () {
updated_at: toDatabaseDate(new Date()),
slug: 'alpha-flow',
name: 'Alpha flow',
+ description: '',
status: 'inactive',
});
@@ -728,6 +733,12 @@ describe('automations repository', function () {
'Free member welcome flow',
'Paid member welcome flow',
]);
+ const descriptions = await knex('automations').orderBy('name').pluck('description');
+ assert.deepEqual(descriptions, [
+ '',
+ 'Welcome new free members after they sign up.',
+ 'Welcome new paid members after they start their subscription.',
+ ]);
});
it('can omit stats', async function () {
@@ -848,22 +859,29 @@ describe('automations repository', function () {
await repo.browse({ includeStats: false });
const automations = await knex('automations')
- .select('id', 'name', 'slug', 'status')
+ .select('id', 'name', 'slug', 'status', 'trigger_tier_scope')
.whereIn('slug', ['member-welcome-email-free', 'member-welcome-email-paid'])
.orderBy('slug');
assert.deepEqual(
- automations.map(({ name, slug, status }) => ({ name, slug, status })),
+ automations.map(({ name, slug, status, trigger_tier_scope }) => ({
+ name,
+ slug,
+ status,
+ trigger_tier_scope,
+ })),
[
{
name: 'Free member welcome flow',
slug: 'member-welcome-email-free',
status: 'inactive',
+ trigger_tier_scope: 'free',
},
{
name: 'Paid member welcome flow',
slug: 'member-welcome-email-paid',
status: 'inactive',
+ trigger_tier_scope: 'all_paid',
},
],
);
@@ -1764,6 +1782,34 @@ describe('automations repository', function () {
assert.equal(step.updated_at, step.locked_at);
};
+ it('returns step data', async function () {
+ const automation = await getAutomationBySlug('member-welcome-email-free');
+ const action = await getActionByIndex(automation.id, 0);
+ const run = await insertRun(automation.id);
+ const readyAt = new Date('2024-01-01T00:00:00.000Z');
+ const stepRow = await insertStep(run.id, action.revision_id, { ready_at: readyAt });
+
+ const { steps } = await repo.fetchAndLockSteps(1);
+ assert.deepEqual(steps, [
+ {
+ id: stepRow.id,
+ locked_by: assertSingleBatchLock(steps),
+ automation_run_id: run.id,
+ automation_id: automation.id,
+ automation_slug: automation.slug,
+ automation_status: 'active',
+ member_id: run.member_id,
+ member_email: run.member_email,
+ action_id: action.action_id,
+ automation_action_revision_id: action.revision_id,
+ ready_at: new Date(toRepositoryDateISOString(readyAt)),
+ step_attempts: 1,
+ type: 'wait',
+ wait_hours: 48,
+ },
+ ]);
+ });
+
it('locks ready and steps with stale locks, but skips future and recently-locked steps', async function () {
const automation = await getAutomationBySlug('member-welcome-email-free');
const action = await getActionByIndex(automation.id, 0);
diff --git a/ghost/core/test/utils/automations-fixtures.ts b/ghost/core/test/utils/automations-fixtures.ts
index 5204fe12d6d..63dfea85058 100644
--- a/ghost/core/test/utils/automations-fixtures.ts
+++ b/ghost/core/test/utils/automations-fixtures.ts
@@ -57,6 +57,7 @@ export async function setupAutomationsFixture(): Promise {
updated_at: timestamp(0),
slug: MEMBER_WELCOME_EMAIL_SLUGS.free,
name: 'Free member welcome flow',
+ description: 'Welcome new free members after they sign up.',
status: 'active',
},
{
@@ -65,6 +66,7 @@ export async function setupAutomationsFixture(): Promise {
updated_at: timestamp(1),
slug: MEMBER_WELCOME_EMAIL_SLUGS.paid,
name: 'Paid member welcome flow',
+ description: 'Welcome new paid members after they start their subscription.',
status: 'active',
},
];
diff --git a/ghost/core/test/utils/fixtures/fixtures.json b/ghost/core/test/utils/fixtures/fixtures.json
index 3e59a521f73..bcbdffe6cfe 100644
--- a/ghost/core/test/utils/fixtures/fixtures.json
+++ b/ghost/core/test/utils/fixtures/fixtures.json
@@ -796,6 +796,11 @@
"action_type": "read",
"object_type": "automation"
},
+ {
+ "name": "Add automations",
+ "action_type": "add",
+ "object_type": "automation"
+ },
{
"name": "Edit automations",
"action_type": "edit",
@@ -1141,7 +1146,7 @@
"collection": "all",
"recommendation": "all",
"gift_link": "all",
- "automation": ["browse", "read", "edit"],
+ "automation": ["browse", "read", "edit", "add"],
"identity": "read"
},
"DB Backup Integration": {
@@ -1191,7 +1196,7 @@
"mention": "browse",
"collection": "all",
"recommendation": "all",
- "automation": ["browse", "read", "edit"],
+ "automation": ["browse", "read", "edit", "add"],
"gift_link": "manage"
},
"Editor": {
diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts
index aa2ec2af8d9..cb34c44c384 100644
--- a/packages/testing/test-data/src/selectors/editor.ts
+++ b/packages/testing/test-data/src/selectors/editor.ts
@@ -146,6 +146,8 @@ export const mobilePreviewToggle = 'Mobile';
export const previewAsSelectLabel = 'Preview as';
export const previewTierSelectLabel = 'Tier';
export const previewNewsletterSelectLabel = 'Newsletter';
+export const preparingPreviewLabel = 'Preparing preview';
+export const sharePreviewButton = 'Share';
export const copyPreviewLinkButton = 'Copy preview link';
export const openPreviewInNewTabLink = 'Open in new tab';
export const closePreviewButton = 'Close';