@aglyn/plugins-marketing 1.0.0-beta.199 → 1.0.0-beta.201
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/package.json +15 -15
- package/src/lib/components/campaign-composer.js +14 -6
- package/src/lib/components/campaign-composer.js.map +1 -1
- package/src/lib/components/campaign-conversions-card.js +6 -0
- package/src/lib/components/campaign-conversions-card.js.map +1 -1
- package/src/lib/components/campaign-detail-card.js +107 -37
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-members-section.d.ts +16 -4
- package/src/lib/components/campaign-members-section.js +171 -105
- package/src/lib/components/campaign-members-section.js.map +1 -1
- package/src/lib/components/campaign-queries.d.ts +11 -0
- package/src/lib/components/campaign-queries.js +13 -0
- package/src/lib/components/campaign-queries.js.map +1 -1
- package/src/lib/components/campaigns-card.d.ts +8 -6
- package/src/lib/components/campaigns-card.js +296 -128
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/email-compose-card.js +4 -3
- package/src/lib/components/email-compose-card.js.map +1 -1
- package/src/lib/components/email-detail.js +16 -5
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/components/email-recipients-card.js +3 -1
- package/src/lib/components/email-recipients-card.js.map +1 -1
- package/src/lib/components/emails-list-card.d.ts +11 -15
- package/src/lib/components/emails-list-card.js +142 -143
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +110 -78
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/components/org-experiments-card.js +7 -3
- package/src/lib/components/org-experiments-card.js.map +1 -1
- package/src/lib/model/campaign-list-query.d.ts +99 -0
- package/src/lib/model/campaign-list-query.js +373 -0
- package/src/lib/model/campaign-list-query.js.map +1 -0
- package/src/lib/model/campaign-members-query.d.ts +37 -0
- package/src/lib/model/campaign-members-query.js +120 -0
- package/src/lib/model/campaign-members-query.js.map +1 -0
- package/src/lib/model/experiment-list-query.d.ts +22 -0
- package/src/lib/model/experiment-list-query.js +85 -0
- package/src/lib/model/experiment-list-query.js.map +1 -0
- package/src/lib/server/campaign-form-flags.d.ts +35 -0
- package/src/lib/server/campaign-form-flags.js +52 -0
- package/src/lib/server/campaign-form-flags.js.map +1 -0
- package/src/lib/server/campaign-manage.d.ts +1 -1
- package/src/lib/server/campaign-manage.js +42 -20
- package/src/lib/server/campaign-manage.js.map +1 -1
- package/src/lib/server/campaign-send.d.ts +42 -1
- package/src/lib/server/campaign-send.js +247 -16
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/email-events.js +13 -1
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/components/email-send-filter.d.ts +0 -32
- package/src/lib/components/email-send-filter.js +0 -55
- package/src/lib/components/email-send-filter.js.map +0 -1
|
@@ -14,18 +14,23 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
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";
|
|
17
|
+
*/ import { checkQuota, contactMatchesSegment, isEmailTopicId, DEFAULT_CAMPAIGN_TOPIC_ID, readMarketingBasis, resolveMarketingConsentPolicy, splitByMarketingConsent, createResourceUid, decodeStoredNodes, emailStarterSendBlock, resolveBrandingProfile, siteLockdownFromDocs, visibleToHost } from "@aglyn/aglyn/server";
|
|
18
18
|
import { renderRecipientEmail } from "@aglyn/aglyn/app-utils/recipient-email-render";
|
|
19
19
|
import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
|
|
20
20
|
import { assignExperimentVariant } from "../model/index.js";
|
|
21
|
-
import { campaignPlacedOnHost } from "@aglyn/shared-ui-email-campaigns/model";
|
|
21
|
+
import { campaignHeldForReviewNotice, campaignPlacedOnHost, campaignSendHeldForReview } from "@aglyn/shared-ui-email-campaigns/model";
|
|
22
22
|
import { readPluginRecordCard } from "@aglyn/aglyn/plugin-manager/plugin-record-cards";
|
|
23
23
|
import { hostPublicOrigin } from "@aglyn/aglyn/server";
|
|
24
|
-
import { consentGroupForSite, orgDataCollectionForHost, orgDataQueryForHost, filterSendableForHost, filterTopicSendable, firebaseAdmin, getOrgForHost, meterHostEmail, claimOrgEmailSendBudget, orgCampaignEmailSendsForMonth, readEmailSendRateConfig, readEmailSendRateWindow, reconcileCampaignSendReservation, reserveCampaignEmailSends, resolveHostSendingIdentity } from "@aglyn/tenant-data-admin";
|
|
24
|
+
import { consentGroupForSite, orgDataCollectionForHost, orgDataQueryForHost, filterDeliverableRecipients, filterSendableForHost, filterTopicSendable, firebaseAdmin, getOrgForHost, meterHostEmail, claimOrgEmailSendBudget, orgCampaignEmailSendsForMonth, readEmailSendRateConfig, readEmailSendRateWindow, reconcileCampaignSendReservation, reserveCampaignEmailSends, resolveHostSendingIdentity } from "@aglyn/tenant-data-admin";
|
|
25
25
|
// The leaf, not the barrel: this plugin's specs substitute the barrel
|
|
26
26
|
// wholesale, and the lookup must reach the real index logic under them.
|
|
27
27
|
import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
28
28
|
import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
|
|
29
|
+
/*
|
|
30
|
+
* The LEAF for the phishing screen's hold (AGL-3356), for the reason every
|
|
31
|
+
* leaf above gives: the specs that reach this file mock the barrel, and a
|
|
32
|
+
* control that holds a send must be the real one under every harness.
|
|
33
|
+
*/ import { canonicalJson, HELD_SEND_AT_MS, screenOutboundSend } from "@aglyn/tenant-data-admin/server/outbound-send-review";
|
|
29
34
|
/*
|
|
30
35
|
* The LEAF module for the impersonation exemption too, and for a sharper
|
|
31
36
|
* version of the reason the notes below give: every spec that reaches this
|
|
@@ -51,6 +56,7 @@ import { filterCadenceSendable, recordMarketingSends } from "@aglyn/tenant-data-
|
|
|
51
56
|
* from the real module.
|
|
52
57
|
*/ import { CAMPAIGN_REACH_CEILING, campaignReachCovers, campaignSettledSize, partitionByCampaignReach, readCampaignReach, readCampaignSettled, recordCampaignReach, recordCampaignSkipped } from "./email-campaign-reach.js";
|
|
53
58
|
import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendIsOnHost } from "./campaign-org-refs.js";
|
|
59
|
+
import { campaignSendSearchFields } from "../model/campaign-list-query.js";
|
|
54
60
|
/*
|
|
55
61
|
* The LEAF module for the reputation controls too, and for the same reason
|
|
56
62
|
* as the three above it: every spec that reaches this file mocks the
|
|
@@ -189,10 +195,33 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
189
195
|
* Thrown ONLY before anything has been sent, so a caller that retries cannot
|
|
190
196
|
* double-send.
|
|
191
197
|
*/ export class CampaignSendDeferredError extends CampaignSendError {
|
|
192
|
-
constructor(message, /** When the hourly window rolls. */ retryAtMs
|
|
193
|
-
|
|
198
|
+
constructor(message, /** When the hourly window rolls. */ retryAtMs, /**
|
|
199
|
+
* `429` for a rate, `423` for a suspension (AGL-3356). The subclass is
|
|
200
|
+
* what a caller acts on; the number is only what the API answers.
|
|
201
|
+
*/ status = 429){
|
|
202
|
+
super(message, status), this.retryAtMs = retryAtMs;
|
|
194
203
|
}
|
|
195
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* The phishing screen held this send for staff review (AGL-3356).
|
|
207
|
+
*
|
|
208
|
+
* A DEFERRAL to every caller that handles one, which is the point: the
|
|
209
|
+
* scheduled processor puts the row back to `scheduled` at the parked time
|
|
210
|
+
* rather than marking it failed, and the send route's claim is released —
|
|
211
|
+
* so a held email is neither lost nor mailed. `409` because the merchant's
|
|
212
|
+
* request conflicts with a review in progress, and the message says so in
|
|
213
|
+
* words they can act on.
|
|
214
|
+
*/ export class CampaignSendHeldError extends CampaignSendDeferredError {
|
|
215
|
+
constructor(reference){
|
|
216
|
+
super('This email is being reviewed before it sends, because new workspaces ' + 'have their first emails checked for impersonation. It has been saved ' + 'and nothing has been sent or counted; it goes out automatically if ' + `it is approved. Reference ${reference}.`, HELD_SEND_AT_MS, 409), this.reference = reference;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* How long a suspended site's scheduled campaign waits before it is asked
|
|
221
|
+
* about again (AGL-3356). An hour keeps a locked workspace's due sends from
|
|
222
|
+
* crowding the processor's read on every fifteen-minute beat, and is short
|
|
223
|
+
* enough that a lifted suspension resumes the same afternoon.
|
|
224
|
+
*/ export const CAMPAIGN_SUSPENDED_RETRY_MS = 60 * 60 * 1000;
|
|
196
225
|
/**
|
|
197
226
|
* Loads a designed email template's nodes + referenced products for the
|
|
198
227
|
* render pipeline. Throws 400 when the screen isn't an email document.
|
|
@@ -327,6 +356,31 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
327
356
|
* (AGL-3307) — see {@link CampaignListUnsubscribeRecord} in
|
|
328
357
|
* `@aglyn/shared-ui-email-campaigns`, which the campaign page reads.
|
|
329
358
|
*/ const LIST_UNSUBSCRIBE_RECORD_FIELD = 'listUnsubscribe';
|
|
359
|
+
/**
|
|
360
|
+
* The deliverability check over one batch (AGL-3328), against the domain the
|
|
361
|
+
* campaign leaves from. Fails OPEN — an unreadable check, or one this
|
|
362
|
+
* deployment did not install, keeps everyone — because the check prevents
|
|
363
|
+
* bounces and a campaign that stopped for want of it would prevent mail.
|
|
364
|
+
*/ async function checkCampaignDeliverability(emails, identity) {
|
|
365
|
+
var _ref, _ref1, _ref2;
|
|
366
|
+
var _String_match;
|
|
367
|
+
const fromAddress = (_ref = (_String_match = String((_ref1 = identity == null ? void 0 : identity.from) != null ? _ref1 : '').match(/<([^>]+)>/)) == null ? void 0 : _String_match[1]) != null ? _ref : String((_ref2 = identity == null ? void 0 : identity.from) != null ? _ref2 : '');
|
|
368
|
+
const sendingDomain = (identity == null ? void 0 : identity.domain) || (fromAddress.includes('@') ? fromAddress.slice(fromAddress.lastIndexOf('@') + 1).trim() : null);
|
|
369
|
+
try {
|
|
370
|
+
return await filterDeliverableRecipients({
|
|
371
|
+
emails,
|
|
372
|
+
sendingDomain,
|
|
373
|
+
purpose: 'bulk'
|
|
374
|
+
});
|
|
375
|
+
} catch (error) {
|
|
376
|
+
console.error('[campaign] deliverability check unavailable — sending to everyone', error);
|
|
377
|
+
return {
|
|
378
|
+
deliverable: emails,
|
|
379
|
+
noMailServer: [],
|
|
380
|
+
gatewayHeld: []
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
330
384
|
export async function performCampaignSend(options) {
|
|
331
385
|
var _ref, _options_senderId, _options_proofFor, _hostPublicOrigin, _options_experimentId;
|
|
332
386
|
var _orgForHost_org, _orgForHost_org1, _consent_get;
|
|
@@ -428,6 +482,31 @@ export async function performCampaignSend(options) {
|
|
|
428
482
|
if (!orgId) {
|
|
429
483
|
throw new CampaignSendError('This site is not part of an organization, so it cannot send campaigns.', 409);
|
|
430
484
|
}
|
|
485
|
+
/*
|
|
486
|
+
* A SUSPENDED SITE SENDS NO CAMPAIGN (AGL-3356).
|
|
487
|
+
*
|
|
488
|
+
* The console dispatcher refuses a locked caller, but the scheduled
|
|
489
|
+
* processor and a resumed batch arrive with no caller at all, so the
|
|
490
|
+
* suspension is asked here, off the two documents this send already holds
|
|
491
|
+
* — no read is added. The incident behind this was a workspace locked for
|
|
492
|
+
* phishing whose deferred batches were still queued to go.
|
|
493
|
+
*
|
|
494
|
+
* A deferral rather than a failure: a suspension is a pause, and lifting
|
|
495
|
+
* it must leave the campaign where it was. Thrown before anything is
|
|
496
|
+
* claimed, counted or sent, so the scheduled processor puts the row back
|
|
497
|
+
* to `scheduled` and asks again after the delay. Any active lock pauses a
|
|
498
|
+
* campaign, a read-only maintenance window included, because a campaign is
|
|
499
|
+
* the one sender that can always wait.
|
|
500
|
+
*/ {
|
|
501
|
+
const nowMs = Date.now();
|
|
502
|
+
const lock = siteLockdownFromDocs({
|
|
503
|
+
org: orgForHost == null ? void 0 : orgForHost.org,
|
|
504
|
+
host: hostSnapshot.data()
|
|
505
|
+
}, nowMs);
|
|
506
|
+
if (lock) {
|
|
507
|
+
throw new CampaignSendDeferredError(lock.scope === 'org' ? 'This workspace is suspended, so its email is paused. Nothing ' + 'has been sent or counted; it resumes when the suspension is lifted.' : 'This site is suspended, so its email is paused. Nothing has ' + 'been sent or counted; it resumes when the suspension is lifted.', nowMs + CAMPAIGN_SUSPENDED_RETRY_MS, 423);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
431
510
|
const sends = orgCampaignSends(firestore, orgId);
|
|
432
511
|
/*==========================================
|
|
433
512
|
* THE FOLLOW-UP'S ADMISSION CHECKS.
|
|
@@ -1132,11 +1211,26 @@ export async function performCampaignSend(options) {
|
|
|
1132
1211
|
* Fails OPEN on an unreadable counter — see `filterCadenceSendable`. A
|
|
1133
1212
|
* pace is not a stop, and the two suppression lists above have already
|
|
1134
1213
|
* removed everybody who asked us to stop entirely.
|
|
1135
|
-
*/ const
|
|
1214
|
+
*/ const paced = await filterCadenceSendable(hostId, onTopic, {
|
|
1136
1215
|
firestore,
|
|
1137
1216
|
group: consentGroup
|
|
1138
1217
|
});
|
|
1139
1218
|
/*
|
|
1219
|
+
* THE FIFTH FILTER: who this send would bounce off (AGL-3328).
|
|
1220
|
+
*
|
|
1221
|
+
* An address whose domain has no mail server is a hard bounce charged to
|
|
1222
|
+
* the sending domain, and an address behind a gateway that refused this
|
|
1223
|
+
* sending domain twice in thirty days is the third refusal in a row —
|
|
1224
|
+
* each one making the next more likely, at that company and at every
|
|
1225
|
+
* other behind the same gateway. `sendEmail` refuses both one message at
|
|
1226
|
+
* a time as well; subtracting them HERE is what makes the count on screen
|
|
1227
|
+
* true before Send, the reason the cadence filter above sits here too.
|
|
1228
|
+
*
|
|
1229
|
+
* Fails OPEN: an audience that could not be checked is mailed as it was
|
|
1230
|
+
* before the check existed.
|
|
1231
|
+
*/ const deliverability = await checkCampaignDeliverability(paced, sendingIdentity);
|
|
1232
|
+
const sendable = deliverability.deliverable;
|
|
1233
|
+
/*
|
|
1140
1234
|
* NOBODY IN THIS BATCH, BUT SOMEBODY AFTER IT.
|
|
1141
1235
|
*
|
|
1142
1236
|
* A send whose whole audience is suppressed is a 400 a merchant needs to
|
|
@@ -1150,12 +1244,13 @@ export async function performCampaignSend(options) {
|
|
|
1150
1244
|
*/ if (!sendable.length && consentSplit.mailable.length <= recipients.length) {
|
|
1151
1245
|
if (options.continuation) return finishContinuation();
|
|
1152
1246
|
/*
|
|
1153
|
-
* Which of the
|
|
1247
|
+
* Which of the five filters emptied the batch, because the answer changes
|
|
1154
1248
|
* what the merchant should do. An audience that unsubscribed is one they
|
|
1155
1249
|
* have to rebuild; an audience holding for its own cadence is one that
|
|
1156
1250
|
* becomes mailable on its own, and telling them it "unsubscribed" would
|
|
1157
|
-
* send them looking for a problem that is not there.
|
|
1158
|
-
|
|
1251
|
+
* send them looking for a problem that is not there. An audience the
|
|
1252
|
+
* deliverability check emptied has addresses that would bounce.
|
|
1253
|
+
*/ throw new CampaignSendError(paced.length ? 'Every recipient would bounce: their domains have no mail server, ' + 'or their mail gateway refused this sender twice this month' : onTopic.length ? 'Every recipient has asked this site for mail less often than this' : 'Every recipient has unsubscribed or been suppressed', 400);
|
|
1159
1254
|
}
|
|
1160
1255
|
// Monthly cap by the owning org's plan (dark-launch rule, AGL-238).
|
|
1161
1256
|
//
|
|
@@ -1235,7 +1330,12 @@ export async function performCampaignSend(options) {
|
|
|
1235
1330
|
* not unsubscribe, and reporting them under a heading that says they
|
|
1236
1331
|
* did is the netting this readout has refused everywhere else.
|
|
1237
1332
|
*/ suppressed: recipients.length - onTopic.length,
|
|
1238
|
-
cadenceHeld: onTopic.length -
|
|
1333
|
+
cadenceHeld: onTopic.length - paced.length,
|
|
1334
|
+
// Who the deliverability check took out, by why (AGL-3328): "N
|
|
1335
|
+
// recipients excluded: no mail server · M behind a gateway that
|
|
1336
|
+
// refused this sender".
|
|
1337
|
+
noMailServer: deliverability.noMailServer.length,
|
|
1338
|
+
gatewayHeld: deliverability.gatewayHeld.length,
|
|
1239
1339
|
/*
|
|
1240
1340
|
* What this send will NOT reach on its first pass, so the composer can
|
|
1241
1341
|
* say "3,000 people, 500 in the first batch, the rest over the next few
|
|
@@ -1298,7 +1398,7 @@ export async function performCampaignSend(options) {
|
|
|
1298
1398
|
* that record already carries its own creation stamp. Re-stamping it would
|
|
1299
1399
|
* move an email's creation date forward every time it was sent again, and
|
|
1300
1400
|
* the emails list orders drafts on exactly that field.
|
|
1301
|
-
*/ const mintsRecord = !options.campaignId;
|
|
1401
|
+
*/ const mintsRecord = !options.campaignId || options.mintsRecord === true;
|
|
1302
1402
|
// Designed email template (AGL-349): loaded once; rendered per
|
|
1303
1403
|
// recipient with their merge values.
|
|
1304
1404
|
const template = options.templateScreenId ? await loadEmailTemplate(hostId, options.templateScreenId) : null;
|
|
@@ -1332,6 +1432,107 @@ export async function performCampaignSend(options) {
|
|
|
1332
1432
|
}, data);
|
|
1333
1433
|
}
|
|
1334
1434
|
/*
|
|
1435
|
+
* THE PHISHING SCREEN, for a workspace in its first fortnight (AGL-3356).
|
|
1436
|
+
*
|
|
1437
|
+
* Asked once the copy is final — the design loaded, the experiment's
|
|
1438
|
+
* variants known — and before anything is claimed, counted or sent, so a
|
|
1439
|
+
* hold costs the workspace nothing but the wait. See
|
|
1440
|
+
* `outbound-send-review.ts` for who is screened and what a hold is, and
|
|
1441
|
+
* `outbound-phishing-screen.ts` for what holds.
|
|
1442
|
+
*
|
|
1443
|
+
* A follow-up is not screened: it mails the copy its first send already
|
|
1444
|
+
* carried, which was. A continuation is: its first batch may predate the
|
|
1445
|
+
* screen, and a hold there parks the rest of the audience rather than
|
|
1446
|
+
* mailing it.
|
|
1447
|
+
*
|
|
1448
|
+
* A HOLD PARKS THE SEND, it does not drop it. The record is written as a
|
|
1449
|
+
* scheduled send whose time no processor run reaches, carrying the copy
|
|
1450
|
+
* `storedSendOptionsFrom` reads back, and the review row in the abuse
|
|
1451
|
+
* queue names it. Staff dismissing the row releases it — the send goes
|
|
1452
|
+
* back on the clock and this screen finds it released — and actioning the
|
|
1453
|
+
* row cancels it.
|
|
1454
|
+
*/ if (!options.followUp) {
|
|
1455
|
+
var _ref4, _hostSnapshot_data, _options_fromName, _sendingIdentity_from, _options_replyTo, _options_preheader, _options_plainText, _experiment_variants;
|
|
1456
|
+
const sendRef = sends.doc(campaignId);
|
|
1457
|
+
const screened = await screenOutboundSend({
|
|
1458
|
+
kind: 'campaign',
|
|
1459
|
+
path: sendRef.path,
|
|
1460
|
+
hostId,
|
|
1461
|
+
orgId,
|
|
1462
|
+
org: (_ref4 = orgForHost == null ? void 0 : orgForHost.org) != null ? _ref4 : null,
|
|
1463
|
+
host: (_hostSnapshot_data = hostSnapshot.data()) != null ? _hostSnapshot_data : null,
|
|
1464
|
+
subject,
|
|
1465
|
+
fromName: (_options_fromName = options.fromName) != null ? _options_fromName : null,
|
|
1466
|
+
// The verified address it will leave on: a lookalike domain there
|
|
1467
|
+
// holds for every workspace (AGL-3362).
|
|
1468
|
+
fromAddress: (_sendingIdentity_from = sendingIdentity.from) != null ? _sendingIdentity_from : null,
|
|
1469
|
+
replyTo: (_options_replyTo = options.replyTo) != null ? _options_replyTo : null,
|
|
1470
|
+
preheader: (_options_preheader = options.preheader) != null ? _options_preheader : null,
|
|
1471
|
+
bodies: [
|
|
1472
|
+
body,
|
|
1473
|
+
(_options_plainText = options.plainText) != null ? _options_plainText : '',
|
|
1474
|
+
template ? canonicalJson(template) : '',
|
|
1475
|
+
experiment ? canonicalJson((_experiment_variants = experiment.variants) != null ? _experiment_variants : null) : ''
|
|
1476
|
+
]
|
|
1477
|
+
});
|
|
1478
|
+
if (screened.outcome === 'rejected') {
|
|
1479
|
+
throw new CampaignSendError('This email was reviewed and will not be sent. Contact support if ' + 'you believe this is a mistake.', 403);
|
|
1480
|
+
}
|
|
1481
|
+
if (screened.outcome === 'held') {
|
|
1482
|
+
var _options_emails;
|
|
1483
|
+
await sendRef.set(_extends({}, campaignSendSiteStamp(hostId), options.continuation ? {} : _extends({
|
|
1484
|
+
subject
|
|
1485
|
+
}, campaignSendSearchFields(subject), {
|
|
1486
|
+
body,
|
|
1487
|
+
audience
|
|
1488
|
+
}, mintsRecord ? {
|
|
1489
|
+
createdAtMs: Date.now()
|
|
1490
|
+
} : {}, options.segmentId ? {
|
|
1491
|
+
segmentId: options.segmentId
|
|
1492
|
+
} : {}, options.listId ? {
|
|
1493
|
+
listId: options.listId
|
|
1494
|
+
} : {}, options.topicId ? {
|
|
1495
|
+
topicId: options.topicId
|
|
1496
|
+
} : {}, ((_options_emails = options.emails) == null ? void 0 : _options_emails.length) ? {
|
|
1497
|
+
emails: options.emails.map(String).slice(0, 500)
|
|
1498
|
+
} : {}, experimentId ? {
|
|
1499
|
+
experimentId
|
|
1500
|
+
} : {}, options.templateScreenId ? {
|
|
1501
|
+
templateScreenId: options.templateScreenId
|
|
1502
|
+
} : {}, options.plainText ? {
|
|
1503
|
+
plainText: options.plainText
|
|
1504
|
+
} : {}, options.fromName ? {
|
|
1505
|
+
fromName: options.fromName
|
|
1506
|
+
} : {}, options.replyTo ? {
|
|
1507
|
+
replyTo: options.replyTo
|
|
1508
|
+
} : {}, options.senderId ? {
|
|
1509
|
+
senderId: options.senderId
|
|
1510
|
+
} : {}, options.preheader ? {
|
|
1511
|
+
preheader: options.preheader
|
|
1512
|
+
} : {}, options.displayName ? {
|
|
1513
|
+
displayName: options.displayName
|
|
1514
|
+
} : {}, options.emailCampaignId ? {
|
|
1515
|
+
emailCampaignId: options.emailCampaignId
|
|
1516
|
+
} : mintsRecord ? {
|
|
1517
|
+
emailCampaignId: null
|
|
1518
|
+
} : {}, {
|
|
1519
|
+
scheduledBy: options.senderUid
|
|
1520
|
+
}), {
|
|
1521
|
+
status: 'scheduled',
|
|
1522
|
+
sendAtMs: HELD_SEND_AT_MS,
|
|
1523
|
+
staffReview: {
|
|
1524
|
+
reviewId: screened.reviewId,
|
|
1525
|
+
reference: screened.reference,
|
|
1526
|
+
state: 'held',
|
|
1527
|
+
heldAtMs: Date.now()
|
|
1528
|
+
}
|
|
1529
|
+
}), {
|
|
1530
|
+
merge: true
|
|
1531
|
+
});
|
|
1532
|
+
throw new CampaignSendHeldError(screened.reference);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
/*
|
|
1335
1536
|
* PLATFORM SEND-RATE ADMISSION CONTROL (AGL-2409).
|
|
1336
1537
|
*
|
|
1337
1538
|
* `sendEmail` governs every message individually and is the hard ceiling.
|
|
@@ -1956,7 +2157,8 @@ export async function performCampaignSend(options) {
|
|
|
1956
2157
|
* below are stored under: they are true of the send that happened.
|
|
1957
2158
|
*/ const measuresTheAudience = !options.continuation;
|
|
1958
2159
|
await sends.doc(campaignId).set(_extends({}, campaignSendSiteStamp(hostId), {
|
|
1959
|
-
subject
|
|
2160
|
+
subject
|
|
2161
|
+
}, campaignSendSearchFields(subject), {
|
|
1960
2162
|
body,
|
|
1961
2163
|
audience
|
|
1962
2164
|
}, mintsRecord ? {
|
|
@@ -1993,6 +2195,8 @@ export async function performCampaignSend(options) {
|
|
|
1993
2195
|
displayName: options.displayName
|
|
1994
2196
|
} : {}, options.emailCampaignId ? {
|
|
1995
2197
|
emailCampaignId: options.emailCampaignId
|
|
2198
|
+
} : mintsRecord ? {
|
|
2199
|
+
emailCampaignId: null
|
|
1996
2200
|
} : {}, {
|
|
1997
2201
|
/*
|
|
1998
2202
|
* WHAT THIS EMAIL DID WITH THE UNSUBSCRIBE HEADER, and why (AGL-3307).
|
|
@@ -2035,7 +2239,9 @@ export async function performCampaignSend(options) {
|
|
|
2035
2239
|
consentWithheld: additive(consentSplit.withheld)
|
|
2036
2240
|
} : {}, {
|
|
2037
2241
|
suppressed: additive(recipients.length - onTopic.length),
|
|
2038
|
-
cadenceHeld: additive(onTopic.length -
|
|
2242
|
+
cadenceHeld: additive(onTopic.length - paced.length),
|
|
2243
|
+
noMailServer: additive(deliverability.noMailServer.length),
|
|
2244
|
+
gatewayHeld: additive(deliverability.gatewayHeld.length),
|
|
2039
2245
|
/*
|
|
2040
2246
|
* That this send's links were trackable at all.
|
|
2041
2247
|
*
|
|
@@ -3036,6 +3242,17 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3036
3242
|
* see the write below — which is console-only text that reached no
|
|
3037
3243
|
* recipient and therefore contradicts no delivered mail.
|
|
3038
3244
|
*=========================================*/ const rewritable = !targetSnapshot.exists || targetState === 'draft' || targetState === 'scheduled';
|
|
3245
|
+
/*
|
|
3246
|
+
* A SEND HELD FOR STAFF REVIEW IS NOT THE MERCHANT'S TO REWRITE
|
|
3247
|
+
* (AGL-3356). Scheduling it would move `sendAtMs` off the parked time
|
|
3248
|
+
* and put it on the clock without a decision, and a rewrite of its
|
|
3249
|
+
* copy would reach staff as content they never reviewed. The name is
|
|
3250
|
+
* still theirs (`update`), and so is canceling it.
|
|
3251
|
+
*/ if (action !== 'update' && campaignSendHeldForReview(targetSnapshot.data())) {
|
|
3252
|
+
return res.status(409).json({
|
|
3253
|
+
error: campaignHeldForReviewNotice(targetSnapshot.data())
|
|
3254
|
+
});
|
|
3255
|
+
}
|
|
3039
3256
|
if (action !== 'update' && !rewritable) {
|
|
3040
3257
|
return res.status(409).json({
|
|
3041
3258
|
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.'
|
|
@@ -3064,7 +3281,8 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3064
3281
|
});
|
|
3065
3282
|
}
|
|
3066
3283
|
await targetRef.set(_extends({}, campaignSendSiteStamp(hostId), {
|
|
3067
|
-
subject
|
|
3284
|
+
subject
|
|
3285
|
+
}, campaignSendSearchFields(subject), {
|
|
3068
3286
|
body,
|
|
3069
3287
|
audience
|
|
3070
3288
|
}, targetSnapshot.exists ? {} : {
|
|
@@ -3113,7 +3331,9 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3113
3331
|
displayName
|
|
3114
3332
|
} : {}, emailCampaignId ? {
|
|
3115
3333
|
emailCampaignId
|
|
3116
|
-
} : {}
|
|
3334
|
+
} : targetSnapshot.exists ? {} : {
|
|
3335
|
+
emailCampaignId: null
|
|
3336
|
+
}, scheduling ? {
|
|
3117
3337
|
status: 'scheduled',
|
|
3118
3338
|
sendAtMs,
|
|
3119
3339
|
scheduledAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),
|
|
@@ -3203,6 +3423,12 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3203
3423
|
});
|
|
3204
3424
|
}
|
|
3205
3425
|
const sendNowState = String((_sendNowSnapshot_get = sendNowSnapshot.get('status')) != null ? _sendNowSnapshot_get : '');
|
|
3426
|
+
// Held for staff review (AGL-3356): "send now" is not a release.
|
|
3427
|
+
if (campaignSendHeldForReview(sendNowSnapshot.data())) {
|
|
3428
|
+
return res.status(409).json({
|
|
3429
|
+
error: campaignHeldForReviewNotice(sendNowSnapshot.data())
|
|
3430
|
+
});
|
|
3431
|
+
}
|
|
3206
3432
|
/*
|
|
3207
3433
|
* Only an email that has not gone out yet. A sent one would be mailed a
|
|
3208
3434
|
* second time to the whole audience under the same id — which is what
|
|
@@ -3305,6 +3531,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3305
3531
|
* which takes no copy from the request at all and subtracts everyone the
|
|
3306
3532
|
* earlier sends recorded.
|
|
3307
3533
|
*=========================================*/ const sendId = String((_ref12 = (_req_body15 = req.body) == null ? void 0 : _req_body15.campaignId) != null ? _ref12 : '');
|
|
3534
|
+
/** The id names no record yet, so this send mints it (AGL-3321). */ let mintsNamedRecord = false;
|
|
3308
3535
|
if (sendId) {
|
|
3309
3536
|
var _existing_get;
|
|
3310
3537
|
if (!isDocumentId(sendId)) {
|
|
@@ -3313,6 +3540,7 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3313
3540
|
});
|
|
3314
3541
|
}
|
|
3315
3542
|
const existing = await orgSends.doc(sendId).get();
|
|
3543
|
+
mintsNamedRecord = !existing.exists;
|
|
3316
3544
|
if (existing.exists && !sendIsOnHost(existing, hostId)) {
|
|
3317
3545
|
return res.status(404).json({
|
|
3318
3546
|
error: 'Unknown email'
|
|
@@ -3334,7 +3562,10 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3334
3562
|
listId: String((_ref14 = (_req_body17 = req.body) == null ? void 0 : _req_body17.listId) != null ? _ref14 : ''),
|
|
3335
3563
|
topicId: topicId || undefined,
|
|
3336
3564
|
emails: Array.isArray((_req_body18 = req.body) == null ? void 0 : _req_body18.emails) ? req.body.emails : undefined,
|
|
3337
|
-
campaignId: sendId
|
|
3565
|
+
campaignId: sendId
|
|
3566
|
+
}, mintsNamedRecord ? {
|
|
3567
|
+
mintsRecord: true
|
|
3568
|
+
} : {}, {
|
|
3338
3569
|
experimentId: String((_ref15 = (_req_body19 = req.body) == null ? void 0 : _req_body19.experimentId) != null ? _ref15 : ''),
|
|
3339
3570
|
templateScreenId: templateScreenId || undefined
|
|
3340
3571
|
}, plainText ? {
|