@aglyn/plugins-marketing 1.0.0-beta.181 → 1.0.0-beta.182

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.
@@ -61,6 +61,11 @@ import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendIsOnHos
61
61
  */ import { claimOrgEmailSendDay, orgAgeDays, readSenderReputation, reconcileOrgEmailSendDay, recordCampaignAccepted, resolveOrgEmailRamp } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
62
62
  import { createHash } from "crypto";
63
63
  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";
64
+ /*
65
+ * The List-Unsubscribe setting sequences share (AGL-3307), from its LEAF
66
+ * module: the specs that reach this file spread the real barrel under their
67
+ * own stubs, and a decision that turns a header off must be the real one.
68
+ */ import { decideListUnsubscribe, listUnsubscribeForcedDetail, listUnsubscribeHeaders, readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubscribe";
64
69
  /**
65
70
  * Recipients one send may address.
66
71
  *
@@ -293,6 +298,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
293
298
  preheader: String((_screenSnapshot_get1 = screenSnapshot.get('emailPreheader')) != null ? _screenSnapshot_get1 : '')
294
299
  };
295
300
  }
301
+ /**
302
+ * The field on a SEND recording what it did with the List-Unsubscribe header
303
+ * (AGL-3307) — see {@link CampaignListUnsubscribeRecord} in
304
+ * `@aglyn/shared-ui-email-campaigns`, which the campaign page reads.
305
+ */ const LIST_UNSUBSCRIBE_RECORD_FIELD = 'listUnsubscribe';
296
306
  export async function performCampaignSend(options) {
297
307
  var _ref, _options_senderId, _options_proofFor, _hostPublicOrigin, _options_experimentId;
298
308
  var _orgForHost_org, _orgForHost_org1, _consent_get;
@@ -409,6 +419,7 @@ export async function performCampaignSend(options) {
409
419
  * Batches this email has already run, so the plan below can tell the second
410
420
  * from the twentieth. Zero on every first send.
411
421
  */ let batchesSoFar = 0;
422
+ /** Why an earlier batch of this email carried a forced header, if one did. */ let forcedBefore = null;
412
423
  /*==========================================
413
424
  * THE NEXT BATCH'S ADMISSION CHECKS.
414
425
  *
@@ -453,6 +464,14 @@ export async function performCampaignSend(options) {
453
464
  ...settled.skipped
454
465
  ]);
455
466
  batchesSoFar = Math.max(0, Math.floor(Number((_ref2 = (_sendSnapshot_get2 = sendSnapshot.get('resume')) == null ? void 0 : _sendSnapshot_get2.batch) != null ? _ref2 : 0)) || 0);
467
+ /*
468
+ * An earlier batch the bulk guard turned the header back on for keeps
469
+ * it on for the rest of the email (AGL-3307): one mailing does not lose
470
+ * its unsubscribe button halfway through because a day rolled over.
471
+ */ const earlier = sendSnapshot.get(LIST_UNSUBSCRIBE_RECORD_FIELD);
472
+ if ((earlier == null ? void 0 : earlier.forced) === true && (earlier.reason === 'bulk-volume' || earlier.reason === 'pooled-identity' || earlier.reason === 'volume-unknown')) {
473
+ forcedBefore = earlier.reason;
474
+ }
456
475
  }
457
476
  /**
458
477
  * A batch that found nothing left to do: the email is FINISHED, not broken.
@@ -967,6 +986,12 @@ export async function performCampaignSend(options) {
967
986
  * every single day and never send anything at all.
968
987
  */ let batchCap = MAX_RECIPIENTS_PER_SEND;
969
988
  /** Today's ramp, resolved once and claimed against below. */ let ramp = null;
989
+ /**
990
+ * Campaign messages this workspace has sent in the last day, for the
991
+ * List-Unsubscribe bulk guard (AGL-3307). `null` when the window could not
992
+ * be read, which the guard answers by carrying the header. Zero on a test
993
+ * send, which is one message to somebody on the workspace.
994
+ */ let recentCampaignVolume = 0;
970
995
  if (!proofOnly) {
971
996
  var _orgForHost_org2, _orgForHost_org3;
972
997
  /** This workspace's seven-day grade, and the window both controls read. */ const reputation = await readSenderReputation({
@@ -974,6 +999,7 @@ export async function performCampaignSend(options) {
974
999
  policy: reputationPolicy,
975
1000
  reinstatedUntilMs: orgForHost == null ? void 0 : (_orgForHost_org2 = orgForHost.org) == null ? void 0 : _orgForHost_org2['emailReputationReinstatedUntilMs']
976
1001
  });
1002
+ recentCampaignVolume = reputation.degraded ? null : reputation.window.acceptedLastTwoDays;
977
1003
  /*
978
1004
  * THE CIRCUIT BREAKER.
979
1005
  *
@@ -1459,7 +1485,42 @@ export async function performCampaignSend(options) {
1459
1485
  * when, not about whom, and settling a recipient on one would drop somebody
1460
1486
  * the provider never even looked at while the campaign closed as complete.
1461
1487
  * They break the loop into `deferred` instead; see the branch below.
1462
- */ const sendableSet = new Set(sendable);
1488
+ */ /*==========================================
1489
+ * THE MAIL-CLIENT UNSUBSCRIBE BUTTON (AGL-3307).
1490
+ *
1491
+ * The campaign's `listUnsubscribe` setting, ON unless somebody turned it
1492
+ * off — and turned back on for this send when leaving it off would break
1493
+ * Gmail's and Yahoo's bulk-sender rule, see `decideListUnsubscribe`. The
1494
+ * volume is what the workspace sent in the last day plus EVERYONE this
1495
+ * email still has to reach, not just this batch, so an email that will
1496
+ * make the workspace a bulk sender carries the header from its first
1497
+ * message rather than from the batch that crosses the line.
1498
+ *
1499
+ * The footer link is not part of this: `renderRecipientEmail` writes it
1500
+ * into every message whatever the header does.
1501
+ *
1502
+ * An unreadable campaign reads as the default, ON, and an unreadable
1503
+ * volume forces it on: the header is the safe direction to be wrong in.
1504
+ *=========================================*/ let listUnsubscribeRequested = true;
1505
+ if (options.emailCampaignId) {
1506
+ try {
1507
+ const container = await orgEmailCampaigns(firestore, orgId).doc(options.emailCampaignId).get();
1508
+ listUnsubscribeRequested = readListUnsubscribeSetting(container.exists ? container.get('listUnsubscribe') : undefined, true);
1509
+ } catch (error) {
1510
+ console.error('[campaign] campaign unreadable — keeping the unsubscribe header on', error);
1511
+ }
1512
+ }
1513
+ const listUnsubscribe = decideListUnsubscribe({
1514
+ requested: listUnsubscribeRequested,
1515
+ recentVolume: recentCampaignVolume != null ? recentCampaignVolume : 0,
1516
+ sendVolume: proofOnly ? sendable.length : consentSplit.mailable.length,
1517
+ pooled: sendingIdentity.source === 'shared',
1518
+ forcedBefore: recentCampaignVolume === null ? 'volume-unknown' : forcedBefore
1519
+ });
1520
+ if (listUnsubscribe.forced) {
1521
+ console.warn(`[campaign] ${options.campaignId || 'new send'}: unsubscribe header ` + `forced on (${listUnsubscribe.reason}, ` + `${listUnsubscribe.volume}/${listUnsubscribe.threshold})`);
1522
+ }
1523
+ const sendableSet = new Set(sendable);
1463
1524
  const settledOut = recipients.filter((email)=>!sendableSet.has(email));
1464
1525
  let sent = 0;
1465
1526
  /** Recipients a rate refusal left untouched mid-batch, if any. */ let deferred = 0;
@@ -1513,9 +1574,9 @@ export async function performCampaignSend(options) {
1513
1574
  * reference `{{unsubscribeUrl}}`, and because the page it opens is still
1514
1575
  * where you go to unsubscribe — with "Unsubscribe from everything" on
1515
1576
  * it, one button away.
1516
- */ const oneClickUrl = buildUnsubscribeUrl(_extends({}, link, {
1577
+ */ const oneClickUrl = listUnsubscribe.on ? buildUnsubscribeUrl(_extends({}, link, {
1517
1578
  surface: 'one-click'
1518
- }));
1579
+ })) : '';
1519
1580
  const unsubscribeUrl = buildUnsubscribeUrl(_extends({}, link, {
1520
1581
  surface: 'preferences'
1521
1582
  }));
@@ -1592,10 +1653,13 @@ export async function performCampaignSend(options) {
1592
1653
  // person can choose on a page; they change nothing about what a
1593
1654
  // machine POSTing this header is promised, which is that the
1594
1655
  // recipient stops hearing from this site.
1595
- headers: {
1596
- 'List-Unsubscribe': `<${oneClickUrl}>`,
1597
- 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
1598
- },
1656
+ //
1657
+ // Only when the campaign's setting — or the bulk guard — says so
1658
+ // (AGL-3307). Off, the footer link above is the way out, and the
1659
+ // pair is written by the builder sequences use.
1660
+ headers: listUnsubscribeHeaders({
1661
+ url: oneClickUrl
1662
+ }),
1599
1663
  // The campaign's own display name where the composer set one, and the
1600
1664
  // org's branding default otherwise. Either way the ADDRESS is the
1601
1665
  // resolved identity's — `applyFromName` replaces the display name in
@@ -1808,6 +1872,11 @@ export async function performCampaignSend(options) {
1808
1872
  * the hour or the day THREW rather than reaching here, so the only reason
1809
1873
  * this is not immediate is a batch that ran to the end of its own cap.
1810
1874
  */ const nextAtMs = plan.resuming ? Date.now() : 0;
1875
+ const listUnsubscribeResult = {
1876
+ on: listUnsubscribe.on,
1877
+ forced: listUnsubscribe.forced,
1878
+ reason: listUnsubscribe.reason
1879
+ };
1811
1880
  if (options.recordCampaign === false) {
1812
1881
  return _extends({
1813
1882
  campaignId,
@@ -1819,7 +1888,9 @@ export async function performCampaignSend(options) {
1819
1888
  sent
1820
1889
  }, deferred ? {
1821
1890
  deferred
1822
- } : {});
1891
+ } : {}, {
1892
+ listUnsubscribe: listUnsubscribeResult
1893
+ });
1823
1894
  }
1824
1895
  /*==========================================
1825
1896
  * ADDING TO A COUNTER, AS AGAINST REPLACING IT.
@@ -1892,7 +1963,25 @@ export async function performCampaignSend(options) {
1892
1963
  displayName: options.displayName
1893
1964
  } : {}, options.emailCampaignId ? {
1894
1965
  emailCampaignId: options.emailCampaignId
1895
- } : {}, experiment ? {
1966
+ } : {}, {
1967
+ /*
1968
+ * WHAT THIS EMAIL DID WITH THE UNSUBSCRIBE HEADER, and why (AGL-3307).
1969
+ * Written absolutely on every batch; a batch after a forced one is
1970
+ * forced too, so the last write is the email's answer. The campaign
1971
+ * page reads `forced` and `detail` to say it was turned back on.
1972
+ */ [LIST_UNSUBSCRIBE_RECORD_FIELD]: _extends({
1973
+ requested: listUnsubscribe.requested,
1974
+ on: listUnsubscribe.on,
1975
+ forced: listUnsubscribe.forced
1976
+ }, listUnsubscribe.reason ? {
1977
+ reason: listUnsubscribe.reason,
1978
+ detail: listUnsubscribeForcedDetail(listUnsubscribe)
1979
+ } : {}, {
1980
+ volume: listUnsubscribe.volume,
1981
+ threshold: listUnsubscribe.threshold,
1982
+ atMs: Date.now()
1983
+ })
1984
+ }, experiment ? {
1896
1985
  experimentId: experiment.$id
1897
1986
  } : {}, {
1898
1987
  stats: _extends({
@@ -1995,7 +2084,9 @@ export async function performCampaignSend(options) {
1995
2084
  batch: plan.batch
1996
2085
  }, plan.resuming ? {
1997
2086
  nextAtMs
1998
- } : {});
2087
+ } : {}, {
2088
+ listUnsubscribe: listUnsubscribeResult
2089
+ });
1999
2090
  }
2000
2091
  /**
2001
2092
  * THE ADDRESSES A TEST SEND MAY BE DELIVERED TO.
@@ -2710,6 +2801,8 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2710
2801
  preheader
2711
2802
  }, persona ? {
2712
2803
  proofPersona: persona
2804
+ } : {}, emailCampaignId ? {
2805
+ emailCampaignId
2713
2806
  } : {}, {
2714
2807
  recordCampaign: false,
2715
2808
  senderUid: decoded.uid,