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 (
+
+
+ {
+ onAction('back');
+ }} />
+
+
+
+
+
+
+
+
+
+ );
+}
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==