@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,639 @@
|
|
|
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
|
+
* MARKETING MAIL — the durable half.
|
|
19
|
+
*
|
|
20
|
+
* `marketing-send.ts` in `@aglyn/shared-util-email` states the policy and
|
|
21
|
+
* holds the injection seam; this answers the question, because answering it
|
|
22
|
+
* needs Firestore and that library may not hold the Admin SDK.
|
|
23
|
+
*
|
|
24
|
+
* Five things, in the order they cost:
|
|
25
|
+
*
|
|
26
|
+
* 1. **Both suppression lists**, through the one shared helper. A person who
|
|
27
|
+
* unsubscribed from this site, hard-bounced anywhere in the product, or
|
|
28
|
+
* pressed "report spam" is not mailed. Asked first because it is the only
|
|
29
|
+
* one whose answer is permanent.
|
|
30
|
+
* 2. **The pace the RECIPIENT asked for** on the preference page, if they
|
|
31
|
+
* asked for one.
|
|
32
|
+
* 3. **An engagement sunset**, when an operator has configured a window —
|
|
33
|
+
* off by default, and it costs a read only when it is on.
|
|
34
|
+
* 4. **A frequency ceiling**, per recipient per site, over a rolling day.
|
|
35
|
+
* 5. **The signed unsubscribe URL**, so the message carries a way out.
|
|
36
|
+
*
|
|
37
|
+
* ## The order of the three pace refusals, and who each one is for
|
|
38
|
+
*
|
|
39
|
+
* Suppression is first and needs no argument: its answer is permanent, and
|
|
40
|
+
* the frequency record must not count a message that was never going to
|
|
41
|
+
* leave. The other three are all refusals about PACE, and they are ordered by
|
|
42
|
+
* whose refusal it is, then by what asking costs.
|
|
43
|
+
*
|
|
44
|
+
* **The recipient's own cadence is second — above both platform controls.**
|
|
45
|
+
* It is the only one of the three a PERSON asked for; the sunset is an
|
|
46
|
+
* inference this platform drew about them and the ceiling is a guard against
|
|
47
|
+
* the merchant. When more than one applies, the honest answer to "why did
|
|
48
|
+
* this not send" is the fact somebody stated, not the guess we made. It is
|
|
49
|
+
* also the cheapest to ask: it is already on the counter document the ceiling
|
|
50
|
+
* reads, so a recipient who asked for monthly mail never pays for the
|
|
51
|
+
* sunset's engagement read. And it is the only one that binds a campaign, so
|
|
52
|
+
* putting it first makes the campaign path and the automated paths evaluate
|
|
53
|
+
* the same refusals in the same order rather than interleaving one control a
|
|
54
|
+
* campaign skips with one it does not.
|
|
55
|
+
*
|
|
56
|
+
* **The sunset is third, above the ceiling.** Its refusal is TERMINAL and the
|
|
57
|
+
* ceiling's is not: a sweep defers a `frequency-capped` message and retries
|
|
58
|
+
* it, so reporting the retryable refusal for a person the sunset would refuse
|
|
59
|
+
* anyway means the same doomed row comes back on every beat. That argument
|
|
60
|
+
* does not reach past the cadence, because a cadence gap is a day, a week or
|
|
61
|
+
* a month — a sweep retries such a row once per interval, not once per beat.
|
|
62
|
+
*
|
|
63
|
+
* ## Which refusals bind a campaign
|
|
64
|
+
*
|
|
65
|
+
* `MarketingSendContext.capped` is `false` for a campaign, and it governs
|
|
66
|
+
* exactly two of the four:
|
|
67
|
+
*
|
|
68
|
+
* | Refusal | A campaign is |
|
|
69
|
+
* | ------------------ | ------------- |
|
|
70
|
+
* | `suppressed` | **bound** |
|
|
71
|
+
* | `cadence-limited` | **bound** |
|
|
72
|
+
* | `unengaged` | exempt (`capped`) |
|
|
73
|
+
* | `frequency-capped` | exempt (`capped`) |
|
|
74
|
+
*
|
|
75
|
+
* The two exemptions share one reason: a campaign is a merchant's reviewed,
|
|
76
|
+
* one-shot act with a recipient count on screen before they press Send, and a
|
|
77
|
+
* platform control that silently removed people from that number would make
|
|
78
|
+
* it a lie. That reason is about a control the merchant did not ask for and
|
|
79
|
+
* cannot see. It does not reach a request the RECIPIENT made — a campaign
|
|
80
|
+
* that overrode the preference page would make it a form recording a choice
|
|
81
|
+
* nothing honors, which is ignoring an unsubscribe one notch quieter.
|
|
82
|
+
*
|
|
83
|
+
* A campaign COUNTS toward the ceiling either way; it is exempt from the
|
|
84
|
+
* refusal, never from the counting.
|
|
85
|
+
*
|
|
86
|
+
* ## The counter is a WINDOW, not a total
|
|
87
|
+
*
|
|
88
|
+
* `hosts/{hostId}/emailFrequency/{sha256(address)}` holds the instants of the
|
|
89
|
+
* marketing messages this site sent this person, trimmed to the window on
|
|
90
|
+
* every touch. A rolling window needs the instants; a running total would
|
|
91
|
+
* need a reset nobody triggers, and a per-day bucket would let a merchant
|
|
92
|
+
* send the whole ceiling at 23:55 and the whole ceiling again at 00:05.
|
|
93
|
+
*
|
|
94
|
+
* The record is bounded by the cap — a handful of numbers — because
|
|
95
|
+
* `marketingFrequencyVerdict` returns the trimmed window and this writes back
|
|
96
|
+
* only that. It is server-owned: the address is hashed exactly as the two
|
|
97
|
+
* suppression lists hash it, and no client writes or reads it.
|
|
98
|
+
*
|
|
99
|
+
* ## The check and the write are not one transaction, on purpose
|
|
100
|
+
*
|
|
101
|
+
* Two concurrent marketing sends to the same person can both read a window
|
|
102
|
+
* with room and both append, so the cap can be exceeded by the number of
|
|
103
|
+
* messages genuinely in flight at that instant. That is accepted rather than
|
|
104
|
+
* papered over: a transaction per recipient would double the round trips on a
|
|
105
|
+
* path that is already one awaited HTTP POST per person, and being off by one
|
|
106
|
+
* on a runaway guard costs a recipient one extra message. Being slow costs
|
|
107
|
+
* every recipient.
|
|
108
|
+
*
|
|
109
|
+
* ## Never a reduction of the person or their data
|
|
110
|
+
*
|
|
111
|
+
* A cap here refuses a SEND. It never removes a contact, never unsubscribes
|
|
112
|
+
* anybody, never trims an audience and never deletes a counter row belonging
|
|
113
|
+
* to somebody. `over-limit.ts` states the rule for capacity limits and this is
|
|
114
|
+
* the same instrument pointed at time: the message does not go, the person
|
|
115
|
+
* stays exactly where they were, and the next window mails them again. The
|
|
116
|
+
* recipient's own cadence is the same shape — a person who asked for monthly
|
|
117
|
+
* mail stays on every audience they were on and is mailed again next month.
|
|
118
|
+
*/ import { getMarketingSendGate, marketingCadenceVerdict, marketingFrequencyVerdict, marketingSunsetDays, marketingSunsetVerdict, normalizeMarketingCadence, setMarketingSendGate } from "@aglyn/shared-util-email";
|
|
119
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
120
|
+
import { readPersonEngagement } from "./email-delivery-log.js";
|
|
121
|
+
import { emailSuppressionKey, filterSendableForHost, filterTopicSendable } from "./email-suppression.js";
|
|
122
|
+
import { buildUnsubscribeUrl } from "./email-unsubscribe-link.js";
|
|
123
|
+
const defaultFirestore = ()=>firebaseAdmin.app().firestore();
|
|
124
|
+
/**
|
|
125
|
+
* Where the per-recipient window lives.
|
|
126
|
+
*
|
|
127
|
+
* Under the host rather than the org: the ceiling is "how much mail this SITE
|
|
128
|
+
* sends one person", which is the unit a recipient experiences and the unit
|
|
129
|
+
* the unsubscribe link is scoped to. An org-wide counter would let one site's
|
|
130
|
+
* newsletter suppress a sibling site's order-related marketing, and the two
|
|
131
|
+
* sites may be unrelated brands.
|
|
132
|
+
*/ export const EMAIL_FREQUENCY_SUBCOLLECTION = 'emailFrequency';
|
|
133
|
+
/** How each cadence reads inside a refusal sentence. */ const CADENCE_PHRASES = {
|
|
134
|
+
all: 'at this pace',
|
|
135
|
+
daily: 'a day',
|
|
136
|
+
weekly: 'a week',
|
|
137
|
+
monthly: 'a month'
|
|
138
|
+
};
|
|
139
|
+
/** What an unreadable, absent or unkeyable counter reads as. */ const NO_RECORD = {
|
|
140
|
+
window: [],
|
|
141
|
+
firstSentAtMs: null,
|
|
142
|
+
lastSentAtMs: null,
|
|
143
|
+
cadence: 'all'
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* One counter snapshot, decoded.
|
|
147
|
+
*
|
|
148
|
+
* Shared by {@link readMarketingFrequencyState} and
|
|
149
|
+
* {@link filterCadenceSendable} so the two cannot come to different
|
|
150
|
+
* conclusions about the same document — in particular about the
|
|
151
|
+
* `lastSentAtMs` fallback, where a per-message answer and a per-campaign
|
|
152
|
+
* answer that disagreed would refuse a recipient on one path and mail them on
|
|
153
|
+
* the other.
|
|
154
|
+
*/ function stateFromSnapshot(snapshot) {
|
|
155
|
+
const stored = snapshot.get('sentAtMs');
|
|
156
|
+
const window = Array.isArray(stored) ? stored.map((at)=>Number(at)) : [];
|
|
157
|
+
const first = Number(snapshot.get('firstSentAtMs'));
|
|
158
|
+
const last = Number(snapshot.get('lastSentAtMs'));
|
|
159
|
+
return {
|
|
160
|
+
window,
|
|
161
|
+
firstSentAtMs: Number.isFinite(first) && first > 0 ? first : null,
|
|
162
|
+
/*
|
|
163
|
+
* The stored instant, or the newest entry still inside the window.
|
|
164
|
+
*
|
|
165
|
+
* The fallback is what makes this work on every record written before
|
|
166
|
+
* `lastSentAtMs` existed: those carry a window and nothing else, and
|
|
167
|
+
* reading `null` for them would let a monthly cadence pass on the first
|
|
168
|
+
* message after this ships for anybody mailed in the last day.
|
|
169
|
+
*
|
|
170
|
+
* `firstSentAtMs` deliberately has NO such fallback. The window would be
|
|
171
|
+
* the wrong answer for it in exactly the direction that matters: a
|
|
172
|
+
* relationship dated from a send inside the last day is younger than any
|
|
173
|
+
* sunset window, so the sunset could never fire — and a relationship
|
|
174
|
+
* dated from the oldest entry in a rolling DAY is not the first send
|
|
175
|
+
* either. Absent means "no record", and no record refuses nobody.
|
|
176
|
+
*/ lastSentAtMs: Number.isFinite(last) ? last : window.length ? Math.max(...window) : null,
|
|
177
|
+
cadence: normalizeMarketingCadence(snapshot.get('cadence'))
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function frequencyDoc(hostId, emailKey, firestore) {
|
|
181
|
+
return (firestore != null ? firestore : defaultFirestore()).collection('hosts').doc(hostId).collection(EMAIL_FREQUENCY_SUBCOLLECTION).doc(emailKey);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The instants inside the window for one recipient of one site's mail.
|
|
185
|
+
*
|
|
186
|
+
* FAILS OPEN — an empty window, meaning "nothing recorded, so there is room".
|
|
187
|
+
* The opposite of {@link filterSendableForHost}, and the difference is what
|
|
188
|
+
* each answer costs when it is wrong. A suppression read that fails open
|
|
189
|
+
* mails somebody who told us to stop; a frequency read that fails closed
|
|
190
|
+
* refuses a message nobody objected to, over a counter that is a runaway
|
|
191
|
+
* guard rather than a consent record.
|
|
192
|
+
*/ export async function readMarketingFrequency(hostId, email, firestore) {
|
|
193
|
+
return (await readMarketingFrequencyState(hostId, email, firestore)).window;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The whole counter document — the window, the first send, the last send and
|
|
197
|
+
* the recipient's chosen pace — in ONE round trip.
|
|
198
|
+
*
|
|
199
|
+
* **One read, four facts, and that is a requirement rather than a tidiness.**
|
|
200
|
+
* Three separate refusals in {@link marketingSendVerdict} read this document,
|
|
201
|
+
* and a `get` per refusal would be three awaited round trips on a path that
|
|
202
|
+
* is already one awaited HTTP POST per recipient. Which is also why the
|
|
203
|
+
* recipient's cadence is stored HERE rather than beside the topic opt-outs:
|
|
204
|
+
* honoring a preference this gate has to consult on every marketing message
|
|
205
|
+
* has to cost nothing, and riding on a document already being read is the
|
|
206
|
+
* only shape that does. See {@link EmailFrequencyRecord.cadence}.
|
|
207
|
+
*
|
|
208
|
+
* FAILS OPEN on every field, and in the same direction each time: an empty
|
|
209
|
+
* window means "there is room", a null `firstSentAtMs` means "no record", a
|
|
210
|
+
* null `lastSentAtMs` means "never mailed", and an unreadable cadence reads
|
|
211
|
+
* as no preference expressed. An unreadable counter is not evidence that
|
|
212
|
+
* somebody asked for less, and it is not evidence that they have gone cold —
|
|
213
|
+
* the opposite of {@link filterSendableForHost}, for the reason
|
|
214
|
+
* {@link readMarketingFrequency} states.
|
|
215
|
+
*/ export async function readMarketingFrequencyState(hostId, email, firestore) {
|
|
216
|
+
const key = emailSuppressionKey(email);
|
|
217
|
+
if (!key) return _extends({}, NO_RECORD);
|
|
218
|
+
try {
|
|
219
|
+
const snapshot = await frequencyDoc(hostId, key, firestore).get();
|
|
220
|
+
if (!snapshot.exists) return _extends({}, NO_RECORD);
|
|
221
|
+
return stateFromSnapshot(snapshot);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error('[email-marketing] frequency read failed; allowing', error);
|
|
224
|
+
return _extends({}, NO_RECORD);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Records the pace a recipient asked for.
|
|
229
|
+
*
|
|
230
|
+
* Written from the preference page, which is unauthenticated and reached by a
|
|
231
|
+
* signed link — so the CALLER has already proved the request is this
|
|
232
|
+
* address's. A merge, because the document is the send counter and this must
|
|
233
|
+
* not disturb the window it shares with.
|
|
234
|
+
*
|
|
235
|
+
* Never throws: a preference that failed to store is a page that should say
|
|
236
|
+
* so, not a 500 on a screen a recipient reached in order to leave.
|
|
237
|
+
*
|
|
238
|
+
* @returns whether it was stored.
|
|
239
|
+
*/ export async function setMarketingCadence(hostId, email, cadence, options) {
|
|
240
|
+
const key = emailSuppressionKey(email);
|
|
241
|
+
if (!key || !hostId) return false;
|
|
242
|
+
try {
|
|
243
|
+
var _ref;
|
|
244
|
+
await frequencyDoc(hostId, key, options == null ? void 0 : options.firestore).set({
|
|
245
|
+
email: String(email).trim().toLowerCase(),
|
|
246
|
+
cadence: normalizeMarketingCadence(cadence),
|
|
247
|
+
cadenceSetAtMs: (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now()
|
|
248
|
+
}, {
|
|
249
|
+
merge: true
|
|
250
|
+
});
|
|
251
|
+
return true;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.error('[email-marketing] cadence write failed', error);
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Records marketing messages against the recipients' windows.
|
|
259
|
+
*
|
|
260
|
+
* Takes a LIST because the campaign sender records a whole batch at once: it
|
|
261
|
+
* is exempt from the refusal (a reviewed, one-shot act whose recipient count
|
|
262
|
+
* is on screen before it goes) but not from the counting, since a campaign is
|
|
263
|
+
* most of the mail a person receives from a site. Doing that per recipient
|
|
264
|
+
* inside the send loop would add a round trip to each of up to 500 sequential
|
|
265
|
+
* sends; doing it here adds one batch after them.
|
|
266
|
+
*
|
|
267
|
+
* Never throws. A lost counter increment means one recipient's ceiling is
|
|
268
|
+
* measured a message low, which is not worth failing a delivered send over.
|
|
269
|
+
*/ export async function recordMarketingSends(hostId, emails, options) {
|
|
270
|
+
var _ref;
|
|
271
|
+
const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
|
|
272
|
+
const keyed = new Map();
|
|
273
|
+
for (const email of emails){
|
|
274
|
+
const key = emailSuppressionKey(email);
|
|
275
|
+
if (key) keyed.set(key, String(email).trim().toLowerCase());
|
|
276
|
+
}
|
|
277
|
+
if (!keyed.size || !hostId) return 0;
|
|
278
|
+
let recorded = 0;
|
|
279
|
+
try {
|
|
280
|
+
var _ref1;
|
|
281
|
+
const db = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : defaultFirestore();
|
|
282
|
+
for (const [key, email] of keyed){
|
|
283
|
+
const ref = frequencyDoc(hostId, key, db);
|
|
284
|
+
const existing = await ref.get().then((snapshot)=>snapshot.exists ? stateFromSnapshot(snapshot) : _extends({}, NO_RECORD)).catch(()=>_extends({}, NO_RECORD));
|
|
285
|
+
const window = marketingFrequencyVerdict([
|
|
286
|
+
...existing.window,
|
|
287
|
+
nowMs
|
|
288
|
+
], nowMs);
|
|
289
|
+
await ref.set(_extends({
|
|
290
|
+
email,
|
|
291
|
+
sentAtMs: window.inWindow,
|
|
292
|
+
lastSentAtMs: nowMs
|
|
293
|
+
}, existing.firstSentAtMs ? {} : {
|
|
294
|
+
firstSentAtMs: nowMs
|
|
295
|
+
}), {
|
|
296
|
+
merge: true
|
|
297
|
+
});
|
|
298
|
+
recorded += 1;
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.error('[email-marketing] frequency record failed', error);
|
|
302
|
+
}
|
|
303
|
+
return recorded;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The subset of `emails` that has NOT asked this site for mail less often
|
|
307
|
+
* than right now.
|
|
308
|
+
*
|
|
309
|
+
* The fourth filter a campaign passes, after the platform suppression list,
|
|
310
|
+
* the site's own and the topic opt-outs — and the only one that is not in
|
|
311
|
+
* `email-suppression.ts` beside those three, because it reads the counter
|
|
312
|
+
* document this module owns and that module is this module's dependency.
|
|
313
|
+
*
|
|
314
|
+
* {@link marketingSendVerdict} enforces the same rule per message, for the
|
|
315
|
+
* senders that reach the gate. THIS is the campaign path's copy, and it is
|
|
316
|
+
* the enforcement there rather than only a count: a campaign carries no
|
|
317
|
+
* `marketing` context — it mints its own unsubscribe URL upstream — so the
|
|
318
|
+
* gate is not on its path at all, and a rule asked only there would not be
|
|
319
|
+
* asked of the sender that produces most of a person's mail.
|
|
320
|
+
*
|
|
321
|
+
* Answering it as a FILTER rather than per message is also the only placement
|
|
322
|
+
* that keeps the composer honest. The argument for exempting a campaign from
|
|
323
|
+
* the platform ceiling is that a control which silently removed people from a
|
|
324
|
+
* reviewed one-shot send would make the number on screen a lie; subtracting
|
|
325
|
+
* a request the recipient actually made, where every other refusal is already
|
|
326
|
+
* subtracted, is what keeps this one from having that problem.
|
|
327
|
+
*
|
|
328
|
+
* Keyed and read with one `getAll`, matching its three neighbors: one round
|
|
329
|
+
* trip bounded by the size of the send, and no composite index to go missing.
|
|
330
|
+
*
|
|
331
|
+
* ## Fails OPEN, like the topic filter beside it
|
|
332
|
+
*
|
|
333
|
+
* A cadence is a PACE, not a stop. Guessing wrong on an unreadable counter
|
|
334
|
+
* costs one recipient one message sooner than they asked for; guessing wrong
|
|
335
|
+
* the other way withholds a whole campaign on a transient read failure. The
|
|
336
|
+
* two suppression lists have already refused everybody who asked us to stop
|
|
337
|
+
* entirely, so nobody who said "no" reaches this line.
|
|
338
|
+
*/ export async function filterCadenceSendable(hostId, emails, options) {
|
|
339
|
+
var _ref;
|
|
340
|
+
if (!emails.length || !hostId) return [
|
|
341
|
+
...emails
|
|
342
|
+
];
|
|
343
|
+
const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
|
|
344
|
+
// An unkeyable address carries no counter, so it has expressed no pace. It
|
|
345
|
+
// is dropped from the LOOKUP and kept in the answer, exactly as the topic
|
|
346
|
+
// filter keeps one: the stricter filters above have already had their say.
|
|
347
|
+
const lookups = [];
|
|
348
|
+
for (const email of emails){
|
|
349
|
+
const key = emailSuppressionKey(email);
|
|
350
|
+
if (key) lookups.push({
|
|
351
|
+
email,
|
|
352
|
+
key
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
if (!lookups.length) return [
|
|
356
|
+
...emails
|
|
357
|
+
];
|
|
358
|
+
try {
|
|
359
|
+
var _ref1;
|
|
360
|
+
const db = (_ref1 = options == null ? void 0 : options.firestore) != null ? _ref1 : defaultFirestore();
|
|
361
|
+
const counters = db.collection('hosts').doc(hostId).collection(EMAIL_FREQUENCY_SUBCOLLECTION);
|
|
362
|
+
const snapshots = await db.getAll(...lookups.map((entry)=>counters.doc(entry.key)));
|
|
363
|
+
const holding = new Set();
|
|
364
|
+
lookups.forEach((entry, index)=>{
|
|
365
|
+
const snapshot = snapshots[index];
|
|
366
|
+
if (!(snapshot == null ? void 0 : snapshot.exists)) return;
|
|
367
|
+
// The same decoder the per-message path uses, so the two cannot
|
|
368
|
+
// disagree about one document. The sunset's `firstSentAtMs` comes back
|
|
369
|
+
// with it and is deliberately unused: a campaign is exempt from that
|
|
370
|
+
// refusal, so subtracting on it here would remove people from a count
|
|
371
|
+
// the gate is going to mail anyway.
|
|
372
|
+
const state = stateFromSnapshot(snapshot);
|
|
373
|
+
const verdict = marketingCadenceVerdict(state.cadence, state.lastSentAtMs, nowMs);
|
|
374
|
+
if (!verdict.allowed) holding.add(entry.email);
|
|
375
|
+
});
|
|
376
|
+
return emails.filter((email)=>!holding.has(email));
|
|
377
|
+
} catch (error) {
|
|
378
|
+
console.error('[email-marketing] cadence lookup failed; failing open', error);
|
|
379
|
+
return [
|
|
380
|
+
...emails
|
|
381
|
+
];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The gate itself, exported so it can be exercised without installing it.
|
|
386
|
+
*
|
|
387
|
+
* ORDER MATTERS. The suppression check is first because its answer is
|
|
388
|
+
* permanent and the frequency record must not count a message that was never
|
|
389
|
+
* going to leave — a suppressed address whose window kept growing would stay
|
|
390
|
+
* capped for a day after being released.
|
|
391
|
+
*/ export async function marketingSendVerdict(request, options) {
|
|
392
|
+
var _ref, _request_email, _request_topicId;
|
|
393
|
+
const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
|
|
394
|
+
const email = String((_request_email = request.email) != null ? _request_email : '').trim().toLowerCase();
|
|
395
|
+
const sendable = await filterSendableForHost(request.hostId, [
|
|
396
|
+
email
|
|
397
|
+
], options == null ? void 0 : options.firestore);
|
|
398
|
+
if (!sendable.length) {
|
|
399
|
+
return {
|
|
400
|
+
allowed: false,
|
|
401
|
+
refusal: 'suppressed',
|
|
402
|
+
detail: 'This address has unsubscribed, bounced permanently, or reported a ' + 'message as spam.'
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
// Normalized once, and read by both the links below and the topic check
|
|
406
|
+
// further down: two spellings of "which stream is this" is how a link comes
|
|
407
|
+
// to carry a topic the gate did not check.
|
|
408
|
+
const topicId = String((_request_topicId = request.topicId) != null ? _request_topicId : '').trim();
|
|
409
|
+
/*
|
|
410
|
+
* TWO URLS OVER ONE SIGNATURE, the same split `campaign-send.ts` makes and
|
|
411
|
+
* for the same reason (RFC 8058).
|
|
412
|
+
*
|
|
413
|
+
* `oneClickUrl` is what `List-Unsubscribe` names: a mailbox provider POSTs
|
|
414
|
+
* it with nobody present and expects the act to have happened when it reads
|
|
415
|
+
* the 200, so it points at the route whose POST writes immediately.
|
|
416
|
+
*
|
|
417
|
+
* `unsubscribeUrl` is the link a PERSON clicks in the footer, and it points
|
|
418
|
+
* at the preference page. Every caller on this path — the abandoned-cart
|
|
419
|
+
* sweep, the restock notice, the newsletter welcome, the automation step —
|
|
420
|
+
* names a topic, and a footer pointing at the one-click route gives the
|
|
421
|
+
* recipient of one of those exactly one choice: stop hearing from this site
|
|
422
|
+
* entirely. The page offers leaving that one stream instead, with
|
|
423
|
+
* "Unsubscribe from everything" still on it.
|
|
424
|
+
*
|
|
425
|
+
* The topic rides both links, so the page opens on the stream the message
|
|
426
|
+
* belonged to rather than on a list the recipient has to search.
|
|
427
|
+
*/ const link = _extends({
|
|
428
|
+
siteBase: request.siteBase,
|
|
429
|
+
hostId: request.hostId,
|
|
430
|
+
email
|
|
431
|
+
}, topicId ? {
|
|
432
|
+
topicId
|
|
433
|
+
} : {});
|
|
434
|
+
const unsubscribeUrl = buildUnsubscribeUrl(_extends({}, link, {
|
|
435
|
+
surface: 'preferences'
|
|
436
|
+
}));
|
|
437
|
+
const oneClickUrl = buildUnsubscribeUrl(_extends({}, link, {
|
|
438
|
+
surface: 'one-click'
|
|
439
|
+
}));
|
|
440
|
+
/*
|
|
441
|
+
* THE STREAM THIS MESSAGE BELONGS TO — the third list, and the narrowest.
|
|
442
|
+
*
|
|
443
|
+
* ONLY when the caller named one. An absent topic is not "the default
|
|
444
|
+
* topic": it is a message that belongs to no stream, and there is nothing
|
|
445
|
+
* for a person to have left. That is what keeps this check off the mail it
|
|
446
|
+
* must never touch — a receipt, a password reset, a booking confirmation,
|
|
447
|
+
* none of which declare a `marketing` context at all and none of which name
|
|
448
|
+
* a stream if they somehow did.
|
|
449
|
+
*
|
|
450
|
+
* After the suppression lists and before the counter read below, matching
|
|
451
|
+
* the order `campaign-send.ts` filters in, for two reasons that agree. It
|
|
452
|
+
* is the weaker fact, and the weaker fact should never be the one that
|
|
453
|
+
* decides — a person who unticked "Promotions and offers" is still a
|
|
454
|
+
* subscriber, where a person on either suppression list is not. And it is a
|
|
455
|
+
* TERMINAL refusal, so answering it before the counter read means a
|
|
456
|
+
* recipient this message was never going to reach costs one lookup rather
|
|
457
|
+
* than three.
|
|
458
|
+
*
|
|
459
|
+
* Fails OPEN, because `filterTopicSendable` does: a topic preference is a
|
|
460
|
+
* narrower fact than a suppression, and a read that failed for an unrelated
|
|
461
|
+
* reason is no reason to withhold a message from somebody the two lists
|
|
462
|
+
* above already cleared.
|
|
463
|
+
*/ if (topicId) {
|
|
464
|
+
const onTopic = await filterTopicSendable(request.hostId, topicId, [
|
|
465
|
+
email
|
|
466
|
+
], options == null ? void 0 : options.firestore);
|
|
467
|
+
if (!onTopic.length) {
|
|
468
|
+
/*
|
|
469
|
+
* ⛔ A REFUSAL AND NOTHING ELSE, as with the pace refusals below.
|
|
470
|
+
*
|
|
471
|
+
* No suppression is written, no membership changes, and the frequency
|
|
472
|
+
* window is not appended to — a message that never left must not count
|
|
473
|
+
* against what this person has received. They stay on every other
|
|
474
|
+
* stream they subscribe to, and the next message on one of those goes.
|
|
475
|
+
*/ return {
|
|
476
|
+
allowed: false,
|
|
477
|
+
refusal: 'topic-unsubscribed',
|
|
478
|
+
detail: 'This address has left the email topic this message belongs to. ' + 'They still receive the other streams from this site.',
|
|
479
|
+
unsubscribeUrl,
|
|
480
|
+
oneClickUrl
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// ONE read, and every refusal below is answered from it — except the
|
|
485
|
+
// sunset's engagement half, which is a different document and is fetched
|
|
486
|
+
// only when a window is configured and nothing above has already refused.
|
|
487
|
+
const state = await readMarketingFrequencyState(request.hostId, email, options == null ? void 0 : options.firestore);
|
|
488
|
+
/*
|
|
489
|
+
* THE RECIPIENT'S OWN REQUEST, first among the three pace refusals and the
|
|
490
|
+
* only one not subject to `capped`.
|
|
491
|
+
*
|
|
492
|
+
* `capped: false` exempts a campaign from the platform CEILING and from the
|
|
493
|
+
* SUNSET, because a control the merchant did not ask for and cannot see,
|
|
494
|
+
* silently removing people from a reviewed one-shot send, would make the
|
|
495
|
+
* recipient count on screen a lie. That argument does not reach this one: a
|
|
496
|
+
* person used the preference page to ask this site for less mail, and a
|
|
497
|
+
* campaign that overrode them would make that page a form that records a
|
|
498
|
+
* request nobody honors — the same failure as ignoring an unsubscribe, one
|
|
499
|
+
* notch quieter.
|
|
500
|
+
*
|
|
501
|
+
* Above the sunset for three reasons. It is the only one of the three a
|
|
502
|
+
* PERSON asked for, and when both apply the honest answer to "why did this
|
|
503
|
+
* not send" is the fact somebody stated rather than the inference we drew.
|
|
504
|
+
* It is answered entirely from the counter document already in hand, so a
|
|
505
|
+
* recipient who asked for monthly mail never pays for the sunset's second
|
|
506
|
+
* read. And it is the one refusal a campaign is bound by, so asking it
|
|
507
|
+
* first gives the campaign path and the automated paths the same order.
|
|
508
|
+
*
|
|
509
|
+
* The sunset's terminality argument does not out-rank any of that: a sweep
|
|
510
|
+
* that defers this row retries it once per cadence interval — a day, a week
|
|
511
|
+
* or a month — not once per beat, which is what that argument is about.
|
|
512
|
+
*
|
|
513
|
+
* A campaign is bound by the rule but not by THIS enforcement of it: it
|
|
514
|
+
* carries no `marketing` context, so nothing on its path reaches this
|
|
515
|
+
* function. {@link filterCadenceSendable} is where it is asked there, and
|
|
516
|
+
* asked as a filter so the recipient count reflects it before Send.
|
|
517
|
+
*/ const cadence = marketingCadenceVerdict(state.cadence, state.lastSentAtMs, nowMs);
|
|
518
|
+
if (!cadence.allowed) {
|
|
519
|
+
/*
|
|
520
|
+
* ⛔ A REFUSAL AND NOTHING ELSE, exactly as below.
|
|
521
|
+
*
|
|
522
|
+
* Nobody is unsubscribed, no membership changes, no contact is touched,
|
|
523
|
+
* and the frequency window is not appended to — a message that never left
|
|
524
|
+
* must not count against what this person has received. Somebody who
|
|
525
|
+
* asked for monthly mail stays on every audience they were on and is
|
|
526
|
+
* mailed again next month.
|
|
527
|
+
*/ return {
|
|
528
|
+
allowed: false,
|
|
529
|
+
refusal: 'cadence-limited',
|
|
530
|
+
detail: `This address asked this site for no more than one marketing ` + `message ${CADENCE_PHRASES[state.cadence]}. The next one may go on ` + `${new Date(cadence.nextAllowedAtMs).toISOString()}.`,
|
|
531
|
+
unsubscribeUrl,
|
|
532
|
+
oneClickUrl
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/*
|
|
536
|
+
* THE SUNSET, ahead of the frequency ceiling.
|
|
537
|
+
*
|
|
538
|
+
* Ordered above the ceiling because its refusal is TERMINAL and the
|
|
539
|
+
* ceiling's is not: a sweep defers a `frequency-capped` message and retries
|
|
540
|
+
* it, so reporting the retryable refusal for a person the sunset would
|
|
541
|
+
* refuse anyway means the same doomed row comes back on every beat.
|
|
542
|
+
*
|
|
543
|
+
* `request.capped` governs it, the same flag the ceiling reads, so a
|
|
544
|
+
* campaign — a reviewed act with its recipient count on screen — is exempt
|
|
545
|
+
* for the reason recorded on `MarketingSendContext.capped`. That leaves the
|
|
546
|
+
* sunset governing the automated paths, which fire with no human present.
|
|
547
|
+
*
|
|
548
|
+
* The engagement read is the ONLY second round trip on this path, and it is
|
|
549
|
+
* spent only when a window is configured, the caller is capped, and the
|
|
550
|
+
* cadence above has already allowed. Off is the default, and off costs
|
|
551
|
+
* nothing.
|
|
552
|
+
*/ const sunsetDays = marketingSunsetDays();
|
|
553
|
+
if (request.capped && sunsetDays > 0) {
|
|
554
|
+
const engagement = await readPersonEngagement(email, options == null ? void 0 : options.firestore);
|
|
555
|
+
const sunset = marketingSunsetVerdict({
|
|
556
|
+
firstSentAtMs: state.firstSentAtMs,
|
|
557
|
+
lastEngagedAtMs: engagement.lastEngagedAtMs
|
|
558
|
+
}, nowMs, sunsetDays);
|
|
559
|
+
if (!sunset.allowed) {
|
|
560
|
+
/*
|
|
561
|
+
* ⛔ A REFUSAL AND NOTHING ELSE.
|
|
562
|
+
*
|
|
563
|
+
* No suppression is written, no membership is touched, no contact is
|
|
564
|
+
* changed, and the frequency window below is not appended to — a
|
|
565
|
+
* message that never left must not count against what this person has
|
|
566
|
+
* received. The person is exactly where they were, and the next send
|
|
567
|
+
* after they open anything goes.
|
|
568
|
+
*/ return {
|
|
569
|
+
allowed: false,
|
|
570
|
+
refusal: 'unengaged',
|
|
571
|
+
detail: `This address has not opened or clicked anything for ` + `${sunset.quietForDays} days, and this site has been mailing it ` + `for longer than the ${sunset.days}-day engagement window. It ` + `becomes mailable again as soon as they engage.`,
|
|
572
|
+
unsubscribeUrl,
|
|
573
|
+
oneClickUrl
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const verdict = marketingFrequencyVerdict(state.window, nowMs);
|
|
578
|
+
if (request.capped && !verdict.allowed) {
|
|
579
|
+
return {
|
|
580
|
+
allowed: false,
|
|
581
|
+
refusal: 'frequency-capped',
|
|
582
|
+
detail: `This address has already received ${verdict.used} marketing ` + `messages from this site today (the ceiling is ${verdict.cap}).`,
|
|
583
|
+
unsubscribeUrl,
|
|
584
|
+
oneClickUrl
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/*
|
|
588
|
+
* The send is granted, so it is counted — including for a campaign, which
|
|
589
|
+
* is exempt from the refusal above and not from this. `sentAtMs` therefore
|
|
590
|
+
* measures what the recipient actually receives rather than only the part
|
|
591
|
+
* of it a cap may stop, which is the difference between a ceiling and a
|
|
592
|
+
* number that describes nothing.
|
|
593
|
+
*/ const appended = marketingFrequencyVerdict([
|
|
594
|
+
...verdict.inWindow,
|
|
595
|
+
nowMs
|
|
596
|
+
], nowMs);
|
|
597
|
+
const key = emailSuppressionKey(email);
|
|
598
|
+
if (key) {
|
|
599
|
+
await frequencyDoc(request.hostId, key, options == null ? void 0 : options.firestore).set(_extends({
|
|
600
|
+
email,
|
|
601
|
+
sentAtMs: appended.inWindow,
|
|
602
|
+
lastSentAtMs: nowMs
|
|
603
|
+
}, state.firstSentAtMs ? {} : {
|
|
604
|
+
firstSentAtMs: nowMs
|
|
605
|
+
}), {
|
|
606
|
+
merge: true
|
|
607
|
+
})// Never blocks the send. A counter write that failed is a ceiling
|
|
608
|
+
// measured one message low, and refusing delivery over it would let a
|
|
609
|
+
// Firestore hiccup become an outage on a merchant's mail.
|
|
610
|
+
.catch((error)=>{
|
|
611
|
+
console.error('[email-marketing] frequency write failed', error);
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
allowed: true,
|
|
616
|
+
unsubscribeUrl,
|
|
617
|
+
oneClickUrl
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Puts the gate on `sendEmail`'s path.
|
|
622
|
+
*
|
|
623
|
+
* **Called at module load**, from the bottom of this file, so that importing
|
|
624
|
+
* `@aglyn/tenant-data-admin` anywhere is enough — every server surface in the
|
|
625
|
+
* product already imports that barrel, and `export *` forces this module to
|
|
626
|
+
* evaluate. The alternative, a call at each server entrypoint, is the
|
|
627
|
+
* many-places-to-remember shape this codebase has rejected twice already.
|
|
628
|
+
*
|
|
629
|
+
* Idempotent: installing twice replaces the same closure with an equivalent
|
|
630
|
+
* one, and the closure holds no state — the state is the Firestore document.
|
|
631
|
+
*/ export function installMarketingSendGate() {
|
|
632
|
+
setMarketingSendGate((request)=>marketingSendVerdict(request));
|
|
633
|
+
}
|
|
634
|
+
/** Whether a gate is installed. Reads the shared seam, not a local flag. */ export function isMarketingSendGateInstalled() {
|
|
635
|
+
return getMarketingSendGate() !== null;
|
|
636
|
+
}
|
|
637
|
+
installMarketingSendGate();
|
|
638
|
+
|
|
639
|
+
//# sourceMappingURL=email-marketing-gate.js.map
|