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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,18 @@ const features: Feature[] = [{
title: 'Domain Warmup',
description: 'Enable custom sending domain warmup for gradual email volume increases',
flag: 'domainWarmup'
},
{
},{
title: 'Updated theme translation (beta)',
description: 'Enable theme translation using i18next instead of the old translation package.',
flag: 'themeTranslation'
}, {
title: 'Comment Moderation',
description: 'Enhanced comment moderation interface with advanced filtering and management. Requires the new admin experience.',
flag: 'commentModeration'
}, {
title: 'Email Size Warnings',
description: 'Enable warnings in editor when content exceeds email cut-off size',
flag: 'emailSizeWarnings'
}];

const AlphaFeatures: React.FC = () => {
Expand Down
19 changes: 19 additions & 0 deletions ghost/admin/app/components/editor/email-size-warning.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{#if this.isEnabled}}
{{! Watch for post.updatedAt changes to trigger recalculation after saves }}
<span {{did-update this.checkEmailSize @post.updatedAtUTC}} class="gh-editor-email-size-warning-container">
<span class="gh-editor-email-size-warning gh-editor-email-size-warning--{{this.warningLevel}}" data-warning-active={{if this.warningLevel "true" "false"}}>
{{#if this.warningLevel}}
<span class="{{concat "gh-editor-email-warning-icon-" this.warningLevel}}">
{{svg-jar "email-warning"}}
</span>
{{/if}}
</span>
{{#if this.warningLevel}}
<div class="gh-editor-email-size-popup">
<div class="gh-editor-email-size-popup-title">Looks like this is a long post</div>
<div class="gh-editor-email-size-popup-text">Email newsletters may get cut off in the inbox behind a "View entire message" link when they're over 100kB.</div>
<div class="gh-editor-email-size-popup-used">You've used: <span class={{this.warningLevel}}>{{this.emailSizeKb}}kB</span></div>
</div>
{{/if}}
</span>
{{/if}}
47 changes: 47 additions & 0 deletions ghost/admin/app/components/editor/email-size-warning.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';

export default class EmailSizeWarningComponent extends Component {
@service emailSizeWarning;
@service feature;
@service settings;

@tracked warningLevel = null;
@tracked emailSizeKb = null;

get isEnabled() {
return this.feature.emailSizeWarnings
&& this.settings.editorDefaultEmailRecipients !== 'disabled'
&& this.post
&& !this.post.email
&& !this.post.isNew;
}

get post() {
return this.args.post;
}

constructor() {
super(...arguments);
if (this.isEnabled) {
this.checkEmailSizeTask.perform();
}
}

@action
checkEmailSize() {
if (this.isEnabled) {
this.checkEmailSizeTask.perform();
}
}

@task({restartable: true})
*checkEmailSizeTask() {
const result = yield this.emailSizeWarning.fetchEmailSize(this.post);
this.warningLevel = result.warningLevel;
this.emailSizeKb = result.emailSizeKb;
}
}
146 changes: 146 additions & 0 deletions ghost/admin/app/services/email-size-warning.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import Service, {inject as service} from '@ember/service';
import {inject} from 'ghost-admin/decorators/inject';
import {task} from 'ember-concurrency';

const YELLOW_THRESHOLD = 90 * 1024; // 90KB
const RED_THRESHOLD = 100 * 1024; // 100KB

// Rewritten URLs in real emails have a fixed format:
// {siteUrl}/r/{8-char-hex}?m={36-char-uuid}
// The path portion "/r/{8-char-hex}?m={36-char-uuid}" is always 50 characters
const REWRITTEN_URL_PATH_LENGTH = 50;

/**
* Service to fetch email size data for posts.
* Deduplicates API requests when multiple components request data for the same post version.
*/
export default class EmailSizeWarningService extends Service {
@service ajax;
@service ghostPaths;
@service store;

@inject config;

_newsletter = null;
_lastPostId = null;
_lastUpdatedAt = null;

/**
* Fetch email size data for a post.
* Returns existing task instance if one is running/completed for same post version.
* Multiple concurrent calls for the same post version will share a single API request.
*
* @param {Object} post - The post model
* @returns {Promise<{warningLevel: string|null, emailSizeKb: number|null}>}
*/
fetchEmailSize(post) {
if (!post?.id || post.isNew) {
return Promise.resolve({warningLevel: null, emailSizeKb: null});
}

const postId = post.id;
const updatedAt = post.updatedAtUTC?.toISOString?.() || post.updatedAtUTC;

// Return existing task instance if we have one for this exact version
if (this._lastPostId === postId && this._lastUpdatedAt === updatedAt && this._fetchTask.last) {
return this._fetchTask.last;
}

this._lastPostId = postId;
this._lastUpdatedAt = updatedAt;

return this._fetchTask.perform(post);
}

async _loadNewsletter() {
if (!this._newsletter) {
const newsletters = await this.store.query('newsletter', {
filter: 'status:active',
order: 'sort_order DESC',
limit: 1
});
this._newsletter = newsletters.firstObject;
}
return this._newsletter;
}

_calculateLinkRewritingAdjustment(html) {
const contentStartMarker = '<!-- POST CONTENT START -->';
const contentEndMarker = '<!-- POST CONTENT END -->';

const startIndex = html.indexOf(contentStartMarker);
const endIndex = html.indexOf(contentEndMarker);

let contentHtml;
if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
contentHtml = html.substring(startIndex + contentStartMarker.length, endIndex);
} else {
contentHtml = html;
}

const siteUrlLength = (this.config.blogUrl || '').length;
const rewrittenUrlLength = siteUrlLength + REWRITTEN_URL_PATH_LENGTH;

const hrefRegex = /href="([^"]+)"/g;
let totalAdjustment = 0;
let match;

while ((match = hrefRegex.exec(contentHtml)) !== null) {
const originalUrl = match[1];

if (originalUrl.startsWith('%%{') && originalUrl.endsWith('}%%')) {
continue;
}
if (originalUrl === '#') {
continue;
}
if (!originalUrl.startsWith('http://') && !originalUrl.startsWith('https://') && !originalUrl.startsWith('/')) {
continue;
}

totalAdjustment += rewrittenUrlLength - originalUrl.length;
}

return totalAdjustment;
}

@task
*_fetchTask(post) {
yield this._loadNewsletter();
if (!this._newsletter) {
return {warningLevel: null, emailSizeKb: null};
}

try {
const url = new URL(
this.ghostPaths.url.api('/email_previews/posts', post.id),
window.location.href
);
url.searchParams.set('newsletter', this._newsletter.slug);

const response = yield this.ajax.request(url.href);
const [emailPreview] = response.email_previews;

if (emailPreview?.html) {
const previewSizeBytes = new Blob([emailPreview.html]).size;
const linkAdjustment = this._calculateLinkRewritingAdjustment(emailPreview.html);
const estimatedSizeBytes = Math.max(0, previewSizeBytes + linkAdjustment);

const emailSizeKb = Math.round(estimatedSizeBytes / 1024);
let warningLevel = null;

if (estimatedSizeBytes >= RED_THRESHOLD) {
warningLevel = 'red';
} else if (estimatedSizeBytes >= YELLOW_THRESHOLD) {
warningLevel = 'yellow';
}

return {warningLevel, emailSizeKb};
}
} catch (error) {
console.error('Email size check failed:', error); // eslint-disable-line no-console
}

return {warningLevel: null, emailSizeKb: null};
}
}
3 changes: 2 additions & 1 deletion ghost/admin/app/services/feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default class FeatureService extends Service {
@feature('contentVisibilityAlpha') contentVisibilityAlpha;
@feature('tagsX') tagsX;
@feature('utmTracking') utmTracking;
@feature('emailSizeWarnings') emailSizeWarnings;

_user = null;

Expand Down Expand Up @@ -131,7 +132,7 @@ export default class FeatureService extends Service {
if (this.config.environment === 'development') {
return;
}

const cookieName = 'ghost-admin-forward';
const hasAdminForwardCookie = !!getCookie(cookieName);

Expand Down
93 changes: 93 additions & 0 deletions ghost/admin/app/styles/layouts/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,99 @@
font-weight: 400;
}

.gh-editor-email-size-warning-container {
position: relative;
display: flex;
align-items: center;
}

.gh-editor-email-size-warning {
display: flex;
align-items: center;
margin-left: 0;
cursor: pointer;
max-width: 0;
overflow: hidden;
opacity: 0;
transition: max-width 0.3s ease, opacity 0.3s ease, margin-left 0.3s ease;
}

.gh-editor-email-size-warning[data-warning-active="true"] {
max-width: 40px;
opacity: 1;
margin-left: 8px;
}
Comment on lines +431 to +446
Copy link
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:

find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) | xargs rg -l "emailSizeWarning|email-size-warning" | head -20

Repository: TryGhost/Ghost

Length of output: 269


🏁 Script executed:

cat -n ./ghost/admin/app/components/editor/email-size-warning.js

Repository: TryGhost/Ghost

Length of output: 7424


🏁 Script executed:

find . -path "*/editor/*" -name "*email-size-warning*" -type f

Repository: TryGhost/Ghost

Length of output: 174


🏁 Script executed:

cat -n ./ghost/admin/app/components/editor/email-size-warning.hbs

Repository: TryGhost/Ghost

Length of output: 1368


🏁 Script executed:

sed -n '431,520p' ./ghost/admin/app/styles/layouts/editor.css

Repository: TryGhost/Ghost

Length of output: 1937


Add keyboard focus and touch handlers to make the popup accessible.

The popup visibility relies on CSS hover state (.gh-editor-email-size-warning:hover ~ .gh-editor-email-size-popup), which excludes keyboard users (Tab navigation) and touch users (no click handler on mobile).

The accompanying JavaScript component must handle:

  • focus/blur states to show/hide the popup on keyboard navigation
  • click/touchstart handlers for mobile devices
  • Proper ARIA attributes (aria-expanded, role="button", aria-label) on the trigger element

Without these, keyboard and touch users cannot access the warning information.

🤖 Prompt for AI Agents
In ghost/admin/app/styles/layouts/editor.css around lines 431 to 446, the
email-size-warning currently relies on CSS hover which prevents keyboard and
touch users from accessing the popup; update the corresponding JS component to:
add focus and blur event handlers that set/unset the same data-warning-active
attribute or class used by CSS so Tab/Shift+Tab reveals/hides the popup; add
click and touchstart handlers that toggle the popup on touch devices (prevent
duplicate events); and add proper ARIA attributes to the trigger element
(role="button", aria-expanded reflecting the active state, and a descriptive
aria-label) so screen readers and keyboard users can discover and control the
popup. Ensure event handlers clean up on unmount to avoid leaks.


.gh-editor-email-warning-icon-red {
color: var(--red);
}

.gh-editor-email-warning-icon-yellow {
color: var(--yellow);
}

.gh-editor-email-size-warning svg {
width: 16px;
height: 16px;
}

.gh-editor-email-size-warning svg path {
stroke: currentColor !important;
}

.gh-editor-email-size-popup {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
display: none;
flex-direction: column;
gap: 4px;
width: 280px;
padding: 20px;
background: var(--white);
border-radius: 8px;
box-shadow: 0px 50px 100px -25px rgba(50, 50, 93, .2), 0px 30px 60px -20px rgba(0, 0, 0, .25), 0px 0px 1px 0px rgba(0, 0, 0, .2);
z-index: 1000;
opacity: 0;
animation: emailSizePopupfadeIn 0.2s ease-in forwards;
}

@keyframes emailSizePopupfadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.gh-editor-email-size-warning:hover ~ .gh-editor-email-size-popup {
display: flex;
}

.gh-editor-email-size-popup-title {
font-size: 1.4rem;
font-weight: 600;
color: var(--darkgrey);
}

.gh-editor-email-size-popup-text {
font-size: 1.25rem;
font-weight: 400;
line-height: 1.4;
color: var(--midgrey);
margin-top: 4px;
}

.gh-editor-email-size-popup-used {
font-size: 1.3rem;
font-weight: 600;
color: var(--darkgrey);
margin-top: 4px;
}

.gh-editor-feedback-trigger {
position: absolute;
left: 30px;
Expand Down
2 changes: 2 additions & 0 deletions ghost/admin/app/templates/lexical-editor.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
<div class="gh-editor-wordcount">
{{gh-pluralize this.wordCount "word"}}
</div>
<Editor::EmailSizeWarning @post={{this.post}} />
<a href="https://ghost.org/help/using-the-editor/" class="flex" target="_blank" rel="noopener noreferrer">{{svg-jar "help"}}</a>
</div>

Expand All @@ -119,6 +120,7 @@
as |publishManagement|
>
<div class="gh-editor-wordcount">
<Editor::EmailSizeWarning @post={{this.post}} />
{{gh-pluralize this.wordCount "word"}}
</div>
<section class="gh-editor-publish-buttons">
Expand Down
1 change: 1 addition & 0 deletions ghost/admin/public/assets/icons/email-warning.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion ghost/core/core/shared/labs.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ const PRIVATE_FEATURES = [
'adminForward',
'domainWarmup',
'themeTranslation',
'commentModeration'
'commentModeration',
'emailSizeWarnings'
];

module.exports.GA_KEYS = [...GA_FEATURES];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Object {
"domainWarmup": true,
"editorExcerpt": true,
"emailCustomization": true,
"emailSizeWarnings": true,
"emailUniqueid": true,
"explore": true,
"i18n": true,
Expand Down