@aglyn/tenant-data-admin 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +7 -0
- package/jest.integration.config.d.ts +39 -0
- package/jest.integration.setup.d.ts +17 -0
- package/package.json +51 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/lib/render-cache.d.ts +184 -0
- package/src/lib/render-cache.js +198 -0
- package/src/lib/render-cache.js.map +1 -0
- package/src/lib/server/account-addresses.d.ts +160 -0
- package/src/lib/server/account-addresses.js +311 -0
- package/src/lib/server/account-addresses.js.map +1 -0
- package/src/lib/server/account-emails.d.ts +219 -0
- package/src/lib/server/account-emails.js +640 -0
- package/src/lib/server/account-emails.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +180 -0
- package/src/lib/server/admin-audit.js +233 -0
- package/src/lib/server/admin-audit.js.map +1 -0
- package/src/lib/server/analytics-retention.d.ts +63 -0
- package/src/lib/server/analytics-retention.js +66 -0
- package/src/lib/server/analytics-retention.js.map +1 -0
- package/src/lib/server/api-http.d.ts +159 -0
- package/src/lib/server/api-http.js +230 -0
- package/src/lib/server/api-http.js.map +1 -0
- package/src/lib/server/api-keys.d.ts +117 -0
- package/src/lib/server/api-keys.js +298 -0
- package/src/lib/server/api-keys.js.map +1 -0
- package/src/lib/server/auth-handoff.d.ts +165 -0
- package/src/lib/server/auth-handoff.js +482 -0
- package/src/lib/server/auth-handoff.js.map +1 -0
- package/src/lib/server/auth-pools.d.ts +246 -0
- package/src/lib/server/auth-pools.js +551 -0
- package/src/lib/server/auth-pools.js.map +1 -0
- package/src/lib/server/billing-addons.d.ts +171 -0
- package/src/lib/server/billing-addons.js +277 -0
- package/src/lib/server/billing-addons.js.map +1 -0
- package/src/lib/server/campaign-attribution-store.d.ts +55 -0
- package/src/lib/server/campaign-attribution-store.js +95 -0
- package/src/lib/server/campaign-attribution-store.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
- package/src/lib/server/campaign-conversion-attribution.js +279 -0
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
- package/src/lib/server/client-error-report.d.ts +301 -0
- package/src/lib/server/client-error-report.js +755 -0
- package/src/lib/server/client-error-report.js.map +1 -0
- package/src/lib/server/connect-account-status.d.ts +74 -0
- package/src/lib/server/connect-account-status.js +94 -0
- package/src/lib/server/connect-account-status.js.map +1 -0
- package/src/lib/server/connect-payout-failure.d.ts +103 -0
- package/src/lib/server/connect-payout-failure.js +127 -0
- package/src/lib/server/connect-payout-failure.js.map +1 -0
- package/src/lib/server/console-api-rate-limit.d.ts +81 -0
- package/src/lib/server/console-api-rate-limit.js +111 -0
- package/src/lib/server/console-api-rate-limit.js.map +1 -0
- package/src/lib/server/console-domains.d.ts +326 -0
- package/src/lib/server/console-domains.js +779 -0
- package/src/lib/server/console-domains.js.map +1 -0
- package/src/lib/server/consume-once.d.ts +91 -0
- package/src/lib/server/consume-once.js +96 -0
- package/src/lib/server/consume-once.js.map +1 -0
- package/src/lib/server/contact-company-link.d.ts +85 -0
- package/src/lib/server/contact-company-link.js +105 -0
- package/src/lib/server/contact-company-link.js.map +1 -0
- package/src/lib/server/contact-email-engagement.d.ts +32 -0
- package/src/lib/server/contact-email-engagement.js +145 -0
- package/src/lib/server/contact-email-engagement.js.map +1 -0
- package/src/lib/server/contact-email-index.d.ts +56 -0
- package/src/lib/server/contact-email-index.js +158 -0
- package/src/lib/server/contact-email-index.js.map +1 -0
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +88 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-merge.d.ts +55 -0
- package/src/lib/server/contact-merge.js +279 -0
- package/src/lib/server/contact-merge.js.map +1 -0
- package/src/lib/server/contact-suppression.d.ts +153 -0
- package/src/lib/server/contact-suppression.js +259 -0
- package/src/lib/server/contact-suppression.js.map +1 -0
- package/src/lib/server/crm-booking-activity.d.ts +86 -0
- package/src/lib/server/crm-booking-activity.js +251 -0
- package/src/lib/server/crm-booking-activity.js.map +1 -0
- package/src/lib/server/crm-email-activity.d.ts +112 -0
- package/src/lib/server/crm-email-activity.js +143 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.d.ts +145 -0
- package/src/lib/server/crm-inbound-email.js +287 -0
- package/src/lib/server/crm-inbound-email.js.map +1 -0
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- package/src/lib/server/crm-records.d.ts +144 -0
- package/src/lib/server/crm-records.js +218 -0
- package/src/lib/server/crm-records.js.map +1 -0
- package/src/lib/server/csp-aggregate.d.ts +62 -0
- package/src/lib/server/csp-aggregate.js +276 -0
- package/src/lib/server/csp-aggregate.js.map +1 -0
- package/src/lib/server/data-storage-gate.d.ts +66 -0
- package/src/lib/server/data-storage-gate.js +48 -0
- package/src/lib/server/data-storage-gate.js.map +1 -0
- package/src/lib/server/dataset-live-pages.d.ts +179 -0
- package/src/lib/server/dataset-live-pages.js +421 -0
- package/src/lib/server/dataset-live-pages.js.map +1 -0
- package/src/lib/server/dns-probe.d.ts +53 -0
- package/src/lib/server/dns-probe.js +184 -0
- package/src/lib/server/dns-probe.js.map +1 -0
- package/src/lib/server/document-id.d.ts +29 -0
- package/src/lib/server/document-id.js +100 -0
- package/src/lib/server/document-id.js.map +1 -0
- package/src/lib/server/domain-provider-vercel.d.ts +33 -0
- package/src/lib/server/domain-provider-vercel.js +298 -0
- package/src/lib/server/domain-provider-vercel.js.map +1 -0
- package/src/lib/server/domain-provider-webhook.d.ts +73 -0
- package/src/lib/server/domain-provider-webhook.js +223 -0
- package/src/lib/server/domain-provider-webhook.js.map +1 -0
- package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
- package/src/lib/server/domain-provider-wildcard.js +163 -0
- package/src/lib/server/domain-provider-wildcard.js.map +1 -0
- package/src/lib/server/domain-provider.d.ts +197 -0
- package/src/lib/server/domain-provider.js +221 -0
- package/src/lib/server/domain-provider.js.map +1 -0
- package/src/lib/server/duplicate-activity.d.ts +54 -0
- package/src/lib/server/duplicate-activity.js +67 -0
- package/src/lib/server/duplicate-activity.js.map +1 -0
- package/src/lib/server/duplicate-resource.d.ts +55 -0
- package/src/lib/server/duplicate-resource.js +474 -0
- package/src/lib/server/duplicate-resource.js.map +1 -0
- package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
- package/src/lib/server/dynamic-list-materialize.js +581 -0
- package/src/lib/server/dynamic-list-materialize.js.map +1 -0
- package/src/lib/server/edit-access-authz.d.ts +56 -0
- package/src/lib/server/edit-access-authz.js +107 -0
- package/src/lib/server/edit-access-authz.js.map +1 -0
- package/src/lib/server/edit-access-token.d.ts +72 -0
- package/src/lib/server/edit-access-token.js +116 -0
- package/src/lib/server/edit-access-token.js.map +1 -0
- package/src/lib/server/edit-hint-token.d.ts +83 -0
- package/src/lib/server/edit-hint-token.js +119 -0
- package/src/lib/server/edit-hint-token.js.map +1 -0
- package/src/lib/server/email-delivery-log.d.ts +509 -0
- package/src/lib/server/email-delivery-log.js +1043 -0
- package/src/lib/server/email-delivery-log.js.map +1 -0
- package/src/lib/server/email-flow-gate.d.ts +80 -0
- package/src/lib/server/email-flow-gate.js +153 -0
- package/src/lib/server/email-flow-gate.js.map +1 -0
- package/src/lib/server/email-marketing-gate.d.ts +333 -0
- package/src/lib/server/email-marketing-gate.js +639 -0
- package/src/lib/server/email-marketing-gate.js.map +1 -0
- package/src/lib/server/email-metering.d.ts +293 -0
- package/src/lib/server/email-metering.js +359 -0
- package/src/lib/server/email-metering.js.map +1 -0
- package/src/lib/server/email-revenue-attribution.d.ts +185 -0
- package/src/lib/server/email-revenue-attribution.js +300 -0
- package/src/lib/server/email-revenue-attribution.js.map +1 -0
- package/src/lib/server/email-send-rate.d.ts +232 -0
- package/src/lib/server/email-send-rate.js +417 -0
- package/src/lib/server/email-send-rate.js.map +1 -0
- package/src/lib/server/email-sender-reputation.d.ts +317 -0
- package/src/lib/server/email-sender-reputation.js +464 -0
- package/src/lib/server/email-sender-reputation.js.map +1 -0
- package/src/lib/server/email-suppression.d.ts +490 -0
- package/src/lib/server/email-suppression.js +648 -0
- package/src/lib/server/email-suppression.js.map +1 -0
- package/src/lib/server/email-topic-confirmation.d.ts +150 -0
- package/src/lib/server/email-topic-confirmation.js +272 -0
- package/src/lib/server/email-topic-confirmation.js.map +1 -0
- package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
- package/src/lib/server/email-unsubscribe-link.js +236 -0
- package/src/lib/server/email-unsubscribe-link.js.map +1 -0
- package/src/lib/server/erase-person.d.ts +116 -0
- package/src/lib/server/erase-person.js +272 -0
- package/src/lib/server/erase-person.js.map +1 -0
- package/src/lib/server/erase.d.ts +458 -0
- package/src/lib/server/erase.js +1506 -0
- package/src/lib/server/erase.js.map +1 -0
- package/src/lib/server/firebase-admin.d.ts +71 -0
- package/src/lib/server/firebase-admin.js +343 -0
- package/src/lib/server/firebase-admin.js.map +1 -0
- package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
- package/src/lib/server/form-dataset-binding-token.js +107 -0
- package/src/lib/server/form-dataset-binding-token.js.map +1 -0
- package/src/lib/server/free-workspace-cap.d.ts +201 -0
- package/src/lib/server/free-workspace-cap.js +404 -0
- package/src/lib/server/free-workspace-cap.js.map +1 -0
- package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
- package/src/lib/server/ga4-measurement-protocol.js +558 -0
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
- package/src/lib/server/host-email-tokens.d.ts +47 -0
- package/src/lib/server/host-email-tokens.js +72 -0
- package/src/lib/server/host-email-tokens.js.map +1 -0
- package/src/lib/server/host-memberships.d.ts +54 -0
- package/src/lib/server/host-memberships.js +194 -0
- package/src/lib/server/host-memberships.js.map +1 -0
- package/src/lib/server/host-sending-domain.d.ts +292 -0
- package/src/lib/server/host-sending-domain.js +451 -0
- package/src/lib/server/host-sending-domain.js.map +1 -0
- package/src/lib/server/host-visitor-records.d.ts +150 -0
- package/src/lib/server/host-visitor-records.js +262 -0
- package/src/lib/server/host-visitor-records.js.map +1 -0
- package/src/lib/server/id-token-refusal.d.ts +28 -0
- package/src/lib/server/id-token-refusal.js +111 -0
- package/src/lib/server/id-token-refusal.js.map +1 -0
- package/src/lib/server/legal-acceptance.d.ts +214 -0
- package/src/lib/server/legal-acceptance.js +305 -0
- package/src/lib/server/legal-acceptance.js.map +1 -0
- package/src/lib/server/list-members.d.ts +211 -0
- package/src/lib/server/list-members.js +176 -0
- package/src/lib/server/list-members.js.map +1 -0
- package/src/lib/server/live-page-usage.d.ts +202 -0
- package/src/lib/server/live-page-usage.js +300 -0
- package/src/lib/server/live-page-usage.js.map +1 -0
- package/src/lib/server/lockdown.d.ts +313 -0
- package/src/lib/server/lockdown.js +643 -0
- package/src/lib/server/lockdown.js.map +1 -0
- package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
- package/src/lib/server/media-cdn-rate-limit.js +204 -0
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +267 -0
- package/src/lib/server/media-delivery.js +485 -0
- package/src/lib/server/media-delivery.js.map +1 -0
- package/src/lib/server/media-download-tokens.d.ts +161 -0
- package/src/lib/server/media-download-tokens.js +386 -0
- package/src/lib/server/media-download-tokens.js.map +1 -0
- package/src/lib/server/media-quarantine.d.ts +127 -0
- package/src/lib/server/media-quarantine.js +170 -0
- package/src/lib/server/media-quarantine.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +126 -0
- package/src/lib/server/media-signing.js +159 -0
- package/src/lib/server/media-signing.js.map +1 -0
- package/src/lib/server/media-storage-path.d.ts +96 -0
- package/src/lib/server/media-storage-path.js +111 -0
- package/src/lib/server/media-storage-path.js.map +1 -0
- package/src/lib/server/media-strong-digest.d.ts +85 -0
- package/src/lib/server/media-strong-digest.js +190 -0
- package/src/lib/server/media-strong-digest.js.map +1 -0
- package/src/lib/server/media-tombstone.d.ts +276 -0
- package/src/lib/server/media-tombstone.js +421 -0
- package/src/lib/server/media-tombstone.js.map +1 -0
- package/src/lib/server/media-variants.d.ts +309 -0
- package/src/lib/server/media-variants.js +428 -0
- package/src/lib/server/media-variants.js.map +1 -0
- package/src/lib/server/member-email-aliases.d.ts +180 -0
- package/src/lib/server/member-email-aliases.js +420 -0
- package/src/lib/server/member-email-aliases.js.map +1 -0
- package/src/lib/server/member-photo.d.ts +88 -0
- package/src/lib/server/member-photo.js +185 -0
- package/src/lib/server/member-photo.js.map +1 -0
- package/src/lib/server/membership-recover-throttle.d.ts +211 -0
- package/src/lib/server/membership-recover-throttle.js +231 -0
- package/src/lib/server/membership-recover-throttle.js.map +1 -0
- package/src/lib/server/notifications.d.ts +55 -0
- package/src/lib/server/notifications.js +134 -0
- package/src/lib/server/notifications.js.map +1 -0
- package/src/lib/server/org-billing.d.ts +97 -0
- package/src/lib/server/org-billing.js +236 -0
- package/src/lib/server/org-billing.js.map +1 -0
- package/src/lib/server/organizations.d.ts +837 -0
- package/src/lib/server/organizations.js +1814 -0
- package/src/lib/server/organizations.js.map +1 -0
- package/src/lib/server/password-reset-throttle.d.ts +84 -0
- package/src/lib/server/password-reset-throttle.js +102 -0
- package/src/lib/server/password-reset-throttle.js.map +1 -0
- package/src/lib/server/personal-data-export.d.ts +241 -0
- package/src/lib/server/personal-data-export.js +858 -0
- package/src/lib/server/personal-data-export.js.map +1 -0
- package/src/lib/server/platform-domain-names.d.ts +62 -0
- package/src/lib/server/platform-domain-names.js +197 -0
- package/src/lib/server/platform-domain-names.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +133 -0
- package/src/lib/server/platform-marketing-consent.js +209 -0
- package/src/lib/server/platform-marketing-consent.js.map +1 -0
- package/src/lib/server/rate-limit-store.d.ts +702 -0
- package/src/lib/server/rate-limit-store.js +1220 -0
- package/src/lib/server/rate-limit-store.js.map +1 -0
- package/src/lib/server/realm-plugins.d.ts +67 -0
- package/src/lib/server/realm-plugins.js +250 -0
- package/src/lib/server/realm-plugins.js.map +1 -0
- package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
- package/src/lib/server/recaptcha-allowlist.js +409 -0
- package/src/lib/server/recaptcha-allowlist.js.map +1 -0
- package/src/lib/server/release-flags.d.ts +105 -0
- package/src/lib/server/release-flags.js +183 -0
- package/src/lib/server/release-flags.js.map +1 -0
- package/src/lib/server/replay-erasures.d.ts +126 -0
- package/src/lib/server/replay-erasures.js +217 -0
- package/src/lib/server/replay-erasures.js.map +1 -0
- package/src/lib/server/resolve-people.d.ts +66 -0
- package/src/lib/server/resolve-people.js +112 -0
- package/src/lib/server/resolve-people.js.map +1 -0
- package/src/lib/server/safe-equal.d.ts +45 -0
- package/src/lib/server/safe-equal.js +58 -0
- package/src/lib/server/safe-equal.js.map +1 -0
- package/src/lib/server/sending-domain-debt.d.ts +127 -0
- package/src/lib/server/sending-domain-debt.js +268 -0
- package/src/lib/server/sending-domain-debt.js.map +1 -0
- package/src/lib/server/sending-domain-recheck.d.ts +151 -0
- package/src/lib/server/sending-domain-recheck.js +253 -0
- package/src/lib/server/sending-domain-recheck.js.map +1 -0
- package/src/lib/server/sending-domains.d.ts +364 -0
- package/src/lib/server/sending-domains.js +683 -0
- package/src/lib/server/sending-domains.js.map +1 -0
- package/src/lib/server/serve-media-cdn.d.ts +433 -0
- package/src/lib/server/serve-media-cdn.js +1370 -0
- package/src/lib/server/serve-media-cdn.js.map +1 -0
- package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
- package/src/lib/server/serve-plugin-fetch.js +294 -0
- package/src/lib/server/serve-plugin-fetch.js.map +1 -0
- package/src/lib/server/sms-keywords.d.ts +63 -0
- package/src/lib/server/sms-keywords.js +146 -0
- package/src/lib/server/sms-keywords.js.map +1 -0
- package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
- package/src/lib/server/sso-break-glass-owners.js +227 -0
- package/src/lib/server/sso-break-glass-owners.js.map +1 -0
- package/src/lib/server/sso-domain-policy.d.ts +140 -0
- package/src/lib/server/sso-domain-policy.js +239 -0
- package/src/lib/server/sso-domain-policy.js.map +1 -0
- package/src/lib/server/sso-drift-logic.d.ts +106 -0
- package/src/lib/server/sso-drift-logic.js +95 -0
- package/src/lib/server/sso-drift-logic.js.map +1 -0
- package/src/lib/server/sso-enforcement.d.ts +204 -0
- package/src/lib/server/sso-enforcement.js +309 -0
- package/src/lib/server/sso-enforcement.js.map +1 -0
- package/src/lib/server/sso-provisioning.d.ts +320 -0
- package/src/lib/server/sso-provisioning.js +715 -0
- package/src/lib/server/sso-provisioning.js.map +1 -0
- package/src/lib/server/staff-alert-email.d.ts +41 -0
- package/src/lib/server/staff-alert-email.js +63 -0
- package/src/lib/server/staff-alert-email.js.map +1 -0
- package/src/lib/server/stripe-account-mode.d.ts +156 -0
- package/src/lib/server/stripe-account-mode.js +162 -0
- package/src/lib/server/stripe-account-mode.js.map +1 -0
- package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
- package/src/lib/server/tenant-write-lockdown.js +176 -0
- package/src/lib/server/tenant-write-lockdown.js.map +1 -0
- package/src/lib/server/test-firestore.d.ts +45 -0
- package/src/lib/server/test-firestore.js +124 -0
- package/src/lib/server/test-firestore.js.map +1 -0
- package/src/lib/server/token-revocation.d.ts +140 -0
- package/src/lib/server/token-revocation.js +255 -0
- package/src/lib/server/token-revocation.js.map +1 -0
- package/src/lib/server/update-existing.d.ts +32 -0
- package/src/lib/server/update-existing.js +73 -0
- package/src/lib/server/update-existing.js.map +1 -0
- package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
- package/src/lib/server/upload-cors-reconcile.js +234 -0
- package/src/lib/server/upload-cors-reconcile.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +262 -0
- package/src/lib/server/upsert-contact.js +546 -0
- package/src/lib/server/upsert-contact.js.map +1 -0
- package/src/lib/server/usage-invoice.d.ts +203 -0
- package/src/lib/server/usage-invoice.js +397 -0
- package/src/lib/server/usage-invoice.js.map +1 -0
- package/src/lib/server/user-profiles.d.ts +151 -0
- package/src/lib/server/user-profiles.js +364 -0
- package/src/lib/server/user-profiles.js.map +1 -0
- package/src/lib/server/vercel-drain-signature.d.ts +30 -0
- package/src/lib/server/vercel-drain-signature.js +61 -0
- package/src/lib/server/vercel-drain-signature.js.map +1 -0
- package/src/lib/server/vercel-log-drain.d.ts +259 -0
- package/src/lib/server/vercel-log-drain.js +479 -0
- package/src/lib/server/vercel-log-drain.js.map +1 -0
- package/src/lib/server/verify-email-cooldown.d.ts +102 -0
- package/src/lib/server/verify-email-cooldown.js +114 -0
- package/src/lib/server/verify-email-cooldown.js.map +1 -0
- package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
- package/src/lib/server/visitor-write-rate-limit.js +117 -0
- package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
- package/src/lib/server/workspace-domains.d.ts +156 -0
- package/src/lib/server/workspace-domains.js +271 -0
- package/src/lib/server/workspace-domains.js.map +1 -0
- package/src/tenancy.d.ts +115 -0
- package/src/tenancy.js +142 -0
- package/src/tenancy.js.map +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-marketing-gate.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * MARKETING MAIL — the durable half.\n *\n * `marketing-send.ts` in `@aglyn/shared-util-email` states the policy and\n * holds the injection seam; this answers the question, because answering it\n * needs Firestore and that library may not hold the Admin SDK.\n *\n * Five things, in the order they cost:\n *\n * 1. **Both suppression lists**, through the one shared helper. A person who\n * unsubscribed from this site, hard-bounced anywhere in the product, or\n * pressed \"report spam\" is not mailed. Asked first because it is the only\n * one whose answer is permanent.\n * 2. **The pace the RECIPIENT asked for** on the preference page, if they\n * asked for one.\n * 3. **An engagement sunset**, when an operator has configured a window —\n * off by default, and it costs a read only when it is on.\n * 4. **A frequency ceiling**, per recipient per site, over a rolling day.\n * 5. **The signed unsubscribe URL**, so the message carries a way out.\n *\n * ## The order of the three pace refusals, and who each one is for\n *\n * Suppression is first and needs no argument: its answer is permanent, and\n * the frequency record must not count a message that was never going to\n * leave. The other three are all refusals about PACE, and they are ordered by\n * whose refusal it is, then by what asking costs.\n *\n * **The recipient's own cadence is second — above both platform controls.**\n * It is the only one of the three a PERSON asked for; the sunset is an\n * inference this platform drew about them and the ceiling is a guard against\n * the merchant. When more than one applies, the honest answer to \"why did\n * this not send\" is the fact somebody stated, not the guess we made. It is\n * also the cheapest to ask: it is already on the counter document the ceiling\n * reads, so a recipient who asked for monthly mail never pays for the\n * sunset's engagement read. And it is the only one that binds a campaign, so\n * putting it first makes the campaign path and the automated paths evaluate\n * the same refusals in the same order rather than interleaving one control a\n * campaign skips with one it does not.\n *\n * **The sunset is third, above the ceiling.** Its refusal is TERMINAL and the\n * ceiling's is not: a sweep defers a `frequency-capped` message and retries\n * it, so reporting the retryable refusal for a person the sunset would refuse\n * anyway means the same doomed row comes back on every beat. That argument\n * does not reach past the cadence, because a cadence gap is a day, a week or\n * a month — a sweep retries such a row once per interval, not once per beat.\n *\n * ## Which refusals bind a campaign\n *\n * `MarketingSendContext.capped` is `false` for a campaign, and it governs\n * exactly two of the four:\n *\n * | Refusal | A campaign is |\n * | ------------------ | ------------- |\n * | `suppressed` | **bound** |\n * | `cadence-limited` | **bound** |\n * | `unengaged` | exempt (`capped`) |\n * | `frequency-capped` | exempt (`capped`) |\n *\n * The two exemptions share one reason: a campaign is a merchant's reviewed,\n * one-shot act with a recipient count on screen before they press Send, and a\n * platform control that silently removed people from that number would make\n * it a lie. That reason is about a control the merchant did not ask for and\n * cannot see. It does not reach a request the RECIPIENT made — a campaign\n * that overrode the preference page would make it a form recording a choice\n * nothing honors, which is ignoring an unsubscribe one notch quieter.\n *\n * A campaign COUNTS toward the ceiling either way; it is exempt from the\n * refusal, never from the counting.\n *\n * ## The counter is a WINDOW, not a total\n *\n * `hosts/{hostId}/emailFrequency/{sha256(address)}` holds the instants of the\n * marketing messages this site sent this person, trimmed to the window on\n * every touch. A rolling window needs the instants; a running total would\n * need a reset nobody triggers, and a per-day bucket would let a merchant\n * send the whole ceiling at 23:55 and the whole ceiling again at 00:05.\n *\n * The record is bounded by the cap — a handful of numbers — because\n * `marketingFrequencyVerdict` returns the trimmed window and this writes back\n * only that. It is server-owned: the address is hashed exactly as the two\n * suppression lists hash it, and no client writes or reads it.\n *\n * ## The check and the write are not one transaction, on purpose\n *\n * Two concurrent marketing sends to the same person can both read a window\n * with room and both append, so the cap can be exceeded by the number of\n * messages genuinely in flight at that instant. That is accepted rather than\n * papered over: a transaction per recipient would double the round trips on a\n * path that is already one awaited HTTP POST per person, and being off by one\n * on a runaway guard costs a recipient one extra message. Being slow costs\n * every recipient.\n *\n * ## Never a reduction of the person or their data\n *\n * A cap here refuses a SEND. It never removes a contact, never unsubscribes\n * anybody, never trims an audience and never deletes a counter row belonging\n * to somebody. `over-limit.ts` states the rule for capacity limits and this is\n * the same instrument pointed at time: the message does not go, the person\n * stays exactly where they were, and the next window mails them again. The\n * recipient's own cadence is the same shape — a person who asked for monthly\n * mail stays on every audience they were on and is mailed again next month.\n */\n\nimport {\n getMarketingSendGate,\n marketingCadenceVerdict,\n marketingFrequencyVerdict,\n marketingSunsetDays,\n marketingSunsetVerdict,\n normalizeMarketingCadence,\n setMarketingSendGate,\n type MarketingCadence,\n type MarketingSendGateRequest,\n type MarketingSendGateVerdict,\n} from '@aglyn/shared-util-email'\nimport firebaseAdmin from './firebase-admin'\nimport { readPersonEngagement } from './email-delivery-log'\nimport {\n emailSuppressionKey,\n filterSendableForHost,\n filterTopicSendable,\n} from './email-suppression'\nimport { buildUnsubscribeUrl } from './email-unsubscribe-link'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * Where the per-recipient window lives.\n *\n * Under the host rather than the org: the ceiling is \"how much mail this SITE\n * sends one person\", which is the unit a recipient experiences and the unit\n * the unsubscribe link is scoped to. An org-wide counter would let one site's\n * newsletter suppress a sibling site's order-related marketing, and the two\n * sites may be unrelated brands.\n */\nexport const EMAIL_FREQUENCY_SUBCOLLECTION = 'emailFrequency'\n\n/** How each cadence reads inside a refusal sentence. */\nconst CADENCE_PHRASES: Record<MarketingCadence, string> = {\n all: 'at this pace',\n daily: 'a day',\n weekly: 'a week',\n monthly: 'a month',\n}\n\n/** The stored window. */\nexport interface EmailFrequencyRecord {\n /** The address, in the clear, lowercased. The id is its hash. */\n email: string\n /** Marketing send instants, trimmed to the window on every touch. */\n sentAtMs: number[]\n /**\n * When this site FIRST sent this person marketing mail.\n *\n * Written once and never again, and it rides here rather than in a store of\n * its own because this document is already written on every marketing send:\n * carrying the field costs nothing, and a second per-recipient document\n * would be a write per send per person for one number.\n *\n * The sunset is what reads it — see `marketingSunsetVerdict`. A person we\n * have not been mailing for longer than the sunset window cannot have been\n * quiet for longer than the sunset window, so this is the field that keeps\n * a new subscriber from being refused for having nothing on record yet.\n *\n * Null for every row written before this field existed, which reads as \"no\n * record\" and therefore refuses nobody. That is the conservative direction\n * for a field introduced after the data, and it self-heals: the row gains\n * the stamp on this site's next marketing send to that person, and the\n * clock starts from then rather than from a past nobody recorded.\n */\n firstSentAtMs?: number\n /**\n * The most recent marketing send, NEVER trimmed.\n *\n * `sentAtMs` is a rolling day, so after a quiet day it is empty and cannot\n * answer \"when did this site last mail this person\" — which is the only\n * question a weekly or monthly cadence asks. One number rather than a\n * window kept for a month.\n *\n * The bookends of the same relationship: `firstSentAtMs` never moves and\n * this one moves on every send, so between them they answer both \"how long\n * have we been mailing this person\" and \"how long since we last did\".\n */\n lastSentAtMs?: number\n /**\n * How often the RECIPIENT asked to hear from this site.\n *\n * Stored on the counter document rather than beside the topic opt-outs, and\n * that is the whole reason it costs nothing: the gate already reads this\n * document on every marketing message, so honoring the preference adds no\n * round trip to any send. It also puts the preference in the one document\n * that is already about how much mail this site sends this person.\n *\n * Absent on every record written before it existed, which reads as no\n * expressed preference — see `normalizeMarketingCadence`.\n */\n cadence?: MarketingCadence\n /** When the recipient chose it, for the record that the request was made. */\n cadenceSetAtMs?: number\n}\n\n/**\n * What one recipient's counter document says, with its defaults applied.\n *\n * Everything the gate asks of Firestore about this person on this site, in\n * one shape, because it is fetched in one round trip — see\n * {@link readMarketingFrequencyState}.\n */\nexport interface MarketingFrequencyState {\n /** Send instants inside the rolling window. */\n window: number[]\n /** When this site first mailed them, or `null` for no record. */\n firstSentAtMs: number | null\n /** The most recent send, or `null` for somebody never mailed. */\n lastSentAtMs: number | null\n /** The recipient's chosen pace. */\n cadence: MarketingCadence\n}\n\n/** What an unreadable, absent or unkeyable counter reads as. */\nconst NO_RECORD: MarketingFrequencyState = {\n window: [],\n firstSentAtMs: null,\n lastSentAtMs: null,\n cadence: 'all',\n}\n\n/**\n * One counter snapshot, decoded.\n *\n * Shared by {@link readMarketingFrequencyState} and\n * {@link filterCadenceSendable} so the two cannot come to different\n * conclusions about the same document — in particular about the\n * `lastSentAtMs` fallback, where a per-message answer and a per-campaign\n * answer that disagreed would refuse a recipient on one path and mail them on\n * the other.\n */\nfunction stateFromSnapshot(snapshot: any): MarketingFrequencyState {\n const stored = snapshot.get('sentAtMs')\n const window = Array.isArray(stored)\n ? stored.map((at: unknown) => Number(at))\n : []\n const first = Number(snapshot.get('firstSentAtMs'))\n const last = Number(snapshot.get('lastSentAtMs'))\n return {\n window,\n firstSentAtMs: Number.isFinite(first) && first > 0 ? first : null,\n /*\n * The stored instant, or the newest entry still inside the window.\n *\n * The fallback is what makes this work on every record written before\n * `lastSentAtMs` existed: those carry a window and nothing else, and\n * reading `null` for them would let a monthly cadence pass on the first\n * message after this ships for anybody mailed in the last day.\n *\n * `firstSentAtMs` deliberately has NO such fallback. The window would be\n * the wrong answer for it in exactly the direction that matters: a\n * relationship dated from a send inside the last day is younger than any\n * sunset window, so the sunset could never fire — and a relationship\n * dated from the oldest entry in a rolling DAY is not the first send\n * either. Absent means \"no record\", and no record refuses nobody.\n */\n lastSentAtMs: Number.isFinite(last)\n ? last\n : window.length\n ? Math.max(...window)\n : null,\n cadence: normalizeMarketingCadence(snapshot.get('cadence')),\n }\n}\n\nfunction frequencyDoc(\n hostId: string,\n emailKey: string,\n firestore?: any,\n): FirebaseFirestore.DocumentReference {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n .doc(emailKey)\n}\n\n/**\n * The instants inside the window for one recipient of one site's mail.\n *\n * FAILS OPEN — an empty window, meaning \"nothing recorded, so there is room\".\n * The opposite of {@link filterSendableForHost}, and the difference is what\n * each answer costs when it is wrong. A suppression read that fails open\n * mails somebody who told us to stop; a frequency read that fails closed\n * refuses a message nobody objected to, over a counter that is a runaway\n * guard rather than a consent record.\n */\nexport async function readMarketingFrequency(\n hostId: string,\n email: string,\n firestore?: any,\n): Promise<number[]> {\n return (await readMarketingFrequencyState(hostId, email, firestore)).window\n}\n\n/**\n * The whole counter document — the window, the first send, the last send and\n * the recipient's chosen pace — in ONE round trip.\n *\n * **One read, four facts, and that is a requirement rather than a tidiness.**\n * Three separate refusals in {@link marketingSendVerdict} read this document,\n * and a `get` per refusal would be three awaited round trips on a path that\n * is already one awaited HTTP POST per recipient. Which is also why the\n * recipient's cadence is stored HERE rather than beside the topic opt-outs:\n * honoring a preference this gate has to consult on every marketing message\n * has to cost nothing, and riding on a document already being read is the\n * only shape that does. See {@link EmailFrequencyRecord.cadence}.\n *\n * FAILS OPEN on every field, and in the same direction each time: an empty\n * window means \"there is room\", a null `firstSentAtMs` means \"no record\", a\n * null `lastSentAtMs` means \"never mailed\", and an unreadable cadence reads\n * as no preference expressed. An unreadable counter is not evidence that\n * somebody asked for less, and it is not evidence that they have gone cold —\n * the opposite of {@link filterSendableForHost}, for the reason\n * {@link readMarketingFrequency} states.\n */\nexport async function readMarketingFrequencyState(\n hostId: string,\n email: string,\n firestore?: any,\n): Promise<MarketingFrequencyState> {\n const key = emailSuppressionKey(email)\n if (!key) return { ...NO_RECORD }\n try {\n const snapshot = await frequencyDoc(hostId, key, firestore).get()\n if (!snapshot.exists) return { ...NO_RECORD }\n return stateFromSnapshot(snapshot)\n } catch (error) {\n console.error('[email-marketing] frequency read failed; allowing', error)\n return { ...NO_RECORD }\n }\n}\n\n/**\n * Records the pace a recipient asked for.\n *\n * Written from the preference page, which is unauthenticated and reached by a\n * signed link — so the CALLER has already proved the request is this\n * address's. A merge, because the document is the send counter and this must\n * not disturb the window it shares with.\n *\n * Never throws: a preference that failed to store is a page that should say\n * so, not a 500 on a screen a recipient reached in order to leave.\n *\n * @returns whether it was stored.\n */\nexport async function setMarketingCadence(\n hostId: string,\n email: string,\n cadence: MarketingCadence,\n options?: { nowMs?: number; firestore?: any },\n): Promise<boolean> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId) return false\n try {\n await frequencyDoc(hostId, key, options?.firestore).set(\n {\n email: String(email).trim().toLowerCase(),\n cadence: normalizeMarketingCadence(cadence),\n cadenceSetAtMs: options?.nowMs ?? Date.now(),\n },\n { merge: true },\n )\n return true\n } catch (error) {\n console.error('[email-marketing] cadence write failed', error)\n return false\n }\n}\n\n/**\n * Records marketing messages against the recipients' windows.\n *\n * Takes a LIST because the campaign sender records a whole batch at once: it\n * is exempt from the refusal (a reviewed, one-shot act whose recipient count\n * is on screen before it goes) but not from the counting, since a campaign is\n * most of the mail a person receives from a site. Doing that per recipient\n * inside the send loop would add a round trip to each of up to 500 sequential\n * sends; doing it here adds one batch after them.\n *\n * Never throws. A lost counter increment means one recipient's ceiling is\n * measured a message low, which is not worth failing a delivered send over.\n */\nexport async function recordMarketingSends(\n hostId: string,\n emails: readonly string[],\n options?: { nowMs?: number; firestore?: any },\n): Promise<number> {\n const nowMs = options?.nowMs ?? Date.now()\n const keyed = new Map<string, string>()\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) keyed.set(key, String(email).trim().toLowerCase())\n }\n if (!keyed.size || !hostId) return 0\n let recorded = 0\n try {\n const db = options?.firestore ?? defaultFirestore()\n for (const [key, email] of keyed) {\n const ref = frequencyDoc(hostId, key, db)\n const existing: MarketingFrequencyState = await ref\n .get()\n .then((snapshot: any) =>\n snapshot.exists ? stateFromSnapshot(snapshot) : { ...NO_RECORD },\n )\n .catch(() => ({ ...NO_RECORD }))\n const window = marketingFrequencyVerdict(\n [...existing.window, nowMs],\n nowMs,\n )\n await ref.set(\n {\n email,\n sentAtMs: window.inWindow,\n lastSentAtMs: nowMs,\n // Write-once, unlike `lastSentAtMs` beside it. Overwriting it would\n // restart the sunset clock on every send, which would make the\n // sunset unreachable — a person whose relationship is always \"as\n // old as the last message\" is never older than the window.\n ...(existing.firstSentAtMs ? {} : { firstSentAtMs: nowMs }),\n },\n { merge: true },\n )\n recorded += 1\n }\n } catch (error) {\n console.error('[email-marketing] frequency record failed', error)\n }\n return recorded\n}\n\n/**\n * The subset of `emails` that has NOT asked this site for mail less often\n * than right now.\n *\n * The fourth filter a campaign passes, after the platform suppression list,\n * the site's own and the topic opt-outs — and the only one that is not in\n * `email-suppression.ts` beside those three, because it reads the counter\n * document this module owns and that module is this module's dependency.\n *\n * {@link marketingSendVerdict} enforces the same rule per message, for the\n * senders that reach the gate. THIS is the campaign path's copy, and it is\n * the enforcement there rather than only a count: a campaign carries no\n * `marketing` context — it mints its own unsubscribe URL upstream — so the\n * gate is not on its path at all, and a rule asked only there would not be\n * asked of the sender that produces most of a person's mail.\n *\n * Answering it as a FILTER rather than per message is also the only placement\n * that keeps the composer honest. The argument for exempting a campaign from\n * the platform ceiling is that a control which silently removed people from a\n * reviewed one-shot send would make the number on screen a lie; subtracting\n * a request the recipient actually made, where every other refusal is already\n * subtracted, is what keeps this one from having that problem.\n *\n * Keyed and read with one `getAll`, matching its three neighbors: one round\n * trip bounded by the size of the send, and no composite index to go missing.\n *\n * ## Fails OPEN, like the topic filter beside it\n *\n * A cadence is a PACE, not a stop. Guessing wrong on an unreadable counter\n * costs one recipient one message sooner than they asked for; guessing wrong\n * the other way withholds a whole campaign on a transient read failure. The\n * two suppression lists have already refused everybody who asked us to stop\n * entirely, so nobody who said \"no\" reaches this line.\n */\nexport async function filterCadenceSendable(\n hostId: string,\n emails: readonly string[],\n options?: { nowMs?: number; firestore?: any },\n): Promise<string[]> {\n if (!emails.length || !hostId) return [...emails]\n const nowMs = options?.nowMs ?? Date.now()\n // An unkeyable address carries no counter, so it has expressed no pace. It\n // is dropped from the LOOKUP and kept in the answer, exactly as the topic\n // filter keeps one: the stricter filters above have already had their say.\n const lookups: Array<{ email: string; key: string }> = []\n for (const email of emails) {\n const key = emailSuppressionKey(email)\n if (key) lookups.push({ email, key })\n }\n if (!lookups.length) return [...emails]\n try {\n const db = options?.firestore ?? defaultFirestore()\n const counters = db\n .collection('hosts')\n .doc(hostId)\n .collection(EMAIL_FREQUENCY_SUBCOLLECTION)\n const snapshots = await db.getAll(\n ...lookups.map((entry) => counters.doc(entry.key)),\n )\n const holding = new Set<string>()\n lookups.forEach((entry, index) => {\n const snapshot = snapshots[index]\n if (!snapshot?.exists) return\n // The same decoder the per-message path uses, so the two cannot\n // disagree about one document. The sunset's `firstSentAtMs` comes back\n // with it and is deliberately unused: a campaign is exempt from that\n // refusal, so subtracting on it here would remove people from a count\n // the gate is going to mail anyway.\n const state = stateFromSnapshot(snapshot)\n const verdict = marketingCadenceVerdict(\n state.cadence,\n state.lastSentAtMs,\n nowMs,\n )\n if (!verdict.allowed) holding.add(entry.email)\n })\n return emails.filter((email) => !holding.has(email))\n } catch (error) {\n console.error(\n '[email-marketing] cadence lookup failed; failing open',\n error,\n )\n return [...emails]\n }\n}\n\n/**\n * The gate itself, exported so it can be exercised without installing it.\n *\n * ORDER MATTERS. The suppression check is first because its answer is\n * permanent and the frequency record must not count a message that was never\n * going to leave — a suppressed address whose window kept growing would stay\n * capped for a day after being released.\n */\nexport async function marketingSendVerdict(\n request: MarketingSendGateRequest,\n options?: { nowMs?: number; firestore?: any },\n): Promise<MarketingSendGateVerdict> {\n const nowMs = options?.nowMs ?? Date.now()\n const email = String(request.email ?? '')\n .trim()\n .toLowerCase()\n const sendable = await filterSendableForHost(\n request.hostId,\n [email],\n options?.firestore,\n )\n if (!sendable.length) {\n return {\n allowed: false,\n refusal: 'suppressed',\n detail:\n 'This address has unsubscribed, bounced permanently, or reported a ' +\n 'message as spam.',\n }\n }\n\n // Normalized once, and read by both the links below and the topic check\n // further down: two spellings of \"which stream is this\" is how a link comes\n // to carry a topic the gate did not check.\n const topicId = String(request.topicId ?? '').trim()\n\n /*\n * TWO URLS OVER ONE SIGNATURE, the same split `campaign-send.ts` makes and\n * for the same reason (RFC 8058).\n *\n * `oneClickUrl` is what `List-Unsubscribe` names: a mailbox provider POSTs\n * it with nobody present and expects the act to have happened when it reads\n * the 200, so it points at the route whose POST writes immediately.\n *\n * `unsubscribeUrl` is the link a PERSON clicks in the footer, and it points\n * at the preference page. Every caller on this path — the abandoned-cart\n * sweep, the restock notice, the newsletter welcome, the automation step —\n * names a topic, and a footer pointing at the one-click route gives the\n * recipient of one of those exactly one choice: stop hearing from this site\n * entirely. The page offers leaving that one stream instead, with\n * \"Unsubscribe from everything\" still on it.\n *\n * The topic rides both links, so the page opens on the stream the message\n * belonged to rather than on a list the recipient has to search.\n */\n const link = {\n siteBase: request.siteBase,\n hostId: request.hostId,\n email,\n ...(topicId ? { topicId } : {}),\n }\n const unsubscribeUrl = buildUnsubscribeUrl({\n ...link,\n surface: 'preferences',\n })\n const oneClickUrl = buildUnsubscribeUrl({ ...link, surface: 'one-click' })\n\n /*\n * THE STREAM THIS MESSAGE BELONGS TO — the third list, and the narrowest.\n *\n * ONLY when the caller named one. An absent topic is not \"the default\n * topic\": it is a message that belongs to no stream, and there is nothing\n * for a person to have left. That is what keeps this check off the mail it\n * must never touch — a receipt, a password reset, a booking confirmation,\n * none of which declare a `marketing` context at all and none of which name\n * a stream if they somehow did.\n *\n * After the suppression lists and before the counter read below, matching\n * the order `campaign-send.ts` filters in, for two reasons that agree. It\n * is the weaker fact, and the weaker fact should never be the one that\n * decides — a person who unticked \"Promotions and offers\" is still a\n * subscriber, where a person on either suppression list is not. And it is a\n * TERMINAL refusal, so answering it before the counter read means a\n * recipient this message was never going to reach costs one lookup rather\n * than three.\n *\n * Fails OPEN, because `filterTopicSendable` does: a topic preference is a\n * narrower fact than a suppression, and a read that failed for an unrelated\n * reason is no reason to withhold a message from somebody the two lists\n * above already cleared.\n */\n if (topicId) {\n const onTopic = await filterTopicSendable(\n request.hostId,\n topicId,\n [email],\n options?.firestore,\n )\n if (!onTopic.length) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE, as with the pace refusals below.\n *\n * No suppression is written, no membership changes, and the frequency\n * window is not appended to — a message that never left must not count\n * against what this person has received. They stay on every other\n * stream they subscribe to, and the next message on one of those goes.\n */\n return {\n allowed: false,\n refusal: 'topic-unsubscribed',\n detail:\n 'This address has left the email topic this message belongs to. ' +\n 'They still receive the other streams from this site.',\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n }\n\n // ONE read, and every refusal below is answered from it — except the\n // sunset's engagement half, which is a different document and is fetched\n // only when a window is configured and nothing above has already refused.\n const state = await readMarketingFrequencyState(\n request.hostId,\n email,\n options?.firestore,\n )\n\n /*\n * THE RECIPIENT'S OWN REQUEST, first among the three pace refusals and the\n * only one not subject to `capped`.\n *\n * `capped: false` exempts a campaign from the platform CEILING and from the\n * SUNSET, because a control the merchant did not ask for and cannot see,\n * silently removing people from a reviewed one-shot send, would make the\n * recipient count on screen a lie. That argument does not reach this one: a\n * person used the preference page to ask this site for less mail, and a\n * campaign that overrode them would make that page a form that records a\n * request nobody honors — the same failure as ignoring an unsubscribe, one\n * notch quieter.\n *\n * Above the sunset for three reasons. It is the only one of the three a\n * PERSON asked for, and when both apply the honest answer to \"why did this\n * not send\" is the fact somebody stated rather than the inference we drew.\n * It is answered entirely from the counter document already in hand, so a\n * recipient who asked for monthly mail never pays for the sunset's second\n * read. And it is the one refusal a campaign is bound by, so asking it\n * first gives the campaign path and the automated paths the same order.\n *\n * The sunset's terminality argument does not out-rank any of that: a sweep\n * that defers this row retries it once per cadence interval — a day, a week\n * or a month — not once per beat, which is what that argument is about.\n *\n * A campaign is bound by the rule but not by THIS enforcement of it: it\n * carries no `marketing` context, so nothing on its path reaches this\n * function. {@link filterCadenceSendable} is where it is asked there, and\n * asked as a filter so the recipient count reflects it before Send.\n */\n const cadence = marketingCadenceVerdict(\n state.cadence,\n state.lastSentAtMs,\n nowMs,\n )\n if (!cadence.allowed) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE, exactly as below.\n *\n * Nobody is unsubscribed, no membership changes, no contact is touched,\n * and the frequency window is not appended to — a message that never left\n * must not count against what this person has received. Somebody who\n * asked for monthly mail stays on every audience they were on and is\n * mailed again next month.\n */\n return {\n allowed: false,\n refusal: 'cadence-limited',\n detail:\n `This address asked this site for no more than one marketing ` +\n `message ${CADENCE_PHRASES[state.cadence]}. The next one may go on ` +\n `${new Date(cadence.nextAllowedAtMs).toISOString()}.`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n\n /*\n * THE SUNSET, ahead of the frequency ceiling.\n *\n * Ordered above the ceiling because its refusal is TERMINAL and the\n * ceiling's is not: a sweep defers a `frequency-capped` message and retries\n * it, so reporting the retryable refusal for a person the sunset would\n * refuse anyway means the same doomed row comes back on every beat.\n *\n * `request.capped` governs it, the same flag the ceiling reads, so a\n * campaign — a reviewed act with its recipient count on screen — is exempt\n * for the reason recorded on `MarketingSendContext.capped`. That leaves the\n * sunset governing the automated paths, which fire with no human present.\n *\n * The engagement read is the ONLY second round trip on this path, and it is\n * spent only when a window is configured, the caller is capped, and the\n * cadence above has already allowed. Off is the default, and off costs\n * nothing.\n */\n const sunsetDays = marketingSunsetDays()\n if (request.capped && sunsetDays > 0) {\n const engagement = await readPersonEngagement(email, options?.firestore)\n const sunset = marketingSunsetVerdict(\n {\n firstSentAtMs: state.firstSentAtMs,\n lastEngagedAtMs: engagement.lastEngagedAtMs,\n },\n nowMs,\n sunsetDays,\n )\n if (!sunset.allowed) {\n /*\n * ⛔ A REFUSAL AND NOTHING ELSE.\n *\n * No suppression is written, no membership is touched, no contact is\n * changed, and the frequency window below is not appended to — a\n * message that never left must not count against what this person has\n * received. The person is exactly where they were, and the next send\n * after they open anything goes.\n */\n return {\n allowed: false,\n refusal: 'unengaged',\n detail:\n `This address has not opened or clicked anything for ` +\n `${sunset.quietForDays} days, and this site has been mailing it ` +\n `for longer than the ${sunset.days}-day engagement window. It ` +\n `becomes mailable again as soon as they engage.`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n }\n\n const verdict = marketingFrequencyVerdict(state.window, nowMs)\n if (request.capped && !verdict.allowed) {\n return {\n allowed: false,\n refusal: 'frequency-capped',\n detail:\n `This address has already received ${verdict.used} marketing ` +\n `messages from this site today (the ceiling is ${verdict.cap}).`,\n unsubscribeUrl,\n oneClickUrl,\n }\n }\n\n /*\n * The send is granted, so it is counted — including for a campaign, which\n * is exempt from the refusal above and not from this. `sentAtMs` therefore\n * measures what the recipient actually receives rather than only the part\n * of it a cap may stop, which is the difference between a ceiling and a\n * number that describes nothing.\n */\n const appended = marketingFrequencyVerdict(\n [...verdict.inWindow, nowMs],\n nowMs,\n )\n const key = emailSuppressionKey(email)\n if (key) {\n await frequencyDoc(request.hostId, key, options?.firestore)\n .set(\n {\n email,\n sentAtMs: appended.inWindow,\n lastSentAtMs: nowMs,\n // Write-once — see EmailFrequencyRecord.firstSentAtMs. Re-stamping\n // it on every send would keep the relationship permanently younger\n // than any sunset window, so the sunset could never fire.\n ...(state.firstSentAtMs ? {} : { firstSentAtMs: nowMs }),\n },\n { merge: true },\n )\n // Never blocks the send. A counter write that failed is a ceiling\n // measured one message low, and refusing delivery over it would let a\n // Firestore hiccup become an outage on a merchant's mail.\n .catch((error: unknown) => {\n console.error('[email-marketing] frequency write failed', error)\n })\n }\n\n return { allowed: true, unsubscribeUrl, oneClickUrl }\n}\n\n/**\n * Puts the gate on `sendEmail`'s path.\n *\n * **Called at module load**, from the bottom of this file, so that importing\n * `@aglyn/tenant-data-admin` anywhere is enough — every server surface in the\n * product already imports that barrel, and `export *` forces this module to\n * evaluate. The alternative, a call at each server entrypoint, is the\n * many-places-to-remember shape this codebase has rejected twice already.\n *\n * Idempotent: installing twice replaces the same closure with an equivalent\n * one, and the closure holds no state — the state is the Firestore document.\n */\nexport function installMarketingSendGate(): void {\n setMarketingSendGate((request) => marketingSendVerdict(request))\n}\n\n/** Whether a gate is installed. Reads the shared seam, not a local flag. */\nexport function isMarketingSendGateInstalled(): boolean {\n return getMarketingSendGate() !== null\n}\n\ninstallMarketingSendGate()\n"],"names":["getMarketingSendGate","marketingCadenceVerdict","marketingFrequencyVerdict","marketingSunsetDays","marketingSunsetVerdict","normalizeMarketingCadence","setMarketingSendGate","firebaseAdmin","readPersonEngagement","emailSuppressionKey","filterSendableForHost","filterTopicSendable","buildUnsubscribeUrl","defaultFirestore","app","firestore","EMAIL_FREQUENCY_SUBCOLLECTION","CADENCE_PHRASES","all","daily","weekly","monthly","NO_RECORD","window","firstSentAtMs","lastSentAtMs","cadence","stateFromSnapshot","snapshot","stored","get","Array","isArray","map","at","Number","first","last","isFinite","length","Math","max","frequencyDoc","hostId","emailKey","collection","doc","readMarketingFrequency","email","readMarketingFrequencyState","key","exists","error","console","setMarketingCadence","options","set","String","trim","toLowerCase","cadenceSetAtMs","nowMs","Date","now","merge","recordMarketingSends","emails","keyed","Map","size","recorded","db","ref","existing","then","catch","sentAtMs","inWindow","filterCadenceSendable","lookups","push","counters","snapshots","getAll","entry","holding","Set","forEach","index","state","verdict","allowed","add","filter","has","marketingSendVerdict","request","sendable","refusal","detail","topicId","link","siteBase","unsubscribeUrl","surface","oneClickUrl","onTopic","nextAllowedAtMs","toISOString","sunsetDays","capped","engagement","sunset","lastEngagedAtMs","quietForDays","days","used","cap","appended","installMarketingSendGate","isMarketingSendGateInstalled"],"mappings":"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"}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The email meters (AGL-1438). **Two of them, deliberately.**
|
|
19
|
+
*
|
|
20
|
+
* Before this, `hosts/{hostId}/counters/emailSends` was written by exactly one
|
|
21
|
+
* caller — the marketing campaign sender. Workflows, commerce, bookings and
|
|
22
|
+
* invites all called `sendEmail` and counted nothing, so the counter that named
|
|
23
|
+
* itself "email sends" held campaign sends alone. Two things followed from
|
|
24
|
+
* that: `emailSendsPerMonth` under-enforced, and the COGS figure AGL-1134
|
|
25
|
+
* started recording off this counter under-reported real email cost by however
|
|
26
|
+
* much non-campaign volume an org sent.
|
|
27
|
+
*
|
|
28
|
+
* UNITS: plain COUNTS — one per recipient address handed to the sender —
|
|
29
|
+
* for the calendar month named by the `YYYY-MM` FIELD on the counter document.
|
|
30
|
+
* Not bytes, not currency, not a running total: each month's field is
|
|
31
|
+
* independent, so summing months is a legitimate year-to-date and reading one
|
|
32
|
+
* is that month alone. Same unit as `workflowRuns`/`actionRuns` beside it, and
|
|
33
|
+
* the same unit `orgCounterTotals` sums, so a campaign of 300 and 300 workflow
|
|
34
|
+
* notifications are the same 300 on the meter.
|
|
35
|
+
*
|
|
36
|
+
* ### `emailSends` — the COST meter. Counts everything. Gates nothing.
|
|
37
|
+
*
|
|
38
|
+
* Every `sendEmail` call site increments this, whatever sent it. It is what the
|
|
39
|
+
* monthly rollup carries for COGS, and it is RECORDED, NOT PRICED — there is
|
|
40
|
+
* still no per-email rate anywhere, so it enters neither `billedCents` nor
|
|
41
|
+
* `costUsd` nor `ORG_COGS_UNIT_RATES_USD`. Pricing it is a decision with an
|
|
42
|
+
* invoice month behind it.
|
|
43
|
+
*
|
|
44
|
+
* ### `campaignEmailSends` — the ENFORCEABLE meter. Counts campaigns only.
|
|
45
|
+
*
|
|
46
|
+
* `emailSendsPerMonth` is checked against this and nothing else. A campaign is
|
|
47
|
+
* discretionary: refusing one at the cap is the outcome the plan sells, the
|
|
48
|
+
* customer sees a clear message, and they upgrade or wait.
|
|
49
|
+
*
|
|
50
|
+
* **Transactional mail is never blocked by a quota, at any tier.** Password
|
|
51
|
+
* resets, invites, order confirmations, booking reminders and workflow
|
|
52
|
+
* notifications send regardless of counter state; they count toward cost and
|
|
53
|
+
* cannot be refused. The failure modes are not symmetric — a blocked password
|
|
54
|
+
* reset locks somebody out of their own account, and the message explaining
|
|
55
|
+
* why is itself an email that will not send; a dropped order confirmation
|
|
56
|
+
* reads to the buyer as a failed order. That converts a billing event into an
|
|
57
|
+
* outage on somebody else's business, where the overage it saved was bounded
|
|
58
|
+
* and billable. It is the same fail-open posture the rest of this codebase
|
|
59
|
+
* takes: rate limiting fails soft, robots/sitemap fail open so a transient
|
|
60
|
+
* error cannot de-index a customer's site.
|
|
61
|
+
*
|
|
62
|
+
* ### Not double-counted
|
|
63
|
+
*
|
|
64
|
+
* One call site, one call to this function, and a campaign send increments BOTH
|
|
65
|
+
* counters from that single call rather than incrementing `emailSends` here and
|
|
66
|
+
* again somewhere else. The counter is keyed by month ON THE DOCUMENT, so
|
|
67
|
+
* nothing accumulates across a re-read, and the rollup re-derives rather than
|
|
68
|
+
* adds. A meter that double-counts is worse than no meter, because it looks
|
|
69
|
+
* authoritative (AGL-1402).
|
|
70
|
+
*
|
|
71
|
+
* ### Transition note
|
|
72
|
+
*
|
|
73
|
+
* `campaignEmailSends` starts empty. For the month this ships, campaign volume
|
|
74
|
+
* already sent is recorded only under the old `emailSends`, so the campaign cap
|
|
75
|
+
* effectively restarts once. That loosens for at most one month, which is the
|
|
76
|
+
* correct direction to be wrong in — the alternative, seeding the enforceable
|
|
77
|
+
* meter from a counter that now also holds transactional mail, would refuse
|
|
78
|
+
* campaigns because of order confirmations.
|
|
79
|
+
*/
|
|
80
|
+
/** Every send, whatever produced it. The cost meter. */
|
|
81
|
+
export declare const EMAIL_SENDS_COUNTER = "emailSends";
|
|
82
|
+
/** Campaign sends alone. The only meter `emailSendsPerMonth` may refuse. */
|
|
83
|
+
export declare const CAMPAIGN_EMAIL_SENDS_COUNTER = "campaignEmailSends";
|
|
84
|
+
/** Top-level home for platform-scoped meters: `meters/platform/counters/*`. */
|
|
85
|
+
export declare const PLATFORM_METER_COLLECTION = "meters";
|
|
86
|
+
export declare const PLATFORM_METER_DOC = "platform";
|
|
87
|
+
/**
|
|
88
|
+
* Who the send is attributed to.
|
|
89
|
+
*
|
|
90
|
+
* - `host` — a site sent it: campaigns, receipts, booking mail, workflow
|
|
91
|
+
* notifications, member mail. Rolls up to the owning org.
|
|
92
|
+
* - `org` — the org sent it with no site involved: invites, member-added,
|
|
93
|
+
* the welcome mail, usage summaries, erasure notices.
|
|
94
|
+
* - `platform` — Aglyn's own account and staff mail: password resets and
|
|
95
|
+
* verification (which happen before any org is known), new-device and
|
|
96
|
+
* passkey alerts, staff alerts, the system-email test send. Real cost, but
|
|
97
|
+
* not any one customer's, so it is counted apart from every org rollup and
|
|
98
|
+
* never reaches a COGS figure or an invoice.
|
|
99
|
+
*/
|
|
100
|
+
export type EmailMeterScope = {
|
|
101
|
+
kind: 'host';
|
|
102
|
+
hostId: string;
|
|
103
|
+
} | {
|
|
104
|
+
kind: 'org';
|
|
105
|
+
orgId: string;
|
|
106
|
+
} | {
|
|
107
|
+
kind: 'platform';
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Whether a quota may refuse this send.
|
|
111
|
+
*
|
|
112
|
+
* `campaign` is the ONLY discretionary class. Everything else is
|
|
113
|
+
* `transactional`, which means: count it, never gate on it.
|
|
114
|
+
*/
|
|
115
|
+
export type EmailSendClass = 'campaign' | 'transactional';
|
|
116
|
+
export interface RecordEmailSendsOptions {
|
|
117
|
+
scope: EmailMeterScope;
|
|
118
|
+
/** Emails actually handed to the sender. Zero and negatives are no-ops. */
|
|
119
|
+
count: number;
|
|
120
|
+
sendClass: EmailSendClass;
|
|
121
|
+
/** `YYYY-MM`; defaults to the current UTC month. */
|
|
122
|
+
month?: string;
|
|
123
|
+
/** Injectable for tests; defaults to the Admin SDK's Firestore. */
|
|
124
|
+
firestore?: any;
|
|
125
|
+
}
|
|
126
|
+
/** The counters document for a scope, e.g. `hosts/{id}/counters/emailSends`. */
|
|
127
|
+
export declare function emailMeterRef(firestore: any, scope: EmailMeterScope, counter: string): any;
|
|
128
|
+
/** Current calendar month as `YYYY-MM`, matching every other counter here. */
|
|
129
|
+
export declare function currentMeterMonth(now?: Date): string;
|
|
130
|
+
/**
|
|
131
|
+
* Records `count` emails against the cost meter, and against the campaign
|
|
132
|
+
* meter too when the send was a campaign.
|
|
133
|
+
*
|
|
134
|
+
* **Never throws.** Metering is bookkeeping that runs after mail has already
|
|
135
|
+
* gone out — a counter write that fails must not turn a delivered receipt into
|
|
136
|
+
* a 500 for the buyer, and must certainly not be able to stop the next send.
|
|
137
|
+
* Failures are logged and swallowed, the same posture `sendEmail` itself takes.
|
|
138
|
+
*/
|
|
139
|
+
export declare function recordEmailSends(options: RecordEmailSendsOptions): Promise<void>;
|
|
140
|
+
/**
|
|
141
|
+
* One transactional (or, with `sendClass`, campaign) send attributed to a site.
|
|
142
|
+
*
|
|
143
|
+
* The overwhelmingly common call — `await meterHostEmail(hostId)` right after
|
|
144
|
+
* a `sendEmail` — so it reads as one line at every call site and there is one
|
|
145
|
+
* place to change if the counter ever moves.
|
|
146
|
+
*/
|
|
147
|
+
export declare function meterHostEmail(hostId: string, count?: number, sendClass?: EmailSendClass): Promise<void>;
|
|
148
|
+
/** Org-scoped transactional mail: invites, welcome, usage summaries. */
|
|
149
|
+
export declare function meterOrgEmail(orgId: string, count?: number): Promise<void>;
|
|
150
|
+
/**
|
|
151
|
+
* Account and staff mail that belongs to no org at send time — a password
|
|
152
|
+
* reset knows only an address, and a staff alert has no customer at all.
|
|
153
|
+
*/
|
|
154
|
+
export declare function meterPlatformEmail(count?: number): Promise<void>;
|
|
155
|
+
/**
|
|
156
|
+
* The enforceable figure for a site this month: campaign sends alone.
|
|
157
|
+
*
|
|
158
|
+
* Reads the campaign meter and NOT `emailSends`, which now also holds every
|
|
159
|
+
* receipt and password reset the site sent. Enforcing the plan's cap against
|
|
160
|
+
* that total is exactly the behaviour this issue exists to prevent.
|
|
161
|
+
*/
|
|
162
|
+
export declare function campaignEmailSendsForMonth(hostRef: any, month: string): Promise<number>;
|
|
163
|
+
/**
|
|
164
|
+
* The org-level campaign meter, and the ONLY figure `emailSendsPerMonth` is
|
|
165
|
+
* enforced against since AGL-2267.
|
|
166
|
+
*
|
|
167
|
+
* ## Why a second counter and not the per-host one
|
|
168
|
+
*
|
|
169
|
+
* `hosts/{hostId}/counters/campaignEmailSends` is per SITE. `emailSendsPerMonth`
|
|
170
|
+
* is an ORG entitlement. An org with N sites therefore received N × the cap it
|
|
171
|
+
* bought — invisible on Free and Starter (`hostLimit: 1`) and growing with the
|
|
172
|
+
* plan, so the customers who paid most got the most cap they had not paid for.
|
|
173
|
+
*
|
|
174
|
+
* The per-host counter is UNCHANGED and still written, by `meterHostEmail`, on
|
|
175
|
+
* the delivered count. It is per-site history and it feeds the cost meter;
|
|
176
|
+
* nothing about it was wrong except being asked a question about an org.
|
|
177
|
+
*
|
|
178
|
+
* ## Why folding the existing per-site counters in was rejected
|
|
179
|
+
*
|
|
180
|
+
* Summing N site counters into one org total retroactively puts multi-site
|
|
181
|
+
* paying customers over a limit they have been under all along — a limit that
|
|
182
|
+
* then refuses a campaign they had every reason to expect to send, days after
|
|
183
|
+
* they bought the plan. This counter therefore STARTS EMPTY, and the campaign
|
|
184
|
+
* cap effectively restarts once for the month this ships.
|
|
185
|
+
*
|
|
186
|
+
* That is the same transition `campaignEmailSends` itself was given, and the
|
|
187
|
+
* same reasoning: it loosens for at most one month, which is the correct
|
|
188
|
+
* direction to be wrong in, and it needs no production measurement of who is
|
|
189
|
+
* currently over — a measurement that could only be taken against live
|
|
190
|
+
* customer data and would have blocked the fix indefinitely.
|
|
191
|
+
*/
|
|
192
|
+
export declare const ORG_CAMPAIGN_EMAIL_SENDS_COUNTER = "campaignEmailSends";
|
|
193
|
+
/** A granted claim on the org's monthly campaign allowance. */
|
|
194
|
+
export interface CampaignSendReservation {
|
|
195
|
+
orgId: string;
|
|
196
|
+
month: string;
|
|
197
|
+
/** Messages claimed up front. */
|
|
198
|
+
reserved: number;
|
|
199
|
+
}
|
|
200
|
+
export type ReserveCampaignSendsResult = {
|
|
201
|
+
ok: true;
|
|
202
|
+
reservation: CampaignSendReservation;
|
|
203
|
+
used: number;
|
|
204
|
+
limit: number;
|
|
205
|
+
} | {
|
|
206
|
+
ok: false;
|
|
207
|
+
used: number;
|
|
208
|
+
limit: number;
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* The enforceable figure for an ORG this month: campaign sends alone.
|
|
212
|
+
*
|
|
213
|
+
* Read-only. Used by the campaign composer's recipient preview, which must
|
|
214
|
+
* not reserve anything (AGL-2178 — "nothing has been written above this
|
|
215
|
+
* line"), and by anything that reports headroom.
|
|
216
|
+
*/
|
|
217
|
+
export declare function orgCampaignEmailSendsForMonth(orgId: string, month: string, firestore?: any): Promise<number>;
|
|
218
|
+
/**
|
|
219
|
+
* Claims `count` campaign sends against the org's monthly allowance, ATOMICALLY.
|
|
220
|
+
*
|
|
221
|
+
* ## Why a reservation and not a check
|
|
222
|
+
*
|
|
223
|
+
* The cap used to be read before the send and incremented after delivery, so
|
|
224
|
+
* two concurrent campaigns both passed the same reading and both sent — the
|
|
225
|
+
* cap was advisory in exactly the conditions it existed for. **A read-then-write
|
|
226
|
+
* cap is not a cap.**
|
|
227
|
+
*
|
|
228
|
+
* The transaction reads the counter and writes an ABSOLUTE value derived from
|
|
229
|
+
* that read, deliberately not `FieldValue.increment`. Firestore aborts and
|
|
230
|
+
* re-runs the callback when a document the transaction read has moved, so a
|
|
231
|
+
* second sender that starts inside the first one's window re-reads the raised
|
|
232
|
+
* figure and is refused. An increment would be atomic on the number and
|
|
233
|
+
* useless for the decision, because the decision is made from a value the
|
|
234
|
+
* write never proves it still held.
|
|
235
|
+
*
|
|
236
|
+
* A refused reservation writes NOTHING.
|
|
237
|
+
*
|
|
238
|
+
* ## Why reserve-then-reconcile rather than reserve-and-keep
|
|
239
|
+
*
|
|
240
|
+
* A campaign reserves against a PARTIAL delivery: `sendEmail` reports per
|
|
241
|
+
* message, and only some of a 500-address batch may go out. Keeping the whole
|
|
242
|
+
* reservation would charge a customer's allowance for mail that never left.
|
|
243
|
+
* So the claim is taken in full up front — that is what makes it a cap — and
|
|
244
|
+
* {@link reconcileCampaignSendReservation} gives back the difference once the
|
|
245
|
+
* delivered count is known.
|
|
246
|
+
*
|
|
247
|
+
* **The failure mode is stated rather than hidden**: if the process dies
|
|
248
|
+
* between reserving and reconciling, the org is charged for the undelivered
|
|
249
|
+
* remainder for the rest of that calendar month. That is conservative in the
|
|
250
|
+
* direction this issue cares about — it can only ever refuse more mail, never
|
|
251
|
+
* let more out — and it self-heals at the month boundary, because each month
|
|
252
|
+
* is an independent field on the document.
|
|
253
|
+
*
|
|
254
|
+
* `limit` may be `Infinity` (`UNLIMITED`), which admits everything.
|
|
255
|
+
*/
|
|
256
|
+
export declare function reserveCampaignEmailSends(options: {
|
|
257
|
+
orgId: string;
|
|
258
|
+
month: string;
|
|
259
|
+
count: number;
|
|
260
|
+
limit: number;
|
|
261
|
+
firestore?: any;
|
|
262
|
+
}): Promise<ReserveCampaignSendsResult>;
|
|
263
|
+
/**
|
|
264
|
+
* Returns the undelivered part of a reservation.
|
|
265
|
+
*
|
|
266
|
+
* Also a transaction, and also an absolute write from its own read: a refund
|
|
267
|
+
* computed from a stale figure would undo a reservation another campaign took
|
|
268
|
+
* in the meantime, which is the same defect one direction over.
|
|
269
|
+
*
|
|
270
|
+
* **Never throws** and never drives the counter below zero. This runs after
|
|
271
|
+
* mail has already gone out; a bookkeeping failure must not turn a delivered
|
|
272
|
+
* campaign into a 500, exactly like `recordEmailSends` above. The cost of
|
|
273
|
+
* swallowing it is that the org keeps a claim it did not use for the rest of
|
|
274
|
+
* the month, which is the safe direction.
|
|
275
|
+
*/
|
|
276
|
+
export declare function reconcileCampaignSendReservation(reservation: CampaignSendReservation | null | undefined, delivered: number, firestore?: any): Promise<void>;
|
|
277
|
+
/**
|
|
278
|
+
* Volume above the plan's included band, in emails — RE-EXPORTED, not defined
|
|
279
|
+
* here, the same move AGL-2155 made for the bandwidth helpers.
|
|
280
|
+
*
|
|
281
|
+
* The definition moved down to `@aglyn/aglyn/app-utils/plan-entitlements`
|
|
282
|
+
* because the billing page has to render the overage BEFORE it is charged,
|
|
283
|
+
* and that page is a client component which cannot import this module: the
|
|
284
|
+
* Admin SDK comes with it. A second copy of the subtraction on the client is
|
|
285
|
+
* the shape where the readout and the invoice quietly stop agreeing.
|
|
286
|
+
*
|
|
287
|
+
* Re-exported so every server caller keeps importing it from the module it
|
|
288
|
+
* always did — one definition, no drift, no import churn.
|
|
289
|
+
*
|
|
290
|
+
* @see priceEmailSendOverage — what the excess costs, at the plan's rate.
|
|
291
|
+
*/
|
|
292
|
+
export { emailSendsOverage } from '@aglyn/aglyn/app-utils/plan-entitlements';
|
|
293
|
+
export default recordEmailSends;
|