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.
- package/MigratorConfig.js +0 -1
- package/bin/create-migration.js +1 -1
- package/bin/generate-golden-email.js +0 -1
- package/components/tryghost-i18n-0.0.0.tgz +0 -0
- package/core/boot.js +13 -7
- package/core/built/admin/assets/{PolarAngleAxis-CLluELUg.js → PolarAngleAxis-DufzOBEz.js} +1 -1
- package/core/built/admin/assets/{_baseAssignValue-Dz1iqdwD.js → _baseAssignValue-BjUm4QeA.js} +1 -1
- package/core/built/admin/assets/{a-large-small-BA74vHPp.js → a-large-small-D0RF_WS9.js} +1 -1
- package/core/built/admin/assets/{account-migration-B-NqdKuN.js → account-migration-B7n6ezrZ.js} +1 -1
- package/core/built/admin/assets/activitypub/{account-migration-ClmVrAXr.js → account-migration-BJeRnMCl.js} +3 -3
- package/core/built/admin/assets/activitypub/activitypub.js +1 -1
- package/core/built/admin/assets/activitypub/{at-sign-9F3qd4Nn.js → at-sign-BygVOKdj.js} +2 -2
- package/core/built/admin/assets/activitypub/{avatar-flipboard-jBHA13Ej.js → avatar-flipboard-CNcjI8lK.js} +2 -2
- package/core/built/admin/assets/activitypub/{bluesky-sharing-1irhvVtF.js → bluesky-sharing-mgBNeRfA.js} +4 -4
- package/core/built/admin/assets/activitypub/{copy-DXRvsGiK.js → copy-BMChXIQc.js} +2 -2
- package/core/built/admin/assets/activitypub/{deleted-feed-item-67VQJde4.js → deleted-feed-item-D-spb6V1.js} +2 -2
- package/core/built/admin/assets/activitypub/{edit-profile-BfpbEj98.js → edit-profile-CDQ3V8-c.js} +4 -4
- package/core/built/admin/assets/activitypub/{feed-DUrI7SPC.js → feed-Vqr3WU-V.js} +4 -4
- package/core/built/admin/assets/activitypub/{hash-CMtB10PN.js → hash-D7SSXVOg.js} +2 -2
- package/core/built/admin/assets/activitypub/{inbox-2xq8SJ7A.js → inbox-BHk5Dw27.js} +2 -2
- package/core/built/admin/assets/activitypub/{index-BT97xLpd.js → index-BejkeH-m.js} +2 -2
- package/core/built/admin/assets/activitypub/{index-DssHQAjK.js → index-BqL23J-m.js} +32 -32
- package/core/built/admin/assets/activitypub/{index-Ba6FXt8y.js → index-CHhvjNuG.js} +6 -6
- package/core/built/admin/assets/activitypub/{index-ERtz4Bvy.js → index-CZsNO9hA.js} +5 -5
- package/core/built/admin/assets/activitypub/{index-sgY7lLrg.js → index-CaIhRYzz.js} +3 -3
- package/core/built/admin/assets/activitypub/{index-D1EWoXrK.js → index-DA2MkQVu.js} +2 -2
- package/core/built/admin/assets/activitypub/{index-sudOvKcY.js → index-DHhFcmfu.js} +21 -21
- package/core/built/admin/assets/activitypub/{index-Ccighz1t.js → index-sh2bDSls.js} +4 -4
- package/core/built/admin/assets/activitypub/{label-KQSsSjmN.js → label-D80yhOIm.js} +2 -2
- package/core/built/admin/assets/activitypub/{moderation-B78if-r0.js → moderation-LJNmKBCi.js} +3 -3
- package/core/built/admin/assets/activitypub/{note-Bfr7ovn7.js → note-CyjKLcQ-.js} +4 -4
- package/core/built/admin/assets/activitypub/{reply-CehesW8Z.js → reply-BiiCFx7G.js} +2 -2
- package/core/built/admin/assets/activitypub/{separator-B_Ly_Qq8.js → separator-CY3PMmtI.js} +2 -2
- package/core/built/admin/assets/activitypub/{settings-D6R97p7v.js → settings-BQy6Gr7R.js} +3 -3
- package/core/built/admin/assets/activitypub/{step-1-Cl1YxgZ1.js → step-1-DCqRwgf3.js} +3 -3
- package/core/built/admin/assets/activitypub/{step-2-DDyR--Qe.js → step-2-DQlhehTO.js} +5 -5
- package/core/built/admin/assets/activitypub/{step-3-Don8gv0n.js → step-3-DAUCAcxW.js} +7 -7
- package/core/built/admin/assets/activitypub/{tabs-DTpfm-wu.js → tabs-B_6UuI7r.js} +3 -3
- package/core/built/admin/assets/activitypub/{topic-filter-Oee10885.js → topic-filter--FT1A_dc.js} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{code-editor-view-CGZuiDGx.js → code-editor-view-Bp_sCJR0.js} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-CwEblQZC.js → index-BPXA3pTW.js} +5 -5
- package/core/built/admin/assets/admin-x-settings/{index-rr_UntGE.js → index-BS375mg1.js} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-BpM6W2br.js → index-B_PlQoEG.js} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-BGdS5xw4.js → index-BflhAbu2.js} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-DtWUeJhp.js → index-CftMe8fe.js} +3 -3
- package/core/built/admin/assets/admin-x-settings/{index-B65inCwj.js → index-DRB7FH6y.js} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-DSX261S9.js → index-DijZ3oAM.js} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-DF6YEs01.js → index-cV0Lof9H.js} +1526 -1500
- package/core/built/admin/assets/admin-x-settings/{index-CluZYkUC.js → index-qc4x3vZj.js} +3 -3
- package/core/built/admin/assets/admin-x-settings/{modals-Bf4KOU31.js → modals-CkjuEByw.js} +55 -67
- package/core/built/admin/assets/{arrow-right-BxP3aeA5.js → arrow-right-BF77Yx73.js} +1 -1
- package/core/built/admin/assets/{at-sign-Bf0y-jim.js → at-sign-DhJF3n_V.js} +1 -1
- package/core/built/admin/assets/{audience-DHR1WgFK.js → audience-DHPKKayV.js} +1 -1
- package/core/built/admin/assets/{automated-emails-DbJbDLib.js → automated-emails-DInKYTEC.js} +1 -1
- package/core/built/admin/assets/{automations-aLgTzSsZ.js → automations-DJpeZw9T.js} +1 -1
- package/core/built/admin/assets/automations-DpQKN9j0.js +1 -0
- package/core/built/admin/assets/{avatar-flipboard-B0F2LMW5.js → avatar-flipboard-yRdmFC6R.js} +1 -1
- package/core/built/admin/assets/{bluesky-sharing-DjhtIuyr.js → bluesky-sharing-qSiCERoR.js} +1 -1
- package/core/built/admin/assets/{chart-BGwxlD0z.js → chart-CP2N9ZC-.js} +1 -1
- package/core/built/admin/assets/{chevron-up-C2FeVzre.js → chevron-up-BQ6fOpFR.js} +1 -1
- package/core/built/admin/assets/{chunk.524.31b31f7958dfae110016.js → chunk.524.ad4560630d4d62fbceb5.js} +4 -4
- package/core/built/admin/assets/{chunk.582.a855020953886af6e83b.js → chunk.582.fd4878e4e0af31ef86c0.js} +6 -6
- package/core/built/admin/assets/{chunk.972.258e6b1dab79b2189f1c.js → chunk.98.69968e0f393d82c2949c.js} +4 -4
- package/core/built/admin/assets/{code-editor-view-DzPkm_nJ.js → code-editor-view-CVODiRVJ.js} +1 -1
- package/core/built/admin/assets/{comments-DgItXUWN.js → comments-CP-NxopD.js} +1 -1
- package/core/built/admin/assets/{content-helpers-BNefU_PE.js → content-helpers-Crzkusiq.js} +1 -1
- package/core/built/admin/assets/{copy-DoNNUZlb.js → copy-r1LFsU75.js} +1 -1
- package/core/built/admin/assets/{data-list-CO1cZQRU.js → data-list-CzSc7z-2.js} +1 -1
- package/core/built/admin/assets/{deleted-feed-item-DC2wSAhv.js → deleted-feed-item-PyEM8FiX.js} +1 -1
- package/core/built/admin/assets/{dropzone-BTODLafn.js → dropzone-DI8A9qO6.js} +1 -1
- package/core/built/admin/assets/{edit-profile-BMMpmWIZ.js → edit-profile-DlJ64lV7.js} +1 -1
- package/core/built/admin/assets/editor-CevNZicQ.js +8 -0
- package/core/built/admin/assets/{empty-indicator-Dza0FQlj.js → empty-indicator-Dxg05yWg.js} +1 -1
- package/core/built/admin/assets/{en-yf9qAqTu.js → en-D93GVp3o.js} +1 -1
- package/core/built/admin/assets/{eye-CAqnnfQF.js → eye-6POPPpTw.js} +1 -1
- package/core/built/admin/assets/{feed-BkNsbWiF.js → feed-P_RMYb5x.js} +1 -1
- package/core/built/admin/assets/{field-Ddb1ZOqM.js → field-DeNjwWrL.js} +1 -1
- package/core/built/admin/assets/{filter-query-core-5Mac92kh.js → filter-query-core-DFs5WSmA.js} +1 -1
- package/core/built/admin/assets/{filters-BCb4bgtg.js → filters-C_BSQgb7.js} +1 -1
- package/core/built/admin/assets/{gh-chart-C6zkIg8k.js → gh-chart-pNHAbeQn.js} +1 -1
- package/core/built/admin/assets/{ghost-5313258fd425ced5db6516acde01ba36.js → ghost-3f5b3569e0beec61266cb74077677fbc.js} +3 -3
- package/core/built/admin/assets/{growth-ezUlN8OE.js → growth-B83tVOYO.js} +1 -1
- package/core/built/admin/assets/{hash-DE2OmChI.js → hash-BCTATV5W.js} +1 -1
- package/core/built/admin/assets/{inbox-OthiHvlV.js → inbox-rE3c5suT.js} +1 -1
- package/core/built/admin/assets/{index-BLyzLfyx.js → index-7EiRc5Ay.js} +1 -1
- package/core/built/admin/assets/{index-CV5v0eFE.js → index-B5bpZGTI.js} +1 -1
- package/core/built/admin/assets/index-BNtzAd7H.css +1 -0
- package/core/built/admin/assets/{index-BC1yWeaJ.js → index-BT33w2Io.js} +1 -1
- package/core/built/admin/assets/{index-DjffQ3VJ.js → index-BbkXWdhb.js} +1 -1
- package/core/built/admin/assets/{index-DH6PA3W6.js → index-BfhSrnrJ.js} +1 -1
- package/core/built/admin/assets/{index-CRvK0Iac.js → index-BmR_OUpb.js} +1 -1
- package/core/built/admin/assets/{index-CNV7fxKZ.js → index-BuhMgn5c.js} +1 -1
- package/core/built/admin/assets/{index-pkixJbdq.js → index-C-075SzJ.js} +34 -34
- package/core/built/admin/assets/{index-DJoByVS8.js → index-CB3hJd9E.js} +1 -1
- package/core/built/admin/assets/{index-D3bb-MEJ.js → index-CCISLIQd.js} +1 -1
- package/core/built/admin/assets/{index-BIEhcKiV.js → index-Cnptpx11.js} +1 -1
- package/core/built/admin/assets/{index-DK1cu59e.js → index-CrBRU81r.js} +1 -1
- package/core/built/admin/assets/{index-B0_p1-R0.js → index-CscUHFJp.js} +1 -1
- package/core/built/admin/assets/{index-Bi5qzf86.js → index-D3ZzfuJt.js} +1 -1
- package/core/built/admin/assets/{index-CKPny_6u.js → index-D4wn-U0V.js} +1 -1
- package/core/built/admin/assets/{index-YzcsD17L.js → index-DNRlkUeu.js} +1 -1
- package/core/built/admin/assets/{index-BoMsDCxD.js → index-D_G6negZ.js} +1 -1
- package/core/built/admin/assets/{index-CwsmINtV.js → index-DfxHSK1s.js} +1 -1
- package/core/built/admin/assets/{index-X91I8PcS.js → index-Dpc3COTl.js} +1 -1
- package/core/built/admin/assets/{index-c1h-EQpd.js → index-UsbnU2jy.js} +1 -1
- package/core/built/admin/assets/{index-oKgNVmwC.js → index-VfrJzh5m.js} +1 -1
- package/core/built/admin/assets/{index-D5MrQBSe.js → index-fbxc8Dpt.js} +1 -1
- package/core/built/admin/assets/{input-group-BOLMrDfJ.js → input-group-BLygwiDK.js} +1 -1
- package/core/built/admin/assets/{koenig-lexical-DlzcWUPw.js → koenig-lexical-B9U3sBS5.js} +1 -1
- package/core/built/admin/assets/{kpi-card-BlF7JKRC.js → kpi-card-Bmnvvp4X.js} +1 -1
- package/core/built/admin/assets/{kpi-card-HDshm3lO.js → kpi-card-CGVtzdgJ.js} +1 -1
- package/core/built/admin/assets/{kpi-tabs-Dty6zMSP.js → kpi-tabs-DxbvMkIU.js} +1 -1
- package/core/built/admin/assets/{kpis-CFz4r5wW.js → kpis-CQBAGVjP.js} +1 -1
- package/core/built/admin/assets/{label-CVzNcbjb.js → label-DpTwHQi_.js} +1 -1
- package/core/built/admin/assets/{links-D9dHwDlP.js → links-Bdw25FJF.js} +1 -1
- package/core/built/admin/assets/{list-page-shoQED7N.js → list-page-BvTQwP5F.js} +1 -1
- package/core/built/admin/assets/{loader-circle-cad701gH.js → loader-circle-3HJkQKWf.js} +1 -1
- package/core/built/admin/assets/{mail-DG4PKpZy.js → mail-DvrqP73n.js} +1 -1
- package/core/built/admin/assets/{main-layout-BmFla3Se.js → main-layout-BAPpm0bz.js} +1 -1
- package/core/built/admin/assets/{members-d19Z6k1C.js → members-CF4CzJSf.js} +1 -1
- package/core/built/admin/assets/minus-DJIePlnU.js +1 -0
- package/core/built/admin/assets/modals-CVsVi0zu.js +35 -0
- package/core/built/admin/assets/{moderation-BWBLnh4h.js → moderation-BX1KH5QL.js} +1 -1
- package/core/built/admin/assets/{newsletter-DNY4MroI.js → newsletter-BlKeb58e.js} +1 -1
- package/core/built/admin/assets/{newsletters-B10iBPP-.js → newsletters-3In8H0Z5.js} +1 -1
- package/core/built/admin/assets/{note-S0b6wjRR.js → note-ybwJCAxm.js} +1 -1
- package/core/built/admin/assets/{offers-B-9mZqKd.js → offers-Cl0ZNEMr.js} +1 -1
- package/core/built/admin/assets/{onboarding-route-AMFn0X5G.js → onboarding-route-A3ECeMAi.js} +1 -1
- package/core/built/admin/assets/{overview-WKadyZRW.js → overview-DrMrfsk-.js} +1 -1
- package/core/built/admin/assets/{pagemenu-BofQcFaF.js → pagemenu-O7HbsehH.js} +1 -1
- package/core/built/admin/assets/{pencil-LfMcD0vS.js → pencil-CknTbJeE.js} +1 -1
- package/core/built/admin/assets/{pin-CayUjL8e.js → pin-BNs-JdQ3.js} +1 -1
- package/core/built/admin/assets/{post-analytics-DMHvt3Jl.js → post-analytics-Dk1VloZp.js} +1 -1
- package/core/built/admin/assets/{post-analytics-context-10cqioP1.js → post-analytics-context-kPL94Ivz.js} +1 -1
- package/core/built/admin/assets/{post-analytics-header-DlICE18K.js → post-analytics-header-CpMOozq-.js} +1 -1
- package/core/built/admin/assets/{post-share-modal-Cfwy2UwD.js → post-share-modal-F0dobdhF.js} +1 -1
- package/core/built/admin/assets/posts/{alert-dialog-cQK32v9G.js → alert-dialog-NoPLULhG.js} +5 -5
- package/core/built/admin/assets/posts/{app-utils-DQ2hixop.js → app-utils-BiM9QO4L.js} +3 -3
- package/core/built/admin/assets/posts/{automations-BqkU9-zY.js → automations-BgIAmq9f.js} +3 -3
- package/core/built/admin/assets/posts/{automations-CoilKpVH.js → automations-D_XwO405.js} +30 -30
- package/core/built/admin/assets/posts/{avatar-BejAO6Rj.js → avatar-BYsYOpmq.js} +5 -5
- package/core/built/admin/assets/posts/{badge-BYGRyd2f.js → badge-B54vLono.js} +2 -2
- package/core/built/admin/assets/posts/{button-CG2Dpjqy.js → button-DMLfwOQt.js} +3 -3
- package/core/built/admin/assets/posts/{check-fsC5BvGC.js → check-D6qDi62T.js} +2 -2
- package/core/built/admin/assets/posts/{comments-CsyF5C1S.js → comments-CyYCbvUG.js} +26 -26
- package/core/built/admin/assets/posts/{data-list-JslRF2F5.js → data-list-B0DrMm-x.js} +4 -4
- package/core/built/admin/assets/posts/{dialog-3KY3A8t5.js → dialog-DtEZpY1q.js} +3 -3
- package/core/built/admin/assets/posts/{editor-3HPW_xW4.js → editor-LJHLmUoW.js} +1943 -1943
- package/core/built/admin/assets/posts/{ellipsis-BhK5S70U.js → ellipsis-JeIF0Biz.js} +2 -2
- package/core/built/admin/assets/posts/{empty-indicator-C9DbE9wZ.js → empty-indicator-dktzN32y.js} +2 -2
- package/core/built/admin/assets/posts/{filters-BCBDCnQF.js → filters-D1L-LU_m.js} +10 -10
- package/core/built/admin/assets/posts/{get-site-timezone-83C41MGz.js → get-site-timezone-dsyl8nlB.js} +3 -3
- package/core/built/admin/assets/posts/{growth-COoL1kMe.js → growth-yX8UB_ce.js} +12 -12
- package/core/built/admin/assets/posts/{heading-D3VqNO3z.js → heading-CNte43wH.js} +2 -2
- package/core/built/admin/assets/posts/{hooks-5YpJYWOw.js → hooks-BIgv7QBp.js} +2 -2
- package/core/built/admin/assets/posts/{index-Dqn5WIFh.js → index-2FO6Vy0r.js} +11 -11
- package/core/built/admin/assets/posts/{input-C1qcZBGw.js → input-BycmlCp8.js} +3 -3
- package/core/built/admin/assets/posts/{input-surface-C4vUAdbV.js → input-surface-vY3ACaso.js} +2 -2
- package/core/built/admin/assets/posts/{koenig-lexical-NZjxFY0X.js → koenig-lexical-Cq8SUcZC.js} +2 -2
- package/core/built/admin/assets/posts/{kpis-C2FEXoUT.js → kpis-CAJcU1w0.js} +27 -27
- package/core/built/admin/assets/posts/{label-CmJzmJY7.js → label-BmLTflMF.js} +3 -3
- package/core/built/admin/assets/posts/{links-nzud7t_t.js → links-BVcmMZmF.js} +4 -4
- package/core/built/admin/assets/posts/{list-page-D_rv4TgN.js → list-page-DX2WRn5T.js} +4 -4
- package/core/built/admin/assets/posts/{loading-indicator-DAOD6O2b.js → loading-indicator-C6FD4nS6.js} +3 -3
- package/core/built/admin/assets/posts/{mail-CtH0gTpq.js → mail-BMyoNUVM.js} +2 -2
- package/core/built/admin/assets/posts/{main-layout-VqvCxOXQ.js → main-layout-ClnxdW1Y.js} +2 -2
- package/core/built/admin/assets/posts/{message-circle-D4bCjxKS.js → message-circle-DwfHX-p1.js} +2 -2
- package/core/built/admin/assets/posts/{newsletter-BfwFyv6d.js → newsletter-Bid5_Bim.js} +19 -19
- package/core/built/admin/assets/posts/{overview-DKGum6ro.js → overview-BXti6Wg_.js} +18 -18
- package/core/built/admin/assets/posts/{pencil-Cffx-vm3.js → pencil-ChTk8IpW.js} +2 -2
- package/core/built/admin/assets/posts/{plus-D2ekwsq0.js → plus-DIKls8C6.js} +2 -2
- package/core/built/admin/assets/posts/{popover-DdbdKflP.js → popover-DVZsAT5u.js} +4 -4
- package/core/built/admin/assets/posts/{post-analytics-BleJYoNZ.js → post-analytics-DBL_kS0g.js} +6 -6
- package/core/built/admin/assets/posts/{post-analytics-context-DT3Avn9Q.js → post-analytics-context-Dr5y5nxd.js} +6 -6
- package/core/built/admin/assets/posts/{post-analytics-header-BktlrM_v.js → post-analytics-header-LH2-Jzgp.js} +13 -13
- package/core/built/admin/assets/posts/{post-share-modal-G8v3ZuSg.js → post-share-modal-D4DrfNfJ.js} +6 -6
- package/core/built/admin/assets/posts/{posts-CdWV_nlm.js → posts-ByxhcCrn.js} +2 -2
- package/core/built/admin/assets/posts/posts.js +1 -1
- package/core/built/admin/assets/posts/{search-CXP00fG8.js → search-DZR23LP-.js} +2 -2
- package/core/built/admin/assets/posts/{settings-DEXxDYc2.js → settings-CWyOKKnK.js} +2 -2
- package/core/built/admin/assets/posts/{sheet-CXHmD_eT.js → sheet-CNe7II5c.js} +3 -3
- package/core/built/admin/assets/posts/{site-C6DajagV.js → site-H9RAiQVv.js} +2 -2
- package/core/built/admin/assets/posts/{skeleton-Bihnkn8q.js → skeleton-Cn6x1x_C.js} +3 -3
- package/core/built/admin/assets/posts/{source-icon-C3oQy3UI.js → source-icon-BZOYujd1.js} +4 -4
- package/core/built/admin/assets/posts/{stats-DFicPtCX.js → stats-ugHCg8Oe.js} +4 -4
- package/core/built/admin/assets/posts/{table-DSJmeU5t.js → table-dG3Q5X95.js} +2 -2
- package/core/built/admin/assets/posts/{tabs-oRdVpN8P.js → tabs--EW8uL5B.js} +4 -4
- package/core/built/admin/assets/posts/{tags-6v5Qt_05.js → tags-DVdOfiSf.js} +2 -2
- package/core/built/admin/assets/posts/{tags-Upnkm0CF.js → tags-i0HJqNYU.js} +16 -16
- package/core/built/admin/assets/posts/{tooltip-Ci7pesWi.js → tooltip-ZvsT1Fs9.js} +4 -4
- package/core/built/admin/assets/posts/{trash-2-NCYWjRlg.js → trash-2-BT9fEHUL.js} +2 -2
- package/core/built/admin/assets/posts/{value-CcwzOh2_.js → value-DoFcsWDk.js} +2 -2
- package/core/built/admin/assets/posts/{virtual-list-window-CNahQBXY.js → virtual-list-window-Bt47XODn.js} +3 -3
- package/core/built/admin/assets/posts/{web-C3KrPq0T.js → web-BMTRLT-h.js} +20 -20
- package/core/built/admin/assets/posts/{zap-k30mZEuO.js → zap-DcIfEzKX.js} +2 -2
- package/core/built/admin/assets/{posts-DAfLuxAA.js → posts-DODOCB1E.js} +1 -1
- package/core/built/admin/assets/{power-Drkzg1xX.js → power-BZ7BM82m.js} +1 -1
- package/core/built/admin/assets/{referrers-TYxTJ2On.js → referrers-xwNb6L-8.js} +1 -1
- package/core/built/admin/assets/{repeat-CTM1qAzl.js → repeat-DHMCQMCA.js} +1 -1
- package/core/built/admin/assets/{reply-DXN16g7W.js → reply-DoLURZi2.js} +1 -1
- package/core/built/admin/assets/{rocket-DLixlWs0.js → rocket-BDH89dZr.js} +1 -1
- package/core/built/admin/assets/{select-DbiLEqYb.js → select-oE39QRll.js} +1 -1
- package/core/built/admin/assets/{send-B71C_46D.js → send-QgRN_yRy.js} +1 -1
- package/core/built/admin/assets/{settings-BBMPA3MP.js → settings-CF4NLxuS.js} +4 -4
- package/core/built/admin/assets/{settings-lSFHFrsJ.js → settings-D7e6KLAi.js} +1 -1
- package/core/built/admin/assets/{share-modal-CXrYja6o.js → share-modal-BxIh7i7o.js} +1 -1
- package/core/built/admin/assets/{sort-button-WYo-9Kge.js → sort-button-CEe_40Pi.js} +1 -1
- package/core/built/admin/assets/{source-icon-D6BzjYPP.js → source-icon-CzTNLO6j.js} +1 -1
- package/core/built/admin/assets/{sprout-DVfG_qhy.js → sprout-B2fN41p-.js} +1 -1
- package/core/built/admin/assets/{square-BbUJwQJi.js → square-CJZsjE-3.js} +1 -1
- package/core/built/admin/assets/{stats-CnXALQ4j.js → stats-jTxCK1CB.js} +1 -1
- package/core/built/admin/assets/{stats-view-B-yeMUkl.js → stats-view-B4jz69N8.js} +1 -1
- package/core/built/admin/assets/{step-1-DUl_hlpV.js → step-1-BovjhDNc.js} +1 -1
- package/core/built/admin/assets/{step-2-DjX_A4Ke.js → step-2-ClG40BfX.js} +1 -1
- package/core/built/admin/assets/{step-3-CnDCCA8T.js → step-3-ZMSyEHZI.js} +1 -1
- package/core/built/admin/assets/{table-AagTvMa9.js → table-B8Pj96or.js} +1 -1
- package/core/built/admin/assets/{tabs-IDJWnQD-.js → tabs-BZosOk0o.js} +1 -1
- package/core/built/admin/assets/{tags-CXAcIDw-.js → tags-BpWoYBfB.js} +1 -1
- package/core/built/admin/assets/{tags-C3lykbgu.js → tags-DZukMyuU.js} +1 -1
- package/core/built/admin/assets/{textarea-Cc3nUXep.js → textarea-BtnzsOk7.js} +1 -1
- package/core/built/admin/assets/{thumbs-down-SsjC2lMD.js → thumbs-down-CZTzyz7D.js} +1 -1
- package/core/built/admin/assets/{tiers-J8AeZmRd.js → tiers-Do7zBqAk.js} +1 -1
- package/core/built/admin/assets/{toggle-group-BzD8yimX.js → toggle-group-abk9FYXi.js} +1 -1
- package/core/built/admin/assets/{topic-filter-CA_L6ehz.js → topic-filter-CdMvjkmc.js} +1 -1
- package/core/built/admin/assets/{trash-BKhRqxsX.js → trash-DNXRsBf0.js} +1 -1
- package/core/built/admin/assets/{underline-_HIgX6e1.js → underline-HXWuVCAi.js} +1 -1
- package/core/built/admin/assets/{undo-2-ClkkOS0O.js → undo-2-C2ntNJKC.js} +1 -1
- package/core/built/admin/assets/{upload-DEPHbyvo.js → upload-D52l7HIb.js} +1 -1
- package/core/built/admin/assets/{use-growth-stats-BM2qWt0C.js → use-growth-stats-Dgs0UxDT.js} +1 -1
- package/core/built/admin/assets/{use-simple-pagination-zyklfekn.js → use-simple-pagination-Cj2rVggA.js} +1 -1
- package/core/built/admin/assets/{user-round-check-DoaGU2hC.js → user-round-check-C0GdT99c.js} +1 -1
- package/core/built/admin/assets/{user-round-x-CwEKNuU6.js → user-round-x-CVCvxuUG.js} +1 -1
- package/core/built/admin/assets/{virtual-list-window-C0Cv0gUC.js → virtual-list-window-mdQS2bt_.js} +1 -1
- package/core/built/admin/assets/{wallet-cards-BjdG5glh.js → wallet-cards-CtLF01jQ.js} +1 -1
- package/core/built/admin/assets/{web-BZNnanCT.js → web-DTib8_n8.js} +1 -1
- package/core/built/admin/index.html +6 -6
- package/core/frontend/helpers/ghost_foot.js +12 -2
- package/core/frontend/helpers/prev_post.js +1 -1
- package/core/frontend/helpers/tpl/gift-toast.hbs +130 -0
- package/core/frontend/services/proxy.js +9 -0
- package/core/frontend/services/routing/controllers/entry/canonical-url.js +15 -0
- package/core/frontend/services/routing/controllers/entry/canonical-url.ts +14 -0
- package/core/frontend/services/routing/controllers/entry/gift-links.js +82 -0
- package/core/frontend/services/routing/controllers/entry/gift-links.ts +92 -0
- package/core/frontend/services/routing/controllers/entry/markdown.js +62 -0
- package/core/frontend/services/routing/controllers/entry/markdown.ts +66 -0
- package/core/frontend/services/routing/controllers/entry.js +96 -105
- package/core/frontend/services/routing/controllers/entry.ts +97 -0
- package/core/frontend/services/routing/controllers/index.js +1 -1
- package/core/frontend/web/middleware/frontend-caching.js +9 -0
- package/core/server/adapters/cache/Redis.js +0 -1
- package/core/server/adapters/redirects/RedirectsStoreBase.d.ts +0 -4
- package/core/server/adapters/scheduling/scheduling-default.js +8 -1
- package/core/server/api/endpoints/gift-links.js +23 -16
- package/core/server/api/endpoints/gift-links.ts +25 -17
- package/core/server/api/endpoints/index.js +0 -1
- package/core/server/api/endpoints/previews.js +3 -24
- package/core/server/api/endpoints/utils/serializers/output/gift-links.js +4 -4
- package/core/server/api/endpoints/utils/serializers/output/gift-links.ts +4 -4
- package/core/server/api/endpoints/utils/serializers/output/index.js +0 -1
- package/core/server/api/endpoints/utils/validators/input/automation_email_previews.js +8 -9
- package/core/server/api/endpoints/utils/validators/input/index.js +0 -1
- package/core/server/data/migrations/versions/6.48/2026-06-23-00-00-00-drop-gift-links-revoked-at.js +6 -0
- 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
- package/core/server/data/migrations/versions/6.48/2026-06-24-00-00-00-drop-gift-links-redemption-columns.js +10 -0
- package/core/server/data/schema/fixtures/fixtures.json +2 -2
- package/core/server/data/schema/schema.js +1 -4
- package/core/server/data/seeders/data-generator.js +4 -1
- package/core/server/data/seeders/importers/members-login-events-importer.js +0 -1
- package/core/server/data/seeders/importers/members-newsletters-importer.js +0 -1
- package/core/server/data/seeders/importers/members-paid-subscription-events-importer.js +0 -1
- package/core/server/data/seeders/importers/members-status-events-importer.js +0 -1
- package/core/server/data/seeders/importers/members-stripe-customers-importer.js +0 -1
- package/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js +0 -1
- package/core/server/data/seeders/importers/members-subscribe-events-importer.js +0 -1
- package/core/server/data/seeders/importers/members-subscription-created-events-importer.js +0 -1
- package/core/server/lib/lexical.js +62 -36
- package/core/server/lib/post-revisions.js +0 -2
- package/core/server/lib/post-revisions.ts +0 -3
- package/core/server/models/index.js +0 -3
- package/core/server/services/auth/reset-authentication.js +0 -2
- package/core/server/services/auth/reset-authentication.ts +0 -3
- package/core/server/services/auth/session/index.js +0 -1
- package/core/server/services/automations/automations-api.js +59 -17
- package/core/server/services/automations/automations-api.ts +69 -16
- package/core/server/services/automations/database-automations-repository.js +58 -3
- package/core/server/services/automations/database-automations-repository.ts +70 -3
- package/core/server/services/automations/stale-lock-cutoff.js +7 -0
- package/core/server/services/automations/stale-lock-cutoff.ts +5 -0
- package/core/server/services/email-address/email-address-parser.js.d.ts +0 -1
- package/core/server/services/email-service/batch-sending-service.js +99 -26
- package/core/server/services/email-service/email-service-wrapper.js +7 -2
- package/core/server/services/email-service/email-service.js +146 -4
- package/core/server/services/gift-links/database.js +0 -3
- package/core/server/services/gift-links/database.ts +1 -4
- package/core/server/services/gift-links/index.js +2 -2
- package/core/server/services/gift-links/index.ts +4 -2
- package/core/server/services/gift-links/models.js +0 -2
- package/core/server/services/gift-links/models.ts +0 -2
- package/core/server/services/gift-links/queries.js +2 -2
- package/core/server/services/gift-links/queries.ts +2 -2
- package/core/server/services/gift-links/serializers.js +0 -2
- package/core/server/services/gift-links/serializers.ts +0 -2
- package/core/server/services/gift-links/service.js +54 -35
- package/core/server/services/gift-links/service.ts +70 -35
- package/core/server/services/gifts/gift-reminder-scheduler.js +0 -2
- package/core/server/services/gifts/gift-reminder-scheduler.ts +0 -2
- package/core/server/services/indexnow.js +8 -1
- package/core/server/services/internal-keys/index.js +0 -1
- package/core/server/services/internal-keys/index.ts +0 -2
- package/core/server/services/lib/dynamic-redirect-manager.d.ts +0 -1
- package/core/server/services/lib/in-memory-repository.js +0 -1
- package/core/server/services/lib/in-memory-repository.ts +0 -2
- package/core/server/services/members/api.js +0 -3
- package/core/server/services/members/create-paid-member-shim.js +31 -0
- package/core/server/services/members/create-paid-member-shim.ts +31 -0
- package/core/server/services/members/emails/signin.js +0 -1
- package/core/server/services/members/members-api/members-api.js +0 -2
- package/core/server/services/members/members-api/repositories/member-repository.js +0 -3
- package/core/server/services/members/service.js +4 -0
- package/core/server/services/post-scheduling/index.js +0 -2
- package/core/server/services/post-scheduling/index.ts +0 -2
- package/core/server/services/post-scheduling/post-scheduling.js +0 -5
- package/core/server/services/post-scheduling/post-scheduling.ts +0 -5
- package/core/server/services/recommendations/service/bookshelf-repository.js +0 -1
- package/core/server/services/recommendations/service/bookshelf-repository.ts +0 -4
- package/core/server/services/recommendations/service/incoming-recommendation-service.js +0 -1
- package/core/server/services/recommendations/service/incoming-recommendation-service.ts +0 -1
- package/core/server/services/recommendations/service/recommendation-controller.js +0 -1
- package/core/server/services/recommendations/service/recommendation-controller.ts +0 -1
- package/core/server/services/recommendations/service/recommendation-metadata-service.js +0 -1
- package/core/server/services/recommendations/service/recommendation-metadata-service.ts +0 -1
- package/core/server/services/recommendations/service/recommendation.js +0 -1
- package/core/server/services/recommendations/service/recommendation.ts +0 -1
- package/core/server/services/remote-flags/index.js +0 -1
- package/core/server/services/remote-flags/index.ts +0 -1
- package/core/server/services/slack.js +5 -1
- package/core/server/services/update-check/index.js +0 -1
- package/core/server/services/url/lazy-find-resource.js +0 -1
- package/core/server/services/url/lazy-find-resource.ts +0 -2
- package/core/server/services/url/lazy-url-service.js +0 -1
- package/core/server/services/url/lazy-url-service.ts +0 -2
- package/core/server/services/url/permalink-matcher.js +0 -1
- package/core/server/services/url/permalink-matcher.ts +0 -2
- package/core/server/services/url/router-filter.js +0 -2
- package/core/server/services/url/router-filter.ts +0 -2
- package/core/server/services/url/url-service-facade.js +0 -1
- package/core/server/services/url/url-service-facade.ts +0 -2
- package/core/server/services/verification/verification-webhook-service.js +0 -1
- package/core/server/services/verification/verification-webhook-service.ts +0 -1
- package/core/server/web/api/endpoints/admin/routes.js +7 -7
- package/core/shared/events-ts/post-deleted-event.js +0 -2
- package/core/shared/events-ts/post-deleted-event.ts +0 -2
- package/core/shared/labs.js +0 -1
- package/package.json +8 -16
- package/pnpm-lock.yaml +162 -113
- package/core/built/admin/assets/automations-BthXBzzq.js +0 -1
- package/core/built/admin/assets/editor-91xrI2A3.js +0 -8
- package/core/built/admin/assets/index-BYb09f-7.css +0 -1
- package/core/built/admin/assets/minus-DmrVsJ-z.js +0 -1
- package/core/built/admin/assets/modals-dnqxCS3l.js +0 -35
- package/core/server/models/outbox.js +0 -24
- package/core/server/services/outbox/events/start-outbox-processing-event.js +0 -22
- package/core/server/services/outbox/handlers/member-created.js +0 -85
- package/core/server/services/outbox/index.js +0 -49
- package/core/server/services/outbox/jobs/index.js +0 -30
- package/core/server/services/outbox/jobs/lib/constants.js +0 -17
- package/core/server/services/outbox/jobs/lib/process-entries.js +0 -117
- package/core/server/services/outbox/jobs/lib/process-outbox.js +0 -96
- package/core/server/services/outbox/jobs/outbox-job.js +0 -36
- 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
|
-
//
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
505
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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>, '
|
|
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
|
-
|
|
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)});
|