@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.
- package/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +56 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/announcement-bar-card.component.d.ts +20 -0
- package/src/lib/components/announcement-bar-card.component.js +292 -0
- package/src/lib/components/announcement-bar-card.component.js.map +1 -0
- package/src/lib/components/campaign-composer.d.ts +84 -0
- package/src/lib/components/campaign-composer.js +1643 -0
- package/src/lib/components/campaign-composer.js.map +1 -0
- package/src/lib/components/campaign-conversions-card.d.ts +22 -0
- package/src/lib/components/campaign-conversions-card.js +642 -0
- package/src/lib/components/campaign-conversions-card.js.map +1 -0
- package/src/lib/components/campaign-detail-card.d.ts +55 -0
- package/src/lib/components/campaign-detail-card.js +771 -0
- package/src/lib/components/campaign-detail-card.js.map +1 -0
- package/src/lib/components/campaign-edit-drawer.d.ts +60 -0
- package/src/lib/components/campaign-edit-drawer.js +252 -0
- package/src/lib/components/campaign-edit-drawer.js.map +1 -0
- package/src/lib/components/campaign-email-zones.d.ts +111 -0
- package/src/lib/components/campaign-email-zones.js +54 -0
- package/src/lib/components/campaign-email-zones.js.map +1 -0
- package/src/lib/components/campaign-glance-card.component.d.ts +18 -0
- package/src/lib/components/campaign-glance-card.component.js +151 -0
- package/src/lib/components/campaign-glance-card.component.js.map +1 -0
- package/src/lib/components/campaign-members-section.d.ts +87 -0
- package/src/lib/components/campaign-members-section.js +499 -0
- package/src/lib/components/campaign-members-section.js.map +1 -0
- package/src/lib/components/campaign-reach-sections.d.ts +137 -0
- package/src/lib/components/campaign-reach-sections.js +694 -0
- package/src/lib/components/campaign-reach-sections.js.map +1 -0
- package/src/lib/components/campaign-report-card.d.ts +91 -0
- package/src/lib/components/campaign-report-card.js +667 -0
- package/src/lib/components/campaign-report-card.js.map +1 -0
- package/src/lib/components/campaign-test-send-drawer.d.ts +45 -0
- package/src/lib/components/campaign-test-send-drawer.js +238 -0
- package/src/lib/components/campaign-test-send-drawer.js.map +1 -0
- package/src/lib/components/campaigns-card.d.ts +31 -0
- package/src/lib/components/campaigns-card.js +667 -0
- package/src/lib/components/campaigns-card.js.map +1 -0
- package/src/lib/components/conversion-attribution.component.d.ts +62 -0
- package/src/lib/components/conversion-attribution.component.js +127 -0
- package/src/lib/components/conversion-attribution.component.js.map +1 -0
- package/src/lib/components/email-compose-card.d.ts +12 -0
- package/src/lib/components/email-compose-card.js +157 -0
- package/src/lib/components/email-compose-card.js.map +1 -0
- package/src/lib/components/email-detail.d.ts +40 -0
- package/src/lib/components/email-detail.js +1180 -0
- package/src/lib/components/email-detail.js.map +1 -0
- package/src/lib/components/email-edit-drawer.d.ts +54 -0
- package/src/lib/components/email-edit-drawer.js +172 -0
- package/src/lib/components/email-edit-drawer.js.map +1 -0
- package/src/lib/components/email-messages-widget.d.ts +22 -0
- package/src/lib/components/email-messages-widget.js +49 -0
- package/src/lib/components/email-messages-widget.js.map +1 -0
- package/src/lib/components/email-recipients-card.d.ts +50 -0
- package/src/lib/components/email-recipients-card.js +338 -0
- package/src/lib/components/email-recipients-card.js.map +1 -0
- package/src/lib/components/emails-list-card.d.ts +36 -0
- package/src/lib/components/emails-list-card.js +544 -0
- package/src/lib/components/emails-list-card.js.map +1 -0
- package/src/lib/components/experiment-zones.d.ts +78 -0
- package/src/lib/components/experiment-zones.js +65 -0
- package/src/lib/components/experiment-zones.js.map +1 -0
- package/src/lib/components/host-experiments-card.component.d.ts +17 -0
- package/src/lib/components/host-experiments-card.component.js +887 -0
- package/src/lib/components/host-experiments-card.component.js.map +1 -0
- package/src/lib/components/host-marketing-summary-card.component.d.ts +15 -0
- package/src/lib/components/host-marketing-summary-card.component.js +161 -0
- package/src/lib/components/host-marketing-summary-card.component.js.map +1 -0
- package/src/lib/components/host-overlays-card.component.d.ts +19 -0
- package/src/lib/components/host-overlays-card.component.js +783 -0
- package/src/lib/components/host-overlays-card.component.js.map +1 -0
- package/src/lib/components/marketing-console-page.d.ts +28 -0
- package/src/lib/components/marketing-console-page.js +166 -0
- package/src/lib/components/marketing-console-page.js.map +1 -0
- package/src/lib/components/marketing-console-sections.d.ts +34 -0
- package/src/lib/components/marketing-console-sections.js +77 -0
- package/src/lib/components/marketing-console-sections.js.map +1 -0
- package/src/lib/components/overlay-stats-row.component.d.ts +15 -0
- package/src/lib/components/overlay-stats-row.component.js +78 -0
- package/src/lib/components/overlay-stats-row.component.js.map +1 -0
- package/src/lib/components/popup-card.component.d.ts +18 -0
- package/src/lib/components/popup-card.component.js +462 -0
- package/src/lib/components/popup-card.component.js.map +1 -0
- package/src/lib/components/record-attribution-widget.d.ts +35 -0
- package/src/lib/components/record-attribution-widget.js +33 -0
- package/src/lib/components/record-attribution-widget.js.map +1 -0
- package/src/lib/components/site-runtime.d.ts +9 -0
- package/src/lib/components/site-runtime.js +1081 -0
- package/src/lib/components/site-runtime.js.map +1 -0
- package/src/lib/components/use-campaign-send-api.d.ts +35 -0
- package/src/lib/components/use-campaign-send-api.js +99 -0
- package/src/lib/components/use-campaign-send-api.js.map +1 -0
- package/src/lib/components/use-campaign-topic-options.d.ts +20 -0
- package/src/lib/components/use-campaign-topic-options.js +44 -0
- package/src/lib/components/use-campaign-topic-options.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +39 -0
- package/src/lib/components/use-emails-hub-path.js +70 -0
- package/src/lib/components/use-emails-hub-path.js.map +1 -0
- package/src/lib/components/use-marketing-hub-path.d.ts +21 -0
- package/src/lib/components/use-marketing-hub-path.js +50 -0
- package/src/lib/components/use-marketing-hub-path.js.map +1 -0
- package/src/lib/components/use-overlay-copy-editor.d.ts +79 -0
- package/src/lib/components/use-overlay-copy-editor.js +110 -0
- package/src/lib/components/use-overlay-copy-editor.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/campaign-membership-figures.d.ts +109 -0
- package/src/lib/model/campaign-membership-figures.js +113 -0
- package/src/lib/model/campaign-membership-figures.js.map +1 -0
- package/src/lib/model/compile-client-automations.d.ts +65 -0
- package/src/lib/model/compile-client-automations.js +95 -0
- package/src/lib/model/compile-client-automations.js.map +1 -0
- package/src/lib/model/experiments.d.ts +130 -0
- package/src/lib/model/experiments.js +186 -0
- package/src/lib/model/experiments.js.map +1 -0
- package/src/lib/model/index.d.ts +26 -0
- package/src/lib/model/index.js +26 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/marketing-record-routes.d.ts +6 -0
- package/src/lib/model/marketing-record-routes.js +46 -0
- package/src/lib/model/marketing-record-routes.js.map +1 -0
- package/src/lib/model/overlay-copy.d.ts +62 -0
- package/src/lib/model/overlay-copy.js +78 -0
- package/src/lib/model/overlay-copy.js.map +1 -0
- package/src/lib/model/overlays.d.ts +113 -0
- package/src/lib/model/overlays.js +93 -0
- package/src/lib/model/overlays.js.map +1 -0
- package/src/lib/model/site-contract.d.ts +100 -0
- package/src/lib/model/site-contract.js +23 -0
- package/src/lib/model/site-contract.js.map +1 -0
- package/src/lib/plugin.d.ts +27 -0
- package/src/lib/plugin.js +168 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/preview-props.d.ts +31 -0
- package/src/lib/preview-props.js +54 -0
- package/src/lib/preview-props.js.map +1 -0
- package/src/lib/server/campaign-manage.d.ts +62 -0
- package/src/lib/server/campaign-manage.js +829 -0
- package/src/lib/server/campaign-manage.js.map +1 -0
- package/src/lib/server/campaign-process-scheduled.d.ts +26 -0
- package/src/lib/server/campaign-process-scheduled.js +198 -0
- package/src/lib/server/campaign-process-scheduled.js.map +1 -0
- package/src/lib/server/campaign-recipients.d.ts +55 -0
- package/src/lib/server/campaign-recipients.js +219 -0
- package/src/lib/server/campaign-recipients.js.map +1 -0
- package/src/lib/server/campaign-send.d.ts +487 -0
- package/src/lib/server/campaign-send.js +3157 -0
- package/src/lib/server/campaign-send.js.map +1 -0
- package/src/lib/server/email-campaign-reach.d.ts +174 -0
- package/src/lib/server/email-campaign-reach.js +254 -0
- package/src/lib/server/email-campaign-reach.js.map +1 -0
- package/src/lib/server/email-events.d.ts +27 -0
- package/src/lib/server/email-events.js +714 -0
- package/src/lib/server/email-events.js.map +1 -0
- package/src/lib/server/get-client-automations.d.ts +60 -0
- package/src/lib/server/get-client-automations.js +87 -0
- package/src/lib/server/get-client-automations.js.map +1 -0
- package/src/lib/server/get-overlays.d.ts +28 -0
- package/src/lib/server/get-overlays.js +65 -0
- package/src/lib/server/get-overlays.js.map +1 -0
- package/src/lib/server/get-screen-experiments.d.ts +42 -0
- package/src/lib/server/get-screen-experiments.js +82 -0
- package/src/lib/server/get-screen-experiments.js.map +1 -0
- package/src/lib/server/lists-materialize.d.ts +18 -0
- package/src/lib/server/lists-materialize.js +130 -0
- package/src/lib/server/lists-materialize.js.map +1 -0
- package/src/lib/server/marketing-figures.d.ts +37 -0
- package/src/lib/server/marketing-figures.js +248 -0
- package/src/lib/server/marketing-figures.js.map +1 -0
- package/src/lib/server/site-page-enricher.d.ts +26 -0
- package/src/lib/server/site-page-enricher.js +210 -0
- package/src/lib/server/site-page-enricher.js.map +1 -0
- package/src/lib/server.d.ts +20 -0
- package/src/lib/server.js +152 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/site.d.ts +22 -0
- package/src/lib/site.js +48 -0
- 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
|