@aglyn/plugins-marketing 1.0.0-beta.200 → 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 +1 -1
- 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/campaigns-card.js +19 -9
- 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.js +3 -1
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +3 -0
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- 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/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.js +12 -2
- package/src/lib/server/campaign-manage.js.map +1 -1
- package/src/lib/server/campaign-send.d.ts +35 -1
- package/src/lib/server/campaign-send.js +230 -11
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/email-events.js +10 -0
- package/src/lib/server/email-events.js.map +1 -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
|
|
@@ -190,10 +195,33 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
190
195
|
* Thrown ONLY before anything has been sent, so a caller that retries cannot
|
|
191
196
|
* double-send.
|
|
192
197
|
*/ export class CampaignSendDeferredError extends CampaignSendError {
|
|
193
|
-
constructor(message, /** When the hourly window rolls. */ retryAtMs
|
|
194
|
-
|
|
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;
|
|
195
203
|
}
|
|
196
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;
|
|
197
225
|
/**
|
|
198
226
|
* Loads a designed email template's nodes + referenced products for the
|
|
199
227
|
* render pipeline. Throws 400 when the screen isn't an email document.
|
|
@@ -328,6 +356,31 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
328
356
|
* (AGL-3307) — see {@link CampaignListUnsubscribeRecord} in
|
|
329
357
|
* `@aglyn/shared-ui-email-campaigns`, which the campaign page reads.
|
|
330
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
|
+
}
|
|
331
384
|
export async function performCampaignSend(options) {
|
|
332
385
|
var _ref, _options_senderId, _options_proofFor, _hostPublicOrigin, _options_experimentId;
|
|
333
386
|
var _orgForHost_org, _orgForHost_org1, _consent_get;
|
|
@@ -429,6 +482,31 @@ export async function performCampaignSend(options) {
|
|
|
429
482
|
if (!orgId) {
|
|
430
483
|
throw new CampaignSendError('This site is not part of an organization, so it cannot send campaigns.', 409);
|
|
431
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
|
+
}
|
|
432
510
|
const sends = orgCampaignSends(firestore, orgId);
|
|
433
511
|
/*==========================================
|
|
434
512
|
* THE FOLLOW-UP'S ADMISSION CHECKS.
|
|
@@ -1133,11 +1211,26 @@ export async function performCampaignSend(options) {
|
|
|
1133
1211
|
* Fails OPEN on an unreadable counter — see `filterCadenceSendable`. A
|
|
1134
1212
|
* pace is not a stop, and the two suppression lists above have already
|
|
1135
1213
|
* removed everybody who asked us to stop entirely.
|
|
1136
|
-
*/ const
|
|
1214
|
+
*/ const paced = await filterCadenceSendable(hostId, onTopic, {
|
|
1137
1215
|
firestore,
|
|
1138
1216
|
group: consentGroup
|
|
1139
1217
|
});
|
|
1140
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
|
+
/*
|
|
1141
1234
|
* NOBODY IN THIS BATCH, BUT SOMEBODY AFTER IT.
|
|
1142
1235
|
*
|
|
1143
1236
|
* A send whose whole audience is suppressed is a 400 a merchant needs to
|
|
@@ -1151,12 +1244,13 @@ export async function performCampaignSend(options) {
|
|
|
1151
1244
|
*/ if (!sendable.length && consentSplit.mailable.length <= recipients.length) {
|
|
1152
1245
|
if (options.continuation) return finishContinuation();
|
|
1153
1246
|
/*
|
|
1154
|
-
* Which of the
|
|
1247
|
+
* Which of the five filters emptied the batch, because the answer changes
|
|
1155
1248
|
* what the merchant should do. An audience that unsubscribed is one they
|
|
1156
1249
|
* have to rebuild; an audience holding for its own cadence is one that
|
|
1157
1250
|
* becomes mailable on its own, and telling them it "unsubscribed" would
|
|
1158
|
-
* send them looking for a problem that is not there.
|
|
1159
|
-
|
|
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);
|
|
1160
1254
|
}
|
|
1161
1255
|
// Monthly cap by the owning org's plan (dark-launch rule, AGL-238).
|
|
1162
1256
|
//
|
|
@@ -1236,7 +1330,12 @@ export async function performCampaignSend(options) {
|
|
|
1236
1330
|
* not unsubscribe, and reporting them under a heading that says they
|
|
1237
1331
|
* did is the netting this readout has refused everywhere else.
|
|
1238
1332
|
*/ suppressed: recipients.length - onTopic.length,
|
|
1239
|
-
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,
|
|
1240
1339
|
/*
|
|
1241
1340
|
* What this send will NOT reach on its first pass, so the composer can
|
|
1242
1341
|
* say "3,000 people, 500 in the first batch, the rest over the next few
|
|
@@ -1333,6 +1432,107 @@ export async function performCampaignSend(options) {
|
|
|
1333
1432
|
}, data);
|
|
1334
1433
|
}
|
|
1335
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
|
+
/*
|
|
1336
1536
|
* PLATFORM SEND-RATE ADMISSION CONTROL (AGL-2409).
|
|
1337
1537
|
*
|
|
1338
1538
|
* `sendEmail` governs every message individually and is the hard ceiling.
|
|
@@ -2039,7 +2239,9 @@ export async function performCampaignSend(options) {
|
|
|
2039
2239
|
consentWithheld: additive(consentSplit.withheld)
|
|
2040
2240
|
} : {}, {
|
|
2041
2241
|
suppressed: additive(recipients.length - onTopic.length),
|
|
2042
|
-
cadenceHeld: additive(onTopic.length -
|
|
2242
|
+
cadenceHeld: additive(onTopic.length - paced.length),
|
|
2243
|
+
noMailServer: additive(deliverability.noMailServer.length),
|
|
2244
|
+
gatewayHeld: additive(deliverability.gatewayHeld.length),
|
|
2043
2245
|
/*
|
|
2044
2246
|
* That this send's links were trackable at all.
|
|
2045
2247
|
*
|
|
@@ -3040,6 +3242,17 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3040
3242
|
* see the write below — which is console-only text that reached no
|
|
3041
3243
|
* recipient and therefore contradicts no delivered mail.
|
|
3042
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
|
+
}
|
|
3043
3256
|
if (action !== 'update' && !rewritable) {
|
|
3044
3257
|
return res.status(409).json({
|
|
3045
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.'
|
|
@@ -3210,6 +3423,12 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
|
|
|
3210
3423
|
});
|
|
3211
3424
|
}
|
|
3212
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
|
+
}
|
|
3213
3432
|
/*
|
|
3214
3433
|
* Only an email that has not gone out yet. A sent one would be mailed a
|
|
3215
3434
|
* second time to the whole audience under the same id — which is what
|