Skip to content

Added CTA and product cards to email editor#26705

Merged
EvanHahn merged 5 commits intomainfrom
ny1123-add-more-cards
Mar 4, 2026
Merged

Added CTA and product cards to email editor#26705
EvanHahn merged 5 commits intomainfrom
ny1123-add-more-cards

Conversation

@EvanHahn
Copy link
Copy Markdown
Contributor

@EvanHahn EvanHahn commented Mar 4, 2026

ref https://linear.app/ghost/issue/NY-1123
ref TryGhost/Koenig#1754

Screenshot


Note

Medium Risk
Moderate risk because it changes welcome email HTML/CSS output and editor capabilities, which can affect email client rendering and existing templates. Changes are scoped and covered by new acceptance and unit tests.

Overview
Enables call-to-action and product cards in the Admin-X welcome email editor when the welcomeEmailEditor flag is on, and tweaks editor typography to keep settings-panel copy compact.

Updates welcome email HTML rendering to include styling for CTA/product cards (including responsive adjustments) and centers the main email container for more consistent layout. Adds Playwright acceptance coverage for inserting these cards via the slash menu, and unit coverage to assert their styles are properly inlined during welcome email rendering.

Bumps @tryghost/koenig-lexical to 1.7.19 across Admin packages to pick up the required editor/card support.

Written by Cursor Bugbot for commit f24fda9. This will update automatically on new commits. Configure here.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 4, 2026

Walkthrough

Adds CallToActionPlugin and ProductPlugin to the welcome-email Koenig editor path and a compact settings-panel CSS rule. Bumps koenig-lexical to 1.7.19 in two package.json files. Introduces extensive CTA and product card styles and responsive rules for member welcome emails, sets the wrapper container cell align="center", and injects dividerColor into the renderer data. Adds acceptance tests for inserting CTA/product cards and unit tests asserting rendered CTA/product styles. No exported or public API signatures changed.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title directly describes the main change: adding CTA and product cards to the email editor, which is the primary objective of the PR.
Description check ✅ Passed The description clearly explains the changes: enabling CTA and product cards in the member welcome email editor, including styling updates, tests, and dependency bumps.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ny1123-add-more-cards

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.


table.body .kg-cta-minimal .kg-cta-image-container {
padding-right: 20px;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate CSS selector makes first rule dead code

Low Severity

The selector table.body .kg-cta-minimal .kg-cta-image-container appears twice in the same media query. The first instance (setting padding-right: 20px) is completely overridden by the second instance, which resets all padding with padding: 0 !important and explicitly sets padding-right: 0 !important. The first rule is dead code with no effect. These two blocks could be merged into one.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs (1)

797-799: Merge duplicated mobile rules for .kg-cta-minimal .kg-cta-image-container.

The same selector is declared twice in the same media query; the later block overrides earlier padding, making the first rule effectively redundant.

Consolidation example
-    table.body .kg-cta-minimal .kg-cta-image-container {
-        padding-right: 20px;
-    }
-
     table.body .kg-cta-immersive .kg-cta-image-container {
         padding-bottom: 20px;
     }
@@
     table.body .kg-cta-minimal .kg-cta-image-container {
         display: inline-block !important;
         width: 100% !important;
         padding: 0 !important;
         padding-bottom: 16px !important;
-        padding-right: 0 !important;  /* Reset the desktop padding */
+        padding-right: 0 !important;
     }

Also applies to: 813-819

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs`
around lines 797 - 799, There are duplicate mobile rules for the selector
`.kg-cta-minimal .kg-cta-image-container` inside the media query; merge the
duplicate declarations into a single rule so the intended padding-right value is
preserved and the redundant block removed (ensure the final rule uses the
correct padding-right that you want to keep), and apply the same consolidation
for the other duplicated block range (the second occurrence around the 813-819
region); keep selector specificity and placement inside the same media query so
behavior on mobile remains unchanged.
apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts (1)

314-368: Consider extracting shared modal/editor setup used by both new slash-menu tests.

The two tests repeat the same setup and editor-clearing sequence; a helper would reduce maintenance churn.

Refactor sketch
+        const openWelcomeEmailEditor = async (page: Page) => {
+            await page.goto('/#/memberemails');
+            await page.waitForLoadState('networkidle');
+            const section = page.getByTestId('memberemails');
+            await expect(section).toBeVisible({timeout: 10000});
+            await section.getByTestId('free-welcome-email-preview').click();
+            const modal = page.getByTestId('welcome-email-modal');
+            await expect(modal).toBeVisible();
+            const editor = modal.locator('[data-kg="editor"] div[contenteditable="true"]').first();
+            await editor.click({timeout: 5000});
+            await page.keyboard.press('ControlOrMeta+a');
+            await page.keyboard.press('Backspace');
+            return {modal};
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts`
around lines 314 - 368, Extract the repeated modal/editor setup in the two tests
('welcome email editor inserts call to action card via slash menu' and 'welcome
email editor inserts product card via slash menu') into a shared async helper
(e.g., openWelcomeEmailEditor) that accepts the Playwright Page and performs the
mockApi call (using globalDataRequests, newslettersRequest,
configWithWelcomeEmailEditorEnabled, automatedEmailsFixture), navigates to
'/#/memberemails', waits for networkidle, clicks free-welcome-email-preview,
waits for welcome-email-modal, locates and clears the editor
(modal.locator('[data-kg="editor"] div[contenteditable="true"]').first(),
ControlOrMeta+a, Backspace) and returns the editor locator; then call that
helper from both tests and simply type the slash command and press Enter before
asserting the card (modal.locator('[data-kg-card="..."]')).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js`:
- Around line 545-547: The tests currently assert exact class attribute ordering
(the three assert.match calls) which is brittle; update each regex used in the
assertions for result.html (the lines calling assert.match with class="kg-card
kg-cta-card …", class="kg-cta-sponsor-label"…, and class="kg-product-card"…) to
match presence of the needed class tokens regardless of order — e.g., replace
the hardcoded class-order regex with a pattern using positive lookaheads that
asserts each required class exists in the class attribute (like
class="(?=[^"]*\bkg-card\b)(?=[^"]*\bkg-cta-card\b)[^"]*") or otherwise test for
individual class tokens separately so class order changes won’t break the test.

---

Nitpick comments:
In
`@apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts`:
- Around line 314-368: Extract the repeated modal/editor setup in the two tests
('welcome email editor inserts call to action card via slash menu' and 'welcome
email editor inserts product card via slash menu') into a shared async helper
(e.g., openWelcomeEmailEditor) that accepts the Playwright Page and performs the
mockApi call (using globalDataRequests, newslettersRequest,
configWithWelcomeEmailEditorEnabled, automatedEmailsFixture), navigates to
'/#/memberemails', waits for networkidle, clicks free-welcome-email-preview,
waits for welcome-email-modal, locates and clears the editor
(modal.locator('[data-kg="editor"] div[contenteditable="true"]').first(),
ControlOrMeta+a, Backspace) and returns the editor locator; then call that
helper from both tests and simply type the slash command and press Enter before
asserting the card (modal.locator('[data-kg-card="..."]')).

In
`@ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs`:
- Around line 797-799: There are duplicate mobile rules for the selector
`.kg-cta-minimal .kg-cta-image-container` inside the media query; merge the
duplicate declarations into a single rule so the intended padding-right value is
preserved and the redundant block removed (ensure the final rule uses the
correct padding-right that you want to keep), and apply the same consolidation
for the other duplicated block range (the second occurrence around the 813-819
region); keep selector specificity and placement inside the same media query so
behavior on mobile remains unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9904ed53-c10c-49df-a8b1-3505e89198c6

📥 Commits

Reviewing files that changed from the base of the PR and between 895aa01 and 758325e.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (7)
  • apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx
  • apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts
  • apps/admin/package.json
  • ghost/admin/package.json
  • ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs
  • ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js
  • ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js

Comment on lines +545 to +547
assert.match(result.html, /class="kg-card kg-cta-card kg-cta-bg-none kg-cta-immersive kg-cta-link-accent"[^>]*style="[^"]*border-bottom: 1px solid #e0e7eb/);
assert.match(result.html, /class="kg-cta-sponsor-label"[^>]*style="[^"]*border-bottom: 1px solid #e0e7eb/);
assert.match(result.html, /class="kg-product-card"[^>]*style="[^"]*background-color: rgba\(255, 255, 255, 0.25\)/);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid class-order-dependent HTML assertions in this new test.

The regex at Line 545 hardcodes full class order, which can create false failures when class order changes without behavior changes.

Proposed robustness tweak
-            assert.match(result.html, /class="kg-card kg-cta-card kg-cta-bg-none kg-cta-immersive kg-cta-link-accent"[^>]*style="[^"]*border-bottom: 1px solid `#e0e7eb/`);
+            assert.match(result.html, /class="[^"]*\bkg-cta-card\b[^"]*\bkg-cta-bg-none\b[^"]*\bkg-cta-immersive\b[^"]*\bkg-cta-link-accent\b/);
+            assert.match(result.html, /class="[^"]*\bkg-cta-card\b[^"]*"[^>]*style="[^"]*border-bottom: 1px solid `#e0e7eb/`);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js`
around lines 545 - 547, The tests currently assert exact class attribute
ordering (the three assert.match calls) which is brittle; update each regex used
in the assertions for result.html (the lines calling assert.match with
class="kg-card kg-cta-card …", class="kg-cta-sponsor-label"…, and
class="kg-product-card"…) to match presence of the needed class tokens
regardless of order — e.g., replace the hardcoded class-order regex with a
pattern using positive lookaheads that asserts each required class exists in the
class attribute (like class="(?=[^"]*\bkg-card\b)(?=[^"]*\bkg-cta-card\b)[^"]*")
or otherwise test for individual class tokens separately so class order changes
won’t break the test.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 73.21%. Comparing base (be257fe) to head (f24fda9).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #26705   +/-   ##
=======================================
  Coverage   73.21%   73.21%           
=======================================
  Files        1532     1532           
  Lines      120525   120525           
  Branches    14576    14576           
=======================================
  Hits        88248    88248           
  Misses      31256    31256           
  Partials     1021     1021           
Flag Coverage Δ
admin-tests 53.89% <ø> (ø)
e2e-tests 73.21% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@EvanHahn EvanHahn requested a review from 9larsons March 4, 2026 19:59
@EvanHahn EvanHahn added the ok to merge for me You can merge this on my behalf if you want. label Mar 4, 2026
Copy link
Copy Markdown
Contributor

@9larsons 9larsons left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patched a couple minor things re: styles. LGTM!

@EvanHahn EvanHahn enabled auto-merge (squash) March 4, 2026 20:59
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicate dividerColor property in template context object
    • Removed the second duplicate dividerColor entry from the template context object to eliminate redundant and potentially confusing code.

Create PR

Or push these changes by commenting:

@cursor push 758969e927
Preview (758969e927)
diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx
--- a/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx
+++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx
@@ -69,6 +69,8 @@
         '[&_:is(hr)]:pt-0',
         // Paragraph spacing & bold
         '[&_p]:mb-4 [&_strong]:font-semibold',
+        // Keep settings panel copy compact
+        '[&_[data-kg-settings-panel]_p]:!mb-0',
         // Nested-editor (callout, etc.) fixes: align placeholder with text
         // 1. Override placeholder font/size/line-height to match the <p> styles
         '[&_.not-kg-prose>div]:!font-inter [&_.not-kg-prose>div]:!tracking-tight [&_.not-kg-prose>div]:!text-xl [&_.not-kg-prose>div]:!leading-[1.6]',
@@ -136,12 +138,14 @@
                                 <koenig.BookmarkPlugin />
                                 <koenig.ButtonPlugin />
                                 <koenig.CalloutPlugin />
+                                <koenig.CallToActionPlugin />
                                 <koenig.CardMenuPlugin />
                                 <koenig.EmailCtaPlugin />
                                 <koenig.EmbedPlugin />
                                 <koenig.HtmlPlugin />
                                 <koenig.ImagePlugin />
                                 <koenig.KoenigSelectorPlugin />
+                                <koenig.ProductPlugin />
                             </>
                         )}
 

diff --git a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts
--- a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts
+++ b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts
@@ -311,6 +311,62 @@
             await expect.poll(() => lastApiRequests.fetchOembed?.url || '').toContain('type=bookmark');
         });
 
+        test('welcome email editor inserts call to action card via slash menu', async ({page}) => {
+            await mockApi({page, requests: {
+                ...globalDataRequests,
+                ...newslettersRequest,
+                browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
+                browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}
+            }});
+
+            await page.goto('/#/memberemails');
+            await page.waitForLoadState('networkidle');
+
+            const section = page.getByTestId('memberemails');
+            await expect(section).toBeVisible({timeout: 10000});
+            await section.getByTestId('free-welcome-email-preview').click();
+
+            const modal = page.getByTestId('welcome-email-modal');
+            await expect(modal).toBeVisible();
+
+            const editor = modal.locator('[data-kg="editor"] div[contenteditable="true"]').first();
+            await editor.click({timeout: 5000});
+            await page.keyboard.press('ControlOrMeta+a');
+            await page.keyboard.press('Backspace');
+            await page.keyboard.type('/call-to-action');
+            await page.keyboard.press('Enter');
+
+            await expect(modal.locator('[data-kg-card="call-to-action"]')).toBeVisible();
+        });
+
+        test('welcome email editor inserts product card via slash menu', async ({page}) => {
+            await mockApi({page, requests: {
+                ...globalDataRequests,
+                ...newslettersRequest,
+                browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
+                browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}
+            }});
+
+            await page.goto('/#/memberemails');
+            await page.waitForLoadState('networkidle');
+
+            const section = page.getByTestId('memberemails');
+            await expect(section).toBeVisible({timeout: 10000});
+            await section.getByTestId('free-welcome-email-preview').click();
+
+            const modal = page.getByTestId('welcome-email-modal');
+            await expect(modal).toBeVisible();
+
+            const editor = modal.locator('[data-kg="editor"] div[contenteditable="true"]').first();
+            await editor.click({timeout: 5000});
+            await page.keyboard.press('ControlOrMeta+a');
+            await page.keyboard.press('Backspace');
+            await page.keyboard.type('/product');
+            await page.keyboard.press('Enter');
+
+            await expect(modal.locator('[data-kg-card="product"]')).toBeVisible();
+        });
+
         test('uses automated email sender fields when populated, even if newsletter differs', async ({page}) => {
             const populatedAutomatedEmailsFixture = {
                 automated_emails: [{

diff --git a/apps/admin/package.json b/apps/admin/package.json
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -15,7 +15,7 @@
     "@tryghost/activitypub": "*",
     "@tryghost/admin-x-framework": "*",
     "@tryghost/admin-x-settings": "*",
-    "@tryghost/koenig-lexical": "1.7.18",
+    "@tryghost/koenig-lexical": "1.7.19",
     "@tryghost/posts": "*",
     "@tryghost/shade": "*",
     "@tryghost/stats": "*",

diff --git a/ghost/admin/package.json b/ghost/admin/package.json
--- a/ghost/admin/package.json
+++ b/ghost/admin/package.json
@@ -50,7 +50,7 @@
     "@tryghost/helpers": "1.1.97",
     "@tryghost/kg-clean-basic-html": "4.2.18",
     "@tryghost/kg-converters": "1.1.18",
-    "@tryghost/koenig-lexical": "1.7.18",
+    "@tryghost/koenig-lexical": "1.7.19",
     "@tryghost/limit-service": "1.4.1",
     "@tryghost/members-csv": "2.0.3",
     "@tryghost/nql": "0.12.10",
@@ -228,4 +228,4 @@
       }
     }
   }
-}
\ No newline at end of file
+}

diff --git a/ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs b/ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs
--- a/ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs
+++ b/ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs
@@ -305,6 +305,304 @@
     font-size: 20px;
 }
 
+.kg-cta-card {
+    margin: 0 0 1.5em 0;
+    padding: 0 24px;
+    {{#if hasRoundedImageCorners}}
+        border-radius: 6px;
+    {{else}}
+        border-radius: 0;
+    {{/if}}
+}
+
+.kg-cta-card + * {
+    margin-top: 1.5em !important;
+}
+
+.kg-cta-card + hr {
+    margin-top: 3em !important;
+}
+
+.kg-cta-card.kg-cta-bg-none {
+    padding: 0;
+    border-radius: 0;
+}
+
+.kg-cta-card.kg-cta-bg-none:not(.kg-cta-no-dividers) {
+    border-bottom: 1px solid {{dividerColor}};
+}
+
+.kg-cta-bg-none.kg-cta-no-label:not(.kg-cta-no-dividers) {
+    border-top: 1px solid {{dividerColor}};
+}
+
+.kg-cta-bg-white {
+    {{#if backgroundIsDark}}
+        background-color: #15212A;
+        background-color: rgba(0, 0, 0, 0.15);
+        border: 1px solid #343434;
+        border: 1px solid rgba(255, 255, 255, 0.25);
+    {{else}}
+        background-color: #ffffff;
+        background-color: rgba(255, 255, 255, 0.25);
+        border: 1px solid #e0e7eb;
+        border: 1px solid rgba(0, 0, 0, 0.12);
+    {{/if}}
+}
+
+.kg-cta-bg-grey {
+    background: #f1f2f4;
+}
+
+.kg-cta-bg-blue {
+    background: #E9F6FB;
+}
+
+.kg-cta-bg-green {
+    background: #E8F8EA;
+}
+
+.kg-cta-bg-yellow {
+    background: #FCF4E3;
+}
+
+.kg-cta-bg-red {
+    background: #FBE9E9;
+}
+
+.kg-cta-bg-pink {
+    background: #FCEEF8;
+}
+
+.kg-cta-bg-purple {
+    background: #F2EDFC;
+}
+
+.kg-cta-sponsor-label {
+    padding: 12px 0;
+    border-bottom: 1px solid {{dividerColor}};
+}
+
+.kg-cta-bg-none .kg-cta-sponsor-label {
+    padding-top: 0;
+}
+
+.kg-cta-immersive.kg-cta-has-img:not(.kg-cta-bg-none) .kg-cta-sponsor-label,
+.kg-cta-bg-none.kg-cta-no-dividers .kg-cta-sponsor-label {
+    border-bottom: 0;
+}
+
+.kg-cta-bg-none .kg-cta-sponsor-label {
+    border-color: {{dividerColor}};
+}
+
+.kg-cta-bg-white .kg-cta-sponsor-label {
+    {{#if backgroundIsDark}}
+    border-color: rgba(255, 255, 255, 0.25);
+    {{else}}
+    border-color: rgba(0, 0, 0, 0.12);
+    {{/if}}
+}
+
+.kg-cta-bg-grey .kg-cta-sponsor-label {
+    border-color: #d0d1d2;
+}
+
+.kg-cta-bg-blue .kg-cta-sponsor-label {
+    border-color: #cddee5;
+}
+
+.kg-cta-bg-green .kg-cta-sponsor-label {
+    border-color: #cce0ce;
+}
+
+.kg-cta-bg-yellow .kg-cta-sponsor-label {
+    border-color: #e3d9c4;
+}
+
+.kg-cta-bg-red .kg-cta-sponsor-label {
+    border-color: #e7d0d0;
+}
+
+.kg-cta-bg-pink .kg-cta-sponsor-label {
+    border-color: #e6d6e1;
+}
+
+.kg-cta-bg-purple .kg-cta-sponsor-label {
+    border-color: #dbd5e7;
+}
+
+.kg-cta-sponsor-label p {
+    margin: 0;
+    color: #89959f;
+    font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
+    font-size: 12px;
+    font-weight: 500;
+    text-transform: uppercase;
+}
+
+.kg-cta-bg-blue .kg-cta-sponsor-label p {
+    color: #97A0A3;
+}
+
+.kg-cta-bg-green .kg-cta-sponsor-label p {
+    color: #97A198;
+}
+
+.kg-cta-bg-yellow .kg-cta-sponsor-label p {
+    color: #A49F94;
+}
+
+.kg-cta-bg-red .kg-cta-sponsor-label p {
+    color: #A39797;
+}
+
+.kg-cta-bg-pink .kg-cta-sponsor-label p {
+    color: #A49BA1;
+}
+
+.kg-cta-bg-purple .kg-cta-sponsor-label p {
+    color: #9D9AA4;
+}
+
+.kg-cta-sponsor-label a {
+    color: #15212A;
+}
+
+.kg-cta-link-accent .kg-cta-sponsor-label a {
+    color: {{accentColor}} !important;
+}
+
+table.kg-cta-content-wrapper:not(.kg-cta-bg-none.kg-cta-no-dividers table.kg-cta-content-wrapper) {
+    padding: 24px 0 26px;
+}
+
+.kg-cta-immersive.kg-cta-has-img:not(.kg-cta-bg-none):not(.kg-cta-no-label) table.kg-cta-content-wrapper {
+    padding-top: 0;
+}
+
+.kg-cta-minimal .kg-cta-image-container {
+    padding-right: 24px;
+}
+
+.kg-cta-immersive .kg-cta-image-container {
+    padding-bottom: 24px;
+}
+
+.kg-cta-immersive.kg-cta-no-text .kg-cta-image-container {
+    padding-bottom: 0;
+}
+
+img.kg-cta-image {
+    display: block;
+    border: 0;
+    {{#if hasRoundedImageCorners}}
+        border-radius: 4px;
+    {{else}}
+        border-radius: 0;
+    {{/if}}
+}
+
+.kg-cta-bg-none img.kg-cta-image {
+    {{#if hasRoundedImageCorners}}
+        border-radius: 6px;
+    {{else}}
+        border-radius: 0;
+    {{/if}}
+}
+
+.kg-cta-minimal img.kg-cta-image {
+    width: 64px;
+    height: 64px;
+    object-fit: cover;
+}
+
+.kg-cta-immersive img.kg-cta-image {
+    margin: 0 auto;
+    height: auto;
+    width: 100%;
+    max-width: 100%;
+}
+
+.post-content .kg-cta-text {
+    font-family: Georgia, serif;
+}
+
+.kg-cta-text a:not(.kg-cta-link-accent .kg-cta-text a) {
+    color: #15212A;
+    text-decoration: underline;
+}
+
+.kg-cta-link-accent .kg-cta-text a {
+    color: {{accentColor}} !important;
+}
+
+.post-content-sans-serif .kg-cta-text {
+    font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
+    font-size: 17px;
+}
+
+.kg-cta-text p {
+    margin-bottom: 1em;
+    line-height: 1.4em;
+}
+
+.kg-cta-text p:last-child {
+    margin-bottom: 0;
+}
+
+.kg-cta-bg-none:not(.kg-cta-minimal.kg-cta-has-img) .kg-cta-text p {
+    line-height: 1.6em;
+}
+
+.kg-cta-immersive.kg-cta-centered .kg-cta-text p {
+    text-align: center;
+}
+
+.kg-cta-button-container {
+    padding-top: 20px;
+}
+
+.kg-cta-immersive.kg-cta-centered table.btn {
+    margin-left: auto;
+    margin-right: auto;
+}
+
+.kg-cta-minimal table.btn td.kg-style-accent {
+    {{#if hasOutlineButtons}}
+    background-color: transparent !important;
+    color: {{accentColor}} !important;
+    border: 1px solid {{accentColor}} !important;
+    {{else}}
+    background-color: {{accentColor}} !important;
+    {{/if}}
+}
+
+.kg-cta-immersive table.btn td.kg-style-accent {
+    {{#if hasOutlineButtons}}
+    background-color: transparent !important;
+    color: {{accentColor}} !important;
+    border: 1px solid {{accentColor}} !important;
+    {{else}}
+    background-color: {{accentColor}} !important;
+    color: #fff !important;
+    {{/if}}
+}
+
+.kg-cta-card .btn a.kg-style-accent {
+    {{#if hasOutlineButtons}}
+    background-color: transparent !important;
+    color: {{accentColor}} !important;
+    {{else}}
+    background-color: {{accentColor}} !important;
+    color: #fff !important;
+    {{/if}}
+}
+
+.kg-cta-has-img.kg-cta-immersive table.btn {
+    width: 100%;
+}
+
 /* -------------------------------------
     BUTTONS
 ------------------------------------- */
@@ -367,6 +665,102 @@
     {{/if}}
 }
 
+.kg-product-card {
+    margin: 0 0 1.5em;
+    {{#if hasRoundedImageCorners}}
+        border-radius: 6px;
+    {{else}}
+        border-radius: 0;
+    {{/if}}
+    {{#if backgroundIsDark}}
+        background-color: #15212A;
+        background-color: rgba(0, 0, 0, 0.15);
+        border: 1px solid #343434;
+        border: 1px solid rgba(255, 255, 255, 0.25);
+    {{else}}
+        background-color: #FFFFFF;
+        background-color: rgba(255, 255, 255, 0.25);
+        border: 1px solid #e0e7eb;
+        border: 1px solid rgba(0, 0, 0, 0.12);
+    {{/if}}
+}
+
+.kg-product-card h4 {
+    {{#if sectionTitleColor}}
+        color: {{sectionTitleColor}} !important;
+    {{else}}
+        {{#if backgroundIsDark}}
+            color: #ffffff !important;
+        {{else}}
+            color: #15212A !important;
+        {{/if}}
+    {{/if}}
+}
+
+.kg-product-card-container {
+    padding: 20px;
+}
+
+.kg-product-image {
+    margin-bottom: 0;
+    padding-top: 0;
+    padding-bottom: 16px;
+}
+
+.kg-product-image img {
+    width: 100%;
+    height: auto;
+    padding: 0;
+    border: none;
+    {{#if hasRoundedImageCorners}}
+        border-radius: 4px;
+    {{/if}}
+}
+
+.kg-product-title {
+    margin-top: 0 !important;
+    margin-bottom: 0 !important;
+    font-size: 22px !important;
+    font-weight: {{titleWeight}};
+}
+
+.kg-product-rating {
+    margin-bottom: 0;
+    padding-top: 0;
+    padding-bottom: 0;
+}
+
+.kg-product-rating img {
+    width: 96px;
+}
+
+.kg-product-description-wrapper {
+    margin-bottom: 0;
+    padding-top: 8px;
+    padding-bottom: 0;
+}
+
+.kg-product-description-wrapper p {
+    margin: 0;
+    font-size: 17px;
+    line-height: 1.4;
+    opacity: 0.7;
+}
+
+.kg-product-description-wrapper li {
+    opacity: 0.7;
+}
+
+.kg-product-button-wrapper {
+    margin-top: 0;
+    padding-top: 16px;
+    padding-bottom: 0;
+}
+
+.kg-product-button-wrapper table.btn {
+    width: 100%;
+}
+
 @media only screen and (max-width: 620px) {
     table.body .kg-bookmark-card {
         width: 90vw;
@@ -380,6 +774,61 @@
         font-size: 13px !important;
     }
 
+    table.body .kg-cta-card {
+        padding: 0 20px;
+    }
+
+    table.body .kg-cta-card.kg-cta-bg-none {
+        padding: 0;
+    }
+
+    table.body .kg-cta-sponsor-label {
+        padding: 10px 0;
+    }
+
+    table.body table.kg-cta-content-wrapper:not(.kg-cta-bg-none.kg-cta-no-dividers table.kg-cta-content-wrapper) {
+        padding: 20px 0;
+    }
+
+    table.body .kg-cta-immersive.kg-cta-has-img:not(.kg-cta-bg-none):not(.kg-cta-no-label) table.kg-cta-content-wrapper {
+        padding-top: 0;
+    }
+
+    table.body .kg-cta-minimal .kg-cta-image-container {
+        padding-right: 20px;
+    }
+
+    table.body .kg-cta-immersive .kg-cta-image-container {
+        padding-bottom: 20px;
+    }
+
+    table.body .kg-cta-immersive.kg-cta-no-text .kg-cta-image-container {
+        padding-bottom: 0;
+    }
+
+    table.body .kg-cta-button-container {
+        padding-top: 16px;
+    }
+
+    table.body .kg-cta-minimal .kg-cta-image-container {
+        display: inline-block !important;
+        width: 100% !important;
+        padding: 0 !important;
+        padding-bottom: 16px !important;
+        padding-right: 0 !important;  /* Reset the desktop padding */
+    }
+
+    table.body .kg-cta-minimal .kg-cta-content-inner {
+        display: inline-block !important;
+        width: 100% !important;
+        padding: 0 !important;
+    }
+
+    table.body .kg-cta-minimal img.kg-cta-image {
+        width: 52px !important;
+        height: 52px !important;
+    }
+
     table.body .kg-embed-card {
         max-width: 90vw !important;
     }

diff --git a/ghost/core/core/server/services/member-welcome-emails/email-templates/wrapper.hbs b/ghost/core/core/server/services/member-welcome-emails/email-templates/wrapper.hbs
--- a/ghost/core/core/server/services/member-welcome-emails/email-templates/wrapper.hbs
+++ b/ghost/core/core/server/services/member-welcome-emails/email-templates/wrapper.hbs
@@ -319,7 +319,7 @@
     <table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
       <tr>
         <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
-        <td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
+        <td class="container" align="center" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
           <div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
             <table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
               <tr>

diff --git a/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js b/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js
--- a/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js
+++ b/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js
@@ -486,6 +486,71 @@
             assert(result.html.includes('Embed note'));
         });
 
+        it('applies call-to-action and product card styles', async function () {
+            lexicalRenderStub.resolves(`
+                <table class="kg-card kg-cta-card kg-cta-bg-none kg-cta-immersive kg-cta-link-accent" border="0" cellpadding="0" cellspacing="0" width="100%">
+                    <tr>
+                        <td class="kg-cta-sponsor-label"><p><a href="https://example.com/sponsor">Sponsor</a></p></td>
+                    </tr>
+                    <tr>
+                        <td class="kg-cta-content">
+                            <table border="0" cellpadding="0" cellspacing="0" width="100%" class="kg-cta-content-wrapper">
+                                <tr>
+                                    <td class="kg-cta-text"><p>CTA body with <a href="https://example.com/cta">link</a></p></td>
+                                </tr>
+                            </table>
+                        </td>
+                    </tr>
+                </table>
+                <table class="kg-product-card" cellspacing="0" cellpadding="0" border="0">
+                    <tr>
+                        <td class="kg-product-card-container">
+                            <table cellspacing="0" cellpadding="0" border="0">
+                                <tr>
+                                    <td class="kg-product-image" align="center">
+                                        <img src="https://example.com/product.jpg" border="0"/>
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <td valign="top">
+                                        <h4 class="kg-product-title">Product title</h4>
+                                    </td>
+                                </tr>
+                                <tr>
+                                    <td class="kg-product-description-wrapper"><p>Product description</p></td>
+                                </tr>
+                                <tr>
+                                    <td class="kg-product-button-wrapper">
+                                        <table class="btn" border="0" cellspacing="0" cellpadding="0">
+                                            <tr>
+                                                <td align="center"><a href="https://example.com/buy">Buy now</a></td>
+                                            </tr>
+                                        </table>
+                                    </td>
+                                </tr>
+                            </table>
+                        </td>
+                    </tr>
+                </table>
+            `);
+            const renderer = new MemberWelcomeEmailRenderer({t: key => key});
+
+            const result = await renderer.render({
+                lexical: '{}',
+                subject: 'Welcome!',
+                member: {name: 'John', email: 'john@example.com'},
+                siteSettings: defaultSiteSettings
+            });
+
+            assert.match(result.html, /class="kg-card kg-cta-card kg-cta-bg-none kg-cta-immersive kg-cta-link-accent"[^>]*style="[^"]*border-bottom: 1px solid #e0e7eb/);
+            assert.match(result.html, /class="kg-cta-sponsor-label"[^>]*style="[^"]*border-bottom: 1px solid #e0e7eb/);
+            assert.match(result.html, /class="kg-product-card"[^>]*style="[^"]*background-color: rgba\(255, 255, 255, 0.25\)/);
+
+            const productButtonTableMatch = result.html.match(/class="kg-product-button-wrapper"[\s\S]*?<table[^>]*class="btn"[^>]*style="([^"]*)"/);
+            assert(productButtonTableMatch, 'product button table should have inline styles');
+            assert(productButtonTableMatch[1].includes('width: 100%'), 'product button table should have width: 100%');
+        });
+
         it('does not inline margin 0 auto on button tables that would override alignment', async function () {
             lexicalRenderStub.resolves(`
                 <table class="kg-card kg-button-card" border="0" cellpadding="0" cellspacing="0">

diff --git a/yarn.lock b/yarn.lock
--- a/yarn.lock
+++ b/yarn.lock
@@ -9245,10 +9245,10 @@
   dependencies:
     semver "^7.7.3"
 
-"@tryghost/koenig-lexical@1.7.18":
-  version "1.7.18"
-  resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.7.18.tgz#563d498db96cad7cf01f55d41ea6a172bf7c87f6"
-  integrity sha512-ljbYfv3PduBT1gJDjqwQyGFHWRvG9hhXhuwRXrpQmzO7YOP23WMuofW45EKOGHxA66ArUSPYQOxTNs4KGCCDYQ==
+"@tryghost/koenig-lexical@1.7.19":
+  version "1.7.19"
+  resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.7.19.tgz#a20d3505cc0b81d15c7d60e62df3fc6db628791f"
+  integrity sha512-6jWUjtY0tNR8akj1F46XAZtrce7wBMaQHhEgCoxd59ktQoVe4Qfi1TkLetVyCYVnruIgEdeEnQwtRktAMO0Q0Q==
 
 "@tryghost/limit-service@1.4.1":
   version "1.4.1"
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

EvanHahn and others added 3 commits March 4, 2026 15:05
ref https://linear.app/ghost/issue/NY-1123
The newsletter email template has align="center" on its container td
but the welcome email wrapper was missing it, causing cards like product
and CTA to not be properly centered in the rendered email.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js (1)

137-142: Remove duplicate dividerColor key.

Lines 137 and 141 both define dividerColor: '#e0e7eb'. While the duplicate currently resolves to the same value, duplicate object keys create unnecessary confusion and can mask future mistakes.

Remove the duplicate
         const html = this.#wrapperTemplate({
             content: contentWithAbsoluteLinks,
             subject: subjectWithReplacements,
             siteTitle: siteSettings.title,
             siteUrl: siteSettings.url,
             accentColor,
             accentContrastColor,
             dividerColor: '#e0e7eb',
             backgroundIsDark: false,
             hasRoundedImageCorners: false,
             sectionTitleColor: null,
-            dividerColor: '#e0e7eb',
             titleWeight: '700',
             hasOutlineButtons: false,
             buttonColor: accentColor,
             buttonTextColor: accentContrastColor,
             buttonBorderRadius: '6px',
             managePreferencesUrl,
             year
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js`
around lines 137 - 142, Remove the duplicate dividerColor property from the
object literal in member-welcome-email-renderer.js so the object only contains
one dividerColor: '#e0e7eb'; locate the object that includes properties
dividerColor, backgroundIsDark, hasRoundedImageCorners, sectionTitleColor,
titleWeight and delete the redundant dividerColor entry (leave the first
occurrence intact) to avoid duplicate keys and potential future confusion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js`:
- Around line 137-142: Remove the duplicate dividerColor property from the
object literal in member-welcome-email-renderer.js so the object only contains
one dividerColor: '#e0e7eb'; locate the object that includes properties
dividerColor, backgroundIsDark, hasRoundedImageCorners, sectionTitleColor,
titleWeight and delete the redundant dividerColor entry (leave the first
occurrence intact) to avoid duplicate keys and potential future confusion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a71d3fad-61f0-4278-88a5-533fe8a5916c

📥 Commits

Reviewing files that changed from the base of the PR and between e81bb765b3ef53d6cfcf978db45c6306b56257ac and ce56c89d0c15cff0202ad321e5111e47bdc401ac.

📒 Files selected for processing (3)
  • apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx
  • ghost/core/core/server/services/member-welcome-emails/email-templates/wrapper.hbs
  • ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx
  • ghost/core/core/server/services/member-welcome-emails/email-templates/wrapper.hbs

@EvanHahn EvanHahn force-pushed the ny1123-add-more-cards branch from ce56c89 to e2ad964 Compare March 4, 2026 21:05
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js (1)

137-141: ⚠️ Potential issue | 🟡 Minor

Remove the duplicated dividerColor key in the template payload.

Line 137 and Line 141 both define dividerColor; the latter silently overwrites the former. It’s harmless now (same value) but easy to drift later.

Suggested fix
         const html = this.#wrapperTemplate({
             content: contentWithAbsoluteLinks,
             subject: subjectWithReplacements,
             siteTitle: siteSettings.title,
             siteUrl: siteSettings.url,
             accentColor,
             accentContrastColor,
             dividerColor: '#e0e7eb',
             backgroundIsDark: false,
             hasRoundedImageCorners: false,
             sectionTitleColor: null,
-            dividerColor: '#e0e7eb',
             titleWeight: '700',
             hasOutlineButtons: false,
             buttonColor: accentColor,
             buttonTextColor: accentContrastColor,
             buttonBorderRadius: '6px',
             managePreferencesUrl,
             year
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js`
around lines 137 - 141, The template payload in member-welcome-email-renderer.js
contains a duplicated key "dividerColor" (same value) which silently overwrites
the first; remove the duplicate so the payload object only defines
"dividerColor" once. Locate the payload object built in the
member-welcome-email-renderer (e.g., the function that constructs the email
template payload / renderMemberWelcomeEmail) and delete the redundant
dividerColor entry (keep the single, correct definition) to prevent future
drift.
🧹 Nitpick comments (1)
apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts (1)

336-337: Consider reducing coupling to slash command trigger strings, which lack documented stability.

The slash trigger strings (/call-to-action, /product) are internal implementation details of @tryghost/koenig-lexical with no published API contract. The package provides no stable public API documentation, and the SlashCardMenuPlugin is subject to change across versions. While tests currently validate the correct outcome via the data-kg-card attribute (a stable internal identifier), a more resilient approach would interact with the slash menu directly—opening the menu and selecting items by their visible label rather than typed trigger strings. This would decouple the tests from internal command identifiers while still verifying end-to-end functionality.

Also applies to: 364-365

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts`
around lines 336 - 337, Tests currently type hardcoded slash trigger strings
like '/call-to-action' and '/product' (the page.keyboard.type calls) which are
unstable; update the test to open the slash menu UI and select the desired item
by its visible label instead of typing the command: simulate opening the slash
menu (e.g., send the UI action that shows the SlashCardMenuPlugin or click the
slash/menu button), find and click the menu entry whose text is "Call to action"
or "Product" to insert the card, then assert the resulting node via data-kg-card
as before; replace the page.keyboard.type('/call-to-action') and
page.keyboard.type('/product') occurrences (also at the analogous lines
referenced) with this UI-driven selection flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs`:
- Around line 476-477: The rule using a complex :not() selector (e.g.,
table.kg-cta-content-wrapper:not(.kg-cta-bg-none.kg-cta-no-dividers
table.kg-cta-content-wrapper)) must be replaced with an email-safe pattern: add
a base rule for table.kg-cta-content-wrapper with the intended padding and then
add explicit override rules that target the excluded combinations directly (for
example selectors that match .kg-cta-bg-none .kg-cta-no-dividers
table.kg-cta-content-wrapper or the exact class combinations to reset/remove
that padding), repeating this replacement for the other occurrences that use
:not() (the similar selectors later in the file); use simple class/tag selectors
with direct descendant/compound classes to ensure clients like Gmail/Outlook
apply the correct styles.

---

Outside diff comments:
In
`@ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js`:
- Around line 137-141: The template payload in member-welcome-email-renderer.js
contains a duplicated key "dividerColor" (same value) which silently overwrites
the first; remove the duplicate so the payload object only defines
"dividerColor" once. Locate the payload object built in the
member-welcome-email-renderer (e.g., the function that constructs the email
template payload / renderMemberWelcomeEmail) and delete the redundant
dividerColor entry (keep the single, correct definition) to prevent future
drift.

---

Nitpick comments:
In
`@apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts`:
- Around line 336-337: Tests currently type hardcoded slash trigger strings like
'/call-to-action' and '/product' (the page.keyboard.type calls) which are
unstable; update the test to open the slash menu UI and select the desired item
by its visible label instead of typing the command: simulate opening the slash
menu (e.g., send the UI action that shows the SlashCardMenuPlugin or click the
slash/menu button), find and click the menu entry whose text is "Call to action"
or "Product" to insert the card, then assert the resulting node via data-kg-card
as before; replace the page.keyboard.type('/call-to-action') and
page.keyboard.type('/product') occurrences (also at the analogous lines
referenced) with this UI-driven selection flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e1e33097-4382-40ff-bb69-fa8221c2f0cb

📥 Commits

Reviewing files that changed from the base of the PR and between ce56c89d0c15cff0202ad321e5111e47bdc401ac and e2ad964.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (8)
  • apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx
  • apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts
  • apps/admin/package.json
  • ghost/admin/package.json
  • ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs
  • ghost/core/core/server/services/member-welcome-emails/email-templates/wrapper.hbs
  • ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js
  • ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • ghost/admin/package.json
  • apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx
  • ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js
  • ghost/core/core/server/services/member-welcome-emails/email-templates/wrapper.hbs

Comment on lines +476 to +477
table.kg-cta-content-wrapper:not(.kg-cta-bg-none.kg-cta-no-dividers table.kg-cta-content-wrapper) {
padding: 24px 0 26px;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and examine the specific line ranges mentioned
cat -n ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs | sed -n '470,490p'

Repository: TryGhost/Ghost

Length of output: 696


🏁 Script executed:

# Also check the other line ranges mentioned
cat -n ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs | sed -n '525,540p'

Repository: TryGhost/Ghost

Length of output: 468


🏁 Script executed:

# And the third range
cat -n ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs | sed -n '785,795p'

Repository: TryGhost/Ghost

Length of output: 504


🌐 Web query:

CSS :not() selector with descendant combinator support email clients Gmail Outlook Apple Mail

💡 Result:

TL;DR (HTML email)

  • Gmail (web + apps): No reliable support for :not() (and most pseudo-classes). Expect rules like a:not(.x) or :not(.a .b) to be ignored. Gmail’s own guide documents only element / class / id selectors (not pseudo-classes). [2], [3]
  • Outlook desktop (Windows, Word rendering engine): No :not() (and very limited selector support overall). [5]
  • Apple Mail (macOS/iOS): :not() works, and because it uses WebKit, it also supports the modern Selectors Level 4 behavior needed for more complex :not(...) arguments (browser-side support is broad in Safari/WebKit). [1], [6]

About “descendant combinator inside :not()

Using a complex selector inside :not() (e.g. :not(.a .b)) is a Selectors Level 4 capability; it’s widely supported in modern browsers (including Safari/WebKit). [6]
But in email clients, this is effectively Apple Mail-only in practice, because Gmail and Outlook desktop won’t apply :not() rules reliably (or at all). [1], [2], [3], [5]

Practical recommendation

For cross-client email, don’t depend on :not() (especially not complex arguments). Prefer:

  • explicit classes you add server-side (e.g. .is-foo / .not-foo)
  • duplicated simpler selectors (or restructure markup) instead of “negation” logic.

🌐 Web query:

Juice CSS inliner complex :not() selector support email rendering

💡 Result:

Juice can handle “complex” :not() selectors for inlining, because it parses/tokenizes selectors with Slick (the MooTools selector engine) [1]. Slick’s :not implementation is explicitly described as more advanced than the standard, and it even documents examples like :not(ul li) (a descendant/“complex” selector inside :not()) [3]. Current Juice releases depend on slick and cheerio accordingly [2].

For email rendering, the key distinction is:

  • If Juice successfully inlines the rule, the email client no longer needs to understand the selector—because the result is an inline style="".
  • If you don’t inline (or you deliberately keep CSS in <style>—e.g., via Juice’s data-embed behavior [1]), then email-client support for :not() matters, and it varies by client; resources like Campaign Monitor track that support [4].

Sources:
[1] Juice README (unpkg) – notes it uses Slick to tokenize selectors + data-embed behavior
[2] Juice package.json – shows dependency on slick and cheerio
[3] MooTools Slick documentation – :not is a superset; example :not(ul li)
[4] Campaign Monitor CSS selector support page for E:not(s)


Replace complex :not() selectors with email-safe override rules.

The new selectors use complex :not(...) patterns with descendant combinators inside (e.g., :not(.class .descendant)) that are not reliably supported by major email clients. Gmail and Outlook Desktop do not support :not() and will ignore these rules entirely, causing CTA wrapper spacing and link color rules to fail in those clients. While CSS inliners like Juice can parse complex :not() syntax, if these rules remain in <style> blocks or if inlining is bypassed, the selectors will be dropped by unsupported clients. The safer pattern—base rules with explicit overrides—ensures consistent fallback behavior across email clients.

Suggested safer rewrite
-table.kg-cta-content-wrapper:not(.kg-cta-bg-none.kg-cta-no-dividers table.kg-cta-content-wrapper) {
+table.kg-cta-content-wrapper {
     padding: 24px 0 26px;
 }
+
+.kg-cta-bg-none.kg-cta-no-dividers table.kg-cta-content-wrapper {
+    padding: 0;
+}
@@
-.kg-cta-text a:not(.kg-cta-link-accent .kg-cta-text a) {
+.kg-cta-text a {
     color: `#15212A`;
     text-decoration: underline;
 }
@@
-    table.body table.kg-cta-content-wrapper:not(.kg-cta-bg-none.kg-cta-no-dividers table.kg-cta-content-wrapper) {
+    table.body table.kg-cta-content-wrapper {
         padding: 20px 0;
     }
+
+    table.body .kg-cta-bg-none.kg-cta-no-dividers table.kg-cta-content-wrapper {
+        padding: 0;
+    }

Also applies to: 531-533, 789-790

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ghost/core/core/server/services/member-welcome-emails/email-templates/partials/card-styles.hbs`
around lines 476 - 477, The rule using a complex :not() selector (e.g.,
table.kg-cta-content-wrapper:not(.kg-cta-bg-none.kg-cta-no-dividers
table.kg-cta-content-wrapper)) must be replaced with an email-safe pattern: add
a base rule for table.kg-cta-content-wrapper with the intended padding and then
add explicit override rules that target the excluded combinations directly (for
example selectors that match .kg-cta-bg-none .kg-cta-no-dividers
table.kg-cta-content-wrapper or the exact class combinations to reset/remove
that padding), repeating this replacement for the other occurrences that use
:not() (the similar selectors later in the file); use simple class/tag selectors
with direct descendant/compound classes to ensure clients like Gmail/Outlook
apply the correct styles.

@EvanHahn EvanHahn merged commit 3a91b98 into main Mar 4, 2026
35 checks passed
@EvanHahn EvanHahn deleted the ny1123-add-more-cards branch March 4, 2026 21:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok to merge for me You can merge this on my behalf if you want.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants