From 17ec1e893718a7675aaf9dc07e27df30c21348e5 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Thu, 23 Nov 2023 10:25:30 +0100 Subject: [PATCH] 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 --- .github/scripts/dev.js | 2 +- ghost/core/.c8rc.json | 4 +- ghost/core/core/boot.js | 4 + ghost/core/core/server/data/schema/schema.js | 2 +- .../EmailAddressServiceWrapper.js | 39 + .../server/services/email-address/index.js | 3 + .../email-service/EmailServiceWrapper.js | 2 + .../core/server/services/mail/GhostMailer.js | 55 +- .../core/server/services/members/service.js | 9 +- .../newsletters/NewslettersService.js | 71 +- .../core/server/services/newsletters/index.js | 4 +- .../settings-helpers/SettingsHelpers.js | 69 +- .../server/services/settings-helpers/index.js | 3 +- ghost/core/core/shared/labs.js | 3 +- .../admin/__snapshots__/members.test.js.snap | 5 +- .../__snapshots__/newsletters.test.js.snap | 2231 ++++++++++++++++- .../admin/__snapshots__/settings.test.js.snap | 12 +- ghost/core/test/e2e-api/admin/members.test.js | 3 +- .../test/e2e-api/admin/newsletters.test.js | 788 +++++- .../core/test/e2e-api/admin/settings.test.js | 4 +- .../e2e-api/members-comments/comments.test.js | 4 +- .../shared/__snapshots__/version.test.js.snap | 8 + .../recommendation-emails.test.js.snap | 8 + .../services/q-email-addresses.test.js | 489 ++++ .../__snapshots__/authentication.test.js.snap | 6 + .../api/admin/members-importer.test.js | 4 + .../server/services/mail/GhostMailer.test.js | 6 + .../services/newsletters/service.test.js | 27 +- .../settings-bread-service.test.js.snap | 4 +- ghost/core/test/utils/batch-email-utils.js | 5 +- .../test/utils/e2e-framework-mock-manager.js | 8 +- ghost/email-addresses/.eslintrc.js | 6 + ghost/email-addresses/README.md | 21 + ghost/email-addresses/package.json | 34 + .../email-addresses/src/EmailAddressParser.ts | 41 + .../src/EmailAddressService.ts | 185 ++ ghost/email-addresses/src/index.ts | 2 + ghost/email-addresses/src/libraries.d.ts | 3 + ghost/email-addresses/test/.eslintrc.js | 7 + ghost/email-addresses/test/hello.test.ts | 8 + ghost/email-addresses/tsconfig.json | 9 + ghost/email-service/lib/EmailRenderer.js | 36 +- .../email-service/test/email-renderer.test.js | 38 +- ghost/staff-service/lib/StaffServiceEmails.js | 4 + ghost/staff-service/package.json | 3 +- .../staff-service/test/staff-service.test.js | 3 + 46 files changed, 4214 insertions(+), 68 deletions(-) create mode 100644 ghost/core/core/server/services/email-address/EmailAddressServiceWrapper.js create mode 100644 ghost/core/core/server/services/email-address/index.js create mode 100644 ghost/core/test/integration/services/q-email-addresses.test.js create mode 100644 ghost/email-addresses/.eslintrc.js create mode 100644 ghost/email-addresses/README.md create mode 100644 ghost/email-addresses/package.json create mode 100644 ghost/email-addresses/src/EmailAddressParser.ts create mode 100644 ghost/email-addresses/src/EmailAddressService.ts create mode 100644 ghost/email-addresses/src/index.ts create mode 100644 ghost/email-addresses/src/libraries.d.ts create mode 100644 ghost/email-addresses/test/.eslintrc.js create mode 100644 ghost/email-addresses/test/hello.test.ts create mode 100644 ghost/email-addresses/tsconfig.json diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index fb7ded3ef7..f1638b5c03 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -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: {} diff --git a/ghost/core/.c8rc.json b/ghost/core/.c8rc.json index f76eb885f7..c1eddd0a0b 100644 --- a/ghost/core/.c8rc.json +++ b/ghost/core/.c8rc.json @@ -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}" ], diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index bc79b9d7c8..1b86330851 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -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(), diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 5cf5703cb8..68567d27b6 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -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', diff --git a/ghost/core/core/server/services/email-address/EmailAddressServiceWrapper.js b/ghost/core/core/server/services/email-address/EmailAddressServiceWrapper.js new file mode 100644 index 0000000000..e6e6086177 --- /dev/null +++ b/ghost/core/core/server/services/email-address/EmailAddressServiceWrapper.js @@ -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; diff --git a/ghost/core/core/server/services/email-address/index.js b/ghost/core/core/server/services/email-address/index.js new file mode 100644 index 0000000000..c6ba540242 --- /dev/null +++ b/ghost/core/core/server/services/email-address/index.js @@ -0,0 +1,3 @@ +const EmailAddressServiceWrapper = require('./EmailAddressServiceWrapper'); + +module.exports = new EmailAddressServiceWrapper(); diff --git a/ghost/core/core/server/services/email-service/EmailServiceWrapper.js b/ghost/core/core/server/services/email-service/EmailServiceWrapper.js index fed21f955e..9a86528793 100644 --- a/ghost/core/core/server/services/email-service/EmailServiceWrapper.js +++ b/ghost/core/core/server/services/email-service/EmailServiceWrapper.js @@ -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} }); diff --git a/ghost/core/core/server/services/mail/GhostMailer.js b/ghost/core/core/server/services/mail/GhostMailer.js index d62ae5236e..abfdc42cd9 100644 --- a/ghost/core/core/server/services/mail/GhostMailer.js +++ b/ghost/core/core/server/services/mail/GhostMailer.js @@ -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}) }); diff --git a/ghost/core/core/server/services/members/service.js b/ghost/core/core/server/services/members/service.js index c6a6b843a9..e3a1508b67 100644 --- a/ghost/core/core/server/services/members/service.js +++ b/ghost/core/core/server/services/members/service.js @@ -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 }); } diff --git a/ghost/core/core/server/services/newsletters/NewslettersService.js b/ghost/core/core/server/services/newsletters/NewslettersService.js index 1b7710cc68..abbe71ac60 100644 --- a/ghost/core/core/server/services/newsletters/NewslettersService.js +++ b/ghost/core/core/server/services/newsletters/NewslettersService.js @@ -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 = { diff --git a/ghost/core/core/server/services/newsletters/index.js b/ghost/core/core/server/services/newsletters/index.js index 2e3ada979d..ef0de97226 100644 --- a/ghost/core/core/server/services/newsletters/index.js +++ b/ghost/core/core/server/services/newsletters/index.js @@ -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 }); diff --git a/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js b/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js index ab2ffb3b0b..2063e7c714 100644 --- a/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js +++ b/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js @@ -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" + 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; diff --git a/ghost/core/core/server/services/settings-helpers/index.js b/ghost/core/core/server/services/settings-helpers/index.js index 48d6f01e8b..1a4f7eae8d 100644 --- a/ghost/core/core/server/services/settings-helpers/index.js +++ b/ghost/core/core/server/services/settings-helpers/index.js @@ -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}); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index d55f35b375..ffa90bee88 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -49,7 +49,8 @@ const ALPHA_FEATURES = [ 'adminXOffers', 'filterEmailDisabled', 'adminXDemo', - 'tkReminders' + 'tkReminders', + 'newEmailAddresses' ]; module.exports.GA_KEYS = [...GA_FEATURES]; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap index a6cc4cbc5b..65d5c69733 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap @@ -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\\" ", + "generateTextFromHTML": false, + "replyTo": null, "subject": "🙌 Complete your sign up to Ghost's Test Site!", "to": "member_getting_confirmation@test.com", } diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap index c4fc78b0c0..eac65c21bc 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/newsletters.test.js.snap @@ -1,5 +1,208 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Newsletters API [Legacy] Can only set newsletter reply to to newsletter or support value 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Updated newsletter name", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "support", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API [Legacy] Can only set newsletter reply to to newsletter or support value 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "926", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API [Legacy] Can only set newsletter reply to to newsletter or support value 3: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Updated newsletter name", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API [Legacy] Can only set newsletter reply to to newsletter or support value 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "929", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API [Legacy] Cannot set newsletter clear sender_reply_to 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the reply-to email address to ", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API [Legacy] Cannot set newsletter clear sender_reply_to 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "272", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API [Legacy] Cannot set newsletter reply-to to any email address 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the reply-to email address to hello@acme.com", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API [Legacy] Cannot set newsletter reply-to to any email address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "286", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API [Legacy] Cannot set newsletter sender_email to invalid email address 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the sender email address to notvalid", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API [Legacy] Cannot set newsletter sender_email to invalid email address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "278", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Newsletters API Can add a newsletter - and subscribe existing members 1: [body] 1`] = ` Object { "meta": Object { @@ -122,8 +325,10 @@ Object { exports[`Newsletters API Can add a newsletter - with custom sender_email 3: [metadata 1] 1`] = ` Object { + "encoding": "base64", "forceTextContent": true, - "from": "noreply@example.com", + "from": "\\"Ghost\\" ", + "generateTextFromHTML": false, "subject": "Verify email address", "to": "test@example.com", } @@ -548,8 +753,10 @@ exports[`Newsletters API Can add a newsletter - with custom sender_email and sub exports[`Newsletters API Can add a newsletter - with custom sender_email and subscribe existing members 3: [metadata 1] 1`] = ` Object { + "encoding": "base64", "forceTextContent": true, - "from": "noreply@example.com", + "from": "\\"Ghost\\" ", + "generateTextFromHTML": false, "subject": "Verify email address", "to": "test@example.com", } @@ -1154,8 +1361,10 @@ Object { exports[`Newsletters API Can edit a newsletters and update the sender_email when already set 3: [metadata 1] 1`] = ` Object { + "encoding": "base64", "forceTextContent": true, - "from": "noreply@example.com", + "from": "\\"Ghost\\" ", + "generateTextFromHTML": false, "subject": "Verify email address", "to": "updated@example.com", } @@ -1925,8 +2134,10 @@ Object { exports[`Newsletters API Can verify property updates 1: [metadata 1] 1`] = ` Object { + "encoding": "base64", "forceTextContent": true, - "from": "noreply@example.com", + "from": "\\"Ghost\\" ", + "generateTextFromHTML": false, "subject": "Verify email address", "to": "verify@example.com", } @@ -2645,3 +2856,2015 @@ Object { ], } `; + +exports[`Newsletters API Managed email with custom sending domain Can clear sender_email 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": null, + "sender_name": "Jamie", + "sender_reply_to": "anything@sendingdomain.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can clear sender_email 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "923", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can keep sender_email 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "noreply@127.0.0.1", + "sender_name": "Jamie", + "sender_reply_to": "anything@sendingdomain.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can keep sender_email 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "938", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to any email address with required verification 1: [body] 1`] = ` +Object { + "meta": Object { + "sent_email_verification": Array [ + "sender_reply_to", + ], + }, + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "noreply@127.0.0.1", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to any email address with required verification 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "977", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to any email address with required verification 3: [metadata 1] 1`] = ` +Object { + "encoding": "base64", + "forceTextContent": true, + "from": "\\"Ghost\\" ", + "generateTextFromHTML": false, + "replyTo": "noreply@acme.com", + "subject": "Verify email address", + "to": "hello@acme.com", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to any email address with required verification 4: [html 1] 1`] = ` +" + + + + + + Confirm your email address + + + + + + + + + +
  +
+ + + + + + + + + + +
+ + + + +
+

Hey there,

+

Please confirm your email address with this link:

+ + + + + + +
+ + + + + + +
Confirm email address
+
+

For your security, the link will expire in 24 hours time.

+
+

You can also copy & paste this URL into your browser:

+

http://127.0.0.1:2369/ghost/#/settings/newsletters/?verifyEmail=REPLACED_TOKEN

+
+
+ + +
+ + + + + + + +
+ If you did not make this request, you can simply delete this message.
This email address will not be used. +
+ Sent to hello@acme.com +
+
+ + + +
+
 
+ + +" +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to any email address with required verification 5: [text 1] 1`] = ` +" + Hey there, + + Please confirm your email address with this link: + + http://127.0.0.1:2369/ghost/#/settings/newsletters/?verifyEmail=REPLACED_TOKEN + + For your security, the link will expire in 24 hours time. + + --- + + Sent to hello@acme.com + If you did not make this request, you can simply delete this message. This email address will not be used. + " +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to matchiing sending domain without required verification 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "anything@sendingdomain.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to matchiing sending domain without required verification 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "938", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to matching sending domain without required verification 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "noreply@127.0.0.1", + "sender_name": "Jamie", + "sender_reply_to": "anything@sendingdomain.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to matching sending domain without required verification 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "938", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to newsletter or support 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "noreply@127.0.0.1", + "sender_name": "Jamie", + "sender_reply_to": "support", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to newsletter or support 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "919", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to newsletter or support 3: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "noreply@127.0.0.1", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set newsletter reply-to to newsletter or support 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "922", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set sender_email to address matching sending domain, without verification 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "anything@sendingdomain.com", + "sender_name": "Jamie", + "sender_reply_to": "anything@sendingdomain.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Can set sender_email to address matching sending domain, without verification 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "947", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Cannot change sender_email to non matching domain 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the sender email address to hello@acme.com", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Cannot change sender_email to non matching domain 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "284", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Cannot clear newsletter reply-to 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the reply-to email address to ", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Cannot clear newsletter reply-to 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "272", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Cannot set newsletter reply-to to invalid email address 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the reply-to email address to notvalid", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Cannot set newsletter reply-to to invalid email address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "280", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email with custom sending domain Cannot set newsletter sender_email to invalid email address 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the sender email address to notvalid", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Managed email with custom sending domain Cannot set newsletter sender_email to invalid email address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "278", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can clear sender_email 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": null, + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can clear sender_email 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "907", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can keep sender_email 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can keep sender_email 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "922", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can only set newsletter reply-to to newsletter or support 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "support", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can only set newsletter reply-to to newsletter or support 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "919", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can only set newsletter reply-to to newsletter or support 3: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can only set newsletter reply-to to newsletter or support 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "922", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to any email address with required verification 1: [body] 1`] = ` +Object { + "meta": Object { + "sent_email_verification": Array [ + "sender_reply_to", + ], + }, + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to any email address with required verification 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "977", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to any email address with required verification 3: [metadata 1] 1`] = ` +Object { + "encoding": "base64", + "forceTextContent": true, + "from": "\\"Ghost\\" ", + "generateTextFromHTML": false, + "replyTo": "noreply@acme.com", + "subject": "Verify email address", + "to": "hello@acme.com", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to any email address with required verification 4: [html 1] 1`] = ` +" + + + + + + Confirm your email address + + + + + + + + + +
  +
+ + + + + + + + + + +
+ + + + +
+

Hey there,

+

Please confirm your email address with this link:

+ + + + + + +
+ + + + + + +
Confirm email address
+
+

For your security, the link will expire in 24 hours time.

+
+

You can also copy & paste this URL into your browser:

+

http://127.0.0.1:2369/ghost/#/settings/newsletters/?verifyEmail=REPLACED_TOKEN

+
+
+ + +
+ + + + + + + +
+ If you did not make this request, you can simply delete this message.
This email address will not be used. +
+ Sent to hello@acme.com +
+
+ + + +
+
 
+ + +" +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to any email address with required verification 5: [text 1] 1`] = ` +" + Hey there, + + Please confirm your email address with this link: + + http://127.0.0.1:2369/ghost/#/settings/newsletters/?verifyEmail=REPLACED_TOKEN + + For your security, the link will expire in 24 hours time. + + --- + + Sent to hello@acme.com + If you did not make this request, you can simply delete this message. This email address will not be used. + " +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to newsletter or support 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "support", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to newsletter or support 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "919", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to newsletter or support 3: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "jamie@example.com", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set newsletter reply-to to newsletter or support 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "922", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set sender_email to default address 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "noreply@127.0.0.1", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Can set sender_email to default address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "922", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Cannot change sender_email 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the sender email address to hello@acme.com", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Cannot change sender_email 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "284", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Cannot clear newsletter reply-to 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the reply-to email address to ", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Cannot clear newsletter reply-to 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "272", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Cannot set newsletter reply-to to invalid email address 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the reply-to email address to notvalid", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Cannot set newsletter reply-to to invalid email address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "280", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Managed email without custom sending domain Cannot set newsletter sender_email to invalid email address 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the sender email address to notvalid", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Managed email without custom sending domain Cannot set newsletter sender_email to invalid email address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "278", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Can change sender_email to any address without verification 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "hello@acme.com", + "sender_name": "Jamie", + "sender_reply_to": "hello@acme.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Can change sender_email to any address without verification 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "923", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Can clear sender_email 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": null, + "sender_name": "Jamie", + "sender_reply_to": "hello@acme.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Can clear sender_email 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "911", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Can keep sender_email 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "anything@sendingdomain.com", + "sender_name": "Jamie", + "sender_reply_to": "hello@acme.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Can keep sender_email 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "935", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Can set newsletter reply-to to any email address without required verification 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "anything@sendingdomain.com", + "sender_name": "Jamie", + "sender_reply_to": "hello@acme.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Can set newsletter reply-to to any email address without required verification 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "935", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Can set newsletter reply-to to newsletter or support 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "anything@sendingdomain.com", + "sender_name": "Jamie", + "sender_reply_to": "support", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Can set newsletter reply-to to newsletter or support 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "928", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Can set newsletter reply-to to newsletter or support 3: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "anything@sendingdomain.com", + "sender_name": "Jamie", + "sender_reply_to": "newsletter", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Can set newsletter reply-to to newsletter or support 4: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "931", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Can set sender_email to address matching sending domain, without verification 1: [body] 1`] = ` +Object { + "newsletters": Array [ + Object { + "background_color": "light", + "body_font_category": "serif", + "border_color": null, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "description": null, + "feedback_enabled": false, + "footer_content": null, + "header_image": "http://127.0.0.1:2369/content/images/2022/05/test.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "name": "Daily newsletter", + "sender_email": "anything@sendingdomain.com", + "sender_name": "Jamie", + "sender_reply_to": "hello@acme.com", + "show_badge": true, + "show_comment_cta": true, + "show_feature_image": true, + "show_header_icon": true, + "show_header_name": true, + "show_header_title": true, + "show_latest_posts": false, + "show_post_title_section": true, + "show_subscription_details": false, + "slug": "daily-newsletter", + "sort_order": 1, + "status": "active", + "subscribe_on_signup": false, + "title_alignment": "center", + "title_color": null, + "title_font_category": "serif", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "visibility": "members", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Can set sender_email to address matching sending domain, without verification 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "935", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Cannot clear newsletter reply-to 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the reply-to email address to ", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Cannot clear newsletter reply-to 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "272", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Cannot set newsletter reply-to to invalid email address 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the reply-to email address to notvalid", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Cannot set newsletter reply-to to invalid email address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "280", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Newsletters API Self hoster without managed email Cannot set newsletter sender_email to invalid email address 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "You cannot set the sender email address to notvalid", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit newsletter.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Newsletters API Self hoster without managed email Cannot set newsletter sender_email to invalid email address 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "278", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap index 6a4a6ffbdc..d4ff609a25 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -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", diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js index a014a1b879..c43d180f0c 100644 --- a/ghost/core/test/e2e-api/admin/members.test.js +++ b/ghost/core/test/e2e-api/admin/members.test.js @@ -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 () { diff --git a/ghost/core/test/e2e-api/admin/newsletters.test.js b/ghost/core/test/e2e-api/admin/newsletters.test.js index eca76b7808..aa738256eb 100644 --- a/ghost/core/test/e2e-api/admin/newsletters.test.js +++ b/ghost/core/test/e2e-api/admin/newsletters.test.js @@ -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(); + }); + }); }); diff --git a/ghost/core/test/e2e-api/admin/settings.test.js b/ghost/core/test/e2e-api/admin/settings.test.js index 82cea6b6ff..b9de1a41d5 100644 --- a/ghost/core/test/e2e-api/admin/settings.test.js +++ b/ghost/core/test/e2e-api/admin/settings.test.js @@ -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', diff --git a/ghost/core/test/e2e-api/members-comments/comments.test.js b/ghost/core/test/e2e-api/members-comments/comments.test.js index 41a9023ca0..3ad561c164 100644 --- a/ghost/core/test/e2e-api/members-comments/comments.test.js +++ b/ghost/core/test/e2e-api/members-comments/comments.test.js @@ -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" '}); }); 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" '}); }); it('Can like a comment', async function () { diff --git a/ghost/core/test/e2e-api/shared/__snapshots__/version.test.js.snap b/ghost/core/test/e2e-api/shared/__snapshots__/version.test.js.snap index c90b85a3d4..45b1666c5a 100644 --- a/ghost/core/test/e2e-api/shared/__snapshots__/version.test.js.snap +++ b/ghost/core/test/e2e-api/shared/__snapshots__/version.test.js.snap @@ -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\\" ", + "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\\" ", + "generateTextFromHTML": true, + "replyTo": null, "subject": "Attention required: Your Zapier integration has failed", "to": "jbloggs@example.com", } diff --git a/ghost/core/test/e2e-server/services/__snapshots__/recommendation-emails.test.js.snap b/ghost/core/test/e2e-server/services/__snapshots__/recommendation-emails.test.js.snap index d603e456bc..42a394d010 100644 --- a/ghost/core/test/e2e-server/services/__snapshots__/recommendation-emails.test.js.snap +++ b/ghost/core/test/e2e-server/services/__snapshots__/recommendation-emails.test.js.snap @@ -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\\" ", + "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\\" ", + "generateTextFromHTML": true, + "replyTo": null, "subject": "👍 New recommendation: Other Ghost Site", "to": "jbloggs@example.com", } diff --git a/ghost/core/test/integration/services/q-email-addresses.test.js b/ghost/core/test/integration/services/q-email-addresses.test.js new file mode 100644 index 0000000000..8eeee0d22e --- /dev/null +++ b/ghost/core/test/integration/services/q-email-addresses.test.js @@ -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" '); + }); + + 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" '); + }); + + it('[STAFF] sends new member notification emails from ghost@domain', async function () { + await sendFreeMemberSignupNotification(); + assertFromAddress('"Example Site" '); + }); + + 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" '); + }); + + 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" '); + }); + + it('[MEMBERS] send a comment reply notification from the support address', async function () { + await sendCommentNotification(); + assertFromAddress('"Example Site" '); + }); + + 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" '); + }); + + 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" ', '"Anything Possible" '); + }); + + 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" ', '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" ', '"Example Site" '); + }); + }); + + 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" '); + }); + + it('[STAFF] sends recommendation emails from mail.from config variable', async function () { + await sendRecommendationNotification(); + assertFromAddress('"Default Address" '); + }); + + it('[STAFF] sends new member notification emails from mail.from config variable', async function () { + await sendFreeMemberSignupNotification(); + assertFromAddress('"Default Address" '); + }); + + 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" '); + }); + + 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" '); + }); + + 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" '); + }); + + 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" '); + }); + + 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" ', '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" ', '"Anything Possible" '); + }); + + 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 Possible" '); + }); + + 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@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" ', '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" ', '"Example Site" '); + }); + }); + + 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" '); + }); + + it('[STAFF] sends new member notification emails from mail.from config variable', async function () { + await sendFreeMemberSignupNotification(); + assertFromAddress('"Example Site" '); + }); + + it('[STAFF] Prefers to use the mail:from sending name if set above the site name', async function () { + configUtils.set('mail:from', '"Default Address" '); + + await sendFreeMemberSignupNotification(); + assertFromAddress('"Default Address" '); + }); + + 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" ', '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" '); + }); + + 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" ', '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" ', '"Anything Possible" '); + }); + + 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" ', '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" ', '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" ', '"Example Site" '); + }); + }); + + 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" '); + }); + + it('[STAFF] sends recommendation emails from mail.from config variable', async function () { + await sendRecommendationNotification(); + assertFromAddress('"Default Address" '); + }); + + it('[STAFF] sends new member notification emails from mail.from config variable', async function () { + await sendFreeMemberSignupNotification(); + assertFromAddress('"Default Address" '); + }); + + 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" '); + }); + + it('[MEMBERS] send a comment reply notification with noreply support address', async function () { + mockSetting('members_support_address', 'noreply'); + + await sendCommentNotification(); + assertFromAddress('"Example Site" '); + }); + + it('[MEMBERS] send a comment reply notification without support address', async function () { + mockSetting('members_support_address', ''); + + await sendCommentNotification(); + + // Use default + assertFromAddress('"Default Address" '); + }); + + 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" '); + }); + + 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" '); + }); + + 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@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" ', '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" ', '"Example Site" '); + }); + }); +}); diff --git a/ghost/core/test/regression/api/admin/__snapshots__/authentication.test.js.snap b/ghost/core/test/regression/api/admin/__snapshots__/authentication.test.js.snap index e7dfc2ce93..9a81864e47 100644 --- a/ghost/core/test/regression/api/admin/__snapshots__/authentication.test.js.snap +++ b/ghost/core/test/regression/api/admin/__snapshots__/authentication.test.js.snap @@ -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", } diff --git a/ghost/core/test/regression/api/admin/members-importer.test.js b/ghost/core/test/regression/api/admin/members-importer.test.js index bd5ec8e593..b4e40bd2e4 100644 --- a/ghost/core/test/regression/api/admin/members-importer.test.js +++ b/ghost/core/test/regression/api/admin/members-importer.test.js @@ -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' }); diff --git a/ghost/core/test/unit/server/services/mail/GhostMailer.test.js b/ghost/core/test/unit/server/services/mail/GhostMailer.test.js index fc4d79e043..9845598ea2 100644 --- a/ghost/core/test/unit/server/services/mail/GhostMailer.test.js +++ b/ghost/core/test/unit/server/services/mail/GhostMailer.test.js @@ -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(); diff --git a/ghost/core/test/unit/server/services/newsletters/service.test.js b/ghost/core/test/unit/server/services/newsletters/service.test.js index a25d2f0a1e..61e394f64d 100644 --- a/ghost/core/test/unit/server/services/newsletters/service.test.js +++ b/ghost/core/test/unit/server/services/newsletters/service.test.js @@ -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; + } + } + }) + } }); }); diff --git a/ghost/core/test/unit/server/services/settings/__snapshots__/settings-bread-service.test.js.snap b/ghost/core/test/unit/server/services/settings/__snapshots__/settings-bread-service.test.js.snap index 3df9cc1947..abc15bdf57 100644 --- a/ghost/core/test/unit/server/services/settings/__snapshots__/settings-bread-service.test.js.snap +++ b/ghost/core/test/unit/server/services/settings/__snapshots__/settings-bread-service.test.js.snap @@ -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\\" ", + "generateTextFromHTML": false, "subject": "Verify email address", "to": "support@example.com", } diff --git a/ghost/core/test/utils/batch-email-utils.js b/ghost/core/test/utils/batch-email-utils.js index 3ea122a0bc..964e12a46c 100644 --- a/ghost/core/test/utils/batch-email-utils.js +++ b/ghost/core/test/utils/batch-email-utils.js @@ -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 }; diff --git a/ghost/core/test/utils/e2e-framework-mock-manager.js b/ghost/core/test/utils/e2e-framework-mock-manager.js index d50bd3ad07..84df7fff44 100644 --- a/ghost/core/test/utils/e2e-framework-mock-manager.js +++ b/ghost/core/test/utils/e2e-framework-mock-manager.js @@ -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(); diff --git a/ghost/email-addresses/.eslintrc.js b/ghost/email-addresses/.eslintrc.js new file mode 100644 index 0000000000..cb690be63f --- /dev/null +++ b/ghost/email-addresses/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/ts' + ] +}; diff --git a/ghost/email-addresses/README.md b/ghost/email-addresses/README.md new file mode 100644 index 0000000000..c60312a3e2 --- /dev/null +++ b/ghost/email-addresses/README.md @@ -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 + diff --git a/ghost/email-addresses/package.json b/ghost/email-addresses/package.json new file mode 100644 index 0000000000..1522944d15 --- /dev/null +++ b/ghost/email-addresses/package.json @@ -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" + } +} diff --git a/ghost/email-addresses/src/EmailAddressParser.ts b/ghost/email-addresses/src/EmailAddressParser.ts new file mode 100644 index 0000000000..b9fdd63d7f --- /dev/null +++ b/ghost/email-addresses/src/EmailAddressParser.ts @@ -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}>`; + } +} diff --git a/ghost/email-addresses/src/EmailAddressService.ts b/ghost/email-addresses/src/EmailAddressService.ts new file mode 100644 index 0000000000..70dd99be41 --- /dev/null +++ b/ghost/email-addresses/src/EmailAddressService.ts @@ -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' + }; + } +} diff --git a/ghost/email-addresses/src/index.ts b/ghost/email-addresses/src/index.ts new file mode 100644 index 0000000000..5163235bb0 --- /dev/null +++ b/ghost/email-addresses/src/index.ts @@ -0,0 +1,2 @@ +export * from './EmailAddressService'; +export * from './EmailAddressParser'; diff --git a/ghost/email-addresses/src/libraries.d.ts b/ghost/email-addresses/src/libraries.d.ts new file mode 100644 index 0000000000..30ebbde8f2 --- /dev/null +++ b/ghost/email-addresses/src/libraries.d.ts @@ -0,0 +1,3 @@ +declare module '@tryghost/errors'; +declare module '@tryghost/tpl'; +declare module '@tryghost/logging'; diff --git a/ghost/email-addresses/test/.eslintrc.js b/ghost/email-addresses/test/.eslintrc.js new file mode 100644 index 0000000000..6fe6dc1504 --- /dev/null +++ b/ghost/email-addresses/test/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test' + ] +}; diff --git a/ghost/email-addresses/test/hello.test.ts b/ghost/email-addresses/test/hello.test.ts new file mode 100644 index 0000000000..e66b88fad4 --- /dev/null +++ b/ghost/email-addresses/test/hello.test.ts @@ -0,0 +1,8 @@ +import assert from 'assert/strict'; + +describe('Hello world', function () { + it('Runs a test', function () { + // TODO: Write me! + assert.ok(require('../')); + }); +}); diff --git a/ghost/email-addresses/tsconfig.json b/ghost/email-addresses/tsconfig.json new file mode 100644 index 0000000000..7f7ed38664 --- /dev/null +++ b/ghost/email-addresses/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "src/**/*" + ], + "compilerOptions": { + "outDir": "build" + } +} diff --git a/ghost/email-service/lib/EmailRenderer.js b/ghost/email-service/lib/EmailRenderer.js index 83766f3152..d0dd431c6e 100644 --- a/ghost/email-service/lib/EmailRenderer.js +++ b/ghost/email-service/lib/EmailRenderer.js @@ -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; } /** diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js index d46f622d1a..fc0d876699 100644 --- a/ghost/email-service/test/email-renderer.test.js +++ b/ghost/email-service/test/email-renderer.test.js @@ -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" `); }); + + 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 () { diff --git a/ghost/staff-service/lib/StaffServiceEmails.js b/ghost/staff-service/lib/StaffServiceEmails.js index c96c64cc90..abb272ea90 100644 --- a/ghost/staff-service/lib/StaffServiceEmails.js +++ b/ghost/staff-service/lib/StaffServiceEmails.js @@ -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}`; } diff --git a/ghost/staff-service/package.json b/ghost/staff-service/package.json index 3348788bb2..7f18499201 100644 --- a/ghost/staff-service/package.json +++ b/ghost/staff-service/package.json @@ -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" } } diff --git a/ghost/staff-service/test/staff-service.test.js b/ghost/staff-service/test/staff-service.test.js index c2a1ec1c43..b9383ee372 100644 --- a/ghost/staff-service/test/staff-service.test.js +++ b/ghost/staff-service/test/staff-service.test.js @@ -152,6 +152,9 @@ describe('StaffService', function () { const settingsHelpers = { getDefaultEmailDomain: () => { return 'ghost.example'; + }, + useNewEmailAddresses: () => { + return false; } };