ghost 5.22.10 → 5.23.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/.c8rc.e2e.json +21 -0
  2. package/README.md +0 -2
  3. package/components/tryghost-adapter-manager-5.23.0.tgz +0 -0
  4. package/components/tryghost-api-framework-5.23.0.tgz +0 -0
  5. package/components/tryghost-api-version-compatibility-service-5.23.0.tgz +0 -0
  6. package/components/{tryghost-audience-feedback-5.22.10.tgz → tryghost-audience-feedback-5.23.0.tgz} +0 -0
  7. package/components/tryghost-bootstrap-socket-5.23.0.tgz +0 -0
  8. package/components/tryghost-constants-5.23.0.tgz +0 -0
  9. package/components/tryghost-custom-theme-settings-service-5.23.0.tgz +0 -0
  10. package/components/tryghost-data-generator-5.23.0.tgz +0 -0
  11. package/components/tryghost-domain-events-5.23.0.tgz +0 -0
  12. package/components/tryghost-email-analytics-provider-mailgun-5.23.0.tgz +0 -0
  13. package/components/tryghost-email-analytics-service-5.23.0.tgz +0 -0
  14. package/components/tryghost-email-content-generator-5.23.0.tgz +0 -0
  15. package/components/tryghost-email-suppression-list-5.23.0.tgz +0 -0
  16. package/components/tryghost-express-dynamic-redirects-5.23.0.tgz +0 -0
  17. package/components/tryghost-extract-api-key-5.23.0.tgz +0 -0
  18. package/components/tryghost-html-to-plaintext-5.23.0.tgz +0 -0
  19. package/components/{tryghost-job-manager-5.22.10.tgz → tryghost-job-manager-5.23.0.tgz} +0 -0
  20. package/components/tryghost-link-redirects-5.23.0.tgz +0 -0
  21. package/components/tryghost-link-replacer-5.23.0.tgz +0 -0
  22. package/components/tryghost-link-tracking-5.23.0.tgz +0 -0
  23. package/components/{tryghost-magic-link-5.22.10.tgz → tryghost-magic-link-5.23.0.tgz} +0 -0
  24. package/components/tryghost-mailgun-client-5.23.0.tgz +0 -0
  25. package/components/{tryghost-member-attribution-5.22.10.tgz → tryghost-member-attribution-5.23.0.tgz} +0 -0
  26. package/components/tryghost-member-events-5.23.0.tgz +0 -0
  27. package/components/tryghost-members-api-5.23.0.tgz +0 -0
  28. package/components/tryghost-members-csv-5.23.0.tgz +0 -0
  29. package/components/tryghost-members-events-service-5.23.0.tgz +0 -0
  30. package/components/tryghost-members-importer-5.23.0.tgz +0 -0
  31. package/components/{tryghost-members-offers-5.22.10.tgz → tryghost-members-offers-5.23.0.tgz} +0 -0
  32. package/components/tryghost-members-payments-5.23.0.tgz +0 -0
  33. package/components/tryghost-members-ssr-5.23.0.tgz +0 -0
  34. package/components/tryghost-members-stripe-service-5.23.0.tgz +0 -0
  35. package/components/tryghost-minifier-5.23.0.tgz +0 -0
  36. package/components/tryghost-mw-api-version-mismatch-5.23.0.tgz +0 -0
  37. package/components/tryghost-mw-cache-control-5.23.0.tgz +0 -0
  38. package/components/{tryghost-mw-error-handler-5.22.10.tgz → tryghost-mw-error-handler-5.23.0.tgz} +0 -0
  39. package/components/tryghost-mw-session-from-token-5.23.0.tgz +0 -0
  40. package/components/tryghost-mw-update-user-last-seen-5.23.0.tgz +0 -0
  41. package/components/tryghost-mw-vhost-5.23.0.tgz +0 -0
  42. package/components/tryghost-oembed-service-5.23.0.tgz +0 -0
  43. package/components/tryghost-package-json-5.23.0.tgz +0 -0
  44. package/components/tryghost-referrers-5.23.0.tgz +0 -0
  45. package/components/tryghost-security-5.23.0.tgz +0 -0
  46. package/components/tryghost-session-service-5.23.0.tgz +0 -0
  47. package/components/tryghost-settings-path-manager-5.23.0.tgz +0 -0
  48. package/components/{tryghost-staff-service-5.22.10.tgz → tryghost-staff-service-5.23.0.tgz} +0 -0
  49. package/components/{tryghost-stats-service-5.22.10.tgz → tryghost-stats-service-5.23.0.tgz} +0 -0
  50. package/components/{tryghost-tiers-5.22.10.tgz → tryghost-tiers-5.23.0.tgz} +0 -0
  51. package/components/tryghost-update-check-service-5.23.0.tgz +0 -0
  52. package/components/tryghost-verification-trigger-5.23.0.tgz +0 -0
  53. package/components/tryghost-version-notifications-data-service-5.23.0.tgz +0 -0
  54. package/content/themes/casper/assets/built/screen.css +1 -1
  55. package/content/themes/casper/assets/built/screen.css.map +1 -1
  56. package/content/themes/casper/assets/css/screen.css +3 -5
  57. package/content/themes/casper/default.hbs +2 -2
  58. package/content/themes/casper/package.json +1 -1
  59. package/core/boot.js +3 -1
  60. package/core/built/admin/assets/{chunk.143.efc32dbcc893663c95cb.js → chunk.143.3dccaccd501e94a38af6.js} +5 -5
  61. package/core/built/admin/assets/{chunk.178.95a503a229a8fd49e91e.js → chunk.178.f3466350ec3bacc7baa6.js} +4 -4
  62. package/core/built/admin/assets/{ghost-b204dcc6ad523053868da9b2d8d65f80.js → ghost-64c5f32d4052347eed1ae73909b526ef.js} +1240 -1202
  63. package/core/built/admin/assets/ghost-bbf3150c788d19852f14fc518b8ebb93.css +1 -0
  64. package/core/built/admin/assets/ghost-dark-681daaaef962911f5bcfdd51d7b4efdd.css +1 -0
  65. package/core/built/admin/assets/{vendor-dc9f883b3468ff84794cf13741e6c4b4.js → vendor-45c4a02c5360deeb66a0438a8bc7c18e.js} +42 -38
  66. package/core/built/admin/index.html +5 -5
  67. package/core/frontend/apps/amp/lib/helpers/amp_content.js +1 -1
  68. package/core/frontend/apps/amp/lib/views/amp.hbs +10 -0
  69. package/core/frontend/helpers/ghost_head.js +1 -7
  70. package/core/server/api/endpoints/db.js +17 -11
  71. package/core/server/api/endpoints/posts.js +4 -1
  72. package/core/server/api/endpoints/utils/serializers/input/posts.js +4 -11
  73. package/core/server/api/endpoints/utils/serializers/output/db.js +3 -7
  74. package/core/server/api/endpoints/utils/serializers/output/members.js +5 -0
  75. package/core/server/data/importer/email-template.js +163 -0
  76. package/core/server/data/importer/import-manager.js +115 -35
  77. package/core/server/data/importer/importers/data/base.js +1 -0
  78. package/core/server/data/importer/importers/data/data-importer.js +27 -1
  79. package/core/server/data/schema/default-settings/default-settings.json +1 -1
  80. package/core/server/models/base/plugins/bulk-operations.js +0 -1
  81. package/core/server/models/comment.js +1 -1
  82. package/core/server/models/member-newsletter.js +9 -0
  83. package/core/server/models/member.js +1 -9
  84. package/core/server/models/stripe-customer-subscription.js +3 -7
  85. package/core/server/services/bulk-email/bulk-email-processor.js +14 -23
  86. package/core/server/services/email-suppression-list/index.js +1 -0
  87. package/core/server/services/email-suppression-list/service.js +32 -0
  88. package/core/server/services/mega/mega.js +50 -5
  89. package/core/server/services/mega/segment-parser.js +1 -3
  90. package/core/server/services/members/api.js +4 -2
  91. package/core/server/services/members/middleware.js +1 -1
  92. package/core/server/services/members/service.js +35 -28
  93. package/core/server/web/members/app.js +0 -1
  94. package/core/shared/config/defaults.json +1 -1
  95. package/core/shared/labs.js +3 -7
  96. package/package.json +103 -101
  97. package/playwright.config.js +15 -0
  98. package/yarn.lock +75 -130
  99. package/components/tryghost-adapter-manager-5.22.10.tgz +0 -0
  100. package/components/tryghost-api-framework-5.22.10.tgz +0 -0
  101. package/components/tryghost-api-version-compatibility-service-5.22.10.tgz +0 -0
  102. package/components/tryghost-bootstrap-socket-5.22.10.tgz +0 -0
  103. package/components/tryghost-constants-5.22.10.tgz +0 -0
  104. package/components/tryghost-custom-theme-settings-service-5.22.10.tgz +0 -0
  105. package/components/tryghost-data-generator-5.22.10.tgz +0 -0
  106. package/components/tryghost-domain-events-5.22.10.tgz +0 -0
  107. package/components/tryghost-email-analytics-provider-mailgun-5.22.10.tgz +0 -0
  108. package/components/tryghost-email-analytics-service-5.22.10.tgz +0 -0
  109. package/components/tryghost-email-content-generator-5.22.10.tgz +0 -0
  110. package/components/tryghost-express-dynamic-redirects-5.22.10.tgz +0 -0
  111. package/components/tryghost-extract-api-key-5.22.10.tgz +0 -0
  112. package/components/tryghost-html-to-plaintext-5.22.10.tgz +0 -0
  113. package/components/tryghost-link-redirects-5.22.10.tgz +0 -0
  114. package/components/tryghost-link-replacer-5.22.10.tgz +0 -0
  115. package/components/tryghost-link-tracking-5.22.10.tgz +0 -0
  116. package/components/tryghost-mailgun-client-5.22.10.tgz +0 -0
  117. package/components/tryghost-member-analytics-service-5.22.10.tgz +0 -0
  118. package/components/tryghost-member-events-5.22.10.tgz +0 -0
  119. package/components/tryghost-members-analytics-ingress-5.22.10.tgz +0 -0
  120. package/components/tryghost-members-api-5.22.10.tgz +0 -0
  121. package/components/tryghost-members-csv-5.22.10.tgz +0 -0
  122. package/components/tryghost-members-events-service-5.22.10.tgz +0 -0
  123. package/components/tryghost-members-importer-5.22.10.tgz +0 -0
  124. package/components/tryghost-members-payments-5.22.10.tgz +0 -0
  125. package/components/tryghost-members-ssr-5.22.10.tgz +0 -0
  126. package/components/tryghost-members-stripe-service-5.22.10.tgz +0 -0
  127. package/components/tryghost-minifier-5.22.10.tgz +0 -0
  128. package/components/tryghost-mw-api-version-mismatch-5.22.10.tgz +0 -0
  129. package/components/tryghost-mw-cache-control-5.22.10.tgz +0 -0
  130. package/components/tryghost-mw-session-from-token-5.22.10.tgz +0 -0
  131. package/components/tryghost-mw-update-user-last-seen-5.22.10.tgz +0 -0
  132. package/components/tryghost-mw-vhost-5.22.10.tgz +0 -0
  133. package/components/tryghost-oembed-service-5.22.10.tgz +0 -0
  134. package/components/tryghost-package-json-5.22.10.tgz +0 -0
  135. package/components/tryghost-referrers-5.22.10.tgz +0 -0
  136. package/components/tryghost-security-5.22.10.tgz +0 -0
  137. package/components/tryghost-session-service-5.22.10.tgz +0 -0
  138. package/components/tryghost-settings-path-manager-5.22.10.tgz +0 -0
  139. package/components/tryghost-update-check-service-5.22.10.tgz +0 -0
  140. package/components/tryghost-verification-trigger-5.22.10.tgz +0 -0
  141. package/components/tryghost-version-notifications-data-service-5.22.10.tgz +0 -0
  142. package/core/built/admin/assets/ghost-03c7a25d23ad4d0725da171f8d7c7b2a.css +0 -1
  143. package/core/built/admin/assets/ghost-dark-8896a076fc06ec2b09343b1c9df7feca.css +0 -1
  144. package/core/server/models/member-analytic-event.js +0 -9
@@ -0,0 +1,163 @@
1
+ module.exports = ({result, siteUrl, postsUrl, emailRecipient}) => `
2
+ <!doctype html>
3
+ <html>
4
+ <head>
5
+ <meta name="viewport" content="width=device-width">
6
+ <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
7
+ <title>Your content import is complete</title>
8
+ <style>
9
+ /* -------------------------------------
10
+ RESPONSIVE AND MOBILE FRIENDLY STYLES
11
+ ------------------------------------- */
12
+ @media only screen and (max-width: 620px) {
13
+ table[class=body] h1 {
14
+ font-size: 28px !important;
15
+ margin-bottom: 10px !important;
16
+ }
17
+ table[class=body] p,
18
+ table[class=body] ul,
19
+ table[class=body] ol,
20
+ table[class=body] td,
21
+ table[class=body] span,
22
+ table[class=body] a {
23
+ font-size: 16px !important;
24
+ }
25
+ table[class=body] .title {
26
+ font-size: 22px !important;
27
+ }
28
+ table[class=body] .wrapper,
29
+ table[class=body] .article {
30
+ padding: 10px !important;
31
+ }
32
+ table[class=body] .content {
33
+ padding: 0 !important;
34
+ }
35
+ table[class=body] .container {
36
+ padding: 0 !important;
37
+ width: 100% !important;
38
+ }
39
+ table[class=body] .main {
40
+ border-left-width: 0 !important;
41
+ border-radius: 0 !important;
42
+ border-right-width: 0 !important;
43
+ }
44
+ table[class=body] .btn table {
45
+ width: 100% !important;
46
+ }
47
+ table[class=body] .btn a {
48
+ width: 100% !important;
49
+ }
50
+ table[class=body] .img-responsive {
51
+ height: auto !important;
52
+ max-width: 100% !important;
53
+ width: auto !important;
54
+ }
55
+ table[class=body] p[class=small],
56
+ table[class=body] a[class=small] {
57
+ font-size: 12x !important;
58
+ }
59
+ }
60
+ /* -------------------------------------
61
+ PRESERVE THESE STYLES IN THE HEAD
62
+ ------------------------------------- */
63
+ @media all {
64
+ .ExternalClass {
65
+ width: 100%;
66
+ }
67
+ .ExternalClass,
68
+ .ExternalClass p,
69
+ .ExternalClass span,
70
+ .ExternalClass font,
71
+ .ExternalClass td,
72
+ .ExternalClass div {
73
+ line-height: 100%;
74
+ }
75
+ .recipient-link a {
76
+ color: inherit !important;
77
+ font-family: inherit !important;
78
+ font-size: inherit !important;
79
+ font-weight: inherit !important;
80
+ line-height: inherit !important;
81
+ text-decoration: none !important;
82
+ }
83
+ #MessageViewBody a {
84
+ color: inherit;
85
+ text-decoration: none;
86
+ font-size: inherit;
87
+ font-family: inherit;
88
+ font-weight: inherit;
89
+ line-height: inherit;
90
+ }
91
+ }
92
+ hr {
93
+ border-width: 0;
94
+ height: 0;
95
+ margin-top: 34px;
96
+ margin-bottom: 34px;
97
+ border-bottom-width: 1px;
98
+ border-bottom-color: #EEF5F8;
99
+ }
100
+ a {
101
+ color: #3A464C;
102
+ }
103
+ </style>
104
+ </head>
105
+ <body class="" style="background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
106
+ <table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
107
+ <tr>
108
+ <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
109
+ <td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
110
+ <div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
111
+
112
+ <!-- START CENTERED CONTAINER -->
113
+ <span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">Your content import is complete</span>
114
+ <table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
115
+
116
+ <!-- START MAIN CONTENT AREA -->
117
+ <tr>
118
+ <td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;">
119
+ <table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
120
+ <tr>
121
+ <td align="center" style="padding-top: 20px; padding-bottom: 12px;"><img src="https://static.ghost.org/v4.0.0/images/ghost-orb-4.png" width="60" height="60" style="width: 60px; height: 60px;" /></td>
122
+ </tr>
123
+ <tr>
124
+ <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top;">
125
+ <p class="title" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 21px; color: #3A464C; font-weight: normal; line-height: 25px; margin-bottom: 30px; margin-top: 50px; font-weight: 600; color: #15212A;">${result.data.problems.length ? 'Import unsuccessful' : 'Your content import has finished successfully'}</p>
126
+ </td>
127
+ </tr>
128
+ ${result.data.problems.length ? `
129
+ <tr>
130
+ <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-bottom: 16px;">One or more error occured while importing your content. Please contact support or report on the <a href="https://forum.ghost.org/">Ghost Community Forum</a>.</td>
131
+ </tr>
132
+ ` : `
133
+ <tr>
134
+ <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-bottom: 12px; padding-top: 16px;">
135
+ <a href="${postsUrl.href}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #15212A; border: solid 1px #15212A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #15212A;">View posts</a>
136
+ </td>
137
+ </tr>
138
+ `}
139
+ </table>
140
+ </td>
141
+ </tr>
142
+ <tr>
143
+ <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-top: 80px; padding-bottom: 10px;">
144
+ <div class="footer">
145
+ <p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; color: #738A94; font-weight: normal; margin: 0; line-height: 18px; margin-bottom: 0px; font-size: 11px;">This email was sent from <a href="${siteUrl.href}" style="color: #738A94;">${siteUrl.host}</a> to <a href="mailto:${emailRecipient}" style="color: #738A94;">${emailRecipient}</a></p>
146
+ </div>
147
+ </td>
148
+ </tr>
149
+
150
+ <!-- END MAIN CONTENT AREA -->
151
+ </table>
152
+
153
+
154
+ <!-- END CENTERED CONTAINER -->
155
+ </div>
156
+ </td>
157
+ <td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">&nbsp;</td>
158
+ </tr>
159
+ </table>
160
+ </body>
161
+ </html>
162
+ `;
163
+
@@ -4,6 +4,7 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const glob = require('glob');
6
6
  const uuid = require('uuid');
7
+ const config = require('../../../shared/config');
7
8
  const {extract} = require('@tryghost/zip');
8
9
  const tpl = require('@tryghost/tpl');
9
10
  const logging = require('@tryghost/logging');
@@ -13,6 +14,12 @@ const JSONHandler = require('./handlers/json');
13
14
  const MarkdownHandler = require('./handlers/markdown');
14
15
  const ImageImporter = require('./importers/image');
15
16
  const DataImporter = require('./importers/data');
17
+ const urlUtils = require('../../../shared/url-utils');
18
+ const {GhostMailer} = require('../../services/mail');
19
+ const jobManager = require('../../services/jobs');
20
+
21
+ const emailTemplate = require('./email-template');
22
+ const ghostMailer = new GhostMailer();
16
23
 
17
24
  const messages = {
18
25
  couldNotCleanUpFile: {
@@ -115,28 +122,6 @@ class ImportManager {
115
122
  return prefix + this.getGlobPattern(directories);
116
123
  }
117
124
 
118
- /**
119
- * Remove files after we're done (abstracted into a function for easier testing)
120
- * @returns {Promise<void>}
121
- */
122
- async cleanUp() {
123
- if (this.fileToDelete === null) {
124
- return;
125
- }
126
-
127
- try {
128
- await fs.remove(this.fileToDelete);
129
- } catch (err) {
130
- logging.error(new errors.InternalServerError({
131
- err: err,
132
- context: tpl(messages.couldNotCleanUpFile.error),
133
- help: tpl(messages.couldNotCleanUpFile.context)
134
- }));
135
- }
136
-
137
- this.fileToDelete = null;
138
- }
139
-
140
125
  /**
141
126
  * Return true if the given file is a Zip
142
127
  * @returns Boolean
@@ -333,15 +318,15 @@ class ImportManager {
333
318
  * data that it should import. Each importer then handles actually importing that data into Ghost
334
319
  * @param {ImportData} importData
335
320
  * @param {ImportOptions} [importOptions] to allow override of certain import features such as locking a user
336
- * @returns {Promise<ImportResult[]>} importResults
321
+ * @returns {Promise<Object.<string, ImportResult>>} importResults
337
322
  */
338
323
  async doImport(importData, importOptions) {
339
324
  importOptions = importOptions || {};
340
- const importResults = [];
325
+ const importResults = {};
341
326
 
342
327
  for (const importer of this.importers) {
343
328
  if (Object.prototype.hasOwnProperty.call(importData, importer.type)) {
344
- importResults.push(await importer.doImport(importData[importer.type], importOptions));
329
+ importResults[importer.type] = await importer.doImport(importData[importer.type], importOptions);
345
330
  }
346
331
  }
347
332
 
@@ -351,45 +336,140 @@ class ImportManager {
351
336
  /**
352
337
  * Import Step 4:
353
338
  * Report on what was imported, currently a no-op
354
- * @param {ImportResult[]} importResults
355
- * @returns {Promise<ImportResult[]>} importResults
339
+ * @param {Object.<string, ImportResult>} importResults
340
+ * @returns {Promise<Object.<string, ImportResult>>} importResults
356
341
  */
357
342
  async generateReport(importResults) {
358
343
  return Promise.resolve(importResults);
359
344
  }
360
345
 
346
+ /**
347
+ * Step 5:
348
+ * Remove files after we're done (abstracted into a function for easier testing)
349
+ * @returns {Promise<void>}
350
+ */
351
+ async cleanUp() {
352
+ if (this.fileToDelete === null) {
353
+ return;
354
+ }
355
+
356
+ try {
357
+ await fs.remove(this.fileToDelete);
358
+ } catch (err) {
359
+ logging.error(new errors.InternalServerError({
360
+ err: err,
361
+ context: tpl(messages.couldNotCleanUpFile.error),
362
+ help: tpl(messages.couldNotCleanUpFile.context)
363
+ }));
364
+ }
365
+
366
+ this.fileToDelete = null;
367
+ }
368
+
369
+ /**
370
+ * Import Step 6:
371
+ * Create an email to notify the user that the import has completed
372
+ * @param {ImportResult} result
373
+ * @param {Object} options
374
+ * @param {string} options.emailRecipient
375
+ * @param {string} options.importTag
376
+ * @returns {string}
377
+ */
378
+ generateCompletionEmail(result, {
379
+ emailRecipient,
380
+ importTag
381
+ }) {
382
+ const siteUrl = new URL(urlUtils.urlFor('home', null, true));
383
+ const postsUrl = new URL('posts', urlUtils.urlFor('admin', null, true));
384
+ if (importTag && result?.data?.tags) {
385
+ const tag = result.data.tags.find(t => t.name === importTag);
386
+ postsUrl.searchParams.set('tag', tag.slug);
387
+ }
388
+
389
+ return emailTemplate({
390
+ result,
391
+ siteUrl,
392
+ postsUrl,
393
+ emailRecipient
394
+ });
395
+ }
396
+
361
397
  /**
362
398
  * Import From File
363
399
  * The main method of the ImportManager, call this to kick everything off!
364
400
  * @param {File} file
365
401
  * @param {ImportOptions} importOptions to allow override of certain import features such as locking a user
366
- * @returns {Promise<ImportResult[]>}
402
+ * @returns {Promise<Object.<string, ImportResult>>}
367
403
  */
368
404
  async importFromFile(file, importOptions = {}) {
369
- try {
405
+ let importData;
406
+ if (importOptions.data) {
407
+ importData = importOptions.data;
408
+ } else {
370
409
  // Step 1: Handle converting the file to usable data
371
- let importData = await this.loadFile(file);
410
+ // Has to be completed outside of job to ensure file is processed before being deleted
411
+ importData = await this.loadFile(file);
412
+ }
413
+
414
+ const env = config.get('env');
415
+ if (!env?.startsWith('testing') && !importOptions.runningInJob) {
416
+ return jobManager.addJob({
417
+ job: () => this.importFromFile(file, Object.assign({}, importOptions, {
418
+ runningInJob: true,
419
+ data: importData
420
+ })),
421
+ offloaded: false
422
+ });
423
+ }
372
424
 
425
+ let importResult;
426
+ try {
373
427
  // Step 2: Let the importers pre-process the data
374
428
  importData = await this.preProcess(importData);
375
-
429
+
376
430
  // Step 3: Actually do the import
377
431
  // @TODO: It would be cool to have some sort of dry run flag here
378
- let importResult = await this.doImport(importData, importOptions);
379
-
432
+ importResult = await this.doImport(importData, importOptions);
433
+
380
434
  // Step 4: Report on the import
381
- return await this.generateReport(importResult);
435
+ importResult = await this.generateReport(importResult);
436
+
437
+ return importResult;
438
+ } catch (err) {
439
+ logging.error(`Content import was unsuccessful`, {
440
+ error: err
441
+ });
382
442
  } finally {
383
443
  // Step 5: Cleanup any files
384
- this.cleanUp();
444
+ await this.cleanUp();
445
+
446
+ if (!env?.startsWith('testing')) {
447
+ // Step 6: Send email
448
+ const email = this.generateCompletionEmail(importResult, {
449
+ emailRecipient: importOptions.user.email,
450
+ importTag: importOptions.importTag
451
+ });
452
+ await ghostMailer.send({
453
+ to: importOptions.user.email,
454
+ subject: importResult.data.problems.length
455
+ ? 'Your content import was unsuccessful'
456
+ : 'Your content import has finished',
457
+ html: email
458
+ });
459
+ }
385
460
  }
386
461
  }
387
462
  }
388
463
 
389
464
  /**
390
465
  * @typedef {object} ImportOptions
466
+ * @property {boolean} [runningInJob]
391
467
  * @property {boolean} [returnImportedData]
392
468
  * @property {boolean} [importPersistUser]
469
+ * @property {Object} [user]
470
+ * @property {string} [user.email]
471
+ * @property {string} [importTag]
472
+ * @property {Object} [data]
393
473
  */
394
474
 
395
475
  /**
@@ -10,6 +10,7 @@ class Base {
10
10
  this.options = options;
11
11
  this.modelName = options.modelName;
12
12
 
13
+ // Problems are currently constructed but not displayed to the user
13
14
  this.problems = [];
14
15
  this.errors = [];
15
16
 
@@ -1,5 +1,5 @@
1
1
  const _ = require('lodash');
2
- const Promise = require('bluebird');
2
+ const ObjectId = require('bson-objectid').default;
3
3
  const semver = require('semver');
4
4
  const {IncorrectUsageError} = require('@tryghost/errors');
5
5
  const debug = require('@tryghost/debug')('importer:data');
@@ -15,6 +15,7 @@ const StripeProductsImporter = require('./stripe-products');
15
15
  const StripePricesImporter = require('./stripe-prices');
16
16
  const CustomThemeSettingsImporter = require('./custom-theme-settings');
17
17
  const RolesImporter = require('./roles');
18
+ const {slugify} = require('@tryghost/string/lib');
18
19
 
19
20
  let importers = {};
20
21
  let DataImporter;
@@ -46,6 +47,31 @@ DataImporter = {
46
47
  doImport: async function doImport(importData, importOptions) {
47
48
  importOptions = importOptions || {};
48
49
 
50
+ if (importOptions.importTag && importData?.data?.posts) {
51
+ const tagId = ObjectId().toHexString();
52
+ if (!('tags' in importData.data)) {
53
+ importData.data.tags = [];
54
+ }
55
+ importData.data.tags.push({
56
+ id: tagId,
57
+ name: importOptions.importTag,
58
+ slug: slugify(importOptions.importTag)
59
+ });
60
+ if (!('posts_tags' in importData.data)) {
61
+ importData.data.posts_tags = [];
62
+ }
63
+ for (const post of importData.data.posts) {
64
+ if (!('id' in post)) {
65
+ // Make sure post has an id if it doesn't already
66
+ post.id = ObjectId().toHexString();
67
+ }
68
+ importData.data.posts_tags.push({
69
+ post_id: post.id,
70
+ tag_id: tagId
71
+ });
72
+ }
73
+ }
74
+
49
75
  const ops = [];
50
76
  let problems = [];
51
77
  let errors = [];
@@ -83,7 +83,7 @@
83
83
  "type": "string"
84
84
  },
85
85
  "cover_image": {
86
- "defaultValue": "https://static.ghost.org/v4.0.0/images/publication-cover.jpg",
86
+ "defaultValue": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
87
87
  "type": "string"
88
88
  },
89
89
  "icon": {
@@ -101,7 +101,6 @@ module.exports = function (Bookshelf) {
101
101
  */
102
102
  bulkDestroy: function bulkDestroy(data, tableName, options = {}) {
103
103
  tableName = tableName || this.prototype.tableName;
104
-
105
104
  return del(Bookshelf.knex, tableName, data, options);
106
105
  }
107
106
  });
@@ -101,7 +101,7 @@ const Comment = ghostBookshelf.Model.extend({
101
101
  },
102
102
 
103
103
  enforcedFilters: function enforcedFilters(options) {
104
- // Convenicence option to merge all filters with parent_id:null filter
104
+ // Convenience option to merge all filters with parent_id:null filter
105
105
  if (options.parentId !== undefined) {
106
106
  if (options.parentId === null) {
107
107
  return 'parent_id:null';
@@ -0,0 +1,9 @@
1
+ const ghostBookshelf = require('./base');
2
+
3
+ const MemberNewsletter = ghostBookshelf.Model.extend({
4
+ tableName: 'members_newsletters'
5
+ });
6
+
7
+ module.exports = {
8
+ MemberNewsletter: ghostBookshelf.model('MemberNewsletter', MemberNewsletter)
9
+ };
@@ -3,7 +3,6 @@ const uuid = require('uuid');
3
3
  const _ = require('lodash');
4
4
  const config = require('../../shared/config');
5
5
  const {gravatar} = require('../lib/image');
6
- const labs = require('../../shared/labs');
7
6
 
8
7
  const Member = ghostBookshelf.Model.extend({
9
8
  tableName: 'members',
@@ -154,11 +153,7 @@ const Member = ghostBookshelf.Model.extend({
154
153
  .query((qb) => {
155
154
  // avoids bookshelf adding a `DISTINCT` to the query
156
155
  // we know the result set will already be unique and DISTINCT hurts query performance
157
- if (labs.isSet('compExpiring')) {
158
- qb.columns('products.*', 'expiry_at');
159
- } else {
160
- qb.columns('products.*');
161
- }
156
+ qb.columns('products.*', 'expiry_at');
162
157
  });
163
158
  },
164
159
 
@@ -207,9 +202,6 @@ const Member = ghostBookshelf.Model.extend({
207
202
  },
208
203
 
209
204
  async updateTierExpiry(products = [], options = {}) {
210
- if (!labs.isSet('compExpiring')) {
211
- return;
212
- }
213
205
  for (const product of products) {
214
206
  if (product?.expiry_at) {
215
207
  const expiry = new Date(product.expiry_at);
@@ -1,6 +1,5 @@
1
1
  const ghostBookshelf = require('./base');
2
2
  const _ = require('lodash');
3
- const labs = require('../../shared/labs');
4
3
 
5
4
  const StripeCustomerSubscription = ghostBookshelf.Model.extend({
6
5
  tableName: 'members_stripe_customers_subscriptions',
@@ -40,14 +39,11 @@ const StripeCustomerSubscription = ghostBookshelf.Model.extend({
40
39
  default_payment_card_last4: defaultSerializedObject.default_payment_card_last4,
41
40
  cancel_at_period_end: defaultSerializedObject.cancel_at_period_end,
42
41
  cancellation_reason: defaultSerializedObject.cancellation_reason,
43
- current_period_end: defaultSerializedObject.current_period_end
42
+ current_period_end: defaultSerializedObject.current_period_end,
43
+ trial_start_at: defaultSerializedObject.trial_start_at,
44
+ trial_end_at: defaultSerializedObject.trial_end_at
44
45
  };
45
46
 
46
- if (labs.isSet('freeTrial')) {
47
- serialized.trial_start_at = defaultSerializedObject.trial_start_at;
48
- serialized.trial_end_at = defaultSerializedObject.trial_end_at;
49
- }
50
-
51
47
  if (!_.isEmpty(defaultSerializedObject.stripePrice)) {
52
48
  serialized.price = {
53
49
  id: defaultSerializedObject.stripePrice.stripe_price_id,
@@ -70,28 +70,9 @@ module.exports = {
70
70
  FailedBatch,
71
71
 
72
72
  // accepts an ID rather than an Email model to better support running via a job queue
73
- async processEmail({emailId, options}) {
73
+ async processEmail({emailModel, options}) {
74
74
  const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
75
- const emailModel = await models.Email.findOne({id: emailId}, knexOptions);
76
-
77
- if (!emailModel) {
78
- throw new errors.IncorrectUsageError({
79
- message: 'Provided email id does not match a known email record',
80
- context: {
81
- id: emailId
82
- }
83
- });
84
- }
85
-
86
- if (emailModel.get('status') !== 'pending') {
87
- throw new errors.IncorrectUsageError({
88
- message: 'Emails can only be processed when in the "pending" state',
89
- context: `Email "${emailId}" has state "${emailModel.get('status')}"`,
90
- code: 'EMAIL_NOT_PENDING'
91
- });
92
- }
93
-
94
- await emailModel.save({status: 'submitting'}, Object.assign({}, knexOptions, {patch: true}));
75
+ const emailId = emailModel.get('id');
95
76
 
96
77
  // get batch IDs via knex to avoid model instantiation
97
78
  // only fetch pending or failed batches to avoid re-sending previously sent emails
@@ -141,6 +122,8 @@ module.exports = {
141
122
 
142
123
  // accepts an ID rather than an EmailBatch model to better support running via a job queue
143
124
  async processEmailBatch({emailBatchId, options, memberSegment}) {
125
+ logging.info('[sendEmailJob] Processing email batch ' + emailBatchId);
126
+
144
127
  const knexOptions = _.pick(options, ['transacting', 'forUpdate']);
145
128
 
146
129
  const emailBatchModel = await models.EmailBatch
@@ -166,7 +149,8 @@ module.exports = {
166
149
  const recipientRows = await models.EmailRecipient
167
150
  .getFilteredCollectionQuery({filter: `batch_id:${emailBatchId}`});
168
151
 
169
- await emailBatchModel.save({status: 'submitting'}, knexOptions);
152
+ // Patch to prevent saving the related email model
153
+ await emailBatchModel.save({status: 'submitting'}, {...knexOptions, patch: true});
170
154
 
171
155
  try {
172
156
  // Load newsletter data on email
@@ -178,14 +162,18 @@ module.exports = {
178
162
  // send the email
179
163
  const sendResponse = await this.send(emailBatchModel.relations.email.toJSON(), recipientRows, memberSegment);
180
164
 
165
+ logging.info('[sendEmailJob] Submitted email batch ' + emailBatchId);
166
+
181
167
  // update batch success status
182
168
  return await emailBatchModel.save({
183
169
  status: 'submitted',
184
170
  provider_id: sendResponse.id.trim().replace(/^<|>$/g, '')
185
171
  }, Object.assign({}, knexOptions, {patch: true}));
186
172
  } catch (error) {
173
+ logging.info('[sendEmailJob] Failed email batch ' + emailBatchId);
174
+
187
175
  // update batch failed status
188
- await emailBatchModel.save({status: 'failed'}, knexOptions);
176
+ await emailBatchModel.save({status: 'failed'}, {...knexOptions, patch: true});
189
177
 
190
178
  // log any error that didn't come from the provider which would have already logged it
191
179
  if (!error.code || error.code !== 'BULK_EMAIL_SEND_FAILED') {
@@ -213,6 +201,8 @@ module.exports = {
213
201
  * @returns {Promise<Object>} - {providerId: 'xxx'}
214
202
  */
215
203
  async send(emailData, recipients, memberSegment) {
204
+ logging.info(`[sendEmailJob] Sending email batch to ${recipients.length} recipients`);
205
+
216
206
  const mailgunConfigured = mailgunClient.isConfigured();
217
207
  if (!mailgunConfigured) {
218
208
  logging.warn('Bulk email has not been configured');
@@ -252,6 +242,7 @@ module.exports = {
252
242
  try {
253
243
  const response = await mailgunClient.send(emailData, recipientData, replacements);
254
244
  debug(`sent message (${Date.now() - startTime}ms)`);
245
+ logging.info(`[sendEmailJob] Sent message (${Date.now() - startTime}ms)`);
255
246
  return response;
256
247
  } catch (err) {
257
248
  let ghostError = new errors.EmailError({
@@ -0,0 +1 @@
1
+ module.exports = require('./service');
@@ -0,0 +1,32 @@
1
+ const {AbstractEmailSuppressionList, EmailSuppressionData} = require('@tryghost/email-suppression-list');
2
+
3
+ class InMemoryEmailSuppressionList extends AbstractEmailSuppressionList {
4
+ async removeEmail(email) {
5
+ if (email === 'fail@member.test') {
6
+ return false;
7
+ }
8
+ return true;
9
+ }
10
+
11
+ async getSuppressionData(email) {
12
+ if (email === 'spam@member.test') {
13
+ return new EmailSuppressionData(true, {
14
+ timestamp: new Date(),
15
+ reason: 'spam'
16
+ });
17
+ }
18
+ if (email === 'fail@member.test') {
19
+ return new EmailSuppressionData(true, {
20
+ timestamp: new Date(),
21
+ reason: 'fail'
22
+ });
23
+ }
24
+ return new EmailSuppressionData(false);
25
+ }
26
+
27
+ async init() {
28
+ return;
29
+ }
30
+ }
31
+
32
+ module.exports = new InMemoryEmailSuppressionList();