ghost 6.47.0 → 6.48.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 (372) hide show
  1. package/MigratorConfig.js +0 -1
  2. package/bin/create-migration.js +1 -1
  3. package/bin/generate-golden-email.js +0 -1
  4. package/components/tryghost-i18n-0.0.0.tgz +0 -0
  5. package/core/boot.js +13 -7
  6. package/core/built/admin/assets/{PolarAngleAxis-CLluELUg.js → PolarAngleAxis-DufzOBEz.js} +1 -1
  7. package/core/built/admin/assets/{_baseAssignValue-Dz1iqdwD.js → _baseAssignValue-BjUm4QeA.js} +1 -1
  8. package/core/built/admin/assets/{a-large-small-BA74vHPp.js → a-large-small-D0RF_WS9.js} +1 -1
  9. package/core/built/admin/assets/{account-migration-B-NqdKuN.js → account-migration-B7n6ezrZ.js} +1 -1
  10. package/core/built/admin/assets/activitypub/{account-migration-ClmVrAXr.js → account-migration-BJeRnMCl.js} +3 -3
  11. package/core/built/admin/assets/activitypub/activitypub.js +1 -1
  12. package/core/built/admin/assets/activitypub/{at-sign-9F3qd4Nn.js → at-sign-BygVOKdj.js} +2 -2
  13. package/core/built/admin/assets/activitypub/{avatar-flipboard-jBHA13Ej.js → avatar-flipboard-CNcjI8lK.js} +2 -2
  14. package/core/built/admin/assets/activitypub/{bluesky-sharing-1irhvVtF.js → bluesky-sharing-mgBNeRfA.js} +4 -4
  15. package/core/built/admin/assets/activitypub/{copy-DXRvsGiK.js → copy-BMChXIQc.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{deleted-feed-item-67VQJde4.js → deleted-feed-item-D-spb6V1.js} +2 -2
  17. package/core/built/admin/assets/activitypub/{edit-profile-BfpbEj98.js → edit-profile-CDQ3V8-c.js} +4 -4
  18. package/core/built/admin/assets/activitypub/{feed-DUrI7SPC.js → feed-Vqr3WU-V.js} +4 -4
  19. package/core/built/admin/assets/activitypub/{hash-CMtB10PN.js → hash-D7SSXVOg.js} +2 -2
  20. package/core/built/admin/assets/activitypub/{inbox-2xq8SJ7A.js → inbox-BHk5Dw27.js} +2 -2
  21. package/core/built/admin/assets/activitypub/{index-BT97xLpd.js → index-BejkeH-m.js} +2 -2
  22. package/core/built/admin/assets/activitypub/{index-DssHQAjK.js → index-BqL23J-m.js} +32 -32
  23. package/core/built/admin/assets/activitypub/{index-Ba6FXt8y.js → index-CHhvjNuG.js} +6 -6
  24. package/core/built/admin/assets/activitypub/{index-ERtz4Bvy.js → index-CZsNO9hA.js} +5 -5
  25. package/core/built/admin/assets/activitypub/{index-sgY7lLrg.js → index-CaIhRYzz.js} +3 -3
  26. package/core/built/admin/assets/activitypub/{index-D1EWoXrK.js → index-DA2MkQVu.js} +2 -2
  27. package/core/built/admin/assets/activitypub/{index-sudOvKcY.js → index-DHhFcmfu.js} +21 -21
  28. package/core/built/admin/assets/activitypub/{index-Ccighz1t.js → index-sh2bDSls.js} +4 -4
  29. package/core/built/admin/assets/activitypub/{label-KQSsSjmN.js → label-D80yhOIm.js} +2 -2
  30. package/core/built/admin/assets/activitypub/{moderation-B78if-r0.js → moderation-LJNmKBCi.js} +3 -3
  31. package/core/built/admin/assets/activitypub/{note-Bfr7ovn7.js → note-CyjKLcQ-.js} +4 -4
  32. package/core/built/admin/assets/activitypub/{reply-CehesW8Z.js → reply-BiiCFx7G.js} +2 -2
  33. package/core/built/admin/assets/activitypub/{separator-B_Ly_Qq8.js → separator-CY3PMmtI.js} +2 -2
  34. package/core/built/admin/assets/activitypub/{settings-D6R97p7v.js → settings-BQy6Gr7R.js} +3 -3
  35. package/core/built/admin/assets/activitypub/{step-1-Cl1YxgZ1.js → step-1-DCqRwgf3.js} +3 -3
  36. package/core/built/admin/assets/activitypub/{step-2-DDyR--Qe.js → step-2-DQlhehTO.js} +5 -5
  37. package/core/built/admin/assets/activitypub/{step-3-Don8gv0n.js → step-3-DAUCAcxW.js} +7 -7
  38. package/core/built/admin/assets/activitypub/{tabs-DTpfm-wu.js → tabs-B_6UuI7r.js} +3 -3
  39. package/core/built/admin/assets/activitypub/{topic-filter-Oee10885.js → topic-filter--FT1A_dc.js} +2 -2
  40. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  41. package/core/built/admin/assets/admin-x-settings/{code-editor-view-CGZuiDGx.js → code-editor-view-Bp_sCJR0.js} +2 -2
  42. package/core/built/admin/assets/admin-x-settings/{index-CwEblQZC.js → index-BPXA3pTW.js} +5 -5
  43. package/core/built/admin/assets/admin-x-settings/{index-rr_UntGE.js → index-BS375mg1.js} +3 -3
  44. package/core/built/admin/assets/admin-x-settings/{index-BpM6W2br.js → index-B_PlQoEG.js} +3 -3
  45. package/core/built/admin/assets/admin-x-settings/{index-BGdS5xw4.js → index-BflhAbu2.js} +3 -3
  46. package/core/built/admin/assets/admin-x-settings/{index-DtWUeJhp.js → index-CftMe8fe.js} +3 -3
  47. package/core/built/admin/assets/admin-x-settings/{index-B65inCwj.js → index-DRB7FH6y.js} +2 -2
  48. package/core/built/admin/assets/admin-x-settings/{index-DSX261S9.js → index-DijZ3oAM.js} +2 -2
  49. package/core/built/admin/assets/admin-x-settings/{index-DF6YEs01.js → index-cV0Lof9H.js} +1526 -1500
  50. package/core/built/admin/assets/admin-x-settings/{index-CluZYkUC.js → index-qc4x3vZj.js} +3 -3
  51. package/core/built/admin/assets/admin-x-settings/{modals-Bf4KOU31.js → modals-CkjuEByw.js} +55 -67
  52. package/core/built/admin/assets/{arrow-right-BxP3aeA5.js → arrow-right-BF77Yx73.js} +1 -1
  53. package/core/built/admin/assets/{at-sign-Bf0y-jim.js → at-sign-DhJF3n_V.js} +1 -1
  54. package/core/built/admin/assets/{audience-DHR1WgFK.js → audience-DHPKKayV.js} +1 -1
  55. package/core/built/admin/assets/{automated-emails-DbJbDLib.js → automated-emails-DInKYTEC.js} +1 -1
  56. package/core/built/admin/assets/{automations-aLgTzSsZ.js → automations-DJpeZw9T.js} +1 -1
  57. package/core/built/admin/assets/automations-DpQKN9j0.js +1 -0
  58. package/core/built/admin/assets/{avatar-flipboard-B0F2LMW5.js → avatar-flipboard-yRdmFC6R.js} +1 -1
  59. package/core/built/admin/assets/{bluesky-sharing-DjhtIuyr.js → bluesky-sharing-qSiCERoR.js} +1 -1
  60. package/core/built/admin/assets/{chart-BGwxlD0z.js → chart-CP2N9ZC-.js} +1 -1
  61. package/core/built/admin/assets/{chevron-up-C2FeVzre.js → chevron-up-BQ6fOpFR.js} +1 -1
  62. package/core/built/admin/assets/{chunk.524.31b31f7958dfae110016.js → chunk.524.ad4560630d4d62fbceb5.js} +4 -4
  63. package/core/built/admin/assets/{chunk.582.a855020953886af6e83b.js → chunk.582.fd4878e4e0af31ef86c0.js} +6 -6
  64. package/core/built/admin/assets/{chunk.972.258e6b1dab79b2189f1c.js → chunk.98.69968e0f393d82c2949c.js} +4 -4
  65. package/core/built/admin/assets/{code-editor-view-DzPkm_nJ.js → code-editor-view-CVODiRVJ.js} +1 -1
  66. package/core/built/admin/assets/{comments-DgItXUWN.js → comments-CP-NxopD.js} +1 -1
  67. package/core/built/admin/assets/{content-helpers-BNefU_PE.js → content-helpers-Crzkusiq.js} +1 -1
  68. package/core/built/admin/assets/{copy-DoNNUZlb.js → copy-r1LFsU75.js} +1 -1
  69. package/core/built/admin/assets/{data-list-CO1cZQRU.js → data-list-CzSc7z-2.js} +1 -1
  70. package/core/built/admin/assets/{deleted-feed-item-DC2wSAhv.js → deleted-feed-item-PyEM8FiX.js} +1 -1
  71. package/core/built/admin/assets/{dropzone-BTODLafn.js → dropzone-DI8A9qO6.js} +1 -1
  72. package/core/built/admin/assets/{edit-profile-BMMpmWIZ.js → edit-profile-DlJ64lV7.js} +1 -1
  73. package/core/built/admin/assets/editor-CevNZicQ.js +8 -0
  74. package/core/built/admin/assets/{empty-indicator-Dza0FQlj.js → empty-indicator-Dxg05yWg.js} +1 -1
  75. package/core/built/admin/assets/{en-yf9qAqTu.js → en-D93GVp3o.js} +1 -1
  76. package/core/built/admin/assets/{eye-CAqnnfQF.js → eye-6POPPpTw.js} +1 -1
  77. package/core/built/admin/assets/{feed-BkNsbWiF.js → feed-P_RMYb5x.js} +1 -1
  78. package/core/built/admin/assets/{field-Ddb1ZOqM.js → field-DeNjwWrL.js} +1 -1
  79. package/core/built/admin/assets/{filter-query-core-5Mac92kh.js → filter-query-core-DFs5WSmA.js} +1 -1
  80. package/core/built/admin/assets/{filters-BCb4bgtg.js → filters-C_BSQgb7.js} +1 -1
  81. package/core/built/admin/assets/{gh-chart-C6zkIg8k.js → gh-chart-pNHAbeQn.js} +1 -1
  82. package/core/built/admin/assets/{ghost-5313258fd425ced5db6516acde01ba36.js → ghost-3f5b3569e0beec61266cb74077677fbc.js} +3 -3
  83. package/core/built/admin/assets/{growth-ezUlN8OE.js → growth-B83tVOYO.js} +1 -1
  84. package/core/built/admin/assets/{hash-DE2OmChI.js → hash-BCTATV5W.js} +1 -1
  85. package/core/built/admin/assets/{inbox-OthiHvlV.js → inbox-rE3c5suT.js} +1 -1
  86. package/core/built/admin/assets/{index-BLyzLfyx.js → index-7EiRc5Ay.js} +1 -1
  87. package/core/built/admin/assets/{index-CV5v0eFE.js → index-B5bpZGTI.js} +1 -1
  88. package/core/built/admin/assets/index-BNtzAd7H.css +1 -0
  89. package/core/built/admin/assets/{index-BC1yWeaJ.js → index-BT33w2Io.js} +1 -1
  90. package/core/built/admin/assets/{index-DjffQ3VJ.js → index-BbkXWdhb.js} +1 -1
  91. package/core/built/admin/assets/{index-DH6PA3W6.js → index-BfhSrnrJ.js} +1 -1
  92. package/core/built/admin/assets/{index-CRvK0Iac.js → index-BmR_OUpb.js} +1 -1
  93. package/core/built/admin/assets/{index-CNV7fxKZ.js → index-BuhMgn5c.js} +1 -1
  94. package/core/built/admin/assets/{index-pkixJbdq.js → index-C-075SzJ.js} +34 -34
  95. package/core/built/admin/assets/{index-DJoByVS8.js → index-CB3hJd9E.js} +1 -1
  96. package/core/built/admin/assets/{index-D3bb-MEJ.js → index-CCISLIQd.js} +1 -1
  97. package/core/built/admin/assets/{index-BIEhcKiV.js → index-Cnptpx11.js} +1 -1
  98. package/core/built/admin/assets/{index-DK1cu59e.js → index-CrBRU81r.js} +1 -1
  99. package/core/built/admin/assets/{index-B0_p1-R0.js → index-CscUHFJp.js} +1 -1
  100. package/core/built/admin/assets/{index-Bi5qzf86.js → index-D3ZzfuJt.js} +1 -1
  101. package/core/built/admin/assets/{index-CKPny_6u.js → index-D4wn-U0V.js} +1 -1
  102. package/core/built/admin/assets/{index-YzcsD17L.js → index-DNRlkUeu.js} +1 -1
  103. package/core/built/admin/assets/{index-BoMsDCxD.js → index-D_G6negZ.js} +1 -1
  104. package/core/built/admin/assets/{index-CwsmINtV.js → index-DfxHSK1s.js} +1 -1
  105. package/core/built/admin/assets/{index-X91I8PcS.js → index-Dpc3COTl.js} +1 -1
  106. package/core/built/admin/assets/{index-c1h-EQpd.js → index-UsbnU2jy.js} +1 -1
  107. package/core/built/admin/assets/{index-oKgNVmwC.js → index-VfrJzh5m.js} +1 -1
  108. package/core/built/admin/assets/{index-D5MrQBSe.js → index-fbxc8Dpt.js} +1 -1
  109. package/core/built/admin/assets/{input-group-BOLMrDfJ.js → input-group-BLygwiDK.js} +1 -1
  110. package/core/built/admin/assets/{koenig-lexical-DlzcWUPw.js → koenig-lexical-B9U3sBS5.js} +1 -1
  111. package/core/built/admin/assets/{kpi-card-BlF7JKRC.js → kpi-card-Bmnvvp4X.js} +1 -1
  112. package/core/built/admin/assets/{kpi-card-HDshm3lO.js → kpi-card-CGVtzdgJ.js} +1 -1
  113. package/core/built/admin/assets/{kpi-tabs-Dty6zMSP.js → kpi-tabs-DxbvMkIU.js} +1 -1
  114. package/core/built/admin/assets/{kpis-CFz4r5wW.js → kpis-CQBAGVjP.js} +1 -1
  115. package/core/built/admin/assets/{label-CVzNcbjb.js → label-DpTwHQi_.js} +1 -1
  116. package/core/built/admin/assets/{links-D9dHwDlP.js → links-Bdw25FJF.js} +1 -1
  117. package/core/built/admin/assets/{list-page-shoQED7N.js → list-page-BvTQwP5F.js} +1 -1
  118. package/core/built/admin/assets/{loader-circle-cad701gH.js → loader-circle-3HJkQKWf.js} +1 -1
  119. package/core/built/admin/assets/{mail-DG4PKpZy.js → mail-DvrqP73n.js} +1 -1
  120. package/core/built/admin/assets/{main-layout-BmFla3Se.js → main-layout-BAPpm0bz.js} +1 -1
  121. package/core/built/admin/assets/{members-d19Z6k1C.js → members-CF4CzJSf.js} +1 -1
  122. package/core/built/admin/assets/minus-DJIePlnU.js +1 -0
  123. package/core/built/admin/assets/modals-CVsVi0zu.js +35 -0
  124. package/core/built/admin/assets/{moderation-BWBLnh4h.js → moderation-BX1KH5QL.js} +1 -1
  125. package/core/built/admin/assets/{newsletter-DNY4MroI.js → newsletter-BlKeb58e.js} +1 -1
  126. package/core/built/admin/assets/{newsletters-B10iBPP-.js → newsletters-3In8H0Z5.js} +1 -1
  127. package/core/built/admin/assets/{note-S0b6wjRR.js → note-ybwJCAxm.js} +1 -1
  128. package/core/built/admin/assets/{offers-B-9mZqKd.js → offers-Cl0ZNEMr.js} +1 -1
  129. package/core/built/admin/assets/{onboarding-route-AMFn0X5G.js → onboarding-route-A3ECeMAi.js} +1 -1
  130. package/core/built/admin/assets/{overview-WKadyZRW.js → overview-DrMrfsk-.js} +1 -1
  131. package/core/built/admin/assets/{pagemenu-BofQcFaF.js → pagemenu-O7HbsehH.js} +1 -1
  132. package/core/built/admin/assets/{pencil-LfMcD0vS.js → pencil-CknTbJeE.js} +1 -1
  133. package/core/built/admin/assets/{pin-CayUjL8e.js → pin-BNs-JdQ3.js} +1 -1
  134. package/core/built/admin/assets/{post-analytics-DMHvt3Jl.js → post-analytics-Dk1VloZp.js} +1 -1
  135. package/core/built/admin/assets/{post-analytics-context-10cqioP1.js → post-analytics-context-kPL94Ivz.js} +1 -1
  136. package/core/built/admin/assets/{post-analytics-header-DlICE18K.js → post-analytics-header-CpMOozq-.js} +1 -1
  137. package/core/built/admin/assets/{post-share-modal-Cfwy2UwD.js → post-share-modal-F0dobdhF.js} +1 -1
  138. package/core/built/admin/assets/posts/{alert-dialog-cQK32v9G.js → alert-dialog-NoPLULhG.js} +5 -5
  139. package/core/built/admin/assets/posts/{app-utils-DQ2hixop.js → app-utils-BiM9QO4L.js} +3 -3
  140. package/core/built/admin/assets/posts/{automations-BqkU9-zY.js → automations-BgIAmq9f.js} +3 -3
  141. package/core/built/admin/assets/posts/{automations-CoilKpVH.js → automations-D_XwO405.js} +30 -30
  142. package/core/built/admin/assets/posts/{avatar-BejAO6Rj.js → avatar-BYsYOpmq.js} +5 -5
  143. package/core/built/admin/assets/posts/{badge-BYGRyd2f.js → badge-B54vLono.js} +2 -2
  144. package/core/built/admin/assets/posts/{button-CG2Dpjqy.js → button-DMLfwOQt.js} +3 -3
  145. package/core/built/admin/assets/posts/{check-fsC5BvGC.js → check-D6qDi62T.js} +2 -2
  146. package/core/built/admin/assets/posts/{comments-CsyF5C1S.js → comments-CyYCbvUG.js} +26 -26
  147. package/core/built/admin/assets/posts/{data-list-JslRF2F5.js → data-list-B0DrMm-x.js} +4 -4
  148. package/core/built/admin/assets/posts/{dialog-3KY3A8t5.js → dialog-DtEZpY1q.js} +3 -3
  149. package/core/built/admin/assets/posts/{editor-3HPW_xW4.js → editor-LJHLmUoW.js} +1943 -1943
  150. package/core/built/admin/assets/posts/{ellipsis-BhK5S70U.js → ellipsis-JeIF0Biz.js} +2 -2
  151. package/core/built/admin/assets/posts/{empty-indicator-C9DbE9wZ.js → empty-indicator-dktzN32y.js} +2 -2
  152. package/core/built/admin/assets/posts/{filters-BCBDCnQF.js → filters-D1L-LU_m.js} +10 -10
  153. package/core/built/admin/assets/posts/{get-site-timezone-83C41MGz.js → get-site-timezone-dsyl8nlB.js} +3 -3
  154. package/core/built/admin/assets/posts/{growth-COoL1kMe.js → growth-yX8UB_ce.js} +12 -12
  155. package/core/built/admin/assets/posts/{heading-D3VqNO3z.js → heading-CNte43wH.js} +2 -2
  156. package/core/built/admin/assets/posts/{hooks-5YpJYWOw.js → hooks-BIgv7QBp.js} +2 -2
  157. package/core/built/admin/assets/posts/{index-Dqn5WIFh.js → index-2FO6Vy0r.js} +11 -11
  158. package/core/built/admin/assets/posts/{input-C1qcZBGw.js → input-BycmlCp8.js} +3 -3
  159. package/core/built/admin/assets/posts/{input-surface-C4vUAdbV.js → input-surface-vY3ACaso.js} +2 -2
  160. package/core/built/admin/assets/posts/{koenig-lexical-NZjxFY0X.js → koenig-lexical-Cq8SUcZC.js} +2 -2
  161. package/core/built/admin/assets/posts/{kpis-C2FEXoUT.js → kpis-CAJcU1w0.js} +27 -27
  162. package/core/built/admin/assets/posts/{label-CmJzmJY7.js → label-BmLTflMF.js} +3 -3
  163. package/core/built/admin/assets/posts/{links-nzud7t_t.js → links-BVcmMZmF.js} +4 -4
  164. package/core/built/admin/assets/posts/{list-page-D_rv4TgN.js → list-page-DX2WRn5T.js} +4 -4
  165. package/core/built/admin/assets/posts/{loading-indicator-DAOD6O2b.js → loading-indicator-C6FD4nS6.js} +3 -3
  166. package/core/built/admin/assets/posts/{mail-CtH0gTpq.js → mail-BMyoNUVM.js} +2 -2
  167. package/core/built/admin/assets/posts/{main-layout-VqvCxOXQ.js → main-layout-ClnxdW1Y.js} +2 -2
  168. package/core/built/admin/assets/posts/{message-circle-D4bCjxKS.js → message-circle-DwfHX-p1.js} +2 -2
  169. package/core/built/admin/assets/posts/{newsletter-BfwFyv6d.js → newsletter-Bid5_Bim.js} +19 -19
  170. package/core/built/admin/assets/posts/{overview-DKGum6ro.js → overview-BXti6Wg_.js} +18 -18
  171. package/core/built/admin/assets/posts/{pencil-Cffx-vm3.js → pencil-ChTk8IpW.js} +2 -2
  172. package/core/built/admin/assets/posts/{plus-D2ekwsq0.js → plus-DIKls8C6.js} +2 -2
  173. package/core/built/admin/assets/posts/{popover-DdbdKflP.js → popover-DVZsAT5u.js} +4 -4
  174. package/core/built/admin/assets/posts/{post-analytics-BleJYoNZ.js → post-analytics-DBL_kS0g.js} +6 -6
  175. package/core/built/admin/assets/posts/{post-analytics-context-DT3Avn9Q.js → post-analytics-context-Dr5y5nxd.js} +6 -6
  176. package/core/built/admin/assets/posts/{post-analytics-header-BktlrM_v.js → post-analytics-header-LH2-Jzgp.js} +13 -13
  177. package/core/built/admin/assets/posts/{post-share-modal-G8v3ZuSg.js → post-share-modal-D4DrfNfJ.js} +6 -6
  178. package/core/built/admin/assets/posts/{posts-CdWV_nlm.js → posts-ByxhcCrn.js} +2 -2
  179. package/core/built/admin/assets/posts/posts.js +1 -1
  180. package/core/built/admin/assets/posts/{search-CXP00fG8.js → search-DZR23LP-.js} +2 -2
  181. package/core/built/admin/assets/posts/{settings-DEXxDYc2.js → settings-CWyOKKnK.js} +2 -2
  182. package/core/built/admin/assets/posts/{sheet-CXHmD_eT.js → sheet-CNe7II5c.js} +3 -3
  183. package/core/built/admin/assets/posts/{site-C6DajagV.js → site-H9RAiQVv.js} +2 -2
  184. package/core/built/admin/assets/posts/{skeleton-Bihnkn8q.js → skeleton-Cn6x1x_C.js} +3 -3
  185. package/core/built/admin/assets/posts/{source-icon-C3oQy3UI.js → source-icon-BZOYujd1.js} +4 -4
  186. package/core/built/admin/assets/posts/{stats-DFicPtCX.js → stats-ugHCg8Oe.js} +4 -4
  187. package/core/built/admin/assets/posts/{table-DSJmeU5t.js → table-dG3Q5X95.js} +2 -2
  188. package/core/built/admin/assets/posts/{tabs-oRdVpN8P.js → tabs--EW8uL5B.js} +4 -4
  189. package/core/built/admin/assets/posts/{tags-6v5Qt_05.js → tags-DVdOfiSf.js} +2 -2
  190. package/core/built/admin/assets/posts/{tags-Upnkm0CF.js → tags-i0HJqNYU.js} +16 -16
  191. package/core/built/admin/assets/posts/{tooltip-Ci7pesWi.js → tooltip-ZvsT1Fs9.js} +4 -4
  192. package/core/built/admin/assets/posts/{trash-2-NCYWjRlg.js → trash-2-BT9fEHUL.js} +2 -2
  193. package/core/built/admin/assets/posts/{value-CcwzOh2_.js → value-DoFcsWDk.js} +2 -2
  194. package/core/built/admin/assets/posts/{virtual-list-window-CNahQBXY.js → virtual-list-window-Bt47XODn.js} +3 -3
  195. package/core/built/admin/assets/posts/{web-C3KrPq0T.js → web-BMTRLT-h.js} +20 -20
  196. package/core/built/admin/assets/posts/{zap-k30mZEuO.js → zap-DcIfEzKX.js} +2 -2
  197. package/core/built/admin/assets/{posts-DAfLuxAA.js → posts-DODOCB1E.js} +1 -1
  198. package/core/built/admin/assets/{power-Drkzg1xX.js → power-BZ7BM82m.js} +1 -1
  199. package/core/built/admin/assets/{referrers-TYxTJ2On.js → referrers-xwNb6L-8.js} +1 -1
  200. package/core/built/admin/assets/{repeat-CTM1qAzl.js → repeat-DHMCQMCA.js} +1 -1
  201. package/core/built/admin/assets/{reply-DXN16g7W.js → reply-DoLURZi2.js} +1 -1
  202. package/core/built/admin/assets/{rocket-DLixlWs0.js → rocket-BDH89dZr.js} +1 -1
  203. package/core/built/admin/assets/{select-DbiLEqYb.js → select-oE39QRll.js} +1 -1
  204. package/core/built/admin/assets/{send-B71C_46D.js → send-QgRN_yRy.js} +1 -1
  205. package/core/built/admin/assets/{settings-BBMPA3MP.js → settings-CF4NLxuS.js} +4 -4
  206. package/core/built/admin/assets/{settings-lSFHFrsJ.js → settings-D7e6KLAi.js} +1 -1
  207. package/core/built/admin/assets/{share-modal-CXrYja6o.js → share-modal-BxIh7i7o.js} +1 -1
  208. package/core/built/admin/assets/{sort-button-WYo-9Kge.js → sort-button-CEe_40Pi.js} +1 -1
  209. package/core/built/admin/assets/{source-icon-D6BzjYPP.js → source-icon-CzTNLO6j.js} +1 -1
  210. package/core/built/admin/assets/{sprout-DVfG_qhy.js → sprout-B2fN41p-.js} +1 -1
  211. package/core/built/admin/assets/{square-BbUJwQJi.js → square-CJZsjE-3.js} +1 -1
  212. package/core/built/admin/assets/{stats-CnXALQ4j.js → stats-jTxCK1CB.js} +1 -1
  213. package/core/built/admin/assets/{stats-view-B-yeMUkl.js → stats-view-B4jz69N8.js} +1 -1
  214. package/core/built/admin/assets/{step-1-DUl_hlpV.js → step-1-BovjhDNc.js} +1 -1
  215. package/core/built/admin/assets/{step-2-DjX_A4Ke.js → step-2-ClG40BfX.js} +1 -1
  216. package/core/built/admin/assets/{step-3-CnDCCA8T.js → step-3-ZMSyEHZI.js} +1 -1
  217. package/core/built/admin/assets/{table-AagTvMa9.js → table-B8Pj96or.js} +1 -1
  218. package/core/built/admin/assets/{tabs-IDJWnQD-.js → tabs-BZosOk0o.js} +1 -1
  219. package/core/built/admin/assets/{tags-CXAcIDw-.js → tags-BpWoYBfB.js} +1 -1
  220. package/core/built/admin/assets/{tags-C3lykbgu.js → tags-DZukMyuU.js} +1 -1
  221. package/core/built/admin/assets/{textarea-Cc3nUXep.js → textarea-BtnzsOk7.js} +1 -1
  222. package/core/built/admin/assets/{thumbs-down-SsjC2lMD.js → thumbs-down-CZTzyz7D.js} +1 -1
  223. package/core/built/admin/assets/{tiers-J8AeZmRd.js → tiers-Do7zBqAk.js} +1 -1
  224. package/core/built/admin/assets/{toggle-group-BzD8yimX.js → toggle-group-abk9FYXi.js} +1 -1
  225. package/core/built/admin/assets/{topic-filter-CA_L6ehz.js → topic-filter-CdMvjkmc.js} +1 -1
  226. package/core/built/admin/assets/{trash-BKhRqxsX.js → trash-DNXRsBf0.js} +1 -1
  227. package/core/built/admin/assets/{underline-_HIgX6e1.js → underline-HXWuVCAi.js} +1 -1
  228. package/core/built/admin/assets/{undo-2-ClkkOS0O.js → undo-2-C2ntNJKC.js} +1 -1
  229. package/core/built/admin/assets/{upload-DEPHbyvo.js → upload-D52l7HIb.js} +1 -1
  230. package/core/built/admin/assets/{use-growth-stats-BM2qWt0C.js → use-growth-stats-Dgs0UxDT.js} +1 -1
  231. package/core/built/admin/assets/{use-simple-pagination-zyklfekn.js → use-simple-pagination-Cj2rVggA.js} +1 -1
  232. package/core/built/admin/assets/{user-round-check-DoaGU2hC.js → user-round-check-C0GdT99c.js} +1 -1
  233. package/core/built/admin/assets/{user-round-x-CwEKNuU6.js → user-round-x-CVCvxuUG.js} +1 -1
  234. package/core/built/admin/assets/{virtual-list-window-C0Cv0gUC.js → virtual-list-window-mdQS2bt_.js} +1 -1
  235. package/core/built/admin/assets/{wallet-cards-BjdG5glh.js → wallet-cards-CtLF01jQ.js} +1 -1
  236. package/core/built/admin/assets/{web-BZNnanCT.js → web-DTib8_n8.js} +1 -1
  237. package/core/built/admin/index.html +6 -6
  238. package/core/frontend/helpers/ghost_foot.js +12 -2
  239. package/core/frontend/helpers/prev_post.js +1 -1
  240. package/core/frontend/helpers/tpl/gift-toast.hbs +130 -0
  241. package/core/frontend/services/proxy.js +9 -0
  242. package/core/frontend/services/routing/controllers/entry/canonical-url.js +15 -0
  243. package/core/frontend/services/routing/controllers/entry/canonical-url.ts +14 -0
  244. package/core/frontend/services/routing/controllers/entry/gift-links.js +82 -0
  245. package/core/frontend/services/routing/controllers/entry/gift-links.ts +92 -0
  246. package/core/frontend/services/routing/controllers/entry/markdown.js +62 -0
  247. package/core/frontend/services/routing/controllers/entry/markdown.ts +66 -0
  248. package/core/frontend/services/routing/controllers/entry.js +96 -105
  249. package/core/frontend/services/routing/controllers/entry.ts +97 -0
  250. package/core/frontend/services/routing/controllers/index.js +1 -1
  251. package/core/frontend/web/middleware/frontend-caching.js +9 -0
  252. package/core/server/adapters/cache/Redis.js +0 -1
  253. package/core/server/adapters/redirects/RedirectsStoreBase.d.ts +0 -4
  254. package/core/server/adapters/scheduling/scheduling-default.js +8 -1
  255. package/core/server/api/endpoints/gift-links.js +23 -16
  256. package/core/server/api/endpoints/gift-links.ts +25 -17
  257. package/core/server/api/endpoints/index.js +0 -1
  258. package/core/server/api/endpoints/previews.js +3 -24
  259. package/core/server/api/endpoints/utils/serializers/output/gift-links.js +4 -4
  260. package/core/server/api/endpoints/utils/serializers/output/gift-links.ts +4 -4
  261. package/core/server/api/endpoints/utils/serializers/output/index.js +0 -1
  262. package/core/server/api/endpoints/utils/validators/input/automation_email_previews.js +8 -9
  263. package/core/server/api/endpoints/utils/validators/input/index.js +0 -1
  264. package/core/server/data/migrations/versions/6.48/2026-06-23-00-00-00-drop-gift-links-revoked-at.js +6 -0
  265. package/core/server/data/migrations/versions/6.48/2026-06-23-00-00-01-rename-gift-links-revoke-all-permission-to-remove-all.js +37 -0
  266. package/core/server/data/migrations/versions/6.48/2026-06-24-00-00-00-drop-gift-links-redemption-columns.js +10 -0
  267. package/core/server/data/schema/fixtures/fixtures.json +2 -2
  268. package/core/server/data/schema/schema.js +1 -4
  269. package/core/server/data/seeders/data-generator.js +4 -1
  270. package/core/server/data/seeders/importers/members-login-events-importer.js +0 -1
  271. package/core/server/data/seeders/importers/members-newsletters-importer.js +0 -1
  272. package/core/server/data/seeders/importers/members-paid-subscription-events-importer.js +0 -1
  273. package/core/server/data/seeders/importers/members-status-events-importer.js +0 -1
  274. package/core/server/data/seeders/importers/members-stripe-customers-importer.js +0 -1
  275. package/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js +0 -1
  276. package/core/server/data/seeders/importers/members-subscribe-events-importer.js +0 -1
  277. package/core/server/data/seeders/importers/members-subscription-created-events-importer.js +0 -1
  278. package/core/server/lib/lexical.js +62 -36
  279. package/core/server/lib/post-revisions.js +0 -2
  280. package/core/server/lib/post-revisions.ts +0 -3
  281. package/core/server/models/index.js +0 -3
  282. package/core/server/services/auth/reset-authentication.js +0 -2
  283. package/core/server/services/auth/reset-authentication.ts +0 -3
  284. package/core/server/services/auth/session/index.js +0 -1
  285. package/core/server/services/automations/automations-api.js +59 -17
  286. package/core/server/services/automations/automations-api.ts +69 -16
  287. package/core/server/services/automations/database-automations-repository.js +58 -3
  288. package/core/server/services/automations/database-automations-repository.ts +70 -3
  289. package/core/server/services/automations/stale-lock-cutoff.js +7 -0
  290. package/core/server/services/automations/stale-lock-cutoff.ts +5 -0
  291. package/core/server/services/email-address/email-address-parser.js.d.ts +0 -1
  292. package/core/server/services/email-service/batch-sending-service.js +99 -26
  293. package/core/server/services/email-service/email-service-wrapper.js +7 -2
  294. package/core/server/services/email-service/email-service.js +146 -4
  295. package/core/server/services/gift-links/database.js +0 -3
  296. package/core/server/services/gift-links/database.ts +1 -4
  297. package/core/server/services/gift-links/index.js +2 -2
  298. package/core/server/services/gift-links/index.ts +4 -2
  299. package/core/server/services/gift-links/models.js +0 -2
  300. package/core/server/services/gift-links/models.ts +0 -2
  301. package/core/server/services/gift-links/queries.js +2 -2
  302. package/core/server/services/gift-links/queries.ts +2 -2
  303. package/core/server/services/gift-links/serializers.js +0 -2
  304. package/core/server/services/gift-links/serializers.ts +0 -2
  305. package/core/server/services/gift-links/service.js +54 -35
  306. package/core/server/services/gift-links/service.ts +70 -35
  307. package/core/server/services/gifts/gift-reminder-scheduler.js +0 -2
  308. package/core/server/services/gifts/gift-reminder-scheduler.ts +0 -2
  309. package/core/server/services/indexnow.js +8 -1
  310. package/core/server/services/internal-keys/index.js +0 -1
  311. package/core/server/services/internal-keys/index.ts +0 -2
  312. package/core/server/services/lib/dynamic-redirect-manager.d.ts +0 -1
  313. package/core/server/services/lib/in-memory-repository.js +0 -1
  314. package/core/server/services/lib/in-memory-repository.ts +0 -2
  315. package/core/server/services/members/api.js +0 -3
  316. package/core/server/services/members/create-paid-member-shim.js +31 -0
  317. package/core/server/services/members/create-paid-member-shim.ts +31 -0
  318. package/core/server/services/members/emails/signin.js +0 -1
  319. package/core/server/services/members/members-api/members-api.js +0 -2
  320. package/core/server/services/members/members-api/repositories/member-repository.js +0 -3
  321. package/core/server/services/members/service.js +4 -0
  322. package/core/server/services/post-scheduling/index.js +0 -2
  323. package/core/server/services/post-scheduling/index.ts +0 -2
  324. package/core/server/services/post-scheduling/post-scheduling.js +0 -5
  325. package/core/server/services/post-scheduling/post-scheduling.ts +0 -5
  326. package/core/server/services/recommendations/service/bookshelf-repository.js +0 -1
  327. package/core/server/services/recommendations/service/bookshelf-repository.ts +0 -4
  328. package/core/server/services/recommendations/service/incoming-recommendation-service.js +0 -1
  329. package/core/server/services/recommendations/service/incoming-recommendation-service.ts +0 -1
  330. package/core/server/services/recommendations/service/recommendation-controller.js +0 -1
  331. package/core/server/services/recommendations/service/recommendation-controller.ts +0 -1
  332. package/core/server/services/recommendations/service/recommendation-metadata-service.js +0 -1
  333. package/core/server/services/recommendations/service/recommendation-metadata-service.ts +0 -1
  334. package/core/server/services/recommendations/service/recommendation.js +0 -1
  335. package/core/server/services/recommendations/service/recommendation.ts +0 -1
  336. package/core/server/services/remote-flags/index.js +0 -1
  337. package/core/server/services/remote-flags/index.ts +0 -1
  338. package/core/server/services/slack.js +5 -1
  339. package/core/server/services/update-check/index.js +0 -1
  340. package/core/server/services/url/lazy-find-resource.js +0 -1
  341. package/core/server/services/url/lazy-find-resource.ts +0 -2
  342. package/core/server/services/url/lazy-url-service.js +0 -1
  343. package/core/server/services/url/lazy-url-service.ts +0 -2
  344. package/core/server/services/url/permalink-matcher.js +0 -1
  345. package/core/server/services/url/permalink-matcher.ts +0 -2
  346. package/core/server/services/url/router-filter.js +0 -2
  347. package/core/server/services/url/router-filter.ts +0 -2
  348. package/core/server/services/url/url-service-facade.js +0 -1
  349. package/core/server/services/url/url-service-facade.ts +0 -2
  350. package/core/server/services/verification/verification-webhook-service.js +0 -1
  351. package/core/server/services/verification/verification-webhook-service.ts +0 -1
  352. package/core/server/web/api/endpoints/admin/routes.js +7 -7
  353. package/core/shared/events-ts/post-deleted-event.js +0 -2
  354. package/core/shared/events-ts/post-deleted-event.ts +0 -2
  355. package/core/shared/labs.js +0 -1
  356. package/package.json +8 -16
  357. package/pnpm-lock.yaml +162 -113
  358. package/core/built/admin/assets/automations-BthXBzzq.js +0 -1
  359. package/core/built/admin/assets/editor-91xrI2A3.js +0 -8
  360. package/core/built/admin/assets/index-BYb09f-7.css +0 -1
  361. package/core/built/admin/assets/minus-DmrVsJ-z.js +0 -1
  362. package/core/built/admin/assets/modals-dnqxCS3l.js +0 -35
  363. package/core/server/models/outbox.js +0 -24
  364. package/core/server/services/outbox/events/start-outbox-processing-event.js +0 -22
  365. package/core/server/services/outbox/handlers/member-created.js +0 -85
  366. package/core/server/services/outbox/index.js +0 -49
  367. package/core/server/services/outbox/jobs/index.js +0 -30
  368. package/core/server/services/outbox/jobs/lib/constants.js +0 -17
  369. package/core/server/services/outbox/jobs/lib/process-entries.js +0 -117
  370. package/core/server/services/outbox/jobs/lib/process-outbox.js +0 -96
  371. package/core/server/services/outbox/jobs/outbox-job.js +0 -36
  372. package/types/ghost-storage-base.d.ts +0 -22
@@ -8,6 +8,7 @@ const messages = {
8
8
  };
9
9
 
10
10
  const MAX_SENDING_CONCURRENCY = 2;
11
+ const SHUTDOWN_CODE = 'BULK_EMAIL_SHUTDOWN_IN_PROGRESS';
11
12
 
12
13
  /**
13
14
  * @typedef {import('./sending-service')} SendingService
@@ -33,6 +34,8 @@ class BatchSendingService {
33
34
  #sentry;
34
35
  #debugStorageFilePath;
35
36
  #getRequiredUrlRelations;
37
+ #shuttingDown = false;
38
+ #inFlight = new Set();
36
39
 
37
40
  // Retry database queries happening before sending the email
38
41
  #BEFORE_RETRY_CONFIG = {maxRetries: 10, maxTime: 10 * 60 * 1000, sleep: 2000};
@@ -116,6 +119,19 @@ class BatchSendingService {
116
119
  return this.#BEFORE_RETRY_CONFIG;
117
120
  }
118
121
 
122
+ /**
123
+ * Stops the batch workers and waits for any in-flight sends to finish.
124
+ * Called by the cleanup pipeline when the container is shutting down. Idempotent.
125
+ */
126
+ async onShutdown() {
127
+ this.#shuttingDown = true;
128
+ if (this.#inFlight.size > 0) {
129
+ logging.warn(`Email send shutdown: awaiting ${this.#inFlight.size} in-flight sendBatches call(s) to settle`);
130
+ }
131
+ await Promise.allSettled([...this.#inFlight]);
132
+ logging.warn(`Email send shutdown: drain complete`);
133
+ }
134
+
119
135
  /**
120
136
  * Schedules a background job that sends the email in the background if it is pending or failed.
121
137
  * @param {Email} email
@@ -171,6 +187,10 @@ class BatchSendingService {
171
187
  }, {patch: true, autoRefresh: false});
172
188
  }, {...this.#AFTER_RETRY_CONFIG, description: `email ${emailId} -> submitted`});
173
189
  } catch (e) {
190
+ if (e && e.code === SHUTDOWN_CODE) {
191
+ logging.info(`Email ${email.id} send stopped because the container is shutting down — leaving status=submitting so it can resume on next boot`);
192
+ return;
193
+ }
174
194
  const ghostError = new errors.EmailError({
175
195
  err: e,
176
196
  code: 'BULK_EMAIL_SEND_FAILED',
@@ -432,6 +452,20 @@ class BatchSendingService {
432
452
  }
433
453
 
434
454
  async sendBatches({email, batches, post, newsletter}) {
455
+ // Track the in-flight call so onShutdown can await it. The cleanup task
456
+ // must wait for the Mailgun POST + EmailBatch DB write to settle before
457
+ // ghost-server schedules process.exit, otherwise mid-flight requests get
458
+ // killed and EmailBatch rows never record what Mailgun actually accepted.
459
+ const work = this.#sendBatchesInner({email, batches, post, newsletter});
460
+ this.#inFlight.add(work);
461
+ try {
462
+ return await work;
463
+ } finally {
464
+ this.#inFlight.delete(work);
465
+ }
466
+ }
467
+
468
+ async #sendBatchesInner({email, batches, post, newsletter}) {
435
469
  logging.info(`Sending ${batches.length} batches for email ${email.id}`);
436
470
  const deadline = this.getDeliveryDeadline(email);
437
471
 
@@ -442,21 +476,28 @@ class BatchSendingService {
442
476
  /** @type {Map<string, import('./email-renderer').EmailBody>} */
443
477
  const emailBodyCache = new Map();
444
478
 
445
- // Calculate deliverytimes for the batches
479
+ // Spread batches across the target window if one is configured. `deliveryTimes`
480
+ // handles the past-deadline case internally (resume of an interrupted send or a
481
+ // delayed job): if the original `created_at + targetDeliveryWindow` deadline has
482
+ // passed, the function respreads remaining batches over a fresh window starting
483
+ // now instead of returning undefined for every batch (which would dump every
484
+ // remaining batch into Mailgun in the same second and break the rate-spread).
485
+ const targetDeliveryWindow = this.#sendingService.getTargetDeliveryWindow();
486
+ const shouldApplyDeliveryTimes = targetDeliveryWindow !== undefined && targetDeliveryWindow > 0;
446
487
  const deliveryTimes = this.calculateDeliveryTimes(email, batches.length);
447
488
 
448
489
  // Loop batches and send them via the EmailProvider
449
490
  let succeededCount = 0;
450
491
  const queue = batches.slice();
451
492
 
452
- // Bind this
453
- let runNext;
454
- runNext = async () => {
455
- const batch = queue.shift();
456
- if (batch) {
493
+ const runWorker = async () => {
494
+ while (!this.#shuttingDown) {
495
+ const batch = queue.shift();
496
+ if (!batch) {
497
+ return;
498
+ }
457
499
  const batchData = {email, batch, post, newsletter, emailBodyCache, deliveryTime: undefined};
458
- // Only set a delivery time if we have a deadline and it hasn't past yet
459
- if (deadline && deadline.getTime() > Date.now()) {
500
+ if (shouldApplyDeliveryTimes) {
460
501
  const deliveryTime = deliveryTimes.shift();
461
502
  if (deliveryTime && deliveryTime >= Date.now()) {
462
503
  batchData.deliveryTime = deliveryTime;
@@ -465,12 +506,20 @@ class BatchSendingService {
465
506
  if (await this.sendBatch(batchData)) {
466
507
  succeededCount += 1;
467
508
  }
468
- await runNext();
469
509
  }
470
510
  };
471
511
 
472
512
  // Run maximum MAX_SENDING_CONCURRENCY at the same time
473
- await Promise.all(new Array(MAX_SENDING_CONCURRENCY).fill(0).map(() => runNext()));
513
+ await Promise.all(new Array(MAX_SENDING_CONCURRENCY).fill(0).map(() => runWorker()));
514
+
515
+ logging.info(`Email ${email.id} send done: ${succeededCount}/${batches.length} batches succeeded, ${queue.length} unstarted`);
516
+
517
+ if (this.#shuttingDown && queue.length > 0) {
518
+ throw new errors.InternalServerError({
519
+ code: SHUTDOWN_CODE,
520
+ message: 'Email send stopped because the container is shutting down'
521
+ });
522
+ }
474
523
 
475
524
  if (succeededCount < batches.length) {
476
525
  if (succeededCount > 0) {
@@ -501,8 +550,24 @@ class BatchSendingService {
501
550
  {...this.#getBeforeRetryConfig(email), description: `updateStatusLock batch ${originalBatch.id} -> submitting`}
502
551
  );
503
552
  if (!batch) {
504
- logging.error(`Tried sending email batch that is not pending or failed ${originalBatch.id}`);
505
- return true;
553
+ // updateStatusLock returned undefined: the batch's current status is neither
554
+ // `pending` nor `failed`, so the lock didn't engage. Two distinct cases, and
555
+ // they need different handling — collapsing them is the bug this branch fixes.
556
+ const currentStatus = originalBatch.get('status');
557
+ if (currentStatus === 'submitted') {
558
+ // Mailgun accepted this batch on a prior run. Nothing to do; return true so
559
+ // the parent email's success counter stays accurate. Expected path during
560
+ // resume of an interrupted send where some batches finished before the crash.
561
+ logging.info(`Email batch ${originalBatch.id} already submitted on a prior run; skipping`);
562
+ return true;
563
+ }
564
+ // Otherwise currentStatus is `submitting`: orphan from a worker that crashed
565
+ // mid-batch. We have no record of Mailgun accepting it, and re-sending risks
566
+ // duplicates. Return false so the parent email is promoted to `failed` and an
567
+ // operator can reconcile against the Mailgun dashboard before retrying.
568
+ // Runbook: docs/newsletter-send-plan-v9.md.
569
+ logging.error(`Email batch ${originalBatch.id} is stuck in status=${currentStatus} (orphan from a crashed worker); not re-sending — marking parent email as failed for operator review`);
570
+ return false;
506
571
  }
507
572
 
508
573
  let succeeded = false;
@@ -760,23 +825,31 @@ class BatchSendingService {
760
825
  * @param {number} numBatches - the number of batches to be sent
761
826
  */
762
827
  calculateDeliveryTimes(email, numBatches) {
763
- const deadline = this.getDeliveryDeadline(email);
764
- const now = new Date();
765
- // If there is no deadline (target delivery window is not set) or the deadline is in the past, delivery immediately
766
- if (!deadline || now >= deadline) {
828
+ let deadline = this.getDeliveryDeadline(email);
829
+ if (!deadline) {
767
830
  return new Array(numBatches).fill(undefined);
768
- } else {
769
- const timeToDeadline = deadline.getTime() - now.getTime();
770
- const batchDelay = timeToDeadline / numBatches;
771
- const deliveryTimes = [];
772
- for (let i = 0; i < numBatches; i++) {
773
- const delay = batchDelay * i;
774
- const deliveryTime = new Date(now.getTime() + delay);
775
- deliveryTimes.push(deliveryTime);
776
- }
777
- return deliveryTimes;
778
831
  }
832
+ const now = new Date();
833
+ // If the original `created_at + targetDeliveryWindow` deadline has passed (resume
834
+ // of an interrupted send, or a job that was delayed for any reason), respread
835
+ // batches over a fresh window of the same size starting now. Otherwise a
836
+ // 50%-resumed 10-minute send would dump every remaining batch into Mailgun in
837
+ // the same second and defeat the rate-spread.
838
+ if (now >= deadline) {
839
+ const targetDeliveryWindow = this.#sendingService.getTargetDeliveryWindow();
840
+ deadline = new Date(now.getTime() + targetDeliveryWindow);
841
+ }
842
+ const timeToDeadline = deadline.getTime() - now.getTime();
843
+ const batchDelay = timeToDeadline / numBatches;
844
+ const deliveryTimes = [];
845
+ for (let i = 0; i < numBatches; i++) {
846
+ const delay = batchDelay * i;
847
+ const deliveryTime = new Date(now.getTime() + delay);
848
+ deliveryTimes.push(deliveryTime);
849
+ }
850
+ return deliveryTimes;
779
851
  }
780
852
  }
781
853
 
782
854
  module.exports = BatchSendingService;
855
+ module.exports.SHUTDOWN_CODE = SHUTDOWN_CODE;
@@ -10,7 +10,7 @@ class EmailServiceWrapper {
10
10
  return jsonModel.url;
11
11
  }
12
12
 
13
- init() {
13
+ init({ghostServer} = {}) {
14
14
  if (this.service) {
15
15
  return;
16
16
  }
@@ -131,13 +131,18 @@ class EmailServiceWrapper {
131
131
  debugStorageFilePath: configService.getContentPath('data')
132
132
  });
133
133
 
134
+ if (ghostServer) {
135
+ ghostServer.registerCleanupTask(() => batchSendingService.onShutdown());
136
+ }
137
+
134
138
  this.renderer = emailRenderer;
135
139
 
136
140
  this.service = new EmailService({
137
141
  batchSendingService,
138
142
  sendingService,
139
143
  models: {
140
- Email
144
+ Email,
145
+ EmailBatch
141
146
  },
142
147
  settingsCache,
143
148
  emailRenderer,
@@ -20,9 +20,16 @@ const messages = {
20
20
  archivedNewsletterError: 'Cannot send email to archived newsletters',
21
21
  missingNewsletterError: 'The post does not have a newsletter relation',
22
22
  emailSendingDisabled: `Email sending is temporarily disabled because your account is currently in review. You should have an email about this from us already, but you can also reach us any time at support@ghost.org`,
23
- retryEmailStatusError: 'Can only retry emails for published posts'
23
+ retryEmailStatusError: 'Can only retry emails for published posts',
24
+ retryEmailNotFailed: 'Only failed emails can be retried'
24
25
  };
25
26
 
27
+ // Resume scanner won't pick up `submitting` rows older than this. Rows beyond the cutoff
28
+ // are flipped to `failed` on first boot so they surface in admin UI for operator review
29
+ // rather than being silently resumed (and sending stale newsletters to current members).
30
+ // Override via `bulkEmail:resumeMaxAgeMs` in config.
31
+ const DEFAULT_RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000;
32
+
26
33
  class EmailService {
27
34
  #batchSendingService;
28
35
  #sendingService;
@@ -44,6 +51,7 @@ class EmailService {
44
51
  * @param {SendingService} dependencies.sendingService
45
52
  * @param {object} dependencies.models
46
53
  * @param {object} dependencies.models.Email
54
+ * @param {object} [dependencies.models.EmailBatch] - Required for resumeInterruptedSends breadcrumbs
47
55
  * @param {object} dependencies.settingsCache
48
56
  * @param {EmailRenderer} dependencies.emailRenderer
49
57
  * @param {EmailSegmenter} dependencies.emailSegmenter
@@ -189,6 +197,136 @@ class EmailService {
189
197
  return email;
190
198
  }
191
199
 
200
+ /**
201
+ * Boot-time scanner: resumes newsletter emails left in `submitting` after a
202
+ * previous container's interrupted send. Iterates sequentially; one failure
203
+ * does not skip others. Rows older than the configured max-age are flipped
204
+ * to `failed` (not resumed) so stale content does not get sent to current members.
205
+ */
206
+ async resumeInterruptedSends() {
207
+ const maxAgeMs = this.#config?.get?.('bulkEmail:resumeMaxAgeMs') ?? DEFAULT_RESUME_MAX_AGE_MS;
208
+ const cutoffIso = new Date(Date.now() - maxAgeMs).toISOString();
209
+
210
+ // Stale rows: too old to safely resume. Flip to `failed` so they surface in admin UI
211
+ // for operator review instead of being silently left in `submitting` forever.
212
+ const stale = await this.#models.Email.findAll({
213
+ filter: `status:submitting+created_at:<'${cutoffIso}'`
214
+ });
215
+ const staleList = stale.models || stale;
216
+ for (const email of staleList) {
217
+ try {
218
+ const locked = await this.#batchSendingService.updateStatusLock(
219
+ this.#models.Email,
220
+ email.id,
221
+ 'failed',
222
+ ['submitting']
223
+ );
224
+ if (locked) {
225
+ logging.warn(`Email resume: ${email.id} created_at=${email.get('created_at') && new Date(email.get('created_at')).toISOString()} exceeds max age (${maxAgeMs}ms) — flipped to failed for operator review`);
226
+ }
227
+ } catch (e) {
228
+ logging.error(e);
229
+ }
230
+ }
231
+
232
+ // Fresh rows: within the cutoff. Resume through the normal emailJob path.
233
+ const emails = await this.#models.Email.findAll({
234
+ filter: `status:submitting+created_at:>'${cutoffIso}'`
235
+ });
236
+ const list = emails.models || emails;
237
+ if (staleList.length === 0 && list.length === 0) {
238
+ return;
239
+ }
240
+ if (list.length > 0) {
241
+ logging.info(`Email resume: found ${list.length} email(s) in submitting status within max age (${maxAgeMs}ms)`);
242
+ }
243
+
244
+ for (const email of list) {
245
+ try {
246
+ await this.#resumeOneEmail(email);
247
+ } catch (e) {
248
+ logging.error(e);
249
+ }
250
+ }
251
+
252
+ logging.info(`Email resume scan complete: ${staleList.length} stale email(s) flipped to failed, ${list.length} fresh email(s) rescheduled`);
253
+ }
254
+
255
+ async #resumeOneEmail(email) {
256
+ const post = await email.getLazyRelation('post');
257
+ const postStatus = post ? post.get('status') : null;
258
+ const sendable = postStatus === 'published' || postStatus === 'sent';
259
+
260
+ if (!sendable) {
261
+ // Parent post was unpublished or deleted while the email was in flight.
262
+ // Can't resume — mark the email as failed so it stops showing as "submitting".
263
+ const locked = await this.#batchSendingService.updateStatusLock(
264
+ this.#models.Email,
265
+ email.id,
266
+ 'failed',
267
+ ['submitting']
268
+ );
269
+ if (locked) {
270
+ logging.warn(`Email resume: ${email.id} parent post status=${postStatus} is not sendable — marked email as failed`);
271
+ }
272
+ return;
273
+ }
274
+
275
+ // Flip submitting -> pending so emailJob's status lock (['pending', 'failed']) admits it.
276
+ // If updateStatusLock returns undefined, another path raced us — just continue.
277
+ const locked = await this.#batchSendingService.updateStatusLock(
278
+ this.#models.Email,
279
+ email.id,
280
+ 'pending',
281
+ ['submitting']
282
+ );
283
+ if (!locked) {
284
+ logging.info(`Email resume: ${email.id} status changed before lock could be taken — skipping`);
285
+ return;
286
+ }
287
+
288
+ // Structured breadcrumb so post-incident timing/batch state is recoverable from logs.
289
+ const breadcrumb = await this.#buildResumeBreadcrumb(email);
290
+ logging.warn(`Email resume: scheduling ${email.id} for re-send ${JSON.stringify(breadcrumb)}`);
291
+
292
+ // Skip checkLimits — this email already passed limits when first sent.
293
+ this.#batchSendingService.scheduleEmail(email);
294
+ }
295
+
296
+ async #buildResumeBreadcrumb(email) {
297
+ const counts = {};
298
+ let latestStatusWrite = email.get('updated_at') || email.get('created_at');
299
+ if (this.#models.EmailBatch) {
300
+ try {
301
+ const batches = await this.#models.EmailBatch.findAll({
302
+ filter: `email_id:'${email.id}'`
303
+ });
304
+ for (const batch of batches.models || batches) {
305
+ const status = batch.get('status');
306
+ counts[status] = (counts[status] || 0) + 1;
307
+ const updatedAt = batch.get('updated_at');
308
+ if (updatedAt && (!latestStatusWrite || updatedAt > latestStatusWrite)) {
309
+ latestStatusWrite = updatedAt;
310
+ }
311
+ }
312
+ } catch (e) {
313
+ // Breadcrumb is best-effort; never block resume on it.
314
+ logging.warn(`Email resume: could not build breadcrumb for ${email.id}: ${e.message}`);
315
+ }
316
+ }
317
+ const msSinceLastStatusWrite = latestStatusWrite
318
+ ? Date.now() - new Date(latestStatusWrite).getTime()
319
+ : null;
320
+ const targetDeliveryWindowMs = this.#config?.get?.('bulkEmail:targetDeliveryWindow') ?? 0;
321
+ return {
322
+ email_id: email.id,
323
+ post_id: email.get('post_id'),
324
+ batch_counts_by_status: counts,
325
+ ms_since_last_status_write: msSinceLastStatusWrite,
326
+ target_delivery_window_ms: targetDeliveryWindowMs
327
+ };
328
+ }
329
+
192
330
  async retryEmail(email) {
193
331
  // Block accidentaly retrying non-published posts (can happen due to bugs in frontend)
194
332
  const post = await email.getLazyRelation('post');
@@ -198,13 +336,17 @@ class EmailService {
198
336
  });
199
337
  }
200
338
 
339
+ if (email.get('status') !== 'failed') {
340
+ throw new errors.BadRequestError({
341
+ message: tpl(messages.retryEmailNotFailed)
342
+ });
343
+ }
344
+
201
345
  await this.checkLimits();
202
346
 
203
347
  // Change email status back to 'pending' before scheduling
204
348
  // so we have a immediate response when retrying an email (schedule can take a while to kick off sometimes)
205
- if (email.get('status') === 'failed') {
206
- await email.save({status: 'pending'}, {patch: true});
207
- }
349
+ await email.save({status: 'pending'}, {patch: true});
208
350
 
209
351
  this.#batchSendingService.scheduleEmail(email);
210
352
  return email;
@@ -13,9 +13,6 @@ const DbDate = zod_1.z.codec(zod_1.z.union([zod_1.z.date(), zod_1.z.string(), zo
13
13
  exports.DbGiftLink = zod_1.z.object({
14
14
  token: zod_1.z.string(),
15
15
  post_id: zod_1.z.string(),
16
- redeemed_count: zod_1.z.number().int().nonnegative(),
17
- last_redeemed_at: DbDate.nullable(),
18
- revoked_at: DbDate.nullable(),
19
16
  created_at: DbDate,
20
17
  updated_at: DbDate.nullable()
21
18
  });
@@ -13,9 +13,6 @@ const DbDate = z.codec(z.union([z.date(), z.string(), z.number()]), z.date(), {
13
13
  export const DbGiftLink = z.object({
14
14
  token: z.string(),
15
15
  post_id: z.string(),
16
- redeemed_count: z.number().int().nonnegative(),
17
- last_redeemed_at: DbDate.nullable(),
18
- revoked_at: DbDate.nullable(),
19
16
  created_at: DbDate,
20
17
  updated_at: DbDate.nullable()
21
18
  });
@@ -33,7 +30,7 @@ declare module 'knex/types/tables' {
33
30
  interface Tables {
34
31
  gift_links: Knex.CompositeTableType<
35
32
  z.infer<typeof DbGiftLink>,
36
- Omit<z.input<typeof DbGiftLink>, 'revoked_at' | 'updated_at'>,
33
+ Omit<z.input<typeof DbGiftLink>, 'updated_at'>,
37
34
  Partial<z.infer<typeof DbGiftLink>>
38
35
  >;
39
36
  post_gift_links: Knex.CompositeTableType<
@@ -7,7 +7,7 @@ function init() {
7
7
  if (exports.service) {
8
8
  return;
9
9
  }
10
- // eslint-disable-next-line @typescript-eslint/no-require-imports
11
10
  const { knex } = require('../../data/db');
12
- exports.service = new service_1.GiftLinksService({ knex });
11
+ const models = require('../../models');
12
+ exports.service = new service_1.GiftLinksService({ knex, Action: models.Action });
13
13
  }
@@ -1,5 +1,7 @@
1
1
  import {GiftLinksService} from './service';
2
2
 
3
+ export type {RequestContext} from './service';
4
+
3
5
  // Set by init() at boot, not at import: knex only exists once the DB has connected.
4
6
  export let service: GiftLinksService | undefined;
5
7
 
@@ -8,8 +10,8 @@ export function init(): void {
8
10
  return;
9
11
  }
10
12
 
11
- // eslint-disable-next-line @typescript-eslint/no-require-imports
12
13
  const {knex} = require('../../data/db');
14
+ const models = require('../../models');
13
15
 
14
- service = new GiftLinksService({knex});
16
+ service = new GiftLinksService({knex, Action: models.Action});
15
17
  }
@@ -5,7 +5,5 @@ const zod_1 = require("zod");
5
5
  exports.GiftLinkToken = zod_1.z.string().brand('GiftLinkToken');
6
6
  exports.GiftLink = zod_1.z.object({
7
7
  token: exports.GiftLinkToken,
8
- redeemedCount: zod_1.z.number().int().nonnegative(),
9
- lastRedeemedAt: zod_1.z.date().nullable(),
10
8
  createdAt: zod_1.z.date()
11
9
  });
@@ -5,8 +5,6 @@ export type GiftLinkToken = z.infer<typeof GiftLinkToken>;
5
5
 
6
6
  export const GiftLink = z.object({
7
7
  token: GiftLinkToken,
8
- redeemedCount: z.number().int().nonnegative(),
9
- lastRedeemedAt: z.date().nullable(),
10
8
  createdAt: z.date()
11
9
  });
12
10
  export type GiftLink = z.infer<typeof GiftLink>;
@@ -10,8 +10,6 @@ const models_1 = require("./models");
10
10
  // The columns the read path selects and the codec decodes into a GiftLink.
11
11
  exports.GiftLinkRow = database_1.DbGiftLink.pick({
12
12
  token: true,
13
- redeemed_count: true,
14
- last_redeemed_at: true,
15
13
  created_at: true
16
14
  });
17
15
  // Maps a selected row to/from the domain GiftLink (snake_case to camelCase, token branding).
@@ -20,6 +18,8 @@ exports.giftLinkCodec = zod_1.z.codec(exports.GiftLinkRow, models_1.GiftLink, {
20
18
  encode: link => (0, case_keys_1.snakeKeys)(link)
21
19
  });
22
20
  const giftLinkColumns = Object.keys(exports.GiftLinkRow.shape).map(column => `gift_links.${column}`);
21
+ // LEFT JOINs from posts: a missing post yields zero rows, while a post with no live link yields one
22
+ // row with null link columns. So zero rows means "no such post".
23
23
  function liveLinksForPost(postId) {
24
24
  return (knex) => knex('posts')
25
25
  .where('posts.id', postId)
@@ -7,8 +7,6 @@ import {GiftLink} from './models';
7
7
  // The columns the read path selects and the codec decodes into a GiftLink.
8
8
  export const GiftLinkRow = DbGiftLink.pick({
9
9
  token: true,
10
- redeemed_count: true,
11
- last_redeemed_at: true,
12
10
  created_at: true
13
11
  });
14
12
 
@@ -28,6 +26,8 @@ const giftLinkColumns = Object.keys(GiftLinkRow.shape).map(column => `gift_links
28
26
  // post exists with no live link (hence the nullable columns).
29
27
  type LiveLinkRow = {[K in keyof z.input<typeof GiftLinkRow>]: z.input<typeof GiftLinkRow>[K] | null};
30
28
 
29
+ // LEFT JOINs from posts: a missing post yields zero rows, while a post with no live link yields one
30
+ // row with null link columns. So zero rows means "no such post".
31
31
  export function liveLinksForPost(postId: string) {
32
32
  return (knex: Knex) => knex('posts')
33
33
  .where('posts.id', postId)
@@ -7,8 +7,6 @@ const models_1 = require("./models");
7
7
  // Response schemas — the shapes the admin endpoints emit.
8
8
  const GiftLinkResource = zod_1.z.object({
9
9
  token: zod_1.z.string(),
10
- redeemed_count: zod_1.z.number(),
11
- last_redeemed_at: zod_1.z.date().nullable(),
12
10
  created_at: zod_1.z.date()
13
11
  });
14
12
  const GiftLinksResponse = zod_1.z.object({ gift_links: zod_1.z.array(GiftLinkResource) });
@@ -5,8 +5,6 @@ import {GiftLink} from './models';
5
5
  // Response schemas — the shapes the admin endpoints emit.
6
6
  const GiftLinkResource = z.object({
7
7
  token: z.string(),
8
- redeemed_count: z.number(),
9
- last_redeemed_at: z.date().nullable(),
10
8
  created_at: z.date()
11
9
  });
12
10
  const GiftLinksResponse = z.object({gift_links: z.array(GiftLinkResource)});