ghost 6.54.1 → 6.56.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 (356) hide show
  1. package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
  2. package/components/tryghost-custom-field-types-0.0.0.tgz +0 -0
  3. package/components/tryghost-kg-default-nodes-2.1.4.tgz +0 -0
  4. package/components/tryghost-kg-default-transforms-1.3.3.tgz +0 -0
  5. package/components/tryghost-kg-html-to-lexical-1.3.3.tgz +0 -0
  6. package/components/tryghost-kg-lexical-html-renderer-1.4.3.tgz +0 -0
  7. package/components/tryghost-nql-string-0.0.0.tgz +0 -0
  8. package/core/boot.js +3 -6
  9. package/core/built/admin/assets/{Combination-CJG7Flp-.js → Combination-BCm9FL4o.js} +1 -1
  10. package/core/built/admin/assets/{PolarAngleAxis-BGjf7sBu.js → PolarAngleAxis-C4qtHy1B.js} +1 -1
  11. package/core/built/admin/assets/{RadialBarChart-zeFf9mBz.js → RadialBarChart-Bh09Stsj.js} +1 -1
  12. package/core/built/admin/assets/{account-migration-BklF2I_I.js → account-migration-D3O_yVck.js} +1 -1
  13. package/core/built/admin/assets/activitypub/{account-migration-CNovr6W-.js → account-migration-D8FbP-Z-.js} +7 -7
  14. package/core/built/admin/assets/activitypub/activitypub.js +6 -6
  15. package/core/built/admin/assets/activitypub/{avatar-B3DEDB1l.js → avatar-11EA1VSs.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{avatar-flipboard-Cqrg0XMx.js → avatar-flipboard-jfbLG-xe.js} +2 -2
  17. package/core/built/admin/assets/activitypub/{bluesky-sharing-Ceslb1_M.js → bluesky-sharing-f1jTQe2P.js} +16 -16
  18. package/core/built/admin/assets/activitypub/{content-formatters-BpQARh28.js → content-formatters-nmTOPY9M.js} +3 -3
  19. package/core/built/admin/assets/activitypub/{copy-D9UWIAan.js → copy-BCwo9ep3.js} +2 -2
  20. package/core/built/admin/assets/activitypub/{dist-DHAjBcV3.js → dist-BEfX-0aW.js} +12 -12
  21. package/core/built/admin/assets/activitypub/{domain-C1qQvJ8C.js → domain-AaDlZlrA.js} +9 -9
  22. package/core/built/admin/assets/activitypub/{edit-profile-BcH-jEfM.js → edit-profile-DPi8Tl5M.js} +945 -945
  23. package/core/built/admin/assets/activitypub/{explore-AxwBh9dI.js → explore-CxTzjILQ.js} +8 -8
  24. package/core/built/admin/assets/activitypub/{feed-BaOlbh7a.js → feed-DPv5x2MX.js} +33 -33
  25. package/core/built/admin/assets/activitypub/{field-B7mYYQpN.js → field-DbgA8lat.js} +3 -3
  26. package/core/built/admin/assets/activitypub/{hash-BC9Q0N4c.js → hash-BWuZoYkA.js} +2 -2
  27. package/core/built/admin/assets/activitypub/{inbox-DQ4ZnC1m.js → inbox-35VDQ2K2.js} +2 -2
  28. package/core/built/admin/assets/activitypub/{inbox-vXez-5uJ.js → inbox-DSrF_i1O.js} +85 -85
  29. package/core/built/admin/assets/activitypub/{label-Chv5T8bw.js → label-CPjFBbo7.js} +3 -3
  30. package/core/built/admin/assets/activitypub/layout-CTgrezN7.js +1 -0
  31. package/core/built/admin/assets/activitypub/{moderation-DtSM2i1I.js → moderation-BkDC1XVZ.js} +11 -11
  32. package/core/built/admin/assets/activitypub/{note-Dckp4S-2.js → note-xEyH4xT-.js} +7 -7
  33. package/core/built/admin/assets/activitypub/{notifications-CG5T22vg.js → notifications-DClaL3ui.js} +9 -9
  34. package/core/built/admin/assets/activitypub/{preferences-D_799jdl.js → preferences-DpnOa-4Z.js} +20 -20
  35. package/core/built/admin/assets/activitypub/{profile-D2JfXBQl.js → profile-Dvi9mz-6.js} +237 -237
  36. package/core/built/admin/assets/activitypub/{reply-CpdMeAO5.js → reply-Cs9ySd6Z.js} +2 -2
  37. package/core/built/admin/assets/activitypub/{routes-CCHe2dQp.js → routes-DFBeTnfn.js} +1139 -1150
  38. package/core/built/admin/assets/activitypub/{separator-BI1FLsYM.js → separator-C5u_VjzY.js} +3 -3
  39. package/core/built/admin/assets/activitypub/{settings-kx-9Enkz.js → settings-CV2fFu6D.js} +13 -13
  40. package/core/built/admin/assets/activitypub/{site-x4_IjVJ0.js → site-_TgDd_EM.js} +81 -81
  41. package/core/built/admin/assets/activitypub/{step-1-DIDg4aHb.js → step-1-BfnNL2oF.js} +5 -5
  42. package/core/built/admin/assets/activitypub/{step-2-BfGe8BYt.js → step-2-DLndZkO-.js} +6 -6
  43. package/core/built/admin/assets/activitypub/{step-3-mE9spAqw.js → step-3-A-PZTtRE.js} +10 -10
  44. package/core/built/admin/assets/activitypub/{tabs-CyPRQQ1N.js → tabs-BUbz7tea.js} +321 -319
  45. package/core/built/admin/assets/activitypub/{topic-filter-DLHBZT93.js → topic-filter-BdX_xYYT.js} +3 -3
  46. package/core/built/admin/assets/activitypub/{use-activity-pub-queries-VzbN99u_.js → use-activity-pub-queries-EwuRemHh.js} +2 -2
  47. package/core/built/admin/assets/activitypub/{use-navigate-with-base-path-CAwXTmqH.js → use-navigate-with-base-path-cy4ACBc4.js} +92 -90
  48. package/core/built/admin/assets/activitypub/{use-reply-chain-data-BAB6fNGt.js → use-reply-chain-data-CyFcGfan.js} +5 -5
  49. package/core/built/admin/assets/activitypub/{x-C8tAnXly.js → x-7zk8mV2p.js} +2 -2
  50. package/core/built/admin/assets/audience-CkBb2LlO.js +1 -0
  51. package/core/built/admin/assets/{automated-emails-of1MvVsv.js → automated-emails-6t2bIRWf.js} +2 -2
  52. package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
  53. package/core/built/admin/assets/{automations-DRFjW5fw.js → automations-cMllU545.js} +1 -1
  54. package/core/built/admin/assets/{bluesky-sharing-BbbjWdzs.js → bluesky-sharing-DPGpyD-G.js} +1 -1
  55. package/core/built/admin/assets/{box-BWbhXh21.js → box-By_EgGtm.js} +1 -1
  56. package/core/built/admin/assets/{breadcrumb-B-M9Ip_2.js → breadcrumb-DIfcF5ow.js} +1 -1
  57. package/core/built/admin/assets/build-CsiH26uS.js +1 -0
  58. package/core/built/admin/assets/{card-DGoDJzrn.js → card-aUHX0hyR.js} +1 -1
  59. package/core/built/admin/assets/{chart-BuRT2Y6p.js → chart-BnXyfH11.js} +1 -1
  60. package/core/built/admin/assets/{chart-helpers-xiSsW8r0.js → chart-helpers-DV0FkdcD.js} +1 -1
  61. package/core/built/admin/assets/{checkbox-EhJKHFdZ.js → checkbox-BIl077a0.js} +1 -1
  62. package/core/built/admin/assets/{chunk.361.3cdb584b973959e52ccc.js → chunk.332.05991a101d67f732dec3.js} +16 -12
  63. package/core/built/admin/assets/chunk.524.87b52f4fada529df2c4c.js +25 -0
  64. package/core/built/admin/assets/chunk.582.340bcca873e806cbd4bb.js +27 -0
  65. package/core/built/admin/assets/{code-editor-view-CZtJFGhR.js → code-editor-view-C0aWNwdl.js} +1 -1
  66. package/core/built/admin/assets/comments-qwhIjH3c.js +1 -0
  67. package/core/built/admin/assets/{container-BYz2Ed39.js → container-LiZf3XdY.js} +1 -1
  68. package/core/built/admin/assets/{data-list-16rkJYYi.js → data-list-CupF7bjM.js} +1 -1
  69. package/core/built/admin/assets/{date-range-select-CTlyo8zW.js → date-range-select-dXc-GsVe.js} +1 -1
  70. package/core/built/admin/assets/{disabled-sources-indicator-qs2GfLvl.js → disabled-sources-indicator-DYAXW-6V.js} +1 -1
  71. package/core/built/admin/assets/dist-BUKmD51u.js +1 -0
  72. package/core/built/admin/assets/{dist-2Tnz2Yfq.js → dist-CNlMj7ML.js} +1 -1
  73. package/core/built/admin/assets/{dist-DGzPJ_bO.js → dist-Cb5RSHSg.js} +1 -1
  74. package/core/built/admin/assets/{dist-P_ohUlTi.js → dist-CqO2lWS0.js} +1 -1
  75. package/core/built/admin/assets/{domain-CPNXKVOV.js → domain-B5ogXUBa.js} +1 -1
  76. package/core/built/admin/assets/{dropzone-BmCXnw6J.js → dropzone-BnFG7dDP.js} +1 -1
  77. package/core/built/admin/assets/{edit-profile-SGrJve5M.js → edit-profile-B07jCZy9.js} +1 -1
  78. package/core/built/admin/assets/editor-DDYSOXGN.js +8 -0
  79. package/core/built/admin/assets/{empty-indicator-oX1u1Pkn.js → empty-indicator-WB1WgT9h.js} +1 -1
  80. package/core/built/admin/assets/{explore-CUYLv9G9.js → explore-Ca1y8HW_.js} +1 -1
  81. package/core/built/admin/assets/{feed-CPdJMttL.js → feed-x4iFMBP4.js} +1 -1
  82. package/core/built/admin/assets/field-ChCAabZg.js +1 -0
  83. package/core/built/admin/assets/filter-sources-LdtH1e-T.js +1 -0
  84. package/core/built/admin/assets/{filters-bXkkWro8.js → filters-CLehPo_D.js} +1 -1
  85. package/core/built/admin/assets/filters-CjVKRP8U.js +1 -0
  86. package/core/built/admin/assets/{ghost-05f9a7182ed56903dba05878a5b8422b.js → ghost-7f01131115186d1af84ead76e79646dd.js} +275 -255
  87. package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-d38c30b866e59f2e06a357b5eb844fe5.css} +1 -1
  88. package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-1bffd5099ba7a749303133efacf8480b.css} +1 -1
  89. package/core/built/admin/assets/gift-link-modal-Buqh2vXh.js +1 -0
  90. package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
  91. package/core/built/admin/assets/growth-DajOBdN0.js +1 -0
  92. package/core/built/admin/assets/growth-Dy2UOSay.js +3 -0
  93. package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
  94. package/core/built/admin/assets/{inbox-Xx1CdnfO.js → inbox-BUOLDoji.js} +13 -13
  95. package/core/built/admin/assets/index-08n05-rc.js +67 -0
  96. package/core/built/admin/assets/index-DgUzgFvS.css +1 -0
  97. package/core/built/admin/assets/{inline-EMAm3okk.js → inline-Cw-gigDW.js} +1 -1
  98. package/core/built/admin/assets/input-group-uSK3XT7b.js +1 -0
  99. package/core/built/admin/assets/{input-surface-BpAjZXY5.js → input-surface-BdOe4KzO.js} +1 -1
  100. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +9132 -9087
  101. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +121 -121
  102. package/core/built/admin/assets/koenig-lexical/style.css +1 -1
  103. package/core/built/admin/assets/{koenig-lexical-B61IALf5.js → koenig-lexical-BFc4yFed.js} +82 -82
  104. package/core/built/admin/assets/{kpi-card-DCdlyaSS.js → kpi-card-BN-E6WYX.js} +1 -1
  105. package/core/built/admin/assets/{kpi-card-BlZaG1Lo.js → kpi-card-CCt-qeWD.js} +1 -1
  106. package/core/built/admin/assets/{kpi-helpers-DpAuS1Nb.js → kpi-helpers-DlxTbTs4.js} +1 -1
  107. package/core/built/admin/assets/{kpi-tabs-Dme44bQS.js → kpi-tabs-BlcUttpn.js} +1 -1
  108. package/core/built/admin/assets/{label-dSs5aepE.js → label-AVrxwmsz.js} +1 -1
  109. package/core/built/admin/assets/label-picker-DVnpu_IF.js +1 -0
  110. package/core/built/admin/assets/labels-DAIqKed7.js +1 -0
  111. package/core/built/admin/assets/layout-B0YLh7R9.js +1 -0
  112. package/core/built/admin/assets/link-helpers-D9LsDOkG.js +1 -0
  113. package/core/built/admin/assets/{list-page-B6ANtzkd.js → list-page-CdsnQR9N.js} +1 -1
  114. package/core/built/admin/assets/member-custom-fields-C8NpZYza.js +1 -0
  115. package/core/built/admin/assets/member-detail-CIVAdKXf.js +1 -0
  116. package/core/built/admin/assets/members-oLPiSkXD.js +10 -0
  117. package/core/built/admin/assets/modals-CB7v-k3T.js +31 -0
  118. package/core/built/admin/assets/moderation-CQ3urTFE.js +1 -0
  119. package/core/built/admin/assets/newsletter-rbeDX9Wk.js +1 -0
  120. package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
  121. package/core/built/admin/assets/newsletters-vt3Ni1Ov.js +1 -0
  122. package/core/built/admin/assets/note-Dk877BTv.js +1 -0
  123. package/core/built/admin/assets/notifications-CPlLjPxO.js +1 -0
  124. package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
  125. package/core/built/admin/assets/onboarding-route-DdStOtjy.js +1 -0
  126. package/core/built/admin/assets/overview--4V-kx9c.js +1 -0
  127. package/core/built/admin/assets/overview-DSLv30oz.js +1 -0
  128. package/core/built/admin/assets/{page-header-op0eN36I.js → page-header-pp4dWmOS.js} +1 -1
  129. package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
  130. package/core/built/admin/assets/{post-analytics-WwRDEnrV.js → post-analytics-BMJrI81R.js} +1 -1
  131. package/core/built/admin/assets/post-analytics-header-pU2m9ZuV.js +1 -0
  132. package/core/built/admin/assets/post-analytics-provider-Btml5vJI.js +1 -0
  133. package/core/built/admin/assets/{post-share-modal-Dx-MEjhI.js → post-share-modal-AcUZkbfL.js} +1 -1
  134. package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
  135. package/core/built/admin/assets/preferences-Ru4CRPHC.js +5 -0
  136. package/core/built/admin/assets/profile-BNb1oj7D.js +1 -0
  137. package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
  138. package/core/built/admin/assets/{router-provider-C1R4AZ2o.js → router-provider-BmEZf6c6.js} +1 -1
  139. package/core/built/admin/assets/select-B0HteHrp.js +1 -0
  140. package/core/built/admin/assets/settings-B1C9Q3wo.js +4 -0
  141. package/core/built/admin/assets/{settings-H6HxFgfI.js → settings-BGgk7oCI.js} +1 -1
  142. package/core/built/admin/assets/{shade-app-B3heOAfz-CGXQwWeO.js → shade-app-B3heOAfz-BhQXxNkT.js} +1 -1
  143. package/core/built/admin/assets/{share-modal-BJCadJyp.js → share-modal-BeuydaBf.js} +1 -1
  144. package/core/built/admin/assets/social-urls-DA2lXrhW.js +8 -0
  145. package/core/built/admin/assets/{sort-button-DyYlfYp7.js → sort-button-Df4t2AL8.js} +1 -1
  146. package/core/built/admin/assets/{source-icon-_ubRDbg_.js → source-icon-ajoaKb41.js} +1 -1
  147. package/core/built/admin/assets/{stack-xiN0eQ6Z.js → stack-CIfOAAVf.js} +1 -1
  148. package/core/built/admin/assets/{stats-BQZMGMvn.js → stats-C_lfxZAw.js} +1 -1
  149. package/core/built/admin/assets/{stats-view-Doei-qCX.js → stats-view-Cxot1iD8.js} +1 -1
  150. package/core/built/admin/assets/{step-1-IKiLBPoC.js → step-1-ogAA4lDr.js} +1 -1
  151. package/core/built/admin/assets/{step-2-jJ41PsDN.js → step-2-C5Xini-7.js} +1 -1
  152. package/core/built/admin/assets/{step-3-CaqFAEqf.js → step-3-CZv1FV81.js} +2 -2
  153. package/core/built/admin/assets/{switch-t-WyENU2.js → switch-CyQcwMAv.js} +1 -1
  154. package/core/built/admin/assets/{table-DxUL78jU.js → table-DFj1wiYv.js} +1 -1
  155. package/core/built/admin/assets/tabs-D4y1gqUd.js +1 -0
  156. package/core/built/admin/assets/{tags-CNujGwkT.js → tags-Dmft-Y4O.js} +1 -1
  157. package/core/built/admin/assets/{text-DwtjwGkh.js → text-DU9Hv33_.js} +1 -1
  158. package/core/built/admin/assets/textarea-d9qEkMOP.js +1 -0
  159. package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
  160. package/core/built/admin/assets/{toggle-group-BeEpQ69p.js → toggle-group-R2QMFvXW.js} +1 -1
  161. package/core/built/admin/assets/{topic-filter-Bb5MOD-1.js → topic-filter-DGDxDalo.js} +1 -1
  162. package/core/built/admin/assets/{use-analytics-data-Cds6XDjg.js → use-analytics-data-C76v4LIp.js} +1 -1
  163. package/core/built/admin/assets/use-filter-params-WrdhAISv.js +1 -0
  164. package/core/built/admin/assets/{use-growth-stats-D8uf98-G.js → use-growth-stats-aNXJOmQr.js} +1 -1
  165. package/core/built/admin/assets/{use-post-referrers-CRtgyuXB.js → use-post-referrers-CG0SZioj.js} +1 -1
  166. package/core/built/admin/assets/{use-reply-chain-data-DbKYX50v.js → use-reply-chain-data-RLdPKRmS.js} +1 -1
  167. package/core/built/admin/assets/{use-simple-pagination-DC01kpo0.js → use-simple-pagination-DgcF69KI.js} +1 -1
  168. package/core/built/admin/assets/{use-tinybird-query-C7bQRJWw.js → use-tinybird-query-C1R_Kbmy.js} +1 -1
  169. package/core/built/admin/assets/{use-tinybird-token-Az4tam7_.js → use-tinybird-token-C9fPjVM_.js} +1 -1
  170. package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
  171. package/core/built/admin/assets/{virtual-list-Dhuh9fGx.js → virtual-list-BmTUzeoF.js} +1 -1
  172. package/core/built/admin/assets/web-BqW0b0_C.js +1 -0
  173. package/core/built/admin/assets/web-Cy5GKMWM.js +2 -0
  174. package/core/built/admin/index.html +18 -18
  175. package/core/frontend/services/data/fetch-data.js +10 -5
  176. package/core/frontend/services/proxy.js +7 -8
  177. package/core/frontend/services/routing/api-adapter.js +115 -0
  178. package/core/frontend/services/routing/api-adapter.ts +157 -0
  179. package/core/frontend/services/routing/collection-router.js +5 -3
  180. package/core/frontend/services/routing/controllers/index.js +1 -1
  181. package/core/frontend/services/routing/controllers/rss.js +26 -31
  182. package/core/frontend/services/routing/controllers/rss.ts +66 -0
  183. package/core/frontend/services/routing/controllers/static.js +8 -7
  184. package/core/frontend/services/routing/parent-router.js +17 -4
  185. package/core/frontend/services/routing/permalink-adapter.js +26 -0
  186. package/core/frontend/services/routing/permalink-adapter.ts +23 -0
  187. package/core/frontend/services/routing/registry.js +59 -33
  188. package/core/frontend/services/routing/router-manager.js +14 -18
  189. package/core/frontend/services/routing/static-routes-router.js +11 -13
  190. package/core/frontend/services/routing/taxonomy-router.js +6 -2
  191. package/core/server/api/endpoints/automation-action-links.js +68 -0
  192. package/core/server/api/endpoints/automation-action-links.ts +46 -0
  193. package/core/server/api/endpoints/index.js +4 -0
  194. package/core/server/api/endpoints/settings.js +0 -2
  195. package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
  196. package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
  197. package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
  198. package/core/server/data/migrations/versions/6.56/2026-08-03-19-27-51-add-automation-run-step-id-to-automated-email-recipients.js +8 -0
  199. package/core/server/data/migrations/versions/6.56/2026-08-04-14-42-39-restrict-notification-add-permission-to-administrators.js +12 -0
  200. package/core/server/data/schema/commands.js +5 -0
  201. package/core/server/data/schema/default-settings/default-settings.json +0 -4
  202. package/core/server/data/schema/fixtures/fixtures.json +2 -2
  203. package/core/server/data/schema/schema.js +6 -1
  204. package/core/server/models/post.js +10 -1
  205. package/core/server/services/automations/automations-api.js +15 -0
  206. package/core/server/services/automations/automations-api.ts +19 -0
  207. package/core/server/services/automations/automations-repository.ts +20 -0
  208. package/core/server/services/automations/database-automations-repository.js +132 -30
  209. package/core/server/services/automations/database-automations-repository.ts +141 -12
  210. package/core/server/services/automations/database-date.js +10 -0
  211. package/core/server/services/automations/database-date.ts +5 -0
  212. package/core/server/services/automations/get-scheduler-idempotency-key.js +13 -0
  213. package/core/server/services/automations/get-scheduler-idempotency-key.ts +8 -0
  214. package/core/server/services/automations/poll.js +7 -2
  215. package/core/server/services/automations/poll.ts +10 -2
  216. package/core/server/services/automations/scheduler-poll-time.js +40 -0
  217. package/core/server/services/automations/scheduler-poll-time.ts +38 -0
  218. package/core/server/services/automations/service.js +19 -3
  219. package/core/server/services/automations/service.ts +20 -3
  220. package/core/server/services/comments/comments-service-emails.js +10 -7
  221. package/core/server/services/comments/comments-service.js +9 -4
  222. package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
  223. package/core/server/services/email-service/email-service-wrapper.js +6 -1
  224. package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
  225. package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
  226. package/core/server/services/gift-links/CONTEXT.md +9 -0
  227. package/core/server/services/gifts/CONTEXT.md +55 -0
  228. package/core/server/services/gifts/README.md +28 -0
  229. package/core/server/services/gifts/gift-bookshelf-repository.js +65 -45
  230. package/core/server/services/gifts/gift-bookshelf-repository.ts +203 -74
  231. package/core/server/services/gifts/gift-checkout-adapter.js +59 -0
  232. package/core/server/services/gifts/gift-checkout-offer.js +74 -0
  233. package/core/server/services/gifts/gift-checkout-offer.ts +127 -0
  234. package/core/server/services/gifts/gift-codec.js +63 -0
  235. package/core/server/services/gifts/gift-codec.ts +62 -0
  236. package/core/server/services/gifts/gift-controller.js +6 -31
  237. package/core/server/services/gifts/gift-controller.ts +6 -69
  238. package/core/server/services/gifts/gift-email-service.ts +3 -2
  239. package/core/server/services/gifts/gift-schema.js +34 -0
  240. package/core/server/services/gifts/gift-schema.ts +39 -0
  241. package/core/server/services/gifts/gift-service-wrapper.js +13 -11
  242. package/core/server/services/gifts/gift-service.js +265 -52
  243. package/core/server/services/gifts/gift-service.ts +431 -97
  244. package/core/server/services/gifts/gift.ts +16 -39
  245. package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
  246. package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
  247. package/core/server/services/link-redirection/link-redirect.js +2 -0
  248. package/core/server/services/link-redirection/link-redirect.ts +3 -1
  249. package/core/server/services/link-redirection/link-redirects-service.js +38 -2
  250. package/core/server/services/link-tracking/click-event.js +3 -0
  251. package/core/server/services/link-tracking/index.js +38 -6
  252. package/core/server/services/link-tracking/link-click-repository.js +35 -9
  253. package/core/server/services/link-tracking/link-click-tracking-service.js +54 -4
  254. package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
  255. package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +20 -4
  256. package/core/server/services/member-welcome-emails/service.js +15 -3
  257. package/core/server/services/members/api.js +1 -2
  258. package/core/server/services/members/import-export/csv/formula.js +16 -0
  259. package/core/server/services/members/import-export/csv/formula.ts +14 -0
  260. package/core/server/services/members/import-export/csv/index.js +3 -1
  261. package/core/server/services/members/import-export/csv/index.ts +1 -0
  262. package/core/server/services/members/import-export/import/completion-email.js +22 -3
  263. package/core/server/services/members/import-export/import/completion-email.ts +29 -7
  264. package/core/server/services/members/import-export/import/importer.js +25 -4
  265. package/core/server/services/members/import-export/import/importer.ts +44 -5
  266. package/core/server/services/members/import-export/index.js +18 -1
  267. package/core/server/services/members/import-export/index.ts +32 -3
  268. package/core/server/services/members/members-api/controllers/router-controller.js +38 -36
  269. package/core/server/services/members/members-api/members-api.js +13 -7
  270. package/core/server/services/members/members-api/repositories/event-repository.js +4 -94
  271. package/core/server/services/members/members-api/services/member-bread-service.js +3 -3
  272. package/core/server/services/members/members-api/services/payments-service.js +6 -68
  273. package/core/server/services/members/service.js +8 -1
  274. package/core/server/services/members-custom-fields/schema.js +8 -1
  275. package/core/server/services/members-custom-fields/schema.ts +8 -0
  276. package/core/server/services/members-custom-fields/values-service.js +136 -8
  277. package/core/server/services/members-custom-fields/values-service.ts +152 -10
  278. package/core/server/services/mentions/service.js +5 -1
  279. package/core/server/services/oembed/oembed-service.js +44 -3
  280. package/core/server/services/posts/posts-service.js +11 -0
  281. package/core/server/services/route-settings/dynamic-routing-service.js +21 -52
  282. package/core/server/services/route-settings/index.js +1 -16
  283. package/core/server/services/route-settings/route-settings-parser.js +25 -25
  284. package/core/server/services/route-settings/route-settings-parser.ts +27 -27
  285. package/core/server/services/route-settings/validation-errors.js +68 -4
  286. package/core/server/services/route-settings/validation-errors.ts +79 -4
  287. package/core/server/services/settings/settings-service.js +0 -18
  288. package/core/server/services/stats/posts-stats-service.js +27 -23
  289. package/core/server/services/stripe/services/webhook/charge-refunded-event-service.js +1 -1
  290. package/core/server/services/stripe/services/webhook/checkout-session-event-service.js +16 -4
  291. package/core/server/services/stripe/stripe-api.js +10 -0
  292. package/core/server/services/url/url-service-facade.js +1 -1
  293. package/core/server/services/url/url-service-facade.ts +8 -1
  294. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  295. package/core/server/web/gift-preview/controller.js +10 -25
  296. package/core/shared/labs.js +2 -2
  297. package/package.json +8 -7
  298. package/pnpm-lock.yaml +28 -18
  299. package/pnpm-workspace.yaml +6 -4
  300. package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
  301. package/core/built/admin/assets/Growth-BTP1LthE.js +0 -3
  302. package/core/built/admin/assets/Newsletters-CSEX0myc.js +0 -1
  303. package/core/built/admin/assets/Overview-BjACEsJF.js +0 -1
  304. package/core/built/admin/assets/Web-CxxxjCcv.js +0 -2
  305. package/core/built/admin/assets/activitypub/layout-CziktJXo.js +0 -1
  306. package/core/built/admin/assets/audience-X_XsZivI.js +0 -1
  307. package/core/built/admin/assets/chunk.524.8365cd9280405c0f6216.js +0 -25
  308. package/core/built/admin/assets/chunk.582.61d078937e6102769f2c.js +0 -27
  309. package/core/built/admin/assets/comments-CvyXxOyN.js +0 -1
  310. package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
  311. package/core/built/admin/assets/dist-CjY-f5Z1.js +0 -1
  312. package/core/built/admin/assets/editor-BOG2YPUm.js +0 -8
  313. package/core/built/admin/assets/field-D0XISCK2.js +0 -1
  314. package/core/built/admin/assets/filter-sources-Bieps75g.js +0 -1
  315. package/core/built/admin/assets/filters-CuB-A7ma.js +0 -1
  316. package/core/built/admin/assets/gift-link-modal-su8MsRdg.js +0 -1
  317. package/core/built/admin/assets/growth-Ba6S5FQX.js +0 -1
  318. package/core/built/admin/assets/index-D8PbzuT9.css +0 -1
  319. package/core/built/admin/assets/index-ukW_0Uie.js +0 -67
  320. package/core/built/admin/assets/input-group-CGOXcy4l.js +0 -1
  321. package/core/built/admin/assets/label-picker-Uq91atex.js +0 -1
  322. package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
  323. package/core/built/admin/assets/layout-WUcz6tu1.js +0 -1
  324. package/core/built/admin/assets/link-helpers-tOlxmuH5.js +0 -1
  325. package/core/built/admin/assets/member-custom-fields-NCzrMuba.js +0 -1
  326. package/core/built/admin/assets/member-detail-DPWDtYnN.js +0 -1
  327. package/core/built/admin/assets/members-B9rO7qr4.js +0 -10
  328. package/core/built/admin/assets/modals-CxRaan1J.js +0 -31
  329. package/core/built/admin/assets/moderation-D8zon60E.js +0 -1
  330. package/core/built/admin/assets/multi-select-combobox-1wYm3nle.js +0 -1
  331. package/core/built/admin/assets/newsletter-DbYMRxRS.js +0 -1
  332. package/core/built/admin/assets/note-PiWPnnrh.js +0 -1
  333. package/core/built/admin/assets/notifications-DEgqqB9G.js +0 -1
  334. package/core/built/admin/assets/nql-BsgFMd93.js +0 -1
  335. package/core/built/admin/assets/onboarding-route-BzF6HeVn.js +0 -1
  336. package/core/built/admin/assets/overview-DixLeIqY.js +0 -1
  337. package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +0 -1
  338. package/core/built/admin/assets/post-analytics-provider-BJCPLMIb.js +0 -1
  339. package/core/built/admin/assets/preferences-D7-j6A4C.js +0 -5
  340. package/core/built/admin/assets/profile-Dl69QPp0.js +0 -1
  341. package/core/built/admin/assets/select-C1qDZnW4.js +0 -1
  342. package/core/built/admin/assets/settings-BbmC_Iqq.js +0 -4
  343. package/core/built/admin/assets/social-urls-BPL1nADp.js +0 -8
  344. package/core/built/admin/assets/tabs-BQcJQ1ke.js +0 -1
  345. package/core/built/admin/assets/textarea-Bjccvh_E.js +0 -1
  346. package/core/built/admin/assets/use-filter-params-DjXkhO54.js +0 -1
  347. package/core/built/admin/assets/web-LtHNiNBV.js +0 -1
  348. package/core/server/services/gifts/gift-repository.js +0 -2
  349. package/core/server/services/gifts/gift-repository.ts +0 -29
  350. package/core/server/services/route-settings/activation-bridge.js +0 -164
  351. package/core/server/services/route-settings/activation-bridge.ts +0 -210
  352. package/core/server/services/route-settings/default-settings-manager.js +0 -63
  353. package/core/server/services/route-settings/route-settings.js +0 -170
  354. package/core/server/services/route-settings/settings-loader.js +0 -60
  355. package/core/server/services/route-settings/settings-path-manager.js +0 -47
  356. package/core/server/services/route-settings/validate.js +0 -440
@@ -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;
@@ -70,6 +69,11 @@ type ActionStatsRow = {
70
69
  email_opened_count: number | null;
71
70
  };
72
71
 
72
+ type ActionLinkRow = {
73
+ url: string;
74
+ clicked_count: string | number;
75
+ };
76
+
73
77
  type ActionRevisionRow = {
74
78
  action_id: string;
75
79
  created_at: string;
@@ -166,6 +170,37 @@ export function createDatabaseAutomationsRepository({
166
170
  });
167
171
  },
168
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
+
169
204
  async edit(id: string, data: EditAutomationData): Promise<Automation | null> {
170
205
  return await knex.transaction(async (trx) => {
171
206
  const automation = await loadAutomation(trx, id);
@@ -241,6 +276,7 @@ export function createDatabaseAutomationsRepository({
241
276
  member_email: options.memberEmail,
242
277
  member_name: options.memberName,
243
278
  automation_action_revision_id: options.automationActionRevisionId,
279
+ automation_run_step_id: options.automationRunStepId,
244
280
  ...(options.mailgunMessageId ? {mailgun_message_id: options.mailgunMessageId} : {}),
245
281
  track_clicks: options.trackClicks,
246
282
  track_opens: options.trackOpens,
@@ -266,6 +302,15 @@ export function createDatabaseAutomationsRepository({
266
302
  }
267
303
 
268
304
  await knex.transaction(async (trx) => {
305
+ const revisionIds = new Set<string>();
306
+ for (const {openedAt, automationActionRevisionId} of eventsByAutomatedEmailRecipientId.values()) {
307
+ if (openedAt) {
308
+ revisionIds.add(automationActionRevisionId);
309
+ }
310
+ }
311
+
312
+ const orderedRevisionIds = await lockActionRevisions(trx, revisionIds);
313
+
269
314
  const notYetOpened = await lockNotYetOpened(trx, eventsByAutomatedEmailRecipientId);
270
315
  const newOpensPerRevision = new Map<string, number>();
271
316
 
@@ -292,11 +337,11 @@ export function createDatabaseAutomationsRepository({
292
337
  }
293
338
  }
294
339
 
295
- // Keep lock acquisition order consistent across concurrent transactions to avoid deadlocks.
296
- const revisions = [...newOpensPerRevision.entries()]
297
- .sort(([left], [right]) => left.localeCompare(right));
298
-
299
- for (const [id, opens] of revisions) {
340
+ for (const id of orderedRevisionIds) {
341
+ const opens = newOpensPerRevision.get(id);
342
+ if (!opens) {
343
+ continue;
344
+ }
300
345
  await trx('automation_action_revisions')
301
346
  .where({id})
302
347
  .update({
@@ -304,10 +349,77 @@ export function createDatabaseAutomationsRepository({
304
349
  });
305
350
  }
306
351
  });
352
+ },
353
+
354
+ async trackEmailClicked({automationActionRevisionId, automationRunStepId, memberId, clickedAt}, {transacting} = {}) {
355
+ const trackClick = async (trx: Knex.Transaction) => {
356
+ await lockActionRevisions(trx, [automationActionRevisionId]);
357
+
358
+ const recipient = await trx('automated_email_recipients')
359
+ .select('id', 'clicked_at')
360
+ .where({
361
+ automation_action_revision_id: automationActionRevisionId,
362
+ automation_run_step_id: automationRunStepId,
363
+ member_id: memberId,
364
+ track_clicks: true
365
+ })
366
+ .forUpdate()
367
+ .first();
368
+
369
+ if (!recipient || recipient.clicked_at !== null) {
370
+ return;
371
+ }
372
+
373
+ const updated = await trx('automated_email_recipients')
374
+ .where({id: recipient.id})
375
+ .whereNull('clicked_at')
376
+ .update({clicked_at: clickedAt});
377
+
378
+ if (updated === 0) {
379
+ return;
380
+ }
381
+
382
+ await trx('automation_action_revisions')
383
+ .where({id: automationActionRevisionId})
384
+ .update({
385
+ email_clicked_count: trx.raw('COALESCE(email_clicked_count, 0) + 1')
386
+ });
387
+
388
+ };
389
+
390
+ if (transacting) {
391
+ await trackClick(transacting);
392
+ return;
393
+ }
394
+
395
+ await knex.transaction(trackClick);
307
396
  }
308
397
  };
309
398
  }
310
399
 
400
+ /**
401
+ * Lock revisions before recipients because inserting a recipient takes a shared
402
+ * foreign-key lock on its revision. Updating that revision later can deadlock
403
+ * with another transaction that has already locked the revision and is waiting
404
+ * for the recipient. Keep multi-revision lock acquisition deterministic.
405
+ */
406
+ async function lockActionRevisions(
407
+ trx: Knex.Transaction,
408
+ revisionIds: Iterable<string>
409
+ ): Promise<string[]> {
410
+ const sortedRevisionIds = [...new Set(revisionIds)]
411
+ .sort((left, right) => left.localeCompare(right));
412
+
413
+ if (sortedRevisionIds.length > 0) {
414
+ await trx('automation_action_revisions')
415
+ .select('id')
416
+ .whereIn('id', sortedRevisionIds)
417
+ .forUpdate();
418
+ }
419
+
420
+ return sortedRevisionIds;
421
+ }
422
+
311
423
  /**
312
424
  * Which of these recipients have yet to open, and so should count towards their
313
425
  * revision's open count. Locks them for the transaction, so a worker racing on
@@ -921,7 +1033,7 @@ async function updateAutomation(trx: Knex.Transaction, automation: AutomationRow
921
1033
  async function replaceAutomationGraph(trx: Knex.Transaction, automationId: string, submittedActions: AutomationAction[], edges: AutomationEdge[]): Promise<void> {
922
1034
  const existingActions = await loadAutomationActionRows(trx, automationId);
923
1035
  const existingActionById = new Map(existingActions.map(action => [action.id, action]));
924
- const actions = await resolveEmailDesignSettingIds(trx, submittedActions);
1036
+ const actions = (await resolveEmailDesignSettingIds(trx, submittedActions)).map(formatActionOnWrite);
925
1037
  const submittedActionIds = new Set(actions.map(action => action.id));
926
1038
  const actionIdsWithOwners = await loadActionIdsWithOwners(trx, [...submittedActionIds]);
927
1039
  const latestRevisionByActionId = new Map((await loadLatestActionRevisions(trx, [...submittedActionIds])).map(revision => [revision.action_id, revision]));
@@ -982,6 +1094,23 @@ async function replaceAutomationGraph(trx: Knex.Transaction, automationId: strin
982
1094
  await insertActionEdges(trx, edges);
983
1095
  }
984
1096
 
1097
+ function formatActionOnWrite(action: AutomationAction): AutomationAction {
1098
+ if (action.type !== 'send_email') {
1099
+ return action;
1100
+ }
1101
+
1102
+ return {
1103
+ ...action,
1104
+ data: {
1105
+ ...action.data,
1106
+ email_lexical: urlUtils.lexicalToTransformReady(action.data.email_lexical, {
1107
+ nodes: lexicalLib.nodes,
1108
+ transformMap: lexicalLib.urlTransformMap
1109
+ })
1110
+ }
1111
+ };
1112
+ }
1113
+
985
1114
  async function resolveEmailDesignSettingIds(trx: Knex.Transaction, actions: ReadonlyArray<AutomationAction>): Promise<AutomationAction[]> {
986
1115
  if (!actions.some(action => action.type === 'send_email' && action.data.email_design_setting_id === DEFAULT_EMAIL_DESIGN_SETTING_REFERENCE)) {
987
1116
  return [...actions];
@@ -1312,7 +1441,7 @@ function buildActionPayload(row: ActionRow, stats: AutomationEmailStats | null):
1312
1441
  type: 'send_email',
1313
1442
  data: {
1314
1443
  email_subject: requireValue(row, 'email_subject'),
1315
- email_lexical: requireValue(row, 'email_lexical'),
1444
+ email_lexical: urlUtils.transformReadyToAbsolute(requireValue(row, 'email_lexical')),
1316
1445
  email_design_setting_id: requireValue(row, 'email_design_setting_id')
1317
1446
  },
1318
1447
  stats: stats ?? EMPTY_EMAIL_STATS
@@ -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);
@@ -0,0 +1,13 @@
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.getSchedulerIdempotencyKey = getSchedulerIdempotencyKey;
7
+ const node_crypto_1 = __importDefault(require("node:crypto"));
8
+ function getSchedulerIdempotencyKey(date, url) {
9
+ const hash = node_crypto_1.default.createHash('sha256');
10
+ hash.update(date.toISOString());
11
+ hash.update(url.href);
12
+ return `ghost-automations-${hash.digest('hex')}`;
13
+ }
@@ -0,0 +1,8 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export function getSchedulerIdempotencyKey(date: Readonly<Date>, url: Readonly<URL>): string {
4
+ const hash = crypto.createHash('sha256');
5
+ hash.update(date.toISOString());
6
+ hash.update(url.href);
7
+ return `ghost-automations-${hash.digest('hex')}`;
8
+ }
@@ -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,10 @@ 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,
133
+ automationRunStepId: step.id
130
134
  });
131
135
  const mailgunMessageId = (0, mailgun_message_id_1.getMailgunMessageId)(sendResult);
132
136
  // Only Mailgun sends can produce open events for automation emails
@@ -134,6 +138,7 @@ const processStep = async ({ automationsApi, memberWelcomeEmailService, schedule
134
138
  try {
135
139
  await automationsApi.recordEmailSent({
136
140
  automationActionRevisionId: step.automation_action_revision_id,
141
+ automationRunStepId: step.id,
137
142
  ...(mailgunMessageId ? { mailgunMessageId } : {}),
138
143
  memberEmail: member.get('email'),
139
144
  memberId: step.member_id,
@@ -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,9 @@ type MemberWelcomeEmailService = {
25
26
  };
26
27
  memberStatus: 'free' | 'paid';
27
28
  trackOpens: boolean;
29
+ trackClicks: boolean;
30
+ automationActionRevisionId: string;
31
+ automationRunStepId: string;
28
32
  }) => Promise<unknown>;
29
33
  };
30
34
  };
@@ -183,7 +187,7 @@ const processStep = async ({
183
187
  break;
184
188
  }
185
189
  memberWelcomeEmailService.init();
186
- const trackClicks = Boolean(settingsCache.get('email_track_clicks'));
190
+ const trackClicks = labs.isSet('automationAnalytics') && Boolean(settingsCache.get('email_track_clicks'));
187
191
  const trackOpens = Boolean(settingsCache.get('email_track_opens'));
188
192
  const sendResult = await memberWelcomeEmailService.api.sendAutomationEmail({
189
193
  email: {
@@ -197,7 +201,10 @@ const processStep = async ({
197
201
  uuid: member.get('uuid')
198
202
  },
199
203
  memberStatus,
200
- trackOpens
204
+ trackOpens,
205
+ trackClicks,
206
+ automationActionRevisionId: step.automation_action_revision_id,
207
+ automationRunStepId: step.id
201
208
  });
202
209
  const mailgunMessageId = getMailgunMessageId(sendResult);
203
210
  // Only Mailgun sends can produce open events for automation emails
@@ -205,6 +212,7 @@ const processStep = async ({
205
212
  try {
206
213
  await automationsApi.recordEmailSent({
207
214
  automationActionRevisionId: step.automation_action_revision_id,
215
+ automationRunStepId: step.id,
208
216
  ...(mailgunMessageId ? {mailgunMessageId} : {}),
209
217
  memberEmail: member.get('email'),
210
218
  memberId: step.member_id,
@@ -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;
@@ -0,0 +1,38 @@
1
+ const SCHEDULER_POLL_INTERVAL_MS = 15 * 60 * 1000;
2
+
3
+ /**
4
+ * An implementation of the [FNV-1a hash][0].
5
+ *
6
+ * We need something that generates per-site jitter within a 15-minute
7
+ * interval. We want a 32-bit number which we can modulo at the end.
8
+ *
9
+ * FNV-1a is good for this purpose because:
10
+ *
11
+ * - it is simple
12
+ * - it generates a 32-bit number
13
+ * - it is [more efficient than CRC32, MD5, and SHA][1], using less CPU and RAM
14
+ *
15
+ * This implementation assumes ASCII strings, which we expect callers to always
16
+ * use. If that assumption is ever violated, we'll compute incorrect jitter,
17
+ * which is acceptable.
18
+ *
19
+ * [0]: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV-1a_hash
20
+ * [1]: https://gist.github.com/EvanHahn/20d845c8157691d24977831159487950
21
+ */
22
+ const fnv1a32 = (asciiStr: string): number => {
23
+ let hash = 0x811c9dc5;
24
+ for (let i = 0; i < asciiStr.length; i++) {
25
+ hash ^= asciiStr.charCodeAt(i);
26
+ hash = Math.imul(hash, 0x01000193);
27
+ }
28
+ return hash >>> 0;
29
+ };
30
+
31
+ /**
32
+ * Round the time up to a 15-minute interval with site-specific jitter.
33
+ */
34
+ export const getSchedulerPollTime = (date: Readonly<Date>, siteIdentifier: string): Date => {
35
+ const siteOffset = fnv1a32(siteIdentifier) % SCHEDULER_POLL_INTERVAL_MS;
36
+ const interval = Math.ceil((date.getTime() - siteOffset) / SCHEDULER_POLL_INTERVAL_MS);
37
+ return new Date(interval * SCHEDULER_POLL_INTERVAL_MS + siteOffset);
38
+ }
@@ -41,8 +41,10 @@ const strict_1 = __importDefault(require("node:assert/strict"));
41
41
  const one_at_a_time_1 = require("../../../shared/one-at-a-time");
42
42
  const poll_1 = require("./poll");
43
43
  const automationsApi = __importStar(require("./automations-api"));
44
+ const get_scheduler_idempotency_key_1 = require("./get-scheduler-idempotency-key");
44
45
  const promises_1 = require("node:timers/promises");
45
46
  const soonest_timer_1 = require("../../lib/soonest-timer");
47
+ const scheduler_poll_time_1 = require("./scheduler-poll-time");
46
48
  // @ts-expect-error This module currently lacks type definitions.
47
49
  const jobs_1 = __importDefault(require("../email-analytics/jobs"));
48
50
  const urlUtils = require('../../../shared/url-utils').default;
@@ -54,11 +56,13 @@ const memberWelcomeEmailService = require('../member-welcome-emails/service');
54
56
  const scheduleAutomationEmailAnalyticsJob = () => (jobs_1.default.scheduleRecurringAutomationsJob(true));
55
57
  class AutomationsService {
56
58
  #enqueuePollAt;
57
- init({ domainEvents, apiUrl, schedulerAdapter, internalKeys }) {
59
+ init({ domainEvents, apiUrl, schedulerAdapter, internalKeys, siteUuid }) {
58
60
  const isInitialized = Boolean(this.#enqueuePollAt);
59
61
  if (isInitialized) {
60
62
  return;
61
63
  }
64
+ // If we don't get a valid site UUID for some reason, compute jitter with the API URL.
65
+ const siteIdentifier = typeof siteUuid === 'string' && siteUuid.length ? siteUuid : apiUrl;
62
66
  const enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create());
63
67
  const soonestTimer = new soonest_timer_1.SoonestTimer(enqueuePollNow);
64
68
  /**
@@ -86,11 +90,23 @@ class AutomationsService {
86
90
  }
87
91
  soonestTimer.scheduleAt(date);
88
92
  try {
93
+ const schedulerPollTime = (0, scheduler_poll_time_1.getSchedulerPollTime)(date, siteIdentifier);
89
94
  const key = await internalKeys.get('ghost-scheduler');
90
- const signedAdminToken = getSignedAdminToken({ publishedAt: date.toISOString(), apiUrl, key });
95
+ const signedAdminToken = getSignedAdminToken({
96
+ publishedAt: schedulerPollTime.toISOString(),
97
+ apiUrl,
98
+ key
99
+ });
91
100
  const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll'));
92
101
  url.searchParams.set('token', signedAdminToken);
93
- schedulerAdapter.schedule({ time: date.getTime(), url: url.toString(), extra: { httpMethod: 'PUT' } });
102
+ schedulerAdapter.schedule({
103
+ time: schedulerPollTime.getTime(),
104
+ url: url.toString(),
105
+ extra: {
106
+ httpMethod: 'PUT',
107
+ idempotencyKey: (0, get_scheduler_idempotency_key_1.getSchedulerIdempotencyKey)(date, url)
108
+ }
109
+ });
94
110
  }
95
111
  catch (err) {
96
112
  logging.error({ event: { name: 'automations.enqueue-poll.error' }, err, at: date.toISOString() }, 'Failed to enqueue automations poll');
@@ -6,8 +6,10 @@ import type DomainEvents from '@tryghost/domain-events';
6
6
  import {oneAtATime} from '../../../shared/one-at-a-time';
7
7
  import {poll} from './poll';
8
8
  import * as automationsApi from './automations-api';
9
+ import {getSchedulerIdempotencyKey} from './get-scheduler-idempotency-key';
9
10
  import {setImmediate as flushEventLoop} from 'node:timers/promises';
10
11
  import {SoonestTimer} from '../../lib/soonest-timer';
12
+ import {getSchedulerPollTime} from './scheduler-poll-time';
11
13
  // @ts-expect-error This module currently lacks type definitions.
12
14
  import emailAnalyticsJobs from '../email-analytics/jobs';
13
15
 
@@ -23,6 +25,7 @@ type AutomationsServiceOptions = {
23
25
  domainEvents: Pick<DomainEvents, 'dispatch' | 'subscribe'>;
24
26
  internalKeys: InternalKeys;
25
27
  schedulerAdapter: Pick<SchedulerAdapter, 'schedule' | 'register'>;
28
+ siteUuid: unknown;
26
29
  };
27
30
 
28
31
  const scheduleAutomationEmailAnalyticsJob = () => (
@@ -32,12 +35,14 @@ const scheduleAutomationEmailAnalyticsJob = () => (
32
35
  export class AutomationsService {
33
36
  #enqueuePollAt: undefined | ((date: Readonly<Date>) => Promise<void>);
34
37
 
35
- init({domainEvents, apiUrl, schedulerAdapter, internalKeys}: AutomationsServiceOptions): void {
38
+ init({domainEvents, apiUrl, schedulerAdapter, internalKeys, siteUuid}: AutomationsServiceOptions): void {
36
39
  const isInitialized = Boolean(this.#enqueuePollAt);
37
40
  if (isInitialized) {
38
41
  return;
39
42
  }
40
43
 
44
+ // If we don't get a valid site UUID for some reason, compute jitter with the API URL.
45
+ const siteIdentifier = typeof siteUuid === 'string' && siteUuid.length ? siteUuid : apiUrl;
41
46
  const enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create());
42
47
 
43
48
  const soonestTimer = new SoonestTimer(enqueuePollNow);
@@ -69,11 +74,23 @@ export class AutomationsService {
69
74
  soonestTimer.scheduleAt(date);
70
75
 
71
76
  try {
77
+ const schedulerPollTime = getSchedulerPollTime(date, siteIdentifier);
72
78
  const key = await internalKeys.get('ghost-scheduler');
73
- const signedAdminToken = getSignedAdminToken({publishedAt: date.toISOString(), apiUrl, key});
79
+ const signedAdminToken = getSignedAdminToken({
80
+ publishedAt: schedulerPollTime.toISOString(),
81
+ apiUrl,
82
+ key
83
+ });
74
84
  const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll'));
75
85
  url.searchParams.set('token', signedAdminToken);
76
- schedulerAdapter.schedule({time: date.getTime(), url: url.toString(), extra: {httpMethod: 'PUT'}});
86
+ schedulerAdapter.schedule({
87
+ time: schedulerPollTime.getTime(),
88
+ url: url.toString(),
89
+ extra: {
90
+ httpMethod: 'PUT',
91
+ idempotencyKey: getSchedulerIdempotencyKey(date, url)
92
+ }
93
+ });
77
94
  } catch (err) {
78
95
  logging.error({event: {name: 'automations.enqueue-poll.error'}, err, at: date.toISOString()}, 'Failed to enqueue automations poll');
79
96
  }
@@ -87,12 +87,13 @@ class CommentsServiceEmails {
87
87
  }
88
88
  }
89
89
 
90
- async notifyParentCommentAuthor(reply, {type = 'parent'} = {}) {
91
- let parent;
92
- if (type === 'in_reply_to') {
93
- parent = await this.models.Comment.findOne({id: reply.get('in_reply_to_id')});
94
- } else {
95
- parent = await this.models.Comment.findOne({id: reply.get('parent_id')});
90
+ /**
91
+ * @returns {Promise<string|undefined>} The id of the member that was notified, or undefined if no email was sent
92
+ */
93
+ async notifyParentCommentAuthor(reply, {type = 'parent', parent = null} = {}) {
94
+ if (!parent) {
95
+ const parentId = type === 'in_reply_to' ? reply.get('in_reply_to_id') : reply.get('parent_id');
96
+ parent = await this.models.Comment.findOne({id: parentId});
96
97
  }
97
98
  const parentMember = parent.related('member');
98
99
 
@@ -140,12 +141,14 @@ class CommentsServiceEmails {
140
141
  text
141
142
  } = await this.commentsServiceEmailRenderer.renderEmailTemplate('new-comment-reply', templateData);
142
143
 
143
- return this.sendMail({
144
+ await this.sendMail({
144
145
  to: parentMemberEmail,
145
146
  subject,
146
147
  html,
147
148
  text
148
149
  });
150
+
151
+ return parentMember.id;
149
152
  }
150
153
 
151
154
  /**
@@ -378,11 +378,16 @@ class CommentsService {
378
378
  async sendNewCommentNotifications(comment) {
379
379
  await this.emails.notifyPostAuthors(comment);
380
380
 
381
- if (comment.get('parent_id')) {
382
- await this.emails.notifyParentCommentAuthor(comment, {type: 'parent'});
383
- }
381
+ const notifiedMemberId = comment.get('parent_id')
382
+ ? await this.emails.notifyParentCommentAuthor(comment, {type: 'parent'})
383
+ : null;
384
+
384
385
  if (comment.get('in_reply_to_id')) {
385
- await this.emails.notifyParentCommentAuthor(comment, {type: 'in_reply_to'});
386
+ const inReplyTo = await this.models.Comment.findOne({id: comment.get('in_reply_to_id')});
387
+
388
+ if (inReplyTo && (!notifiedMemberId || inReplyTo.get('member_id') !== notifiedMemberId)) {
389
+ await this.emails.notifyParentCommentAuthor(comment, {type: 'in_reply_to', parent: inReplyTo});
390
+ }
386
391
  }
387
392
  }
388
393
 
@@ -84,7 +84,9 @@
84
84
  <table role="presentation" border="0" cellpadding="0" cellspacing="0">
85
85
  <tr>
86
86
  <td>
87
- <a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
87
+ <h1 class="post-title-heading">
88
+ <a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
89
+ </h1>
88
90
  </td>
89
91
  </tr>
90
92
  </table>
@@ -6,7 +6,12 @@ const events = require('../../lib/common/events');
6
6
  class EmailServiceWrapper {
7
7
  getPostUrl(post) {
8
8
  const jsonModel = post.toJSON();
9
- url.forPost(post.id, jsonModel, {options: {}});
9
+ // The URL service routes by resource type. Pages and posts share the
10
+ // Post model, so the page's own type must reach forPost — otherwise it
11
+ // defaults to 'posts', matches no post collection, and 404s under the
12
+ // lazy service.
13
+ const type = jsonModel.type === 'page' ? 'pages' : 'posts';
14
+ url.forPost(post.id, jsonModel, {options: {}}, type);
10
15
  return jsonModel.url;
11
16
  }
12
17
 
@@ -161,6 +161,14 @@ img.is-dark-background {
161
161
  color: {{postTitleColor}};
162
162
  }
163
163
 
164
+ /* Outlook's Word engine ignores `inherit`, so repeat the `.post-title > table td` values */
165
+ .post-title-heading {
166
+ margin: 0;
167
+ font-size: 36px;
168
+ line-height: 1.1;
169
+ font-weight: {{titleWeight}};
170
+ }
171
+
164
172
  .post-title a {
165
173
  color: {{postTitleColor}} !important;
166
174
  }