@aglyn/plugins-marketing 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 (183) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +7 -0
  3. package/package.json +56 -0
  4. package/src/index.d.ts +19 -0
  5. package/src/index.js +20 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
  8. package/src/lib/components/announcement-bar-card.component.js +292 -0
  9. package/src/lib/components/announcement-bar-card.component.js.map +1 -0
  10. package/src/lib/components/campaign-composer.d.ts +84 -0
  11. package/src/lib/components/campaign-composer.js +1643 -0
  12. package/src/lib/components/campaign-composer.js.map +1 -0
  13. package/src/lib/components/campaign-conversions-card.d.ts +22 -0
  14. package/src/lib/components/campaign-conversions-card.js +642 -0
  15. package/src/lib/components/campaign-conversions-card.js.map +1 -0
  16. package/src/lib/components/campaign-detail-card.d.ts +55 -0
  17. package/src/lib/components/campaign-detail-card.js +771 -0
  18. package/src/lib/components/campaign-detail-card.js.map +1 -0
  19. package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
  20. package/src/lib/components/campaign-edit-drawer.js +252 -0
  21. package/src/lib/components/campaign-edit-drawer.js.map +1 -0
  22. package/src/lib/components/campaign-email-zones.d.ts +111 -0
  23. package/src/lib/components/campaign-email-zones.js +54 -0
  24. package/src/lib/components/campaign-email-zones.js.map +1 -0
  25. package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
  26. package/src/lib/components/campaign-glance-card.component.js +151 -0
  27. package/src/lib/components/campaign-glance-card.component.js.map +1 -0
  28. package/src/lib/components/campaign-members-section.d.ts +87 -0
  29. package/src/lib/components/campaign-members-section.js +499 -0
  30. package/src/lib/components/campaign-members-section.js.map +1 -0
  31. package/src/lib/components/campaign-reach-sections.d.ts +137 -0
  32. package/src/lib/components/campaign-reach-sections.js +694 -0
  33. package/src/lib/components/campaign-reach-sections.js.map +1 -0
  34. package/src/lib/components/campaign-report-card.d.ts +91 -0
  35. package/src/lib/components/campaign-report-card.js +667 -0
  36. package/src/lib/components/campaign-report-card.js.map +1 -0
  37. package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
  38. package/src/lib/components/campaign-test-send-drawer.js +238 -0
  39. package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
  40. package/src/lib/components/campaigns-card.d.ts +31 -0
  41. package/src/lib/components/campaigns-card.js +667 -0
  42. package/src/lib/components/campaigns-card.js.map +1 -0
  43. package/src/lib/components/conversion-attribution.component.d.ts +62 -0
  44. package/src/lib/components/conversion-attribution.component.js +127 -0
  45. package/src/lib/components/conversion-attribution.component.js.map +1 -0
  46. package/src/lib/components/email-compose-card.d.ts +12 -0
  47. package/src/lib/components/email-compose-card.js +157 -0
  48. package/src/lib/components/email-compose-card.js.map +1 -0
  49. package/src/lib/components/email-detail.d.ts +40 -0
  50. package/src/lib/components/email-detail.js +1180 -0
  51. package/src/lib/components/email-detail.js.map +1 -0
  52. package/src/lib/components/email-edit-drawer.d.ts +54 -0
  53. package/src/lib/components/email-edit-drawer.js +172 -0
  54. package/src/lib/components/email-edit-drawer.js.map +1 -0
  55. package/src/lib/components/email-messages-widget.d.ts +22 -0
  56. package/src/lib/components/email-messages-widget.js +49 -0
  57. package/src/lib/components/email-messages-widget.js.map +1 -0
  58. package/src/lib/components/email-recipients-card.d.ts +50 -0
  59. package/src/lib/components/email-recipients-card.js +338 -0
  60. package/src/lib/components/email-recipients-card.js.map +1 -0
  61. package/src/lib/components/emails-list-card.d.ts +36 -0
  62. package/src/lib/components/emails-list-card.js +544 -0
  63. package/src/lib/components/emails-list-card.js.map +1 -0
  64. package/src/lib/components/experiment-zones.d.ts +78 -0
  65. package/src/lib/components/experiment-zones.js +65 -0
  66. package/src/lib/components/experiment-zones.js.map +1 -0
  67. package/src/lib/components/host-experiments-card.component.d.ts +17 -0
  68. package/src/lib/components/host-experiments-card.component.js +887 -0
  69. package/src/lib/components/host-experiments-card.component.js.map +1 -0
  70. package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
  71. package/src/lib/components/host-marketing-summary-card.component.js +161 -0
  72. package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
  73. package/src/lib/components/host-overlays-card.component.d.ts +19 -0
  74. package/src/lib/components/host-overlays-card.component.js +783 -0
  75. package/src/lib/components/host-overlays-card.component.js.map +1 -0
  76. package/src/lib/components/marketing-console-page.d.ts +28 -0
  77. package/src/lib/components/marketing-console-page.js +166 -0
  78. package/src/lib/components/marketing-console-page.js.map +1 -0
  79. package/src/lib/components/marketing-console-sections.d.ts +34 -0
  80. package/src/lib/components/marketing-console-sections.js +77 -0
  81. package/src/lib/components/marketing-console-sections.js.map +1 -0
  82. package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
  83. package/src/lib/components/overlay-stats-row.component.js +78 -0
  84. package/src/lib/components/overlay-stats-row.component.js.map +1 -0
  85. package/src/lib/components/popup-card.component.d.ts +18 -0
  86. package/src/lib/components/popup-card.component.js +462 -0
  87. package/src/lib/components/popup-card.component.js.map +1 -0
  88. package/src/lib/components/record-attribution-widget.d.ts +35 -0
  89. package/src/lib/components/record-attribution-widget.js +33 -0
  90. package/src/lib/components/record-attribution-widget.js.map +1 -0
  91. package/src/lib/components/site-runtime.d.ts +9 -0
  92. package/src/lib/components/site-runtime.js +1081 -0
  93. package/src/lib/components/site-runtime.js.map +1 -0
  94. package/src/lib/components/use-campaign-send-api.d.ts +35 -0
  95. package/src/lib/components/use-campaign-send-api.js +99 -0
  96. package/src/lib/components/use-campaign-send-api.js.map +1 -0
  97. package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
  98. package/src/lib/components/use-campaign-topic-options.js +44 -0
  99. package/src/lib/components/use-campaign-topic-options.js.map +1 -0
  100. package/src/lib/components/use-emails-hub-path.d.ts +39 -0
  101. package/src/lib/components/use-emails-hub-path.js +70 -0
  102. package/src/lib/components/use-emails-hub-path.js.map +1 -0
  103. package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
  104. package/src/lib/components/use-marketing-hub-path.js +50 -0
  105. package/src/lib/components/use-marketing-hub-path.js.map +1 -0
  106. package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
  107. package/src/lib/components/use-overlay-copy-editor.js +110 -0
  108. package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
  109. package/src/lib/constants/bundle-common.d.ts +8 -0
  110. package/src/lib/constants/bundle-common.js +9 -0
  111. package/src/lib/constants/bundle-common.js.map +1 -0
  112. package/src/lib/model/campaign-membership-figures.d.ts +109 -0
  113. package/src/lib/model/campaign-membership-figures.js +113 -0
  114. package/src/lib/model/campaign-membership-figures.js.map +1 -0
  115. package/src/lib/model/compile-client-automations.d.ts +65 -0
  116. package/src/lib/model/compile-client-automations.js +95 -0
  117. package/src/lib/model/compile-client-automations.js.map +1 -0
  118. package/src/lib/model/experiments.d.ts +130 -0
  119. package/src/lib/model/experiments.js +186 -0
  120. package/src/lib/model/experiments.js.map +1 -0
  121. package/src/lib/model/index.d.ts +26 -0
  122. package/src/lib/model/index.js +26 -0
  123. package/src/lib/model/index.js.map +1 -0
  124. package/src/lib/model/marketing-record-routes.d.ts +6 -0
  125. package/src/lib/model/marketing-record-routes.js +46 -0
  126. package/src/lib/model/marketing-record-routes.js.map +1 -0
  127. package/src/lib/model/overlay-copy.d.ts +62 -0
  128. package/src/lib/model/overlay-copy.js +78 -0
  129. package/src/lib/model/overlay-copy.js.map +1 -0
  130. package/src/lib/model/overlays.d.ts +113 -0
  131. package/src/lib/model/overlays.js +93 -0
  132. package/src/lib/model/overlays.js.map +1 -0
  133. package/src/lib/model/site-contract.d.ts +100 -0
  134. package/src/lib/model/site-contract.js +23 -0
  135. package/src/lib/model/site-contract.js.map +1 -0
  136. package/src/lib/plugin.d.ts +27 -0
  137. package/src/lib/plugin.js +168 -0
  138. package/src/lib/plugin.js.map +1 -0
  139. package/src/lib/preview-props.d.ts +31 -0
  140. package/src/lib/preview-props.js +54 -0
  141. package/src/lib/preview-props.js.map +1 -0
  142. package/src/lib/server/campaign-manage.d.ts +62 -0
  143. package/src/lib/server/campaign-manage.js +829 -0
  144. package/src/lib/server/campaign-manage.js.map +1 -0
  145. package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
  146. package/src/lib/server/campaign-process-scheduled.js +198 -0
  147. package/src/lib/server/campaign-process-scheduled.js.map +1 -0
  148. package/src/lib/server/campaign-recipients.d.ts +55 -0
  149. package/src/lib/server/campaign-recipients.js +219 -0
  150. package/src/lib/server/campaign-recipients.js.map +1 -0
  151. package/src/lib/server/campaign-send.d.ts +487 -0
  152. package/src/lib/server/campaign-send.js +3157 -0
  153. package/src/lib/server/campaign-send.js.map +1 -0
  154. package/src/lib/server/email-campaign-reach.d.ts +174 -0
  155. package/src/lib/server/email-campaign-reach.js +254 -0
  156. package/src/lib/server/email-campaign-reach.js.map +1 -0
  157. package/src/lib/server/email-events.d.ts +27 -0
  158. package/src/lib/server/email-events.js +714 -0
  159. package/src/lib/server/email-events.js.map +1 -0
  160. package/src/lib/server/get-client-automations.d.ts +60 -0
  161. package/src/lib/server/get-client-automations.js +87 -0
  162. package/src/lib/server/get-client-automations.js.map +1 -0
  163. package/src/lib/server/get-overlays.d.ts +28 -0
  164. package/src/lib/server/get-overlays.js +65 -0
  165. package/src/lib/server/get-overlays.js.map +1 -0
  166. package/src/lib/server/get-screen-experiments.d.ts +42 -0
  167. package/src/lib/server/get-screen-experiments.js +82 -0
  168. package/src/lib/server/get-screen-experiments.js.map +1 -0
  169. package/src/lib/server/lists-materialize.d.ts +18 -0
  170. package/src/lib/server/lists-materialize.js +130 -0
  171. package/src/lib/server/lists-materialize.js.map +1 -0
  172. package/src/lib/server/marketing-figures.d.ts +37 -0
  173. package/src/lib/server/marketing-figures.js +248 -0
  174. package/src/lib/server/marketing-figures.js.map +1 -0
  175. package/src/lib/server/site-page-enricher.d.ts +26 -0
  176. package/src/lib/server/site-page-enricher.js +210 -0
  177. package/src/lib/server/site-page-enricher.js.map +1 -0
  178. package/src/lib/server.d.ts +20 -0
  179. package/src/lib/server.js +152 -0
  180. package/src/lib/server.js.map +1 -0
  181. package/src/lib/site.d.ts +22 -0
  182. package/src/lib/site.js +48 -0
  183. package/src/lib/site.js.map +1 -0
@@ -0,0 +1,3157 @@
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
+ */ import { checkQuota, contactMatchesSegment, isEmailTopicId, DEFAULT_CAMPAIGN_TOPIC_ID, readMarketingBasis, resolveMarketingConsentPolicy, splitByMarketingConsent, createResourceUid, decodeStoredNodes, emailStarterSendBlock, resolveBrandingProfile, visibleToHost } from "@aglyn/aglyn/server";
18
+ import { renderRecipientEmail } from "@aglyn/aglyn/app-utils/recipient-email-render";
19
+ import { assignExperimentVariant } from "../model/index.js";
20
+ import { readPluginRecordCard } from "@aglyn/aglyn/plugin-manager/plugin-record-cards";
21
+ import { hostPublicOrigin } from "@aglyn/aglyn/server";
22
+ import { consentGroupForSite, orgDataCollectionForHost, orgDataQueryForHost, filterSendableForHost, filterTopicSendable, firebaseAdmin, getOrgForHost, meterHostEmail, claimOrgEmailSendBudget, orgCampaignEmailSendsForMonth, readEmailSendRateConfig, readEmailSendRateWindow, reconcileCampaignSendReservation, reserveCampaignEmailSends, resolveHostSendingIdentity } from "@aglyn/tenant-data-admin";
23
+ // The leaf, not the barrel: this plugin's specs substitute the barrel
24
+ // wholesale, and the lookup must reach the real index logic under them.
25
+ import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
26
+ import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
27
+ /*
28
+ * The LEAF module for the impersonation exemption too, and for a sharper
29
+ * version of the reason the notes below give: every spec that reaches this
30
+ * file mocks the `@aglyn/tenant-data-admin` barrel, and a factory that
31
+ * omitted this name would make the verification gate THROW rather than
32
+ * refuse. A control that fails by exception is one nobody can reason about.
33
+ * Resolved from the module itself, the real predicate runs under every
34
+ * harness.
35
+ */ import { isImpersonationSession } from "@aglyn/tenant-data-admin/server/firebase-admin";
36
+ /*
37
+ * The LEAF module, not the barrel, for the reason `document-id` is imported
38
+ * the same way: a barrel import resolves to whatever a spec's `jest.mock` of
39
+ * `@aglyn/tenant-data-admin` happens to contain, and nearly every spec that
40
+ * reaches this file mocks it.
41
+ */ import { buildUnsubscribeUrl, unsubscribeSignature as sharedUnsubscribeSignature } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
42
+ import { filterCadenceSendable, recordMarketingSends } from "@aglyn/tenant-data-admin/server/email-marketing-gate";
43
+ /*
44
+ * The LEAF module again, and for the third reason listed above `document-id`:
45
+ * the specs that reach this file mock the `@aglyn/tenant-data-admin` barrel,
46
+ * and a reach helper resolved through it would be whatever their factory
47
+ * happens to contain. The one function here that may not be wrong — the read
48
+ * that decides who has already had this email — is the one that must come
49
+ * from the real module.
50
+ */ import { CAMPAIGN_REACH_CEILING, campaignReachCovers, campaignSettledSize, partitionByCampaignReach, readCampaignReach, readCampaignSettled, recordCampaignReach, recordCampaignSkipped } from "./email-campaign-reach.js";
51
+ /*
52
+ * The LEAF module for the reputation controls too, and for the same reason
53
+ * as the three above it: every spec that reaches this file mocks the
54
+ * `@aglyn/tenant-data-admin` barrel, so a breaker resolved through it would
55
+ * be whatever a factory happened to contain — which for a control that
56
+ * REFUSES a send means a test could pass against a breaker that is not there.
57
+ * Resolved from the module itself, the real control runs, and it fails open
58
+ * against a harness that cannot serve it.
59
+ */ import { claimOrgEmailSendDay, orgAgeDays, readSenderReputation, reconcileOrgEmailSendDay, recordCampaignAccepted, resolveOrgEmailRamp } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
60
+ import { createHash } from "crypto";
61
+ import { EMAIL_MAX_AUDIENCE_PER_SEND, EMAIL_MAX_RECIPIENTS_PER_SEND, campaignBatchPlan, createProviderRequestPacer, effectiveReputationPolicy, HOST_SENDERS_COLLECTION, isEmailConfigured, rateLimitedRetryAtMs, readHostSender, sendEmail, sendingIdentityRefusal, sentAsStamp } from "@aglyn/shared-util-email";
62
+ /**
63
+ * Recipients one send may address.
64
+ *
65
+ * The number lives in `send-ceilings.ts` with the other two email ceilings
66
+ * rather than here, because it only means anything in relation to them: it has
67
+ * to fit inside a workspace's share of the platform hour, which in turn has to
68
+ * fit inside the platform hour. Held privately here it was a third number
69
+ * nobody could check against the other two.
70
+ */ const MAX_RECIPIENTS_PER_SEND = EMAIL_MAX_RECIPIENTS_PER_SEND;
71
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
72
+ /** How many audience documents one Firestore round trip fetches. */ const AUDIENCE_PAGE_SIZE = 500;
73
+ /**
74
+ * The read budget for resolving one audience — a ceiling on the SCAN, not a
75
+ * silent cap on the answer.
76
+ *
77
+ * It is deliberately the largest window this file already spent (`contacts`
78
+ * and list members read 5,000), so no audience costs more to resolve than it
79
+ * did before; `leads` and `siteMembers` read 1,000 and now share the same
80
+ * number, which is the point — a site with 3,000 leads was told its audience
81
+ * was 1,000.
82
+ *
83
+ * Reaching it does not truncate anything silently. The resolution reports
84
+ * {@link CampaignSendResult.audienceTruncated}, `audienceSize` becomes a
85
+ * floor rather than a total, and the composer and the History row both say so.
86
+ * An audience larger than one send may carry is delivered across several
87
+ * batches; an audience larger than THIS is one the sender cannot resolve at
88
+ * all, and batching does not change that. It is the same number as
89
+ * `CAMPAIGN_REACH_CEILING`, taken from the one place both are stated.
90
+ */ const AUDIENCE_SCAN_CEILING = EMAIL_MAX_AUDIENCE_PER_SEND;
91
+ /**
92
+ * Page a query to exhaustion in document-name order.
93
+ *
94
+ * ## Why the read has to be ordered at all
95
+ *
96
+ * Firestore answers a `limit()` with no `orderBy` in document-id order, and
97
+ * these ids are generated — so the old bare `limit(1000)` / `limit(5000)`
98
+ * picked an arbitrary slice of any audience larger than the window, and the
99
+ * merchant was told that slice was the whole audience. A cursor needs an
100
+ * ordering regardless; this makes the selection explicable ("the first N by
101
+ * document name") and stable across sends instead of merely bounded.
102
+ *
103
+ * ## Why the document NAME and not a date
104
+ *
105
+ * `orderBy(field)` drops every document that lacks that field, so ordering an
106
+ * audience newest-first would silently un-invite people. There is no field
107
+ * every writer of these four collections sets: list members carry `addedAt`
108
+ * only when `enrollListMember` CREATED the row, and the newsletter handler
109
+ * that wrote the collection before it stored `{ email, name, source }` and no
110
+ * date at all — so `orderBy('addedAt')` would drop every newsletter
111
+ * subscriber from every list campaign. `__name__` is the one key every
112
+ * document has, and an unfiltered collection ordered by it needs no index.
113
+ *
114
+ * `startAfter` takes the SNAPSHOT rather than its id, so the cursor keeps
115
+ * working if a filter is ever added ahead of the ordering.
116
+ *
117
+ * @returns the documents, and whether {@link AUDIENCE_SCAN_CEILING} — rather
118
+ * than the end of the collection — is what stopped the sweep. The
119
+ * flag is settled by one extra single-document read, so "more than
120
+ * 5,000" is never claimed of a collection holding exactly 5,000.
121
+ */ async function sweepAudience(base) {
122
+ const ordered = base.orderBy(firebaseAdmin.firestore.FieldPath.documentId());
123
+ const docs = [];
124
+ let cursor;
125
+ for(;;){
126
+ const page = await (cursor ? ordered.startAfter(cursor) : ordered).limit(AUDIENCE_PAGE_SIZE).get();
127
+ docs.push(...page.docs);
128
+ cursor = page.docs[page.docs.length - 1];
129
+ if (!cursor || page.docs.length < AUDIENCE_PAGE_SIZE) {
130
+ return {
131
+ docs,
132
+ truncated: false
133
+ };
134
+ }
135
+ if (docs.length >= AUDIENCE_SCAN_CEILING) {
136
+ const probe = await ordered.startAfter(cursor).limit(1).get();
137
+ return {
138
+ docs,
139
+ truncated: probe.docs.length > 0
140
+ };
141
+ }
142
+ }
143
+ }
144
+ /**
145
+ * Stable doc id for a suppression entry (emails are PII — hash them).
146
+ *
147
+ * A WRITER's derivation now — `email-events.ts` files bounces and complaints
148
+ * under it. The send path reads through `emailSuppressionKey`, which hashes
149
+ * the same trimmed, lowercased form and additionally refuses to guess an id
150
+ * for a value that is not an address.
151
+ */ export function suppressionId(email) {
152
+ return createHash('sha256').update(email.toLowerCase()).digest('hex');
153
+ }
154
+ /** HMAC for unsubscribe links; env-gated on the shared secret. */ export function unsubscribeSignature(hostId, email, secret, campaignId, topicId) {
155
+ /*
156
+ * Delegated to the module that owns the signed subject.
157
+ *
158
+ * The campaign sender mints links, the unsubscribe handler verifies them,
159
+ * and the marketing gate mints them for every other audience path — three
160
+ * parties to one HMAC subject, which stays correct only while there is one
161
+ * implementation of it. The name stays exported because it is this
162
+ * module's published surface.
163
+ */ return sharedUnsubscribeSignature(hostId, email, secret, campaignId, topicId);
164
+ }
165
+ /** Send failures carry the HTTP status the API route should answer. */ export class CampaignSendError extends Error {
166
+ constructor(message, status){
167
+ super(message), this.status = status;
168
+ }
169
+ }
170
+ /**
171
+ * The platform send rate had no room for this campaign THIS HOUR (AGL-2409).
172
+ *
173
+ * A subclass rather than a status code the caller has to recognise, because
174
+ * the difference it carries is not "which HTTP number" — it is **retry, do not
175
+ * fail**. A scheduled campaign that trips the hourly ceiling must go back to
176
+ * `scheduled` and be picked up by the next 15-minute run; marking it `failed`,
177
+ * which is what every other `CampaignSendError` correctly does, would turn a
178
+ * ramp into a lost campaign that a merchant has to notice and re-create.
179
+ *
180
+ * Thrown ONLY before anything has been sent, so a caller that retries cannot
181
+ * double-send.
182
+ */ export class CampaignSendDeferredError extends CampaignSendError {
183
+ constructor(message, /** When the hourly window rolls. */ retryAtMs){
184
+ super(message, 429), this.retryAtMs = retryAtMs;
185
+ }
186
+ }
187
+ /**
188
+ * Loads a designed email template's nodes + referenced products for the
189
+ * render pipeline. Throws 400 when the screen isn't an email document.
190
+ */ async function loadEmailTemplate(hostId, screenId) {
191
+ var _decodeStoredNodes, _ref, _screenSnapshot_get, _screenSnapshot_get1;
192
+ const firestore = firebaseAdmin.app().firestore();
193
+ const screenRef = firestore.collection('hosts').doc(hostId).collection('screens').doc(screenId);
194
+ const screenSnapshot = await screenRef.get();
195
+ if (!screenSnapshot.exists) {
196
+ throw new CampaignSendError('Unknown email template', 400);
197
+ }
198
+ const versionId = screenSnapshot.get('versionId');
199
+ const versionSnapshot = versionId ? await screenRef.collection('versions').doc(String(versionId)).get() : null;
200
+ /**
201
+ * Decoded, because a `kind: 'email'` screen is a SCREEN document and its
202
+ * versions are compressed msgpack `Bytes` from the first designer save
203
+ * onward (AGL-1394). `createEmailScreen` writes it under
204
+ * `hosts/{h}/screens/{id}` and the Emails list opens it in the SCREEN
205
+ * besigner, which saves through `use-screen-version`'s converter —
206
+ * `Bytes.fromUint8Array(compress(nodes))`. Only the very first version,
207
+ * seeded from a JSON body through `/api/hosts/versions`, is a plain map.
208
+ *
209
+ * `publish-email-template.ts` reading `nodes` raw is not evidence that this
210
+ * one may: that reads `emailTemplates`, a different collection whose
211
+ * besigner saves with a bare `setDoc` and no converter.
212
+ *
213
+ * The guard below is why this was silent rather than loud. `Object.keys` of
214
+ * a Buffer yields the byte INDICES, so a compressed version read as a
215
+ * populated template, `Object.values` found no `emailProduct` node, and a
216
+ * designed campaign went out to real customer inboxes with its product
217
+ * blocks missing — discovered by the recipients. Decoding first is also what
218
+ * makes the guard mean something: `decodeStoredNodes` returns null for an
219
+ * undecodable payload, so the send is refused instead of mailed empty.
220
+ */ const nodes = (_decodeStoredNodes = decodeStoredNodes(versionSnapshot == null ? void 0 : versionSnapshot.get('nodes'))) != null ? _decodeStoredNodes : {};
221
+ if (!Object.keys(nodes).length) {
222
+ throw new CampaignSendError('The email template is empty', 400);
223
+ }
224
+ /**
225
+ * The marketplace kill switch, reaching an email somebody already installed
226
+ * (AGL-657's copy-on-install is what makes this necessary).
227
+ *
228
+ * An installed starter is a copy in this site's own screens, so every other
229
+ * marketplace lever — unpublish, takedown, a rejected version — stops at the
230
+ * storefront and is felt by nobody who already has the design. The kill is
231
+ * the one that has to reach a tenant who installed last week and is sending
232
+ * today, and this is the only chokepoint every campaign passes through.
233
+ *
234
+ * It refuses the SEND and leaves the document alone. The tenant keeps the
235
+ * design, keeps editing it, keeps previewing it; what they cannot do is put
236
+ * it on the sending domain every other tenant shares. Reaching into somebody
237
+ * else's content to enforce a decision about a third party's artifact would
238
+ * take the wrong thing away.
239
+ *
240
+ * Read off the VERSION first, then the screen: the version is the document
241
+ * these bytes came out of, and a screen whose design was later replaced
242
+ * wholesale should be judged on what it is now. Costs one document read on a
243
+ * design that carries no marketplace provenance at all, which is every email
244
+ * a site wrote itself — the `listingId` guard inside `emailStarterSendBlock`
245
+ * is what keeps that read from happening.
246
+ */ const installedFrom = (_ref = versionSnapshot == null ? void 0 : versionSnapshot.get('installedFrom')) != null ? _ref : screenSnapshot.get('installedFrom');
247
+ if (installedFrom == null ? void 0 : installedFrom.listingId) {
248
+ const revocation = (await firestore.collection('revocations').doc(installedFrom.listingId).get()).data();
249
+ const block = emailStarterSendBlock({
250
+ installedFrom,
251
+ revocation
252
+ });
253
+ if (block) throw new CampaignSendError(block.reason, 409);
254
+ }
255
+ // Resolve emailProduct references (by id — rename-safe, AGL-343).
256
+ const productIds = [
257
+ ...new Set(Object.values(nodes).filter((node)=>(node == null ? void 0 : node.componentId) === 'emailProduct').map((node)=>{
258
+ var _ref;
259
+ var _node_props;
260
+ return String((_ref = node == null ? void 0 : (_node_props = node.props) == null ? void 0 : _node_props.productId) != null ? _ref : '');
261
+ })// AGL-1771: a besigner node prop is merchant-authored and reaches
262
+ // `.doc()` below, where a slash-bearing value throws and turns the
263
+ // whole send into a 500. Dropped rather than refused: the block simply
264
+ // resolves to no product, exactly as it does for a deleted one, and a
265
+ // designed campaign is not worth blocking over one bad reference.
266
+ .filter(isDocumentId))
267
+ ].slice(0, 20);
268
+ const products = {};
269
+ await Promise.all(productIds.map(async (productId)=>{
270
+ // Asked of whichever plugin keeps products: its name, its "from" price
271
+ // as that plugin words it, its image and where it is read. A site with
272
+ // no such plugin, and a product since deleted, both resolve to no card —
273
+ // the block draws nothing, as it always has for a missing product.
274
+ const card = await readPluginRecordCard('product', {
275
+ hostId,
276
+ id: productId
277
+ });
278
+ if (!card) return;
279
+ products[productId] = {
280
+ name: card.title,
281
+ priceLabel: card.caption,
282
+ imageUrl: card.imageUrl,
283
+ url: card.path
284
+ };
285
+ }));
286
+ return {
287
+ nodes,
288
+ products,
289
+ subject: String((_screenSnapshot_get = screenSnapshot.get('emailSubject')) != null ? _screenSnapshot_get : ''),
290
+ preheader: String((_screenSnapshot_get1 = screenSnapshot.get('emailPreheader')) != null ? _screenSnapshot_get1 : '')
291
+ };
292
+ }
293
+ export async function performCampaignSend(options) {
294
+ var _ref, _options_senderId, _options_proofFor, _hostPublicOrigin, _options_experimentId;
295
+ var _orgForHost_org, _orgForHost_org1, _consent_get;
296
+ const unsubscribeSecret = process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.CRON_SECRET;
297
+ if (!isEmailConfigured() || !unsubscribeSecret) {
298
+ throw new CampaignSendError('Campaigns are not configured (RESEND_API_KEY, USAGE_EMAIL_FROM, ' + 'EMAIL_UNSUBSCRIBE_SECRET).', 501);
299
+ }
300
+ const { hostId, subject, body, audience } = options;
301
+ // AGL-1771: every optional id on `options` becomes a `.doc()` argument
302
+ // below, and `.doc()` appends a SLASH-SEPARATED path rather than taking one
303
+ // opaque id — so an unvalidated one names the nesting as well as the
304
+ // document. `campaignId` is the one that matters most: it is WRITTEN at the
305
+ // bottom of this function, so `a/b/c` filed the campaign at
306
+ // `campaigns/a/b/c`, beneath a document that does not exist and therefore
307
+ // invisible to the merchant's own campaigns list — and it is the same value
308
+ // that comes back on every Resend tag days later (AGL-1768), which is why
309
+ // tracing where an id was MINTED matters more than where it was last
310
+ // handled. The rest are read-only, where the cost is a 500 dressed up as an
311
+ // outage rather than a stray document; refused here so the caller is told
312
+ // which id was wrong.
313
+ //
314
+ // `hostId` is deliberately NOT guarded here, and that is measured rather
315
+ // than overlooked: both callers prove it first — the handler resolves the
316
+ // host document and checks the caller's role on it before calling in, and
317
+ // the scheduled processor passes `hostRef.id` off a document it just read. A
318
+ // guard here could not fail today. A third caller would need to earn that.
319
+ for (const [name, value] of [
320
+ [
321
+ 'campaignId',
322
+ options.campaignId
323
+ ],
324
+ [
325
+ 'experimentId',
326
+ options.experimentId
327
+ ],
328
+ [
329
+ 'templateScreenId',
330
+ options.templateScreenId
331
+ ],
332
+ [
333
+ 'segmentId',
334
+ options.segmentId
335
+ ],
336
+ [
337
+ 'listId',
338
+ options.listId
339
+ ]
340
+ ]){
341
+ if (value && !isDocumentId(value)) {
342
+ throw new CampaignSendError(`Invalid ${name}`, 400);
343
+ }
344
+ }
345
+ /*
346
+ * `topicId` is checked against its OWN predicate, not `isDocumentId`.
347
+ *
348
+ * It is a path component like the others, but it is also a colon-joined
349
+ * component of the unsubscribe link's signed subject, and `isDocumentId`
350
+ * permits a colon. Signing one would let a single subject string be read as
351
+ * two different parameter tuples — see `signedSubject` in the email plugin's
352
+ * `unsubscribe-link.ts`. Refused at the point the topic ENTERS the send, so
353
+ * the link that leaves it is unambiguous by construction.
354
+ */ if (options.topicId && !isEmailTopicId(options.topicId)) {
355
+ throw new CampaignSendError('Invalid topicId', 400);
356
+ }
357
+ const topicId = options.topicId || DEFAULT_CAMPAIGN_TOPIC_ID;
358
+ /**
359
+ * Whether this send ADDS to an email that already exists, rather than
360
+ * starting one.
361
+ *
362
+ * True for a merchant's follow-up and for an automatic batch. The two
363
+ * differ in what they subtract and in what they record — see
364
+ * `CampaignSendOptions.continuation` — but they agree on the one thing this
365
+ * flag decides: every counter on the campaign is an increment rather than a
366
+ * replacement, so no rate can come out over a denominator that counts less
367
+ * mail than its numerator.
368
+ */ const addsToExistingSend = Boolean(options.followUp || options.continuation);
369
+ const firestore = firebaseAdmin.app().firestore();
370
+ const hostRef = firestore.collection('hosts').doc(hostId);
371
+ const hostSnapshot = await hostRef.get();
372
+ if (!hostSnapshot.exists) {
373
+ throw new CampaignSendError('Unknown site', 404);
374
+ }
375
+ /*==========================================
376
+ * THE FOLLOW-UP'S ADMISSION CHECKS.
377
+ *
378
+ * Here rather than in the route, and re-run even though the route has
379
+ * already loaded the same document to build these options: the properties
380
+ * below are the ones that decide whether somebody gets a second copy of an
381
+ * email, and a check a caller performs is a check the next caller forgets.
382
+ * Two reads of one small document on a deliberate button press is what that
383
+ * costs.
384
+ *=========================================*/ let reachedKeys = null;
385
+ /**
386
+ * Batches this email has already run, so the plan below can tell the second
387
+ * from the twentieth. Zero on every first send.
388
+ */ let batchesSoFar = 0;
389
+ /*==========================================
390
+ * THE NEXT BATCH'S ADMISSION CHECKS.
391
+ *
392
+ * The same two properties the follow-up checks below, read off the same
393
+ * record and refused for the same reasons — with `skipped` folded into the
394
+ * subtraction, which is the whole difference between the two. See
395
+ * `CampaignSendOptions.continuation`.
396
+ *=========================================*/ if (options.continuation) {
397
+ var _sendSnapshot_get, _ref1, _ref2;
398
+ var _sendSnapshot_get1, _sendSnapshot_get2;
399
+ if (!options.campaignId) {
400
+ throw new CampaignSendError('A batch has to name the email it is continuing', 400);
401
+ }
402
+ const sendSnapshot = await hostRef.collection('campaigns').doc(options.campaignId).get();
403
+ if (!sendSnapshot.exists) {
404
+ throw new CampaignSendError('Unknown email', 404);
405
+ }
406
+ /*
407
+ * A batch continues an email the processor has CLAIMED. `sending` is what
408
+ * that claim writes; `scheduled` is accepted beside it so a campaign that
409
+ * was written back by a deferral and is picked up again is not refused by
410
+ * its own retry.
411
+ */ const status = String((_sendSnapshot_get = sendSnapshot.get('status')) != null ? _sendSnapshot_get : '');
412
+ if (status !== 'sending' && status !== 'scheduled') {
413
+ throw new CampaignSendError('This email is not in the middle of being sent', 400);
414
+ }
415
+ /*
416
+ * FAILS CLOSED, exactly as the follow-up does. The record of who this
417
+ * email has already mailed is the only thing standing between a resumed
418
+ * send and a second copy in somebody's inbox, and a batch that cannot
419
+ * read it must not run.
420
+ */ const settled = await readCampaignSettled(hostId, options.campaignId, firestore);
421
+ const sentSoFar = Number((_ref1 = (_sendSnapshot_get1 = sendSnapshot.get('stats')) == null ? void 0 : _sendSnapshot_get1.sent) != null ? _ref1 : 0);
422
+ if (!campaignReachCovers(settled.reached, sentSoFar)) {
423
+ throw new CampaignSendError('This email does not have a complete record of who it reached, so ' + 'the rest of it cannot be sent without risking a second copy for ' + 'somebody who already has it.', 409);
424
+ }
425
+ if (campaignSettledSize(settled) >= CAMPAIGN_REACH_CEILING) {
426
+ throw new CampaignSendError(`This email has already addressed ${CAMPAIGN_REACH_CEILING.toLocaleString()} ` + 'people, which is the most one email may reach. Compose a new ' + 'email for the rest of the audience.', 409);
427
+ }
428
+ reachedKeys = new Set([
429
+ ...settled.reached,
430
+ ...settled.skipped
431
+ ]);
432
+ batchesSoFar = Math.max(0, Math.floor(Number((_ref2 = (_sendSnapshot_get2 = sendSnapshot.get('resume')) == null ? void 0 : _sendSnapshot_get2.batch) != null ? _ref2 : 0)) || 0);
433
+ }
434
+ /**
435
+ * A batch that found nothing left to do: the email is FINISHED, not broken.
436
+ *
437
+ * Every "there is nobody to send to" refusal below is a 400 that tells a
438
+ * merchant their audience is empty — which is the right answer to a send
439
+ * they just pressed, and the wrong one to a batch of an email that has
440
+ * already delivered two thousand messages. The processor would mark it
441
+ * `failed`, and a campaign that reached most of its list would be filed
442
+ * under the same word as one that never left.
443
+ *
444
+ * So a continuation closes the email out instead, with the counters the
445
+ * earlier batches wrote left exactly as they are.
446
+ */ const finishContinuation = async ()=>{
447
+ var _options_campaignId;
448
+ const sendId = String((_options_campaignId = options.campaignId) != null ? _options_campaignId : '');
449
+ await hostRef.collection('campaigns').doc(sendId).set({
450
+ status: 'sent',
451
+ resume: {
452
+ remaining: 0,
453
+ batch: batchesSoFar + 1,
454
+ nextAtMs: 0
455
+ },
456
+ lastSentAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
457
+ }, {
458
+ merge: true
459
+ });
460
+ return {
461
+ campaignId: sendId,
462
+ recipients: 0,
463
+ audienceSize: 0,
464
+ sent: 0,
465
+ remaining: 0,
466
+ resuming: false,
467
+ batch: batchesSoFar + 1
468
+ };
469
+ };
470
+ if (options.followUp) {
471
+ var _ref3;
472
+ var _sendSnapshot_get3;
473
+ if (!options.campaignId) {
474
+ throw new CampaignSendError('A follow-up has to name the email it is adding to', 400);
475
+ }
476
+ const sendSnapshot = await hostRef.collection('campaigns').doc(options.campaignId).get();
477
+ if (!sendSnapshot.exists) {
478
+ throw new CampaignSendError('Unknown email', 404);
479
+ }
480
+ /*
481
+ * Only a SENT email has anybody to add to. A scheduled one has not gone
482
+ * out — sending it now is what the scheduler is for, and doing it here
483
+ * would deliver it twice; a canceled one was withdrawn on purpose.
484
+ */ if (sendSnapshot.get('status') !== 'sent') {
485
+ throw new CampaignSendError('Only an email that has already been sent can go to more people', 400);
486
+ }
487
+ /*
488
+ * FAILS CLOSED. `readCampaignReach` throws rather than answering
489
+ * "nobody", and that throw is deliberately not caught: the whole feature
490
+ * rests on being able to name who already has this email, and a send that
491
+ * cannot is a send that must not happen.
492
+ */ const keys = await readCampaignReach(hostId, options.campaignId, firestore);
493
+ const sentSoFar = Number((_ref3 = (_sendSnapshot_get3 = sendSnapshot.get('stats')) == null ? void 0 : _sendSnapshot_get3.sent) != null ? _ref3 : 0);
494
+ if (!campaignReachCovers(keys, sentSoFar)) {
495
+ throw new CampaignSendError('This email does not have a complete record of who it reached, so ' + 'it cannot be sent to more people without risking a second copy ' + 'for somebody who already has it. Compose a new email to the ' + 'people you want to add.', 409);
496
+ }
497
+ if (keys.size >= CAMPAIGN_REACH_CEILING) {
498
+ throw new CampaignSendError(`This email has already reached ${keys.size.toLocaleString()} ` + `people, which is the most one email may reach ` + `(${CAMPAIGN_REACH_CEILING.toLocaleString()}). Compose a new email ` + 'for the rest of the audience.', 409);
499
+ }
500
+ reachedKeys = keys;
501
+ }
502
+ /*
503
+ Audience resolution. Names ride along for merge tags (AGL-272).
504
+
505
+ ⚠️ THE FIELD NAME IS PER COLLECTION AND THEY DO NOT AGREE (AGL-2303).
506
+ `contacts` and `leads` store `name`; `siteMembers` stores `displayName`.
507
+ A merge tag whose source field does not exist does not error — it
508
+ substitutes an empty string into mail that has already been sent. Whenever
509
+ an audience is added here, check what its collection actually writes.
510
+ */ let recipients;
511
+ /**
512
+ * Set when {@link AUDIENCE_SCAN_CEILING} stopped the resolution, so
513
+ * `audience` below is a floor and every number derived from it says so.
514
+ * A `manual` audience arrives whole in the request and can never be one.
515
+ */ let audienceTruncated = false;
516
+ /**
517
+ * The list's name as it stands at the moment of the send.
518
+ *
519
+ * Recorded on the campaign rather than looked up when a report is read, for
520
+ * the reason every other send-time figure is recorded: a list can be
521
+ * renamed or deleted, and resolving the name later either rewrites the
522
+ * history of a campaign that went out months ago or loses it entirely. The
523
+ * campaign went to the list under this name, and that stays true.
524
+ */ let listName = '';
525
+ const names = new Map();
526
+ /*
527
+ * The consent facts ride out of the audience sweep with the names
528
+ * (`docs/specs/email-overhaul.md` §3f), because paging the audience is the
529
+ * only point in the send where the person's DOCUMENT is in hand.
530
+ *
531
+ * That placement is the correction §3f carries: consent is a property of
532
+ * the PERSON, knowable while the sweep is already reading them, where
533
+ * suppression is a per-address keyed lookup deliberately deferred until
534
+ * after the cap. Reading consent back later would be a second pass over
535
+ * every silo, per campaign, to recover data this loop already had.
536
+ *
537
+ * An audience whose members carry no consent field records an `unrecorded`
538
+ * basis — a THIRD state, handled by the policy, and never a quiet `true`.
539
+ */ /*
540
+ * THE CONTROLLER THIS SEND IS MADE BY — the declared group of sites that
541
+ * are one sender, or this site alone.
542
+ *
543
+ * Resolved before the sweep because every consent read below is about a
544
+ * controller and not about a site: three sites a business declared as one
545
+ * sender share a basis, and twelve unrelated client brands in an agency's
546
+ * account share nothing. The org read is deduped per request, so the policy
547
+ * lookup further down pays nothing for this.
548
+ */ const consentGroup = await consentGroupForSite(hostId);
549
+ const consent = new Map();
550
+ const collectConsent = (email, data)=>{
551
+ const cleaned = email.trim().toLowerCase();
552
+ if (!cleaned) return;
553
+ consent.set(cleaned, readMarketingBasis(data, consentGroup));
554
+ };
555
+ const collectName = (email, name)=>{
556
+ const cleaned = email.trim().toLowerCase();
557
+ if (cleaned && typeof name === 'string' && name.trim()) {
558
+ names.set(cleaned, name.trim());
559
+ }
560
+ };
561
+ if (audience === 'leads') {
562
+ const leads = await sweepAudience(hostRef.collection('leads'));
563
+ audienceTruncated = leads.truncated;
564
+ recipients = leads.docs.map((doc)=>{
565
+ var _doc_get;
566
+ const email = String((_doc_get = doc.get('email')) != null ? _doc_get : '');
567
+ collectName(email, doc.get('name'));
568
+ collectConsent(email, doc.data());
569
+ return email;
570
+ });
571
+ } else if (audience === 'members') {
572
+ const members = await sweepAudience(hostRef.collection('siteMembers'));
573
+ audienceTruncated = members.truncated;
574
+ recipients = members.docs.map((doc)=>{
575
+ var _doc_get, _doc_get1;
576
+ const email = String((_doc_get = doc.get('email')) != null ? _doc_get : '');
577
+ /*==========================================
578
+ * `displayName`, NOT `name` (AGL-2303).
579
+ *
580
+ * `siteMembers` has never had a `name` field — sign-up, the account
581
+ * page and the admin password route all write `displayName`. So this
582
+ * read matched nothing on every member campaign ever sent, `names` was
583
+ * empty for the whole audience, and `{{contact.name}}` and
584
+ * `{{contact.firstName}}` rendered as EMPTY STRINGS in mail that went
585
+ * out to real people. `resolveMergeTags` substitutes rather than
586
+ * failing, so nothing errored and nothing looked wrong here.
587
+ *
588
+ * `name` is kept as a fallback and read second: a lead promoted to a
589
+ * member, or a future writer, may carry either.
590
+ *=========================================*/ collectName(email, (_doc_get1 = doc.get('displayName')) != null ? _doc_get1 : doc.get('name'));
591
+ collectConsent(email, doc.data());
592
+ return email;
593
+ });
594
+ } else if (audience === 'segment') {
595
+ var _options_segmentId, _segmentSnapshot_get, _segmentSnapshot_get1;
596
+ // Contact segments (AGL-199): resolve the saved filter against the
597
+ // contacts collection server-side.
598
+ const segmentId = String((_options_segmentId = options.segmentId) != null ? _options_segmentId : '');
599
+ const segmentSnapshot = segmentId ? await (await orgDataCollectionForHost(hostId, 'contactSegments')).doc(segmentId).get() : null;
600
+ // A doc get cannot carry the scope filter, so check after the read
601
+ // (AGL-1039). Reported as "unknown" rather than "forbidden": whether a
602
+ // segment exists in another site's scope is not this caller's business.
603
+ if (!(segmentSnapshot == null ? void 0 : segmentSnapshot.exists) || !visibleToHost(segmentSnapshot.get('visibleTo'), hostId)) {
604
+ throw new CampaignSendError('Unknown segment', 400);
605
+ }
606
+ const segment = {
607
+ tags: (_segmentSnapshot_get = segmentSnapshot.get('tags')) != null ? _segmentSnapshot_get : [],
608
+ sources: (_segmentSnapshot_get1 = segmentSnapshot.get('sources')) != null ? _segmentSnapshot_get1 : []
609
+ };
610
+ // Scoped (AGL-1039): a campaign sent from one site must not reach
611
+ // another site's audience — the agency case is a client's campaign
612
+ // blasting the whole org's contact list.
613
+ /*
614
+ * The only audience whose sweep carries a FILTER, and the reason the
615
+ * ordering is `__name__` rather than a field: Firestore's automatic
616
+ * single-field index for an array member is keyed on that value and the
617
+ * document name, so `array-contains-any` plus `orderBy(__name__)` is
618
+ * served by it. Ordering on any other field would need a composite index
619
+ * per audience — `cloud/firebase-firestore.indexes.json` carries exactly
620
+ * that shape for `media`, and a missing one fails the whole send.
621
+ */ const contacts = await sweepAudience((await orgDataQueryForHost(hostId, 'contacts')).query);
622
+ /*
623
+ * A segment's membership is decided HERE rather than by the query — the
624
+ * tag and source rules are evaluated in `contactMatchesSegment` against
625
+ * documents the scan already fetched — so the ceiling bounds CONTACTS
626
+ * READ, not contacts matched. A narrow segment over a large org therefore
627
+ * reports a small `audienceSize` with `audienceTruncated` set, reading as
628
+ * "at least this many, we stopped counting" and not as a total.
629
+ */ audienceTruncated = contacts.truncated;
630
+ recipients = contacts.docs.filter((doc)=>{
631
+ var _doc_get, _doc_get1;
632
+ return contactMatchesSegment({
633
+ tags: (_doc_get = doc.get('tags')) != null ? _doc_get : [],
634
+ sources: (_doc_get1 = doc.get('sources')) != null ? _doc_get1 : {}
635
+ }, segment);
636
+ }).map((doc)=>{
637
+ var _doc_get;
638
+ const email = String((_doc_get = doc.get('email')) != null ? _doc_get : '');
639
+ collectName(email, doc.get('name'));
640
+ collectConsent(email, doc.data());
641
+ return email;
642
+ });
643
+ } else if (audience === 'list') {
644
+ var _options_listId, _get;
645
+ var _parent;
646
+ // Org lists (AGL-254): static audiences enrolled manually or by the
647
+ // enrollList automation step.
648
+ const listId = String((_options_listId = options.listId) != null ? _options_listId : '');
649
+ const listRef = listId ? (_parent = (await orgDataCollectionForHost(hostId, 'contacts')).parent) == null ? void 0 : _parent.collection('lists').doc(listId) : null;
650
+ if (!listRef) throw new CampaignSendError('Unknown list', 400);
651
+ // One document beside a sweep that reads up to the audience ceiling, and
652
+ // the only place the name is knowable without a second round trip later.
653
+ listName = String((_get = (await listRef.get()).get('name')) != null ? _get : '');
654
+ const members = await sweepAudience(listRef.collection('members'));
655
+ audienceTruncated = members.truncated;
656
+ recipients = members.docs.map((doc)=>{
657
+ var _doc_get;
658
+ const email = String((_doc_get = doc.get('email')) != null ? _doc_get : '');
659
+ collectName(email, doc.get('name'));
660
+ collectConsent(email, doc.data());
661
+ return email;
662
+ });
663
+ } else {
664
+ recipients = Array.isArray(options.emails) ? options.emails.map((value)=>String(value)) : [];
665
+ }
666
+ /*
667
+ * The AUDIENCE — deduplicated and validated, and deliberately measured
668
+ * BEFORE the per-send cap.
669
+ *
670
+ * The cap and the audience are two different numbers and the composer has
671
+ * always shown only the smaller one, which is how a site with 3,000 leads
672
+ * was told its audience was 500 and never found out the other 2,500 were
673
+ * not being mailed. Every result from here down carries both, so a send can
674
+ * report "reached N of M" rather than reporting N as if it were M.
675
+ */ const addressable = [
676
+ ...new Set(recipients.map((email)=>email.trim().toLowerCase()).filter((email)=>EMAIL_PATTERN.test(email)))
677
+ ];
678
+ if (!addressable.length) {
679
+ if (options.continuation) return finishContinuation();
680
+ throw new CampaignSendError('The audience is empty', 400);
681
+ }
682
+ /*==========================================
683
+ * THE SUBTRACTION: NOBODY GETS THIS EMAIL TWICE.
684
+ *
685
+ * ## Why it is HERE, above the per-send cap
686
+ *
687
+ * Not a preference — the only position that works. The cap a few lines
688
+ * below takes "the FIRST N of a stable order", and that stability is what
689
+ * makes it defensible: two sends of an unchanged audience address the same
690
+ * people. Which means a follow-up that subtracted AFTER the cap would be
691
+ * handed the same first 500 addresses the original send took, discover that
692
+ * all 500 have had the email, and mail nobody — every time, forever, for
693
+ * any audience larger than one send.
694
+ *
695
+ * So the subtraction runs on the whole resolved audience and the cap then
696
+ * takes the first N of what is LEFT, which is the same rule pointed at the
697
+ * remainder.
698
+ *
699
+ * ## What it costs, and why that is the right trade
700
+ *
701
+ * Nothing per address: the reach record is one document, read once above,
702
+ * and the test is a hash and a set lookup. This is the one filter in the
703
+ * send that can afford to run over the whole audience rather than the
704
+ * capped list, which is why suppression stays where it is.
705
+ *
706
+ * ## What `resolved` means from here down
707
+ *
708
+ * The people this send may address — so on a follow-up it is the NEW part
709
+ * of the audience, and every figure derived from it (the consent split,
710
+ * `audienceSize`) describes that part. The write at the bottom adds those
711
+ * figures to the ones already recorded, so the totals on the email cover
712
+ * both sends over two populations that cannot overlap.
713
+ *=========================================*/ const partitioned = reachedKeys ? partitionByCampaignReach(addressable, reachedKeys) : {
714
+ unreached: addressable,
715
+ alreadyReached: 0
716
+ };
717
+ const alreadyReached = partitioned.alreadyReached;
718
+ const resolved = partitioned.unreached;
719
+ if (!resolved.length) {
720
+ if (options.continuation) return finishContinuation();
721
+ throw new CampaignSendError('Everyone in this audience has already had this email, so nothing has ' + 'been sent.', 400);
722
+ }
723
+ /*
724
+ * THE CONSENT JOIN (`docs/specs/email-overhaul.md` §3f).
725
+ *
726
+ * `marketingConsent` had seven writers and no reader on any send path, so a
727
+ * recorded opt-OUT reached the same inbox as a recorded opt-in.
728
+ *
729
+ * ## Why HERE, above the cap, and not beside suppression
730
+ *
731
+ * §3f used to say "after suppression and before the cap", which is not an
732
+ * order this file has ever had — the cap runs first and suppression after
733
+ * it. Consent belongs at the SWEEP: it is a property of the person, already
734
+ * read into `consent` by the loop above at no extra cost, so filtering on
735
+ * it before the cap means the 500 slots go to people who may actually be
736
+ * mailed. Suppression stays where it is, because it is a keyed lookup per
737
+ * address and moving it up would cost the whole audience in reads on every
738
+ * debounced preview.
739
+ *
740
+ * It is also, necessarily, before the meter claim: a recipient the rule
741
+ * withholds is never counted against the org's monthly allowance. Being
742
+ * charged for mail that policy forbids sending would make the consent rule
743
+ * cost the merchant money as well as reach.
744
+ *
745
+ * ## What it does to an audience that exists today
746
+ *
747
+ * NOT retroactive by default. `resolveMarketingConsentPolicy` answers
748
+ * `mode: 'forward'` for an org that has configured nothing, which keeps
749
+ * every address captured before the cutoff reachable and reports it as
750
+ * grandfathered rather than mailing it silently. The one thing enforced
751
+ * unconditionally is a STORED refusal, which no policy may mail.
752
+ *
753
+ * The retroactive mode can shrink an audience sharply, so it is a stored
754
+ * per-org setting and never a default. The split below is what makes that
755
+ * decision informed — it rides the same readout as `audienceSize`, so a
756
+ * merchant sees which population is which before sending.
757
+ *
758
+ * The org is resolved here rather than at the quota block below because the
759
+ * policy lives on it and this is the first thing that needs it; the quota
760
+ * lines further down reuse the same read rather than taking a second one.
761
+ */ const orgForHost = await getOrgForHost(hostId).catch(()=>null);
762
+ const orgId = String((_ref = orgForHost == null ? void 0 : orgForHost.orgId) != null ? _ref : '');
763
+ /*
764
+ * THE SENDING IDENTITY, and the refusal when it is not usable.
765
+ *
766
+ * Resolved ABOVE the dry run on purpose. `preview` is where a merchant finds
767
+ * out what a send will do before writing copy, so it must answer the same
768
+ * question a real send would — both which identity the mail leaves on, and
769
+ * whether it may leave at all. Resolving after this point would let
770
+ * `preview` report a healthy dry run for a campaign that Send then refuses.
771
+ *
772
+ * The address comes from the org document by way of the host's selection,
773
+ * never from `options`. A `From:` assembled from request input is the
774
+ * spoofing path the verified-identity rule exists to close.
775
+ *
776
+ * A refusal is a 409 rather than a silent no-op because that is the whole
777
+ * point: `USAGE_EMAIL_FROM` was empty in production for weeks and no surface
778
+ * ever said so, since every sender treats mail as best-effort. A tenant
779
+ * whose DNS is unfinished has to be told, by name, at the composer.
780
+ */ /*
781
+ * The DOMAIN is the site's standing selection, and nothing a request says
782
+ * moves it.
783
+ *
784
+ * No option reaches it. It is read from the host document, so a request
785
+ * cannot name a domain to send as — which is the spoofing path — and cannot
786
+ * drop the selection to reach the shared platform domain either, which is
787
+ * the reputation path. Both used to be one field.
788
+ *
789
+ * The MAILBOX in front of it is chosen per send, and by id: `senderId` names
790
+ * a row in `hosts/{hostId}/senders` that an org admin configured, so it can
791
+ * only reach an address this site was already set up to send as. That is the
792
+ * whole difference between it and the free `sendingIdentity` the route reads
793
+ * from nobody.
794
+ */ /*
795
+ * WHICH SENDER, and the refusal when the campaign names one this site does
796
+ * not hold.
797
+ *
798
+ * The one line that decides the mailbox a campaign leaves on. A site's
799
+ * senders are `hosts/{hostId}/senders/{senderId}`, and the host's
800
+ * `sendingLocalPart` is the DEFAULT sender's projection — so a send that
801
+ * names nobody resolves exactly as it did before the collection existed,
802
+ * including on a site that has never written to it.
803
+ *
804
+ * An unknown id is REFUSED rather than defaulted, and that is the whole
805
+ * reason this is a read and not a `??`. Quietly sending as the default is
806
+ * the same class of failure as the mailbox validation that used to answer
807
+ * `hello` to a name it could not parse: a merchant is told their campaign
808
+ * went out as the sender they picked, and it did not.
809
+ *
810
+ * Refused HERE, above the dry run, so `preview` answers it too — the
811
+ * composer finds out at the picker rather than from the Send button.
812
+ */ const senderId = String((_options_senderId = options.senderId) != null ? _options_senderId : '').trim();
813
+ const senderSnapshot = senderId ? await hostRef.collection(HOST_SENDERS_COLLECTION).doc(senderId).get() : null;
814
+ if (senderId && !(senderSnapshot == null ? void 0 : senderSnapshot.exists)) {
815
+ throw new CampaignSendError('The sender this email is set to go out as is no longer one this site ' + 'holds. Pick a sender in the composer, or add it back under ' + 'Emails → Sending.', 404);
816
+ }
817
+ const chosenSender = (senderSnapshot == null ? void 0 : senderSnapshot.exists) ? readHostSender({
818
+ id: senderId,
819
+ data: senderSnapshot.data()
820
+ }) : null;
821
+ /*
822
+ * `purpose: 'marketing'` is what makes the SUMMARY name the arrangement. A
823
+ * campaign is the one send site that knows for certain what it is carrying,
824
+ * so it says so, and a site on the pool is told in the composer that its
825
+ * reputation is shared and graded more tightly for it.
826
+ */ const sendingIdentity = await resolveHostSendingIdentity({
827
+ orgId,
828
+ hostId,
829
+ selectedDomain: hostSnapshot.get('sendingDomain'),
830
+ selectedLocalPart: (chosenSender == null ? void 0 : chosenSender.localPart) || hostSnapshot.get('sendingLocalPart'),
831
+ poolMember: hostSnapshot.get('sendingPoolMember'),
832
+ purpose: 'marketing'
833
+ });
834
+ const identityRefusal = sendingIdentityRefusal(sendingIdentity);
835
+ if (identityRefusal) {
836
+ var _identityRefusal_missing;
837
+ const missing = ((_identityRefusal_missing = identityRefusal.missing) == null ? void 0 : _identityRefusal_missing.length) ? ` Missing: ${identityRefusal.missing.join(', ')}.` : '';
838
+ throw new CampaignSendError(`${identityRefusal.message}${missing}`, 409);
839
+ }
840
+ /**
841
+ * WHOSE REPUTATION THIS CAMPAIGN SPENDS.
842
+ *
843
+ * A pooled sender shares one domain with every other site that has no domain
844
+ * of its own, so a complaint it earns is charged to their receipts as much as
845
+ * to its own. That asymmetry is the whole reason the pool used to refuse
846
+ * marketing outright, and it is answered by grading rather than by
847
+ * prohibition: on the pool a campaign is held to `strict`, which stops it on
848
+ * the WATCH thresholds — Google's "keep under" 0.10% complaint rate and a 5%
849
+ * bounce rate — instead of waiting for the trip levels three and two times
850
+ * higher.
851
+ *
852
+ * On a domain the merchant owns, the org's own setting stands. There the
853
+ * reputation being spent is theirs alone, and how fast they spend it is
854
+ * theirs to decide.
855
+ *
856
+ * It overrides `none` as well, which is the point rather than an oversight:
857
+ * a workspace that has switched its own breaker off must not thereby switch
858
+ * off the one protecting the other sites on its pool member. Same posture as
859
+ * the platform frequency ceiling, which is the same number on every plan for
860
+ * the same reason.
861
+ */ const reputationPolicy = effectiveReputationPolicy(sendingIdentity.source, orgForHost == null ? void 0 : (_orgForHost_org = orgForHost.org) == null ? void 0 : _orgForHost_org['emailReputationPolicy']);
862
+ const consentPolicy = resolveMarketingConsentPolicy(orgForHost == null ? void 0 : (_orgForHost_org1 = orgForHost.org) == null ? void 0 : _orgForHost_org1['marketingConsentPolicy']);
863
+ /*
864
+ * THE PROOF CARVE-OUT.
865
+ *
866
+ * A proof delivered to an account holder on this workspace is not a
867
+ * marketing send. The recipient is the person who pressed the button, or a
868
+ * colleague of theirs who already receives this workspace's console mail,
869
+ * and the consent rule exists to protect somebody from mail they did not
870
+ * ask for — which is not what proofing a draft to your own team is.
871
+ *
872
+ * Without this the composer's test send is dead under `strict`: it delivers
873
+ * through the `manual` audience, a hand-typed address is backed by no
874
+ * document, and `unrecorded` is withheld before reaching the clause that
875
+ * grandfathers a record carrying no capture date. Proofing your own email
876
+ * would be refused on consent grounds.
877
+ *
878
+ * ⚠️ TWO PROPERTIES KEEP THE EXEMPTION ONE ADDRESS WIDE, and both are here
879
+ * rather than at the call site, because a caller that could widen it is
880
+ * exactly what this must not be:
881
+ *
882
+ * 1. The address must ALREADY be in the resolved audience. The option can
883
+ * therefore only exempt a recipient, never introduce one — passing an
884
+ * address that is not being sent to does nothing at all.
885
+ * 2. A stored `declined` is still refused, below. A refusal is the one
886
+ * thing no policy may mail, and a proof is not the first exception to
887
+ * it: somebody who declined marketing on this site un-declines rather
888
+ * than being quietly overridden.
889
+ *
890
+ * Neither of them is what stops a merchant proofing to a stranger. That is
891
+ * {@link eligibleProofAddress}, at the route, because it is a question
892
+ * about WHO an address belongs to and this function has no way to ask it.
893
+ * The two here are what hold when that check is wrong.
894
+ */ const proofFor = String((_options_proofFor = options.proofFor) != null ? _options_proofFor : '').trim().toLowerCase();
895
+ const proofAddress = proofFor && resolved.includes(proofFor) ? proofFor : '';
896
+ /*
897
+ * THE ONE READ THAT MAKES THE `declined` GUARANTEE TRUE.
898
+ *
899
+ * A proof is delivered through the `manual` audience, and a manual audience
900
+ * is a list of ADDRESSES — no documents are swept, so `consent` is empty
901
+ * and every proof address arrives as `unrecorded`. The refusal below was
902
+ * therefore unreachable on the only path that can reach it: the carve-out
903
+ * promised that a stored opt-out still refuses, and nothing ever looked one
904
+ * up.
905
+ *
906
+ * Keyed on the single address and only when a proof is in flight, so the
907
+ * cost is one small lookup on an explicit click rather than anything on the
908
+ * campaign path.
909
+ */ if (proofAddress && !consent.has(proofAddress)) {
910
+ const stored = await readStoredConsent(hostId, proofAddress);
911
+ if (stored) {
912
+ consent.set(proofAddress, readMarketingBasis(stored, consentGroup));
913
+ }
914
+ }
915
+ if (proofAddress && ((_consent_get = consent.get(proofAddress)) == null ? void 0 : _consent_get.basis) === 'declined') {
916
+ throw new CampaignSendError(`${proofAddress} has a recorded marketing opt-out on this site, so the ` + 'test send was not delivered. Proof to an address that has not opted ' + 'out, or opt that one back in.', 400);
917
+ }
918
+ const consentSplit = splitByMarketingConsent(proofAddress ? resolved.filter((one)=>one !== proofAddress) : resolved, consent, consentPolicy, consentGroup);
919
+ if (proofAddress) consentSplit.mailable.unshift(proofAddress);
920
+ if (!consentSplit.mailable.length) {
921
+ if (options.continuation) return finishContinuation();
922
+ throw new CampaignSendError('No recipient in this audience has a marketing consent record, so ' + 'nothing has been sent. Add an opt-in checkbox to the form or sign-up ' + 'this audience comes from, or send to an audience that has one.', 400);
923
+ }
924
+ /*==========================================
925
+ * THE TWO PLATFORM CONTROLS THAT SIZE THIS BATCH.
926
+ *
927
+ * Both read the same seven-day window, so they are resolved together and
928
+ * the window is read once. Both are campaign-only by construction — this
929
+ * function is the only caller, and transactional mail cannot reach it.
930
+ *
931
+ * A TEST SEND is exempt from both. It delivers one message to the address
932
+ * of the person who pressed the button, it writes no campaign record, and
933
+ * refusing it would leave a merchant whose list has a problem unable to
934
+ * even look at the email they are trying to fix.
935
+ *=========================================*/ const platformRate = await readEmailSendRateConfig();
936
+ const proofOnly = options.recordCampaign === false;
937
+ /**
938
+ * How many people this batch may address.
939
+ *
940
+ * The per-send cap unless the new-sender ramp is lower, in which case the
941
+ * ramp is what the batch takes and the rest of the audience goes out on the
942
+ * following days. Shrinking rather than deferring is the only shape that
943
+ * works: a workspace on a 200-a-day step would defer a 500-recipient batch
944
+ * every single day and never send anything at all.
945
+ */ let batchCap = MAX_RECIPIENTS_PER_SEND;
946
+ /** Today's ramp, resolved once and claimed against below. */ let ramp = null;
947
+ if (!proofOnly) {
948
+ var _orgForHost_org2, _orgForHost_org3;
949
+ /** This workspace's seven-day grade, and the window both controls read. */ const reputation = await readSenderReputation({
950
+ orgId,
951
+ policy: reputationPolicy,
952
+ reinstatedUntilMs: orgForHost == null ? void 0 : (_orgForHost_org2 = orgForHost.org) == null ? void 0 : _orgForHost_org2['emailReputationReinstatedUntilMs']
953
+ });
954
+ /*
955
+ * THE CIRCUIT BREAKER.
956
+ *
957
+ * A 409 rather than a deferral, and that difference is the point. A
958
+ * deferral says "not this hour" and retries itself; this says "not until
959
+ * something changes", and the thing that has to change is the list. A
960
+ * campaign that rescheduled itself against a tripped breaker would mail
961
+ * the same bad addresses on a timer.
962
+ *
963
+ * NOTHING IS REMOVED. No contact is deleted, no audience is trimmed,
964
+ * nobody is unsubscribed and no list membership moves — the refusal is on
965
+ * the SEND, which is a flow, and refusing a flow strands nobody's data.
966
+ * That is the enforce-at-the-reduction rule (`over-limit.ts`) applied to
967
+ * the one control in this file that could be tempted to break it.
968
+ *
969
+ * The message carries the numbers and what to do about them, because a
970
+ * merchant who cannot send and cannot find out why will open a ticket
971
+ * that says the product is broken.
972
+ */ if (reputation.blocked) {
973
+ throw new CampaignSendError(reputation.reason, 409);
974
+ }
975
+ /*
976
+ * THE NEW-SENDER RAMP.
977
+ *
978
+ * A workspace created today may not put its whole first import onto the
979
+ * domain every other tenant's receipts leave on. The step it is on is
980
+ * earned by clean volume as well as reached by age, and a workspace past
981
+ * its first week — which is every existing customer, and every org whose
982
+ * record predates the creation timestamp — is not ramped at all.
983
+ */ ramp = resolveOrgEmailRamp({
984
+ ageDays: orgAgeDays(orgForHost == null ? void 0 : (_orgForHost_org3 = orgForHost.org) == null ? void 0 : _orgForHost_org3['createdAt']),
985
+ deliveredLifetime: reputation.window.accepted,
986
+ platformPerHour: platformRate.perHour
987
+ });
988
+ if (!ramp.graduated && platformRate.enabled) {
989
+ const dayRemaining = Math.max(0, ramp.perDay - reputation.window.claimedToday);
990
+ if (dayRemaining <= 0) {
991
+ throw new CampaignSendDeferredError(`${ramp.detail} It has already sent ` + `${reputation.window.claimedToday.toLocaleString()} today, so ` + 'this campaign has not been sent and nothing has been counted — ' + 'it goes out automatically tomorrow. Transactional mail — ' + 'receipts, booking reminders, password resets — keeps sending.', Math.floor(Date.now() / 86400000) * 86400000 + 86400000);
992
+ }
993
+ batchCap = Math.min(batchCap, dayRemaining);
994
+ }
995
+ }
996
+ /*
997
+ * The cap takes the FIRST N of a stable order, which is what makes taking
998
+ * some of the audience defensible at all: two sends of the same unchanged
999
+ * audience now address the same people, and which people is answerable
1000
+ * ("the first N by document name"). It was previously whichever slice
1001
+ * Firestore happened to return.
1002
+ *
1003
+ * What is left over is not lost. The plan at the bottom of this function
1004
+ * writes the email back as `scheduled` with a record of how far it got, and
1005
+ * the next run addresses the first N of the REMAINDER — the same rule
1006
+ * pointed at what is left, which is the same move the follow-up's
1007
+ * subtraction makes one block above.
1008
+ */ recipients = consentSplit.mailable.slice(0, batchCap);
1009
+ /*
1010
+ * BOTH suppression lists, on one derivation (D6 of
1011
+ * `docs/specs/email-overhaul.md`).
1012
+ *
1013
+ * This read used to be the site's own list alone, so an address that hard
1014
+ * bounced or reported spam on any OTHER send — another site in the org, or
1015
+ * transactional mail carrying no site tag, which is where most of the
1016
+ * platform list comes from — was mailed anyway. Every tenant's campaigns
1017
+ * leave by one sending domain under `p=reject`, so that is not one
1018
+ * merchant's deliverability, it is everyone's.
1019
+ *
1020
+ * Checked on the capped list rather than the whole audience on purpose: it
1021
+ * is a keyed lookup per address, so its cost is the size of what is being
1022
+ * mailed, and asking about people this send will not reach would buy a
1023
+ * larger read for a number nobody acts on.
1024
+ */ const notSuppressed = await filterSendableForHost(hostId, recipients, firestore);
1025
+ /*
1026
+ * The THIRD list, and the narrowest: who has left THIS stream.
1027
+ *
1028
+ * After the two suppression lists rather than before them, because it is the
1029
+ * weaker fact and the weaker fact should never be the one that decides. A
1030
+ * person who unticked "Promotions and offers" is still a subscriber; a
1031
+ * person on either suppression list is not, and asking about their topic
1032
+ * preferences would be a read taken on a question already answered.
1033
+ */ const onTopic = await filterTopicSendable(hostId, topicId, notSuppressed, firestore);
1034
+ /*
1035
+ * THE FOURTH FILTER: how often the recipient asked to hear from this site.
1036
+ *
1037
+ * The one pace control a campaign is bound by, and the reason it is bound
1038
+ * is the reason the other two do not bind it. A ceiling and a sunset are
1039
+ * conclusions the platform draws about a person; this is a request the
1040
+ * person made, on a page this product built for them to make it. A
1041
+ * campaign that overrode it would make the preference center a form that
1042
+ * records a choice nobody honors — the same failure as ignoring an
1043
+ * unsubscribe, one notch quieter.
1044
+ *
1045
+ * HERE rather than at the per-message gate, and that placement is the whole
1046
+ * point. `sendEmail`'s marketing gate is not on this path: a campaign
1047
+ * carries no `marketing` context, because it mints its own unsubscribe URL
1048
+ * upstream and needs it as a merge value long before the message exists.
1049
+ * Enforcing a pace one message at a time would also refuse people AFTER the
1050
+ * merchant read a number that counted them, which is the thing every filter
1051
+ * on this path exists not to do. Subtracted where the other three are
1052
+ * subtracted, the count on screen is true before Send is pressed.
1053
+ *
1054
+ * Fails OPEN on an unreadable counter — see `filterCadenceSendable`. A
1055
+ * pace is not a stop, and the two suppression lists above have already
1056
+ * removed everybody who asked us to stop entirely.
1057
+ */ const sendable = await filterCadenceSendable(hostId, onTopic, {
1058
+ firestore
1059
+ });
1060
+ /*
1061
+ * NOBODY IN THIS BATCH, BUT SOMEBODY AFTER IT.
1062
+ *
1063
+ * A send whose whole audience is suppressed is a 400 a merchant needs to
1064
+ * see. A BATCH whose five hundred are all suppressed is not — there are
1065
+ * two and a half thousand people behind them, and refusing here would end
1066
+ * the campaign at the first block of bad addresses in the list.
1067
+ *
1068
+ * The batch falls through instead: it addresses nobody, records the
1069
+ * addresses it refused so the next batch does not spend its slots on them
1070
+ * again, and the plan at the bottom schedules the remainder.
1071
+ */ if (!sendable.length && consentSplit.mailable.length <= recipients.length) {
1072
+ if (options.continuation) return finishContinuation();
1073
+ /*
1074
+ * Which of the four filters emptied the batch, because the answer changes
1075
+ * what the merchant should do. An audience that unsubscribed is one they
1076
+ * have to rebuild; an audience holding for its own cadence is one that
1077
+ * becomes mailable on its own, and telling them it "unsubscribed" would
1078
+ * send them looking for a problem that is not there.
1079
+ */ throw new CampaignSendError(onTopic.length ? 'Every recipient has asked this site for mail less often than this' : 'Every recipient has unsubscribed or been suppressed', 400);
1080
+ }
1081
+ // Monthly cap by the owning org's plan (dark-launch rule, AGL-238).
1082
+ //
1083
+ // A campaign is the ONLY send a quota may refuse (AGL-1438). It is
1084
+ // discretionary — the customer sees a clear message, and upgrades or waits —
1085
+ // where refusing a receipt or a password reset would convert a billing event
1086
+ // into an outage on their business. So this is measured against
1087
+ // `campaignEmailSends` and NOT against `emailSends`, which since AGL-1438
1088
+ // also carries every order confirmation, booking reminder and workflow
1089
+ // notification the site sent. Enforcing the campaign cap against that total
1090
+ // would refuse a campaign because the store had a busy week of orders.
1091
+ //
1092
+ // SINCE AGL-2267 THE COUNTER IS PER ORG AND THE CLAIM IS ATOMIC. The cap was
1093
+ // enforced against `hosts/{hostId}/counters/campaignEmailSends` — per SITE —
1094
+ // while `emailSendsPerMonth` is an ORG entitlement, so an org with N sites
1095
+ // got N × the cap it bought. And it was read here and incremented after
1096
+ // delivery, so two concurrent campaigns both passed the same reading. See
1097
+ // `email-metering.ts` for the counter, the transition, and why the existing
1098
+ // per-site counters were NOT folded in.
1099
+ const monthKey = new Date().toISOString().slice(0, 7);
1100
+ // Plan-less orgs resolve as free (AGL-247) — the cap always runs. The org
1101
+ // document is read ONCE, at the consent join above, and reused here and for
1102
+ // branding below; both used to re-fetch it.
1103
+ // The limit itself, read through the one shared resolver. `checkQuota` with
1104
+ // a usage of 0 is how a plain limit is read; the ALLOW/REFUSE decision is
1105
+ // not made here — it is made by the atomic reservation below.
1106
+ const campaignSendLimit = checkQuota(orgForHost == null ? void 0 : orgForHost.org, 'emailSendsPerMonth', 0).limit;
1107
+ const overCapError = ()=>new CampaignSendError(`Monthly campaign email limit reached (${campaignSendLimit}) — upgrade ` + 'in Billing or shrink the audience. Transactional mail — receipts, ' + 'booking reminders, password resets — keeps sending.', 403);
1108
+ {
1109
+ // A cheap read-only pre-check, so an over-cap campaign is refused before
1110
+ // the template load, the experiment read and the campaign id — and so the
1111
+ // DRY RUN has an answer without writing anything (AGL-2178: "nothing has
1112
+ // been written above this line"). It is not the enforcement; it cannot be,
1113
+ // because a read is not a claim.
1114
+ const used = await orgCampaignEmailSendsForMonth(orgId, monthKey);
1115
+ if (used + sendable.length > campaignSendLimit) throw overCapError();
1116
+ }
1117
+ /*
1118
+ * Recipient PREVIEW (AGL-2178). The campaign composer mockup shows
1119
+ * `Recipients 1,240` beside the audience picker, and the console had no
1120
+ * count before a send at all — the number appeared afterwards, in a
1121
+ * snackbar.
1122
+ *
1123
+ * It returns from HERE rather than from a counting function of its own,
1124
+ * and that is the whole point: the figure has already been through
1125
+ * audience resolution, normalisation, de-duplication, the
1126
+ * `MAX_RECIPIENTS_PER_SEND` cap, both suppression lists and the monthly
1127
+ * quota. A second implementation would be a second set of rules to
1128
+ * drift, and the one number a merchant checks before pressing Send is
1129
+ * the worst possible place for an estimate that disagrees with what
1130
+ * happens.
1131
+ *
1132
+ * `audience` rides along so the composer can show the SHORTFALL rather
1133
+ * than only the send size. The preview is the surface a merchant reads
1134
+ * before deciding, so it is the surface on which "your audience is 3,000
1135
+ * and this send reaches 500" has to appear.
1136
+ *
1137
+ * Nothing has been written above this line — every step so far is a
1138
+ * read — so an early return here leaves no campaign document, no
1139
+ * counter and no id behind.
1140
+ */ if (options.dryRun) {
1141
+ return _extends({
1142
+ campaignId: '',
1143
+ recipients: recipients.length,
1144
+ audienceSize: resolved.length
1145
+ }, audienceTruncated ? {
1146
+ audienceTruncated: true
1147
+ } : {}, options.followUp ? {
1148
+ followUp: true,
1149
+ alreadyReached
1150
+ } : {}, {
1151
+ sendable: sendable.length,
1152
+ /*
1153
+ * Measured to `onTopic` rather than to `sendable`, so the cadence
1154
+ * filter below does not silently enlarge a number labelled
1155
+ * "unsubscribed or suppressed". Somebody who asked for monthly mail did
1156
+ * not unsubscribe, and reporting them under a heading that says they
1157
+ * did is the netting this readout has refused everywhere else.
1158
+ */ suppressed: recipients.length - onTopic.length,
1159
+ cadenceHeld: onTopic.length - sendable.length,
1160
+ /*
1161
+ * What this send will NOT reach on its first pass, so the composer can
1162
+ * say "3,000 people, 500 in the first batch, the rest over the next few
1163
+ * runs" instead of showing 500 beside an audience of 3,000 and leaving
1164
+ * a merchant to guess which number is the promise.
1165
+ */ remaining: Math.max(0, consentSplit.mailable.length - recipients.length),
1166
+ /*
1167
+ * The consent split, measured over the WHOLE audience and named rather
1168
+ * than netted (§3f).
1169
+ *
1170
+ * Over the whole audience, not over the capped 500, because it rides
1171
+ * the same readout as `audienceSize` and answers a question about the
1172
+ * audience: of the 3,200 people this list holds, how many asked for
1173
+ * this mail? Reporting it over the capped set would make the figures
1174
+ * move whenever the cap bit, for reasons that have nothing to do with
1175
+ * consent.
1176
+ *
1177
+ * Three numbers because one would hide the thing a merchant has to
1178
+ * decide about. `consented` is who has a basis; `grandfathered` is who
1179
+ * is reachable only because enforcement is not retroactive, and is
1180
+ * therefore exactly the population that disappears the day the org
1181
+ * turns the strict mode on; `consentWithheld` is who the rule already
1182
+ * refuses.
1183
+ *
1184
+ * `consentedByOperator` splits the first of those, because "who has a
1185
+ * basis" and "who asked" stopped being the same question once a basis
1186
+ * could be asserted on somebody's behalf. Reporting only the total
1187
+ * would present an operator backfill as that many opt-ins, which is
1188
+ * the one thing the provenance field exists to prevent.
1189
+ */ consented: consentSplit.consented,
1190
+ consentedByOperator: consentSplit.consentedByOperator,
1191
+ grandfathered: consentSplit.grandfathered,
1192
+ consentWithheld: consentSplit.withheld,
1193
+ // Which identity this campaign would leave on, so the composer can say
1194
+ // so rather than leaving a merchant to assume.
1195
+ identity: sendingIdentity.summary,
1196
+ identitySource: sendingIdentity.source,
1197
+ sent: 0,
1198
+ dryRun: true
1199
+ });
1200
+ }
1201
+ // `hostPublicOrigin`, not a hand-rolled apex (AGL-2195). Campaign links are
1202
+ // mailed out and clicked days later; a wrong apex sends the operator's whole
1203
+ // audience to a domain the operator does not control.
1204
+ const siteBase = (_hostPublicOrigin = hostPublicOrigin({
1205
+ cname: hostSnapshot.get('cname'),
1206
+ subdomain: hostSnapshot.get('subdomain')
1207
+ })) != null ? _hostPublicOrigin : '';
1208
+ // White-label sender identity (White-Label Phase 3): a campaign sent from a
1209
+ // white-label store reads as that store's brand. Resolved once for the whole
1210
+ // batch from the owning org doc through the one shared resolver.
1211
+ const branding = resolveBrandingProfile(orgForHost == null ? void 0 : orgForHost.org);
1212
+ const campaignId = options.campaignId || createResourceUid();
1213
+ /*
1214
+ * Whether this send MINTS the record or writes onto one that already
1215
+ * exists.
1216
+ *
1217
+ * A caller naming a `campaignId` is addressing a record somebody else
1218
+ * created — a draft being sent now, a sent email taking a follow-up — and
1219
+ * that record already carries its own creation stamp. Re-stamping it would
1220
+ * move an email's creation date forward every time it was sent again, and
1221
+ * the emails list orders drafts on exactly that field.
1222
+ */ const mintsRecord = !options.campaignId;
1223
+ // Designed email template (AGL-349): loaded once; rendered per
1224
+ // recipient with their merge values.
1225
+ const template = options.templateScreenId ? await loadEmailTemplate(hostId, options.templateScreenId) : null;
1226
+ /**
1227
+ * The message's SOURCE, settled once for the whole send.
1228
+ *
1229
+ * A designed email supplies both parts an inbox receives from the same
1230
+ * nodes, so `options.body` has nowhere to go on this branch and the union
1231
+ * gives it nowhere to be put. Deciding it here rather than inside the
1232
+ * per-recipient loop is also what stops the two ever disagreeing across a
1233
+ * batch.
1234
+ */ const designedContent = template ? _extends({
1235
+ mode: 'design',
1236
+ template
1237
+ }, options.plainText ? {
1238
+ plainText: options.plainText
1239
+ } : {}) : null;
1240
+ // Email A/B (AGL-255): each recipient deterministically lands in a
1241
+ // variant whose subject/body overrides apply; sends count as that
1242
+ // variant's exposures. A finished experiment sends the winner copy.
1243
+ const experimentId = String((_options_experimentId = options.experimentId) != null ? _options_experimentId : '');
1244
+ let experiment = null;
1245
+ if (experimentId) {
1246
+ const experimentSnapshot = await hostRef.collection('experiments').doc(experimentId).get();
1247
+ const data = experimentSnapshot.data();
1248
+ if (!experimentSnapshot.exists || !data || data.target !== 'email' || data.status !== 'running' && !data.winnerVariantId) {
1249
+ throw new CampaignSendError('Pick a running email experiment', 400);
1250
+ }
1251
+ experiment = _extends({
1252
+ $id: experimentSnapshot.id
1253
+ }, data);
1254
+ }
1255
+ /*
1256
+ * PLATFORM SEND-RATE ADMISSION CONTROL (AGL-2409).
1257
+ *
1258
+ * `sendEmail` governs every message individually and is the hard ceiling.
1259
+ * This is the admission check in front of it, and it exists for one reason:
1260
+ * without it, a campaign that does not fit in the current hour would deliver
1261
+ * to the first N addresses and stop, and a scheduled campaign in that state
1262
+ * cannot be retried without double-sending the N that already went.
1263
+ *
1264
+ * Asking for room for the WHOLE batch up front turns the ordinary case into
1265
+ * "all of it, or none of it and try again next run". A read, not a claim —
1266
+ * two campaigns can still both pass this and then contend at the per-message
1267
+ * governor, which is why the loop below also handles a mid-batch refusal
1268
+ * rather than assuming this settled it.
1269
+ */ {
1270
+ // The configuration was read above, where it sized this batch against the
1271
+ // new-sender ramp. One read, two controls: a second one here could answer
1272
+ // differently inside one send, which would let a campaign be sized
1273
+ // against one ceiling and admitted against another.
1274
+ const config = platformRate;
1275
+ const window = await readEmailSendRateWindow();
1276
+ if (config.enabled && window.used + sendable.length > config.perHour) {
1277
+ throw new CampaignSendDeferredError(`The platform is sending at its hourly limit (${config.perHour}/hour). ` + 'This campaign has not been sent and nothing has been counted — it ' + 'will go out automatically on the next run, or you can send it again ' + 'after the hour rolls.', window.resetMs);
1278
+ }
1279
+ /*
1280
+ * THIS WORKSPACE'S SHARE OF THAT HOUR.
1281
+ *
1282
+ * The check above bounds the platform; it does not bound how much of the
1283
+ * platform one tenant may take. Without this, an org with a large audience
1284
+ * occupies the whole hour and every other customer's campaigns are refused
1285
+ * by a ceiling they did nothing to reach.
1286
+ *
1287
+ * The ceiling is DERIVED from the live platform ceiling
1288
+ * (`orgHourlyCampaignCeiling`), so a staff ramp moves both together and
1289
+ * the two can never contradict each other. See `send-ceilings.ts` for the
1290
+ * arithmetic tying this to the per-send cap and the plan allowance.
1291
+ *
1292
+ * A deferral, not a refusal: the campaign stays a draft, the audience is
1293
+ * untouched, no list membership changes and no suppression or delivery
1294
+ * record is affected. A send is a flow rather than a holding, which is the
1295
+ * one place the enforce-at-the-reduction rule does not reach — refusing a
1296
+ * flow strands nobody's data.
1297
+ *
1298
+ * Taken BEFORE the monthly claim so that a workspace deferred for the hour
1299
+ * has not spent a month's allowance on a campaign that did not go.
1300
+ */ const hourly = await claimOrgEmailSendBudget({
1301
+ orgId,
1302
+ count: sendable.length,
1303
+ platformPerHour: config.perHour,
1304
+ enabled: config.enabled
1305
+ });
1306
+ if (!hourly.allowed) {
1307
+ throw new CampaignSendDeferredError(`This workspace may send ${hourly.ceiling.toLocaleString()} campaign ` + `emails an hour and has sent ${hourly.used.toLocaleString()} this ` + `hour, so there is room for ${hourly.remaining.toLocaleString()} ` + `and this campaign needs ${sendable.length.toLocaleString()}. ` + 'Nothing has been sent and nothing has been counted — the campaign ' + 'is unchanged and will go out automatically on the next run, or you ' + 'can send it again after the hour rolls. Transactional mail — ' + 'receipts, booking reminders, password resets — keeps sending.', hourly.retryAtMs);
1308
+ }
1309
+ }
1310
+ /*
1311
+ * THE DAY'S RAMP, CLAIMED.
1312
+ *
1313
+ * Between the hourly claim and the monthly reservation, and the ordering is
1314
+ * the same argument both of its neighbours make. AFTER the hourly one, so a
1315
+ * workspace deferred for the hour has not spent a day's budget. BEFORE the
1316
+ * monthly one, because an unreconciled claim costs whatever its window is
1317
+ * and a day is cheaper to leak than a month.
1318
+ *
1319
+ * A graduated workspace claims nothing and pays no read; see
1320
+ * `claimOrgEmailSendDay`.
1321
+ */ const dayClaim = await claimOrgEmailSendDay({
1322
+ orgId,
1323
+ count: sendable.length,
1324
+ ramp: ramp != null ? ramp : resolveOrgEmailRamp({
1325
+ ageDays: null,
1326
+ deliveredLifetime: 0,
1327
+ platformPerHour: platformRate.perHour
1328
+ }),
1329
+ enabled: platformRate.enabled
1330
+ });
1331
+ if (!dayClaim.allowed) {
1332
+ throw new CampaignSendDeferredError(`This workspace may send ${dayClaim.ceiling.toLocaleString()} campaign ` + `emails a day while it establishes a sending history, and has sent ` + `${dayClaim.used.toLocaleString()} today. Nothing has been sent and ` + 'nothing has been counted — the rest goes out automatically ' + 'tomorrow. Transactional mail — receipts, booking reminders, ' + 'password resets — keeps sending.', dayClaim.retryAtMs);
1333
+ }
1334
+ /*
1335
+ * THE MONTHLY CLAIM (AGL-2267), taken here and not at the pre-check above.
1336
+ *
1337
+ * As late as possible on purpose: everything between the pre-check and this
1338
+ * line can throw (an unknown template, a stopped experiment), and a claim
1339
+ * taken before them would leak the org's allowance for the rest of the month
1340
+ * on a campaign that never existed. From here to the `finally` below there
1341
+ * is nothing that can throw before the reconcile runs.
1342
+ */ const claim = await reserveCampaignEmailSends({
1343
+ orgId,
1344
+ month: monthKey,
1345
+ count: sendable.length,
1346
+ limit: campaignSendLimit
1347
+ });
1348
+ if (!claim.ok) {
1349
+ // The day's claim was taken a few lines above and this campaign is not
1350
+ // going out, so it is given back before the throw. The `finally` below
1351
+ // has not been entered yet, which is exactly why this cannot be left to
1352
+ // it.
1353
+ await reconcileOrgEmailSendDay(dayClaim.reservation, 0);
1354
+ throw overCapError();
1355
+ }
1356
+ const reservation = claim.reservation;
1357
+ const dayReservation = dayClaim.reservation;
1358
+ const variantSends = {};
1359
+ /**
1360
+ * Who this campaign actually reached, for the marketing frequency window.
1361
+ *
1362
+ * A campaign is exempt from the frequency REFUSAL — it is a merchant's
1363
+ * reviewed, one-shot act with a recipient count on screen before they press
1364
+ * Send, and a cap that silently removed people from it would make that
1365
+ * number a lie — but it is most of the mail a person receives from a site,
1366
+ * so a ceiling that did not count it would describe nothing. Collected here
1367
+ * and written once below rather than per recipient, because this loop is
1368
+ * already one awaited HTTP POST per person.
1369
+ *
1370
+ * ## The ENGAGEMENT SUNSET is exempt too, and for a stronger reason
1371
+ *
1372
+ * `marketingSunsetVerdict` refuses a site that has been mailing somebody
1373
+ * past the sunset window with nothing in it to say they are still
1374
+ * listening. Nothing on this path consults it, deliberately, and the
1375
+ * reasoning is the frequency argument plus two things that only apply here.
1376
+ *
1377
+ * The obvious objection is that a ceiling guards a merchant's volume while
1378
+ * a sunset guards a shared sending domain's reputation, so the platform's
1379
+ * interest should outrank a merchant's reviewed act. That distinction does
1380
+ * not survive contact with the two controls: `marketingFrequencyCap` is
1381
+ * itself "a deliverability control on a shared sending domain" that "cannot
1382
+ * be something one plan buys its way past". Both serve the platform. The
1383
+ * exemption was never about whose interest a control serves — it is about
1384
+ * whether an invisible subtraction may change a number a person already
1385
+ * read and approved.
1386
+ *
1387
+ * What separates the two is what a refusal is made of, and it separates
1388
+ * them in the direction of exempting the sunset MORE readily:
1389
+ *
1390
+ * 1. **A sunset refuses on an inference, not on a stated fact.**
1391
+ * Suppression carries "stop", the cadence filter above carries "less
1392
+ * often" — both said by the recipient. A ceiling counts messages that
1393
+ * demonstrably arrived. A sunset concludes from silence that somebody
1394
+ * has gone, which is the one refusal on this path that can be wrong
1395
+ * about a person who is still reading.
1396
+ * 2. **Its refusal is terminal where a ceiling's is retryable.** An
1397
+ * automated sweep defers a capped message and sends it later; a
1398
+ * sunsetted address is refused every time until they engage with mail
1399
+ * they are no longer being sent. Applied to campaigns that is the
1400
+ * largest silent subtraction in the system, made on the weakest
1401
+ * evidence.
1402
+ *
1403
+ * And it would break the one campaign written for exactly these people. A
1404
+ * win-back is a reviewed marketing act whose audience is, by definition,
1405
+ * everybody a sunset would refuse — so binding campaigns to it would make
1406
+ * the message that exists to end a disengagement the message that cannot be
1407
+ * sent. No automated path has that case: nobody writes a cart reminder
1408
+ * aimed at people who stopped reading.
1409
+ *
1410
+ * The reputation this leaves unguarded on the campaign path is guarded by
1411
+ * the four filters that DO run before this loop — both suppression lists,
1412
+ * the topic opt-outs and the recipient's own cadence — plus the sender
1413
+ * ramp and the hourly governor. The sunset governs the automated paths,
1414
+ * which fire with no human present. `MarketingSendContext.capped` carries
1415
+ * the same decision for the paths that reach the gate.
1416
+ */ const reached = [];
1417
+ /**
1418
+ * WHO THIS BATCH CONSIDERED AND WILL NOT MAIL, so the next one does not
1419
+ * consider them again.
1420
+ *
1421
+ * Two populations, and the reason they belong together is what the next
1422
+ * batch does with them. The suppression and topic filters above removed
1423
+ * people from `sendable`; the loop below removes any address the provider
1424
+ * would not take. Both stay at the head of a stable order, so a batch that
1425
+ * did not record them would re-select them, spend a slot on each, and — at
1426
+ * enough of them — address five hundred people it cannot mail and make no
1427
+ * progress at all.
1428
+ *
1429
+ * A message that failed for a transient reason is settled out with the
1430
+ * rest. That is not a new loss: before batching, a failed recipient was
1431
+ * never retried either, because there was no second pass. What it buys is
1432
+ * that one unreachable address cannot stall the two thousand behind it.
1433
+ *
1434
+ * The one failure that is NOT settled here is a refusal about the rate —
1435
+ * the hourly governor's, or the provider's 429. Those are answers about
1436
+ * when, not about whom, and settling a recipient on one would drop somebody
1437
+ * the provider never even looked at while the campaign closed as complete.
1438
+ * They break the loop into `deferred` instead; see the branch below.
1439
+ */ const sendableSet = new Set(sendable);
1440
+ const settledOut = recipients.filter((email)=>!sendableSet.has(email));
1441
+ let sent = 0;
1442
+ /** Recipients a rate refusal left untouched mid-batch, if any. */ let deferred = 0;
1443
+ /**
1444
+ * THE PACE INSIDE ONE BATCH.
1445
+ *
1446
+ * The hourly governor above decides HOW MANY messages this workspace may
1447
+ * put on the domain in an hour; it says nothing about how closely together
1448
+ * they arrive, and the provider only counts the second. A batch of five
1449
+ * hundred is the one place in this codebase that issues provider requests
1450
+ * in a tight loop, so it is the one place that has to hold the request
1451
+ * rate — everything else here sends one message and returns.
1452
+ *
1453
+ * Spreading rather than concurrency: a sequential `await` already caps this
1454
+ * loop at one request in flight, and the defect is that its rate is
1455
+ * whatever the round trip happens to be. See
1456
+ * {@link createProviderRequestPacer} for why waiting out the REMAINDER of
1457
+ * the interval costs a real send nothing.
1458
+ */ const paceProviderRequest = createProviderRequestPacer();
1459
+ try {
1460
+ for(let index = 0; index < sendable.length; index += 1){
1461
+ var _options_proofPersona;
1462
+ var _variant_subject, _variant_body;
1463
+ const email = sendable[index];
1464
+ // `cid` is what lets an unsubscribe be attributed to the campaign that
1465
+ // caused it. Without it the suppression list records that somebody left
1466
+ // and nothing about which mailing they left over, which is the one
1467
+ // question an unsubscribe rate exists to answer.
1468
+ const link = {
1469
+ siteBase,
1470
+ hostId,
1471
+ email,
1472
+ campaignId,
1473
+ topicId,
1474
+ secret: unsubscribeSecret
1475
+ };
1476
+ /*
1477
+ * TWO URLS OVER ONE SIGNATURE, and which one goes where is the whole
1478
+ * RFC 8058 story.
1479
+ *
1480
+ * `oneClickUrl` is what the `List-Unsubscribe` header names. A mailbox
1481
+ * provider POSTs it with no human present and expects the act to have
1482
+ * happened when it reads the 200 — so it points at the route whose POST
1483
+ * writes immediately, and it must never point at a page of checkboxes
1484
+ * that has to be submitted by somebody.
1485
+ *
1486
+ * `unsubscribeUrl` is the link a PERSON clicks in the footer, and it
1487
+ * points at the preference center, where the topic this message
1488
+ * belonged to is one of the things they can stop instead of all of it.
1489
+ * The merge token keeps its name because designed templates in the wild
1490
+ * reference `{{unsubscribeUrl}}`, and because the page it opens is still
1491
+ * where you go to unsubscribe — with "Unsubscribe from everything" on
1492
+ * it, one button away.
1493
+ */ const oneClickUrl = buildUnsubscribeUrl(_extends({}, link, {
1494
+ surface: 'one-click'
1495
+ }));
1496
+ const unsubscribeUrl = buildUnsubscribeUrl(_extends({}, link, {
1497
+ surface: 'preferences'
1498
+ }));
1499
+ // Variant assignment keys on the recipient address (AGL-255) so a
1500
+ // re-send reaches the same variant.
1501
+ const variant = experiment ? assignExperimentVariant(experiment, experiment.$id, email) : null;
1502
+ /*
1503
+ * THIS RECIPIENT'S MESSAGE, through the renderer the composer previews
1504
+ * with (`@aglyn/aglyn/app-utils/recipient-email-render`).
1505
+ *
1506
+ * Merge tags resolve after the variant override so variant copy can use
1507
+ * tags too, a designed template renders per recipient, and a plain-text
1508
+ * body gets the HTML part `sendEmail` would otherwise synthesize for it.
1509
+ * Shared rather than inlined because a preview rendered by a second
1510
+ * implementation is a preview of something else — the two defects this
1511
+ * send path has already shipped, product blocks silently dropped and
1512
+ * merge tags resolving to empty strings for a whole audience, are both
1513
+ * invisible to a preview that does not run this exact code.
1514
+ */ const message = renderRecipientEmail({
1515
+ subject: (variant == null ? void 0 : (_variant_subject = variant.subject) == null ? void 0 : _variant_subject.trim()) || subject,
1516
+ preheader: options.preheader,
1517
+ /*
1518
+ * ONE SOURCE, chosen once for the whole send.
1519
+ *
1520
+ * `designedContent` is resolved above the loop because the mode is a
1521
+ * property of the EMAIL, not of the recipient. A designed message
1522
+ * carries no body at all — the union has no field for one — so the
1523
+ * variant's body override is only reachable on the text branch, which
1524
+ * is the only branch where a body is the message.
1525
+ */ content: designedContent != null ? designedContent : {
1526
+ mode: 'text',
1527
+ body: (variant == null ? void 0 : (_variant_body = variant.body) == null ? void 0 : _variant_body.trim()) || body
1528
+ },
1529
+ /*
1530
+ * The persona where a proof named one, and the actual recipient
1531
+ * everywhere else.
1532
+ *
1533
+ * Only the RENDER moves. `email` above still decides who the message
1534
+ * is delivered to, which unsubscribe link is signed, and which
1535
+ * address a suppression would be recorded against — so a proof shows
1536
+ * a real contact's merge values without putting that contact's
1537
+ * opt-out link in somebody else's inbox.
1538
+ */ recipient: (_options_proofPersona = options.proofPersona) != null ? _options_proofPersona : {
1539
+ email,
1540
+ name: names.get(email)
1541
+ },
1542
+ siteBase,
1543
+ hostId,
1544
+ unsubscribeUrl
1545
+ });
1546
+ await paceProviderRequest();
1547
+ const result = await sendEmail(_extends({
1548
+ to: email,
1549
+ subject: message.subject
1550
+ }, message.html ? {
1551
+ html: message.html
1552
+ } : {}, {
1553
+ // The plain-text footer names what the link actually opens. "Choose
1554
+ // which emails you get" in front of "or unsubscribe" is the only place
1555
+ // a text-only reader learns that leaving one stream is an option at
1556
+ // all, and the word "unsubscribe" stays in the line because that is
1557
+ // what a recipient scans the footer for. It is written by
1558
+ // `renderRecipientEmail`, so the composer's preview shows the footer
1559
+ // that is actually mailed.
1560
+ text: message.text,
1561
+ // RFC 8058 one-click (AGL-2408). `List-Unsubscribe` alone does NOT
1562
+ // satisfy Gmail's and Yahoo's bulk-sender rules — the pair does, and
1563
+ // Gmail is where most of a merchant's list lives. A client honoring
1564
+ // the pair POSTs `List-Unsubscribe=One-Click` to the URL, which is
1565
+ // why the handler had to accept POST first: advertising one-click
1566
+ // against a GET-only handler would promise a verb nothing served.
1567
+ //
1568
+ // `oneClickUrl`, NOT the preference center. Topics narrow what a
1569
+ // person can choose on a page; they change nothing about what a
1570
+ // machine POSTing this header is promised, which is that the
1571
+ // recipient stops hearing from this site.
1572
+ headers: {
1573
+ 'List-Unsubscribe': `<${oneClickUrl}>`,
1574
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
1575
+ },
1576
+ // The campaign's own display name where the composer set one, and the
1577
+ // org's branding default otherwise. Either way the ADDRESS is the
1578
+ // resolved identity's — `applyFromName` replaces the display name in
1579
+ // front of it and nothing else.
1580
+ fromName: options.fromName || branding.fromName
1581
+ }, options.replyTo ? {
1582
+ replyTo: options.replyTo
1583
+ } : {}, {
1584
+ // The server's answer to which verified address this leaves on. The
1585
+ // send path re-checks it, so a refusal holds even here where the
1586
+ // route has already passed one.
1587
+ sendingIdentity,
1588
+ // A campaign is a site talking to its own audience, so the shared
1589
+ // platform address is not reachable from it even if the resolution
1590
+ // above somehow yielded nothing.
1591
+ audience: 'tenant',
1592
+ // Event attribution (AGL-268): the opens/clicks webhook maps
1593
+ // deliveries back to the campaign (and experiment) via tags.
1594
+ tags: [
1595
+ {
1596
+ name: 'hostId',
1597
+ value: hostId
1598
+ },
1599
+ {
1600
+ name: 'campaignId',
1601
+ value: campaignId
1602
+ },
1603
+ ...experiment ? [
1604
+ {
1605
+ name: 'experimentId',
1606
+ value: experiment.$id
1607
+ }
1608
+ ] : []
1609
+ ],
1610
+ context: 'campaign'
1611
+ }));
1612
+ if (result.sent) {
1613
+ sent += 1;
1614
+ reached.push(email);
1615
+ if (variant) {
1616
+ var _variantSends_variant_id;
1617
+ variantSends[variant.id] = ((_variantSends_variant_id = variantSends[variant.id]) != null ? _variantSends_variant_id : 0) + 1;
1618
+ }
1619
+ continue;
1620
+ }
1621
+ /*
1622
+ * A REFUSAL ABOUT THE RATE STOPS THE BATCH, whichever control produced
1623
+ * it.
1624
+ *
1625
+ * The hourly governor refuses because a counter that only goes up has
1626
+ * reached its ceiling, so every message after this one in the window
1627
+ * gets the same answer. The provider refuses with a 429 because
1628
+ * requests arrived too close together, and the next one is closer
1629
+ * still. Neither says anything about the recipient in hand.
1630
+ *
1631
+ * Not a throw: some of this batch has already been delivered, and a throw
1632
+ * here would lose the delivered count, skip the meters and (on the
1633
+ * scheduled path) re-queue a campaign that would double-send. The
1634
+ * remainder is reported instead, the reservation is reconciled to what
1635
+ * actually went, and the merchant sees a number that is short.
1636
+ *
1637
+ * Any OTHER failure — a rejection, a network error — is per-recipient and
1638
+ * the loop continues.
1639
+ */ if (rateLimitedRetryAtMs(result) !== null) {
1640
+ /*
1641
+ * Everything from HERE ON is untouched and retryable, which is what
1642
+ * makes it the remainder rather than a loss: the campaign schedules
1643
+ * itself for the next window and addresses these people then.
1644
+ * Counted from the index rather than from `sent`, so a rejection
1645
+ * earlier in the batch is not counted twice — once as settled and
1646
+ * again as deferred.
1647
+ */ deferred = sendable.length - index;
1648
+ break;
1649
+ }
1650
+ /*
1651
+ * The provider would not take this address. Settled rather than left
1652
+ * for the next batch — see `settledOut` above for why an address that
1653
+ * keeps failing must not keep consuming a slot.
1654
+ */ settledOut.push(email);
1655
+ }
1656
+ } finally{
1657
+ /*
1658
+ * Give back what did not go out (AGL-2267).
1659
+ *
1660
+ * In a `finally` so it runs on the throw paths too. The claim was taken
1661
+ * for the whole batch — that is what makes it a cap — and a campaign that
1662
+ * delivered 300 of 500 must not spend 500 of the org's allowance.
1663
+ * `reconcileCampaignSendReservation` never throws.
1664
+ */ await reconcileCampaignSendReservation(reservation, sent);
1665
+ /*
1666
+ * And the day's, for the same reason one line up. The hourly claim is
1667
+ * deliberately NOT reconciled — its window is an hour — but a day is long
1668
+ * enough that a failed batch would cost a new workspace the rest of it.
1669
+ */ await reconcileOrgEmailSendDay(dayReservation, sent);
1670
+ }
1671
+ // Both meters, from one call, on the DELIVERED count (AGL-1438). Ahead of
1672
+ // the `recordCampaign` early return below, because a test send is a real
1673
+ // email with a real cost even though it writes no campaign record — and
1674
+ // ahead of nothing else that writes `emailSends`, so a campaign reaches the
1675
+ // cost meter exactly once. This sender used to increment that counter
1676
+ // itself, which is how a counter named for all email came to hold campaign
1677
+ // sends alone.
1678
+ // The frequency window, for the messages that left. After the reservation
1679
+ // reconcile and never in front of it: this is a deliverability counter and
1680
+ // the reconcile is a merchant's allowance, so the allowance is settled
1681
+ // first. `recordMarketingSends` never throws.
1682
+ await recordMarketingSends(hostId, reached);
1683
+ await meterHostEmail(hostId, sent, 'campaign');
1684
+ /*
1685
+ * The DENOMINATOR every per-tenant rate divides by.
1686
+ *
1687
+ * Recorded on the DELIVERED count and not on what was attempted, so a
1688
+ * bounce rate is bounces over messages that actually left. It is also the
1689
+ * volume the new-sender ramp reads to decide which step a workspace has
1690
+ * earned, which is why it is written from the send rather than inferred
1691
+ * from the delivery webhook: a ramp that only moved when a provider
1692
+ * reported back would stall a new tenant on the day the webhook was slow.
1693
+ *
1694
+ * Never throws, like both meters above it.
1695
+ */ await recordCampaignAccepted(orgId, sent);
1696
+ /*
1697
+ * WHO THIS EMAIL HAS NOW REACHED, so a later send can subtract them.
1698
+ *
1699
+ * Written for every real send and not only for follow-ups, because the
1700
+ * record has to exist BEFORE anybody asks for one — an email whose first
1701
+ * send kept no account of itself can never be sent to more people, which is
1702
+ * exactly what `campaignReachCovers` refuses above.
1703
+ *
1704
+ * A test send is excluded with the campaign record it also skips: it
1705
+ * delivers to the requester's own address under an id nothing will ever
1706
+ * follow up, and counting it would file a real person's address against a
1707
+ * mailing that does not exist.
1708
+ *
1709
+ * Ahead of the campaign write below rather than after it, so a failure
1710
+ * between the two leaves an email whose reach record is AHEAD of its
1711
+ * recorded `sent`. `campaignReachCovers` reads that as covered, which is
1712
+ * the safe direction: a follow-up subtracts more people than it strictly
1713
+ * has to. The other order would leave the record short and refuse the
1714
+ * follow-up, which is also safe but loses the feature over a transient.
1715
+ */ if (options.recordCampaign !== false) {
1716
+ await recordCampaignReach(hostId, campaignId, reached, firestore);
1717
+ /*
1718
+ * And who it decided NOT to mail, under a field of its own.
1719
+ *
1720
+ * Written for every real send rather than only for a batched one, for the
1721
+ * reason the reach record itself is: the record has to exist before
1722
+ * anybody asks for one, and the batch that asks is the NEXT invocation of
1723
+ * this function, which will have no way to know that these five hundred
1724
+ * addresses were already considered.
1725
+ */ await recordCampaignSkipped(hostId, campaignId, settledOut, firestore);
1726
+ }
1727
+ // Sends are the email variant's exposures (AGL-255).
1728
+ if (experiment && experiment.status === 'running') {
1729
+ for (const [variantId, count] of Object.entries(variantSends)){
1730
+ // AGL-1771: `variant.id` is MERCHANT-AUTHORED — `validateExperiment`
1731
+ // checks the ids are unique and nothing about their shape — and it is a
1732
+ // path component here. The same third instance `d51e23df4` found on the
1733
+ // conversion write in `email-events.ts`, on the exposure write that
1734
+ // pairs with it. This one stays a merge-set and stays a create: the
1735
+ // first send for a variant has no stats document, the experiment was
1736
+ // just read, and the emails really went out.
1737
+ if (!isDocumentId(variantId)) continue;
1738
+ await hostRef.collection('experiments').doc(experiment.$id).collection('stats').doc(variantId).set({
1739
+ exposures: firebaseAdmin.firestore.FieldValue.increment(count),
1740
+ updatedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp()
1741
+ }, {
1742
+ merge: true
1743
+ }).catch(()=>undefined);
1744
+ }
1745
+ }
1746
+ /*==========================================
1747
+ * WHAT HAPPENS TO THE REST OF THE AUDIENCE.
1748
+ *
1749
+ * The cap took the first N of the people this send may mail. Everyone past
1750
+ * it, plus anybody an hourly cut left untouched, is the REMAINDER — and
1751
+ * until now the remainder was simply not mailed, and a merchant with three
1752
+ * thousand contacts pressed Send six times to reach them.
1753
+ *
1754
+ * The plan is pure and lives in `send-ceilings.ts` with the numbers it
1755
+ * reasons about. It answers two things: how many are left, and whether
1756
+ * another batch runs. The second is the one that matters, because a job
1757
+ * that reschedules itself has exactly one interesting failure — doing it
1758
+ * forever — and there are three ways this one stops: nothing left, the
1759
+ * batch guard, and a batch that settled nobody.
1760
+ *=========================================*/ const plan = campaignBatchPlan({
1761
+ mailable: consentSplit.mailable.length,
1762
+ addressed: recipients.length,
1763
+ retryable: deferred,
1764
+ settled: sent + settledOut.length,
1765
+ batchesSoFar
1766
+ });
1767
+ /**
1768
+ * When the next batch may go.
1769
+ *
1770
+ * Now, in the ordinary case: the scheduled-campaign processor claims
1771
+ * anything `scheduled` and due, and its next run is what continues this
1772
+ * email. A window the send was paced by moves it out — but a send paced by
1773
+ * the hour or the day THREW rather than reaching here, so the only reason
1774
+ * this is not immediate is a batch that ran to the end of its own cap.
1775
+ */ const nextAtMs = plan.resuming ? Date.now() : 0;
1776
+ if (options.recordCampaign === false) {
1777
+ return _extends({
1778
+ campaignId,
1779
+ recipients: sendable.length,
1780
+ audienceSize: resolved.length
1781
+ }, audienceTruncated ? {
1782
+ audienceTruncated: true
1783
+ } : {}, {
1784
+ sent
1785
+ }, deferred ? {
1786
+ deferred
1787
+ } : {});
1788
+ }
1789
+ /*==========================================
1790
+ * ADDING TO A COUNTER, AS AGAINST REPLACING IT.
1791
+ *
1792
+ * `set(..., {merge: true})` merges a nested map FIELD BY FIELD, so a
1793
+ * follow-up writing a plain `stats.recipients` would overwrite the original
1794
+ * send's figure with its own — and every rate on the report divides by
1795
+ * `sent` or by `delivered`. `delivered`, `opens`, `bounced` and the rest are
1796
+ * incremented by the delivery webhook keyed on this same `campaignId`, so
1797
+ * they already cover BOTH sends. Replacing `sent` with the follow-up's
1798
+ * smaller number would leave a numerator counting two sends over a
1799
+ * denominator counting one, and a delivery rate of 300%.
1800
+ *
1801
+ * So every send-recorded counter goes through here: a plain number the
1802
+ * first time, an increment on a follow-up. The two populations a follow-up
1803
+ * measures are disjoint by construction — it addressed nobody the earlier
1804
+ * sends reached — so the sums are totals rather than double counts.
1805
+ *=========================================*/ const additive = (value)=>addsToExistingSend ? firebaseAdmin.firestore.FieldValue.increment(value) : value;
1806
+ /**
1807
+ * The figures a BATCH must not add to, because it did not measure a second
1808
+ * population — it measured a slice of the one the first batch already
1809
+ * counted.
1810
+ *
1811
+ * `audienceSize` is the arithmetic that makes this concrete. A first batch
1812
+ * over three thousand people records 3,000; a second batch sees the 2,500
1813
+ * that are left and, adding, would record 5,500 — an audience that does not
1814
+ * exist, under every rate on the report. The same holds for the consent
1815
+ * split, which is measured over the whole remaining audience rather than
1816
+ * over the capped slice.
1817
+ *
1818
+ * So a continuation omits them entirely and the first batch's figures
1819
+ * stand, which is the same "recorded, not recomputed" rule the populations
1820
+ * below are stored under: they are true of the send that happened.
1821
+ */ const measuresTheAudience = !options.continuation;
1822
+ await hostRef.collection('campaigns').doc(campaignId).set(_extends({
1823
+ subject,
1824
+ body,
1825
+ audience
1826
+ }, mintsRecord ? {
1827
+ createdAtMs: Date.now()
1828
+ } : {}, options.listId ? {
1829
+ listId: options.listId
1830
+ } : {}, listName ? {
1831
+ listName
1832
+ } : {}, options.segmentId ? {
1833
+ segmentId: options.segmentId
1834
+ } : {}, {
1835
+ // The RESOLVED topic, not `options.topicId`. Recording the default
1836
+ // explicitly is what lets the campaign report and the preference page
1837
+ // agree about which stream this send belonged to, without either of them
1838
+ // re-deriving a default that could drift from the other's.
1839
+ topicId
1840
+ }, options.templateScreenId ? {
1841
+ templateScreenId: options.templateScreenId
1842
+ } : {}, options.plainText ? {
1843
+ plainText: options.plainText
1844
+ } : {}, options.fromName ? {
1845
+ fromName: options.fromName
1846
+ } : {}, options.replyTo ? {
1847
+ replyTo: options.replyTo
1848
+ } : {}, senderId ? {
1849
+ senderId
1850
+ } : {}, sentAsStamp({
1851
+ from: sendingIdentity.from,
1852
+ fromName: options.fromName || branding.fromName,
1853
+ replyTo: options.replyTo
1854
+ }), options.preheader ? {
1855
+ preheader: options.preheader
1856
+ } : {}, options.displayName ? {
1857
+ displayName: options.displayName
1858
+ } : {}, options.emailCampaignId ? {
1859
+ emailCampaignId: options.emailCampaignId
1860
+ } : {}, experiment ? {
1861
+ experimentId: experiment.$id
1862
+ } : {}, {
1863
+ stats: _extends({
1864
+ recipients: additive(sendable.length),
1865
+ sent: additive(sent)
1866
+ }, measuresTheAudience ? _extends({
1867
+ audienceSize: additive(resolved.length)
1868
+ }, audienceTruncated ? {
1869
+ audienceSizeTruncated: true
1870
+ } : {}) : {}, !plan.resuming && plan.remaining > 0 ? {
1871
+ deferred: plan.remaining
1872
+ } : {}, Object.keys(variantSends).length ? {
1873
+ variantSends: Object.fromEntries(Object.entries(variantSends).map(([variantId, count])=>[
1874
+ variantId,
1875
+ additive(count)
1876
+ ]))
1877
+ } : {}, measuresTheAudience ? {
1878
+ consented: additive(consentSplit.consented),
1879
+ consentedByOperator: additive(consentSplit.consentedByOperator),
1880
+ grandfathered: additive(consentSplit.grandfathered),
1881
+ consentWithheld: additive(consentSplit.withheld)
1882
+ } : {}, {
1883
+ suppressed: additive(recipients.length - onTopic.length),
1884
+ cadenceHeld: additive(onTopic.length - sendable.length),
1885
+ /*
1886
+ * That this send's links were trackable at all.
1887
+ *
1888
+ * Click tracking rewrites links in the HTML part, so a send that
1889
+ * carried none reports zero clicks whatever recipients did — a
1890
+ * structural zero that is indistinguishable on screen from a campaign
1891
+ * nobody clicked. `sendEmail` now synthesises an HTML part for a
1892
+ * text-only send, so every send from here on carries one; recording
1893
+ * the fact is what lets the report withhold a click RATE for the
1894
+ * campaigns that predate it instead of publishing a meaningless one.
1895
+ */ clickTracked: true
1896
+ }),
1897
+ /*
1898
+ * `scheduled` WHILE AN EMAIL IS STILL GOING OUT, and it is a real
1899
+ * state rather than a convenience.
1900
+ *
1901
+ * The scheduled-campaign processor claims anything `scheduled` and due,
1902
+ * and that claim is the resume beat — including for a send a merchant
1903
+ * pressed by hand, which has no beat of its own. Reusing the state also
1904
+ * means the existing collection-group index serves it and that Cancel
1905
+ * already works: a merchant who decides mid-campaign that the copy is
1906
+ * wrong can stop the rest of it, which is a thing they could not do
1907
+ * before because there was no rest.
1908
+ *
1909
+ * ⚠️ IT READS AS "NOT SENT YET" UNLESS A SURFACE SAYS OTHERWISE. The
1910
+ * `resume` map below is what makes the row honest — "reached 500 of
1911
+ * 3,000, sending" rather than a scheduled email that has in fact
1912
+ * already delivered five hundred messages. `campaignSendProgress` in
1913
+ * the recipient-email renderer derives that sentence from these fields
1914
+ * and is the one place it is composed.
1915
+ */ status: plan.resuming ? 'scheduled' : 'sent'
1916
+ }, plan.resuming ? {
1917
+ sendAtMs: nextAtMs
1918
+ } : {}, {
1919
+ /*
1920
+ * HOW FAR THIS EMAIL HAS GOT, written absolutely on every batch.
1921
+ *
1922
+ * Not part of `stats`, because `stats` is what the email DID and this
1923
+ * is where it currently is. A finished email keeps the record — batches
1924
+ * of 6, remaining 0 — so "this went out over six runs" is answerable
1925
+ * afterwards rather than only while it is happening.
1926
+ */ resume: _extends({
1927
+ remaining: plan.remaining,
1928
+ batch: plan.batch,
1929
+ nextAtMs
1930
+ }, plan.stop ? {
1931
+ stop: plan.stop
1932
+ } : {})
1933
+ }, addsToExistingSend ? {
1934
+ lastSentAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
1935
+ lastSentBy: options.senderUid
1936
+ } : {
1937
+ sentAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
1938
+ sentBy: options.senderUid
1939
+ }, options.continuation ? {} : {
1940
+ sendCount: additive(1)
1941
+ }), {
1942
+ merge: true
1943
+ });
1944
+ return _extends({
1945
+ campaignId,
1946
+ recipients: sendable.length,
1947
+ audienceSize: resolved.length
1948
+ }, audienceTruncated ? {
1949
+ audienceTruncated: true
1950
+ } : {}, options.followUp ? {
1951
+ followUp: true,
1952
+ alreadyReached
1953
+ } : {}, {
1954
+ sent
1955
+ }, deferred ? {
1956
+ deferred
1957
+ } : {}, {
1958
+ remaining: plan.remaining,
1959
+ resuming: plan.resuming,
1960
+ batch: plan.batch
1961
+ }, plan.resuming ? {
1962
+ nextAtMs
1963
+ } : {});
1964
+ }
1965
+ /**
1966
+ * THE ADDRESSES A TEST SEND MAY BE DELIVERED TO.
1967
+ *
1968
+ * The caller's own account address, plus every account holder on the org that
1969
+ * owns this site. Exported because the composer has to OFFER this set — a
1970
+ * free-text box beside a rule enforced on the server is a box whose every
1971
+ * wrong answer is a refusal the person could not have predicted.
1972
+ *
1973
+ * ## Why membership, and not "any address the merchant types"
1974
+ *
1975
+ * A test send is exempt from the marketing-consent rule (see the proof
1976
+ * carve-out in `performCampaignSend`). An exemption that could be pointed at
1977
+ * any address would not be a test-send feature, it would be a way to mail
1978
+ * anybody without consent and call it a test. Membership is the narrowest
1979
+ * boundary that still answers what the button is for: proofing a draft to the
1980
+ * people who work on it.
1981
+ *
1982
+ * ## What is NOT here
1983
+ *
1984
+ * `siteMembers`, `leads` and contacts. They are the tenant's audience, and
1985
+ * the audience is precisely the population the consent rule protects. A
1986
+ * contact can be chosen as the PERSONA a proof renders as — see
1987
+ * {@link resolveProofPersona} — which reaches nobody.
1988
+ *
1989
+ * ## What this does not relax
1990
+ *
1991
+ * Everything else. The send still runs both suppression lists, so an address
1992
+ * that bounced or complained is refused however senior its owner; and a
1993
+ * stored `declined` on the address still refuses, because a refusal is the
1994
+ * one thing no policy may mail.
1995
+ */ export async function proofRecipientsForHost(options) {
1996
+ var _ref, _ref1, _ref2;
1997
+ const callerEmail = String((_ref = options == null ? void 0 : options.callerEmail) != null ? _ref : '').trim().toLowerCase();
1998
+ const found = new Map();
1999
+ if (callerEmail) {
2000
+ found.set(callerEmail, {
2001
+ email: callerEmail,
2002
+ label: 'You',
2003
+ self: true
2004
+ });
2005
+ }
2006
+ const orgForHost = await getOrgForHost(options == null ? void 0 : options.hostId).catch(()=>null);
2007
+ const orgId = String((_ref1 = orgForHost == null ? void 0 : orgForHost.orgId) != null ? _ref1 : '');
2008
+ if (!orgId) return [
2009
+ ...found.values()
2010
+ ];
2011
+ const members = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('members')/*
2012
+ * A ceiling rather than the whole roster, because this feeds a picker and
2013
+ * a picker of two thousand names is not a picker. It is ordered by
2014
+ * document id so the page is stable across calls — a `limit` with no
2015
+ * order is a random sample, and a person who saw a colleague in the list
2016
+ * yesterday must not find them missing today.
2017
+ */ .orderBy(firebaseAdmin.firestore.FieldPath.documentId()).limit(200).get().catch(()=>null);
2018
+ for (const doc of (_ref2 = members == null ? void 0 : members.docs) != null ? _ref2 : []){
2019
+ var _doc_get, _doc_get1;
2020
+ const email = String((_doc_get = doc.get('email')) != null ? _doc_get : '').trim().toLowerCase();
2021
+ if (!email || found.has(email)) continue;
2022
+ found.set(email, {
2023
+ email,
2024
+ label: String((_doc_get1 = doc.get('displayName')) != null ? _doc_get1 : '').trim() || email,
2025
+ self: false
2026
+ });
2027
+ }
2028
+ return [
2029
+ ...found.values()
2030
+ ];
2031
+ }
2032
+ /**
2033
+ * Whether one address may receive a proof of this site's mail.
2034
+ *
2035
+ * Asked as a membership question rather than by scanning
2036
+ * {@link proofRecipientsForHost}'s ceilinged page: the picker is allowed to
2037
+ * show the first two hundred colleagues, and the gate is not allowed to
2038
+ * refuse the two hundred and first.
2039
+ */ async function eligibleProofAddress(options) {
2040
+ var _ref, _ref1, _ref2;
2041
+ const address = String((_ref = options == null ? void 0 : options.address) != null ? _ref : '').trim().toLowerCase();
2042
+ if (!address) return false;
2043
+ if (address === String((_ref1 = options == null ? void 0 : options.callerEmail) != null ? _ref1 : '').trim().toLowerCase()) {
2044
+ return true;
2045
+ }
2046
+ const orgForHost = await getOrgForHost(options == null ? void 0 : options.hostId).catch(()=>null);
2047
+ const orgId = String((_ref2 = orgForHost == null ? void 0 : orgForHost.orgId) != null ? _ref2 : '');
2048
+ if (!orgId) return false;
2049
+ const match = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).collection('members').where('email', '==', address).limit(1).get().catch(()=>null);
2050
+ return Boolean(match && !match.empty);
2051
+ }
2052
+ /** How many of each source the persona picker offers. */ const PROOF_PERSONA_SAMPLE = 20;
2053
+ /**
2054
+ * A SAMPLE of the people this site's mail is addressed to, for the picker.
2055
+ *
2056
+ * A sample and not a search: the question the drawer asks is "show me this
2057
+ * email as somebody real", and twenty names from each source answers it for
2058
+ * the cost of three small reads. A contact picker with a query behind it is a
2059
+ * different feature, and it would put a text input in front of the org's
2060
+ * whole contact list on a surface whose job is to prove one email.
2061
+ *
2062
+ * Ordered by document id in every source, because a `limit` with no `orderBy`
2063
+ * is a random sample in doc-id order that a client `sort` then makes LOOK
2064
+ * newest-first. Naming the order keeps the picker stable between openings —
2065
+ * and on the org path it is also the order the automatic index for the scope
2066
+ * filter can actually serve.
2067
+ */ export async function proofPersonasForHost(hostId) {
2068
+ const byId = firebaseAdmin.firestore.FieldPath.documentId();
2069
+ const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(hostId);
2070
+ const found = new Map();
2071
+ const collect = (docs, source, nameFields)=>{
2072
+ for (const doc of docs != null ? docs : []){
2073
+ var _doc_get;
2074
+ const email = String((_doc_get = doc.get('email')) != null ? _doc_get : '').trim().toLowerCase();
2075
+ if (!email || found.has(email)) continue;
2076
+ const name = nameFields.map((field)=>{
2077
+ var _doc_get;
2078
+ return String((_doc_get = doc.get(field)) != null ? _doc_get : '').trim();
2079
+ }).find(Boolean);
2080
+ found.set(email, {
2081
+ email,
2082
+ name: name != null ? name : '',
2083
+ source
2084
+ });
2085
+ }
2086
+ };
2087
+ const [leads, members, contacts] = await Promise.all([
2088
+ hostRef.collection('leads').orderBy(byId).limit(PROOF_PERSONA_SAMPLE).get().catch(()=>null),
2089
+ hostRef.collection('siteMembers').orderBy(byId).limit(PROOF_PERSONA_SAMPLE).get().catch(()=>null),
2090
+ orgDataQueryForHost(hostId, 'contacts').then(({ query })=>query.orderBy(byId).limit(PROOF_PERSONA_SAMPLE).get()).catch(()=>null)
2091
+ ]);
2092
+ collect(leads == null ? void 0 : leads.docs, 'lead', [
2093
+ 'name'
2094
+ ]);
2095
+ // `displayName` first, and `name` only as a fallback: `siteMembers` has
2096
+ // never had a `name` field, so reading it alone renders every member
2097
+ // persona nameless — which is the exact defect that made merge tags resolve
2098
+ // to empty strings for whole audiences.
2099
+ collect(members == null ? void 0 : members.docs, 'member', [
2100
+ 'displayName',
2101
+ 'name'
2102
+ ]);
2103
+ collect(contacts == null ? void 0 : contacts.docs, 'contact', [
2104
+ 'name',
2105
+ 'firstName'
2106
+ ]);
2107
+ return [
2108
+ ...found.values()
2109
+ ];
2110
+ }
2111
+ /**
2112
+ * THE DOCUMENT ONE ADDRESS HAS ON THIS SITE, wherever it lives, or null.
2113
+ *
2114
+ * ONE lookup for both of the questions a proof asks about a person — what is
2115
+ * their name, and did they opt out — because the two must not disagree about
2116
+ * WHO the address is. A persona resolved from the contact record beside a
2117
+ * consent basis resolved from a lead record would be two different people
2118
+ * wearing one address.
2119
+ *
2120
+ * The three sources are the three an audience is built from, tried in the
2121
+ * order a small site grows them. Nothing here is taken from the request.
2122
+ *
2123
+ * The org `contacts` lookup goes through the org's address index narrowed
2124
+ * to this site (AGL-2633): the index is consulted for the address, the
2125
+ * contact it names is checked against `visibleTo` in memory, and only
2126
+ * then does the `email ==` query run. So a person whose two records were
2127
+ * merged is found under the address that became an alternate, and the
2128
+ * scope check is not skipped — it is applied to the one document an
2129
+ * address names, which is the same shape the segment branch above uses.
2130
+ */ async function findAudienceDocument(hostId, email) {
2131
+ var _contact_data;
2132
+ const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(hostId);
2133
+ for (const [collection, nameFields] of [
2134
+ [
2135
+ 'leads',
2136
+ [
2137
+ 'name'
2138
+ ]
2139
+ ],
2140
+ // `displayName` first: `siteMembers` has never had a `name` field, and
2141
+ // reading only that is how merge tags came to render empty for a whole
2142
+ // audience.
2143
+ [
2144
+ 'siteMembers',
2145
+ [
2146
+ 'displayName',
2147
+ 'name'
2148
+ ]
2149
+ ]
2150
+ ]){
2151
+ var _snapshot_docs;
2152
+ const snapshot = await hostRef.collection(collection).where('email', '==', email).limit(1).get().catch(()=>null);
2153
+ const doc = snapshot == null ? void 0 : (_snapshot_docs = snapshot.docs) == null ? void 0 : _snapshot_docs[0];
2154
+ if (doc) {
2155
+ var _doc_data;
2156
+ return {
2157
+ data: (_doc_data = doc.data()) != null ? _doc_data : {},
2158
+ nameFields
2159
+ };
2160
+ }
2161
+ }
2162
+ const contacts = await orgDataCollectionForHost(hostId, 'contacts').catch(()=>null);
2163
+ const contact = contacts ? await findContactByEmail(contacts, email, {
2164
+ hostId
2165
+ }).catch(()=>null) : null;
2166
+ return contact ? {
2167
+ data: (_contact_data = contact.data()) != null ? _contact_data : {},
2168
+ nameFields: [
2169
+ 'name',
2170
+ 'firstName'
2171
+ ]
2172
+ } : null;
2173
+ }
2174
+ /**
2175
+ * The stored marketing-consent fields for one address, or null when this site
2176
+ * holds no document for it at all.
2177
+ *
2178
+ * Null and an opted-out record are different answers and the caller treats
2179
+ * them differently: an address we have never seen keeps whatever basis the
2180
+ * policy assigns an unrecorded person, and one that said no is refused.
2181
+ */ async function readStoredConsent(hostId, email) {
2182
+ var _ref;
2183
+ if (!email || !EMAIL_PATTERN.test(email)) return null;
2184
+ const found = await findAudienceDocument(hostId, email).catch(()=>null);
2185
+ return (_ref = found == null ? void 0 : found.data) != null ? _ref : null;
2186
+ }
2187
+ /**
2188
+ * The person whose stored data a proof renders as, or null.
2189
+ *
2190
+ * Read from the audience documents rather than taken from the request, so a
2191
+ * proof demonstrates what the MERGE will do to real data.
2192
+ */ async function resolveProofPersona(hostId, rawEmail) {
2193
+ var _ref;
2194
+ const email = String(rawEmail != null ? rawEmail : '').trim().toLowerCase();
2195
+ if (!email || !EMAIL_PATTERN.test(email)) return null;
2196
+ const found = await findAudienceDocument(hostId, email).catch(()=>null);
2197
+ const name = ((_ref = found == null ? void 0 : found.nameFields) != null ? _ref : []).map((field)=>{
2198
+ var _ref;
2199
+ var _found_data;
2200
+ return String((_ref = found == null ? void 0 : (_found_data = found.data) == null ? void 0 : _found_data[field]) != null ? _ref : '').trim();
2201
+ }).find(Boolean);
2202
+ /*
2203
+ * An address that matches nobody still renders, as itself with no name.
2204
+ *
2205
+ * Refusing would be worse: the merchant asked to see what the email looks
2206
+ * like addressed to this person, and "we could not find them" is an answer
2207
+ * about our storage rather than about their email. The drawer says which
2208
+ * record a persona came from, so a proof that fell through to this reads as
2209
+ * the address it is.
2210
+ */ return name ? {
2211
+ email,
2212
+ name
2213
+ } : {
2214
+ email
2215
+ };
2216
+ }
2217
+ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2218
+ var _snapshot_get, _snapshot_get1, _snapshot_get2, _snapshot_get3;
2219
+ const audience = String((_snapshot_get = snapshot.get('audience')) != null ? _snapshot_get : '');
2220
+ /*
2221
+ * A `manual` audience is stored on a scheduled or drafted email and is not
2222
+ * stored on one that has already gone out, so the same audience kind is
2223
+ * repeatable for one caller and not the other.
2224
+ *
2225
+ * `emails` is written by the branches that store a send for later. An
2226
+ * immediate send takes its addresses from the request and keeps none of
2227
+ * them, which is what leaves a follow-up with nothing to re-resolve.
2228
+ */ const emails = Array.isArray(snapshot.get('emails')) ? snapshot.get('emails').map(String) : undefined;
2229
+ if (audience === 'manual' && (followUp || !(emails == null ? void 0 : emails.length))) {
2230
+ throw new CampaignSendError(followUp ? 'This email went to addresses typed into the composer, which are ' + 'not kept, so there is no audience to add anybody from. Compose a ' + 'new email to the people you want to reach.' : 'This email is addressed to typed-in recipients but records none, ' + 'so there is nobody to send it to.', 400);
2231
+ }
2232
+ const templateScreenId = String((_snapshot_get1 = snapshot.get('templateScreenId')) != null ? _snapshot_get1 : '');
2233
+ const body = String((_snapshot_get2 = snapshot.get('body')) != null ? _snapshot_get2 : '');
2234
+ if (!templateScreenId && !body) {
2235
+ throw new CampaignSendError('This email has no message to send', 400);
2236
+ }
2237
+ const optional = (field)=>{
2238
+ var _snapshot_get;
2239
+ const value = String((_snapshot_get = snapshot.get(field)) != null ? _snapshot_get : '');
2240
+ return value ? {
2241
+ [field]: value
2242
+ } : {};
2243
+ };
2244
+ return _extends({
2245
+ hostId,
2246
+ campaignId: snapshot.id
2247
+ }, followUp ? {
2248
+ followUp: true
2249
+ } : {}, {
2250
+ senderUid,
2251
+ subject: String((_snapshot_get3 = snapshot.get('subject')) != null ? _snapshot_get3 : ''),
2252
+ body,
2253
+ audience
2254
+ }, (emails == null ? void 0 : emails.length) ? {
2255
+ emails
2256
+ } : {}, templateScreenId ? {
2257
+ templateScreenId
2258
+ } : {}, optional('plainText'), optional('segmentId'), optional('listId'), optional('topicId'), optional('fromName'), optional('replyTo'), optional('senderId'), optional('preheader'), optional('displayName'), optional('emailCampaignId'), followUp ? {} : optional('experimentId'));
2259
+ }
2260
+ /**
2261
+ * Campaign API (AGL-161/272): `action` picks the operation.
2262
+ *
2263
+ * Every one of them requires a site admin or editor.
2264
+ *
2265
+ * ## The ones that mail something
2266
+ *
2267
+ * `send` (the default) delivers copy carried in the request. `sendNow` mails
2268
+ * a draft or a scheduled email ahead of its time, and `followUp` mails an
2269
+ * already-sent one to the people it has not reached; both of those take every
2270
+ * field off the RECORD rather than the request. `test` delivers to the caller
2271
+ * alone and records nothing.
2272
+ *
2273
+ * ## The ones that only write
2274
+ *
2275
+ * `draft` stores an email that has not been sent, `schedule` stores one with
2276
+ * a `sendAtMs` for the processor to deliver, `update` corrects the merchant's
2277
+ * own name for an email at any point in its life, and `cancel` withdraws a
2278
+ * scheduled one. None of them reserves allowance or moves a meter.
2279
+ *
2280
+ * ## The ones that answer a question
2281
+ *
2282
+ * `preview` resolves the audience and reports the counts, `renderPreview`
2283
+ * renders the composed message. Neither writes.
2284
+ */ export const campaignSendHandler = async (req, res)=>{
2285
+ var _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6, _ref7, _ref8, _ref9, _ref10, _req_headers_authorization;
2286
+ var _req_body, _req_body1, _req_body2, _req_body3, _req_body4, _req_body5, _req_body6, _req_body7, _req_body8, _req_body9, _req_body10, _req_body11, _req_body12, _req_body13, _req_body14;
2287
+ if (req.method !== 'POST') {
2288
+ return res.status(405).json({
2289
+ error: 'Method not allowed'
2290
+ });
2291
+ }
2292
+ const hostId = String((_ref = (_req_body = req.body) == null ? void 0 : _req_body.hostId) != null ? _ref : '');
2293
+ const action = String((_ref1 = (_req_body1 = req.body) == null ? void 0 : _req_body1.action) != null ? _ref1 : 'send');
2294
+ const subject = String((_ref2 = (_req_body2 = req.body) == null ? void 0 : _req_body2.subject) != null ? _ref2 : '').trim().slice(0, 150);
2295
+ const body = String((_ref3 = (_req_body3 = req.body) == null ? void 0 : _req_body3.body) != null ? _ref3 : '').trim().slice(0, 20000);
2296
+ const audience = String((_ref4 = (_req_body4 = req.body) == null ? void 0 : _req_body4.audience) != null ? _ref4 : 'leads');
2297
+ const templateScreenId = String((_ref5 = (_req_body5 = req.body) == null ? void 0 : _req_body5.templateScreenId) != null ? _ref5 : '');
2298
+ /*
2299
+ * THE AUTHOR'S OWN PLAIN-TEXT PART, for a designed email.
2300
+ *
2301
+ * Capped like the body and NOT flattened like the header fields beside it:
2302
+ * this is a message part rather than a header value, so its line breaks are
2303
+ * the formatting a text-only reader gets rather than an injection shape.
2304
+ */ const plainText = String((_ref6 = (_req_body6 = req.body) == null ? void 0 : _req_body6.plainText) != null ? _ref6 : '').trim().slice(0, 20000);
2305
+ /*
2306
+ * Which design version that part was written against, so a composer can say
2307
+ * when it has gone stale. Validated as a document id because it is stored
2308
+ * and compared against one.
2309
+ */ const plainTextVersionId = String((_ref7 = (_req_body7 = req.body) == null ? void 0 : _req_body7.plainTextVersionId) != null ? _ref7 : '');
2310
+ /*
2311
+ * The composer's sender fields, and the one rule they all obey: a value a
2312
+ * merchant typed reaches a MIME header, so it is flattened to a single line
2313
+ * before it goes anywhere. `applyFromName` quotes the display name and
2314
+ * strips quotes from it, but nothing downstream removes a CR or an LF, and
2315
+ * a header value carrying one is the injection shape.
2316
+ */ const headerSafe = (value, max)=>String(value != null ? value : '')/*
2317
+ * The control characters are the POINT of this class rather than an
2318
+ * accident in it: CR and LF inside a header value ARE the injection
2319
+ * shape, and `no-control-regex` cannot tell a pattern that matches
2320
+ * them in order to remove them from one that matches them by mistake.
2321
+ */ // eslint-disable-next-line no-control-regex
2322
+ .replace(/[\s\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, max);
2323
+ // 78 characters is the line length a display name has to live inside.
2324
+ const fromName = headerSafe((_req_body8 = req.body) == null ? void 0 : _req_body8.fromName, 78);
2325
+ const replyTo = headerSafe((_req_body9 = req.body) == null ? void 0 : _req_body9.replyTo, 254).toLowerCase();
2326
+ const preheader = headerSafe((_req_body10 = req.body) == null ? void 0 : _req_body10.preheader, 200);
2327
+ /*
2328
+ * The email's own name, which is console-only. Flattened to a single line
2329
+ * with the header fields beside it even though it reaches no header: it is
2330
+ * rendered into a table and a page title, and a value carrying control
2331
+ * characters is worth normalizing wherever it is going.
2332
+ */ const displayName = headerSafe((_req_body11 = req.body) == null ? void 0 : _req_body11.displayName, 60);
2333
+ /*
2334
+ * `req.body.sendingIdentity` is READ BY NOTHING.
2335
+ *
2336
+ * A body naming `acme.com`, or `platform`, is not an error and is not
2337
+ * honored: the DOMAIN is resolved from the host document, so there is no
2338
+ * value this field could carry that would reach it. Left undocumented it
2339
+ * would look like an oversight; said here, it is the closure.
2340
+ *
2341
+ * `senderId` below does not reopen it, and is worth reading against it. What
2342
+ * was closed is a request naming an ADDRESS — a domain, or a free local part
2343
+ * — because a mailbox has to be one somebody serves. What this names is a
2344
+ * row in `hosts/{hostId}/senders`, written under the `org.settings` gate and
2345
+ * validated there, so the addresses it can reach are the ones this site was
2346
+ * already configured to send as. An id this site does not hold is refused
2347
+ * rather than defaulted, which is the property that keeps the two apart:
2348
+ * nothing a request says can produce an address that was not configured.
2349
+ */ const senderId = String((_ref8 = (_req_body12 = req.body) == null ? void 0 : _req_body12.senderId) != null ? _ref8 : '');
2350
+ if (senderId && !isDocumentId(senderId)) {
2351
+ return res.status(400).json({
2352
+ error: 'Invalid sender'
2353
+ });
2354
+ }
2355
+ if (plainTextVersionId && !isDocumentId(plainTextVersionId)) {
2356
+ return res.status(400).json({
2357
+ error: 'Invalid design version'
2358
+ });
2359
+ }
2360
+ // The campaign this send joins. Validated as a document id here because it
2361
+ // is stored and later queried as one.
2362
+ const emailCampaignId = String((_ref9 = (_req_body13 = req.body) == null ? void 0 : _req_body13.emailCampaignId) != null ? _ref9 : '');
2363
+ if (!hostId) return res.status(400).json({
2364
+ error: 'Missing hostId'
2365
+ });
2366
+ if (emailCampaignId && !isDocumentId(emailCampaignId)) {
2367
+ return res.status(400).json({
2368
+ error: 'Invalid campaign'
2369
+ });
2370
+ }
2371
+ if (replyTo && !EMAIL_PATTERN.test(replyTo)) {
2372
+ return res.status(400).json({
2373
+ error: 'Reply-to must be an email address'
2374
+ });
2375
+ }
2376
+ /*
2377
+ * Designed emails carry their content in the template; plain sends still
2378
+ * need subject + body.
2379
+ *
2380
+ * THE ACTIONS THAT MAIL NOTHING ARE EXEMPT, and the composer is the reason.
2381
+ * It asks for the recipient count as soon as it mounts — before any copy
2382
+ * exists, which is the whole point of asking — so requiring copy of
2383
+ * `preview` refused every count a plain-text campaign ever asked for, and
2384
+ * the readout under the Subject field showed this message instead of the
2385
+ * audience size and the consent split. The preview branch below substitutes
2386
+ * placeholder copy precisely because it needs none: the count is a fact
2387
+ * about the audience, and no part of resolving it reads the subject or the
2388
+ * body. `renderPreview` is exempt for the same reason in the other
2389
+ * direction — it renders whatever has been typed so far, including nothing.
2390
+ */ /*
2391
+ * `followUp` and `sendNow` join the exempt actions, and for a stricter
2392
+ * reason than the other three: they do not merely need no copy, they must
2393
+ * be given none. The message they mail is the one already on the record —
2394
+ * see `storedSendOptionsFrom` — so a subject and body in the request would
2395
+ * be fields the route silently discards, and a required field that is
2396
+ * discarded is the shape that teaches a caller it was used.
2397
+ *
2398
+ * `draft` is exempt for the opposite reason. A draft is an email that has
2399
+ * not been written yet: requiring a subject and a body of it would mean
2400
+ * there is no way to create one, which is the whole state.
2401
+ *
2402
+ * `update` is exempt because it edits neither — it carries a name and
2403
+ * nothing else.
2404
+ */ /*
2405
+ * `proofOptions` joins them for the plainest of the reasons: it answers who
2406
+ * a test may be sent to and whose data could fill it, which is a question
2407
+ * about the workspace and not about the message. The composer asks it when
2408
+ * the test drawer opens, which is routinely before a subject exists.
2409
+ */ const mails = action !== 'cancel' && action !== 'preview' && action !== 'proofOptions' && action !== 'renderPreview' && action !== 'followUp' && action !== 'sendNow' && action !== 'draft' && action !== 'update';
2410
+ if (mails && !templateScreenId && (!subject || !body)) {
2411
+ return res.status(400).json({
2412
+ error: 'Missing subject or body'
2413
+ });
2414
+ }
2415
+ /*==========================================
2416
+ * ONE MESSAGE, ONE SOURCE.
2417
+ *
2418
+ * `body` is the MESSAGE of a plain-text email — the thing recipients read,
2419
+ * from which the HTML part is synthesized. `plainText` is the text HALF of a
2420
+ * designed email, whose message lives in the nodes. Two strings of plain
2421
+ * text, two different jobs, and each belongs to exactly one mode.
2422
+ *
2423
+ * A `body` arriving beside a template used to be accepted, computed for
2424
+ * merge tags and then dropped: `renderRecipientEmail` read it only when no
2425
+ * template was given, and both gates — this one and the composer's — passed
2426
+ * on EITHER input, so a merchant who picked a design and also wrote a
2427
+ * message lost the message with nothing said.
2428
+ *
2429
+ * It is refused rather than quietly reinterpreted as the text part. Copy
2430
+ * written as "the message" is not copy reviewed as "what a text-only reader
2431
+ * gets", and promoting it would start mailing unreviewed text out of records
2432
+ * that already exist. What the author wants is offered explicitly instead —
2433
+ * `plainText`, which they can see, edit and preview — and the composer
2434
+ * shows a stranded body rather than adopting it.
2435
+ *
2436
+ * `plainText` without a template is refused for the mirror reason: a
2437
+ * plain-text email's text part IS its body, so a second string claiming to
2438
+ * be the text part is the same two-sources problem under a new name.
2439
+ *
2440
+ * Only the actions that carry COMPOSED copy are checked. `preview`
2441
+ * substitutes placeholder copy and reads neither; `followUp`, `sendNow`,
2442
+ * `update` and `cancel` mail what the record already holds, and a record
2443
+ * written before this rule may legitimately still carry an inert body
2444
+ * beside its template — refusing those would strand an existing draft over
2445
+ * a field its send has never read.
2446
+ *=========================================*/ const carriesComposedCopy = action === 'send' || action === 'schedule' || action === 'draft' || action === 'test' || action === 'renderPreview';
2447
+ if (carriesComposedCopy && templateScreenId && body) {
2448
+ return res.status(400).json({
2449
+ error: 'This email is built from a design, which carries its own message. ' + 'Write the plain-text version instead, or switch this email to ' + 'plain text.'
2450
+ });
2451
+ }
2452
+ if (carriesComposedCopy && !templateScreenId && plainText) {
2453
+ return res.status(400).json({
2454
+ error: 'A plain-text email has no separate text version — what you type is ' + 'the message.'
2455
+ });
2456
+ }
2457
+ if (![
2458
+ 'leads',
2459
+ 'members',
2460
+ 'manual',
2461
+ 'segment',
2462
+ 'list'
2463
+ ].includes(audience)) {
2464
+ return res.status(400).json({
2465
+ error: 'Unknown audience'
2466
+ });
2467
+ }
2468
+ /*
2469
+ * The composer's topic, refused here as well as inside `performCampaignSend`.
2470
+ *
2471
+ * Both, because the SCHEDULE branch below writes the campaign document
2472
+ * without going through the send — the same asymmetry AGL-1771 found for
2473
+ * `campaignId` — so a topic that only the send validated would be stored
2474
+ * unchecked and then signed into a link a fortnight later.
2475
+ *
2476
+ * An empty value is not an error: it means "the composer did not say", which
2477
+ * `performCampaignSend` resolves to the default topic.
2478
+ */ const topicId = String((_ref10 = (_req_body14 = req.body) == null ? void 0 : _req_body14.topicId) != null ? _ref10 : '');
2479
+ if (topicId && !isEmailTopicId(topicId)) {
2480
+ return res.status(400).json({
2481
+ error: 'Unknown topic'
2482
+ });
2483
+ }
2484
+ const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
2485
+ const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
2486
+ if (!idToken) return res.status(401).json({
2487
+ error: 'Unauthenticated'
2488
+ });
2489
+ try {
2490
+ var _hostSnapshot_get, _ref11, _ref12, _ref13, _ref14;
2491
+ var _req_body15, _req_body16, _req_body17, _req_body18, _req_body19;
2492
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
2493
+ /*
2494
+ * A VERIFIED ADDRESS IS A PRECONDITION OF SENDING MAIL (AGL-479/2589),
2495
+ * and this is the door every send arrives at — broadcast, scheduled and
2496
+ * test alike, since all three branches sit below this check.
2497
+ *
2498
+ * Stated here rather than inferred, which is the whole change. The role
2499
+ * lookup on the next lines already refused an unverified account, but
2500
+ * only as a side effect of provisioning: nothing can enter a host's
2501
+ * `memberRoles` map without having verified, so the role resolved to
2502
+ * undefined and the 403 came from the wrong sentence. That is a fact
2503
+ * about how accounts are created, not a rule about who may send, and it
2504
+ * has already moved once — signup provisions an ORG for an account that
2505
+ * has not verified yet (AGL-2585). A comparable grace on host creation
2506
+ * would have opened this surface with nothing else in the way, on a
2507
+ * `p=reject` sending domain, and no test would have gone red.
2508
+ *
2509
+ * `campaign-send-verification.spec.ts` holds the property directly, so a
2510
+ * future grace fails a test instead of leaking reputation.
2511
+ *
2512
+ * Impersonation is exempt on the AGL-480 reasoning the other ~135 gates
2513
+ * use: staff have authenticated separately, the act is audited, and the
2514
+ * account most likely to need support is the newest one.
2515
+ */ if (!decoded.email_verified && !isImpersonationSession(decoded)) {
2516
+ // The shape of `emailUnverifiedResponse()` (AGL-479), written out
2517
+ // because that helper returns a fetch `Response` and this is a
2518
+ // (req, res) plugin handler.
2519
+ return res.status(403).json({
2520
+ error: 'Verify your email to continue',
2521
+ reason: 'email-unverified'
2522
+ });
2523
+ }
2524
+ const firestore = firebaseAdmin.app().firestore();
2525
+ const hostRef = firestore.collection('hosts').doc(hostId);
2526
+ const hostSnapshot = await hostRef.get();
2527
+ if (!hostSnapshot.exists) {
2528
+ return res.status(404).json({
2529
+ error: 'Unknown site'
2530
+ });
2531
+ }
2532
+ const memberRole = ((_hostSnapshot_get = hostSnapshot.get('memberRoles')) != null ? _hostSnapshot_get : {})[decoded.uid];
2533
+ if (memberRole !== 'admin' && memberRole !== 'editor') {
2534
+ return res.status(403).json({
2535
+ error: 'Not a site admin or editor'
2536
+ });
2537
+ }
2538
+ if (action === 'proofOptions') {
2539
+ var _decoded_email;
2540
+ /*
2541
+ * THE TWO LISTS THE TEST DRAWER IS BUILT FROM, and they are different
2542
+ * kinds of thing.
2543
+ *
2544
+ * `recipients` is a RULE made visible: these are the only addresses a
2545
+ * test may be delivered to, so the drawer offers them instead of a text
2546
+ * box whose every other answer is a refusal nobody could have predicted.
2547
+ *
2548
+ * `personas` is a CONVENIENCE: whose stored data a proof renders as.
2549
+ * Choosing one mails that person nothing, which the drawer says in as
2550
+ * many words — the two controls sit next to each other and the whole
2551
+ * risk of the feature is somebody reading the second as the first.
2552
+ */ const [recipients, personas] = await Promise.all([
2553
+ proofRecipientsForHost({
2554
+ hostId,
2555
+ callerEmail: String((_decoded_email = decoded.email) != null ? _decoded_email : '')
2556
+ }),
2557
+ proofPersonasForHost(hostId)
2558
+ ]);
2559
+ return res.status(200).json({
2560
+ recipients,
2561
+ personas
2562
+ });
2563
+ }
2564
+ if (action === 'test') {
2565
+ var _decoded_email1, _ref15, _ref16;
2566
+ var _req_body20, _req_body21;
2567
+ /*
2568
+ * PROOF ONE EMAIL: as somebody, to somebody, from an identity — and
2569
+ * still no campaign record and no counter.
2570
+ *
2571
+ * The three choices are independent and only one of them decides who
2572
+ * receives mail. `personaEmail` changes what the merge tags RESOLVE to
2573
+ * and reaches nobody; `to` is the only address anything is delivered
2574
+ * to. Which identity it leaves on is not among them: that is resolved
2575
+ * from the host document, and no request field reaches it.
2576
+ */ const ownEmail = String((_decoded_email1 = decoded.email) != null ? _decoded_email1 : '').trim().toLowerCase();
2577
+ const requestedTo = String((_ref15 = (_req_body20 = req.body) == null ? void 0 : _req_body20.to) != null ? _ref15 : '').trim().toLowerCase();
2578
+ const testEmail = requestedTo || ownEmail;
2579
+ if (!testEmail) {
2580
+ return res.status(400).json({
2581
+ error: 'Your account has no email address for tests'
2582
+ });
2583
+ }
2584
+ if (!EMAIL_PATTERN.test(testEmail)) {
2585
+ return res.status(400).json({
2586
+ error: 'Enter a valid address to send the test to'
2587
+ });
2588
+ }
2589
+ /*
2590
+ * WHO A TEST SEND MAY REACH, decided here because it is the only layer
2591
+ * that can ask whose address this is.
2592
+ *
2593
+ * A proof may go to the caller's own account address or to another
2594
+ * ACCOUNT HOLDER on the owning workspace, and to nobody else. The two
2595
+ * populations a merchant could otherwise reach through this button are
2596
+ * exactly the two that must not be reachable: a contact or lead, who is
2597
+ * subject to the consent rule this send is exempt from, and a stranger,
2598
+ * who has no relationship with the workspace at all.
2599
+ *
2600
+ * `siteMembers` are deliberately NOT eligible. They are the tenant's
2601
+ * customers — the audience — and an audience member reached by a
2602
+ * "test" is an audience member who has been mailed.
2603
+ */ const eligible = await eligibleProofAddress({
2604
+ hostId,
2605
+ callerEmail: ownEmail,
2606
+ address: testEmail
2607
+ });
2608
+ if (!eligible) {
2609
+ return res.status(403).json({
2610
+ error: `A test can only be sent to you or to someone with an account on ` + `this workspace, and ${testEmail} is neither. Add them to the ` + `workspace first, or send the test to yourself and forward it.`
2611
+ });
2612
+ }
2613
+ /*
2614
+ * The person whose data fills the merge tags. Looked up rather than
2615
+ * taken from the request, so what a proof shows is what the audience
2616
+ * document actually holds — a persona assembled from a request would
2617
+ * demonstrate the composer's own typing rather than the merge.
2618
+ */ const persona = await resolveProofPersona(hostId, String((_ref16 = (_req_body21 = req.body) == null ? void 0 : _req_body21.personaEmail) != null ? _ref16 : ''));
2619
+ const result = await performCampaignSend(_extends({
2620
+ hostId,
2621
+ subject,
2622
+ body: body || 'Test send',
2623
+ audience: 'manual',
2624
+ emails: [
2625
+ testEmail
2626
+ ],
2627
+ templateScreenId: templateScreenId || undefined
2628
+ }, plainText ? {
2629
+ plainText
2630
+ } : {}, {
2631
+ fromName,
2632
+ replyTo
2633
+ }, senderId ? {
2634
+ senderId
2635
+ } : {}, {
2636
+ preheader
2637
+ }, persona ? {
2638
+ proofPersona: persona
2639
+ } : {}, {
2640
+ recordCampaign: false,
2641
+ senderUid: decoded.uid,
2642
+ // Not marketing: the recipient holds an account on this workspace.
2643
+ proofFor: testEmail
2644
+ }));
2645
+ return res.status(200).json(_extends({}, result, {
2646
+ test: true,
2647
+ to: testEmail
2648
+ }, persona ? {
2649
+ personaEmail: persona.email
2650
+ } : {}));
2651
+ }
2652
+ if (action === 'renderPreview') {
2653
+ var _hostPublicOrigin, _decoded_email2, _decoded_name;
2654
+ /*
2655
+ * THE MESSAGE, RENDERED, AND NOT ONE ADDRESS RESOLVED.
2656
+ *
2657
+ * Separate from `preview` because the two answer different questions at
2658
+ * different costs. `preview` sweeps the audience — up to 5,000 documents
2659
+ * — to count people, and its answer changes only when the audience does.
2660
+ * This one answers "what does my email look like", which changes on
2661
+ * every keystroke, and reads at most the template and its products.
2662
+ * Folding the render into `preview` would page the merchant's whole
2663
+ * contact list once per debounce tick, for a number that had not moved.
2664
+ *
2665
+ * Rendered through `renderRecipientEmail`, which is what the per-recipient
2666
+ * send loop calls, so this is the HTML that will be mailed and not a
2667
+ * likeness of it.
2668
+ */ const template = templateScreenId ? await loadEmailTemplate(hostId, templateScreenId) : null;
2669
+ const siteBase = (_hostPublicOrigin = hostPublicOrigin({
2670
+ cname: hostSnapshot.get('cname'),
2671
+ subdomain: hostSnapshot.get('subdomain')
2672
+ })) != null ? _hostPublicOrigin : '';
2673
+ const orgForHost = await getOrgForHost(hostId);
2674
+ const branding = resolveBrandingProfile(orgForHost == null ? void 0 : orgForHost.org);
2675
+ /*
2676
+ * Personalized for the REQUESTER, because a preview showing raw
2677
+ * `{{firstName|there}}` tells a merchant nothing about what a recipient
2678
+ * will read, and inventing a fictional contact would make a merge tag
2679
+ * that resolves to nothing look like one that works.
2680
+ */ const rendered = renderRecipientEmail({
2681
+ subject,
2682
+ preheader,
2683
+ content: template ? _extends({
2684
+ mode: 'design',
2685
+ template
2686
+ }, plainText ? {
2687
+ plainText
2688
+ } : {}) : {
2689
+ mode: 'text',
2690
+ body
2691
+ },
2692
+ recipient: {
2693
+ email: String((_decoded_email2 = decoded.email) != null ? _decoded_email2 : ''),
2694
+ name: String((_decoded_name = decoded['name']) != null ? _decoded_name : '')
2695
+ },
2696
+ siteBase,
2697
+ hostId,
2698
+ // Unsigned, and it is not a working opt-out: minting a real signature
2699
+ // here would put a live preference link for the requester's own
2700
+ // address into a page they are only reading. The footer's presence,
2701
+ // and its wording, is what the preview is showing.
2702
+ unsubscribeUrl: `${siteBase}/api/email/preferences`
2703
+ });
2704
+ return res.status(200).json(_extends({}, rendered, {
2705
+ preheader: preheader || (template == null ? void 0 : template.preheader) || '',
2706
+ fromName: fromName || branding.fromName
2707
+ }, replyTo ? {
2708
+ replyTo
2709
+ } : {}));
2710
+ }
2711
+ if (action === 'preview') {
2712
+ var _ref17, _ref18;
2713
+ var _req_body22, _req_body23, _req_body24;
2714
+ // Read-only, and it needs the same admin/editor role as a send: the
2715
+ // audience size of someone else's site is not public information.
2716
+ const result = await performCampaignSend(_extends({
2717
+ hostId,
2718
+ subject: subject || 'preview',
2719
+ body: body || 'preview',
2720
+ audience,
2721
+ segmentId: String((_ref17 = (_req_body22 = req.body) == null ? void 0 : _req_body22.segmentId) != null ? _ref17 : ''),
2722
+ listId: String((_ref18 = (_req_body23 = req.body) == null ? void 0 : _req_body23.listId) != null ? _ref18 : ''),
2723
+ topicId: topicId || undefined,
2724
+ emails: Array.isArray((_req_body24 = req.body) == null ? void 0 : _req_body24.emails) ? req.body.emails.map(String) : undefined,
2725
+ templateScreenId: templateScreenId || undefined
2726
+ }, senderId ? {
2727
+ senderId
2728
+ } : {}, {
2729
+ senderUid: decoded.uid,
2730
+ dryRun: true
2731
+ }));
2732
+ return res.status(200).json(result);
2733
+ }
2734
+ if (action === 'followUp') {
2735
+ var _ref19;
2736
+ var _req_body25, _req_body26;
2737
+ /*
2738
+ * SEND AN EMAIL THAT HAS ALREADY GONE OUT TO THE PEOPLE IT HAS NOT
2739
+ * REACHED.
2740
+ *
2741
+ * The request names a site and an email and carries nothing else that
2742
+ * is read. Everything the send needs comes back off the record, and
2743
+ * `performCampaignSend` re-checks the admission rules this branch does
2744
+ * not check at all — it is the same authorization, the same consent
2745
+ * split, the same two suppression lists, the same monthly reservation
2746
+ * and the same hourly ceilings, because it is the same function.
2747
+ *
2748
+ * `dryRun` rides through so the console can ask how many people are
2749
+ * left before offering the button, and the answer comes from the code
2750
+ * that would do the sending rather than from a count of its own.
2751
+ */ const followUpId = String((_ref19 = (_req_body25 = req.body) == null ? void 0 : _req_body25.campaignId) != null ? _ref19 : '');
2752
+ if (!isDocumentId(followUpId)) {
2753
+ return res.status(400).json({
2754
+ error: 'Invalid campaignId'
2755
+ });
2756
+ }
2757
+ const sendSnapshot = await hostRef.collection('campaigns').doc(followUpId).get();
2758
+ if (!sendSnapshot.exists) {
2759
+ return res.status(404).json({
2760
+ error: 'Unknown email'
2761
+ });
2762
+ }
2763
+ const result = await performCampaignSend(_extends({}, storedSendOptionsFrom(sendSnapshot, hostId, decoded.uid, true), ((_req_body26 = req.body) == null ? void 0 : _req_body26.dryRun) ? {
2764
+ dryRun: true
2765
+ } : {}));
2766
+ return res.status(200).json(result);
2767
+ }
2768
+ /*==========================================
2769
+ * AN EMAIL THAT EXISTS BEFORE IT IS SENT.
2770
+ *
2771
+ * `draft` is the same write the `schedule` branch below makes, minus the
2772
+ * send time — a record in the same collection, under the same id it will
2773
+ * keep forever, carrying the copy composed so far and `status: 'draft'`.
2774
+ *
2775
+ * ## Why a state on the record rather than a collection of its own
2776
+ *
2777
+ * The id is the reason. `performCampaignSend` adopts a `campaignId` it is
2778
+ * given, so a draft becomes the sent email AT ITS OWN ID — which is what
2779
+ * makes `/marketing/campaigns/{sendId}` resolve from the moment the email is
2780
+ * created, and what keeps the `cid=` inside every delivered unsubscribe
2781
+ * HMAC pointing at the record it was minted for. A draft in a second
2782
+ * collection would have to be copied to a new id at send time, and the
2783
+ * URL a merchant had open would stop being the email's URL.
2784
+ *
2785
+ * ## What a draft costs
2786
+ *
2787
+ * Nothing. This branch reserves no monthly allowance, claims no hourly
2788
+ * budget and moves no meter — it writes one document, exactly as
2789
+ * `schedule` always has. The scheduled processor queries
2790
+ * `status == 'scheduled'`, so a draft is never picked up and cannot
2791
+ * escape on its own; `performCampaignSend` is the only thing that mails
2792
+ * it, and only when somebody asks.
2793
+ *=========================================*/ if (action === 'draft' || action === 'schedule' || action === 'update') {
2794
+ var _ref20, _ref21, _targetSnapshot_get;
2795
+ var _req_body27, _req_body28, _req_body29, _req_body30, _req_body31, _req_body32;
2796
+ const scheduling = action === 'schedule';
2797
+ const sendAtMs = Number((_ref20 = (_req_body27 = req.body) == null ? void 0 : _req_body27.sendAtMs) != null ? _ref20 : 0);
2798
+ if (scheduling && (!Number.isFinite(sendAtMs) || sendAtMs <= Date.now())) {
2799
+ return res.status(400).json({
2800
+ error: 'Pick a future send time'
2801
+ });
2802
+ }
2803
+ const campaignId = String((_ref21 = (_req_body28 = req.body) == null ? void 0 : _req_body28.campaignId) != null ? _ref21 : '') || createResourceUid();
2804
+ // AGL-1771: this branch WRITES, and it is the only campaign write that
2805
+ // does not go through `performCampaignSend`'s guard. A `campaignId` of
2806
+ // `a/b/c` scheduled the campaign at `campaigns/a/b/c` — which the
2807
+ // scheduled-campaign processor would then pick up by `collectionGroup`
2808
+ // and send, from a document the merchant can neither see in their
2809
+ // campaigns list nor cancel.
2810
+ if (!isDocumentId(campaignId)) {
2811
+ return res.status(400).json({
2812
+ error: 'Invalid campaignId'
2813
+ });
2814
+ }
2815
+ const targetRef = hostRef.collection('campaigns').doc(campaignId);
2816
+ const targetSnapshot = await targetRef.get();
2817
+ const targetState = targetSnapshot.exists ? String((_targetSnapshot_get = targetSnapshot.get('status')) != null ? _targetSnapshot_get : '') : '';
2818
+ /*==========================================
2819
+ * WHAT IS ALREADY IN AN INBOX IS NOT EDITABLE.
2820
+ *
2821
+ * These branches address an EXISTING document by id, so without this
2822
+ * check `schedule` would happily merge a new subject and body onto an
2823
+ * email that went out last March and set its status back to
2824
+ * `scheduled` — rewriting the record of what was delivered, and handing
2825
+ * the processor a message to mail a second time under a `cid` whose
2826
+ * unsubscribe links are already in inboxes.
2827
+ *
2828
+ * So copy may only be written while the email is still unsent. `update`
2829
+ * is the deliberate exception and is why it is in this branch at all:
2830
+ * it writes the merchant's own NAME for the email and nothing else —
2831
+ * see the write below — which is console-only text that reached no
2832
+ * recipient and therefore contradicts no delivered mail.
2833
+ *=========================================*/ const rewritable = !targetSnapshot.exists || targetState === 'draft' || targetState === 'scheduled';
2834
+ if (action !== 'update' && !rewritable) {
2835
+ return res.status(409).json({
2836
+ error: targetState === 'sent' ? 'This email has already been sent, so its message and ' + 'audience can no longer be changed. Compose a new email.' : 'This email was canceled, so it can no longer be scheduled. ' + 'Compose a new email.'
2837
+ });
2838
+ }
2839
+ if (action === 'update') {
2840
+ /*
2841
+ * The one field a sent email still owns. It is the friendly name the
2842
+ * create drawer captures — never the subject, which describes mail
2843
+ * that is already in inboxes — so it can be corrected at any point in
2844
+ * an email's life without making the record disagree with what was
2845
+ * delivered.
2846
+ */ if (!targetSnapshot.exists) {
2847
+ return res.status(404).json({
2848
+ error: 'Unknown email'
2849
+ });
2850
+ }
2851
+ await targetRef.set({
2852
+ displayName
2853
+ }, {
2854
+ merge: true
2855
+ });
2856
+ return res.status(200).json({
2857
+ campaignId,
2858
+ displayName
2859
+ });
2860
+ }
2861
+ await targetRef.set(_extends({
2862
+ subject,
2863
+ body,
2864
+ audience
2865
+ }, targetSnapshot.exists ? {} : {
2866
+ createdAtMs: Date.now()
2867
+ }, ((_req_body29 = req.body) == null ? void 0 : _req_body29.segmentId) ? {
2868
+ segmentId: String(req.body.segmentId)
2869
+ } : {}, ((_req_body30 = req.body) == null ? void 0 : _req_body30.listId) ? {
2870
+ listId: String(req.body.listId)
2871
+ } : {}, topicId ? {
2872
+ topicId
2873
+ } : {}, Array.isArray((_req_body31 = req.body) == null ? void 0 : _req_body31.emails) ? {
2874
+ emails: req.body.emails.map(String).slice(0, 500)
2875
+ } : {}, ((_req_body32 = req.body) == null ? void 0 : _req_body32.experimentId) ? {
2876
+ experimentId: String(req.body.experimentId)
2877
+ } : {}, {
2878
+ /*
2879
+ * THE TEMPLATE IS CLEARED WHEN THERE IS NONE, not merely omitted.
2880
+ *
2881
+ * `templateScreenId` is the field that decides which of the two ways
2882
+ * this email is written — see `emailMessageMode` — so leaving it
2883
+ * standing under `merge: true` is not a stale pointer, it is the
2884
+ * wrong mode. A draft moved from a design to a typed message would
2885
+ * be stored carrying BOTH, reopen as designed, and mail the design
2886
+ * while the message the merchant just wrote sat unread on the
2887
+ * record. The same discard this pair of fields already produced
2888
+ * once, arriving by the save path instead of the send path.
2889
+ */ templateScreenId: templateScreenId || firebaseAdmin.firestore.FieldValue.delete(),
2890
+ /*
2891
+ * The authored plain-text part, and the design version it was
2892
+ * written against. Cleared together and for the same reason the
2893
+ * template is: an override left standing under `merge: true` after
2894
+ * somebody switched this email to plain text would be a text part
2895
+ * for a design the record no longer names, and the staleness the
2896
+ * version id exists to expose would be measured against nothing.
2897
+ */ plainText: plainText || firebaseAdmin.firestore.FieldValue.delete(),
2898
+ plainTextVersionId: plainText && plainTextVersionId || firebaseAdmin.firestore.FieldValue.delete()
2899
+ }, fromName ? {
2900
+ fromName
2901
+ } : {}, replyTo ? {
2902
+ replyTo
2903
+ } : {}, senderId ? {
2904
+ senderId
2905
+ } : {}, preheader ? {
2906
+ preheader
2907
+ } : {}, displayName ? {
2908
+ displayName
2909
+ } : {}, emailCampaignId ? {
2910
+ emailCampaignId
2911
+ } : {}, scheduling ? {
2912
+ status: 'scheduled',
2913
+ sendAtMs,
2914
+ scheduledAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
2915
+ scheduledBy: decoded.uid
2916
+ } : {
2917
+ status: 'draft',
2918
+ /*
2919
+ * The send time is CLEARED rather than left standing.
2920
+ *
2921
+ * Saving a scheduled email back to a draft is how a merchant
2922
+ * takes it off the clock, and `merge: true` leaves any field
2923
+ * this write does not name — so a `sendAtMs` left behind
2924
+ * would sit on a draft as a due date nothing acts on, and the
2925
+ * emails list orders on exactly that field.
2926
+ */ sendAtMs: firebaseAdmin.firestore.FieldValue.delete(),
2927
+ draftedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
2928
+ draftedBy: decoded.uid
2929
+ }), {
2930
+ merge: true
2931
+ });
2932
+ return res.status(200).json({
2933
+ campaignId,
2934
+ status: scheduling ? 'scheduled' : 'draft'
2935
+ });
2936
+ }
2937
+ if (action === 'cancel') {
2938
+ var _ref22;
2939
+ var _req_body33;
2940
+ const campaignId = String((_ref22 = (_req_body33 = req.body) == null ? void 0 : _req_body33.campaignId) != null ? _ref22 : '');
2941
+ // AGL-1771: the ref used to be built one line ABOVE the `campaignId ?`
2942
+ // check below, which defeated that check — `.doc('')` throws on an empty
2943
+ // path segment, so the 400 this branch intends became a 500. Guarding
2944
+ // first is what lets the ref be built at all.
2945
+ if (!isDocumentId(campaignId)) {
2946
+ return res.status(400).json({
2947
+ error: 'Not a scheduled campaign'
2948
+ });
2949
+ }
2950
+ const campaignRef = hostRef.collection('campaigns').doc(campaignId);
2951
+ const campaignSnapshot = await campaignRef.get();
2952
+ if (!(campaignSnapshot == null ? void 0 : campaignSnapshot.exists) || campaignSnapshot.get('status') !== 'scheduled') {
2953
+ return res.status(400).json({
2954
+ error: 'Not a scheduled campaign'
2955
+ });
2956
+ }
2957
+ await campaignRef.set({
2958
+ status: 'canceled',
2959
+ canceledAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
2960
+ canceledBy: decoded.uid
2961
+ }, {
2962
+ merge: true
2963
+ });
2964
+ return res.status(200).json({
2965
+ campaignId,
2966
+ status: 'canceled'
2967
+ });
2968
+ }
2969
+ if (action === 'sendNow') {
2970
+ var _ref23, _sendNowSnapshot_get, _ref24;
2971
+ var _req_body34, _sendNowSnapshot_get1;
2972
+ /*==========================================
2973
+ * MAIL A DRAFTED OR SCHEDULED EMAIL, NOW.
2974
+ *
2975
+ * The request names a site and an email and carries nothing else that
2976
+ * is read. Everything comes off the record through
2977
+ * `storedSendOptionsFrom`, for the reason documented there: this branch
2978
+ * addresses an existing document by id, and a caller who could also
2979
+ * supply the copy could put arbitrary text on somebody else's send id
2980
+ * and mail it under that id's `cid`.
2981
+ *
2982
+ * It is not a second send path. `performCampaignSend` runs whole — the
2983
+ * same authorization, the same consent split, the same two suppression
2984
+ * lists, the same topic filtering, the same monthly reservation and the
2985
+ * same hourly governor — because it is the same function the scheduled
2986
+ * processor and the composer both call.
2987
+ *=========================================*/ const sendNowId = String((_ref23 = (_req_body34 = req.body) == null ? void 0 : _req_body34.campaignId) != null ? _ref23 : '');
2988
+ if (!isDocumentId(sendNowId)) {
2989
+ return res.status(400).json({
2990
+ error: 'Invalid campaignId'
2991
+ });
2992
+ }
2993
+ const sendNowRef = hostRef.collection('campaigns').doc(sendNowId);
2994
+ const sendNowSnapshot = await sendNowRef.get();
2995
+ if (!sendNowSnapshot.exists) {
2996
+ return res.status(404).json({
2997
+ error: 'Unknown email'
2998
+ });
2999
+ }
3000
+ const sendNowState = String((_sendNowSnapshot_get = sendNowSnapshot.get('status')) != null ? _sendNowSnapshot_get : '');
3001
+ /*
3002
+ * Only an email that has not gone out yet. A sent one would be mailed a
3003
+ * second time to the whole audience under the same id — which is what
3004
+ * `followUp` exists to do safely, minus everyone already reached — and
3005
+ * a canceled one was withdrawn on purpose.
3006
+ */ if (sendNowState !== 'draft' && sendNowState !== 'scheduled') {
3007
+ return res.status(400).json({
3008
+ error: sendNowState === 'sent' ? 'This email has already been sent. Use "Send to more ' + 'recipients" to reach the people it has not.' : 'Only a draft or a scheduled email can be sent now'
3009
+ });
3010
+ }
3011
+ /*==========================================
3012
+ * A CAMPAIGN BETWEEN BATCHES IS `scheduled`, AND MUST NOT RESTART.
3013
+ *
3014
+ * An audience larger than one send goes out over several runs, and
3015
+ * between them the email is stored as `scheduled` — the state the
3016
+ * processor claims to continue it. So the check above admits it, and
3017
+ * this branch would then call `performCampaignSend` with no
3018
+ * `continuation`: the whole audience resolved again, nobody subtracted,
3019
+ * and a second copy delivered to everybody the earlier batches already
3020
+ * reached, under the same `cid` whose unsubscribe links are in their
3021
+ * inboxes.
3022
+ *
3023
+ * Refused rather than quietly turned into a continuation. The rest of
3024
+ * this campaign is already due — the processor picks it up on its next
3025
+ * beat — so there is nothing for a merchant to ask for here, and
3026
+ * "Send now" on a send already in flight is a request made under a
3027
+ * misunderstanding the answer should correct.
3028
+ *=========================================*/ const resumeRemaining = Math.floor(Number((_ref24 = (_sendNowSnapshot_get1 = sendNowSnapshot.get('resume')) == null ? void 0 : _sendNowSnapshot_get1.remaining) != null ? _ref24 : 0));
3029
+ if (Number.isFinite(resumeRemaining) && resumeRemaining > 0) {
3030
+ return res.status(409).json({
3031
+ error: 'This email is already being sent, and the rest of it goes out ' + 'on its own. Sending it now would mail a second copy to ' + 'everyone it has already reached.'
3032
+ });
3033
+ }
3034
+ /*
3035
+ * CLAIMED BEFORE IT IS MAILED, exactly as the scheduled processor
3036
+ * claims one.
3037
+ *
3038
+ * A scheduled email whose time arrives mid-send would otherwise be
3039
+ * picked up by the processor's `status == 'scheduled'` query while this
3040
+ * request is still resolving its audience, and mailed twice. Moving it
3041
+ * to `sending` first is the same claim under the same transaction the
3042
+ * processor uses, so whichever gets there first is the only one that
3043
+ * sends.
3044
+ */ const claimed = await firestore.runTransaction(async (transaction)=>{
3045
+ var _fresh_get;
3046
+ const fresh = await transaction.get(sendNowRef);
3047
+ if (String((_fresh_get = fresh.get('status')) != null ? _fresh_get : '') !== sendNowState) return false;
3048
+ transaction.update(sendNowRef, {
3049
+ status: 'sending'
3050
+ });
3051
+ return true;
3052
+ });
3053
+ if (!claimed) {
3054
+ return res.status(409).json({
3055
+ error: 'This email is already being sent'
3056
+ });
3057
+ }
3058
+ try {
3059
+ var _req_body35, _req_body36;
3060
+ const result = await performCampaignSend(_extends({}, storedSendOptionsFrom(sendNowSnapshot, hostId, decoded.uid, false), ((_req_body35 = req.body) == null ? void 0 : _req_body35.dryRun) ? {
3061
+ dryRun: true
3062
+ } : {}));
3063
+ /*
3064
+ * A dry run writes nothing, so the claim above is the only change it
3065
+ * made and it has to be put back — otherwise asking how many people
3066
+ * an email would reach would leave it stuck in `sending`.
3067
+ */ if ((_req_body36 = req.body) == null ? void 0 : _req_body36.dryRun) {
3068
+ await sendNowRef.set({
3069
+ status: sendNowState
3070
+ }, {
3071
+ merge: true
3072
+ });
3073
+ }
3074
+ return res.status(200).json(result);
3075
+ } catch (error) {
3076
+ // The claim is released on every failure. `performCampaignSend`
3077
+ // writes `status: 'sent'` itself on the way out, so nothing here
3078
+ // needs to set it — but a refusal that left the email in `sending`
3079
+ // would be an email the merchant can neither send nor cancel.
3080
+ await sendNowRef.set({
3081
+ status: sendNowState
3082
+ }, {
3083
+ merge: true
3084
+ });
3085
+ throw error;
3086
+ }
3087
+ }
3088
+ /*==========================================
3089
+ * AN IMMEDIATE SEND MAY NAME AN EMAIL, BUT ONLY AN UNSENT ONE.
3090
+ *
3091
+ * This branch takes its copy from the REQUEST, which is what the composer
3092
+ * needs — it is sending the message being typed. But it also accepts a
3093
+ * `campaignId`, and `performCampaignSend` adopts it, so without this check
3094
+ * a request naming a send that already went out would write new copy over
3095
+ * the record of what was delivered, replace its counters with this send's
3096
+ * own, and mail the whole audience a second copy under a `cid` whose
3097
+ * unsubscribe links are already in inboxes.
3098
+ *
3099
+ * Reaching the people an existing email has NOT reached is `followUp`,
3100
+ * which takes no copy from the request at all and subtracts everyone the
3101
+ * earlier sends recorded.
3102
+ *=========================================*/ const sendId = String((_ref11 = (_req_body15 = req.body) == null ? void 0 : _req_body15.campaignId) != null ? _ref11 : '');
3103
+ if (sendId) {
3104
+ var _existing_get;
3105
+ if (!isDocumentId(sendId)) {
3106
+ return res.status(400).json({
3107
+ error: 'Invalid campaignId'
3108
+ });
3109
+ }
3110
+ const existing = await hostRef.collection('campaigns').doc(sendId).get();
3111
+ const existingState = existing.exists ? String((_existing_get = existing.get('status')) != null ? _existing_get : '') : '';
3112
+ if (existing.exists && existingState !== 'draft' && existingState !== 'scheduled') {
3113
+ return res.status(409).json({
3114
+ error: existingState === 'sent' ? 'This email has already been sent. Use "Send to more ' + 'recipients" to reach the people it has not.' : 'This email was canceled, so it cannot be sent. Compose a ' + 'new email.'
3115
+ });
3116
+ }
3117
+ }
3118
+ const result = await performCampaignSend(_extends({
3119
+ hostId,
3120
+ subject,
3121
+ body,
3122
+ audience,
3123
+ segmentId: String((_ref12 = (_req_body16 = req.body) == null ? void 0 : _req_body16.segmentId) != null ? _ref12 : ''),
3124
+ listId: String((_ref13 = (_req_body17 = req.body) == null ? void 0 : _req_body17.listId) != null ? _ref13 : ''),
3125
+ topicId: topicId || undefined,
3126
+ emails: Array.isArray((_req_body18 = req.body) == null ? void 0 : _req_body18.emails) ? req.body.emails : undefined,
3127
+ campaignId: sendId,
3128
+ experimentId: String((_ref14 = (_req_body19 = req.body) == null ? void 0 : _req_body19.experimentId) != null ? _ref14 : ''),
3129
+ templateScreenId: templateScreenId || undefined
3130
+ }, plainText ? {
3131
+ plainText
3132
+ } : {}, {
3133
+ fromName,
3134
+ replyTo
3135
+ }, senderId ? {
3136
+ senderId
3137
+ } : {}, {
3138
+ preheader,
3139
+ displayName,
3140
+ emailCampaignId,
3141
+ senderUid: decoded.uid
3142
+ }));
3143
+ return res.status(200).json(result);
3144
+ } catch (error) {
3145
+ if (error instanceof CampaignSendError) {
3146
+ return res.status(error.status).json({
3147
+ error: error.message
3148
+ });
3149
+ }
3150
+ console.error(error);
3151
+ return res.status(500).json({
3152
+ error: 'Campaign send failed'
3153
+ });
3154
+ }
3155
+ };
3156
+
3157
+ //# sourceMappingURL=campaign-send.js.map