ghost 5.24.1 → 5.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/components/{tryghost-adapter-manager-5.24.1.tgz → tryghost-adapter-manager-5.25.0.tgz} +0 -0
  2. package/components/{tryghost-api-framework-5.24.1.tgz → tryghost-api-framework-5.25.0.tgz} +0 -0
  3. package/components/{tryghost-api-version-compatibility-service-5.24.1.tgz → tryghost-api-version-compatibility-service-5.25.0.tgz} +0 -0
  4. package/components/{tryghost-audience-feedback-5.24.1.tgz → tryghost-audience-feedback-5.25.0.tgz} +0 -0
  5. package/components/tryghost-bootstrap-socket-5.25.0.tgz +0 -0
  6. package/components/tryghost-constants-5.25.0.tgz +0 -0
  7. package/components/{tryghost-custom-theme-settings-service-5.24.1.tgz → tryghost-custom-theme-settings-service-5.25.0.tgz} +0 -0
  8. package/components/{tryghost-data-generator-5.24.1.tgz → tryghost-data-generator-5.25.0.tgz} +0 -0
  9. package/components/tryghost-domain-events-5.25.0.tgz +0 -0
  10. package/components/tryghost-email-analytics-provider-mailgun-5.25.0.tgz +0 -0
  11. package/components/tryghost-email-analytics-service-5.25.0.tgz +0 -0
  12. package/components/{tryghost-email-content-generator-5.24.1.tgz → tryghost-email-content-generator-5.25.0.tgz} +0 -0
  13. package/components/tryghost-email-events-5.25.0.tgz +0 -0
  14. package/components/tryghost-email-service-5.25.0.tgz +0 -0
  15. package/components/tryghost-email-suppression-list-5.25.0.tgz +0 -0
  16. package/components/{tryghost-express-dynamic-redirects-5.24.1.tgz → tryghost-express-dynamic-redirects-5.25.0.tgz} +0 -0
  17. package/components/tryghost-extract-api-key-5.25.0.tgz +0 -0
  18. package/components/{tryghost-html-to-plaintext-5.24.1.tgz → tryghost-html-to-plaintext-5.25.0.tgz} +0 -0
  19. package/components/tryghost-job-manager-5.25.0.tgz +0 -0
  20. package/components/{tryghost-link-redirects-5.24.1.tgz → tryghost-link-redirects-5.25.0.tgz} +0 -0
  21. package/components/tryghost-link-replacer-5.25.0.tgz +0 -0
  22. package/components/{tryghost-link-tracking-5.24.1.tgz → tryghost-link-tracking-5.25.0.tgz} +0 -0
  23. package/components/tryghost-magic-link-5.25.0.tgz +0 -0
  24. package/components/tryghost-mailgun-client-5.25.0.tgz +0 -0
  25. package/components/{tryghost-member-attribution-5.24.1.tgz → tryghost-member-attribution-5.25.0.tgz} +0 -0
  26. package/components/{tryghost-member-events-5.24.1.tgz → tryghost-member-events-5.25.0.tgz} +0 -0
  27. package/components/tryghost-members-api-5.25.0.tgz +0 -0
  28. package/components/{tryghost-members-csv-5.24.1.tgz → tryghost-members-csv-5.25.0.tgz} +0 -0
  29. package/components/tryghost-members-events-service-5.25.0.tgz +0 -0
  30. package/components/tryghost-members-importer-5.25.0.tgz +0 -0
  31. package/components/tryghost-members-offers-5.25.0.tgz +0 -0
  32. package/components/tryghost-members-payments-5.25.0.tgz +0 -0
  33. package/components/tryghost-members-ssr-5.25.0.tgz +0 -0
  34. package/components/{tryghost-members-stripe-service-5.24.1.tgz → tryghost-members-stripe-service-5.25.0.tgz} +0 -0
  35. package/components/tryghost-minifier-5.25.0.tgz +0 -0
  36. package/components/tryghost-mw-api-version-mismatch-5.25.0.tgz +0 -0
  37. package/components/tryghost-mw-cache-control-5.25.0.tgz +0 -0
  38. package/components/{tryghost-mw-error-handler-5.24.1.tgz → tryghost-mw-error-handler-5.25.0.tgz} +0 -0
  39. package/components/tryghost-mw-session-from-token-5.25.0.tgz +0 -0
  40. package/components/tryghost-mw-update-user-last-seen-5.25.0.tgz +0 -0
  41. package/components/tryghost-mw-vhost-5.25.0.tgz +0 -0
  42. package/components/tryghost-oembed-service-5.25.0.tgz +0 -0
  43. package/components/tryghost-package-json-5.25.0.tgz +0 -0
  44. package/components/tryghost-referrers-5.25.0.tgz +0 -0
  45. package/components/tryghost-security-5.25.0.tgz +0 -0
  46. package/components/{tryghost-session-service-5.24.1.tgz → tryghost-session-service-5.25.0.tgz} +0 -0
  47. package/components/tryghost-settings-path-manager-5.25.0.tgz +0 -0
  48. package/components/tryghost-staff-service-5.25.0.tgz +0 -0
  49. package/components/tryghost-stats-service-5.25.0.tgz +0 -0
  50. package/components/{tryghost-tiers-5.24.1.tgz → tryghost-tiers-5.25.0.tgz} +0 -0
  51. package/components/{tryghost-update-check-service-5.24.1.tgz → tryghost-update-check-service-5.25.0.tgz} +0 -0
  52. package/components/tryghost-verification-trigger-5.25.0.tgz +0 -0
  53. package/components/tryghost-version-notifications-data-service-5.25.0.tgz +0 -0
  54. package/core/boot.js +1 -1
  55. package/core/built/admin/assets/{chunk.143.310262c6914e58d37fe5.js → chunk.143.e2bde11e02e8a15d322f.js} +23 -23
  56. package/core/built/admin/assets/{chunk.178.25e79705a13a6fa051d8.js → chunk.178.ce7c2b3c8b02f8374ec8.js} +4 -4
  57. package/core/built/admin/assets/{chunk.507.71dd4bfc4ccb354cc629.js → chunk.507.37279ad3a2f6aeb82302.js} +13 -13
  58. package/core/built/admin/assets/{chunk.613.6bbcc18224567657fc2e.js → chunk.613.a29fe5699dd5f7fb05f1.js} +162 -160
  59. package/core/built/admin/assets/{chunk.613.6bbcc18224567657fc2e.js.LICENSE.txt → chunk.613.a29fe5699dd5f7fb05f1.js.LICENSE.txt} +0 -0
  60. package/core/built/admin/assets/ghost-16dd31e7747e2dce6313f4362842b7b7.css +1 -0
  61. package/core/built/admin/assets/{ghost-34bc21923675def87aa2516f72ca15d7.js → ghost-a66ff073d219101414fa7b344095c1ca.js} +1084 -1051
  62. package/core/built/admin/assets/ghost-dark-1117e1c672c39d594cdd1dfdf3d1c1ac.css +1 -0
  63. package/core/built/admin/assets/{vendor-04415b2b8a59aa9567dfa5d819ada71c.js → vendor-a767da2d0322ba6e801e0d719ccf5a26.js} +40 -38
  64. package/core/built/admin/index.html +6 -6
  65. package/core/server/api/endpoints/emails.js +59 -0
  66. package/core/server/api/endpoints/utils/serializers/input/emails.js +15 -0
  67. package/core/server/api/endpoints/utils/serializers/input/index.js +4 -0
  68. package/core/server/api/endpoints/utils/serializers/output/emails.js +32 -0
  69. package/core/server/api/endpoints/utils/serializers/output/index.js +4 -0
  70. package/core/server/api/endpoints/utils/serializers/output/mappers/email-batches.js +31 -0
  71. package/core/server/api/endpoints/utils/serializers/output/mappers/email-failures.js +53 -0
  72. package/core/server/data/exporter/table-lists.js +4 -1
  73. package/core/server/data/importer/import-manager.js +1 -3
  74. package/core/server/data/migrations/versions/5.25/2022-11-24-10-36-add-suppressions-table.js +9 -0
  75. package/core/server/data/migrations/versions/5.25/2022-11-24-10-37-add-email-spam-complaint-events-table.js +12 -0
  76. package/core/server/data/migrations/versions/5.25/2022-11-29-08-30-add-error-recipient-failures-table.js +20 -0
  77. package/core/server/data/schema/schema.js +45 -0
  78. package/core/server/lib/lexical.js +37 -0
  79. package/core/server/models/email-batch.js +13 -0
  80. package/core/server/models/email-recipient-failure.js +29 -0
  81. package/core/server/models/email-spam-complaint-event.js +44 -0
  82. package/core/server/models/post.js +7 -1
  83. package/core/server/models/suppression.js +9 -0
  84. package/core/server/services/email-analytics/index.js +8 -2
  85. package/core/server/services/email-analytics/jobs/fetch-latest/index.js +50 -0
  86. package/core/server/services/email-analytics/jobs/{fetch-latest.js → fetch-latest/run.js} +16 -41
  87. package/core/server/services/email-analytics/jobs/index.js +1 -1
  88. package/core/server/services/email-service/wrapper.js +73 -12
  89. package/core/server/services/email-suppression-list/InMemoryEmailSuppressionList.js +36 -0
  90. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +117 -0
  91. package/core/server/services/email-suppression-list/service.js +15 -38
  92. package/core/server/services/jobs/job-service.js +5 -2
  93. package/core/server/services/mail/GhostMailer.js +1 -0
  94. package/core/server/services/mega/feedback-buttons.js +6 -0
  95. package/core/server/services/mega/mega.js +11 -7
  96. package/core/server/services/mega/template.js +12 -0
  97. package/core/server/services/members/api.js +2 -1
  98. package/core/server/services/members/config.js +11 -2
  99. package/core/server/services/members/service.js +2 -16
  100. package/core/server/services/members-events/index.js +2 -2
  101. package/core/server/services/settings-helpers/settings-helpers.js +4 -0
  102. package/core/server/web/api/endpoints/admin/routes.js +2 -0
  103. package/package.json +109 -107
  104. package/yarn.lock +185 -163
  105. package/.c8rc.e2e.json +0 -21
  106. package/components/tryghost-bootstrap-socket-5.24.1.tgz +0 -0
  107. package/components/tryghost-constants-5.24.1.tgz +0 -0
  108. package/components/tryghost-domain-events-5.24.1.tgz +0 -0
  109. package/components/tryghost-email-analytics-provider-mailgun-5.24.1.tgz +0 -0
  110. package/components/tryghost-email-analytics-service-5.24.1.tgz +0 -0
  111. package/components/tryghost-email-events-5.24.1.tgz +0 -0
  112. package/components/tryghost-email-service-5.24.1.tgz +0 -0
  113. package/components/tryghost-email-suppression-list-5.24.1.tgz +0 -0
  114. package/components/tryghost-extract-api-key-5.24.1.tgz +0 -0
  115. package/components/tryghost-job-manager-5.24.1.tgz +0 -0
  116. package/components/tryghost-link-replacer-5.24.1.tgz +0 -0
  117. package/components/tryghost-magic-link-5.24.1.tgz +0 -0
  118. package/components/tryghost-mailgun-client-5.24.1.tgz +0 -0
  119. package/components/tryghost-members-api-5.24.1.tgz +0 -0
  120. package/components/tryghost-members-events-service-5.24.1.tgz +0 -0
  121. package/components/tryghost-members-importer-5.24.1.tgz +0 -0
  122. package/components/tryghost-members-offers-5.24.1.tgz +0 -0
  123. package/components/tryghost-members-payments-5.24.1.tgz +0 -0
  124. package/components/tryghost-members-ssr-5.24.1.tgz +0 -0
  125. package/components/tryghost-minifier-5.24.1.tgz +0 -0
  126. package/components/tryghost-mw-api-version-mismatch-5.24.1.tgz +0 -0
  127. package/components/tryghost-mw-cache-control-5.24.1.tgz +0 -0
  128. package/components/tryghost-mw-session-from-token-5.24.1.tgz +0 -0
  129. package/components/tryghost-mw-update-user-last-seen-5.24.1.tgz +0 -0
  130. package/components/tryghost-mw-vhost-5.24.1.tgz +0 -0
  131. package/components/tryghost-oembed-service-5.24.1.tgz +0 -0
  132. package/components/tryghost-package-json-5.24.1.tgz +0 -0
  133. package/components/tryghost-referrers-5.24.1.tgz +0 -0
  134. package/components/tryghost-security-5.24.1.tgz +0 -0
  135. package/components/tryghost-settings-path-manager-5.24.1.tgz +0 -0
  136. package/components/tryghost-staff-service-5.24.1.tgz +0 -0
  137. package/components/tryghost-stats-service-5.24.1.tgz +0 -0
  138. package/components/tryghost-verification-trigger-5.24.1.tgz +0 -0
  139. package/components/tryghost-version-notifications-data-service-5.24.1.tgz +0 -0
  140. package/core/built/admin/assets/ghost-dark-a2076b08f23a9e6340072bc7b06ec9e7.css +0 -1
  141. package/core/built/admin/assets/ghost-f428683b68c0eea9042acc7c021641e0.css +0 -1
  142. package/core/server/services/email-analytics/lib/event-processor.js +0 -178
  143. package/core/server/services/members/settings.js +0 -30
  144. package/playwright.config.js +0 -26
@@ -835,6 +835,24 @@ module.exports = {
835
835
  ['email_id', 'member_email']
836
836
  ]
837
837
  },
838
+ email_recipient_failures: {
839
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
840
+ email_id: {type: 'string', maxlength: 24, nullable: false, references: 'emails.id'},
841
+ member_id: {type: 'string', maxlength: 24, nullable: true},
842
+ email_recipient_id: {type: 'string', maxlength: 24, nullable: false, references: 'email_recipients.id'},
843
+ code: {type: 'integer', nullable: false, unsigned: true},
844
+ enhanced_code: {type: 'string', maxlength: 50, nullable: true},
845
+ message: {type: 'string', maxlength: 2000, nullable: false},
846
+ severity: {
847
+ type: 'string',
848
+ maxlength: 50,
849
+ nullable: false,
850
+ defaultTo: 'permanent',
851
+ validations: {isIn: [['temporary', 'permanent']]}
852
+ },
853
+ failed_at: {type: 'dateTime', nullable: false},
854
+ event_id: {type: 'string', maxlength: 255, nullable: true}
855
+ },
838
856
  tokens: {
839
857
  id: {type: 'string', maxlength: 24, nullable: false, primary: true},
840
858
  token: {type: 'string', maxlength: 32, nullable: false, index: true},
@@ -931,5 +949,32 @@ module.exports = {
931
949
  post_id: {type: 'string', maxlength: 24, nullable: false, references: 'posts.id', cascadeDelete: true},
932
950
  created_at: {type: 'dateTime', nullable: false},
933
951
  updated_at: {type: 'dateTime', nullable: true}
952
+ },
953
+ suppressions: {
954
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
955
+ email_address: {type: 'string', maxlength: 191, nullable: false, unique: true, validations: {isEmail: true}},
956
+ email_id: {type: 'string', maxlength: 24, nullable: true, references: 'emails.id'},
957
+ reason: {
958
+ type: 'string',
959
+ maxlength: 50,
960
+ nullable: false,
961
+ validations: {
962
+ isIn: [[
963
+ 'spam',
964
+ 'bounce'
965
+ ]]
966
+ }
967
+ },
968
+ created_at: {type: 'dateTime', nullable: false}
969
+ },
970
+ email_spam_complaint_events: {
971
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
972
+ member_id: {type: 'string', maxlength: 24, nullable: false, references: 'members.id', cascadeDelete: true},
973
+ email_id: {type: 'string', maxlength: 24, nullable: false, references: 'emails.id'},
974
+ email_address: {type: 'string', maxlength: 191, nullable: false, unique: false, validations: {isEmail: true}},
975
+ created_at: {type: 'dateTime', nullable: false},
976
+ '@@UNIQUE_CONSTRAINTS@@': [
977
+ ['email_id', 'member_id']
978
+ ]
934
979
  }
935
980
  };
@@ -1,4 +1,8 @@
1
+ const urlUtils = require('../../shared/url-utils');
2
+
3
+ let nodes;
1
4
  let lexicalHtmlRenderer;
5
+ let urlTransformMap;
2
6
 
3
7
  module.exports = {
4
8
  get lexicalHtmlRenderer() {
@@ -8,5 +12,38 @@ module.exports = {
8
12
  }
9
13
 
10
14
  return lexicalHtmlRenderer;
15
+ },
16
+
17
+ get nodes() {
18
+ if (!nodes) {
19
+ const {DEFAULT_NODES} = require('@tryghost/kg-default-nodes');
20
+ nodes = DEFAULT_NODES;
21
+ }
22
+
23
+ return nodes;
24
+ },
25
+
26
+ get urlTransformMap() {
27
+ if (!urlTransformMap) {
28
+ urlTransformMap = {
29
+ absoluteToRelative: {
30
+ url: urlUtils.absoluteToRelative.bind(urlUtils),
31
+ html: urlUtils.htmlAbsoluteToRelative.bind(urlUtils),
32
+ markdown: urlUtils.markdownAbsoluteToRelative.bind(urlUtils)
33
+ },
34
+ relativeToAbsolute: {
35
+ url: urlUtils.relativeToAbsolute.bind(urlUtils),
36
+ html: urlUtils.htmlRelativeToAbsolute.bind(urlUtils),
37
+ markdown: urlUtils.markdownRelativeToAbsolute.bind(urlUtils)
38
+ },
39
+ toTransformReady: {
40
+ url: urlUtils.toTransformReady.bind(urlUtils),
41
+ html: urlUtils.htmlToTransformReady.bind(urlUtils),
42
+ markdown: urlUtils.markdownToTransformReady.bind(urlUtils)
43
+ }
44
+ };
45
+ }
46
+
47
+ return urlTransformMap;
11
48
  }
12
49
  };
@@ -18,6 +18,19 @@ const EmailBatch = ghostBookshelf.Model.extend({
18
18
  members() {
19
19
  return this.belongsToMany('Member', 'email_recipients', 'batch_id', 'member_id');
20
20
  }
21
+ }, {
22
+ countRelations() {
23
+ return {
24
+ recipients(modelOrCollection) {
25
+ modelOrCollection.query('columns', 'email_batches.*', (qb) => {
26
+ qb.count('email_recipients.id')
27
+ .from('email_recipients')
28
+ .whereRaw('email_batches.id = email_recipients.batch_id')
29
+ .as('count__recipients');
30
+ });
31
+ }
32
+ };
33
+ }
21
34
  });
22
35
 
23
36
  const EmailBatches = ghostBookshelf.Collection.extend({
@@ -0,0 +1,29 @@
1
+ const ghostBookshelf = require('./base');
2
+
3
+ const EmailRecipientFailure = ghostBookshelf.Model.extend({
4
+ tableName: 'email_recipient_failures',
5
+ hasTimestamps: false,
6
+
7
+ defaults() {
8
+ return {
9
+ };
10
+ },
11
+
12
+ email() {
13
+ return this.belongsTo('Email', 'email_id');
14
+ },
15
+
16
+ member() {
17
+ return this.belongsTo('Member', 'member_id');
18
+ },
19
+
20
+ emailRecipient() {
21
+ return this.belongsTo('EmailRecipient', 'email_recipient_id');
22
+ }
23
+ }, {
24
+
25
+ });
26
+
27
+ module.exports = {
28
+ EmailRecipientFailure: ghostBookshelf.model('EmailRecipientFailure', EmailRecipientFailure)
29
+ };
@@ -0,0 +1,44 @@
1
+ const errors = require('@tryghost/errors');
2
+ const ghostBookshelf = require('./base');
3
+
4
+ const EmailSpamComplaintEvent = ghostBookshelf.Model.extend({
5
+ tableName: 'email_spam_complaint_events',
6
+
7
+ filterRelations: function filterRelations() {
8
+ return {
9
+ email: {
10
+ // Mongo-knex doesn't support belongsTo relations
11
+ tableName: 'emails',
12
+ tableNameAs: 'email',
13
+ type: 'manyToMany',
14
+ joinTable: 'email_spam_complaint_events',
15
+ joinFrom: 'id',
16
+ joinTo: 'email_id'
17
+ }
18
+ };
19
+ },
20
+
21
+ email() {
22
+ return this.belongsTo('Email', 'email_id');
23
+ },
24
+
25
+ member() {
26
+ return this.belongsTo('Member', 'member_id');
27
+ }
28
+ }, {
29
+ async edit() {
30
+ throw new errors.IncorrectUsageError({
31
+ message: 'Cannot edit EmailSpamComplaintEvent'
32
+ });
33
+ },
34
+
35
+ async destroy() {
36
+ throw new errors.IncorrectUsageError({
37
+ message: 'Cannot destroy EmailSpamComplaintEvent'
38
+ });
39
+ }
40
+ });
41
+
42
+ module.exports = {
43
+ EmailSpamComplaintEvent: ghostBookshelf.model('EmailSpamComplaintEvent', EmailSpamComplaintEvent)
44
+ };
@@ -180,7 +180,13 @@ Post = ghostBookshelf.Model.extend({
180
180
  cardTransformers: mobiledocLib.cards
181
181
  }
182
182
  },
183
- lexical: 'lexicalToTransformReady',
183
+ lexical: {
184
+ method: 'lexicalToTransformReady',
185
+ options: {
186
+ nodes: lexicalLib.nodes,
187
+ transformMap: lexicalLib.urlTransformMap
188
+ }
189
+ },
184
190
  html: 'htmlToTransformReady',
185
191
  plaintext: 'plaintextToTransformReady',
186
192
  custom_excerpt: 'htmlToTransformReady',
@@ -0,0 +1,9 @@
1
+ const ghostBookshelf = require('./base');
2
+
3
+ const Suppression = ghostBookshelf.Model.extend({
4
+ tableName: 'suppressions'
5
+ });
6
+
7
+ module.exports = {
8
+ Suppression: ghostBookshelf.model('Suppression', Suppression)
9
+ };
@@ -2,14 +2,20 @@ const config = require('../../../shared/config');
2
2
  const db = require('../../data/db');
3
3
  const settings = require('../../../shared/settings-cache');
4
4
  const {EmailAnalyticsService} = require('@tryghost/email-analytics-service');
5
- const EventProcessor = require('./lib/event-processor');
5
+ const {EmailEventProcessor} = require('@tryghost/email-service');
6
6
  const MailgunProvider = require('@tryghost/email-analytics-provider-mailgun');
7
7
  const queries = require('./lib/queries');
8
+ const DomainEvents = require('@tryghost/domain-events');
9
+
10
+ const eventProcessor = new EmailEventProcessor({
11
+ domainEvents: DomainEvents,
12
+ db
13
+ });
8
14
 
9
15
  module.exports = new EmailAnalyticsService({
10
16
  config,
11
17
  settings,
12
- eventProcessor: new EventProcessor({db}),
18
+ eventProcessor,
13
19
  providers: [
14
20
  new MailgunProvider({config, settings})
15
21
  ],
@@ -0,0 +1,50 @@
1
+ const {parentPort} = require('worker_threads');
2
+
3
+ // recurring job to fetch analytics since the most recently seen event timestamp
4
+
5
+ // Exit early when cancelled to prevent stalling shutdown. No cleanup needed when cancelling as everything is idempotent and will pick up
6
+ // where it left off on next run
7
+ function cancel() {
8
+ if (parentPort) {
9
+ parentPort.postMessage('Email analytics fetch-latest job cancelled before completion');
10
+ parentPort.postMessage('cancelled');
11
+ } else {
12
+ setTimeout(() => {
13
+ process.exit(0);
14
+ }, 1000);
15
+ }
16
+ }
17
+
18
+ if (parentPort) {
19
+ parentPort.once('message', (message) => {
20
+ if (message === 'cancel') {
21
+ return cancel();
22
+ }
23
+ });
24
+ }
25
+
26
+ (async () => {
27
+ const {run} = require('./run');
28
+ const {eventStats, aggregateEndDate, fetchStartDate} = await run({
29
+ domainEvents: {
30
+ dispatch(event) {
31
+ parentPort.postMessage({
32
+ event: {
33
+ type: event.constructor.name,
34
+ data: event
35
+ }
36
+ });
37
+ }
38
+ }
39
+ });
40
+
41
+ if (parentPort) {
42
+ parentPort.postMessage(`Fetched ${eventStats.totalEvents} events and aggregated stats for ${eventStats.emailIds.length} emails in ${aggregateEndDate - fetchStartDate}ms`);
43
+ parentPort.postMessage('done');
44
+ } else {
45
+ // give the logging pipes time finish writing before exit
46
+ setTimeout(() => {
47
+ process.exit(0);
48
+ }, 1000);
49
+ }
50
+ })();
@@ -1,32 +1,8 @@
1
- const {parentPort} = require('worker_threads');
2
1
  const debug = require('@tryghost/debug')('jobs:email-analytics:fetch-latest');
3
2
 
4
- // recurring job to fetch analytics since the most recently seen event timestamp
5
-
6
- // Exit early when cancelled to prevent stalling shutdown. No cleanup needed when cancelling as everything is idempotent and will pick up
7
- // where it left off on next run
8
- function cancel() {
9
- if (parentPort) {
10
- parentPort.postMessage('Email analytics fetch-latest job cancelled before completion');
11
- parentPort.postMessage('cancelled');
12
- } else {
13
- setTimeout(() => {
14
- process.exit(0);
15
- }, 1000);
16
- }
17
- }
18
-
19
- if (parentPort) {
20
- parentPort.once('message', (message) => {
21
- if (message === 'cancel') {
22
- return cancel();
23
- }
24
- });
25
- }
26
-
27
- (async () => {
28
- const config = require('../../../../shared/config');
29
- const db = require('../../../data/db');
3
+ async function run({domainEvents}) {
4
+ const config = require('../../../../../shared/config');
5
+ const db = require('../../../../data/db');
30
6
 
31
7
  const settingsRows = await db.knex('settings')
32
8
  .whereIn('key', ['mailgun_api_key', 'mailgun_domain', 'mailgun_base_url']);
@@ -44,14 +20,21 @@ if (parentPort) {
44
20
  };
45
21
 
46
22
  const {EmailAnalyticsService} = require('@tryghost/email-analytics-service');
47
- const EventProcessor = require('../lib/event-processor');
48
23
  const MailgunProvider = require('@tryghost/email-analytics-provider-mailgun');
49
- const queries = require('../lib/queries');
24
+ const queries = require('../../lib/queries');
25
+ const {EmailEventProcessor} = require('@tryghost/email-service');
26
+
27
+ // Since this is running in a worker thread, we cant dispatch directly
28
+ // So we post the events as a message to the job manager
29
+ const eventProcessor = new EmailEventProcessor({
30
+ domainEvents,
31
+ db
32
+ });
50
33
 
51
34
  const emailAnalyticsService = new EmailAnalyticsService({
52
35
  config,
53
36
  settings,
54
- eventProcessor: new EventProcessor({db}),
37
+ eventProcessor,
55
38
  providers: [
56
39
  new MailgunProvider({config, settings})
57
40
  ],
@@ -69,14 +52,6 @@ if (parentPort) {
69
52
  await emailAnalyticsService.aggregateStats(eventStats);
70
53
  const aggregateEndDate = new Date();
71
54
  debug(`Finished aggregating email analytics in ${aggregateEndDate - aggregateStartDate}ms`);
72
-
73
- if (parentPort) {
74
- parentPort.postMessage(`Fetched ${eventStats.totalEvents} events and aggregated stats for ${eventStats.emailIds.length} emails in ${aggregateEndDate - fetchStartDate}ms`);
75
- parentPort.postMessage('done');
76
- } else {
77
- // give the logging pipes time finish writing before exit
78
- setTimeout(() => {
79
- process.exit(0);
80
- }, 1000);
81
- }
82
- })();
55
+ return {eventStats, fetchStartDate, fetchEndDate, aggregateStartDate, aggregateEndDate};
56
+ }
57
+ module.exports.run = run;
@@ -30,7 +30,7 @@ module.exports = {
30
30
 
31
31
  jobsService.addJob({
32
32
  at: `${s} ${m}/5 * * * *`,
33
- job: path.resolve(__dirname, 'fetch-latest.js'),
33
+ job: path.resolve(__dirname, 'fetch-latest/index.js'),
34
34
  name: 'email-analytics-fetch-latest'
35
35
  });
36
36
 
@@ -1,25 +1,74 @@
1
1
  const logging = require('@tryghost/logging');
2
- const ObjectID = require('bson-objectid').default;
2
+ const url = require('../../../server/api/endpoints/utils/serializers/output/utils/url');
3
3
 
4
4
  class EmailServiceWrapper {
5
+ getPostUrl(post) {
6
+ const jsonModel = post.toJSON();
7
+ url.forPost(post.id, jsonModel, {options: {}});
8
+ return jsonModel.url;
9
+ }
10
+
5
11
  init() {
6
- const {EmailService, EmailController, EmailRenderer, SendingService, BatchSendingService, EmailSegmenter} = require('@tryghost/email-service');
7
- const {Post, Newsletter, Email, EmailBatch, EmailRecipient, Member} = require('../../models');
12
+ if (this.service) {
13
+ return;
14
+ }
15
+
16
+ const {EmailService, EmailController, EmailRenderer, SendingService, BatchSendingService, EmailSegmenter, EmailEventStorage, MailgunEmailProvider} = require('@tryghost/email-service');
17
+ const {Post, Newsletter, Email, EmailBatch, EmailRecipient, Member, EmailRecipientFailure, EmailSpamComplaintEvent} = require('../../models');
18
+ const MailgunClient = require('@tryghost/mailgun-client');
19
+ const configService = require('../../../shared/config');
8
20
  const settingsCache = require('../../../shared/settings-cache');
21
+ const settingsHelpers = require('../../services/settings-helpers');
9
22
  const jobsService = require('../jobs');
10
23
  const membersService = require('../members');
11
24
  const db = require('../../data/db');
25
+ const sentry = require('../../../shared/sentry');
12
26
  const membersRepository = membersService.api.members;
13
27
  const limitService = require('../limits');
28
+ const domainEvents = require('@tryghost/domain-events');
14
29
 
15
- const emailRenderer = new EmailRenderer();
16
- const sendingService = new SendingService({
17
- emailProvider: {
18
- send: ({plaintext, subject, from, replyTo, recipients}) => {
19
- logging.info(`Sending email\nSubject: ${subject}\nFrom: ${from}\nReplyTo: ${replyTo}\nRecipients: ${recipients.length}\n\n${plaintext}`);
20
- return Promise.resolve({id: 'fake_provider_id_' + ObjectID().toHexString()});
21
- }
30
+ const mobiledocLib = require('../../lib/mobiledoc');
31
+ const lexicalLib = require('../../lib/lexical');
32
+ const urlUtils = require('../../../shared/url-utils');
33
+ const memberAttribution = require('../member-attribution');
34
+ const linkReplacer = require('@tryghost/link-replacer');
35
+ const linkTracking = require('../link-tracking');
36
+ const audienceFeedback = require('../audience-feedback');
37
+
38
+ // capture errors from mailgun client and log them in sentry
39
+ const errorHandler = (error) => {
40
+ logging.info(`Capturing error for mailgun email provider service`);
41
+ sentry.captureException(error);
42
+ };
43
+
44
+ // Mailgun client instance for email provider
45
+ const mailgunClient = new MailgunClient({
46
+ config: configService, settings: settingsCache
47
+ });
48
+
49
+ const mailgunEmailProvider = new MailgunEmailProvider({
50
+ mailgunClient,
51
+ errorHandler
52
+ });
53
+
54
+ const emailRenderer = new EmailRenderer({
55
+ settingsCache,
56
+ settingsHelpers,
57
+ renderers: {
58
+ mobiledoc: mobiledocLib.mobiledocHtmlRenderer,
59
+ lexical: lexicalLib.lexicalHtmlRenderer
22
60
  },
61
+ imageSize: null,
62
+ urlUtils,
63
+ getPostUrl: this.getPostUrl,
64
+ linkReplacer,
65
+ linkTracking,
66
+ memberAttributionService: memberAttribution.service,
67
+ audienceFeedbackService: audienceFeedback.service
68
+ });
69
+
70
+ const sendingService = new SendingService({
71
+ emailProvider: mailgunEmailProvider,
23
72
  emailRenderer
24
73
  });
25
74
 
@@ -42,15 +91,17 @@ class EmailServiceWrapper {
42
91
 
43
92
  this.service = new EmailService({
44
93
  batchSendingService,
94
+ sendingService,
45
95
  models: {
46
96
  Email
47
97
  },
48
98
  settingsCache,
49
99
  emailRenderer,
50
100
  emailSegmenter,
51
- limitService
101
+ limitService,
102
+ membersRepository
52
103
  });
53
-
104
+
54
105
  this.controller = new EmailController(this.service, {
55
106
  models: {
56
107
  Post,
@@ -58,6 +109,16 @@ class EmailServiceWrapper {
58
109
  Email
59
110
  }
60
111
  });
112
+
113
+ this.eventStorage = new EmailEventStorage({
114
+ db,
115
+ membersRepository,
116
+ models: {
117
+ EmailRecipientFailure,
118
+ EmailSpamComplaintEvent
119
+ }
120
+ });
121
+ this.eventStorage.listen(domainEvents);
61
122
  }
62
123
  }
63
124
 
@@ -0,0 +1,36 @@
1
+ const {AbstractEmailSuppressionList, EmailSuppressionData} = require('@tryghost/email-suppression-list');
2
+
3
+ module.exports = class InMemoryEmailSuppressionList extends AbstractEmailSuppressionList {
4
+ store = ['spam@member.test', 'fail@member.test'];
5
+
6
+ async removeEmail(email) {
7
+ if ((email === 'fail@member.test' || email === 'spam@member.test') && this.store.includes(email)) {
8
+ this.store = this.store.filter(el => el !== email);
9
+
10
+ setTimeout(() => this.store.push(email), 3000);
11
+ return true;
12
+ }
13
+
14
+ return false;
15
+ }
16
+
17
+ async getSuppressionData(email) {
18
+ if (email === 'spam@member.test' && this.store.includes(email)) {
19
+ return new EmailSuppressionData(true, {
20
+ timestamp: new Date(),
21
+ reason: 'spam'
22
+ });
23
+ }
24
+ if (email === 'fail@member.test' && this.store.includes(email)) {
25
+ return new EmailSuppressionData(true, {
26
+ timestamp: new Date(),
27
+ reason: 'fail'
28
+ });
29
+ }
30
+ return new EmailSuppressionData(false);
31
+ }
32
+
33
+ async init() {
34
+ return;
35
+ }
36
+ };
@@ -0,0 +1,117 @@
1
+ const {AbstractEmailSuppressionList, EmailSuppressionData} = require('@tryghost/email-suppression-list');
2
+ const {SpamComplaintEvent, EmailBouncedEvent} = require('@tryghost/email-events');
3
+ const DomainEvents = require('@tryghost/domain-events');
4
+ const logging = require('@tryghost/logging');
5
+
6
+ /**
7
+ * @typedef {object} IMailgunAPIClient
8
+ * @prop {(email: string) => Promise<any>} removeBounce
9
+ * @prop {(email: string) => Promise<any>} removeComplaint
10
+ * @prop {(email: string) => Promise<any>} removeUnsubscribe
11
+ */
12
+
13
+ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
14
+ /**
15
+ * @param {object} deps
16
+ * @param {import('bookshelf').Model} deps.Suppression
17
+ * @param {IMailgunAPIClient} deps.apiClient
18
+ */
19
+ constructor(deps) {
20
+ super();
21
+ this.Suppression = deps.Suppression;
22
+ this.apiClient = deps.apiClient;
23
+ }
24
+
25
+ async removeEmail(email) {
26
+ try {
27
+ await this.apiClient.removeBounce(email);
28
+ await this.apiClient.removeComplaint(email);
29
+ await this.apiClient.removeUnsubscribe(email);
30
+ } catch (err) {
31
+ logging.error(err);
32
+ return false;
33
+ }
34
+
35
+ try {
36
+ await this.Suppression.destroy({
37
+ destroyBy: {
38
+ email_address: email
39
+ }
40
+ });
41
+ } catch (err) {
42
+ logging.error(err);
43
+ return false;
44
+ }
45
+
46
+ return true;
47
+ }
48
+
49
+ async getSuppressionData(email) {
50
+ try {
51
+ const model = await this.Suppression.findOne({
52
+ email_address: email
53
+ });
54
+
55
+ if (!model) {
56
+ return new EmailSuppressionData(false);
57
+ }
58
+
59
+ return new EmailSuppressionData(true, {
60
+ timestamp: model.get('created_at'),
61
+ reason: model.get('reason') === 'spam' ? 'spam' : 'fail'
62
+ });
63
+ } catch (err) {
64
+ logging.error(err);
65
+ return new EmailSuppressionData(false);
66
+ }
67
+ }
68
+
69
+ async getBulkSuppressionData(emails) {
70
+ if (emails.length === 0) {
71
+ return [];
72
+ }
73
+
74
+ try {
75
+ const collection = await this.Suppression.findAll({
76
+ filter: `email_address:[${emails.join(',')}]`
77
+ });
78
+
79
+ return emails.map((email) => {
80
+ const model = collection.models.find(m => m.get('email_address') === email);
81
+
82
+ if (!model) {
83
+ return new EmailSuppressionData(false);
84
+ }
85
+
86
+ return new EmailSuppressionData(true, {
87
+ timestamp: model.get('created_at'),
88
+ reason: model.get('reason') === 'spam' ? 'spam' : 'fail'
89
+ });
90
+ });
91
+ } catch (err) {
92
+ logging.error(err);
93
+ return emails.map(() => new EmailSuppressionData(false));
94
+ }
95
+ }
96
+
97
+ async init() {
98
+ const handleEvent = async (event) => {
99
+ try {
100
+ await this.Suppression.add({
101
+ email_address: event.email,
102
+ email_id: event.emailId,
103
+ reason: 'bounce',
104
+ created_at: event.timestamp
105
+ });
106
+ } catch (err) {
107
+ if (err.code !== 'ER_DUP_ENTRY') {
108
+ logging.error(err);
109
+ }
110
+ }
111
+ };
112
+ DomainEvents.subscribe(EmailBouncedEvent, handleEvent);
113
+ DomainEvents.subscribe(SpamComplaintEvent, handleEvent);
114
+ }
115
+ }
116
+
117
+ module.exports = MailgunEmailSuppressionList;