Skip to content

Commit 295cfd0

Browse files
authored
Updated member welcome email with automated_email content (#25626)
ref https://linear.app/ghost/issue/NY-773 - Updates member welcome emails so that they fetch content from `automated_emails` instead of hard-coded - Includes logic to check if the automated email is active - Cleans up some of the boundaries between member welcome emails and the outbox; outbox doesn't need to worry about the mail configuration anymore
1 parent 5e09965 commit 295cfd0

File tree

24 files changed

+836
-364
lines changed

24 files changed

+836
-364
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {Factory} from '@/data-factory';
2+
import {generateId} from '@/data-factory';
3+
4+
export interface AutomatedEmail {
5+
id: string;
6+
status: 'active' | 'inactive';
7+
name: string;
8+
slug: string;
9+
subject: string;
10+
lexical: string;
11+
sender_name: string | null;
12+
sender_email: string | null;
13+
sender_reply_to: string | null;
14+
created_at: Date;
15+
updated_at: Date | null;
16+
}
17+
18+
export class AutomatedEmailFactory extends Factory<Partial<AutomatedEmail>, AutomatedEmail> {
19+
entityType = 'automated_emails';
20+
21+
build(options: Partial<AutomatedEmail> = {}): AutomatedEmail {
22+
const now = new Date();
23+
24+
const defaults: AutomatedEmail = {
25+
id: generateId(),
26+
status: 'active',
27+
name: 'Welcome Email (Free)',
28+
slug: 'member-welcome-email-free',
29+
subject: 'Welcome to {{site.title}}!',
30+
lexical: JSON.stringify(this.defaultLexicalContent()),
31+
sender_name: null,
32+
sender_email: null,
33+
sender_reply_to: null,
34+
created_at: now,
35+
updated_at: null
36+
};
37+
38+
return {...defaults, ...options} as AutomatedEmail;
39+
}
40+
41+
private defaultLexicalContent() {
42+
return {
43+
root: {
44+
children: [{
45+
type: 'paragraph',
46+
children: [{
47+
type: 'text',
48+
text: 'Welcome to {{site.title}}!'
49+
}]
50+
}],
51+
direction: null,
52+
format: '',
53+
indent: 0,
54+
type: 'root',
55+
version: 1
56+
}
57+
};
58+
}
59+
}
60+

e2e/data-factory/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export {TagFactory} from './factories/tag-factory';
66
export type {Tag} from './factories/tag-factory';
77
export {MemberFactory} from './factories/member-factory';
88
export type {Member} from './factories/member-factory';
9+
export {AutomatedEmailFactory} from './factories/automated-email-factory';
10+
export type {AutomatedEmail} from './factories/automated-email-factory';
911
export * from './factories/user-factory';
1012

1113
// Persistence Adapters
@@ -22,3 +24,4 @@ export {generateId, generateUuid, generateSlug} from './utils';
2224
export {createPostFactory} from './setup';
2325
export {createTagFactory} from './setup';
2426
export {createMemberFactory} from './setup';
27+
export {createAutomatedEmailFactory} from './setup';

e2e/data-factory/setup.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {AutomatedEmailFactory} from './factories/automated-email-factory';
12
import {GhostAdminApiAdapter} from './persistence/adapters/ghost-api';
23
import {HttpClient} from './persistence/adapters/http-client';
34
import {MemberFactory} from './factories/member-factory';
@@ -37,3 +38,11 @@ export function createMemberFactory(httpClient: HttpClient): MemberFactory {
3738
return new MemberFactory(adapter);
3839
}
3940

41+
export function createAutomatedEmailFactory(httpClient: HttpClient): AutomatedEmailFactory {
42+
const adapter = new GhostAdminApiAdapter(
43+
httpClient,
44+
'automated_emails'
45+
);
46+
return new AutomatedEmailFactory(adapter);
47+
}
48+

e2e/tests/public/member-signup.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {EmailClient, MailPit} from '@/helpers/services/email/mail-pit';
22
import {HomePage, PublicPage} from '@/public-pages';
3+
import {createAutomatedEmailFactory} from '@/data-factory';
34
import {expect, test} from '@/helpers/playwright';
45
import {extractMagicLink} from '@/helpers/services/email/utils';
56
import {signupViaPortal} from '@/helpers/playwright/flows/signup';
@@ -48,6 +49,9 @@ test.describe('Ghost Public - Member Signup', () => {
4849
});
4950

5051
test('received welcome email', async ({page, config}) => {
52+
const automatedEmailFactory = createAutomatedEmailFactory(page.request);
53+
await automatedEmailFactory.create();
54+
5155
const emailInbox = config!.memberWelcomeEmailTestInbox;
5256
const homePage = new HomePage(page);
5357
await homePage.goto();
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const htmlToPlaintext = require('@tryghost/html-to-plaintext');
4+
const juice = require('juice');
5+
const lexicalLib = require('../../lib/lexical');
6+
const errors = require('@tryghost/errors');
7+
const {MESSAGES} = require('./constants');
8+
9+
class MemberWelcomeEmailRenderer {
10+
#wrapperTemplate;
11+
12+
constructor() {
13+
this.Handlebars = require('handlebars').create();
14+
const wrapperSource = fs.readFileSync(
15+
path.join(__dirname, './email-templates/wrapper.hbs'),
16+
'utf8'
17+
);
18+
this.#wrapperTemplate = this.Handlebars.compile(wrapperSource);
19+
}
20+
21+
/**
22+
* Renders a member welcome email
23+
* @param {Object} options
24+
* @param {string} options.lexical - Lexical JSON string to render
25+
* @param {string} options.subject - Email subject (may contain template variables)
26+
* @param {Object} options.member - Member data (name, email)
27+
* @param {Object} options.siteSettings - Site settings (title, url, accentColor)
28+
* @returns {Promise<{html: string, text: string, subject: string}>}
29+
*/
30+
async render({lexical, subject, member, siteSettings}) {
31+
let content;
32+
try {
33+
content = await lexicalLib.render(lexical, {target: 'email'});
34+
} catch (err) {
35+
throw new errors.IncorrectUsageError({
36+
message: MESSAGES.INVALID_LEXICAL_STRUCTURE,
37+
context: err.message
38+
});
39+
}
40+
41+
const memberName = member.name || 'there';
42+
const firstName = memberName.split(' ')[0];
43+
44+
const templateData = {
45+
site: {
46+
title: siteSettings.title,
47+
url: siteSettings.url
48+
},
49+
member: {
50+
name: memberName,
51+
email: member.email || '',
52+
firstname: firstName
53+
},
54+
siteTitle: siteSettings.title,
55+
siteUrl: siteSettings.url,
56+
accentColor: siteSettings.accentColor
57+
};
58+
59+
const contentWithReplacements = this.Handlebars.compile(content)(templateData);
60+
const subjectWithReplacements = this.Handlebars.compile(subject)(templateData);
61+
62+
const html = this.#wrapperTemplate({
63+
...templateData,
64+
content: contentWithReplacements,
65+
subject: subjectWithReplacements
66+
});
67+
68+
const inlinedHtml = juice(html, {inlinePseudoElements: true, removeStyleTags: true});
69+
const text = htmlToPlaintext.email(inlinedHtml);
70+
71+
return {
72+
html: inlinedHtml,
73+
text,
74+
subject: subjectWithReplacements
75+
};
76+
}
77+
}
78+
79+
module.exports = MemberWelcomeEmailRenderer;
80+
Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
const MEMBER_WELCOME_EMAIL_LOG_KEY = '[MEMBER-WELCOME-EMAIL]';
22

3+
const MEMBER_WELCOME_EMAIL_SLUGS = {
4+
free: 'member-welcome-email-free',
5+
paid: 'member-welcome-email-paid'
6+
};
7+
8+
const MESSAGES = {
9+
NO_MEMBER_WELCOME_EMAIL: 'No member welcome email found',
10+
INVALID_LEXICAL_STRUCTURE: 'Member welcome email has invalid content structure',
11+
MISSING_TEST_INBOX_CONFIG: 'memberWelcomeEmailTestInbox config is required but not defined',
12+
memberWelcomeEmailInactive: memberStatus => `Member welcome email for "${memberStatus}" members is inactive`
13+
};
14+
315
module.exports = {
4-
MEMBER_WELCOME_EMAIL_LOG_KEY
5-
};
16+
MEMBER_WELCOME_EMAIL_LOG_KEY,
17+
MEMBER_WELCOME_EMAIL_SLUGS,
18+
MESSAGES
19+
};

ghost/core/core/server/services/member-welcome-emails/email-templates/welcome.html.js

Lines changed: 0 additions & 148 deletions
This file was deleted.

ghost/core/core/server/services/member-welcome-emails/email-templates/welcome.txt.js

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)