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:
parent
17804dd3ac
commit
17ec1e8937
2
.github/scripts/dev.js
vendored
2
.github/scripts/dev.js
vendored
@ -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: {}
|
||||
|
@ -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}"
|
||||
],
|
||||
|
@ -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(),
|
||||
|
@ -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',
|
||||
|
@ -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;
|
3
ghost/core/core/server/services/email-address/index.js
Normal file
3
ghost/core/core/server/services/email-address/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
const EmailAddressServiceWrapper = require('./EmailAddressServiceWrapper');
|
||||
|
||||
module.exports = new EmailAddressServiceWrapper();
|
@ -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}
|
||||
});
|
||||
|
@ -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})
|
||||
});
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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});
|
||||
|
@ -49,7 +49,8 @@ const ALPHA_FEATURES = [
|
||||
'adminXOffers',
|
||||
'filterEmailDisabled',
|
||||
'adminXDemo',
|
||||
'tkReminders'
|
||||
'tkReminders',
|
||||
'newEmailAddresses'
|
||||
];
|
||||
|
||||
module.exports.GA_KEYS = [...GA_FEATURES];
|
||||
|
@ -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",
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
@ -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 () {
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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',
|
||||
|
@ -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 () {
|
||||
|
@ -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",
|
||||
}
|
||||
|
@ -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",
|
||||
}
|
||||
|
489
ghost/core/test/integration/services/q-email-addresses.test.js
Normal file
489
ghost/core/test/integration/services/q-email-addresses.test.js
Normal 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>');
|
||||
});
|
||||
});
|
||||
});
|
@ -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",
|
||||
}
|
||||
|
@ -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'
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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",
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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();
|
||||
|
6
ghost/email-addresses/.eslintrc.js
Normal file
6
ghost/email-addresses/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/ts'
|
||||
]
|
||||
};
|
21
ghost/email-addresses/README.md
Normal file
21
ghost/email-addresses/README.md
Normal 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
|
||||
|
34
ghost/email-addresses/package.json
Normal file
34
ghost/email-addresses/package.json
Normal 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"
|
||||
}
|
||||
}
|
41
ghost/email-addresses/src/EmailAddressParser.ts
Normal file
41
ghost/email-addresses/src/EmailAddressParser.ts
Normal 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}>`;
|
||||
}
|
||||
}
|
185
ghost/email-addresses/src/EmailAddressService.ts
Normal file
185
ghost/email-addresses/src/EmailAddressService.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
2
ghost/email-addresses/src/index.ts
Normal file
2
ghost/email-addresses/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './EmailAddressService';
|
||||
export * from './EmailAddressParser';
|
3
ghost/email-addresses/src/libraries.d.ts
vendored
Normal file
3
ghost/email-addresses/src/libraries.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
declare module '@tryghost/errors';
|
||||
declare module '@tryghost/tpl';
|
||||
declare module '@tryghost/logging';
|
7
ghost/email-addresses/test/.eslintrc.js
Normal file
7
ghost/email-addresses/test/.eslintrc.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/test'
|
||||
]
|
||||
};
|
8
ghost/email-addresses/test/hello.test.ts
Normal file
8
ghost/email-addresses/test/hello.test.ts
Normal 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('../'));
|
||||
});
|
||||
});
|
9
ghost/email-addresses/tsconfig.json
Normal file
9
ghost/email-addresses/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"outDir": "build"
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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 () {
|
||||
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -152,6 +152,9 @@ describe('StaffService', function () {
|
||||
const settingsHelpers = {
|
||||
getDefaultEmailDomain: () => {
|
||||
return 'ghost.example';
|
||||
},
|
||||
useNewEmailAddresses: () => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user