ghost 6.54.0 → 6.55.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 (327) 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-custom-field-types-0.0.0.tgz +0 -0
  6. package/components/tryghost-kg-default-cards-10.3.3.tgz +0 -0
  7. package/core/boot.js +3 -6
  8. package/core/built/admin/assets/{account-migration-DLvVA5yq.js → account-migration-sjHLx5dy.js} +1 -1
  9. package/core/built/admin/assets/activitypub/{account-migration-DHbZcxGM.js → account-migration-DoYXtnZ8.js} +4 -4
  10. package/core/built/admin/assets/activitypub/activitypub.js +2 -2
  11. package/core/built/admin/assets/activitypub/{bluesky-sharing-zfxUey_L.js → bluesky-sharing-Bxeh6MKW.js} +4 -4
  12. package/core/built/admin/assets/activitypub/{dist-BqkDUQxJ2.js → dist-ByFmDSNP2.js} +64 -63
  13. package/core/built/admin/assets/activitypub/{dist-Ba_QxHHl.js → dist-D8sFfdpC.js} +2 -2
  14. package/core/built/admin/assets/activitypub/{domain-CmwSZLHK.js → domain-CWueFW6q.js} +5 -5
  15. package/core/built/admin/assets/activitypub/{edit-profile-CXdwNmVG.js → edit-profile-BGxPU7N5.js} +3 -3
  16. package/core/built/admin/assets/activitypub/{explore-DfbjzGM1.js → explore-DBK-u0Ch.js} +3 -3
  17. package/core/built/admin/assets/activitypub/{feed-DSEBJOqf.js → feed-Bcir5RQZ.js} +3 -3
  18. package/core/built/admin/assets/activitypub/{field-CY7XVcx6.js → field-BV-CIT3d.js} +2 -2
  19. package/core/built/admin/assets/activitypub/{inbox-DtQ0kytT.js → inbox-yC0nfvzl.js} +4 -4
  20. package/core/built/admin/assets/activitypub/{label-Dw96GQ7u.js → label-DTRS9TDk.js} +2 -2
  21. package/core/built/admin/assets/activitypub/layout-qx-bUL1k.js +1 -0
  22. package/core/built/admin/assets/activitypub/{moderation-Dg_-I-8C.js → moderation-Dwl9t0pb.js} +4 -4
  23. package/core/built/admin/assets/activitypub/{note-Cy1-uKMY.js → note-B-zKsZ7r.js} +3 -3
  24. package/core/built/admin/assets/activitypub/{notifications-B_fvoLIM.js → notifications-DjJZD54t.js} +3 -3
  25. package/core/built/admin/assets/activitypub/{preferences-ByJseZp6.js → preferences-CoU8vs6d.js} +6 -6
  26. package/core/built/admin/assets/activitypub/{profile-BFtbnIjz.js → profile-BAsaJekx.js} +6 -6
  27. package/core/built/admin/assets/activitypub/{routes-Dh3xXfce.js → routes-CLH3OVTP.js} +28 -25
  28. package/core/built/admin/assets/activitypub/{settings-DTgqJPxM.js → settings-DA6pNJ-Q.js} +3 -3
  29. package/core/built/admin/assets/activitypub/{site-CdL9ZLqm.js → site-CQD42Pw5.js} +2 -2
  30. package/core/built/admin/assets/activitypub/{tabs-ogqwoBlb.js → tabs-Cg0saow1.js} +7 -7
  31. package/core/built/admin/assets/activitypub/{use-reply-chain-data-CxonteFD.js → use-reply-chain-data-Br4aOWOh.js} +2 -2
  32. package/core/built/admin/assets/audience-DjEWNhp3.js +1 -0
  33. package/core/built/admin/assets/{automated-emails-Cn87L5Md.js → automated-emails-DRy7qY-J.js} +2 -2
  34. package/core/built/admin/assets/{automations-BP_aHamJ.js → automations-Bx3VcFIL.js} +1 -1
  35. package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
  36. package/core/built/admin/assets/{bluesky-sharing-Bh36XPh4.js → bluesky-sharing-ClMd0-Ft.js} +1 -1
  37. package/core/built/admin/assets/{breadcrumb-D34YWnaw.js → breadcrumb-BD0U2ygg.js} +1 -1
  38. package/core/built/admin/assets/{chart-helpers-DbklaJw9.js → chart-helpers-fjQKRVyY.js} +1 -1
  39. package/core/built/admin/assets/checkbox-CWa28lgO.js +1 -0
  40. package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js → chunk.211.6f7328d63c41536451b3.js} +737 -559
  41. package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js.LICENSE.txt → chunk.211.6f7328d63c41536451b3.js.LICENSE.txt} +2 -0
  42. package/core/built/admin/assets/{chunk.589.096f4d0bb775e5229aa9.js → chunk.481.6c7395a49484bb4583aa.js} +41 -38
  43. package/core/built/admin/assets/chunk.524.cffd399783a519e404a9.js +25 -0
  44. package/core/built/admin/assets/chunk.582.eebf0d0b2d202bb746a5.js +27 -0
  45. package/core/built/admin/assets/code-editor-view-CCr0rBWS.js +1 -0
  46. package/core/built/admin/assets/comments-DyUSMUdj.js +1 -0
  47. package/core/built/admin/assets/{date-range-select-B5vSsrpx.js → date-range-select-BFp58EMk.js} +1 -1
  48. package/core/built/admin/assets/{disabled-sources-indicator-NkOIoMue.js → disabled-sources-indicator-CUxMfiPa.js} +1 -1
  49. package/core/built/admin/assets/dist-2Tnz2Yfq.js +1 -0
  50. package/core/built/admin/assets/dist-CjY-f5Z1.js +1 -0
  51. package/core/built/admin/assets/dist-DIC8yz5C.js +1 -0
  52. package/core/built/admin/assets/domain-C0bV2UV8.js +1 -0
  53. package/core/built/admin/assets/dropzone-DlK-K2DQ.js +5 -0
  54. package/core/built/admin/assets/{edit-profile-rvDGrS8J.js → edit-profile-BKT-REJw.js} +1 -1
  55. package/core/built/admin/assets/editor-Dp865O-4.js +8 -0
  56. package/core/built/admin/assets/{explore-BZAZOg5X.js → explore-ClUpJZOp.js} +1 -1
  57. package/core/built/admin/assets/{feed-CFV8xDw9.js → feed-DF92dUMP.js} +1 -1
  58. package/core/built/admin/assets/field-Do7lieBA.js +1 -0
  59. package/core/built/admin/assets/{filter-sources-Fx8wiYYw.js → filter-sources-BV2rnpfA.js} +1 -1
  60. package/core/built/admin/assets/filters-CUXJ2Nm9.js +1 -0
  61. package/core/built/admin/assets/{filters-CGX_pf2N.js → filters-DfbIWc_l.js} +1 -1
  62. package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-a9c788f6f0b1ea6cf0f14ca4b13f4496.css} +1 -1
  63. package/core/built/admin/assets/{ghost-b1dce91ad3232cc259ee5fbbf9526243.js → ghost-c4057474943effc568c5659ccc4909d7.js} +407 -406
  64. package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-db36d795d21c61426837df40af270966.css} +1 -1
  65. package/core/built/admin/assets/gift-link-modal-f9VIMNDv.js +1 -0
  66. package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
  67. package/core/built/admin/assets/growth-B3I6gNLm.js +1 -0
  68. package/core/built/admin/assets/{Growth-CSaHoSVD.js → growth-CVw1My4S.js} +2 -2
  69. package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
  70. package/core/built/admin/assets/{inbox-BRp1_aaC.js → inbox-CFBSu4Ed.js} +12 -12
  71. package/core/built/admin/assets/index-9Cw8z3rA.css +1 -0
  72. package/core/built/admin/assets/index-BGG7NckO.js +67 -0
  73. package/core/built/admin/assets/input-group-BSgG_lVw.js +1 -0
  74. package/core/built/admin/assets/input-surface-BpAjZXY5.js +1 -0
  75. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +974 -937
  76. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +28 -28
  77. package/core/built/admin/assets/koenig-lexical/style.css +1 -1
  78. package/core/built/admin/assets/{koenig-lexical-CM7UFNdL.js → koenig-lexical-f8vHRYvz.js} +28 -28
  79. package/core/built/admin/assets/{kpi-card-BrGH6FsT.js → kpi-card-DSap9rAy.js} +1 -1
  80. package/core/built/admin/assets/{kpi-card-CIGyijjx.js → kpi-card-DU0rn9Cj.js} +1 -1
  81. package/core/built/admin/assets/{kpi-helpers-_HRRsklE.js → kpi-helpers-Dz33AV6h.js} +1 -1
  82. package/core/built/admin/assets/{kpi-tabs-BSvh9eoq.js → kpi-tabs-BtTTrKvJ.js} +1 -1
  83. package/core/built/admin/assets/{label-BOR9XdKl.js → label-DS8Mi6G5.js} +1 -1
  84. package/core/built/admin/assets/label-picker-1DvSuLA1.js +1 -0
  85. package/core/built/admin/assets/labels-CDSUt4J_.js +1 -0
  86. package/core/built/admin/assets/layout-yQEi2qDM.js +1 -0
  87. package/core/built/admin/assets/{link-helpers-tOlxmuH5.js → link-helpers-7EYqvvHs.js} +1 -1
  88. package/core/built/admin/assets/member-custom-fields-yOMHyAg9.js +1 -0
  89. package/core/built/admin/assets/member-detail-BAq7gDaa.js +1 -0
  90. package/core/built/admin/assets/members-CQpVFJu4.js +10 -0
  91. package/core/built/admin/assets/modals-ReORvWnu.js +31 -0
  92. package/core/built/admin/assets/moderation-DySbgyUS.js +1 -0
  93. package/core/built/admin/assets/newsletter-CJQvtHqN.js +1 -0
  94. package/core/built/admin/assets/{Newsletters-C5cs6FGo.js → newsletters-B3QQXfGT.js} +1 -1
  95. package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
  96. package/core/built/admin/assets/note-C8kxrz6w.js +1 -0
  97. package/core/built/admin/assets/notifications-CgtSfX7t.js +1 -0
  98. package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
  99. package/core/built/admin/assets/onboarding-route-4qtziA9d.js +1 -0
  100. package/core/built/admin/assets/overview-Bf2sbznq.js +1 -0
  101. package/core/built/admin/assets/overview-CBfePxcr.js +1 -0
  102. package/core/built/admin/assets/{page-header-C0jVxtS8.js → page-header-B3Wb9Jqv.js} +1 -1
  103. package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
  104. package/core/built/admin/assets/{post-analytics-Iw7dk1mT.js → post-analytics-We-ko5Cb.js} +1 -1
  105. package/core/built/admin/assets/post-analytics-header-Bmc9su6q.js +1 -0
  106. package/core/built/admin/assets/post-analytics-provider-B6UynTGb.js +1 -0
  107. package/core/built/admin/assets/{post-share-modal-DBfKl2GZ.js → post-share-modal-DFfO93_0.js} +1 -1
  108. package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
  109. package/core/built/admin/assets/{preferences-D4EANGoc.js → preferences-DJPfHEGu.js} +2 -2
  110. package/core/built/admin/assets/profile-DqBQp-gT.js +1 -0
  111. package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
  112. package/core/built/admin/assets/select-C5_xEt5b.js +1 -0
  113. package/core/built/admin/assets/settings-BwiVIXau.js +4 -0
  114. package/core/built/admin/assets/{settings-Bz3RZRmE.js → settings-WXNeWyU1.js} +1 -1
  115. package/core/built/admin/assets/shade-app-B3heOAfz-CGXQwWeO.js +3 -0
  116. package/core/built/admin/assets/{share-modal-CkdTTMJd.js → share-modal-DsgW3wbg.js} +1 -1
  117. package/core/built/admin/assets/social-urls-BQPFEk1_.js +8 -0
  118. package/core/built/admin/assets/{sort-button-DZp9Y2r1.js → sort-button-Bb_FCl7e.js} +1 -1
  119. package/core/built/admin/assets/{source-icon-CHr9qJ2Y.js → source-icon-BeaRxUn9.js} +1 -1
  120. package/core/built/admin/assets/{stats-DGoXZxWE.js → stats-Bjj_kmJ1.js} +1 -1
  121. package/core/built/admin/assets/{stats-view-D2-1L0ms.js → stats-view-BxPbW6K1.js} +1 -1
  122. package/core/built/admin/assets/{step-1-RdahbwyG.js → step-1-B1e3-1F8.js} +1 -1
  123. package/core/built/admin/assets/{step-2-D83PeRwd.js → step-2-C5WaeBJo.js} +1 -1
  124. package/core/built/admin/assets/{step-3-p_Op4wv4.js → step-3-B46iVPwu.js} +2 -2
  125. package/core/built/admin/assets/switch-z5Y6Uzt6.js +1 -0
  126. package/core/built/admin/assets/{table-B8LgTQJZ.js → table-CB-4_HBJ.js} +1 -1
  127. package/core/built/admin/assets/tabs-CCEgKYzq.js +1 -0
  128. package/core/built/admin/assets/{tags-B4l891z2.js → tags-Cg2VJGmG.js} +1 -1
  129. package/core/built/admin/assets/textarea-Bjccvh_E.js +1 -0
  130. package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
  131. package/core/built/admin/assets/{toggle-group-DNXDkAwf.js → toggle-group-b3IlxvhV.js} +1 -1
  132. package/core/built/admin/assets/{topic-filter-DZGjEcz3.js → topic-filter-DAFT87tQ.js} +1 -1
  133. package/core/built/admin/assets/{use-analytics-data-CaT8RirX.js → use-analytics-data-S0FTd43s.js} +1 -1
  134. package/core/built/admin/assets/use-filter-params-Gpoq2E1L.js +1 -0
  135. package/core/built/admin/assets/{use-growth-stats-9Db2CuJe.js → use-growth-stats-CIaiHOVb.js} +1 -1
  136. package/core/built/admin/assets/{use-post-referrers-BV54YcQS.js → use-post-referrers-Cq2vBPrA.js} +1 -1
  137. package/core/built/admin/assets/{use-reply-chain-data-DuYZGDan.js → use-reply-chain-data-DBDRtGu-.js} +1 -1
  138. package/core/built/admin/assets/{use-simple-pagination-8fTua9Nt.js → use-simple-pagination-CjYNxdwe.js} +1 -1
  139. package/core/built/admin/assets/{use-tinybird-query-Dz156HvZ.js → use-tinybird-query-falHAtuK.js} +1 -1
  140. package/core/built/admin/assets/{use-tinybird-token-C9RUdL_n.js → use-tinybird-token-D7-8ND1y.js} +1 -1
  141. package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
  142. package/core/built/admin/assets/{virtual-list-BCvP0YF-.js → virtual-list-CvnC9zF2.js} +1 -1
  143. package/core/built/admin/assets/web-B_5GZRvH.js +1 -0
  144. package/core/built/admin/assets/web-jFYWCKDn.js +2 -0
  145. package/core/built/admin/index.html +14 -10
  146. package/core/frontend/services/proxy.js +10 -1
  147. package/core/frontend/services/routing/router-manager.js +14 -14
  148. package/core/frontend/services/routing/static-routes-router.js +8 -10
  149. package/core/server/adapters/storage/LocalFilesStorage.js +13 -11
  150. package/core/server/adapters/storage/LocalFilesStorage.ts +24 -0
  151. package/core/server/adapters/storage/LocalImagesStorage.js +14 -12
  152. package/core/server/adapters/storage/LocalImagesStorage.ts +24 -0
  153. package/core/server/adapters/storage/LocalMediaStorage.js +13 -11
  154. package/core/server/adapters/storage/LocalMediaStorage.ts +24 -0
  155. package/core/server/adapters/storage/LocalStorageBase.js +107 -167
  156. package/core/server/adapters/storage/LocalStorageBase.ts +342 -0
  157. package/core/server/adapters/storage/utils.js +36 -50
  158. package/core/server/adapters/storage/utils.ts +60 -0
  159. package/core/server/api/endpoints/automation-action-links.js +68 -0
  160. package/core/server/api/endpoints/automation-action-links.ts +46 -0
  161. package/core/server/api/endpoints/db.js +6 -0
  162. package/core/server/api/endpoints/index.js +4 -0
  163. package/core/server/api/endpoints/members.js +25 -28
  164. package/core/server/api/endpoints/pages.js +6 -1
  165. package/core/server/api/endpoints/posts.js +11 -2
  166. package/core/server/api/endpoints/settings.js +0 -2
  167. package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +4 -1
  168. package/core/server/api/endpoints/utils/serializers/output/members.js +18 -87
  169. package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
  170. package/core/server/data/exporter/export-filename.js +2 -1
  171. package/core/server/data/importer/importers/data/revue-subscriber-importer.js +6 -10
  172. package/core/server/data/importer/importers/json-to-html.js +24 -4
  173. package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
  174. package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
  175. package/core/server/data/schema/commands.js +5 -0
  176. package/core/server/data/schema/default-settings/default-settings.json +0 -4
  177. package/core/server/data/schema/schema.js +5 -1
  178. package/core/server/lib/image/image-size.js +3 -17
  179. package/core/server/lib/image/image-utils.js +15 -1
  180. package/core/server/lib/request-external.js +1 -1
  181. package/core/server/models/user.js +46 -70
  182. package/core/server/services/auth/session/session-service.js +28 -1
  183. package/core/server/services/automations/automations-api.js +21 -6
  184. package/core/server/services/automations/automations-api.ts +25 -6
  185. package/core/server/services/automations/automations-repository.ts +19 -0
  186. package/core/server/services/automations/database-automations-repository.js +116 -28
  187. package/core/server/services/automations/database-automations-repository.ts +118 -9
  188. package/core/server/services/automations/database-date.js +10 -0
  189. package/core/server/services/automations/database-date.ts +5 -0
  190. package/core/server/services/automations/poll.js +5 -2
  191. package/core/server/services/automations/poll.ts +7 -2
  192. package/core/server/services/automations/scheduler-poll-time.js +40 -0
  193. package/core/server/services/automations/scheduler-poll-time.ts +38 -0
  194. package/core/server/services/automations/service.js +15 -3
  195. package/core/server/services/automations/service.ts +16 -3
  196. package/core/server/services/comments/comments-service-emails.js +14 -8
  197. package/core/server/services/comments/comments-service.js +9 -4
  198. package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
  199. package/core/server/services/email-service/email-service-wrapper.js +6 -1
  200. package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
  201. package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
  202. package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
  203. package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
  204. package/core/server/services/link-redirection/link-redirect.js +2 -0
  205. package/core/server/services/link-redirection/link-redirect.ts +3 -1
  206. package/core/server/services/link-redirection/link-redirects-service.js +38 -2
  207. package/core/server/services/link-tracking/click-event.js +3 -0
  208. package/core/server/services/link-tracking/index.js +38 -6
  209. package/core/server/services/link-tracking/link-click-repository.js +30 -9
  210. package/core/server/services/link-tracking/link-click-tracking-service.js +44 -3
  211. package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
  212. package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +19 -4
  213. package/core/server/services/member-welcome-emails/service.js +11 -3
  214. package/core/server/services/members/{importer → import-export}/config.js +4 -9
  215. package/core/server/services/members/{importer → import-export}/config.ts +4 -9
  216. package/core/server/services/members/import-export/csv/formula.js +16 -0
  217. package/core/server/services/members/import-export/csv/formula.ts +14 -0
  218. package/core/server/services/members/import-export/csv/index.js +12 -0
  219. package/core/server/services/members/import-export/csv/index.ts +3 -0
  220. package/core/server/services/members/import-export/csv/parse.js +64 -0
  221. package/core/server/services/members/import-export/csv/parse.ts +69 -0
  222. package/core/server/services/members/import-export/csv/serialize.js +13 -0
  223. package/core/server/services/members/import-export/csv/serialize.ts +18 -0
  224. package/core/server/services/members/import-export/export/exporter.js +202 -0
  225. package/core/server/services/members/import-export/export/exporter.ts +325 -0
  226. package/core/server/services/members/import-export/import/completion-email.js +84 -0
  227. package/core/server/services/members/import-export/import/completion-email.ts +141 -0
  228. package/core/server/services/members/import-export/import/importer.js +297 -0
  229. package/core/server/services/members/import-export/import/importer.ts +468 -0
  230. package/core/server/services/members/import-export/import/reader.js +32 -0
  231. package/core/server/services/members/import-export/import/reader.ts +33 -0
  232. package/core/server/services/members/import-export/import/row.js +33 -0
  233. package/core/server/services/members/import-export/import/row.ts +52 -0
  234. package/core/server/services/members/import-export/import/spool.js +30 -0
  235. package/core/server/services/members/import-export/import/spool.ts +38 -0
  236. package/core/server/services/members/import-export/index.js +99 -0
  237. package/core/server/services/members/import-export/index.ts +137 -0
  238. package/core/server/services/members/members-api/controllers/router-controller.js +7 -0
  239. package/core/server/services/members/service.js +25 -20
  240. package/core/server/services/members-custom-fields/values-service.js +19 -7
  241. package/core/server/services/members-custom-fields/values-service.ts +19 -7
  242. package/core/server/services/mentions/service.js +5 -1
  243. package/core/server/services/posts/posts-exporter.js +6 -4
  244. package/core/server/services/posts/posts-service.js +2 -2
  245. package/core/server/services/route-settings/activation-bridge.js +59 -49
  246. package/core/server/services/route-settings/activation-bridge.ts +91 -51
  247. package/core/server/services/route-settings/dynamic-routing-service.js +22 -51
  248. package/core/server/services/route-settings/index.js +1 -16
  249. package/core/server/services/settings/settings-service.js +0 -18
  250. package/core/server/services/themes/storage.js +9 -0
  251. package/core/server/services/themes/theme-storage.js +1 -1
  252. package/core/server/services/url/url-service-facade.js +1 -1
  253. package/core/server/services/url/url-service-facade.ts +8 -1
  254. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  255. package/core/shared/config/defaults.json +2 -1
  256. package/core/shared/express.js +6 -1
  257. package/core/shared/labs.js +2 -1
  258. package/package.json +11 -11
  259. package/pnpm-lock.yaml +18 -18
  260. package/pnpm-workspace.yaml +8 -7
  261. package/components/tryghost-adapter-base-scheduling-0.1.1.tgz +0 -0
  262. package/core/built/admin/assets/Overview-j4NaeUNm.js +0 -1
  263. package/core/built/admin/assets/Web-_fD935OX.js +0 -2
  264. package/core/built/admin/assets/activitypub/layout-IdVq3lRy.js +0 -1
  265. package/core/built/admin/assets/audience-BKRdsFmy.js +0 -1
  266. package/core/built/admin/assets/checkbox-DA4-1UiI.js +0 -1
  267. package/core/built/admin/assets/chunk.524.45e0875f41b71e46e849.js +0 -25
  268. package/core/built/admin/assets/chunk.582.51defd4b009f0de8d4d5.js +0 -27
  269. package/core/built/admin/assets/code-editor-view-BKBrkffZ.js +0 -1
  270. package/core/built/admin/assets/comments-CzVNIT8b.js +0 -1
  271. package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
  272. package/core/built/admin/assets/dist-CT46_f_J.js +0 -1
  273. package/core/built/admin/assets/domain-B7_n5nfx.js +0 -1
  274. package/core/built/admin/assets/dropzone-DcuJEEUf.js +0 -5
  275. package/core/built/admin/assets/editor-BCiIyFjm.js +0 -8
  276. package/core/built/admin/assets/field-BW-M2Zwp.js +0 -1
  277. package/core/built/admin/assets/filters-DtcXMZDU.js +0 -1
  278. package/core/built/admin/assets/gift-link-modal-Bk8qpnMw.js +0 -1
  279. package/core/built/admin/assets/growth-Ck2s3JjR.js +0 -1
  280. package/core/built/admin/assets/index-BBf502Wg.js +0 -69
  281. package/core/built/admin/assets/index-BCCZKxP2.css +0 -1
  282. package/core/built/admin/assets/input-group-DjuYnNCd.js +0 -1
  283. package/core/built/admin/assets/label-picker-BdNAWuUX.js +0 -1
  284. package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
  285. package/core/built/admin/assets/layout-Bw4GnxXp.js +0 -1
  286. package/core/built/admin/assets/member-custom-fields-DvFyamLu.js +0 -1
  287. package/core/built/admin/assets/member-detail-Cg2vTqvJ.js +0 -1
  288. package/core/built/admin/assets/members-DsPxYAVm.js +0 -10
  289. package/core/built/admin/assets/modals-Bf_fyvHZ.js +0 -31
  290. package/core/built/admin/assets/moderation-Dlp7M5sa.js +0 -1
  291. package/core/built/admin/assets/multi-select-combobox-CH1o8XXQ.js +0 -1
  292. package/core/built/admin/assets/newsletter-CQ5CAYUI.js +0 -1
  293. package/core/built/admin/assets/note-BrAZ7Z0o.js +0 -1
  294. package/core/built/admin/assets/notifications-BnbHLOPC.js +0 -1
  295. package/core/built/admin/assets/onboarding-route-eU146kuA.js +0 -1
  296. package/core/built/admin/assets/overview-BpbaK-xu.js +0 -1
  297. package/core/built/admin/assets/post-analytics-header-D9pJ4iNm.js +0 -1
  298. package/core/built/admin/assets/post-analytics-provider-BDqcfBor.js +0 -1
  299. package/core/built/admin/assets/profile-dJ44Bn_W.js +0 -1
  300. package/core/built/admin/assets/select--2uVN9_-.js +0 -1
  301. package/core/built/admin/assets/settings-SD9bxIpt.js +0 -4
  302. package/core/built/admin/assets/social-urls-BvFZwYcE.js +0 -8
  303. package/core/built/admin/assets/switch-qp518gFQ.js +0 -1
  304. package/core/built/admin/assets/tabs-D3ZOYkM7.js +0 -1
  305. package/core/built/admin/assets/textarea-CTlb3Uu6.js +0 -1
  306. package/core/built/admin/assets/use-filter-params-DeQ8sX4N.js +0 -1
  307. package/core/built/admin/assets/web-DTZnnUGr.js +0 -1
  308. package/core/server/services/mail/templates/newsletter.html +0 -876
  309. package/core/server/services/members/csv/index.js +0 -10
  310. package/core/server/services/members/csv/index.ts +0 -3
  311. package/core/server/services/members/csv/parse.js +0 -94
  312. package/core/server/services/members/csv/parse.ts +0 -111
  313. package/core/server/services/members/csv/types.js +0 -2
  314. package/core/server/services/members/csv/types.ts +0 -39
  315. package/core/server/services/members/csv/unparse.js +0 -69
  316. package/core/server/services/members/csv/unparse.ts +0 -79
  317. package/core/server/services/members/exporter/query.js +0 -233
  318. package/core/server/services/members/importer/index.js +0 -30
  319. package/core/server/services/members/importer/labels.js +0 -125
  320. package/core/server/services/members/importer/members-csv-importer.js +0 -520
  321. package/core/server/services/route-settings/default-settings-manager.js +0 -63
  322. package/core/server/services/route-settings/route-settings.js +0 -170
  323. package/core/server/services/route-settings/settings-loader.js +0 -60
  324. package/core/server/services/route-settings/settings-path-manager.js +0 -47
  325. package/core/server/services/route-settings/validate.js +0 -440
  326. /package/core/server/services/members/{importer → import-export/import}/email-template.js +0 -0
  327. /package/core/server/services/members/{importer/members-csv-importer-stripe-utils.js → import-export/import/stripe-utils.js} +0 -0
@@ -9,11 +9,13 @@ const tpl_1 = __importDefault(require("@tryghost/tpl"));
9
9
  const node_crypto_1 = __importDefault(require("node:crypto"));
10
10
  const bson_objectid_1 = __importDefault(require("bson-objectid"));
11
11
  const dequal_1 = require("dequal");
12
- const moment_1 = __importDefault(require("moment"));
12
+ // @ts-expect-error This module currently lacks type definitions.
13
+ const lexical_1 = __importDefault(require("../../lib/lexical"));
14
+ const url_utils_1 = __importDefault(require("../../../shared/url-utils"));
13
15
  const constants_1 = require("../member-welcome-emails/constants");
16
+ const database_date_1 = require("./database-date");
14
17
  const stale_lock_cutoff_1 = require("./stale-lock-cutoff");
15
18
  const HOUR_MS = 60 * 60 * 1000;
16
- const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
17
19
  const DEFAULT_WELCOME_EMAIL_AUTOMATIONS = [{
18
20
  name: 'Free member welcome flow',
19
21
  slug: constants_1.MEMBER_WELCOME_EMAIL_SLUGS.free
@@ -28,9 +30,6 @@ const messages = {
28
30
  defaultEmailDesignSettingNotFound: 'Default automated email design setting not found.'
29
31
  };
30
32
  const DEFAULT_EMAIL_DESIGN_SETTING_REFERENCE = constants_1.DEFAULT_EMAIL_DESIGN_SETTING_SLUG;
31
- function toDatabaseDate(date) {
32
- return (0, moment_1.default)(date).format(DATABASE_DATE_FORMAT);
33
- }
34
33
  function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier }) {
35
34
  return {
36
35
  async browse() {
@@ -54,6 +53,33 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
54
53
  return await buildAutomation(trx, automation);
55
54
  });
56
55
  },
56
+ async getAutomationActionLinks(automationId, actionId) {
57
+ const action = await knex('automation_actions')
58
+ .select('id')
59
+ .where({
60
+ id: actionId,
61
+ automation_id: automationId
62
+ })
63
+ .whereNull('deleted_at')
64
+ .first();
65
+ if (!action) {
66
+ return null;
67
+ }
68
+ const rows = await knex('redirects as redirects')
69
+ .countDistinct({ clicked_count: 'members_click_events.member_id' })
70
+ .select(knex.raw('MIN(??) as ??', ['redirects.to', 'url']))
71
+ .innerJoin('automation_action_revisions as revisions', 'revisions.id', 'redirects.automation_action_revision_id')
72
+ .leftJoin('members_click_events', 'members_click_events.redirect_id', 'redirects.id')
73
+ .where('revisions.action_id', actionId)
74
+ .whereNotNull('redirects.to_hash')
75
+ .groupBy('redirects.to_hash')
76
+ .orderBy('clicked_count', 'desc')
77
+ .orderBy('url', 'asc');
78
+ return rows.map(row => ({
79
+ url: url_utils_1.default.transformReadyToAbsolute(row.url),
80
+ clicked_count: Number(row.clicked_count)
81
+ }));
82
+ },
57
83
  async edit(id, data) {
58
84
  return await knex.transaction(async (trx) => {
59
85
  const automation = await loadAutomation(trx, id);
@@ -64,7 +90,7 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
64
90
  const updatedAutomation = await updateAutomation(trx, {
65
91
  ...automation,
66
92
  status: data.status,
67
- updated_at: toDatabaseDate(now)
93
+ updated_at: (0, database_date_1.toDatabaseDate)(now)
68
94
  });
69
95
  await replaceAutomationGraph(trx, updatedAutomation.id, data.actions, data.edges);
70
96
  if (updatedAutomation.status === 'inactive') {
@@ -101,7 +127,7 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
101
127
  .update({
102
128
  email_sent_count: trx.raw('COALESCE(??, 0) + ?', ['email_sent_count', 1])
103
129
  });
104
- const now = toDatabaseDate(new Date());
130
+ const now = (0, database_date_1.toDatabaseDate)(new Date());
105
131
  await trx('automated_email_recipients').insert({
106
132
  id: (0, bson_objectid_1.default)().toHexString(),
107
133
  member_id: options.memberId,
@@ -162,6 +188,48 @@ function createDatabaseAutomationsRepository({ knex, fakeWaitHoursMultiplier })
162
188
  });
163
189
  }
164
190
  });
191
+ },
192
+ async trackEmailClicked({ automationActionRevisionId, memberId, clickedAt }, { transacting } = {}) {
193
+ const trackClick = async (trx) => {
194
+ // Match recordEmailSent's lock order to avoid deadlocks.
195
+ await trx('automation_action_revisions')
196
+ .select('id')
197
+ .where({ id: automationActionRevisionId })
198
+ .forUpdate()
199
+ .first();
200
+ const recipient = await trx('automated_email_recipients')
201
+ .select('id', 'clicked_at')
202
+ .where({
203
+ automation_action_revision_id: automationActionRevisionId,
204
+ member_id: memberId,
205
+ track_clicks: true
206
+ })
207
+ .where('created_at', '<=', (0, database_date_1.toDatabaseDate)(clickedAt))
208
+ .orderBy('created_at', 'desc')
209
+ .orderBy('id', 'desc')
210
+ .forUpdate()
211
+ .first();
212
+ if (!recipient || recipient.clicked_at !== null) {
213
+ return;
214
+ }
215
+ const updated = await trx('automated_email_recipients')
216
+ .where({ id: recipient.id })
217
+ .whereNull('clicked_at')
218
+ .update({ clicked_at: clickedAt });
219
+ if (updated === 0) {
220
+ return;
221
+ }
222
+ await trx('automation_action_revisions')
223
+ .where({ id: automationActionRevisionId })
224
+ .update({
225
+ email_clicked_count: trx.raw('COALESCE(email_clicked_count, 0) + 1')
226
+ });
227
+ };
228
+ if (transacting) {
229
+ await trackClick(transacting);
230
+ return;
231
+ }
232
+ await knex.transaction(trackClick);
165
233
  }
166
234
  };
167
235
  }
@@ -194,7 +262,7 @@ async function ensureDefaultAutomations(trx) {
194
262
  }
195
263
  }
196
264
  async function ensureAutomation(trx, defaults) {
197
- const now = toDatabaseDate(new Date());
265
+ const now = (0, database_date_1.toDatabaseDate)(new Date());
198
266
  const id = (0, bson_objectid_1.default)().toHexString();
199
267
  await trx('automations')
200
268
  .insert({
@@ -233,7 +301,7 @@ async function ensureWelcomeEmailAction(trx, automationId) {
233
301
  if (!email) {
234
302
  return;
235
303
  }
236
- const now = toDatabaseDate(new Date());
304
+ const now = (0, database_date_1.toDatabaseDate)(new Date());
237
305
  const actionId = (0, bson_objectid_1.default)().toHexString();
238
306
  await insertActions(trx, [{
239
307
  id: actionId,
@@ -263,7 +331,7 @@ async function trigger(trx, options) {
263
331
  return;
264
332
  }
265
333
  const now = new Date();
266
- const nowString = toDatabaseDate(now);
334
+ const nowString = (0, database_date_1.toDatabaseDate)(now);
267
335
  const readyAt = getReadyAtForAction(firstAction, now, fakeWaitHoursMultiplier);
268
336
  const run = {
269
337
  id: (0, bson_objectid_1.default)().toHexString(),
@@ -282,14 +350,14 @@ async function trigger(trx, options) {
282
350
  });
283
351
  }
284
352
  async function insertRunStep(trx, { automationRunId, automationActionRevisionId, now, readyAt }) {
285
- const nowString = toDatabaseDate(now);
353
+ const nowString = (0, database_date_1.toDatabaseDate)(now);
286
354
  await trx('automation_run_steps').insert({
287
355
  id: (0, bson_objectid_1.default)().toHexString(),
288
356
  created_at: nowString,
289
357
  updated_at: nowString,
290
358
  automation_run_id: automationRunId,
291
359
  automation_action_revision_id: automationActionRevisionId,
292
- ready_at: toDatabaseDate(readyAt)
360
+ ready_at: (0, database_date_1.toDatabaseDate)(readyAt)
293
361
  });
294
362
  }
295
363
  async function fetchAndLockSteps(trx, limit) {
@@ -304,9 +372,9 @@ async function fetchAndLockSteps(trx, limit) {
304
372
  // 2. Try to lock those rows.
305
373
  // 3. Select any rows we successfully locked.
306
374
  const now = new Date();
307
- const nowString = toDatabaseDate(now);
375
+ const nowString = (0, database_date_1.toDatabaseDate)(now);
308
376
  const staleLockCutoff = (0, stale_lock_cutoff_1.getStaleLockCutoff)(now);
309
- const staleLockCutoffString = toDatabaseDate(staleLockCutoff);
377
+ const staleLockCutoffString = (0, database_date_1.toDatabaseDate)(staleLockCutoff);
310
378
  const lockId = node_crypto_1.default.randomUUID();
311
379
  // 1. Select up to `limit` candidate rows.
312
380
  const candidates = await trx('automation_run_steps')
@@ -374,7 +442,7 @@ async function findNextPendingReadyAt(trx, staleLockCutoff) {
374
442
  .where((builder) => {
375
443
  builder
376
444
  .whereNull('locked_by')
377
- .orWhere('locked_at', '<', toDatabaseDate(staleLockCutoff));
445
+ .orWhere('locked_at', '<', (0, database_date_1.toDatabaseDate)(staleLockCutoff));
378
446
  })
379
447
  .orderBy('ready_at')
380
448
  .first();
@@ -479,7 +547,7 @@ async function findNextActionRevision(trx, sourceActionId) {
479
547
  return row ?? null;
480
548
  }
481
549
  async function markStepTerminal(trx, step, status) {
482
- const nowString = toDatabaseDate(new Date());
550
+ const nowString = (0, database_date_1.toDatabaseDate)(new Date());
483
551
  return await updateStep(trx, step, {
484
552
  status,
485
553
  finished_at: nowString,
@@ -491,12 +559,12 @@ async function retryStep(trx, step, retryAt) {
491
559
  await markStepTerminal(trx, step, 'automation disabled');
492
560
  return false;
493
561
  }
494
- const nowString = toDatabaseDate(new Date());
562
+ const nowString = (0, database_date_1.toDatabaseDate)(new Date());
495
563
  return await updateStep(trx, step, {
496
564
  status: 'pending',
497
565
  started_at: null,
498
566
  finished_at: null,
499
- ready_at: toDatabaseDate(retryAt),
567
+ ready_at: (0, database_date_1.toDatabaseDate)(retryAt),
500
568
  updated_at: nowString
501
569
  });
502
570
  }
@@ -521,8 +589,8 @@ function getReadyAtForAction(action, now, fakeWaitHoursMultiplier) {
521
589
  }
522
590
  }
523
591
  async function cancelCancelablePendingStepsForAutomation(trx, automationId, now) {
524
- const nowString = toDatabaseDate(now);
525
- const staleLockCutoff = toDatabaseDate((0, stale_lock_cutoff_1.getStaleLockCutoff)(now));
592
+ const nowString = (0, database_date_1.toDatabaseDate)(now);
593
+ const staleLockCutoff = (0, database_date_1.toDatabaseDate)((0, stale_lock_cutoff_1.getStaleLockCutoff)(now));
526
594
  await trx('automation_run_steps')
527
595
  .update({
528
596
  status: 'automation disabled',
@@ -626,11 +694,11 @@ async function updateAutomation(trx, automation) {
626
694
  async function replaceAutomationGraph(trx, automationId, submittedActions, edges) {
627
695
  const existingActions = await loadAutomationActionRows(trx, automationId);
628
696
  const existingActionById = new Map(existingActions.map(action => [action.id, action]));
629
- const actions = await resolveEmailDesignSettingIds(trx, submittedActions);
697
+ const actions = (await resolveEmailDesignSettingIds(trx, submittedActions)).map(formatActionOnWrite);
630
698
  const submittedActionIds = new Set(actions.map(action => action.id));
631
699
  const actionIdsWithOwners = await loadActionIdsWithOwners(trx, [...submittedActionIds]);
632
700
  const latestRevisionByActionId = new Map((await loadLatestActionRevisions(trx, [...submittedActionIds])).map(revision => [revision.action_id, revision]));
633
- const now = toDatabaseDate(new Date());
701
+ const now = (0, database_date_1.toDatabaseDate)(new Date());
634
702
  const actionsToInsert = [];
635
703
  const revisionsToInsert = [];
636
704
  for (const action of actions) {
@@ -680,6 +748,21 @@ async function replaceAutomationGraph(trx, automationId, submittedActions, edges
680
748
  await deleteAutomationEdges(trx, automationId);
681
749
  await insertActionEdges(trx, edges);
682
750
  }
751
+ function formatActionOnWrite(action) {
752
+ if (action.type !== 'send_email') {
753
+ return action;
754
+ }
755
+ return {
756
+ ...action,
757
+ data: {
758
+ ...action.data,
759
+ email_lexical: url_utils_1.default.lexicalToTransformReady(action.data.email_lexical, {
760
+ nodes: lexical_1.default.nodes,
761
+ transformMap: lexical_1.default.urlTransformMap
762
+ })
763
+ }
764
+ };
765
+ }
683
766
  async function resolveEmailDesignSettingIds(trx, actions) {
684
767
  if (!actions.some(action => action.type === 'send_email' && action.data.email_design_setting_id === DEFAULT_EMAIL_DESIGN_SETTING_REFERENCE)) {
685
768
  return [...actions];
@@ -793,14 +876,14 @@ async function insertActionRevisions(trx, revisions) {
793
876
  }
794
877
  function getNextRevisionCreatedAt(latestCreatedAt, requestedCreatedAt) {
795
878
  if (!latestCreatedAt) {
796
- return toDatabaseDate(requestedCreatedAt);
879
+ return (0, database_date_1.toDatabaseDate)(requestedCreatedAt);
797
880
  }
798
881
  const requestedTime = new Date(requestedCreatedAt).getTime();
799
882
  const latestTime = new Date(latestCreatedAt).getTime();
800
883
  if (requestedTime > latestTime) {
801
- return toDatabaseDate(requestedCreatedAt);
884
+ return (0, database_date_1.toDatabaseDate)(requestedCreatedAt);
802
885
  }
803
- return toDatabaseDate(new Date(latestTime + 1000));
886
+ return (0, database_date_1.toDatabaseDate)(new Date(latestTime + 1000));
804
887
  }
805
888
  function buildActionRevision(actionId, action, createdAt) {
806
889
  switch (action.type) {
@@ -902,6 +985,7 @@ async function loadActionStats(trx, actionIds) {
902
985
  const rows = await trx('automation_action_revisions')
903
986
  .select('action_id')
904
987
  .sum({
988
+ email_clicked_count: 'email_clicked_count',
905
989
  email_sent_count: 'email_sent_count',
906
990
  email_opened_count: 'email_opened_count'
907
991
  })
@@ -945,7 +1029,7 @@ function buildActionPayload(row, stats) {
945
1029
  type: 'send_email',
946
1030
  data: {
947
1031
  email_subject: requireValue(row, 'email_subject'),
948
- email_lexical: requireValue(row, 'email_lexical'),
1032
+ email_lexical: url_utils_1.default.transformReadyToAbsolute(requireValue(row, 'email_lexical')),
949
1033
  email_design_setting_id: requireValue(row, 'email_design_setting_id')
950
1034
  },
951
1035
  stats: stats ?? EMPTY_EMAIL_STATS
@@ -953,22 +1037,26 @@ function buildActionPayload(row, stats) {
953
1037
  }
954
1038
  }
955
1039
  const EMPTY_EMAIL_STATS = {
1040
+ email_clicked_count: 0,
956
1041
  email_sent_count: 0,
957
1042
  email_opened_count: 0,
958
1043
  opened_rate: null,
959
1044
  clicked_rate: null
960
1045
  };
961
1046
  function buildEmailStats(row) {
1047
+ const emailClickedCount = row.email_clicked_count ?? 0;
962
1048
  const emailSentCount = row.email_sent_count ?? 0;
963
1049
  const emailOpenedCount = row.email_opened_count ?? 0;
964
1050
  return {
1051
+ email_clicked_count: emailClickedCount,
965
1052
  email_sent_count: emailSentCount,
966
1053
  email_opened_count: emailOpenedCount,
967
1054
  opened_rate: emailSentCount
968
1055
  ? Math.round(emailOpenedCount / emailSentCount * 100)
969
1056
  : null,
970
- // TODO(NY-1387) Populate clicked_rate once click tracking is implemented.
971
- clicked_rate: null
1057
+ clicked_rate: emailSentCount
1058
+ ? Math.round(emailClickedCount / emailSentCount * 100)
1059
+ : null
972
1060
  };
973
1061
  }
974
1062
  function requireValue(row, field) {
@@ -4,7 +4,9 @@ import crypto from 'node:crypto';
4
4
  import ObjectId from 'bson-objectid';
5
5
  import {dequal} from 'dequal';
6
6
  import {type Knex} from 'knex';
7
- import moment from 'moment';
7
+ // @ts-expect-error This module currently lacks type definitions.
8
+ import lexicalLib from '../../lib/lexical';
9
+ import urlUtils from '../../../shared/url-utils';
8
10
  import {DEFAULT_EMAIL_DESIGN_SETTING_SLUG, MEMBER_WELCOME_EMAIL_SLUGS} from '../member-welcome-emails/constants';
9
11
  import type {
10
12
  AutomatedEmailEvents,
@@ -19,11 +21,11 @@ import type {
19
21
  EditAutomationData,
20
22
  Page
21
23
  } from './automations-repository';
24
+ import {toDatabaseDate} from './database-date';
22
25
  import {getStaleLockCutoff} from './stale-lock-cutoff';
23
26
  import type {ExclusifyUnion, ReadonlyDeep} from 'type-fest';
24
27
 
25
28
  const HOUR_MS = 60 * 60 * 1000;
26
- const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
27
29
  const DEFAULT_WELCOME_EMAIL_AUTOMATIONS = [{
28
30
  name: 'Free member welcome flow',
29
31
  slug: MEMBER_WELCOME_EMAIL_SLUGS.free
@@ -50,9 +52,6 @@ interface AutomationRow {
50
52
  updated_at: string;
51
53
  }
52
54
 
53
- function toDatabaseDate(date: Date | string): string {
54
- return moment(date).format(DATABASE_DATE_FORMAT);
55
- }
56
55
 
57
56
  interface ActionRow {
58
57
  id: string;
@@ -65,10 +64,16 @@ interface ActionRow {
65
64
 
66
65
  type ActionStatsRow = {
67
66
  action_id: string;
67
+ email_clicked_count: number | null;
68
68
  email_sent_count: number | null;
69
69
  email_opened_count: number | null;
70
70
  };
71
71
 
72
+ type ActionLinkRow = {
73
+ url: string;
74
+ clicked_count: string | number;
75
+ };
76
+
72
77
  type ActionRevisionRow = {
73
78
  action_id: string;
74
79
  created_at: string;
@@ -165,6 +170,37 @@ export function createDatabaseAutomationsRepository({
165
170
  });
166
171
  },
167
172
 
173
+ async getAutomationActionLinks(automationId, actionId) {
174
+ const action = await knex('automation_actions')
175
+ .select('id')
176
+ .where({
177
+ id: actionId,
178
+ automation_id: automationId
179
+ })
180
+ .whereNull('deleted_at')
181
+ .first();
182
+
183
+ if (!action) {
184
+ return null;
185
+ }
186
+
187
+ const rows = await knex('redirects as redirects')
188
+ .countDistinct({clicked_count: 'members_click_events.member_id'})
189
+ .select<ActionLinkRow[]>(knex.raw('MIN(??) as ??', ['redirects.to', 'url']))
190
+ .innerJoin('automation_action_revisions as revisions', 'revisions.id', 'redirects.automation_action_revision_id')
191
+ .leftJoin('members_click_events', 'members_click_events.redirect_id', 'redirects.id')
192
+ .where('revisions.action_id', actionId)
193
+ .whereNotNull('redirects.to_hash')
194
+ .groupBy('redirects.to_hash')
195
+ .orderBy('clicked_count', 'desc')
196
+ .orderBy('url', 'asc');
197
+
198
+ return rows.map(row => ({
199
+ url: urlUtils.transformReadyToAbsolute(row.url),
200
+ clicked_count: Number(row.clicked_count)
201
+ }));
202
+ },
203
+
168
204
  async edit(id: string, data: EditAutomationData): Promise<Automation | null> {
169
205
  return await knex.transaction(async (trx) => {
170
206
  const automation = await loadAutomation(trx, id);
@@ -303,6 +339,57 @@ export function createDatabaseAutomationsRepository({
303
339
  });
304
340
  }
305
341
  });
342
+ },
343
+
344
+ async trackEmailClicked({automationActionRevisionId, memberId, clickedAt}, {transacting} = {}) {
345
+ const trackClick = async (trx: Knex.Transaction) => {
346
+ // Match recordEmailSent's lock order to avoid deadlocks.
347
+ await trx('automation_action_revisions')
348
+ .select('id')
349
+ .where({id: automationActionRevisionId})
350
+ .forUpdate()
351
+ .first();
352
+
353
+ const recipient = await trx('automated_email_recipients')
354
+ .select('id', 'clicked_at')
355
+ .where({
356
+ automation_action_revision_id: automationActionRevisionId,
357
+ member_id: memberId,
358
+ track_clicks: true
359
+ })
360
+ .where('created_at', '<=', toDatabaseDate(clickedAt))
361
+ .orderBy('created_at', 'desc')
362
+ .orderBy('id', 'desc')
363
+ .forUpdate()
364
+ .first();
365
+
366
+ if (!recipient || recipient.clicked_at !== null) {
367
+ return;
368
+ }
369
+
370
+ const updated = await trx('automated_email_recipients')
371
+ .where({id: recipient.id})
372
+ .whereNull('clicked_at')
373
+ .update({clicked_at: clickedAt});
374
+
375
+ if (updated === 0) {
376
+ return;
377
+ }
378
+
379
+ await trx('automation_action_revisions')
380
+ .where({id: automationActionRevisionId})
381
+ .update({
382
+ email_clicked_count: trx.raw('COALESCE(email_clicked_count, 0) + 1')
383
+ });
384
+
385
+ };
386
+
387
+ if (transacting) {
388
+ await trackClick(transacting);
389
+ return;
390
+ }
391
+
392
+ await knex.transaction(trackClick);
306
393
  }
307
394
  };
308
395
  }
@@ -920,7 +1007,7 @@ async function updateAutomation(trx: Knex.Transaction, automation: AutomationRow
920
1007
  async function replaceAutomationGraph(trx: Knex.Transaction, automationId: string, submittedActions: AutomationAction[], edges: AutomationEdge[]): Promise<void> {
921
1008
  const existingActions = await loadAutomationActionRows(trx, automationId);
922
1009
  const existingActionById = new Map(existingActions.map(action => [action.id, action]));
923
- const actions = await resolveEmailDesignSettingIds(trx, submittedActions);
1010
+ const actions = (await resolveEmailDesignSettingIds(trx, submittedActions)).map(formatActionOnWrite);
924
1011
  const submittedActionIds = new Set(actions.map(action => action.id));
925
1012
  const actionIdsWithOwners = await loadActionIdsWithOwners(trx, [...submittedActionIds]);
926
1013
  const latestRevisionByActionId = new Map((await loadLatestActionRevisions(trx, [...submittedActionIds])).map(revision => [revision.action_id, revision]));
@@ -981,6 +1068,23 @@ async function replaceAutomationGraph(trx: Knex.Transaction, automationId: strin
981
1068
  await insertActionEdges(trx, edges);
982
1069
  }
983
1070
 
1071
+ function formatActionOnWrite(action: AutomationAction): AutomationAction {
1072
+ if (action.type !== 'send_email') {
1073
+ return action;
1074
+ }
1075
+
1076
+ return {
1077
+ ...action,
1078
+ data: {
1079
+ ...action.data,
1080
+ email_lexical: urlUtils.lexicalToTransformReady(action.data.email_lexical, {
1081
+ nodes: lexicalLib.nodes,
1082
+ transformMap: lexicalLib.urlTransformMap
1083
+ })
1084
+ }
1085
+ };
1086
+ }
1087
+
984
1088
  async function resolveEmailDesignSettingIds(trx: Knex.Transaction, actions: ReadonlyArray<AutomationAction>): Promise<AutomationAction[]> {
985
1089
  if (!actions.some(action => action.type === 'send_email' && action.data.email_design_setting_id === DEFAULT_EMAIL_DESIGN_SETTING_REFERENCE)) {
986
1090
  return [...actions];
@@ -1264,6 +1368,7 @@ async function loadActionStats(
1264
1368
  const rows: ActionStatsRow[] = await trx('automation_action_revisions')
1265
1369
  .select('action_id')
1266
1370
  .sum({
1371
+ email_clicked_count: 'email_clicked_count',
1267
1372
  email_sent_count: 'email_sent_count',
1268
1373
  email_opened_count: 'email_opened_count'
1269
1374
  })
@@ -1310,7 +1415,7 @@ function buildActionPayload(row: ActionRow, stats: AutomationEmailStats | null):
1310
1415
  type: 'send_email',
1311
1416
  data: {
1312
1417
  email_subject: requireValue(row, 'email_subject'),
1313
- email_lexical: requireValue(row, 'email_lexical'),
1418
+ email_lexical: urlUtils.transformReadyToAbsolute(requireValue(row, 'email_lexical')),
1314
1419
  email_design_setting_id: requireValue(row, 'email_design_setting_id')
1315
1420
  },
1316
1421
  stats: stats ?? EMPTY_EMAIL_STATS
@@ -1319,6 +1424,7 @@ function buildActionPayload(row: ActionRow, stats: AutomationEmailStats | null):
1319
1424
  }
1320
1425
 
1321
1426
  const EMPTY_EMAIL_STATS: AutomationEmailStats = {
1427
+ email_clicked_count: 0,
1322
1428
  email_sent_count: 0,
1323
1429
  email_opened_count: 0,
1324
1430
  opened_rate: null,
@@ -1326,16 +1432,19 @@ const EMPTY_EMAIL_STATS: AutomationEmailStats = {
1326
1432
  };
1327
1433
 
1328
1434
  function buildEmailStats(row: ActionStatsRow): AutomationEmailStats {
1435
+ const emailClickedCount = row.email_clicked_count ?? 0;
1329
1436
  const emailSentCount = row.email_sent_count ?? 0;
1330
1437
  const emailOpenedCount = row.email_opened_count ?? 0;
1331
1438
  return {
1439
+ email_clicked_count: emailClickedCount,
1332
1440
  email_sent_count: emailSentCount,
1333
1441
  email_opened_count: emailOpenedCount,
1334
1442
  opened_rate: emailSentCount
1335
1443
  ? Math.round(emailOpenedCount / emailSentCount * 100)
1336
1444
  : null,
1337
- // TODO(NY-1387) Populate clicked_rate once click tracking is implemented.
1338
- clicked_rate: null
1445
+ clicked_rate: emailSentCount
1446
+ ? Math.round(emailClickedCount / emailSentCount * 100)
1447
+ : null
1339
1448
  };
1340
1449
  }
1341
1450
 
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.toDatabaseDate = void 0;
7
+ const moment_1 = __importDefault(require("moment"));
8
+ const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
9
+ const toDatabaseDate = (date) => (0, moment_1.default)(date).format(DATABASE_DATE_FORMAT);
10
+ exports.toDatabaseDate = toDatabaseDate;
@@ -0,0 +1,5 @@
1
+ import moment from 'moment';
2
+
3
+ const DATABASE_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss';
4
+
5
+ export const toDatabaseDate = (date: Date | string): string => moment(date).format(DATABASE_DATE_FORMAT);
@@ -12,6 +12,7 @@ const constants_2 = require("./constants");
12
12
  // @ts-expect-error Models currently lack type definitions.
13
13
  const models_1 = require("../../models");
14
14
  const settingsCache = require('../../../shared/settings-cache');
15
+ const labs = require('../../../shared/labs');
15
16
  const slugToMemberStatus = new Map(Object.entries(constants_1.MEMBER_WELCOME_EMAIL_SLUGS).map(([status, slug]) => [slug, status]));
16
17
  const hasUpdatesAndAnnouncementsEnabled = (member) => {
17
18
  const preference = member.get('enable_updates_and_announcements');
@@ -112,7 +113,7 @@ const processStep = async ({ automationsApi, memberWelcomeEmailService, schedule
112
113
  break;
113
114
  }
114
115
  memberWelcomeEmailService.init();
115
- const trackClicks = Boolean(settingsCache.get('email_track_clicks'));
116
+ const trackClicks = labs.isSet('automationAnalytics') && Boolean(settingsCache.get('email_track_clicks'));
116
117
  const trackOpens = Boolean(settingsCache.get('email_track_opens'));
117
118
  const sendResult = await memberWelcomeEmailService.api.sendAutomationEmail({
118
119
  email: {
@@ -126,7 +127,9 @@ const processStep = async ({ automationsApi, memberWelcomeEmailService, schedule
126
127
  uuid: member.get('uuid')
127
128
  },
128
129
  memberStatus,
129
- trackOpens
130
+ trackOpens,
131
+ trackClicks,
132
+ automationActionRevisionId: step.automation_action_revision_id
130
133
  });
131
134
  const mailgunMessageId = (0, mailgun_message_id_1.getMailgunMessageId)(sendResult);
132
135
  // Only Mailgun sends can produce open events for automation emails
@@ -8,6 +8,7 @@ import {MAX_ATTEMPTS, MAX_STEPS_PER_BATCH, RETRY_DELAY_MS} from './constants';
8
8
  import {Member} from '../../models';
9
9
 
10
10
  const settingsCache = require('../../../shared/settings-cache');
11
+ const labs = require('../../../shared/labs');
11
12
 
12
13
  type MemberWelcomeEmailService = {
13
14
  init: () => unknown;
@@ -25,6 +26,8 @@ type MemberWelcomeEmailService = {
25
26
  };
26
27
  memberStatus: 'free' | 'paid';
27
28
  trackOpens: boolean;
29
+ trackClicks: boolean;
30
+ automationActionRevisionId: string;
28
31
  }) => Promise<unknown>;
29
32
  };
30
33
  };
@@ -183,7 +186,7 @@ const processStep = async ({
183
186
  break;
184
187
  }
185
188
  memberWelcomeEmailService.init();
186
- const trackClicks = Boolean(settingsCache.get('email_track_clicks'));
189
+ const trackClicks = labs.isSet('automationAnalytics') && Boolean(settingsCache.get('email_track_clicks'));
187
190
  const trackOpens = Boolean(settingsCache.get('email_track_opens'));
188
191
  const sendResult = await memberWelcomeEmailService.api.sendAutomationEmail({
189
192
  email: {
@@ -197,7 +200,9 @@ const processStep = async ({
197
200
  uuid: member.get('uuid')
198
201
  },
199
202
  memberStatus,
200
- trackOpens
203
+ trackOpens,
204
+ trackClicks,
205
+ automationActionRevisionId: step.automation_action_revision_id
201
206
  });
202
207
  const mailgunMessageId = getMailgunMessageId(sendResult);
203
208
  // Only Mailgun sends can produce open events for automation emails
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSchedulerPollTime = void 0;
4
+ const SCHEDULER_POLL_INTERVAL_MS = 15 * 60 * 1000;
5
+ /**
6
+ * An implementation of the [FNV-1a hash][0].
7
+ *
8
+ * We need something that generates per-site jitter within a 15-minute
9
+ * interval. We want a 32-bit number which we can modulo at the end.
10
+ *
11
+ * FNV-1a is good for this purpose because:
12
+ *
13
+ * - it is simple
14
+ * - it generates a 32-bit number
15
+ * - it is [more efficient than CRC32, MD5, and SHA][1], using less CPU and RAM
16
+ *
17
+ * This implementation assumes ASCII strings, which we expect callers to always
18
+ * use. If that assumption is ever violated, we'll compute incorrect jitter,
19
+ * which is acceptable.
20
+ *
21
+ * [0]: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV-1a_hash
22
+ * [1]: https://gist.github.com/EvanHahn/20d845c8157691d24977831159487950
23
+ */
24
+ const fnv1a32 = (asciiStr) => {
25
+ let hash = 0x811c9dc5;
26
+ for (let i = 0; i < asciiStr.length; i++) {
27
+ hash ^= asciiStr.charCodeAt(i);
28
+ hash = Math.imul(hash, 0x01000193);
29
+ }
30
+ return hash >>> 0;
31
+ };
32
+ /**
33
+ * Round the time up to a 15-minute interval with site-specific jitter.
34
+ */
35
+ const getSchedulerPollTime = (date, siteIdentifier) => {
36
+ const siteOffset = fnv1a32(siteIdentifier) % SCHEDULER_POLL_INTERVAL_MS;
37
+ const interval = Math.ceil((date.getTime() - siteOffset) / SCHEDULER_POLL_INTERVAL_MS);
38
+ return new Date(interval * SCHEDULER_POLL_INTERVAL_MS + siteOffset);
39
+ };
40
+ exports.getSchedulerPollTime = getSchedulerPollTime;