diff --git a/ghost/core/core/server/services/email-suppression-list/service.js b/ghost/core/core/server/services/email-suppression-list/service.js index 54f450d2fd..97c0f45105 100644 --- a/ghost/core/core/server/services/email-suppression-list/service.js +++ b/ghost/core/core/server/services/email-suppression-list/service.js @@ -1,21 +1,27 @@ const {AbstractEmailSuppressionList, EmailSuppressionData} = require('@tryghost/email-suppression-list'); class InMemoryEmailSuppressionList extends AbstractEmailSuppressionList { + store = ['spam@member.test', 'fail@member.test']; + async removeEmail(email) { - if (email === 'fail@member.test') { - return false; + if ((email === 'fail@member.test' || email === 'spam@member.test') && this.store.includes(email)) { + this.store = this.store.filter(el => el !== email); + + setTimeout(() => this.store.push(email), 3000); + return true; } - return true; + + return false; } async getSuppressionData(email) { - if (email === 'spam@member.test') { + if (email === 'spam@member.test' && this.store.includes(email)) { return new EmailSuppressionData(true, { timestamp: new Date(), reason: 'spam' }); } - if (email === 'fail@member.test') { + if (email === 'fail@member.test' && this.store.includes(email)) { return new EmailSuppressionData(true, { timestamp: new Date(), reason: 'fail' diff --git a/ghost/portal/src/actions.js b/ghost/portal/src/actions.js index 56f01caa39..d61c54cd3c 100644 --- a/ghost/portal/src/actions.js +++ b/ghost/portal/src/actions.js @@ -301,6 +301,29 @@ async function updateNewsletterPreference({data, state, api}) { } } +async function removeEmailFromSuppressionList({state, api}) { + try { + await api.member.deleteSuppression(); + const action = 'removeEmailFromSuppressionList:success'; + return { + action, + popupNotification: createPopupNotification({ + type: 'removeEmailFromSuppressionList:success', autoHide: true, closeable: true, state, status: 'success', + message: 'You have been successfully resubscribed' + }) + }; + } catch (e) { + return { + action: 'removeEmailFromSuppressionList:failed', + popupNotification: createPopupNotification({ + type: 'removeEmailFromSuppressionList:failed', + autoHide: true, closeable: true, state, status: 'error', + message: 'Your email has failed to resubscribe, please try again' + }) + }; + } +} + async function updateNewsletter({data, state, api}) { try { const {subscribed} = data; @@ -377,14 +400,16 @@ async function refreshMemberData({state, api}) { if (member) { return { member, - success: true + success: true, + action: 'refreshMemberData:success' }; } return null; } catch (err) { return { success: false, - error: err + error: err, + action: 'refreshMemberData:failed' }; } } @@ -469,7 +494,8 @@ const Actions = { editBilling, checkoutPlan, updateNewsletterPreference, - showPopupNotification + showPopupNotification, + removeEmailFromSuppressionList }; /** Handle actions in the App, returns updated state */ diff --git a/ghost/portal/src/components/Frame.styles.js b/ghost/portal/src/components/Frame.styles.js index a859cfc0cf..7e681709f1 100644 --- a/ghost/portal/src/components/Frame.styles.js +++ b/ghost/portal/src/components/Frame.styles.js @@ -18,6 +18,7 @@ import {MagicLinkStyles} from './pages/MagicLinkPage'; import {PopupNotificationStyles} from './common/PopupNotification'; import {OfferPageStyles} from './pages/OfferPage'; import {FeedbackPageStyles} from './pages/FeedbackPage'; +import EmailSuppressedPage from '!!raw-loader!./pages/EmailSuppressedPage.css'; // Global styles const FrameStyles = ` @@ -1173,6 +1174,7 @@ export function getFrameStyles({site}) { PopupNotificationStyles + MobileStyles + MultipleProductsGlobalStyles + - FeedbackPageStyles; + FeedbackPageStyles + + EmailSuppressedPage; return FrameStyle; } diff --git a/ghost/portal/src/components/pages/AccountHomePage/AccountHomePage.css b/ghost/portal/src/components/pages/AccountHomePage/AccountHomePage.css index 419f8d1690..e7fffa527c 100644 --- a/ghost/portal/src/components/pages/AccountHomePage/AccountHomePage.css +++ b/ghost/portal/src/components/pages/AccountHomePage/AccountHomePage.css @@ -65,6 +65,21 @@ footer.gh-portal-account-footer { margin: 24px 0 32px; } +.gh-portal-list-detail .gh-portal-email-notice { + display: flex; + align-items: center; + gap: 5px; + margin-top: 6px; + color: var(--red); + font-weight: 500; + font-size: 1.25rem; +} + +.gh-portal-email-notice-icon { + width: 20px; + height: 20px; +} + .gh-portal-billing-button-loader { width: 32px; height: 32px; diff --git a/ghost/portal/src/components/pages/AccountHomePage/AccountHomePage.test.js b/ghost/portal/src/components/pages/AccountHomePage/AccountHomePage.test.js index 0a7e7fa3c4..7c90eda0c9 100644 --- a/ghost/portal/src/components/pages/AccountHomePage/AccountHomePage.test.js +++ b/ghost/portal/src/components/pages/AccountHomePage/AccountHomePage.test.js @@ -1,23 +1,32 @@ import React from 'react'; import {render, fireEvent} from 'utils/test-utils'; import AccountHomePage from './AccountHomePage'; +import {member} from 'utils/test-fixtures'; +import {site} from 'utils/fixtures'; const setup = (overrides) => { const {mockOnActionFn, ...utils} = render( - + , + { + overrideContext: { + ...overrides + } + } ); const logoutBtn = utils.queryByRole('button', {name: 'logout'}); return { logoutBtn, mockOnActionFn, - ...utils + utils }; }; describe('Account Home Page', () => { test('renders', () => { - const {logoutBtn} = setup(); + const {logoutBtn, utils} = setup(); expect(logoutBtn).toBeInTheDocument(); + expect(utils.queryByText('You\'re currently not receiving emails')).not.toBeInTheDocument(); + expect(utils.queryByText('Email newsletter')).toBeInTheDocument(); }); test('can call signout', () => { @@ -26,4 +35,29 @@ describe('Account Home Page', () => { fireEvent.click(logoutBtn); expect(mockOnActionFn).toHaveBeenCalledWith('signout'); }); + + test('can show suppressed info', () => { + const {mockOnActionFn, utils} = setup({member: member.suppressed}); + + expect(utils.queryByText('You\'re currently not receiving emails')).toBeInTheDocument(); + + const manageBtn = utils.queryByRole('button', {name: 'Manage'}); + expect(manageBtn).toBeInTheDocument(); + + fireEvent.click(manageBtn); + expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {lastPage: 'accountHome', page: 'emailSuppressed'}); + }); + + test('can show Manage button for few newsletters', () => { + const {mockOnActionFn, utils} = setup({site: site}); + + expect(utils.queryByText('Update your preferences')).toBeInTheDocument(); + expect(utils.queryByText('You\'re currently not receiving emails')).not.toBeInTheDocument(); + + const manageBtn = utils.queryByRole('button', {name: 'Manage'}); + expect(manageBtn).toBeInTheDocument(); + + fireEvent.click(manageBtn); + expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {lastPage: 'accountHome', page: 'accountEmail'}); + }); }); diff --git a/ghost/portal/src/components/pages/AccountHomePage/components/AccountActions.js b/ghost/portal/src/components/pages/AccountHomePage/components/AccountActions.js index 59a2eb88e8..329b2962b8 100644 --- a/ghost/portal/src/components/pages/AccountHomePage/components/AccountActions.js +++ b/ghost/portal/src/components/pages/AccountHomePage/components/AccountActions.js @@ -1,12 +1,13 @@ import AppContext from 'AppContext'; import {useContext} from 'react'; +import {hasCommentsEnabled, hasMultipleNewsletters, isEmailSuppressed} from 'utils/helpers'; import PaidAccountActions from './PaidAccountActions'; import EmailNewsletterAction from './EmailNewsletterAction'; import EmailPreferencesAction from './EmailPreferencesAction'; const AccountActions = () => { - const {member, onAction} = useContext(AppContext); + const {member, onAction, site} = useContext(AppContext); const {name, email} = member; const openEditProfile = () => { @@ -16,6 +17,8 @@ const AccountActions = () => { }); }; + const showEmailPreferences = hasMultipleNewsletters({site}) || hasCommentsEnabled({site}) || isEmailSuppressed({member}); + return (
@@ -28,8 +31,12 @@ const AccountActions = () => { - - + { + showEmailPreferences + ? + : + } +
{/* */}
diff --git a/ghost/portal/src/components/pages/AccountHomePage/components/EmailNewsletterAction.js b/ghost/portal/src/components/pages/AccountHomePage/components/EmailNewsletterAction.js index ed5bde86c2..83da9566a5 100644 --- a/ghost/portal/src/components/pages/AccountHomePage/components/EmailNewsletterAction.js +++ b/ghost/portal/src/components/pages/AccountHomePage/components/EmailNewsletterAction.js @@ -1,15 +1,12 @@ import AppContext from 'AppContext'; import Switch from 'components/common/Switch'; -import {getSiteNewsletters, hasCommentsEnabled, hasMultipleNewsletters} from 'utils/helpers'; +import {getSiteNewsletters} from 'utils/helpers'; import {useContext} from 'react'; function EmailNewsletterAction() { const {member, site, onAction} = useContext(AppContext); let {newsletters} = member; - if (hasMultipleNewsletters({site}) || hasCommentsEnabled({site})) { - return null; - } const subscribed = !!newsletters?.length; let label = subscribed ? 'Subscribed' : 'Unsubscribed'; const onToggleSubscription = (e, sub) => { diff --git a/ghost/portal/src/components/pages/AccountHomePage/components/EmailPreferencesAction.js b/ghost/portal/src/components/pages/AccountHomePage/components/EmailPreferencesAction.js index da98e3953e..212f020d57 100644 --- a/ghost/portal/src/components/pages/AccountHomePage/components/EmailPreferencesAction.js +++ b/ghost/portal/src/components/pages/AccountHomePage/components/EmailPreferencesAction.js @@ -1,21 +1,31 @@ import AppContext from 'AppContext'; -import {hasCommentsEnabled, hasMultipleNewsletters} from 'utils/helpers'; import {useContext} from 'react'; +import {isEmailSuppressed} from 'utils/helpers'; +import {ReactComponent as EmailDeliveryFailedIcon} from 'images/icons/email-delivery-failed.svg'; function EmailPreferencesAction() { - const {site, onAction} = useContext(AppContext); - if (!hasMultipleNewsletters({site}) && !hasCommentsEnabled({site})) { - return null; - } + const {onAction, member} = useContext(AppContext); + const emailSuppressed = isEmailSuppressed({member}); + const page = emailSuppressed ? 'emailSuppressed' : 'accountEmail'; + return (

Emails

-

Update your preferences

+ { + emailSuppressed + ? ( +

+ + You're currently not receiving emails +

+ ) + :

Update your preferences

+ }
diff --git a/ghost/portal/src/components/pages/EmailSuppressedPage.css b/ghost/portal/src/components/pages/EmailSuppressedPage.css new file mode 100644 index 0000000000..dea21a797d --- /dev/null +++ b/ghost/portal/src/components/pages/EmailSuppressedPage.css @@ -0,0 +1,20 @@ +.gh-email-suppressed-page-title { + margin-bottom: 14px; +} + +.gh-email-suppressed-page-icon { + display: block; + width: 38px; + height: 38px; + margin: 0 auto 18px; +} + +.gh-email-suppressed-page-text { + padding: 0 14px; + text-align: center; + color: var(--grey6); +} + +.gh-email-suppressed-page-text a { + color: var(--grey3); +} diff --git a/ghost/portal/src/components/pages/EmailSuppressedPage.js b/ghost/portal/src/components/pages/EmailSuppressedPage.js new file mode 100644 index 0000000000..ee25e02538 --- /dev/null +++ b/ghost/portal/src/components/pages/EmailSuppressedPage.js @@ -0,0 +1,63 @@ +import AppContext from 'AppContext'; +import {useContext, useEffect} from 'react'; +import CloseButton from 'components/common/CloseButton'; +import BackButton from 'components/common/BackButton'; +import ActionButton from 'components/common/ActionButton'; +import {ReactComponent as EmailDeliveryFailedIcon} from 'images/icons/email-delivery-failed.svg'; + +export default function EmailSuppressedPage() { + const {brandColor, lastPage, onAction, action} = useContext(AppContext); + + useEffect(() => { + if (['removeEmailFromSuppressionList:success'].includes(action)) { + onAction('refreshMemberData'); + } + + if (['removeEmailFromSuppressionList:failed', 'refreshMemberData:success', 'refreshMemberData:failed'].includes(action)) { + onAction('back'); + } + }, [action, onAction]); + + const isRunning = ['removeEmailFromSuppressionList:running', 'refreshMemberData:running'].includes(action); + + const handleSubmit = () => { + onAction('removeEmailFromSuppressionList'); + }; + + return ( +
+
+
+ + + +
+

Email disabled

+ +

+ All newsletters have been disabled on your account.
This can happen due to a spam complaint or + permanent failure (bounce). +

+ + { + window.open('https://ghost.org', '_blank'); + }}> + Learn more about why this happens + +
+ + +
+ ); +} diff --git a/ghost/portal/src/components/pages/EmailSuppressedPage.test.js b/ghost/portal/src/components/pages/EmailSuppressedPage.test.js new file mode 100644 index 0000000000..b238aae735 --- /dev/null +++ b/ghost/portal/src/components/pages/EmailSuppressedPage.test.js @@ -0,0 +1,33 @@ +import React from 'react'; +import {render, fireEvent} from 'utils/test-utils'; +import EmailSuppressedPage from './EmailSuppressedPage'; + +const setup = (overrides) => { + const {mockOnActionFn, ...utils} = render( + + ); + const resubscribeBtn = utils.queryByRole('button', {name: 'Resubscribe your email'}); + const title = utils.queryByText('Email disabled'); + + return { + resubscribeBtn, + title, + mockOnActionFn, + ...utils + }; +}; + +describe('Email Suppressed Page', () => { + test('renders', () => { + const {resubscribeBtn, title} = setup(); + expect(title).toBeInTheDocument(); + expect(resubscribeBtn).toBeInTheDocument(); + }); + + test('can call resubscribe button', () => { + const {mockOnActionFn, resubscribeBtn} = setup(); + + fireEvent.click(resubscribeBtn); + expect(mockOnActionFn).toHaveBeenCalledWith('removeEmailFromSuppressionList'); + }); +}); diff --git a/ghost/portal/src/images/icons/email-delivery-failed.svg b/ghost/portal/src/images/icons/email-delivery-failed.svg new file mode 100644 index 0000000000..51b55c2123 --- /dev/null +++ b/ghost/portal/src/images/icons/email-delivery-failed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ghost/portal/src/pages.js b/ghost/portal/src/pages.js index 8d2dfb224b..e96d04a566 100644 --- a/ghost/portal/src/pages.js +++ b/ghost/portal/src/pages.js @@ -10,6 +10,7 @@ import OfferPage from './components/pages/OfferPage'; import NewsletterSelectionPage from './components/pages/NewsletterSelectionPage'; import UnsubscribePage from './components/pages/UnsubscribePage'; import FeedbackPage from './components/pages/FeedbackPage'; +import EmailSuppressedPage from './components/pages/EmailSuppressedPage'; /** List of all available pages in Portal, mapped to their UI component * Any new page added to portal needs to be mapped here @@ -26,7 +27,8 @@ const Pages = { magiclink: MagicLinkPage, loading: LoadingPage, offer: OfferPage, - feedback: FeedbackPage + feedback: FeedbackPage, + emailSuppressed: EmailSuppressedPage }; /** Return page if valid, fallback to signup */ diff --git a/ghost/portal/src/utils/api.js b/ghost/portal/src/utils/api.js index 04316d42b6..9895208752 100644 --- a/ghost/portal/src/utils/api.js +++ b/ghost/portal/src/utils/api.js @@ -199,6 +199,20 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) { }); }, + deleteSuppression() { + const url = endpointFor({type: 'members', resource: 'member/suppression'}); + + return makeRequest({ + url, + method: 'DELETE' + }).then(function (res) { + if (!res.ok) { + throw new Error('Your email has failed to resubscribe, please try again'); + } + return true; + }); + }, + async sendMagicLink({email, emailType, labels, name, oldEmail, newsletters}) { const url = endpointFor({type: 'members', resource: 'send-magic-link'}); const body = { diff --git a/ghost/portal/src/utils/fixtures-generator.js b/ghost/portal/src/utils/fixtures-generator.js index a47a8f9e1f..b63950e6b4 100644 --- a/ghost/portal/src/utils/fixtures-generator.js +++ b/ghost/portal/src/utils/fixtures-generator.js @@ -113,7 +113,11 @@ export function getMemberData({ subscriptions = [], paid = false, avatarImage: avatar_image = '', - subscribed = true + subscribed = true, + email_suppression = { + suppressed: false, + info: null + } } = {}) { return { uuid: `member_${objectId()}`, @@ -123,7 +127,8 @@ export function getMemberData({ paid, subscribed, avatar_image, - subscriptions + subscriptions, + email_suppression }; } diff --git a/ghost/portal/src/utils/helpers.js b/ghost/portal/src/utils/helpers.js index 0b09114720..e8d5abfdfd 100644 --- a/ghost/portal/src/utils/helpers.js +++ b/ghost/portal/src/utils/helpers.js @@ -497,6 +497,10 @@ export function hasMultipleNewsletters({site}) { return newsletters?.length > 1; } +export function isEmailSuppressed({member}) { + return member?.email_suppression?.suppressed; +} + export function hasOnlyFreeProduct({site}) { const products = getSiteProducts({site}); return (products.length === 1 && hasFreeProductPrice({site})); diff --git a/ghost/portal/src/utils/test-fixtures.js b/ghost/portal/src/utils/test-fixtures.js index 8534fbcde7..42d2268848 100644 --- a/ghost/portal/src/utils/test-fixtures.js +++ b/ghost/portal/src/utils/test-fixtures.js @@ -189,6 +189,22 @@ export const member = { avatarImage: '', subscribed: true }), + suppressed: getMemberData({ + name: 'Jamie Larson', + email: 'jamie@example.com', + firstname: 'Jamie', + subscriptions: [], + paid: false, + avatarImage: '', + subscribed: true, + email_suppression: { + suppressed: true, + info: { + reason: 'spam', + timestamp: '2022-11-23T09:54:06.210Z' + } + } + }), paid: getMemberData({ paid: true, subscriptions: [ diff --git a/yarn.lock b/yarn.lock index ef3e48b2a6..58b857a834 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16765,7 +16765,7 @@ jest-pnp-resolver@^1.2.2: resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz#b704ac0ae028a89108a4d040b3f919dfddc8e33c" integrity sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w== -jest-raw-loader@^1.0.1: +jest-raw-loader@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz#ce9f56d54650f157c4a7d16d224ba5d613bcd626" integrity sha512-g9oaAjeC4/rIJk1Wd3RxVbOfMizowM7LSjEJqa4R9qDX0OjQNABXOhH+GaznUp+DjTGVPi2vPPbQXyX87DOnYg== @@ -22206,7 +22206,7 @@ raw-body@~1.1.0: bytes "1" string_decoder "0.10" -raw-loader@^4.0.2: +raw-loader@4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-4.0.2.tgz#1aac6b7d1ad1501e66efdac1522c73e59a584eb6" integrity sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==