ghost 6.54.0 → 6.54.1

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 (238) hide show
  1. package/components/ghost-storage-base-3.0.0.tgz +0 -0
  2. package/components/tryghost-adapter-base-cache-0.2.0.tgz +0 -0
  3. package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
  4. package/components/tryghost-adapter-base-sso-0.1.1.tgz +0 -0
  5. package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
  6. package/core/built/admin/assets/Growth-BTP1LthE.js +3 -0
  7. package/core/built/admin/assets/Newsletters-CSEX0myc.js +1 -0
  8. package/core/built/admin/assets/Overview-BjACEsJF.js +1 -0
  9. package/core/built/admin/assets/Web-CxxxjCcv.js +2 -0
  10. package/core/built/admin/assets/{account-migration-DLvVA5yq.js → account-migration-BklF2I_I.js} +1 -1
  11. package/core/built/admin/assets/activitypub/{account-migration-DHbZcxGM.js → account-migration-CNovr6W-.js} +4 -4
  12. package/core/built/admin/assets/activitypub/activitypub.js +2 -2
  13. package/core/built/admin/assets/activitypub/{bluesky-sharing-zfxUey_L.js → bluesky-sharing-Ceslb1_M.js} +4 -4
  14. package/core/built/admin/assets/activitypub/{dist-BqkDUQxJ2.js → dist-ByFmDSNP2.js} +64 -63
  15. package/core/built/admin/assets/activitypub/{dist-Ba_QxHHl.js → dist-DHAjBcV3.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{domain-CmwSZLHK.js → domain-C1qQvJ8C.js} +5 -5
  17. package/core/built/admin/assets/activitypub/{edit-profile-CXdwNmVG.js → edit-profile-BcH-jEfM.js} +3 -3
  18. package/core/built/admin/assets/activitypub/{explore-DfbjzGM1.js → explore-AxwBh9dI.js} +3 -3
  19. package/core/built/admin/assets/activitypub/{feed-DSEBJOqf.js → feed-BaOlbh7a.js} +3 -3
  20. package/core/built/admin/assets/activitypub/{field-CY7XVcx6.js → field-B7mYYQpN.js} +2 -2
  21. package/core/built/admin/assets/activitypub/{inbox-DtQ0kytT.js → inbox-vXez-5uJ.js} +4 -4
  22. package/core/built/admin/assets/activitypub/{label-Dw96GQ7u.js → label-Chv5T8bw.js} +2 -2
  23. package/core/built/admin/assets/activitypub/layout-CziktJXo.js +1 -0
  24. package/core/built/admin/assets/activitypub/{moderation-Dg_-I-8C.js → moderation-DtSM2i1I.js} +4 -4
  25. package/core/built/admin/assets/activitypub/{note-Cy1-uKMY.js → note-Dckp4S-2.js} +3 -3
  26. package/core/built/admin/assets/activitypub/{notifications-B_fvoLIM.js → notifications-CG5T22vg.js} +3 -3
  27. package/core/built/admin/assets/activitypub/{preferences-ByJseZp6.js → preferences-D_799jdl.js} +6 -6
  28. package/core/built/admin/assets/activitypub/{profile-BFtbnIjz.js → profile-D2JfXBQl.js} +6 -6
  29. package/core/built/admin/assets/activitypub/{routes-Dh3xXfce.js → routes-CCHe2dQp.js} +28 -25
  30. package/core/built/admin/assets/activitypub/{settings-DTgqJPxM.js → settings-kx-9Enkz.js} +3 -3
  31. package/core/built/admin/assets/activitypub/{site-CdL9ZLqm.js → site-x4_IjVJ0.js} +2 -2
  32. package/core/built/admin/assets/activitypub/{tabs-ogqwoBlb.js → tabs-CyPRQQ1N.js} +3 -3
  33. package/core/built/admin/assets/activitypub/{use-reply-chain-data-CxonteFD.js → use-reply-chain-data-BAB6fNGt.js} +2 -2
  34. package/core/built/admin/assets/{audience-BKRdsFmy.js → audience-X_XsZivI.js} +1 -1
  35. package/core/built/admin/assets/{automated-emails-Cn87L5Md.js → automated-emails-of1MvVsv.js} +1 -1
  36. package/core/built/admin/assets/{automations-BP_aHamJ.js → automations-DRFjW5fw.js} +1 -1
  37. package/core/built/admin/assets/{bluesky-sharing-Bh36XPh4.js → bluesky-sharing-BbbjWdzs.js} +1 -1
  38. package/core/built/admin/assets/{breadcrumb-D34YWnaw.js → breadcrumb-B-M9Ip_2.js} +1 -1
  39. package/core/built/admin/assets/{chart-helpers-DbklaJw9.js → chart-helpers-xiSsW8r0.js} +1 -1
  40. package/core/built/admin/assets/checkbox-EhJKHFdZ.js +1 -0
  41. package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js → chunk.211.6f7328d63c41536451b3.js} +737 -559
  42. package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js.LICENSE.txt → chunk.211.6f7328d63c41536451b3.js.LICENSE.txt} +2 -0
  43. package/core/built/admin/assets/{chunk.589.096f4d0bb775e5229aa9.js → chunk.361.3cdb584b973959e52ccc.js} +3 -3
  44. package/core/built/admin/assets/chunk.524.8365cd9280405c0f6216.js +25 -0
  45. package/core/built/admin/assets/chunk.582.61d078937e6102769f2c.js +27 -0
  46. package/core/built/admin/assets/code-editor-view-CZtJFGhR.js +1 -0
  47. package/core/built/admin/assets/comments-CvyXxOyN.js +1 -0
  48. package/core/built/admin/assets/{date-range-select-B5vSsrpx.js → date-range-select-CTlyo8zW.js} +1 -1
  49. package/core/built/admin/assets/{disabled-sources-indicator-NkOIoMue.js → disabled-sources-indicator-qs2GfLvl.js} +1 -1
  50. package/core/built/admin/assets/dist-2Tnz2Yfq.js +1 -0
  51. package/core/built/admin/assets/dist-CjY-f5Z1.js +1 -0
  52. package/core/built/admin/assets/dist-DIC8yz5C.js +1 -0
  53. package/core/built/admin/assets/{domain-B7_n5nfx.js → domain-CPNXKVOV.js} +1 -1
  54. package/core/built/admin/assets/dropzone-BmCXnw6J.js +5 -0
  55. package/core/built/admin/assets/{edit-profile-rvDGrS8J.js → edit-profile-SGrJve5M.js} +1 -1
  56. package/core/built/admin/assets/editor-BOG2YPUm.js +8 -0
  57. package/core/built/admin/assets/{explore-BZAZOg5X.js → explore-CUYLv9G9.js} +1 -1
  58. package/core/built/admin/assets/{feed-CFV8xDw9.js → feed-CPdJMttL.js} +1 -1
  59. package/core/built/admin/assets/{field-BW-M2Zwp.js → field-D0XISCK2.js} +1 -1
  60. package/core/built/admin/assets/{filter-sources-Fx8wiYYw.js → filter-sources-Bieps75g.js} +1 -1
  61. package/core/built/admin/assets/{filters-CGX_pf2N.js → filters-CuB-A7ma.js} +1 -1
  62. package/core/built/admin/assets/filters-bXkkWro8.js +1 -0
  63. package/core/built/admin/assets/{ghost-b1dce91ad3232cc259ee5fbbf9526243.js → ghost-05f9a7182ed56903dba05878a5b8422b.js} +290 -289
  64. package/core/built/admin/assets/{gift-link-modal-Bk8qpnMw.js → gift-link-modal-su8MsRdg.js} +1 -1
  65. package/core/built/admin/assets/{growth-Ck2s3JjR.js → growth-Ba6S5FQX.js} +1 -1
  66. package/core/built/admin/assets/{inbox-BRp1_aaC.js → inbox-Xx1CdnfO.js} +8 -8
  67. package/core/built/admin/assets/index-D8PbzuT9.css +1 -0
  68. package/core/built/admin/assets/index-ukW_0Uie.js +67 -0
  69. package/core/built/admin/assets/input-group-CGOXcy4l.js +1 -0
  70. package/core/built/admin/assets/input-surface-BpAjZXY5.js +1 -0
  71. package/core/built/admin/assets/{koenig-lexical-CM7UFNdL.js → koenig-lexical-B61IALf5.js} +1 -1
  72. package/core/built/admin/assets/{kpi-card-CIGyijjx.js → kpi-card-BlZaG1Lo.js} +1 -1
  73. package/core/built/admin/assets/{kpi-card-BrGH6FsT.js → kpi-card-DCdlyaSS.js} +1 -1
  74. package/core/built/admin/assets/{kpi-helpers-_HRRsklE.js → kpi-helpers-DpAuS1Nb.js} +1 -1
  75. package/core/built/admin/assets/{kpi-tabs-BSvh9eoq.js → kpi-tabs-Dme44bQS.js} +1 -1
  76. package/core/built/admin/assets/{label-BOR9XdKl.js → label-dSs5aepE.js} +1 -1
  77. package/core/built/admin/assets/{label-picker-BdNAWuUX.js → label-picker-Uq91atex.js} +1 -1
  78. package/core/built/admin/assets/layout-WUcz6tu1.js +1 -0
  79. package/core/built/admin/assets/{member-custom-fields-DvFyamLu.js → member-custom-fields-NCzrMuba.js} +1 -1
  80. package/core/built/admin/assets/member-detail-DPWDtYnN.js +1 -0
  81. package/core/built/admin/assets/{members-DsPxYAVm.js → members-B9rO7qr4.js} +2 -2
  82. package/core/built/admin/assets/modals-CxRaan1J.js +31 -0
  83. package/core/built/admin/assets/{moderation-Dlp7M5sa.js → moderation-D8zon60E.js} +1 -1
  84. package/core/built/admin/assets/{multi-select-combobox-CH1o8XXQ.js → multi-select-combobox-1wYm3nle.js} +1 -1
  85. package/core/built/admin/assets/newsletter-DbYMRxRS.js +1 -0
  86. package/core/built/admin/assets/{note-BrAZ7Z0o.js → note-PiWPnnrh.js} +1 -1
  87. package/core/built/admin/assets/{notifications-BnbHLOPC.js → notifications-DEgqqB9G.js} +1 -1
  88. package/core/built/admin/assets/{onboarding-route-eU146kuA.js → onboarding-route-BzF6HeVn.js} +1 -1
  89. package/core/built/admin/assets/{overview-BpbaK-xu.js → overview-DixLeIqY.js} +1 -1
  90. package/core/built/admin/assets/{page-header-C0jVxtS8.js → page-header-op0eN36I.js} +1 -1
  91. package/core/built/admin/assets/{post-analytics-Iw7dk1mT.js → post-analytics-WwRDEnrV.js} +1 -1
  92. package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +1 -0
  93. package/core/built/admin/assets/{post-analytics-provider-BDqcfBor.js → post-analytics-provider-BJCPLMIb.js} +1 -1
  94. package/core/built/admin/assets/{post-share-modal-DBfKl2GZ.js → post-share-modal-Dx-MEjhI.js} +1 -1
  95. package/core/built/admin/assets/preferences-D7-j6A4C.js +5 -0
  96. package/core/built/admin/assets/profile-Dl69QPp0.js +1 -0
  97. package/core/built/admin/assets/select-C1qDZnW4.js +1 -0
  98. package/core/built/admin/assets/settings-BbmC_Iqq.js +4 -0
  99. package/core/built/admin/assets/{settings-Bz3RZRmE.js → settings-H6HxFgfI.js} +1 -1
  100. package/core/built/admin/assets/shade-app-B3heOAfz-CGXQwWeO.js +3 -0
  101. package/core/built/admin/assets/{share-modal-CkdTTMJd.js → share-modal-BJCadJyp.js} +1 -1
  102. package/core/built/admin/assets/social-urls-BPL1nADp.js +8 -0
  103. package/core/built/admin/assets/{sort-button-DZp9Y2r1.js → sort-button-DyYlfYp7.js} +1 -1
  104. package/core/built/admin/assets/{source-icon-CHr9qJ2Y.js → source-icon-_ubRDbg_.js} +1 -1
  105. package/core/built/admin/assets/{stats-DGoXZxWE.js → stats-BQZMGMvn.js} +1 -1
  106. package/core/built/admin/assets/{stats-view-D2-1L0ms.js → stats-view-Doei-qCX.js} +1 -1
  107. package/core/built/admin/assets/{step-1-RdahbwyG.js → step-1-IKiLBPoC.js} +1 -1
  108. package/core/built/admin/assets/{step-2-D83PeRwd.js → step-2-jJ41PsDN.js} +1 -1
  109. package/core/built/admin/assets/{step-3-p_Op4wv4.js → step-3-CaqFAEqf.js} +2 -2
  110. package/core/built/admin/assets/switch-t-WyENU2.js +1 -0
  111. package/core/built/admin/assets/{table-B8LgTQJZ.js → table-DxUL78jU.js} +1 -1
  112. package/core/built/admin/assets/{tabs-D3ZOYkM7.js → tabs-BQcJQ1ke.js} +1 -1
  113. package/core/built/admin/assets/{tags-B4l891z2.js → tags-CNujGwkT.js} +1 -1
  114. package/core/built/admin/assets/textarea-Bjccvh_E.js +1 -0
  115. package/core/built/admin/assets/{toggle-group-DNXDkAwf.js → toggle-group-BeEpQ69p.js} +1 -1
  116. package/core/built/admin/assets/{topic-filter-DZGjEcz3.js → topic-filter-Bb5MOD-1.js} +1 -1
  117. package/core/built/admin/assets/{use-analytics-data-CaT8RirX.js → use-analytics-data-Cds6XDjg.js} +1 -1
  118. package/core/built/admin/assets/use-filter-params-DjXkhO54.js +1 -0
  119. package/core/built/admin/assets/{use-growth-stats-9Db2CuJe.js → use-growth-stats-D8uf98-G.js} +1 -1
  120. package/core/built/admin/assets/{use-post-referrers-BV54YcQS.js → use-post-referrers-CRtgyuXB.js} +1 -1
  121. package/core/built/admin/assets/{use-reply-chain-data-DuYZGDan.js → use-reply-chain-data-DbKYX50v.js} +1 -1
  122. package/core/built/admin/assets/{use-simple-pagination-8fTua9Nt.js → use-simple-pagination-DC01kpo0.js} +1 -1
  123. package/core/built/admin/assets/{use-tinybird-query-Dz156HvZ.js → use-tinybird-query-C7bQRJWw.js} +1 -1
  124. package/core/built/admin/assets/{use-tinybird-token-C9RUdL_n.js → use-tinybird-token-Az4tam7_.js} +1 -1
  125. package/core/built/admin/assets/{virtual-list-BCvP0YF-.js → virtual-list-Dhuh9fGx.js} +1 -1
  126. package/core/built/admin/assets/web-LtHNiNBV.js +1 -0
  127. package/core/built/admin/index.html +11 -7
  128. package/core/frontend/services/proxy.js +11 -1
  129. package/core/server/adapters/storage/LocalFilesStorage.js +13 -11
  130. package/core/server/adapters/storage/LocalFilesStorage.ts +24 -0
  131. package/core/server/adapters/storage/LocalImagesStorage.js +14 -12
  132. package/core/server/adapters/storage/LocalImagesStorage.ts +24 -0
  133. package/core/server/adapters/storage/LocalMediaStorage.js +13 -11
  134. package/core/server/adapters/storage/LocalMediaStorage.ts +24 -0
  135. package/core/server/adapters/storage/LocalStorageBase.js +107 -167
  136. package/core/server/adapters/storage/LocalStorageBase.ts +342 -0
  137. package/core/server/adapters/storage/utils.js +36 -50
  138. package/core/server/adapters/storage/utils.ts +60 -0
  139. package/core/server/api/endpoints/db.js +6 -0
  140. package/core/server/api/endpoints/members.js +25 -28
  141. package/core/server/api/endpoints/pages.js +6 -1
  142. package/core/server/api/endpoints/posts.js +11 -2
  143. package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +4 -1
  144. package/core/server/api/endpoints/utils/serializers/output/members.js +18 -87
  145. package/core/server/data/exporter/export-filename.js +2 -1
  146. package/core/server/data/importer/importers/data/revue-subscriber-importer.js +6 -10
  147. package/core/server/data/importer/importers/json-to-html.js +24 -4
  148. package/core/server/lib/image/image-size.js +3 -17
  149. package/core/server/lib/image/image-utils.js +15 -1
  150. package/core/server/lib/request-external.js +1 -1
  151. package/core/server/models/user.js +46 -70
  152. package/core/server/services/auth/session/session-service.js +28 -1
  153. package/core/server/services/automations/automations-api.js +6 -6
  154. package/core/server/services/automations/automations-api.ts +6 -6
  155. package/core/server/services/automations/automations-repository.ts +1 -0
  156. package/core/server/services/automations/database-automations-repository.js +7 -2
  157. package/core/server/services/automations/database-automations-repository.ts +8 -2
  158. package/core/server/services/comments/comments-service-emails.js +4 -1
  159. package/core/server/services/members/{importer → import-export}/config.js +4 -9
  160. package/core/server/services/members/{importer → import-export}/config.ts +4 -9
  161. package/core/server/services/members/{csv → import-export/csv}/index.js +3 -3
  162. package/core/server/services/members/import-export/csv/index.ts +2 -0
  163. package/core/server/services/members/import-export/csv/parse.js +64 -0
  164. package/core/server/services/members/import-export/csv/parse.ts +69 -0
  165. package/core/server/services/members/import-export/csv/serialize.js +13 -0
  166. package/core/server/services/members/import-export/csv/serialize.ts +18 -0
  167. package/core/server/services/members/import-export/export/exporter.js +202 -0
  168. package/core/server/services/members/import-export/export/exporter.ts +325 -0
  169. package/core/server/services/members/import-export/import/completion-email.js +65 -0
  170. package/core/server/services/members/import-export/import/completion-email.ts +119 -0
  171. package/core/server/services/members/import-export/import/importer.js +276 -0
  172. package/core/server/services/members/import-export/import/importer.ts +430 -0
  173. package/core/server/services/members/import-export/import/reader.js +32 -0
  174. package/core/server/services/members/import-export/import/reader.ts +33 -0
  175. package/core/server/services/members/import-export/import/row.js +33 -0
  176. package/core/server/services/members/import-export/import/row.ts +52 -0
  177. package/core/server/services/members/import-export/import/spool.js +30 -0
  178. package/core/server/services/members/import-export/import/spool.ts +38 -0
  179. package/core/server/services/members/import-export/index.js +90 -0
  180. package/core/server/services/members/import-export/index.ts +118 -0
  181. package/core/server/services/members/members-api/controllers/router-controller.js +7 -0
  182. package/core/server/services/members/service.js +17 -19
  183. package/core/server/services/posts/posts-exporter.js +6 -4
  184. package/core/server/services/posts/posts-service.js +2 -2
  185. package/core/server/services/themes/storage.js +9 -0
  186. package/core/server/services/themes/theme-storage.js +1 -1
  187. package/core/shared/config/defaults.json +2 -1
  188. package/core/shared/express.js +6 -1
  189. package/core/shared/labs.js +2 -1
  190. package/package.json +9 -9
  191. package/pnpm-lock.yaml +17 -17
  192. package/pnpm-workspace.yaml +7 -7
  193. package/components/tryghost-adapter-base-scheduling-0.1.1.tgz +0 -0
  194. package/core/built/admin/assets/Growth-CSaHoSVD.js +0 -3
  195. package/core/built/admin/assets/Newsletters-C5cs6FGo.js +0 -1
  196. package/core/built/admin/assets/Overview-j4NaeUNm.js +0 -1
  197. package/core/built/admin/assets/Web-_fD935OX.js +0 -2
  198. package/core/built/admin/assets/activitypub/layout-IdVq3lRy.js +0 -1
  199. package/core/built/admin/assets/checkbox-DA4-1UiI.js +0 -1
  200. package/core/built/admin/assets/chunk.524.45e0875f41b71e46e849.js +0 -25
  201. package/core/built/admin/assets/chunk.582.51defd4b009f0de8d4d5.js +0 -27
  202. package/core/built/admin/assets/code-editor-view-BKBrkffZ.js +0 -1
  203. package/core/built/admin/assets/comments-CzVNIT8b.js +0 -1
  204. package/core/built/admin/assets/dist-CT46_f_J.js +0 -1
  205. package/core/built/admin/assets/dropzone-DcuJEEUf.js +0 -5
  206. package/core/built/admin/assets/editor-BCiIyFjm.js +0 -8
  207. package/core/built/admin/assets/filters-DtcXMZDU.js +0 -1
  208. package/core/built/admin/assets/index-BBf502Wg.js +0 -69
  209. package/core/built/admin/assets/index-BCCZKxP2.css +0 -1
  210. package/core/built/admin/assets/input-group-DjuYnNCd.js +0 -1
  211. package/core/built/admin/assets/layout-Bw4GnxXp.js +0 -1
  212. package/core/built/admin/assets/member-detail-Cg2vTqvJ.js +0 -1
  213. package/core/built/admin/assets/modals-Bf_fyvHZ.js +0 -31
  214. package/core/built/admin/assets/newsletter-CQ5CAYUI.js +0 -1
  215. package/core/built/admin/assets/post-analytics-header-D9pJ4iNm.js +0 -1
  216. package/core/built/admin/assets/preferences-D4EANGoc.js +0 -5
  217. package/core/built/admin/assets/profile-dJ44Bn_W.js +0 -1
  218. package/core/built/admin/assets/select--2uVN9_-.js +0 -1
  219. package/core/built/admin/assets/settings-SD9bxIpt.js +0 -4
  220. package/core/built/admin/assets/social-urls-BvFZwYcE.js +0 -8
  221. package/core/built/admin/assets/switch-qp518gFQ.js +0 -1
  222. package/core/built/admin/assets/textarea-CTlb3Uu6.js +0 -1
  223. package/core/built/admin/assets/use-filter-params-DeQ8sX4N.js +0 -1
  224. package/core/built/admin/assets/web-DTZnnUGr.js +0 -1
  225. package/core/server/services/mail/templates/newsletter.html +0 -876
  226. package/core/server/services/members/csv/index.ts +0 -3
  227. package/core/server/services/members/csv/parse.js +0 -94
  228. package/core/server/services/members/csv/parse.ts +0 -111
  229. package/core/server/services/members/csv/types.js +0 -2
  230. package/core/server/services/members/csv/types.ts +0 -39
  231. package/core/server/services/members/csv/unparse.js +0 -69
  232. package/core/server/services/members/csv/unparse.ts +0 -79
  233. package/core/server/services/members/exporter/query.js +0 -233
  234. package/core/server/services/members/importer/index.js +0 -30
  235. package/core/server/services/members/importer/labels.js +0 -125
  236. package/core/server/services/members/importer/members-csv-importer.js +0 -520
  237. /package/core/server/services/members/{importer → import-export/import}/email-template.js +0 -0
  238. /package/core/server/services/members/{importer/members-csv-importer-stripe-utils.js → import-export/import/stripe-utils.js} +0 -0
@@ -1,10 +1,12 @@
1
1
  //@ts-check
2
2
  const _ = require('lodash');
3
3
  const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output:members');
4
- const {unparse} = require('../../../../../services/members/csv');
4
+ const {serialize: serializeCSV} = require('../../../../../services/members/import-export/csv');
5
+ // The export CSV row shape and its encode live in the domain (typed against the export
6
+ // row) rather than here, so a change to the export row is caught by the compiler.
7
+ const {toExportCsvRow} = require('../../../../../services/members/import-export/export/exporter');
5
8
  const mappers = require('./mappers');
6
9
  const {Transform} = require('stream');
7
- const papaparse = require('papaparse');
8
10
  const {createCSVStreamResponse} = require('./stream-csv-response');
9
11
  module.exports = {
10
12
  browse: createSerializer('browse', paginatedMembers),
@@ -25,56 +27,6 @@ module.exports = {
25
27
  activityFeed: createSerializer('activityFeed', activityFeed)
26
28
  };
27
29
 
28
- /**
29
- * Formats a single member for CSV export
30
- * @param {Object} member - Member object
31
- * @returns {Object} Formatted member
32
- */
33
- function formatMemberForCSV(member) {
34
- let labels = '';
35
- if (Array.isArray(member.labels)) {
36
- labels = member.labels.map((l) => {
37
- return typeof l === 'string' ? l : l.name;
38
- }).join(',');
39
- }
40
-
41
- let tiers = '';
42
- if (Array.isArray(member.tiers)) {
43
- tiers = member.tiers.map((tier) => {
44
- return tier.name;
45
- }).join(',');
46
- }
47
-
48
- // Convert boolean 'false' to empty string for tests to pass
49
- // Only comped = true should result in 'true', otherwise empty string
50
- const complimentaryPlan = member.comped === true ? 'true' : '';
51
-
52
- // Gift members carry the gift id so an exported CSV can be re-imported and reassigned
53
- // back to a (possibly new) member record via the gifts table
54
- const giftId = member.gift_id || '';
55
-
56
- // Convert subscribed boolean to string representation
57
- const subscribedToEmails = member.subscribed === true ? 'true' : 'false';
58
-
59
- return {
60
- id: member.id,
61
- email: member.email,
62
- name: member.name,
63
- note: member.note,
64
- subscribed_to_emails: subscribedToEmails,
65
- complimentary_plan: complimentaryPlan,
66
- stripe_customer_id: member.stripe_customer_id,
67
- created_at: member.created_at,
68
- deleted_at: member.deleted_at || null,
69
- labels: labels,
70
- tiers: tiers,
71
- gift_id: giftId,
72
- // The exporter flattens custom field values into their CSV columns, since
73
- // which columns exist is a per-site question only the database can answer.
74
- ...member.custom_field_cells
75
- };
76
- }
77
-
78
30
  /**
79
31
  * @template PageMeta
80
32
  *
@@ -426,30 +378,16 @@ function createCSVTransform() {
426
378
  transform(member, encoding, callback) {
427
379
  try {
428
380
  // Format the member data for CSV
429
- const formattedMember = formatMemberForCSV(member);
381
+ const formattedMember = toExportCsvRow(member);
430
382
 
431
383
  // For first chunk, include the headers
432
384
  if (fields === null) {
433
385
  fields = Object.keys(formattedMember);
434
- const csv = papaparse.unparse({
435
- fields,
436
- data: [formattedMember]
437
- }, {
438
- header: true,
439
- escapeFormulae: true,
440
- newline: '\r\n' // Explicitly set Windows-style line endings for compatibility
441
- });
386
+ const csv = serializeCSV([formattedMember], {columns: fields, header: true});
442
387
  callback(null, csv);
443
388
  } else {
444
389
  // For subsequent chunks, don't include headers, just the data
445
- const csv = papaparse.unparse({
446
- fields,
447
- data: [formattedMember]
448
- }, {
449
- header: false,
450
- escapeFormulae: true,
451
- newline: '\r\n' // Explicitly set Windows-style line endings for compatibility
452
- });
390
+ const csv = serializeCSV([formattedMember], {columns: fields, header: false});
453
391
 
454
392
  // Make sure each row starts with a newline to ensure separation between rows
455
393
  // Ensure consistent line endings by using explicit CR+LF sequence
@@ -465,28 +403,21 @@ function createCSVTransform() {
465
403
  /**
466
404
  * @template PageMeta
467
405
  *
468
- * @param {{data: any[]|Object, filename?: string}} data
406
+ * @param {{data: NodeJS.ReadableStream, filename?: string}} data
469
407
  *
470
- * @returns {string|Function} - A CSV string or response handler function
408
+ * @returns {Function} - A response handler that streams the CSV
471
409
  */
472
410
  function exportCSV(data) {
473
411
  debug('exportCSV');
474
412
 
475
- // Check if data.data is a stream (has the pipe method)
476
- if (data.data && typeof data.data.pipe === 'function') {
477
- debug('CSV stream response');
413
+ // The export endpoint always yields a stream (response.stream: true), so there is
414
+ // only ever a stream to pipe.
415
+ const datetime = (new Date()).toJSON().substring(0, 10);
416
+ const filename = data.filename || `members.${datetime}.csv`;
478
417
 
479
- // Fall back to the legacy filename if the endpoint didn't provide one
480
- const datetime = (new Date()).toJSON().substring(0, 10);
481
- const filename = data.filename || `members.${datetime}.csv`;
482
-
483
- return createCSVStreamResponse({
484
- source: data.data,
485
- transform: createCSVTransform(),
486
- filename
487
- });
488
- }
489
-
490
- // Otherwise use the unparse function for array data
491
- return unparse(data.data);
418
+ return createCSVStreamResponse({
419
+ source: data.data,
420
+ transform: createCSVTransform(),
421
+ filename
422
+ });
492
423
  }
@@ -3,6 +3,7 @@ const logging = require('@tryghost/logging');
3
3
  const errors = require('@tryghost/errors');
4
4
  const security = require('@tryghost/security');
5
5
  const models = require('../../models');
6
+ const path = require('path');
6
7
 
7
8
  const modelOptions = {context: {internal: true}};
8
9
 
@@ -14,7 +15,7 @@ const exportFileName = async function exportFileName(options) {
14
15
 
15
16
  // custom filename
16
17
  if (options.filename) {
17
- return options.filename + '.json';
18
+ return path.basename(options.filename) + '.json';
18
19
  }
19
20
 
20
21
  try {
@@ -6,7 +6,6 @@ const path = require('path');
6
6
  const fs = require('fs-extra');
7
7
 
8
8
  const config = require('../../../../../shared/config');
9
- const models = require('../../../../models');
10
9
 
11
10
  class RevueSubscriberImporter extends BaseImporter {
12
11
  constructor(allDataFromFile) {
@@ -38,17 +37,14 @@ class RevueSubscriberImporter extends BaseImporter {
38
37
  const outputFilePath = path.join(config.getContentPath('data'), '/', outputFileName);
39
38
  const csvData = papaparse.unparse(this.dataToImport);
40
39
 
41
- const memberImporterOptions = {
42
- pathToCSV: outputFilePath,
43
- globalLabels: [{name: importLabel}],
44
- importLabel: {name: importLabel},
45
- LabelModel: models.Label,
46
- forceInline: true
47
- };
48
-
49
40
  await fs.writeFile(outputFilePath, csvData);
50
41
 
51
- return membersService.processImport(memberImporterOptions);
42
+ // Inline: the data import has no request to hold open and needs the result now.
43
+ return membersService.importInline({
44
+ filePath: outputFilePath,
45
+ extraLabels: [{name: importLabel}],
46
+ requestUserEmail: null
47
+ });
52
48
  }
53
49
  }
54
50
 
@@ -4,6 +4,25 @@ const {cards} = require('@tryghost/kg-default-cards');
4
4
  const imageCard = cards.find(c => c.name === 'image');
5
5
  const embedCard = cards.find(c => c.name === 'embed');
6
6
 
7
+ const getValidURL = (url) => {
8
+ const normalizedURL = typeof url === 'string' ? url.trim() : '';
9
+
10
+ if (!normalizedURL) {
11
+ return '';
12
+ }
13
+
14
+ try {
15
+ const parsedURL = new URL(normalizedURL, 'https://example.com');
16
+ if (parsedURL.protocol === 'http:' || parsedURL.protocol === 'https:') {
17
+ return normalizedURL;
18
+ }
19
+ } catch {
20
+ // Invalid URLs are omitted from imported links
21
+ }
22
+
23
+ return '';
24
+ };
25
+
7
26
  // Take the array of items for a specific post and return the converted HTML
8
27
  const itemsToHtml = (items) => {
9
28
  let itemHTMLChunks = [];
@@ -30,22 +49,23 @@ const itemsToHtml = (items) => {
30
49
  } else if (type === 'link') {
31
50
  // This could be a bookmark, or it could be a paragraph of text with a linked header, there's no way to tell
32
51
  // The safest option here is to output an image with text under it
52
+ const itemURL = getValidURL(item.url);
33
53
  let cardOpts = {
34
54
  env: {dom: new SimpleDom.Document()},
35
55
  payload: {
36
56
  src: item.image,
37
57
  caption: item.title,
38
- href: item.url
58
+ href: itemURL
39
59
  }
40
60
  };
41
61
  itemHTMLChunks.push(serializer.serialize(imageCard.render(cardOpts)));
42
-
43
- let linkHTML = `<h4><a href="${item.url}">${item.title}</a></h4>${item.description}`;
62
+ const linkTitleHTML = itemURL ? `<a href="${serializer.escapeAttrValue(itemURL)}">${item.title}</a>` : item.title;
63
+ let linkHTML = `<h4>${linkTitleHTML}</h4>${item.description}`;
44
64
  itemHTMLChunks.push(linkHTML);
45
65
  } else if (type === 'tweet') {
46
66
  // Should this be an oEmbed call? Probably.
47
67
  itemHTMLChunks.push(`<figure class="kg-card kg-embed-card">
48
- <blockquote class="twitter-tweet"><a href="${item.url}"></a></blockquote>
68
+ <blockquote class="twitter-tweet"><a href="${serializer.escapeAttrValue(item.url)}"></a></blockquote>
49
69
  <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
50
70
  </figure>`);
51
71
  } else if (type === 'video') {
@@ -17,29 +17,15 @@ const FETCH_ONLY_FORMATS = [
17
17
  ];
18
18
 
19
19
  class ImageSize {
20
- constructor({config, imageStore, storageUtils, validator, urlUtils, request, probe}) {
20
+ constructor({config, imageStore, storageUtils, validator, urlUtils, fetchExternal, probe}) {
21
21
  this.config = config;
22
22
  this.imageStore = imageStore;
23
23
  this.storageUtils = storageUtils;
24
24
  this.validator = validator;
25
25
  this.urlUtils = urlUtils;
26
- this.request = request;
26
+ this.fetchExternal = fetchExternal;
27
27
  this.probe = probe;
28
28
 
29
- this.REQUEST_OPTIONS = {
30
- // we need the user-agent, otherwise some https request may fail (e.g. cloudfare)
31
- headers: {
32
- 'User-Agent': 'Mozilla/5.0 Safari/537.36'
33
- },
34
- timeout: {
35
- request: this.config.get('times:getImageSizeTimeoutInMS') || 10000
36
- },
37
- retry: {
38
- limit: 0 // for `got`, used with image-size
39
- },
40
- responseType: 'buffer'
41
- };
42
-
43
29
  this.NEEDLE_OPTIONS = {
44
30
  // we need the user-agent, otherwise some https request may fail (e.g. cloudflare)
45
31
  headers: {
@@ -107,7 +93,7 @@ class ImageSize {
107
93
  // download full image then use image-size to get it's dimensions
108
94
  // returns promise which resolves dimensions
109
95
  _fetchImageSizeFromUrl(imageUrl) {
110
- return this.request(imageUrl, this.REQUEST_OPTIONS).then((response) => {
96
+ return this.fetchExternal(imageUrl, this.NEEDLE_OPTIONS).then((response) => {
111
97
  return this._imageSizeFromBuffer(response.body);
112
98
  });
113
99
  }
@@ -24,10 +24,23 @@ function probe(url, options = {}, {probeImageSize: probeImageSizeFn = probeImage
24
24
  return promise;
25
25
  }
26
26
 
27
+ // For image formats that require a full fetch. This function ensures that
28
+ // SSRF protections are in place by using the externalRequest `got` client.
29
+ function fetchExternal(url, options = {}) {
30
+ return externalRequest.get(url, {
31
+ headers: options.headers,
32
+ timeout: {
33
+ request: options.response_timeout || 10000
34
+ },
35
+ responseType: 'buffer',
36
+ retry: {limit: 0}
37
+ });
38
+ }
39
+
27
40
  class ImageUtils {
28
41
  constructor({config, urlUtils, settingsCache, storageUtils, imageStore, validator, request, cacheStore}) {
29
42
  this.blogIcon = new BlogIcon({config, urlUtils, settingsCache, storageUtils});
30
- this.imageSize = new ImageSize({config, imageStore, storageUtils, validator, urlUtils, request, probe});
43
+ this.imageSize = new ImageSize({config, imageStore, storageUtils, validator, urlUtils, fetchExternal, probe});
31
44
  this.cachedImageSizeFromUrl = new CachedImageSizeFromUrl({
32
45
  getImageSizeFromUrl: this.imageSize.getImageSizeFromUrl.bind(this.imageSize),
33
46
  cache: cacheStore
@@ -38,3 +51,4 @@ class ImageUtils {
38
51
 
39
52
  module.exports = ImageUtils;
40
53
  module.exports.probe = probe;
54
+ module.exports.fetchExternal = fetchExternal;
@@ -248,7 +248,7 @@ function installSafeDnsLookup(options) {
248
248
  const requestHref = options.url.href;
249
249
  // Use 'lookup' (the native http.request option) rather than 'dnsLookup'
250
250
  // (got's public API property which doesn't flow to the native request).
251
- options.lookup = (hostname, dnsOpts, callback) => {
251
+ options.dnsLookup = (hostname, dnsOpts, callback) => {
252
252
  if (typeof dnsOpts === 'function') {
253
253
  callback = dnsOpts;
254
254
  dnsOpts = {};
@@ -6,7 +6,6 @@ const limitService = require('../services/limits');
6
6
  const tpl = require('@tryghost/tpl');
7
7
  const errors = require('@tryghost/errors');
8
8
  const security = require('@tryghost/security');
9
- const {pipeline} = require('@tryghost/promise');
10
9
  const validatePassword = require('../lib/validate-password');
11
10
  const permissions = require('../services/permissions');
12
11
  const urlUtils = require('../../shared/url-utils').default;
@@ -572,91 +571,68 @@ User = ghostBookshelf.Model.extend({
572
571
  * @extends ghostBookshelf.Model.edit to handle returning the full object
573
572
  * **See:** [ghostBookshelf.Model.edit](base.js.html#edit)
574
573
  */
575
- edit: function edit(data, unfilteredOptions) {
574
+ edit: async function edit(data, unfilteredOptions) {
576
575
  const options = this.filterOptions(unfilteredOptions, 'edit');
577
576
  const self = this;
578
- const ops = [];
579
577
 
580
578
  if (data.roles && data.roles.length > 1) {
581
- return Promise.reject(
582
- new errors.ValidationError({
583
- message: tpl(messages.onlyOneRolePerUserSupported)
584
- })
585
- );
579
+ throw new errors.ValidationError({
580
+ message: tpl(messages.onlyOneRolePerUserSupported)
581
+ });
586
582
  }
587
583
 
588
584
  if (data.email) {
589
- ops.push(function checkForDuplicateEmail() {
590
- return self.getByEmail(data.email, options).then(function then(user) {
591
- if (user && user.id !== options.id) {
592
- return Promise.reject(new errors.ValidationError({
593
- message: tpl(messages.userUpdateError.emailIsAlreadyInUse)
594
- }));
595
- }
585
+ const existingUser = await self.getByEmail(data.email, options);
586
+ if (existingUser && existingUser.id !== options.id) {
587
+ throw new errors.ValidationError({
588
+ message: tpl(messages.userUpdateError.emailIsAlreadyInUse)
596
589
  });
597
- });
590
+ }
598
591
  }
599
592
 
600
- ops.push(function update() {
601
- return ghostBookshelf.Model.edit.call(self, data, options).then(async (user) => {
602
- let roleId;
593
+ const user = await ghostBookshelf.Model.edit.call(self, data, options);
603
594
 
604
- if (!data.roles || !data.roles.length) {
605
- return user;
606
- }
607
-
608
- roleId = data.roles[0].id || data.roles[0];
609
-
610
- return user.roles().fetch().then((roles) => {
611
- // return if the role is already assigned
612
- if (roles.models[0].id === roleId) {
613
- return;
614
- }
595
+ if (!data.roles || !data.roles.length) {
596
+ return user;
597
+ }
615
598
 
616
- if (ASSIGNABLE_ROLES.includes(roleId)) {
617
- // return if the role is already assigned
618
- if (roles.models[0].get('name') === roleId) {
619
- return;
620
- }
599
+ const roleId = data.roles[0].id || data.roles[0];
600
+ const roles = await user.roles().fetch();
601
+ let roleToAssign;
621
602
 
622
- return ghostBookshelf.model('Role').findOne({
623
- name: roleId
624
- });
625
- } else if (ObjectId.isValid(roleId)){
626
- return ghostBookshelf.model('Role').findOne({
627
- id: roleId
628
- });
629
- } else {
630
- return Promise.reject(
631
- new errors.ValidationError({
632
- message: tpl(messages.invalidRoleValue.message),
633
- context: tpl(messages.invalidRoleValue.context),
634
- help: tpl(messages.invalidRoleValue.help)
635
- })
636
- );
637
- }
638
- }).then((roleToAssign) => {
639
- if (roleToAssign && roleToAssign.get('name') === 'Owner') {
640
- return Promise.reject(
641
- new errors.ValidationError({
642
- message: tpl(messages.methodDoesNotSupportOwnerRole)
643
- })
644
- );
645
- } else if (roleToAssign) {
646
- // assign all other roles
647
- return user.roles().updatePivot({role_id: roleToAssign.id});
648
- }
649
- }).then(() => {
650
- options.status = 'all';
651
- return self.findOne({id: user.id}, options);
652
- }).then((model) => {
653
- model._changed = user._changed;
654
- return model;
603
+ if (roles.models[0].id !== roleId) {
604
+ if (ASSIGNABLE_ROLES.includes(roleId)) {
605
+ if (roles.models[0].get('name') !== roleId) {
606
+ roleToAssign = await ghostBookshelf.model('Role').findOne({
607
+ name: roleId
608
+ });
609
+ }
610
+ } else if (ObjectId.isValid(roleId)) {
611
+ roleToAssign = await ghostBookshelf.model('Role').findOne({
612
+ id: roleId
655
613
  });
614
+ } else {
615
+ throw new errors.ValidationError({
616
+ message: tpl(messages.invalidRoleValue.message),
617
+ context: tpl(messages.invalidRoleValue.context),
618
+ help: tpl(messages.invalidRoleValue.help)
619
+ });
620
+ }
621
+ }
622
+
623
+ if (roleToAssign && roleToAssign.get('name') === 'Owner') {
624
+ throw new errors.ValidationError({
625
+ message: tpl(messages.methodDoesNotSupportOwnerRole)
656
626
  });
657
- });
627
+ } else if (roleToAssign) {
628
+ // assign all other roles
629
+ await user.roles().updatePivot({role_id: roleToAssign.id});
630
+ }
658
631
 
659
- return pipeline(ops);
632
+ options.status = 'all';
633
+ const model = await self.findOne({id: user.id}, options);
634
+ model._changed = user._changed;
635
+ return model;
660
636
  },
661
637
 
662
638
  /**
@@ -208,7 +208,34 @@ module.exports = function createSessionService({
208
208
  * @returns {Promise<void>}
209
209
  */
210
210
  async function createSessionForUser(req, res, user) {
211
- const session = await getSession(req, res);
211
+ const previousSession = await getSession(req, res);
212
+
213
+ // Carried over to the new session so verification state survives login
214
+ const {
215
+ user_id: previousUserId,
216
+ verified: previousVerified,
217
+ auth_code_challenge: previousAuthCodeChallenge,
218
+ auth_code_generated_at: previousAuthCodeGeneratedAt
219
+ } = previousSession;
220
+
221
+ // Ensure a new session is always created
222
+ await new Promise((resolve, reject) => {
223
+ req.session.regenerate((err) => {
224
+ if (err) {
225
+ reject(err);
226
+ return;
227
+ }
228
+ resolve();
229
+ });
230
+ });
231
+
232
+ const session = req.session;
233
+ session.user_id = previousUserId;
234
+ // A different user doesn't inherit the previous user's verification
235
+ session.verified = previousUserId && previousUserId !== user.id ? undefined : previousVerified;
236
+ session.auth_code_challenge = previousAuthCodeChallenge;
237
+ session.auth_code_generated_at = previousAuthCodeGeneratedAt;
238
+
212
239
  const origin = getOriginOfRequest(req);
213
240
  await assignUserToSession({
214
241
  session,
@@ -47,8 +47,8 @@ const waitActionSchema = zod_1.z.object({
47
47
  type: zod_1.z.literal('wait'),
48
48
  data: zod_1.z.object({
49
49
  wait_hours: zod_1.z.number().int().positive()
50
- }).strict()
51
- }).strict();
50
+ })
51
+ });
52
52
  const sendEmailActionSchema = zod_1.z.object({
53
53
  id: objectIdSchema,
54
54
  type: zod_1.z.literal('send_email'),
@@ -56,12 +56,12 @@ const sendEmailActionSchema = zod_1.z.object({
56
56
  email_subject: zod_1.z.string(),
57
57
  email_lexical: zod_1.z.string(),
58
58
  email_design_setting_id: zod_1.z.string().min(1)
59
- }).strict()
60
- }).strict();
59
+ })
60
+ });
61
61
  const edgeSchema = zod_1.z.object({
62
62
  source_action_id: objectIdSchema,
63
63
  target_action_id: objectIdSchema
64
- }).strict();
64
+ });
65
65
  const editAutomationDataSchema = zod_1.z.object({
66
66
  status: zod_1.z.enum(['active', 'inactive']),
67
67
  actions: zod_1.z.array(zod_1.z.discriminatedUnion('type', [
@@ -69,7 +69,7 @@ const editAutomationDataSchema = zod_1.z.object({
69
69
  sendEmailActionSchema
70
70
  ])).min(1).max(MAX_AUTOMATION_ACTIONS),
71
71
  edges: zod_1.z.array(edgeSchema)
72
- }).strict();
72
+ });
73
73
  const repository = (0, database_automations_repository_1.createDatabaseAutomationsRepository)({
74
74
  knex,
75
75
  fakeWaitHoursMultiplier: (0, fake_wait_hours_multiplier_1.parseFakeWaitHoursMultiplier)(config.get('automations:fakeWaitHoursMultiplier'))
@@ -39,8 +39,8 @@ const waitActionSchema = z.object({
39
39
  type: z.literal('wait'),
40
40
  data: z.object({
41
41
  wait_hours: z.number().int().positive()
42
- }).strict()
43
- }).strict();
42
+ })
43
+ });
44
44
 
45
45
  const sendEmailActionSchema = z.object({
46
46
  id: objectIdSchema,
@@ -49,13 +49,13 @@ const sendEmailActionSchema = z.object({
49
49
  email_subject: z.string(),
50
50
  email_lexical: z.string(),
51
51
  email_design_setting_id: z.string().min(1)
52
- }).strict()
53
- }).strict();
52
+ })
53
+ });
54
54
 
55
55
  const edgeSchema = z.object({
56
56
  source_action_id: objectIdSchema,
57
57
  target_action_id: objectIdSchema
58
- }).strict();
58
+ });
59
59
 
60
60
  const editAutomationDataSchema = z.object({
61
61
  status: z.enum(['active', 'inactive']),
@@ -64,7 +64,7 @@ const editAutomationDataSchema = z.object({
64
64
  sendEmailActionSchema
65
65
  ])).min(1).max(MAX_AUTOMATION_ACTIONS),
66
66
  edges: z.array(edgeSchema)
67
- }).strict();
67
+ });
68
68
 
69
69
  const repository = createDatabaseAutomationsRepository({
70
70
  knex,
@@ -25,6 +25,7 @@ export interface WaitAction {
25
25
  }
26
26
 
27
27
  export interface AutomationEmailStats {
28
+ email_clicked_count: number;
28
29
  email_sent_count: number;
29
30
  email_opened_count: number;
30
31
  opened_rate: number | null;
@@ -902,6 +902,7 @@ async function loadActionStats(trx, actionIds) {
902
902
  const rows = await trx('automation_action_revisions')
903
903
  .select('action_id')
904
904
  .sum({
905
+ email_clicked_count: 'email_clicked_count',
905
906
  email_sent_count: 'email_sent_count',
906
907
  email_opened_count: 'email_opened_count'
907
908
  })
@@ -953,22 +954,26 @@ function buildActionPayload(row, stats) {
953
954
  }
954
955
  }
955
956
  const EMPTY_EMAIL_STATS = {
957
+ email_clicked_count: 0,
956
958
  email_sent_count: 0,
957
959
  email_opened_count: 0,
958
960
  opened_rate: null,
959
961
  clicked_rate: null
960
962
  };
961
963
  function buildEmailStats(row) {
964
+ const emailClickedCount = row.email_clicked_count ?? 0;
962
965
  const emailSentCount = row.email_sent_count ?? 0;
963
966
  const emailOpenedCount = row.email_opened_count ?? 0;
964
967
  return {
968
+ email_clicked_count: emailClickedCount,
965
969
  email_sent_count: emailSentCount,
966
970
  email_opened_count: emailOpenedCount,
967
971
  opened_rate: emailSentCount
968
972
  ? Math.round(emailOpenedCount / emailSentCount * 100)
969
973
  : null,
970
- // TODO(NY-1387) Populate clicked_rate once click tracking is implemented.
971
- clicked_rate: null
974
+ clicked_rate: emailSentCount
975
+ ? Math.round(emailClickedCount / emailSentCount * 100)
976
+ : null
972
977
  };
973
978
  }
974
979
  function requireValue(row, field) {