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; } };