@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.
Files changed (374) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/jest.integration.config.d.ts +39 -0
  4. package/jest.integration.setup.d.ts +17 -0
  5. package/package.json +51 -0
  6. package/src/index.d.ts +17 -0
  7. package/src/index.js +18 -0
  8. package/src/index.js.map +1 -0
  9. package/src/lib/render-cache.d.ts +184 -0
  10. package/src/lib/render-cache.js +198 -0
  11. package/src/lib/render-cache.js.map +1 -0
  12. package/src/lib/server/account-addresses.d.ts +160 -0
  13. package/src/lib/server/account-addresses.js +311 -0
  14. package/src/lib/server/account-addresses.js.map +1 -0
  15. package/src/lib/server/account-emails.d.ts +219 -0
  16. package/src/lib/server/account-emails.js +640 -0
  17. package/src/lib/server/account-emails.js.map +1 -0
  18. package/src/lib/server/admin-audit.d.ts +180 -0
  19. package/src/lib/server/admin-audit.js +233 -0
  20. package/src/lib/server/admin-audit.js.map +1 -0
  21. package/src/lib/server/analytics-retention.d.ts +63 -0
  22. package/src/lib/server/analytics-retention.js +66 -0
  23. package/src/lib/server/analytics-retention.js.map +1 -0
  24. package/src/lib/server/api-http.d.ts +159 -0
  25. package/src/lib/server/api-http.js +230 -0
  26. package/src/lib/server/api-http.js.map +1 -0
  27. package/src/lib/server/api-keys.d.ts +117 -0
  28. package/src/lib/server/api-keys.js +298 -0
  29. package/src/lib/server/api-keys.js.map +1 -0
  30. package/src/lib/server/auth-handoff.d.ts +165 -0
  31. package/src/lib/server/auth-handoff.js +482 -0
  32. package/src/lib/server/auth-handoff.js.map +1 -0
  33. package/src/lib/server/auth-pools.d.ts +246 -0
  34. package/src/lib/server/auth-pools.js +551 -0
  35. package/src/lib/server/auth-pools.js.map +1 -0
  36. package/src/lib/server/billing-addons.d.ts +171 -0
  37. package/src/lib/server/billing-addons.js +277 -0
  38. package/src/lib/server/billing-addons.js.map +1 -0
  39. package/src/lib/server/campaign-attribution-store.d.ts +55 -0
  40. package/src/lib/server/campaign-attribution-store.js +95 -0
  41. package/src/lib/server/campaign-attribution-store.js.map +1 -0
  42. package/src/lib/server/campaign-conversion-attribution.d.ts +205 -0
  43. package/src/lib/server/campaign-conversion-attribution.js +279 -0
  44. package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
  45. package/src/lib/server/client-error-report.d.ts +301 -0
  46. package/src/lib/server/client-error-report.js +755 -0
  47. package/src/lib/server/client-error-report.js.map +1 -0
  48. package/src/lib/server/connect-account-status.d.ts +74 -0
  49. package/src/lib/server/connect-account-status.js +94 -0
  50. package/src/lib/server/connect-account-status.js.map +1 -0
  51. package/src/lib/server/connect-payout-failure.d.ts +103 -0
  52. package/src/lib/server/connect-payout-failure.js +127 -0
  53. package/src/lib/server/connect-payout-failure.js.map +1 -0
  54. package/src/lib/server/console-api-rate-limit.d.ts +81 -0
  55. package/src/lib/server/console-api-rate-limit.js +111 -0
  56. package/src/lib/server/console-api-rate-limit.js.map +1 -0
  57. package/src/lib/server/console-domains.d.ts +326 -0
  58. package/src/lib/server/console-domains.js +779 -0
  59. package/src/lib/server/console-domains.js.map +1 -0
  60. package/src/lib/server/consume-once.d.ts +91 -0
  61. package/src/lib/server/consume-once.js +96 -0
  62. package/src/lib/server/consume-once.js.map +1 -0
  63. package/src/lib/server/contact-company-link.d.ts +85 -0
  64. package/src/lib/server/contact-company-link.js +105 -0
  65. package/src/lib/server/contact-company-link.js.map +1 -0
  66. package/src/lib/server/contact-email-engagement.d.ts +32 -0
  67. package/src/lib/server/contact-email-engagement.js +145 -0
  68. package/src/lib/server/contact-email-engagement.js.map +1 -0
  69. package/src/lib/server/contact-email-index.d.ts +56 -0
  70. package/src/lib/server/contact-email-index.js +158 -0
  71. package/src/lib/server/contact-email-index.js.map +1 -0
  72. package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
  73. package/src/lib/server/contact-lifecycle-floor.js +88 -0
  74. package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
  75. package/src/lib/server/contact-merge.d.ts +55 -0
  76. package/src/lib/server/contact-merge.js +279 -0
  77. package/src/lib/server/contact-merge.js.map +1 -0
  78. package/src/lib/server/contact-suppression.d.ts +153 -0
  79. package/src/lib/server/contact-suppression.js +259 -0
  80. package/src/lib/server/contact-suppression.js.map +1 -0
  81. package/src/lib/server/crm-booking-activity.d.ts +86 -0
  82. package/src/lib/server/crm-booking-activity.js +251 -0
  83. package/src/lib/server/crm-booking-activity.js.map +1 -0
  84. package/src/lib/server/crm-email-activity.d.ts +112 -0
  85. package/src/lib/server/crm-email-activity.js +143 -0
  86. package/src/lib/server/crm-email-activity.js.map +1 -0
  87. package/src/lib/server/crm-inbound-email.d.ts +145 -0
  88. package/src/lib/server/crm-inbound-email.js +287 -0
  89. package/src/lib/server/crm-inbound-email.js.map +1 -0
  90. package/src/lib/server/crm-next-activity.d.ts +74 -0
  91. package/src/lib/server/crm-next-activity.js +194 -0
  92. package/src/lib/server/crm-next-activity.js.map +1 -0
  93. package/src/lib/server/crm-records.d.ts +144 -0
  94. package/src/lib/server/crm-records.js +218 -0
  95. package/src/lib/server/crm-records.js.map +1 -0
  96. package/src/lib/server/csp-aggregate.d.ts +62 -0
  97. package/src/lib/server/csp-aggregate.js +276 -0
  98. package/src/lib/server/csp-aggregate.js.map +1 -0
  99. package/src/lib/server/data-storage-gate.d.ts +66 -0
  100. package/src/lib/server/data-storage-gate.js +48 -0
  101. package/src/lib/server/data-storage-gate.js.map +1 -0
  102. package/src/lib/server/dataset-live-pages.d.ts +179 -0
  103. package/src/lib/server/dataset-live-pages.js +421 -0
  104. package/src/lib/server/dataset-live-pages.js.map +1 -0
  105. package/src/lib/server/dns-probe.d.ts +53 -0
  106. package/src/lib/server/dns-probe.js +184 -0
  107. package/src/lib/server/dns-probe.js.map +1 -0
  108. package/src/lib/server/document-id.d.ts +29 -0
  109. package/src/lib/server/document-id.js +100 -0
  110. package/src/lib/server/document-id.js.map +1 -0
  111. package/src/lib/server/domain-provider-vercel.d.ts +33 -0
  112. package/src/lib/server/domain-provider-vercel.js +298 -0
  113. package/src/lib/server/domain-provider-vercel.js.map +1 -0
  114. package/src/lib/server/domain-provider-webhook.d.ts +73 -0
  115. package/src/lib/server/domain-provider-webhook.js +223 -0
  116. package/src/lib/server/domain-provider-webhook.js.map +1 -0
  117. package/src/lib/server/domain-provider-wildcard.d.ts +54 -0
  118. package/src/lib/server/domain-provider-wildcard.js +163 -0
  119. package/src/lib/server/domain-provider-wildcard.js.map +1 -0
  120. package/src/lib/server/domain-provider.d.ts +197 -0
  121. package/src/lib/server/domain-provider.js +221 -0
  122. package/src/lib/server/domain-provider.js.map +1 -0
  123. package/src/lib/server/duplicate-activity.d.ts +54 -0
  124. package/src/lib/server/duplicate-activity.js +67 -0
  125. package/src/lib/server/duplicate-activity.js.map +1 -0
  126. package/src/lib/server/duplicate-resource.d.ts +55 -0
  127. package/src/lib/server/duplicate-resource.js +474 -0
  128. package/src/lib/server/duplicate-resource.js.map +1 -0
  129. package/src/lib/server/dynamic-list-materialize.d.ts +153 -0
  130. package/src/lib/server/dynamic-list-materialize.js +581 -0
  131. package/src/lib/server/dynamic-list-materialize.js.map +1 -0
  132. package/src/lib/server/edit-access-authz.d.ts +56 -0
  133. package/src/lib/server/edit-access-authz.js +107 -0
  134. package/src/lib/server/edit-access-authz.js.map +1 -0
  135. package/src/lib/server/edit-access-token.d.ts +72 -0
  136. package/src/lib/server/edit-access-token.js +116 -0
  137. package/src/lib/server/edit-access-token.js.map +1 -0
  138. package/src/lib/server/edit-hint-token.d.ts +83 -0
  139. package/src/lib/server/edit-hint-token.js +119 -0
  140. package/src/lib/server/edit-hint-token.js.map +1 -0
  141. package/src/lib/server/email-delivery-log.d.ts +509 -0
  142. package/src/lib/server/email-delivery-log.js +1043 -0
  143. package/src/lib/server/email-delivery-log.js.map +1 -0
  144. package/src/lib/server/email-flow-gate.d.ts +80 -0
  145. package/src/lib/server/email-flow-gate.js +153 -0
  146. package/src/lib/server/email-flow-gate.js.map +1 -0
  147. package/src/lib/server/email-marketing-gate.d.ts +333 -0
  148. package/src/lib/server/email-marketing-gate.js +639 -0
  149. package/src/lib/server/email-marketing-gate.js.map +1 -0
  150. package/src/lib/server/email-metering.d.ts +293 -0
  151. package/src/lib/server/email-metering.js +359 -0
  152. package/src/lib/server/email-metering.js.map +1 -0
  153. package/src/lib/server/email-revenue-attribution.d.ts +185 -0
  154. package/src/lib/server/email-revenue-attribution.js +300 -0
  155. package/src/lib/server/email-revenue-attribution.js.map +1 -0
  156. package/src/lib/server/email-send-rate.d.ts +232 -0
  157. package/src/lib/server/email-send-rate.js +417 -0
  158. package/src/lib/server/email-send-rate.js.map +1 -0
  159. package/src/lib/server/email-sender-reputation.d.ts +317 -0
  160. package/src/lib/server/email-sender-reputation.js +464 -0
  161. package/src/lib/server/email-sender-reputation.js.map +1 -0
  162. package/src/lib/server/email-suppression.d.ts +490 -0
  163. package/src/lib/server/email-suppression.js +648 -0
  164. package/src/lib/server/email-suppression.js.map +1 -0
  165. package/src/lib/server/email-topic-confirmation.d.ts +150 -0
  166. package/src/lib/server/email-topic-confirmation.js +272 -0
  167. package/src/lib/server/email-topic-confirmation.js.map +1 -0
  168. package/src/lib/server/email-unsubscribe-link.d.ts +174 -0
  169. package/src/lib/server/email-unsubscribe-link.js +236 -0
  170. package/src/lib/server/email-unsubscribe-link.js.map +1 -0
  171. package/src/lib/server/erase-person.d.ts +116 -0
  172. package/src/lib/server/erase-person.js +272 -0
  173. package/src/lib/server/erase-person.js.map +1 -0
  174. package/src/lib/server/erase.d.ts +458 -0
  175. package/src/lib/server/erase.js +1506 -0
  176. package/src/lib/server/erase.js.map +1 -0
  177. package/src/lib/server/firebase-admin.d.ts +71 -0
  178. package/src/lib/server/firebase-admin.js +343 -0
  179. package/src/lib/server/firebase-admin.js.map +1 -0
  180. package/src/lib/server/form-dataset-binding-token.d.ts +35 -0
  181. package/src/lib/server/form-dataset-binding-token.js +107 -0
  182. package/src/lib/server/form-dataset-binding-token.js.map +1 -0
  183. package/src/lib/server/free-workspace-cap.d.ts +201 -0
  184. package/src/lib/server/free-workspace-cap.js +404 -0
  185. package/src/lib/server/free-workspace-cap.js.map +1 -0
  186. package/src/lib/server/ga4-measurement-protocol.d.ts +256 -0
  187. package/src/lib/server/ga4-measurement-protocol.js +558 -0
  188. package/src/lib/server/ga4-measurement-protocol.js.map +1 -0
  189. package/src/lib/server/host-email-tokens.d.ts +47 -0
  190. package/src/lib/server/host-email-tokens.js +72 -0
  191. package/src/lib/server/host-email-tokens.js.map +1 -0
  192. package/src/lib/server/host-memberships.d.ts +54 -0
  193. package/src/lib/server/host-memberships.js +194 -0
  194. package/src/lib/server/host-memberships.js.map +1 -0
  195. package/src/lib/server/host-sending-domain.d.ts +292 -0
  196. package/src/lib/server/host-sending-domain.js +451 -0
  197. package/src/lib/server/host-sending-domain.js.map +1 -0
  198. package/src/lib/server/host-visitor-records.d.ts +150 -0
  199. package/src/lib/server/host-visitor-records.js +262 -0
  200. package/src/lib/server/host-visitor-records.js.map +1 -0
  201. package/src/lib/server/id-token-refusal.d.ts +28 -0
  202. package/src/lib/server/id-token-refusal.js +111 -0
  203. package/src/lib/server/id-token-refusal.js.map +1 -0
  204. package/src/lib/server/legal-acceptance.d.ts +214 -0
  205. package/src/lib/server/legal-acceptance.js +305 -0
  206. package/src/lib/server/legal-acceptance.js.map +1 -0
  207. package/src/lib/server/list-members.d.ts +211 -0
  208. package/src/lib/server/list-members.js +176 -0
  209. package/src/lib/server/list-members.js.map +1 -0
  210. package/src/lib/server/live-page-usage.d.ts +202 -0
  211. package/src/lib/server/live-page-usage.js +300 -0
  212. package/src/lib/server/live-page-usage.js.map +1 -0
  213. package/src/lib/server/lockdown.d.ts +313 -0
  214. package/src/lib/server/lockdown.js +643 -0
  215. package/src/lib/server/lockdown.js.map +1 -0
  216. package/src/lib/server/media-cdn-rate-limit.d.ts +188 -0
  217. package/src/lib/server/media-cdn-rate-limit.js +204 -0
  218. package/src/lib/server/media-cdn-rate-limit.js.map +1 -0
  219. package/src/lib/server/media-delivery.d.ts +267 -0
  220. package/src/lib/server/media-delivery.js +485 -0
  221. package/src/lib/server/media-delivery.js.map +1 -0
  222. package/src/lib/server/media-download-tokens.d.ts +161 -0
  223. package/src/lib/server/media-download-tokens.js +386 -0
  224. package/src/lib/server/media-download-tokens.js.map +1 -0
  225. package/src/lib/server/media-quarantine.d.ts +127 -0
  226. package/src/lib/server/media-quarantine.js +170 -0
  227. package/src/lib/server/media-quarantine.js.map +1 -0
  228. package/src/lib/server/media-signing.d.ts +126 -0
  229. package/src/lib/server/media-signing.js +159 -0
  230. package/src/lib/server/media-signing.js.map +1 -0
  231. package/src/lib/server/media-storage-path.d.ts +96 -0
  232. package/src/lib/server/media-storage-path.js +111 -0
  233. package/src/lib/server/media-storage-path.js.map +1 -0
  234. package/src/lib/server/media-strong-digest.d.ts +85 -0
  235. package/src/lib/server/media-strong-digest.js +190 -0
  236. package/src/lib/server/media-strong-digest.js.map +1 -0
  237. package/src/lib/server/media-tombstone.d.ts +276 -0
  238. package/src/lib/server/media-tombstone.js +421 -0
  239. package/src/lib/server/media-tombstone.js.map +1 -0
  240. package/src/lib/server/media-variants.d.ts +309 -0
  241. package/src/lib/server/media-variants.js +428 -0
  242. package/src/lib/server/media-variants.js.map +1 -0
  243. package/src/lib/server/member-email-aliases.d.ts +180 -0
  244. package/src/lib/server/member-email-aliases.js +420 -0
  245. package/src/lib/server/member-email-aliases.js.map +1 -0
  246. package/src/lib/server/member-photo.d.ts +88 -0
  247. package/src/lib/server/member-photo.js +185 -0
  248. package/src/lib/server/member-photo.js.map +1 -0
  249. package/src/lib/server/membership-recover-throttle.d.ts +211 -0
  250. package/src/lib/server/membership-recover-throttle.js +231 -0
  251. package/src/lib/server/membership-recover-throttle.js.map +1 -0
  252. package/src/lib/server/notifications.d.ts +55 -0
  253. package/src/lib/server/notifications.js +134 -0
  254. package/src/lib/server/notifications.js.map +1 -0
  255. package/src/lib/server/org-billing.d.ts +97 -0
  256. package/src/lib/server/org-billing.js +236 -0
  257. package/src/lib/server/org-billing.js.map +1 -0
  258. package/src/lib/server/organizations.d.ts +837 -0
  259. package/src/lib/server/organizations.js +1814 -0
  260. package/src/lib/server/organizations.js.map +1 -0
  261. package/src/lib/server/password-reset-throttle.d.ts +84 -0
  262. package/src/lib/server/password-reset-throttle.js +102 -0
  263. package/src/lib/server/password-reset-throttle.js.map +1 -0
  264. package/src/lib/server/personal-data-export.d.ts +241 -0
  265. package/src/lib/server/personal-data-export.js +858 -0
  266. package/src/lib/server/personal-data-export.js.map +1 -0
  267. package/src/lib/server/platform-domain-names.d.ts +62 -0
  268. package/src/lib/server/platform-domain-names.js +197 -0
  269. package/src/lib/server/platform-domain-names.js.map +1 -0
  270. package/src/lib/server/platform-marketing-consent.d.ts +133 -0
  271. package/src/lib/server/platform-marketing-consent.js +209 -0
  272. package/src/lib/server/platform-marketing-consent.js.map +1 -0
  273. package/src/lib/server/rate-limit-store.d.ts +702 -0
  274. package/src/lib/server/rate-limit-store.js +1220 -0
  275. package/src/lib/server/rate-limit-store.js.map +1 -0
  276. package/src/lib/server/realm-plugins.d.ts +67 -0
  277. package/src/lib/server/realm-plugins.js +250 -0
  278. package/src/lib/server/realm-plugins.js.map +1 -0
  279. package/src/lib/server/recaptcha-allowlist.d.ts +90 -0
  280. package/src/lib/server/recaptcha-allowlist.js +409 -0
  281. package/src/lib/server/recaptcha-allowlist.js.map +1 -0
  282. package/src/lib/server/release-flags.d.ts +105 -0
  283. package/src/lib/server/release-flags.js +183 -0
  284. package/src/lib/server/release-flags.js.map +1 -0
  285. package/src/lib/server/replay-erasures.d.ts +126 -0
  286. package/src/lib/server/replay-erasures.js +217 -0
  287. package/src/lib/server/replay-erasures.js.map +1 -0
  288. package/src/lib/server/resolve-people.d.ts +66 -0
  289. package/src/lib/server/resolve-people.js +112 -0
  290. package/src/lib/server/resolve-people.js.map +1 -0
  291. package/src/lib/server/safe-equal.d.ts +45 -0
  292. package/src/lib/server/safe-equal.js +58 -0
  293. package/src/lib/server/safe-equal.js.map +1 -0
  294. package/src/lib/server/sending-domain-debt.d.ts +127 -0
  295. package/src/lib/server/sending-domain-debt.js +268 -0
  296. package/src/lib/server/sending-domain-debt.js.map +1 -0
  297. package/src/lib/server/sending-domain-recheck.d.ts +151 -0
  298. package/src/lib/server/sending-domain-recheck.js +253 -0
  299. package/src/lib/server/sending-domain-recheck.js.map +1 -0
  300. package/src/lib/server/sending-domains.d.ts +364 -0
  301. package/src/lib/server/sending-domains.js +683 -0
  302. package/src/lib/server/sending-domains.js.map +1 -0
  303. package/src/lib/server/serve-media-cdn.d.ts +433 -0
  304. package/src/lib/server/serve-media-cdn.js +1370 -0
  305. package/src/lib/server/serve-media-cdn.js.map +1 -0
  306. package/src/lib/server/serve-plugin-fetch.d.ts +61 -0
  307. package/src/lib/server/serve-plugin-fetch.js +294 -0
  308. package/src/lib/server/serve-plugin-fetch.js.map +1 -0
  309. package/src/lib/server/sms-keywords.d.ts +63 -0
  310. package/src/lib/server/sms-keywords.js +146 -0
  311. package/src/lib/server/sms-keywords.js.map +1 -0
  312. package/src/lib/server/sso-break-glass-owners.d.ts +91 -0
  313. package/src/lib/server/sso-break-glass-owners.js +227 -0
  314. package/src/lib/server/sso-break-glass-owners.js.map +1 -0
  315. package/src/lib/server/sso-domain-policy.d.ts +140 -0
  316. package/src/lib/server/sso-domain-policy.js +239 -0
  317. package/src/lib/server/sso-domain-policy.js.map +1 -0
  318. package/src/lib/server/sso-drift-logic.d.ts +106 -0
  319. package/src/lib/server/sso-drift-logic.js +95 -0
  320. package/src/lib/server/sso-drift-logic.js.map +1 -0
  321. package/src/lib/server/sso-enforcement.d.ts +204 -0
  322. package/src/lib/server/sso-enforcement.js +309 -0
  323. package/src/lib/server/sso-enforcement.js.map +1 -0
  324. package/src/lib/server/sso-provisioning.d.ts +320 -0
  325. package/src/lib/server/sso-provisioning.js +715 -0
  326. package/src/lib/server/sso-provisioning.js.map +1 -0
  327. package/src/lib/server/staff-alert-email.d.ts +41 -0
  328. package/src/lib/server/staff-alert-email.js +63 -0
  329. package/src/lib/server/staff-alert-email.js.map +1 -0
  330. package/src/lib/server/stripe-account-mode.d.ts +156 -0
  331. package/src/lib/server/stripe-account-mode.js +162 -0
  332. package/src/lib/server/stripe-account-mode.js.map +1 -0
  333. package/src/lib/server/tenant-write-lockdown.d.ts +131 -0
  334. package/src/lib/server/tenant-write-lockdown.js +176 -0
  335. package/src/lib/server/tenant-write-lockdown.js.map +1 -0
  336. package/src/lib/server/test-firestore.d.ts +45 -0
  337. package/src/lib/server/test-firestore.js +124 -0
  338. package/src/lib/server/test-firestore.js.map +1 -0
  339. package/src/lib/server/token-revocation.d.ts +140 -0
  340. package/src/lib/server/token-revocation.js +255 -0
  341. package/src/lib/server/token-revocation.js.map +1 -0
  342. package/src/lib/server/update-existing.d.ts +32 -0
  343. package/src/lib/server/update-existing.js +73 -0
  344. package/src/lib/server/update-existing.js.map +1 -0
  345. package/src/lib/server/upload-cors-reconcile.d.ts +118 -0
  346. package/src/lib/server/upload-cors-reconcile.js +234 -0
  347. package/src/lib/server/upload-cors-reconcile.js.map +1 -0
  348. package/src/lib/server/upsert-contact.d.ts +262 -0
  349. package/src/lib/server/upsert-contact.js +546 -0
  350. package/src/lib/server/upsert-contact.js.map +1 -0
  351. package/src/lib/server/usage-invoice.d.ts +203 -0
  352. package/src/lib/server/usage-invoice.js +397 -0
  353. package/src/lib/server/usage-invoice.js.map +1 -0
  354. package/src/lib/server/user-profiles.d.ts +151 -0
  355. package/src/lib/server/user-profiles.js +364 -0
  356. package/src/lib/server/user-profiles.js.map +1 -0
  357. package/src/lib/server/vercel-drain-signature.d.ts +30 -0
  358. package/src/lib/server/vercel-drain-signature.js +61 -0
  359. package/src/lib/server/vercel-drain-signature.js.map +1 -0
  360. package/src/lib/server/vercel-log-drain.d.ts +259 -0
  361. package/src/lib/server/vercel-log-drain.js +479 -0
  362. package/src/lib/server/vercel-log-drain.js.map +1 -0
  363. package/src/lib/server/verify-email-cooldown.d.ts +102 -0
  364. package/src/lib/server/verify-email-cooldown.js +114 -0
  365. package/src/lib/server/verify-email-cooldown.js.map +1 -0
  366. package/src/lib/server/visitor-write-rate-limit.d.ts +87 -0
  367. package/src/lib/server/visitor-write-rate-limit.js +117 -0
  368. package/src/lib/server/visitor-write-rate-limit.js.map +1 -0
  369. package/src/lib/server/workspace-domains.d.ts +156 -0
  370. package/src/lib/server/workspace-domains.js +271 -0
  371. package/src/lib/server/workspace-domains.js.map +1 -0
  372. package/src/tenancy.d.ts +115 -0
  373. package/src/tenancy.js +142 -0
  374. package/src/tenancy.js.map +1 -0
@@ -0,0 +1,1043 @@
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
+ * THE PER-RECIPIENT DELIVERY LOG,
19
+ * `emailDeliveries/{emailKey}/messages/{providerMessageId}`.
20
+ *
21
+ * ## What it answers
22
+ *
23
+ * "Did this person get their invite, and did they open it?" — the question
24
+ * every support conversation about a missing email starts with, and the one
25
+ * that until now could only be answered by signing into the sending provider
26
+ * and searching a list that is not scoped to the account being discussed.
27
+ *
28
+ * ## Why a store, and where the provider still comes in
29
+ *
30
+ * The READ is always local. Fanning out to the ESP on render would put a
31
+ * vendor at the centre of a staff screen, at three specific costs: lock-in to
32
+ * a per-vendor list shape, a rolling retention window our own record outlives,
33
+ * and a third-party round trip on every page view. Resend's list endpoint also
34
+ * has no recipient filter at all, so a per-person lookup would mean paging the
35
+ * whole account's history on each render.
36
+ *
37
+ * The WRITE has two sources, and the second exists because the first is not
38
+ * enough on its own:
39
+ *
40
+ * - **The event feed** ({@link recordEmailDeliveryEvent}) — live, complete,
41
+ * and the only source of open and click counts. It knows nothing about
42
+ * mail sent before it was connected.
43
+ * - **A history import** ({@link importEmailDeliveryHistory}) — a one-off
44
+ * (and re-runnable) sweep of the provider's own list, through the same
45
+ * neutral vocabulary. Without it the log is empty for every message that
46
+ * predates the webhook, which is exactly the mail a support question is
47
+ * about. A card that shows nothing for a person we demonstrably emailed is
48
+ * the failure this whole file exists to remove.
49
+ *
50
+ * ## Shape
51
+ *
52
+ * A subcollection per recipient rather than one flat collection with a `to`
53
+ * field. The read is then a single ordered query inside one small collection
54
+ * — no composite index to go missing, and no `where` clause whose absent
55
+ * field would silently drop documents. The parent id is
56
+ * {@link emailSuppressionKey}'s `sha256`, deliberately the SAME derivation the
57
+ * suppression lists use, so the two can never disagree about which document
58
+ * describes which person.
59
+ *
60
+ * One document per MESSAGE, not per event: `sent`, `delivered`, `opened` and
61
+ * three `clicked`s are one row in the staff view, and an append-only event
62
+ * collection would make the common read six documents instead of one. Opens
63
+ * and clicks are counted rather than listed, because the count is the fact a
64
+ * staffer uses and an unbounded array is how a document reaches the 1 MiB
65
+ * limit on a mailing nobody was watching.
66
+ *
67
+ * ## Never throws
68
+ *
69
+ * Every function here is best-effort, on the same reasoning as the rest of the
70
+ * mail path: a webhook must acknowledge the provider, and a staff page must
71
+ * render, whatever Firestore is doing. A failed write loses a row from a log;
72
+ * a thrown one loses the delivery event AND teaches the provider to retry.
73
+ */ import { FieldValue } from "firebase-admin/firestore";
74
+ import { worstDeliveryStatus } from "@aglyn/shared-util-email";
75
+ import { eraseCampaignAttributionsForPersonKey } from "./campaign-attribution-store.js";
76
+ import { emailSuppressionKey } from "./email-suppression.js";
77
+ import firebaseAdmin from "./firebase-admin.js";
78
+ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
79
+ export const EMAIL_DELIVERIES_COLLECTION = 'emailDeliveries';
80
+ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
81
+ /** The most messages one staff read will return. */ export const EMAIL_DELIVERY_READ_LIMIT = 50;
82
+ /**
83
+ * The most distinct links one message records.
84
+ *
85
+ * A newsletter with forty links clicked by one reader must not grow the
86
+ * document without bound; the first few tell a staffer what they need.
87
+ */ export const EMAIL_DELIVERY_MAX_LINKS = 10;
88
+ /**
89
+ * Records one normalized event against its message.
90
+ *
91
+ * A transaction rather than a merge-set, for one property that matters to the
92
+ * reader: `firstSeenAtMs` must be written exactly once and must never be
93
+ * absent. Events arrive out of order — an `opened` can beat its own `sent`
94
+ * through the queue — so "create with the first event's time, then leave it
95
+ * alone" needs a read in the same atomic step as the write. A document missing
96
+ * that field would be dropped from the `orderBy` read entirely and the message
97
+ * would simply not appear, which is the failure mode a delivery log can least
98
+ * afford.
99
+ *
100
+ * @returns the outcome, or `null` when nothing was written. `null` is the
101
+ * ordinary answer for an address that is not an address; it is never
102
+ * an error.
103
+ */ export async function recordEmailDeliveryEvent(event, firestore) {
104
+ const key = emailSuppressionKey(event.to);
105
+ if (!key || !event.providerMessageId) return null;
106
+ let firstOfType = false;
107
+ try {
108
+ const db = firestore != null ? firestore : defaultFirestore();
109
+ const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).collection(EMAIL_DELIVERY_MESSAGES_COLLECTION).doc(event.providerMessageId);
110
+ await db.runTransaction(async (transaction)=>{
111
+ var _ref;
112
+ var _event_tags, _event_tags1;
113
+ const snapshot = await transaction.get(ref);
114
+ const existing = (_ref = snapshot.exists ? snapshot.data() : null) != null ? _ref : {};
115
+ /*
116
+ * Set INSIDE the transaction body, which may run more than once: a
117
+ * Firestore transaction retries on contention, and a value computed
118
+ * before the retry would describe the state that lost the race. This
119
+ * assignment (not `||=`) makes the last attempt — the one whose write
120
+ * committed — the one whose reading is reported.
121
+ */ firstOfType = !(existing.timestamps && existing.timestamps[event.type] !== undefined);
122
+ const update = {
123
+ messageId: event.providerMessageId,
124
+ provider: event.provider,
125
+ to: event.to,
126
+ status: worstDeliveryStatus(existing.status, event.type),
127
+ /*
128
+ * A NESTED MAP, not a dotted key.
129
+ *
130
+ * `set({merge:true})` treats `'timestamps.sent'` as a field whose
131
+ * NAME contains a dot — only `update()` reads a dot as a path. So the
132
+ * dotted form wrote a top-level field nothing reads and left
133
+ * `timestamps` empty, which the staff card rendered as a message with
134
+ * no send date. What merge DOES do is merge nested maps at depth, so
135
+ * this form keeps every sibling state rather than replacing them.
136
+ */ timestamps: {
137
+ [event.type]: event.at
138
+ },
139
+ lastEventAtMs: event.at,
140
+ updatedAt: FieldValue.serverTimestamp()
141
+ };
142
+ // Written once. A later event for the same message carries the same
143
+ // subject, but an `email.opened` payload may carry none at all — and
144
+ // overwriting a known subject with null is how a staff row loses the
145
+ // only thing that identifies it.
146
+ if (!snapshot.exists) update.firstSeenAtMs = event.at;
147
+ if (event.subject && !existing.subject) update.subject = event.subject;
148
+ if (event.context && !existing.context) update.context = event.context;
149
+ if (((_event_tags = event.tags) == null ? void 0 : _event_tags.hostId) && !existing.hostId) update.hostId = event.tags.hostId;
150
+ if (((_event_tags1 = event.tags) == null ? void 0 : _event_tags1.campaignId) && !existing.campaignId) update.campaignId = event.tags.campaignId;
151
+ if (event.bounceType) update.bounceType = event.bounceType;
152
+ if (event.detail) update.detail = event.detail;
153
+ if (event.type === 'opened') update.openCount = FieldValue.increment(1);
154
+ if (event.type === 'clicked') {
155
+ update.clickCount = FieldValue.increment(1);
156
+ if (event.link) {
157
+ const links = Array.isArray(existing.clickedLinks) ? existing.clickedLinks.map(String) : [];
158
+ if (!links.includes(event.link) && links.length < EMAIL_DELIVERY_MAX_LINKS) {
159
+ update.clickedLinks = [
160
+ ...links,
161
+ event.link
162
+ ];
163
+ }
164
+ }
165
+ }
166
+ transaction.set(ref, update, {
167
+ merge: true
168
+ });
169
+ });
170
+ return {
171
+ firstOfType,
172
+ providerMessageId: event.providerMessageId,
173
+ to: event.to,
174
+ type: event.type,
175
+ at: event.at
176
+ };
177
+ } catch (error) {
178
+ console.error('[email-delivery-log] write failed', event.providerMessageId, error);
179
+ return null;
180
+ }
181
+ }
182
+ /**
183
+ * Records one message the PROVIDER already knows about — the history import.
184
+ *
185
+ * ## Why this is not just `recordEmailDeliveryEvent` with a made-up event
186
+ *
187
+ * A snapshot is weaker evidence than an event, in two specific ways, and
188
+ * writing it as an event would silently promote it:
189
+ *
190
+ * - **It carries no counts.** A provider's list reports one `last_event` per
191
+ * message and no engagement detail, so `opened` means "at least once" and
192
+ * can never mean "three times". Incrementing `openCount` from a snapshot
193
+ * would invent a number, and re-running the import would invent it again.
194
+ * - **It can be STALER than what we already hold.** The event feed is live;
195
+ * an import is a page of results fetched some time ago. So the status is
196
+ * merged with {@link worstDeliveryStatus} rather than assigned, and a row
197
+ * the webhook has already advanced is never walked backwards.
198
+ *
199
+ * Everything else it fills is a gap-fill only: `subject` and `sentAt` are
200
+ * written when absent and left alone when present. The net effect is that
201
+ * importing history is idempotent and can be run as often as you like, and a
202
+ * message the event feed has covered is untouched by it.
203
+ *
204
+ * `context` is deliberately NOT recoverable here. It comes from a send tag,
205
+ * and the list endpoint does not return tags — so an imported row shows the
206
+ * subject and the status but cannot say which of our senders produced it. The
207
+ * card renders that absence rather than guessing.
208
+ *
209
+ * @returns whether a row was written or updated.
210
+ */ export async function recordEmailDeliverySnapshot(snapshot, firestore) {
211
+ const key = emailSuppressionKey(snapshot.to);
212
+ if (!key || !snapshot.providerMessageId || !snapshot.sentAt) return false;
213
+ try {
214
+ const db = firestore != null ? firestore : defaultFirestore();
215
+ const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).collection(EMAIL_DELIVERY_MESSAGES_COLLECTION).doc(snapshot.providerMessageId);
216
+ await db.runTransaction(async (transaction)=>{
217
+ var _ref;
218
+ var _existing_timestamps;
219
+ const stored = await transaction.get(ref);
220
+ const existing = (_ref = stored.exists ? stored.data() : null) != null ? _ref : {};
221
+ const update = {
222
+ messageId: snapshot.providerMessageId,
223
+ provider: snapshot.provider,
224
+ to: snapshot.to,
225
+ status: worstDeliveryStatus(existing.status, snapshot.status),
226
+ importedAtMs: Date.now(),
227
+ updatedAt: FieldValue.serverTimestamp()
228
+ };
229
+ if (!stored.exists) update.firstSeenAtMs = snapshot.sentAt;
230
+ if (snapshot.subject && !existing.subject) update.subject = snapshot.subject;
231
+ // Only when the event feed has not already dated the send itself. An
232
+ // imported `created_at` is the provider's, and so is the webhook's, but
233
+ // the webhook's arrived with the rest of that message's truth. Nested
234
+ // map rather than a dotted key, for the reason recorded above.
235
+ if (!((_existing_timestamps = existing.timestamps) == null ? void 0 : _existing_timestamps.sent)) {
236
+ update.timestamps = {
237
+ sent: snapshot.sentAt
238
+ };
239
+ }
240
+ transaction.set(ref, update, {
241
+ merge: true
242
+ });
243
+ });
244
+ return true;
245
+ } catch (error) {
246
+ console.error('[email-delivery-log] snapshot write failed', snapshot.providerMessageId, error);
247
+ return false;
248
+ }
249
+ }
250
+ /**
251
+ * Records a batch, independently — one bad event must not lose the others.
252
+ *
253
+ * @returns one outcome per event that was WRITTEN; events that wrote nothing
254
+ * are absent, so the length is still the count the old return value
255
+ * reported.
256
+ */ export async function recordEmailDeliveryEvents(events, firestore) {
257
+ const results = await Promise.all(events.map((event)=>recordEmailDeliveryEvent(event, firestore)));
258
+ return results.filter((one)=>one !== null);
259
+ }
260
+ /*==========================================
261
+ * THE PER-PERSON ENGAGEMENT ROLLUP.
262
+ *
263
+ * The message rows above answer "what did we send this person". They cannot
264
+ * answer "has this person engaged with anything lately" without reading every
265
+ * row in their `messages` subcollection, which is the expensive-read shape
266
+ * this codebase refuses — and that single absence is what made an audience
267
+ * rule like "opened in the last 30 days" unanswerable and engagement-based
268
+ * sunsetting unbuildable.
269
+ *
270
+ * So the rollup lands on the PARENT of the messages, `emailDeliveries/{key}`,
271
+ * which already exists as the erasure tombstone's home. One document per
272
+ * person, read by key, no query and therefore no index.
273
+ *
274
+ * ## Address-global, not per site
275
+ *
276
+ * The store keys on an address, the erasure path treats it as an address, and
277
+ * the deliverability problem the rollup exists to serve is domain-wide: every
278
+ * tenant's mail leaves on one domain under one DKIM `d=`, so the engagement
279
+ * that moves the platform's spam rate is engagement with ANY of it. A
280
+ * per-site map would also have to be capped, and capping a map needs a read
281
+ * of it on every write.
282
+ *
283
+ * The cost of that choice is stated rather than hidden: a person who engages
284
+ * with one site's mail reads as engaged when a second site asks. That is the
285
+ * lenient direction for a control whose only power is to REFUSE a send.
286
+ *
287
+ * ## What one webhook event costs
288
+ *
289
+ * A rollup that wrote on every event would be a write per event per person,
290
+ * which is a bill — a single reader opening a newsletter six times, plus
291
+ * mailbox-provider prefetches, is one fact and six writes. So the rollup
292
+ * moves only on an event that is the FIRST of its type for its message, which
293
+ * {@link recordEmailDeliveryEvent}'s transaction already decided at no extra
294
+ * cost. `delivered`, `bounced`, `complained`, `sent` and `delayed` move
295
+ * nothing here at all.
296
+ *
297
+ * That bound is also what makes it replay-proof for free, by the same
298
+ * reasoning the campaign counters rest on: a redelivered or replayed event
299
+ * finds its type already recorded, reports `firstOfType: false`, and
300
+ * contributes nothing.
301
+ *
302
+ * ⚠️ The bound has one consequence worth naming. A reader who opens only mail
303
+ * they have already opened does not advance their own stamp, so a person can
304
+ * read a year-old message and still measure as cold. Every message we send
305
+ * them afterwards is a fresh first-open, so the stamp advances the moment
306
+ * they engage with anything new — which is the population any sunset rule is
307
+ * actually about.
308
+ *=========================================*/ /** The event types that count as a person engaging. */ const ENGAGEMENT_TYPES = [
309
+ 'opened',
310
+ 'clicked'
311
+ ];
312
+ /** The empty answer, so a caller never has to invent one. */ export const NO_PERSON_ENGAGEMENT = {
313
+ lastEngagedAtMs: null,
314
+ lastOpenedAtMs: null,
315
+ lastClickedAtMs: null
316
+ };
317
+ /** Reads the three stamps off a parent document's data. */ function engagementFrom(data) {
318
+ const number = (value)=>{
319
+ const parsed = Number(value != null ? value : 0);
320
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
321
+ };
322
+ const opened = number(data == null ? void 0 : data['lastOpenedAtMs']);
323
+ const clicked = number(data == null ? void 0 : data['lastClickedAtMs']);
324
+ const engaged = number(data == null ? void 0 : data['lastEngagedAtMs']);
325
+ return {
326
+ lastEngagedAtMs: engaged != null ? engaged : opened || clicked ? Math.max(opened != null ? opened : 0, clicked != null ? clicked : 0) : null,
327
+ lastOpenedAtMs: opened,
328
+ lastClickedAtMs: clicked
329
+ };
330
+ }
331
+ /**
332
+ * Advances the engagement stamps for the people these outcomes are about.
333
+ *
334
+ * A transaction, and it buys exactly one property: the stamps only ever move
335
+ * FORWARD. Provider events are not ordered, and a replay of an event whose
336
+ * first delivery never landed can carry an instant from months ago — a blind
337
+ * merge-set would let that overwrite a fresh stamp and quietly make an active
338
+ * subscriber look cold to a control whose whole job is refusing to mail cold
339
+ * people. Reading before writing is a cheaper unit than the write beside it,
340
+ * and it happens at most once per message per event type.
341
+ *
342
+ * Never throws, for the same reason nothing else in this file does: a rollup
343
+ * that failed loses a stamp, and a rollup that threw would lose the webhook's
344
+ * acknowledgement and teach the provider to retry the whole event.
345
+ *
346
+ * @returns how many person documents were written.
347
+ */ export async function recordPersonEngagement(outcomes, firestore) {
348
+ /** Person key → the newest instant seen per engagement type in this batch. */ const byPerson = new Map();
349
+ for (const outcome of outcomes){
350
+ var _byPerson_get;
351
+ if (!outcome.firstOfType) continue;
352
+ if (!ENGAGEMENT_TYPES.includes(outcome.type)) continue;
353
+ const key = emailSuppressionKey(outcome.to);
354
+ const at = Number(outcome.at);
355
+ if (!key || !Number.isFinite(at) || at <= 0) continue;
356
+ const held = (_byPerson_get = byPerson.get(key)) != null ? _byPerson_get : {
357
+ openedAtMs: 0,
358
+ clickedAtMs: 0
359
+ };
360
+ if (outcome.type === 'opened') {
361
+ held.openedAtMs = Math.max(held.openedAtMs, at);
362
+ } else {
363
+ held.clickedAtMs = Math.max(held.clickedAtMs, at);
364
+ }
365
+ byPerson.set(key, held);
366
+ }
367
+ if (!byPerson.size) return 0;
368
+ const db = firestore != null ? firestore : defaultFirestore();
369
+ let written = 0;
370
+ for (const [key, seen] of byPerson){
371
+ try {
372
+ const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key);
373
+ await db.runTransaction(async (transaction)=>{
374
+ var _ref, _stored_lastOpenedAtMs, _stored_lastClickedAtMs, _stored_lastEngagedAtMs, _stored_lastOpenedAtMs1, _stored_lastClickedAtMs1, _stored_lastEngagedAtMs1;
375
+ const snapshot = await transaction.get(ref);
376
+ const stored = engagementFrom((_ref = snapshot.exists ? snapshot.data() : null) != null ? _ref : {});
377
+ const opened = Math.max((_stored_lastOpenedAtMs = stored.lastOpenedAtMs) != null ? _stored_lastOpenedAtMs : 0, seen.openedAtMs);
378
+ const clicked = Math.max((_stored_lastClickedAtMs = stored.lastClickedAtMs) != null ? _stored_lastClickedAtMs : 0, seen.clickedAtMs);
379
+ const engaged = Math.max((_stored_lastEngagedAtMs = stored.lastEngagedAtMs) != null ? _stored_lastEngagedAtMs : 0, opened, clicked);
380
+ // Nothing moved forward, so nothing is written. An out-of-order event
381
+ // is the ordinary case this skips, and skipping it costs a write
382
+ // rather than losing a fact.
383
+ if (opened === ((_stored_lastOpenedAtMs1 = stored.lastOpenedAtMs) != null ? _stored_lastOpenedAtMs1 : 0) && clicked === ((_stored_lastClickedAtMs1 = stored.lastClickedAtMs) != null ? _stored_lastClickedAtMs1 : 0) && engaged === ((_stored_lastEngagedAtMs1 = stored.lastEngagedAtMs) != null ? _stored_lastEngagedAtMs1 : 0)) {
384
+ return;
385
+ }
386
+ /*
387
+ * A merge-set that CREATES. Unlike the campaign counters, there is no
388
+ * document here to resurrect: `emailDeliveries/{key}` is a container
389
+ * this store owns, its only other content is the erasure tombstone,
390
+ * and a person's first recorded open is exactly when it should come
391
+ * into existence.
392
+ */ transaction.set(ref, _extends({}, opened ? {
393
+ lastOpenedAtMs: opened
394
+ } : {}, clicked ? {
395
+ lastClickedAtMs: clicked
396
+ } : {}, {
397
+ lastEngagedAtMs: engaged,
398
+ updatedAt: FieldValue.serverTimestamp()
399
+ }), {
400
+ merge: true
401
+ });
402
+ written += 1;
403
+ });
404
+ } catch (error) {
405
+ console.error('[email-delivery-log] engagement rollup failed', key, error);
406
+ }
407
+ }
408
+ return written;
409
+ }
410
+ /**
411
+ * One person's engagement, by address. Never throws.
412
+ *
413
+ * Returns {@link NO_PERSON_ENGAGEMENT} for an address we hold nothing about,
414
+ * AND for a read that failed. The two are deliberately the same answer here:
415
+ * every caller uses this to decide whether to REFUSE something, and both
416
+ * readings must resolve to "we have no evidence this person is cold", which
417
+ * is the only safe direction for a control that stops mail.
418
+ */ export async function readPersonEngagement(email, firestore) {
419
+ const key = emailSuppressionKey(email);
420
+ if (!key) return NO_PERSON_ENGAGEMENT;
421
+ try {
422
+ var _snapshot_data;
423
+ const db = firestore != null ? firestore : defaultFirestore();
424
+ const snapshot = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).get();
425
+ // No `exists` branch: a missing document has no data, and `engagementFrom`
426
+ // already answers an absent field with null. A second gate saying the same
427
+ // thing would be a line no test can distinguish from its own removal.
428
+ return engagementFrom((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
429
+ } catch (error) {
430
+ console.error('[email-delivery-log] engagement read failed', error);
431
+ return NO_PERSON_ENGAGEMENT;
432
+ }
433
+ }
434
+ /**
435
+ * Engagement for many people at once, keyed by their person key.
436
+ *
437
+ * A `getAll` rather than a query: these are keyed document reads, so this
438
+ * needs no index, cannot be truncated by a `limit`, and cannot drop somebody
439
+ * for missing a field the way an `orderBy` would. The audience materializer
440
+ * calls it a page at a time and counts every read against its scan budget.
441
+ *
442
+ * A key with no document is present in the result with
443
+ * {@link NO_PERSON_ENGAGEMENT}, so a caller never has to tell "absent" from
444
+ * "not read" — and a failure returns every requested key that way for the
445
+ * same reason {@link readPersonEngagement} does.
446
+ */ export async function readPersonEngagementByKeys(keys, firestore) {
447
+ const wanted = [
448
+ ...new Set(keys.filter(Boolean))
449
+ ];
450
+ const found = new Map();
451
+ for (const key of wanted)found.set(key, NO_PERSON_ENGAGEMENT);
452
+ if (!wanted.length) return found;
453
+ try {
454
+ const db = firestore != null ? firestore : defaultFirestore();
455
+ const collection = db.collection(EMAIL_DELIVERIES_COLLECTION);
456
+ const snapshots = await db.getAll(...wanted.map((key)=>collection.doc(key)));
457
+ for (const snapshot of snapshots){
458
+ var _snapshot_data;
459
+ if (!(snapshot == null ? void 0 : snapshot.exists)) continue;
460
+ found.set(snapshot.id, engagementFrom((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {}));
461
+ }
462
+ } catch (error) {
463
+ console.error('[email-delivery-log] engagement batch read failed', error);
464
+ }
465
+ return found;
466
+ }
467
+ /*==========================================
468
+ * THE CAMPAIGN TOUCH — which campaign this person last CLICKED, per site.
469
+ *
470
+ * The engagement rollup above answers "is this person still listening". It
471
+ * cannot answer "which email brought them here", because it keeps instants
472
+ * and not identities, and that second question is what revenue attribution
473
+ * is: an order arrives, and something has to say which campaign preceded it.
474
+ *
475
+ * ## Here, on the person's own document
476
+ *
477
+ * The alternative was a per-host collection of touch documents, and it fails
478
+ * on erasure. `eraseEmailDeliveriesForAddresses` erases by ADDRESS and knows
479
+ * nothing about which sites have mailed it, so a per-host collection would be
480
+ * a record of a person's clicks that an erasure request could not reach. On
481
+ * the person document it is one field, deleted with the stamps it belongs
482
+ * beside — a click is the same personal fact as the open recorded next to it.
483
+ *
484
+ * ## A CLICK ONLY
485
+ *
486
+ * `ENGAGEMENT_TYPES` includes opens because the control it feeds REFUSES to
487
+ * mail people, and the generous signal is the correct one for a refusal. This
488
+ * is the opposite kind of decision — it CREDITS a campaign with money — so it
489
+ * takes the strict signal. Since Apple's Mail Privacy Protection an open is
490
+ * substantially a statement about the recipient's mail client, and crediting
491
+ * revenue to one would credit whichever campaign most recently reached an
492
+ * Apple Mail user with orders from people who never read it.
493
+ *
494
+ * ## Per host, and capped
495
+ *
496
+ * A single global touch would credit site A's campaign with site B's order,
497
+ * or refuse both — the send path refuses cross-site reach and the revenue
498
+ * join has to agree with it. So the field is a map keyed by host, and a map
499
+ * on a document has to be bounded: past {@link EMAIL_TOUCH_MAX_HOSTS} the
500
+ * oldest touch is evicted, inside the transaction the forward-only rule
501
+ * already pays for. A person who clicks mail from eleven different sites
502
+ * loses their oldest click, which costs an attribution rather than a fact
503
+ * anybody else reads.
504
+ *=========================================*/ /** The field on `emailDeliveries/{key}` holding the per-host touches. */ export const EMAIL_TOUCH_FIELD = 'campaignTouches';
505
+ /**
506
+ * How many sites' touches one person's document keeps.
507
+ *
508
+ * A cap, not a page size: the map lives in a document with a 1 MiB ceiling
509
+ * and nothing else bounds how many sites may mail one address.
510
+ */ export const EMAIL_TOUCH_MAX_HOSTS = 10;
511
+ /** Reads the touch map off a person document's data, defensively. */ function touchesFrom(data) {
512
+ const raw = data == null ? void 0 : data[EMAIL_TOUCH_FIELD];
513
+ if (!raw || typeof raw !== 'object') return {};
514
+ const found = {};
515
+ for (const [hostId, entry] of Object.entries(raw)){
516
+ var _ref, _ref1;
517
+ const campaignId = String((_ref = entry == null ? void 0 : entry.campaignId) != null ? _ref : '');
518
+ const atMs = Number((_ref1 = entry == null ? void 0 : entry.atMs) != null ? _ref1 : 0);
519
+ if (!campaignId || !Number.isFinite(atMs) || atMs <= 0) continue;
520
+ found[hostId] = {
521
+ campaignId,
522
+ atMs
523
+ };
524
+ }
525
+ return found;
526
+ }
527
+ /**
528
+ * Records that this person clicked this campaign's mail. Never throws.
529
+ *
530
+ * Forward-only, in a transaction, for the reason {@link recordPersonEngagement}
531
+ * is: provider delivery is at-least-once and unordered, so a replayed click
532
+ * from last month must not displace this week's. That same property is what
533
+ * makes this idempotent — a redelivered event finds its own instant already
534
+ * stored and writes nothing.
535
+ *
536
+ * @returns whether the touch moved forward.
537
+ */ export async function recordEmailCampaignTouch(touch, firestore) {
538
+ var _touch_hostId, _touch_campaignId;
539
+ const key = emailSuppressionKey(touch.email);
540
+ const hostId = String((_touch_hostId = touch.hostId) != null ? _touch_hostId : '');
541
+ const campaignId = String((_touch_campaignId = touch.campaignId) != null ? _touch_campaignId : '');
542
+ const atMs = Number(touch.atMs);
543
+ if (!key || !hostId || !campaignId) return false;
544
+ if (!Number.isFinite(atMs) || atMs <= 0) return false;
545
+ try {
546
+ const db = firestore != null ? firestore : defaultFirestore();
547
+ const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key);
548
+ let moved = false;
549
+ await db.runTransaction(async (transaction)=>{
550
+ var _ref;
551
+ moved = false;
552
+ const snapshot = await transaction.get(ref);
553
+ const stored = touchesFrom((_ref = snapshot.exists ? snapshot.data() : null) != null ? _ref : {});
554
+ const held = stored[hostId];
555
+ // Not newer than what is already there, so nothing is written. An
556
+ // out-of-order or replayed event is the ordinary case this skips.
557
+ if (held && held.atMs >= atMs) return;
558
+ const update = {
559
+ [hostId]: {
560
+ campaignId,
561
+ atMs
562
+ }
563
+ };
564
+ /*
565
+ * EVICTION, and only when this host is NEW to the map. Replacing an
566
+ * existing host's touch cannot grow it, so the cap is checked exactly
567
+ * where the map can cross it. The oldest goes, because the window makes
568
+ * an old touch the one least likely to be credited with anything.
569
+ *
570
+ * `FieldValue.delete()` INSIDE the map: a merge-set merges nested maps
571
+ * at depth, which is what keeps every other host's touch — and is also
572
+ * why an evicted key has to be deleted explicitly rather than by
573
+ * omission.
574
+ */ if (!held && Object.keys(stored).length >= EMAIL_TOUCH_MAX_HOSTS) {
575
+ const oldest = Object.entries(stored).sort((a, b)=>a[1].atMs - b[1].atMs || a[0].localeCompare(b[0]))[0];
576
+ if (oldest) update[oldest[0]] = FieldValue.delete();
577
+ }
578
+ transaction.set(ref, {
579
+ [EMAIL_TOUCH_FIELD]: update,
580
+ updatedAt: FieldValue.serverTimestamp()
581
+ }, {
582
+ merge: true
583
+ });
584
+ moved = true;
585
+ });
586
+ return moved;
587
+ } catch (error) {
588
+ console.error('[email-delivery-log] campaign touch write failed', error);
589
+ return false;
590
+ }
591
+ }
592
+ /**
593
+ * The last campaign this person clicked on this site, or `null`.
594
+ *
595
+ * One keyed document read — no query, no index, and nothing that can be
596
+ * truncated. `null` for an address we hold no touch for AND for a read that
597
+ * failed, which are the same answer on purpose: both mean "we cannot say
598
+ * which campaign preceded this order", and the only safe thing to do with
599
+ * that is credit nobody.
600
+ */ export async function readEmailCampaignTouch(email, hostId, firestore) {
601
+ const key = emailSuppressionKey(email);
602
+ if (!key || !hostId) return null;
603
+ try {
604
+ var _snapshot_data;
605
+ const db = firestore != null ? firestore : defaultFirestore();
606
+ const snapshot = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).get();
607
+ const held = touchesFrom((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {})[hostId];
608
+ if (!held) return null;
609
+ return {
610
+ hostId,
611
+ campaignId: held.campaignId,
612
+ clickedAtMs: held.atMs
613
+ };
614
+ } catch (error) {
615
+ console.error('[email-delivery-log] campaign touch read failed', error);
616
+ return null;
617
+ }
618
+ }
619
+ /** Default page budget for one import run. 100 messages per page. */ export const EMAIL_DELIVERY_IMPORT_MAX_PAGES = 20;
620
+ /**
621
+ * Imports already-sent mail from a provider into the log.
622
+ *
623
+ * Bounded by PAGES rather than run to completion: this is called from a
624
+ * request handler, and an account with a large history would otherwise hold
625
+ * one open until it timed out — losing every page it had already written,
626
+ * because a partial import that reports nothing is indistinguishable from one
627
+ * that did nothing. Instead it stops at the budget, returns `nextCursor`, and
628
+ * the caller resumes. Every page is written before the next is fetched, so an
629
+ * interrupted run keeps its work.
630
+ *
631
+ * Idempotent by construction — see {@link recordEmailDeliverySnapshot}: a
632
+ * message the event feed already covered is not walked backwards, and
633
+ * re-running invents no counts.
634
+ *
635
+ * The `source` is injected rather than constructed here. This module may not
636
+ * know which provider is in use, and a test must be able to run the whole
637
+ * loop — pagination, cursor handling, the stop condition — without a network.
638
+ */ export async function importEmailDeliveryHistory(options) {
639
+ var _options_maxPages, _options_cursor;
640
+ const maxPages = Math.max(1, (_options_maxPages = options.maxPages) != null ? _options_maxPages : EMAIL_DELIVERY_IMPORT_MAX_PAGES);
641
+ let cursor = (_options_cursor = options.cursor) != null ? _options_cursor : null;
642
+ let scanned = 0;
643
+ let recorded = 0;
644
+ let pages = 0;
645
+ while(pages < maxPages){
646
+ const page = await options.source({
647
+ cursor
648
+ });
649
+ pages += 1;
650
+ scanned += page.snapshots.length;
651
+ for (const snapshot of page.snapshots){
652
+ if (await recordEmailDeliverySnapshot(snapshot, options.firestore)) {
653
+ recorded += 1;
654
+ }
655
+ }
656
+ cursor = page.nextCursor;
657
+ if (!cursor) break;
658
+ }
659
+ return {
660
+ scanned,
661
+ recorded,
662
+ pages,
663
+ nextCursor: cursor,
664
+ truncated: Boolean(cursor)
665
+ };
666
+ }
667
+ /**
668
+ * The messages sent to one address, newest first.
669
+ *
670
+ * Ordered on `firstSeenAtMs`, which the writer guarantees on creation, rather
671
+ * than on a per-state timestamp that only some rows carry: `orderBy` drops
672
+ * every document missing the field, so ordering on `timestamps.sent` would
673
+ * silently hide any message whose `sent` webhook never arrived — exactly the
674
+ * message a staffer is looking for.
675
+ *
676
+ * @returns the rows, or an empty array. The caller distinguishes "none" from
677
+ * "could not read" through {@link readEmailDeliveryHistory}.
678
+ */ export async function readEmailDeliveries(email, options) {
679
+ var _ref, _ref1;
680
+ const key = emailSuppressionKey(email);
681
+ if (!key) return [];
682
+ const db = (_ref = options == null ? void 0 : options.firestore) != null ? _ref : defaultFirestore();
683
+ const snapshot = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).collection(EMAIL_DELIVERY_MESSAGES_COLLECTION).orderBy('firstSeenAtMs', 'desc').limit(Math.max(1, (_ref1 = options == null ? void 0 : options.limit) != null ? _ref1 : EMAIL_DELIVERY_READ_LIMIT)).get();
684
+ return snapshot.docs.map(deliveryRecordFrom);
685
+ }
686
+ /**
687
+ * One stored message document as {@link EmailDeliveryRecord}.
688
+ *
689
+ * Shared by every reader in this file so the defaults are decided once. A
690
+ * second copy would be a second answer to "what does an absent `openCount`
691
+ * mean", and the two would drift the first time a field is added.
692
+ */ function deliveryRecordFrom(doc) {
693
+ var _doc_data, _data_messageId, _data_provider, _data_to, _data_subject, _data_context, _data_status, _data_timestamps, _data_firstSeenAtMs, _data_openCount, _data_clickCount, _data_bounceType, _data_detail, _data_hostId, _data_campaignId;
694
+ const data = (_doc_data = doc.data()) != null ? _doc_data : {};
695
+ return {
696
+ messageId: String((_data_messageId = data.messageId) != null ? _data_messageId : doc.id),
697
+ provider: String((_data_provider = data.provider) != null ? _data_provider : 'unknown'),
698
+ to: String((_data_to = data.to) != null ? _data_to : ''),
699
+ subject: (_data_subject = data.subject) != null ? _data_subject : null,
700
+ context: (_data_context = data.context) != null ? _data_context : null,
701
+ status: (_data_status = data.status) != null ? _data_status : 'sent',
702
+ timestamps: (_data_timestamps = data.timestamps) != null ? _data_timestamps : {},
703
+ firstSeenAtMs: Number((_data_firstSeenAtMs = data.firstSeenAtMs) != null ? _data_firstSeenAtMs : 0),
704
+ openCount: Number((_data_openCount = data.openCount) != null ? _data_openCount : 0),
705
+ clickCount: Number((_data_clickCount = data.clickCount) != null ? _data_clickCount : 0),
706
+ clickedLinks: Array.isArray(data.clickedLinks) ? data.clickedLinks.map(String) : [],
707
+ bounceType: (_data_bounceType = data.bounceType) != null ? _data_bounceType : null,
708
+ detail: (_data_detail = data.detail) != null ? _data_detail : null,
709
+ hostId: (_data_hostId = data.hostId) != null ? _data_hostId : null,
710
+ campaignId: (_data_campaignId = data.campaignId) != null ? _data_campaignId : null
711
+ };
712
+ }
713
+ /**
714
+ * {@link readEmailDeliveries} with the read failure kept separate from an
715
+ * empty result.
716
+ *
717
+ * The same shape `devices` uses on the staff detail route, for the same
718
+ * reason: "we have no record of any email to this person" and "we could not
719
+ * reach the log" lead a staffer to opposite next actions, and a card that
720
+ * renders both as an empty table sends them down the wrong one.
721
+ */ export async function readEmailDeliveryHistory(email, options) {
722
+ try {
723
+ return {
724
+ lookupFailed: false,
725
+ rows: await readEmailDeliveries(email, options)
726
+ };
727
+ } catch (error) {
728
+ console.error('[email-delivery-log] read failed', error);
729
+ return {
730
+ lookupFailed: true,
731
+ rows: []
732
+ };
733
+ }
734
+ }
735
+ /*==========================================
736
+ * ACROSS THE CAMPAIGNS OF ONE SITE.
737
+ *
738
+ * The readers above answer "what did we send this person". This one answers
739
+ * the other direction — "who did this campaign reach, and which of them
740
+ * opened it" — and it is the SAME store, queried across the recipient
741
+ * documents instead of down one of them.
742
+ *
743
+ * That direction is a collection-group query, and it is the one shape this
744
+ * file's header says the per-address layout avoids. It is worth the index
745
+ * here for the reason the index exists at all: the alternative is a second
746
+ * per-recipient store keyed by campaign, written by the same webhook, which
747
+ * would be two records of the same fact and one of them eventually wrong.
748
+ *
749
+ * ⚠️ EVERY caller must be authorised on `hostId` before calling. The rows
750
+ * carry recipient addresses, and the `hostId` filter below is a query
751
+ * predicate, not a permission — it narrows the read to one site's mail and
752
+ * says nothing about who is asking.
753
+ *=========================================*/ /** The most recipient rows one campaign-engagement read returns. */ export const EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE = 25;
754
+ /**
755
+ * How many campaigns one engagement read can span.
756
+ *
757
+ * Firestore's `in` operator takes at most 30 values, and the query below runs
758
+ * as a merge of one sub-query per value — so this is a hard limit of the
759
+ * store rather than a number worth tuning. A design used by more campaigns
760
+ * than this reads its most recent 30, and the caller is told so.
761
+ */ export const EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS = 30;
762
+ /**
763
+ * The recipients of one site's campaigns, newest message first.
764
+ *
765
+ * ## What each filter orders on, and why it is not one query with a flag
766
+ *
767
+ * `all` orders on `firstSeenAtMs`, which {@link recordEmailDeliveryEvent}
768
+ * guarantees on creation. `opened` and `clicked` carry an inequality —
769
+ * `openCount > 0` — and Firestore requires the first ordering to be on the
770
+ * inequality's own field, so those two order on the count and then on the
771
+ * time. That is not a workaround: a message never opened has no `openCount`
772
+ * field at all, so the inequality is also what excludes it, and the ordering
773
+ * puts the most engaged recipient first, which is the order a merchant reads
774
+ * such a table in.
775
+ *
776
+ * ## Never throws
777
+ *
778
+ * Same contract as the rest of this file: `lookupFailed` distinguishes a read
779
+ * that could not run — a missing index is the likely one — from a campaign
780
+ * nobody opened. Rendering those two the same way is how a merchant concludes
781
+ * their campaign reached nobody.
782
+ */ export async function readCampaignEngagement(options) {
783
+ var _options_limit;
784
+ const { hostId, campaignIds, filter = 'all', cursor = null, firestore } = options;
785
+ const pageSize = Math.max(1, Math.min(EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE, (_options_limit = options.limit) != null ? _options_limit : EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE));
786
+ const ids = campaignIds.filter(Boolean).slice(0, EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS);
787
+ const campaignsOmitted = Math.max(0, campaignIds.filter(Boolean).length - ids.length);
788
+ const empty = {
789
+ rows: [],
790
+ cursor: null,
791
+ lookupFailed: false,
792
+ campaignsOmitted
793
+ };
794
+ if (!hostId || !ids.length) return empty;
795
+ try {
796
+ const db = firestore != null ? firestore : defaultFirestore();
797
+ let query = db.collectionGroup(EMAIL_DELIVERY_MESSAGES_COLLECTION)// `hostId` first so the read is provably one site's mail even if a
798
+ // caller ever passes a campaign id belonging to another.
799
+ .where('hostId', '==', hostId).where('campaignId', 'in', ids);
800
+ if (filter === 'opened') {
801
+ query = query.where('openCount', '>', 0).orderBy('openCount', 'desc');
802
+ } else if (filter === 'clicked') {
803
+ query = query.where('clickCount', '>', 0).orderBy('clickCount', 'desc');
804
+ }
805
+ query = query.orderBy('firstSeenAtMs', 'desc');
806
+ if (cursor) {
807
+ const anchor = await db.doc(cursor).get();
808
+ // A cursor whose document has been erased resumes nothing rather than
809
+ // silently restarting at page one, which would loop the reader through
810
+ // the same rows forever.
811
+ if (!anchor.exists) return empty;
812
+ query = query.startAfter(anchor);
813
+ }
814
+ const snapshot = await query.limit(pageSize).get();
815
+ const rows = snapshot.docs.map(deliveryRecordFrom);
816
+ return {
817
+ rows,
818
+ // Null on a short page: a full page is the only state from which more
819
+ // rows can exist, and offering a cursor that returns nothing makes a
820
+ // finished table look unfinished.
821
+ cursor: rows.length === pageSize ? String(snapshot.docs[snapshot.docs.length - 1].ref.path) : null,
822
+ lookupFailed: false,
823
+ campaignsOmitted
824
+ };
825
+ } catch (error) {
826
+ console.error('[email-delivery-log] campaign engagement read failed', error);
827
+ return _extends({}, empty, {
828
+ lookupFailed: true
829
+ });
830
+ }
831
+ }
832
+ /** The tombstone on one address, or null. Never throws. */ export async function readEmailDeliveryErasure(email, firestore) {
833
+ const key = emailSuppressionKey(email);
834
+ if (!key) return null;
835
+ try {
836
+ var _doc_get, _doc_get1;
837
+ const db = firestore != null ? firestore : defaultFirestore();
838
+ const doc = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).get();
839
+ if (!doc.exists) return null;
840
+ const at = Number((_doc_get = doc.get('erasedAtMs')) != null ? _doc_get : 0);
841
+ if (!at) return null;
842
+ return {
843
+ at,
844
+ count: Number((_doc_get1 = doc.get('erasedCount')) != null ? _doc_get1 : 0)
845
+ };
846
+ } catch (unused) {
847
+ return null;
848
+ }
849
+ }
850
+ /**
851
+ * Every message sent to any address this account holds, newest first.
852
+ *
853
+ * Merged and re-sorted rather than concatenated: the rows are one person's
854
+ * mail and a staffer reads them as a timeline, so grouping them by which
855
+ * address happened to receive them would put the answer in two places and
856
+ * make "what was the last thing we sent them" a question about two tables.
857
+ * Each row keeps its own `to`, so the card can still say which address.
858
+ *
859
+ * `lookupFailed` is true when ANY address failed. A partial read of a
860
+ * delivery log is the same hazard as an empty one — it under-reports mail we
861
+ * sent — and reporting it as a clean result is how a staffer comes to tell a
862
+ * customer something untrue.
863
+ */ export async function readEmailDeliveryHistoryForAddresses(addresses, options) {
864
+ var _ref;
865
+ const limit = Math.max(1, (_ref = options == null ? void 0 : options.limit) != null ? _ref : EMAIL_DELIVERY_READ_LIMIT);
866
+ const addressesRead = [];
867
+ const erasures = {};
868
+ const rows = [];
869
+ let lookupFailed = false;
870
+ for (const address of addresses){
871
+ const key = emailSuppressionKey(address);
872
+ if (!key) continue;
873
+ addressesRead.push(address);
874
+ try {
875
+ rows.push(...await readEmailDeliveries(address, _extends({
876
+ limit
877
+ }, options)));
878
+ } catch (error) {
879
+ console.error('[email-delivery-log] read failed', error);
880
+ lookupFailed = true;
881
+ }
882
+ const erasure = await readEmailDeliveryErasure(address, options == null ? void 0 : options.firestore);
883
+ if (erasure) erasures[address] = erasure;
884
+ }
885
+ rows.sort((a, b)=>b.firstSeenAtMs - a.firstSeenAtMs);
886
+ return {
887
+ lookupFailed,
888
+ rows: rows.slice(0, limit),
889
+ addressesRead,
890
+ erasures
891
+ };
892
+ }
893
+ /**
894
+ * Erase the delivery log for every address an account holds, except the ones
895
+ * a second account also holds.
896
+ *
897
+ * ## The shared-address decision
898
+ *
899
+ * The log describes an ADDRESS, not an account. Where one account holds an
900
+ * address, erasing it is simply erasing the subject's mail, and this sweeps
901
+ * it.
902
+ *
903
+ * Where TWO accounts hold one address, the same rows are two people's answer
904
+ * to "what did you send me", and the two readings are incompatible:
905
+ *
906
+ * - **One human, two accounts** — the ordinary live shape, an account whose
907
+ * federated provider address is another account's primary. Erasing is
908
+ * right; the mail is the requester's.
909
+ * - **A genuinely shared mailbox** — `billing@`, `support@`, a role account
910
+ * two different people hold. Erasing destroys the second person's delivery
911
+ * history for an address they legitimately hold, and they asked for
912
+ * nothing.
913
+ *
914
+ * ⛔ **Nothing here can tell those apart.** The difference is a fact about the
915
+ * humans, and the data holds no fact about the humans — only that two account
916
+ * records name one address. So this function does not choose. It erases what
917
+ * it can decide about and reports the rest as CONTESTED, and `eraseUser`
918
+ * refuses the whole erasure rather than half-perform one: destroying the
919
+ * second party's mail has no remedy, and quietly leaving it while reporting
920
+ * the erasure complete is the gap this area exists to close. Refusing is the
921
+ * only outcome that is neither, and it is reversible — a human decides which
922
+ * reading applies, detaches the address or confirms the account, and the
923
+ * erasure runs.
924
+ *
925
+ * ⚠️ A contested address is not tombstoned. The tombstone means "the records
926
+ * here were removed under an erasure request", and writing one over rows that
927
+ * are still present would tell the second holder their mail is gone while it
928
+ * sits underneath — a worse misreading than the blank table, because it is
929
+ * confidently wrong rather than merely empty. Nothing was removed, so their
930
+ * card renders their mail exactly as before.
931
+ *
932
+ * ⚠️ `shared` is one-directional evidence. True proves a second holder; false
933
+ * only means none was found, because there is no lookup for an account
934
+ * holding an address through a federated provider (see
935
+ * `account-addresses.ts`). So the tombstone is still written for EVERY
936
+ * address that IS erased, not only ones believed unshared — it costs one
937
+ * small document and closes the case where a second holder exists behind the
938
+ * gap in the probe and would otherwise meet a blank table.
939
+ *
940
+ * ⛔ Only addresses the account HOLDS, resolved through the one resolver. An
941
+ * address arriving here that the account does not hold erases a stranger's
942
+ * mail, which no erasure request authorises.
943
+ */ export async function eraseEmailDeliveriesForAddresses(addresses, firestore) {
944
+ const db = firestore != null ? firestore : defaultFirestore();
945
+ const erased = [];
946
+ const contestedAddresses = [];
947
+ let removed = 0;
948
+ for (const entry of addresses){
949
+ const key = emailSuppressionKey(entry.address);
950
+ if (!key) continue;
951
+ // Before any write for this address, so a contested one is untouched
952
+ // rather than erased-then-regretted. There is no undo below this line.
953
+ if (entry.shared === true) {
954
+ contestedAddresses.push(entry.address);
955
+ continue;
956
+ }
957
+ erased.push(entry.address);
958
+ const count = await eraseEmailDeliveries(entry.address, db).catch(()=>0);
959
+ removed += count;
960
+ // The tombstone lands whether or not anything was removed: an address we
961
+ // erased and found empty is still an address whose records this request
962
+ // covered, and a later import must not be able to refill it silently.
963
+ try {
964
+ await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).set({
965
+ erasedAtMs: Date.now(),
966
+ erasedCount: FieldValue.increment(count),
967
+ /*
968
+ * The engagement rollup goes with the messages it was summarised
969
+ * from. "This person read our mail on the 3rd" is the same
970
+ * personal fact as the row it was derived from, and a summary
971
+ * that outlived its source would leave an erasure that removed
972
+ * the evidence and kept the conclusion.
973
+ */ lastEngagedAtMs: FieldValue.delete(),
974
+ lastOpenedAtMs: FieldValue.delete(),
975
+ lastClickedAtMs: FieldValue.delete(),
976
+ /*
977
+ * And the campaign touches, for the same reason and one step
978
+ * further: "this person clicked THIS campaign on the 3rd" names
979
+ * both the person and what they were reading, so it is the
980
+ * strongest personal fact on the document. The orders it has
981
+ * already been credited with keep their own record — that one is
982
+ * a commercial fact about a sale, held under the order's id
983
+ * rather than the person's — but nothing here may go on
984
+ * attributing their FUTURE orders to mail they asked us to forget.
985
+ */ [EMAIL_TOUCH_FIELD]: FieldValue.delete(),
986
+ updatedAt: FieldValue.serverTimestamp()
987
+ }, {
988
+ merge: true
989
+ });
990
+ } catch (error) {
991
+ console.error('[email-delivery-log] tombstone write failed', error);
992
+ }
993
+ /*
994
+ * And the CONCLUSIONS drawn from those touches, on every site.
995
+ *
996
+ * A conversion attribution says "this person came from that campaign and
997
+ * then submitted this form / became this lead / made this booking". It is
998
+ * derived from the click stamp deleted a few lines above and is a
999
+ * strictly stronger statement than the stamp was, so deleting the stamp
1000
+ * and keeping the attribution would be an erasure that removed the
1001
+ * evidence and kept the conclusion.
1002
+ *
1003
+ * Keyed on `personKey`, which is `emailSuppressionKey` — the same
1004
+ * derivation, one function — so the sweep covers exactly the person this
1005
+ * loop is erasing. Per address rather than per host, because an erasure
1006
+ * request names an address and knows nothing about which sites it ever
1007
+ * visited.
1008
+ */ await eraseCampaignAttributionsForPersonKey(key, db);
1009
+ }
1010
+ return {
1011
+ removed,
1012
+ addresses: erased,
1013
+ contestedAddresses
1014
+ };
1015
+ }
1016
+ /**
1017
+ * Deletes everything recorded for one address.
1018
+ *
1019
+ * The log holds an address, the subjects sent to it and when they were opened
1020
+ * — personal data by any reading — so the erasure path has to be able to reach
1021
+ * it. Batched because a long-lived account can hold hundreds of rows and a
1022
+ * single `delete()` per document would be one round trip each.
1023
+ */ export async function eraseEmailDeliveries(email, firestore) {
1024
+ const key = emailSuppressionKey(email);
1025
+ if (!key) return 0;
1026
+ const db = firestore != null ? firestore : defaultFirestore();
1027
+ const parent = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).collection(EMAIL_DELIVERY_MESSAGES_COLLECTION);
1028
+ let removed = 0;
1029
+ // Bounded loop rather than `while (true)`: a pathological collection must
1030
+ // not be able to hold an erasure request open indefinitely.
1031
+ for(let pass = 0; pass < 20; pass += 1){
1032
+ const snapshot = await parent.limit(400).get();
1033
+ if (snapshot.empty) break;
1034
+ const batch = db.batch();
1035
+ snapshot.docs.forEach((doc)=>batch.delete(doc.ref));
1036
+ await batch.commit();
1037
+ removed += snapshot.size;
1038
+ if (snapshot.size < 400) break;
1039
+ }
1040
+ return removed;
1041
+ }
1042
+
1043
+ //# sourceMappingURL=email-delivery-log.js.map