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

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.
@@ -16,6 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { checkQuota, contactMatchesSegment, isEmailTopicId, DEFAULT_CAMPAIGN_TOPIC_ID, readMarketingBasis, resolveMarketingConsentPolicy, splitByMarketingConsent, createResourceUid, decodeStoredNodes, emailStarterSendBlock, resolveBrandingProfile, visibleToHost } from "@aglyn/aglyn/server";
18
18
  import { renderRecipientEmail } from "@aglyn/aglyn/app-utils/recipient-email-render";
19
+ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
19
20
  import { assignExperimentVariant } from "../model/index.js";
20
21
  import { campaignPlacedOnHost } from "@aglyn/shared-ui-email-campaigns/model";
21
22
  import { readPluginRecordCard } from "@aglyn/aglyn/plugin-manager/plugin-record-cards";
@@ -61,6 +62,11 @@ import { campaignSendSiteStamp, orgCampaignSends, orgEmailCampaigns, sendIsOnHos
61
62
  */ import { claimOrgEmailSendDay, orgAgeDays, readSenderReputation, reconcileOrgEmailSendDay, recordCampaignAccepted, resolveOrgEmailRamp } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
62
63
  import { createHash } from "crypto";
63
64
  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";
65
+ /*
66
+ * The List-Unsubscribe setting sequences share (AGL-3307), from its LEAF
67
+ * module: the specs that reach this file spread the real barrel under their
68
+ * own stubs, and a decision that turns a header off must be the real one.
69
+ */ import { decideListUnsubscribe, listUnsubscribeForcedDetail, listUnsubscribeHeaders, readListUnsubscribeSetting } from "@aglyn/shared-util-email/list-unsubscribe";
64
70
  /**
65
71
  * Recipients one send may address.
66
72
  *
@@ -190,6 +196,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
190
196
  /**
191
197
  * Loads a designed email template's nodes + referenced products for the
192
198
  * render pipeline. Throws 400 when the screen isn't an email document.
199
+ *
200
+ * The nodes come back COMPOSED: every reusable block the design places — a
201
+ * shared header, a footer — expanded into the blocks it stands for (AGL-3287).
202
+ * Everything downstream renders from this one map: each recipient's copy, the
203
+ * test send and the composer's preview.
193
204
  */ async function loadEmailTemplate(hostId, screenId) {
194
205
  var _decodeStoredNodes, _ref, _screenSnapshot_get, _screenSnapshot_get1;
195
206
  const firestore = firebaseAdmin.app().firestore();
@@ -220,8 +231,8 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
220
231
  * blocks missing — discovered by the recipients. Decoding first is also what
221
232
  * makes the guard mean something: `decodeStoredNodes` returns null for an
222
233
  * undecodable payload, so the send is refused instead of mailed empty.
223
- */ const nodes = (_decodeStoredNodes = decodeStoredNodes(versionSnapshot == null ? void 0 : versionSnapshot.get('nodes'))) != null ? _decodeStoredNodes : {};
224
- if (!Object.keys(nodes).length) {
234
+ */ const stored = (_decodeStoredNodes = decodeStoredNodes(versionSnapshot == null ? void 0 : versionSnapshot.get('nodes'))) != null ? _decodeStoredNodes : {};
235
+ if (!Object.keys(stored).length) {
225
236
  throw new CampaignSendError('The email template is empty', 400);
226
237
  }
227
238
  /**
@@ -255,6 +266,24 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
255
266
  });
256
267
  if (block) throw new CampaignSendError(block.reason, 409);
257
268
  }
269
+ /*
270
+ * THE SITE'S REUSABLE BLOCKS, EXPANDED (AGL-3287).
271
+ *
272
+ * A header or footer placed in the design is a `reusableInstance` naming one
273
+ * of the site's components, and the mail renderer draws a node it does not
274
+ * know as nothing — so without this every campaign went out without the
275
+ * blocks its author could see on the canvas. Grafted from the components'
276
+ * PUBLISHED definitions, read fresh, never from a cache: a send is not a
277
+ * page, and a header republished a minute ago is the one the author expects.
278
+ *
279
+ * BEFORE the product scan below, which is the ordering that matters: a
280
+ * product block inside a footer only exists once the footer is grafted.
281
+ * Each placement's property values and attribute overrides apply; its style
282
+ * overrides land in `sx`, which the mail renderer does not read.
283
+ */ const nodes = await composeHostComponentNodes(stored, {
284
+ firestore,
285
+ hostId
286
+ });
258
287
  // Resolve emailProduct references (by id — rename-safe, AGL-343).
259
288
  const productIds = [
260
289
  ...new Set(Object.values(nodes).filter((node)=>(node == null ? void 0 : node.componentId) === 'emailProduct').map((node)=>{
@@ -293,6 +322,11 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
293
322
  preheader: String((_screenSnapshot_get1 = screenSnapshot.get('emailPreheader')) != null ? _screenSnapshot_get1 : '')
294
323
  };
295
324
  }
325
+ /**
326
+ * The field on a SEND recording what it did with the List-Unsubscribe header
327
+ * (AGL-3307) — see {@link CampaignListUnsubscribeRecord} in
328
+ * `@aglyn/shared-ui-email-campaigns`, which the campaign page reads.
329
+ */ const LIST_UNSUBSCRIBE_RECORD_FIELD = 'listUnsubscribe';
296
330
  export async function performCampaignSend(options) {
297
331
  var _ref, _options_senderId, _options_proofFor, _hostPublicOrigin, _options_experimentId;
298
332
  var _orgForHost_org, _orgForHost_org1, _consent_get;
@@ -409,6 +443,7 @@ export async function performCampaignSend(options) {
409
443
  * Batches this email has already run, so the plan below can tell the second
410
444
  * from the twentieth. Zero on every first send.
411
445
  */ let batchesSoFar = 0;
446
+ /** Why an earlier batch of this email carried a forced header, if one did. */ let forcedBefore = null;
412
447
  /*==========================================
413
448
  * THE NEXT BATCH'S ADMISSION CHECKS.
414
449
  *
@@ -453,6 +488,14 @@ export async function performCampaignSend(options) {
453
488
  ...settled.skipped
454
489
  ]);
455
490
  batchesSoFar = Math.max(0, Math.floor(Number((_ref2 = (_sendSnapshot_get2 = sendSnapshot.get('resume')) == null ? void 0 : _sendSnapshot_get2.batch) != null ? _ref2 : 0)) || 0);
491
+ /*
492
+ * An earlier batch the bulk guard turned the header back on for keeps
493
+ * it on for the rest of the email (AGL-3307): one mailing does not lose
494
+ * its unsubscribe button halfway through because a day rolled over.
495
+ */ const earlier = sendSnapshot.get(LIST_UNSUBSCRIBE_RECORD_FIELD);
496
+ if ((earlier == null ? void 0 : earlier.forced) === true && (earlier.reason === 'bulk-volume' || earlier.reason === 'pooled-identity' || earlier.reason === 'volume-unknown')) {
497
+ forcedBefore = earlier.reason;
498
+ }
456
499
  }
457
500
  /**
458
501
  * A batch that found nothing left to do: the email is FINISHED, not broken.
@@ -967,6 +1010,12 @@ export async function performCampaignSend(options) {
967
1010
  * every single day and never send anything at all.
968
1011
  */ let batchCap = MAX_RECIPIENTS_PER_SEND;
969
1012
  /** Today's ramp, resolved once and claimed against below. */ let ramp = null;
1013
+ /**
1014
+ * Campaign messages this workspace has sent in the last day, for the
1015
+ * List-Unsubscribe bulk guard (AGL-3307). `null` when the window could not
1016
+ * be read, which the guard answers by carrying the header. Zero on a test
1017
+ * send, which is one message to somebody on the workspace.
1018
+ */ let recentCampaignVolume = 0;
970
1019
  if (!proofOnly) {
971
1020
  var _orgForHost_org2, _orgForHost_org3;
972
1021
  /** This workspace's seven-day grade, and the window both controls read. */ const reputation = await readSenderReputation({
@@ -974,6 +1023,7 @@ export async function performCampaignSend(options) {
974
1023
  policy: reputationPolicy,
975
1024
  reinstatedUntilMs: orgForHost == null ? void 0 : (_orgForHost_org2 = orgForHost.org) == null ? void 0 : _orgForHost_org2['emailReputationReinstatedUntilMs']
976
1025
  });
1026
+ recentCampaignVolume = reputation.degraded ? null : reputation.window.acceptedLastTwoDays;
977
1027
  /*
978
1028
  * THE CIRCUIT BREAKER.
979
1029
  *
@@ -1459,7 +1509,42 @@ export async function performCampaignSend(options) {
1459
1509
  * when, not about whom, and settling a recipient on one would drop somebody
1460
1510
  * the provider never even looked at while the campaign closed as complete.
1461
1511
  * They break the loop into `deferred` instead; see the branch below.
1462
- */ const sendableSet = new Set(sendable);
1512
+ */ /*==========================================
1513
+ * THE MAIL-CLIENT UNSUBSCRIBE BUTTON (AGL-3307).
1514
+ *
1515
+ * The campaign's `listUnsubscribe` setting, ON unless somebody turned it
1516
+ * off — and turned back on for this send when leaving it off would break
1517
+ * Gmail's and Yahoo's bulk-sender rule, see `decideListUnsubscribe`. The
1518
+ * volume is what the workspace sent in the last day plus EVERYONE this
1519
+ * email still has to reach, not just this batch, so an email that will
1520
+ * make the workspace a bulk sender carries the header from its first
1521
+ * message rather than from the batch that crosses the line.
1522
+ *
1523
+ * The footer link is not part of this: `renderRecipientEmail` writes it
1524
+ * into every message whatever the header does.
1525
+ *
1526
+ * An unreadable campaign reads as the default, ON, and an unreadable
1527
+ * volume forces it on: the header is the safe direction to be wrong in.
1528
+ *=========================================*/ let listUnsubscribeRequested = true;
1529
+ if (options.emailCampaignId) {
1530
+ try {
1531
+ const container = await orgEmailCampaigns(firestore, orgId).doc(options.emailCampaignId).get();
1532
+ listUnsubscribeRequested = readListUnsubscribeSetting(container.exists ? container.get('listUnsubscribe') : undefined, true);
1533
+ } catch (error) {
1534
+ console.error('[campaign] campaign unreadable — keeping the unsubscribe header on', error);
1535
+ }
1536
+ }
1537
+ const listUnsubscribe = decideListUnsubscribe({
1538
+ requested: listUnsubscribeRequested,
1539
+ recentVolume: recentCampaignVolume != null ? recentCampaignVolume : 0,
1540
+ sendVolume: proofOnly ? sendable.length : consentSplit.mailable.length,
1541
+ pooled: sendingIdentity.source === 'shared',
1542
+ forcedBefore: recentCampaignVolume === null ? 'volume-unknown' : forcedBefore
1543
+ });
1544
+ if (listUnsubscribe.forced) {
1545
+ console.warn(`[campaign] ${options.campaignId || 'new send'}: unsubscribe header ` + `forced on (${listUnsubscribe.reason}, ` + `${listUnsubscribe.volume}/${listUnsubscribe.threshold})`);
1546
+ }
1547
+ const sendableSet = new Set(sendable);
1463
1548
  const settledOut = recipients.filter((email)=>!sendableSet.has(email));
1464
1549
  let sent = 0;
1465
1550
  /** Recipients a rate refusal left untouched mid-batch, if any. */ let deferred = 0;
@@ -1513,9 +1598,9 @@ export async function performCampaignSend(options) {
1513
1598
  * reference `{{unsubscribeUrl}}`, and because the page it opens is still
1514
1599
  * where you go to unsubscribe — with "Unsubscribe from everything" on
1515
1600
  * it, one button away.
1516
- */ const oneClickUrl = buildUnsubscribeUrl(_extends({}, link, {
1601
+ */ const oneClickUrl = listUnsubscribe.on ? buildUnsubscribeUrl(_extends({}, link, {
1517
1602
  surface: 'one-click'
1518
- }));
1603
+ })) : '';
1519
1604
  const unsubscribeUrl = buildUnsubscribeUrl(_extends({}, link, {
1520
1605
  surface: 'preferences'
1521
1606
  }));
@@ -1592,10 +1677,13 @@ export async function performCampaignSend(options) {
1592
1677
  // person can choose on a page; they change nothing about what a
1593
1678
  // machine POSTing this header is promised, which is that the
1594
1679
  // recipient stops hearing from this site.
1595
- headers: {
1596
- 'List-Unsubscribe': `<${oneClickUrl}>`,
1597
- 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
1598
- },
1680
+ //
1681
+ // Only when the campaign's setting — or the bulk guard — says so
1682
+ // (AGL-3307). Off, the footer link above is the way out, and the
1683
+ // pair is written by the builder sequences use.
1684
+ headers: listUnsubscribeHeaders({
1685
+ url: oneClickUrl
1686
+ }),
1599
1687
  // The campaign's own display name where the composer set one, and the
1600
1688
  // org's branding default otherwise. Either way the ADDRESS is the
1601
1689
  // resolved identity's — `applyFromName` replaces the display name in
@@ -1808,6 +1896,11 @@ export async function performCampaignSend(options) {
1808
1896
  * the hour or the day THREW rather than reaching here, so the only reason
1809
1897
  * this is not immediate is a batch that ran to the end of its own cap.
1810
1898
  */ const nextAtMs = plan.resuming ? Date.now() : 0;
1899
+ const listUnsubscribeResult = {
1900
+ on: listUnsubscribe.on,
1901
+ forced: listUnsubscribe.forced,
1902
+ reason: listUnsubscribe.reason
1903
+ };
1811
1904
  if (options.recordCampaign === false) {
1812
1905
  return _extends({
1813
1906
  campaignId,
@@ -1819,7 +1912,9 @@ export async function performCampaignSend(options) {
1819
1912
  sent
1820
1913
  }, deferred ? {
1821
1914
  deferred
1822
- } : {});
1915
+ } : {}, {
1916
+ listUnsubscribe: listUnsubscribeResult
1917
+ });
1823
1918
  }
1824
1919
  /*==========================================
1825
1920
  * ADDING TO A COUNTER, AS AGAINST REPLACING IT.
@@ -1892,7 +1987,25 @@ export async function performCampaignSend(options) {
1892
1987
  displayName: options.displayName
1893
1988
  } : {}, options.emailCampaignId ? {
1894
1989
  emailCampaignId: options.emailCampaignId
1895
- } : {}, experiment ? {
1990
+ } : {}, {
1991
+ /*
1992
+ * WHAT THIS EMAIL DID WITH THE UNSUBSCRIBE HEADER, and why (AGL-3307).
1993
+ * Written absolutely on every batch; a batch after a forced one is
1994
+ * forced too, so the last write is the email's answer. The campaign
1995
+ * page reads `forced` and `detail` to say it was turned back on.
1996
+ */ [LIST_UNSUBSCRIBE_RECORD_FIELD]: _extends({
1997
+ requested: listUnsubscribe.requested,
1998
+ on: listUnsubscribe.on,
1999
+ forced: listUnsubscribe.forced
2000
+ }, listUnsubscribe.reason ? {
2001
+ reason: listUnsubscribe.reason,
2002
+ detail: listUnsubscribeForcedDetail(listUnsubscribe)
2003
+ } : {}, {
2004
+ volume: listUnsubscribe.volume,
2005
+ threshold: listUnsubscribe.threshold,
2006
+ atMs: Date.now()
2007
+ })
2008
+ }, experiment ? {
1896
2009
  experimentId: experiment.$id
1897
2010
  } : {}, {
1898
2011
  stats: _extends({
@@ -1995,7 +2108,9 @@ export async function performCampaignSend(options) {
1995
2108
  batch: plan.batch
1996
2109
  }, plan.resuming ? {
1997
2110
  nextAtMs
1998
- } : {});
2111
+ } : {}, {
2112
+ listUnsubscribe: listUnsubscribeResult
2113
+ });
1999
2114
  }
2000
2115
  /**
2001
2116
  * THE ADDRESSES A TEST SEND MAY BE DELIVERED TO.
@@ -2710,6 +2825,8 @@ function storedSendOptionsFrom(snapshot, hostId, senderUid, followUp) {
2710
2825
  preheader
2711
2826
  }, persona ? {
2712
2827
  proofPersona: persona
2828
+ } : {}, emailCampaignId ? {
2829
+ emailCampaignId
2713
2830
  } : {}, {
2714
2831
  recordCampaign: false,
2715
2832
  senderUid: decoded.uid,