@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,464 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* PER-TENANT SENDING REPUTATION — the durable half.
|
|
19
|
+
*
|
|
20
|
+
* The policy is pure and lives in `@aglyn/shared-util-email`
|
|
21
|
+
* (`sender-reputation.ts`); this adds the counters that make a rate a fact
|
|
22
|
+
* rather than an intention, and the daily claim that paces a workspace's
|
|
23
|
+
* first week.
|
|
24
|
+
*
|
|
25
|
+
* ## One document per workspace per day
|
|
26
|
+
*
|
|
27
|
+
* `rateLimits/emailRep_{YYYY-MM-DD}_{orgId}` holds four numbers:
|
|
28
|
+
*
|
|
29
|
+
* - `claimed` — campaign messages the day's ramp has granted. The PACING
|
|
30
|
+
* counter, taken before a send and reconciled to what actually left.
|
|
31
|
+
* - `accepted` — campaign messages that actually left. The DENOMINATOR every
|
|
32
|
+
* rate divides by.
|
|
33
|
+
* - `bounced`, `complained` — what the provider reported back.
|
|
34
|
+
*
|
|
35
|
+
* A day per document rather than a map of days on one, because the day is
|
|
36
|
+
* what the TTL policy can sweep: `rateLimits` already carries an `expiresAt`
|
|
37
|
+
* policy, and a document that ages out is a window that prunes itself. A map
|
|
38
|
+
* of every day a workspace has ever sent would grow forever and would have to
|
|
39
|
+
* be pruned by a job nobody would write.
|
|
40
|
+
*
|
|
41
|
+
* A per-ORG document rather than per-site, because the org is the tenant: the
|
|
42
|
+
* plan, the monthly allowance and the hourly share are all keyed on it, and a
|
|
43
|
+
* merchant with three sites on one domain is one sender to a mailbox
|
|
44
|
+
* provider.
|
|
45
|
+
*
|
|
46
|
+
* The window document deliberately carries **no `lastAtMs`** — the
|
|
47
|
+
* rate-limiter health probe queries this collection on that field, and a
|
|
48
|
+
* per-day document in its range would compete with the degradation markers it
|
|
49
|
+
* exists to find. The same reasoning as `sendRate_*` and `sendRateOrg_*`
|
|
50
|
+
* beside it.
|
|
51
|
+
*
|
|
52
|
+
* ## Which day a bounce is filed under
|
|
53
|
+
*
|
|
54
|
+
* The day the EVENT arrived, not the day the message was sent. The webhook
|
|
55
|
+
* knows the first and would need a per-message lookup for the second, and
|
|
56
|
+
* over a seven-day window the two differ only at the edges — a bounce lands
|
|
57
|
+
* within minutes and a complaint within hours or a day. Stated because the
|
|
58
|
+
* rate is a ratio of two things counted on slightly different clocks, and a
|
|
59
|
+
* reader comparing it against a provider's dashboard should know why they
|
|
60
|
+
* will not agree to the last decimal.
|
|
61
|
+
*
|
|
62
|
+
* ## Failure posture: OPEN, and that is not the usual answer here
|
|
63
|
+
*
|
|
64
|
+
* An unreadable counter grades `ok` and grants the send, matching
|
|
65
|
+
* `consumeEmailSendBudget` and `claimOrgEmailSendBudget`. The breaker is the
|
|
66
|
+
* one control in this file that could refuse a paying customer's campaign,
|
|
67
|
+
* and a refusal produced by a Firestore blip is an outage dressed as a policy
|
|
68
|
+
* decision. The reputation risk it fails to catch is bounded by an hour of
|
|
69
|
+
* sending; the refusal it would produce is not bounded by anything.
|
|
70
|
+
*/ import { EMAIL_REPUTATION_WINDOW_DAYS, emailRampVerdict, emailReputationVerdict, normalizeEmailReputationPolicy, orgDailyCampaignCeiling, reputationDayKey, reputationWindowDayKeys, daysBetween } from "@aglyn/shared-util-email";
|
|
71
|
+
import { readEmailSendRateConfig } from "./email-send-rate.js";
|
|
72
|
+
import { firebaseAdmin } from "./firebase-admin.js";
|
|
73
|
+
import { RATE_LIMIT_COLLECTION } from "./rate-limit-store.js";
|
|
74
|
+
/** Id prefix for the per-org, per-day reputation documents. */ export const EMAIL_REPUTATION_DOC_PREFIX = 'emailRep_';
|
|
75
|
+
/** One day in ms, named so the arithmetic below reads as arithmetic. */ const DAY_MS = 86400000;
|
|
76
|
+
/** The document id for one workspace's day. */ export function emailReputationDocId(dayKey, orgId) {
|
|
77
|
+
return `${EMAIL_REPUTATION_DOC_PREFIX}${dayKey}_${orgId}`;
|
|
78
|
+
}
|
|
79
|
+
function reputationRef(firestore, dayKey, orgId) {
|
|
80
|
+
return firestore.collection(RATE_LIMIT_COLLECTION).doc(emailReputationDocId(dayKey, orgId));
|
|
81
|
+
}
|
|
82
|
+
/** A stored count, clamped. A corrupt negative reads as 0, never as headroom. */ function storedCount(snapshot, field) {
|
|
83
|
+
var _ref;
|
|
84
|
+
const value = Number((_ref = (snapshot == null ? void 0 : snapshot.exists) ? snapshot.get(field) : 0) != null ? _ref : 0);
|
|
85
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* When a day's document may be swept.
|
|
89
|
+
*
|
|
90
|
+
* One window plus a day of slack past the end of the day it counts, so the
|
|
91
|
+
* oldest day a rate still divides by is never deleted underneath the read
|
|
92
|
+
* that needs it.
|
|
93
|
+
*/ function reputationExpiresAt(dayStartMs) {
|
|
94
|
+
return new Date(dayStartMs + (EMAIL_REPUTATION_WINDOW_DAYS + 2) * DAY_MS);
|
|
95
|
+
}
|
|
96
|
+
/** Midnight UTC of the day a timestamp falls in. */ function dayStartMs(atMs) {
|
|
97
|
+
return Math.floor(atMs / DAY_MS) * DAY_MS;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Adds to one of a workspace's daily counters.
|
|
101
|
+
*
|
|
102
|
+
* **Never throws.** Reputation accounting runs after mail has gone out, or
|
|
103
|
+
* inside a webhook the provider will retry — a counter write that failed must
|
|
104
|
+
* not turn a delivered campaign into a 500 or a webhook into a retry storm.
|
|
105
|
+
* `FieldValue.increment` rather than a transaction because nothing decides
|
|
106
|
+
* anything from this write: the decision is made by a separate read, and an
|
|
107
|
+
* increment is the contention-free way to record a fact.
|
|
108
|
+
*/ export async function recordEmailReputationEvent(options) {
|
|
109
|
+
var _options_orgId, _options_count, _options_atMs;
|
|
110
|
+
const orgId = String((_options_orgId = options.orgId) != null ? _options_orgId : '');
|
|
111
|
+
const count = Math.floor(Number((_options_count = options.count) != null ? _options_count : 1));
|
|
112
|
+
if (!orgId || !Number.isFinite(count) || count <= 0) return;
|
|
113
|
+
const atMs = Number((_options_atMs = options.atMs) != null ? _options_atMs : Date.now());
|
|
114
|
+
const at = Number.isFinite(atMs) && atMs > 0 ? atMs : Date.now();
|
|
115
|
+
try {
|
|
116
|
+
var _options_firestore;
|
|
117
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
118
|
+
await reputationRef(firestore, reputationDayKey(at), orgId).set({
|
|
119
|
+
[options.counter]: firebaseAdmin.firestore.FieldValue.increment(count),
|
|
120
|
+
orgId,
|
|
121
|
+
dayKey: reputationDayKey(at),
|
|
122
|
+
// NOT `lastAtMs` — see the storage note at the top of this file.
|
|
123
|
+
expiresAt: reputationExpiresAt(dayStartMs(at))
|
|
124
|
+
}, {
|
|
125
|
+
merge: true
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error('[sender-reputation] counter write failed', error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Campaign messages that actually left, for a workspace. The denominator. */ export function recordCampaignAccepted(orgId, count, options) {
|
|
132
|
+
return recordEmailReputationEvent(_extends({
|
|
133
|
+
orgId,
|
|
134
|
+
counter: 'accepted',
|
|
135
|
+
count
|
|
136
|
+
}, options));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A permanent bounce or a spam complaint, attributed to the workspace that
|
|
140
|
+
* sent it.
|
|
141
|
+
*
|
|
142
|
+
* The suppression the same event produces is a separate act and is not
|
|
143
|
+
* conditional on this: an address comes off the list whether or not the
|
|
144
|
+
* counter write lands.
|
|
145
|
+
*/ export function recordEmailReputationFailure(orgId, kind, options) {
|
|
146
|
+
return recordEmailReputationEvent(_extends({
|
|
147
|
+
orgId,
|
|
148
|
+
counter: kind === 'complaint' ? 'complained' : 'bounced',
|
|
149
|
+
count: 1
|
|
150
|
+
}, options));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Sums a workspace's last {@link EMAIL_REPUTATION_WINDOW_DAYS} days.
|
|
154
|
+
*
|
|
155
|
+
* One `getAll` over the window's day documents — seven reads per campaign,
|
|
156
|
+
* not per message, and none at all for a workspace that has never sent. An
|
|
157
|
+
* absent day is a quiet day and reads as zero.
|
|
158
|
+
*/ export async function readSenderReputationWindow(options) {
|
|
159
|
+
var _options_windowDays, _options_orgId, _options_now;
|
|
160
|
+
const windowDays = Math.max(1, Math.floor(Number((_options_windowDays = options.windowDays) != null ? _options_windowDays : EMAIL_REPUTATION_WINDOW_DAYS)) || EMAIL_REPUTATION_WINDOW_DAYS);
|
|
161
|
+
const empty = {
|
|
162
|
+
accepted: 0,
|
|
163
|
+
bounced: 0,
|
|
164
|
+
complained: 0,
|
|
165
|
+
claimedToday: 0,
|
|
166
|
+
windowDays,
|
|
167
|
+
degraded: false
|
|
168
|
+
};
|
|
169
|
+
const orgId = String((_options_orgId = options.orgId) != null ? _options_orgId : '');
|
|
170
|
+
if (!orgId) return empty;
|
|
171
|
+
const now = Number((_options_now = options.now) != null ? _options_now : Date.now());
|
|
172
|
+
try {
|
|
173
|
+
var _options_firestore;
|
|
174
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
175
|
+
const dayKeys = reputationWindowDayKeys(now, windowDays);
|
|
176
|
+
const today = reputationDayKey(now);
|
|
177
|
+
const snapshots = await firestore.getAll(...dayKeys.map((dayKey)=>reputationRef(firestore, dayKey, orgId)));
|
|
178
|
+
return snapshots.reduce((totals, snapshot, index)=>_extends({}, totals, {
|
|
179
|
+
accepted: totals.accepted + storedCount(snapshot, 'accepted'),
|
|
180
|
+
bounced: totals.bounced + storedCount(snapshot, 'bounced'),
|
|
181
|
+
complained: totals.complained + storedCount(snapshot, 'complained'),
|
|
182
|
+
claimedToday: dayKeys[index] === today ? totals.claimedToday + storedCount(snapshot, 'claimed') : totals.claimedToday
|
|
183
|
+
}), empty);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error('[sender-reputation] window unavailable — grading ok', error);
|
|
186
|
+
return _extends({}, empty, {
|
|
187
|
+
degraded: true
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Grades one workspace.
|
|
193
|
+
*
|
|
194
|
+
* The policy and the grace period come from the ORG DOCUMENT, which the
|
|
195
|
+
* campaign sender has already read — passed in rather than re-fetched, so
|
|
196
|
+
* this costs the window read and nothing else.
|
|
197
|
+
*/ export async function readSenderReputation(options) {
|
|
198
|
+
var _options_now, _options_reinstatedUntilMs;
|
|
199
|
+
const now = Number((_options_now = options.now) != null ? _options_now : Date.now());
|
|
200
|
+
const window = await readSenderReputationWindow({
|
|
201
|
+
orgId: options.orgId,
|
|
202
|
+
now,
|
|
203
|
+
firestore: options.firestore
|
|
204
|
+
});
|
|
205
|
+
const verdict = emailReputationVerdict({
|
|
206
|
+
counts: window,
|
|
207
|
+
// A degraded read grades against zeroes, which is `ok` by construction.
|
|
208
|
+
// Stated rather than special-cased: the fail-open posture at the top of
|
|
209
|
+
// this file is the reason, and a branch here would be a second place for
|
|
210
|
+
// it to be got wrong.
|
|
211
|
+
policy: normalizeEmailReputationPolicy(options.policy),
|
|
212
|
+
windowDays: window.windowDays,
|
|
213
|
+
reinstatedUntilMs: Number((_options_reinstatedUntilMs = options.reinstatedUntilMs) != null ? _options_reinstatedUntilMs : 0) || null,
|
|
214
|
+
now
|
|
215
|
+
});
|
|
216
|
+
return _extends({}, verdict, {
|
|
217
|
+
degraded: window.degraded,
|
|
218
|
+
window
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The ramp step a workspace has EARNED, from a window it has already read.
|
|
223
|
+
*
|
|
224
|
+
* A thin wrapper over the pure policy that supplies the one number the policy
|
|
225
|
+
* cannot know: what a graduated workspace's day is, which is a share of the
|
|
226
|
+
* live platform ceiling and therefore moves with a staff ramp.
|
|
227
|
+
*/ export function resolveOrgEmailRamp(options) {
|
|
228
|
+
return emailRampVerdict({
|
|
229
|
+
ageDays: options.ageDays,
|
|
230
|
+
deliveredLifetime: options.deliveredLifetime,
|
|
231
|
+
graduatedPerDay: orgDailyCampaignCeiling(options.platformPerHour)
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A workspace's ramp step, resolved from its record and its history.
|
|
236
|
+
*
|
|
237
|
+
* The reads {@link resolveOrgEmailRamp} needs, in the order that pays for
|
|
238
|
+
* the fewest of them. The platform ceiling comes first because it is process-
|
|
239
|
+
* cached for fifteen seconds and therefore free; the seven-day window is a
|
|
240
|
+
* `getAll` and is only paid when the ramp can still bind.
|
|
241
|
+
*
|
|
242
|
+
* ## A graduated workspace pays nothing
|
|
243
|
+
*
|
|
244
|
+
* Age alone decides graduation — delivery volume can only RAISE a step, never
|
|
245
|
+
* reach past the last one — so a workspace past its first week is answered
|
|
246
|
+
* from the org record with no window read at all. That is every existing
|
|
247
|
+
* customer and every org whose record predates the creation timestamp, which
|
|
248
|
+
* is to say almost every call.
|
|
249
|
+
*
|
|
250
|
+
* ## An unreadable window is `degraded`, and it is the caller's to answer
|
|
251
|
+
*
|
|
252
|
+
* The window read grades itself rather than throwing, and an unreadable one
|
|
253
|
+
* reads as zero delivered — the LOWEST step, which is the conservative
|
|
254
|
+
* direction for a ceiling. What it is not is a fact, so it is flagged: a
|
|
255
|
+
* surface admitting one message at a time cannot defer the remainder to
|
|
256
|
+
* tomorrow the way a campaign can, and a limiter with no answer must refuse
|
|
257
|
+
* rather than admit.
|
|
258
|
+
*
|
|
259
|
+
* ## The platform flag parks this control
|
|
260
|
+
*
|
|
261
|
+
* `enabled: false` on the send-rate config graduates every workspace here,
|
|
262
|
+
* exactly as it parks the hourly share and {@link claimOrgEmailSendDay} — one
|
|
263
|
+
* switch, and it turns the ramp off everywhere it is consulted.
|
|
264
|
+
*/ export async function readOrgEmailRamp(options) {
|
|
265
|
+
var _options_now;
|
|
266
|
+
const now = Number((_options_now = options.now) != null ? _options_now : Date.now());
|
|
267
|
+
const at = Number.isFinite(now) && now > 0 ? now : Date.now();
|
|
268
|
+
const config = await readEmailSendRateConfig(options.firestore ? {
|
|
269
|
+
firestore: options.firestore,
|
|
270
|
+
now: at
|
|
271
|
+
} : {
|
|
272
|
+
now: at
|
|
273
|
+
});
|
|
274
|
+
const platformPerHour = config.perHour;
|
|
275
|
+
if (!config.enabled) {
|
|
276
|
+
// A parked control answers with the verdict an established workspace
|
|
277
|
+
// gets, so a surface reading the step does not blank while it is off.
|
|
278
|
+
const parked = resolveOrgEmailRamp({
|
|
279
|
+
ageDays: null,
|
|
280
|
+
deliveredLifetime: 0,
|
|
281
|
+
platformPerHour
|
|
282
|
+
});
|
|
283
|
+
return {
|
|
284
|
+
ramp: parked,
|
|
285
|
+
degraded: false
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const ageDays = orgAgeDays(options.createdAt, at);
|
|
289
|
+
/*
|
|
290
|
+
* The step this workspace would be on having delivered nothing. It is the
|
|
291
|
+
* graduation test and the floor in one value: delivered volume only ever
|
|
292
|
+
* raises a step, so a verdict that graduates here graduates at any volume,
|
|
293
|
+
* and one that does not is the lowest step the workspace can be on.
|
|
294
|
+
*/ const floor = resolveOrgEmailRamp({
|
|
295
|
+
ageDays,
|
|
296
|
+
deliveredLifetime: 0,
|
|
297
|
+
platformPerHour
|
|
298
|
+
});
|
|
299
|
+
if (floor.graduated) return {
|
|
300
|
+
ramp: floor,
|
|
301
|
+
degraded: false
|
|
302
|
+
};
|
|
303
|
+
const window = await readSenderReputationWindow({
|
|
304
|
+
orgId: options.orgId,
|
|
305
|
+
now: at,
|
|
306
|
+
firestore: options.firestore
|
|
307
|
+
});
|
|
308
|
+
if (window.degraded) return {
|
|
309
|
+
ramp: floor,
|
|
310
|
+
degraded: true
|
|
311
|
+
};
|
|
312
|
+
return {
|
|
313
|
+
ramp: resolveOrgEmailRamp({
|
|
314
|
+
ageDays,
|
|
315
|
+
deliveredLifetime: window.accepted,
|
|
316
|
+
platformPerHour
|
|
317
|
+
}),
|
|
318
|
+
degraded: false
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* THE NEW-SENDER RAMP, ENFORCED.
|
|
323
|
+
*
|
|
324
|
+
* Claims `count` campaign messages against what this workspace may send
|
|
325
|
+
* today, atomically. The transaction reads the counter and writes an ABSOLUTE
|
|
326
|
+
* value derived from that read rather than an increment, for the reason
|
|
327
|
+
* `reserveCampaignEmailSends` and `claimOrgEmailSendBudget` both do: the read
|
|
328
|
+
* is the authority for the decision, and an increment proves nothing about
|
|
329
|
+
* the value the decision was made from.
|
|
330
|
+
*
|
|
331
|
+
* The ceiling arrives as a resolved {@link EmailRampVerdict} rather than
|
|
332
|
+
* being derived here, because the caller has already paid the window read
|
|
333
|
+
* that decides which step the workspace is on — see
|
|
334
|
+
* {@link resolveOrgEmailRamp}. A graduated workspace claims nothing at all:
|
|
335
|
+
* the ramp does not bind it, and the hourly share that does has already been
|
|
336
|
+
* claimed by the time this runs.
|
|
337
|
+
*
|
|
338
|
+
* **Campaigns only**, like the hourly claim beside it. This function is not
|
|
339
|
+
* on `sendEmail`'s path and cannot be reached by a transactional message.
|
|
340
|
+
*
|
|
341
|
+
* A refused claim writes NOTHING — a campaign that resumes tomorrow must not
|
|
342
|
+
* have spent tomorrow's budget on being told no.
|
|
343
|
+
*/ export async function claimOrgEmailSendDay(options) {
|
|
344
|
+
var _options_now, _options_orgId;
|
|
345
|
+
const now = Number((_options_now = options.now) != null ? _options_now : Date.now());
|
|
346
|
+
const at = Number.isFinite(now) && now > 0 ? now : Date.now();
|
|
347
|
+
const dayKey = reputationDayKey(at);
|
|
348
|
+
const retryAtMs = dayStartMs(at) + DAY_MS;
|
|
349
|
+
const count = Math.max(0, Math.floor(Number(options.count) || 0));
|
|
350
|
+
const orgId = String((_options_orgId = options.orgId) != null ? _options_orgId : '');
|
|
351
|
+
const ramp = options.ramp;
|
|
352
|
+
const granted = (used, degraded, reservation)=>({
|
|
353
|
+
allowed: true,
|
|
354
|
+
used,
|
|
355
|
+
ceiling: ramp.perDay,
|
|
356
|
+
remaining: Math.max(0, ramp.perDay - (used + count)),
|
|
357
|
+
retryAtMs,
|
|
358
|
+
ramp,
|
|
359
|
+
degraded,
|
|
360
|
+
reservation
|
|
361
|
+
});
|
|
362
|
+
// A parked control still reports a real ceiling, so a surface reading this
|
|
363
|
+
// does not blank while the control is off.
|
|
364
|
+
if (options.enabled === false) return granted(0, false, null);
|
|
365
|
+
// A graduated workspace is not paced by the ramp at all. No read, no write,
|
|
366
|
+
// and no claim to give back.
|
|
367
|
+
if (ramp.graduated) return granted(0, false, null);
|
|
368
|
+
if (!orgId) return granted(0, true, null);
|
|
369
|
+
try {
|
|
370
|
+
var _options_firestore;
|
|
371
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
372
|
+
const ref = reputationRef(firestore, dayKey, orgId);
|
|
373
|
+
return await firestore.runTransaction(async (tx)=>{
|
|
374
|
+
const snapshot = await tx.get(ref);
|
|
375
|
+
const used = storedCount(snapshot, 'claimed');
|
|
376
|
+
if (used + count > ramp.perDay) {
|
|
377
|
+
// A refused claim writes NOTHING.
|
|
378
|
+
return {
|
|
379
|
+
allowed: false,
|
|
380
|
+
used,
|
|
381
|
+
ceiling: ramp.perDay,
|
|
382
|
+
remaining: Math.max(0, ramp.perDay - used),
|
|
383
|
+
retryAtMs,
|
|
384
|
+
ramp,
|
|
385
|
+
degraded: false,
|
|
386
|
+
reservation: null
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
tx.set(ref, {
|
|
390
|
+
claimed: used + count,
|
|
391
|
+
orgId,
|
|
392
|
+
dayKey,
|
|
393
|
+
expiresAt: reputationExpiresAt(dayStartMs(at))
|
|
394
|
+
}, {
|
|
395
|
+
merge: true
|
|
396
|
+
});
|
|
397
|
+
return granted(used, false, {
|
|
398
|
+
orgId,
|
|
399
|
+
dayKey,
|
|
400
|
+
claimed: count
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.error('[sender-reputation] day window unavailable — allowing', error);
|
|
405
|
+
return granted(0, true, null);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Returns the undelivered part of a day's ramp claim.
|
|
410
|
+
*
|
|
411
|
+
* The hourly claim is deliberately never reconciled — its window is an hour
|
|
412
|
+
* and an unrefunded remainder costs the org the rest of it. A DAY is long
|
|
413
|
+
* enough that the same choice would cost a new workspace its whole first day
|
|
414
|
+
* over one failed batch, which is the opposite of what a ramp is for.
|
|
415
|
+
*
|
|
416
|
+
* **Never throws** and never drives the counter below zero, exactly as
|
|
417
|
+
* `reconcileCampaignSendReservation` does one surface over.
|
|
418
|
+
*/ export async function reconcileOrgEmailSendDay(reservation, delivered, firestore) {
|
|
419
|
+
if (!(reservation == null ? void 0 : reservation.orgId)) return;
|
|
420
|
+
const sent = Math.max(0, Math.floor(Number(delivered) || 0));
|
|
421
|
+
const refund = Math.max(0, reservation.claimed - sent);
|
|
422
|
+
if (refund <= 0) return;
|
|
423
|
+
try {
|
|
424
|
+
const db = firestore != null ? firestore : firebaseAdmin.app().firestore();
|
|
425
|
+
const ref = reputationRef(db, reservation.dayKey, reservation.orgId);
|
|
426
|
+
await db.runTransaction(async (tx)=>{
|
|
427
|
+
const snapshot = await tx.get(ref);
|
|
428
|
+
const used = storedCount(snapshot, 'claimed');
|
|
429
|
+
tx.set(ref, {
|
|
430
|
+
claimed: Math.max(0, used - refund)
|
|
431
|
+
}, {
|
|
432
|
+
merge: true
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
} catch (error) {
|
|
436
|
+
console.error('[sender-reputation] day reconcile failed', error);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* How old a workspace is, in days, from whatever its `createdAt` turns out to
|
|
441
|
+
* be.
|
|
442
|
+
*
|
|
443
|
+
* Firestore hands back a `Timestamp`, a restore or a fixture can leave a
|
|
444
|
+
* number, and an org written before the field existed has nothing. All three
|
|
445
|
+
* are handled and the last one answers `null`, which
|
|
446
|
+
* {@link claimOrgEmailSendDay} reads as graduated — the only safe direction,
|
|
447
|
+
* for the reason stated there.
|
|
448
|
+
*/ export function orgAgeDays(createdAt, now = Date.now()) {
|
|
449
|
+
const raw = createdAt;
|
|
450
|
+
let createdMs = 0;
|
|
451
|
+
if (typeof raw === 'number') createdMs = raw;
|
|
452
|
+
else if (typeof raw === 'string') createdMs = Date.parse(raw);
|
|
453
|
+
else if (typeof (raw == null ? void 0 : raw.toMillis) === 'function') createdMs = raw.toMillis();
|
|
454
|
+
else if (typeof (raw == null ? void 0 : raw.seconds) === 'number') createdMs = raw.seconds * 1000;
|
|
455
|
+
/*
|
|
456
|
+
* The one check, and it has to answer `null` rather than 0. A creation date
|
|
457
|
+
* that could not be read is an org whose record predates the field, which
|
|
458
|
+
* is an EXISTING customer; `0` would say "created today" and ramp every
|
|
459
|
+
* paying tenant on the platform down to the first step.
|
|
460
|
+
*/ if (!Number.isFinite(createdMs) || createdMs <= 0) return null;
|
|
461
|
+
return daysBetween(createdMs, now);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
//# sourceMappingURL=email-sender-reputation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/email-sender-reputation.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 * PER-TENANT SENDING REPUTATION — the durable half.\n *\n * The policy is pure and lives in `@aglyn/shared-util-email`\n * (`sender-reputation.ts`); this adds the counters that make a rate a fact\n * rather than an intention, and the daily claim that paces a workspace's\n * first week.\n *\n * ## One document per workspace per day\n *\n * `rateLimits/emailRep_{YYYY-MM-DD}_{orgId}` holds four numbers:\n *\n * - `claimed` — campaign messages the day's ramp has granted. The PACING\n * counter, taken before a send and reconciled to what actually left.\n * - `accepted` — campaign messages that actually left. The DENOMINATOR every\n * rate divides by.\n * - `bounced`, `complained` — what the provider reported back.\n *\n * A day per document rather than a map of days on one, because the day is\n * what the TTL policy can sweep: `rateLimits` already carries an `expiresAt`\n * policy, and a document that ages out is a window that prunes itself. A map\n * of every day a workspace has ever sent would grow forever and would have to\n * be pruned by a job nobody would write.\n *\n * A per-ORG document rather than per-site, because the org is the tenant: the\n * plan, the monthly allowance and the hourly share are all keyed on it, and a\n * merchant with three sites on one domain is one sender to a mailbox\n * provider.\n *\n * The window document deliberately carries **no `lastAtMs`** — the\n * rate-limiter health probe queries this collection on that field, and a\n * per-day document in its range would compete with the degradation markers it\n * exists to find. The same reasoning as `sendRate_*` and `sendRateOrg_*`\n * beside it.\n *\n * ## Which day a bounce is filed under\n *\n * The day the EVENT arrived, not the day the message was sent. The webhook\n * knows the first and would need a per-message lookup for the second, and\n * over a seven-day window the two differ only at the edges — a bounce lands\n * within minutes and a complaint within hours or a day. Stated because the\n * rate is a ratio of two things counted on slightly different clocks, and a\n * reader comparing it against a provider's dashboard should know why they\n * will not agree to the last decimal.\n *\n * ## Failure posture: OPEN, and that is not the usual answer here\n *\n * An unreadable counter grades `ok` and grants the send, matching\n * `consumeEmailSendBudget` and `claimOrgEmailSendBudget`. The breaker is the\n * one control in this file that could refuse a paying customer's campaign,\n * and a refusal produced by a Firestore blip is an outage dressed as a policy\n * decision. The reputation risk it fails to catch is bounded by an hour of\n * sending; the refusal it would produce is not bounded by anything.\n */\n\nimport {\n EMAIL_REPUTATION_WINDOW_DAYS,\n emailRampVerdict,\n emailReputationVerdict,\n normalizeEmailReputationPolicy,\n orgDailyCampaignCeiling,\n reputationDayKey,\n reputationWindowDayKeys,\n daysBetween,\n type EmailRampVerdict,\n type EmailReputationCounts,\n type EmailReputationPolicy,\n type EmailReputationVerdict,\n} from '@aglyn/shared-util-email'\nimport { readEmailSendRateConfig } from './email-send-rate'\nimport { firebaseAdmin } from './firebase-admin'\nimport { RATE_LIMIT_COLLECTION } from './rate-limit-store'\n\n/** Id prefix for the per-org, per-day reputation documents. */\nexport const EMAIL_REPUTATION_DOC_PREFIX = 'emailRep_'\n\n/** One day in ms, named so the arithmetic below reads as arithmetic. */\nconst DAY_MS = 86_400_000\n\n/** The document id for one workspace's day. */\nexport function emailReputationDocId(dayKey: string, orgId: string): string {\n return `${EMAIL_REPUTATION_DOC_PREFIX}${dayKey}_${orgId}`\n}\n\n/** The counted fields, so a writer cannot invent a fifth by typo. */\nexport type EmailReputationCounter =\n | 'claimed'\n | 'accepted'\n | 'bounced'\n | 'complained'\n\nfunction reputationRef(firestore: any, dayKey: string, orgId: string): any {\n return firestore\n .collection(RATE_LIMIT_COLLECTION)\n .doc(emailReputationDocId(dayKey, orgId))\n}\n\n/** A stored count, clamped. A corrupt negative reads as 0, never as headroom. */\nfunction storedCount(snapshot: any, field: EmailReputationCounter): number {\n const value = Number((snapshot?.exists ? snapshot.get(field) : 0) ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * When a day's document may be swept.\n *\n * One window plus a day of slack past the end of the day it counts, so the\n * oldest day a rate still divides by is never deleted underneath the read\n * that needs it.\n */\nfunction reputationExpiresAt(dayStartMs: number): Date {\n return new Date(dayStartMs + (EMAIL_REPUTATION_WINDOW_DAYS + 2) * DAY_MS)\n}\n\n/** Midnight UTC of the day a timestamp falls in. */\nfunction dayStartMs(atMs: number): number {\n return Math.floor(atMs / DAY_MS) * DAY_MS\n}\n\n/**\n * Adds to one of a workspace's daily counters.\n *\n * **Never throws.** Reputation accounting runs after mail has gone out, or\n * inside a webhook the provider will retry — a counter write that failed must\n * not turn a delivered campaign into a 500 or a webhook into a retry storm.\n * `FieldValue.increment` rather than a transaction because nothing decides\n * anything from this write: the decision is made by a separate read, and an\n * increment is the contention-free way to record a fact.\n */\nexport async function recordEmailReputationEvent(options: {\n orgId: string\n counter: EmailReputationCounter\n count?: number\n atMs?: number\n firestore?: any\n}): Promise<void> {\n const orgId = String(options.orgId ?? '')\n const count = Math.floor(Number(options.count ?? 1))\n if (!orgId || !Number.isFinite(count) || count <= 0) return\n const atMs = Number(options.atMs ?? Date.now())\n const at = Number.isFinite(atMs) && atMs > 0 ? atMs : Date.now()\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n await reputationRef(firestore, reputationDayKey(at), orgId).set(\n {\n [options.counter]: firebaseAdmin.firestore.FieldValue.increment(count),\n orgId,\n dayKey: reputationDayKey(at),\n // NOT `lastAtMs` — see the storage note at the top of this file.\n expiresAt: reputationExpiresAt(dayStartMs(at)),\n },\n { merge: true },\n )\n } catch (error) {\n console.error('[sender-reputation] counter write failed', error)\n }\n}\n\n/** Campaign messages that actually left, for a workspace. The denominator. */\nexport function recordCampaignAccepted(\n orgId: string,\n count: number,\n options?: { atMs?: number; firestore?: any },\n): Promise<void> {\n return recordEmailReputationEvent({\n orgId,\n counter: 'accepted',\n count,\n ...options,\n })\n}\n\n/**\n * A permanent bounce or a spam complaint, attributed to the workspace that\n * sent it.\n *\n * The suppression the same event produces is a separate act and is not\n * conditional on this: an address comes off the list whether or not the\n * counter write lands.\n */\nexport function recordEmailReputationFailure(\n orgId: string,\n kind: 'bounce' | 'complaint',\n options?: { atMs?: number; firestore?: any },\n): Promise<void> {\n return recordEmailReputationEvent({\n orgId,\n counter: kind === 'complaint' ? 'complained' : 'bounced',\n count: 1,\n ...options,\n })\n}\n\n/** The counts a workspace's window holds, plus what the ramp needs. */\nexport interface SenderReputationWindow extends EmailReputationCounts {\n /**\n * Campaign messages the ramp has granted TODAY.\n *\n * Read here rather than by a second query because today's document is\n * already in this window: the ramp needs one number off it and the rates\n * need three off all of them, and two reads of one document to answer two\n * questions about the same workspace is a round trip nobody has to pay.\n */\n claimedToday: number\n /** Days the window covers. */\n windowDays: number\n /** True when the window could not be read and everything below is zero. */\n degraded: boolean\n}\n\n/**\n * Sums a workspace's last {@link EMAIL_REPUTATION_WINDOW_DAYS} days.\n *\n * One `getAll` over the window's day documents — seven reads per campaign,\n * not per message, and none at all for a workspace that has never sent. An\n * absent day is a quiet day and reads as zero.\n */\nexport async function readSenderReputationWindow(options: {\n orgId: string\n now?: number\n windowDays?: number\n firestore?: any\n}): Promise<SenderReputationWindow> {\n const windowDays = Math.max(\n 1,\n Math.floor(Number(options.windowDays ?? EMAIL_REPUTATION_WINDOW_DAYS)) ||\n EMAIL_REPUTATION_WINDOW_DAYS,\n )\n const empty: SenderReputationWindow = {\n accepted: 0,\n bounced: 0,\n complained: 0,\n claimedToday: 0,\n windowDays,\n degraded: false,\n }\n const orgId = String(options.orgId ?? '')\n if (!orgId) return empty\n const now = Number(options.now ?? Date.now())\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const dayKeys = reputationWindowDayKeys(now, windowDays)\n const today = reputationDayKey(now)\n const snapshots = await firestore.getAll(\n ...dayKeys.map((dayKey) => reputationRef(firestore, dayKey, orgId)),\n )\n return snapshots.reduce(\n (totals: SenderReputationWindow, snapshot: any, index: number) => ({\n ...totals,\n accepted: totals.accepted + storedCount(snapshot, 'accepted'),\n bounced: totals.bounced + storedCount(snapshot, 'bounced'),\n complained: totals.complained + storedCount(snapshot, 'complained'),\n claimedToday:\n dayKeys[index] === today\n ? totals.claimedToday + storedCount(snapshot, 'claimed')\n : totals.claimedToday,\n }),\n empty,\n )\n } catch (error) {\n console.error('[sender-reputation] window unavailable — grading ok', error)\n return { ...empty, degraded: true }\n }\n}\n\n/** A workspace's reputation, and whether its campaigns may go out. */\nexport interface SenderReputationRead extends EmailReputationVerdict {\n /** True when the window could not be read, so nothing was graded. */\n degraded: boolean\n /** The counts the grade was made from, so a caller can size a ramp too. */\n window: SenderReputationWindow\n}\n\n/**\n * Grades one workspace.\n *\n * The policy and the grace period come from the ORG DOCUMENT, which the\n * campaign sender has already read — passed in rather than re-fetched, so\n * this costs the window read and nothing else.\n */\nexport async function readSenderReputation(options: {\n orgId: string\n policy?: unknown\n reinstatedUntilMs?: unknown\n now?: number\n firestore?: any\n}): Promise<SenderReputationRead> {\n const now = Number(options.now ?? Date.now())\n const window = await readSenderReputationWindow({\n orgId: options.orgId,\n now,\n firestore: options.firestore,\n })\n const verdict = emailReputationVerdict({\n counts: window,\n // A degraded read grades against zeroes, which is `ok` by construction.\n // Stated rather than special-cased: the fail-open posture at the top of\n // this file is the reason, and a branch here would be a second place for\n // it to be got wrong.\n policy: normalizeEmailReputationPolicy(options.policy),\n windowDays: window.windowDays,\n reinstatedUntilMs: Number(options.reinstatedUntilMs ?? 0) || null,\n now,\n })\n return { ...verdict, degraded: window.degraded, window }\n}\n\n/**\n * The ramp step a workspace has EARNED, from a window it has already read.\n *\n * A thin wrapper over the pure policy that supplies the one number the policy\n * cannot know: what a graduated workspace's day is, which is a share of the\n * live platform ceiling and therefore moves with a staff ramp.\n */\nexport function resolveOrgEmailRamp(options: {\n /** Days since the workspace was created; null or absent graduates it. */\n ageDays: number | null | undefined\n /** Campaign messages it has delivered inside the window. */\n deliveredLifetime: number\n /** The live platform ceiling. */\n platformPerHour: number\n}): EmailRampVerdict {\n return emailRampVerdict({\n ageDays: options.ageDays,\n deliveredLifetime: options.deliveredLifetime,\n graduatedPerDay: orgDailyCampaignCeiling(options.platformPerHour),\n })\n}\n\n/** A workspace's ramp step, and whether the history behind it was readable. */\nexport interface OrgEmailRampRead {\n /** The step, from the age and the delivery history that resolved. */\n ramp: EmailRampVerdict\n /**\n * True when the ramp BINDS and the window it is sized from was unreadable,\n * so {@link ramp} is a floor rather than an answer.\n *\n * A caller that pages a batch — the campaign path — may treat this as the\n * step and carry on, because the floor is the lowest step and erring low\n * defers mail rather than sending it. A caller that admits or refuses ONE\n * message has no such third answer and must refuse: see\n * {@link readOrgEmailRamp}.\n */\n degraded: boolean\n}\n\n/**\n * A workspace's ramp step, resolved from its record and its history.\n *\n * The reads {@link resolveOrgEmailRamp} needs, in the order that pays for\n * the fewest of them. The platform ceiling comes first because it is process-\n * cached for fifteen seconds and therefore free; the seven-day window is a\n * `getAll` and is only paid when the ramp can still bind.\n *\n * ## A graduated workspace pays nothing\n *\n * Age alone decides graduation — delivery volume can only RAISE a step, never\n * reach past the last one — so a workspace past its first week is answered\n * from the org record with no window read at all. That is every existing\n * customer and every org whose record predates the creation timestamp, which\n * is to say almost every call.\n *\n * ## An unreadable window is `degraded`, and it is the caller's to answer\n *\n * The window read grades itself rather than throwing, and an unreadable one\n * reads as zero delivered — the LOWEST step, which is the conservative\n * direction for a ceiling. What it is not is a fact, so it is flagged: a\n * surface admitting one message at a time cannot defer the remainder to\n * tomorrow the way a campaign can, and a limiter with no answer must refuse\n * rather than admit.\n *\n * ## The platform flag parks this control\n *\n * `enabled: false` on the send-rate config graduates every workspace here,\n * exactly as it parks the hourly share and {@link claimOrgEmailSendDay} — one\n * switch, and it turns the ramp off everywhere it is consulted.\n */\nexport async function readOrgEmailRamp(options: {\n orgId: string\n /** The org record's `createdAt`, in whatever shape it was stored. */\n createdAt: unknown\n now?: number\n firestore?: any\n}): Promise<OrgEmailRampRead> {\n const now = Number(options.now ?? Date.now())\n const at = Number.isFinite(now) && now > 0 ? now : Date.now()\n const config = await readEmailSendRateConfig(\n options.firestore ? { firestore: options.firestore, now: at } : { now: at },\n )\n const platformPerHour = config.perHour\n if (!config.enabled) {\n // A parked control answers with the verdict an established workspace\n // gets, so a surface reading the step does not blank while it is off.\n const parked = resolveOrgEmailRamp({\n ageDays: null,\n deliveredLifetime: 0,\n platformPerHour,\n })\n return { ramp: parked, degraded: false }\n }\n const ageDays = orgAgeDays(options.createdAt, at)\n /*\n * The step this workspace would be on having delivered nothing. It is the\n * graduation test and the floor in one value: delivered volume only ever\n * raises a step, so a verdict that graduates here graduates at any volume,\n * and one that does not is the lowest step the workspace can be on.\n */\n const floor = resolveOrgEmailRamp({\n ageDays,\n deliveredLifetime: 0,\n platformPerHour,\n })\n if (floor.graduated) return { ramp: floor, degraded: false }\n const window = await readSenderReputationWindow({\n orgId: options.orgId,\n now: at,\n firestore: options.firestore,\n })\n if (window.degraded) return { ramp: floor, degraded: true }\n return {\n ramp: resolveOrgEmailRamp({\n ageDays,\n deliveredLifetime: window.accepted,\n platformPerHour,\n }),\n degraded: false,\n }\n}\n\n/** The answer to a per-day ramp claim. Every field is a stated number. */\nexport interface OrgEmailSendDayClaim {\n allowed: boolean\n /** Messages claimed today BEFORE this send. */\n used: number\n /** What this workspace may send today. */\n ceiling: number\n /** Headroom after this send, floored at 0. */\n remaining: number\n /** When the day rolls and a deferred campaign may go, ms. */\n retryAtMs: number\n /** The ramp decision the ceiling came from. */\n ramp: EmailRampVerdict\n /** True when the counter was unreachable and this failed open. */\n degraded: boolean\n /**\n * What to hand {@link reconcileOrgEmailSendDay} once the batch is done.\n * Null when nothing was claimed — a graduated workspace, a parked control,\n * or a refusal.\n */\n reservation: OrgEmailSendDayReservation | null\n}\n\n/** A claim on a day's ramp, so an undelivered remainder can be given back. */\nexport interface OrgEmailSendDayReservation {\n orgId: string\n dayKey: string\n claimed: number\n}\n\n/**\n * THE NEW-SENDER RAMP, ENFORCED.\n *\n * Claims `count` campaign messages against what this workspace may send\n * today, atomically. The transaction reads the counter and writes an ABSOLUTE\n * value derived from that read rather than an increment, for the reason\n * `reserveCampaignEmailSends` and `claimOrgEmailSendBudget` both do: the read\n * is the authority for the decision, and an increment proves nothing about\n * the value the decision was made from.\n *\n * The ceiling arrives as a resolved {@link EmailRampVerdict} rather than\n * being derived here, because the caller has already paid the window read\n * that decides which step the workspace is on — see\n * {@link resolveOrgEmailRamp}. A graduated workspace claims nothing at all:\n * the ramp does not bind it, and the hourly share that does has already been\n * claimed by the time this runs.\n *\n * **Campaigns only**, like the hourly claim beside it. This function is not\n * on `sendEmail`'s path and cannot be reached by a transactional message.\n *\n * A refused claim writes NOTHING — a campaign that resumes tomorrow must not\n * have spent tomorrow's budget on being told no.\n */\nexport async function claimOrgEmailSendDay(options: {\n orgId: string\n /** Messages this batch would send. */\n count: number\n /** What this workspace may send today, from {@link resolveOrgEmailRamp}. */\n ramp: EmailRampVerdict\n /** False parks the control, exactly as the platform governor's flag does. */\n enabled?: boolean\n now?: number\n firestore?: any\n}): Promise<OrgEmailSendDayClaim> {\n const now = Number(options.now ?? Date.now())\n const at = Number.isFinite(now) && now > 0 ? now : Date.now()\n const dayKey = reputationDayKey(at)\n const retryAtMs = dayStartMs(at) + DAY_MS\n const count = Math.max(0, Math.floor(Number(options.count) || 0))\n const orgId = String(options.orgId ?? '')\n const ramp = options.ramp\n\n const granted = (\n used: number,\n degraded: boolean,\n reservation: OrgEmailSendDayReservation | null,\n ): OrgEmailSendDayClaim => ({\n allowed: true,\n used,\n ceiling: ramp.perDay,\n remaining: Math.max(0, ramp.perDay - (used + count)),\n retryAtMs,\n ramp,\n degraded,\n reservation,\n })\n\n // A parked control still reports a real ceiling, so a surface reading this\n // does not blank while the control is off.\n if (options.enabled === false) return granted(0, false, null)\n // A graduated workspace is not paced by the ramp at all. No read, no write,\n // and no claim to give back.\n if (ramp.graduated) return granted(0, false, null)\n if (!orgId) return granted(0, true, null)\n\n try {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const ref = reputationRef(firestore, dayKey, orgId)\n return await firestore.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = storedCount(snapshot, 'claimed')\n if (used + count > ramp.perDay) {\n // A refused claim writes NOTHING.\n return {\n allowed: false,\n used,\n ceiling: ramp.perDay,\n remaining: Math.max(0, ramp.perDay - used),\n retryAtMs,\n ramp,\n degraded: false,\n reservation: null,\n }\n }\n tx.set(\n ref,\n {\n claimed: used + count,\n orgId,\n dayKey,\n expiresAt: reputationExpiresAt(dayStartMs(at)),\n },\n { merge: true },\n )\n return granted(used, false, { orgId, dayKey, claimed: count })\n })\n } catch (error) {\n console.error('[sender-reputation] day window unavailable — allowing', error)\n return granted(0, true, null)\n }\n}\n\n/**\n * Returns the undelivered part of a day's ramp claim.\n *\n * The hourly claim is deliberately never reconciled — its window is an hour\n * and an unrefunded remainder costs the org the rest of it. A DAY is long\n * enough that the same choice would cost a new workspace its whole first day\n * over one failed batch, which is the opposite of what a ramp is for.\n *\n * **Never throws** and never drives the counter below zero, exactly as\n * `reconcileCampaignSendReservation` does one surface over.\n */\nexport async function reconcileOrgEmailSendDay(\n reservation: OrgEmailSendDayReservation | null | undefined,\n delivered: number,\n firestore?: any,\n): Promise<void> {\n if (!reservation?.orgId) return\n const sent = Math.max(0, Math.floor(Number(delivered) || 0))\n const refund = Math.max(0, reservation.claimed - sent)\n if (refund <= 0) return\n try {\n const db = firestore ?? firebaseAdmin.app().firestore()\n const ref = reputationRef(db, reservation.dayKey, reservation.orgId)\n await db.runTransaction(async (tx: any) => {\n const snapshot = await tx.get(ref)\n const used = storedCount(snapshot, 'claimed')\n tx.set(ref, { claimed: Math.max(0, used - refund) }, { merge: true })\n })\n } catch (error) {\n console.error('[sender-reputation] day reconcile failed', error)\n }\n}\n\n/**\n * How old a workspace is, in days, from whatever its `createdAt` turns out to\n * be.\n *\n * Firestore hands back a `Timestamp`, a restore or a fixture can leave a\n * number, and an org written before the field existed has nothing. All three\n * are handled and the last one answers `null`, which\n * {@link claimOrgEmailSendDay} reads as graduated — the only safe direction,\n * for the reason stated there.\n */\nexport function orgAgeDays(\n createdAt: unknown,\n now: number = Date.now(),\n): number | null {\n const raw = createdAt as\n | { toMillis?: () => number; seconds?: number }\n | number\n | string\n | null\n | undefined\n let createdMs = 0\n if (typeof raw === 'number') createdMs = raw\n else if (typeof raw === 'string') createdMs = Date.parse(raw)\n else if (typeof raw?.toMillis === 'function') createdMs = raw.toMillis()\n else if (typeof raw?.seconds === 'number') createdMs = raw.seconds * 1000\n /*\n * The one check, and it has to answer `null` rather than 0. A creation date\n * that could not be read is an org whose record predates the field, which\n * is an EXISTING customer; `0` would say \"created today\" and ramp every\n * paying tenant on the platform down to the first step.\n */\n if (!Number.isFinite(createdMs) || createdMs <= 0) return null\n return daysBetween(createdMs, now)\n}\n\n/** Re-exported so a caller reads one module for the whole control. */\nexport type { EmailReputationPolicy, EmailRampVerdict }\n"],"names":["EMAIL_REPUTATION_WINDOW_DAYS","emailRampVerdict","emailReputationVerdict","normalizeEmailReputationPolicy","orgDailyCampaignCeiling","reputationDayKey","reputationWindowDayKeys","daysBetween","readEmailSendRateConfig","firebaseAdmin","RATE_LIMIT_COLLECTION","EMAIL_REPUTATION_DOC_PREFIX","DAY_MS","emailReputationDocId","dayKey","orgId","reputationRef","firestore","collection","doc","storedCount","snapshot","field","value","Number","exists","get","isFinite","Math","floor","reputationExpiresAt","dayStartMs","Date","atMs","recordEmailReputationEvent","options","String","count","now","at","app","set","counter","FieldValue","increment","expiresAt","merge","error","console","recordCampaignAccepted","recordEmailReputationFailure","kind","readSenderReputationWindow","windowDays","max","empty","accepted","bounced","complained","claimedToday","degraded","dayKeys","today","snapshots","getAll","map","reduce","totals","index","readSenderReputation","window","verdict","counts","policy","reinstatedUntilMs","resolveOrgEmailRamp","ageDays","deliveredLifetime","graduatedPerDay","platformPerHour","readOrgEmailRamp","config","perHour","enabled","parked","ramp","orgAgeDays","createdAt","graduated","claimOrgEmailSendDay","retryAtMs","granted","used","reservation","allowed","ceiling","perDay","remaining","ref","runTransaction","tx","claimed","reconcileOrgEmailSendDay","delivered","sent","refund","db","raw","createdMs","parse","toMillis","seconds"],"mappings":"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"}
|