Skip to content

Commit 64c69e8

Browse files
authored
πŸ› Fixed email rendering to use cached image dimensions (#26295)
ref HKG-1583 - Email rendering was bypassing the image dimension cache β€” it used the raw `ImageSize.getImageSizeFromUrl()` which makes a fresh HTTP request/probe on every call, even though the frontend metadata path already cached these via `CachedImageSizeFromUrl` - Updated the caching logic into the existing `getCachedImageSizeFromUrl()` β€” it now returns shallow copies to prevent cache corruption when consumers mutate dimensions in-place, only caches 404s permanently with a `notFound` marker so they can be distinguished from stale transient errors (transient errors are not cached, allowing retry), and retries fetch when the cache holds a stale error entry without dimensions - Wired the email renderer to use `cachedImageSizeFromUrl` directly as the `imageSize` dependency, replacing the uncached `ImageSize` instance - Added a guard in `limitImageWidth` for missing dimensions β€” since `getCachedImageSizeFromUrl` never throws (returns `{url}` on error), it checks `!size.width` and returns defaults, so the renderer gracefully handles failed fetches without a behavior change - The other consumer β€” `image-dimensions.js` (frontend metadata) β€” did not need any changes since it already calls `getCachedImageSizeFromUrl` and handles missing dimensions gracefully. The shallow copy fix and improved caching strategy benefit it automatically - Updated unit tests for `getCachedImageSizeFromUrl` (transient errors not cached, 404s cached, stale entry retry, cache corruption prevention) and added tests verifying the email renderer wiring end-to-end (cache hit, cache miss with write-back, stale error entry retry, fetch failure fallback)
1 parent 52c4daa commit 64c69e8

File tree

6 files changed

+252
-56
lines changed

6 files changed

+252
-56
lines changed

β€Žghost/core/core/server/lib/image/cached-image-size-from-url.jsβ€Ž

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const logging = require('@tryghost/logging');
1414
* @property {string} url image url
1515
* @property {number} height image height
1616
* @property {number} width image width
17+
* @property {boolean} notFound true if the image is not found
1718
*/
1819

1920
class CachedImageSizeFromUrl {
@@ -30,46 +31,57 @@ class CachedImageSizeFromUrl {
3031

3132
/**
3233
* Get cached image size from URL
33-
* Always returns {object} imageSizeCache
34+
* Returns null when dimensions are unavailable (invalid URL, 404, transient
35+
* errors) so consumers can gracefully skip images with missing dimensions.
36+
*
37+
* Caching strategy:
38+
* - Successful fetches are cached
39+
* - NotFoundError (404) is cached permanently with a marker
40+
* - Transient errors (timeouts, 500s) are NOT cached, allowing retry on next call
41+
* - Stale error entries (cached without dimensions) trigger a retry
42+
*
3443
* @param {string} url
3544
* @returns {Promise<ImageSizeCache>}
36-
* @description Takes a url and returns image width and height from cache if available.
37-
* If not in cache, `getImageSizeFromUrl` is called and returns the dimensions in a Promise.
3845
*/
3946
async getCachedImageSizeFromUrl(url) {
4047
if (!url || url === undefined || url === null) {
41-
return;
48+
return null;
4249
}
4350

4451
const cachedImageSize = await this.cache.get(url);
4552

46-
if (cachedImageSize) {
53+
// Check for cachedImageSize.width to handle legacy cache entries
54+
// that were stored as {url} without dimensions or a notFound marker.
55+
// These stale entries fall through to trigger a re-fetch and self-heal.
56+
if (cachedImageSize && cachedImageSize.width) {
4757
debug('Read image from cache:', url);
58+
return {...cachedImageSize};
59+
}
4860

49-
return cachedImageSize;
50-
} else {
51-
try {
52-
const res = await this.getImageSizeFromUrl(url);
53-
await this.cache.set(url, res);
54-
55-
debug('Cached image:', url);
61+
// 404s are cached permanently β€” don't retry
62+
if (cachedImageSize && cachedImageSize.notFound) {
63+
debug('Read image from cache (not found):', url);
64+
return null;
65+
}
5666

57-
return this.cache.get(url);
58-
} catch (err) {
59-
if (err instanceof errors.NotFoundError) {
60-
debug('Cached image (not found):', url);
61-
} else {
62-
debug('Cached image (error):', url);
63-
logging.error(err);
64-
}
67+
try {
68+
const res = await this.getImageSizeFromUrl(url);
69+
await this.cache.set(url, {...res});
6570

66-
// in case of error we just attach the url
67-
await this.cache.set(url, {
68-
url
69-
});
71+
debug('Cached image:', url);
7072

71-
return this.cache.get(url);
73+
return res;
74+
} catch (err) {
75+
if (err instanceof errors.NotFoundError) {
76+
debug('Cached image (not found):', url);
77+
// Cache 404s with a marker
78+
await this.cache.set(url, {url, notFound: true});
79+
} else {
80+
debug('Image fetch error (not cached):', url);
81+
logging.error(err);
7282
}
83+
84+
return null;
7385
}
7486
}
7587
}

β€Žghost/core/core/server/services/email-service/email-renderer.jsβ€Ž

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ class EmailRenderer {
148148
* @param {object} dependencies.renderers
149149
* @param {{render(object, options): string}} dependencies.renderers.lexical
150150
* @param {{render(object, options): string}} dependencies.renderers.mobiledoc
151-
* @param {{getImageSizeFromUrl(url: string): Promise<{width: number, height: number}>}} dependencies.imageSize
151+
* @param {{getCachedImageSizeFromUrl(url: string): Promise<{url: string, width: number, height: number} | null>}} dependencies.imageSize
152152
* @param {{urlFor(type: string, optionsOrAbsolute, absolute): string, isSiteUrl(url, context): boolean}} dependencies.urlUtils
153153
* @param {{isLocalImage(url: string): boolean}} dependencies.storageUtils
154154
* @param {(post: Post) => string} dependencies.getPostUrl
@@ -1402,7 +1402,11 @@ class EmailRenderer {
14021402
};
14031403
} else {
14041404
try {
1405-
const size = await this.#imageSize.getImageSizeFromUrl(href);
1405+
const size = await this.#imageSize.getCachedImageSizeFromUrl(href);
1406+
1407+
if (!size || !size.width) {
1408+
return {href, width: 0, height: null};
1409+
}
14061410

14071411
if (size.width >= visibleWidth) {
14081412
if (!visibleHeight) {

β€Žghost/core/core/server/services/email-service/email-service-wrapper.jsβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ class EmailServiceWrapper {
4747
const audienceFeedback = require('../audience-feedback');
4848
const storageUtils = require('../../adapters/storage/utils');
4949
const emailAnalyticsJobs = require('../email-analytics/jobs');
50-
const {imageSize} = require('../../lib/image');
50+
const {cachedImageSizeFromUrl} = require('../../lib/image');
5151

5252
// capture errors from mailgun client and log them in sentry
5353
const errorHandler = (error) => {
@@ -79,7 +79,7 @@ class EmailServiceWrapper {
7979
mobiledoc: mobiledocLib,
8080
lexical: lexicalLib
8181
},
82-
imageSize,
82+
imageSize: cachedImageSizeFromUrl,
8383
urlUtils,
8484
storageUtils,
8585
getPostUrl: this.getPostUrl,

β€Žghost/core/test/unit/frontend/helpers/ghost-head.test.jsβ€Ž

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ describe('{{ghost_head}} helper', function () {
378378

379379
// @TODO: this is a LOT of mocking :/
380380
routingRegistryGetRssUrlStub = sinon.stub(routing.registry, 'getRssUrl').returns('http://localhost:65530/rss/');
381-
sinon.stub(imageLib.imageSize, 'getImageSizeFromUrl').resolves();
381+
sinon.stub(imageLib.cachedImageSizeFromUrl, 'getCachedImageSizeFromUrl').resolves();
382382
getStub = sinon.stub(settingsCache, 'get');
383383

384384
getStub.withArgs('title').returns('Ghost');
@@ -418,7 +418,7 @@ describe('{{ghost_head}} helper', function () {
418418
safeVersion: '0.3'
419419
}
420420
}));
421-
sinon.assert.calledOnce(loggingErrorStub);
421+
sinon.assert.notCalled(loggingErrorStub);
422422
});
423423

424424
it('returns structured data on first index page', async function () {

β€Žghost/core/test/unit/server/lib/image/cached-image-size-from-url.test.jsβ€Ž

Lines changed: 80 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('lib/image: image size cache', function () {
6262
assert.equal(image2.height, 50);
6363
});
6464

65-
it('can handle generic image-size errors', async function () {
65+
it('should not cache transient errors, allowing retry on next call', async function () {
6666
const url = 'http://mysite.com/content/image/mypostcoverimage.jpg';
6767

6868
sizeOfStub.rejects('error');
@@ -74,17 +74,26 @@ describe('lib/image: image size cache', function () {
7474
});
7575

7676
const loggingStub = sinon.stub(logging, 'error');
77-
await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
77+
const result = await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
7878

79-
cacheStore.get(url).should.not.be.undefined;
80-
const image = cacheStore.get(url);
81-
assert.equal(image.url, 'http://mysite.com/content/image/mypostcoverimage.jpg');
82-
assert.equal(image.width, undefined);
83-
assert.equal(image.height, undefined);
79+
assert.equal(result, null);
80+
81+
// Transient errors should NOT be cached
82+
assert.equal(cacheStore.get(url), undefined);
8483
sinon.assert.calledOnce(loggingStub);
84+
assert.equal(sizeOfStub.calledOnce, true);
85+
86+
// Second call should retry the fetch since nothing was cached
87+
const result2 = await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
88+
89+
assert.equal(result2, null);
90+
91+
// Cache should still be empty after the second transient error
92+
assert.equal(cacheStore.get(url), undefined);
93+
assert.equal(sizeOfStub.calledTwice, true);
8594
});
8695

87-
it('can handle NotFoundError error', async function () {
96+
it('should cache NotFoundError permanently and not refetch on subsequent calls', async function () {
8897
const url = 'http://mysite.com/content/image/mypostcoverimage.jpg';
8998

9099
sizeOfStub.rejects(new errors.NotFoundError('it iz gone mate!'));
@@ -95,13 +104,71 @@ describe('lib/image: image size cache', function () {
95104
cache: cacheStore
96105
});
97106

98-
await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
107+
const result = await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
108+
assert.equal(result, null);
99109

110+
// Verify 404 was cached with notFound marker
100111
cacheStore.get(url).should.not.be.undefined;
101112
const image = cacheStore.get(url);
102-
assert.equal(image.url, 'http://mysite.com/content/image/mypostcoverimage.jpg');
103-
assert.equal(image.width, undefined);
104-
assert.equal(image.height, undefined);
113+
assert.equal(image.url, url);
114+
assert.equal(image.notFound, true);
115+
116+
// Second call should NOT refetch β€” 404 is permanent
117+
const secondResult = await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
118+
assert.equal(secondResult, null);
119+
assert.equal(sizeOfStub.calledOnce, true);
120+
});
121+
122+
it('should retry fetch when cache has a stale error entry (no dimensions)', async function () {
123+
const url = 'http://mysite.com/content/image/photo.jpg';
124+
125+
sizeOfStub.resolves({width: 500, height: 400, type: 'jpg'});
126+
127+
const cacheStore = new InMemoryCache();
128+
// Pre-populate cache with an error entry (no width/height)
129+
cacheStore.set(url, {url});
130+
131+
const cachedImageSizeFromUrl = new CachedImageSizeFromUrl({
132+
getImageSizeFromUrl: sizeOfStub,
133+
cache: cacheStore
134+
});
135+
136+
const result = await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
137+
138+
assert.equal(result.width, 500);
139+
assert.equal(result.height, 400);
140+
assert.equal(sizeOfStub.calledOnce, true);
141+
142+
// Verify cache was overwritten with valid dimensions
143+
const cached = cacheStore.get(url);
144+
assert.equal(cached.width, 500);
145+
assert.equal(cached.height, 400);
146+
});
147+
148+
it('should not corrupt cache when caller mutates the returned object', async function () {
149+
const url = 'http://mysite.com/content/image/mypostcoverimage.jpg';
150+
151+
sizeOfStub.resolves({width: 2000, height: 1000, type: 'jpg'});
152+
153+
const cacheStore = new InMemoryCache();
154+
const cachedImageSizeFromUrl = new CachedImageSizeFromUrl({
155+
getImageSizeFromUrl: sizeOfStub,
156+
cache: cacheStore
157+
});
158+
159+
const result = await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
160+
result.width = 1200;
161+
result.height = 600;
162+
163+
// Cache should still hold the original dimensions
164+
const cached = cacheStore.get(url);
165+
assert.equal(cached.width, 2000);
166+
assert.equal(cached.height, 1000);
167+
168+
// A subsequent call should also return original dimensions
169+
const secondResult = await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
170+
assert.equal(secondResult.width, 2000);
171+
assert.equal(secondResult.height, 1000);
105172
});
106173

107174
it('should return null if url is null', async function () {
@@ -114,6 +181,6 @@ describe('lib/image: image size cache', function () {
114181

115182
result = await cachedImageSizeFromUrl.getCachedImageSizeFromUrl(url);
116183

117-
assert.equal(result, undefined);
184+
assert.equal(result, null);
118185
});
119186
});

0 commit comments

Comments
Β (0)