diff --git a/ghost/admin/app/components/members-activity/event-type-filter.js b/ghost/admin/app/components/members-activity/event-type-filter.js
index 957eafeec0..50029841c6 100644
--- a/ghost/admin/app/components/members-activity/event-type-filter.js
+++ b/ghost/admin/app/components/members-activity/event-type-filter.js
@@ -11,7 +11,8 @@ const ALL_EVENT_TYPES = [
{event: 'email_opened_event', icon: 'filter-dropdown-email-opened', name: 'Email opened', group: 'emails'},
{event: 'email_delivered_event', icon: 'filter-dropdown-email-received', name: 'Email received', group: 'emails'},
{event: 'email_complaint_event', icon: 'filter-dropdown-email-flagged-as-spam', name: 'Email flagged as spam', group: 'emails'},
- {event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'}
+ {event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'},
+ {event: 'email_change_event', icon: 'filter-dropdown-email-address-changed', name: 'Email address changed', group: 'emails'}
];
export default class MembersActivityEventTypeFilter extends Component {
diff --git a/ghost/admin/app/helpers/parse-member-event.js b/ghost/admin/app/helpers/parse-member-event.js
index 0a723477d8..8a3a045633 100644
--- a/ghost/admin/app/helpers/parse-member-event.js
+++ b/ghost/admin/app/helpers/parse-member-event.js
@@ -114,6 +114,10 @@ export default class ParseMemberEventHelper extends Helper {
icon = 'subscriptions';
}
+ if (event.type === 'email_change_event') {
+ icon = 'email-changed';
+ }
+
return 'event-' + icon;
}
@@ -208,8 +212,15 @@ export default class ParseMemberEventHelper extends Helper {
return 'less like this';
}
+ if (event.type === 'email_change_event') {
+ if (event.data.from_email && event.data.to_email) {
+ return `Email address changed from ${event.data.from_email} to ${event.data.to_email}`;
+ }
+ return 'Email address changed';
+ }
+
if (event.type === 'donation_event') {
- return `Made a one-time payment`;
+ return 'Made a one-time payment';
}
}
diff --git a/ghost/admin/public/assets/icons/event-email-changed.svg b/ghost/admin/public/assets/icons/event-email-changed.svg
new file mode 100644
index 0000000000..2ed1e12e8f
--- /dev/null
+++ b/ghost/admin/public/assets/icons/event-email-changed.svg
@@ -0,0 +1,4 @@
+
diff --git a/ghost/admin/public/assets/icons/filter-dropdown-email-address-changed.svg b/ghost/admin/public/assets/icons/filter-dropdown-email-address-changed.svg
new file mode 100644
index 0000000000..8b2fd02f2e
--- /dev/null
+++ b/ghost/admin/public/assets/icons/filter-dropdown-email-address-changed.svg
@@ -0,0 +1,4 @@
+
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 447dca53c7..23a80d62ff 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
@@ -2910,12 +2910,12 @@ Object {
Object {
"comped": 4,
"date": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}/,
- "free": 4,
+ "free": 5,
"paid": 2,
},
],
"resource": "members",
- "total": 10,
+ "total": 11,
}
`;
@@ -6323,6 +6323,130 @@ Object {
}
`;
+exports[`Members API can change the email address 1: [body] 1`] = `
+Object {
+ "members": Array [
+ Object {
+ "attribution": Object {
+ "id": null,
+ "referrer_medium": "Ghost Admin",
+ "referrer_source": "Created manually",
+ "referrer_url": null,
+ "title": null,
+ "type": null,
+ "url": null,
+ },
+ "avatar_image": null,
+ "comped": false,
+ "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
+ "email": "jon.snow@test.com",
+ "email_count": 0,
+ "email_open_rate": null,
+ "email_opened_count": 0,
+ "email_suppression": Object {
+ "info": null,
+ "suppressed": false,
+ },
+ "geolocation": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
+ "labels": Any,
+ "last_seen_at": null,
+ "name": "Jon Snow",
+ "newsletters": Array [],
+ "note": null,
+ "status": "free",
+ "subscribed": false,
+ "subscriptions": Any,
+ "tiers": Array [],
+ "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\\}/,
+ },
+ ],
+}
+`;
+
+exports[`Members API can change the 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": "665",
+ "content-type": "application/json; charset=utf-8",
+ "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
+ "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
+ "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/members\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
+ "vary": "Accept-Version, Origin, Accept-Encoding",
+ "x-powered-by": "Express",
+}
+`;
+
+exports[`Members API can change the email address 3: [body] 1`] = `
+Object {
+ "members": Array [
+ Object {
+ "attribution": Object {
+ "id": null,
+ "referrer_medium": "Ghost Admin",
+ "referrer_source": "Created manually",
+ "referrer_url": null,
+ "title": null,
+ "type": null,
+ "url": null,
+ },
+ "avatar_image": null,
+ "comped": false,
+ "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
+ "email": "aegon.targaryen@test.com",
+ "email_count": 0,
+ "email_open_rate": null,
+ "email_opened_count": 0,
+ "email_suppression": Object {
+ "info": null,
+ "suppressed": false,
+ },
+ "geolocation": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
+ "labels": Any,
+ "last_seen_at": null,
+ "name": "Jon Snow",
+ "newsletters": Array [],
+ "note": null,
+ "status": "free",
+ "subscribed": false,
+ "subscriptions": Any,
+ "tiers": Array [],
+ "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\\}/,
+ },
+ ],
+}
+`;
+
+exports[`Members API can change the email address 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": "672",
+ "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[`Members API can change the email address 5: [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": "1118",
+ "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[`Members API without Stripe Add should fail when comped flag is passed in but Stripe is not enabled 1: [body] 1`] = `
Object {
"errors": Array [
diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js
index ce1e7f7356..12f9d891cf 100644
--- a/ghost/core/test/e2e-api/admin/members.test.js
+++ b/ghost/core/test/e2e-api/admin/members.test.js
@@ -2131,6 +2131,83 @@ describe('Members API', function () {
});
});
+ it('can change the email address', async function () {
+ const memberToChange = {
+ name: 'Jon Snow',
+ email: 'jon.snow@test.com',
+ newsletters: []
+ };
+
+ const memberChanged = {
+ email: 'aegon.targaryen@test.com'
+ };
+
+ // Create member
+ const {body} = await agent
+ .post(`/members/`)
+ .body({members: [memberToChange]})
+ .expectStatus(201)
+ .matchBodySnapshot({
+ members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 0))
+ })
+ .matchHeaderSnapshot({
+ 'content-version': anyContentVersion,
+ etag: anyEtag,
+ location: anyLocationFor('members')
+ });
+
+ // Update email address
+ const newMember = body.members[0];
+ await agent
+ .put(`/members/${newMember.id}/`)
+ .body({members: [memberChanged]})
+ .expectStatus(200)
+ .matchBodySnapshot({
+ members: new Array(1).fill(buildMemberMatcherShallowIncludesWithTiers(0, 0))
+ })
+ .matchHeaderSnapshot({
+ 'content-version': anyContentVersion,
+ etag: anyEtag
+ });
+
+ // Check member events
+ await assertMemberEvents({
+ eventType: 'MemberEmailChangeEvent',
+ memberId: newMember.id,
+ asserts: [
+ {
+ from_email: 'jon.snow@test.com',
+ to_email: 'aegon.targaryen@test.com'
+ }
+ ]
+ });
+
+ // Check activity feed
+ const {body: eventsBody} = await agent
+ .get(`/members/events?filter=data.member_id:'${newMember.id}'`)
+ .body({members: [memberChanged]})
+ .expectStatus(200)
+ .matchHeaderSnapshot({
+ 'content-version': anyContentVersion,
+ etag: anyEtag
+ });
+
+ const events = eventsBody.events;
+
+ matchArrayWithoutOrder(events, [
+ {
+ type: 'email_change_event',
+ data: {
+ from_email: 'jon.snow@test.com',
+ to_email: 'aegon.targaryen@test.com'
+ }
+ },
+ {
+ type: 'signup_event'
+ }
+ ]);
+ });
+
describe('email_disabled', function () {
const testMemberId = '6543c13c13575e086a06b222';
const suppressedEmail = 'suppressed@email.com';
diff --git a/ghost/members-api/lib/members-api.js b/ghost/members-api/lib/members-api.js
index 5b4b328789..00068ed53c 100644
--- a/ghost/members-api/lib/members-api.js
+++ b/ghost/members-api/lib/members-api.js
@@ -122,7 +122,8 @@ module.exports = function MembersAPI({
EmailSpamComplaintEvent,
Comment,
labsService,
- memberAttributionService
+ memberAttributionService,
+ MemberEmailChangeEvent
});
const memberBREADService = new MemberBREADService({
diff --git a/ghost/members-api/lib/repositories/EventRepository.js b/ghost/members-api/lib/repositories/EventRepository.js
index 98484299bc..eb3574e391 100644
--- a/ghost/members-api/lib/repositories/EventRepository.js
+++ b/ghost/members-api/lib/repositories/EventRepository.js
@@ -31,7 +31,8 @@ module.exports = class EventRepository {
EmailSpamComplaintEvent,
Comment,
labsService,
- memberAttributionService
+ memberAttributionService,
+ MemberEmailChangeEvent
}) {
this._DonationPaymentEvent = DonationPaymentEvent;
this._MemberSubscribeEvent = MemberSubscribeEvent;
@@ -48,6 +49,7 @@ module.exports = class EventRepository {
this._MemberFeedback = MemberFeedback;
this._EmailSpamComplaintEvent = EmailSpamComplaintEvent;
this._memberAttributionService = memberAttributionService;
+ this._MemberEmailChangeEvent = MemberEmailChangeEvent;
}
async getEventTimeline(options = {}) {
@@ -76,7 +78,8 @@ module.exports = class EventRepository {
pageActions.push(
{type: 'newsletter_event', action: 'getNewsletterSubscriptionEvents'},
{type: 'login_event', action: 'getLoginEvents'},
- {type: 'payment_event', action: 'getPaymentEvents'}
+ {type: 'payment_event', action: 'getPaymentEvents'},
+ {type: 'email_change_event', action: 'getEmailChangeEvent'}
);
}
@@ -764,6 +767,38 @@ module.exports = class EventRepository {
};
}
+ async getEmailChangeEvent(options = {}, filter) {
+ options = {
+ ...options,
+ withRelated: ['member'],
+ filter: 'custom:true',
+ mongoTransformer: chainTransformers(
+ // First set the filter manually
+ replaceCustomFilterTransformer(filter),
+
+ // Map the used keys in that filter
+ ...mapKeys({
+ 'data.created_at': 'created_at',
+ 'data.member_id': 'member_id'
+ })
+ )
+ };
+
+ const {data: models, meta} = await this._MemberEmailChangeEvent.findPage(options);
+
+ const data = models.map((model) => {
+ return {
+ type: 'email_change_event',
+ data: model.toJSON(options)
+ };
+ });
+
+ return {
+ data,
+ meta
+ };
+ }
+
/**
* Split the filter in two parts:
* - One with 'type' that will be applied to all the pages