@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.
Files changed (52) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-composer.js +14 -6
  3. package/src/lib/components/campaign-composer.js.map +1 -1
  4. package/src/lib/components/campaign-conversions-card.js +6 -0
  5. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  6. package/src/lib/components/campaign-detail-card.js +107 -37
  7. package/src/lib/components/campaign-detail-card.js.map +1 -1
  8. package/src/lib/components/campaign-members-section.d.ts +16 -4
  9. package/src/lib/components/campaign-members-section.js +171 -105
  10. package/src/lib/components/campaign-members-section.js.map +1 -1
  11. package/src/lib/components/campaign-queries.d.ts +11 -0
  12. package/src/lib/components/campaign-queries.js +13 -0
  13. package/src/lib/components/campaign-queries.js.map +1 -1
  14. package/src/lib/components/campaigns-card.d.ts +8 -6
  15. package/src/lib/components/campaigns-card.js +296 -128
  16. package/src/lib/components/campaigns-card.js.map +1 -1
  17. package/src/lib/components/email-compose-card.js +4 -3
  18. package/src/lib/components/email-compose-card.js.map +1 -1
  19. package/src/lib/components/email-detail.js +16 -5
  20. package/src/lib/components/email-detail.js.map +1 -1
  21. package/src/lib/components/email-recipients-card.js +3 -1
  22. package/src/lib/components/email-recipients-card.js.map +1 -1
  23. package/src/lib/components/emails-list-card.d.ts +11 -15
  24. package/src/lib/components/emails-list-card.js +142 -143
  25. package/src/lib/components/emails-list-card.js.map +1 -1
  26. package/src/lib/components/host-experiments-card.component.js +110 -78
  27. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  28. package/src/lib/components/org-experiments-card.js +7 -3
  29. package/src/lib/components/org-experiments-card.js.map +1 -1
  30. package/src/lib/model/campaign-list-query.d.ts +99 -0
  31. package/src/lib/model/campaign-list-query.js +373 -0
  32. package/src/lib/model/campaign-list-query.js.map +1 -0
  33. package/src/lib/model/campaign-members-query.d.ts +37 -0
  34. package/src/lib/model/campaign-members-query.js +120 -0
  35. package/src/lib/model/campaign-members-query.js.map +1 -0
  36. package/src/lib/model/experiment-list-query.d.ts +22 -0
  37. package/src/lib/model/experiment-list-query.js +85 -0
  38. package/src/lib/model/experiment-list-query.js.map +1 -0
  39. package/src/lib/server/campaign-form-flags.d.ts +35 -0
  40. package/src/lib/server/campaign-form-flags.js +52 -0
  41. package/src/lib/server/campaign-form-flags.js.map +1 -0
  42. package/src/lib/server/campaign-manage.d.ts +1 -1
  43. package/src/lib/server/campaign-manage.js +42 -20
  44. package/src/lib/server/campaign-manage.js.map +1 -1
  45. package/src/lib/server/campaign-send.d.ts +42 -1
  46. package/src/lib/server/campaign-send.js +247 -16
  47. package/src/lib/server/campaign-send.js.map +1 -1
  48. package/src/lib/server/email-events.js +13 -1
  49. package/src/lib/server/email-events.js.map +1 -1
  50. package/src/lib/components/email-send-filter.d.ts +0 -32
  51. package/src/lib/components/email-send-filter.js +0 -55
  52. 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
- super(message, 429), this.retryAtMs = retryAtMs;
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 sendable = await filterCadenceSendable(hostId, onTopic, {
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 four filters emptied the batch, because the answer changes
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
- */ 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);
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 - sendable.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 - sendable.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
- } : {}, scheduling ? {
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 ? {