Added email address alignment protections (#19094)

ref GRO-54
fixes GRO-63
fixes GRO-62
fixes GRO-69

When the config `hostSettings:managedEmail:enabled` is enabled, or the
new flag (`newEmailAddresses`) is enabled for self-hosters, we'll start
to check the from addresses of all outgoing emails more strictly.

- Current flow: nothing changes if the managedEmail config is not set or
the `newEmailAddresses` feature flag is not set
- When managedEmail is enabled: never allow to send an email from any
chosen email. We always use `mail.from` for all outgoing emails. Custom
addresses should be set as replyTo instead. Changing the newsletter
sender_email is not allowed anymore (and ignored if it is set).
- When managedEmail is enabled with a custom sending domain: if a from
address doesn't match the sending domain, we'll default to mail.from and
use the original as a replyTo if appropriate and only when no other
replyTo was set. A newsletter sender email addresss can only be set to
an email address on this domain.
- When `newEmailAddresses` is enabled: self hosters are free to set all
email addresses to whatever they want, without verification. In addition
to that, we stop making up our own email addresses and send from
`mail.from` by default instead of generating a `noreply`+ `@` +
`sitedomain.com` address

A more in depth example of all cases can be seen in
`ghost/core/test/integration/services/email-addresses.test.js`

Includes lots of new E2E tests for most new situations. Apart from that,
all email snapshots are changed because the from and replyTo addresses
are now included in snapshots (so we can see unexpected changes in the
future).

Dropped test coverage requirement, because tests were failing coverage
locally, but not in CI

Fixed settings test that set the site title to an array - bug tracked in
GRO-68
This commit is contained in:
Simon Backx 2023-11-23 10:25:30 +01:00 committed by GitHub
parent 17804dd3ac
commit 17ec1e8937
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 4214 additions and 68 deletions

View File

@ -45,7 +45,7 @@ const COMMAND_ADMIN = {
const COMMAND_TYPESCRIPT = {
name: 'ts',
command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations -- nx run \\$NX_PROJECT_NAME:build:ts',
command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations,ghost/email-addresses -- nx run \\$NX_PROJECT_NAME:build:ts',
cwd: path.resolve(__dirname, '../../'),
prefixColor: 'cyan',
env: {}

View File

@ -6,10 +6,10 @@
"text-summary",
"cobertura"
],
"statements": 58.8,
"statements": 58.7,
"branches": 84,
"functions": 50,
"lines": 58.8,
"lines": 58.7,
"include": [
"core/{*.js,frontend,server,shared}"
],

View File

@ -330,6 +330,7 @@ async function initServices({config}) {
const mailEvents = require('./server/services/mail-events');
const donationService = require('./server/services/donations');
const recommendationsService = require('./server/services/recommendations');
const emailAddressService = require('./server/services/email-address');
const urlUtils = require('./shared/url-utils');
@ -341,6 +342,9 @@ async function initServices({config}) {
// so they are initialized before it.
await stripe.init();
// NOTE: newsletter service and email service depend on email address service
await emailAddressService.init(),
await Promise.all([
memberAttribution.init(),
mentionsService.init(),

View File

@ -17,7 +17,7 @@ module.exports = {
slug: {type: 'string', maxlength: 191, nullable: false, unique: true},
sender_name: {type: 'string', maxlength: 191, nullable: true},
sender_email: {type: 'string', maxlength: 191, nullable: true},
sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter', validations: {isIn: [['newsletter', 'support']]}},
sender_reply_to: {type: 'string', maxlength: 191, nullable: false, defaultTo: 'newsletter'},
status: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'active', validations: {isIn: [['active', 'archived']]}},
visibility: {
type: 'string',

View File

@ -0,0 +1,39 @@
class EmailAddressServiceWrapper {
/**
* @type {import('@tryghost/email-addresses').EmailAddressService}
*/
service;
init() {
if (this.service) {
return;
}
const labs = require('../../../shared/labs');
const config = require('../../../shared/config');
const settingsHelpers = require('../settings-helpers');
const validator = require('@tryghost/validator');
const {
EmailAddressService
} = require('@tryghost/email-addresses');
this.service = new EmailAddressService({
labs,
getManagedEmailEnabled: () => {
return config.get('hostSettings:managedEmail:enabled') ?? false;
},
getSendingDomain: () => {
return config.get('hostSettings:managedEmail:sendingDomain') || null;
},
getDefaultEmail: () => {
return settingsHelpers.getDefaultEmail();
},
isValidEmailAddress: (emailAddress) => {
return validator.isEmail(emailAddress);
}
});
}
}
module.exports = EmailAddressServiceWrapper;

View File

@ -0,0 +1,3 @@
const EmailAddressServiceWrapper = require('./EmailAddressServiceWrapper');
module.exports = new EmailAddressServiceWrapper();

View File

@ -26,6 +26,7 @@ class EmailServiceWrapper {
const membersRepository = membersService.api.members;
const limitService = require('../limits');
const labs = require('../../../shared/labs');
const emailAddressService = require('../email-address');
const mobiledocLib = require('../../lib/mobiledoc');
const lexicalLib = require('../../lib/lexical');
@ -70,6 +71,7 @@ class EmailServiceWrapper {
memberAttributionService: memberAttribution.service,
audienceFeedbackService: audienceFeedback.service,
outboundLinkTagger: memberAttribution.outboundLinkTagger,
emailAddressService: emailAddressService.service,
labs,
models: {Post}
});

View File

@ -8,6 +8,8 @@ const tpl = require('@tryghost/tpl');
const settingsCache = require('../../../shared/settings-cache');
const urlUtils = require('../../../shared/url-utils');
const metrics = require('@tryghost/metrics');
const settingsHelpers = require('../settings-helpers');
const emailAddress = require('../email-address');
const messages = {
title: 'Ghost at {domain}',
checkEmailConfigInstructions: 'Please see {url} for instructions on configuring email.',
@ -16,29 +18,59 @@ const messages = {
reason: ' Reason: {reason}.',
messageSent: 'Message sent. Double check inbox and spam folder!'
};
const {EmailAddressParser} = require('@tryghost/email-addresses');
const logging = require('@tryghost/logging');
function getDomain() {
const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
return domain && domain[1];
}
function getFromAddress(requestedFromAddress) {
/**
* @param {string} requestedFromAddress
* @param {string} requestedReplyToAddress
* @returns {{from: string, replyTo?: string|null}}
*/
function getFromAddress(requestedFromAddress, requestedReplyToAddress) {
if (settingsHelpers.useNewEmailAddresses()) {
if (!requestedFromAddress) {
// Use the default config
requestedFromAddress = emailAddress.service.defaultFromEmail;
}
// Clean up email addresses (checks whether sending is allowed + email address is valid)
const addresses = emailAddress.service.getAddressFromString(requestedFromAddress, requestedReplyToAddress);
// fill in missing name if not set
const defaultSiteTitle = settingsCache.get('title') ? settingsCache.get('title') : tpl(messages.title, {domain: getDomain()});
if (!addresses.from.name) {
addresses.from.name = defaultSiteTitle;
}
return {
from: EmailAddressParser.stringify(addresses.from),
replyTo: addresses.replyTo ? EmailAddressParser.stringify(addresses.replyTo) : null
};
}
const configAddress = config.get('mail') && config.get('mail').from;
const address = requestedFromAddress || configAddress;
// If we don't have a from address at all
if (!address) {
// Default to noreply@[blog.url]
return getFromAddress(`noreply@${getDomain()}`);
return getFromAddress(`noreply@${getDomain()}`, requestedReplyToAddress);
}
// If we do have a from address, and it's just an email
if (validator.isEmail(address, {require_tld: false})) {
const defaultSiteTitle = settingsCache.get('title') ? settingsCache.get('title').replace(/"/g, '\\"') : tpl(messages.title, {domain: getDomain()});
return `"${defaultSiteTitle}" <${address}>`;
return {
from: `"${defaultSiteTitle}" <${address}>`
};
}
return address;
logging.warn(`Invalid from address used for sending emails: ${address}`);
return {from: address};
}
/**
@ -47,16 +79,21 @@ function getFromAddress(requestedFromAddress) {
* @param {Object} message
* @param {boolean} [message.forceTextContent] - force text content
* @param {string} [message.from] - sender email address
* @param {string} [message.replyTo]
* @returns {Object}
*/
function createMessage(message) {
const encoding = 'base64';
const generateTextFromHTML = !message.forceTextContent;
return Object.assign({}, message, {
from: getFromAddress(message.from),
const addresses = getFromAddress(message.from, message.replyTo);
return {
...message,
...addresses,
generateTextFromHTML,
encoding
});
};
}
function createMailError({message, err, ignoreDefaultMessage} = {message: ''}) {
@ -154,13 +191,13 @@ module.exports = class GhostMailer {
return tpl(messages.messageSent);
}
if (response.pending.length > 0) {
if (response.pending && response.pending.length > 0) {
throw createMailError({
message: tpl(messages.reason, {reason: 'Email has been temporarily rejected'})
});
}
if (response.errors.length > 0) {
if (response.errors && response.errors.length > 0) {
throw createMailError({
message: tpl(messages.reason, {reason: response.errors[0].message})
});

View File

@ -89,7 +89,13 @@ const initVerificationTrigger = () => {
isVerificationRequired: () => settingsCache.get('email_verification_required') === true,
sendVerificationEmail: async ({subject, message, amountTriggered}) => {
const escalationAddress = config.get('hostSettings:emailVerification:escalationAddress');
const fromAddress = config.get('user_email');
let fromAddress = config.get('user_email');
let replyTo = undefined;
if (settingsHelpers.useNewEmailAddresses()) {
replyTo = fromAddress;
fromAddress = settingsHelpers.getNoReplyAddress();
}
if (escalationAddress) {
await ghostMailer.send({
@ -100,6 +106,7 @@ const initVerificationTrigger = () => {
}),
forceTextContent: true,
from: fromAddress,
replyTo,
to: escalationAddress
});
}

View File

@ -8,7 +8,9 @@ const errors = require('@tryghost/errors');
const messages = {
nameAlreadyExists: 'A newsletter with the same name already exists',
newsletterNotFound: 'Newsletter not found.'
newsletterNotFound: 'Newsletter not found.',
senderEmailNotAllowed: 'You cannot set the sender email address to {email}',
replyToNotAllowed: 'You cannot set the reply-to email address to {email}'
};
class NewslettersService {
@ -21,9 +23,10 @@ class NewslettersService {
* @param {Object} options.singleUseTokenProvider
* @param {Object} options.urlUtils
* @param {ILimitService} options.limitService
* @param {Object} options.emailAddressService
* @param {Object} options.labs
*/
constructor({NewsletterModel, MemberModel, mail, singleUseTokenProvider, urlUtils, limitService, labs}) {
constructor({NewsletterModel, MemberModel, mail, singleUseTokenProvider, urlUtils, limitService, labs, emailAddressService}) {
this.NewsletterModel = NewsletterModel;
this.MemberModel = MemberModel;
this.urlUtils = urlUtils;
@ -31,6 +34,8 @@ class NewslettersService {
this.limitService = limitService;
/** @private */
this.labs = labs;
/** @private */
this.emailAddressService = emailAddressService;
/* email verification setup */
@ -243,14 +248,48 @@ class NewslettersService {
async prepAttrsForEmailVerification(attrs, newsletter) {
const cleanedAttrs = _.cloneDeep(attrs);
const emailsToVerify = [];
const emailProperties = [
{property: 'sender_email', type: 'from', emptyable: true, error: messages.senderEmailNotAllowed}
];
for (const property of ['sender_email']) {
if (!this.emailAddressService.service.useNewEmailAddresses) {
// Validate reply_to is either newsletter or support
if (cleanedAttrs.sender_reply_to !== undefined) {
if (!['newsletter', 'support'].includes(cleanedAttrs.sender_reply_to)) {
throw new errors.ValidationError({
message: tpl(messages.replyToNotAllowed, {email: cleanedAttrs.sender_reply_to})
});
}
}
} else {
if (cleanedAttrs.sender_reply_to !== undefined) {
if (!['newsletter', 'support'].includes(cleanedAttrs.sender_reply_to)) {
emailProperties.push({property: 'sender_reply_to', type: 'replyTo', emptyable: false, error: messages.replyToNotAllowed});
}
}
}
for (const {property, type, emptyable, error} of emailProperties) {
const email = cleanedAttrs[property];
const hasChanged = !newsletter || newsletter.get(property) !== email;
if (await this.requiresEmailVerification({email, hasChanged})) {
delete cleanedAttrs[property];
emailsToVerify.push({email, property});
if (hasChanged && email !== undefined) {
if (email === null || email === '' && emptyable) {
continue;
}
const validated = this.emailAddressService.service.validate(email, type);
if (!validated.allowed) {
throw new errors.ValidationError({
message: tpl(error, {email})
});
}
if (validated.verificationEmailRequired) {
delete cleanedAttrs[property];
emailsToVerify.push({email, property});
}
}
}
@ -264,19 +303,6 @@ class NewslettersService {
return {cleanedAttrs, emailsToVerify};
}
/**
* @private
*/
async requiresEmailVerification({email, hasChanged}) {
if (!email || !hasChanged) {
return false;
}
// TODO: check other newsletters for known/verified email
return true;
}
/**
* @private
*/
@ -304,6 +330,13 @@ class NewslettersService {
fromEmail = `no-reply@${toDomain}`;
}
if (this.emailAddressService.useNewEmailAddresses) {
// Gone with the old logic: always use the default email address here
// We don't need to validate the FROM address, only the to address
// Also because we are not only validating FROM addresses, but also possible REPLY-TO addresses, which we won't send FROM
fromEmail = this.emailAddressService.defaultFromAddress;
}
const {ghostMailer} = this;
this.magicLinkService.transporter = {

View File

@ -5,6 +5,7 @@ const models = require('../../models');
const urlUtils = require('../../../shared/url-utils');
const limitService = require('../limits');
const labs = require('../../../shared/labs');
const emailAddressService = require('../email-address');
const MAGIC_LINK_TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
const MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE = 10 * 60 * 1000;
@ -22,5 +23,6 @@ module.exports = new NewslettersService({
}),
urlUtils,
limitService,
labs
labs,
emailAddressService: emailAddressService
});

View File

@ -1,15 +1,18 @@
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const {EmailAddressParser} = require('@tryghost/email-addresses');
const logging = require('@tryghost/logging');
const messages = {
incorrectKeyType: 'type must be one of "direct" or "connect".'
};
class SettingsHelpers {
constructor({settingsCache, urlUtils, config}) {
constructor({settingsCache, urlUtils, config, labs}) {
this.settingsCache = settingsCache;
this.urlUtils = urlUtils;
this.config = config;
this.labs = labs;
}
isMembersEnabled() {
@ -83,7 +86,18 @@ class SettingsHelpers {
return this.settingsCache.get('firstpromoter_id');
}
/**
* @deprecated
* Please don't make up new email addresses: use the default email addresses
*/
getDefaultEmailDomain() {
if (this.#managedEmailEnabled()) {
const customSendingDomain = this.#managedSendingDomain();
if (customSendingDomain) {
return customSendingDomain;
}
}
const url = this.urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
const domain = (url && url[1]) || '';
if (domain.startsWith('www.')) {
@ -93,7 +107,15 @@ class SettingsHelpers {
}
getMembersSupportAddress() {
const supportAddress = this.settingsCache.get('members_support_address') || 'noreply';
let supportAddress = this.settingsCache.get('members_support_address');
if (!supportAddress && this.useNewEmailAddresses()) {
// In the new flow, we make a difference between an empty setting (= use default) and a 'noreply' setting (=use noreply @ domain)
// Also keep the name of the default email!
return EmailAddressParser.stringify(this.getDefaultEmail());
}
supportAddress = supportAddress || 'noreply';
// Any fromAddress without domain uses site domain, like default setting `noreply`
if (supportAddress.indexOf('@') < 0) {
@ -102,13 +124,56 @@ class SettingsHelpers {
return supportAddress;
}
/**
* @deprecated Use getDefaultEmail().address (without name) or EmailAddressParser.stringify(this.getDefaultEmail()) (with name) instead
*/
getNoReplyAddress() {
return this.getDefaultEmail().address;
}
getDefaultEmail() {
if (this.useNewEmailAddresses()) {
// parse the email here and remove the sender name
// E.g. when set to "bar" <from@default.com>
const configAddress = this.config.get('mail:from');
const parsed = EmailAddressParser.parse(configAddress);
if (parsed) {
return parsed;
}
// For missing configs, we default to the old flow
logging.warn('Missing mail.from config, falling back to a generated email address. Please update your config file and set a valid from address');
}
return {
address: this.getLegacyNoReplyAddress()
};
}
/**
* @deprecated
* Please start using the new EmailAddressService
*/
getLegacyNoReplyAddress() {
return `noreply@${this.getDefaultEmailDomain()}`;
}
areDonationsEnabled() {
return this.isStripeConnected();
}
useNewEmailAddresses() {
return this.#managedEmailEnabled() || this.labs.isSet('newEmailAddresses');
}
// PRIVATE
#managedEmailEnabled() {
return !!this.config.get('hostSettings:managedEmail:enabled');
}
#managedSendingDomain() {
return this.config.get('hostSettings:managedEmail:sendingDomain');
}
}
module.exports = SettingsHelpers;

View File

@ -2,5 +2,6 @@ const settingsCache = require('../../../shared/settings-cache');
const urlUtils = require('../../../shared/url-utils');
const config = require('../../../shared/config');
const SettingsHelpers = require('./SettingsHelpers');
const labs = require('../../../shared/labs');
module.exports = new SettingsHelpers({settingsCache, urlUtils, config});
module.exports = new SettingsHelpers({settingsCache, urlUtils, config, labs});

View File

@ -49,7 +49,8 @@ const ALPHA_FEATURES = [
'adminXOffers',
'filterEmailDisabled',
'adminXDemo',
'tkReminders'
'tkReminders',
'newEmailAddresses'
];
module.exports.GA_KEYS = [...GA_FEATURES];

View File

@ -1810,8 +1810,11 @@ exports[`Members API Can add and send a signup confirmation email 4: [text 1] 1`
exports[`Members API Can add and send a signup confirmation email 5: [metadata 1] 1`] = `
Object {
"encoding": "base64",
"forceTextContent": true,
"from": "noreply@127.0.0.1",
"from": "\\"Ghost's Test Site\\" <noreply@127.0.0.1>",
"generateTextFromHTML": false,
"replyTo": null,
"subject": "🙌 Complete your sign up to Ghost's Test Site!",
"to": "member_getting_confirmation@test.com",
}

View File

@ -420,7 +420,7 @@ Object {
},
Object {
"key": "title",
"value": "[]",
"value": null,
},
Object {
"key": "description",
@ -778,7 +778,7 @@ Object {
},
Object {
"key": "title",
"value": "[]",
"value": null,
},
Object {
"key": "description",
@ -1135,7 +1135,7 @@ Object {
},
Object {
"key": "title",
"value": "[]",
"value": null,
},
Object {
"key": "description",
@ -1497,7 +1497,7 @@ Object {
},
Object {
"key": "title",
"value": "[]",
"value": null,
},
Object {
"key": "description",
@ -1947,7 +1947,7 @@ Object {
},
Object {
"key": "title",
"value": "[]",
"value": null,
},
Object {
"key": "description",
@ -2369,7 +2369,7 @@ Object {
},
Object {
"key": "title",
"value": "[]",
"value": null,
},
Object {
"key": "description",

View File

@ -21,7 +21,7 @@ const urlUtils = require('../../../core/shared/url-utils');
const settingsCache = require('../../../core/shared/settings-cache');
const DomainEvents = require('@tryghost/domain-events');
const logging = require('@tryghost/logging');
const {stripeMocker} = require('../../utils/e2e-framework-mock-manager');
const {stripeMocker, mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
/**
* Assert that haystack and needles match, ignoring the order.
@ -194,6 +194,7 @@ describe('Members API without Stripe', function () {
beforeEach(function () {
mockManager.mockMail();
mockLabsDisabled('newEmailAddresses');
});
afterEach(function () {

View File

@ -1,10 +1,12 @@
const assert = require('assert/strict');
const sinon = require('sinon');
const {agentProvider, mockManager, fixtureManager, configUtils, dbUtils, matchers, regexes} = require('../../utils/e2e-framework');
const {anyContentVersion, anyEtag, anyObjectId, anyUuid, anyISODateTime, anyLocationFor, anyNumber} = matchers;
const {anyContentVersion, anyEtag, anyObjectId, anyUuid, anyErrorId, anyISODateTime, anyLocationFor, anyNumber} = matchers;
const {queryStringToken} = regexes;
const models = require('../../../core/server/models');
const logging = require('@tryghost/logging');
const {mockLabsDisabled, mockLabsEnabled} = require('../../utils/e2e-framework-mock-manager');
const settingsHelpers = require('../../../core/server/services/settings-helpers');
const assertMemberRelationCount = async (newsletterId, expectedCount) => {
const relations = await dbUtils.knex('members_newsletters').where({newsletter_id: newsletterId}).pluck('id');
@ -39,6 +41,7 @@ describe('Newsletters API', function () {
beforeEach(function () {
emailMockReceiver = mockManager.mockMail();
mockLabsDisabled('newEmailAddresses');
});
afterEach(function () {
@ -358,6 +361,103 @@ describe('Newsletters API', function () {
}]);
});
it('[Legacy] Can only set newsletter reply to to newsletter or support value', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'support'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'newsletter'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('[Legacy] Cannot set newsletter clear sender_reply_to', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: ''
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('[Legacy] Cannot set newsletter reply-to to any email address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'hello@acme.com'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('[Legacy] Cannot set newsletter sender_email to invalid email address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: 'notvalid'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can verify property updates', async function () {
const cheerio = require('cheerio');
@ -760,4 +860,690 @@ describe('Newsletters API', function () {
etag: anyEtag
});
});
describe('Managed email without custom sending domain', function () {
this.beforeEach(function () {
configUtils.set('hostSettings:managedEmail:enabled', true);
configUtils.set('hostSettings:managedEmail:sendingDomain', null);
});
it('Can set newsletter reply-to to newsletter or support', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'support'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'newsletter'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot clear newsletter reply-to', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: ''
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot set newsletter reply-to to invalid email address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'notvalid'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can set newsletter reply-to to any email address with required verification', async function () {
const id = fixtureManager.get('newsletters', 0).id;
const before = await models.Newsletter.findOne({id});
const beforeSenderReplyTo = before.get('sender_reply_to');
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'hello@acme.com'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot],
meta: {
sent_email_verification: ['sender_reply_to']
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await before.refresh();
assert.equal(before.get('sender_reply_to'), beforeSenderReplyTo, 'sender_reply_to should not have changed because it first requires verification');
emailMockReceiver
.assertSentEmailCount(1)
.matchMetadataSnapshot()
.matchHTMLSnapshot([{
pattern: queryStringToken('verifyEmail'),
replacement: 'verifyEmail=REPLACED_TOKEN'
}])
.matchPlaintextSnapshot([{
pattern: queryStringToken('verifyEmail'),
replacement: 'verifyEmail=REPLACED_TOKEN'
}]);
});
it('Cannot change sender_email', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: 'hello@acme.com'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot set newsletter sender_email to invalid email address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: 'notvalid'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can keep sender_email', async function () {
const id = fixtureManager.get('newsletters', 0).id;
const before = await models.Newsletter.findOne({id});
assert(before.get('sender_email'), 'This test requires a non empty sender_email');
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: before.get('sender_email')
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can set sender_email to default address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
const before = await models.Newsletter.findOne({id});
assert(before.get('sender_email'), 'This test requires a non empty sender_email');
const defaultAddress = settingsHelpers.getDefaultEmail().address;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: defaultAddress
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can clear sender_email', async function () {
const id = fixtureManager.get('newsletters', 0).id;
const before = await models.Newsletter.findOne({id});
const beforeEmail = before.get('sender_email');
assert(before.get('sender_email'), 'This test requires a non empty sender_email');
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: ''
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
// Revert back
await before.refresh();
before.set('sender_email', beforeEmail);
await before.save();
});
});
describe('Managed email with custom sending domain', function () {
this.beforeEach(function () {
configUtils.set('hostSettings:managedEmail:enabled', true);
configUtils.set('hostSettings:managedEmail:sendingDomain', 'sendingdomain.com');
});
it('Can set newsletter reply-to to newsletter or support', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'support'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'newsletter'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot clear newsletter reply-to', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: ''
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot set newsletter reply-to to invalid email address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'notvalid'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can set newsletter reply-to to any email address with required verification', async function () {
const id = fixtureManager.get('newsletters', 0).id;
const before = await models.Newsletter.findOne({id});
const beforeSenderReplyTo = before.get('sender_reply_to');
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'hello@acme.com'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot],
meta: {
sent_email_verification: ['sender_reply_to']
}
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await before.refresh();
assert.equal(before.get('sender_reply_to'), beforeSenderReplyTo, 'sender_reply_to should not have changed because it first requires verification');
emailMockReceiver
.assertSentEmailCount(1)
.matchMetadataSnapshot()
.matchHTMLSnapshot([{
pattern: queryStringToken('verifyEmail'),
replacement: 'verifyEmail=REPLACED_TOKEN'
}])
.matchPlaintextSnapshot([{
pattern: queryStringToken('verifyEmail'),
replacement: 'verifyEmail=REPLACED_TOKEN'
}]);
});
it('Can set newsletter reply-to to matching sending domain without required verification', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'anything@sendingdomain.com'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const before = await models.Newsletter.findOne({id});
assert.equal(before.get('sender_reply_to'), 'anything@sendingdomain.com');
emailMockReceiver
.assertSentEmailCount(0);
});
it('Cannot change sender_email to non matching domain', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: 'hello@acme.com'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot set newsletter sender_email to invalid email address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: 'notvalid'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can keep sender_email', async function () {
const id = fixtureManager.get('newsletters', 0).id;
const before = await models.Newsletter.findOne({id});
assert(before.get('sender_email'), 'This test requires a non empty sender_email');
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: before.get('sender_email')
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can set sender_email to address matching sending domain, without verification', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: 'anything@sendingdomain.com'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const before = await models.Newsletter.findOne({id});
assert.equal(before.get('sender_email'), 'anything@sendingdomain.com');
emailMockReceiver
.assertSentEmailCount(0);
});
it('Can clear sender_email', async function () {
const id = fixtureManager.get('newsletters', 0).id;
const before = await models.Newsletter.findOne({id});
const beforeEmail = before.get('sender_email');
assert(before.get('sender_email'), 'This test requires a non empty sender_email');
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: ''
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
// Revert back
await before.refresh();
before.set('sender_email', beforeEmail);
await before.save();
});
});
describe('Self hoster without managed email', function () {
this.beforeEach(function () {
configUtils.set('hostSettings:managedEmail:enabled', false);
configUtils.set('hostSettings:managedEmail:sendingDomain', '');
mockLabsEnabled('newEmailAddresses');
});
it('Can set newsletter reply-to to newsletter or support', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'support'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'newsletter'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot clear newsletter reply-to', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: ''
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Cannot set newsletter reply-to to invalid email address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'notvalid'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can set newsletter reply-to to any email address without required verification', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_reply_to: 'hello@acme.com'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const before = await models.Newsletter.findOne({id});
assert.equal(before.get('sender_reply_to'), 'hello@acme.com');
emailMockReceiver
.assertSentEmailCount(0);
});
it('Can change sender_email to any address without verification', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: 'hello@acme.com'
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
const before = await models.Newsletter.findOne({id});
assert.equal(before.get('sender_email'), 'hello@acme.com');
emailMockReceiver
.assertSentEmailCount(0);
});
it('Cannot set newsletter sender_email to invalid email address', async function () {
const id = fixtureManager.get('newsletters', 0).id;
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: 'notvalid'
}]
})
.expectStatus(422)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
});
it('Can clear sender_email', async function () {
const id = fixtureManager.get('newsletters', 0).id;
const before = await models.Newsletter.findOne({id});
const beforeEmail = before.get('sender_email');
assert(before.get('sender_email'), 'This test requires a non empty sender_email');
await agent.put(`newsletters/${id}`)
.body({
newsletters: [{
sender_email: ''
}]
})
.expectStatus(200)
.matchBodySnapshot({
newsletters: [newsletterSnapshot]
})
.matchHeaderSnapshot({
'content-version': anyContentVersion,
etag: anyEtag
});
// Revert back
await before.refresh();
before.set('sender_email', beforeEmail);
await before.save();
});
});
});

View File

@ -6,6 +6,7 @@ const settingsCache = require('../../../core/shared/settings-cache');
const {agentProvider, fixtureManager, mockManager, matchers} = require('../../utils/e2e-framework');
const {stringMatching, anyEtag, anyUuid, anyContentLength, anyContentVersion} = matchers;
const models = require('../../../core/server/models');
const {mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
const {anyErrorId} = matchers;
const CURRENT_SETTINGS_COUNT = 84;
@ -49,6 +50,7 @@ describe('Settings API', function () {
beforeEach(function () {
mockManager.mockMail();
mockLabsDisabled('newEmailAddresses');
});
afterEach(function () {
@ -102,7 +104,7 @@ describe('Settings API', function () {
const settingsToChange = [
{
key: 'title',
value: []
value: ''
},
{
key: 'codeinjection_head',

View File

@ -420,7 +420,7 @@ describe('Comments API', function () {
it('Can reply to a comment with www domain', async function () {
// Test that the www. is stripped from the default
configUtils.set('url', 'http://www.domain.example/');
await testCanReply(member, {from: 'noreply@domain.example'});
await testCanReply(member, {from: '"Ghost" <noreply@domain.example>'});
});
it('Can reply to a comment with custom support email', async function () {
@ -434,7 +434,7 @@ describe('Comments API', function () {
}
return getStub.wrappedMethod.call(settingsCache, key, options);
});
await testCanReply(member, {from: 'support@example.com'});
await testCanReply(member, {from: '"Ghost" <support@example.com>'});
});
it('Can like a comment', async function () {

View File

@ -540,6 +540,10 @@ a {
exports[`API Versioning Admin API responds with error and sends email ONCE when requested version is BEHIND and CANNOT respond multiple times 3: [metadata 1] 1`] = `
Object {
"encoding": "base64",
"from": "\\"Ghost\\" <noreply@127.0.0.1>",
"generateTextFromHTML": true,
"replyTo": null,
"subject": "Attention required: Your Zapier integration has failed",
"to": "jbloggs@example.com",
}
@ -1040,6 +1044,10 @@ a {
exports[`API Versioning Admin API responds with error when requested version is BEHIND and CANNOT respond 3: [metadata 1] 1`] = `
Object {
"encoding": "base64",
"from": "\\"Ghost\\" <noreply@127.0.0.1>",
"generateTextFromHTML": true,
"replyTo": null,
"subject": "Attention required: Your Zapier integration has failed",
"to": "jbloggs@example.com",
}

View File

@ -239,6 +239,10 @@ If you would no longer like to receive these notifications you can adjust your s
exports[`Incoming Recommendation Emails Sends a different email if we receive a recommendation back 3: [metadata 1] 1`] = `
Object {
"encoding": "base64",
"from": "\\"Ghost\\" <noreply@127.0.0.1>",
"generateTextFromHTML": true,
"replyTo": null,
"subject": "👍 New recommendation: Other Ghost Site",
"to": "jbloggs@example.com",
}
@ -709,6 +713,10 @@ If you would no longer like to receive these notifications you can adjust your s
exports[`Incoming Recommendation Emails Sends an email if we receive a recommendation 4: [metadata 1] 1`] = `
Object {
"encoding": "base64",
"from": "\\"Ghost\\" <noreply@127.0.0.1>",
"generateTextFromHTML": true,
"replyTo": null,
"subject": "👍 New recommendation: Other Ghost Site",
"to": "jbloggs@example.com",
}

View File

@ -0,0 +1,489 @@
const DomainEvents = require('@tryghost/domain-events');
const {Mention} = require('@tryghost/webmentions');
const mentionsService = require('../../../core/server/services/mentions');
const assert = require('assert/strict');
const {agentProvider, fixtureManager, mockManager} = require('../../utils/e2e-framework');
const configUtils = require('../../utils/configUtils');
const {mockLabsDisabled, mockLabsEnabled, mockSetting} = require('../../utils/e2e-framework-mock-manager');
const ObjectId = require('bson-objectid').default;
const {sendEmail, getDefaultNewsletter, getLastEmail} = require('../../utils/batch-email-utils');
const urlUtils = require('../../utils/urlUtils');
let emailMockReceiver, agent, membersAgent;
async function sendNewsletter() {
// Prepare a post and email model
await sendEmail(agent);
}
async function sendRecommendationNotification() {
// incoming recommendation in this case
const webmention = await Mention.create({
source: 'https://www.otherghostsite.com/.well-known/recommendations.json',
target: 'https://www.mysite.com/',
timestamp: new Date(),
payload: null,
resourceId: null,
resourceType: null,
sourceTitle: 'Other Ghost Site',
sourceSiteTitle: 'Other Ghost Site',
sourceAuthor: null,
sourceExcerpt: null,
sourceFavicon: null,
sourceFeaturedImage: null
});
// Mark it as verified
webmention.verify('{"url": "https://www.mysite.com/"}', 'application/json');
assert.ok(webmention.verified);
// Save to repository
await mentionsService.repository.save(webmention);
await DomainEvents.allSettled();
}
async function sendFreeMemberSignupNotification() {
const email = ObjectId().toHexString() + '@email.com';
const membersService = require('../../../core/server/services/members');
await membersService.api.members.create({email, name: 'Member Test'});
await DomainEvents.allSettled();
}
async function sendCommentNotification() {
const postId = fixtureManager.get('posts', 0).id;
await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
parent_id: fixtureManager.get('comments', 0).id,
html: 'This is a reply'
}]})
.expectStatus(201);
}
function configureSite({siteUrl}) {
configUtils.set('url', new URL(siteUrl).href);
}
async function configureNewsletter({sender_email, sender_reply_to, sender_name}) {
const defaultNewsletter = await getDefaultNewsletter();
defaultNewsletter.set('sender_email', sender_email || null);
defaultNewsletter.set('sender_reply_to', sender_reply_to || 'newsletter');
defaultNewsletter.set('sender_name', sender_name || null);
await defaultNewsletter.save();
}
function assertFromAddress(from, replyTo) {
let i = 0;
while (emailMockReceiver.getSentEmail(i)) {
const email = emailMockReceiver.getSentEmail(i);
assert.equal(email.from, from, `From address (${email.from}) of ${i + 1}th email (${email.subject}) does not match ${from}`);
if (!replyTo) {
assert(email.replyTo === null || email.replyTo === undefined, `Unexpected reply-to address (${email.replyTo}) of ${i + 1}th email (${email.subject}), expected none`);
} else {
assert.equal(email.replyTo, replyTo, `ReplyTo address (${email.replyTo}) of ${i + 1}th email (${email.subject}) does not match ${replyTo}`);
}
i += 1;
}
assert(i > 0, 'No emails were sent');
}
async function assertFromAddressNewsletter(aFrom, aReplyTo) {
const email = (await getLastEmail());
const {from} = email;
const replyTo = email['h:Reply-To'];
assert.equal(from, aFrom, `From address (${from}) does not match ${aFrom}`);
if (!aReplyTo) {
assert(replyTo === null || replyTo === undefined, `Unexpected reply-to address (${replyTo}), expected none`);
} else {
assert.equal(replyTo, aReplyTo, `ReplyTo address (${replyTo}) does not match ${aReplyTo}`);
}
}
// Tests the from and replyTo addresses for most emails send from within Ghost.
describe('Email addresses', function () {
before(async function () {
// Can only set site URL once because otherwise agents are messed up
configureSite({
siteUrl: 'http://blog.acme.com'
});
const agents = await agentProvider.getAgentsForMembers();
agent = agents.adminAgent;
membersAgent = agents.membersAgent;
await fixtureManager.init('newsletters', 'members:newsletters', 'users', 'posts', 'comments');
await agent.loginAsAdmin();
await membersAgent.loginAs('member@example.com');
});
beforeEach(async function () {
emailMockReceiver = mockManager.mockMail();
mockManager.mockMailgun();
mockLabsDisabled('newEmailAddresses');
configureSite({
siteUrl: 'http://blog.acme.com'
});
mockSetting('title', 'Example Site');
mockSetting('members_support_address', 'support@address.com');
mockSetting('comments_enabled', 'all');
configUtils.set('mail:from', '"Postmaster" <postmaster@examplesite.com>');
});
afterEach(async function () {
await configUtils.restore();
urlUtils.restore();
mockManager.restore();
});
describe('Legacy setup', function () {
it('[STAFF] sends recommendation notification emails from mail.from', async function () {
await sendRecommendationNotification();
assertFromAddress('"Postmaster" <postmaster@examplesite.com>');
});
it('[STAFF] sends new member notification emails from ghost@domain', async function () {
await sendFreeMemberSignupNotification();
assertFromAddress('"Example Site" <ghost@blog.acme.com>');
});
it('[MEMBERS] send a comment reply notification from the generated noreply email address if support address is set to noreply', async function () {
mockSetting('members_support_address', 'noreply');
await sendCommentNotification();
assertFromAddress('"Example Site" <noreply@blog.acme.com>');
});
it('[MEMBERS] send a comment reply notification from the generated noreply email address if no support address is set', async function () {
mockSetting('members_support_address', '');
await sendCommentNotification();
assertFromAddress('"Example Site" <noreply@blog.acme.com>');
});
it('[MEMBERS] send a comment reply notification from the support address', async function () {
await sendCommentNotification();
assertFromAddress('"Example Site" <support@address.com>');
});
it('[NEWSLETTER] Allows to send a newsletter from any configured email address', async function () {
await configureNewsletter({
sender_email: 'anything@possible.com',
sender_name: 'Anything Possible',
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <anything@possible.com>', '"Anything Possible" <anything@possible.com>');
});
it('[NEWSLETTER] Sends from a generated noreply by default', async function () {
await configureNewsletter({
sender_email: null,
sender_name: 'Anything Possible',
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <noreply@blog.acme.com>', '"Anything Possible" <noreply@blog.acme.com>');
});
it('[NEWSLETTER] Can set the reply to to the support address', async function () {
await configureNewsletter({
sender_email: null,
sender_name: 'Anything Possible',
sender_reply_to: 'support'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <noreply@blog.acme.com>', 'support@address.com');
});
it('[NEWSLETTER] Uses site title as default sender name', async function () {
await configureNewsletter({
sender_email: null,
sender_name: null,
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Example Site" <noreply@blog.acme.com>', '"Example Site" <noreply@blog.acme.com>');
});
});
describe('Custom sending domain', function () {
beforeEach(async function () {
configUtils.set('hostSettings:managedEmail:enabled', true);
configUtils.set('hostSettings:managedEmail:sendingDomain', 'sendingdomain.com');
configUtils.set('mail:from', '"Default Address" <default@sendingdomain.com>');
});
it('[STAFF] sends recommendation emails from mail.from config variable', async function () {
await sendRecommendationNotification();
assertFromAddress('"Default Address" <default@sendingdomain.com>');
});
it('[STAFF] sends new member notification emails from mail.from config variable', async function () {
await sendFreeMemberSignupNotification();
assertFromAddress('"Default Address" <default@sendingdomain.com>');
});
it('[STAFF] Uses site title as email address name if no name set in mail:from', async function () {
configUtils.set('mail:from', 'default@sendingdomain.com');
await sendFreeMemberSignupNotification();
assertFromAddress('"Example Site" <default@sendingdomain.com>');
});
it('[MEMBERS] send a comment reply notification from the configured sending domain if support address is set to noreply', async function () {
mockSetting('members_support_address', 'noreply');
await sendCommentNotification();
assertFromAddress('"Example Site" <noreply@sendingdomain.com>');
});
it('[MEMBERS] send a comment reply notification from the default email address if no support address is set', async function () {
mockSetting('members_support_address', '');
await sendCommentNotification();
assertFromAddress('"Default Address" <default@sendingdomain.com>');
});
it('[MEMBERS] send a comment reply notification from the support address only if it matches the sending domain', async function () {
mockSetting('members_support_address', 'support@sendingdomain.com');
await sendCommentNotification();
assertFromAddress('"Example Site" <support@sendingdomain.com>');
});
it('[MEMBERS] send a comment reply notification with replyTo set to the support address if it doesn\'t match the sending domain', async function () {
await sendCommentNotification();
assertFromAddress('"Default Address" <default@sendingdomain.com>', 'support@address.com');
});
it('[NEWSLETTER] Does not allow to send a newsletter from any configured email address, instead uses mail.from', async function () {
await configureNewsletter({
sender_email: 'anything@possible.com',
sender_name: 'Anything Possible',
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <default@sendingdomain.com>', '"Anything Possible" <default@sendingdomain.com>');
});
it('[NEWSLETTER] Does allow to send a newsletter from a custom sending domain', async function () {
await configureNewsletter({
sender_email: 'anything@sendingdomain.com',
sender_name: 'Anything Possible',
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <anything@sendingdomain.com>', '"Anything Possible" <anything@sendingdomain.com>');
});
it('[NEWSLETTER] Does allow to set the replyTo address to any address', async function () {
await configureNewsletter({
sender_email: 'anything@sendingdomain.com',
sender_name: 'Anything Possible',
sender_reply_to: 'anything@possible.com'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <anything@sendingdomain.com>', 'anything@possible.com');
});
it('[NEWSLETTER] Can set the reply to to the support address', async function () {
await configureNewsletter({
sender_email: null,
sender_name: 'Anything Possible',
sender_reply_to: 'support'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <default@sendingdomain.com>', 'support@address.com');
});
it('[NEWSLETTER] Uses site title as default sender name', async function () {
await configureNewsletter({
sender_email: null,
sender_name: null,
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Example Site" <default@sendingdomain.com>', '"Example Site" <default@sendingdomain.com>');
});
});
describe('Managed email without custom sending domain', function () {
beforeEach(async function () {
configUtils.set('hostSettings:managedEmail:enabled', true);
configUtils.set('hostSettings:managedEmail:sendingDomain', undefined);
configUtils.set('mail:from', 'default@sendingdomain.com');
});
it('[STAFF] sends recommendation emails from mail.from config variable', async function () {
await sendRecommendationNotification();
assertFromAddress('"Example Site" <default@sendingdomain.com>');
});
it('[STAFF] sends new member notification emails from mail.from config variable', async function () {
await sendFreeMemberSignupNotification();
assertFromAddress('"Example Site" <default@sendingdomain.com>');
});
it('[STAFF] Prefers to use the mail:from sending name if set above the site name', async function () {
configUtils.set('mail:from', '"Default Address" <default@sendingdomain.com>');
await sendFreeMemberSignupNotification();
assertFromAddress('"Default Address" <default@sendingdomain.com>');
});
it('[MEMBERS] send a comment reply notification from mail.from if support address is set to noreply', async function () {
mockSetting('members_support_address', 'noreply');
await sendCommentNotification();
assertFromAddress('"Example Site" <default@sendingdomain.com>', 'noreply@blog.acme.com');
});
it('[MEMBERS] send a comment reply notification from mail.from if no support address is set, without a replyTo', async function () {
mockSetting('members_support_address', '');
await sendCommentNotification();
assertFromAddress('"Example Site" <default@sendingdomain.com>');
});
it('[MEMBERS] send a comment reply notification from mail.from with member support address set as replyTo', async function () {
mockSetting('members_support_address', 'hello@acme.com');
await sendCommentNotification();
assertFromAddress('"Example Site" <default@sendingdomain.com>', 'hello@acme.com');
});
it('[NEWSLETTER] Does not allow to send a newsletter from any configured email address, instead uses mail.from', async function () {
await configureNewsletter({
sender_email: 'anything@possible.com',
sender_name: 'Anything Possible',
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <default@sendingdomain.com>', '"Anything Possible" <default@sendingdomain.com>');
});
it('[NEWSLETTER] Does allow to set the replyTo address to any address', async function () {
await configureNewsletter({
sender_email: 'anything@possible.com',
sender_name: 'Anything Possible',
sender_reply_to: 'anything@possible.com'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <default@sendingdomain.com>', 'anything@possible.com');
});
it('[NEWSLETTER] Can set the reply to to the support address', async function () {
await configureNewsletter({
sender_email: null,
sender_name: 'Anything Possible',
sender_reply_to: 'support'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <default@sendingdomain.com>', 'support@address.com');
});
it('[NEWSLETTER] Uses site title as default sender name', async function () {
await configureNewsletter({
sender_email: null,
sender_name: null,
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Example Site" <default@sendingdomain.com>', '"Example Site" <default@sendingdomain.com>');
});
});
describe('Self-hosted', function () {
beforeEach(async function () {
mockLabsEnabled('newEmailAddresses');
configUtils.set('hostSettings:managedEmail:enabled', false);
configUtils.set('hostSettings:managedEmail:sendingDomain', undefined);
configUtils.set('mail:from', '"Default Address" <default@sendingdomain.com>');
});
it('[STAFF] sends recommendation emails from mail.from config variable', async function () {
await sendRecommendationNotification();
assertFromAddress('"Default Address" <default@sendingdomain.com>');
});
it('[STAFF] sends new member notification emails from mail.from config variable', async function () {
await sendFreeMemberSignupNotification();
assertFromAddress('"Default Address" <default@sendingdomain.com>');
});
it('[STAFF] Uses site title as email address name if no name set in mail:from', async function () {
configUtils.set('mail:from', 'default@sendingdomain.com');
await sendFreeMemberSignupNotification();
assertFromAddress('"Example Site" <default@sendingdomain.com>');
});
it('[MEMBERS] send a comment reply notification with noreply support address', async function () {
mockSetting('members_support_address', 'noreply');
await sendCommentNotification();
assertFromAddress('"Example Site" <noreply@blog.acme.com>');
});
it('[MEMBERS] send a comment reply notification without support address', async function () {
mockSetting('members_support_address', '');
await sendCommentNotification();
// Use default
assertFromAddress('"Default Address" <default@sendingdomain.com>');
});
it('[MEMBERS] send a comment reply notification from chosen support address', async function () {
mockSetting('members_support_address', 'hello@acme.com');
await sendCommentNotification();
assertFromAddress('"Example Site" <hello@acme.com>');
});
it('[NEWSLETTER] Does allow to send a newsletter from any configured email address', async function () {
await configureNewsletter({
sender_email: 'anything@possible.com',
sender_name: 'Anything Possible',
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <anything@possible.com>', '"Anything Possible" <anything@possible.com>');
});
it('[NEWSLETTER] Does allow to set the replyTo address to any address', async function () {
await configureNewsletter({
sender_email: 'anything@possible.com',
sender_name: 'Anything Possible',
sender_reply_to: 'anything@noreply.com'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <anything@possible.com>', 'anything@noreply.com');
});
it('[NEWSLETTER] Can set the reply to to the support address', async function () {
await configureNewsletter({
sender_email: null,
sender_name: 'Anything Possible',
sender_reply_to: 'support'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Anything Possible" <default@sendingdomain.com>', 'support@address.com');
});
it('[NEWSLETTER] Uses site title as default sender name', async function () {
await configureNewsletter({
sender_email: null,
sender_name: null,
sender_reply_to: 'newsletter'
});
await sendNewsletter();
await assertFromAddressNewsletter('"Example Site" <default@sendingdomain.com>', '"Example Site" <default@sendingdomain.com>');
});
});
});

View File

@ -239,6 +239,9 @@ test@example.com [test@example.com]"
exports[`Authentication API Blog setup complete setup 5: [metadata 1] 1`] = `
Object {
"encoding": "base64",
"from": "noreply@127.0.0.1",
"generateTextFromHTML": true,
"subject": "Your New Ghost Site",
"to": "test@example.com",
}
@ -514,6 +517,9 @@ test@example.com [test@example.com]"
exports[`Authentication API Blog setup complete setup with default theme 5: [metadata 1] 1`] = `
Object {
"encoding": "base64",
"from": "noreply@127.0.0.1",
"generateTextFromHTML": true,
"subject": "Your New Ghost Site",
"to": "test@example.com",
}

View File

@ -341,6 +341,10 @@ describe('Members Importer API', function () {
assert(!!settingsCache.get('email_verification_required'), 'Email verification should now be required');
mockManager.assert.sentEmail({
subject: 'Your member import is complete'
});
mockManager.assert.sentEmail({
subject: 'Email needs verification'
});

View File

@ -7,6 +7,7 @@ const configUtils = require('../../../../utils/configUtils');
const urlUtils = require('../../../../../core/shared/url-utils');
let mailer;
const assert = require('assert/strict');
const emailAddress = require('../../../../../core/server/services/email-address');
// Mock SMTP config
const SMTP = {
@ -41,6 +42,11 @@ const mailDataIncomplete = {
const sandbox = sinon.createSandbox();
describe('Mail: Ghostmailer', function () {
before(function () {
emailAddress.init();
sinon.restore();
});
afterEach(async function () {
mailer = null;
await configUtils.restore();

View File

@ -8,7 +8,7 @@ const mail = require('../../../../../core/server/services/mail');
// Mocked utilities
const urlUtils = require('../../../../utils/urlUtils');
const {mockManager} = require('../../../../utils/e2e-framework');
const {EmailAddressService} = require('@tryghost/email-addresses');
const NewslettersService = require('../../../../../core/server/services/newsletters/NewslettersService');
class TestTokenProvider {
@ -41,7 +41,30 @@ describe('NewslettersService', function () {
mail,
singleUseTokenProvider: tokenProvider,
urlUtils: urlUtils.stubUrlUtilsFromConfig(),
limitService
limitService,
emailAddressService: {
service: new EmailAddressService({
getManagedEmailEnabled: () => {
return false;
},
getSendingDomain: () => {
return null;
},
getDefaultEmail: () => {
return {
address: 'default@example.com'
};
},
isValidEmailAddress: () => {
return true;
},
labs: {
isSet() {
return false;
}
}
})
}
});
});

View File

@ -188,8 +188,10 @@ exports[`UNIT > Settings BREAD Service: edit setting members_support_address tri
exports[`UNIT > Settings BREAD Service: edit setting members_support_address triggers email verification 3: [metadata 1] 1`] = `
Object {
"encoding": "base64",
"forceTextContent": true,
"from": "noreply@example.com",
"from": "\\"Ghost at 127.0.0.1\\" <noreply@example.com>",
"generateTextFromHTML": false,
"subject": "Verify email address",
"to": "support@example.com",
}

View File

@ -59,7 +59,7 @@ async function createPublishedPostEmail(agent, settings = {}, email_recipient_fi
let lastEmailModel;
/**
* @typedef {{html: string, plaintext: string, emailModel: any, recipientData: any}} SendEmail
* @typedef {{html: string, plaintext: string, emailModel: any, recipientData: any, from: string, replyTo?: string}} SendEmail
*/
/**
@ -214,5 +214,6 @@ module.exports = {
sendEmail,
sendFailedEmail,
retryEmail,
matchEmailSnapshot
matchEmailSnapshot,
getLastEmail
};

View File

@ -15,7 +15,7 @@ let emailCount = 0;
// Mockable services
const mailService = require('../../core/server/services/mail/index');
const originalMailServiceSend = mailService.GhostMailer.prototype.send;
const originalMailServiceSendMail = mailService.GhostMailer.prototype.sendMail;
const labs = require('../../core/shared/labs');
const events = require('../../core/server/lib/common/events');
const settingsCache = require('../../core/shared/settings-cache');
@ -106,8 +106,8 @@ const mockMail = (response = 'Mail is disabled') => {
sendResponse: response
});
mailService.GhostMailer.prototype.send = mockMailReceiver.send.bind(mockMailReceiver);
mocks.mail = sinon.spy(mailService.GhostMailer.prototype, 'send');
mailService.GhostMailer.prototype.sendMail = mockMailReceiver.send.bind(mockMailReceiver);
mocks.mail = sinon.spy(mailService.GhostMailer.prototype, 'sendMail');
mocks.mockMailReceiver = mockMailReceiver;
return mockMailReceiver;
@ -281,7 +281,7 @@ const restore = () => {
mocks.webhookMockReceiver.reset();
}
mailService.GhostMailer.prototype.send = originalMailServiceSend;
mailService.GhostMailer.prototype.sendMail = originalMailServiceSendMail;
// Disable network again after restoring sinon
disableNetwork();

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/ts'
]
};

View File

@ -0,0 +1,21 @@
# Email addresses
## Usage
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View File

@ -0,0 +1,34 @@
{
"name": "@tryghost/email-addresses",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/email-addresses",
"author": "Ghost Foundation",
"private": true,
"main": "build/index.js",
"types": "build/index.d.ts",
"scripts": {
"dev": "tsc --watch --preserveWatchOutput --sourceMap",
"build": "tsc",
"build:ts": "yarn build",
"prepare": "tsc",
"test:unit": "NODE_ENV=testing c8 --src src --all --reporter text --reporter cobertura -- mocha --reporter dot -r ts-node/register './test/**/*.test.ts'",
"test": "yarn test:types && yarn test:unit",
"test:types": "tsc --noEmit",
"lint:code": "eslint src/ --ext .ts --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .ts --cache"
},
"files": [
"build"
],
"devDependencies": {
"c8": "8.0.1",
"mocha": "10.2.0",
"sinon": "15.2.0",
"ts-node": "10.9.1",
"typescript": "5.3.2"
},
"dependencies": {
"nodemailer": "^6.6.3"
}
}

View File

@ -0,0 +1,41 @@
import addressparser from 'nodemailer/lib/addressparser';
export type EmailAddress = {
address: string,
name?: string
}
export class EmailAddressParser {
static parse(email: string) : EmailAddress|null {
if (!email || typeof email !== 'string' || !email.length) {
return null;
}
const parsed = addressparser(email);
if (parsed.length !== 1) {
return null;
}
const first = parsed[0];
// Check first has a group property
if ('group' in first) {
// Unsupported format
return null;
}
return {
address: first.address,
name: first.name || undefined
};
}
static stringify(email: EmailAddress) : string {
if (!email.name) {
return email.address;
}
const escapedName = email.name.replace(/"/g, '\\"');
return `"${escapedName}" <${email.address}>`;
}
}

View File

@ -0,0 +1,185 @@
import logging from '@tryghost/logging';
import {EmailAddress, EmailAddressParser} from './EmailAddressParser';
export type EmailAddresses = {
from: EmailAddress,
replyTo?: EmailAddress
}
export type EmailAddressesValidation = {
allowed: boolean,
verificationEmailRequired: boolean,
reason?: string
}
export type EmailAddressType = 'from' | 'replyTo';
type LabsService = {
isSet: (flag: string) => boolean
}
export class EmailAddressService {
#getManagedEmailEnabled: () => boolean;
#getSendingDomain: () => string | null;
#getDefaultEmail: () => EmailAddress;
#isValidEmailAddress: (email: string) => boolean;
#labs: LabsService;
constructor(dependencies: {
getManagedEmailEnabled: () => boolean,
getSendingDomain: () => string | null,
getDefaultEmail: () => EmailAddress,
isValidEmailAddress: (email: string) => boolean,
labs: LabsService
}) {
this.#getManagedEmailEnabled = dependencies.getManagedEmailEnabled;
this.#getSendingDomain = dependencies.getSendingDomain;
this.#getDefaultEmail = dependencies.getDefaultEmail;
this.#isValidEmailAddress = dependencies.isValidEmailAddress;
this.#labs = dependencies.labs;
}
get sendingDomain(): string | null {
return this.#getSendingDomain();
}
get managedEmailEnabled(): boolean {
return this.#getManagedEmailEnabled();
}
get useNewEmailAddresses() {
return this.managedEmailEnabled || this.#labs.isSet('newEmailAddresses');
}
get defaultFromEmail(): EmailAddress {
return this.#getDefaultEmail();
}
getAddressFromString(from: string, replyTo?: string): EmailAddresses {
const parsedFrom = EmailAddressParser.parse(from);
const parsedReplyTo = replyTo ? EmailAddressParser.parse(replyTo) : undefined;
return this.getAddress({
from: parsedFrom ?? this.defaultFromEmail,
replyTo: parsedReplyTo ?? undefined
});
}
/**
* When sending an email, we should always ensure DMARC alignment.
* Because of that, we restrict which email addresses we send from. All emails should be either
* send from a configured domain (hostSettings.managedEmail.sendingDomains), or from the configured email address (mail.from).
*
* If we send an email from an email address that doesn't pass, we'll just default to the default email address,
* and instead add a replyTo email address from the requested from address.
*/
getAddress(preferred: EmailAddresses): EmailAddresses {
if (preferred.replyTo && !this.#isValidEmailAddress(preferred.replyTo.address)) {
// Remove invalid replyTo addresses
logging.error(`[EmailAddresses] Invalid replyTo address: ${preferred.replyTo.address}`);
preferred.replyTo = undefined;
}
// Validate the from address
if (!this.#isValidEmailAddress(preferred.from.address)) {
// Never allow an invalid email address
return {
from: this.defaultFromEmail,
replyTo: preferred.replyTo || undefined
};
}
if (!this.managedEmailEnabled) {
// Self hoster or legacy Ghost Pro
return preferred;
}
// Case: always allow the default from address
if (preferred.from.address === this.defaultFromEmail.address) {
if (!preferred.from.name) {
// Use the default sender name if it is missing
preferred.from.name = this.defaultFromEmail.name;
}
return preferred;
}
if (this.sendingDomain) {
// Check if FROM address is from the sending domain
if (preferred.from.address.endsWith(`@${this.sendingDomain}`)) {
return preferred;
}
// Invalid configuration: don't allow to send from this sending domain
logging.error(`[EmailAddresses] Invalid configuration: cannot send emails from ${preferred.from} when sending domain is ${this.sendingDomain}`);
}
// Only allow to send from the configured from address
const address = {
from: this.defaultFromEmail,
replyTo: preferred.replyTo || preferred.from
};
// Do allow to change the sender name if requested
if (preferred.from.name) {
address.from.name = preferred.from.name;
}
if (address.replyTo.address === address.from.address) {
return {
from: address.from
};
}
return address;
}
/**
* When changing any from or reply to addresses in the system, we need to validate them
*/
validate(email: string, type: EmailAddressType): EmailAddressesValidation {
if (!this.#isValidEmailAddress(email)) {
// Never allow an invalid email address
return {
allowed: email === this.defaultFromEmail.address, // Localhost email noreply@127.0.0.1 is marked as invalid, but we should allow it
verificationEmailRequired: false,
reason: 'invalid'
};
}
if (!this.managedEmailEnabled) {
// Self hoster or legacy Ghost Pro
return {
allowed: true,
verificationEmailRequired: type === 'from' && !this.useNewEmailAddresses
};
}
if (this.sendingDomain) {
// Only allow it if it ends with the sending domain
if (email.endsWith(`@${this.sendingDomain}`)) {
return {
allowed: true,
verificationEmailRequired: false
};
}
// Use same restrictions as one without a sending domain for other addresses
}
// Only allow to edit the replyTo address, with verification
if (type === 'replyTo') {
return {
allowed: true,
verificationEmailRequired: true
};
}
// Not allowed to change from
return {
allowed: email === this.defaultFromEmail.address,
verificationEmailRequired: false,
reason: 'not allowed'
};
}
}

View File

@ -0,0 +1,2 @@
export * from './EmailAddressService';
export * from './EmailAddressParser';

View File

@ -0,0 +1,3 @@
declare module '@tryghost/errors';
declare module '@tryghost/tpl';
declare module '@tryghost/logging';

View File

@ -0,0 +1,7 @@
module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -0,0 +1,8 @@
import assert from 'assert/strict';
describe('Hello world', function () {
it('Runs a test', function () {
// TODO: Write me!
assert.ok(require('../'));
});
});

View File

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"include": [
"src/**/*"
],
"compilerOptions": {
"outDir": "build"
}
}

View File

@ -9,6 +9,7 @@ const {DateTime} = require('luxon');
const htmlToPlaintext = require('@tryghost/html-to-plaintext');
const tpl = require('@tryghost/tpl');
const cheerio = require('cheerio');
const {EmailAddressParser} = require('@tryghost/email-addresses');
const messages = {
subscriptionStatus: {
@ -108,6 +109,7 @@ class EmailRenderer {
#memberAttributionService;
#outboundLinkTagger;
#audienceFeedbackService;
#emailAddressService;
#labs;
#models;
@ -126,6 +128,7 @@ class EmailRenderer {
* @param {object} dependencies.linkTracking
* @param {object} dependencies.memberAttributionService
* @param {object} dependencies.audienceFeedbackService
* @param {object} dependencies.emailAddressService
* @param {object} dependencies.outboundLinkTagger
* @param {object} dependencies.labs
* @param {{Post: object}} dependencies.models
@ -142,6 +145,7 @@ class EmailRenderer {
linkTracking,
memberAttributionService,
audienceFeedbackService,
emailAddressService,
outboundLinkTagger,
labs,
models
@ -157,6 +161,7 @@ class EmailRenderer {
this.#linkTracking = linkTracking;
this.#memberAttributionService = memberAttributionService;
this.#audienceFeedbackService = audienceFeedbackService;
this.#emailAddressService = emailAddressService;
this.#outboundLinkTagger = outboundLinkTagger;
this.#labs = labs;
this.#models = models;
@ -166,7 +171,7 @@ class EmailRenderer {
return post.related('posts_meta')?.get('email_subject') || post.get('title');
}
getFromAddress(_post, newsletter) {
#getRawFromAddress(post, newsletter) {
let senderName = this.#settingsCache.get('title') ? this.#settingsCache.get('title').replace(/"/g, '\\"') : '';
if (newsletter.get('sender_name')) {
senderName = newsletter.get('sender_name');
@ -185,8 +190,19 @@ class EmailRenderer {
fromAddress = localAddress;
}
}
return {
address: fromAddress,
name: senderName || undefined
};
}
return senderName ? `"${senderName}" <${fromAddress}>` : fromAddress;
getFromAddress(post, newsletter) {
// Clean from address to ensure DMARC alignment
const addresses = this.#emailAddressService.getAddress({
from: this.#getRawFromAddress(post, newsletter)
});
return EmailAddressParser.stringify(addresses.from);
}
/**
@ -198,7 +214,21 @@ class EmailRenderer {
if (newsletter.get('sender_reply_to') === 'support') {
return this.#settingsHelpers.getMembersSupportAddress();
}
return this.getFromAddress(post, newsletter);
if (newsletter.get('sender_reply_to') === 'newsletter') {
return this.getFromAddress(post, newsletter);
}
const addresses = this.#emailAddressService.getAddress({
from: this.#getRawFromAddress(post, newsletter),
replyTo: {
address: newsletter.get('sender_reply_to')
}
});
if (addresses.replyTo) {
return EmailAddressParser.stringify(addresses.replyTo);
}
return null;
}
/**

View File

@ -681,6 +681,11 @@ describe('Email renderer', function () {
},
labs: {
isSet: () => false
},
emailAddressService: {
getAddress(addresses) {
return addresses;
}
}
});
@ -723,6 +728,11 @@ describe('Email renderer', function () {
});
describe('getReplyToAddress', function () {
let emailAddressService = {
getAddress(addresses) {
return addresses;
}
};
let emailRenderer = new EmailRenderer({
settingsCache: {
get: (key) => {
@ -741,7 +751,8 @@ describe('Email renderer', function () {
},
labs: {
isSet: () => false
}
},
emailAddressService
});
it('returns support address', function () {
@ -763,6 +774,31 @@ describe('Email renderer', function () {
const response = emailRenderer.getReplyToAddress({}, newsletter);
response.should.equal(`"Ghost" <ghost@example.com>`);
});
it('returns correct custom reply to address', function () {
const newsletter = createModel({
sender_email: 'ghost@example.com',
sender_name: 'Ghost',
sender_reply_to: 'anything@iwant.com'
});
const response = emailRenderer.getReplyToAddress({}, newsletter);
assert.equal(response, 'anything@iwant.com');
});
it('handles removed replyto addresses', function () {
const newsletter = createModel({
sender_email: 'ghost@example.com',
sender_name: 'Ghost',
sender_reply_to: 'anything@iwant.com'
});
emailAddressService.getAddress = ({from}) => {
return {
from
};
};
const response = emailRenderer.getReplyToAddress({}, newsletter);
assert.equal(response, null);
});
});
describe('getSegments', function () {

View File

@ -2,6 +2,7 @@ const {promises: fs, readFileSync} = require('fs');
const path = require('path');
const moment = require('moment');
const glob = require('glob');
const {EmailAddressParser} = require('@tryghost/email-addresses');
class StaffServiceEmails {
constructor({logging, models, mailer, settingsHelpers, settingsCache, urlUtils, labs}) {
@ -420,6 +421,9 @@ class StaffServiceEmails {
}
get fromEmailAddress() {
if (this.settingsHelpers.useNewEmailAddresses()) {
return EmailAddressParser.stringify(this.settingsHelpers.getDefaultEmail());
}
return `ghost@${this.defaultEmailDomain}`;
}

View File

@ -25,6 +25,7 @@
"dependencies": {
"lodash": "4.17.21",
"moment": "2.29.1",
"handlebars": "4.7.8"
"handlebars": "4.7.8",
"@tryghost/email-addresses": "0.0.0"
}
}

View File

@ -152,6 +152,9 @@ describe('StaffService', function () {
const settingsHelpers = {
getDefaultEmailDomain: () => {
return 'ghost.example';
},
useNewEmailAddresses: () => {
return false;
}
};